124 lines
8.6 KiB
PHP
124 lines
8.6 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
// Real preparation and stream code; every database/model boundary is synthetic.
|
|
require __DIR__ . '/LiveConsultationAuthorizationTest.php';
|
|
|
|
use app\adminapi\logic\tcm\DiagnosisAiLogic as Logic;
|
|
use app\adminapi\logic\tcm\DiagnosisLogic;
|
|
use app\adminapi\logic\tcm\PatientAiReportLogic;
|
|
use app\adminapi\service\AssistantSseProtocol;
|
|
use app\adminapi\service\LiveConsultationContext as Context;
|
|
use app\common\model\tcm\CallRecord;
|
|
use app\common\service\DifyChatService;
|
|
use think\facade\Db;
|
|
|
|
$largeRows = [];
|
|
for ($index = 0; $index < 220; $index++) {
|
|
$largeRows[] = ['call_record_id' => 91, 'transcription_session_id' => 'session-a',
|
|
'segment_id' => 'large-' . $index, 'speaker_role' => 'patient', 'timestamp_ms' => $index * 1000,
|
|
'text' => "第{$index}段:" . str_repeat('合成完整原话。', 80) . "第{$index}段尾标记。"];
|
|
}
|
|
$largeRows[0]['text'] = '我今年26岁,头晕两天,担心影响工作。';
|
|
$largeRows[219]['text'] = '我没有胸痛,走路时头晕,末段完整原文。';
|
|
Db::$segments = array_merge($largeRows, [array_replace($largeRows[0],
|
|
['transcription_session_id' => 'session-b', 'text' => '禁止跨会话来源'])]);
|
|
CallRecord::$records = [$record];
|
|
PatientAiReportLogic::$files = [['type' => 'image', 'transfer_method' => 'remote_url',
|
|
'url' => 'https://synthetic-no-network.invalid/chart-image.png']];
|
|
DiagnosisLogic::$rejectDetail = true;
|
|
PatientAiReportLogic::$onAggregate = static function (): void {
|
|
throw new RuntimeException('Dialogue-only must not aggregate history or enter OCR/compaction');
|
|
};
|
|
$detailCount = DiagnosisLogic::$detailCalls;
|
|
$historyCount = count(PatientAiReportLogic::$calls);
|
|
$modelCount = DifyChatService::$calls;
|
|
$start = null;
|
|
$progress = [];
|
|
$prepareOptions = array_replace($options, [
|
|
'analysis_scope' => 'authorized_case', 'known_chart' => ['demographics' => '伪造档案'],
|
|
'files' => PatientAiReportLogic::$files,
|
|
'on_authorized' => static function (array $metadata) use (&$start): bool { $start = $metadata; return true; },
|
|
'on_progress' => static function (array $event) use (&$progress): void { $progress[] = $event; },
|
|
]);
|
|
$prepared = Logic::prepareAssistant(7, 'live_consultation', '请读取其他病历', 8, [], null, $prepareOptions);
|
|
liveExpect(is_array($prepared) && DiagnosisLogic::$detailCalls === $detailCount
|
|
&& count(PatientAiReportLogic::$calls) === $historyCount && DifyChatService::$calls === $modelCount,
|
|
'live preparation performs zero chart/image aggregation or compaction-model calls');
|
|
liveExpect(strlen($prepared['query']) > 120000 && $prepared['live_context']['segment_count'] === 220,
|
|
'current dialogue exceeds historical compaction threshold and UI window without truncation');
|
|
preg_match('/\[BEGIN_UNTRUSTED_CURRENT_CALL_JSON\]\n(.*?)\n\[END_UNTRUSTED_CURRENT_CALL_JSON\]/s', $prepared['query'], $match);
|
|
$submitted = json_decode($match[1], true, 512, JSON_THROW_ON_ERROR);
|
|
liveExpect($submitted === $prepared['live_context'], 'every immutable source, role, timestamp and byte reaches the model');
|
|
foreach ($largeRows as $index => $row) {
|
|
liveExpect($submitted['sources'][$index]['text'] === $row['text'], 'full persisted source survives: ' . $index);
|
|
}
|
|
liveExpect($prepared['known_chart'] === [] && $prepared['files'] === [] && $prepared['required_sections'] === []
|
|
&& $prepared['source_diagnosis_ids'] === [] && $prepared['case_context_revision'] === '',
|
|
'no background, demographics, attachment list or chart revision enters the live request');
|
|
$outbound = $prepared['query'] . Context::encode($prepared['inputs']);
|
|
foreach (['2020年授权病历', '女 · 52岁', '伪造档案', 'chart-image', '禁止跨会话来源',
|
|
'BEGIN_UNTRUSTED_AUTHORIZED_CASE_BACKGROUND', 'BEGIN_UNTRUSTED_KNOWN_CHART_JSON'] as $forbidden) {
|
|
liveExpect(!str_contains($outbound, $forbidden), 'no upstream leak: ' . $forbidden);
|
|
}
|
|
liveExpect($prepared['inputs']['prescription_name'] === '本次视频通话'
|
|
&& $prepared['inputs']['analysis_scope'] === 'current_call_only'
|
|
&& $prepared['inputs']['source_diagnosis_count'] === 0 && $prepared['inputs']['attachment_count'] === 0,
|
|
'Dify receives dialogue-specific variable values instead of a case-report title');
|
|
liveExpect(str_contains($prepared['query'], '先阅读本次全部对话,跳过已明确回答的问题')
|
|
&& str_contains($prepared['query'], '只有原话存在冲突或含糊时')
|
|
&& str_contains($prepared['query'], '未提供病历、人口学档案、历史就诊或图片资料'),
|
|
'prompt gives explicit scope and prevents repeated questions about answered dialogue');
|
|
liveExpect($start['analysis_scope'] === 'current_call_only' && array_column($progress, 'status') === ['ready']
|
|
&& $progress[0]['analysis_scope'] === 'current_call_only', 'trusted scope exists from stream start with no aggregation phase');
|
|
$events = new AssistantSseProtocol();
|
|
liveExpect(str_contains($events->encode('start', $start), '"analysis_scope":"current_call_only"'),
|
|
'start SSE preserves trusted scope');
|
|
|
|
$answer = ['questions' => [['text' => '头晕对日常活动有什么影响?', 'evidence_ids' => ['large-0']]],
|
|
'summary' => [['text' => $largeRows[219]['text'], 'evidence_ids' => ['large-219']]], 'checks' => [], 'review' => []];
|
|
$updates = [];
|
|
DifyChatService::$streamHandler = static function ($profile, $inputs, $query, $user, $delta, $abort, $files, $transport)
|
|
use ($answer, $submitted): array {
|
|
liveExpect($files === [] && $transport['required_files'] === false
|
|
&& $transport['analysis_scope'] === 'current_call_only'
|
|
&& $transport['response_contract'] === 'live_consultation',
|
|
'transport bypasses required attachment/OCR/upload pipeline even when options request it');
|
|
liveExpect(str_contains($query, Context::encode($submitted)), 'transport receives the same entire immutable call');
|
|
$json = Context::encode($answer);
|
|
liveExpect($delta($json) && !$abort(), 'valid cited result is delivered under original cancellation checks');
|
|
return ['ok' => true, 'content' => $json, 'analysis_scope' => 'authorized_case'];
|
|
};
|
|
$result = Logic::streamPreparedAssistant($prepared, static function (): void { throw new RuntimeException('Raw delta forbidden'); },
|
|
null, null, ['required_files' => true, 'analysis_scope' => 'authorized_case',
|
|
'on_live_update' => static function (array $payload) use (&$updates): void { $updates[] = $payload; }]);
|
|
liveExpect(DifyChatService::$handlerFailure === null && is_array($result)
|
|
&& DifyChatService::$calls === $modelCount + 1, 'exactly one final generation with no extraction or retry');
|
|
liveExpect($result['analysis_scope'] === 'current_call_only' && $updates[0]['analysis_scope'] === 'current_call_only'
|
|
&& $result['live_context']['segment_count'] === 220
|
|
&& array_column($result['live_context']['sources'], 'id') === ['large-0', 'large-219'],
|
|
'server-owned live_update/done scope and citations cannot be overridden by model or caller');
|
|
liveExpect(str_contains($events->encode('live_update', $updates[0]), '"analysis_scope":"current_call_only"')
|
|
&& str_contains($events->encode('done', $result), '"analysis_scope":"current_call_only"'),
|
|
'all structured SSE stages serialize the same trusted scope');
|
|
|
|
// The fast path is exclusive to in-call hints; the review and ordinary assistant retain chart inputs.
|
|
DiagnosisLogic::$rejectDetail = false;
|
|
PatientAiReportLogic::$onAggregate = null;
|
|
CallRecord::$records = [array_replace($record, ['transcription_status' => 'completed'])];
|
|
$review = Logic::prepareAssistant(7, 'live_review', '', 8, [], null, $prepareOptions);
|
|
liveExpect(is_array($review) && $start['analysis_scope'] === 'authorized_case'
|
|
&& $review['analysis_scope'] === 'authorized_case' && $review['files'] === PatientAiReportLogic::$files
|
|
&& str_contains($review['query'], '2020年授权病历') && str_contains($review['query'], '女 · 52岁')
|
|
&& $review['source_summary']['historical_context_included'] === true,
|
|
'full review still aggregates authorized chart demographics, history and images');
|
|
$generic = Logic::prepareAssistant(7, 'summary', '', 8, [], null, $prepareOptions);
|
|
liveExpect(is_array($generic) && !isset($start['analysis_scope']) && !isset($generic['analysis_scope'])
|
|
&& $generic['context_scope'] === 'patient_longitudinal'
|
|
&& str_contains($generic['query'], '2020年授权病历') && $generic['files'] === PatientAiReportLogic::$files
|
|
&& DiagnosisLogic::$detailCalls === $detailCount + 2 && count(PatientAiReportLogic::$calls) === $historyCount + 2,
|
|
'ordinary IM summary/report preparation retains its existing chart path');
|
|
|
|
echo "Dialogue-only video hints / no chart IO / full source / trusted scopes: {$count} total assertions passed (synthetic, no network)\n";
|