222 lines
14 KiB
PHP
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');
|
|
}
|
|
}
|
|
}
|