feat: add scoped read-only audio preview and isolated Dify transport

This commit is contained in:
2026-10-09 16:10:59 +08:00
parent 27772bec61
commit 4d0af7f3f4
36 changed files with 1517 additions and 68 deletions
+27
View File
@@ -2,6 +2,10 @@
# Only dify may reuse existing [prescription_ai] base/key. openai_audio needs MODEL; asr_then_llm needs both stage model/key/base triples.
[followup_audio]
ENABLED = false
PREVIEW_ONLY = false
# IDs only. Empty or invalid diagnosis allowlist denies preview; admin list never replaces RBAC.
TEST_DIAGNOSIS_IDS =
TEST_ADMIN_IDS =
AUDIO_VERIFIED = false
VERIFIED_PROFILES =
PROFILE = qwen
@@ -66,3 +70,26 @@ OPENAI_EXTRACTION_MAX_TOKENS = 8192
# The local Qwen run exhausted 8192 tokens with thinking=true; false produced JSON, not a quality pass.
# QWEN_EXTRACTION_ENABLE_THINKING = false
# OPENAI_EXTRACTION_ENABLE_THINKING = false
# Synthetic connection readiness only; never sets AUDIO_VERIFIED or allows clinical adoption.
QWEN_PREVIEW_VERIFIED_FINGERPRINT =
OPENAI_PREVIEW_VERIFIED_FINGERPRINT =
# Explicit per-stage protocol. No automatic fallback. Dify uses dedicated App keys only.
QWEN_ASR_PROTOCOL = openai
QWEN_ASR_BINDING_REVISION =
QWEN_EXTRACTION_PROTOCOL = openai
QWEN_EXTRACTION_BINDING_REVISION =
OPENAI_ASR_PROTOCOL = openai
OPENAI_ASR_BINDING_REVISION =
OPENAI_EXTRACTION_PROTOCOL = openai
OPENAI_EXTRACTION_BINDING_REVISION =
# For Dify: ASR_PROTOCOL=dify, EXTRACTION_PROTOCOL=dify_chat, RESPONSE_FORMAT=prompt_json.
# Every Dify stage needs a nonempty BINDING_REVISION. MODEL is expected identity, not sent.
# Dify App owns generation parameters: local max_tokens/response_format/thinking are NOT forwarded.
# Leave EXTRACTION_ENABLE_THINKING absent for Dify; set generation limits in the dedicated App.
# Changing that App requires a new binding revision and fresh preview fingerprint acceptance.
# asr_then_llm only: curl (default) or openssl_stream (explicit verified-TLS child process).
# No automatic transport fallback; changing this requires new fingerprint readiness.
HTTP_TRANSPORT = curl
@@ -7,6 +7,7 @@ namespace app\adminapi\logic\tcm;
use app\common\service\followupaudio\FollowupAudioAccess as Access;
use app\common\service\followupaudio\FollowupAudioApply as Apply;
use app\common\service\followupaudio\FollowupAudioFields as Fields;
use app\common\service\followupaudio\FollowupAudioGate as Gate;
use app\common\service\followupaudio\FollowupAudioProviderConfig as ProviderConfig;
use app\common\service\followupaudio\FollowupAudioStore as Store;
use app\common\service\followupaudio\FollowupAudioUpload as Upload;
@@ -16,15 +17,19 @@ final class FollowupAudioLogic
{
public static function capabilities(int $diagnosisId, int $actor, array $info): array
{
$diagnosis = Access::diagnosis($diagnosisId, $actor, $info);
$enabled = Store::enabled();
$diagnosis = Access::diagnosis($diagnosisId, $actor, $info, false, false);
$scope = Gate::scopeAllowed($diagnosisId, $actor);
$preview = Gate::previewOnly();
$ready = Store::ready();
$enabled = Store::enabled() && $scope;
$verified = Store::verified();
$daily = Access::canDaily($actor, $info);
return [
'enabled' => $enabled, 'audio_verified' => $verified,
'can_upload' => $enabled && $verified, 'can_apply' => $enabled && $verified,
'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_daily' => $daily, 'limits' => Upload::limits(),
'models' => ProviderConfig::publicModels(),
'models' => $enabled ? ProviderConfig::readyModels() : [],
// No migration/dictionary dependency is introduced when the feature is OFF.
'fields' => $enabled ? self::catalogForActor($diagnosis, $actor, $info) : [],
];
@@ -35,7 +40,7 @@ final class FollowupAudioLogic
if (!Store::enabled()) {
throw new DomainException('回访录音功能尚未启用');
}
if ($verified && !Store::verified()) {
if ($verified && !Store::ready()) {
throw new DomainException('当前服务与模型的音频能力尚未验证,暂不接受真实录音或写入');
}
}
@@ -53,7 +58,7 @@ final class FollowupAudioLogic
if (!$date || $date->format('Y-m-d H:i:s') !== $p['recorded_at'] || $date->getTimestamp() > time() + 300) {
throw new DomainException('请填写正确的实际通话时间(北京时间),不能晚于当前时间');
}
if (!in_array($p['model_key'], ['qwen', 'openai'], true) || !Store::verified($p['model_key'])) {
if (!in_array($p['model_key'], ['qwen', 'openai'], true) || !Store::ready($p['model_key'])) {
throw new DomainException('所选模型音频能力尚未验证');
}
return Store::create($upload, $p['recorded_at'], $p['model_key'], $actor, $info);
@@ -81,6 +86,7 @@ final class FollowupAudioLogic
public static function apply(int $id, int $version, array $items, int $actor, array $info): array
{
Gate::assertMayApply();
self::requireEnabled(true);
$task = Access::task($id, $actor, $info);
self::checkDailyItems($task, $items, $actor, $info);
+4 -1
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace app\command;
use app\common\service\followupaudio\FollowupAudioStore;
use app\common\service\followupaudio\FollowupAudioGate;
use app\common\service\followupaudio\FollowupAudioWorker;
use think\console\Command;
use think\console\Input;
@@ -42,7 +43,9 @@ final class FollowupAudioWork extends Command
$worked = $worker->runOnce();
if ($input->getOption('once') || $worked) {
$output->writeln('FOLLOWUP_AUDIO ' . json_encode(['enabled' => FollowupAudioStore::enabled(),
'audio_verified' => FollowupAudioStore::verified(), 'processed' => $worked]));
'audio_verified' => FollowupAudioStore::verified(),
'preview_only' => FollowupAudioGate::previewOnly(),
'preview_ready' => FollowupAudioGate::previewOnly() && FollowupAudioStore::ready(), 'processed' => $worked]));
}
} catch (\Throwable $e) {
$output->writeln('FOLLOWUP_AUDIO storage_or_configuration_error');
@@ -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);
+10
View File
@@ -2,6 +2,10 @@
/** Audio is deliberately OFF until the synthetic short/15m/1h gate passes for the current app. */
return [
'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', ''),
'test_admin_ids' => env('followup_audio.TEST_ADMIN_IDS', ''),
'enabled' => filter_var(env('followup_audio.ENABLED', false), FILTER_VALIDATE_BOOLEAN),
'audio_verified' => filter_var(env('followup_audio.AUDIO_VERIFIED', false), FILTER_VALIDATE_BOOLEAN),
// Each selected application must separately pass all three synthetic fixtures.
@@ -15,6 +19,7 @@ return [
'chunk_bytes' => 2097152,
'retention_days' => 90,
'lease_seconds' => 600,
'http_transport' => (string) env('followup_audio.HTTP_TRANSPORT', 'curl'),
'request_timeout' => (int) env('followup_audio.REQUEST_TIMEOUT', 240),
'concurrency' => max(1, min(8, (int) env('followup_audio.CONCURRENCY', 1))),
// Keep one stable key across web/worker nodes; empty reuses the existing prescription AI key.
@@ -46,11 +51,15 @@ return [
// Explicit server-only two-stage identities; no fallback to prescription_ai or another slot.
'allow_loopback_tunnel' => filter_var(env($prefix . 'ALLOW_LOOPBACK_TUNNEL', false), FILTER_VALIDATE_BOOLEAN),
'asr' => [
'protocol' => (string) env($prefix . 'ASR_PROTOCOL', 'openai'),
'binding_revision' => (string) env($prefix . 'ASR_BINDING_REVISION', ''),
'base_url' => (string) env($prefix . 'ASR_BASE_URL', ''),
'api_key' => (string) env($prefix . 'ASR_API_KEY', ''),
'model' => (string) env($prefix . 'ASR_MODEL', ''),
],
'extraction' => [
'protocol' => (string) env($prefix . 'EXTRACTION_PROTOCOL', 'openai'),
'binding_revision' => (string) env($prefix . 'EXTRACTION_BINDING_REVISION', ''),
'base_url' => (string) env($prefix . 'EXTRACTION_BASE_URL', ''),
'api_key' => (string) env($prefix . 'EXTRACTION_API_KEY', ''),
'model' => (string) env($prefix . 'EXTRACTION_MODEL', ''),
@@ -60,6 +69,7 @@ return [
'enable_thinking' => env($prefix . 'EXTRACTION_ENABLE_THINKING', null) === null ? null
: (filter_var(env($prefix . 'EXTRACTION_ENABLE_THINKING'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? 'invalid'),
],
'preview_verified_fingerprint' => (string) env($prefix . 'PREVIEW_VERIFIED_FINGERPRINT', ''),
'verified_fingerprint' => (string) env($prefix . 'VERIFIED_FINGERPRINT', ''),
];
}, ['qwen', 'openai'])),
@@ -0,0 +1,90 @@
<?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\FollowupAudioPipeline as Pipeline;
use app\common\service\followupaudio\FollowupAudioProviderConfig as Provider;
use app\common\service\followupaudio\FollowupAudioStreamTransport as Stream;
use app\common\service\followupaudio\FollowupAudioException as Error;
$dir = sys_get_temp_dir() . '/fa-dify-stage-' . bin2hex(random_bytes(6)); mkdir($dir, 0700);
new think\App(); $db = new PDO('sqlite:' . $dir . '/dictionary.sqlite');
$db->exec('CREATE TABLE zyt_dict_data(id INTEGER PRIMARY KEY,type_value TEXT,status INTEGER,sort INTEGER,name TEXT,value TEXT)');
$manager = new think\DbManager(); $manager->setConfig(['default' => 'sqlite', 'connections' => ['sqlite' => ['type' => 'sqlite', 'database' => $dir . '/dictionary.sqlite', 'prefix' => 'zyt_']]]);
think\Container::getInstance()->instance('think\DbManager', $manager);
$checks = 0; $expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $checks++; };
$reject = static function (callable $call, string $code) use ($expect): void { try { $call(); } catch (Error $e) { $expect($e->errorCode === $code, 'expected ' . $code . ', got ' . $e->errorCode); return; } throw new RuntimeException('Expected ' . $code); };
$socket = stream_socket_server('tcp://127.0.0.1:0', $errno, $error); $port = (int) substr(strrchr(stream_socket_get_name($socket, false), ':'), 1); fclose($socket);
$server = proc_open([PHP_BINARY, '-n', '-S', '127.0.0.1:' . $port, __DIR__ . '/fixtures/followup_audio/dify_pipeline_router.php'],
[0 => ['pipe', 'r'], 1 => ['file', $dir . '/server.stdout', 'a'], 2 => ['file', $dir . '/server.stderr', 'a']], $pipes, null,
array_merge(getenv(), ['FOLLOWUP_AUDIO_DIFY_MOCK_DIR' => $dir])); fclose($pipes[0]);
$bytes = str_repeat(pack('v', 1000), 16000); $wave = $dir . '/source.wav';
file_put_contents($wave, 'RIFF' . pack('V', 36 + strlen($bytes)) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 1, 16000, 32000, 2, 16) . 'data' . pack('V', strlen($bytes)) . $bytes); chmod($wave, 0600);
$settings = ['request_timeout' => 5, 'providers' => ['qwen' => ['driver' => 'asr_then_llm', 'label' => 'Synthetic', 'allow_loopback_tunnel' => true,
'asr' => ['protocol' => 'dify', 'binding_revision' => 'synthetic-asr-v1', 'base_url' => 'http://127.0.0.1:' . $port . '/success/v1', 'api_key' => 'synthetic-dify-key', 'model' => 'expected-asr'],
'extraction' => ['protocol' => 'dify_chat', 'binding_revision' => 'synthetic-chat-v1', 'base_url' => 'http://127.0.0.1:' . $port . '/success/v1', 'api_key' => 'synthetic-dify-key', 'model' => 'expected-llm', 'response_format' => 'prompt_json']]]];
$state = []; $fields = [];
$heartbeat = static function (array $value) use (&$state, &$fields): bool { $fields[] = $value; if (isset($value['pipeline_checkpoint'])) { $state = $value['pipeline_checkpoint']['state']; } return true; };
$taskFor = static fn (array $p): array => ['id' => 1, 'model_key' => 'qwen', 'sha256' => hash_file('sha256', $wave), 'duration_seconds' => 1,
'recorded_at' => '2026-09-29 10:00:00', 'upstream_started_at' => 0, 'upstream_ids_json' => json_encode(['provider_fingerprint' => $p['fingerprint']])];
$tls = null;
try {
for ($i = 0; $i < 100; $i++) { $socket = @stream_socket_client('tcp://127.0.0.1:' . $port, $errno, $error, 0.1); if ($socket) { fclose($socket); break; } usleep(20000); }
foreach (['curl', 'openssl_stream'] as $transport) {
$options = $settings; $options['http_transport'] = $transport; $provider = Provider::resolve('qwen', $options, []); $task = $taskFor($provider); $state = []; $fields = [];
$result = (new Pipeline($options, $provider))->run($wave, $task, $heartbeat);
$expect($result['transcript'] === '今天早晨空腹血糖六点一。' && count($result['items']) === 1, $transport . ' actual Dify stage pipeline');
$expect($state['extraction']['upstream_ids']['message_id'] === 'dify-message-success' && $state['extraction']['upstream_ids']['conversation_id'] === 'dify-conversation-never-reused', 'valid upstream IDs retained');
$rows = array_map(static fn ($line) => json_decode($line, true), file($dir . '/requests.jsonl', FILE_IGNORE_NEW_LINES));
$expect(!in_array(false, array_column($rows, 'valid'), true), 'actual HTTP has Dify-only shape, no local model/generation/conversation leakage');
$before = count($rows); (new Pipeline($options, $provider))->run($wave, $task, $heartbeat, $state);
$expect(count(file($dir . '/requests.jsonl')) === $before, 'known completed Dify results are not resent');
foreach (['reject' => 'ASR_REJECTED', 'unknown' => 'UPSTREAM_UNCERTAIN', 'redirect' => 'UPSTREAM_UNCERTAIN', 'oversize' => 'UPSTREAM_UNCERTAIN'] as $scenario => $code) {
$bad = $options; $bad['providers']['qwen']['asr']['base_url'] = 'http://127.0.0.1:' . $port . '/' . $scenario . '/v1';
if ($scenario === 'oversize') { $bad['max_response_bytes'] = 1024; }
$p = Provider::resolve('qwen', $bad, []); $t = $taskFor($p); $state = []; $fields = [];
$before = count(file($dir . '/requests.jsonl'));
$reject(static fn () => (new Pipeline($bad, $p))->run($wave, $t, $heartbeat), $code);
$expect(count(file($dir . '/requests.jsonl')) === $before + 1, 'no HTTP redirect/protocol/transport fallback on ' . $scenario);
$expect($state['chunks'][0]['state'] === ($scenario === 'reject' ? 'rejected' : 'intent'), 'known rejection vs unresolved intent preserved');
if ($scenario === 'unknown') {
$expect((bool) array_filter($fields, static fn ($value) => isset($value['upstream_run_id']) && $value['upstream_run_id'] === 'unknown-observed-id'), 'unknown response ID retained before error');
$reject(static fn () => (new Pipeline($bad, $p))->run($wave, $t, $heartbeat, $state), 'RECONCILIATION_REQUIRED');
$expect(count(file($dir . '/requests.jsonl')) === $before + 1, 'unknown result is not retried');
}
}
$length = $options; $length['providers']['qwen']['extraction']['base_url'] = 'http://127.0.0.1:' . $port . '/length/v1';
$p = Provider::resolve('qwen', $length, []); $t = $taskFor($p); $state = [];
$reject(static fn () => (new Pipeline($length, $p))->run($wave, $t, $heartbeat), 'UPSTREAM_SCHEMA_INVALID');
$expect($state['extraction']['state'] === 'complete' && $state['extraction']['answer'] === '', 'Dify explicit length finish stays a known completed failure');
}
foreach (['asr', 'extraction'] as $stage) { $bad = $settings; $bad['providers']['qwen'][$stage]['binding_revision'] = ''; $reject(static fn () => Provider::resolve('qwen', $bad, []), 'CONFIG_INVALID'); }
$bad = $settings; $bad['providers']['qwen']['extraction']['response_format'] = 'json_schema'; $reject(static fn () => Provider::resolve('qwen', $bad, []), 'CONFIG_INVALID');
$bad = $settings; $bad['providers']['qwen']['extraction']['enable_thinking'] = true; $reject(static fn () => Provider::resolve('qwen', $bad, []), 'CONFIG_INVALID');
$a = Provider::resolve('qwen', $settings, []); $bad = $settings; $bad['providers']['qwen']['asr']['binding_revision'] = 'synthetic-asr-v2';
$expect(Provider::resolve('qwen', $bad, [])['fingerprint'] !== $a['fingerprint'], 'Dify revision changes identity');
$bad = $settings; $bad['http_transport'] = 'openssl_stream'; $expect(Provider::resolve('qwen', $bad, [])['fingerprint'] !== $a['fingerprint'], 'explicit TLS transport changes identity');
$expect(!isset($a['extraction']['max_tokens'], $a['extraction']['enable_thinking']), 'Dify generation owned by App, not claimed as local effective knobs');
// Verified TLS rejects an untrusted local certificate; no trust store change or verify=false workaround.
$cert = proc_open(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', $dir . '/key.pem', '-out', $dir . '/cert.pem', '-days', '1', '-subj', '/CN=localhost'],
[0 => ['pipe', 'r'], 1 => ['file', $dir . '/cert.stdout', 'a'], 2 => ['file', $dir . '/cert.stderr', 'a']], $p); fclose($p[0]); $expect(proc_close($cert) === 0, 'local test certificate generated');
$socket = stream_socket_server('tcp://127.0.0.1:0', $errno, $error); $tlsPort = (int) substr(strrchr(stream_socket_get_name($socket, false), ':'), 1); fclose($socket);
$tls = proc_open(['openssl', 's_server', '-accept', '127.0.0.1:' . $tlsPort, '-cert', $dir . '/cert.pem', '-key', $dir . '/key.pem', '-quiet', '-www'],
[0 => ['pipe', 'r'], 1 => ['file', $dir . '/tls.stdout', 'a'], 2 => ['file', $dir . '/tls.stderr', 'a']], $p); fclose($p[0]); usleep(200000);
$spec = ['url' => 'https://127.0.0.1:' . $tlsPort . '/v1/chat-messages', 'api_key' => 'synthetic-dify-key', 'timeout' => 3, 'stage' => 'extraction', 'json' => ['inputs' => new stdClass(), 'query' => 'synthetic']];
$reply = Stream::request($spec, static fn (): bool => true, 1024, false);
$expect($reply['errno'] !== 0 && $reply['http_code'] === 0, 'untrusted TLS fails closed');
$spec['url'] = 'http://127.0.0.1:' . $port . '/slow/v1/chat-messages'; $spec['json'] = (new Pipeline($settings, $a))->extractRequest('synthetic', [['id' => 's', 'start_ms' => 0, 'end_ms' => 1000, 'text' => 'synthetic']], '2026-09-29 10:00:00', ['blood' => [['key' => 'systolic_pressure', 'type' => 'number']]]);
$spec['timeout'] = 1; $start = microtime(true); $reply = Stream::request($spec, static fn (): bool => true, 1024, true);
$expect($reply['errno'] === 28 && microtime(true) - $start < 3, 'blocked response headers remain wall-clock bounded');
$spec['timeout'] = 10; $beats = 0; $start = microtime(true);
$reply = Stream::request($spec, static function () use (&$beats): bool { $beats++; return $beats < 2; }, 1024, true);
$expect($reply['errno'] === 42 && $beats === 2 && microtime(true) - $start < 7, 'parent rechecks authorization after5s while child waits for headers');
echo 'FOLLOWUP_AUDIO_DIFY_PIPELINE assertions=' . $checks . ' PASS native_audio_to_text=1 blocking_chat=1 no_fake_generation_args=1 revision_bound=1 curl_and_openssl=1 verified_tls=1 parent_heartbeat=1 body_timeout_limits=1 no_fallback=1 unknown_fenced=1' . PHP_EOL;
} finally {
if (is_resource($tls)) { proc_terminate($tls, 9); proc_close($tls); }
proc_terminate($server, 9); proc_close($server);
foreach (glob($dir . '/*') as $file) { unlink($file); } rmdir($dir);
}
@@ -9,6 +9,8 @@ $access = file_get_contents($root . '/app/common/service/followupaudio/FollowupA
$middleware = file_get_contents($root . '/app/adminapi/http/middleware/AuthMiddleware.php');
$stream = file_get_contents($root . '/app/common/service/followupaudio/FollowupAudioStream.php');
$console = file_get_contents($root . '/config/console.php');
$apply = file_get_contents($root . '/app/common/service/followupaudio/FollowupAudioApply.php');
$gate = file_get_contents($root . '/app/common/service/followupaudio/FollowupAudioGate.php');
$checks = 0;
function expectEndpoint(bool $condition, string $message): void
{
@@ -26,8 +28,13 @@ expectEndpoint(str_contains($controller, 'array_diff(array_keys($params), $allow
expectEndpoint(str_contains($controller, 'count($items) > 500'), 'bounded review items');
expectEndpoint(str_contains($controller, "'code' => 'FOLLOWUP_AUDIO_STALE_REVIEW'"), 'typed stale review response');
expectEndpoint(substr_count($controller, 'Logic::requireEnabled(true)') >= 3, 'upload capability gate');
expectEndpoint(str_contains($logic, "Store::verified(\$p['model_key'])"), 'per-model audio capability gate');
expectEndpoint(str_contains($logic, "'models' => ProviderConfig::publicModels()"), 'only fingerprint-verified server model labels advertised');
expectEndpoint(str_contains($logic, "Store::ready(\$p['model_key'])"), 'per-model mode-aware readiness gate');
expectEndpoint(str_contains($logic, "'models' => \$enabled ? ProviderConfig::readyModels() : []"), 'only scoped fingerprint-ready server labels advertised');
expectEndpoint(str_contains($logic, '$verified = Store::verified()') && str_contains($logic, "'audio_verified' => \$verified"), 'full accuracy verification remains separate');
expectEndpoint(str_contains($logic, "'preview_only' => \$preview") && str_contains($logic, "'can_review' => \$enabled && \$ready"), 'explicit preview and review capabilities');
expectEndpoint(strpos($apply, 'FollowupAudioGate::assertMayApply();') < strpos($apply, '$connection = Db::connect()'), 'preview hard denial before any apply DB access');
expectEndpoint(strpos($apply, 'FollowupAudioGate::assertMayApply($task);') < strpos($apply, "if (\$task['status'] === 'applied')"), 'persistent preview origin denied before idempotent applied path');
expectEndpoint(str_contains($gate, 'FOLLOWUP_AUDIO_PREVIEW_ONLY') && str_contains($gate, 'FOLLOWUP_AUDIO_PREVIEW_SCOPE_DENIED'), 'stable fail-closed preview errors');
expectEndpoint(!str_contains($logic, 'Dify 音频能力') && !str_contains($logic, 'api_key') && !str_contains($logic, 'base_url'),
'provider-neutral public capabilities never expose credentials or addresses');
expectEndpoint(str_contains($logic, "(int) \$upload['diagnosis_id'] !== \$p['diagnosis_id']"), 'upload/diagnosis binding');
+125
View File
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
require __DIR__ . '/fixtures/followup_audio/database.php';
use app\common\service\followupaudio\FollowupAudioGate as Gate;
use app\common\service\followupaudio\FollowupAudioStore as Store;
use app\common\service\followupaudio\FollowupAudioApply as Apply;
use app\common\service\followupaudio\FollowupAudioUpload as Upload;
use app\common\service\followupaudio\FollowupAudioWorker as Worker;
use app\common\service\followupaudio\FollowupAudioDify as Dify;
use app\common\service\followupaudio\FollowupAudioProviderConfig as Provider;
use app\common\service\followupaudio\FollowupAudioPipelineCheckpoint as Checkpoint;
use app\common\service\followupaudio\FollowupAudioTranscriptPrompt as Prompt;
use app\adminapi\logic\tcm\FollowupAudioLogic as Logic;
use think\facade\Db;
if ((int) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PORT') !== 23319) { throw new RuntimeException('Preview tests require dedicated disposable23319'); }
$provider = ['driver' => 'asr_then_llm', 'label' => 'Synthetic preview',
'asr' => ['protocol' => 'dify', 'binding_revision' => 'test-asr-v1', 'base_url' => 'https://synthetic.invalid/v1', 'api_key' => 'synthetic-key', 'model' => 'expected-asr'],
'extraction' => ['protocol' => 'dify_chat', 'binding_revision' => 'test-chat-v1', 'base_url' => 'https://synthetic.invalid/v1', 'api_key' => 'synthetic-key', 'model' => 'expected-llm', 'response_format' => 'prompt_json']];
$f = followupAudioTestDatabase(['preview_only' => true, 'audio_verified' => false, 'verified_profiles' => [], 'test_diagnosis_ids' => '', 'providers' => ['qwen' => $provider]]);
$checks = 0; $calls = ['asr' => 0, 'extraction' => 0]; $active = 0; $mode = 'success';
$expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $checks++; };
$reject = static function (callable $call, string $code) use ($expect): void { try { $call(); } catch (Throwable $e) { $expect(str_contains($e->getMessage(), $code) || ($e->errorCode ?? '') === $code, 'expected ' . $code . ', got ' . $e->getMessage()); return; } throw new RuntimeException('Expected ' . $code); };
$set = static function (array $fields) use ($f): void { $f['config']->set($fields, 'followup_audio'); };
$businessTables = ['zyt_tcm_diagnosis','zyt_tcm_prescription_order','zyt_tcm_blood_record','zyt_patient_diet_record','zyt_patient_exercise_record','zyt_tracking_note','zyt_followup_audio_audit'];
$snapshot = static function () use ($f, $businessTables): string { $rows = []; foreach ($businessTables as $table) { $rows[$table] = $f['pdo']->query('SELECT * FROM `' . $table . '` ORDER BY id')->fetchAll(PDO::FETCH_ASSOC); } return hash('sha256', json_encode($rows)); };
$beforeBusiness = $snapshot();
$connection = Db::connect()->getConfig(); $connection['trigger_sql'] = true;
Db::setConfig(['default' => 'mysql', 'connections' => ['mysql' => $connection]]); Db::connect('mysql', true);
$businessWrites = 0; $featureWrites = 0; $sqlEvents = 0;
Db::listen(static function (string $sql) use (&$businessWrites, &$featureWrites, &$sqlEvents, $businessTables): void {
$sqlEvents++;
if (preg_match('/\b(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+`?(zyt_[a-z0-9_]+)/i', $sql, $match)) {
if (in_array(strtolower($match[1]), $businessTables, true)) { $businessWrites++; }
elseif (str_starts_with(strtolower($match[1]), 'zyt_followup_audio_')) { $featureWrites++; }
}
});
$make = static function () use ($f, &$active): int {
static $index = 0; $index++; $data = str_repeat(pack('vv', 1000 + $index, 2000 + $index), 16000);
$path = $f['private'] . '/preview-' . $index . '.wav';
file_put_contents($path, 'RIFF' . pack('V', 36 + strlen($data)) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 2, 16000, 64000, 4, 16) . 'data' . pack('V', strlen($data)) . $data); chmod($path, 0600);
$upload = followupAudioTestUpload($f, $path, 'synthetic.wav');
$task = Logic::create(['diagnosis_id' => 1, 'upload_id' => $upload['id'], 'recorded_at' => '2026-09-29 10:00:00', 'model_key' => 'qwen'], 1, $f['actor']);
$active = $task['id']; return $active;
};
$transport = static function (array $spec) use (&$active, &$calls, &$mode, $set, $expect): array {
$calls[$spec['stage']]++;
$row = Store::task($active); $state = Store::open($active, 'pipeline', $row['pipeline_cipher']);
$expect(Checkpoint::hasIntent($state) && Gate::taskPreview($row), 'preview origin and intent durable before upstream');
if ($spec['stage'] === 'asr') {
$expect(str_ends_with($spec['url'], '/audio-to-text') && !isset($spec['multipart']['model']) && isset($spec['multipart']['user']), 'actual preview Dify ASR contract');
if ($mode === 'revoke_scope') { $set(['test_diagnosis_ids' => '']); }
if ($mode === 'revoke_admin') { $set(['test_admin_ids' => '2']); }
return ['http_code' => 200, 'errno' => 0, 'body' => json_encode(['text' => '合成:今天早晨空腹血糖六点一。'], JSON_UNESCAPED_UNICODE)];
}
$expect(str_ends_with($spec['url'], '/chat-messages') && !isset($spec['json']['max_tokens'], $spec['json']['model'], $spec['json']['conversation_id']), 'actual preview Dify extraction contract');
$citation = Prompt::citations(Checkpoint::segments($state))[0];
$answer = ['schema_version' => 'followup-audio-transcript-v2', 'summary' => '合成预览', 'uncertainties' => [], 'items' => [[
'kind' => 'blood', 'values' => ['fasting_blood_sugar' => 6.1], 'record_date' => '2026-09-29', 'record_time' => '08:00',
'date_text' => '今天', 'time_text' => '早晨', 'time_period' => null, 'time_estimated' => false, 'needs_review' => true, 'evidence_ids' => [$citation['id']],
]]];
return ['http_code' => 200, 'errno' => 0, 'body' => json_encode(['message_id' => 'preview-dify-message', 'answer' => json_encode($answer, JSON_UNESCAPED_UNICODE)])];
};
$run = static fn (): bool => (new Worker(new Dify($transport)))->runOnce();
try {
foreach (['', 'name-only', '1,,2', '0', '-1', ['1', null], ['id' => 1]] as $ids) {
$set(['test_diagnosis_ids' => $ids]); $cap = Logic::capabilities(1, 1, $f['actor']);
$expect(!$cap['enabled'] && !$cap['test_scope_allowed'] && $cap['models'] === [] && !$cap['can_apply'], 'empty/malformed scope fails closed without breaking capabilities');
$reject(fn () => Upload::createSession(1, 'synthetic.wav', 44, 1, $f['actor']), 'PREVIEW_SCOPE_DENIED');
}
$set(['test_diagnosis_ids' => '1', 'test_admin_ids' => '1']);
$expect(!Store::ready() && !Store::verified(), 'neither preview nor full gate fabricated');
$set(['audio_verified' => true, 'verified_profiles' => ['qwen']]);
$expect(Store::verified() && !Store::ready(), 'full gate cannot substitute missing preview fingerprint');
$set(['audio_verified' => false, 'verified_profiles' => []]);
$slots = config('followup_audio.providers'); $slots['qwen']['preview_verified_fingerprint'] = Provider::resolve('qwen')['fingerprint']; $set(['providers' => $slots]);
$cap = Logic::capabilities(1, 1, $f['actor']);
$expect($cap['enabled'] && $cap['preview_only'] && $cap['preview_ready'] && $cap['test_scope_allowed'] && $cap['can_upload'] && $cap['can_review'] && !$cap['can_apply'] && !$cap['audio_verified'], 'preview upload/review separately ready, clinical apply always false');
$outside = Logic::capabilities(2, 1, $f['actor']);
$expect(!$outside['enabled'] && !$outside['test_scope_allowed'] && $outside['models'] === [] && $outside['fields'] === [], 'other diagnosis normal editor receives disabled audio capabilities');
$reject(fn () => Upload::createSession(2, 'synthetic.wav', 44, 1, $f['actor']), 'PREVIEW_SCOPE_DENIED');
$reject(fn () => Upload::createSession(1, 'synthetic.wav', 44, 2, []), 'PREVIEW_SCOPE_DENIED');
$set(['test_admin_ids' => '']);
$reject(fn () => Upload::createSession(1, 'synthetic.wav', 44, 3, ['root' => 1]), '诊单不存在或无权操作');
$set(['test_admin_ids' => '1']);
$id = $make(); $claim = Store::claim();
$reject(fn () => Store::checkpoint($id, $claim['lease_token'], ['upstream_ids_json' => ['preview_only' => false]]), 'CHECKPOINT_INVALID');
$expect(Gate::taskPreview(Store::task($id)), 'preview-origin snapshot cannot be cleared by callback');
Store::fail($id, $claim['lease_token'], 'SYNTHETIC_LOCAL_CHECK', ''); Store::retry($id);
$expect($run(), 'actual Worker processes ready preview while full verified false');
$detail = Logic::detail($id, 1, $f['actor']);
$expect($detail['status'] === 'review' && $detail['preview_only'] && $detail['channel_roles'] === 'unconfirmed' && $calls === ['asr' => 2, 'extraction' => 1], 'allowed preview reaches transcript/review only');
$items = $detail['items']; $items[0]['selected'] = true; $items[0]['needs_review'] = false;
$saved = Logic::saveDraft($id, $detail['version'], $items, 1, $f['actor'], 'left_service');
$expect($saved['channel_roles'] === 'left_service' && !$saved['items'][0]['selected'], 'preview role and draft remain usable');
$items = $saved['items']; $items[0]['selected'] = true; $items[0]['needs_review'] = false;
$saved = Logic::saveDraft($id, $saved['version'], $items, 1, $f['actor']);
$reject(fn () => Logic::apply($id, $saved['version'], $items, 1, $f['actor']), 'PREVIEW_ONLY');
$reject(fn () => Apply::apply($id, $saved['version'], $items, 1, $f['actor']), 'PREVIEW_ONLY');
$set(['audio_verified' => true, 'verified_profiles' => ['qwen']]);
$reject(fn () => Apply::apply($id, $saved['version'], $items, 1, $f['actor']), 'PREVIEW_ONLY');
$set(['preview_only' => false]);
$expect(Store::verified() && Store::detail($id)['preview_only'], 'origin survives later normal/full-verified configuration');
$reject(fn () => Apply::apply($id, $saved['version'], $items, 1, $f['actor']), 'PREVIEW_ONLY');
Db::name('followup_audio_task')->where('id', $id)->update(['status' => 'applied', 'stage' => 'applied', 'applied_cipher' => Store::seal($id, 'applied', ['items' => []])]);
$reject(fn () => Apply::apply($id, $saved['version'], [], 1, $f['actor']), 'PREVIEW_ONLY');
$set(['preview_only' => true, 'audio_verified' => false, 'verified_profiles' => []]);
$reject(fn () => Apply::apply($id, 999, [], 1, $f['actor']), 'PREVIEW_ONLY');
foreach (['revoke_scope', 'revoke_admin'] as $mode) {
$set(['test_diagnosis_ids' => '1', 'test_admin_ids' => '1']); $unknownId = $make(); $beforeCalls = $calls; $run();
$row = Store::task($unknownId);
$expect($row['status'] === 'needs_reconciliation' && $calls['asr'] === $beforeCalls['asr'] + 1 && $calls['extraction'] === $beforeCalls['extraction'], 'scope/admin revoked after first request stops remaining upstream work');
$state = Store::open($unknownId, 'pipeline', $row['pipeline_cipher']); $expect(Checkpoint::hasIntent($state), 'unknown in-flight outcome retained');
$reject(fn () => Logic::detail($unknownId, 1, $f['actor']), 'PREVIEW_SCOPE_DENIED');
$set(['test_diagnosis_ids' => '1', 'test_admin_ids' => '1']);
$reject(fn () => Store::retry($unknownId), 'RETRY_NOT_SAFE');
$expect(Store::task($unknownId)['pipeline_cipher'] === $row['pipeline_cipher'], 'restoring allowlist does not clear unknown checkpoint');
}
$mode = 'success'; $set(['test_diagnosis_ids' => '1', 'test_admin_ids' => '1']); $queued = $make(); $set(['test_diagnosis_ids' => '']); $beforeCalls = $calls;
$expect(!$run() && Store::task($queued)['status'] === 'queued' && $calls === $beforeCalls, 'revoked queued diagnosis not claimed or sent');
$expect($snapshot() === $beforeBusiness && $businessWrites === 0 && $featureWrites > 0 && $sqlEvents > 0, 'actual SQL listener: feature writes allowed, all clinical/audit tables zero writes and unchanged');
echo 'FOLLOWUP_AUDIO_PREVIEW assertions=' . $checks . ' PASS mysql23319=1 full_audio_verified_not_faked=1 allowlist_fail_closed=1 rbac=1 preview_review=1 clinical_writes=0 apply_root_direct_applied_denied=1 origin_permanent=1 revoke_stops_upstream=1 unknown_preserved=1' . PHP_EOL;
} finally { followupAudioTestDatabaseCleanup($f); }
@@ -27,7 +27,7 @@ namespace {
return $value;
}
$root = dirname(__DIR__) . '/app/common/service/followupaudio/';
foreach (['Exception', 'ProviderConfig', 'Store'] as $class) { require $root . 'FollowupAudio' . $class . '.php'; }
foreach (['Exception', 'ProviderConfig', 'Gate', 'Store'] as $class) { require $root . 'FollowupAudio' . $class . '.php'; }
require dirname(__DIR__) . '/app/adminapi/logic/tcm/FollowupAudioLogic.php';
use app\common\service\followupaudio\FollowupAudioStore as Store;
use app\common\service\followupaudio\FollowupAudioProviderConfig as Providers;
@@ -11,7 +11,7 @@ use app\common\service\followupaudio\FollowupAudioTranscriptPrompt as Prompt;
use app\common\service\followupaudio\FollowupAudioPolicy as Policy;
use think\facade\Db;
if ((int) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PORT') !== 23317) { throw new RuntimeException('Stereo test requires its separate disposable23317'); }
if (!in_array((int) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PORT'), [23317, 23319], true)) { throw new RuntimeException('Stereo test requires a dedicated disposable test port'); }
$f = followupAudioTestDatabase(['asr_stereo_chunk_seconds' => 2]);
$checks = 0; $calls = ['asr' => 0, 'extraction' => 0]; $active = 0; $mode = 'success';
$expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $checks++; };
@@ -117,5 +117,5 @@ try {
$legacyAppliedId = $make(1); $oldAppliedCipher = Store::seal($legacyAppliedId, 'applied', ['items' => [['kind' => 'blood', 'record_id' => 999]]]);
Db::name('followup_audio_task')->where('id', $legacyAppliedId)->update(['status' => 'applied', 'stage' => 'applied', 'applied_cipher' => $oldAppliedCipher]);
$expect(Store::detail($legacyAppliedId)['applied_items'] === [['kind' => 'blood', 'record_id' => 999]] && Store::task($legacyAppliedId)['applied_cipher'] === $oldAppliedCipher, 'old applied results unchanged');
echo 'FOLLOWUP_AUDIO_STEREO_DATABASE assertions=' . $checks . ' PASS mysql23317=1 actual_worker=1 both_channels=1 role_scope_version=1 role_change_clears=1 apply_role_required=1 immutable_channels=1 audit_annotation=1 unknown_fenced=1 local_silence=1 legacy_unchanged=1' . PHP_EOL;
echo 'FOLLOWUP_AUDIO_STEREO_DATABASE assertions=' . $checks . ' PASS mysql' . (int) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PORT') . '=1 actual_worker=1 both_channels=1 role_scope_version=1 role_change_clears=1 apply_role_required=1 immutable_channels=1 audit_annotation=1 unknown_fenced=1 local_silence=1 legacy_unchanged=1' . PHP_EOL;
} finally { followupAudioTestDatabaseCleanup($f); }
+1
View File
@@ -17,6 +17,7 @@ namespace app\common\service\followupaudio {
public static bool $providerMatches = true;
public static function enabled(): bool { return self::$enabled; }
public static function verified(?string $profile = null): bool { return self::$verified; }
public static function ready(?string $profile = null): bool { return self::$verified; }
public static function claim(): ?array { self::$events[] = 'claim'; return self::$verified ? ['id' => 1, 'actor_id' => 1, 'diagnosis_id' => 1, 'lease_token' => 'test', 'model_key' => 'qwen'] : null; }
public static function assertTaskProvider(array $task): void { if (!self::$providerMatches) { throw new FollowupAudioException('PROVIDER_CONFIGURATION_CHANGED'); } }
public static function heartbeat(int $id, string $token): bool { self::$events[] = 'heartbeat'; return self::$lease; }
@@ -0,0 +1,34 @@
<?php
$directory = getenv('FOLLOWUP_AUDIO_DIFY_MOCK_DIR');
$uri = $_SERVER['REQUEST_URI'];
$audio = str_ends_with($uri, '/audio-to-text'); $chat = str_ends_with($uri, '/chat-messages');
$valid = ($_SERVER['HTTP_AUTHORIZATION'] ?? '') === 'Bearer synthetic-dify-key';
if ($audio) {
$valid = $valid && array_keys($_POST) === ['user'] && preg_match('/^fa-opaque-[a-f0-9]{48}$/D', $_POST['user'] ?? '')
&& isset($_FILES['file']) && ($_FILES['file']['type'] ?? '') === 'audio/wav' && is_file($_FILES['file']['tmp_name']);
$meta = ['stage' => 'asr', 'valid' => (bool) $valid, 'uri' => $uri, 'bytes' => isset($_FILES['file']) ? filesize($_FILES['file']['tmp_name']) : 0];
} elseif ($chat) {
$raw = file_get_contents('php://input'); $object = json_decode($raw); $body = json_decode($raw, true);
$valid = $valid && is_object($object->inputs ?? null) && (array) $object->inputs === [] && is_string($body['query'] ?? null)
&& ($body['response_mode'] ?? '') === 'blocking' && ($body['auto_generate_name'] ?? null) === false
&& preg_match('/^fa-opaque-[a-f0-9]{48}$/D', $body['user'] ?? '')
&& array_diff(array_keys($body), ['inputs', 'query', 'response_mode', 'user', 'auto_generate_name']) === [];
$meta = ['stage' => 'extraction', 'valid' => (bool) $valid, 'uri' => $uri];
} else { http_response_code(404); echo '{}'; return; }
file_put_contents($directory . '/requests.jsonl', json_encode($meta) . "\n", FILE_APPEND);
header('Content-Type: application/json');
if (!$valid) { http_response_code(400); echo '{"code":"bad_contract"}'; return; }
if (str_contains($uri, '/slow/')) { sleep(12); }
if (str_contains($uri, '/redirect/')) { http_response_code(302); header('Location: /success/v1/audio-to-text'); echo '{}'; return; }
if (str_contains($uri, '/reject/')) { http_response_code(429); echo '{"message_id":"known-reject-id"}'; return; }
if (str_contains($uri, '/unknown/')) { http_response_code(500); echo '{"message_id":"unknown-observed-id"}'; return; }
if (str_contains($uri, '/oversize/')) { echo json_encode(['text' => str_repeat('x', 200000)]); return; }
if ($audio) { echo json_encode(['text' => '今天早晨空腹血糖六点一。'], JSON_UNESCAPED_UNICODE); return; }
preg_match('/c_[a-f0-9]{16,64}/', $body['query'], $match);
$answer = ['schema_version' => 'followup-audio-transcript-v2', 'summary' => '合成摘要', 'uncertainties' => [], 'items' => [[
'kind' => 'blood', 'values' => ['fasting_blood_sugar' => 6.1], 'record_date' => '2026-09-29', 'record_time' => null,
'date_text' => '今天', 'time_text' => '早晨', 'time_period' => '早晨', 'time_estimated' => true, 'needs_review' => true,
'evidence_ids' => [$match[0]],
]]];
echo json_encode(['event' => 'message', 'message_id' => 'dify-message-success', 'task_id' => 'dify-task-success', 'conversation_id' => 'dify-conversation-never-reused',
'answer' => json_encode($answer, JSON_UNESCAPED_UNICODE), 'metadata' => ['finish_reason' => str_contains($uri, '/length/') ? 'length' : 'stop']]);