Files
zyt/server/tests/LiveConsultationDialogueOnlyTest.php
2026-10-30 13:41:35 +08:00

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";