Files
zyt/server/app/command/FollowupAudioProbe.php
T

222 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace app\command;
use app\common\service\followupaudio\FollowupAudioDify;
use app\common\service\followupaudio\FollowupAudioException;
use app\common\service\followupaudio\FollowupAudioProviderConfig;
use app\common\service\followupaudio\FollowupAudioStore;
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, '服务端 followup_audio 配置的 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));
$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-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');
}
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]; }
}
if (isset($fields['pipeline_checkpoint'])) {
// Even synthetic two-stage probes must durably retain their stage outcomes before more I/O.
// Keep plaintext transcript/answers out of the JSON report and console.
$report['cases'][$case]['pipeline_cipher'] = FollowupAudioStore::seal(0, 'probe-' . $case, $fields['pipeline_checkpoint']['state']);
}
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 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'
|| ($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
{
// ASR punctuation/spacing is not a lexical error; changed words still fail.
$spokenWords = static fn (string $text): string => preg_replace('/[\p{P}\p{Z}\s]+/u', '', $text);
$transcript = $spokenWords((string) ($result['transcript'] ?? ''));
$canaries = true;
foreach ($expected['canaries'] ?? [] as $canary) {
$canaries = $canaries && is_string($canary) && $canary !== '' && str_contains($transcript, $spokenWords($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');
}
}
}