fix: preserve confirmed followup snapshots and expand flow checks
This commit is contained in:
@@ -204,6 +204,8 @@ function stop() {
|
|||||||
loading.value = false; uploading.value = false; mutation.value = ''; pollRunning = false
|
loading.value = false; uploading.value = false; mutation.value = ''; pollRunning = false
|
||||||
}
|
}
|
||||||
function installDetail(fresh: FollowupTaskDetail, preserve: boolean, preserveRoles = preserve) {
|
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'
|
const keepRoles = preserveRoles && roleDirty.value && fresh.status === 'review'
|
||||||
if (!fresh.audio_available) releaseAudio()
|
if (!fresh.audio_available) releaseAudio()
|
||||||
if (retainedTranscriptNotice(fresh) && (!detail.value || detail.value.status !== fresh.status || !detail.value.transcript_available)) {
|
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'
|
const newlyApplied = detail.value?.status === 'review' && fresh.status === 'applied'
|
||||||
installDetail(fresh, preserve)
|
installDetail(fresh, preserve)
|
||||||
if (newlyApplied && !previewOnly.value) emit('applied', props.diagnosisId)
|
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() {
|
async function refresh() {
|
||||||
if (!effectiveActive.value || !props.diagnosisId || pollRunning || busy.value) return
|
if (!effectiveActive.value || !props.diagnosisId || pollRunning || busy.value) return
|
||||||
|
|||||||
@@ -1423,3 +1423,22 @@ test('preview upload stops before create when readiness, upload permission or al
|
|||||||
f.dispose()
|
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()
|
||||||
|
})
|
||||||
|
|||||||
@@ -33,19 +33,21 @@ def main():
|
|||||||
output.mkdir(parents=True, exist_ok=True)
|
output.mkdir(parents=True, exist_ok=True)
|
||||||
output.chmod(0o700)
|
output.chmod(0o700)
|
||||||
manifest = {"generator": "followup-audio-synthetic-v1", "synthetic": True,
|
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 = ["紫色海豚", "水晶风车", "绿色蜻蜓", "银色树叶", "蓝色鲸鱼", "金色河流", "红色石榴", "白色云朵"]
|
words = ["紫色海豚", "水晶风车", "绿色蜻蜓", "银色树叶", "蓝色鲸鱼", "金色河流", "红色石榴", "白色云朵"]
|
||||||
with tempfile.TemporaryDirectory(prefix="followup-audio-synthetic-") as temporary:
|
with tempfile.TemporaryDirectory(prefix="followup-audio-synthetic-") as temporary:
|
||||||
temp = pathlib.Path(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])
|
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)):
|
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))
|
head_canary = "".join(secrets.SystemRandom().sample(words, 3))
|
||||||
tail_canary = "".join(secrets.SystemRandom().sample(words, 3))
|
tail_canary = "".join(secrets.SystemRandom().sample(words, 3))
|
||||||
while tail_canary == head_canary:
|
while tail_canary == head_canary:
|
||||||
tail_canary = "".join(secrets.SystemRandom().sample(words, 3))
|
tail_canary = "".join(secrets.SystemRandom().sample(words, 3))
|
||||||
head = f"这是一段人工合成的音频测试。开始校验词是,{head_canary}。昨天晚上九点,收缩压一百二十六,舒张压八十二。前天下午两点,餐后血糖七点二。"
|
head = f"患者说,我先读三个词,{head_canary}。昨天晚上九点,我测的血压是收缩压一百二十六,舒张压八十二。前天下午两点,我测的餐后血糖是七点二。"
|
||||||
tail = f"现在是这段音频的最后部分。今天早晨七点,收缩压{systolic},舒张压{diastolic}。末尾校验词是,{tail_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 / "head.aiff"), head])
|
||||||
run(["say", "-v", args.voice, "-r", "190", "-o", str(temp / "tail.aiff"), tail])
|
run(["say", "-v", args.voice, "-r", "190", "-o", str(temp / "tail.aiff"), tail])
|
||||||
filler_seconds = seconds - duration(temp / "head.aiff") - duration(temp / "tail.aiff")
|
filler_seconds = seconds - duration(temp / "head.aiff") - duration(temp / "tail.aiff")
|
||||||
|
|||||||
@@ -182,10 +182,12 @@ final class FollowupAudioProbe extends Command
|
|||||||
/** Checks synthetic audio-only canaries, tail event, and distinct normalized temporal facts. */
|
/** Checks synthetic audio-only canaries, tail event, and distinct normalized temporal facts. */
|
||||||
public static function verifyExtraction(array $result, array $expected): array
|
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;
|
$canaries = true;
|
||||||
foreach ($expected['canaries'] ?? [] as $canary) {
|
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 = [];
|
$matched = [];
|
||||||
foreach ($expected['facts'] ?? [] as $index => $fact) {
|
foreach ($expected['facts'] ?? [] as $index => $fact) {
|
||||||
|
|||||||
@@ -147,6 +147,8 @@ final class FollowupAudioApply
|
|||||||
}
|
}
|
||||||
Db::name('followup_audio_task')->where('id', $taskId)->update([
|
Db::name('followup_audio_task')->where('id', $taskId)->update([
|
||||||
'status' => 'applied', 'stage' => 'applied', 'version' => (int) $task['version'] + 1,
|
'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_cipher' => FollowupAudioStore::seal($taskId, 'applied', ['items' => $applied] + $roleAnnotation),
|
||||||
'applied_at' => time(), 'updated_at' => time(),
|
'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()];
|
return [$code, $output->fetch()];
|
||||||
};
|
};
|
||||||
try {
|
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);
|
[$code, $stdout] = $run('qwen', false);
|
||||||
$expect($code === 1 && Dify::$calls === 0 && str_contains($stdout, 'SYNTHETIC_ACK_REQUIRED'), 'explicit synthetic acknowledgement required');
|
$expect($code === 1 && Dify::$calls === 0 && str_contains($stdout, 'SYNTHETIC_ACK_REQUIRED'), 'explicit synthetic acknowledgement required');
|
||||||
[$code, $stdout] = $run('qwen');
|
[$code, $stdout] = $run('qwen');
|
||||||
|
|||||||
Reference in New Issue
Block a user