feat: add durable ASR-to-patient extraction and guarded review
This commit is contained in:
@@ -14,7 +14,8 @@ final class FollowupAudioProviderConfig
|
||||
$legacy = $legacy ?? (array) config('prescription_ai', []);
|
||||
$provider = (array) ($settings['providers'][$profile] ?? []);
|
||||
$driver = (string) ($provider['driver'] ?? 'dify');
|
||||
if (!in_array($driver, ['dify', 'openai_audio'], true)) { throw new FollowupAudioException('CONFIG_INVALID'); }
|
||||
if (!in_array($driver, ['dify', 'openai_audio', 'asr_then_llm'], true)) { throw new FollowupAudioException('CONFIG_INVALID'); }
|
||||
if ($driver === 'asr_then_llm') { return self::pipeline($provider, $settings, $profile); }
|
||||
$base = (string) ($provider['base_url'] ?? '');
|
||||
$key = (string) ($provider['api_key'] ?? '');
|
||||
if ($driver === 'dify') {
|
||||
@@ -71,10 +72,60 @@ final class FollowupAudioProviderConfig
|
||||
try { $provider = self::resolve($profile, $settings, $legacy); }
|
||||
catch (FollowupAudioException $error) { return false; }
|
||||
$verified = (string) ($settings['providers'][$profile]['verified_fingerprint'] ?? '');
|
||||
return str_starts_with($provider['base_url'], 'https://') && preg_match('/^[a-f0-9]{64}$/D', $verified)
|
||||
$secure = $provider['driver'] === 'asr_then_llm'
|
||||
? self::secureEndpoint($provider['asr']['base_url'], $provider['allow_loopback_tunnel'])
|
||||
&& self::secureEndpoint($provider['extraction']['base_url'], $provider['allow_loopback_tunnel'])
|
||||
: str_starts_with($provider['base_url'], 'https://');
|
||||
return $secure && preg_match('/^[a-f0-9]{64}$/D', $verified)
|
||||
&& hash_equals($provider['fingerprint'], $verified);
|
||||
}
|
||||
|
||||
/** Literal loopback only, explicitly configured for a locally established SSH tunnel. No DNS exception. */
|
||||
public static function secureEndpoint(string $base, bool $allowLoopback): bool
|
||||
{
|
||||
$parts = parse_url($base);
|
||||
return ($parts['scheme'] ?? '') === 'https' || ($allowLoopback && ($parts['scheme'] ?? '') === 'http'
|
||||
&& in_array($parts['host'] ?? '', ['127.0.0.1', '[::1]'], true));
|
||||
}
|
||||
|
||||
private static function pipeline(array $provider, array $settings, string $profile): array
|
||||
{
|
||||
$allowLoopback = ($provider['allow_loopback_tunnel'] ?? false) === true;
|
||||
$resolved = ['driver' => 'asr_then_llm', 'allow_loopback_tunnel' => $allowLoopback];
|
||||
foreach (['asr' => '/audio/transcriptions', 'extraction' => '/chat/completions'] as $stage => $endpoint) {
|
||||
$input = (array) ($provider[$stage] ?? []);
|
||||
// Use existing endpoint/key/model syntax validation without inheriting any legacy service.
|
||||
$part = self::resolve($profile, ['providers' => [$profile => array_merge($input, ['driver' => 'openai_audio'])]], []);
|
||||
$base = $part['base_url'];
|
||||
if ($stage === 'asr') {
|
||||
$original = rtrim((string) ($input['base_url'] ?? ''), '/');
|
||||
if (str_ends_with($original, $endpoint)) { $base = substr($original, 0, -strlen($endpoint)); }
|
||||
}
|
||||
if (!self::secureEndpoint($base, $allowLoopback)) { throw new FollowupAudioException('HTTPS_REQUIRED'); }
|
||||
$resolved[$stage] = ['base_url' => $base, 'api_key' => $part['api_key'], 'model' => $part['model']];
|
||||
}
|
||||
$chunkSeconds = (int) ($settings['asr_chunk_seconds'] ?? 120);
|
||||
if ($chunkSeconds < 1 || $chunkSeconds > 120) { throw new FollowupAudioException('CONFIG_INVALID'); }
|
||||
$resolved['chunk_seconds'] = $chunkSeconds;
|
||||
$mode = $provider['extraction']['response_format'] ?? 'json_schema';
|
||||
$maxTokens = $provider['extraction']['max_tokens'] ?? 8192;
|
||||
$thinking = $provider['extraction']['enable_thinking'] ?? null;
|
||||
if (!in_array($mode, ['json_schema', 'json_object', 'prompt_json'], true)
|
||||
|| !is_int($maxTokens) || $maxTokens < 256 || $maxTokens > 8192
|
||||
|| ($thinking !== null && !is_bool($thinking))) { throw new FollowupAudioException('CONFIG_INVALID'); }
|
||||
$resolved['extraction'] += ['response_format' => $mode, 'max_tokens' => $maxTokens, 'enable_thinking' => $thinking];
|
||||
$resolved['output_schema'] = FollowupAudioExtractionSchema::VERSION;
|
||||
$resolved['citation_policy'] = FollowupAudioTranscriptPrompt::CITATION_POLICY;
|
||||
$resolved['schema_dialect'] = FollowupAudioExtractionSchema::DIALECT;
|
||||
$resolved['label'] = trim((string) ($provider['label'] ?? $profile));
|
||||
if ($resolved['label'] === '' || strlen($resolved['label']) > 200 || preg_match('/[\x00-\x1f\x7f]/', $resolved['label'])) {
|
||||
throw new FollowupAudioException('CONFIG_INVALID');
|
||||
}
|
||||
$resolved['fingerprint'] = hash('sha256', json_encode([$resolved['driver'], $resolved['asr'], $resolved['extraction'],
|
||||
$allowLoopback, $chunkSeconds, 'pcm-s16le-mono-16000-v1', $resolved['output_schema'], $resolved['citation_policy'], $resolved['schema_dialect']], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
public static function publicModels(): array
|
||||
{
|
||||
$models = [];
|
||||
|
||||
Reference in New Issue
Block a user