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'); } } }