90]; }
function hrtime(bool $number = false): int|array { $v = $GLOBALS['checkpointClock']; return $number ? $v : [intdiv($v, 1000000000), $v % 1000000000]; }
}
namespace app\adminapi\service { function hrtime(bool $number = false): int|array { return \app\adminapi\logic\tcm\hrtime($number); } }
namespace app\common\service {
final class DifyChatService {
public static array $calls = [], $queue = [];
public static function streamChat($profile, $inputs, $query, $user, $onDelta, $abort, $files = [], $options = []): array {
self::$calls[] = compact('profile', 'inputs', 'query', 'user', 'files', 'options');
if (self::$queue === []) throw new \RuntimeException('Unexpected additional generation.');
$r = array_shift(self::$queue); $id = 'checkpoint-' . count(self::$calls);
if (isset($options['on_task'])) ($options['on_task'])($id);
$GLOBALS['checkpointClock'] += ($r['advance_ms'] ?? 0) * 1000000;
foreach ($r['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['checkpointClock'] += ($r['cleanup_ms'] ?? 0) * 1000000;
if (!empty($r['terminal']) && isset($options['on_terminal'])) ($options['on_terminal'])($id, 'stopped');
return ['ok' => false, 'error_code' => 'AI_ASSISTANT_CANCELLED'];
}
if (!empty($r['disconnect_after_chunks'])) $GLOBALS['checkpointDisconnect'] = true;
$GLOBALS['checkpointClock'] += ($r['finish_advance_ms'] ?? 0) * 1000000;
if (isset($options['on_terminal'])) ($options['on_terminal'])($id, 'succeeded');
return $r;
}
public static function chat(...$args): array { throw new \RuntimeException('Blocking replacement is forbidden.'); }
}
}
namespace {
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\logic\tcm\DiagnosisAiLogic as Logic;
use app\adminapi\service\AssistantRequestControl;
use app\adminapi\service\TcmEvidenceGuard;
use app\adminapi\service\TcmLongReportContract;
use app\common\service\DifyChatService as Transport;
$checks = 0; $checkpointClock = 1000000000; $checkpointDisconnect = false;
function recoveryExpect(bool $ok, string $why): void { ++$GLOBALS['checks']; if (!$ok) throw new RuntimeException($why); }
$chief = '乏力、活动后气短与白天自汗持续两周;患者自述活动后症状较明显,休息后可缓解,具体起止和日常活动关系保留本次访谈记录。';
$current = ['id' => 1, 'diagnosis_date' => '2026-10-23', 'chief_complaint' => $chief,
'present_illness' => '口干;未记录固定刺痛。', 'tongue' => '舌淡红苔少', 'pulse' => '脉细弱'];
$guard = TcmEvidenceGuard::assess($current);
$query = 'ALL_13_ORIGINAL_BYTES生成1800至2200字完整中医辨证长报告。';
$prepared = ['task' => 'custom', 'profile' => 'openai', 'admin_id' => 1, 'query' => $query,
'inputs' => ['source' => 'ALL_13_ORIGINAL_BYTES', 'opaque' => "original\r\nbytes"],
'files' => [['type' => 'image', 'url' => 'https://synthetic.invalid/unchanged.png']],
'user' => 'checkpoint-user', 'tcm_evidence_guard' => $guard, 'upstream_timeout' => 90];
$body = '本段为离线工程机制核验文字,记录输出次序与字节边界,临床事实和方药审核由独立测试负责。';
$prefix = "一、四诊\n\n望诊:未记录。\n\n闻诊:未记录。\n\n问诊:2026-10-23“" . $chief . "”(s1);2026-10-23“口干;未记录固定刺痛。”(s2)。\n\n切诊:其他触诊未记录,脉象见独立脉象项。\n\n舌象:2026-10-23“舌淡红苔少”(s3)。\n\n脉象:2026-10-23“脉细弱”(s4)。\n\n";
foreach (['二、八纲', '三、气血津液与脏腑', '四、证名', '五、治法'] as $title) $prefix .= $title . "\n\n" . str_repeat($body, 3) . "\n\n";
$makeTail = static function (string $p, int $length, string $intro = '主方方向:暂不列方,继续复核资料及监测。') use ($body): string {
$t = "六、中药方药及复核监测\n\n" . $intro . "\n\n" . str_repeat($body, 3);
return $t . str_repeat('核', max(0, $length - mb_strlen($p . $t, 'UTF-8')));
};
$tail = $makeTail($prefix, 2000); $whole = $prefix . $tail;
recoveryExpect(TcmLongReportContract::validate($whole, $query)['valid'], 'The transport fixture retains the actual original range and all six substantive bodies.');
$badTail = "六、中药方药及复核监测\n\n主方方向:甲乙散方向,仅限医师审核。\n\n局限:核心方证证据不足。\n\n";
$root = sys_get_temp_dir() . '/tcm-final-checkpoint-' . bin2hex(random_bytes(6)); mkdir($root, 0700);
function recoveryRun(array $prepared, array $queue, array $behavior = []): array {
$GLOBALS['checkpointClock'] = 1000000000; $GLOBALS['checkpointDisconnect'] = false;
Transport::$calls = []; Transport::$queue = $queue;
$control = new AssistantRequestControl(1, 1, '12345678-1234-4234-8234-' . bin2hex(random_bytes(6)), $GLOBALS['root']); $control->begin();
$visible = ''; $reasons = [];
$options = ['can_start_attempt' => static fn (): bool => $control->canStartAttempt(),
'on_task' => static fn ($id) => $control->bindTask($prepared['profile'], $prepared['user'], $id),
'on_terminal' => static fn ($id, $status) => $control->markTerminal($id, $status),
'on_cancel_task' => static function ($id, $stop, $why) use ($control, &$reasons, $behavior): array {
$reasons[] = $why; if ($why === 'validation') $control->signalValidationFailure(); else $control->signalCancel();
if (!empty($behavior['cancel_cleanup'])) $control->signalCancel();
return $control->stopBoundTask($stop, $id);
}];
if (!empty($behavior['without_gate'])) unset($options['can_start_attempt']);
$result = Logic::streamPreparedAssistant($prepared, static function ($delta) use (&$visible, $behavior): bool {
if (!empty($behavior['refuse_tail']) && str_starts_with($delta, '六、')) return false;
$visible .= $delta; return true;
}, static fn (): bool => $control->shouldAbort() || $GLOBALS['checkpointDisconnect'], null, $options);
return ['out' => $result, 'visible' => $visible, 'calls' => Transport::$calls, 'error' => Logic::getAssistantErrorCode(),
'meta' => Logic::getAssistantErrorMeta(), 'state' => $control->publicState(), 'reasons' => $reasons];
}
try {
foreach (['qwen', 'openai'] as $profile) {
$p = $prepared; $p['profile'] = $profile;
$failure = ['ok' => false, 'chunks' => [$prefix, $badTail, 'NEVER_FORWARD'], 'terminal' => true, 'advance_ms' => 45000, 'cleanup_ms' => 1500];
$success = ['ok' => true, 'content' => $tail, 'chunks' => mb_str_split($tail, 7, 'UTF-8')];
$r = recoveryRun($p, [['ok' => true, 'content' => $whole, 'chunks' => mb_str_split($whole, 7, 'UTF-8')]]);
recoveryExpect($r['out'] !== null && $r['visible'] === $whole && $r['out']['answer'] === $whole
&& count($r['calls']) === 1 && $r['out']['clinical_repair_count'] === 0, 'A valid original single-attempt report retains exact bytes and no unnecessary repair.');
$earlyPrefix = substr($prefix, 0, strpos($prefix, '三、'));
$remaining = substr($whole, strlen($earlyPrefix));
$r = recoveryRun($p, [['ok' => true, 'content' => $earlyPrefix, 'chunks' => [$earlyPrefix], 'advance_ms' => 45000],
['ok' => true, 'content' => $remaining, 'chunks' => mb_str_split($remaining, 7, 'UTF-8')]]);
recoveryExpect($r['out'] !== null && $r['visible'] === $whole && $r['out']['answer'] === $whole
&& count($r['calls']) === 2 && $r['out']['clinical_repair_count'] === 1,
'Natural truncation after sections1,2 uses the sole model suffix for sections3–6, preserving exact raw identity.');
preg_match('~\s*(.*?)\s*~su', $r['calls'][1]['query'], $m); $earlyData = json_decode($m[1], true);
recoveryExpect($earlyData['delivered_prefix'] === $earlyPrefix && $earlyData['already_delivered_section_headings'] === [1, 2]
&& $earlyData['resume_existing_section'] === 2 && $earlyData['restart_undelivered_section'] === null
&& !str_contains($r['calls'][1]['query'], '当前必须从第六节标题开始')
&& !str_contains($r['calls'][1]['query'], '必须从“六、中药方药及复核监测”标题重新生成'),
'Generic early truncation resumes the existing section or missing following sections, without forcing a sixth-section restart.');
recoveryExpect(TcmLongReportContract::validate($r['visible'], $query)['section_order'] === [1, 2, 3, 4, 5, 6]
&& $earlyData['remaining_suffix_character_range'] === [1800 - mb_strlen($earlyPrefix, 'UTF-8'), 2200 - mb_strlen($earlyPrefix, 'UTF-8')]
&& $r['calls'][1]['options']['timeout_ms'] === 45000,
'Sections3–6 appear once within the original remaining character and absolute time budget.');
foreach ($r['calls'] as $call) recoveryExpect($call['profile'] === $profile && $call['inputs'] === $p['inputs']
&& $call['files'] === $p['files'] && str_starts_with($call['query'], $p['query']),
'Early-truncation recovery preserves the original channel and complete original source payload.');
$factFailure = array_replace($failure, ['chunks' => [$prefix, "六、方药\n\n当前无固定刺痛。\n\n"]]);
$quotedFailure = array_replace($failure, ['chunks' => [$prefix, "六、方药\n\n主方方向:“甲乙散”(s1)。核心方证证据不足。\n\n"]]);
foreach ([$failure, $factFailure, $quotedFailure, ['ok' => true, 'content' => $prefix . $makeTail($prefix, 2221), 'chunks' => [$prefix . $makeTail($prefix, 2221)], 'advance_ms' => 45000]] as $first) {
$r = recoveryRun($p, [$first, $success]);
recoveryExpect($r['out'] !== null && $r['visible'] === $whole && $r['out']['answer'] === $whole, 'A private invalid final tail is replaced only by an original new model tail with exact final identity.');
recoveryExpect(count($r['calls']) === 2 && $r['out']['clinical_repair_count'] === 1 && $r['out']['report_stage_count'] === 2, 'Only the existing sole continuation is used.');
recoveryExpect(!str_contains($r['visible'], '甲乙散') && !str_contains($r['visible'], 'NEVER_FORWARD') && !str_contains($r['visible'], '当前无固定刺痛'), 'No rejected sixth-section draft byte is exposed.');
foreach ($r['calls'] as $call) recoveryExpect($call['profile'] === $profile && $call['inputs'] === $p['inputs'] && $call['files'] === $p['files']
&& $call['user'] === $p['user'] && str_starts_with($call['query'], $p['query']) && substr_count($call['query'], '') === 1,
'Original channel, source inputs, files, user and question survive both calls.');
preg_match('~\s*(.*?)\s*~su', $r['calls'][1]['query'], $m); $data = json_decode($m[1], true);
recoveryExpect($data['delivered_prefix'] === $prefix && $data['restart_undelivered_section'] === 6 && $data['resume_existing_section'] === 6,
'Continuation explicitly starts the never-delivered sixth heading after the exact first-five prefix.');
if ($first === $quotedFailure) recoveryExpect(in_array('HERBAL_PREMISE_SELF_CONFLICT', array_column($data['issues'], 'code'), true),
'The unrelated s1 citation counterfeit is specifically rejected by the premise checkpoint against the actual source ledger.');
recoveryExpect($data['original_whole_report_range'] === [1800, 2200]
&& $data['remaining_suffix_character_range'] === [1800 - mb_strlen($prefix, 'UTF-8'), 2200 - mb_strlen($prefix, 'UTF-8')]
&& str_contains($r['calls'][1]['query'], '第六节不列具体方名、药味'),
'Correction explains omitted proposals when key evidence remains missing and retains the exact remaining whole-report character budget.');
recoveryExpect($r['calls'][1]['options']['timeout_ms'] === (!empty($first['cleanup_ms']) ? 43500 : 45000), 'Generation and confirmed-stop cleanup consume the same90-second deadline.');
}
$r = recoveryRun($p, [array_replace($failure, ['terminal' => false])]);
recoveryExpect($r['out'] === null && count($r['calls']) === 1 && $r['error'] === 'UPSTREAM_CLEANUP_PENDING' && $r['visible'] === $prefix, 'Accepted stop without actual terminal blocks recovery.');
$r = recoveryRun($p, [$failure], ['cancel_cleanup' => true]);
recoveryExpect($r['out'] === null && count($r['calls']) === 1 && $r['error'] === 'AI_ASSISTANT_CANCELLED', 'Cancellation during cleanup never starts recovery.');
$r = recoveryRun($p, [array_replace($failure, ['advance_ms' => 89000, 'cleanup_ms' => 1000])]);
recoveryExpect($r['out'] === null && count($r['calls']) === 1 && $r['error'] === 'UPSTREAM_TIMEOUT', 'No repair gets an extra deadline after cleanup consumes90seconds.');
$r = recoveryRun($p, [$failure, ['ok' => false, 'chunks' => [$badTail], 'terminal' => true]]);
recoveryExpect($r['out'] === null && count($r['calls']) === 2 && $r['visible'] === $prefix, 'A second invalid tail cannot create a third generation.');
$r = recoveryRun($p, [$failure], ['without_gate' => true]);
recoveryExpect($r['out'] === null && count($r['calls']) === 1, 'Missing actual-terminal readiness cannot authorize recovery.');
$r = recoveryRun($p, [['ok' => true, 'content' => $whole, 'chunks' => [$whole], 'disconnect_after_chunks' => true]]);
recoveryExpect($r['out'] === null && $r['visible'] === $prefix && count($r['calls']) === 1, 'Disconnect while the final tail is buffered never exposes it or starts another call.');
$r = recoveryRun($p, [['ok' => true, 'content' => $whole, 'chunks' => [$whole]]], ['refuse_tail' => true]);
recoveryExpect($r['out'] === null && $r['visible'] === $prefix && $r['error'] === 'AI_ASSISTANT_CANCELLED' && count($r['calls']) === 1, 'Callback false on final flush remains cancellation.');
$r = recoveryRun($p, [['ok' => true, 'content' => $whole, 'chunks' => [$whole], 'finish_advance_ms' => 90000]]);
recoveryExpect($r['out'] === null && $r['visible'] === $prefix && $r['error'] === 'UPSTREAM_TIMEOUT' && count($r['calls']) === 1, 'Expired absolute deadline before final flush cannot release the tail.');
$tightPrefix = $prefix . str_repeat('核', 2148 - mb_strlen($prefix, 'UTF-8')) . "\n\n";
$tightDraft = $tightPrefix . $makeTail($tightPrefix, 2300);
$r = recoveryRun($p, [['ok' => true, 'content' => $tightDraft, 'chunks' => [$tightDraft]]]);
recoveryExpect($r['out'] === null && count($r['calls']) === 1 && $r['visible'] === $tightPrefix, 'No remaining character room for a complete sixth section prevents a repair attempt.');
}
} finally {
foreach (glob($root . '/*') ?: [] as $f) if (is_file($f)) unlink($f);
if (is_file($root . '/.gc.lock')) unlink($root . '/.gc.lock'); rmdir($root);
}
echo "TCM final-section sole-recovery integration: {$checks} assertions passed (offline transport; original acceptance unchanged).\n";
}