feat: enable explicitly authorized human-reviewed clinical adoption
This commit is contained in:
@@ -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 =
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 .= '原任务的服务配置已变更或缺少配置快照,不能自动改用新服务重跑。';
|
||||
}
|
||||
|
||||
@@ -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', ''),
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require dirname(__DIR__).'/vendor/autoload.php';require dirname(__DIR__).'/vendor/topthink/framework/src/helper.php';
|
||||
use app\common\service\followupaudio\FollowupAudioGate as G;use app\common\service\followupaudio\FollowupAudioProviderConfig as P;use app\common\service\followupaudio\FollowupAudioStore as S;
|
||||
new think\App();$config=new think\Config();think\Container::getInstance()->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;
|
||||
Reference in New Issue
Block a user