feat(followup-audio): normalize extraction to record field contracts

This commit is contained in:
2026-10-12 14:34:31 +08:00
parent 9e3aa9fc61
commit 86041042b9
9 changed files with 449 additions and 21 deletions
@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
/** Offline contract test: a disposable dictionary only, no application initialize(), network or clinical writes. */
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
if (getenv('FOLLOWUP_AUDIO_FIELDS_SOURCE')) { require (string) getenv('FOLLOWUP_AUDIO_FIELDS_SOURCE'); }
use app\common\service\followupaudio\FollowupAudioFields as Fields;
new think\App();
$directory = sys_get_temp_dir() . '/fa-field-contract-' . bin2hex(random_bytes(6));
mkdir($directory, 0700);
$database = $directory . '/dictionary.sqlite';
$pdo = new PDO('sqlite:' . $database);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('CREATE TABLE zyt_dict_data(id INTEGER PRIMARY KEY,type_value TEXT,status INTEGER,sort INTEGER,name TEXT,value TEXT)');
$pdo->exec("INSERT INTO zyt_dict_data VALUES(1,'appetite',1,2,'口干','configured_dry'),(2,'appetite',1,1,'口苦','configured_bitter'),(3,'appetite',0,0,'已停用','disabled')");
$manager = new think\DbManager();
$manager->setConfig(['default' => 'sqlite', 'connections' => ['sqlite' => ['type' => 'sqlite', 'database' => $database, 'prefix' => 'zyt_']]]);
think\Container::getInstance()->instance('think\DbManager', $manager);
$checks = 0;
$expect = static function (bool $ok, string $message) use (&$checks): void {
if (!$ok) { throw new RuntimeException($message); }
$checks++;
};
$reject = static function (callable $call, string $code) use ($expect): void {
try { $call(); } catch (DomainException $error) {
$expect($error->getMessage() === $code, 'Expected ' . $code . ', got ' . $error->getMessage()); return;
}
throw new RuntimeException('Expected ' . $code);
};
try {
$catalog = Fields::catalog();
$byKind = [];
foreach ($catalog as $kind => $fields) { $byKind[$kind] = array_column($fields, null, 'key'); }
$expect(array_map('count', $catalog) === ['diagnosis' => 40, 'blood' => 8, 'diet' => 4, 'exercise' => 4, 'tracking_note' => 1], 'Existing 57 fields retained');
$expect($byKind['diagnosis']['appetite']['options'] === [
['label' => '口苦', 'value' => 'configured_bitter'], ['label' => '口干', 'value' => 'configured_dry'],
], 'Configured active dictionary values ordered by sort, never hardcoded seed values');
$expect($byKind['diagnosis']['water_intake']['options'] === [], 'Empty dictionary remains empty');
$expect(array_column($byKind['diagnosis']['local_hospital_diagnosis']['options'], 'value') === ['糖尿病', '消渴病', '糖尿病前期'], 'Local diagnosis uses its actual fixed stored strings');
$expect(array_column($byKind['diagnosis']['gender']['options'], 'value') === [0, 1], 'Integer enum type preserved');
$expect(Fields::validateValues('diagnosis', ['appetite' => ['configured_dry']]) === ['appetite' => ['configured_dry']], 'Real configured enum accepted');
$reject(static fn () => Fields::validateValues('diagnosis', ['appetite' => ['dry']]), 'FOLLOWUP_AUDIO_OPTION_INVALID');
$reject(static fn () => Fields::validateValues('diagnosis', ['appetite' => []]), 'FOLLOWUP_AUDIO_OPTION_INVALID');
$reject(static fn () => Fields::validateValues('diagnosis', ['water_intake' => 'normal']), 'FOLLOWUP_AUDIO_OPTION_INVALID');
$reject(static fn () => Fields::validateValues('exercise', ['intensity' => null]), 'FOLLOWUP_AUDIO_FIELD_INVALID');
$reject(static fn () => Fields::validateValues('exercise', ['intensity' => false]), 'FOLLOWUP_AUDIO_FIELD_INVALID');
$expect(Fields::validateValues('exercise', ['exercise_type' => '散步', 'duration' => 30]) === ['exercise_type' => '散步', 'duration' => 30], 'Missing intensity stays absent');
$expect(Fields::validateValues('blood', ['fasting_blood_sugar' => 999.99, 'systolic_pressure' => 999]) === ['fasting_blood_sugar' => 999.99, 'systolic_pressure' => 999], 'Existing upper bounds unchanged');
$expect(Fields::validateValues('exercise', ['duration' => 1440]) === ['duration' => 1440], 'Existing duration maximum unchanged');
$reject(static fn () => Fields::validateValues('exercise', ['duration' => 0]), 'FOLLOWUP_AUDIO_NUMBER_INVALID');
$reject(static fn () => Fields::validateValues('exercise', ['duration' => 1.5]), 'FOLLOWUP_AUDIO_NUMBER_INVALID');
$reject(static fn () => Fields::validateValues('blood', ['fasting_blood_sugar' => 6.123]), 'FOLLOWUP_AUDIO_NUMBER_INVALID');
$reject(static fn () => Fields::validateValues('blood', ['fasting_blood_sugar' => 1000]), 'FOLLOWUP_AUDIO_NUMBER_INVALID');
$expect(Fields::validateValues('diagnosis', ['diabetes_discovery_year' => '自述发现糖尿病约2年。'])['diabetes_discovery_year'] === '自述发现糖尿病约2年。', 'Disease duration remains text');
$expect(Fields::toDatabase('diet', ['breakfast' => '小米粥1碗']) === ['breakfast_foods' => '小米粥1碗'], 'Meal storage alias unchanged');
$expect(Fields::toDatabase('diagnosis', ['appetite' => ['configured_dry', 'configured_bitter']]) === ['appetite' => 'configured_dry,configured_bitter'], 'Multiselect storage unchanged');
$pdo->exec("UPDATE zyt_dict_data SET value='updated_dry' WHERE id=1");
$fresh = array_column(Fields::catalog()['diagnosis'], null, 'key');
$expect($fresh['appetite']['options'][1]['value'] === 'updated_dry', 'Dictionary is reloaded for each catalog');
$reject(static fn () => Fields::validateValues('diagnosis', ['appetite' => ['configured_dry']]), 'FOLLOWUP_AUDIO_OPTION_INVALID');
$expect(Fields::validateValues('diagnosis', ['appetite' => ['updated_dry']]) === ['appetite' => ['updated_dry']], 'Validator consumes same fresh dictionary');
if (!in_array('--validation-only', $argv, true)) {
foreach ([['diet', 'breakfast', 500], ['diet', 'lunch', 500], ['diet', 'dinner', 500],
['diet', 'note', 255], ['exercise', 'note', 255]] as [$kind, $key, $limit]) {
$expect($byKind[$kind][$key]['max'] === $limit, "$kind.$key matches deployed VARCHAR size");
$value = str_repeat('合', $limit);
$expect(Fields::validateValues($kind, [$key => $value]) === [$key => $value], "$kind.$key accepts exact Unicode length");
$reject(static fn () => Fields::validateValues($kind, [$key => $value . '成']), 'FOLLOWUP_AUDIO_TEXT_INVALID');
}
$definitions = (new ReflectionMethod(Fields::class, 'definitions'))->invoke(null);
foreach ($catalog as $kind => $fields) {
foreach ($fields as $field) {
$key = $field['key'];
foreach (['type', 'min', 'max', 'integer', 'dictionary'] as $constraint) {
if (array_key_exists($constraint, $definitions[$kind][$key])) {
$expect(($field[$constraint] ?? null) === $definitions[$kind][$key][$constraint], "$kind.$key exposes shared $constraint");
}
}
foreach (['semantic', 'temporal_scope', 'missing_policy', 'source_policy', 'recording_rules'] as $metadata) {
$expect(!empty($field[$metadata]), "$kind.$key exposes $metadata");
}
$expect(str_contains($field['missing_policy'], '不填表单默认值'), "$kind.$key never fills defaults");
if (isset($field['options'])) {
$expect(str_contains($field['enum_no_match_policy'], '无匹配或字典为空则省略'), "$kind.$key no-match is explicit");
}
}
}
$assertRule = static function (string $kind, string $key, string $fragment) use ($byKind, $expect): void {
$expect(str_contains(json_encode($byKind[$kind][$key], JSON_UNESCAPED_UNICODE), $fragment), "$kind.$key contains semantic rule: $fragment");
};
foreach ([['diagnosis', 'appetite', '不是食欲'], ['diagnosis', 'diabetes_discovery_year', '不是发现年份'],
['diagnosis', 'current_medications', '历史停药与当前恢复分开'], ['diagnosis', 'diagnosis_date', '不从病程推算'],
['diagnosis', 'local_hospital_diagnosis', '不从血糖或症状推断诊断'], ['diagnosis', 'diagnosis_type', '初诊、复诊或会诊'], ['diagnosis', 'diagnosis_type', '推断初复诊'],
['diagnosis', 'fatty_liver_degree', '不依据症状或体重推断分级'], ['diagnosis', 'allergy_history', '整体有无'],
['diagnosis', 'weight_change', '近一个月'], ['diagnosis', 'water_intake', '不推毫升数'],
['blood', 'postprandial_blood_sugar', '仅明确餐后2小时'], ['blood', 'western_medicine', '某日西药实际服用事件'],
['blood', 'insulin', '不将一次注射升级为长期方案'], ['exercise', 'intensity', '不继承UI默认1'],
['exercise', 'duration', '未运动不生成duration=0'], ['diet', 'note', '加餐'],
['diet', 'breakfast', '不输出breakfast_foods'], ['exercise', 'note', '不假装具备时刻级存储'],
['tracking_note', 'content', '按事件日期追加而非覆盖']] as $case) { $assertRule(...$case); }
foreach ([['diagnosis', 'patient_name'], ['diagnosis', 'phone'], ['diagnosis', 'id_card'],
['diagnosis', 'region'], ['diagnosis', 'local_hospital_name'], ['exercise', 'exercise_type']] as [$kind, $key]) {
$assertRule($kind, $key, '标量文本只输出');
$expect(!str_contains(implode(' ', $byKind[$kind][$key]['recording_rules']), '整理为简洁记录句'), "$kind.$key is not narrative");
}
$expect($byKind['blood']['postprandial_blood_sugar']['unit'] === 'mmol/L', 'Postprandial unit exposed');
$expect($byKind['exercise']['duration']['decimal_places'] === 0, 'Integer precision exposed');
$expect($byKind['blood']['fasting_blood_sugar']['decimal_places'] === 2, 'Decimal precision exposed');
$expect($byKind['blood']['postprandial_blood_sugar']['label'] === '餐后2小时血糖(mmol/L)', 'Catalog label agrees with existing UI meaning');
}
echo 'FOLLOWUP_AUDIO_FIELD_CONTRACT assertions=' . $checks . ' PASS mode=' . (in_array('--validation-only', $argv, true) ? 'validation' : 'full') . " fields=57 network_calls=0 clinical_writes=0\n";
} finally {
$pdo = null;
if (is_file($database)) { unlink($database); }
rmdir($directory);
}