feat: add reviewed follow-up audio patient enrichment

This commit is contained in:
2026-09-29 15:44:30 +08:00
parent 3cdc812f2b
commit 70be2fc70f
46 changed files with 7384 additions and 246 deletions
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\common\service\followupaudio\FollowupAudioStore;
use think\console\Command;
use think\console\Input;
use think\console\Output;
/** Cleanup runs even while the feature gate is off; active leases remain protected in Store. */
final class FollowupAudioCleanup extends Command
{
protected function configure()
{
$this->setName('followup-audio:cleanup')->setDescription('清理过期音频与全文,不清除已采用的正式记录');
}
protected function execute(Input $input, Output $output): int
{
try {
$counts = FollowupAudioStore::cleanupExpired();
// Count fields only: no patient identifiers, paths, or clinical bodies.
$public = [];
foreach ($counts as $key => $value) {
if (is_int($value) && preg_match('/^[a-z_]+$/D', (string) $key)) { $public[$key] = $value; }
}
$output->writeln('FOLLOWUP_AUDIO_CLEANUP ' . json_encode($public));
return (int) ($counts['errors'] ?? 0) > 0 ? 1 : 0;
} catch (\Throwable $e) {
$output->writeln('FOLLOWUP_AUDIO_CLEANUP storage_or_configuration_error');
return 1;
}
}
}
+180
View File
@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\common\service\followupaudio\FollowupAudioDify;
use app\common\service\followupaudio\FollowupAudioException;
use think\console\Command;
use think\console\Input;
use think\console\input\Option;
use think\console\Output;
/** No patient path, transcript, provider override or alternate credentials accepted. */
final class FollowupAudioProbe extends Command
{
protected function configure()
{
$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('case', null, Option::VALUE_REQUIRED, 'all/short/medium/long', 'all');
}
protected function execute(Input $input, Output $output): int
{
if (!$input->getOption('synthetic')) { $output->writeln('FOLLOWUP_AUDIO_PROBE SYNTHETIC_ACK_REQUIRED'); return 1; }
$profile = (string) $input->getOption('profile');
$selected = (string) $input->getOption('case');
if (!in_array($profile, ['qwen', 'openai'], true) || !in_array($selected, ['all', 'short', 'medium', 'long'], true)) {
$output->writeln('FOLLOWUP_AUDIO_PROBE INVALID_OPTION'); return 1;
}
$lock = null;
try {
$path = realpath((string) $input->getOption('manifest'));
if (!$path || basename($path) !== 'manifest.json' || filesize($path) > 100000) {
throw new FollowupAudioException('SYNTHETIC_FIXTURE_REQUIRED');
}
$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';
$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,
'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');
}
$report['configuration'] = $configuration;
foreach ($fixtures as $fixture) {
$case = $fixture['case'];
if ($selected !== 'all' && $case !== $selected) { continue; }
$previous = $report['cases'][$case] ?? [];
if (($previous['status'] ?? '') === 'passed') {
$output->writeln('FOLLOWUP_AUDIO_PROBE ' . json_encode(['case' => $case, 'status' => 'retained_passed'])); continue;
}
if (!empty($previous['upstream_started_at'])) {
$output->writeln('FOLLOWUP_AUDIO_PROBE ' . json_encode(['case' => $case, 'status' => 'needs_reconciliation',
'code' => 'NO_RESUBMISSION', 'report' => $reportPath])); return 2;
}
$entry = ['status' => 'running', 'audio_sha256' => $fixture['sha256'],
'duration_seconds' => $fixture['duration_seconds'], 'started_at' => time()];
$report['cases'][$case] = $entry;
self::save($reportPath, $report);
$heartbeat = static function (array $fields = []) use (&$report, $case, $reportPath): bool {
foreach (['stage', 'upstream_started_at', 'upstream_file_id', 'upstream_run_id', 'upstream_ids_json'] as $field) {
if (array_key_exists($field, $fields)) { $report['cases'][$case][$field] = $fields[$field]; }
}
self::save($reportPath, $report);
return true;
};
try {
// Expectations/canaries are not passed into query or inputs; they are audio-only evidence.
$result = $dify->probe(dirname($path) . '/' . $fixture['file'], $fixture, $profile, $heartbeat);
$checks = self::verifyExtraction($result, $fixture['expected']);
$passed = !in_array(false, $checks, true);
$report['cases'][$case] += ['checks' => $checks];
$report['cases'][$case]['status'] = $passed ? 'passed' : 'failed';
$report['cases'][$case]['code'] = $passed ? 'OK' : 'AUDIO_CAPABILITY_NOT_CONFIRMED';
} catch (FollowupAudioException $e) {
$report['cases'][$case]['status'] = $e->uncertain ? 'needs_reconciliation' : 'blocked';
$report['cases'][$case]['code'] = $e->errorCode;
} catch (\Throwable $e) {
$report['cases'][$case]['status'] = !empty($report['cases'][$case]['upstream_started_at']) ? 'needs_reconciliation' : 'blocked';
$report['cases'][$case]['code'] = 'PROBE_INTERNAL_ERROR';
}
$report['cases'][$case]['finished_at'] = time();
$report['audio_verified'] = count(array_filter($report['cases'], static fn (array $row): bool => ($row['status'] ?? '') === 'passed')) === 3;
self::save($reportPath, $report);
$output->writeln('FOLLOWUP_AUDIO_PROBE ' . json_encode(['case' => $case, 'status' => $report['cases'][$case]['status'],
'code' => $report['cases'][$case]['code'], 'audio_verified' => $report['audio_verified'], 'report' => $reportPath]));
if ($report['cases'][$case]['status'] !== 'passed') { return 2; }
}
return !empty($report['audio_verified']) ? 0 : 2;
} catch (\Throwable $e) {
$output->writeln('FOLLOWUP_AUDIO_PROBE ' . ($e instanceof FollowupAudioException ? $e->errorCode : 'PROBE_INVALID_OR_UNWRITABLE'));
return 1;
} finally {
if (is_resource($lock)) { flock($lock, LOCK_UN); fclose($lock); }
}
}
private static function validateManifest($manifest, string $directory): array
{
if (!is_array($manifest) || ($manifest['generator'] ?? '') !== 'followup-audio-synthetic-v1'
|| ($manifest['synthetic'] ?? false) !== true || ($manifest['recorded_at'] ?? '') !== '2026-09-29 10:00:00'
|| !is_array($manifest['fixtures'] ?? null) || count($manifest['fixtures']) !== 3) {
throw new FollowupAudioException('SYNTHETIC_FIXTURE_REQUIRED');
}
$found = [];
$minimum = ['short' => 20, 'medium' => 890, 'long' => 3580];
foreach ($manifest['fixtures'] as &$fixture) {
$case = $fixture['case'] ?? '';
$file = $fixture['file'] ?? '';
$path = $directory . '/' . $file;
if (!isset($minimum[$case]) || isset($found[$case]) || $file !== $case . '.mp3'
|| !is_file($path) || is_link($path) || realpath($path) !== $path
|| (float) ($fixture['duration_seconds'] ?? 0) < $minimum[$case]
|| (float) $fixture['duration_seconds'] > 3600
|| !is_string($fixture['sha256'] ?? null) || !hash_equals($fixture['sha256'], (string) hash_file('sha256', $path))
|| !is_array($fixture['expected'] ?? null) || count($fixture['expected']['canaries'] ?? []) < 2
|| count($fixture['expected']['facts'] ?? []) < 3) {
throw new FollowupAudioException('SYNTHETIC_FIXTURE_REQUIRED');
}
$fixture['synthetic'] = true;
$fixture['generator'] = $manifest['generator'];
$found[$case] = $fixture;
}
return [$found['short'], $found['medium'], $found['long']];
}
/** Checks synthetic audio-only canaries, tail event, and distinct normalized temporal facts. */
public static function verifyExtraction(array $result, array $expected): array
{
$transcript = preg_replace('/\s+/u', '', (string) ($result['transcript'] ?? ''));
$canaries = true;
foreach ($expected['canaries'] ?? [] as $canary) {
$canaries = $canaries && is_string($canary) && $canary !== '' && str_contains($transcript, preg_replace('/\s+/u', '', $canary));
}
$matched = [];
foreach ($expected['facts'] ?? [] as $index => $fact) {
$matched[$index] = false;
foreach ($result['items'] ?? [] as $item) {
if (($item['kind'] ?? '') !== $fact['kind'] || ($item['record_date'] ?? '') !== $fact['record_date']
|| substr((string) ($item['record_time'] ?? ''), 0, 5) !== $fact['record_time']) { continue; }
$same = true;
foreach ($fact['values'] as $key => $value) {
$actual = $item['values'][$key] ?? null;
$same = $same && $actual !== null && is_numeric($actual) && (float) $actual === (float) $value;
}
if ($same) { $matched[$index] = true; break; }
}
}
return ['audio_only_canaries' => $canaries && count($expected['canaries'] ?? []) >= 2,
'historical_temporal_facts' => count($matched) >= 3 && !in_array(false, $matched, true),
'unique_tail_fact' => count($matched) >= 3 && (bool) end($matched),
'nonempty_summary' => trim((string) ($result['summary'] ?? '')) !== ''];
}
private static function save(string $path, array $report): void
{
$temporary = $path . '.' . bin2hex(random_bytes(6)) . '.tmp';
$data = json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR) . "\n";
if (file_put_contents($temporary, $data, LOCK_EX) !== strlen($data) || !chmod($temporary, 0600) || !rename($temporary, $path)) {
@unlink($temporary);
throw new FollowupAudioException('PROBE_CHECKPOINT_FAILED');
}
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace app\command;
use app\common\service\followupaudio\FollowupAudioStore;
use app\common\service\followupaudio\FollowupAudioWorker;
use think\console\Command;
use think\console\Input;
use think\console\input\Option;
use think\console\Output;
use think\facade\Config;
use think\facade\Db;
final class FollowupAudioWork extends Command
{
protected function configure()
{
$this->setName('followup-audio:work')->setDescription('独立随访音频消费者;默认关闭,未知结果不重发')
->addOption('once', null, Option::VALUE_NONE, '只处理一轮');
}
protected function execute(Input $input, Output $output): int
{
$running = true;
if (function_exists('pcntl_async_signals')) {
pcntl_async_signals(true);
pcntl_signal(SIGTERM, static function () use (&$running): void { $running = false; });
pcntl_signal(SIGINT, static function () use (&$running): void { $running = false; });
}
$database = (array) config('database');
$connection = (string) ($database['default'] ?? 'mysql');
if (isset($database['connections'][$connection])) {
$database['connections'][$connection]['break_reconnect'] = true;
Config::set($database, 'database');
}
$worker = new FollowupAudioWorker();
do {
$worked = false;
try {
$worked = $worker->runOnce();
if ($input->getOption('once') || $worked) {
$output->writeln('FOLLOWUP_AUDIO ' . json_encode(['enabled' => FollowupAudioStore::enabled(),
'audio_verified' => FollowupAudioStore::verified(), 'processed' => $worked]));
}
} catch (\Throwable $e) {
$output->writeln('FOLLOWUP_AUDIO storage_or_configuration_error');
try { Db::connect()->close(); } catch (\Throwable $ignored) {}
if ($input->getOption('once')) { return 1; }
}
if (!$input->getOption('once') && $running) { usleep($worked ? 100000 : 1000000); }
} while (!$input->getOption('once') && $running);
return 0;
}
}