90, 'models' => [ 'qwen' => ['name' => 'offline-qwen', 'label' => '离线千问'], 'openai' => ['name' => 'offline-openai', 'label' => '离线OpenAI'], ]]; } class DiagnosisLogic { public static function detail($params, $adminInfo): array { return $GLOBALS['completeCurrent']; } } class PatientAiReportLogic { public static function contextForAuthorizedDiagnosis($diagnosis, $adminId, $adminInfo): array { ++$GLOBALS['completeAggregationCalls']; return $GLOBALS['completeLongitudinal']; } public static function compactSourceForPrompt($profile, $text, $options = []): array { throw new \RuntimeException('A below-budget fixture must never compact or call a model.'); } } } namespace app\common\service { class DifyChatService { public static array $calls = []; public static array $responses = []; public static array $chunks = []; public static bool $insideStream = false; public static function chat($profile, $inputs, $query, $user, $files = [], $options = []): array { self::$calls[] = compact('profile', 'inputs', 'query', 'files', 'options') + ['transport' => 'blocking']; if (self::$responses === []) throw new \RuntimeException('Unexpected extra upstream call.'); return array_shift(self::$responses); } public static function streamChat($profile, $inputs, $query, $user, $onDelta, $abort, $files = [], $options = []): array { self::$calls[] = compact('profile', 'inputs', 'query', 'files', 'options') + ['transport' => 'stream']; self::$insideStream = true; try { foreach (self::$chunks as $chunk) { if ($onDelta($chunk) === false) return ['ok' => false, 'error_code' => 'AI_ASSISTANT_CANCELLED']; } } finally { self::$insideStream = false; } if (self::$responses === []) throw new \RuntimeException('Unexpected extra upstream call.'); return array_shift(self::$responses); } } } namespace { require dirname(__DIR__) . '/vendor/autoload.php'; use app\adminapi\logic\tcm\DiagnosisAiLogic; use app\adminapi\service\AiRequiredSource; use app\common\service\DifyChatService; $completeAssertions = 0; function completeExpect(bool $ok, string $message): void { ++$GLOBALS['completeAssertions']; if (!$ok) throw new \RuntimeException($message); } function completeResetTransport(array $responses): void { DifyChatService::$calls = []; DifyChatService::$responses = $responses; DifyChatService::$chunks = []; } function completeExpectFactFidelity(string $query, string $route): void { // Prompt delivery contract only; these assertions cannot certify model compliance. $rules = [ 'source polarity, severity and chronology' => ['“明确有”“明确无”“未记录/未采集”', '否定、程度、日期与本次/历史身份'], 'unrecorded pulse is unknown' => ['心率或舌像', '未记涩、数不能补写“不涩、不数”'], 'formula indications cannot create patient facts' => ['常见方证表现不得变成本例已有症状', '本例已记录的短证据', '保留反证'], 'one coherent primary direction' => ['主方向', '不把互相冲突的治法/方剂并列为同时适用'], 'medication actions require current use' => ['当前确实在用的依据', '早已停用的药物不得再安排'], 'normal tests do not prove zero risk' => ['未记录异常不等于检查正常', '局部或单次正常检查不等于风险为零'], 'unsupported claims require removal or explicit uncertainty' => ['找不到原文支持', '删除或改为明确待验证条件'], 'patient negatives retain literal source boundaries' => ['四诊已记录阴性', '不另写“本例无A、B、C”', '不得写“已排除/肯定不是”'], 'a primary formula requires traceable case evidence' => ['新增主方方向最多一个', '每个支持点逐字短引本例原文', '不展开备用方名或加味药清单'], ]; foreach ($rules as $rule => $fragments) { completeExpect(count(array_filter($fragments, static fn (string $fragment): bool => str_contains($query, $fragment))) === count($fragments), 'Fact-fidelity instructions reach ' . $route . ': ' . $rule); } } $completePermissions = ['tcm.diagnosis/aiassistant', 'tcm.diagnosis/aianalysis']; $completeScopeAllowed = true; $completeAggregationCalls = 0; $completeCurrent = ['id' => 11, 'patient_id' => 99, 'age' => 42, 'gender' => '女', 'diagnosis_date' => '2026-10-23', 'chief_complaint' => '合成病例:乏力、食少持续两周,活动后较明显,休息后稍缓解;患者自述仍能完成日常活动,近两日以居家家务为主,白天与傍晚均有乏力,起止和活动关系按本次访谈记录。', 'tongue' => '本次舌淡红,苔薄白', 'pulse' => '本次脉弦细', 'patient_name' => '不应传输的原始姓名', 'phone' => '13800138000']; $completeSections = []; $completeMetadata = []; foreach (AiRequiredSource::LABELS as $key => $label) { $completeSections[] = ['key' => $key, 'label' => $label, 'value' => [['source_marker' => 'COMPLETE_' . $key]]]; $completeMetadata[] = ['key' => $key, 'label' => $label, 'availability' => 'present', 'count' => 1, 'status' => 'prepared']; } foreach ($completeSections as &$section) { if ($section['key'] === 'diagnosis_info') { $section['value'][0] += ['diagnosis_date' => '2026-10-23', 'tongue' => $completeCurrent['tongue'], 'pulse' => $completeCurrent['pulse']]; $section['value'][] = ['diagnosis_date' => '2020-01-01', 'tongue' => '历史舌红', 'pulse' => '历史脉滑']; } if ($section['key'] === 'prescriptions') { $section['value'][0] += ['prescription_date' => '2020-01-01', 'prescription_name' => '历史四君子汤', 'herbs' => [['name' => '历史党参', 'dosage' => 10]]]; } } unset($section); $completeLongitudinal = ['snapshot' => ['required_content' => $completeSections], 'source_summary' => ['required_sections' => $completeMetadata, 'snapshot_complete' => true], 'source_diagnosis_ids' => [3, 11], 'files' => [['type' => 'image', 'url' => 'https://synthetic.invalid/not-fetched.png']]]; $reflection = new \ReflectionClass(DiagnosisAiLogic::class); foreach (['MAX_REPORT_LENGTH' => 12000, 'MAX_PROMPT_SOURCE_BYTES' => 120000, 'MAX_ANALYSIS_RESPONSE_BYTES' => 32768, 'MAX_ANALYSIS_ADVICE_LENGTH' => 1200, 'MAX_ANALYSIS_RISK_ITEMS' => 8, 'MAX_ANALYSIS_RISK_LABEL_LENGTH' => 120] as $name => $bound) { completeExpect($reflection->getConstant($name) === $bound, 'Established budget remains unchanged: ' . $name); } completeExpect($reflection->getConstant('MODEL_KEYS') === ['qwen', 'openai'], 'Both existing profile keys remain available.'); completeExpect(str_ends_with($reflection->getConstant('ASSISTANT_PROMPT_VERSION'), '-v43') && str_ends_with($reflection->getConstant('ANALYSIS_PROMPT_VERSION'), '-v43'), 'Both changed clinical prompts invalidate prior provenance/cache keys.'); // Capture a real preparation; a source-only replacement here would lose the clinical prompt. $prepared = DiagnosisAiLogic::prepareAssistant(11, 'summary', '请依据本次完整四诊给出完整辨证报告并区分历史资料', 7, []); completeExpect($prepared !== null && !$prepared['tcm_evidence_guard']['restricted'], 'Complete CURRENT examination keeps the clinical path.'); completeExpect($prepared['reference_scope'] === 'case' && $prepared['knowledge_source'] === null, 'Ordinary assistant preparation selects case references without claiming a teaching source.'); completeExpect($prepared['tcm_evidence_guard']['current_tongue'] === $completeCurrent['tongue'] && $prepared['tcm_evidence_guard']['current_pulse'] === $completeCurrent['pulse'], 'History does not overwrite current examination identity.'); completeExpect(str_contains($prepared['query'], '四诊采集') && str_contains($prepared['query'], '中药方药') && !str_contains($prepared['query'], '只返回JSON:{"fact_ids"'), 'Ordinary clinical instructions survive preparation.'); completeExpectFactFidelity($prepared['query'], 'ordinary assistant preparation'); foreach (array_keys(AiRequiredSource::LABELS) as $key) { completeExpect(str_contains($prepared['query'], 'COMPLETE_' . $key), 'No required source category is replaced by the guard index: ' . $key); } foreach (['本次舌淡红', '本次脉弦细', '历史舌红', '历史脉滑', '2020-01-01', '历史四君子汤', '历史党参'] as $fact) { completeExpect(str_contains($prepared['query'], $fact), 'Complete clinical prompt preserves provenance-relevant source text: ' . $fact); } completeExpect(str_contains($prepared['query'], '不冒充本次新增建议'), 'Historical prescriptions retain an explicit non-new-advice instruction.'); completeExpect($prepared['files'] === $completeLongitudinal['files'] && count($prepared['required_sections']) === 13 && $prepared['source_form'] === 'full_context' && $prepared['upstream_timeout'] === 90, 'Attachment/manifest/full-context/timeout envelope survives normal preparation.'); $serialized = json_encode([$prepared['query'], $prepared['inputs']], JSON_UNESCAPED_UNICODE); completeExpect(!str_contains($serialized, '不应传输的原始姓名') && !str_contains($serialized, '13800138000'), 'Current raw identity is not inserted in either upstream channel.'); // Transport fixture only: this intentionally does not certify the clinical content of a real model. // These literal quotations match current s1/s2/s3. The generic material // after section one exercises bytes/length only, never clinical quality. $report = "一、四诊\n\n望诊:未记录。\n\n闻诊:未记录。\n\n" . "问诊:2026-10-23“合成病例:乏力、食少持续两周,活动后较明显,休息后稍缓解;患者自述仍能完成日常活动,近两日以居家家务为主,白天与傍晚均有乏力,起止和活动关系按本次访谈记录。”(s1)。\n\n" . "切诊:其他触诊未记录,脉象见独立脉象项。\n\n" . "舌象:2026-10-23“本次舌淡红,苔薄白”(s2)。\n\n脉象:2026-10-23“本次脉弦细”(s3)。\n\n" . "二、八纲\n\n各对结论区分已知记录、假设与待核实依据。此处仅演示离线工程传输段落,不形成任何临床结论。\n\n" . "三、气血津液与脏腑定位\n\n此处仅演示离线工程传输段落,真实定位须由医师核对原始证据及不确定性。\n\n" . "四、证名\n\n此处仅演示离线工程传输段落,证名需由医师按实际病例证据复核。\n\n" . "五、治法\n\n此处仅演示离线工程传输段落,治法需与经复核的证名和实际病例证据对应。\n\n" . "六、中药方药\n\n此处仅演示离线工程传输段落,既往资料和本次资料的时点由来源保留,不能据工程文字执行治疗。\n"; foreach (range(1, 24) as $number) { $report .= '合成长报告传输段' . $number . ':保留本段来源时点、既往记录与不确定性;本测试只检查原文传输,没有模型推理或临床处方结论。' . "\n"; } $report = str_replace("\n", "\n\n", $report); completeExpect(mb_strlen($report, 'UTF-8') > 1200 && mb_strlen($report, 'UTF-8') < 12000, 'Fixture exercises a long assistant answer beyond structured-analysis field size.'); completeResetTransport([['ok' => true, 'content' => $report]]); DifyChatService::$chunks = mb_str_split($report, 79, 'UTF-8'); $visible = []; $visibleDuringStream = []; $streamed = DiagnosisAiLogic::streamPreparedAssistant($prepared, static function (string $delta) use (&$visible, &$visibleDuringStream): bool { $visible[] = $delta; $visibleDuringStream[] = DifyChatService::$insideStream; return true; }); completeExpect($streamed !== null && $streamed['answer_mode'] === 'clinical_analysis' && $streamed['evidence_selection_valid'] === null, 'Complete-evidence streaming is reported as clinical analysis.'); completeExpect(count($visible) > 1 && !in_array(false, $visibleDuringStream, true), 'Ordinary clinical deltas are delivered during generation, before the final result.'); completeExpect(implode('', $visible) === $report && $streamed['answer'] === trim($report), 'Long model text is preserved without source-view substitution or missing tail.'); completeExpect(count(DifyChatService::$calls) === 1 && DifyChatService::$calls[0]['options']['knowledge_mode'] === 'clinical' && DifyChatService::$calls[0]['options']['reference_scope'] === 'case' && DifyChatService::$calls[0]['options']['required_files'] === true, 'One clinical stream uses the existing profile, case scope and required-source contract.'); completeExpect($streamed['knowledge_source'] === null && $streamed['ancient_book_sources'] === [], 'Ordinary streamed answers do not falsely attribute unsubmitted reference material.'); completeResetTransport([['ok' => true, 'content' => $report]]); $blocking = DiagnosisAiLogic::assistant(11, 'summary', '请给出完整辨证报告', 7, []); completeExpect($blocking !== null && $blocking['answer'] === trim($report) && $blocking['knowledge_source'] === null && $blocking['ancient_book_sources'] === [] && count(DifyChatService::$calls) === 1 && DifyChatService::$calls[0]['options']['reference_scope'] === 'case' && DifyChatService::$calls[0]['options']['knowledge_mode'] === 'clinical' && DifyChatService::$calls[0]['files'] === $completeLongitudinal['files'], 'Blocking assistant retains the case scope, complete report and original attachments.'); completeResetTransport([['ok' => false, 'error_code' => 'INCOMPLETE_RESPONSE'], ['ok' => true, 'content' => $report]]); $compatibility = DiagnosisAiLogic::streamPreparedAssistant($prepared, static fn (string $text): bool => true, null, null, ['reference_scope' => 'full']); completeExpect($compatibility !== null && count(DifyChatService::$calls) === 2 && array_column(DifyChatService::$calls, 'transport') === ['stream', 'blocking'] && array_column(array_column(DifyChatService::$calls, 'options'), 'reference_scope') === ['case', 'case'] && array_column(array_column(DifyChatService::$calls, 'options'), 'knowledge_mode') === ['clinical', 'clinical'], 'Both sides of the pre-delta compatibility retry retain the server-selected case scope.'); $draftPrepared = DiagnosisAiLogic::prepareAssistant(11, 'prescription_generate', '请生成处方草稿', 7, ['root' => 1]); completeExpect($draftPrepared !== null && $draftPrepared['reference_scope'] === 'full' && is_array($draftPrepared['knowledge_source']), 'Structured prescription preparation retains its full reference scope.'); $clinical = ['diagnosis_advice' => '四诊:本次舌淡红苔薄白、脉弦细。八纲:逐项说明依据与待核实项。气血津液与脏腑:按源事实讨论。证名:待医生复核。', 'risk_assessment' => [['label' => '合成风险待核实', 'level' => 'low']], 'treatment_advice' => '治法与证名对应。中药方药:历史处方仅据实复核,非本次新增建议。必要复核:补充不确定性。']; foreach (['qwen', 'openai'] as $profile) { completeResetTransport([['ok' => true, 'content' => json_encode($clinical, JSON_UNESCAPED_UNICODE)]]); $analysis = DiagnosisAiLogic::analysis(11, 7, [], $profile); completeExpect($analysis !== null && array_intersect_key($analysis, $clinical) === $clinical, 'Real analysis parser preserves all three clinical fields rather than substituting a source-only review: ' . $profile); completeExpect($analysis['answer_mode'] === 'clinical_analysis' && $analysis['model_key'] === $profile && $analysis['evidence_selection_valid'] === null, 'Profile/mode remains the independently selected clinical route: ' . $profile); completeExpect(count(DifyChatService::$calls) === 1 && DifyChatService::$calls[0]['profile'] === $profile && DifyChatService::$calls[0]['options']['knowledge_mode'] === 'clinical' && DifyChatService::$calls[0]['options']['reference_scope'] === 'case', 'Each profile uses its own existing clinical transport with case references: ' . $profile); completeExpect($analysis['knowledge_source'] === null && $analysis['ancient_book_sources'] === [], 'Case analysis does not claim unsubmitted teaching references: ' . $profile); completeExpect(str_contains(DifyChatService::$calls[0]['query'], '只输出一个 JSON 对象') && !str_contains(DifyChatService::$calls[0]['query'], '只返回JSON:{"fact_ids"'), 'Ordinary analysis keeps the JSON prompt: ' . $profile); completeExpectFactFidelity(DifyChatService::$calls[0]['query'], $profile . ' structured analysis'); } completeResetTransport([['ok' => true, 'content' => '合成格式错误'], ['ok' => true, 'content' => json_encode($clinical, JSON_UNESCAPED_UNICODE)]]); $repaired = DiagnosisAiLogic::analysis(11, 7, [], 'qwen'); completeExpect($repaired !== null && array_intersect_key($repaired, $clinical) === $clinical && $repaired['answer_mode'] === 'clinical_analysis', 'A format failure followed by valid JSON stays an ordinary clinical result.'); completeExpect(count(DifyChatService::$calls) === 2 && array_column(DifyChatService::$calls, 'profile') === ['qwen', 'qwen'] && array_column(array_column(DifyChatService::$calls, 'options'), 'reference_scope') === ['case', 'case'] && str_contains(DifyChatService::$calls[1]['query'], '格式校验提醒'), 'Format repair stays on the same existing profile with one bounded retry.'); completeExpectFactFidelity(DifyChatService::$calls[1]['query'], 'same-profile JSON format repair'); completeResetTransport([['ok' => true, 'content' => 'bad1'], ['ok' => true, 'content' => 'bad2']]); completeExpect(DiagnosisAiLogic::analysis(11, 7, [], 'qwen') === null && count(DifyChatService::$calls) === 2, 'Repeated invalid clinical JSON fails after the existing two attempts instead of rendering a local successful review.'); $completeScopeAllowed = false; completeResetTransport([]); $aggregationsBeforeDenial = $completeAggregationCalls; completeExpect(DiagnosisAiLogic::prepareAssistant(11, 'summary', '', 7, []) === null && DiagnosisAiLogic::analysis(11, 7, [], 'qwen') === null && DifyChatService::$calls === [] && $completeAggregationCalls === $aggregationsBeforeDenial, 'Denied diagnosis scope blocks aggregation and both clinical transports.'); $completeScopeAllowed = true; $completePermissions = []; completeExpect(DiagnosisAiLogic::prepareAssistant(11, 'summary', '', 7, []) === null && DiagnosisAiLogic::analysis(11, 7, [], 'qwen') === null && DifyChatService::$calls === [] && $completeAggregationCalls === $aggregationsBeforeDenial, 'Missing action permission blocks aggregation and clinical transports.'); echo 'Diagnosis AI complete-evidence clinical path: ' . $completeAssertions . " assertions passed (offline stubs only).\n"; }