90]; } function hrtime(bool $asNumber = false): int|array { $now = $GLOBALS['resumeClockNs']; return $asNumber ? $now : [intdiv($now, 1000000000), $now % 1000000000]; } } namespace app\adminapi\service { function hrtime(bool $asNumber = false): int|array { return \app\adminapi\logic\tcm\hrtime($asNumber); } } namespace app\common\service { class DifyChatService { public static array $calls = []; public static array $queue = []; public static function streamChat($profile, $inputs, $query, $user, $onDelta, $abort, $files = [], $options = []): array { self::$calls[] = compact('profile', 'inputs', 'query', 'user', 'files', 'options') + ['clock_ns' => $GLOBALS['resumeClockNs']]; if (self::$queue === []) throw new \RuntimeException('Unexpected third generation.'); $response = array_shift(self::$queue); $GLOBALS['resumeClockNs'] += (int) ($response['advance_ms'] ?? 0) * 1000000; $id = 'resume-task-' . count(self::$calls); if (isset($options['on_task'])) ($options['on_task'])($id); if (!empty($response['timeout']) && isset($options['on_timeout'])) ($options['on_timeout'])($id); foreach ($response['chunks'] ?? [] as $chunk) { if ($onDelta($chunk) !== false) continue; if (isset($options['on_cancel_task'])) { ($options['on_cancel_task'])($id, static fn (): bool => true, 'cancelled'); } $GLOBALS['resumeClockNs'] += (int) ($response['cleanup_ms'] ?? 0) * 1000000; if (!empty($response['terminal']) && isset($options['on_terminal'])) { ($options['on_terminal'])($id, 'stopped'); } return ['ok' => false, 'error_code' => 'AI_ASSISTANT_CANCELLED']; } if (isset($options['on_terminal'])) ($options['on_terminal'])($id, 'succeeded'); return $response; } public static function chat(...$arguments): array { throw new \RuntimeException('Unexpected blocking compatibility attempt.'); } } } namespace { require dirname(__DIR__) . '/vendor/autoload.php'; use app\adminapi\logic\tcm\DiagnosisAiLogic; use app\adminapi\service\AssistantRequestControl; use app\adminapi\service\TcmClinicalEvidenceContract; use app\adminapi\service\TcmClinicalEvidenceIndex; use app\adminapi\service\TcmClinicalFactStateGuard; use app\adminapi\service\TcmClinicalReportStream; use app\adminapi\service\TcmEvidenceGuard; use app\adminapi\service\TcmLongReportContract; use app\common\service\DifyChatService; $assertions = 0; $resumeClockNs = 1000000000; function resumeExpect(bool $condition, string $message): void { ++$GLOBALS['assertions']; if (!$condition) throw new \RuntimeException($message); } $current = ['id' => 1, 'diagnosis_date' => '2026-10-23', 'chief_complaint' => '乏力持续两周,活动后较明显,休息后可缓解;患者自述仍能完成日常活动,近两日以居家家务为主,白天与傍晚均有乏力,具体起止和活动关系按本次访谈记录。', 'present_illness' => '口干;未记录固定刺痛。', 'tongue' => '舌淡红苔少', 'pulse' => '脉细弱']; $guard = TcmEvidenceGuard::assess($current, []); $prepared = ['task' => 'custom', 'profile' => 'qwen', 'admin_id' => 1, 'query' => '原安全规则ALL_13_UNCHANGED_SOURCE_BYTES' . '请生成1800至2200字完整中医辨证长报告', 'inputs' => ['source' => 'ALL_13_ORIGINAL_SECTIONS', 'opaque' => "original\nbytes"], 'user' => 'same-original-user', 'tcm_evidence_guard' => $guard, 'files' => [['type' => 'image', 'url' => 'https://synthetic.invalid/original.png']], 'upstream_timeout' => 90, 'required_sections' => [], 'source_summary' => []]; // A synthetic delivery fixture, never a clinical quality claim. Its range // and body thresholds are checked by the unchanged production contract. $body = str_repeat('本段只用于离线工程传输契约测试,验证模型原始文字的次序与来源上下文,不能替代真实病例的医师审核。', 7); // Hardcoded factual first-section protocol; engineering filler starts only // in section two. IDs follow the original four clinical fields. $prefix = "一、四诊\r\n\r\n望诊:未记录。\r\n\r\n闻诊:未记录。\r\n\r\n" . "问诊:2026-10-23“乏力持续两周,活动后较明显,休息后可缓解;患者自述仍能完成日常活动,近两日以居家家务为主,白天与傍晚均有乏力,具体起止和活动关系按本次访谈记录。”(s1);2026-10-23“口干;未记录固定刺痛。”(s2)。\r\n\r\n" . "切诊:其他触诊未记录,脉象见独立脉象项。\r\n\r\n" . "舌象:2026-10-23“舌淡红苔少”(s3)。\r\n\r\n脉象:2026-10-23“脉细弱”(s4)。\r\n\r\n"; $headings = ['二、八纲', '三、气血津液与脏腑', '四、证名', '五、治法', '六、方药']; $suffix = implode("\r\n\r\n", array_map(static fn (string $heading): string => $heading . "\r\n\r\n" . $body, $headings)); $complete = $prefix . $suffix; $fixtureShape = TcmLongReportContract::validate($complete, $prepared['query']); resumeExpect($fixtureShape['valid'], 'Fixture satisfies the original1800–2200 full-report contract: ' . json_encode($fixtureShape)); resumeExpect(TcmClinicalFactStateGuard::validate($guard['fact_state_ledger'], $complete)['valid'], 'Fixture has no source-state conflict.'); resumeExpect(TcmClinicalEvidenceContract::validate(TcmClinicalEvidenceIndex::build($guard['fact_state_ledger']), $complete, true)['valid'], 'Hardcoded transport fixture obeys source/role protocol; this is not a clinical-quality result.'); $bad = "当前无固定刺痛。\r\n\r\n"; $controlRoot = sys_get_temp_dir() . '/tcm-bounded-resume-' . bin2hex(random_bytes(6)); mkdir($controlRoot, 0700); function runResume(array $prepared, array $responses, array $overrides = []): array { $GLOBALS['resumeClockNs'] = 1000000000; DifyChatService::$calls = []; DifyChatService::$queue = $responses; $control = new AssistantRequestControl(1, 1, '12345678-1234-4234-8234-' . bin2hex(random_bytes(6)), $GLOBALS['controlRoot']); $control->begin(); $visible = ''; $chunks = []; $reasons = []; $options = [ 'can_start_attempt' => static fn (): bool => $control->canStartAttempt(), 'on_task' => static fn (string $id) => $control->bindTask($prepared['profile'], $prepared['user'], $id), 'on_terminal' => static fn (string $id, string $status) => $control->markTerminal($id, $status), 'on_timeout' => static fn () => $control->signalTimeout(), 'on_cancel_task' => static function (string $id, callable $stop, string $reason) use ($control, &$reasons, $overrides): array { $reasons[] = $reason; if ($reason === 'validation') $control->signalValidationFailure(); else $control->signalCancel(); if (!empty($overrides['cancel_during_cleanup'])) $control->signalCancel(); return $control->stopBoundTask($stop, $id); }, ]; if (isset($overrides['gate'])) $options['can_start_attempt'] = $overrides['gate']; if (!empty($overrides['without_gate'])) unset($options['can_start_attempt']); if (isset($overrides['on_progress'])) $options['on_progress'] = $overrides['on_progress']; if (isset($overrides['timeout_ms'])) $options['timeout_ms'] = $overrides['timeout_ms']; $out = DiagnosisAiLogic::streamPreparedAssistant($prepared, static function (string $delta) use (&$visible, &$chunks, $control, $overrides): bool { if (!empty($overrides['client_rejects'])) { $control->signalCancel(); return false; } $visible .= $delta; $chunks[] = ['bytes' => $delta, 'stage' => count(DifyChatService::$calls)]; return true; }, static fn (): bool => $control->shouldAbort(), null, $options); return ['out' => $out, 'visible' => $visible, 'chunks' => $chunks, 'reasons' => $reasons, 'error' => DiagnosisAiLogic::getAssistantErrorCode(), 'meta' => DiagnosisAiLogic::getAssistantErrorMeta(), 'state' => $control->publicState(), 'calls' => DifyChatService::$calls]; } foreach (['qwen', 'openai'] as $profile) { $p = $prepared; $p['profile'] = $profile; $firstFailure = ['ok' => false, 'chunks' => [$prefix, $bad, 'NEVER_DELIVER_THIS'], 'advance_ms' => 45000, 'cleanup_ms' => 1500, 'terminal' => true]; $success = ['ok' => true, 'content' => $suffix, 'chunks' => mb_str_split($suffix, 7, 'UTF-8')]; $r = runResume($p, [$firstFailure, $success]); resumeExpect($r['out'] !== null && $r['out']['answer'] === $complete && $r['visible'] === $complete, $profile . ': original prefix plus validated suffix are byte-identical to the final answer.'); resumeExpect($r['out']['report_stage_count'] === 2 && $r['out']['clinical_repair_count'] === 1 && count($r['calls']) === 2, 'Exactly two actual stream calls and one clinical recovery are disclosed.'); resumeExpect(!str_contains($r['visible'], $bad) && !str_contains($r['visible'], 'NEVER_DELIVER_THIS') && substr_count($r['visible'], $prefix) === 1 && $r['reasons'] === ['validation'], 'Rejected and subsequent old-task bytes never reach the client; prefix appears once.'); foreach ($r['calls'] as $call) { resumeExpect($call['profile'] === $profile && $call['inputs'] === $p['inputs'] && $call['files'] === $p['files'] && $call['user'] === $p['user'], 'Recovery retains the original profile, all sources, files and user.'); resumeExpect(str_starts_with($call['query'], $p['query']) && substr_count($call['query'], '') === 1, 'Original question and source bytes are unchanged and are not replaced by a suffix question.'); resumeExpect(!isset($call['options']['_validated_report_prefix']) && !isset($call['options']['_report_attempt_observer']), 'Private context and observers never become transport options.'); } resumeExpect($r['calls'][0]['options']['timeout_ms'] === 90000 && $r['calls'][1]['options']['timeout_ms'] === 43500, 'Generation and confirmed-stop cleanup consume the same90s absolute deadline.'); preg_match('~\s*(.*?)\s*~su', $r['calls'][1]['query'], $continuationData); $continuationData = json_decode($continuationData[1] ?? '', true); resumeExpect(($continuationData['delivered_prefix'] ?? null) === $prefix && ($continuationData['rejected_undelivered_unit'] ?? null) === $bad && ($continuationData['original_whole_report_range'] ?? null) === [1800, 2200], 'Continuation receives the exact accepted prefix, private rejected unit and unchanged whole-report range.'); resumeExpect($r['meta'] === [], 'A completed recovery clears obsolete first-attempt error metadata.'); $r = runResume($p, [array_replace($firstFailure, ['terminal' => false])]); resumeExpect($r['out'] === null && count($r['calls']) === 1 && $r['error'] === 'UPSTREAM_CLEANUP_PENDING' && $r['state']['upstream_stop_confirmed'] && !$r['state']['upstream_terminal_confirmed'], 'Accepted stop without actual terminal never permits another call.'); $r = runResume($p, [$firstFailure], ['cancel_during_cleanup' => true]); resumeExpect($r['out'] === null && count($r['calls']) === 1 && $r['error'] === 'AI_ASSISTANT_CANCELLED', 'Doctor cancellation during cleanup cannot start recovery even after terminal.'); $r = runResume($p, [array_replace($firstFailure, ['timeout' => true])]); resumeExpect($r['out'] === null && count($r['calls']) === 1 && $r['state']['timeout_reached'], 'A timeout tombstone blocks recovery even with remaining local milliseconds.'); $r = runResume($p, [array_replace($firstFailure, ['advance_ms' => 89000, 'cleanup_ms' => 1000])]); resumeExpect($r['out'] === null && count($r['calls']) === 1 && $r['error'] === 'UPSTREAM_TIMEOUT', 'No new call starts after the shared generation-and-stop budget expires.'); $r = runResume($p, [$firstFailure], ['without_gate' => true]); resumeExpect($r['out'] === null && count($r['calls']) === 1 && $r['error'] === 'TCM_FACT_STATE_CONFLICT', 'A missing readiness verifier never means the old task has stopped.'); $r = runResume($p, [$firstFailure, ['ok' => false, 'chunks' => [$bad], 'terminal' => true]]); resumeExpect($r['out'] === null && count($r['calls']) === 2 && $r['visible'] === $prefix && $r['error'] === 'TCM_FACT_STATE_CONFLICT' && $r['meta']['report_stage_count'] === 2 && $r['meta']['clinical_repair_count'] === 1, 'A second source-state rejection remains failure and never starts a third call.'); resumeExpect(!str_contains(json_encode($r['meta'], JSON_UNESCAPED_UNICODE), '固定刺痛'), 'Public failure metadata never exposes rejected or accepted clinical prose.'); $r = runResume($p, [$firstFailure, ['ok' => true, 'content' => $complete, 'chunks' => [$complete]]]); resumeExpect($r['out'] === null && count($r['calls']) === 2 && $r['visible'] === $prefix, 'A repeated first-section heading is rejected before duplicate prefix bytes are delivered.'); $r = runResume($p, [$firstFailure, ['ok' => false, 'error_code' => 'UPSTREAM_TIMEOUT', 'chunks' => [], 'timeout' => true]]); resumeExpect($r['out'] === null && count($r['calls']) === 2 && $r['error'] === 'UPSTREAM_TIMEOUT' && $r['visible'] === $prefix, 'A second-attempt timeout preserves only the delivered prefix and remains failed.'); $shortSuffix = "二、八纲\r\n\r\n只交付一句话。"; $r = runResume($p, [$firstFailure, ['ok' => true, 'content' => $shortSuffix, 'chunks' => [$shortSuffix]]]); resumeExpect($r['out'] === null && count($r['calls']) === 2 && $r['error'] === 'TCM_REPORT_INCOMPLETE', 'A short suffix never becomes a hidden partial-report success.'); $r = runResume($p, [$firstFailure, array_replace($success, ['content' => $suffix . 'INVISIBLE_RESULT_BYTES'])]); resumeExpect($r['out'] === null && count($r['calls']) === 2 && $r['error'] === 'STREAM_CONTENT_MISMATCH', 'Final transport content must equal the exact accepted aggregate.'); $r = runResume($p, [['ok' => true, 'content' => $complete, 'chunks' => [$complete]]]); resumeExpect(count($r['calls']) === 1 && $r['out'] !== null && $r['visible'] === $complete, 'A valid full response flushes its exact private final section without recovery.'); $finalOffset = strpos($complete, '六、方药'); resumeExpect($finalOffset !== false, 'The transport fixture has a final-section boundary.'); $finalTail = substr($complete, $finalOffset); $firstFive = substr($complete, 0, $finalOffset); $overlong = $complete . $body . $body; $r = runResume($p, [ ['ok' => true, 'content' => $overlong, 'chunks' => [$overlong]], ['ok' => true, 'content' => $finalTail, 'chunks' => [$finalTail]], ]); $firstAttemptVisible = implode('', array_column(array_filter($r['chunks'], static fn (array $chunk): bool => $chunk['stage'] === 1), 'bytes')); resumeExpect(count($r['calls']) === 2 && $r['out'] !== null && $r['visible'] === $complete && $firstAttemptVisible === $firstFive && $r['out']['clinical_repair_count'] === 1, 'An overlong private final section can use the sole recovery; rejected tail bytes never become visible.'); $r = runResume($p, [$firstFailure], ['client_rejects' => true]); resumeExpect($r['out'] === null && count($r['calls']) === 1 && $r['error'] === 'AI_ASSISTANT_CANCELLED', 'Client rejection never starts recovery.'); $r = runResume($p, [['ok' => false, 'error_code' => 'EMPTY_RESPONSE', 'chunks' => []]]); resumeExpect($r['out'] === null && count($r['calls']) === 1, 'An empty transport failure never hides a blocking compatibility generation.'); $r = runResume($p, [array_replace($firstFailure, ['advance_ms' => 3000, 'cleanup_ms' => 1000]), $success], ['timeout_ms' => 12500, 'on_progress' => static function (): void { $GLOBALS['resumeClockNs'] += 500000000; }]); resumeExpect($r['out'] !== null && $r['calls'][0]['options']['timeout_ms'] === 12500 && $r['calls'][1]['options']['timeout_ms'] === 8000, 'A shorter caller deadline includes continuation preparation/progress time and is never reset.'); $r = runResume($p, [$firstFailure], ['gate' => static fn (): bool => false]); resumeExpect($r['out'] === null && $r['calls'] === [] && $r['meta']['report_stage_count'] === 0 && $r['meta']['clinical_repair_count'] === 0, 'A blocked initial gate discloses zero actual calls.'); $r = runResume($p, [array_replace($firstFailure, ['advance_ms' => 200, 'cleanup_ms' => 100])], ['timeout_ms' => 1000, 'on_progress' => static function (): void { $GLOBALS['resumeClockNs'] += 2000000000; }]); resumeExpect($r['out'] === null && count($r['calls']) === 1 && $r['error'] === 'UPSTREAM_TIMEOUT' && $r['meta']['clinical_repair_count'] === 0, 'Continuation preparation cannot reset an exhausted shared deadline or inflate actual repair counts.'); } // An accepted prefix may end within a section, not just at a heading. $inside = "一、四诊\r\n\r\n望诊:未记录。\r\n\r\n"; $remaining = substr($complete, strlen($inside)); $r = runResume($prepared, [ ['ok' => false, 'chunks' => [$inside, $bad], 'terminal' => true], ['ok' => true, 'content' => $remaining, 'chunks' => [$remaining]], ]); resumeExpect($r['out'] !== null && $r['visible'] === $inside . $remaining && substr_count($r['visible'], '一、四诊') === 1, 'Continuation finishes a partially delivered section without inserting another heading.'); $r = runResume($prepared, [ ['ok' => true, 'content' => $prefix . rtrim($bad), 'chunks' => [$prefix, rtrim($bad)]], ['ok' => true, 'content' => $suffix, 'chunks' => [$suffix]], ]); resumeExpect($r['out'] !== null && $r['visible'] === $complete && $r['reasons'] === [] && count($r['calls']) === 2, 'A rejected final buffered unit may recover after the original natural terminal without fake doctor cancellation.'); $r = runResume($prepared, [ ['ok' => false, 'chunks' => [$bad], 'terminal' => true], ['ok' => true, 'content' => $complete, 'chunks' => [$complete]], ]); resumeExpect($r['out'] !== null && $r['visible'] === $complete && count($r['calls']) === 2, 'An empty accepted prefix can recover once with a complete new report after actual terminal.'); $contextVisible = ''; $context = new TcmClinicalReportStream($guard['fact_state_ledger'], static function (string $text) use (&$contextVisible): bool { $contextVisible .= $text; return true; }, null, "过敏史:\n\n"); resumeExpect(!$context->push("无。\n\n") && $contextVisible === '' && $context->rejected(), 'A field label in the old prefix retains unknown-to-negative protection across the attempt boundary.'); $context = new TcmClinicalReportStream($guard['fact_state_ledger'], static function (string $text) use (&$contextVisible): bool { $contextVisible .= $text; return true; }, null, "过敏史:\n\n"); resumeExpect($context->push("未记录。\n\n") && $context->finish() && $contextVisible === "未记录。\n\n", 'A faithful field value continues the old label while forwarding suffix bytes only.'); $sourceId = array_values(array_filter($guard['fact_state_ledger']['sources'], static fn (array $row): bool => $row['field'] === 'present_illness'))[0]['id']; $context = new TcmClinicalReportStream($guard['fact_state_ledger'], static fn (): bool => true, null, "一、四诊\n\n固定刺痛未记录(" . $sourceId . ")。\n\n"); resumeExpect(!$context->push("当前无固定刺痛。\n\n"), 'Earlier truthful source citations never excuse a new false negative after recovery.'); $context = new TcmClinicalReportStream($guard['fact_state_ledger'], static fn (): bool => true, null, "一、四诊\n\n固定刺痛未记录(" . $sourceId . ")。\n\n"); resumeExpect(!$context->push("本次固定刺痛未记录(s999)。\n\n"), 'Source annotation validation stays active after carrying a prior source context.'); // The repair hint identifies the exact authorized source and current // position. It never rewrites the delivered prefix or original inputs. $delivery = (new ReflectionMethod(DiagnosisAiLogic::class, 'prepareLongReportDelivery'))->invoke(null, $prepared); $sourceRow = $guard['fact_state_ledger']['sources'][$sourceId]; $hinted = (new ReflectionMethod(DiagnosisAiLogic::class, 'prepareLongReportContinuation'))->invoke(null, $delivery, $prefix, $bad, [ ['code' => 'NEGATION_SCOPE_WIDENED', 'source_id' => $sourceId], ['code' => 'NEGATIVE_TO_POSITIVE', 'source_id' => $sourceId], ['code' => 'UNKNOWN_TO_NEGATIVE', 'source_id' => 's999999'], ]); preg_match('~\n(.*?)\n~s', $hinted['query'], $hintBlock); $hint = json_decode($hintBlock[1], true, 512, JSON_THROW_ON_ERROR); resumeExpect($hint['issue_source_quotations'] === [['source_id' => $sourceId, 'field' => $sourceRow['field'], 'scope' => $sourceRow['scope'], 'date' => $sourceRow['date'], 'quote' => $sourceRow['quote']]], 'Feedback quotes the exact authorized source once; unknown IDs cannot supply invented evidence.'); resumeExpect($hint['delivered_prefix'] === $prefix && $hint['rejected_undelivered_unit'] === $bad, 'Repair feedback preserves original clinical bytes rather than substituting a corrected report.'); resumeExpect($hint['remaining_suffix_character_range'] === [1800 - mb_strlen($prefix), 2200 - mb_strlen($prefix)] && $hint['original_whole_report_range'] === [1800, 2200], 'Suffix arithmetic includes all raw prefix characters and does not loosen the original whole range.'); resumeExpect($hint['resume_existing_section'] === 1 && $hint['already_delivered_section_headings'] === [1], 'An in-section retry identifies an existing section instead of instructing a repeated heading.'); resumeExpect(str_starts_with($hinted['query'], $delivery['query']) && $hinted['inputs'] === $prepared['inputs'] && $hinted['files'] === $prepared['files'] && $hinted['profile'] === $prepared['profile'] && $hinted['user'] === $prepared['user'], 'All original inputs/question/source/files/profile/user survive unchanged.'); $lastPrefix = "一、四诊\n\n二、八纲\n\n三、气血津液与脏腑\n\n四、证名\n\n五、治法\n\n六、方药\n\n"; $lastHinted = (new ReflectionMethod(DiagnosisAiLogic::class, 'prepareLongReportContinuation'))->invoke(null, $delivery, $lastPrefix, $bad, []); preg_match('~\n(.*?)\n~s', $lastHinted['query'], $lastBlock); $lastHint = json_decode($lastBlock[1], true, 512, JSON_THROW_ON_ERROR); resumeExpect($lastHint['resume_existing_section'] === 6 && $lastHint['already_delivered_section_headings'] === range(1, 6) && $lastHint['issue_source_quotations'] === [], 'A late retry identifies section six and never invents a report-only source.'); foreach (glob($controlRoot . '/*') ?: [] as $path) if (is_file($path)) unlink($path); if (is_file($controlRoot . '/.gc.lock')) unlink($controlRoot . '/.gc.lock'); rmdir($controlRoot); echo "TCM bounded same-channel long report recovery: {$assertions} assertions passed (offline; no model/network).\n"; }