263 lines
20 KiB
PHP
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);
|
|
}
|
|
}
|