Files
zyt/server/tests/LiveConsultationStreamTest.php
T
2026-10-30 13:41:35 +08:00

239 lines
18 KiB
PHP

<?php
declare(strict_types=1);
namespace app\adminapi\logic\tcm {
// Advance only the assistant's monotonic clock; no sleeps and no network are needed.
function hrtime(bool $asNumber = false) {
return $asNumber ? \hrtime(true) + ($GLOBALS['liveClockAdvanceNs'] ?? 0) : \hrtime(false);
}
}
namespace app\common\service {
final class DifyWorkflowRunStatus {
public static function lookup(array $identity, int $budget): array {
\liveExpect($budget > 0 && $budget <= 1000, 'terminal lookup preserves a finite IO budget');
return ['ok' => true, 'workflow_run_id' => $identity['workflow_run_id'],
'status' => $GLOBALS['liveTerminalStatus'] ?? 'succeeded'];
}
}
}
namespace {
require __DIR__ . '/LiveConsultationAuthorizationTest.php';
use app\adminapi\logic\tcm\DiagnosisAiLogic as Logic;
use app\adminapi\logic\tcm\PatientAiReportLogic;
use app\adminapi\service\LiveConsultationContext as Context;
use app\adminapi\service\LiveConsultationResult as Result;
use app\adminapi\service\AssistantRequestControl as Control;
use app\adminapi\service\AssistantSseProtocol;
use app\adminapi\service\AssistantSseTiming;
use app\common\model\tcm\CallRecord;
use app\common\service\DifyChatService;
use think\facade\Db;
$GLOBALS['liveClockAdvanceNs'] = 0;
$visible = [];
$sink = static function (string $delta) use (&$visible): bool { $visible[] = $delta; return true; };
$received = [];
$modelJson = Context::encode($base);
DifyChatService::$streamHandler = static function ($profile, $inputs, $query, $user, $delta, $abort, $files, $transport)
use (&$received, &$modelJson, &$visible): array {
$received = compact('profile', 'inputs', 'query', 'user', 'files', 'transport');
foreach (mb_str_split($modelJson, 5, 'UTF-8') as $chunk) {
liveExpect($delta($chunk) && !$abort(), 'valid JSON fragments stay buffered');
liveExpect($visible === [], 'no raw fragment reaches the clinical delta callback');
}
return ['ok' => true, 'content' => $modelJson];
};
$prepared = Logic::prepareAssistant(7, 'live_consultation', '合成附加问题', 8, [], null, $options);
liveExpect($prepared['live_context']['segment_count'] === 145, 'prepared full current source is not a UI window');
liveExpect($prepared['known_chart'] === [] && $prepared['analysis_scope'] === 'current_call_only'
&& !str_contains(Context::encode($prepared['inputs']) . $prepared['query'], '女 · 52岁'),
'live query and workflow variables carry no chart demographics');
liveExpect(str_contains($prepared['query'], '长段结尾标记') && !str_contains($prepared['inputs']['herbs_json'], '长段结尾标记')
&& str_contains($prepared['inputs']['herbs_json'], $prepared['live_context']['revision']), 'query preserves >500 char current source once; inputs retain revision manifest');
liveExpect(!str_contains($prepared['query'], '2020年授权病历')
&& !str_contains($prepared['query'], 'BEGIN_UNTRUSTED_AUTHORIZED_CASE_BACKGROUND')
&& !str_contains($prepared['query'], 'BEGIN_UNTRUSTED_KNOWN_CHART_JSON')
&& $prepared['source_diagnosis_ids'] === [] && $prepared['files'] === []
&& $prepared['source_summary']['historical_context_included'] === false,
'dialogue-only scope omits historical chart blocks and all attachments');
$before = DifyChatService::$calls;
$result = Logic::streamPreparedAssistant($prepared, $sink, null, null,
['knowledge_mode' => 'untrusted', 'reference_scope' => 'untrusted', 'response_contract' => 'untrusted']);
liveExpect(is_array($result) && DifyChatService::$calls === $before + 1 && $visible === [], 'one model boundary call returns only a fully validated result');
liveExpect(array_diff_key($result['live_context'], ['sources' => true]) === array_diff_key($prepared['live_context'], ['sources' => true])
&& $result['live_assistance']['summary'][0]['text'] === '患者原话:' . $texts[1], 'done binds complete source revision and quote role');
liveExpect(array_column($result['live_context']['sources'], 'id') === ['seg-1', 'seg-3']
&& $result['live_context']['segment_count'] === 145, 'response projects only cited raw sources while metadata still binds all 145 persisted segments');
liveExpect($result['answer_mode'] === 'live_assistance' && $result['knowledge_source'] === null
&& $result['ancient_book_sources'] === [] && str_contains($result['answer'], '截至当前'), 'no fabricated knowledge provenance or completeness claim');
liveExpect($received['transport']['knowledge_mode'] === 'clinical' && $received['transport']['reference_scope'] === 'consultation'
&& $received['transport']['required_files'] === false && $received['transport']['response_contract'] === 'live_consultation'
&& $received['transport']['analysis_scope'] === 'current_call_only' && $received['files'] === [],
'server owns live response contract, knowledge mode and dialogue-only transport');
liveExpect($received['transport']['request_deadline_ns'] <= $prepared['request_deadline_ns']
&& $received['transport']['timeout_ms'] > 0 && $received['transport']['timeout_ms'] <= 5000, 'finite original deadline reaches transport');
$sse = new AssistantSseProtocol();
$sse->encode('start', ['task' => 'live_consultation']);
$frame = $sse->encode('done', $result);
preg_match('/^data: (.+)$/m', $frame, $match);
$done = json_decode($match[1], true, 512, JSON_THROW_ON_ERROR);
liveExpect($done['analysis_scope'] === 'current_call_only' && $done['live_assistance'] === $result['live_assistance'] && $done['live_context'] === $result['live_context']
&& $sse->encode('delta', ['text' => 'late']) === null, 'SSE done preserves the full structured contract with one terminal event');
$oldRevision = $result['live_context']['revision'];
Db::$segments[0]['text'] = '同一段已经修订。';
$revised = Logic::prepareAssistant(7, 'live_consultation', '', 8, [], null, $options);
liveExpect($revised['live_context']['revision'] !== $oldRevision && $prepared['live_context']['revision'] === $oldRevision,
'same-count persisted edit changes the next snapshot without mutating in-flight evidence');
Db::$segments[0]['text'] = $texts[0];
// Completed and partial reviews share the same terminal JSON/citation gate.
foreach (['completed', 'partial'] as $transcriptionStatus) {
CallRecord::$records = [array_replace($record, ['transcription_status' => $transcriptionStatus])];
$reviewPrepared = Logic::prepareAssistant(7, 'live_review', '', 8, [], null, $options);
$reviewData = $base; $reviewData['review'] = [['text' => '后续追问需保留时间范围', 'evidence_ids' => ['seg-0']]];
$modelJson = Context::encode($reviewData);
$reviewResult = Logic::streamPreparedAssistant($reviewPrepared, $sink);
liveExpect(is_array($reviewResult) && $reviewResult['live_context']['transcription_status'] === $transcriptionStatus
&& count($reviewResult['live_assistance']['review']) === 1, 'review accepts persisted final status and cited review');
liveExpect($received['transport']['response_contract'] === 'live_consultation'
&& $received['transport']['analysis_scope'] === 'authorized_case'
&& $received['transport']['required_files'] === true
&& $reviewResult['analysis_scope'] === 'authorized_case'
&& $reviewPrepared['known_chart']['demographics'] === '女 · 52岁'
&& str_contains($reviewPrepared['query'], '2020年授权病历')
&& str_contains($reviewPrepared['query'], 'authorized_case_background_not_current_call_facts')
&& str_contains($reviewPrepared['query'], '重复存档')
&& $reviewPrepared['source_summary']['historical_context_included'] === true,
'review retains full chart/background under the existing live response contract');
liveExpect($transcriptionStatus !== 'partial' || str_contains($reviewResult['answer'], '部分保存'), 'partial warning survives model delivery');
}
CallRecord::$records = [$record];
// Fail closed after a successful upstream terminal; never retry invalid clinical JSON.
$invalidResults = ['{"summary":[]}', 'not-json'];
$invalidData = $base; $invalidData['summary'][0]['evidence_ids'] = ['foreign-session-id'];
$invalidResults[] = Context::encode($invalidData);
$invalidData = $base; $invalidData['checks'][0]['text'] = '核实后建议立即服用某药';
$invalidResults[] = Context::encode($invalidData);
foreach ($invalidResults as $invalidJson) {
DifyChatService::$streamHandler = static function ($p, $i, $q, $u, $delta) use ($invalidJson): array {
$delta($invalidJson);
return ['ok' => true, 'content' => $invalidJson];
};
$before = DifyChatService::$calls;
liveExpect(Logic::streamPreparedAssistant($prepared, $sink) === null && Logic::getAssistantErrorCode() === 'LIVE_RESULT_INVALID'
&& DifyChatService::$calls === $before + 1 && $visible === [], 'invalid JSON/claim/citation rejected without delivery or retry');
}
DifyChatService::$streamHandler = static function (): array { return ['ok' => false, 'error_code' => 'INCOMPLETE_RESPONSE', 'error' => 'synthetic incomplete']; };
$before = DifyChatService::$calls;
liveExpect(Logic::streamPreparedAssistant($prepared, $sink) === null && DifyChatService::$calls === $before + 1
&& Logic::getAssistantErrorCode() === 'INCOMPLETE_RESPONSE', 'incomplete terminal has no automatic duplicate generation');
// Admission, cancellation, and preparation time all precede the real transport boundary.
$before = DifyChatService::$calls;
liveExpect(Logic::streamPreparedAssistant($prepared, $sink, static fn (): bool => true) === null
&& Logic::getAssistantErrorCode() === 'AI_ASSISTANT_CANCELLED' && DifyChatService::$calls === $before, 'pre-cancel starts no model work');
liveExpect(Logic::streamPreparedAssistant($prepared, $sink, null, null, ['can_start_attempt' => static fn (): bool => false]) === null
&& Logic::getAssistantErrorCode() === 'UPSTREAM_CLEANUP_PENDING' && DifyChatService::$calls === $before, 'unconfirmed previous task blocks admission');
$timeoutCalls = 0;
$timedOut = array_replace($prepared, ['request_deadline_ns' => hrtime(true) - 1]);
liveExpect(Logic::streamPreparedAssistant($timedOut, $sink, null, null,
['on_timeout' => static function () use (&$timeoutCalls): void { $timeoutCalls++; }]) === null
&& Logic::getAssistantErrorCode() === 'UPSTREAM_TIMEOUT' && $timeoutCalls === 1
&& DifyChatService::$calls === $before, 'expired deadline signals timeout before any upstream call');
CallRecord::$records = [array_replace($record, ['transcription_status' => 'completed'])];
PatientAiReportLogic::$onAggregate = static function (): void { $GLOBALS['liveClockAdvanceNs'] += 70000000; };
$budgetPrepared = Logic::prepareAssistant(7, 'live_review', '', 8, [], null, $options + ['timeout_ms' => 100]);
liveExpect(is_array($budgetPrepared), 'preparation uses a single budget');
DifyChatService::$streamHandler = static function ($p, $i, $q, $u, $delta, $abort, $files, $transport) use ($base): array {
liveExpect($transport['timeout_ms'] > 0 && $transport['timeout_ms'] <= 30, '70 ms preparation leaves at most 30 ms from the original 100 ms');
$GLOBALS['liveClockAdvanceNs'] += 100000000;
return ['ok' => true, 'content' => Context::encode($base)];
};
liveExpect(Logic::streamPreparedAssistant($budgetPrepared, $sink) === null && Logic::getAssistantErrorCode() === 'UPSTREAM_TIMEOUT',
'late valid JSON is rejected after the shared deadline');
$GLOBALS['liveClockAdvanceNs'] = 0;
PatientAiReportLogic::$onAggregate = static function (): void { $GLOBALS['liveClockAdvanceNs'] += 200000000; };
$before = DifyChatService::$calls;
liveExpect(Logic::prepareAssistant(7, 'live_review', '', 8, [], null, $options + ['timeout_ms' => 100]) === null
&& Logic::getAssistantErrorCode() === 'UPSTREAM_TIMEOUT' && DifyChatService::$calls === $before,
'exhausted preparation never starts final generation');
$GLOBALS['liveClockAdvanceNs'] = 0;
$cancelInHistory = false;
PatientAiReportLogic::$onAggregate = static function () use (&$cancelInHistory): void { $cancelInHistory = true; };
liveExpect(Logic::prepareAssistant(7, 'live_review', '', 8, [], null,
$options + ['should_abort' => static function () use (&$cancelInHistory): bool { return $cancelInHistory; }]) === null
&& Logic::getAssistantErrorCode() === 'AI_ASSISTANT_CANCELLED', 'cancel during authorized history prevents generation');
PatientAiReportLogic::$onAggregate = null;
CallRecord::$records = [$record];
// Use the literal production lifecycle callbacks with real file-backed ownership and a mock producer.
$controller = file_get_contents(dirname(__DIR__) . '/app/adminapi/controller/tcm/DiagnosisController.php');
$callbackLiterals = [];
foreach (['on_submission', 'on_task', 'on_terminal', 'on_transfer_end', 'can_start_attempt', 'on_timeout', 'on_cancel_task', 'on_stop'] as $key) {
$pattern = in_array($key, ['on_submission', 'on_task'], true)
? '/\$options\[\'' . preg_quote($key, '/') . '\'\] = (static function.*?\n });/s'
: '/\'' . preg_quote($key, '/') . '\' => (static function.*?\n }),/s';
if (preg_match_all($pattern, $controller, $matches) !== 1) throw new RuntimeException('Canonical callback not unique: ' . $key);
$callbackLiterals[$key] = $matches[1][0];
}
$directory = sys_get_temp_dir() . '/zyt-live-control-' . bin2hex(random_bytes(8));
try {
foreach (['success', 'cancel_unconfirmed', 'oversized'] as $index => $mode) {
$control = new Control(8, 7, sprintf('c1234567-1234-4234-8234-%012d', $index + 1), $directory);
liveExpect($control->begin(), 'live lifecycle request begins');
$profile = 'qwen'; $upstreamUser = 'admin-diagnosis-assistant-8'; $lifecycle = [];
foreach ($callbackLiterals as $key => $literal) $lifecycle[$key] = eval(
'namespace app\\adminapi\\controller\\tcm; use app\\common\\service\\DifyWorkflowRunStatus; return ' . $literal . ';');
$prepared = Logic::prepareAssistant(7, 'live_consultation', '', 8, [], null, $options);
$GLOBALS['liveTerminalStatus'] = $mode === 'success' ? 'succeeded' : 'stopped';
DifyChatService::$streamHandler = static function ($p, $i, $q, $u, $delta, $abort, $files, $transport)
use ($base, $mode, $control): array {
$submission = ($transport['on_submission'])();
liveExpect(!$control->canStartAttempt(), 'owned submission blocks concurrent attempts before task metadata');
$taskId = 'live-synthetic-task';
$runId = $mode === 'cancel_unconfirmed' ? '' : 'live-synthetic-run';
($transport['on_task'])($taskId, $runId, $submission);
liveExpect($control->publicState()['upstream_pending_task_count'] === 1, 'original task remains owned');
if ($mode === 'cancel_unconfirmed') {
$control->signalCancel();
liveExpect(!$delta('{"summary":') && $abort(), 'mid-stream cancel interrupts buffering and transport');
$receipt = ($transport['on_cancel_task'])($taskId, static fn (): bool => false, 'cancelled');
liveExpect(!$receipt['accepted'] && $control->publicState()['upstream_pending_task_count'] === 1,
'failed stop retains unconfirmed upstream work after the stop IO returns');
} elseif ($mode === 'oversized') {
liveExpect(!$delta(str_repeat('x', Result::MAX_BYTES + 1)) && $abort(), 'oversized stream triggers bounded abort');
($transport['on_cancel_task'])($taskId, static fn (): bool => true, 'cancelled');
liveExpect(!$control->publicState()['cancel_requested'], 'output validation stop does not become user cancellation');
($transport['on_terminal'])($taskId, 'stopped', $runId);
} else {
liveExpect($delta(Context::encode($base)), 'success stays privately buffered');
($transport['on_terminal'])($taskId, 'succeeded', $runId);
}
($transport['on_transfer_end'])($taskId, $submission, 'unconfirmed');
return ['ok' => true, 'content' => Context::encode($base)];
};
$before = DifyChatService::$calls;
$controlled = Logic::streamPreparedAssistant($prepared, $sink, static fn (): bool => $control->shouldAbort(), null, $lifecycle);
liveExpect(DifyChatService::$handlerFailure === null, 'mock producer assertion: ' . DifyChatService::$handlerFailure);
liveExpect(DifyChatService::$calls === $before + 1 && $visible === [], 'controlled path has no duplicate generation or raw delivery');
if ($mode === 'success') liveExpect(is_array($controlled)
&& $control->publicState()['upstream_pending_task_count'] === 0, 'success closes the same original generation');
elseif ($mode === 'cancel_unconfirmed') liveExpect($controlled === null
&& Logic::getAssistantErrorCode() === 'AI_ASSISTANT_CANCELLED'
&& !$control->canStartAttempt() && $control->publicState()['upstream_pending_task_count'] === 1,
'unconfirmed cancellation never reports success or releases ownership');
else liveExpect($controlled === null && Logic::getAssistantErrorCode() === 'LIVE_RESULT_INVALID', 'oversized output is validation failure');
}
} finally {
foreach (glob($directory . '/*') ?: [] as $path) unlink($path);
if (is_file($directory . '/.gc.lock')) unlink($directory . '/.gc.lock');
if (is_dir($directory)) rmdir($directory);
}
liveExpect((new AssistantSseTiming())->event('upstream_done', ['error_code' => 'LIVE_RESULT_INVALID'])['error_code'] === 'LIVE_RESULT_INVALID', 'safe live validation enum is observable in timing');
echo "Live consultation streaming / SSE / budget / ownership: {$count} total assertions passed (no DB/network/model)\n";
}