Files
zyt/server/tests/TcmFinalSectionRecoveryIntegrationTest.php
T
2026-10-27 14:03:35 +08:00

159 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace think\facade { class Log { public static function warning(string $s, array $c = []): void {} public static function write(string $s, string $l = ''): void {} } }
namespace app\adminapi\logic\tcm {
function config(string $s): array { return ['timeout' => 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 = '<CASE_DATA>ALL_13_ORIGINAL_BYTES</CASE_DATA><USER_QUESTION>生成1800至2200字完整中医辨证长报告。</USER_QUESTION>';
$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('~<REPORT_CONTINUATION_DATA>\s*(.*?)\s*</REPORT_CONTINUATION_DATA>~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'], '<USER_QUESTION>') === 1,
'Original channel, source inputs, files, user and question survive both calls.');
preg_match('~<REPORT_CONTINUATION_DATA>\s*(.*?)\s*</REPORT_CONTINUATION_DATA>~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";
}