90]; } function hrtime(bool $number = false): int|array { $value = $GLOBALS['answerRecoveryClock']; return $number ? $value : [intdiv($value, 1000000000), $value % 1000000000]; } } namespace app\adminapi\service { function hrtime(bool $number = false): int|array { return \app\adminapi\logic\tcm\hrtime($number); } function usleep(int $microseconds): void { $GLOBALS['answerRecoveryClock'] += $microseconds * 1000; } } namespace app\common\service { final class DifyChatService { public static array $calls = [], $queue = []; public static function streamChat($profile, $inputs, $query, $user, $delta, $abort, $files = [], $options = []): array { return self::respond('stream', $profile, $inputs, $query, $user, $files, $options, $delta); } public static function chat($profile, $inputs, $query, $user, $files = [], $options = []): array { return self::respond('blocking', $profile, $inputs, $query, $user, $files, $options); } private static function respond(string $kind, $profile, $inputs, $query, $user, $files, $options, ?callable $delta = null): array { self::$calls[] = compact('kind', 'profile', 'inputs', 'query', 'user', 'files', 'options'); if (self::$queue === []) throw new \RuntimeException('Unexpected extra generation.'); $response = array_shift(self::$queue); $task = 'answer-recovery-' . count(self::$calls); $run = 'answer-recovery-run-' . count(self::$calls); if (isset($options['on_task'])) ($options['on_task'])($task, $run); $GLOBALS['answerRecoveryClock'] += ($response['advance_ms'] ?? 0) * 1000000; foreach ($response['chunks'] ?? [] as $chunk) { if ($delta === null || $delta($chunk) !== false) continue; if (isset($options['on_cancel_task'])) ($options['on_cancel_task'])($task, static fn (): bool => true, 'cancelled'); $GLOBALS['answerRecoveryClock'] += ($response['cleanup_ms'] ?? 0) * 1000000; if ($response['terminal'] ?? true) ($options['on_terminal'])($task, 'stopped', $run); if (isset($options['on_transfer_end'])) ($options['on_transfer_end'])($task, $response['terminal'] ?? true ? 'stopped' : 'running'); return ['ok' => false, 'error_code' => 'AI_ASSISTANT_CANCELLED']; } if (isset($options['on_terminal'])) ($options['on_terminal'])($task, 'succeeded', $run); if (isset($options['on_transfer_end'])) ($options['on_transfer_end'])($task, 'succeeded'); return $response; } } } namespace { require dirname(__DIR__) . '/vendor/autoload.php'; use app\adminapi\logic\tcm\DiagnosisAiLogic as Logic; use app\adminapi\service\AssistantRequestControl; use app\adminapi\service\TcmClinicalReportStream; use app\adminapi\service\TcmEvidenceGuard; use app\common\service\DifyChatService as Transport; $checks = 0; $answerRecoveryClock = 1000000000; function answerExpect(bool $condition, string $reason): void { ++$GLOBALS['checks']; if (!$condition) throw new RuntimeException($reason); } $root = sys_get_temp_dir() . '/zyt-answer-recovery-' . bin2hex(random_bytes(6)); mkdir($root, 0700); $prepared = ['task' => 'summary', 'profile' => 'qwen', 'user' => 'offline-recovery', 'upstream_timeout' => 90, 'query' => 'ORIGINAL_THIRTEEN_SOURCES总结当前病情、证候与风险。', 'inputs' => ['source' => "ORIGINAL\r\nBYTES"], 'files' => [['type' => 'image', 'url' => 'https://synthetic.invalid/unchanged.png']], 'tcm_evidence_guard' => TcmEvidenceGuard::assess(['chief_complaint' => '口干。', 'present_illness' => '未记录固定刺痛。', 'tongue' => '舌淡红。'])]; $prefix = "中医辨证分析\n\n一、四诊信息整理\n\n"; $body = "本次已有口干记录,证候判断仍须结合已采资料由医师复核。\n\n"; $bad = "当前无固定刺痛。\n\n"; $pulseBad = "脉象:未记录。\n\n"; function answerRun(array $prepared, array $responses, array $behavior = []): array { $GLOBALS['answerRecoveryClock'] = 1000000000; Transport::$calls = []; Transport::$queue = $responses; $control = new AssistantRequestControl(1, 1, '12345678-1234-4234-8234-' . bin2hex(random_bytes(6)), $GLOBALS['root']); $control->begin(); $visible = ''; $progress = []; $reasons = []; $options = [ 'can_start_attempt' => static fn (): bool => $control->canStartAttempt(), 'on_task' => static fn ($task, $run) => $control->bindTask($prepared['profile'], $prepared['user'], $task, $run), 'on_terminal' => static fn ($task, $status, $run) => $control->markTerminal($task, $status, $run), 'on_transfer_end' => static fn ($task, $status) => $control->closeGeneration($task, static fn ($identity): array => ['ok' => true, 'workflow_run_id' => $identity['workflow_run_id'], 'status' => $status]), 'on_cancel_task' => static function ($task, $stop, $reason) use ($control, &$reasons, $behavior): array { $reasons[] = $reason; if ($reason === 'validation') $control->signalValidationFailure(); else $control->signalCancel(); if (!empty($behavior['cancel_cleanup'])) $control->signalCancel(); return $control->stopBoundTask($stop, $task); }, 'on_progress' => static function ($event) use (&$progress): void { $progress[] = $event; }, ]; if (!empty($behavior['without_gate'])) unset($options['can_start_attempt']); $options = array_replace($options, $behavior['options'] ?? []); $result = Logic::streamPreparedAssistant($prepared, static function ($delta) use (&$visible, $behavior): bool { if (!empty($behavior['refuse_delta'])) return false; $visible .= $delta; return true; }, static fn (): bool => $control->shouldAbort(), null, $options); return ['out' => $result, 'visible' => $visible, 'calls' => Transport::$calls, 'error' => Logic::getAssistantErrorCode(), 'meta' => Logic::getAssistantErrorMeta(), 'progress' => $progress, 'reasons' => $reasons]; } try { foreach (['qwen', 'openai'] as $profile) { foreach (['summary', 'custom', 'tcm_pattern'] as $task) { $p = array_replace($prepared, compact('profile', 'task')); $good = ['ok' => true, 'content' => $body, 'chunks' => mb_str_split($body, 3, 'UTF-8')]; $r = answerRun($p, [['ok' => true, 'content' => $prefix . $body, 'chunks' => [$prefix, $body]]]); answerExpect($r['out'] !== null && $r['out']['answer'] === trim($prefix . $body) && $r['visible'] === $prefix . $body && count($r['calls']) === 1 && $r['out']['clinical_repair_count'] === 0, 'Valid ordinary answer stays single-attempt and byte-identical.'); $heading = " ### 三、资料归纳 \t\r\n\r\n"; foreach ([1, 2, 7, 1000] as $chunkSize) { $first = $heading . $bad; $suffix = $heading . $body; $r = answerRun($p, [ ['ok' => true, 'content' => $first, 'chunks' => mb_str_split($first, $chunkSize, 'UTF-8')], ['ok' => true, 'content' => $suffix, 'chunks' => mb_str_split($suffix, $chunkSize, 'UTF-8')], ]); answerExpect($r['out'] !== null && $r['visible'] === $heading . $suffix && $r['out']['answer'] === trim($heading . $suffix), 'Ordinary correction retains both explicit heading units, including original whitespace and CRLF bytes.'); answerExpect(count($r['calls']) === 2 && $r['out']['clinical_repair_count'] === 1 && $r['reasons'] === ['validation'], 'A repeated heading does not add attempts or bypass confirmed cleanup.'); } foreach ([$bad => 'TCM_FACT_STATE_CONFLICT', $pulseBad => 'PULSE_EXCLUDED_FROM_ASSESSMENT'] as $invalid => $error) { $r = answerRun($p, [ ['ok' => true, 'content' => $heading . $bad, 'chunks' => [$heading, $bad]], ['ok' => true, 'content' => $heading . $invalid, 'chunks' => [$heading, $invalid]], $good, ]); answerExpect($r['out'] === null && $r['visible'] === $heading . $heading && count($r['calls']) === 2 && $r['error'] === $error && $r['meta']['clinical_repair_count'] === 1, 'A retained duplicate heading still runs fact and pulse validation; an invalid correction cannot start a third attempt.'); } $r = answerRun($p, [ ['ok' => true, 'content' => $heading . $body . $bad, 'chunks' => [$heading, $body, $bad]], ['ok' => true, 'content' => $heading . $body, 'chunks' => [$heading, $body]], $good, ]); answerExpect($r['out'] === null && $r['visible'] === $heading . $body . $heading && count($r['calls']) === 2 && in_array('REPORT_CONTINUATION_REPEATED_SECTION', array_column($r['meta']['clinical_fact_state_issues'], 'code'), true), 'Substantive duplicate prose is rejected after a retained heading without rewriting either attempt.'); foreach ([$bad, $pulseBad] as $invalid) { foreach ([1, 2, 7, 1000] as $chunkSize) { $first = ['ok' => true, 'content' => $prefix . $invalid, 'chunks' => mb_str_split($prefix . $invalid, $chunkSize, 'UTF-8'), 'advance_ms' => 4000, 'cleanup_ms' => 1000]; $r = answerRun($p, [$first, $good]); answerExpect($r['out'] !== null && $r['visible'] === $prefix . $body && $r['out']['answer'] === trim($r['visible']), 'Header-only failure recovers using exact new model suffix.'); answerExpect(count($r['calls']) === 2 && $r['out']['clinical_repair_count'] === 1 && $r['reasons'] === ['validation'], 'Only one correction after confirmed validation cleanup.'); foreach ($r['calls'] as $call) { answerExpect($call['profile'] === $profile && $call['inputs'] === $p['inputs'] && $call['files'] === $p['files'] && $call['user'] === $p['user'] && str_starts_with($call['query'], $p['query']), 'Original model, sources, files, user and question survive.'); } answerExpect($r['calls'][1]['options']['timeout_ms'] === 85000 && count($r['progress']) === 1, 'Correction shares original 90-second deadline including cleanup.'); preg_match('~\s*(.*?)\s*~su', $r['calls'][1]['query'], $m); $data = json_decode($m[1], true, 512, JSON_THROW_ON_ERROR); answerExpect($data['delivered_prefix'] === $prefix && $data['rejected_undelivered_unit'] === $invalid && $data['issues'] !== [], 'Private correction has exact rejection and approved-prefix context.'); answerExpect(!str_contains(json_encode($r['out'], JSON_UNESCAPED_UNICODE), trim($invalid)), 'Rejected paragraph never reaches final answer.'); } } } $p = array_replace($prepared, compact('profile')); $failure = ['ok' => true, 'content' => $prefix . $bad, 'chunks' => [$prefix, $bad]]; $good = ['ok' => true, 'content' => $body, 'chunks' => [$body]]; $r = answerRun($p, [array_replace($failure, ['terminal' => false])]); answerExpect($r['out'] === null && count($r['calls']) === 1 && $r['visible'] === $prefix && $r['error'] === 'UPSTREAM_CLEANUP_PENDING', 'Stop acknowledgement without terminal never admits another generation.'); $r = answerRun($p, [$failure], ['cancel_cleanup' => true]); answerExpect($r['out'] === null && count($r['calls']) === 1 && $r['error'] === 'AI_ASSISTANT_CANCELLED', 'User cancellation during cleanup blocks repair.'); $r = answerRun($p, [array_replace($failure, ['advance_ms' => 89000, 'cleanup_ms' => 1000])]); answerExpect($r['out'] === null && count($r['calls']) === 1 && $r['error'] === 'UPSTREAM_TIMEOUT', 'Deadline is never reset for correction.'); $r = answerRun($p, [$failure, $failure, $good]); answerExpect($r['out'] === null && count($r['calls']) === 2 && $r['meta']['clinical_repair_count'] === 1, 'Second invalid answer cannot create a third generation or successful report.'); $r = answerRun($p, [$failure], ['without_gate' => true]); answerExpect($r['out'] === null && count($r['calls']) === 1 && $r['error'] === 'TCM_FACT_STATE_CONFLICT', 'No correction without lifecycle readiness callback.'); $r = answerRun($p, [array_replace($failure, ['chunks' => [$prefix, rtrim($bad)], 'cleanup_ms' => 0]), $good]); answerExpect($r['out'] !== null && $r['visible'] === $prefix . $body, 'Invalid successful-EOF tail also recovers after natural terminal.'); $r = answerRun($p, [['ok' => false, 'error_code' => 'UPSTREAM_TIMEOUT']]); answerExpect($r['out'] === null && count($r['calls']) === 1, 'Unrelated upstream timeout does not enter clinical repair.'); $r = answerRun($p, [['ok' => false, 'error_code' => 'INCOMPLETE_RESPONSE'], ['ok' => true, 'content' => $bad], $good]); answerExpect($r['out'] === null && count($r['calls']) === 2 && $r['visible'] === '', 'Compatibility plus clinical failure shares two-attempt ceiling.'); $r = answerRun($p, [['ok' => true, 'content' => $body, 'chunks' => [$body]]], ['refuse_delta' => true]); answerExpect($r['out'] === null && count($r['calls']) === 1, 'Downstream refusal cannot start a correction.'); $r = answerRun($p, [array_replace($failure, ['advance_ms' => 4000, 'cleanup_ms' => 1000]), $good], ['options' => ['timeout_ms' => 12000]]); answerExpect($r['out'] !== null && $r['calls'][1]['options']['timeout_ms'] === 7000, 'Short caller budget includes first attempt and cleanup.'); $r = answerRun($p, [array_replace($failure, ['advance_ms' => 4000, 'cleanup_ms' => 1000]), $good], ['options' => ['request_deadline_ns' => 4000000000]]); answerExpect($r['out'] === null && count($r['calls']) === 1 && $r['error'] === 'UPSTREAM_TIMEOUT', 'Options-only absolute deadline is never replaced by a fresh budget.'); foreach ([[4000000000, 12000000000], [12000000000, 4000000000]] as [$preparedDeadline, $optionDeadline]) { $r = answerRun($p + ['request_deadline_ns' => $preparedDeadline], [array_replace($failure, ['advance_ms' => 4000, 'cleanup_ms' => 1000]), $good], ['options' => ['request_deadline_ns' => $optionDeadline]]); answerExpect($r['out'] === null && count($r['calls']) === 1 && $r['error'] === 'UPSTREAM_TIMEOUT', 'The earlier of prepared and options deadlines always wins.'); } $r = answerRun($p, [$failure, ['ok' => true, 'content' => $prefix . $body, 'chunks' => [$prefix, $body]]]); answerExpect($r['out'] === null && $r['visible'] === $prefix && count($r['calls']) === 2 && in_array('REPORT_CONTINUATION_REPEATED_SECTION', array_column($r['meta']['clinical_fact_state_issues'], 'code'), true), 'Repeated delivered prefix is rejected before duplicate bytes reach the client.'); } foreach (range(1, 6) as $level) { $unit = str_repeat('#', $level) . " 资料归纳 \t\r\n\r\n"; $visible = ''; $stream = new TcmClinicalReportStream($prepared['tcm_evidence_guard']['fact_state_ledger'], static function (string $delta) use (&$visible): bool { $visible .= $delta; return true; }, null, $unit, null, null, null, null, true, true); $ok = true; foreach (mb_str_split($unit, 1, 'UTF-8') as $chunk) $ok = $stream->push($chunk) && $ok; answerExpect($ok && $stream->finish() && $visible === $unit && $stream->acceptedContent() === $unit . $unit, 'Every ATX heading level preserves duplicate bytes through the actual stream.'); } foreach (["一、四诊信息整理\n\n", "1. 本次已有口干记录\n\n", $body, "### 资料归纳\n本次已有口干记录。\n\n", " ### 资料归纳\n\n", "\t### 资料归纳\n\n", "####### 资料归纳\n\n", "###资料归纳\n\n", "### \t\n\n", "### ###\n\n"] as $unit) { $visible = ''; $stream = new TcmClinicalReportStream($prepared['tcm_evidence_guard']['fact_state_ledger'], static function (string $delta) use (&$visible): bool { $visible .= $delta; return true; }, null, $unit, null, null, null, null, true, true); answerExpect(!$stream->push($unit) && $visible === '' && $stream->issues()[0]['code'] === 'REPORT_CONTINUATION_REPEATED_SECTION', 'Numbered prose, mixed units, code indentation and malformed or empty headings retain duplicate rejection.'); } foreach ([['', null], [$prepared['query'], null], [null, ''], [null, $prepared['query']]] as [$continuation, $checkpoint]) { $unit = "### 四诊信息整理\n\n"; $visible = ''; $stream = new TcmClinicalReportStream($prepared['tcm_evidence_guard']['fact_state_ledger'], static function (string $delta) use (&$visible): bool { $visible .= $delta; return true; }, null, $unit, $continuation, null, $checkpoint, null, true, true); answerExpect(!$stream->push($unit) && $visible === '' && $stream->issues()[0]['code'] === 'REPORT_CONTINUATION_REPEATED_SECTION', 'Every non-null long-report context keeps explicit duplicate headings strictly rejected.'); } $unit = "### 一、四诊\n\n"; $visible = ''; $stream = new TcmClinicalReportStream($prepared['tcm_evidence_guard']['fact_state_ledger'], static function (string $delta) use (&$visible): bool { $visible .= $delta; return true; }, null, $unit, '生成完整中医辨证长报告。', null, null, null, true); answerExpect(!$stream->push($unit) && $visible === '' && $stream->issues()[0]['code'] === 'REPORT_CONTINUATION_REPEATED_SECTION', 'The actual long-report section contract rejects repeated headings independently of ordinary prefix-repeat detection.'); echo "Clinical answer recovery: {$checks} assertions passed (offline, real request control).\n"; } finally { $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); foreach ($files as $path) { if ($path->isDir()) rmdir($path->getPathname()); else unlink($path->getPathname()); } if (is_dir($root)) rmdir($root); } }