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