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";
}