314 lines
24 KiB
PHP
314 lines
24 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
namespace think\facade {
|
||
class Log {
|
||
public static function warning(string $message, array $context = []): void {}
|
||
public static function write(string $message, string $level = ''): void {}
|
||
}
|
||
}
|
||
namespace app\adminapi\logic\tcm {
|
||
function config(string $key): array { return ['timeout' => 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' => '原安全规则<CASE_DATA>ALL_13_UNCHANGED_SOURCE_BYTES</CASE_DATA>'
|
||
. '<USER_QUESTION>请生成1800至2200字完整中医辨证长报告</USER_QUESTION>',
|
||
'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'], '<USER_QUESTION>') === 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('~<REPORT_CONTINUATION_DATA>\s*(.*?)\s*</REPORT_CONTINUATION_DATA>~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('~<REPORT_CONTINUATION_DATA>\n(.*?)\n</REPORT_CONTINUATION_DATA>~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('~<REPORT_CONTINUATION_DATA>\n(.*?)\n</REPORT_CONTINUATION_DATA>~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";
|
||
}
|