122 lines
13 KiB
PHP
122 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
require __DIR__ . '/fixtures/followup_audio/database.php';
|
|
use app\common\service\followupaudio\FollowupAudioStore as Store;
|
|
use app\common\service\followupaudio\FollowupAudioApply as Apply;
|
|
use app\common\service\followupaudio\FollowupAudioWorker as Worker;
|
|
use app\common\service\followupaudio\FollowupAudioDify as Dify;
|
|
use app\common\service\followupaudio\FollowupAudioPipelineCheckpoint as Checkpoint;
|
|
use app\common\service\followupaudio\FollowupAudioTranscriptPrompt as Prompt;
|
|
use app\common\service\followupaudio\FollowupAudioPolicy as Policy;
|
|
use think\facade\Db;
|
|
|
|
if (!in_array((int) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PORT'), [23317, 23319], true)) { throw new RuntimeException('Stereo test requires a dedicated disposable test port'); }
|
|
$f = followupAudioTestDatabase(['asr_stereo_chunk_seconds' => 2]);
|
|
$checks = 0; $calls = ['asr' => 0, 'extraction' => 0]; $active = 0; $mode = 'success';
|
|
$expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $checks++; };
|
|
$reject = static function (callable $call, string $code) use ($expect): void {
|
|
try { $call(); } catch (Throwable $e) { $expect(str_contains($e->getMessage(), $code) || ($e->errorCode ?? '') === $code, 'expected ' . $code . ', got ' . $e->getMessage()); return; }
|
|
throw new RuntimeException('Expected ' . $code);
|
|
};
|
|
$make = static function (int $channels = 2, bool $allSilent = false) use ($f, &$active): int {
|
|
static $serial = 0; $serial++;
|
|
$frame = $allSilent ? str_repeat("\0", $channels * 2) : pack('v', 1000 + $serial) . ($channels === 2 ? pack('v', 2000 + $serial) : '');
|
|
$data = str_repeat($frame, 16000 * ($allSilent ? 1 : 5));
|
|
$path = $f['private'] . '/synthetic-' . $serial . '.wav';
|
|
file_put_contents($path, 'RIFF' . pack('V', 36 + strlen($data)) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, $channels, 16000, 32000 * $channels, 2 * $channels, 16) . 'data' . pack('V', strlen($data)) . $data); chmod($path, 0600);
|
|
$upload = followupAudioTestUpload($f, $path, 'synthetic.wav');
|
|
$task = Store::create($upload, '2026-09-29 10:00:00', 'qwen', 1, $f['actor']); $active = $task['id']; return $active;
|
|
};
|
|
$transport = static function (array $spec) use (&$calls, &$active, &$mode, $expect): array {
|
|
$calls[$spec['stage']]++; $task = Store::task($active); $state = Store::open($active, 'pipeline', $task['pipeline_cipher']);
|
|
$expect($state['schema_version'] === 2 && Checkpoint::hasIntent($state), 'new v2 encrypted intent precedes transport');
|
|
if ($spec['stage'] === 'asr') {
|
|
$intent = array_values(array_filter($state['chunks'], static fn ($chunk): bool => $chunk['state'] === 'intent'))[0];
|
|
if ($mode === 'unknown_right' && ($intent['channel'] ?? -1) === 1) { return ['http_code' => 0, 'errno' => 28, 'body' => '']; }
|
|
return ['http_code' => 200, 'errno' => 0, 'body' => json_encode(['text' => ($intent['channel'] ?? 0) === 0 ? '合成:今天早晨空腹血糖六点一。' : '合成:今天早餐吃了鸡蛋。'], JSON_UNESCAPED_UNICODE)];
|
|
}
|
|
$citations = Prompt::citations(Checkpoint::segments($state));
|
|
$answer = ['schema_version' => 'followup-audio-transcript-v2', 'summary' => '合成摘要', 'uncertainties' => [], 'items' => [[
|
|
'kind' => 'blood', 'values' => ['fasting_blood_sugar' => 6.1], 'record_date' => '2026-09-29', 'record_time' => '08:00',
|
|
'date_text' => '今天', 'time_text' => '早晨', 'time_period' => null, 'time_estimated' => false, 'needs_review' => true,
|
|
'evidence_ids' => [$citations[0]['id']],
|
|
]]];
|
|
return ['http_code' => 200, 'errno' => 0, 'body' => json_encode(['choices' => [['finish_reason' => 'stop', 'message' => ['content' => json_encode($answer, JSON_UNESCAPED_UNICODE)]]]])];
|
|
};
|
|
$run = static fn (): bool => (new Worker(new Dify($transport)))->runOnce();
|
|
$resolve = static function (array $detail): array { $items = $detail['items']; foreach ($items as &$item) { $item['selected'] = true; $item['needs_review'] = false; } unset($item); return $items; };
|
|
try {
|
|
$id = $make(); $expect($run(), 'actual Worker consumes stereo task'); $detail = Store::detail($id);
|
|
$expect($detail['status'] === 'review' && $calls === ['asr' => 6, 'extraction' => 1], 'both channels every window then one extraction');
|
|
$expect($detail['channel_roles'] === 'unconfirmed' && count($detail['transcript_segments']) === 6 && array_column($detail['transcript_segments'], 'channel') === [0, 1, 0, 1, 0, 1], 'detail exposes canonical ordered channels without inferred roles');
|
|
$expect($detail['items'][0]['evidence'][0]['channel'] === 0 && $detail['items'][0]['needs_review'] && !$detail['items'][0]['selected'], 'stereo evidence channel immutable and review forced');
|
|
$reject(fn () => Apply::apply($id, $detail['version'], $resolve($detail), 1, $f['actor']), 'CHANNEL_ROLES_REQUIRED');
|
|
$before = Store::task($id)['review_cipher'];
|
|
$reject(fn () => Store::saveDraft($id, $detail['version'], [], 3, 'left_service'), '诊单不存在或无权操作');
|
|
$expect(Store::task($id)['review_cipher'] === $before, 'role scope failure does not mutate ciphertext');
|
|
Db::name('admin')->where('id', 1)->update(['disable' => 1]);
|
|
$reject(fn () => Store::saveDraft($id, $detail['version'], [], 1, 'left_service'), '账号已停用');
|
|
Db::name('admin')->where('id', 1)->update(['disable' => 0]);
|
|
$reject(fn () => Store::saveDraft($id, $detail['version'], [], 1, 'patient_is_left'), 'CHANNEL_ROLES_INVALID');
|
|
$saved = Store::saveDraft($id, $detail['version'], $resolve($detail), 1, 'left_service');
|
|
$expect($saved['channel_roles'] === 'left_service' && $saved['version'] === $detail['version'] + 1 && !$saved['items'][0]['selected'] && $saved['items'][0]['needs_review'], 'role confirmation is version-bound and clears simultaneous selection');
|
|
$reject(fn () => Store::saveDraft($id, $detail['version'], [], 1, 'right_service'), 'VERSION_CONFLICT');
|
|
$expect(Store::detail($id)['channel_roles'] === 'left_service', 'stale role changes cannot win');
|
|
$forged = $saved['items']; $forged[0]['evidence'][0]['channel'] = 1;
|
|
$reject(fn () => Store::saveDraft($id, $saved['version'], $forged, 1), 'IMMUTABLE_FIELD');
|
|
$forged = $saved['items']; $forged[0]['channel_roles'] = 'right_service';
|
|
$reject(fn () => Store::saveDraft($id, $saved['version'], $forged, 1), 'IMMUTABLE_FIELD');
|
|
$saved = Store::saveDraft($id, $saved['version'], $resolve($saved), 1);
|
|
$expect($saved['channel_roles'] === 'left_service' && $saved['items'][0]['selected'], 'ordinary value review retains saved role');
|
|
$beforeCalls = $calls;
|
|
$saved = Store::saveDraft($id, $saved['version'], $resolve($saved), 1, 'right_service');
|
|
$expect($saved['channel_roles'] === 'right_service' && !$saved['items'][0]['selected'] && $saved['items'][0]['needs_review'] && $calls === $beforeCalls, 'role swap forces new review, no re-extraction');
|
|
$sourceEvidence = $saved['items'][0]['evidence'];
|
|
Db::name('tcm_blood_record')->insert(['diagnosis_id' => 1, 'patient_id' => 101, 'record_date' => Policy::dayTimestamp('2026-09-29'), 'record_time' => '08:00', 'fasting_blood_sugar' => 5.5]);
|
|
$reject(fn () => Apply::apply($id, $saved['version'], $resolve($saved), 1, $f['actor']), 'STALE_REVIEW');
|
|
$fresh = Store::detail($id);
|
|
$expect($fresh['channel_roles'] === 'right_service' && !$fresh['items'][0]['selected'] && $fresh['items'][0]['needs_review'], 'actual conflict refresh preserves role metadata');
|
|
$expect($fresh['items'][0]['evidence'] === $sourceEvidence, 'source evidence not relabeled by role change');
|
|
$applied = Apply::apply($id, $fresh['version'], $resolve($fresh), 1, $f['actor']);
|
|
$expect($applied['status'] === 'applied' && (int) Db::name('followup_audio_audit')->where('task_id', $id)->count() === 1, 'explicit re-review applies one new local record');
|
|
$audit = Db::name('followup_audio_audit')->where('task_id', $id)->find();
|
|
$auditSource = Store::open($id, 'audit-source:' . $audit['item_id'], $audit['source_cipher']);
|
|
$expect($auditSource['channel_roles'] === 'right_service' && $auditSource['channel_roles_annotation']['actor_id'] === 1 && $auditSource['evidence'][0]['channel'] === 0, 'audit stores human role annotation separately from original channel evidence');
|
|
$appliedCipher = Store::task($id)['applied_cipher'];
|
|
$expect(Store::detail($id)['channel_roles'] === 'right_service', 'applied role comes from stored server metadata');
|
|
$reject(fn () => Store::saveDraft($id, Store::detail($id)['version'], [], 1, 'left_service'), 'NOT_REVIEWABLE');
|
|
$expect(Store::task($id)['applied_cipher'] === $appliedCipher, 'adopted results immutable');
|
|
|
|
$monoId = $make(1); $run(); $mono = Store::detail($monoId);
|
|
$expect($mono['status'] === 'review' && $mono['channel_roles'] === 'unconfirmed' && !array_key_exists('channel', $mono['transcript_segments'][0]), 'mono remains compatible');
|
|
$reject(fn () => Store::saveDraft($monoId, $mono['version'], [], 1, 'left_service'), 'CHANNEL_ROLES_INVALID');
|
|
$expect(Apply::apply($monoId, $mono['version'], $resolve($mono), 1, $f['actor'])['status'] === 'applied', 'mono has no new role-confirmation gate');
|
|
|
|
$mode = 'unknown_right'; $unknownId = $make(); $beforeCalls = $calls; $run(); $unknown = Store::detail($unknownId);
|
|
$expect($unknown['status'] === 'needs_reconciliation' && !$unknown['can_retry'] && $unknown['pipeline_progress']['transcribed'] === 1
|
|
&& $calls['asr'] === $beforeCalls['asr'] + 2, 'unknown right channel retains completed left and forbids blind retry');
|
|
$unknownCipher = Store::task($unknownId)['pipeline_cipher']; $reject(fn () => Store::retry($unknownId), 'RETRY_NOT_SAFE');
|
|
$expect(Store::task($unknownId)['pipeline_cipher'] === $unknownCipher, 'unknown checkpoint never cleared');
|
|
$mode = 'success'; $silentId = $make(2, true); $beforeCalls = $calls; $run(); $silent = Store::detail($silentId);
|
|
$expect($calls === $beforeCalls && $silent['error_code'] === 'ASR_TRANSCRIPT_EMPTY' && $silent['pipeline_progress'] === ['transcribed' => 2, 'total' => 2], 'both exactly-zero channels retained without ASR or LLM');
|
|
$silentState = Store::open($silentId, 'pipeline', Store::task($silentId)['pipeline_cipher']);
|
|
$expect(array_column($silentState['chunks'], 'state') === ['local_silence', 'local_silence'] && (int) Store::task($silentId)['upstream_started_at'] === 0, 'no fabricated upstream result for local silence');
|
|
|
|
$legacyId = $make(1); $legacyTask = Store::task($legacyId); $oldFingerprint = str_repeat('c', 64);
|
|
$legacyState = ['schema_version' => 1, 'revision' => 3, 'source_sha256' => $legacyTask['sha256'], 'fingerprint' => $oldFingerprint, 'duration_ms' => 5000, 'chunk_ms' => 120000,
|
|
'chunks' => [['id' => 'asr-' . hash('sha256', $legacyTask['sha256'] . ':' . $oldFingerprint . ':0:5000'), 'start_ms' => 0, 'end_ms' => 5000,
|
|
'state' => 'complete', 'request_id' => 'fa-' . str_repeat('c', 32), 'text' => 'legacy synthetic text']], 'extraction' => ['state' => 'pending']];
|
|
$legacyCipher = Store::seal($legacyId, 'pipeline', $legacyState);
|
|
Db::name('followup_audio_task')->where('id', $legacyId)->update(['status' => 'failed', 'stage' => 'failed', 'pipeline_cipher' => $legacyCipher,
|
|
'upstream_ids_json' => json_encode(['provider_fingerprint' => $oldFingerprint]), 'upstream_started_at' => time()]);
|
|
$oldDetail = Store::detail($legacyId);
|
|
$expect($oldDetail['transcript'] === 'legacy synthetic text' && $oldDetail['channel_roles'] === 'unconfirmed' && !isset($oldDetail['transcript_segments'][0]['channel']) && !$oldDetail['can_retry'], 'old task text readable without rebinding provider');
|
|
$expect(Store::task($legacyId)['pipeline_cipher'] === $legacyCipher, 'legacy reads do not rewrite checkpoints');
|
|
$reject(fn () => Store::retry($legacyId), 'PROVIDER_CONFIGURATION_CHANGED');
|
|
$legacyAppliedId = $make(1); $oldAppliedCipher = Store::seal($legacyAppliedId, 'applied', ['items' => [['kind' => 'blood', 'record_id' => 999]]]);
|
|
Db::name('followup_audio_task')->where('id', $legacyAppliedId)->update(['status' => 'applied', 'stage' => 'applied', 'applied_cipher' => $oldAppliedCipher]);
|
|
$expect(Store::detail($legacyAppliedId)['applied_items'] === [['kind' => 'blood', 'record_id' => 999]] && Store::task($legacyAppliedId)['applied_cipher'] === $oldAppliedCipher, 'old applied results unchanged');
|
|
echo 'FOLLOWUP_AUDIO_STEREO_DATABASE assertions=' . $checks . ' PASS mysql' . (int) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PORT') . '=1 actual_worker=1 both_channels=1 role_scope_version=1 role_change_clears=1 apply_role_required=1 immutable_channels=1 audit_annotation=1 unknown_fenced=1 local_silence=1 legacy_unchanged=1' . PHP_EOL;
|
|
} finally { followupAudioTestDatabaseCleanup($f); }
|