Files
zyt/server/tests/FollowupAudioExtractionAcceptanceTest.php
T

465 lines
34 KiB
PHP

<?php
declare(strict_types=1);
/**
* Synthetic text/structured-candidate acceptance, not an ASR or live-model test.
* No application initialization, .env, network, audio upload, or patient data.
* Cross-layer checks use only a disposable in-memory SQLite dictionary.
* Run with --json for the full input/expected/actual evidence ledger.
*/
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
use app\common\service\followupaudio\FollowupAudioFields as Fields;
use app\common\service\followupaudio\FollowupAudioPolicy as Policy;
use app\common\service\followupaudio\FollowupAudioApply as Apply;
use app\common\model\tcm\Diagnosis;
use app\common\model\tcm\DietRecord;
use app\common\model\tcm\ExerciseRecord;
$base = '2026-09-20 23:00:00';
$cases = [];
$event = static fn (string $kind, array $values, string $quote, array $extra = []): array => 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('/<el-checkbox-group v-model="formData.local_hospital_diagnosis">([\s\S]*?)<\/el-checkbox-group>/', $editor, $hospitalGroup);
preg_match_all('/<el-checkbox-button label="([^"]+)"/', $hospitalGroup[1] ?? '', $hospitalLabels);
$hospitalMatches = $hospitalOptions['local_hospital_diagnosis'] === ($hospitalLabels[1] ?? []);
$rejectUnrelated = false;
try { Fields::validateValues('diagnosis', ['local_hospital_diagnosis' => ['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);