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

263 lines
20 KiB
PHP

<?php
declare(strict_types=1);
/** Offline real-lifecycle regression for ordinary consultation answer correction. */
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 $number = false): int|array {
$value = $GLOBALS['answerRecoveryClock'];
return $number ? $value : [intdiv($value, 1000000000), $value % 1000000000];
}
}
namespace app\adminapi\service {
function hrtime(bool $number = false): int|array { return \app\adminapi\logic\tcm\hrtime($number); }
function usleep(int $microseconds): void { $GLOBALS['answerRecoveryClock'] += $microseconds * 1000; }
}
namespace app\common\service {
final class DifyChatService {
public static array $calls = [], $queue = [];
public static function streamChat($profile, $inputs, $query, $user, $delta, $abort, $files = [], $options = []): array {
return self::respond('stream', $profile, $inputs, $query, $user, $files, $options, $delta);
}
public static function chat($profile, $inputs, $query, $user, $files = [], $options = []): array {
return self::respond('blocking', $profile, $inputs, $query, $user, $files, $options);
}
private static function respond(string $kind, $profile, $inputs, $query, $user, $files, $options, ?callable $delta = null): array {
self::$calls[] = compact('kind', 'profile', 'inputs', 'query', 'user', 'files', 'options');
if (self::$queue === []) throw new \RuntimeException('Unexpected extra generation.');
$response = array_shift(self::$queue);
$task = 'answer-recovery-' . count(self::$calls);
$run = 'answer-recovery-run-' . count(self::$calls);
if (isset($options['on_task'])) ($options['on_task'])($task, $run);
$GLOBALS['answerRecoveryClock'] += ($response['advance_ms'] ?? 0) * 1000000;
foreach ($response['chunks'] ?? [] as $chunk) {
if ($delta === null || $delta($chunk) !== false) continue;
if (isset($options['on_cancel_task'])) ($options['on_cancel_task'])($task, static fn (): bool => true, 'cancelled');
$GLOBALS['answerRecoveryClock'] += ($response['cleanup_ms'] ?? 0) * 1000000;
if ($response['terminal'] ?? true) ($options['on_terminal'])($task, 'stopped', $run);
if (isset($options['on_transfer_end'])) ($options['on_transfer_end'])($task, $response['terminal'] ?? true ? 'stopped' : 'running');
return ['ok' => false, 'error_code' => 'AI_ASSISTANT_CANCELLED'];
}
if (isset($options['on_terminal'])) ($options['on_terminal'])($task, 'succeeded', $run);
if (isset($options['on_transfer_end'])) ($options['on_transfer_end'])($task, 'succeeded');
return $response;
}
}
}
namespace {
require dirname(__DIR__) . '/vendor/autoload.php';
use app\adminapi\logic\tcm\DiagnosisAiLogic as Logic;
use app\adminapi\service\AssistantRequestControl;
use app\adminapi\service\TcmClinicalReportStream;
use app\adminapi\service\TcmEvidenceGuard;
use app\common\service\DifyChatService as Transport;
$checks = 0;
$answerRecoveryClock = 1000000000;
function answerExpect(bool $condition, string $reason): void {
++$GLOBALS['checks'];
if (!$condition) throw new RuntimeException($reason);
}
$root = sys_get_temp_dir() . '/zyt-answer-recovery-' . bin2hex(random_bytes(6));
mkdir($root, 0700);
$prepared = ['task' => 'summary', 'profile' => 'qwen', 'user' => 'offline-recovery', 'upstream_timeout' => 90,
'query' => '<CASE_DATA>ORIGINAL_THIRTEEN_SOURCES</CASE_DATA><USER_QUESTION>总结当前病情、证候与风险。</USER_QUESTION>',
'inputs' => ['source' => "ORIGINAL\r\nBYTES"],
'files' => [['type' => 'image', 'url' => 'https://synthetic.invalid/unchanged.png']],
'tcm_evidence_guard' => TcmEvidenceGuard::assess(['chief_complaint' => '口干。',
'present_illness' => '未记录固定刺痛。', 'tongue' => '舌淡红。'])];
$prefix = "中医辨证分析\n\n一、四诊信息整理\n\n";
$body = "本次已有口干记录,证候判断仍须结合已采资料由医师复核。\n\n";
$bad = "当前无固定刺痛。\n\n";
$pulseBad = "脉象:未记录。\n\n";
function answerRun(array $prepared, array $responses, array $behavior = []): array {
$GLOBALS['answerRecoveryClock'] = 1000000000;
Transport::$calls = []; Transport::$queue = $responses;
$control = new AssistantRequestControl(1, 1, '12345678-1234-4234-8234-' . bin2hex(random_bytes(6)), $GLOBALS['root']);
$control->begin();
$visible = ''; $progress = []; $reasons = [];
$options = [
'can_start_attempt' => static fn (): bool => $control->canStartAttempt(),
'on_task' => static fn ($task, $run) => $control->bindTask($prepared['profile'], $prepared['user'], $task, $run),
'on_terminal' => static fn ($task, $status, $run) => $control->markTerminal($task, $status, $run),
'on_transfer_end' => static fn ($task, $status) => $control->closeGeneration($task,
static fn ($identity): array => ['ok' => true, 'workflow_run_id' => $identity['workflow_run_id'], 'status' => $status]),
'on_cancel_task' => static function ($task, $stop, $reason) use ($control, &$reasons, $behavior): array {
$reasons[] = $reason;
if ($reason === 'validation') $control->signalValidationFailure(); else $control->signalCancel();
if (!empty($behavior['cancel_cleanup'])) $control->signalCancel();
return $control->stopBoundTask($stop, $task);
},
'on_progress' => static function ($event) use (&$progress): void { $progress[] = $event; },
];
if (!empty($behavior['without_gate'])) unset($options['can_start_attempt']);
$options = array_replace($options, $behavior['options'] ?? []);
$result = Logic::streamPreparedAssistant($prepared, static function ($delta) use (&$visible, $behavior): bool {
if (!empty($behavior['refuse_delta'])) return false;
$visible .= $delta;
return true;
}, static fn (): bool => $control->shouldAbort(), null, $options);
return ['out' => $result, 'visible' => $visible, 'calls' => Transport::$calls,
'error' => Logic::getAssistantErrorCode(), 'meta' => Logic::getAssistantErrorMeta(),
'progress' => $progress, 'reasons' => $reasons];
}
try {
foreach (['qwen', 'openai'] as $profile) {
foreach (['summary', 'custom', 'tcm_pattern'] as $task) {
$p = array_replace($prepared, compact('profile', 'task'));
$good = ['ok' => true, 'content' => $body, 'chunks' => mb_str_split($body, 3, 'UTF-8')];
$r = answerRun($p, [['ok' => true, 'content' => $prefix . $body, 'chunks' => [$prefix, $body]]]);
answerExpect($r['out'] !== null && $r['out']['answer'] === trim($prefix . $body)
&& $r['visible'] === $prefix . $body && count($r['calls']) === 1
&& $r['out']['clinical_repair_count'] === 0, 'Valid ordinary answer stays single-attempt and byte-identical.');
$heading = " ### 三、资料归纳 \t\r\n\r\n";
foreach ([1, 2, 7, 1000] as $chunkSize) {
$first = $heading . $bad;
$suffix = $heading . $body;
$r = answerRun($p, [
['ok' => true, 'content' => $first, 'chunks' => mb_str_split($first, $chunkSize, 'UTF-8')],
['ok' => true, 'content' => $suffix, 'chunks' => mb_str_split($suffix, $chunkSize, 'UTF-8')],
]);
answerExpect($r['out'] !== null && $r['visible'] === $heading . $suffix
&& $r['out']['answer'] === trim($heading . $suffix),
'Ordinary correction retains both explicit heading units, including original whitespace and CRLF bytes.');
answerExpect(count($r['calls']) === 2 && $r['out']['clinical_repair_count'] === 1
&& $r['reasons'] === ['validation'], 'A repeated heading does not add attempts or bypass confirmed cleanup.');
}
foreach ([$bad => 'TCM_FACT_STATE_CONFLICT', $pulseBad => 'PULSE_EXCLUDED_FROM_ASSESSMENT'] as $invalid => $error) {
$r = answerRun($p, [
['ok' => true, 'content' => $heading . $bad, 'chunks' => [$heading, $bad]],
['ok' => true, 'content' => $heading . $invalid, 'chunks' => [$heading, $invalid]], $good,
]);
answerExpect($r['out'] === null && $r['visible'] === $heading . $heading
&& count($r['calls']) === 2 && $r['error'] === $error && $r['meta']['clinical_repair_count'] === 1,
'A retained duplicate heading still runs fact and pulse validation; an invalid correction cannot start a third attempt.');
}
$r = answerRun($p, [
['ok' => true, 'content' => $heading . $body . $bad, 'chunks' => [$heading, $body, $bad]],
['ok' => true, 'content' => $heading . $body, 'chunks' => [$heading, $body]], $good,
]);
answerExpect($r['out'] === null && $r['visible'] === $heading . $body . $heading && count($r['calls']) === 2
&& in_array('REPORT_CONTINUATION_REPEATED_SECTION', array_column($r['meta']['clinical_fact_state_issues'], 'code'), true),
'Substantive duplicate prose is rejected after a retained heading without rewriting either attempt.');
foreach ([$bad, $pulseBad] as $invalid) {
foreach ([1, 2, 7, 1000] as $chunkSize) {
$first = ['ok' => true, 'content' => $prefix . $invalid,
'chunks' => mb_str_split($prefix . $invalid, $chunkSize, 'UTF-8'),
'advance_ms' => 4000, 'cleanup_ms' => 1000];
$r = answerRun($p, [$first, $good]);
answerExpect($r['out'] !== null && $r['visible'] === $prefix . $body
&& $r['out']['answer'] === trim($r['visible']), 'Header-only failure recovers using exact new model suffix.');
answerExpect(count($r['calls']) === 2 && $r['out']['clinical_repair_count'] === 1
&& $r['reasons'] === ['validation'], 'Only one correction after confirmed validation cleanup.');
foreach ($r['calls'] as $call) {
answerExpect($call['profile'] === $profile && $call['inputs'] === $p['inputs']
&& $call['files'] === $p['files'] && $call['user'] === $p['user']
&& str_starts_with($call['query'], $p['query']), 'Original model, sources, files, user and question survive.');
}
answerExpect($r['calls'][1]['options']['timeout_ms'] === 85000
&& count($r['progress']) === 1, 'Correction shares original 90-second deadline including cleanup.');
preg_match('~<CLINICAL_ANSWER_REPAIR_DATA>\s*(.*?)\s*</CLINICAL_ANSWER_REPAIR_DATA>~su', $r['calls'][1]['query'], $m);
$data = json_decode($m[1], true, 512, JSON_THROW_ON_ERROR);
answerExpect($data['delivered_prefix'] === $prefix && $data['rejected_undelivered_unit'] === $invalid
&& $data['issues'] !== [], 'Private correction has exact rejection and approved-prefix context.');
answerExpect(!str_contains(json_encode($r['out'], JSON_UNESCAPED_UNICODE), trim($invalid)), 'Rejected paragraph never reaches final answer.');
}
}
}
$p = array_replace($prepared, compact('profile'));
$failure = ['ok' => true, 'content' => $prefix . $bad, 'chunks' => [$prefix, $bad]];
$good = ['ok' => true, 'content' => $body, 'chunks' => [$body]];
$r = answerRun($p, [array_replace($failure, ['terminal' => false])]);
answerExpect($r['out'] === null && count($r['calls']) === 1 && $r['visible'] === $prefix
&& $r['error'] === 'UPSTREAM_CLEANUP_PENDING', 'Stop acknowledgement without terminal never admits another generation.');
$r = answerRun($p, [$failure], ['cancel_cleanup' => true]);
answerExpect($r['out'] === null && count($r['calls']) === 1 && $r['error'] === 'AI_ASSISTANT_CANCELLED', 'User cancellation during cleanup blocks repair.');
$r = answerRun($p, [array_replace($failure, ['advance_ms' => 89000, 'cleanup_ms' => 1000])]);
answerExpect($r['out'] === null && count($r['calls']) === 1 && $r['error'] === 'UPSTREAM_TIMEOUT', 'Deadline is never reset for correction.');
$r = answerRun($p, [$failure, $failure, $good]);
answerExpect($r['out'] === null && count($r['calls']) === 2 && $r['meta']['clinical_repair_count'] === 1,
'Second invalid answer cannot create a third generation or successful report.');
$r = answerRun($p, [$failure], ['without_gate' => true]);
answerExpect($r['out'] === null && count($r['calls']) === 1 && $r['error'] === 'TCM_FACT_STATE_CONFLICT', 'No correction without lifecycle readiness callback.');
$r = answerRun($p, [array_replace($failure, ['chunks' => [$prefix, rtrim($bad)], 'cleanup_ms' => 0]), $good]);
answerExpect($r['out'] !== null && $r['visible'] === $prefix . $body, 'Invalid successful-EOF tail also recovers after natural terminal.');
$r = answerRun($p, [['ok' => false, 'error_code' => 'UPSTREAM_TIMEOUT']]);
answerExpect($r['out'] === null && count($r['calls']) === 1, 'Unrelated upstream timeout does not enter clinical repair.');
$r = answerRun($p, [['ok' => false, 'error_code' => 'INCOMPLETE_RESPONSE'],
['ok' => true, 'content' => $bad], $good]);
answerExpect($r['out'] === null && count($r['calls']) === 2 && $r['visible'] === '', 'Compatibility plus clinical failure shares two-attempt ceiling.');
$r = answerRun($p, [['ok' => true, 'content' => $body, 'chunks' => [$body]]], ['refuse_delta' => true]);
answerExpect($r['out'] === null && count($r['calls']) === 1, 'Downstream refusal cannot start a correction.');
$r = answerRun($p, [array_replace($failure, ['advance_ms' => 4000, 'cleanup_ms' => 1000]), $good],
['options' => ['timeout_ms' => 12000]]);
answerExpect($r['out'] !== null && $r['calls'][1]['options']['timeout_ms'] === 7000, 'Short caller budget includes first attempt and cleanup.');
$r = answerRun($p, [array_replace($failure, ['advance_ms' => 4000, 'cleanup_ms' => 1000]), $good],
['options' => ['request_deadline_ns' => 4000000000]]);
answerExpect($r['out'] === null && count($r['calls']) === 1 && $r['error'] === 'UPSTREAM_TIMEOUT',
'Options-only absolute deadline is never replaced by a fresh budget.');
foreach ([[4000000000, 12000000000], [12000000000, 4000000000]] as [$preparedDeadline, $optionDeadline]) {
$r = answerRun($p + ['request_deadline_ns' => $preparedDeadline],
[array_replace($failure, ['advance_ms' => 4000, 'cleanup_ms' => 1000]), $good],
['options' => ['request_deadline_ns' => $optionDeadline]]);
answerExpect($r['out'] === null && count($r['calls']) === 1 && $r['error'] === 'UPSTREAM_TIMEOUT',
'The earlier of prepared and options deadlines always wins.');
}
$r = answerRun($p, [$failure, ['ok' => true, 'content' => $prefix . $body, 'chunks' => [$prefix, $body]]]);
answerExpect($r['out'] === null && $r['visible'] === $prefix && count($r['calls']) === 2
&& in_array('REPORT_CONTINUATION_REPEATED_SECTION', array_column($r['meta']['clinical_fact_state_issues'], 'code'), true),
'Repeated delivered prefix is rejected before duplicate bytes reach the client.');
}
foreach (range(1, 6) as $level) {
$unit = str_repeat('#', $level) . " 资料归纳 \t\r\n\r\n";
$visible = '';
$stream = new TcmClinicalReportStream($prepared['tcm_evidence_guard']['fact_state_ledger'],
static function (string $delta) use (&$visible): bool { $visible .= $delta; return true; },
null, $unit, null, null, null, null, true, true);
$ok = true;
foreach (mb_str_split($unit, 1, 'UTF-8') as $chunk) $ok = $stream->push($chunk) && $ok;
answerExpect($ok && $stream->finish() && $visible === $unit && $stream->acceptedContent() === $unit . $unit,
'Every ATX heading level preserves duplicate bytes through the actual stream.');
}
foreach (["一、四诊信息整理\n\n", "1. 本次已有口干记录\n\n", $body,
"### 资料归纳\n本次已有口干记录。\n\n", " ### 资料归纳\n\n", "\t### 资料归纳\n\n",
"####### 资料归纳\n\n", "###资料归纳\n\n", "### \t\n\n", "### ###\n\n"] as $unit) {
$visible = '';
$stream = new TcmClinicalReportStream($prepared['tcm_evidence_guard']['fact_state_ledger'],
static function (string $delta) use (&$visible): bool { $visible .= $delta; return true; },
null, $unit, null, null, null, null, true, true);
answerExpect(!$stream->push($unit) && $visible === '' && $stream->issues()[0]['code'] === 'REPORT_CONTINUATION_REPEATED_SECTION',
'Numbered prose, mixed units, code indentation and malformed or empty headings retain duplicate rejection.');
}
foreach ([['', null], [$prepared['query'], null], [null, ''], [null, $prepared['query']]] as [$continuation, $checkpoint]) {
$unit = "### 四诊信息整理\n\n";
$visible = '';
$stream = new TcmClinicalReportStream($prepared['tcm_evidence_guard']['fact_state_ledger'],
static function (string $delta) use (&$visible): bool { $visible .= $delta; return true; },
null, $unit, $continuation, null, $checkpoint, null, true, true);
answerExpect(!$stream->push($unit) && $visible === '' && $stream->issues()[0]['code'] === 'REPORT_CONTINUATION_REPEATED_SECTION',
'Every non-null long-report context keeps explicit duplicate headings strictly rejected.');
}
$unit = "### 一、四诊\n\n";
$visible = '';
$stream = new TcmClinicalReportStream($prepared['tcm_evidence_guard']['fact_state_ledger'],
static function (string $delta) use (&$visible): bool { $visible .= $delta; return true; },
null, $unit, '<USER_QUESTION>生成完整中医辨证长报告。</USER_QUESTION>', null, null, null, true);
answerExpect(!$stream->push($unit) && $visible === '' && $stream->issues()[0]['code'] === 'REPORT_CONTINUATION_REPEATED_SECTION',
'The actual long-report section contract rejects repeated headings independently of ordinary prefix-repeat detection.');
echo "Clinical answer recovery: {$checks} assertions passed (offline, real request control).\n";
} finally {
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST);
foreach ($files as $path) {
if ($path->isDir()) rmdir($path->getPathname()); else unlink($path->getPathname());
}
if (is_dir($root)) rmdir($root);
}
}