(.*?)~su', $text, $matches); evidencePromptExpect(count($matches[1]) === 1, 'One original boundary: ' . $tag); return $matches[1][0]; } $originalRows = CompleteTcmCase::rows(); $snapshot = $originalRows + [ 'daily_records' => ['blood_glucose_pressure' => $originalRows['blood_records'], 'diet' => $originalRows['diet_records'], 'exercise' => $originalRows['exercise_records']], 'chat_records' => ['tencent_im' => $originalRows['im_messages'], 'wechat_work' => []], ]; $differentCurrent = [ 'id' => 900000081, 'diagnosis_date' => '2026-10-19', 'chief_complaint' => '右腕疼痛2天。', 'present_illness' => '自述搬物后右腕疼痛,转动时加重,食欲正常;未记录夜间疼痛和体重变化。', 'symptoms' => '望诊右腕轻度肿胀;闻诊语声清晰,未记录异常气味。问诊及切诊见本次记录。', 'tongue' => '本次面诊:舌质淡红,苔薄白。', 'pulse' => '本次医师切诊:脉弦,按之有力。', 'remark' => '独立工程资料;无附件。', 'allergy_history' => 0, ]; $differentSnapshot = ['diagnoses' => [$differentCurrent], 'doctor_notes' => [], 'daily_records' => ['blood_glucose_pressure' => [], 'diet' => [], 'exercise' => []], 'prescriptions' => [], 'video_calls' => [], 'chat_records' => ['tencent_im' => [], 'wechat_work' => []]]; $cases = [ ['current' => CompleteTcmCase::current(), 'snapshot' => $snapshot, 'profile' => 'openai'], ['current' => $differentCurrent, 'snapshot' => $differentSnapshot, 'profile' => 'qwen'], ]; $reflection = new ReflectionClass(DiagnosisAiLogic::class); $build = $reflection->getMethod('buildAssistantPrompt'); $delivery = $reflection->getMethod('prepareLongReportDelivery'); $resume = $reflection->getMethod('prepareLongReportContinuation'); $manifestPrompt = $reflection->getMethod('requiredSectionsPrompt'); $question = "生成1800至2200字完整中医辨证长报告,完整六节。\n保留全部来源、原日期与未知状态。"; foreach ($cases as $number => $case) { $submission = AiRequiredSource::submission($case['snapshot']); $originalSubmission = serialize($submission); evidencePromptExpect(array_column($submission['required_content'], 'key') === array_keys(AiRequiredSource::LABELS), 'All thirteen original source categories are submitted: ' . $number); $source = json_encode(['original_rows' => $case['snapshot'], 'required_submission' => $submission], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); $guard = TcmEvidenceGuard::assess($case['current']); evidencePromptExpect(empty($guard['restricted']), 'Both unrelated cases have actual tongue/pulse source records.'); $context = ['case_text' => $source, 'demographics' => '独立合成工程病例', 'tcm_evidence_guard' => $guard]; $query = $build->invoke(null, $context, 'custom', $question); $prepared = ['task' => 'custom', 'query' => $query, 'profile' => $case['profile'], 'user' => 'offline-prompt-case-' . $number, 'tcm_evidence_guard' => $guard, 'inputs' => ['all_original_sources' => $source, 'required_submission' => $submission, 'opaque' => "literal\r\nbytes"], 'files' => [['type' => 'image', 'url' => 'https://synthetic.invalid/original.png']], 'upstream_timeout' => 90]; $before = serialize($prepared); $actual = $delivery->invoke(null, $prepared); evidencePromptExpect(serialize($prepared) === $before && serialize($submission) === $originalSubmission, 'Input preparation does not mutate any source request or source category.'); foreach (['task', 'profile', 'user', 'inputs', 'files', 'upstream_timeout', 'tcm_evidence_guard'] as $key) { evidencePromptExpect($actual[$key] === $prepared[$key], 'Original request field survives: ' . $key); } foreach (['CASE_DATA', 'USER_QUESTION'] as $tag) { evidencePromptExpect(evidencePromptBlock($actual['query'], $tag) === evidencePromptBlock($query, $tag), 'Original source/question bytes survive: ' . $tag); } evidencePromptExpect(trim(evidencePromptBlock($actual['query'], 'USER_QUESTION')) === $question, 'The exact original multiline question is not replaced by a generated task.'); $index = TcmClinicalEvidenceIndex::build($guard['fact_state_ledger']); evidencePromptExpect($actual['_clinical_evidence_index'] === $index, 'The application-owned evidence index is retained.'); evidencePromptExpect(array_keys($index['sources']) === array_keys($guard['fact_state_ledger']['sources']), 'The supplemental index keeps original source IDs, not newly assigned span IDs.'); $indexPrompt = TcmClinicalEvidenceIndex::prompt($index, $reflection->getMethod('initialQualifiedDenialConstraints')->invoke(null, $index)); evidencePromptExpect($indexPrompt !== '' && substr_count($actual['query'], $indexPrompt) === 1, 'The role protocol/index is supplied exactly once.'); evidencePromptExpect(substr_count($actual['query'], '辨证逐项举证:') === 1, 'Component evidence rules appear once, without competing copies.'); foreach (['每个脏腑限定、证名成分、治法成分及方药支持点', '一般”“正常”本身不是病理阳性', '较宽但有据的主方向和完整报告', '津少、阴虚、虚热与阴虚火旺分别判断', '关键条件未证实则暂不列方'] as $rule) { evidencePromptExpect(str_contains($query, $rule), 'The compact full-report prompt includes the shared principle: ' . $rule); } $control = str_replace([$indexPrompt, evidencePromptBlock($query, 'CASE_DATA'), evidencePromptBlock($query, 'USER_QUESTION')], '', $actual['query']); foreach (['生脉散', '肺脾气虚', '右腕疼痛', '藿香正气散'] as $caseSpecific) { evidencePromptExpect(!str_contains($control, $caseSpecific), 'Control prose never prescribes a fixture diagnosis/formula: ' . $caseSpecific); } $manifest = AiRequiredSource::manifest($case['snapshot']); $manifestBefore = serialize($manifest); $availability = $manifestPrompt->invoke(null, $manifest); evidencePromptExpect(str_contains($availability, '该栏目未提供记录(原标记:无)') && !preg_match('/:无(?:\r?\n|$)/u', $availability), 'Empty columns have explicit record-status wording.'); evidencePromptExpect(serialize($manifest) === $manifestBefore && serialize($submission) === $originalSubmission, 'Availability explanation does not rewrite raw payload values or manifest state.'); // This test treats already delivered bytes as opaque; it does not certify a clinical prefix. $prefix = "一、四诊\r\n\r\n已交付原样片段。\r\n\r\n"; $sourceId = array_key_first($index['sources']); $continuation = $resume->invoke(null, $actual, $prefix, '未交付的工程片段', [['code' => 'EXAM_ROLE_MISMATCH', 'source_id' => $sourceId]]); evidencePromptExpect($continuation['_clinical_evidence_index'] === $index && $continuation['_validated_report_prefix'] === $prefix, 'Continuation retains the same evidence index and exact fixed prefix.'); evidencePromptExpect(substr_count($continuation['query'], $indexPrompt) === 1, 'Continuation reuses the original role protocol/index without another copy.'); foreach (['inputs', 'files', 'profile', 'user'] as $key) evidencePromptExpect($continuation[$key] === $prepared[$key], 'Continuation preserves the authorized original field: ' . $key); foreach (['CASE_DATA', 'USER_QUESTION'] as $tag) evidencePromptExpect( evidencePromptBlock($continuation['query'], $tag) === evidencePromptBlock($query, $tag), 'Continuation cannot change original source/question bytes.'); $data = json_decode(evidencePromptBlock($continuation['query'], 'REPORT_CONTINUATION_DATA'), true, 64, JSON_THROW_ON_ERROR); evidencePromptExpect($data['delivered_prefix'] === $prefix && $data['original_whole_report_range'] === [1800, 2200] && $data['remaining_suffix_character_range'] === [1800 - mb_strlen($prefix, 'UTF-8'), 2200 - mb_strlen($prefix, 'UTF-8')], 'The original whole-report range and exact prefix arithmetic survive role-repair hints.'); foreach (['EXAM_ROLE_MISMATCH', 'EXAM_SOURCE_QUOTE_INVALID', 'EVIDENCE_SOURCE_DATE_MISMATCH', 'EXAM_FACT_PROTOCOL_INVALID', 'EXAM_KNOWN_ROLE_TO_UNKNOWN', 'UNQUOTED_PATIENT_DENIAL'] as $code) { evidencePromptExpect(str_contains($continuation['query'], $code), 'Repair explanations cover the bounded source protocol: ' . $code); } } $policy = TcmClinicalResponsePolicy::reference(); evidencePromptExpect(mb_strlen($policy, 'UTF-8') <= 600, 'The global response policy stays compact in Unicode characters.'); $once = TcmClinicalResponsePolicy::augmentQuery('原始工程问题'); evidencePromptExpect(TcmClinicalResponsePolicy::augmentQuery($once) === $once && substr_count($once, '[TCM_CLINICAL_RESPONSE:') === 1, 'Global policy augmentation remains idempotent.'); evidencePromptExpect(str_contains($policy, '按实际采集方式归属') && str_contains($policy, '每个限定成分分别举证'), 'The bounded global rule includes role and component evidence principles.'); foreach (['ASSISTANT_PROMPT_VERSION', 'ANALYSIS_PROMPT_VERSION'] as $constant) { evidencePromptExpect(str_ends_with($reflection->getConstant($constant), '-v43'), 'Changed prompt contracts have a fresh version: ' . $constant); } echo "TCM evidence-role input prompts: {$assertions} assertions passed (two unrelated source cases; offline).\n";