diff --git a/admin/src/api/followupAudio.ts b/admin/src/api/followupAudio.ts index 9cfc481c8..0680c128f 100644 --- a/admin/src/api/followupAudio.ts +++ b/admin/src/api/followupAudio.ts @@ -25,6 +25,8 @@ export interface FollowupCapabilities { audio_verified: boolean preview_only?: boolean preview_ready?: boolean + manual_review_enabled?: boolean + manual_review_ready?: boolean can_review?: boolean test_scope_allowed?: boolean can_upload: boolean diff --git a/admin/src/views/tcm/diagnosis/components/FollowupAudioPanel.vue b/admin/src/views/tcm/diagnosis/components/FollowupAudioPanel.vue index 61d35ef67..1f95b7170 100644 --- a/admin/src/views/tcm/diagnosis/components/FollowupAudioPanel.vue +++ b/admin/src/views/tcm/diagnosis/components/FollowupAudioPanel.vue @@ -12,12 +12,12 @@
-

当前仅开放测试识别与提炼,可核对并保存审核草稿;测试预览任务不提供病历、日常记录或备注的确认写入。

+

{{ capabilities.preview_only ? '当前仅开放测试识别与提炼,可核对并保存审核草稿;测试预览任务不提供确认写入。' : '这是历史测试任务,不写入病例;新上传的真实录音可按正常业务流程审核后确认入单。' }}

录音只生成建议,不会自动写入。逐项核对后选择采纳,再确认写入;日常记录按日期和时段分别保留。

仅当前可见标签页每 5 秒刷新;已编辑内容不会被覆盖。后台分析不受关闭页面影响,未知结果请先核对任务或账单,勿重复上传。

任务 #{{ detail.id }} · 版本 {{ detail.version }} · 保留至 {{ formatExpiry(detail.expires_at) }}

- + diff --git a/admin/src/views/tcm/diagnosis/components/followupAudioState.ts b/admin/src/views/tcm/diagnosis/components/followupAudioState.ts index b788f9a78..7970c5184 100644 --- a/admin/src/views/tcm/diagnosis/components/followupAudioState.ts +++ b/admin/src/views/tcm/diagnosis/components/followupAudioState.ts @@ -112,13 +112,13 @@ export function followupFeatureEnabled(caps: FollowupCapabilities | null): boole return !!caps?.enabled && (!caps.preview_only || caps.test_scope_allowed === true) } export function followupAudioReady(caps: FollowupCapabilities | null): boolean { - return !!caps && (caps.preview_only ? caps.preview_ready === true && caps.test_scope_allowed === true : caps.audio_verified) + return !!caps && (caps.preview_only ? caps.preview_ready === true && caps.test_scope_allowed === true : caps.audio_verified || (caps.manual_review_enabled === true && caps.manual_review_ready === true)) } export function followupCanReview(caps: FollowupCapabilities | null, previewTask = false): boolean { return followupFeatureEnabled(caps) && !!caps && (caps.preview_only || previewTask ? caps.can_review === true : caps.can_apply) } export function followupCanApply(caps: FollowupCapabilities | null, previewTask = false): boolean { - return followupFeatureEnabled(caps) && !!caps && !caps.preview_only && !previewTask && caps.can_apply + return followupFeatureEnabled(caps) && followupAudioReady(caps) && !!caps && !caps.preview_only && !previewTask && caps.can_apply } export function uploadIssue(file: Pick | null, recordedAt: string, model: string, caps: FollowupCapabilities | null, viewOnly: boolean): string { if (viewOnly || !caps || !followupFeatureEnabled(caps) || !followupAudioReady(caps) || !caps.can_upload) return '当前不可上传录音' diff --git a/admin/tests/followup-audio.test.cjs b/admin/tests/followup-audio.test.cjs index 3a6a000c5..c4c9fc9c0 100644 --- a/admin/tests/followup-audio.test.cjs +++ b/admin/tests/followup-audio.test.cjs @@ -1249,9 +1249,9 @@ test('preview actual component can upload, view transcript and save draft with f assert.match(f.panel.applyIssue.value, /测试预览/) f.dispose() const source = fs.readFileSync(path.join(componentDir, 'FollowupAudioPanel.vue'), 'utf8') - assert.match(source, /v-if="previewOnly"[^>]+title="仅测试识别与提炼,不写入病例"/) + assert.match(source, /v-if="previewOnly"[^>]+:title="capabilities.preview_only/) assert.match(source, /]+@click="applySelected">确认写入/) - assert.match(source, /预览就绪不代表完整音频或准确度验收通过/) + assert.match(source, /历史测试任务,仅供查看和审阅/) }) test('preview roles and review_refreshed drafts remain editable without can_apply and preserve dirty work', async () => { @@ -1442,3 +1442,20 @@ test('dirty draft is replaced by the authoritative applied result from another o assert.deepEqual(f.events, [['applied', 10]]) f.dispose() }) + + +test('manual human-reviewed business mode exposes upload and explicit Apply without claiming model accuracy', async () => { + const manual = { ...caps(), audio_verified: false, preview_only: false, manual_review_enabled: true, manual_review_ready: true, can_review: true } + assert.equal(state.followupAudioReady(manual), true) + assert.equal(state.followupCanApply(manual), true) + assert.equal(state.followupCanApply(manual, true), false) + assert.equal(state.followupAudioReady({ ...manual, manual_review_enabled: false }), false) + assert.equal(state.followupCanApply({ ...manual, manual_review_ready: false }), false) + assert.equal(state.followupAudioReady({ ...manual, manual_review_ready: undefined }), false) + const f = setupPanel({}, { capabilities: manual }); await settle(); await f.panel.openTask(1) + assert.equal(f.panel.canUpload.value, true) + assert.equal(f.panel.canApplyReview.value, true) + await f.panel.saveDraft(); assert.equal(f.calls.filter(x => x[0] === 'apply').length, 0) + await f.panel.applySelected(); assert.equal(f.calls.filter(x => x[0] === 'apply').length, 1) + f.dispose() +}) diff --git a/server/.env.followup-audio.example b/server/.env.followup-audio.example index a8f78f4ec..e72e40a63 100644 --- a/server/.env.followup-audio.example +++ b/server/.env.followup-audio.example @@ -3,6 +3,9 @@ [followup_audio] ENABLED = false PREVIEW_ONLY = false +# Explicit human-reviewed business use, with an exact connection-ready provider fingerprint. +# This never marks AUDIO_VERIFIED true or removes original preview-task markers. +MANUAL_REVIEW_ENABLED = false # IDs only. Empty or invalid diagnosis allowlist denies preview; admin list never replaces RBAC. TEST_DIAGNOSIS_IDS = TEST_ADMIN_IDS = diff --git a/server/app/adminapi/logic/tcm/FollowupAudioLogic.php b/server/app/adminapi/logic/tcm/FollowupAudioLogic.php index e28da28b2..18fb18cf9 100644 --- a/server/app/adminapi/logic/tcm/FollowupAudioLogic.php +++ b/server/app/adminapi/logic/tcm/FollowupAudioLogic.php @@ -23,11 +23,13 @@ final class FollowupAudioLogic $ready = Store::ready(); $enabled = Store::enabled() && $scope; $verified = Store::verified(); + $manualReady = Gate::manualReviewReady(); $daily = Access::canDaily($actor, $info); return [ 'enabled' => $enabled, 'audio_verified' => $verified, + 'manual_review_enabled' => Gate::manualReviewEnabled(), 'manual_review_ready' => $manualReady, 'preview_only' => $preview, 'preview_ready' => $preview && $ready, 'test_scope_allowed' => $scope, - 'can_upload' => $enabled && $ready, 'can_review' => $enabled && $ready, 'can_apply' => $enabled && $verified && !$preview, + 'can_upload' => $enabled && $ready, 'can_review' => $enabled && $ready, 'can_apply' => $enabled && ($verified || $manualReady) && !$preview, 'can_daily' => $daily, 'limits' => Upload::limits(), 'models' => $enabled ? ProviderConfig::readyModels() : [], // No migration/dictionary dependency is introduced when the feature is OFF. diff --git a/server/app/common/service/followupaudio/FollowupAudioGate.php b/server/app/common/service/followupaudio/FollowupAudioGate.php index 042bec383..7ffd0cf79 100644 --- a/server/app/common/service/followupaudio/FollowupAudioGate.php +++ b/server/app/common/service/followupaudio/FollowupAudioGate.php @@ -42,6 +42,23 @@ final class FollowupAudioGate if (!self::scopeAllowed($diagnosisId, $actor, $settings)) { throw new DomainException('FOLLOWUP_AUDIO_PREVIEW_SCOPE_DENIED'); } } + /** Explicit human-adoption operation is separate from model accuracy certification. */ + public static function manualReviewEnabled(?array $settings = null): bool + { + return (($settings ?? (array) config('followup_audio', []))['manual_review_enabled'] ?? false) === true; + } + + public static function manualReviewReady(?string $profile = null, ?array $settings = null, ?array $legacy = null): bool + { + $settings = $settings ?? (array) config('followup_audio', []); $legacy = $legacy ?? (array) config('prescription_ai', []); + if (self::previewOnly($settings) || !self::manualReviewEnabled($settings)) { return false; } + if ($profile === null) { + foreach (['qwen', 'openai'] as $slot) { if (self::manualReviewReady($slot, $settings, $legacy)) { return true; } } + return false; + } + return FollowupAudioProviderConfig::previewVerified($profile, $settings, $legacy); + } + public static function ready(?string $profile = null, ?array $settings = null, ?array $legacy = null): bool { $settings = $settings ?? (array) config('followup_audio', []); $legacy = $legacy ?? (array) config('prescription_ai', []); @@ -50,7 +67,7 @@ final class FollowupAudioGate return false; } return self::previewOnly($settings) ? FollowupAudioProviderConfig::previewVerified($profile, $settings, $legacy) - : FollowupAudioProviderConfig::verified($profile, $settings, $legacy); + : FollowupAudioProviderConfig::verified($profile, $settings, $legacy) || self::manualReviewReady($profile, $settings, $legacy); } /** Persisted preview-origin tasks never become eligible for adoption after a configuration change. */ diff --git a/server/app/common/service/followupaudio/FollowupAudioStore.php b/server/app/common/service/followupaudio/FollowupAudioStore.php index 9ca30c6ae..2ecbc5d08 100644 --- a/server/app/common/service/followupaudio/FollowupAudioStore.php +++ b/server/app/common/service/followupaudio/FollowupAudioStore.php @@ -113,6 +113,9 @@ final class FollowupAudioStore private static function reuse(array $task, string $recordedAt): array { $message = '同一诊单、录音内容及模型已有任务,已复用原任务,不会再次调用模型。'; + if (!FollowupAudioGate::previewOnly() && FollowupAudioGate::taskPreview($task)) { + $message .= '这是历史测试预览任务,保持测试用途;本次未创建业务任务,原任务不提供确认入单。'; + } if (!self::providerMatches($task)) { $message .= '原任务的服务配置已变更或缺少配置快照,不能自动改用新服务重跑。'; } diff --git a/server/config/followup_audio.php b/server/config/followup_audio.php index b946d98e3..a6322e8b2 100644 --- a/server/config/followup_audio.php +++ b/server/config/followup_audio.php @@ -2,6 +2,8 @@ /** Audio is deliberately OFF until the synthetic short/15m/1h gate passes for the current app. */ return [ + // Human-reviewed business operation; does not claim complete model accuracy certification. + 'manual_review_enabled' => filter_var(env('followup_audio.MANUAL_REVIEW_ENABLED', false), FILTER_VALIDATE_BOOLEAN), 'preview_only' => filter_var(env('followup_audio.PREVIEW_ONLY', false), FILTER_VALIDATE_BOOLEAN), // Parse strictly at the gate; empty/malformed diagnosis lists never grant preview access. 'test_diagnosis_ids' => env('followup_audio.TEST_DIAGNOSIS_IDS', ''), diff --git a/server/tests/FollowupAudioControllerFlowTest.php b/server/tests/FollowupAudioControllerFlowTest.php index e95cfb24e..aed87db5b 100644 --- a/server/tests/FollowupAudioControllerFlowTest.php +++ b/server/tests/FollowupAudioControllerFlowTest.php @@ -58,7 +58,15 @@ if (($argv[1] ?? '') === '--request') { exit(0); } -$f = followupAudioTestDatabase(['preview_only' => false]); +$manualMode = in_array('--manual-review', $argv, true); +$f = followupAudioTestDatabase(['preview_only' => false, 'manual_review_enabled' => $manualMode, + 'audio_verified' => !$manualMode, 'verified_profiles' => $manualMode ? [] : ['qwen']]); +if ($manualMode) { + $providers = config('followup_audio.providers'); + $providers['qwen']['preview_verified_fingerprint'] = Provider::resolve('qwen')['fingerprint']; + $providers['qwen']['verified_fingerprint'] = ''; + $f['config']->set(['providers' => $providers], 'followup_audio'); +} $checks = 0; $expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $checks++; @@ -169,6 +177,7 @@ try { } 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'); + if ($manualMode) { $expect(!$cap['audio_verified'] && $cap['manual_review_enabled'] && $cap['manual_review_ready'], 'manual business does not fake model accuracy verification'); } $detail = $make([['diagnosis', ['symptoms' => '合成症状']], ['blood', ['fasting_blood_sugar' => 6.1]]]); $reject(followupControllerRequest('apply', $body($resolve($detail))), 'FOLLOWUP_AUDIO_CHANNEL_ROLES_REQUIRED'); $initialVersion = $detail['version']; diff --git a/server/tests/FollowupAudioManualReviewTest.php b/server/tests/FollowupAudioManualReviewTest.php new file mode 100644 index 000000000..4c2f137f0 --- /dev/null +++ b/server/tests/FollowupAudioManualReviewTest.php @@ -0,0 +1,16 @@ +instance('config',$config); +$n=0;$ok=static function(bool$b,string$m)use(&$n){if(!$b)throw new RuntimeException($m);$n++;}; +$slot=['driver'=>'asr_then_llm','label'=>'Synthetic','asr'=>['protocol'=>'dify','binding_revision'=>'fixture','base_url'=>'https://asr.invalid/v1','api_key'=>'synthetic','model'=>'synthetic-asr'],'extraction'=>['base_url'=>'https://text.invalid/v1','api_key'=>'synthetic','model'=>'synthetic-text']]; +$s=['enabled'=>true,'preview_only'=>false,'manual_review_enabled'=>true,'audio_verified'=>false,'verified_profiles'=>[],'providers'=>['openai'=>$slot],'profile'=>'openai','test_diagnosis_ids'=>'1'];$p=P::resolve('openai',$s,[]);$s['providers']['openai']['preview_verified_fingerprint']=$p['fingerprint'];$config->set($s,'followup_audio'); +$ok(G::ready('openai'),'explicit manual business readiness supported');$ok(!S::verified(),'not an accuracy certification');$ok(G::manualReviewReady()&&G::manualReviewReady('openai'),'any and selected readiness');$ok(G::scopeAllowed(1131,1)&&G::scopeAllowed(12335,1),'no preview whitelist in business mode');$ok(array_column(P::readyModels(),'value')===['openai'],'only actual ready model advertised'); +foreach([false,'true',null]as$flag){$x=$s;$x['manual_review_enabled']=$flag;$ok(!G::ready('openai',$x,[]),'explicit boolean operator grant required');} +foreach(['','different-binding']as$fp){$x=$s;$x['providers']['openai']['preview_verified_fingerprint']=$fp;$ok(!G::ready('openai',$x,[]),'binding readiness required');} +$x=$s;$x['providers']['openai']['extraction']['model']='changed-model';$ok(!G::ready('openai',$x,[]),'model drift invalidates manual readiness');$ok(!G::manualReviewReady('qwen',$s,[]),'one profile does not grant another'); +$x=$s;$x['preview_only']=true;$config->set($x,'followup_audio');$ok(!G::manualReviewReady(),'preview dominates manual grant');$ok(!G::scopeAllowed(1131,1),'preview keeps its scoped list');try{G::assertMayApply();throw new RuntimeException('expected deny');}catch(DomainException$e){$ok($e->getMessage()==='FOLLOWUP_AUDIO_PREVIEW_ONLY','preview hard denial');} +$config->set($s,'followup_audio');G::assertMayApply(['upstream_ids_json'=>'{}']);$ok(true,'new business task may reach human Apply');try{G::assertMayApply(['upstream_ids_json'=>'{"preview_only":true}']);throw new RuntimeException('expected origin deny');}catch(DomainException$e){$ok($e->getMessage()==='FOLLOWUP_AUDIO_PREVIEW_ONLY','old synthetic preview remains nonadoptable');} +$x=$s;$x['manual_review_enabled']=false;$config->set($x,'followup_audio');try{S::assertEnabled('openai');throw new RuntimeException('expected revoke');}catch(DomainException$e){$ok($e->getMessage()==='FOLLOWUP_AUDIO_DISABLED_OR_UNVERIFIED','manual grant revocation stops writes and processing');} +echo 'FOLLOWUP_AUDIO_MANUAL_REVIEW assertions='.$n.' PASS all_authorized_diagnoses=1 explicit_human_apply=1 accuracy_flag_unchanged=1 binding_required=1 preview_origin_retained=1'.PHP_EOL;