fix: preserve confirmed followup snapshots and expand flow checks

This commit is contained in:
2026-10-09 17:46:25 +08:00
parent 9185c6db04
commit e762a367da
7 changed files with 334 additions and 7 deletions
@@ -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
+19
View File
@@ -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()
})
+6 -4
View File
@@ -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")
+4 -2
View File
@@ -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) {
@@ -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(),
]);
@@ -0,0 +1,289 @@
<?php
declare(strict_types=1);
/** Public controller + real app Request contract, against a disposable local database only. */
require __DIR__ . '/fixtures/followup_audio/database.php';
use app\adminapi\controller\tcm\FollowupAudioController;
use app\common\service\followupaudio\FollowupAudioStore as Store;
use app\common\service\followupaudio\FollowupAudioProviderConfig as Provider;
use app\common\service\followupaudio\FollowupAudioPolicy as Policy;
use think\Container;
use think\facade\Db;
if ((int) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PORT') !== 23319
|| getenv('FOLLOWUP_AUDIO_TEST_ALLOW_DISPOSABLE') !== '1') {
throw new RuntimeException('Controller flow requires dedicated disposable MySQL 23319');
}
function followupControllerRequest(string $action, array $body, int $actor = 1, string $method = 'POST'): array
{
// Preserve the same JSON scalar types received by app Request; never call Logic directly here.
$body = json_decode(json_encode($body, JSON_THROW_ON_ERROR), true, 512, JSON_THROW_ON_ERROR);
$request = (new app\Request())->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); }
@@ -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');