feat: add scoped read-only audio preview and isolated Dify transport
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user