feat: configure audio providers and add local sample acceptance
This commit is contained in:
@@ -2,11 +2,11 @@
|
||||
|
||||
## 当前交付边界
|
||||
|
||||
源码在隔离工作副本 `/Users/long/.codex/worktrees/followup-audio/zyt`。原目录 `/Users/long/Work/zyt` 的已有源码保持不变;未提交、未推送、未迁移生产、未部署。入口默认关闭。
|
||||
源码在隔离工作副本 `/Users/long/.codex/worktrees/followup-audio/zyt`。原目录 `/Users/long/Work/zyt` 的已有源码保持不变;功能位于 `codex/followup-audio` 分支,初始提交 `70be2fc70`。未推送、未迁移生产、未部署。入口默认关闭。
|
||||
|
||||
已完成网页患者编辑入口、诊单绑定私有分片上传、独立持久异步任务、Dify原始音频传递、可编辑审阅草稿、显式人工确认、多表原子写入、同日真实记录明细、历史跟踪备注、来源审核轨迹及90天清理。
|
||||
|
||||
**真实模型门禁仍未通过**:本地 qwen/openai 探针都真实执行并返回 `CONFIG_MISSING`,没有上传录音、没有产生已知模型调用。只能说明隔离环境未配置,不能推断生产Dify是否支持。媒体测试中的完整一小时WAV→MP3、HTTP回环模型和浏览器合成数据不是实际Dify识别验收;混合口音需代表性脱敏样例。
|
||||
**真实模型门禁仍未通过**:9月29日本地 Dify 探针返回 `CONFIG_MISSING`;10月7日已按用户指定兼容接口发起合成音频检查,返回 `AUDIO_NOT_PROCESSED`,详见文末。不能把 HTTP 200、上传成功或文本能力当成音频识别成功。媒体测试中的完整一小时WAV→MP3、HTTP回环模型和浏览器合成数据不是实际Dify识别验收;混合口音需代表性脱敏样例。
|
||||
|
||||
## 使用与数据规则
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
## 配置与运行前检查
|
||||
|
||||
1. 核对实际数据库字段/表前缀,先在线备份;经发布授权后应用 `server/sql/2026_09_29_followup_audio.sql`。SQL使用现有 `zyt_` 前缀,可重复执行;新增四表及血糖时间来源字段,不改旧数据。不得把测试库模拟schema当作生产已核验。
|
||||
2. 使用 `server/.env.followup-audio.example`,继续复用现有 `[prescription_ai]` base URL/profile凭据。不要把生产数据库凭据复制到本地测试环境,不要把API密钥放前端或提交Git。
|
||||
2. 使用 `server/.env.followup-audio.example` 配置明确的驱动、模型和凭据。`dify` 可复用现有 `[prescription_ai]` base URL/profile凭据;`openai_audio` 必须显式配置自己的地址、密钥和模型。不会自动切换协议/供应商或回退为纯文本。不要把生产数据库凭据复制到本地测试环境,不要把API密钥放前端或提交Git。
|
||||
3. PHP8.2已做本地验证;需要curl/fileinfo/openssl、可执行FFmpeg/FFprobe。Web/worker用户都必须可读写同一个私有录音目录,目录不能放在public或公开存储域名下。部署多节点需共享私有存储和稳定加密密钥(至少32字符,离线备份),不要依赖各节点自动生成不同runtime key。
|
||||
4. 反向代理/PHP单请求上限至少容纳2MiB分片及表单开销;不要将500MiB整个请求交给PHP上传。短期处理副本也要计入磁盘容量。
|
||||
5. Dify存储副本/缓存的保留与删除策略另行核验;本系统90天删除不代表Dify侧已删除。现实现没有通用Dify远端删除凭证或删除API,不能作此承诺。
|
||||
@@ -44,7 +44,7 @@ php think followup-audio:probe --synthetic --manifest /private/tmp/zyt-followup-
|
||||
|
||||
必须核实真实音频被读取、首尾事实和数值/否定/时间关系、未截断长录音,再用获准的代表性脱敏口音样例人工回听评估。上传成功、HTTP200或纯文本回复都不是通过。探针有状态文件:已通过的case复用;结果未知先核对原Dify任务/账单,禁止自动重发。不要删除未知状态来强制重试。
|
||||
|
||||
只有验证通过的profile可加入 `VERIFIED_PROFILES`;人工核验通过后才设置 `AUDIO_VERIFIED=true`,经授权发布后再设 `ENABLED=true`。默认 `timestamp_verified=false`,不把LLM编出的毫秒位置当成ASR对齐。
|
||||
只有验证通过的profile可加入 `VERIFIED_PROFILES`,且必须将本次报告的 `application_fingerprint` 写入对应 `QWEN_VERIFIED_FINGERPRINT` / `OPENAI_VERIFIED_FINGERPRINT`;人工核验通过后才设置 `AUDIO_VERIFIED=true`,经授权发布后再设 `ENABLED=true`。默认 `timestamp_verified=false`,不把LLM编出的毫秒位置当成ASR对齐。
|
||||
|
||||
## 独立消费者、清理与故障处理
|
||||
|
||||
@@ -87,3 +87,47 @@ node admin/scripts/verify-daily-records.cjs
|
||||
- 饮食早餐/午餐/晚餐提炼别名映射到实际`*_foods`字段,审阅读回、审计、现有日常表单编辑和提交保持一致,未选餐食及图片不清空;CSV合法值`0`不再被`empty()`抹去;医院诊断选项使用现有页面的三个静态枚举。
|
||||
- 补测通过30条规则、16条防守和5条跨层检查,以及真实隔离MySQL113项检查。9个模型语义案例仍标注NOT_VERIFIED;真实Dify最新探针仍CONFIG_MISSING。不得把这些本地测试描述为真实ASR、方言或一小时录音识别已通过。
|
||||
- 本次完整补测账本见工作副本`artifacts/followup-audio-implementation/extraction-acceptance-20260929.md`和`.json`,保留修复前结果、修复后结果及独立回滚证据。主四角色路径保持不变,本次记录追加到原验证记录。
|
||||
|
||||
|
||||
## 2026-10-07:真实样本、本地验收与可配置模型
|
||||
|
||||
### 本轮实际结果与边界
|
||||
|
||||
- 用户提供的4段MP3均可读取,长度分别约10分52秒、12分25秒、6分54秒、11分39秒。真实 Upload 服务完成分片、重复分片幂等、私有重组、全量 FFmpeg 解码;原始 SHA256 全部不变。验收用本地一次性 SQLite 和合成授权,不代表生产 HTTP/RBAC 验收。
|
||||
- 新的 opt-in `server/tests/FollowupAudioSampleAcceptanceTest.php` 读取 Git 外0600私有manifest;无参数安全跳过。4段录音与44个独立合成日期候选合计287项检查通过。模拟通话日期为2026-10-07、2026-03-01、2026-01-01、2024-03-01,覆盖普通日、跨月、跨年、闰年和模糊日期;这些候选不是从患者录音识别出来的内容。
|
||||
- 用户指定的兼容接口 `/models` 可访问。合成短音频按 `input_audio` 发给 `gpt-6.1-sol` 得到 HTTP200,但明确回复未收到可读取音频;`/audio/transcriptions` 请求返回404。新实现的原生能力探针再次以结构化输出契约执行,退出2、`AUDIO_NOT_PROCESSED`。未继续发送15分钟/一小时样本,没有发送4段真实患者录音,没有写入任何正式诊单。
|
||||
- HTTPS 实测证书与域名不匹配。没有关闭证书校验。HTTP只用于明确获准的合成检查,正式患者处理强制HTTPS,不能靠开启布尔配置绕过。
|
||||
- 上述结果说明目前所测入口/模型不能完成本功能的音频识别,不等于该服务所有未测模型均不支持。纯文本提炼不能替代语音转写。
|
||||
- 音频请求格式参考[OpenAI官方音频输入说明](https://developers.openai.com/api/docs/guides/audio-chat-completions),实际服务能力以本轮请求证据为准。
|
||||
|
||||
### 模型配置(服务端环境变量,不在客服页面暴露凭据)
|
||||
|
||||
当前沿用 `qwen` / `openai` 两个稳定逻辑槽位;槽位不再决定固定品牌/模型,前端显示服务端配置的标签。示例:
|
||||
|
||||
```ini
|
||||
[followup_audio]
|
||||
ENABLED = false
|
||||
AUDIO_VERIFIED = false
|
||||
VERIFIED_PROFILES =
|
||||
PROFILE = openai
|
||||
OPENAI_DRIVER = openai_audio
|
||||
OPENAI_BASE_URL = https://HOST/v1
|
||||
OPENAI_API_KEY =
|
||||
OPENAI_MODEL = MODEL
|
||||
OPENAI_LABEL = 回访分析模型
|
||||
OPENAI_VERIFIED_FINGERPRINT =
|
||||
ALLOW_INSECURE_SYNTHETIC = false
|
||||
```
|
||||
|
||||
- API_KEY只通过部署环境或Git忽略的本地`.env`注入;本轮本地`.env`权限0600,未打包/提交。正式配置不要使用文档占位值。
|
||||
- `DRIVER=dify`选择Dify应用,内部使用哪个模型需在Dify端配置;本地MODEL字段不能改变Dify应用模型。`DRIVER=openai_audio`会将MODEL实际发送到Chat Completions,必须是该服务真正支持音频输入的模型。
|
||||
- 地址、密钥、driver、model任一变化都会使验证指纹失效;修改配置后重启消费者并重新验证。标签变化不改变提供方身份。不要保留旧指纹作为新模型的验收凭证。
|
||||
- 新任务将选择时的提供方指纹保存到现有`upstream_ids_json`,不增加数据库schema。消费者、心跳、checkpoint和完成均校验;旧任务缺少指纹或配置漂移不会静默改走新服务。已有结果不自动重分析,相同内容防重规则保持。
|
||||
- 探针状态保存为 `probe-<profile>-<fingerprint>.json`,旧身份报告保留。未知/已开始的同身份请求不自动重发;失败先核对已有请求ID及响应。不能删除报告来强制重试。
|
||||
- 需要先修复HTTPS并提供支持音频的模型/转写能力,然后完成短/中/长门禁,再对代表性真实录音人工回听、核对主体/否定/数值及时间。此步骤尚未完成,入口继续关闭。
|
||||
|
||||
### 本轮验证记录
|
||||
|
||||
ProviderConfig24、OpenAI音频传输47、Dify106、Media41、Probe14、隔离MySQL Core132、Worker20、Endpoint21、Access11、ProviderIntegration20均通过。音频传输单测采用受控响应/回环服务,不是外部ASR准确率;4个真实样本的287项检查只证明本地媒体与独立日期规则。网页未修改,38项录音组件测试回归通过。
|
||||
|
||||
完整证据在 `/Users/long/Work/zyt/artifacts/followup-audio-20260929/samples-20261007/`,主四角色路径沿用原交付;本地配置、真实音频和私有manifest都不纳入源码包。
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Merge this section into the target environment's existing .env. No credentials are provided here.
|
||||
# Existing [prescription_ai] configuration is reused, not replaced.
|
||||
# Only dify may reuse existing [prescription_ai] base/key. MODEL is required only for openai_audio.
|
||||
[followup_audio]
|
||||
ENABLED = false
|
||||
AUDIO_VERIFIED = false
|
||||
@@ -14,3 +14,22 @@ FFPROBE = ffprobe
|
||||
# At least 32 characters; keep secret, stable, backed up and identical on web/worker nodes.
|
||||
# Empty uses the existing prescription_analysis key; never rotate without a data re-encryption plan.
|
||||
ENCRYPTION_KEY =
|
||||
|
||||
# Never enable HTTP for real audio. This switch applies only to the synthetic CLI probe.
|
||||
ALLOW_INSECURE_SYNTHETIC = false
|
||||
# Supported drivers: dify, openai_audio. openai_audio MODEL is sent in the request.
|
||||
# Dify selects an application with its key; MODEL does not change the model inside that app.
|
||||
QWEN_DRIVER = dify
|
||||
QWEN_BASE_URL =
|
||||
QWEN_API_KEY =
|
||||
QWEN_MODEL =
|
||||
QWEN_LABEL = qwen
|
||||
QWEN_VERIFIED_FINGERPRINT =
|
||||
OPENAI_DRIVER = dify
|
||||
OPENAI_BASE_URL =
|
||||
OPENAI_API_KEY =
|
||||
OPENAI_MODEL =
|
||||
OPENAI_LABEL = openai
|
||||
OPENAI_VERIFIED_FINGERPRINT =
|
||||
# Fingerprints must come from all three passing synthetic gates for this exact driver/URL/model/key.
|
||||
# Any identity change invalidates prior verification; unknown prior requests still require reconciliation.
|
||||
|
||||
@@ -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\FollowupAudioProviderConfig as ProviderConfig;
|
||||
use app\common\service\followupaudio\FollowupAudioStore as Store;
|
||||
use app\common\service\followupaudio\FollowupAudioUpload as Upload;
|
||||
use DomainException;
|
||||
@@ -23,10 +24,7 @@ final class FollowupAudioLogic
|
||||
'enabled' => $enabled, 'audio_verified' => $verified,
|
||||
'can_upload' => $enabled && $verified, 'can_apply' => $enabled && $verified,
|
||||
'can_daily' => $daily, 'limits' => Upload::limits(),
|
||||
'models' => array_values(array_filter(
|
||||
[['value' => 'qwen', 'label' => '千问'], ['value' => 'openai', 'label' => 'OpenAI']],
|
||||
static fn (array $model): bool => Store::verified($model['value'])
|
||||
)),
|
||||
'models' => ProviderConfig::publicModels(),
|
||||
// No migration/dictionary dependency is introduced when the feature is OFF.
|
||||
'fields' => $enabled ? self::catalogForActor($diagnosis, $actor, $info) : [],
|
||||
];
|
||||
@@ -38,7 +36,7 @@ final class FollowupAudioLogic
|
||||
throw new DomainException('回访录音功能尚未启用');
|
||||
}
|
||||
if ($verified && !Store::verified()) {
|
||||
throw new DomainException('Dify 音频能力尚未验证,暂不接受真实录音或写入');
|
||||
throw new DomainException('当前服务与模型的音频能力尚未验证,暂不接受真实录音或写入');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace app\command;
|
||||
|
||||
use app\common\service\followupaudio\FollowupAudioDify;
|
||||
use app\common\service\followupaudio\FollowupAudioException;
|
||||
use app\common\service\followupaudio\FollowupAudioProviderConfig;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\input\Option;
|
||||
@@ -19,7 +20,7 @@ final class FollowupAudioProbe extends Command
|
||||
$this->setName('followup-audio:probe')->setDescription('只对脚本生成的合成短/15分钟/1小时音频做能力验证;不会自动开启功能')
|
||||
->addOption('synthetic', null, Option::VALUE_NONE, '明确确认只使用合成数据')
|
||||
->addOption('manifest', null, Option::VALUE_REQUIRED, 'generate_followup_audio_fixtures.py 输出的 manifest.json')
|
||||
->addOption('profile', null, Option::VALUE_REQUIRED, '现有 prescription_ai 配置 qwen/openai', 'qwen')
|
||||
->addOption('profile', null, Option::VALUE_REQUIRED, '服务端 followup_audio 配置的 qwen/openai 逻辑槽位', 'qwen')
|
||||
->addOption('case', null, Option::VALUE_REQUIRED, 'all/short/medium/long', 'all');
|
||||
}
|
||||
|
||||
@@ -39,20 +40,43 @@ final class FollowupAudioProbe extends Command
|
||||
}
|
||||
$manifest = json_decode((string) file_get_contents($path), true, 32, JSON_THROW_ON_ERROR);
|
||||
$fixtures = self::validateManifest($manifest, dirname($path));
|
||||
$reportPath = dirname($path) . '/probe-' . $profile . '.json';
|
||||
$dify = new FollowupAudioDify();
|
||||
$configuration = $dify->configurationStatus($profile);
|
||||
$fingerprint = (string) ($configuration['application_fingerprint'] ?? '');
|
||||
if ($fingerprint !== '' && !preg_match('/^[a-f0-9]{64}$/D', $fingerprint)) {
|
||||
throw new FollowupAudioException('PROBE_IDENTITY_CHANGED');
|
||||
}
|
||||
// Identity-specific evidence never overwrites a previous app or protocol's probe report.
|
||||
$reportPath = dirname($path) . '/probe-' . $profile . '-' . ($fingerprint !== '' ? $fingerprint : 'unconfigured') . '.json';
|
||||
$legacyPath = dirname($path) . '/probe-' . $profile . '.json';
|
||||
if (is_file($legacyPath)) {
|
||||
$legacy = json_decode((string) file_get_contents($legacyPath), true, 32, JSON_THROW_ON_ERROR);
|
||||
$legacyFingerprint = (string) ($legacy['configuration']['application_fingerprint'] ?? '');
|
||||
if (self::sameLegacyDifyApplication($profile, $legacyFingerprint) && array_filter((array) ($legacy['cases'] ?? []),
|
||||
static fn (array $row): bool => !empty($row['upstream_started_at']) && ($row['status'] ?? '') !== 'passed')) {
|
||||
// Upgrading the identity algorithm must not turn an old billable unknown into a new request.
|
||||
$output->writeln('FOLLOWUP_AUDIO_PROBE ' . json_encode(['status' => 'needs_reconciliation',
|
||||
'code' => 'NO_RESUBMISSION', 'report' => $legacyPath]));
|
||||
return 2;
|
||||
}
|
||||
if (!is_file($reportPath) && $fingerprint !== '' && hash_equals($fingerprint, $legacyFingerprint)) {
|
||||
$reportPath = $legacyPath; // Same-identity unknown/passed entries remain authoritative.
|
||||
} elseif ($legacyFingerprint === '' && array_filter((array) ($legacy['cases'] ?? []),
|
||||
static fn (array $row): bool => !empty($row['upstream_started_at']))) {
|
||||
throw new FollowupAudioException('PROBE_IDENTITY_UNBOUND');
|
||||
}
|
||||
}
|
||||
$lock = fopen($reportPath . '.lock', 'c+b');
|
||||
if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) { throw new FollowupAudioException('PROBE_ALREADY_RUNNING'); }
|
||||
@chmod($reportPath . '.lock', 0600);
|
||||
$hash = hash_file('sha256', $path);
|
||||
$report = is_file($reportPath) ? json_decode((string) file_get_contents($reportPath), true, 32, JSON_THROW_ON_ERROR) : [
|
||||
'schema_version' => 'followup-audio-probe-v1', 'synthetic' => true,
|
||||
'schema_version' => 'followup-audio-probe-v2', 'synthetic' => true,
|
||||
'manifest_sha256' => $hash, 'profile' => $profile, 'cases' => [],
|
||||
];
|
||||
if (!is_array($report) || ($report['manifest_sha256'] ?? '') !== $hash || ($report['profile'] ?? '') !== $profile) {
|
||||
throw new FollowupAudioException('PROBE_IDENTITY_CHANGED');
|
||||
}
|
||||
$dify = new FollowupAudioDify();
|
||||
$configuration = $dify->configurationStatus($profile);
|
||||
if (!empty($report['configuration']['application_fingerprint'])
|
||||
&& ($report['configuration']['application_fingerprint'] !== ($configuration['application_fingerprint'] ?? ''))) {
|
||||
throw new FollowupAudioException('PROBE_APPLICATION_CHANGED');
|
||||
@@ -111,6 +135,15 @@ final class FollowupAudioProbe extends Command
|
||||
}
|
||||
}
|
||||
|
||||
private static function sameLegacyDifyApplication(string $profile, string $fingerprint): bool
|
||||
{
|
||||
if (!preg_match('/^[a-f0-9]{64}$/D', $fingerprint)) { return false; }
|
||||
try { $provider = FollowupAudioProviderConfig::resolve($profile); }
|
||||
catch (FollowupAudioException $error) { return false; }
|
||||
return $provider['driver'] === 'dify'
|
||||
&& hash_equals(hash('sha256', $provider['base_url'] . "\0" . $provider['api_key']), $fingerprint);
|
||||
}
|
||||
|
||||
private static function validateManifest($manifest, string $directory): array
|
||||
{
|
||||
if (!is_array($manifest) || ($manifest['generator'] ?? '') !== 'followup-audio-synthetic-v1'
|
||||
|
||||
@@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\followupaudio;
|
||||
|
||||
/** Dedicated, fail-closed local_file audio transport; intentionally does not use DifyChatService. */
|
||||
/** Fail-closed audio transport; explicit Dify/OpenAI driver, never text or provider fallback. */
|
||||
final class FollowupAudioDify
|
||||
{
|
||||
private array $settings;
|
||||
@@ -24,7 +24,7 @@ final class FollowupAudioDify
|
||||
if (empty($this->settings['enabled'])) {
|
||||
throw new FollowupAudioException('FEATURE_DISABLED');
|
||||
}
|
||||
if (empty($this->settings['audio_verified']) || !in_array((string) ($task['model_key'] ?? ''), (array) ($this->settings['verified_profiles'] ?? []), true)) {
|
||||
if (!FollowupAudioProviderConfig::verified((string) ($task['model_key'] ?? ''), $this->settings, $this->provider)) {
|
||||
throw new FollowupAudioException('AUDIO_NOT_VERIFIED');
|
||||
}
|
||||
if ((int) ($task['upstream_started_at'] ?? 0) > 0) {
|
||||
@@ -53,25 +53,28 @@ final class FollowupAudioDify
|
||||
'upload_id' => 'synthetic', 'sha256' => $fixture['sha256'],
|
||||
'recorded_at' => '2026-09-29 10:00:00', 'model_key' => $profile,
|
||||
'duration_seconds' => (float) ($fixture['duration_seconds'] ?? 0),
|
||||
], $heartbeat);
|
||||
], $heartbeat, true);
|
||||
}
|
||||
|
||||
/** Returns status and an irreversible app-identity fingerprint, never configuration values/credentials. */
|
||||
public function configurationStatus(string $profile): array
|
||||
{
|
||||
try {
|
||||
[$base, $key] = $this->resolveProfile($profile);
|
||||
return ['configured' => true, 'profile' => $profile, 'code' => 'OK',
|
||||
'application_fingerprint' => hash('sha256', $base . "\0" . $key)];
|
||||
} catch (FollowupAudioException $e) {
|
||||
return ['configured' => false, 'profile' => in_array($profile, ['qwen', 'openai'], true) ? $profile : 'invalid',
|
||||
'code' => $e->errorCode];
|
||||
}
|
||||
return FollowupAudioProviderConfig::status($profile, $this->settings, $this->provider);
|
||||
}
|
||||
|
||||
private function analyzeFile(string $path, array $task, callable $heartbeat): array
|
||||
private function analyzeFile(string $path, array $task, callable $heartbeat, bool $synthetic = false): array
|
||||
{
|
||||
[$base, $key, $timeout] = $this->resolveProfile((string) ($task['model_key'] ?? ''));
|
||||
[$base, $key, $timeout, $provider] = $this->resolveProfile((string) ($task['model_key'] ?? ''));
|
||||
if (!str_starts_with($base, 'https://') && (!$synthetic || empty($this->settings['allow_insecure_synthetic']))) {
|
||||
throw new FollowupAudioException('HTTPS_REQUIRED');
|
||||
}
|
||||
if (!$synthetic) {
|
||||
$snapshot = json_decode((string) ($task['upstream_ids_json'] ?? ''), true);
|
||||
if (!is_array($snapshot) || !is_string($snapshot['provider_fingerprint'] ?? null)
|
||||
|| !hash_equals($provider['fingerprint'], $snapshot['provider_fingerprint'])) {
|
||||
throw new FollowupAudioException('PROVIDER_CONFIGURATION_CHANGED');
|
||||
}
|
||||
}
|
||||
$audio = $this->inspectAudio($path, (string) ($task['sha256'] ?? ''));
|
||||
if (isset($task['duration_seconds']) && abs((float) $task['duration_seconds'] - $audio['duration']) > 1.0) {
|
||||
throw new FollowupAudioException('AUDIO_INVALID');
|
||||
@@ -81,36 +84,52 @@ final class FollowupAudioDify
|
||||
if (!$date || $date->format('Y-m-d H:i:s') !== $recordedAt) {
|
||||
throw new FollowupAudioException('RECORDED_AT_INVALID');
|
||||
}
|
||||
$processing = $this->prepareAudio($audio, $heartbeat);
|
||||
$processing = $this->prepareAudio($audio, $heartbeat, $provider['driver'] === 'openai_audio');
|
||||
try {
|
||||
$query = $this->prompt($recordedAt, $audio['duration']);
|
||||
$ids = ['request_id' => 'fa-' . bin2hex(random_bytes(16))];
|
||||
$ids = ['request_id' => 'fa-' . bin2hex(random_bytes(16)), 'provider_fingerprint' => $provider['fingerprint']];
|
||||
$user = 'followup-audio-' . substr(hash('sha256', (string) ($task['id'] ?? '') . ':' . $ids['request_id']), 0, 32);
|
||||
// Persist intent BEFORE any network side effect, including upload. A crashed worker cannot resend.
|
||||
$this->checkpoint($heartbeat, ['stage' => 'uploading', 'upstream_started_at' => time(),
|
||||
'upstream_ids_json' => json_encode($ids, JSON_THROW_ON_ERROR)], false);
|
||||
$uploaded = $this->request([
|
||||
'url' => $base . '/files/upload', 'api_key' => $key, 'timeout' => $timeout, 'request_ids' => $ids,
|
||||
'multipart' => ['user' => $user, 'file' => new \CURLFile($processing['path'], $processing['mime'], 'followup-audio.' . $processing['extension'])],
|
||||
], $heartbeat);
|
||||
$fileId = $this->identifier($uploaded['id'] ?? null);
|
||||
if ($fileId === '') {
|
||||
throw new FollowupAudioException('UPSTREAM_UPLOAD_INVALID', true);
|
||||
if ($provider['driver'] === 'openai_audio') {
|
||||
// Build and validate the complete request before persisting the single billable intent.
|
||||
$payload = $this->openAiPayload($processing, $query, $provider['model']);
|
||||
$this->checkpoint($heartbeat, ['stage' => 'analyzing', 'upstream_started_at' => time(),
|
||||
'upstream_ids_json' => json_encode($ids, JSON_THROW_ON_ERROR)], false);
|
||||
$response = $this->request(['url' => $base . '/chat/completions', 'api_key' => $key,
|
||||
'timeout' => $timeout, 'json' => $payload, 'request_ids' => $ids], $heartbeat);
|
||||
$response['message_id'] = $this->identifier($response['id'] ?? null);
|
||||
$choices = is_array($response['choices'] ?? null) ? $response['choices'] : [];
|
||||
$choice = is_array($choices[0] ?? null) ? $choices[0] : [];
|
||||
$choice['message'] = is_array($choice['message'] ?? null) ? $choice['message'] : [];
|
||||
$response['answer'] = count($choices) === 1
|
||||
&& ($choice['finish_reason'] ?? '') === 'stop' && empty($choice['message']['refusal'])
|
||||
&& empty($choice['message']['tool_calls']) ? ($choice['message']['content'] ?? null) : null;
|
||||
} else {
|
||||
// Persist intent BEFORE any network side effect, including upload. A crashed worker cannot resend.
|
||||
$this->checkpoint($heartbeat, ['stage' => 'uploading', 'upstream_started_at' => time(),
|
||||
'upstream_ids_json' => json_encode($ids, JSON_THROW_ON_ERROR)], false);
|
||||
$uploaded = $this->request([
|
||||
'url' => $base . '/files/upload', 'api_key' => $key, 'timeout' => $timeout, 'request_ids' => $ids,
|
||||
'multipart' => ['user' => $user, 'file' => new \CURLFile($processing['path'], $processing['mime'], 'followup-audio.' . $processing['extension'])],
|
||||
], $heartbeat);
|
||||
$fileId = $this->identifier($uploaded['id'] ?? null);
|
||||
if ($fileId === '') {
|
||||
throw new FollowupAudioException('UPSTREAM_UPLOAD_INVALID', true);
|
||||
}
|
||||
// Preserve the observed file ID even if the upstream mislabeled/rejected its media type.
|
||||
$this->checkpoint($heartbeat, ['stage' => 'analyzing', 'upstream_file_id' => $fileId], true);
|
||||
if (isset($uploaded['mime_type']) && (!is_string($uploaded['mime_type']) || !str_starts_with($uploaded['mime_type'], 'audio/'))) {
|
||||
throw new FollowupAudioException('UPSTREAM_AUDIO_REJECTED');
|
||||
}
|
||||
$payload = [
|
||||
'inputs' => new \stdClass(),
|
||||
'query' => $query,
|
||||
'response_mode' => 'blocking', 'user' => $user, 'auto_generate_name' => false,
|
||||
'files' => [['type' => 'audio', 'transfer_method' => 'local_file', 'upload_file_id' => $fileId]],
|
||||
];
|
||||
self::assertAudioPayload($payload, $fileId);
|
||||
$response = $this->request(['url' => $base . '/chat-messages', 'api_key' => $key,
|
||||
'timeout' => $timeout, 'json' => $payload, 'request_ids' => $ids], $heartbeat);
|
||||
}
|
||||
// Preserve the observed file ID even if the upstream mislabeled/rejected its media type.
|
||||
$this->checkpoint($heartbeat, ['stage' => 'analyzing', 'upstream_file_id' => $fileId], true);
|
||||
if (isset($uploaded['mime_type']) && (!is_string($uploaded['mime_type']) || !str_starts_with($uploaded['mime_type'], 'audio/'))) {
|
||||
throw new FollowupAudioException('UPSTREAM_AUDIO_REJECTED');
|
||||
}
|
||||
$payload = [
|
||||
'inputs' => new \stdClass(),
|
||||
'query' => $query,
|
||||
'response_mode' => 'blocking', 'user' => $user, 'auto_generate_name' => false,
|
||||
'files' => [['type' => 'audio', 'transfer_method' => 'local_file', 'upload_file_id' => $fileId]],
|
||||
];
|
||||
self::assertAudioPayload($payload, $fileId);
|
||||
$response = $this->request(['url' => $base . '/chat-messages', 'api_key' => $key,
|
||||
'timeout' => $timeout, 'json' => $payload, 'request_ids' => $ids], $heartbeat);
|
||||
foreach (['task_id', 'message_id', 'conversation_id', 'upstream_request_id'] as $name) {
|
||||
$id = $this->identifier($response[$name] ?? null);
|
||||
if ($id !== '') { $ids[$name] = $id; }
|
||||
@@ -156,38 +175,25 @@ final class FollowupAudioDify
|
||||
|
||||
private function resolveProfile(string $profile): array
|
||||
{
|
||||
if (!in_array($profile, ['qwen', 'openai'], true)) {
|
||||
throw new FollowupAudioException('INVALID_PROFILE');
|
||||
}
|
||||
$base = rtrim((string) ($this->provider['base_url'] ?? ''), '/');
|
||||
$key = (string) ($this->provider['models'][$profile]['api_key'] ?? '');
|
||||
if ($base === '' || trim($key) === '') {
|
||||
throw new FollowupAudioException('CONFIG_MISSING');
|
||||
}
|
||||
$parts = parse_url($base);
|
||||
if (!is_array($parts) || !in_array($parts['scheme'] ?? '', ['https', 'http'], true)
|
||||
|| empty($parts['host']) || isset($parts['user']) || isset($parts['pass']) || isset($parts['query'])
|
||||
|| isset($parts['fragment']) || preg_match('/[\x00-\x20\x7f]/', $base)
|
||||
|| preg_match('/[\x00-\x20\x7f]/', $key)) {
|
||||
throw new FollowupAudioException('CONFIG_INVALID');
|
||||
}
|
||||
$path = (string) ($parts['path'] ?? '');
|
||||
if (str_ends_with($path, '/chat/completions')) {
|
||||
throw new FollowupAudioException('DIFY_APPLICATION_REQUIRED');
|
||||
}
|
||||
if (str_ends_with($path, '/chat-messages')) {
|
||||
$base = substr($base, 0, -strlen('/chat-messages'));
|
||||
} elseif (!str_ends_with($path, '/v1')) {
|
||||
$base .= '/v1';
|
||||
}
|
||||
$provider = FollowupAudioProviderConfig::resolve($profile, $this->settings, $this->provider);
|
||||
$timeout = (int) ($this->settings['request_timeout'] ?? 240);
|
||||
if ($timeout < 1 || $timeout > 300) {
|
||||
throw new FollowupAudioException('CONFIG_INVALID');
|
||||
if ($timeout < 1 || $timeout > 300) { throw new FollowupAudioException('CONFIG_INVALID'); }
|
||||
if (!function_exists('curl_init')) { throw new FollowupAudioException('CURL_UNAVAILABLE'); }
|
||||
return [$provider['base_url'], $provider['api_key'], $timeout, $provider];
|
||||
}
|
||||
|
||||
/** Only MP3/WAV input_audio is supported, with bytes from the verified complete processing copy. */
|
||||
private function openAiPayload(array $audio, string $query, string $model): array
|
||||
{
|
||||
if (!in_array($audio['extension'], ['mp3', 'wav'], true)) { throw new FollowupAudioException('AUDIO_INVALID'); }
|
||||
$bytes = file_get_contents($audio['path']);
|
||||
if (!is_string($bytes) || $bytes === '' || strlen($bytes) > (int) ($this->settings['upstream_max_bytes'] ?? 20971520)) {
|
||||
throw new FollowupAudioException('UPSTREAM_AUDIO_LIMIT');
|
||||
}
|
||||
if (!function_exists('curl_init')) {
|
||||
throw new FollowupAudioException('CURL_UNAVAILABLE');
|
||||
}
|
||||
return [$base, $key, $timeout];
|
||||
return ['model' => $model, 'stream' => false, 'messages' => [['role' => 'user', 'content' => [
|
||||
['type' => 'text', 'text' => $query],
|
||||
['type' => 'input_audio', 'input_audio' => ['data' => base64_encode($bytes), 'format' => $audio['extension']]],
|
||||
]]]];
|
||||
}
|
||||
|
||||
private function inspectAudio(string $path, string $sha256, bool $processing = false): array
|
||||
@@ -240,7 +246,7 @@ final class FollowupAudioDify
|
||||
}
|
||||
|
||||
/** Preserve the exact original. Only a private, bounded audio copy may be sent to the same Dify app. */
|
||||
private function prepareAudio(array $audio, callable $heartbeat): array
|
||||
private function prepareAudio(array $audio, callable $heartbeat, bool $openAi = false): array
|
||||
{
|
||||
// Dify has a separate audio-upload ceiling (default 50 MiB); use a conservative 20 MiB local budget.
|
||||
// Deployment must verify its own app/model limits via the synthetic probe before enabling a profile.
|
||||
@@ -249,7 +255,8 @@ final class FollowupAudioDify
|
||||
if ($limit < 1 || $limit > 52428800 || $timeout < 1 || $timeout > 600) {
|
||||
throw new FollowupAudioException('CONFIG_INVALID');
|
||||
}
|
||||
if (filesize($audio['path']) <= $limit && $audio['extension'] !== 'amr') { return $audio; }
|
||||
if (filesize($audio['path']) <= $limit && $audio['extension'] !== 'amr'
|
||||
&& (!$openAi || in_array($audio['extension'], ['mp3', 'wav'], true))) { return $audio; }
|
||||
// 32 kbit/s mono speech preserves the entire hour within ~14.5 MB, without splitting model requests.
|
||||
if ($audio['duration'] * 4000 + 2048 > $limit) {
|
||||
throw new FollowupAudioException('UPSTREAM_AUDIO_LIMIT');
|
||||
@@ -416,8 +423,9 @@ final class FollowupAudioDify
|
||||
$http = (int) ($response['http_code'] ?? 0);
|
||||
// Even failed/uncertain replies can contain a billable task ID. Persist it before raising a sanitized error.
|
||||
$candidate = json_decode((string) ($response['body'] ?? ''), true);
|
||||
if (is_array($candidate) && isset($candidate['id'], $spec['json']['messages'])) { $candidate['message_id'] = $candidate['id']; }
|
||||
if ((int) ($response['errno'] ?? 0) !== 0 || $http < 200 || $http >= 300
|
||||
|| !is_array($candidate) || !empty($candidate['code']) || ($candidate['event'] ?? '') === 'error') {
|
||||
|| !is_array($candidate) || !empty($candidate['code']) || !empty($candidate['error']) || ($candidate['event'] ?? '') === 'error') {
|
||||
$observed = $spec['request_ids'] ?? [];
|
||||
foreach (['task_id', 'message_id', 'conversation_id'] as $name) {
|
||||
$id = $this->identifier($candidate[$name] ?? null);
|
||||
@@ -441,7 +449,7 @@ final class FollowupAudioDify
|
||||
try { $body = json_decode((string) ($response['body'] ?? ''), true, 64, JSON_THROW_ON_ERROR); }
|
||||
catch (\Throwable $e) { throw new FollowupAudioException('UPSTREAM_UNCERTAIN', true); }
|
||||
if (!is_array($body)) { throw new FollowupAudioException('UPSTREAM_UNCERTAIN', true); }
|
||||
if (!empty($body['code']) || ($body['event'] ?? '') === 'error') {
|
||||
if (!empty($body['code']) || !empty($body['error']) || ($body['event'] ?? '') === 'error') {
|
||||
throw new FollowupAudioException('UPSTREAM_UNCERTAIN', true);
|
||||
}
|
||||
$requestId = $this->identifier($response['request_id'] ?? null);
|
||||
|
||||
@@ -15,14 +15,16 @@ final class FollowupAudioException extends \RuntimeException
|
||||
$this->errorCode = $errorCode;
|
||||
$this->uncertain = $uncertain;
|
||||
$messages = [
|
||||
'CONFIG_MISSING' => '当前 Dify 应用凭据未配置,音频能力尚未验证',
|
||||
'CONFIG_MISSING' => '当前音频模型服务未完整配置,音频能力尚未验证',
|
||||
'HTTPS_REQUIRED' => '真实音频只允许通过有效 HTTPS 服务传输',
|
||||
'PROVIDER_CONFIGURATION_CHANGED' => '音频模型配置已变化或缺少绑定,任务未发送,请重新核对',
|
||||
'FEATURE_DISABLED' => '随访音频功能未启用',
|
||||
'AUDIO_NOT_VERIFIED' => '当前应用尚未通过音频能力验证',
|
||||
'AUDIO_NOT_PROCESSED' => '上游未确认读取原始音频,未生成可采用结果',
|
||||
'UPSTREAM_UNCERTAIN' => '上游结果未知,请先核对任务或计费,禁止重复提交',
|
||||
'RECONCILIATION_REQUIRED' => '任务已有上游请求,须先人工核对,禁止重复提交',
|
||||
'UPSTREAM_SCHEMA_INVALID' => '上游未返回完整、可核验的结构化音频事实',
|
||||
'UPSTREAM_AUDIO_REJECTED' => '当前 Dify 应用拒绝音频附件,未降级为纯文本',
|
||||
'UPSTREAM_AUDIO_REJECTED' => '当前模型服务拒绝音频附件,未降级为纯文本',
|
||||
'LEASE_LOST' => '任务租约或访问权限已失效',
|
||||
'ACCESS_REVOKED' => '执行权限已撤销',
|
||||
'UPSTREAM_AUDIO_LIMIT' => '录音处理副本仍超出已配置的上游限制,未发送,请联系管理员',
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\followupaudio;
|
||||
|
||||
/** Server-only provider identity. Changing any request identity invalidates the audio gate. */
|
||||
final class FollowupAudioProviderConfig
|
||||
{
|
||||
public static function resolve(string $profile, ?array $settings = null, ?array $legacy = null): array
|
||||
{
|
||||
if (!in_array($profile, ['qwen', 'openai'], true)) { throw new FollowupAudioException('INVALID_PROFILE'); }
|
||||
$settings = $settings ?? (array) config('followup_audio', []);
|
||||
$legacy = $legacy ?? (array) config('prescription_ai', []);
|
||||
$provider = (array) ($settings['providers'][$profile] ?? []);
|
||||
$driver = (string) ($provider['driver'] ?? 'dify');
|
||||
if (!in_array($driver, ['dify', 'openai_audio'], true)) { throw new FollowupAudioException('CONFIG_INVALID'); }
|
||||
$base = (string) ($provider['base_url'] ?? '');
|
||||
$key = (string) ($provider['api_key'] ?? '');
|
||||
if ($driver === 'dify') {
|
||||
if ($base === '') { $base = (string) ($legacy['base_url'] ?? ''); }
|
||||
if ($key === '') { $key = (string) ($legacy['models'][$profile]['api_key'] ?? ''); }
|
||||
}
|
||||
$model = (string) ($provider['model'] ?? '');
|
||||
$label = trim((string) ($provider['label'] ?? ''));
|
||||
if ($label === '') { $label = $model !== '' ? $model : $profile; }
|
||||
if ($base === '' || $key === '' || ($driver === 'openai_audio' && $model === '')) { throw new FollowupAudioException('CONFIG_MISSING'); }
|
||||
$parts = parse_url($base);
|
||||
if (!is_array($parts) || !in_array($parts['scheme'] ?? '', ['http', 'https'], true)
|
||||
|| empty($parts['host']) || isset($parts['user']) || isset($parts['pass']) || isset($parts['query']) || isset($parts['fragment'])
|
||||
|| preg_match('/[\x00-\x20\x7f\\\\]/', $base) || preg_match('/[\x00-\x20\x7f]/', $key)
|
||||
|| strlen($model) > 200 || preg_match('/[\x00-\x20\x7f]/', $model)
|
||||
|| trim($label) === '' || strlen($label) > 200 || preg_match('/[\x00-\x1f\x7f]/', $label)) {
|
||||
throw new FollowupAudioException('CONFIG_INVALID');
|
||||
}
|
||||
$base = rtrim($base, '/');
|
||||
$path = rtrim((string) ($parts['path'] ?? ''), '/');
|
||||
if ($driver === 'dify' && str_ends_with($path, '/chat/completions')) {
|
||||
throw new FollowupAudioException('DIFY_APPLICATION_REQUIRED');
|
||||
}
|
||||
if ($driver === 'openai_audio' && str_ends_with($path, '/chat-messages')) {
|
||||
throw new FollowupAudioException('CONFIG_INVALID');
|
||||
}
|
||||
$endpoint = $driver === 'dify' ? '/chat-messages' : '/chat/completions';
|
||||
if (str_ends_with($base, $endpoint)) { $base = substr($base, 0, -strlen($endpoint)); }
|
||||
elseif (!str_ends_with($base, '/v1')) { $base .= '/v1'; }
|
||||
$fingerprint = hash('sha256', json_encode([$driver, $base, $model, $key], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
|
||||
return ['driver' => $driver, 'base_url' => $base, 'api_key' => $key, 'model' => $model, 'label' => $label, 'fingerprint' => $fingerprint];
|
||||
}
|
||||
|
||||
/** No endpoint, model credential or plaintext provider data in capability responses. */
|
||||
public static function status(string $profile, ?array $settings = null, ?array $legacy = null): array
|
||||
{
|
||||
try {
|
||||
$provider = self::resolve($profile, $settings, $legacy);
|
||||
return ['configured' => true, 'profile' => $profile, 'code' => 'OK', 'application_fingerprint' => $provider['fingerprint']];
|
||||
} catch (FollowupAudioException $error) {
|
||||
return ['configured' => false, 'profile' => in_array($profile, ['qwen', 'openai'], true) ? $profile : 'invalid', 'code' => $error->errorCode];
|
||||
}
|
||||
}
|
||||
|
||||
public static function isVerified(string $profile): bool
|
||||
{
|
||||
return self::verified($profile, (array) config('followup_audio', []), (array) config('prescription_ai', []));
|
||||
}
|
||||
|
||||
/** Shared with injected-config transport tests; enabled remains an independent operational switch. */
|
||||
public static function verified(string $profile, array $settings, array $legacy): bool
|
||||
{
|
||||
if (empty($settings['audio_verified']) || !in_array($profile, (array) ($settings['verified_profiles'] ?? []), true)) { return false; }
|
||||
try { $provider = self::resolve($profile, $settings, $legacy); }
|
||||
catch (FollowupAudioException $error) { return false; }
|
||||
$verified = (string) ($settings['providers'][$profile]['verified_fingerprint'] ?? '');
|
||||
return str_starts_with($provider['base_url'], 'https://') && preg_match('/^[a-f0-9]{64}$/D', $verified)
|
||||
&& hash_equals($provider['fingerprint'], $verified);
|
||||
}
|
||||
|
||||
public static function publicModels(): array
|
||||
{
|
||||
$models = [];
|
||||
foreach (['qwen', 'openai'] as $profile) {
|
||||
if (self::isVerified($profile)) { $models[] = ['value' => $profile, 'label' => self::resolve($profile)['label']]; }
|
||||
}
|
||||
return $models;
|
||||
}
|
||||
}
|
||||
@@ -14,13 +14,30 @@ final class FollowupAudioStore
|
||||
public static function enabled(): bool { return (bool) config('followup_audio.enabled', false); }
|
||||
public static function verified(?string $profile = null): bool
|
||||
{
|
||||
$profiles = self::verifiedProfiles();
|
||||
return (bool) config('followup_audio.audio_verified', false) && ($profile === null ? $profiles !== [] : in_array($profile, $profiles, true));
|
||||
return $profile === null ? self::verifiedProfiles() !== [] : FollowupAudioProviderConfig::isVerified($profile);
|
||||
}
|
||||
|
||||
private static function verifiedProfiles(): array
|
||||
{
|
||||
return array_values(array_intersect(['qwen', 'openai'], (array) config('followup_audio.verified_profiles', [])));
|
||||
return array_values(array_filter(['qwen', 'openai'], [FollowupAudioProviderConfig::class, 'isVerified']));
|
||||
}
|
||||
|
||||
/** A task binds to the exact provider/endpoint/model/key identity approved when it was created. */
|
||||
public static function providerMatches(array $task): bool
|
||||
{
|
||||
$ids = json_decode((string) ($task['upstream_ids_json'] ?? '{}'), true);
|
||||
$fingerprint = is_array($ids) ? ($ids['provider_fingerprint'] ?? '') : '';
|
||||
if (!is_string($fingerprint) || !preg_match('/^[a-f0-9]{64}$/D', $fingerprint)) { return false; }
|
||||
$status = FollowupAudioProviderConfig::status((string) ($task['model_key'] ?? ''));
|
||||
return !empty($status['configured']) && is_string($status['application_fingerprint'] ?? null)
|
||||
&& hash_equals($fingerprint, $status['application_fingerprint']);
|
||||
}
|
||||
|
||||
public static function assertTaskProvider(array $task): void
|
||||
{
|
||||
if (!self::providerMatches($task)) {
|
||||
throw new FollowupAudioException('PROVIDER_CONFIGURATION_CHANGED', (int) ($task['upstream_started_at'] ?? 0) > 0);
|
||||
}
|
||||
}
|
||||
|
||||
public static function assertEnabled(?string $profile = null): void
|
||||
@@ -69,7 +86,9 @@ final class FollowupAudioStore
|
||||
'duration_seconds' => $stored['duration_seconds'], 'recorded_at' => $recordedAt, 'model_key' => $modelKey,
|
||||
'status' => 'queued', 'stage' => 'queued', 'version' => 1, 'attempts' => 0,
|
||||
'lease_token' => '', 'lease_until' => 0, 'upstream_started_at' => 0,
|
||||
'upstream_run_id' => '', 'upstream_file_id' => '', 'upstream_ids_json' => '{}',
|
||||
'upstream_run_id' => '', 'upstream_file_id' => '', 'upstream_ids_json' => FollowupAudioPolicy::canonical([
|
||||
'provider_fingerprint' => FollowupAudioProviderConfig::resolve($modelKey)['fingerprint'],
|
||||
]),
|
||||
'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,
|
||||
]);
|
||||
@@ -91,6 +110,9 @@ final class FollowupAudioStore
|
||||
private static function reuse(array $task, string $recordedAt): array
|
||||
{
|
||||
$message = '同一诊单、录音内容及模型已有任务,已复用原任务,不会再次调用模型。';
|
||||
if (!self::providerMatches($task)) {
|
||||
$message .= '原任务的服务配置已变更或缺少配置快照,不能自动改用新服务重跑。';
|
||||
}
|
||||
if ($task['recorded_at'] !== $recordedAt) {
|
||||
$message .= '沿用原任务的录音时间,请在未采用的审阅项中更正记录日期;已采用任务不会重跑。';
|
||||
}
|
||||
@@ -127,7 +149,7 @@ final class FollowupAudioStore
|
||||
$result['error_message'] = '录音及审阅已到保留期限,不能采用';
|
||||
}
|
||||
$result['can_retry'] = self::enabled() && self::verified((string) $task['model_key']) && $alive && $task['status'] === 'failed'
|
||||
&& (int) $task['upstream_started_at'] === 0 && (int) $task['attempts'] < 3;
|
||||
&& self::providerMatches($task) && (int) $task['upstream_started_at'] === 0 && (int) $task['attempts'] < 3;
|
||||
$result['audio_available'] = $alive && (string) Db::name('followup_audio_upload')->where('id', $task['upload_id'])->value('status') === 'complete';
|
||||
return $result;
|
||||
}
|
||||
@@ -192,6 +214,7 @@ final class FollowupAudioStore
|
||||
Db::transaction(static function () use ($taskId): void {
|
||||
$task = self::lockTask($taskId);
|
||||
self::assertEnabled((string) $task['model_key']);
|
||||
self::assertTaskProvider($task);
|
||||
if ($task['status'] !== 'failed' || (int) $task['upstream_started_at'] !== 0 || (int) $task['attempts'] >= 3
|
||||
|| (int) $task['expires_at'] <= time() || (int) $task['purged_at']) {
|
||||
throw new DomainException('FOLLOWUP_AUDIO_RETRY_NOT_SAFE');
|
||||
@@ -207,7 +230,7 @@ final class FollowupAudioStore
|
||||
/** A singleton DB mutex enforces concurrency across independent CLI processes. */
|
||||
public static function claim(): ?array
|
||||
{
|
||||
if (!self::enabled() || !self::verified()) { return null; }
|
||||
if (!self::enabled()) { return null; }
|
||||
return Db::transaction(static function (): ?array {
|
||||
if (!Db::name('followup_audio_mutex')->where('id', 1)->lock(true)->find()) {
|
||||
throw new DomainException('FOLLOWUP_AUDIO_MIGRATION_REQUIRED');
|
||||
@@ -227,9 +250,22 @@ final class FollowupAudioStore
|
||||
$active = Db::name('followup_audio_task')->where('status', 'running')->where('lease_until', '>', $now)
|
||||
->field('id')->lock(true)->select()->toArray();
|
||||
if (count($active) >= max(1, min(8, (int) config('followup_audio.concurrency', 1)))) { return null; }
|
||||
$task = Db::name('followup_audio_task')->where('status', 'queued')->where('upstream_started_at', 0)->whereIn('model_key', self::verifiedProfiles())
|
||||
->where('expires_at', '>', $now)->where('purged_at', 0)->order('id', 'asc')->lock(true)->find();
|
||||
if (!$task) { return null; }
|
||||
$pending = Db::name('followup_audio_task')->where('status', 'queued')->where('upstream_started_at', 0)
|
||||
->where('expires_at', '>', $now)->where('purged_at', 0)->order('id', 'asc')->limit(200)->lock(true)->select()->toArray();
|
||||
$task = null;
|
||||
foreach ($pending as $candidate) {
|
||||
if (!self::providerMatches($candidate)) {
|
||||
// Old rows without a binding and changed configurations are visible failures, never silently re-routed.
|
||||
Db::name('followup_audio_task')->where('id', $candidate['id'])->update([
|
||||
'status' => 'failed', 'stage' => 'failed', 'error_code' => 'PROVIDER_CONFIGURATION_CHANGED',
|
||||
'error_message' => '任务服务配置已变更或缺少配置快照,已停止处理;恢复原配置并核对后再操作',
|
||||
'updated_at' => $now, 'version' => (int) $candidate['version'] + 1,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
if (self::verified((string) $candidate['model_key'])) { $task = $candidate; break; }
|
||||
}
|
||||
if ($task === null) { return null; }
|
||||
$changes = ['status' => 'running', 'stage' => 'preparing', 'lease_token' => bin2hex(random_bytes(32)),
|
||||
'lease_until' => $now + self::leaseSeconds(), 'attempts' => (int) $task['attempts'] + 1,
|
||||
'updated_at' => $now, 'version' => (int) $task['version'] + 1];
|
||||
@@ -269,6 +305,13 @@ 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 === 'provider_fingerprint') {
|
||||
if (!is_string($identifier) || !is_string($previous[$name] ?? null)
|
||||
|| !hash_equals($previous[$name], $identifier)) {
|
||||
throw new DomainException('FOLLOWUP_AUDIO_CHECKPOINT_INVALID');
|
||||
}
|
||||
continue; // The immutable task snapshot is retained, never replaced by a callback.
|
||||
}
|
||||
if (!in_array($name, ['request_id', 'task_id', 'message_id', 'conversation_id', 'upstream_request_id'], true)) {
|
||||
throw new DomainException('FOLLOWUP_AUDIO_CHECKPOINT_INVALID');
|
||||
}
|
||||
@@ -313,13 +356,17 @@ final class FollowupAudioStore
|
||||
{
|
||||
return Db::transaction(static function () use ($id, $token, $code, $uncertain): bool {
|
||||
$task = self::lockTask($id);
|
||||
if (!self::hasLease($task, $token)) { return false; }
|
||||
if (!self::hasLease($task, $token, false)) { return false; }
|
||||
// An arbitrary exception/message can contain patient text or credentials: never persist it.
|
||||
$code = preg_match('/^[A-Z][A-Z0-9_]{2,95}$/D', $code) ? $code : 'FOLLOWUP_AUDIO_PROCESS_FAILED';
|
||||
$uncertain = $uncertain || (int) $task['upstream_started_at'] > 0;
|
||||
$status = $uncertain ? 'needs_reconciliation' : 'failed';
|
||||
Db::name('followup_audio_task')->where('id', $id)->update([
|
||||
'status' => $status, 'stage' => $status, 'error_code' => $code,
|
||||
'error_message' => $uncertain ? '上游结果待核对,禁止重复提交' : '处理未完成,请查看错误代码;不会自动重复调用',
|
||||
'error_message' => $uncertain ? '上游结果待核对,禁止重复提交'
|
||||
: ($code === 'PROVIDER_CONFIGURATION_CHANGED'
|
||||
? '任务服务配置已变更或缺少配置快照,已停止处理;恢复原配置并核对后再操作'
|
||||
: '处理未完成,请查看错误代码;不会自动重复调用'),
|
||||
'lease_token' => '', 'lease_until' => 0, 'updated_at' => time(), 'version' => (int) $task['version'] + 1,
|
||||
]);
|
||||
return true;
|
||||
@@ -422,9 +469,10 @@ final class FollowupAudioStore
|
||||
return new PrescriptionAiCipher($key === '' ? null : $key);
|
||||
}
|
||||
|
||||
private static function hasLease(array $task, string $token): bool
|
||||
private static function hasLease(array $task, string $token, bool $processing = true): bool
|
||||
{
|
||||
return self::enabled() && self::verified((string) $task['model_key']) && $task['status'] === 'running' && $token !== ''
|
||||
return (!$processing || self::enabled() && self::verified((string) $task['model_key']) && self::providerMatches($task))
|
||||
&& $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'];
|
||||
}
|
||||
|
||||
@@ -17,15 +17,17 @@ final class FollowupAudioWorker
|
||||
|
||||
public function runOnce(): bool
|
||||
{
|
||||
if (!FollowupAudioStore::enabled() || !FollowupAudioStore::verified() || !($task = FollowupAudioStore::claim())) {
|
||||
if (!FollowupAudioStore::enabled() || !($task = FollowupAudioStore::claim())) {
|
||||
return false;
|
||||
}
|
||||
$id = (int) $task['id'];
|
||||
$token = (string) $task['lease_token'];
|
||||
$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'); }
|
||||
$heartbeat = function (array $fields = []) use ($task, $id, $token, &$started): bool {
|
||||
FollowupAudioStore::assertTaskProvider($task);
|
||||
if (!FollowupAudioStore::enabled() || !FollowupAudioStore::verified((string) $task['model_key'])) { return false; }
|
||||
$actor = PrescriptionAiAccess::actor((int) $task['actor_id']);
|
||||
if (!$actor) { return false; }
|
||||
@@ -42,7 +44,7 @@ final class FollowupAudioWorker
|
||||
throw new FollowupAudioException('LEASE_LOST', true);
|
||||
}
|
||||
} catch (FollowupAudioException $e) {
|
||||
FollowupAudioStore::fail($id, $token, $e->errorCode, $e->getMessage(), $e->uncertain);
|
||||
FollowupAudioStore::fail($id, $token, $e->errorCode, $e->getMessage(), $e->uncertain || $started);
|
||||
} catch (\Throwable $e) {
|
||||
// The exception may contain SQL, names, transcript, credentials or a signed URL.
|
||||
FollowupAudioStore::fail($id, $token, 'INTERNAL_ERROR', '音频任务执行异常,请核对任务状态', $started);
|
||||
|
||||
@@ -26,5 +26,19 @@ return [
|
||||
'ffmpeg' => (string) env('followup_audio.FFMPEG', 'ffmpeg'),
|
||||
'ffprobe' => (string) env('followup_audio.FFPROBE', 'ffprobe'),
|
||||
'max_response_bytes' => 8388608,
|
||||
// No audio-specific provider key or fallback: prescription_ai.base_url/models are reused.
|
||||
// HTTP is permitted only for explicitly acknowledged synthetic probes, never patient requests.
|
||||
'allow_insecure_synthetic' => filter_var(env('followup_audio.ALLOW_INSECURE_SYNTHETIC', false), FILTER_VALIDATE_BOOLEAN),
|
||||
// Stable logical slots preserve task schema. No model identifier or supplier is hardcoded.
|
||||
'providers' => array_combine(['qwen', 'openai'], array_map(static function (string $profile): array {
|
||||
$prefix = 'followup_audio.' . strtoupper($profile) . '_';
|
||||
return [
|
||||
'driver' => (string) env($prefix . 'DRIVER', 'dify'),
|
||||
// Only the dify driver may reuse empty base/key fields from prescription_ai.
|
||||
'base_url' => (string) env($prefix . 'BASE_URL', ''),
|
||||
'api_key' => (string) env($prefix . 'API_KEY', ''),
|
||||
'model' => (string) env($prefix . 'MODEL', ''),
|
||||
'label' => (string) env($prefix . 'LABEL', $profile),
|
||||
'verified_fingerprint' => (string) env($prefix . 'VERIFIED_FINGERPRINT', ''),
|
||||
];
|
||||
}, ['qwen', 'openai'])),
|
||||
];
|
||||
|
||||
@@ -12,15 +12,29 @@ namespace app\common\service\followupaudio {
|
||||
}
|
||||
namespace {
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
$app = new \think\App(dirname(__DIR__) . '/'); $app->initialize();
|
||||
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
|
||||
// Explicit disposable local database only; no application initialization or .env loading.
|
||||
$port = (int) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PORT');
|
||||
if ($port <= 0 || getenv('FOLLOWUP_AUDIO_TEST_ALLOW_DISPOSABLE') !== '1') {
|
||||
throw new \RuntimeException('Explicit local disposable MySQL port and FOLLOWUP_AUDIO_TEST_ALLOW_DISPOSABLE=1 required');
|
||||
}
|
||||
$password = (string) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PASSWORD');
|
||||
$database = 'fa_access_' . bin2hex(random_bytes(6));
|
||||
$control = new \PDO("mysql:host=127.0.0.1;port={$port};charset=utf8mb4", 'root', $password,
|
||||
[\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]);
|
||||
$control->exec("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4");
|
||||
new \think\App();
|
||||
set_exception_handler(static function (\Throwable $e): void {
|
||||
fwrite(STDERR, $e->getMessage() . PHP_EOL . $e->getTraceAsString() . PHP_EOL); exit(1);
|
||||
});
|
||||
$app->config->set(['default' => 'mysql', 'connections' => ['mysql' => [
|
||||
'type' => 'mysql', 'hostname' => '127.0.0.1', 'hostport' => 23316,
|
||||
'database' => 'followup_audio_test', 'username' => 'root', 'password' => 'followup_audio_isolated_test',
|
||||
$manager = new \think\DbManager();
|
||||
$manager->setConfig(['default' => 'mysql', 'connections' => ['mysql' => [
|
||||
'type' => 'mysql', 'hostname' => '127.0.0.1', 'hostport' => $port,
|
||||
'database' => $database, 'username' => 'root', 'password' => $password,
|
||||
'charset' => 'utf8mb4', 'prefix' => 'faa_', 'debug' => false,
|
||||
]]], 'database');
|
||||
]]]);
|
||||
\think\Container::getInstance()->instance('think\DbManager', $manager);
|
||||
\think\Container::getInstance()->instance('config', new \think\Config());
|
||||
$db = \think\facade\Db::class;
|
||||
$tables = [
|
||||
'admin' => 'id BIGINT PRIMARY KEY, name VARCHAR(30), root INT, disable INT, delete_time BIGINT NULL',
|
||||
@@ -52,7 +66,7 @@ namespace {
|
||||
$deny = function (callable $f, string $label) use ($ok): void {
|
||||
try { $f(); } catch (\DomainException $e) { $ok(true, $label); return; } $ok(false, $label);
|
||||
};
|
||||
$other = new \PDO('mysql:host=127.0.0.1;port=23316;dbname=followup_audio_test;charset=utf8mb4', 'root', 'followup_audio_isolated_test', [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]);
|
||||
$other = new \PDO("mysql:host=127.0.0.1;port={$port};dbname={$database};charset=utf8mb4", 'root', $password, [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]);
|
||||
try {
|
||||
$ok((int) $a::diagnosis(91, 8, ['root' => 1])['id'] === 91, 'own patient');
|
||||
$deny(fn () => $a::diagnosis(92, 8, ['root' => 1]), 'forged cached root must fail');
|
||||
@@ -80,6 +94,7 @@ namespace {
|
||||
echo "Followup audio real authorization: {$checks} checks passed (isolated MySQL; current-read revocation/reassignment)\n";
|
||||
} finally {
|
||||
try { $db::rollback(); } catch (\Throwable $e) {}
|
||||
foreach (array_keys($tables) as $name) { $db::execute("DROP TABLE IF EXISTS faa_{$name}"); }
|
||||
$manager->connect()->close();
|
||||
$control->exec("DROP DATABASE IF EXISTS `{$database}`");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
|
||||
use app\common\service\followupaudio\FollowupAudioApply as Apply;
|
||||
use app\common\service\followupaudio\FollowupAudioFields as Fields;
|
||||
use app\common\service\followupaudio\FollowupAudioPolicy as Policy;
|
||||
use app\common\service\followupaudio\FollowupAudioProviderConfig as ProviderConfig;
|
||||
use app\common\service\followupaudio\FollowupAudioStore as Store;
|
||||
use think\Container;
|
||||
use think\facade\Db;
|
||||
@@ -37,6 +38,17 @@ $private = $child ? (string) getenv('FOLLOWUP_AUDIO_TEST_PRIVATE') : '/private/t
|
||||
$config->set(['enabled' => true, 'audio_verified' => true, 'verified_profiles' => ['qwen', 'openai'], 'encryption_key' => str_repeat('synthetic-test-key-', 4),
|
||||
'lease_seconds' => 60, 'concurrency' => 1, 'retention_days' => 90, 'private_dir' => $private], 'followup_audio');
|
||||
Container::getInstance()->instance('config', $config);
|
||||
// Explicit synthetic provider identity: no live credentials, application .env or external requests.
|
||||
$testProviders = [];
|
||||
foreach (['qwen', 'openai'] as $profile) {
|
||||
$testProviders[$profile] = ['driver' => 'dify', 'base_url' => 'https://synthetic.invalid/v1',
|
||||
'api_key' => 'synthetic-test-key', 'model' => 'synthetic-audio', 'label' => 'Synthetic ' . $profile];
|
||||
}
|
||||
$config->set(['providers' => $testProviders], 'followup_audio');
|
||||
foreach (array_keys($testProviders) as $profile) {
|
||||
$testProviders[$profile]['verified_fingerprint'] = ProviderConfig::resolve($profile)['fingerprint'];
|
||||
}
|
||||
$config->set(['providers' => $testProviders], 'followup_audio');
|
||||
$root = ['root' => 1, 'admin_id' => 1, 'id' => 1, 'name' => 'Synthetic'];
|
||||
if ($mode === '--claim') { echo json_encode(['id' => Store::claim()['id'] ?? null]) . "\n"; exit(0); }
|
||||
if ($mode === '--create') {
|
||||
@@ -182,9 +194,16 @@ try {
|
||||
$expect(!Store::verified() && !Store::verified('qwen'), 'No profile is implicitly verified');
|
||||
$config->set(['verified_profiles' => ['qwen']], 'followup_audio');
|
||||
$expect(Store::verified('qwen') && !Store::verified('openai'), 'Verification is profile-specific');
|
||||
$unboundProviders = $testProviders; $unboundProviders['qwen']['verified_fingerprint'] = '';
|
||||
$config->set(['providers' => $unboundProviders], 'followup_audio');
|
||||
$expect(!Store::verified('qwen'), 'Flags alone cannot verify an unbound provider');
|
||||
$config->set(['providers' => $testProviders], 'followup_audio');
|
||||
$reject(fn () => Store::create($firstUpload, $recordedAt, 'openai', 1, $root), 'DISABLED_OR_UNVERIFIED');
|
||||
$config->set(['verified_profiles' => ['qwen', 'openai']], 'followup_audio');
|
||||
$a = Store::create($firstUpload, $recordedAt, 'qwen', 1, $root);
|
||||
$binding = json_decode(Store::task($a['task_id'])['upstream_ids_json'], true, 16, JSON_THROW_ON_ERROR);
|
||||
$expect($binding === ['provider_fingerprint' => ProviderConfig::resolve('qwen')['fingerprint']],
|
||||
'New task persists only immutable provider fingerprint, not credentials or endpoint');
|
||||
$expect(Store::create($firstUpload, $recordedAt, 'qwen', 1, $root)['task_id'] === $a['task_id'], 'Repeated create does not enqueue duplicate upstream work');
|
||||
$reject(fn () => Store::create($firstUpload, $recordedAt, 'openai', 1, $root), 'UPLOAD_ALREADY_USED');
|
||||
$differentUpload = $upload(1, 1, $firstUpload);
|
||||
@@ -203,6 +222,18 @@ try {
|
||||
$expect(Store::checkpoint((int) $running['id'], $running['lease_token'], ['stage' => 'uploading', 'upstream_started_at' => time(),
|
||||
'upstream_ids_json' => ['request_id' => 'opaque-test-request']]), 'Upstream started checkpoint persisted');
|
||||
$reject(fn () => Store::checkpoint((int) $running['id'], $running['lease_token'], ['api_key' => 'forbidden']), 'CHECKPOINT_INVALID');
|
||||
$savedIds = json_decode(Store::task((int) $running['id'])['upstream_ids_json'], true, 16, JSON_THROW_ON_ERROR);
|
||||
$expect($savedIds['provider_fingerprint'] === $binding['provider_fingerprint'] && $savedIds['request_id'] === 'opaque-test-request',
|
||||
'Normal upstream checkpoints preserve immutable fingerprint');
|
||||
foreach ([str_repeat('0', 64), '', null] as $replacement) {
|
||||
$reject(fn () => Store::checkpoint((int) $running['id'], $running['lease_token'],
|
||||
['upstream_ids_json' => ['provider_fingerprint' => $replacement]]), 'CHECKPOINT_INVALID');
|
||||
}
|
||||
$expect(Store::checkpoint((int) $running['id'], $running['lease_token'], ['upstream_ids_json' => '{}'])
|
||||
&& json_decode(Store::task((int) $running['id'])['upstream_ids_json'], true)['provider_fingerprint'] === $binding['provider_fingerprint'],
|
||||
'An empty checkpoint cannot delete task binding');
|
||||
$expect(Store::checkpoint((int) $running['id'], $running['lease_token'], ['upstream_ids_json' => $binding]),
|
||||
'Transport may echo exactly the original immutable fingerprint');
|
||||
Db::name('followup_audio_task')->where('id', $running['id'])->update(['lease_until' => time() - 1]);
|
||||
$other = Store::claim();
|
||||
$expect(Store::task((int) $running['id'])['status'] === 'needs_reconciliation', 'Expired attempted request cannot be resubmitted');
|
||||
@@ -223,6 +254,51 @@ try {
|
||||
$parallelClaim = Store::claim();
|
||||
$expect((int) $parallelClaim['id'] === $parallelCreated[0]['id'], 'Concurrent dedupe leaves exactly one queued upstream operation');
|
||||
Store::fail((int) $parallelClaim['id'], $parallelClaim['lease_token'], 'LOCAL_PROBE_FAILED', '');
|
||||
// Queue bindings survive model switches; changed configuration never silently reroutes old audio.
|
||||
$oldTask = Store::create($upload(), $recordedAt, 'qwen', 1, $root);
|
||||
Db::name('followup_audio_task')->where('id', $oldTask['id'])->update(['upstream_ids_json' => '{}']);
|
||||
$expect(Store::claim() === null, 'Legacy task without fingerprint is never claimed');
|
||||
$legacy = Store::task($oldTask['id']);
|
||||
$expect($legacy['status'] === 'failed' && $legacy['error_code'] === 'PROVIDER_CONFIGURATION_CHANGED'
|
||||
&& (int) $legacy['attempts'] === 0 && !Store::summary($legacy)['can_retry'], 'Legacy binding failure is visible and cannot retry');
|
||||
$changedQueued = Store::create($upload(), $recordedAt, 'qwen', 1, $root);
|
||||
$changedProviders = $testProviders; $changedProviders['qwen']['model'] = 'synthetic-changed-model';
|
||||
$config->set(['providers' => $changedProviders], 'followup_audio');
|
||||
$expect(!Store::verified('qwen') && Store::claim() === null, 'Changed unverified model cannot consume queued audio');
|
||||
$changedRow = Store::task($changedQueued['id']);
|
||||
$expect($changedRow['status'] === 'failed' && $changedRow['error_code'] === 'PROVIDER_CONFIGURATION_CHANGED'
|
||||
&& (int) $changedRow['upstream_started_at'] === 0, 'Configuration drift fails before upstream intent');
|
||||
$changedProviders['qwen']['verified_fingerprint'] = ProviderConfig::resolve('qwen')['fingerprint'];
|
||||
$config->set(['providers' => $changedProviders], 'followup_audio');
|
||||
$expect(Store::verified('qwen') && !Store::summary($changedRow)['can_retry'], 'Reverified new provider does not unlock old task retry');
|
||||
$providerMismatch = false;
|
||||
try { Store::retry($changedQueued['id']); }
|
||||
catch (\app\common\service\followupaudio\FollowupAudioException $error) { $providerMismatch = $error->errorCode === 'PROVIDER_CONFIGURATION_CHANGED'; }
|
||||
$expect($providerMismatch, 'Retry reports stable provider-binding error rather than rerouting');
|
||||
$config->set(['providers' => $testProviders], 'followup_audio');
|
||||
$expect(Store::retry($changedQueued['id'])['status'] === 'queued', 'Explicit restoration of exact original provider allows safe pre-request retry');
|
||||
$restored = Store::claim();
|
||||
Store::fail((int) $restored['id'], $restored['lease_token'], 'LOCAL_PROBE_FAILED', '');
|
||||
$switchUpload = $upload(); $switchTask = Store::create($switchUpload, $recordedAt, 'qwen', 1, $root);
|
||||
$switchClaim = Store::claim();
|
||||
Store::checkpoint($switchTask['id'], $switchClaim['lease_token'], ['upstream_started_at' => time()]);
|
||||
$config->set(['providers' => $changedProviders], 'followup_audio');
|
||||
$expect(!Store::heartbeat($switchTask['id'], $switchClaim['lease_token'])
|
||||
&& !Store::checkpoint($switchTask['id'], $switchClaim['lease_token'], ['stage' => 'analyzing'])
|
||||
&& !Store::complete($switchTask['id'], $switchClaim['lease_token'], []),
|
||||
'Heartbeat checkpoint and complete independently fence provider drift');
|
||||
$config->set(['enabled' => false, 'audio_verified' => false], 'followup_audio');
|
||||
$expect(Store::fail($switchTask['id'], $switchClaim['lease_token'], 'PROVIDER_CONFIGURATION_CHANGED', 'must not persist', false),
|
||||
'Configuration and feature withdrawal cannot prevent fenced failure persistence');
|
||||
$failedSwitch = Store::task($switchTask['id']);
|
||||
$expect($failedSwitch['status'] === 'needs_reconciliation' && $failedSwitch['lease_token'] === '',
|
||||
'Attempted old-provider work is held for reconciliation, not left running');
|
||||
$config->set(['enabled' => true, 'audio_verified' => true], 'followup_audio');
|
||||
$switchReuse = Store::create($upload(1, 1, $switchUpload), $recordedAt, 'qwen', 1, $root);
|
||||
$expect($switchReuse['id'] === $switchTask['id'] && $switchReuse['reused']
|
||||
&& $switchReuse['status'] === 'needs_reconciliation' && str_contains($switchReuse['reuse_message'], '配置'),
|
||||
'Reverified provider switch plus reupload cannot bypass unknown-request dedupe');
|
||||
$config->set(['providers' => $testProviders], 'followup_audio');
|
||||
$makeReview = static function (array $extraction, int $diagnosisId = 1, int $actor = 1) use ($upload, $recordedAt, $root): array {
|
||||
$created = Store::create($upload($diagnosisId, $actor), $recordedAt, 'qwen', $actor, $root);
|
||||
$claim = Store::claim();
|
||||
|
||||
@@ -51,7 +51,7 @@ $expectError = static function (callable $call, string $code, bool $uncertain =
|
||||
$expect(!str_contains($e->getMessage(), 'private-body') && !str_contains($e->getMessage(), 'synthetic-test-key'), 'redacted error');
|
||||
}
|
||||
};
|
||||
$settings = ['enabled' => true, 'audio_verified' => true, 'verified_profiles' => ['qwen', 'openai'], 'ffprobe' => 'ffprobe', 'request_timeout' => 5, 'max_seconds' => 3600];
|
||||
$settings = ['allow_insecure_synthetic' => true, 'enabled' => true, 'audio_verified' => true, 'verified_profiles' => ['qwen', 'openai'], 'ffprobe' => 'ffprobe', 'request_timeout' => 5, 'max_seconds' => 3600];
|
||||
$adapter = static function (string $scenario, array $extra = []) use ($port, $settings): Dify {
|
||||
return new Dify(null, $extra + $settings, ['base_url' => 'http://127.0.0.1:' . $port . '/' . $scenario . '/v1',
|
||||
'models' => ['qwen' => ['api_key' => 'synthetic-test-key'], 'openai' => ['api_key' => 'synthetic-test-key']]]);
|
||||
@@ -115,8 +115,12 @@ try {
|
||||
$expectError(static fn () => $unverified->analyze([], $heartbeat), 'AUDIO_NOT_VERIFIED');
|
||||
$notVerifiedProfile = new Dify($testTransport, ['verified_profiles' => ['qwen']] + $settings, $provider);
|
||||
$expectError(static fn () => $notVerifiedProfile->analyze(['model_key' => 'openai'], $heartbeat), 'AUDIO_NOT_VERIFIED');
|
||||
$safeProvider = ['base_url' => 'https://synthetic.invalid/v1', 'models' => ['qwen' => ['api_key' => 'synthetic-test-key']]];
|
||||
$verifiedSettings = $settings;
|
||||
$verifiedSettings['providers']['qwen']['verified_fingerprint'] = \app\common\service\followupaudio\FollowupAudioProviderConfig::resolve('qwen', $settings, $safeProvider)['fingerprint'];
|
||||
$verifiedGuarded = new Dify($testTransport, $verifiedSettings, $safeProvider);
|
||||
$guarded = new Dify($testTransport, $settings, $provider);
|
||||
$expectError(static fn () => $guarded->analyze(['upstream_started_at' => 1, 'model_key' => 'qwen'], $heartbeat), 'RECONCILIATION_REQUIRED', true);
|
||||
$expectError(static fn () => $verifiedGuarded->analyze(['upstream_started_at' => 1, 'model_key' => 'qwen'], $heartbeat), 'RECONCILIATION_REQUIRED', true);
|
||||
$expectError(static fn () => $guarded->probe($wave, $fixture + ['irrelevant' => 'x'], 'unsupported', $heartbeat), 'INVALID_PROFILE');
|
||||
$badFixture = $fixture; $badFixture['sha256'] = str_repeat('0', 64);
|
||||
$expectError(static fn () => $guarded->probe($wave, $badFixture, 'qwen', $heartbeat), 'AUDIO_INVALID');
|
||||
|
||||
@@ -27,7 +27,9 @@ expectEndpoint(str_contains($controller, 'count($items) > 500'), 'bounded review
|
||||
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' => array_values(array_filter("), 'only verified models advertised');
|
||||
expectEndpoint(str_contains($logic, "'models' => ProviderConfig::publicModels()"), 'only fingerprint-verified server model labels advertised');
|
||||
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');
|
||||
expectEndpoint(str_contains($logic, "new \\DateTimeZone('Asia/Shanghai')"), 'explicit local date anchor timezone');
|
||||
expectEndpoint(str_contains($stream, 'private, no-store, max-age=0'), 'private playback response');
|
||||
|
||||
@@ -64,12 +64,31 @@ $makeTask = static function (string $path, string $extension) use ($directory):
|
||||
'file_name' => 'synthetic.' . $extension, 'extension' => $extension, 'total_bytes' => filesize($target),
|
||||
'received_bytes' => filesize($target), 'sha256' => $hash, 'duration_seconds' => $duration,
|
||||
'status' => 'complete', 'created_at' => time(), 'expires_at' => time() + 86400]);
|
||||
return ['id' => 1, 'upload_id' => $id, 'diagnosis_id' => 91, 'actor_id' => 7, 'model_key' => 'qwen',
|
||||
return ['upstream_ids_json' => '{}', 'id' => 1, 'upload_id' => $id, 'diagnosis_id' => 91, 'actor_id' => 7, 'model_key' => 'qwen',
|
||||
'recorded_at' => '2026-09-29 10:00:00', 'sha256' => $hash, 'duration_seconds' => $duration, 'path' => $target];
|
||||
};
|
||||
$adapter = static function (string $scenario, array $overrides = []) use ($port, $settings): Dify {
|
||||
return new Dify(null, $overrides + $settings, ['base_url' => 'http://127.0.0.1:' . $port . '/' . $scenario . '/v1',
|
||||
'models' => ['qwen' => ['api_key' => 'synthetic-test-key']]]);
|
||||
$provider = ['base_url' => 'https://synthetic.invalid/' . $scenario . '/v1',
|
||||
'models' => ['qwen' => ['api_key' => 'synthetic-test-key']]];
|
||||
$effective = $overrides + $settings;
|
||||
// Keep a stable task identity while varying transport scenarios; the test seam owns scenario routing.
|
||||
$provider['base_url'] = 'https://synthetic.invalid/v1';
|
||||
$effective['providers']['qwen']['verified_fingerprint'] = \app\common\service\followupaudio\FollowupAudioProviderConfig::resolve('qwen', $effective, $provider)['fingerprint'];
|
||||
$adapter = null;
|
||||
$transport = static function (array $spec, callable $heartbeat) use (&$adapter, $port, $scenario): array {
|
||||
$spec['url'] = str_replace('https://synthetic.invalid', 'http://127.0.0.1:' . $port . '/' . $scenario, $spec['url']);
|
||||
// Reflection is a test seam only; production cURL still verifies HTTPS certificates.
|
||||
$curl = new ReflectionMethod(Dify::class, 'curl');
|
||||
return $curl->invoke($adapter, $spec, $heartbeat);
|
||||
};
|
||||
$adapter = new Dify($transport, $effective, $provider);
|
||||
return $adapter;
|
||||
};
|
||||
$taskFingerprint = \app\common\service\followupaudio\FollowupAudioProviderConfig::resolve('qwen', $settings,
|
||||
['base_url' => 'https://synthetic.invalid/v1', 'models' => ['qwen' => ['api_key' => 'synthetic-test-key']]])['fingerprint'];
|
||||
$bindTask = static function (array $task) use ($taskFingerprint): array {
|
||||
$task['upstream_ids_json'] = json_encode(['provider_fingerprint' => $taskFingerprint]);
|
||||
return $task;
|
||||
};
|
||||
$requests = static fn (): array => is_file($directory . '/requests.jsonl') ? array_map(static fn (string $line): array =>
|
||||
json_decode($line, true), file($directory . '/requests.jsonl', FILE_IGNORE_NEW_LINES)) : [];
|
||||
@@ -87,7 +106,7 @@ try {
|
||||
file_put_contents($directory . '/source.amr', "#!AMR\n" . str_repeat("\x3c" . str_repeat("\0", 31), 150));
|
||||
$tasks = [];
|
||||
foreach (['wav', 'm4a', 'mp3', 'amr'] as $extension) {
|
||||
$task = $makeTask($directory . '/source.' . $extension, $extension); $tasks[$extension] = $task;
|
||||
$task = $bindTask($makeTask($directory . '/source.' . $extension, $extension)); $tasks[$extension] = $task;
|
||||
$events = []; $copies = [];
|
||||
$heartbeat = static function (array $fields = []) use (&$events, &$copies, $task): bool {
|
||||
$events[] = $fields;
|
||||
@@ -140,7 +159,7 @@ try {
|
||||
$expect(count($requests()) === $before + 2 && glob(dirname($wave['path']) . '/processing.*') === [],
|
||||
'unknown HTTP result is not resent and processing copy is erased');
|
||||
$expect(hash_file('sha256', $wave['path']) === $wave['sha256'], 'unknown result leaves original intact');
|
||||
$makeWav($directory . '/long.wav', 3600); $long = $makeTask($directory . '/long.wav', 'wav');
|
||||
$makeWav($directory . '/long.wav', 3600); $long = $bindTask($makeTask($directory . '/long.wav', 'wav'));
|
||||
$copyMetadata = [];
|
||||
$adapter('media-long')->analyze($long, static function (array $fields = []) use ($long, &$copyMetadata): bool {
|
||||
if (isset($fields['upstream_started_at'])) {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/** Synthetic bytes, real ffmpeg/ffprobe, injected HTTP response; no network, .env or patient data. */
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
|
||||
use app\common\service\followupaudio\FollowupAudioDify as Adapter;
|
||||
use app\common\service\followupaudio\FollowupAudioProviderConfig as Provider;
|
||||
use app\common\service\followupaudio\FollowupAudioException as AudioError;
|
||||
new think\App();
|
||||
$directory = sys_get_temp_dir() . '/followup-audio-openai-' . bin2hex(random_bytes(6)); mkdir($directory, 0700, true);
|
||||
$pdo = new PDO('sqlite:' . $directory . '/dictionary.sqlite');
|
||||
$pdo->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' => $directory . '/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++; };
|
||||
$expectError = static function (callable $call, string $code, bool $uncertain = false) use ($expect): void {
|
||||
try { $call(); } catch (AudioError $error) {
|
||||
$expect($error->errorCode === $code && $error->uncertain === $uncertain, 'expected ' . $code . ', got ' . $error->errorCode); return;
|
||||
}
|
||||
throw new RuntimeException('Expected ' . $code);
|
||||
};
|
||||
$slot = ['driver' => 'openai_audio', 'base_url' => 'https://synthetic.invalid/v1', 'api_key' => 'synthetic-key', 'model' => 'audio-model-from-config'];
|
||||
$settings = ['enabled' => true, 'audio_verified' => true, 'verified_profiles' => ['qwen'], 'providers' => ['qwen' => $slot],
|
||||
'max_seconds' => 3600, 'ffmpeg' => 'ffmpeg', 'ffprobe' => 'ffprobe', 'request_timeout' => 5];
|
||||
$fingerprint = Provider::resolve('qwen', $settings, [])['fingerprint'];
|
||||
$settings['providers']['qwen']['verified_fingerprint'] = $fingerprint;
|
||||
$wave = $directory . '/short.wav';
|
||||
$samples = str_repeat(pack('v', 0), 48000);
|
||||
$bytes = 'RIFF' . pack('V', 36 + strlen($samples)) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 1, 16000, 32000, 2, 16)
|
||||
. 'data' . pack('V', strlen($samples)) . $samples;
|
||||
file_put_contents($wave, $bytes);
|
||||
$fixture = ['synthetic' => true, 'generator' => 'followup-audio-synthetic-v1', 'case' => 'short', 'sha256' => hash_file('sha256', $wave), 'duration_seconds' => 3];
|
||||
$raw = ['schema_version' => 'followup-audio-v1', 'audio_processed' => true, 'summary' => '合成事实',
|
||||
'transcript' => '昨天晚上九点,收缩压126,舒张压82。', 'uncertainties' => [], 'items' => [[
|
||||
'kind' => 'blood', 'values' => ['systolic_pressure' => 126, 'diastolic_pressure' => 82],
|
||||
'record_date' => null, 'record_time' => '21:00:00', 'time_period' => null, 'time_estimated' => false,
|
||||
'date_text' => '昨天', 'time_text' => '晚上九点', 'evidence' => [['text' => '昨天晚上九点,收缩压126,舒张压82。']], 'needs_review' => false,
|
||||
]]];
|
||||
$events = []; $requests = []; $mode = 'success';
|
||||
$heartbeat = static function (array $fields = []) use (&$events): bool { $events[] = $fields; return true; };
|
||||
$transport = static function (array $spec) use (&$requests, &$events, &$mode, $raw, $expect): array {
|
||||
$expect(!empty(end($events)['upstream_started_at']), 'intent is durable before a single chat request');
|
||||
$requests[] = $spec;
|
||||
$answer = $raw;
|
||||
if ($mode === 'not_read') { $answer['audio_processed'] = false; }
|
||||
$content = $mode === 'text_only' ? '没有收到可读取音频附件' : json_encode($answer, JSON_UNESCAPED_UNICODE);
|
||||
$response = ['id' => 'synthetic-completion-id', 'choices' => [['finish_reason' => $mode === 'truncated' ? 'length' : 'stop', 'message' => ['role' => 'assistant', 'content' => $content]]]];
|
||||
if ($mode === 'malformed_choices') { $response['choices'] = 'invalid'; }
|
||||
return ['http_code' => $mode === 'unknown' ? 504 : ($mode === 'rejected' ? 400 : 200),
|
||||
'errno' => 0, 'request_id' => 'synthetic-request-id', 'body' => json_encode($response)];
|
||||
};
|
||||
$adapter = new Adapter($transport, $settings, []);
|
||||
try {
|
||||
$result = $adapter->probe($wave, $fixture, 'qwen', $heartbeat);
|
||||
$spec = $requests[0]; $content = $spec['json']['messages'][0]['content'];
|
||||
$expect(count($requests) === 1 && $spec['url'] === 'https://synthetic.invalid/v1/chat/completions', 'one explicit OpenAI endpoint, no upload or Dify fallback');
|
||||
$expect($spec['json']['model'] === $slot['model'], 'selected model actually sent');
|
||||
$expect($content[1]['type'] === 'input_audio' && $content[1]['input_audio']['format'] === 'wav'
|
||||
&& base64_decode($content[1]['input_audio']['data'], true) === $bytes, 'exact original WAV bytes attached');
|
||||
$expect($result['items'][0]['record_date'] === '2026-09-28', 'same policy and temporal normalization');
|
||||
$ids = json_decode(end($events)['upstream_ids_json'], true);
|
||||
$expect($ids['provider_fingerprint'] === $fingerprint && $ids['message_id'] === 'synthetic-completion-id'
|
||||
&& $ids['upstream_request_id'] === 'synthetic-request-id', 'response ID and bound identity checkpointed');
|
||||
foreach (['not_read' => 'AUDIO_NOT_PROCESSED', 'text_only' => 'UPSTREAM_SCHEMA_INVALID', 'truncated' => 'UPSTREAM_SCHEMA_INVALID',
|
||||
'malformed_choices' => 'UPSTREAM_SCHEMA_INVALID', 'rejected' => 'UPSTREAM_AUDIO_REJECTED', 'unknown' => 'UPSTREAM_UNCERTAIN'] as $scenario => $code) {
|
||||
$mode = $scenario; $before = count($requests);
|
||||
$expectError(static fn () => $adapter->probe($wave, $fixture, 'qwen', $heartbeat), $code, $scenario === 'unknown');
|
||||
$expect(count($requests) === $before + 1, 'HTTP success/rejection/unknown never causes fallback or resend');
|
||||
$ids = json_decode(end($events)['upstream_ids_json'], true);
|
||||
$expect($ids['provider_fingerprint'] === $fingerprint && $ids['message_id'] === 'synthetic-completion-id', 'failed response retains identity and billable ID');
|
||||
}
|
||||
$mode = 'success';
|
||||
$http = $settings; $http['providers']['qwen']['base_url'] = 'http://synthetic.invalid/v1';
|
||||
$before = count($requests);
|
||||
$expectError(static fn () => (new Adapter($transport, $http, []))->probe($wave, $fixture, 'qwen', $heartbeat), 'HTTPS_REQUIRED');
|
||||
$http['allow_insecure_synthetic'] = true;
|
||||
(new Adapter($transport, $http, []))->probe($wave, $fixture, 'qwen', $heartbeat);
|
||||
$expect(count($requests) === $before + 1, 'HTTP requires explicit synthetic-only switch');
|
||||
$expectError(static fn () => (new Adapter($transport, $http, []))->analyze(['model_key' => 'qwen'], $heartbeat), 'AUDIO_NOT_VERIFIED');
|
||||
$before = count($requests);
|
||||
$expectError(static fn () => $adapter->analyze(['model_key' => 'qwen', 'upstream_started_at' => 1], $heartbeat), 'RECONCILIATION_REQUIRED', true);
|
||||
$expect(count($requests) === $before, 'started tasks never resend');
|
||||
$analyze = new ReflectionMethod(Adapter::class, 'analyzeFile');
|
||||
$task = ['id' => 'synthetic-task', 'recorded_at' => '2026-09-29 10:00:00', 'model_key' => 'qwen',
|
||||
'sha256' => $fixture['sha256'], 'duration_seconds' => 3, 'upstream_ids_json' => '{}'];
|
||||
$expectError(static fn () => $analyze->invoke($adapter, $wave, $task, $heartbeat), 'PROVIDER_CONFIGURATION_CHANGED');
|
||||
$task['upstream_ids_json'] = json_encode(['provider_fingerprint' => str_repeat('0', 64)]);
|
||||
$expectError(static fn () => $analyze->invoke($adapter, $wave, $task, $heartbeat), 'PROVIDER_CONFIGURATION_CHANGED');
|
||||
$expect(count($requests) === $before, 'missing or changed provider snapshot fails before sending');
|
||||
$task['upstream_ids_json'] = json_encode(['provider_fingerprint' => $fingerprint]);
|
||||
// M4A and AMR must fully normalize to MP3, not be mislabeled input_audio formats.
|
||||
$p = proc_open(['ffmpeg', '-nostdin', '-v', 'error', '-i', $wave, '-c:a', 'aac', $directory . '/short.m4a'], [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
|
||||
fclose($pipes[0]); stream_get_contents($pipes[1]); stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]);
|
||||
$expect(proc_close($p) === 0, 'synthetic M4A generated');
|
||||
file_put_contents($directory . '/short.amr', "#!AMR\n" . str_repeat("\x3c" . str_repeat("\0", 31), 150));
|
||||
foreach (['m4a', 'amr'] as $extension) {
|
||||
$path = $directory . '/short.' . $extension; $hash = hash_file('sha256', $path); $task['sha256'] = $hash;
|
||||
$analyze->invoke($adapter, $path, $task, $heartbeat);
|
||||
$audio = end($requests)['json']['messages'][0]['content'][1]['input_audio'];
|
||||
$expect($audio['format'] === 'mp3' && base64_decode($audio['data'], true) !== false, $extension . ' complete MP3 processing payload');
|
||||
$expect(hash_file('sha256', $path) === $hash && glob($directory . '/processing.*') === [], $extension . ' original preserved and private copy cleaned');
|
||||
}
|
||||
$source = file_get_contents(dirname(__DIR__) . '/app/common/service/followupaudio/FollowupAudioDify.php');
|
||||
$expect(str_contains($source, 'CURLOPT_SSL_VERIFYPEER => true') && str_contains($source, 'CURLOPT_SSL_VERIFYHOST => 2'), 'TLS validation is never disabled');
|
||||
echo 'FOLLOWUP_AUDIO_OPENAI assertions=' . $checks . ' PASS real_input_audio=1 model_configurable=1 no_text_fallback=1 no_resend=1 https_patient_gate=1 full_normalization=1' . PHP_EOL;
|
||||
} finally {
|
||||
$manager->connect()->close(); $pdo = null;
|
||||
foreach (glob($directory . '/*') ?: [] as $path) { if (is_file($path)) { unlink($path); } }
|
||||
rmdir($directory);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ namespace app\common\service\followupaudio {
|
||||
/** Command-state test double. Actual HTTP/audio behavior is covered by FollowupAudioDifyTest. */
|
||||
final class FollowupAudioDify {
|
||||
public static string $mode = 'uncertain';
|
||||
public static string $fingerprint = 'synthetic-application-one';
|
||||
public static string $fingerprint = '1111111111111111111111111111111111111111111111111111111111111111';
|
||||
public static int $calls = 0;
|
||||
public function configurationStatus(string $profile): array { return ['configured' => true, 'profile' => $profile, 'code' => 'OK', 'application_fingerprint' => self::$fingerprint]; }
|
||||
public function probe(string $path, array $fixture, string $profile, callable $heartbeat): array {
|
||||
@@ -24,6 +24,8 @@ namespace {
|
||||
use app\command\FollowupAudioProbe as Probe;
|
||||
use think\console\Input;
|
||||
use think\console\Output;
|
||||
new think\App(); // No initialization, real config or DB.
|
||||
$safeConfig = new think\Config(); think\Container::getInstance()->instance('config', $safeConfig);
|
||||
$directory = sys_get_temp_dir() . '/followup-audio-probe-state-' . bin2hex(random_bytes(6));
|
||||
mkdir($directory, 0700, true);
|
||||
$directory = realpath($directory);
|
||||
@@ -52,26 +54,42 @@ namespace {
|
||||
$expect($code === 1 && Dify::$calls === 0 && str_contains($stdout, 'SYNTHETIC_ACK_REQUIRED'), 'explicit synthetic acknowledgement required');
|
||||
[$code, $stdout] = $run('qwen');
|
||||
$expect($code === 2 && Dify::$calls === 1 && str_contains($stdout, 'needs_reconciliation'), 'unknown result stops next cases');
|
||||
$report = json_decode(file_get_contents($directory . '/probe-qwen.json'), true);
|
||||
$report = json_decode(file_get_contents($directory . '/probe-qwen-' . Dify::$fingerprint . '.json'), true);
|
||||
$expect($report['cases']['short']['upstream_started_at'] > 0, 'intent persisted before request');
|
||||
[$code, $stdout] = $run('qwen');
|
||||
$expect($code === 2 && Dify::$calls === 1 && str_contains($stdout, 'NO_RESUBMISSION'), 'resume never recharges unknown result');
|
||||
$currentQwenPath = $directory . '/probe-qwen-' . Dify::$fingerprint . '.json';
|
||||
$legacyQwenPath = $directory . '/probe-qwen.json';
|
||||
rename($currentQwenPath, $legacyQwenPath);
|
||||
$legacyBytes = file_get_contents($legacyQwenPath);
|
||||
[$code, $stdout] = $run('qwen');
|
||||
$expect($code === 2 && Dify::$calls === 1 && str_contains($stdout, 'NO_RESUBMISSION'), 'matching legacy identity remains authoritative for unknown requests');
|
||||
$expect(file_get_contents($legacyQwenPath) === $legacyBytes, 'legacy unknown evidence retained unchanged');
|
||||
$legacyReport = json_decode($legacyBytes, true);
|
||||
$legacyReport['configuration']['application_fingerprint'] = hash('sha256', "https://synthetic.invalid/v1\0synthetic-key");
|
||||
file_put_contents($legacyQwenPath, json_encode($legacyReport));
|
||||
$safeConfig->set(['providers' => ['qwen' => ['driver' => 'dify', 'base_url' => 'https://synthetic.invalid/v1', 'api_key' => 'synthetic-key']]], 'followup_audio');
|
||||
[$code, $stdout] = $run('qwen');
|
||||
$expect($code === 2 && Dify::$calls === 1 && str_contains($stdout, 'NO_RESUBMISSION'), 'legacy Dify hash upgrade cannot resubmit same-app unknown');
|
||||
$safeConfig->set([], 'followup_audio');
|
||||
|
||||
Dify::$mode = 'success';
|
||||
[$code, $stdout] = $run('openai');
|
||||
$expect($code === 0 && Dify::$calls === 4, 'three lengths passed sequentially');
|
||||
$bytes = file_get_contents($directory . '/probe-openai.json');
|
||||
$bytes = file_get_contents($directory . '/probe-openai-' . Dify::$fingerprint . '.json');
|
||||
$report = json_decode($bytes, true);
|
||||
$expect($report['audio_verified'] && count($report['cases']) === 3, 'all three required before verification report');
|
||||
$expect(!str_contains($bytes . $stdout, 'PRIVATE_TRANSCRIPT_NEVER_PRINTED'), 'no transcript in output or report');
|
||||
[$code, $stdout] = $run('openai');
|
||||
$expect($code === 0 && Dify::$calls === 4 && substr_count($stdout, 'retained_passed') === 3, 'passed gate results reused without resending');
|
||||
Dify::$fingerprint = 'synthetic-application-two';
|
||||
$oldPath = $directory . '/probe-openai-' . Dify::$fingerprint . '.json';
|
||||
Dify::$fingerprint = '2222222222222222222222222222222222222222222222222222222222222222';
|
||||
[$code, $stdout] = $run('openai');
|
||||
$expect($code === 1 && Dify::$calls === 4 && str_contains($stdout, 'PROBE_APPLICATION_CHANGED'), 'new application cannot inherit old gate');
|
||||
$expect(file_get_contents($directory . '/probe-openai.json') === $bytes, 'application mismatch leaves original evidence unchanged');
|
||||
$expect($code === 0 && Dify::$calls === 7 && !str_contains($stdout, 'retained_passed'), 'new application executes its own gate');
|
||||
$expect(file_get_contents($oldPath) === $bytes, 'application mismatch leaves original evidence unchanged');
|
||||
file_put_contents($directory . '/short.mp3', 'tampered');
|
||||
[$code, $stdout] = $run('qwen');
|
||||
$expect($code === 1 && Dify::$calls === 4 && str_contains($stdout, 'SYNTHETIC_FIXTURE_REQUIRED'), 'tampered fixture cannot run');
|
||||
$expect($code === 1 && Dify::$calls === 7 && str_contains($stdout, 'SYNTHETIC_FIXTURE_REQUIRED'), 'tampered fixture cannot run');
|
||||
echo 'FOLLOWUP_AUDIO_PROBE_COMMAND assertions=' . $checks . ' PASS durable_no_resend=1 retained_results=1 app_identity=1' . PHP_EOL;
|
||||
} finally {
|
||||
foreach (glob($directory . '/*') ?: [] as $file) { if (is_file($file)) { unlink($file); } }
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/** No app initialization, .env, network or real credentials. */
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
|
||||
use app\common\service\followupaudio\FollowupAudioProviderConfig as Provider;
|
||||
use app\common\service\followupaudio\FollowupAudioException as AudioError;
|
||||
new think\App();
|
||||
$config = new think\Config();
|
||||
think\Container::getInstance()->instance('config', $config);
|
||||
$checks = 0;
|
||||
$expect = static function (bool $ok, string $why) use (&$checks): void { if (!$ok) { throw new RuntimeException($why); } $checks++; };
|
||||
$expectError = static function (callable $call, string $code) use ($expect): void {
|
||||
try { $call(); } catch (AudioError $error) { $expect($error->errorCode === $code, $code); return; }
|
||||
throw new RuntimeException('Expected ' . $code);
|
||||
};
|
||||
$legacy = ['base_url' => 'https://legacy.invalid/v1', 'models' => ['qwen' => ['api_key' => 'synthetic-legacy-key', 'name' => 'must-not-inherit']]];
|
||||
$dify = Provider::resolve('qwen', [], $legacy);
|
||||
$expect($dify['driver'] === 'dify' && $dify['base_url'] === $legacy['base_url'] && $dify['model'] === '', 'legacy Dify app identity allowed without fake internal model');
|
||||
$slot = ['driver' => 'openai_audio', 'base_url' => 'https://audio.invalid/v1/chat/completions',
|
||||
'api_key' => 'synthetic-provider-key', 'model' => 'configurable-audio-model', 'label' => 'Audio label'];
|
||||
$settings = ['enabled' => false, 'audio_verified' => true, 'verified_profiles' => ['qwen'], 'providers' => ['qwen' => $slot]];
|
||||
$resolved = Provider::resolve('qwen', $settings, $legacy);
|
||||
$expect($resolved['base_url'] === 'https://audio.invalid/v1' && $resolved['model'] === $slot['model'], 'explicit selected driver model and normalized endpoint');
|
||||
$settings['providers']['qwen']['verified_fingerprint'] = $resolved['fingerprint'];
|
||||
$config->set($settings, 'followup_audio'); $config->set($legacy, 'prescription_ai');
|
||||
$expect(Provider::isVerified('qwen'), 'verified state independent of disabled operational switch');
|
||||
$expect(Provider::publicModels() === [['value' => 'qwen', 'label' => 'Audio label']], 'only verified slots exposed');
|
||||
$status = Provider::status('qwen', $settings, $legacy);
|
||||
$expect(array_keys($status) === ['configured', 'profile', 'code', 'application_fingerprint'], 'safe exact status keys');
|
||||
$public = json_encode([$status, Provider::publicModels()]);
|
||||
$expect(!str_contains($public, 'synthetic-provider-key') && !str_contains($public, 'audio.invalid'), 'public response contains no endpoint or key');
|
||||
foreach (['driver' => 'dify', 'base_url' => 'https://changed.invalid/v1', 'model' => 'another-model', 'api_key' => 'other-synthetic-key'] as $field => $value) {
|
||||
$changed = $settings; $changed['providers']['qwen'][$field] = $value;
|
||||
$expect(!Provider::verified('qwen', $changed, $legacy), 'identity change invalidates ' . $field);
|
||||
}
|
||||
$changed = $settings; $changed['providers']['qwen']['label'] = 'Renamed display';
|
||||
$expect(Provider::verified('qwen', $changed, $legacy), 'display-only label does not change request identity');
|
||||
$changed = $settings; $changed['providers']['qwen']['label'] = '';
|
||||
$expect(Provider::resolve('qwen', $changed, $legacy)['label'] === $slot['model'], 'empty label falls back to configured model');
|
||||
$changed = $settings; $changed['providers']['qwen']['base_url'] = 'http://audio.invalid/v1';
|
||||
$changed['providers']['qwen']['verified_fingerprint'] = Provider::resolve('qwen', $changed, $legacy)['fingerprint'];
|
||||
$changed['allow_insecure_synthetic'] = true;
|
||||
$expect(!Provider::verified('qwen', $changed, $legacy), 'HTTP never becomes production-verified');
|
||||
foreach (['model', 'base_url', 'api_key'] as $field) {
|
||||
$changed = $settings; $changed['providers']['qwen'][$field] = '';
|
||||
$expectError(static fn () => Provider::resolve('qwen', $changed, $legacy), 'CONFIG_MISSING');
|
||||
}
|
||||
foreach (['ftp://audio.invalid/v1', 'https://user:password@audio.invalid/v1', 'https://audio.invalid/v1?token=x',
|
||||
"https://audio.invalid/v1\n", 'https://audio.invalid/v1#fragment'] as $url) {
|
||||
$changed = $settings; $changed['providers']['qwen']['base_url'] = $url;
|
||||
$expectError(static fn () => Provider::resolve('qwen', $changed, []), 'CONFIG_INVALID');
|
||||
}
|
||||
$changed = $settings; $changed['providers']['qwen']['driver'] = 'automatic';
|
||||
$expectError(static fn () => Provider::resolve('qwen', $changed, []), 'CONFIG_INVALID');
|
||||
$changed = $settings; $changed['providers']['qwen']['verified_fingerprint'] = '';
|
||||
$expect(!Provider::verified('qwen', $changed, $legacy), 'old unbound flags do not enable audio');
|
||||
$expect(Provider::status('invalid', $settings, $legacy)['code'] === 'INVALID_PROFILE', 'logical slot allowlist retained');
|
||||
echo 'FOLLOWUP_AUDIO_PROVIDER_CONFIG assertions=' . $checks . ' PASS explicit_driver=1 model_configurable=1 exact_fingerprint=1 https_verified=1 secrets_hidden=1' . PHP_EOL;
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/** No autoloader, app, .env, filesystem inputs, network or DB: public gate must fail closed before I/O. */
|
||||
namespace think\facade {
|
||||
final class Db
|
||||
{
|
||||
public static int $calls = 0;
|
||||
public static function __callStatic(string $name, array $args): never
|
||||
{
|
||||
self::$calls++;
|
||||
throw new \RuntimeException('DATABASE_MUST_NOT_BE_USED');
|
||||
}
|
||||
}
|
||||
}
|
||||
namespace {
|
||||
$settings = [];
|
||||
function config(string $key, $default = null)
|
||||
{
|
||||
global $settings;
|
||||
$value = $settings;
|
||||
foreach (explode('.', $key) as $part) {
|
||||
if (!is_array($value) || !array_key_exists($part, $value)) { return $default; }
|
||||
$value = $value[$part];
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
$root = dirname(__DIR__) . '/app/common/service/followupaudio/';
|
||||
foreach (['Exception', 'ProviderConfig', '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;
|
||||
use app\adminapi\logic\tcm\FollowupAudioLogic as Logic;
|
||||
$checks = 0;
|
||||
$expect = static function (bool $ok, string $why) use (&$checks): void {
|
||||
if (!$ok) { throw new RuntimeException($why); }
|
||||
$checks++;
|
||||
};
|
||||
$expect(!Store::enabled() && !Store::verified() && !Store::verified('qwen'), 'Missing app configuration stays disabled and unverified');
|
||||
$expect(Store::claim() === null, 'Missing app configuration never enters queue transaction');
|
||||
$expect(Providers::publicModels() === [], 'Missing provider configuration publishes no models');
|
||||
try { Store::assertEnabled('qwen'); throw new RuntimeException('Expected disabled gate'); }
|
||||
catch (DomainException $error) { $expect($error->getMessage() === 'FOLLOWUP_AUDIO_DISABLED_OR_UNVERIFIED', 'Stable disabled error'); }
|
||||
try { Logic::requireEnabled(true); throw new RuntimeException('Expected public disabled gate'); }
|
||||
catch (DomainException $error) { $expect($error->getMessage() === '回访录音功能尚未启用', 'Public gate avoids database'); }
|
||||
$settings['followup_audio'] = ['enabled' => true, 'audio_verified' => true, 'verified_profiles' => ['qwen']];
|
||||
$expect(!Store::verified(), 'Unbound flags alone never grant capability');
|
||||
try { Logic::requireEnabled(true); throw new RuntimeException('Expected public unverified gate'); }
|
||||
catch (DomainException $error) {
|
||||
$expect(str_contains($error->getMessage(), '当前服务与模型') && !str_contains($error->getMessage(), 'Dify'), 'Public unverified error is provider-neutral');
|
||||
}
|
||||
$expect(Providers::publicModels() === [], 'Unconfigured flags do not advertise models');
|
||||
$provider = ['driver' => 'openai_audio', 'base_url' => 'https://synthetic.invalid/v1',
|
||||
'api_key' => 'synthetic-test-only', 'model' => 'audio-fixture-v1', 'label' => 'Synthetic audio model'];
|
||||
$settings['followup_audio']['providers']['qwen'] = $provider;
|
||||
$fingerprint = Providers::resolve('qwen')['fingerprint'];
|
||||
$settings['followup_audio']['providers']['qwen']['verified_fingerprint'] = $fingerprint;
|
||||
$task = ['model_key' => 'qwen', 'upstream_ids_json' => json_encode(['provider_fingerprint' => $fingerprint]), 'upstream_started_at' => 0];
|
||||
$expect(Store::verified('qwen') && Store::providerMatches($task), 'Exact approved provider matches bound task');
|
||||
$expect(Providers::publicModels() === [['value' => 'qwen', 'label' => 'Synthetic audio model']], 'Public model list contains only configured value/label');
|
||||
$public = json_encode(Providers::publicModels());
|
||||
$expect(!str_contains($public, 'synthetic.invalid') && !str_contains($public, $provider['api_key']), 'Public labels never expose server URL/key');
|
||||
foreach (['{}', 'not-json', '{"provider_fingerprint":null}', '{"provider_fingerprint":123}'] as $ids) {
|
||||
$unbound = array_replace($task, ['upstream_ids_json' => $ids]);
|
||||
$expect(!Store::providerMatches($unbound), 'Missing malformed or invalid fingerprint cannot be trusted');
|
||||
}
|
||||
foreach (['driver' => 'dify', 'base_url' => 'https://changed.invalid/v1', 'api_key' => 'rotated-synthetic-key', 'model' => 'audio-fixture-v2'] as $field => $changed) {
|
||||
$settings['followup_audio']['providers']['qwen'] = $provider + ['verified_fingerprint' => $fingerprint];
|
||||
$settings['followup_audio']['providers']['qwen'][$field] = $changed;
|
||||
$expect(!Store::verified('qwen') && !Store::providerMatches($task), 'Provider identity drift invalidates capability and task binding: ' . $field);
|
||||
}
|
||||
$expect(\think\facade\Db::$calls === 0, 'All missing-config and provider-binding checks performed without database I/O');
|
||||
echo 'FOLLOWUP_AUDIO_PROVIDER_INTEGRATION assertions=' . $checks . " PASS database_calls=0 app_initialized=0 network_calls=0\n";
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Opt-in, LOCAL ONLY acceptance of user-supplied audio. No ASR, provider calls, application bootstrap or .env.
|
||||
* Usage: php tests/FollowupAudioSampleAcceptanceTest.php --manifest /absolute/private/manifest.json
|
||||
* A manifest must be a private (0600) local JSON file OUTSIDE Git, with this schema:
|
||||
* {"samples":[{"id":"sample-1","path":"/absolute/local/audio.mp3","sha256":"<64 lowercase hex>"}],
|
||||
* "recorded_at_cases":[{"id":"ordinary","recorded_at":"2026-10-07 00:00:00"},
|
||||
* {"id":"month","recorded_at":"2026-03-01 00:00:00"},
|
||||
* {"id":"year","recorded_at":"2026-01-01 00:00:00"},
|
||||
* {"id":"leap","recorded_at":"2024-03-01 00:00:00"}]}
|
||||
* Optional --ffmpeg/--ffprobe select absolute local executables; otherwise PATH is used.
|
||||
* Real paths, recordings, keys and transcripts MUST NOT be added to committed fixtures.
|
||||
* Date candidates below are explicitly SYNTHETIC, never transcripts of the supplied recordings.
|
||||
*/
|
||||
namespace app\common\service\followupaudio {
|
||||
/** Test-only access boundary: upload implementation and SQLite are real; production RBAC is NOT exercised. */
|
||||
final class FollowupAudioAccess
|
||||
{
|
||||
public static function diagnosis(int $id, int $actor, array $info, bool $daily = false): array
|
||||
{
|
||||
if ($id !== 91 || $actor !== 7) { throw new \DomainException('SYNTHETIC_SCOPE_DENIED'); }
|
||||
return ['id' => 91, 'patient_id' => 101];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
namespace {
|
||||
use app\common\service\followupaudio\FollowupAudioDify as Dify;
|
||||
use app\common\service\followupaudio\FollowupAudioPolicy as Policy;
|
||||
use app\common\service\followupaudio\FollowupAudioUpload as Upload;
|
||||
use think\Container;
|
||||
use think\facade\Db;
|
||||
|
||||
function fail(string $code): never { throw new RuntimeException($code); }
|
||||
function expect(bool $condition, string $code): void
|
||||
{
|
||||
if (!$condition) { fail($code); }
|
||||
$GLOBALS['sample_checks']++;
|
||||
}
|
||||
function reject(callable $call, string $code): void
|
||||
{
|
||||
try { $call(); } catch (DomainException | RuntimeException $e) {
|
||||
expect($e->getMessage() === $code, 'WRONG_REJECTION_CODE');
|
||||
return;
|
||||
}
|
||||
fail('EXPECTED_REJECTION');
|
||||
}
|
||||
function exactKeys(array $value, array $keys): bool
|
||||
{
|
||||
$actual = array_keys($value); sort($actual); sort($keys);
|
||||
return $actual === $keys;
|
||||
}
|
||||
function localFile(string $path, string $error): string
|
||||
{
|
||||
if ($path === '' || $path[0] !== '/' || preg_match('/[\x00-\x1f\x7f]/', $path)
|
||||
|| preg_match('~/(?:\.|\.\.)(?:/|$)~', $path) || is_link($path) || !is_file($path) || !is_readable($path)) {
|
||||
fail($error);
|
||||
}
|
||||
$real = realpath($path);
|
||||
if ($real === false) { fail($error); }
|
||||
// Reject symlink components too; no implicit reads through a linked private input.
|
||||
$part = '';
|
||||
foreach (explode('/', ltrim($path, '/')) as $component) {
|
||||
$part .= '/' . $component;
|
||||
if (is_link($part)) { fail($error); }
|
||||
}
|
||||
return $real;
|
||||
}
|
||||
function outsideGit(string $path): bool
|
||||
{
|
||||
for ($dir = dirname($path); ; $dir = dirname($dir)) {
|
||||
if (file_exists($dir . '/.git')) { return false; }
|
||||
if ($dir === dirname($dir)) { return true; }
|
||||
}
|
||||
}
|
||||
function parseManifest(string $json): array
|
||||
{
|
||||
try { $value = json_decode($json, true, 64, JSON_THROW_ON_ERROR); }
|
||||
catch (JsonException $e) { fail('MANIFEST_JSON_INVALID'); }
|
||||
if (!is_array($value) || !exactKeys($value, ['samples', 'recorded_at_cases'])
|
||||
|| !is_array($value['samples']) || !array_is_list($value['samples']) || count($value['samples']) < 1
|
||||
|| count($value['samples']) > 20 || !is_array($value['recorded_at_cases']) || !array_is_list($value['recorded_at_cases'])) {
|
||||
fail('MANIFEST_SCHEMA_INVALID');
|
||||
}
|
||||
$ids = []; $paths = [];
|
||||
foreach ($value['samples'] as &$sample) {
|
||||
if (!is_array($sample) || !exactKeys($sample, ['id', 'path', 'sha256'])
|
||||
|| !is_string($sample['id']) || !preg_match('/^sample-[1-9][0-9]?$/D', $sample['id'])
|
||||
|| isset($ids[$sample['id']]) || !is_string($sample['path'])
|
||||
|| !is_string($sample['sha256']) || !preg_match('/^[a-f0-9]{64}$/D', $sample['sha256'])) {
|
||||
fail('MANIFEST_SAMPLE_INVALID');
|
||||
}
|
||||
$sample['path'] = localFile($sample['path'], 'SAMPLE_PATH_INVALID');
|
||||
if (isset($paths[$sample['path']])) { fail('MANIFEST_SAMPLE_DUPLICATE'); }
|
||||
$ids[$sample['id']] = true; $paths[$sample['path']] = true;
|
||||
}
|
||||
unset($sample);
|
||||
$expected = ['ordinary' => '2026-10-07', 'month' => '2026-03-01', 'year' => '2026-01-01', 'leap' => '2024-03-01'];
|
||||
$dates = [];
|
||||
foreach ($value['recorded_at_cases'] as $case) {
|
||||
if (!is_array($case) || !exactKeys($case, ['id', 'recorded_at']) || !is_string($case['id'])
|
||||
|| !isset($expected[$case['id']]) || isset($dates[$case['id']]) || !is_string($case['recorded_at'])
|
||||
|| substr($case['recorded_at'], 0, 10) !== $expected[$case['id']]) { fail('MANIFEST_DATES_INVALID'); }
|
||||
try { Policy::strictRecordedAt($case['recorded_at']); }
|
||||
catch (DomainException $e) { fail('MANIFEST_DATES_INVALID'); }
|
||||
$dates[$case['id']] = $case['recorded_at'];
|
||||
}
|
||||
if (count($dates) !== count($expected)) { fail('MANIFEST_DATES_INVALID'); }
|
||||
return $value;
|
||||
}
|
||||
function process(array $command): array
|
||||
{
|
||||
$child = proc_open($command, [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
|
||||
if (!is_resource($child)) { fail('DECODE_PROCESS_UNAVAILABLE'); }
|
||||
fclose($pipes[0]); stream_set_blocking($pipes[1], false); stream_set_blocking($pipes[2], false);
|
||||
$stdout = ''; $stderr = ''; $exit = -1; $deadline = microtime(true) + 180;
|
||||
try {
|
||||
do {
|
||||
$stdout .= stream_get_contents($pipes[1]); $stderr .= stream_get_contents($pipes[2]);
|
||||
$state = proc_get_status($child);
|
||||
if (!$state['running']) { $exit = $state['exitcode']; break; }
|
||||
if (microtime(true) > $deadline || strlen($stdout) + strlen($stderr) > 1048576) {
|
||||
proc_terminate($child, 9); fail('DECODE_PROCESS_LIMIT');
|
||||
}
|
||||
usleep(10000);
|
||||
} while (true);
|
||||
$stdout .= stream_get_contents($pipes[1]); $stderr .= stream_get_contents($pipes[2]);
|
||||
} finally {
|
||||
fclose($pipes[1]); fclose($pipes[2]); $closed = proc_close($child);
|
||||
}
|
||||
return ['command' => $command, 'stdout' => $stdout, 'stderr' => $stderr, 'exit_status' => $exit < 0 ? $closed : $exit];
|
||||
}
|
||||
function eraseOwnedTree(string $root): void
|
||||
{
|
||||
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST);
|
||||
foreach ($files as $file) {
|
||||
$file->isDir() && !$file->isLink() ? rmdir($file->getPathname()) : unlink($file->getPathname());
|
||||
}
|
||||
rmdir($root);
|
||||
}
|
||||
|
||||
$GLOBALS['sample_checks'] = 0; $directory = null; $originals = []; $exit = 0; $oldMask = umask(0077);
|
||||
set_error_handler(static function (int $severity): bool {
|
||||
if (!(error_reporting() & $severity)) { return false; }
|
||||
// Never print a warning that might contain a source filename or private input.
|
||||
throw new RuntimeException('LOCAL_IO_FAILED');
|
||||
});
|
||||
try {
|
||||
if ($argc === 1) {
|
||||
echo "SKIP Followup audio real samples: opt in with --manifest outside Git; no files read, no ASR.\n";
|
||||
} else {
|
||||
$options = [];
|
||||
for ($i = 1; $i < $argc; $i += 2) {
|
||||
if (!in_array($argv[$i], ['--manifest', '--ffmpeg', '--ffprobe'], true) || !isset($argv[$i + 1])
|
||||
|| isset($options[$argv[$i]])) { fail('ARGUMENTS_INVALID'); }
|
||||
$options[$argv[$i]] = $argv[$i + 1];
|
||||
}
|
||||
if (!isset($options['--manifest'])) { fail('MANIFEST_REQUIRED'); }
|
||||
$manifestPath = localFile($options['--manifest'], 'MANIFEST_PATH_INVALID');
|
||||
if (!outsideGit($manifestPath)) { fail('MANIFEST_MUST_BE_OUTSIDE_GIT'); }
|
||||
if ((fileperms($manifestPath) & 0777) !== 0600 || filesize($manifestPath) > 65536) { fail('MANIFEST_NOT_PRIVATE_OR_TOO_LARGE'); }
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
|
||||
$manifest = parseManifest(file_get_contents($manifestPath));
|
||||
if (!in_array('sqlite', PDO::getAvailableDrivers(), true)) { fail('SQLITE_REQUIRED_NO_PRODUCTION_FALLBACK'); }
|
||||
$tools = [];
|
||||
foreach (['ffmpeg', 'ffprobe'] as $name) {
|
||||
$tools[$name] = $options['--' . $name] ?? $name;
|
||||
if (isset($options['--' . $name]) && ($tools[$name][0] !== '/' || !is_executable($tools[$name]))) { fail('MEDIA_TOOL_INVALID'); }
|
||||
}
|
||||
$directory = realpath(sys_get_temp_dir()) . '/followup-audio-samples-' . bin2hex(random_bytes(12));
|
||||
if (!mkdir($directory . '/private', 0700, true)) { fail('PRIVATE_DIRECTORY_FAILED'); }
|
||||
new think\App(); // Intentionally NOT initialize(): never loads real app config/services/.env.
|
||||
$database = $directory . '/test.sqlite';
|
||||
$pdo = new PDO('sqlite:' . $database); chmod($database, 0600);
|
||||
$pdo->exec('CREATE TABLE sample_followup_audio_upload (id TEXT PRIMARY KEY, diagnosis_id INT, actor_id INT,
|
||||
file_name TEXT, extension TEXT, total_bytes INT, received_bytes INT, sha256 TEXT, duration_seconds REAL,
|
||||
status TEXT, created_at INT, expires_at INT)');
|
||||
$manager = new think\DbManager();
|
||||
$manager->setConfig(['default' => 'sqlite', 'connections' => ['sqlite' => [
|
||||
'type' => 'sqlite', 'database' => $database, 'prefix' => 'sample_']]]);
|
||||
Container::getInstance()->instance('think\DbManager', $manager);
|
||||
$settings = ['enabled' => false, 'audio_verified' => false, 'verified_profiles' => [],
|
||||
'private_dir' => $directory . '/private', 'ffmpeg' => $tools['ffmpeg'], 'ffprobe' => $tools['ffprobe'],
|
||||
'max_bytes' => 524288000, 'max_seconds' => 3600, 'chunk_bytes' => 65536,
|
||||
'upstream_max_bytes' => 20971520, 'normalize_timeout' => 120];
|
||||
$config = new think\Config(); $config->set($settings, 'followup_audio');
|
||||
Container::getInstance()->instance('config', $config);
|
||||
$networkAttempts = 0;
|
||||
$adapter = new Dify(static function () use (&$networkAttempts): never { $networkAttempts++; fail('NETWORK_FORBIDDEN'); }, $settings, []);
|
||||
$inspect = new ReflectionMethod(Dify::class, 'inspectAudio');
|
||||
$prepare = new ReflectionMethod(Dify::class, 'prepareAudio');
|
||||
$inspect->setAccessible(true); $prepare->setAccessible(true);
|
||||
$media = [];
|
||||
foreach ($manifest['samples'] as $sample) {
|
||||
$path = $sample['path']; $hash = (string) hash_file('sha256', $path); $originals[$path] = $hash;
|
||||
expect(hash_equals($sample['sha256'], $hash), 'INPUT_HASH_MISMATCH');
|
||||
[, $extension] = Upload::fileName('sample.' . strtolower(pathinfo($path, PATHINFO_EXTENSION)));
|
||||
$baseline = Upload::inspect($path, $extension);
|
||||
$session = Upload::createSession(91, $sample['id'] . '.' . $extension, filesize($path), 7, []);
|
||||
$source = fopen($path, 'rb'); $chunks = 0;
|
||||
try {
|
||||
while (!feof($source)) {
|
||||
$contents = fread($source, $session['chunk_bytes']);
|
||||
if ($contents === '') { break; }
|
||||
file_put_contents($directory . '/chunk', $contents); chmod($directory . '/chunk', 0600);
|
||||
Upload::putChunk($session['upload_id'], $chunks++, $directory . '/chunk', 7, []);
|
||||
}
|
||||
} finally { fclose($source); }
|
||||
$result = Upload::complete($session['upload_id'], 7, []);
|
||||
expect(hash_equals($hash, $result['sha256']), 'REASSEMBLY_HASH_MISMATCH');
|
||||
expect(Upload::complete($session['upload_id'], 7, []) === $result, 'COMPLETION_NOT_IDEMPOTENT');
|
||||
$assembled = Upload::path(Upload::session($session['upload_id']));
|
||||
expect(hash_file('sha256', $assembled) === $hash, 'ASSEMBLED_BYTES_CHANGED');
|
||||
expect(abs($baseline['duration_seconds'] - $result['duration_seconds']) < 0.001, 'ASSEMBLED_DURATION_CHANGED');
|
||||
$decode = process([$tools['ffmpeg'], '-nostdin', '-hide_banner', '-v', 'error', '-xerror',
|
||||
'-protocol_whitelist', 'file,pipe', '-threads', '1', '-i', $assembled, '-map', '0:a:0',
|
||||
'-vn', '-sn', '-dn', '-f', 'null', '-']);
|
||||
expect($decode['exit_status'] === 0, 'FULL_DECODE_FAILED');
|
||||
$local = $inspect->invoke($adapter, $assembled, $hash);
|
||||
expect(abs($local['duration'] - $baseline['duration_seconds']) <= 0.001, 'DIFY_LOCAL_INSPECT_MISMATCH');
|
||||
// Only private LOCAL preprocessing helpers: never analyze(), probe(), or a provider response.
|
||||
$prepared = $prepare->invoke($adapter, $local, static fn (): bool => true);
|
||||
$temporary = !empty($prepared['temporary']);
|
||||
if ($temporary) {
|
||||
expect(dirname($prepared['path']) === dirname($assembled), 'PROCESSING_COPY_SCOPE_INVALID');
|
||||
expect((fileperms($prepared['path']) & 0777) === 0600, 'PROCESSING_COPY_NOT_PRIVATE');
|
||||
unlink($prepared['path']);
|
||||
} else { expect($prepared['path'] === $assembled, 'UNEXPECTED_PREPARATION_PATH'); }
|
||||
expect(hash_file('sha256', $path) === $hash && hash_file('sha256', $assembled) === $hash, 'ORIGINAL_CHANGED');
|
||||
$media[] = ['id' => $sample['id'], 'bytes' => filesize($path), 'sha256' => $hash,
|
||||
'duration_seconds' => $result['duration_seconds'], 'chunks' => $chunks,
|
||||
'upload' => 'real_service_with_synthetic_access_and_disposable_sqlite',
|
||||
'decode' => $decode, 'local_prepare' => $temporary ? 'private_processing_copy' : 'original_compatible_unchanged',
|
||||
'original_sha256_unchanged' => true];
|
||||
}
|
||||
$dateResults = [];
|
||||
$yesterdays = ['ordinary' => '2026-10-06', 'month' => '2026-02-28', 'year' => '2025-12-31', 'leap' => '2024-02-29'];
|
||||
foreach ($manifest['recorded_at_cases'] as $case) {
|
||||
$today = substr($case['recorded_at'], 0, 10);
|
||||
$cases = [
|
||||
['today', '今天', $today, '11:21', null, false, false],
|
||||
['yesterday', '昨天', $yesterdays[$case['id']], '11:21', null, false, false],
|
||||
['ambiguous-last-week', '上周', null, '11:21', null, false, true],
|
||||
];
|
||||
foreach (['凌晨' => null, '早晨' => '08:00', '上午' => '08:00', '中午' => '12:00',
|
||||
'下午' => '15:00', '晚上' => '20:00', '睡前' => '22:00', '' => null] as $period => $clock) {
|
||||
$cases[] = ['estimated-' . ($period ?: 'unspecified'), '今天', $today, $clock, $period ?: null, true, true];
|
||||
}
|
||||
foreach ($cases as [$id, $dateText, $expectedDate, $clock, $period, $estimate, $review]) {
|
||||
$quote = $dateText . ($period ?? '') . '空腹血糖6.7';
|
||||
$raw = ['kind' => 'blood', 'values' => ['fasting_blood_sugar' => 6.7], 'date_text' => $dateText,
|
||||
'record_date' => $id === 'ambiguous-last-week' ? $today : null, 'record_time' => $estimate ? null : $clock,
|
||||
'time_period' => $period, 'evidence' => [['text' => $quote]]];
|
||||
$normalized = Policy::normalizeExtraction(['transcript' => $quote, 'summary' => 'Synthetic date-only candidate, NOT ASR',
|
||||
'items' => [$raw], 'uncertainties' => []], $case['recorded_at']);
|
||||
expect(count($normalized['items']) === 1, 'SYNTHETIC_CANDIDATE_MISSING');
|
||||
$item = $normalized['items'][0];
|
||||
expect($item['record_date'] === $expectedDate, 'SYNTHETIC_DATE_MISMATCH');
|
||||
expect($item['record_time'] === $clock, 'SYNTHETIC_CLOCK_MISMATCH');
|
||||
expect($item['time_estimated'] === $estimate, 'SYNTHETIC_ESTIMATION_MISMATCH');
|
||||
expect($item['needs_review'] === $review && $item['selected'] === false, 'SYNTHETIC_REVIEW_MISMATCH');
|
||||
$dateResults[] = ['case' => $case['id'] . ':' . $id, 'synthetic_only' => true,
|
||||
'record_date' => $item['record_date'], 'record_time' => $item['record_time'],
|
||||
'time_estimated' => $item['time_estimated'], 'needs_review' => $item['needs_review'], 'selected' => false];
|
||||
}
|
||||
}
|
||||
// Input rejection tests use only anonymous temporary paths; no application/environment files are read.
|
||||
$beforeNegative = $GLOBALS['sample_checks'];
|
||||
reject(static fn () => parseManifest('{'), 'MANIFEST_JSON_INVALID');
|
||||
reject(static fn () => parseManifest('{}'), 'MANIFEST_SCHEMA_INVALID');
|
||||
foreach (['https://example.invalid/audio.mp3', 'relative.mp3', $directory . '/../missing.mp3', $directory . '/missing.mp3', $directory] as $bad) {
|
||||
$invalid = $manifest; $invalid['samples'][0]['path'] = $bad;
|
||||
reject(static fn () => parseManifest(json_encode($invalid, JSON_THROW_ON_ERROR)), 'SAMPLE_PATH_INVALID');
|
||||
}
|
||||
symlink($directory . '/chunk', $directory . '/linked.mp3');
|
||||
$invalid = $manifest; $invalid['samples'][0]['path'] = $directory . '/linked.mp3';
|
||||
reject(static fn () => parseManifest(json_encode($invalid, JSON_THROW_ON_ERROR)), 'SAMPLE_PATH_INVALID');
|
||||
unlink($directory . '/linked.mp3');
|
||||
$invalid = $manifest; $invalid['samples'][] = $invalid['samples'][0];
|
||||
reject(static fn () => parseManifest(json_encode($invalid, JSON_THROW_ON_ERROR)), 'MANIFEST_SAMPLE_INVALID');
|
||||
$invalid = $manifest; $invalid['recorded_at_cases'][0]['recorded_at'] = '2026-02-30 00:00:00';
|
||||
reject(static fn () => parseManifest(json_encode($invalid, JSON_THROW_ON_ERROR)), 'MANIFEST_DATES_INVALID');
|
||||
$negativeChecks = $GLOBALS['sample_checks'] - $beforeNegative;
|
||||
expect($networkAttempts === 0, 'NETWORK_WAS_ATTEMPTED');
|
||||
expect((fileperms($directory) & 0777) === 0700, 'PRIVATE_ROOT_MODE_INVALID');
|
||||
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST) as $file) {
|
||||
expect(!$file->isLink() && (($file->getPerms() & 0777) === ($file->isDir() ? 0700 : 0600)), 'PRIVATE_MODE_INVALID');
|
||||
}
|
||||
echo json_encode(['suite' => 'followup-audio-local-samples-v1', 'status' => 'PASS',
|
||||
'checks' => $GLOBALS['sample_checks'], 'media_samples' => count($media), 'synthetic_date_cases' => count($dateResults),
|
||||
'negative_input_checks' => $negativeChecks, 'asr' => 'NOT_RUN', 'network_attempts' => $networkAttempts,
|
||||
'production_database' => 'NOT_USED', 'app_env' => 'NOT_LOADED',
|
||||
'limitations' => ['Synthetic Access stub does not validate production RBAC.',
|
||||
'SQLite upload acceptance does not validate production MySQL or HTTP endpoints.',
|
||||
'Date results are synthetic policy inputs, not recognition of supplied recordings.',
|
||||
'No Dify upload, model recognition, transcript accuracy, or business writeback is claimed.'],
|
||||
'media' => $media, 'synthetic_dates' => $dateResults], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL;
|
||||
}
|
||||
} catch (Throwable $error) {
|
||||
$code = preg_match('/^[A-Z0-9_]+$/D', $error->getMessage()) ? $error->getMessage() : 'LOCAL_ACCEPTANCE_FAILED';
|
||||
fwrite(STDERR, 'FAIL ' . $code . PHP_EOL); $exit = 1;
|
||||
} finally {
|
||||
foreach ($originals as $path => $hash) {
|
||||
if (!is_file($path) || hash_file('sha256', $path) !== $hash) { fwrite(STDERR, "FAIL ORIGINAL_HASH_CHANGED\n"); $exit = 1; }
|
||||
}
|
||||
if ($directory !== null && is_dir($directory)) {
|
||||
try { eraseOwnedTree($directory); }
|
||||
catch (Throwable $error) { fwrite(STDERR, "FAIL TEMPORARY_CLEANUP_FAILED\n"); $exit = 1; }
|
||||
}
|
||||
umask($oldMask); restore_error_handler();
|
||||
}
|
||||
exit($exit);
|
||||
}
|
||||
@@ -14,9 +14,11 @@ namespace app\common\service\followupaudio {
|
||||
public static bool $verified = true;
|
||||
public static array $events = [];
|
||||
public static bool $lease = true;
|
||||
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 claim(): ?array { self::$events[] = 'claim'; return ['id' => 1, 'actor_id' => 1, 'diagnosis_id' => 1, 'lease_token' => 'test', 'model_key' => 'qwen']; }
|
||||
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; }
|
||||
public static function checkpoint(int $id, string $token, array $fields): bool { self::$events[] = ['checkpoint' => $fields]; return self::$lease; }
|
||||
public static function complete(int $id, string $token, array $extraction): bool { self::$events[] = ['complete' => $extraction]; return self::$lease; }
|
||||
@@ -29,9 +31,11 @@ namespace app\common\service\followupaudio {
|
||||
final class FollowupAudioDify {
|
||||
public static string $mode = 'success';
|
||||
public function analyze(array $task, callable $heartbeat): array {
|
||||
if (self::$mode === 'change-before-send') { FollowupAudioStore::$providerMatches = false; }
|
||||
if ($heartbeat(['upstream_started_at' => 1, 'stage' => 'uploading']) === false) { throw new FollowupAudioException('LEASE_LOST'); }
|
||||
if (self::$mode === 'uncertain') { throw new FollowupAudioException('UPSTREAM_UNCERTAIN', true); }
|
||||
if (self::$mode === 'internal') { throw new \RuntimeException('private-patient'); }
|
||||
if (self::$mode === 'change-after-send') { FollowupAudioStore::$providerMatches = false; }
|
||||
if (self::$mode === 'revoke') { \app\common\service\prescriptionai\PrescriptionAiAccess::$active = false; }
|
||||
return ['summary' => 'synthetic', 'items' => []];
|
||||
}
|
||||
@@ -51,8 +55,8 @@ namespace {
|
||||
Store::$enabled = false;
|
||||
$expect(!$worker->runOnce() && Store::$events === [], 'disabled before claim');
|
||||
Store::$enabled = true; Store::$verified = false;
|
||||
$expect(!$worker->runOnce() && Store::$events === [], 'unverified before claim');
|
||||
Store::$verified = true;
|
||||
$expect(!$worker->runOnce() && Store::$events === ['claim'], 'unverified task is not consumed; queue may fence stale bindings');
|
||||
Store::$events = []; Store::$verified = true;
|
||||
$expect($worker->runOnce(), 'success consumed task');
|
||||
$expect(isset(Store::$events[count(Store::$events)-1]['complete']), 'success completed after authorization');
|
||||
foreach (['uncertain' => 'UPSTREAM_UNCERTAIN', 'internal' => 'INTERNAL_ERROR', 'revoke' => 'LEASE_LOST'] as $mode => $code) {
|
||||
@@ -68,5 +72,18 @@ namespace {
|
||||
Actors::$active = true; Access::$allowed = false; Store::$events = [];
|
||||
$worker->runOnce(); $last = end(Store::$events);
|
||||
$expect($last['fail'] === 'INTERNAL_ERROR' && !$last['uncertain'], 'scope denied before upstream');
|
||||
Access::$allowed = true; Actors::$active = true;
|
||||
foreach (['change-before-send' => false, 'change-after-send' => true] as $mode => $uncertain) {
|
||||
Dify::$mode = $mode; Store::$events = []; Store::$providerMatches = true;
|
||||
$worker->runOnce(); $last = end(Store::$events);
|
||||
$expect($last['fail'] === 'PROVIDER_CONFIGURATION_CHANGED' && $last['uncertain'] === $uncertain,
|
||||
$mode . ' is fenced with correct upstream uncertainty');
|
||||
$expect(!array_filter(Store::$events, static fn ($event): bool => is_array($event) && isset($event['complete'])),
|
||||
$mode . ' never completes result under a changed provider');
|
||||
}
|
||||
Store::$providerMatches = false; Store::$events = []; Dify::$mode = 'success';
|
||||
$worker->runOnce(); $last = end(Store::$events);
|
||||
$expect($last['fail'] === 'PROVIDER_CONFIGURATION_CHANGED' && !$last['uncertain'] && count(Store::$events) === 2,
|
||||
'Initial missing or stale task fingerprint is rejected before any heartbeat or transport');
|
||||
echo 'FOLLOWUP_AUDIO_WORKER assertions=' . $checks . ' PASS gate=1 actor_recheck=1 reconciliation=1' . PHP_EOL;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user