feat: add scoped read-only audio preview and isolated Dify transport
This commit is contained in:
@@ -40,8 +40,9 @@ final class FollowupAudioAccess
|
||||
return in_array(strtolower($permission), array_map('strtolower', $permissions), true);
|
||||
}
|
||||
|
||||
public static function diagnosis(int $diagnosisId, int $actor, array $info, bool $daily = false): array
|
||||
public static function diagnosis(int $diagnosisId, int $actor, array $info, bool $daily = false, bool $enforcePreviewScope = true): array
|
||||
{
|
||||
if ($enforcePreviewScope) { FollowupAudioGate::assertScope($diagnosisId, $actor); }
|
||||
if ($diagnosisId <= 0) {
|
||||
throw new DomainException('诊单不存在或无权操作');
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ final class FollowupAudioApply
|
||||
|
||||
public static function apply(int $taskId, int $version, array $items, int $actor, array $info): array
|
||||
{
|
||||
FollowupAudioGate::assertMayApply(); // Before DB, root checks and the already-applied idempotent path.
|
||||
FollowupAudioStore::assertEnabled();
|
||||
// Scope helpers perform ordinary reads. READ COMMITTED avoids a pre-lock actor/scope snapshot,
|
||||
// and SET TRANSACTION changes this one transaction only (never the connection/session default).
|
||||
@@ -26,6 +27,7 @@ final class FollowupAudioApply
|
||||
$connection->execute('SET TRANSACTION ISOLATION LEVEL READ COMMITTED');
|
||||
$result = Db::transaction(static function () use ($taskId, $version, $items, $actor, $info): array {
|
||||
$task = FollowupAudioStore::lockTask($taskId);
|
||||
FollowupAudioGate::assertMayApply($task);
|
||||
FollowupAudioStore::assertEnabled((string) $task['model_key']);
|
||||
$info = FollowupAudioAccess::actor($actor);
|
||||
FollowupAudioAccess::task($taskId, $actor, $info);
|
||||
@@ -93,6 +95,7 @@ final class FollowupAudioApply
|
||||
if ($identityValues !== []) { self::assertIdentityMutable($diagnosis, $identityValues, $actor, $info); }
|
||||
$applied = [];
|
||||
foreach ($selected as $item) {
|
||||
FollowupAudioGate::assertMayApply($task);
|
||||
$kind = $item['kind'];
|
||||
$table = self::TABLES[$kind];
|
||||
$targetId = $kind === 'diagnosis' ? (int) $diagnosis['id'] : (int) ($item['target_id'] ?? 0);
|
||||
|
||||
@@ -24,9 +24,10 @@ final class FollowupAudioDify
|
||||
if (empty($this->settings['enabled'])) {
|
||||
throw new FollowupAudioException('FEATURE_DISABLED');
|
||||
}
|
||||
if (!FollowupAudioProviderConfig::verified((string) ($task['model_key'] ?? ''), $this->settings, $this->provider)) {
|
||||
if (!FollowupAudioGate::ready((string) ($task['model_key'] ?? ''), $this->settings, $this->provider)) {
|
||||
throw new FollowupAudioException('AUDIO_NOT_VERIFIED');
|
||||
}
|
||||
FollowupAudioGate::assertScope((int) ($task['diagnosis_id'] ?? 0), (int) ($task['actor_id'] ?? 0), $this->settings);
|
||||
$resolved = FollowupAudioProviderConfig::resolve((string) $task['model_key'], $this->settings, $this->provider);
|
||||
if ($resolved['driver'] !== 'asr_then_llm' && (int) ($task['upstream_started_at'] ?? 0) > 0) {
|
||||
throw new FollowupAudioException('RECONCILIATION_REQUIRED', true);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
namespace app\common\service\followupaudio;
|
||||
|
||||
use DomainException;
|
||||
|
||||
/** Preview is a separate, ID-scoped recognition permission; never permission to write clinical facts. */
|
||||
final class FollowupAudioGate
|
||||
{
|
||||
public static function previewOnly(?array $settings = null): bool
|
||||
{
|
||||
return (bool) (($settings ?? (array) config('followup_audio', []))['preview_only'] ?? false);
|
||||
}
|
||||
|
||||
public static function scopeAllowed(int $diagnosisId, int $actor, ?array $settings = null): bool
|
||||
{
|
||||
$settings = $settings ?? (array) config('followup_audio', []);
|
||||
if (!self::previewOnly($settings)) { return true; }
|
||||
try { $diagnoses = self::ids($settings['test_diagnosis_ids'] ?? ''); $admins = self::ids($settings['test_admin_ids'] ?? ''); }
|
||||
catch (DomainException $error) { return false; }
|
||||
return $diagnosisId > 0 && $actor > 0 && $diagnoses !== [] && in_array($diagnosisId, $diagnoses, true)
|
||||
&& ($admins === [] || in_array($actor, $admins, true));
|
||||
}
|
||||
|
||||
private static function ids($raw): array
|
||||
{
|
||||
if (is_string($raw)) { $raw = trim($raw) === '' ? [] : explode(',', $raw); }
|
||||
if (!is_array($raw) || !array_is_list($raw) || count($raw) > 1000) { throw new DomainException('FOLLOWUP_AUDIO_PREVIEW_SCOPE_INVALID'); }
|
||||
$ids = [];
|
||||
foreach ($raw as $entry) {
|
||||
if ((!is_int($entry) && !is_string($entry)) || !preg_match('/^[1-9][0-9]{0,17}$/D', trim((string) $entry))) {
|
||||
throw new DomainException('FOLLOWUP_AUDIO_PREVIEW_SCOPE_INVALID');
|
||||
}
|
||||
$ids[] = (int) trim((string) $entry);
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
public static function assertScope(int $diagnosisId, int $actor, ?array $settings = null): void
|
||||
{
|
||||
if (!self::scopeAllowed($diagnosisId, $actor, $settings)) { throw new DomainException('FOLLOWUP_AUDIO_PREVIEW_SCOPE_DENIED'); }
|
||||
}
|
||||
|
||||
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', []);
|
||||
if ($profile === null) {
|
||||
foreach (['qwen', 'openai'] as $slot) { if (self::ready($slot, $settings, $legacy)) { return true; } }
|
||||
return false;
|
||||
}
|
||||
return self::previewOnly($settings) ? FollowupAudioProviderConfig::previewVerified($profile, $settings, $legacy)
|
||||
: FollowupAudioProviderConfig::verified($profile, $settings, $legacy);
|
||||
}
|
||||
|
||||
/** Persisted preview-origin tasks never become eligible for adoption after a configuration change. */
|
||||
public static function taskPreview(array $task): bool
|
||||
{
|
||||
$ids = json_decode((string) ($task['upstream_ids_json'] ?? '{}'), true);
|
||||
if (!is_array($ids) || !array_key_exists('preview_only', $ids)) { return false; }
|
||||
if ($ids['preview_only'] !== true) { throw new DomainException('FOLLOWUP_AUDIO_PREVIEW_MARKER_INVALID'); }
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function assertMayApply(?array $task = null): void
|
||||
{
|
||||
if (self::previewOnly() || ($task !== null && self::taskPreview($task))) { throw new DomainException('FOLLOWUP_AUDIO_PREVIEW_ONLY'); }
|
||||
}
|
||||
}
|
||||
@@ -71,13 +71,14 @@ final class FollowupAudioPipeline
|
||||
continue;
|
||||
}
|
||||
$next = $state;
|
||||
unset($next['chunks'][$index]['upstream_ids']);
|
||||
$next['chunks'][$index]['state'] = 'intent';
|
||||
$next['chunks'][$index]['request_id'] = 'fa-' . bin2hex(random_bytes(16));
|
||||
$this->save($state, $next, $task, $heartbeat, 'transcribing', true);
|
||||
$response = $this->request('asr', ['multipart' => ['model' => $this->provider['asr']['model'], 'response_format' => 'json',
|
||||
'file' => new \CURLFile($chunkPath, 'audio/wav', 'chunk.wav')]], $heartbeat);
|
||||
$response = $this->request('asr', $this->transcriptionRequest($chunkPath, $state['chunks'][$index]['request_id']), $heartbeat);
|
||||
if ($response['rejected']) {
|
||||
$next = $state; $next['chunks'][$index]['state'] = 'rejected';
|
||||
if ($response['upstream_ids'] !== []) { $next['chunks'][$index]['upstream_ids'] = $response['upstream_ids']; }
|
||||
$this->save($state, $next, $task, $heartbeat, 'transcribing');
|
||||
throw new FollowupAudioException('ASR_REJECTED');
|
||||
}
|
||||
@@ -86,6 +87,7 @@ final class FollowupAudioPipeline
|
||||
throw new FollowupAudioException('ASR_RESPONSE_INVALID', true);
|
||||
}
|
||||
$next = $state; $next['chunks'][$index]['state'] = 'complete'; $next['chunks'][$index]['text'] = $body['text'];
|
||||
if ($response['upstream_ids'] !== []) { $next['chunks'][$index]['upstream_ids'] = $response['upstream_ids']; }
|
||||
$this->save($state, $next, $task, $heartbeat, 'transcribing');
|
||||
unlink($chunkPath);
|
||||
}
|
||||
@@ -93,23 +95,33 @@ final class FollowupAudioPipeline
|
||||
$transcript = implode("\n", array_column($segments, 'text'));
|
||||
self::assertTranscriptQuality($transcript, $segments);
|
||||
if ($state['extraction']['state'] !== 'complete') {
|
||||
$payload = $this->extractRequest($transcript, $segments, $task['recorded_at'], FollowupAudioFields::catalog());
|
||||
$requestId = 'fa-' . bin2hex(random_bytes(16));
|
||||
$payload = $this->extractRequest($transcript, $segments, $task['recorded_at'], FollowupAudioFields::catalog(), $requestId);
|
||||
self::beat($heartbeat, []);
|
||||
$this->assertSource($audio['path'], $task['sha256']);
|
||||
$next = $state; $next['extraction'] = ['state' => 'intent', 'request_id' => 'fa-' . bin2hex(random_bytes(16))];
|
||||
$next = $state; $next['extraction'] = ['state' => 'intent', 'request_id' => $requestId];
|
||||
$this->save($state, $next, $task, $heartbeat, 'extracting', true);
|
||||
$response = $this->request('extraction', ['json' => $payload], $heartbeat);
|
||||
if ($response['rejected']) {
|
||||
$next = $state; $next['extraction']['state'] = 'rejected';
|
||||
if ($response['upstream_ids'] !== []) { $next['extraction']['upstream_ids'] = $response['upstream_ids']; }
|
||||
$this->save($state, $next, $task, $heartbeat, 'extracting');
|
||||
throw new FollowupAudioException('EXTRACTION_REJECTED');
|
||||
}
|
||||
$choices = $response['body']['choices'] ?? [];
|
||||
$choice = is_array($choices) && count($choices) === 1 ? ($choices[0] ?? []) : [];
|
||||
$answer = ($choice['finish_reason'] ?? '') === 'stop' && empty($choice['message']['refusal']) && empty($choice['message']['tool_calls'])
|
||||
&& is_string($choice['message']['content'] ?? null) ? $choice['message']['content'] : '';
|
||||
if (($this->provider['extraction']['protocol'] ?? 'openai') === 'dify_chat') {
|
||||
$body = $response['body'];
|
||||
$finish = $body['finish_reason'] ?? $body['metadata']['finish_reason'] ?? $body['metadata']['usage']['finish_reason'] ?? null;
|
||||
$answer = ($finish === null || $finish === 'stop') && empty($body['refusal']) && empty($body['tool_calls'])
|
||||
&& is_string($body['answer'] ?? null) ? $body['answer'] : '';
|
||||
} else {
|
||||
$choices = $response['body']['choices'] ?? [];
|
||||
$choice = is_array($choices) && count($choices) === 1 ? ($choices[0] ?? []) : [];
|
||||
$answer = ($choice['finish_reason'] ?? '') === 'stop' && empty($choice['message']['refusal']) && empty($choice['message']['tool_calls'])
|
||||
&& is_string($choice['message']['content'] ?? null) ? $choice['message']['content'] : '';
|
||||
}
|
||||
// A received invalid answer is still a known completed request; retain it encrypted, never silently reissue it.
|
||||
$next = $state; $next['extraction']['state'] = 'complete'; $next['extraction']['answer'] = $answer;
|
||||
if ($response['upstream_ids'] !== []) { $next['extraction']['upstream_ids'] = $response['upstream_ids']; }
|
||||
$this->save($state, $next, $task, $heartbeat, 'validating');
|
||||
}
|
||||
$this->assertSource($audio['path'], $task['sha256']);
|
||||
@@ -127,7 +139,7 @@ final class FollowupAudioPipeline
|
||||
}
|
||||
|
||||
/** Pure production request builder for authorized cached-ASR acceptance; performs no network or state mutation. */
|
||||
public function extractRequest(string $transcript, array $segments, string $recordedAt, array $catalog): array
|
||||
public function extractRequest(string $transcript, array $segments, string $recordedAt, array $catalog, ?string $requestId = null): array
|
||||
{
|
||||
$part = $this->provider['extraction'];
|
||||
$mode = $part['response_format'] ?? 'json_schema';
|
||||
@@ -138,6 +150,11 @@ final class FollowupAudioPipeline
|
||||
|| ($thinking !== null && !is_bool($thinking))) { throw new FollowupAudioException('CONFIG_INVALID'); }
|
||||
$prompt = FollowupAudioTranscriptPrompt::build($transcript, $segments, $recordedAt, $catalog);
|
||||
self::assertTranscriptQuality($transcript, $segments);
|
||||
if (($part['protocol'] ?? 'openai') === 'dify_chat') {
|
||||
if ($mode !== 'prompt_json' || empty($part['binding_revision']) || $thinking !== null) { throw new FollowupAudioException('CONFIG_INVALID'); }
|
||||
return ['inputs' => new \stdClass(), 'query' => $prompt, 'response_mode' => 'blocking',
|
||||
'user' => $this->opaqueUser($requestId ?? hash('sha256', $transcript)), 'auto_generate_name' => false];
|
||||
}
|
||||
$payload = ['model' => $part['model'], 'stream' => false, 'temperature' => 0, 'max_tokens' => $maxTokens,
|
||||
'messages' => [['role' => 'user', 'content' => $prompt]]];
|
||||
if ($mode === 'json_schema') { $payload['response_format'] = self::buildResponseFormat($catalog, FollowupAudioTranscriptPrompt::citations($segments)); }
|
||||
@@ -147,6 +164,20 @@ final class FollowupAudioPipeline
|
||||
return $payload;
|
||||
}
|
||||
|
||||
public function transcriptionRequest(string $chunkPath, string $requestId): array
|
||||
{
|
||||
$file = new \CURLFile($chunkPath, 'audio/wav', 'chunk.wav');
|
||||
if (($this->provider['asr']['protocol'] ?? 'openai') === 'dify') {
|
||||
return ['multipart' => ['file' => $file, 'user' => $this->opaqueUser($requestId)]];
|
||||
}
|
||||
return ['multipart' => ['model' => $this->provider['asr']['model'], 'response_format' => 'json', 'file' => $file]];
|
||||
}
|
||||
|
||||
private function opaqueUser(string $requestId): string
|
||||
{
|
||||
return 'fa-opaque-' . substr(hash('sha256', $this->provider['fingerprint'] . ':' . $requestId), 0, 48);
|
||||
}
|
||||
|
||||
public static function buildResponseFormat(array $catalog, array $citations): array
|
||||
{
|
||||
return FollowupAudioExtractionSchema::responseFormat($catalog, $citations);
|
||||
@@ -265,19 +296,41 @@ final class FollowupAudioPipeline
|
||||
$limit = (int) ($this->settings['max_response_bytes'] ?? 8388608);
|
||||
if ($timeout < 1 || $timeout > 300 || $limit < 1024 || $limit > 8388608) { throw new FollowupAudioException('CONFIG_INVALID'); }
|
||||
$part = $this->provider[$stage];
|
||||
$spec = ['url' => $part['base_url'] . ($stage === 'asr' ? '/audio/transcriptions' : '/chat/completions'),
|
||||
$protocol = $part['protocol'] ?? 'openai';
|
||||
$endpoint = $stage === 'asr' ? ($protocol === 'dify' ? '/audio-to-text' : '/audio/transcriptions')
|
||||
: ($protocol === 'dify_chat' ? '/chat-messages' : '/chat/completions');
|
||||
$spec = ['url' => $part['base_url'] . $endpoint,
|
||||
'api_key' => $part['api_key'], 'timeout' => $timeout, 'stage' => $stage] + $payload;
|
||||
try { $response = $this->transport ? ($this->transport)($spec, $heartbeat) : $this->curl($spec, $heartbeat, $limit); }
|
||||
try {
|
||||
$response = $this->transport ? ($this->transport)($spec, $heartbeat)
|
||||
: (($this->provider['http_transport'] ?? 'curl') === 'openssl_stream'
|
||||
? FollowupAudioStreamTransport::request($spec, $heartbeat, $limit, $this->provider['allow_loopback_tunnel'])
|
||||
: $this->curl($spec, $heartbeat, $limit));
|
||||
}
|
||||
catch (\Throwable $e) { throw new FollowupAudioException('UPSTREAM_UNCERTAIN', true); }
|
||||
$http = (int) ($response['http_code'] ?? 0);
|
||||
$candidate = is_string($response['body'] ?? null) && strlen($response['body']) <= $limit ? json_decode($response['body'], true) : null;
|
||||
$ids = [];
|
||||
if (is_array($candidate)) {
|
||||
foreach (['task_id', 'message_id', 'conversation_id'] as $key) {
|
||||
if (is_string($candidate[$key] ?? null) && preg_match('/^[A-Za-z0-9_.:-]{1,190}$/D', $candidate[$key])) { $ids[$key] = $candidate[$key]; }
|
||||
}
|
||||
if (!isset($ids['message_id']) && $stage === 'extraction' && is_string($candidate['id'] ?? null)
|
||||
&& preg_match('/^[A-Za-z0-9_.:-]{1,190}$/D', $candidate['id'])) { $ids['message_id'] = $candidate['id']; }
|
||||
}
|
||||
if ($ids !== []) {
|
||||
$fields = ['upstream_ids_json' => json_encode($ids, JSON_THROW_ON_ERROR)];
|
||||
if (isset($ids['message_id']) || isset($ids['task_id'])) { $fields['upstream_run_id'] = $ids['message_id'] ?? $ids['task_id']; }
|
||||
self::beat($heartbeat, $fields); // Preserve observed opaque IDs even when the reply is uncertain/rejected.
|
||||
}
|
||||
if (($response['errno'] ?? 0) !== 0 || $http === 0 || $http >= 500 || $http === 408
|
||||
|| !is_string($response['body'] ?? null) || strlen($response['body']) > $limit) { throw new FollowupAudioException('UPSTREAM_UNCERTAIN', true); }
|
||||
if (in_array($http, [400, 401, 403, 404, 413, 415, 422, 429], true)) { return ['rejected' => true, 'body' => []]; }
|
||||
if (in_array($http, [400, 401, 403, 404, 413, 415, 422, 429], true)) { return ['rejected' => true, 'body' => [], 'upstream_ids' => $ids]; }
|
||||
if ($http < 200 || $http >= 300) { throw new FollowupAudioException('UPSTREAM_UNCERTAIN', true); }
|
||||
try { $body = json_decode($response['body'], true, 64, JSON_THROW_ON_ERROR); }
|
||||
catch (\Throwable $e) { throw new FollowupAudioException('UPSTREAM_UNCERTAIN', true); }
|
||||
if (!is_array($body) || !empty($body['error'])) { throw new FollowupAudioException('UPSTREAM_UNCERTAIN', true); }
|
||||
return ['rejected' => false, 'body' => $body];
|
||||
if (!is_array($body) || !empty($body['error']) || !empty($body['code']) || ($body['event'] ?? '') === 'error') { throw new FollowupAudioException('UPSTREAM_UNCERTAIN', true); }
|
||||
return ['rejected' => false, 'body' => $body, 'upstream_ids' => $ids];
|
||||
}
|
||||
|
||||
private function curl(array $spec, callable $heartbeat, int $limit): array
|
||||
|
||||
@@ -71,8 +71,15 @@ final class FollowupAudioPipelineCheckpoint
|
||||
|
||||
private static function entry(array $entry, bool $extraction): void
|
||||
{
|
||||
$allowed = $extraction ? ['state', 'request_id', 'answer'] : ['id', 'start_ms', 'end_ms', 'channel', 'state', 'request_id', 'text', 'source', 'pcm_sha256', 'pcm_bytes'];
|
||||
$allowed = $extraction ? ['state', 'request_id', 'answer', 'upstream_ids'] : ['id', 'start_ms', 'end_ms', 'channel', 'state', 'request_id', 'text', 'source', 'pcm_sha256', 'pcm_bytes', 'upstream_ids'];
|
||||
if (array_diff(array_keys($entry), $allowed) || !in_array($entry['state'] ?? '', ['pending', 'intent', 'complete', 'rejected', 'local_silence'], true)) { self::invalid(); }
|
||||
if (array_key_exists('upstream_ids', $entry)) {
|
||||
if (!is_array($entry['upstream_ids']) || $entry['upstream_ids'] === [] || !in_array($entry['state'], ['complete', 'rejected'], true)) { self::invalid(); }
|
||||
foreach ($entry['upstream_ids'] as $key => $value) {
|
||||
if (!in_array($key, ['task_id', 'message_id', 'conversation_id'], true) || !is_string($value)
|
||||
|| !preg_match('/^[A-Za-z0-9_.:-]{1,190}$/D', $value)) { self::invalid(); }
|
||||
}
|
||||
}
|
||||
if (array_key_exists('channel', $entry) && (!is_int($entry['channel']) || !in_array($entry['channel'], [0, 1], true))) { self::invalid(); }
|
||||
if ($entry['state'] === 'local_silence') {
|
||||
if ($extraction || array_key_exists('request_id', $entry) || ($entry['text'] ?? null) !== '' || ($entry['source'] ?? '') !== 'local_silence'
|
||||
|
||||
@@ -14,6 +14,8 @@ final class FollowupAudioProviderConfig
|
||||
$legacy = $legacy ?? (array) config('prescription_ai', []);
|
||||
$provider = (array) ($settings['providers'][$profile] ?? []);
|
||||
$driver = (string) ($provider['driver'] ?? 'dify');
|
||||
$httpTransport = $settings['http_transport'] ?? 'curl';
|
||||
if (!in_array($httpTransport, ['curl', 'openssl_stream'], true) || ($driver !== 'asr_then_llm' && $httpTransport !== 'curl')) { 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'] ?? '');
|
||||
@@ -80,6 +82,29 @@ final class FollowupAudioProviderConfig
|
||||
&& hash_equals($provider['fingerprint'], $verified);
|
||||
}
|
||||
|
||||
/** Synthetic connection readiness is not the full audio/accuracy acceptance gate. */
|
||||
public static function previewVerified(string $profile, ?array $settings = null, ?array $legacy = null): bool
|
||||
{
|
||||
$settings = $settings ?? (array) config('followup_audio', []);
|
||||
$legacy = $legacy ?? (array) config('prescription_ai', []);
|
||||
try { $provider = self::resolve($profile, $settings, $legacy); }
|
||||
catch (FollowupAudioException $error) { return false; }
|
||||
$fingerprint = (string) ($settings['providers'][$profile]['preview_verified_fingerprint'] ?? '');
|
||||
$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', $fingerprint) && hash_equals($provider['fingerprint'], $fingerprint);
|
||||
}
|
||||
|
||||
public static function readyModels(): array
|
||||
{
|
||||
$models = [];
|
||||
foreach (['qwen', 'openai'] as $profile) {
|
||||
if (FollowupAudioGate::ready($profile)) { $models[] = ['value' => $profile, 'label' => self::resolve($profile)['label']]; }
|
||||
}
|
||||
return $models;
|
||||
}
|
||||
|
||||
/** Literal loopback only, explicitly configured for a locally established SSH tunnel. No DNS exception. */
|
||||
public static function secureEndpoint(string $base, bool $allowLoopback): bool
|
||||
{
|
||||
@@ -94,15 +119,30 @@ final class FollowupAudioProviderConfig
|
||||
$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'])]], []);
|
||||
$protocol = $input['protocol'] ?? 'openai';
|
||||
$allowed = $stage === 'asr' ? ['openai', 'dify'] : ['openai', 'dify_chat'];
|
||||
if (!in_array($protocol, $allowed, true)) { throw new FollowupAudioException('CONFIG_INVALID'); }
|
||||
$revision = $input['binding_revision'] ?? '';
|
||||
if ($protocol !== 'openai' && (!is_string($revision) || !preg_match('/^[A-Za-z0-9_.:-]{1,128}$/D', $revision))) {
|
||||
throw new FollowupAudioException('CONFIG_INVALID');
|
||||
}
|
||||
$checked = $input;
|
||||
if ($protocol !== 'openai') {
|
||||
$endpoint = $stage === 'asr' ? '/audio-to-text' : '/chat-messages';
|
||||
$original = rtrim((string) ($input['base_url'] ?? ''), '/');
|
||||
if (str_ends_with($original, $endpoint)) { $checked['base_url'] = substr($original, 0, -strlen($endpoint)); }
|
||||
}
|
||||
// Model is a bound expected deployment identity for Dify, not a fake request parameter.
|
||||
$part = self::resolve($profile, ['providers' => [$profile => array_merge($checked, ['driver' => 'openai_audio'])]], []);
|
||||
$base = $part['base_url'];
|
||||
if ($stage === 'asr') {
|
||||
if ($stage === 'asr' && $protocol === 'openai') {
|
||||
$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']];
|
||||
// Default/explicit OpenAI retains its previous exact fingerprint representation.
|
||||
if ($protocol !== 'openai') { $resolved[$stage] += ['protocol' => $protocol, 'binding_revision' => $revision]; }
|
||||
}
|
||||
$chunkSeconds = (int) ($settings['asr_chunk_seconds'] ?? 120);
|
||||
if ($chunkSeconds < 1 || $chunkSeconds > 120) { throw new FollowupAudioException('CONFIG_INVALID'); }
|
||||
@@ -118,7 +158,11 @@ final class FollowupAudioProviderConfig
|
||||
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];
|
||||
if (($resolved['extraction']['protocol'] ?? 'openai') === 'dify_chat') {
|
||||
if (($provider['extraction']['response_format'] ?? null) !== 'prompt_json' || $thinking !== null) { throw new FollowupAudioException('CONFIG_INVALID'); }
|
||||
$resolved['extraction']['response_format'] = 'prompt_json';
|
||||
// Dify App owns model generation parameters. Local max_tokens is not transmitted or claimed effective.
|
||||
} else { $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;
|
||||
@@ -126,8 +170,13 @@ final class FollowupAudioProviderConfig
|
||||
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'], $resolved['channel_policy'], $stereoSeconds, $resolved['silence_policy'], 'checkpoint-v2'], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
|
||||
$identity = [$resolved['driver'], $resolved['asr'], $resolved['extraction'],
|
||||
$allowLoopback, $chunkSeconds, 'pcm-s16le-mono-16000-v1', $resolved['output_schema'], $resolved['citation_policy'], $resolved['schema_dialect'], $resolved['channel_policy'], $stereoSeconds, $resolved['silence_policy'], 'checkpoint-v2'];
|
||||
if (($settings['http_transport'] ?? 'curl') !== 'curl') {
|
||||
$resolved['http_transport'] = 'openssl_stream';
|
||||
$identity[] = 'openssl-stream-child-v1';
|
||||
}
|
||||
$resolved['fingerprint'] = hash('sha256', json_encode($identity, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
|
||||
return $resolved;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ final class FollowupAudioStore
|
||||
return $profile === null ? self::verifiedProfiles() !== [] : FollowupAudioProviderConfig::isVerified($profile);
|
||||
}
|
||||
|
||||
public static function ready(?string $profile = null): bool { return FollowupAudioGate::ready($profile); }
|
||||
|
||||
private static function verifiedProfiles(): array
|
||||
{
|
||||
return array_values(array_filter(['qwen', 'openai'], [FollowupAudioProviderConfig::class, 'isVerified']));
|
||||
@@ -43,7 +45,7 @@ final class FollowupAudioStore
|
||||
|
||||
public static function assertEnabled(?string $profile = null): void
|
||||
{
|
||||
if (!self::enabled() || !self::verified($profile)) { throw new DomainException('FOLLOWUP_AUDIO_DISABLED_OR_UNVERIFIED'); }
|
||||
if (!self::enabled() || !self::ready($profile)) { throw new DomainException('FOLLOWUP_AUDIO_DISABLED_OR_UNVERIFIED'); }
|
||||
}
|
||||
|
||||
public static function create(array $upload, string $recordedAt, string $modelKey, int $actor, array $info): array
|
||||
@@ -89,7 +91,7 @@ final class FollowupAudioStore
|
||||
'lease_token' => '', 'lease_until' => 0, 'upstream_started_at' => 0,
|
||||
'upstream_run_id' => '', 'upstream_file_id' => '', 'upstream_ids_json' => FollowupAudioPolicy::canonical([
|
||||
'provider_fingerprint' => FollowupAudioProviderConfig::resolve($modelKey)['fingerprint'],
|
||||
]),
|
||||
] + (FollowupAudioGate::previewOnly() ? ['preview_only' => true] : [])),
|
||||
'error_code' => '', 'error_message' => '', 'extraction_cipher' => '', 'review_cipher' => '', 'applied_cipher' => '',
|
||||
'created_at' => $now, 'updated_at' => $now, 'expires_at' => $expiresAt, 'applied_at' => 0, 'purged_at' => 0,
|
||||
]);
|
||||
@@ -149,7 +151,9 @@ final class FollowupAudioStore
|
||||
$result['error_code'] = 'FOLLOWUP_AUDIO_EXPIRED';
|
||||
$result['error_message'] = '录音及审阅已到保留期限,不能采用';
|
||||
}
|
||||
$result['can_retry'] = self::enabled() && self::verified((string) $task['model_key']) && $alive && $task['status'] === 'failed'
|
||||
$result['preview_only'] = FollowupAudioGate::previewOnly() || FollowupAudioGate::taskPreview($task);
|
||||
$result['can_retry'] = self::enabled() && self::ready((string) $task['model_key'])
|
||||
&& FollowupAudioGate::scopeAllowed((int) $task['diagnosis_id'], (int) $task['actor_id']) && $alive && $task['status'] === 'failed'
|
||||
&& self::providerMatches($task) && self::safeToResume($task) && (int) $task['attempts'] < 3;
|
||||
$pipeline = $alive ? self::pipelineState($task) : [];
|
||||
$segments = $pipeline ? FollowupAudioPipelineCheckpoint::segments($pipeline) : [];
|
||||
@@ -233,6 +237,7 @@ final class FollowupAudioStore
|
||||
$task = self::lockTask($taskId);
|
||||
self::assertEnabled((string) $task['model_key']);
|
||||
self::assertTaskProvider($task);
|
||||
FollowupAudioGate::assertScope((int) $task['diagnosis_id'], (int) $task['actor_id']);
|
||||
if ($task['status'] !== 'failed' || !self::safeToResume($task) || (int) $task['attempts'] >= 3
|
||||
|| (int) $task['expires_at'] <= time() || (int) $task['purged_at']) {
|
||||
throw new DomainException('FOLLOWUP_AUDIO_RETRY_NOT_SAFE');
|
||||
@@ -272,6 +277,7 @@ final class FollowupAudioStore
|
||||
->where('expires_at', '>', $now)->where('purged_at', 0)->order('id', 'asc')->limit(200)->lock(true)->select()->toArray();
|
||||
$task = null;
|
||||
foreach ($pending as $candidate) {
|
||||
if (!FollowupAudioGate::scopeAllowed((int) $candidate['diagnosis_id'], (int) $candidate['actor_id'])) { continue; }
|
||||
if (!self::safeToResume($candidate)) {
|
||||
Db::name('followup_audio_task')->where('id', $candidate['id'])->update([
|
||||
'status' => 'needs_reconciliation', 'stage' => 'needs_reconciliation',
|
||||
@@ -289,7 +295,7 @@ final class FollowupAudioStore
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
if (self::verified((string) $candidate['model_key'])) { $task = $candidate; break; }
|
||||
if (self::ready((string) $candidate['model_key'])) { $task = $candidate; break; }
|
||||
}
|
||||
if ($task === null) { return null; }
|
||||
$changes = ['status' => 'running', 'stage' => 'preparing', 'lease_token' => bin2hex(random_bytes(32)),
|
||||
@@ -339,6 +345,10 @@ final class FollowupAudioStore
|
||||
if (!is_array($ids)) { throw new DomainException('FOLLOWUP_AUDIO_CHECKPOINT_INVALID'); }
|
||||
$previous = json_decode($task['upstream_ids_json'] ?: '{}', true, 16, JSON_THROW_ON_ERROR);
|
||||
foreach ($ids as $name => $identifier) {
|
||||
if ($name === 'preview_only') {
|
||||
if (($previous[$name] ?? null) !== true || $identifier !== true) { throw new DomainException('FOLLOWUP_AUDIO_CHECKPOINT_INVALID'); }
|
||||
continue;
|
||||
}
|
||||
if ($name === 'provider_fingerprint') {
|
||||
if (!is_string($identifier) || !is_string($previous[$name] ?? null)
|
||||
|| !hash_equals($previous[$name], $identifier)) {
|
||||
@@ -547,7 +557,8 @@ final class FollowupAudioStore
|
||||
|
||||
private static function hasLease(array $task, string $token, bool $processing = true): bool
|
||||
{
|
||||
return (!$processing || self::enabled() && self::verified((string) $task['model_key']) && self::providerMatches($task))
|
||||
return (!$processing || self::enabled() && self::ready((string) $task['model_key']) && self::providerMatches($task)
|
||||
&& FollowupAudioGate::scopeAllowed((int) $task['diagnosis_id'], (int) $task['actor_id']))
|
||||
&& $task['status'] === 'running' && $token !== ''
|
||||
&& hash_equals((string) $task['lease_token'], $token) && (int) $task['lease_until'] > time()
|
||||
&& (int) $task['expires_at'] > time() && !(int) $task['purged_at'];
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
namespace app\common\service\followupaudio;
|
||||
|
||||
/** Explicit native-OpenSSL transport. Blocking headers are isolated from the parent's lease/scope heartbeats. */
|
||||
final class FollowupAudioStreamTransport
|
||||
{
|
||||
public static function request(array $spec, callable $heartbeat, int $limit, bool $allowLoopback): array
|
||||
{
|
||||
$failure = static fn (int $errno): array => ['http_code' => 0, 'errno' => $errno, 'body' => ''];
|
||||
$input = $spec;
|
||||
if (isset($input['json'])) { $input['json_wire'] = json_encode($input['json'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); unset($input['json']); }
|
||||
$input['max_response_bytes'] = $limit; $input['allow_loopback_tunnel'] = $allowLoopback;
|
||||
if (isset($input['multipart']['file'])) {
|
||||
$file = $input['multipart']['file'];
|
||||
if (!$file instanceof \CURLFile) { return $failure(43); }
|
||||
$input['multipart']['file'] = ['path' => $file->getFilename(), 'mime' => $file->getMimeType(), 'name' => $file->getPostFilename()];
|
||||
}
|
||||
$wire = json_encode($input, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||
if (strlen($wire) > 16777216 || $limit < 1024 || $limit > 8388608 || ($spec['timeout'] ?? 0) < 1 || $spec['timeout'] > 300) { return $failure(43); }
|
||||
try { if ($heartbeat([]) !== true) { return $failure(42); } } catch (\Throwable $e) { return $failure(42); }
|
||||
$process = @proc_open([PHP_BINARY, __FILE__, '--child'], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
|
||||
if (!is_resource($process)) { return $failure(7); }
|
||||
foreach ($pipes as $pipe) { stream_set_blocking($pipe, false); }
|
||||
$offset = 0; $output = ''; $deadline = microtime(true) + (int) $spec['timeout']; $lastHeartbeat = microtime(true); $exit = -1;
|
||||
try {
|
||||
do {
|
||||
if (isset($pipes[0])) {
|
||||
$written = @fwrite($pipes[0], substr($wire, $offset, 65536));
|
||||
if ($written === false) { return $failure(7); }
|
||||
$offset += $written;
|
||||
if ($offset === strlen($wire)) { fclose($pipes[0]); unset($pipes[0]); }
|
||||
}
|
||||
$output .= (string) stream_get_contents($pipes[1], 65536);
|
||||
stream_get_contents($pipes[2], 65536); // Raw child diagnostics may contain sensitive paths; never expose them.
|
||||
if (strlen($output) > (int) ceil($limit * 4 / 3) + 65536) { return $failure(23); }
|
||||
$status = proc_get_status($process);
|
||||
if (!$status['running']) { $exit = (int) $status['exitcode']; break; }
|
||||
if (microtime(true) >= $deadline) { return $failure(28); }
|
||||
if (microtime(true) - $lastHeartbeat >= 5) {
|
||||
try { $allowed = $heartbeat([]) === true; } catch (\Throwable $e) { $allowed = false; }
|
||||
if (!$allowed) { return $failure(42); }
|
||||
$lastHeartbeat = microtime(true);
|
||||
}
|
||||
usleep(10000);
|
||||
} while (true);
|
||||
$output .= (string) stream_get_contents($pipes[1]);
|
||||
if ($exit !== 0 || strlen($output) > (int) ceil($limit * 4 / 3) + 65536) { return $failure(7); }
|
||||
$result = json_decode($output, true);
|
||||
if (!is_array($result) || !is_string($result['body_base64'] ?? null)) { return $failure(7); }
|
||||
$body = base64_decode($result['body_base64'], true);
|
||||
if ($body === false || strlen($body) > $limit) { return $failure(23); }
|
||||
return ['http_code' => (int) ($result['http_code'] ?? 0), 'errno' => (int) ($result['errno'] ?? 7), 'body' => $body];
|
||||
} finally {
|
||||
if (is_resource($process)) { $status = proc_get_status($process); if ($status['running']) { proc_terminate($process, 9); } }
|
||||
foreach ($pipes as $pipe) { if (is_resource($pipe)) { fclose($pipe); } }
|
||||
if (is_resource($process)) { proc_close($process); }
|
||||
}
|
||||
}
|
||||
|
||||
/** CLI child reads a private specification from stdin only: never credentials in argv or logs. */
|
||||
private static function child(array $spec): array
|
||||
{
|
||||
$result = ['http_code' => 0, 'errno' => 43, 'body_base64' => ''];
|
||||
$url = $spec['url'] ?? ''; $parts = is_string($url) ? parse_url($url) : false;
|
||||
$stage = $spec['stage'] ?? ''; $timeout = $spec['timeout'] ?? 0; $limit = $spec['max_response_bytes'] ?? 0;
|
||||
if (!is_array($parts) || !in_array($stage, ['asr', 'extraction'], true) || !is_int($timeout) || $timeout < 1 || $timeout > 300
|
||||
|| !is_int($limit) || $limit < 1024 || $limit > 8388608 || empty($parts['host']) || isset($parts['user']) || isset($parts['pass'])
|
||||
|| isset($parts['query']) || isset($parts['fragment']) || preg_match('/[\x00-\x20\x7f\\\\]/', $url)
|
||||
|| !is_string($spec['api_key'] ?? null) || $spec['api_key'] === '' || preg_match('/[\x00-\x20\x7f]/', $spec['api_key'])) { return $result; }
|
||||
$secure = ($parts['scheme'] ?? '') === 'https';
|
||||
$loopback = ($spec['allow_loopback_tunnel'] ?? false) === true && ($parts['scheme'] ?? '') === 'http'
|
||||
&& in_array($parts['host'], ['127.0.0.1', '[::1]'], true);
|
||||
if (!$secure && !$loopback) { return $result; }
|
||||
$allowed = $stage === 'asr' ? ['/audio-to-text', '/audio/transcriptions'] : ['/chat-messages', '/chat/completions'];
|
||||
$matches = array_filter($allowed, static fn (string $suffix): bool => str_ends_with((string) ($parts['path'] ?? ''), $suffix));
|
||||
if ($matches === []) { return $result; }
|
||||
$headers = ['Accept: application/json', 'Authorization: Bearer ' . $spec['api_key'], 'Connection: close'];
|
||||
if (isset($spec['json_wire']) && !isset($spec['multipart']) && $stage === 'extraction' && is_string($spec['json_wire'])
|
||||
&& is_object(json_decode($spec['json_wire']))) {
|
||||
$body = $spec['json_wire'];
|
||||
$headers[] = 'Content-Type: application/json';
|
||||
} elseif (isset($spec['multipart']) && !isset($spec['json_wire']) && $stage === 'asr' && is_array($spec['multipart'])) {
|
||||
$multipart = $spec['multipart']; $file = $multipart['file'] ?? null;
|
||||
if (!is_array($file) || !is_string($file['path'] ?? null) || !is_file($file['path']) || is_link($file['path'])
|
||||
|| !is_readable($file['path']) || filesize($file['path']) < 44 || filesize($file['path']) > 8388608
|
||||
|| ($file['mime'] ?? '') !== 'audio/wav' || ($file['name'] ?? '') !== 'chunk.wav'
|
||||
|| array_diff(array_keys($multipart), ['file', 'user', 'model', 'response_format'])) { return $result; }
|
||||
$audio = file_get_contents($file['path']);
|
||||
if (!is_string($audio) || substr($audio, 0, 4) !== 'RIFF' || substr($audio, 8, 4) !== 'WAVE') { return $result; }
|
||||
$boundary = 'fa-' . bin2hex(random_bytes(16)); $body = '';
|
||||
foreach ($multipart as $name => $value) {
|
||||
if ($name === 'file') { continue; }
|
||||
if (!is_string($value) || strlen($value) > 256 || preg_match('/[\x00-\x20\x7f]/', $value)) { return $result; }
|
||||
$body .= '--' . $boundary . "\r\nContent-Disposition: form-data; name=\"" . $name . "\"\r\n\r\n" . $value . "\r\n";
|
||||
}
|
||||
$body .= '--' . $boundary . "\r\nContent-Disposition: form-data; name=\"file\"; filename=\"chunk.wav\"\r\nContent-Type: audio/wav\r\n\r\n" . $audio . "\r\n--" . $boundary . "--\r\n";
|
||||
$headers[] = 'Content-Type: multipart/form-data; boundary=' . $boundary;
|
||||
} else { return $result; }
|
||||
if (strlen($body) > 16777216) { return $result; }
|
||||
$headers[] = 'Content-Length: ' . strlen($body);
|
||||
$context = stream_context_create(['http' => ['method' => 'POST', 'header' => implode("\r\n", $headers), 'content' => $body,
|
||||
'timeout' => $timeout, 'ignore_errors' => true, 'follow_location' => 0, 'max_redirects' => 0, 'protocol_version' => 1.1],
|
||||
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true, 'allow_self_signed' => false, 'peer_name' => trim($parts['host'], '[]'), 'SNI_enabled' => true]]);
|
||||
$stream = @fopen($url, 'rb', false, $context);
|
||||
if (!is_resource($stream)) { $result['errno'] = 35; return $result; }
|
||||
try {
|
||||
foreach ($http_response_header ?? [] as $header) {
|
||||
if (preg_match('/^HTTP\/\S+\s+(\d{3})/', $header, $match)) { $result['http_code'] = (int) $match[1]; }
|
||||
}
|
||||
$response = '';
|
||||
while (!feof($stream)) {
|
||||
$bytes = @fread($stream, min(65536, $limit - strlen($response) + 1));
|
||||
if ($bytes === false) { $result['errno'] = 56; return $result; }
|
||||
$response .= $bytes;
|
||||
if (strlen($response) > $limit) { $result['errno'] = 23; return $result; }
|
||||
if (stream_get_meta_data($stream)['timed_out']) { $result['errno'] = 28; return $result; }
|
||||
if ($bytes === '' && !feof($stream)) { usleep(10000); }
|
||||
}
|
||||
$result['errno'] = 0; $result['body_base64'] = base64_encode($response); return $result;
|
||||
} finally { fclose($stream); }
|
||||
}
|
||||
|
||||
public static function childMain(): void
|
||||
{
|
||||
$result = ['http_code' => 0, 'errno' => 43, 'body_base64' => ''];
|
||||
try {
|
||||
$wire = stream_get_contents(STDIN, 16777217);
|
||||
$spec = is_string($wire) && strlen($wire) <= 16777216 ? json_decode($wire, true) : null;
|
||||
if (is_array($spec)) { $result = self::child($spec); }
|
||||
} catch (\Throwable $error) { /* Do not emit request data or native TLS diagnostics. */ }
|
||||
echo json_encode($result, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
if (PHP_SAPI === 'cli' && ($argv[1] ?? '') === '--child' && realpath((string) ($argv[0] ?? '')) === __FILE__) {
|
||||
FollowupAudioStreamTransport::childMain();
|
||||
}
|
||||
@@ -25,10 +25,10 @@ final class FollowupAudioWorker
|
||||
$started = (int) ($task['upstream_started_at'] ?? 0) > 0;
|
||||
try {
|
||||
FollowupAudioStore::assertTaskProvider($task);
|
||||
if (!FollowupAudioStore::verified((string) $task['model_key'])) { throw new FollowupAudioException('AUDIO_NOT_VERIFIED'); }
|
||||
if (!FollowupAudioStore::ready((string) $task['model_key'])) { throw new FollowupAudioException('AUDIO_NOT_VERIFIED'); }
|
||||
$heartbeat = function (array $fields = []) use ($task, $id, $token, &$started): bool {
|
||||
FollowupAudioStore::assertTaskProvider($task);
|
||||
if (!FollowupAudioStore::enabled() || !FollowupAudioStore::verified((string) $task['model_key'])) { return false; }
|
||||
if (!FollowupAudioStore::enabled() || !FollowupAudioStore::ready((string) $task['model_key'])) { return false; }
|
||||
$actor = PrescriptionAiAccess::actor((int) $task['actor_id']);
|
||||
if (!$actor) { return false; }
|
||||
FollowupAudioAccess::task($id, (int) $task['actor_id'], $actor);
|
||||
|
||||
Reference in New Issue
Block a user