diff --git a/admin/src/views/tcm/diagnosis/components/FollowupAudioPanel.vue b/admin/src/views/tcm/diagnosis/components/FollowupAudioPanel.vue index 7c4db5163..61d35ef67 100644 --- a/admin/src/views/tcm/diagnosis/components/FollowupAudioPanel.vue +++ b/admin/src/views/tcm/diagnosis/components/FollowupAudioPanel.vue @@ -204,6 +204,8 @@ function stop() { loading.value = false; uploading.value = false; mutation.value = ''; pollRunning = false } function installDetail(fresh: FollowupTaskDetail, preserve: boolean, preserveRoles = preserve) { + // Completed tasks use the server's confirmed snapshot, never an unsaved local draft. + preserve = preserve && fresh.status === 'review' const keepRoles = preserveRoles && roleDirty.value && fresh.status === 'review' if (!fresh.audio_available) releaseAudio() if (retainedTranscriptNotice(fresh) && (!detail.value || detail.value.status !== fresh.status || !detail.value.transcript_available)) { @@ -227,7 +229,8 @@ async function readDetail(id: number, preserve = false) { const newlyApplied = detail.value?.status === 'review' && fresh.status === 'applied' installDetail(fresh, preserve) if (newlyApplied && !previewOnly.value) emit('applied', props.diagnosisId) - if (preserve) errorMessage.value = '服务端记录或版本已变化;已保留你的字段与日期修改,并取消所有选择。请重新核对当前值后选择采纳。' + if (preserve && fresh.status === 'review') errorMessage.value = '服务端记录或版本已变化;已保留你的字段与日期修改,并取消所有选择。请重新核对当前值后选择采纳。' + else if (preserve && newlyApplied) errorMessage.value = '任务已由其他人确认,已显示实际写入结果;本页未保存的修改未写入。' } async function refresh() { if (!effectiveActive.value || !props.diagnosisId || pollRunning || busy.value) return diff --git a/admin/tests/followup-audio.test.cjs b/admin/tests/followup-audio.test.cjs index 49e5ca5e9..3a6a000c5 100644 --- a/admin/tests/followup-audio.test.cjs +++ b/admin/tests/followup-audio.test.cjs @@ -1423,3 +1423,22 @@ test('preview upload stops before create when readiness, upload permission or al f.dispose() } }) + + +test('dirty draft is replaced by the authoritative applied result from another operator', async () => { + let stored = task() + const copy = value => JSON.parse(JSON.stringify(value)) + const f = setupPanel({ followupAudioLists: async () => ({ items: [copy(stored)] }), followupAudioDetail: async () => copy(stored) }) + await settle(); await f.panel.openTask(1) + f.panel.reviewItems.value[0].values.fasting_blood_sugar = 9.9 + stored = task(1, { status: 'applied', version: 2, items: [item({ values: { fasting_blood_sugar: 6.5 }, selected: true, needs_review: false })] }) + await f.panel.refresh(); await settle() + assert.equal(f.panel.detail.value.status, 'applied') + assert.equal(f.panel.reviewItems.value[0].values.fasting_blood_sugar, 6.5) + assert.equal(f.panel.reviewItems.value[0].selected, true) + assert.equal(f.panel.reviewItems.value[0].needs_review, false) + assert.equal(f.panel.dirty.value, false) + assert.match(f.panel.errorMessage.value, /未保存.*未写入/) + assert.deepEqual(f.events, [['applied', 10]]) + f.dispose() +}) diff --git a/scripts/generate_followup_audio_fixtures.py b/scripts/generate_followup_audio_fixtures.py index f8f04da29..bfd8c8b0a 100755 --- a/scripts/generate_followup_audio_fixtures.py +++ b/scripts/generate_followup_audio_fixtures.py @@ -33,19 +33,21 @@ def main(): output.mkdir(parents=True, exist_ok=True) output.chmod(0o700) manifest = {"generator": "followup-audio-synthetic-v1", "synthetic": True, - "recorded_at": "2026-09-29 10:00:00", "fixtures": []} + "recorded_at": "2026-09-29 10:00:00", "speech_revision": "ordinary-dialogue-v2", "fixtures": []} words = ["紫色海豚", "水晶风车", "绿色蜻蜓", "银色树叶", "蓝色鲸鱼", "金色河流", "红色石榴", "白色云朵"] with tempfile.TemporaryDirectory(prefix="followup-audio-synthetic-") as temporary: temp = pathlib.Path(temporary) - middle_text = "这是纯合成的声音长度测试,不对应任何真实患者。现在继续校验这段音频的可听性。重复的测试语句不是医疗事实,不应反复生成随访记录。" + # Synthetic provenance stays in the manifest. The speech must not contradict + # the expected task by declaring all reported measurements non-facts. + middle_text = "客服说,电话里的声音还清楚吗?患者说,很清楚。客服说,好的,我们继续聊。患者说,好的,您说吧。" run(["say", "-v", args.voice, "-r", "190", "-o", str(temp / "middle.aiff"), middle_text]) for case, seconds, systolic, diastolic in (("short", 60, 131, 86), ("medium", 900, 139, 88), ("long", 3598, 147, 91)): head_canary = "".join(secrets.SystemRandom().sample(words, 3)) tail_canary = "".join(secrets.SystemRandom().sample(words, 3)) while tail_canary == head_canary: tail_canary = "".join(secrets.SystemRandom().sample(words, 3)) - head = f"这是一段人工合成的音频测试。开始校验词是,{head_canary}。昨天晚上九点,收缩压一百二十六,舒张压八十二。前天下午两点,餐后血糖七点二。" - tail = f"现在是这段音频的最后部分。今天早晨七点,收缩压{systolic},舒张压{diastolic}。末尾校验词是,{tail_canary}。合成测试到此结束。" + head = f"患者说,我先读三个词,{head_canary}。昨天晚上九点,我测的血压是收缩压一百二十六,舒张压八十二。前天下午两点,我测的餐后血糖是七点二。" + tail = f"患者说,我再补充一次测量。今天早晨七点,我的收缩压是{systolic},舒张压是{diastolic}。最后再读三个词,{tail_canary}。客服说,好的,再见。" run(["say", "-v", args.voice, "-r", "190", "-o", str(temp / "head.aiff"), head]) run(["say", "-v", args.voice, "-r", "190", "-o", str(temp / "tail.aiff"), tail]) filler_seconds = seconds - duration(temp / "head.aiff") - duration(temp / "tail.aiff") diff --git a/server/app/command/FollowupAudioProbe.php b/server/app/command/FollowupAudioProbe.php index 295e8dfd5..7d483cac9 100644 --- a/server/app/command/FollowupAudioProbe.php +++ b/server/app/command/FollowupAudioProbe.php @@ -182,10 +182,12 @@ final class FollowupAudioProbe extends Command /** Checks synthetic audio-only canaries, tail event, and distinct normalized temporal facts. */ public static function verifyExtraction(array $result, array $expected): array { - $transcript = preg_replace('/\s+/u', '', (string) ($result['transcript'] ?? '')); + // ASR punctuation/spacing is not a lexical error; changed words still fail. + $spokenWords = static fn (string $text): string => preg_replace('/[\p{P}\p{Z}\s]+/u', '', $text); + $transcript = $spokenWords((string) ($result['transcript'] ?? '')); $canaries = true; foreach ($expected['canaries'] ?? [] as $canary) { - $canaries = $canaries && is_string($canary) && $canary !== '' && str_contains($transcript, preg_replace('/\s+/u', '', $canary)); + $canaries = $canaries && is_string($canary) && $canary !== '' && str_contains($transcript, $spokenWords($canary)); } $matched = []; foreach ($expected['facts'] ?? [] as $index => $fact) { diff --git a/server/app/common/service/followupaudio/FollowupAudioApply.php b/server/app/common/service/followupaudio/FollowupAudioApply.php index 556e3cbc2..f4a3d4af9 100644 --- a/server/app/common/service/followupaudio/FollowupAudioApply.php +++ b/server/app/common/service/followupaudio/FollowupAudioApply.php @@ -147,6 +147,8 @@ final class FollowupAudioApply } Db::name('followup_audio_task')->where('id', $taskId)->update([ 'status' => 'applied', 'stage' => 'applied', 'version' => (int) $task['version'] + 1, + // The completed detail must show exactly the edits/selection confirmed in this transaction. + 'review_cipher' => FollowupAudioStore::seal($taskId, 'review', array_replace($review, ['items' => $merged])), 'applied_cipher' => FollowupAudioStore::seal($taskId, 'applied', ['items' => $applied] + $roleAnnotation), 'applied_at' => time(), 'updated_at' => time(), ]); diff --git a/server/tests/FollowupAudioControllerFlowTest.php b/server/tests/FollowupAudioControllerFlowTest.php new file mode 100644 index 000000000..e95cfb24e --- /dev/null +++ b/server/tests/FollowupAudioControllerFlowTest.php @@ -0,0 +1,289 @@ +withServer(['REQUEST_METHOD' => $method]); + $request = $method === 'GET' ? $request->withGet($body) : $request->withPost($body); + $controller = (new ReflectionClass(FollowupAudioController::class))->newInstanceWithoutConstructor(); + // The endpoint itself reloads the current actor; injected info must not grant root rights. + foreach (['request' => $request, 'adminId' => $actor, 'adminInfo' => ['root' => 1]] as $key => $value) { + $property = new ReflectionProperty($controller, $key); + $property->setAccessible(true); $property->setValue($controller, $value); + } + return $controller->$action()->getData(); +} + +if (($argv[1] ?? '') === '--request') { + $path = $argv[2] ?? ''; + if (!preg_match('#^/private/tmp/fa_pipeline_[a-f0-9]{12}/controller-child-[a-f0-9]+\.json$#D', $path)) { + throw new RuntimeException('Disposable child input required'); + } + $input = json_decode(file_get_contents($path), true, 512, JSON_THROW_ON_ERROR); + if ($input['connection']['hostname'] !== '127.0.0.1' || (int) $input['connection']['hostport'] !== 23319 + || !preg_match('/^fa_pipeline_[a-f0-9]{12}$/D', $input['connection']['database'])) { + throw new RuntimeException('Disposable child connection required'); + } + new think\App(); // Deliberately not initialized: no application .env/config discovery. + $manager = new think\DbManager(); + $manager->setConfig(['default' => 'mysql', 'connections' => ['mysql' => $input['connection']]]); + Container::getInstance()->instance('think\DbManager', $manager); + $config = new think\Config(); $config->set($input['settings'], 'followup_audio'); + Container::getInstance()->instance('config', $config); + file_put_contents($path . '.ready', 'ready'); + $deadline = microtime(true) + 15; + while (!is_file($input['barrier'])) { + if (microtime(true) > $deadline) { throw new RuntimeException('Child barrier timeout'); } + usleep(10000); + } + echo json_encode(followupControllerRequest($input['action'], $input['body']), JSON_THROW_ON_ERROR) . PHP_EOL; + exit(0); +} + +$f = followupAudioTestDatabase(['preview_only' => false]); +$checks = 0; +$expect = static function (bool $ok, string $why) use (&$checks): void { + if (!$ok) { throw new RuntimeException($why); } $checks++; +}; +$success = static function (array $response, string $why) use ($expect): array { + $expect($response['code'] === 1, $why . ': ' . $response['msg']); return $response['data']; +}; +$reject = static function (array $response, string $message) use ($expect): void { + $expect($response['code'] === 0 && $response['msg'] === $message, + 'expected ' . $message . ', got ' . json_encode($response, JSON_UNESCAPED_UNICODE)); +}; +$body = static fn (array $detail): array => ['id' => $detail['id'], 'version' => $detail['version'], 'items' => $detail['items']]; +$resolve = static function (array $detail): array { + foreach ($detail['items'] as &$item) { $item['selected'] = true; $item['needs_review'] = false; } unset($item); + return $detail; +}; +$snapshot = static function (): string { + $rows = []; + foreach (['tcm_diagnosis', 'tcm_prescription_order', 'tcm_blood_record', 'patient_diet_record', + 'patient_exercise_record', 'tracking_note', 'followup_audio_audit', 'followup_audio_task'] as $table) { + $rows[$table] = Db::name($table)->order('id')->select()->toArray(); + } + return hash('sha256', json_encode($rows, JSON_THROW_ON_ERROR)); +}; +$make = static function (array $proposals, bool $stereo = true) use ($f, $success, $expect): array { + static $serial = 0; $serial++; + $pcm = str_repeat(pack('vv', 1000 + $serial, 2000 + $serial), 16000); + $path = $f['private'] . '/controller-synthetic-' . $serial . '.wav'; + file_put_contents($path, 'RIFF' . pack('V', 36 + strlen($pcm)) . 'WAVEfmt ' + . pack('VvvVVvv', 16, 1, 2, 16000, 64000, 4, 16) . 'data' . pack('V', strlen($pcm)) . $pcm); + chmod($path, 0600); + $upload = followupAudioTestUpload($f, $path, 'synthetic.wav'); + $created = $success(followupControllerRequest('create', ['diagnosis_id' => 1, 'upload_id' => $upload['id'], + 'recorded_at' => '2026-09-29 10:00:00', 'model_key' => 'qwen']), 'controller create'); + $texts = [' 今天我说合成症状,空腹血糖六点一,早餐鸡蛋,散步二十五分钟,合成回访。 ', '今天客服确认合成数据。']; + $segments = [ + ['id' => 'left', 'text' => $texts[0], 'start_ms' => 0, 'end_ms' => 1000, 'channel' => 0], + ['id' => 'right', 'text' => $texts[1], 'start_ms' => 0, 'end_ms' => 1000, 'channel' => 1], + ]; + $items = []; + foreach ($proposals as [$kind, $values]) { + $evidence = ['text' => $texts[0]]; + if ($stereo) { $evidence += ['segment_id' => 'left', 'position_type' => 'segment', 'start_ms' => 0, 'end_ms' => 1000, 'channel' => 0]; } + $items[] = ['kind' => $kind, 'values' => $values, 'record_date' => '2026-09-29', 'date_text' => '今天', + 'record_time' => '08:00', 'time_text' => '早晨', 'evidence' => [$evidence]]; + } + $extraction = ['summary' => 'Synthetic controller flow', 'transcript' => implode("\n", $texts), 'items' => $items, 'uncertainties' => []]; + if ($stereo) { $extraction['transcript_segments'] = $segments; } + $claim = Store::claim(); + $expect((int) ($claim['id'] ?? 0) === (int) $created['id'], 'claim exact synthetic controller task'); + $expect(Store::complete($created['id'], $claim['lease_token'], $extraction), 'synthetic extraction accepted without upstream'); + $detail = $success(followupControllerRequest('detail', ['id' => $created['id']], 1, 'GET'), 'controller detail'); + $expect(count($detail['items']) === count($proposals), 'all synthetic proposals normalized'); + return $detail; +}; +$parallel = static function (string $action, array $payload) use ($f, $expect): array { + $jobs = []; $barrier = $f['private'] . '/barrier-' . bin2hex(random_bytes(6)); + try { + for ($i = 0; $i < 2; $i++) { + $path = $f['private'] . '/controller-child-' . bin2hex(random_bytes(6)) . '.json'; + file_put_contents($path, json_encode(['connection' => Db::connect()->getConfig(), 'settings' => config('followup_audio'), + 'action' => $action, 'body' => $payload, 'barrier' => $barrier], JSON_THROW_ON_ERROR)); + chmod($path, 0600); + $pipes = []; $process = proc_open([PHP_BINARY, __FILE__, '--request', $path], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + if (!is_resource($process)) { throw new RuntimeException('Child launch failed'); } + fclose($pipes[0]); $jobs[] = [$process, $pipes, $path]; + } + $deadline = microtime(true) + 15; + while (!is_file($jobs[0][2] . '.ready') || !is_file($jobs[1][2] . '.ready')) { + if (microtime(true) > $deadline) { throw new RuntimeException('Concurrent controller startup timeout'); } + usleep(10000); + } + file_put_contents($barrier, 'go'); + } finally { + // Release even on setup failure, then reap every launched PHP process before fixture cleanup. + if (!is_file($barrier)) { file_put_contents($barrier, 'go'); } + $results = []; + foreach ($jobs as [$process, $pipes]) { + $stdout = stream_get_contents($pipes[1]); $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); fclose($pipes[2]); $exit = proc_close($process); + $results[] = ['exit' => $exit, 'stderr' => $stderr, 'stdout' => $stdout]; + } + } + foreach ($results as $result) { $expect($result['exit'] === 0 && $result['stderr'] === '', 'concurrent controller child exits cleanly'); } + return array_map(static fn (array $result): array => json_decode(trim($result['stdout']), true, 512, JSON_THROW_ON_ERROR), $results); +}; + +try { + if (($argv[1] ?? '') === '--direct-apply-regression') { + $direct = $make([['blood', ['fasting_blood_sugar' => 6.1]]], false); + $submitted = $resolve($direct); + $submitted['items'][0]['values']['fasting_blood_sugar'] = 6.8; + $result = $success(followupControllerRequest('apply', $body($submitted)), 'direct apply without saving draft'); + $detail = $success(followupControllerRequest('detail', ['id' => $direct['id']], 1, 'GET'), 'detail after direct apply'); + $actual = (float) Db::name('tcm_blood_record')->where('id', $result['applied_items'][0]['record_id'])->value('fasting_blood_sugar'); + $expect($actual === 6.8 && $result['applied_items'][0]['values']['fasting_blood_sugar'] === 6.8, + 'direct Apply persists submitted correction'); + $observed = ['status' => $detail['status'], 'database_value' => $actual, + 'applied_value' => $detail['applied_items'][0]['values']['fasting_blood_sugar'], + 'detail_value' => $detail['items'][0]['values']['fasting_blood_sugar'], + 'detail_selected' => $detail['items'][0]['selected'], 'detail_needs_review' => $detail['items'][0]['needs_review']]; + echo 'DIRECT_APPLY_DETAIL_OBSERVED ' . json_encode($observed, JSON_THROW_ON_ERROR) . PHP_EOL; + $expect($detail['status'] === 'applied' && $detail['items'][0]['values']['fasting_blood_sugar'] === 6.8 + && $detail['items'][0]['selected'] === true && $detail['items'][0]['needs_review'] === false, + 'DIRECT_APPLY_DETAIL_MISMATCH: applied detail must reflect confirmed submitted values and selection without a prior saveDraft'); + echo 'FOLLOWUP_AUDIO_DIRECT_APPLY_DETAIL assertions=' . $checks . ' PASS' . PHP_EOL; + } else { + $cap = $success(followupControllerRequest('capabilities', ['diagnosis_id' => 1], 1, 'GET'), 'normal capabilities'); + $expect($cap['enabled'] && !$cap['preview_only'] && $cap['can_apply'], 'normal mode enabled only inside isolated fixture'); + $detail = $make([['diagnosis', ['symptoms' => '合成症状']], ['blood', ['fasting_blood_sugar' => 6.1]]]); + $reject(followupControllerRequest('apply', $body($resolve($detail))), 'FOLLOWUP_AUDIO_CHANNEL_ROLES_REQUIRED'); + $initialVersion = $detail['version']; + $detail = $success(followupControllerRequest('saveDraft', $body($resolve($detail)) + ['channel_roles' => 'left_service']), 'save left role'); + $expect($detail['channel_roles'] === 'left_service' && $detail['version'] === $initialVersion + 1 + && !$detail['items'][0]['selected'] && $detail['items'][0]['needs_review'], 'role confirmation invalidates simultaneous selection'); + $beforeRole = $detail; + $detail = $success(followupControllerRequest('saveDraft', $body($resolve($detail)) + ['channel_roles' => 'right_service']), 'save right role'); + $expect($detail['channel_roles'] === 'right_service' && !$detail['items'][1]['selected'] && $detail['items'][1]['needs_review'], 'role swap forces fresh review'); + $reject(followupControllerRequest('apply', $body($detail)), 'FOLLOWUP_AUDIO_NOTHING_SELECTED'); + $unreviewed = $detail; $unreviewed['items'][0]['selected'] = true; + $reject(followupControllerRequest('apply', $body($unreviewed)), 'FOLLOWUP_AUDIO_REVIEW_REQUIRED'); + $reject(followupControllerRequest('saveDraft', $body($beforeRole) + ['channel_roles' => 'left_service']), 'FOLLOWUP_AUDIO_VERSION_CONFLICT'); + $detail = $resolve($detail); $detail['items'][1]['values']['fasting_blood_sugar'] = 6.2; + $evidence = array_column($detail['items'], 'evidence'); + $detail = $success(followupControllerRequest('saveDraft', $body($detail)), 'manual correction and confirmation'); + $expect($detail['items'][1]['values']['fasting_blood_sugar'] === 6.2 && $detail['items'][1]['selected'] === true + && $detail['items'][1]['needs_review'] === false, 'typed JSON values and booleans survive draft roundtrip'); + $expect(array_column($detail['items'], 'evidence') === $evidence && $evidence[0][0]['channel'] === 0 + && $evidence[0][0]['start_ms'] === 0 && str_starts_with($evidence[0][0]['text'], ' '), 'exact whitespace evidence and integer zero retained'); + $unchanged = $snapshot(); + foreach ([ + ['selected', 'true', 'FOLLOWUP_AUDIO_BOOLEAN_INVALID'], + ['selected', 1, 'FOLLOWUP_AUDIO_BOOLEAN_INVALID'], + ['needs_review', 'false', 'FOLLOWUP_AUDIO_BOOLEAN_INVALID'], + ['target_id', '1', 'FOLLOWUP_AUDIO_TARGET_INVALID'], + ['expected_hash', str_repeat('0', 64), 'FOLLOWUP_AUDIO_IMMUTABLE_FIELD'], + ['invented_field', true, 'FOLLOWUP_AUDIO_IMMUTABLE_FIELD'], + ] as [$key, $value, $error]) { + $invalid = $body($detail); $invalid['items'][0][$key] = $value; + foreach (['saveDraft', 'apply'] as $action) { $reject(followupControllerRequest($action, $invalid), $error); } + } + foreach (['saveDraft', 'apply'] as $action) { + $invalid = $body($detail); $invalid['items'][0]['evidence'][0]['text'] .= '伪造'; + $reject(followupControllerRequest($action, $invalid), 'FOLLOWUP_AUDIO_IMMUTABLE_FIELD'); + $invalid = $body($detail); $invalid['items'][0]['values']['patient_id'] = 202; + $reject(followupControllerRequest($action, $invalid), 'FOLLOWUP_AUDIO_UNPROPOSED_FIELD'); + $invalid = $body($detail); $invalid['items'][1]['values']['fasting_blood_sugar'] = 6.123; + $reject(followupControllerRequest($action, $invalid), 'FOLLOWUP_AUDIO_NUMBER_INVALID'); + $reject(followupControllerRequest($action, $body($detail) + ['unlisted' => true]), '请求包含不支持的字段'); + $reject(followupControllerRequest($action, $body($detail), 3), '诊单不存在或无权操作'); + } + $reject(followupControllerRequest('saveDraft', $body($detail) + ['channel_roles' => 'invented']), 'FOLLOWUP_AUDIO_CHANNEL_ROLES_INVALID'); + Db::name('admin')->where('id', 1)->update(['disable' => 1]); + $reject(followupControllerRequest('apply', $body($detail)), '账号已停用或无权访问'); + Db::name('admin')->where('id', 1)->update(['disable' => 0]); + Db::name('system_role_menu')->where('role_id', 2)->where('menu_id', 2)->delete(); + $reject(followupControllerRequest('apply', $body($detail), 2), '诊单不存在或无权操作'); + Db::name('system_role_menu')->insert(['role_id' => 2, 'menu_id' => 2]); + $expect($snapshot() === $unchanged, 'invalid payloads and permissions never mutate review or business records'); + $applied = $success(followupControllerRequest('apply', $body($detail)), 'normal apply succeeds'); + $expect($applied['status'] === 'applied' && count($applied['applied_items']) === 2 + && (float) Db::name('tcm_blood_record')->value('fasting_blood_sugar') === 6.2 + && Db::name('tcm_diagnosis')->where('id', 1)->value('symptoms') === '合成症状', 'confirmed manual values reach clinical records'); + $afterApply = $snapshot(); + $expect(Policy::canonical($success(followupControllerRequest('apply', $body($detail)), 'repeated apply')) === Policy::canonical($applied) + && $snapshot() === $afterApply, 'repeat apply returns original IDs without writes'); + $audit = Db::name('followup_audio_audit')->where('task_id', $detail['id'])->where('kind', 'blood')->find(); + $source = Store::open($detail['id'], 'audit-source:' . $audit['item_id'], $audit['source_cipher']); + $expect($source['values']['fasting_blood_sugar'] === 6.1 && $source['channel_roles'] === 'right_service' + && $source['evidence'] === $evidence[1], 'audit retains original proposal and evidence separate from human correction'); + + $stale = $make([['diagnosis', ['symptoms' => '合成更新']]], false); + Db::name('tcm_diagnosis')->where('id', 1)->update(['symptoms' => 'concurrent editor value']); + $response = followupControllerRequest('apply', $body($resolve($stale))); + $reject($response, '病历或日常记录已发生变化,请重新审阅差异后确认'); + $expect($response['data']['code'] === 'FOLLOWUP_AUDIO_STALE_REVIEW' + && Db::name('tcm_diagnosis')->where('id', 1)->value('symptoms') === 'concurrent editor value', 'concurrent business edit is never overwritten'); + $fresh = $success(followupControllerRequest('detail', ['id' => $stale['id']], 1, 'GET'), 'fresh conflict detail'); + $expect($fresh['version'] === $stale['version'] + 1 && !$fresh['items'][0]['selected'] + && $fresh['items'][0]['needs_review'] && $fresh['items'][0]['current_values']['symptoms'] === 'concurrent editor value', 'stale conflict refresh commits only new review state'); + foreach (['saveDraft', 'apply'] as $action) { $reject(followupControllerRequest($action, $body($resolve($stale))), 'FOLLOWUP_AUDIO_VERSION_CONFLICT'); } + $success(followupControllerRequest('apply', $body($resolve($fresh))), 'explicit re-review adopts fresh version'); + + $concurrent = $make([['diagnosis', ['symptoms' => '并发草稿']]], false); + $results = $parallel('saveDraft', $body($resolve($concurrent))); + $codes = array_column($results, 'code'); sort($codes); + $expect($codes === [0, 1] && count(array_filter($results, static fn (array $r): bool => $r['msg'] === 'FOLLOWUP_AUDIO_VERSION_CONFLICT')) === 1, + 'two actual controller processes with same draft version yield exactly one winner'); + $fresh = $success(followupControllerRequest('detail', ['id' => $concurrent['id']], 1, 'GET'), 'concurrent winner detail'); + $expect($fresh['version'] === $concurrent['version'] + 1, 'parallel draft advances version exactly once'); + $results = $parallel('apply', $body($fresh)); + $first = $success($results[0], 'parallel apply first'); $second = $success($results[1], 'parallel apply second'); + $expect(Policy::canonical($first) === Policy::canonical($second) && (int) Db::name('followup_audio_audit')->where('task_id', $fresh['id'])->count() === 1, + 'two actual controller Apply processes produce one adoption and identical IDs'); + + $atomic = $make([['diagnosis', ['symptoms' => 'rollback symptom']], ['blood', ['fasting_blood_sugar' => 6.4]], + ['diet', ['breakfast' => 'rollback breakfast']], ['exercise', ['exercise_type' => 'walk', 'duration' => 25, 'intensity' => 1]], + ['tracking_note', ['content' => 'rollback note']]], false); + $beforeFailure = $snapshot(); + Db::execute('SET @fa_controller_rollback_reached = 0'); + $f['pdo']->exec("CREATE TRIGGER fa_controller_audit_failure BEFORE INSERT ON zyt_followup_audio_audit FOR EACH ROW BEGIN IF NEW.kind = 'tracking_note' THEN SET @fa_controller_rollback_reached = 1; SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='synthetic final audit rejection'; END IF; END"); + try { + $reject(followupControllerRequest('apply', $body($resolve($atomic))), '回访录音服务暂不可用,请联系管理员检查部署'); + $expect((int) Db::query('SELECT @fa_controller_rollback_reached AS reached')[0]['reached'] === 1, + 'actual failing Apply reached the fifth audit after writing all five business targets'); + $expect($snapshot() === $beforeFailure && Store::task($atomic['id'])['status'] === 'review', + 'failure at final audit restores all five business tables, preceding audits, task version/status/cipher'); + } finally { $f['pdo']->exec('DROP TRIGGER fa_controller_audit_failure'); } + $applied = $success(followupControllerRequest('apply', $body($resolve($atomic))), 'retry after removed synthetic failure'); + $expect(count($applied['applied_items']) === 5 && (int) Db::name('followup_audio_audit')->where('task_id', $atomic['id'])->count() === 5, + 'transaction rollback restores retryable behavior and clean retry writes exactly five records/audits'); + + $providers = config('followup_audio.providers'); + $providers['qwen']['preview_verified_fingerprint'] = Provider::resolve('qwen')['fingerprint']; + $f['config']->set(['providers' => $providers, 'preview_only' => true, 'test_diagnosis_ids' => '1', 'test_admin_ids' => '1'], 'followup_audio'); + $preview = $make([['blood', ['fasting_blood_sugar' => 6.5]]], false); + $beforePreview = $snapshot(); + $reject(followupControllerRequest('apply', $body($resolve($preview))), 'FOLLOWUP_AUDIO_PREVIEW_ONLY'); + $f['config']->set(['preview_only' => false], 'followup_audio'); + $reject(followupControllerRequest('apply', $body($resolve($preview))), 'FOLLOWUP_AUDIO_PREVIEW_ONLY'); + $expect($snapshot() === $beforePreview, 'preview-origin task permanently forbids writes after normal-mode switch'); + echo 'FOLLOWUP_AUDIO_CONTROLLER_FLOW assertions=' . $checks + . ' PASS mysql23319=1 request_controller=1 normal_apply=1 role_confirm_swap=1 manual_correction=1 typed_json=1' + . ' invalid_payload_denied=1 rbac=1 stale_review=1 concurrent_draft_one_winner=1 concurrent_apply_idempotent=1' + . ' five_table_atomic_rollback=1 retry_restored=1 preview_origin_permanent=1 upstream_calls=0' . PHP_EOL; + } +} finally { followupAudioTestDatabaseCleanup($f); } diff --git a/server/tests/FollowupAudioProbeCommandTest.php b/server/tests/FollowupAudioProbeCommandTest.php index 116154954..ea56aa1aa 100644 --- a/server/tests/FollowupAudioProbeCommandTest.php +++ b/server/tests/FollowupAudioProbeCommandTest.php @@ -50,6 +50,16 @@ namespace { return [$code, $output->fetch()]; }; try { + $expected = $manifest['fixtures'][0]['expected']; + $expected['canaries'] = ['紫色海豚金色河流绿色蜻蜓', '银色树叶紫色海豚红色石榴']; + $result = ['summary' => 'synthetic', 'transcript' => '紫色海豚、金色河流,绿色蜻蜓。银色树叶,紫色海豚,红色石榴。', 'items' => $expected['facts']]; + $expect(!in_array(false, Probe::verifyExtraction($result, $expected), true), 'ASR punctuation does not change audio-only canary words'); + $changed = $result; $changed['transcript'] = str_replace('石榴', '石流', $changed['transcript']); + $expect(!Probe::verifyExtraction($changed, $expected)['audio_only_canaries'], 'wrong spoken-word recognition still fails'); + $changed = $result; $changed['items'][0]['record_date'] = '2026-09-27'; + $expect(!Probe::verifyExtraction($changed, $expected)['historical_temporal_facts'], 'wrong relative date still fails'); + $changed = $result; array_pop($changed['items']); + $expect(!Probe::verifyExtraction($changed, $expected)['unique_tail_fact'], 'missing last audio fact still fails'); [$code, $stdout] = $run('qwen', false); $expect($code === 1 && Dify::$calls === 0 && str_contains($stdout, 'SYNTHETIC_ACK_REQUIRED'), 'explicit synthetic acknowledgement required'); [$code, $stdout] = $run('qwen');