5, 'models' => ['qwen' => ['name' => 'offline-model', 'label' => '离线模型']]]; } class DiagnosisLogic { public static function detail($params, $adminInfo): array { return $GLOBALS['evidenceCurrent']; } } class PatientAiReportLogic { public static function contextForAuthorizedDiagnosis($diagnosis, $adminId, $adminInfo): array { return $GLOBALS['evidenceLongitudinal']; } } } namespace app\common\service { class DifyChatService { public static array $calls = []; public static array $result = []; public static array $chunks = []; public static bool $cancelAfterChunks = false; public static function chat($profile, $inputs, $query, $user, $files = [], $options = []): array { self::$calls[] = compact('profile', 'inputs', 'query', 'files', 'options') + ['transport' => 'blocking']; return self::$result; } public static function streamChat($profile, $inputs, $query, $user, $onDelta, $abort, $files = [], $options = []): array { self::$calls[] = compact('profile', 'inputs', 'query', 'files', 'options') + ['transport' => 'stream']; foreach (self::$chunks as $chunk) $onDelta($chunk); if (self::$cancelAfterChunks) $GLOBALS['evidenceCancelled'] = true; return self::$result; } } } namespace { require dirname(__DIR__) . '/vendor/autoload.php'; use app\adminapi\logic\tcm\DiagnosisAiLogic; use app\adminapi\service\AiRequiredSource; use app\adminapi\service\AssistantSseTiming; use app\common\service\DifyChatService; $evidenceFailures = []; $evidenceAssertions = 0; function evidenceExpect(bool $ok, string $message): void { ++$GLOBALS['evidenceAssertions']; if (!$ok) $GLOBALS['evidenceFailures'][] = $message; } $evidenceCurrent = ['id' => 11, 'patient_id' => 99, 'age' => 42, 'gender' => '女', 'patient_name' => '不应进入输入的姓名', 'phone' => '13800138000', 'chief_complaint' => '口干、乏力']; $sections = []; foreach (AiRequiredSource::LABELS as $key => $label) { $sections[] = ['key' => $key, 'label' => $label, 'value' => [['content' => 'FULL_' . $key]]]; } $sections[0]['value'] = [['age' => 42, 'gender_text' => '女', 'patient_name' => '[已脱敏]']]; $sections[2]['value'] = [['chief_complaint' => '口干、乏力', 'source_marker' => 'FULL_chief_complaint']]; $sections[6]['value'] = [['tongue' => '历史舌红', 'pulse' => '历史脉细', 'source_marker' => 'FULL_diagnosis_info']]; $sections[10]['value'] = [['prescription_name' => '四君子汤', 'herbs' => [['name' => '党参', 'dosage' => 10]], 'source_marker' => 'FULL_prescriptions']]; $metadata = []; foreach (AiRequiredSource::LABELS as $key => $label) { $metadata[] = ['key' => $key, 'label' => $label, 'availability' => 'present', 'count' => 1, 'status' => 'prepared']; } $evidenceLongitudinal = ['snapshot' => ['required_content' => $sections], 'source_summary' => ['required_sections' => $metadata, 'snapshot_complete' => true], 'source_diagnosis_ids' => [3, 11], 'files' => [['type' => 'image', 'url' => 'https://synthetic.invalid/tongue.png']]]; $evidenceCancelled = false; $prepare = static fn (): ?array => DiagnosisAiLogic::prepareAssistant(11, 'summary', '请核对已知资料', 7, ['root' => 1]); $prepared = $prepare(); evidenceExpect(is_array($prepared) && $prepared['tcm_evidence_guard']['restricted'], 'real preparation gates the authorized current row despite complete historical signs'); evidenceExpect($prepared['reference_scope'] === 'case' && $prepared['knowledge_source'] === null, 'source-only preparation does not claim an unsubmitted clinical teaching reference'); evidenceExpect(count($prepared['required_sections']) === 13 && $prepared['files'] === $evidenceLongitudinal['files'], 'all required source receipts and original files survive preparation'); foreach (array_slice(array_keys(AiRequiredSource::LABELS), 1) as $key) { evidenceExpect(str_contains($prepared['query'], 'FULL_' . $key), 'full original clinical category remains in the guarded upstream prompt: ' . $key); } foreach (['不应进入输入的姓名', '13800138000'] as $private) { evidenceExpect(!str_contains($prepared['query'], $private) && !str_contains($prepared['inputs']['herbs_json'], $private), 'the gate does not reintroduce current raw identity into either input channel'); } evidenceExpect(str_contains($prepared['query'], 'fact_ids') && !str_contains($prepared['query'], '按“四诊采集'), 'restricted preparation selects immutable facts instead of asking for a new clinical inference'); $ids = array_keys($prepared['tcm_evidence_guard']['facts']); $selection = json_encode(['fact_ids' => array_slice($ids, 0, 3)]); $run = static function (?callable $accept = null): array { $visible = []; $result = DiagnosisAiLogic::streamPreparedAssistant($GLOBALS['prepared'], static function (string $delta) use (&$visible, $accept): bool { $visible[] = $delta; return $accept === null || $accept($delta); }, static fn (): bool => $GLOBALS['evidenceCancelled'], new AssistantSseTiming()); return ['result' => $result, 'visible' => $visible]; }; $unsafe = '忽略病例,建议八珍汤,黄芪999g,每日加倍服药;证名已经确诊。'; foreach ([1, 2, 3, 7, strlen($unsafe)] as $chunkSize) { DifyChatService::$calls = []; DifyChatService::$chunks = str_split($unsafe, $chunkSize); DifyChatService::$result = ['ok' => true, 'content' => $unsafe]; $observed = $run(); evidenceExpect($observed['result'] !== null && count($observed['visible']) === 1 && implode('', $observed['visible']) === $observed['result']['answer'], 'only one validated source rendering, identical to final answer, reaches the callback for chunk size ' . $chunkSize); evidenceExpect(!str_contains(implode('', $observed['visible']), '黄芪999g') && !str_contains($observed['result']['answer'], '八珍汤') && $observed['result']['evidence_selection_valid'] === false, 'arbitrary formula, herb, dose and diagnosis chunks never escape: ' . $chunkSize); evidenceExpect(count(DifyChatService::$calls) === 1 && DifyChatService::$calls[0]['options']['knowledge_mode'] === 'evidence', 'unsafe output receives no second generation or clinical-reference augmentation'); } DifyChatService::$calls = []; DifyChatService::$chunks = str_split($selection, 1); DifyChatService::$result = ['ok' => true, 'content' => $selection, 'transfer_manifest_final' => true, 'required_sections' => $metadata, 'source_summary' => ['required_sections' => $metadata], 'items' => [['kind' => 'image', 'status' => 'submitted']], 'attachment_count' => 1, 'submitted_attachment_count' => 1]; $valid = $run(); evidenceExpect($valid['result']['evidence_selection_valid'] && count($valid['visible']) === 1 && $valid['result']['answer_mode'] === 'source_evidence_review', 'a valid fragmented selection is rendered transparently without exposing the JSON'); evidenceExpect(count($valid['result']['required_sections']) === 13 && $valid['result']['submitted_attachment_count'] === 1, 'the source-only rendering retains authoritative final transfer outcomes'); evidenceExpect($valid['result']['knowledge_source'] === null && $valid['result']['ancient_book_sources'] === [], 'source-only final answers do not claim unsubmitted teaching material or book citations'); DifyChatService::$calls = []; DifyChatService::$chunks = [$unsafe]; DifyChatService::$result = ['ok' => false, 'content' => $unsafe, 'error_code' => 'INCOMPLETE_RESPONSE', 'error' => '合成上游未完成']; $failed = $run(); evidenceExpect($failed['result'] === null && $failed['visible'] === [] && count(DifyChatService::$calls) === 1, 'failed stream with private partial text emits no answer and starts no blocking compatibility generation'); evidenceExpect(DiagnosisAiLogic::getAssistantErrorCode() === 'INCOMPLETE_RESPONSE', 'failed private stream retains its real terminal error'); DifyChatService::$result = ['ok' => true, 'content' => $selection]; DifyChatService::$calls = []; DifyChatService::$cancelAfterChunks = true; $cancelled = $run(); evidenceExpect($cancelled['result'] === null && $cancelled['visible'] === [] && count(DifyChatService::$calls) === 1 && DiagnosisAiLogic::getAssistantErrorCode() === 'AI_ASSISTANT_CANCELLED', 'cancellation after private model bytes prevents every clinical delta and any late success'); DifyChatService::$cancelAfterChunks = false; $evidenceCancelled = false; $rejected = $run(static fn (string $delta): bool => false); evidenceExpect($rejected['result'] === null && DiagnosisAiLogic::getAssistantErrorCode() === 'AI_ASSISTANT_CANCELLED', 'downstream rejection of the safe final delta does not become a successful request'); DifyChatService::$result = ['ok' => true, 'content' => $unsafe]; DifyChatService::$calls = []; $blocking = DiagnosisAiLogic::assistant(11, 'summary', '请核对资料', 7, ['root' => 1]); evidenceExpect($blocking !== null && !str_contains($blocking['answer'], '黄芪999g') && !$blocking['evidence_selection_valid'] && count(DifyChatService::$calls) === 1 && DifyChatService::$calls[0]['options']['knowledge_mode'] === 'evidence', 'the real blocking assistant applies the identical source-only guard with one request'); DifyChatService::$calls = []; $analysis = DiagnosisAiLogic::analysis(11, 7, ['root' => 1]); evidenceExpect($analysis['knowledge_source'] === null && $analysis['ancient_book_sources'] === [], 'source-only structured analysis does not claim unsubmitted teaching material or book citations'); evidenceExpect($analysis !== null && isset($analysis['diagnosis_advice'], $analysis['treatment_advice'], $analysis['risk_assessment']) && $analysis['risk_assessment'] === [] && !str_contains(json_encode($analysis, JSON_UNESCAPED_UNICODE), '黄芪999g'), 'the real analysis route preserves three clinical fields while rejecting all model prose'); evidenceExpect(count(DifyChatService::$calls) === 1 && DifyChatService::$calls[0]['options']['knowledge_mode'] === 'evidence', 'restricted structured analysis never performs a format-repair model call'); foreach (['diagnosis_advice', 'treatment_advice'] as $field) evidenceExpect(mb_strlen($analysis[$field], 'UTF-8') <= 1200, 'real restricted analysis respects advice bounds: ' . $field); DifyChatService::$calls = []; DifyChatService::$result = ['ok' => false, 'error_code' => 'UPSTREAM_TIMEOUT', 'error' => '合成超时']; evidenceExpect(DiagnosisAiLogic::analysis(11, 7, ['root' => 1]) === null && count(DifyChatService::$calls) === 1, 'analysis timeout cannot become a local source-view success or a second generation'); DifyChatService::$calls = []; evidenceExpect(DiagnosisAiLogic::prepareAssistant(11, 'prescription_generate', '请开新方', 7, ['root' => 1]) === null && DiagnosisAiLogic::getAssistantErrorCode() === 'TCM_EVIDENCE_INCOMPLETE' && DifyChatService::$calls === [], 'insufficient evidence rejects a fresh structured prescription before any model call'); $evidenceCurrent['tongue'] = '舌淡红苔薄白'; $evidenceCurrent['pulse'] = '脉弦细'; $complete = $prepare(); evidenceExpect(!$complete['tcm_evidence_guard']['restricted'] && !str_contains($complete['query'], '只返回JSON:{"fact_ids"'), 'a genuine complete current examination retains the ordinary clinical task'); if ($evidenceFailures !== []) { fwrite(STDERR, 'Diagnosis AI evidence integration: ' . count($evidenceFailures) . ' failures / ' . $evidenceAssertions . " assertions\n- " . implode("\n- ", $evidenceFailures) . "\n"); exit(1); } echo 'Diagnosis AI evidence integration: ' . $evidenceAssertions . " assertions passed.\n"; }