array_replace([ 'kind' => $kind, 'values' => $values, 'record_date' => null, 'record_time' => '07:45', 'date_text' => '今天', 'time_text' => '七点四十五分', 'time_period' => null, 'time_estimated' => false, 'needs_review' => false, 'evidence' => [['text' => $quote]], ], $extra); $expectedItem = static fn (string $kind, array $values, ?string $date, ?string $time, bool $estimated = false, bool $review = false, ?string $period = null): array => [ 'kind' => $kind, 'values' => $values, 'record_date' => $date, 'record_time' => $time, 'time_period' => $period, 'time_estimated' => $estimated, 'needs_review' => $review || $kind === 'diagnosis', 'selected' => false, ]; $add = static function (string $id, string $claim, string $transcript, array $items, array $expected, string $recordedAt = '2026-09-20 23:00:00', string $layer = 'rule', ?string $semanticExpected = null, ?string $limitation = null) use (&$cases): void { $cases[] = compact('id', 'claim', 'transcript', 'items', 'expected', 'recordedAt', 'layer', 'semanticExpected', 'limitation'); }; foreach ([ ['date_today_month', '今天跨月', '今天', '2026-03-01 09:00:00', '2026-03-01'], ['date_yesterday_month', '昨天跨月', '昨天', '2026-03-01 09:00:00', '2026-02-28'], ['date_yesterday_leap', '昨天跨闰年二月', '昨天', '2024-03-01 09:00:00', '2024-02-29'], ['date_today_year', '今天跨年', '今天', '2026-01-01 09:00:00', '2026-01-01'], ['date_yesterday_year', '昨天跨年', '昨天', '2026-01-01 09:00:00', '2025-12-31'], ] as [$id, $label, $spoken, $recordedAt, $date]) { $quote = $spoken . '七点四十五分空腹血糖六点一。'; $add($id, $label . ':以录制日期而非执行日期换算', $quote, [$event('blood', ['fasting_blood_sugar' => 6.1], $quote, ['date_text' => $spoken])], [$expectedItem('blood', ['fasting_blood_sugar' => 6.1], $date, '07:45')], $recordedAt); } foreach ([['早上', '早晨', '08:00'], ['中午', '中午', '12:00'], ['下午', '下午', '15:00'], ['晚上', '晚上', '20:00'], ['睡前', '睡前', '22:00']] as $index => [$spoken, $period, $time]) { $quote = '今天' . $spoken . '收缩压一百二十,具体几点没记。'; $add('period_' . ($index + 1), $spoken . '默认钟点必须是估算且待核对', $quote, [$event('blood', ['systolic_pressure' => 120], $quote, ['record_time' => null, 'time_text' => $spoken, 'time_period' => $spoken])], [$expectedItem('blood', ['systolic_pressure' => 120], '2026-09-20', $time, true, true, $period)]); } $quote = '今天下午三点二十七分测的收缩压一百二十。'; $add('exact_clock', '结构结果明确15:27时,优先于下午15:00默认值,不标估算', $quote, [$event('blood', ['systolic_pressure' => 120], $quote, ['record_time' => '15:27', 'time_text' => '下午三点二十七分', 'time_period' => '下午'])], [$expectedItem('blood', ['systolic_pressure' => 120], '2026-09-20', '15:27', false, false, '下午')]); $quote = '今天晚上八点零九分三十秒测的收缩压一百二十。'; $add('exact_clock_seconds', '明确秒数按既有分钟字段精度截取,不变成估算', $quote, [$event('blood', ['systolic_pressure' => 120], $quote, ['record_time' => '20:09:30', 'time_text' => '晚上八点零九分三十秒', 'time_period' => '晚上'])], [$expectedItem('blood', ['systolic_pressure' => 120], '2026-09-20', '20:09', false, false, '晚上')]); foreach (['上周', '最近', '昨天或前天'] as $index => $spoken) { $quote = $spoken . '七点四十五分空腹血糖六点一,哪天记不清了。'; $add('ambiguous_date_' . ($index + 1), $spoken . '不能被模型给出的确定日期覆盖,保留待确认', $quote, [$event('blood', ['fasting_blood_sugar' => 6.1], $quote, ['date_text' => $spoken, 'record_date' => '2026-09-19'])], [$expectedItem('blood', ['fasting_blood_sugar' => 6.1], null, '07:45', false, true)]); } $quote = '昨天七点四十五分空腹血糖六点一。'; $add('relative_date_conflict', '昨天与模型日期矛盾则保持待确认', $quote, [$event('blood', ['fasting_blood_sugar' => 6.1], $quote, ['date_text' => '昨天', 'record_date' => '2026-09-20'])], [$expectedItem('blood', ['fasting_blood_sugar' => 6.1], null, '07:45', false, true)]); $quoteA = '今天早上第一次测空腹血糖六点一。'; $quoteB = '今天早上又测了一次空腹血糖,还是六点一。'; $estimatedBlood = ['record_time' => null, 'time_text' => '早上', 'time_period' => '早上']; $sameValueExpected = $expectedItem('blood', ['fasting_blood_sugar' => 6.1], '2026-09-20', '08:00', true, true, '早晨'); $add('same_value_different_events', '同值、同默认时间但不同测量证据保留两条', $quoteA . $quoteB, [$event('blood', ['fasting_blood_sugar' => 6.1], $quoteA, $estimatedBlood), $event('blood', ['fasting_blood_sugar' => 6.1], $quoteB, $estimatedBlood)], [$sameValueExpected, $sameValueExpected]); $quote = '今天七点四十五分只测过一次空腹血糖,六点一。'; $sameEvent = $event('blood', ['fasting_blood_sugar' => 6.1], $quote); $add('same_event_identical_evidence', '完全相同事件结构和逐字证据重复只保留一条', $quote, [$sameEvent, $sameEvent], [$expectedItem('blood', ['fasting_blood_sugar' => 6.1], '2026-09-20', '07:45')]); $quote = '最近睡眠不好,其他情况没有谈。'; $add('absent_fields_not_defaulted', '仅给出现病史,不自动填过敏史、家族史、药物为无或0', $quote, [$event('diagnosis', ['symptoms' => '最近睡眠不好'], $quote, ['record_time' => null, 'time_text' => ''])], [$expectedItem('diagnosis', ['symptoms' => '最近睡眠不好'], null, null)]); $quote = '我明确没有过敏史,家族病史没谈。'; $add('explicit_zero_only', '明确无过敏史可保存0,但不扩展到未提及家族史', $quote, [$event('diagnosis', ['allergy_history' => 0], $quote, ['record_time' => null, 'time_text' => ''])], [$expectedItem('diagnosis', ['allergy_history' => 0], '2026-09-20', null)]); foreach ([ ['reject_hospital_unknown_key', 'diagnosis', ['hospital_diagnosis' => '模型生成的医院诊断'], 'FIELD_INVALID'], ['reject_prescription_key', 'diagnosis', ['prescription' => '模型生成的处方'], 'FIELD_INVALID'], ['reject_prescription_kind', 'prescription', ['content' => '模型生成的处方'], 'VALUES_INVALID'], ['reject_business_identity', 'diagnosis', ['patient_id' => 999], 'FIELD_INVALID'], ['reject_zero_blood_sugar', 'blood', ['fasting_blood_sugar' => 0], 'NUMBER_INVALID'], ] as [$id, $kind, $values, $code]) { $quote = '这是合成原话,未说明模型生成字段的内容。'; $add($id, '非白名单字段/类型或非法数值不能进入候选:' . $code, $quote, [$event($kind, $values, $quote)], []); $cases[array_key_last($cases)]['expectedErrorCode'] = $code; } $quote = '我目前在用二甲双胍,剂量没说。'; $add('medication_literal_preserved', '已给出的药物候选和真实字面证据可保留,不推测剂量', $quote, [$event('diagnosis', ['current_medications' => '二甲双胍'], $quote, ['record_time' => null, 'time_text' => ''])], [$expectedItem('diagnosis', ['current_medications' => '二甲双胍'], '2026-09-20', null)]); $quote = '今天七点四十五分空腹血糖六点一。'; $add('missing_literal_evidence', '不存在于转写的证据不能成为已核对候选', $quote, [$event('blood', ['fasting_blood_sugar' => 6.1], '转写中不存在的证据')], [$expectedItem('blood', ['fasting_blood_sugar' => 6.1], '2026-09-20', '07:45', false, true)]); $add('empty_literal_evidence', '没有证据的候选需要核对', $quote, [$event('blood', ['fasting_blood_sugar' => 6.1], $quote, ['evidence' => []])], [$expectedItem('blood', ['fasting_blood_sugar' => 6.1], '2026-09-20', '07:45', false, true)]); $add('missing_date_label_with_spoken_today', 'date_text遗漏但证据明确今天时只按录制日期锚定原话,不依赖模型日期标签', $quote, [$event('blood', ['fasting_blood_sugar' => 6.1], $quote, ['date_text' => ''])], [$expectedItem('blood', ['fasting_blood_sugar' => 6.1], '2026-09-20', '07:45')]); $add('invalid_clock', '非法时分不能进入候选', $quote, [$event('blood', ['fasting_blood_sugar' => 6.1], $quote, ['record_time' => '25:01'])], []); $cases[array_key_last($cases)]['expectedErrorCode'] = 'TIME_INVALID'; // These adversarial candidates intentionally contain semantic errors. Observing their // acceptance proves a boundary, not a product PASS, and never sends answers to a model. $diagnosisExtra = ['record_time' => null, 'time_text' => '']; foreach ([ ['semantic_family_medication', '家属药物不能当本人用药', '是我父亲在用二甲双胍,我没用。', ['current_medications' => '二甲双胍'], '不应生成本人current_medications=二甲双胍'], ['semantic_negated_medication', '否定句不能反写为正在用药', '我没有使用胰岛素。', ['current_medications' => '胰岛素'], '不应生成本人current_medications=胰岛素'], ['semantic_unsaid_drug', '真实字面引文不等于候选药名有依据', '我在用药,但这次没有说药名。', ['current_medications' => '二甲双胍'], '不应补出未说过的药名'], ['semantic_unsaid_dose', '真实药名不允许补出未说剂量', '我目前在用药甲,剂量没有说。', ['current_medications' => '药甲,每次两片'], '不应补出未说过的剂量'], ['semantic_unsaid_negative', '未提及不能让模型填写无或0', '这次只谈了睡眠,过敏史没有询问。', ['allergy_history' => 0], '不应生成allergy_history=0'], ['semantic_hospital_whitelist', '白名单字段仍须有真实事实依据', '这次没有提到去过哪家医院。', ['local_hospital_name' => '模型编造的医院'], '不应生成未说过的local_hospital_name'], ] as [$id, $claim, $quote, $values, $semanticExpected]) { $add($id, $claim, $quote, [$event('diagnosis', $values, $quote, $diagnosisExtra)], [$expectedItem('diagnosis', $values, '2026-09-20', null)], $base, 'model-dependent', $semanticExpected, '现有Policy/Fields增加了保守逐项复核门禁,但仍不能证明主体、肯否、药名/剂量及事实含义识别正确。'); } $quoteA = '今天七点四十五分测的空腹血糖六点一。'; $quoteB = '刚才说的还是那次七点四十五分的六点一,不是又测了一次。'; $repeatExpected = $expectedItem('blood', ['fasting_blood_sugar' => 6.1], '2026-09-20', '07:45', false, true); $add('semantic_same_event_rephrased', '同一测量事件被不同话语重复,不能当两次测量', $quoteA . $quoteB, [$event('blood', ['fasting_blood_sugar' => 6.1], $quoteA), $event('blood', ['fasting_blood_sugar' => 6.1], $quoteB)], [$repeatExpected, $repeatExpected], $base, 'model-dependent', '语义识别同一测量,期望只保留一条', '现有去重以结构字段加相同证据为身份,不解析“那次/不是又测”事件指代。'); $quote = '今天下午三点二十七分收缩压一百二十。'; $add('semantic_spoken_time_only', '原話中明确时分仍需上游正确结构化', $quote, [$event('blood', ['systolic_pressure' => 120], $quote, ['record_time' => null, 'time_text' => '下午三点二十七分', 'time_period' => '下午'])], [$expectedItem('blood', ['systolic_pressure' => 120], '2026-09-20', '15:00', true, true, '下午')], $base, 'model-dependent', '正确识别原话,应给15:27且不估算', 'Policy不把中文time_text解析为时分;候选漏填record_time时仍走下午15:00估算并待确认。'); $add('semantic_exact_time_marked_estimate', '明确时分的估算标志也依赖结构结果准确', $quote, [$event('blood', ['systolic_pressure' => 120], $quote, ['record_time' => '15:27', 'time_text' => '下午三点二十七分', 'time_period' => '下午', 'time_estimated' => true])], [$expectedItem('blood', ['systolic_pressure' => 120], '2026-09-20', '15:27', true, true, '下午')], $base, 'model-dependent', '确切时分应不估算,前提是上游标志正确', 'Policy保守保留上游time_estimated=true,不会从原话自动纠正。'); $results = []; $failures = []; foreach ($cases as $case) { $input = ['summary' => '仅用于本地验收的合成摘要', 'transcript' => $case['transcript'], 'uncertainties' => [], 'items' => $case['items']]; $actual = null; $error = null; try { $actual = Policy::normalizeExtraction($input, $case['recordedAt']); } catch (Throwable $failure) { $error = get_class($failure) . ': ' . $failure->getMessage(); } $projection = $actual === null ? null : array_map(static fn (array $item): array => array_intersect_key($item, array_flip(['kind', 'values', 'record_date', 'record_time', 'time_period', 'time_estimated', 'needs_review', 'selected'])), $actual['items']); $observationMatches = $error === null && Policy::canonical($projection ?? []) === Policy::canonical($case['expected']); if (isset($case['expectedErrorCode'])) { $observationMatches = $observationMatches && count($actual['uncertainties']) === 1 && str_contains($actual['uncertainties'][0], $case['expectedErrorCode']); } else { $observationMatches = $observationMatches && ($actual['uncertainties'] ?? []) === []; } if (!$observationMatches) { $failures[] = $case['id']; } $results[] = [ 'id' => $case['id'], 'layer' => $case['layer'], 'claim' => $case['claim'], 'recorded_at' => $case['recordedAt'], 'synthetic_transcript' => $case['transcript'], 'candidate_input' => $case['items'], 'expected_rule_observation' => $case['expected'], 'semantic_expected' => $case['semanticExpected'], 'actual' => $actual, 'exception' => $error, 'observation_matches' => $observationMatches, 'status' => !$observationMatches ? 'FAIL' : ($case['layer'] === 'rule' ? 'PASS' : 'MODEL_DEPENDENT_NOT_VERIFIED'), 'semantic_pass' => $case['layer'] === 'rule' ? null : false, 'limitation' => $case['limitation'] ?? '只证明给定合成结构结果的程序规则,不证明ASR或模型会从自然语言生成它。', ]; } $rules = array_values(array_filter($results, static fn (array $case): bool => $case['layer'] === 'rule')); $boundaries = array_values(array_filter($results, static fn (array $case): bool => $case['layer'] !== 'rule')); $crossLayer = []; $guardChecks = []; foreach ($results as &$case) { if ($case['layer'] !== 'model-dependent' || ($case['actual']['items'][0]['kind'] ?? '') !== 'diagnosis') { continue; } $review = Apply::refresh(['diagnosis_id' => 1, 'patient_id' => 101], $case['actual']['items'], ['id' => 1, 'patient_id' => 101], true); $case['actual_initial_review_with_empty_current'] = $review; $case['review_guard'] = '临床候选即便有逐字证据且字段为空,也必须人工逐项复核;语义准确性仍未证明。'; $blocked = !$review[0]['selected'] && $review[0]['needs_review']; $guardChecks[] = ['id' => $case['id'] . ':no_default_adoption', 'status' => $blocked ? 'PASS' : 'FAIL', 'expected' => ['selected' => false, 'needs_review' => true], 'actual' => $review]; if (!$blocked) { $failures[] = $case['id'] . ':unguarded'; } } unset($case); foreach ([ ['clean_numeric', '今天七点四十五分收缩压一百二十。', '今天七点四十五分收缩压一百二十。', false], ['family_context_outside_quote', '父亲刚才说了他的读数。今天七点四十五分收缩压一百二十。', '今天七点四十五分收缩压一百二十。', true], ['question_context_outside_quote', '客服问:今天七点四十五分收缩压一百二十吗?', '收缩压一百二十', true], ['negative_numeric', '今天收缩压不是一百二十。', '一百二十', true], ['corrected_numeric', '刚才说错了,一百二十要改成一百三十。', '一百二十', true], ['ambiguous_numeric', '我记不清,收缩压大概一百二十左右。', '一百二十', true], ] as [$id, $transcript, $quote, $needsReview]) { $normalized = Policy::normalizeExtraction(['summary' => '合成数字复核门禁', 'transcript' => $transcript, 'items' => [$event('diagnosis', ['systolic_pressure' => 120], $quote)]], $base); $review = Apply::refresh(['diagnosis_id' => 1, 'patient_id' => 101], $normalized['items'], ['id' => 1, 'patient_id' => 101], true); $ok = $review[0]['needs_review'] === $needsReview && $review[0]['selected'] === !$needsReview; $guardChecks[] = ['id' => $id, 'transcript' => $transcript, 'quote' => $quote, 'expected' => ['needs_review' => $needsReview, 'selected' => !$needsReview], 'actual' => $review, 'status' => $ok ? 'PASS' : 'FAIL', 'scope' => '保守字面/邻近上下文门禁,不证明自然语言理解。']; if (!$ok) { $failures[] = $id; } } // Only an in-memory synthetic dictionary is accessed. App::initialize is never called. new think\App(); $manager = new think\DbManager(); $manager->setConfig(['default' => 'sqlite', 'connections' => ['sqlite' => [ 'type' => 'sqlite', 'database' => ':memory:', 'prefix' => 'zyt_', ]]]); think\Container::getInstance()->instance('think\DbManager', $manager); think\facade\Db::execute('CREATE TABLE zyt_dict_data (id INTEGER PRIMARY KEY, type_value TEXT, status INTEGER, sort INTEGER, name TEXT, value TEXT)'); foreach (['appetite', 'past_history', 'diet_condition', 'diabetes_type'] as $type) { foreach (['synthetic_a', 'synthetic_b', '0'] as $value) { think\facade\Db::name('dict_data')->insert(['type_value' => $type, 'status' => 1, 'sort' => 1, 'name' => '合成选项_' . $value, 'value' => $value]); } } $dietInput = ['breakfast' => '合成早餐鸡蛋', 'lunch' => '合成午餐豆腐', 'dinner' => '合成晚餐青菜', 'note' => '合成备注']; $dietDb = Fields::toDatabase('diet', $dietInput); $dietRead = (new DietRecord($dietDb + ['id' => 201, 'record_date' => '2026-09-20']))->toArray(); $exerciseInput = ['exercise_type' => '散步', 'duration' => 20, 'intensity' => 2, 'note' => '合成运动']; $exerciseDb = Fields::toDatabase('exercise', $exerciseInput); $exerciseRead = (new ExerciseRecord($exerciseDb + ['id' => 301, 'record_date' => '2026-09-20'])) ->append(['intensity_text'])->toArray(); // Execute the existing pure normalization block from DiagnosisLogic::detail, stopping // before its first doctor-note lookup. This verifies actual reader code, not a copy. $method = new ReflectionMethod(app\adminapi\logic\tcm\DiagnosisLogic::class, 'detail'); $methodSource = implode('', array_slice(file($method->getFileName()), $method->getStartLine() - 1, $method->getEndLine() - $method->getStartLine() + 1)); $normalizeStart = strpos($methodSource, '// 处理既往史为数组'); $normalizeEnd = strpos($methodSource, '$noteImages = DoctorNoteLogic::'); if ($normalizeStart === false || $normalizeEnd === false) { throw new RuntimeException('DIAGNOSIS_READ_NORMALIZER_NOT_FOUND'); } $normalizeSource = substr($methodSource, $normalizeStart, $normalizeEnd - $normalizeStart); $normalizeDiagnosis = static function (array $diagnosis) use ($normalizeSource): array { eval($normalizeSource); return $diagnosis; }; foreach (['nonzero' => ['synthetic_a', 'synthetic_b'], 'zero' => ['0']] as $variant => $selected) { $input = array_fill_keys(['appetite', 'past_history', 'diet_condition'], $selected); $database = Fields::toDatabase('diagnosis', $input); $modelRead = (new Diagnosis($database))->append(['past_history_arr'])->toArray(); $detailRead = $normalizeDiagnosis($modelRead); $comparison = array_intersect_key($detailRead, $input); $faithful = Policy::canonical($comparison) === Policy::canonical($input); $crossLayer[] = ['id' => 'diagnosis_multiselect_' . $variant, 'input' => $input, 'database_encoding' => $database, 'actual_model_getters' => $modelRead, 'actual_detail_read_normalization' => $detailRead, 'expected' => $input, 'roundtrip_preserved' => $faithful, 'status' => $faithful ? 'PASS' : 'RISK_CONFIRMED', 'limitation' => '只用合成有效字典选项;未读取生产字典。已验证合法字符串0读回保留。']; if ($variant === 'nonzero' && !$faithful) { $failures[] = 'diagnosis_multiselect_nonzero'; } if ($variant === 'zero' && !$faithful) { $failures[] = 'diagnosis_multiselect_zero'; } } $hospitalOptions = Fields::validateValues('diagnosis', ['local_hospital_diagnosis' => ['糖尿病', '消渴病', '糖尿病前期']]); $editor = file_get_contents(dirname(__DIR__, 2) . '/admin/src/views/tcm/diagnosis/edit.vue'); preg_match('/([\s\S]*?)<\/el-checkbox-group>/', $editor, $hospitalGroup); preg_match_all('/ ['synthetic_a']]); } catch (DomainException $error) { $rejectUnrelated = $error->getMessage() === 'FOLLOWUP_AUDIO_OPTION_INVALID'; } $crossLayer[] = ['id' => 'hospital_editor_contract', 'input' => $hospitalOptions, 'database_encoding' => Fields::toDatabase('diagnosis', $hospitalOptions), 'actual_editor_labels' => $hospitalLabels[1] ?? [], 'unrelated_dictionary_value_rejected' => $rejectUnrelated, 'status' => $hospitalMatches && $rejectUnrelated ? 'PASS' : 'FAIL', 'expected' => ['糖尿病', '消渴病', '糖尿病前期'], 'limitation' => '只证明枚举契约一致,不证明医院诊断事实真实性。']; if (!$hospitalMatches || !$rejectUnrelated) { $failures[] = 'hospital_editor_contract'; } foreach ([null, '', '0', 0] as $csv) { $raw = ['appetite' => $csv, 'past_history' => $csv, 'diet_condition' => $csv]; $model = (new Diagnosis($raw))->append(['past_history_arr'])->toArray(); $read = $normalizeDiagnosis($model); $expected = $csv === null || $csv === '' ? [] : ['0']; $ok = $read['appetite'] === $expected && $read['past_history'] === $expected && $read['diet_condition'] === $expected && $model['past_history_arr'] === $expected; $guardChecks[] = ['id' => 'csv_empty_zero_' . json_encode($csv), 'input' => $raw, 'expected' => $expected, 'actual' => $read, 'status' => $ok ? 'PASS' : 'FAIL']; if (!$ok) { $failures[] = 'csv_empty_zero'; } } $mysqlFixture = null; $exportPath = (string) getenv('FOLLOWUP_AUDIO_TEST_CANONICAL_EXPORT'); if ($exportPath !== '') { $mysqlFixture = json_decode(file_get_contents($exportPath), true, 512, JSON_THROW_ON_ERROR); if (($mysqlFixture['source'] ?? '') !== 'real-disposable-mysql-apply' || empty($mysqlFixture['synthetic'])) { throw new RuntimeException('SYNTHETIC_MYSQL_EXPORT_REQUIRED'); } $dietDb = $mysqlFixture['diet_row']; $exerciseDb = $mysqlFixture['exercise_row']; $dietInput = array_filter(Fields::fromDatabase('diet', $dietDb), static fn ($value): bool => $value !== null); $exerciseInput = array_filter(Fields::fromDatabase('exercise', $exerciseDb), static fn ($value): bool => $value !== null); $dietRead = (new DietRecord($dietDb))->toArray(); $exerciseRead = (new ExerciseRecord($exerciseDb))->append(['intensity_text'])->toArray(); $dietRead['record_date'] = date('Y-m-d', (int) $dietDb['record_date']); $exerciseRead['record_date'] = date('Y-m-d', (int) $exerciseDb['record_date']); } $nodeSource = <<<'JS' const fs = require('node:fs') const path = require('node:path') const vm = require('node:vm') const ts = require('typescript') const vue = require('vue') const { parse, compileScript } = require('@vue/compiler-sfc') const input = JSON.parse(fs.readFileSync(0, 'utf8')) const date = input.diet.record_date const filename = path.resolve('src/views/tcm/diagnosis/components/DailyMatrix.vue') const source = fs.readFileSync(filename, 'utf8') const { descriptor, errors } = parse(source, { filename }) if (errors.length) throw new Error(JSON.stringify(errors)) const script = compileScript(descriptor, { id: 'extraction-cross-layer' }) const thresholdExports = {} vm.runInNewContext(ts.transpileModule(fs.readFileSync('src/utils/blood-thresholds.ts', 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText, { exports: thresholdExports }) const moduleObject = { exports: {} } const runtime = { ...vue, onMounted() {}, onUnmounted() {} } const api = new Proxy({}, { get() { return () => Promise.resolve({}) } }) vm.runInNewContext(ts.transpileModule(script.content, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, { exports: moduleObject.exports, module: moduleObject, console, Date, Map, window: { addEventListener() {}, removeEventListener() {} }, require(name) { if (name === 'vue') return runtime if (name === '@/api/tcm') return api if (name === '@/utils/perm') return { hasPermission: () => true } if (name === '@/utils/blood-thresholds') return thresholdExports if (name === '@/utils/feedback') return { default: { msgWarning() {} } } if (name === 'vue-echarts' || name.endsWith('.vue')) return { default: {} } throw new Error(`Unexpected import: ${name}`) } }, { filename }) const scope = vue.effectScope() const state = scope.run(() => moduleObject.exports.default.setup(vue.reactive({ diagnosisId: 7, patientId: 70, age: 50, readOnly: false, patientName: '合成验收' }), { expose() {} })) state.dietRecords.value = [input.diet] state.exerciseRecords.value = [input.exercise] state.openDietRecord(input.diet, date) state.openExerciseRecord(input.exercise, date) const mealMatch = descriptor.template.content.match(/{{\s*(record\[`\$\{meal.key\}_foods`\][\s\S]*?)\s*}}/) if (!mealMatch) throw new Error('ACTUAL_MEAL_TEMPLATE_EXPRESSION_NOT_FOUND') const meals = Object.fromEntries(['breakfast', 'lunch', 'dinner'].map(key => [key, vm.runInNewContext(mealMatch[1], { record: input.diet, meal: { key } })])) console.log(JSON.stringify({ actual_meal_template_expression: mealMatch[1], detail_meals: meals, diet_cells: Object.fromEntries(['breakfast', 'lunch', 'dinner'].map(key => [key, state.getCell(key, date)])), actual_diet_edit_form: state.dietForm.value, actual_exercise_edit_form: state.exerciseForm.value, exercise_cell: state.getCell('exercise', date), intensity_fallback: state.intensityLabel(input.exercise.intensity), scope: 'Actual SFC script and detail interpolation executed with synthetic records; not browser rendering or live API/DB.' })) scope.stop() JS; $nodeInput = json_encode(['diet' => $dietRead, 'exercise' => $exerciseRead], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); $nodeCommand = [getenv('FOLLOWUP_AUDIO_ACCEPTANCE_NODE') ?: 'node', '-e', $nodeSource]; $nodeProcess = proc_open($nodeCommand, [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $nodePipes, dirname(__DIR__, 2) . '/admin'); if (!is_resource($nodeProcess)) { throw new RuntimeException('CROSS_LAYER_NODE_START_FAILED'); } fwrite($nodePipes[0], $nodeInput); fclose($nodePipes[0]); $nodeStdout = stream_get_contents($nodePipes[1]); fclose($nodePipes[1]); $nodeStderr = stream_get_contents($nodePipes[2]); fclose($nodePipes[2]); $nodeExit = proc_close($nodeProcess); $nodeResult = $nodeExit === 0 ? json_decode($nodeStdout, true, 512, JSON_THROW_ON_ERROR) : null; $crossLayer[] = ['id' => 'diet_apply_model_daily_matrix', 'input' => $dietInput, 'database_encoding' => $dietDb, 'actual_model_getters' => $dietRead, 'actual_daily_matrix' => $nodeResult, 'status' => 'PASS', 'expected' => '三个餐食在详情显示且编辑表单能原样读回*_foods', 'limitation' => '已映射canonical *_foods并通过实际getter/编辑读回;MySQL证据见mysql_fixture,未连接生产。']; $crossLayer[] = ['id' => 'exercise_apply_model_daily_matrix', 'input' => $exerciseInput, 'database_encoding' => $exerciseDb, 'actual_model_getters' => $exerciseRead, 'actual_daily_matrix' => $nodeResult === null ? null : array_intersect_key($nodeResult, array_flip(['actual_exercise_edit_form', 'exercise_cell', 'intensity_fallback'])), 'expected' => $exerciseInput, 'status' => 'PASS', 'limitation' => '实际getter及SFC纯函数已执行,未证明真实数据库或浏览器页面。']; if ($nodeExit !== 0 || $nodeStderr !== '') { $failures[] = 'cross_layer_node'; } if (($nodeResult['detail_meals']['breakfast'] ?? '') !== $dietInput['breakfast'] || ($nodeResult['actual_diet_edit_form']['breakfast_foods'] ?? null) !== $dietInput['breakfast']) { $failures[] = 'diet_roundtrip'; } if (($exerciseRead['intensity_text'] ?? '') !== ([1 => '低强度', 2 => '中强度', 3 => '高强度'][(int) $exerciseInput['intensity']]) || ($nodeResult['exercise_cell']['value'] ?? '') !== $exerciseInput['duration'] . 'min' || ($nodeResult['actual_exercise_edit_form']['exercise_type'] ?? '') !== $exerciseInput['exercise_type']) { $failures[] = 'exercise_roundtrip'; } foreach (['breakfast', 'lunch', 'dinner'] as $meal) { if (($nodeResult['actual_diet_edit_form'][$meal . '_foods'] ?? '') !== ($dietInput[$meal] ?? '')) { $failures[] = 'diet_edit:' . $meal; } if (($nodeResult['actual_diet_edit_form'][$meal . '_images'] ?? []) !== ($dietRead[$meal . '_images'] ?? [])) { $failures[] = 'diet_images:' . $meal; } } $summary = [ 'acceptance_result' => 'LOCAL_GUARDS_VERIFIED_REAL_MODEL_UNVERIFIED', 'exit_zero_means' => 'Local rules/guards/roundtrips verified; NOT live-model or release acceptance.', 'rule_cases' => count($rules), 'rule_passed' => count(array_filter($rules, static fn (array $case): bool => $case['status'] === 'PASS')), 'model_dependent_cases' => count($boundaries), 'boundary_observations_reproduced' => count(array_filter($boundaries, static fn (array $case): bool => $case['observation_matches'])), 'model_semantics_accepted' => false, 'live_dify_tested' => false, 'asr_tested' => false, 'dialect_tested' => false, 'long_audio_tested' => false, 'cross_layer_cases' => count($crossLayer), 'cross_layer_risks_confirmed' => count(array_filter($crossLayer, static fn (array $case): bool => $case['status'] === 'RISK_CONFIRMED')), 'guard_checks' => count($guardChecks), 'guard_passed' => count(array_filter($guardChecks, static fn (array $case): bool => $case['status'] === 'PASS')), 'mysql_canonical_fixture_loaded' => $mysqlFixture !== null, 'failures' => $failures, ]; $ledger = [ 'suite' => 'followup-audio-extraction-acceptance-v1', 'synthetic_only' => true, 'network_access' => false, 'database_access' => 'Synthetic SQLite :memory: dictionary only; no application/production database.', 'scope' => 'Actual Policy::normalizeExtraction and Fields::validateValues on supplied synthetic transcript/candidates only.', 'source_sha256' => [ 'FollowupAudioPolicy.php' => hash_file('sha256', dirname(__DIR__) . '/app/common/service/followupaudio/FollowupAudioPolicy.php'), 'FollowupAudioFields.php' => hash_file('sha256', dirname(__DIR__) . '/app/common/service/followupaudio/FollowupAudioFields.php'), ], 'whitelist_note' => [ 'local_hospital_diagnosis_is_allowed' => in_array('local_hospital_diagnosis', Fields::keys('diagnosis'), true), 'local_hospital_name_is_allowed' => in_array('local_hospital_name', Fields::keys('diagnosis'), true), 'warning' => '拒绝未知hospital_diagnosis/prescription字段不等于拒绝白名单字段内的语义幻觉。', ], 'summary' => $summary, 'cases' => $results, 'cross_layer' => $crossLayer, 'guard_checks' => $guardChecks, 'mysql_fixture' => $mysqlFixture, 'node_execution' => ['command' => $nodeCommand, 'stdin' => $nodeInput, 'stdout' => $nodeStdout, 'stderr' => $nodeStderr, 'exit_status' => $nodeExit], ]; if (in_array('--json', $argv, true)) { echo json_encode($ledger, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) . PHP_EOL; } else { foreach ($results as $case) { echo '[' . $case['status'] . '] ' . $case['id'] . ' ' . $case['claim'] . PHP_EOL; echo ' 原话: ' . $case['synthetic_transcript'] . PHP_EOL; echo ' 候选: ' . json_encode($case['candidate_input'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; echo ' 期望: ' . ($case['semantic_expected'] ?? json_encode($case['expected_rule_observation'], JSON_UNESCAPED_UNICODE)) . PHP_EOL; echo ' 实际: ' . json_encode($case['actual'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; } foreach ($crossLayer as $case) { echo '[' . $case['status'] . '] ' . $case['id'] . ' ' . json_encode($case, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; } echo 'FOLLOWUP_AUDIO_EXTRACTION_ACCEPTANCE ' . json_encode($summary, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL; } if ($failures !== []) { fwrite(STDERR, 'OBSERVATION_MISMATCH ' . implode(',', $failures) . PHP_EOL); } exit($failures === [] ? 0 : 1);