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

199 lines
14 KiB
PHP

<?php
declare(strict_types=1);
require __DIR__ . '/LiveConsultationStreamTest.php';
use app\adminapi\logic\tcm\DiagnosisAiLogic as Logic;
use app\adminapi\service\LiveConsultationContext as Context;
use app\adminapi\service\LiveConsultationResult as Result;
use app\adminapi\service\AssistantSseProtocol;
use app\adminapi\service\AssistantSseTiming;
use app\common\service\DifyChatService;
$prepared = Logic::prepareAssistant(7, 'live_consultation', '', 8, [], null, $options);
$ordered = ['questions' => $base['questions'], 'summary' => $base['summary'], 'checks' => $base['checks'], 'review' => []];
$ordered['questions'][] = ['text' => '本次最希望了解哪方面?', 'evidence_ids' => ['seg-1']];
$ordered['questions'][] = ['text' => '这些情况有变化吗?', 'evidence_ids' => ['seg-1']];
$full = Context::encode($ordered);
$first = '{"questions":' . Context::encode($ordered['questions']);
$updates = [];
$upstreamFinished = false;
$events = new AssistantSseProtocol();
liveExpect($events->encode('live_update', []) === null, 'live update cannot precede authorization/start');
$events->encode('start', ['task' => 'live_consultation']);
$onUpdate = static function (array $payload) use (&$updates, &$upstreamFinished, $events, $prepared): bool {
liveExpect(!$upstreamFinished && $payload['live_partial'] === true, 'validated draft arrives before upstream final response');
liveExpect($payload['diagnosis_id'] === 7 && $payload['analysis_scope'] === 'current_call_only'
&& $payload['live_context']['revision'] === $prepared['live_context']['revision'],
'every update binds original authorized diagnosis and complete source revision');
$frame = $events->encode('live_update', $payload);
liveExpect(is_string($frame) && str_starts_with($frame, "event: live_update\n"), 'protocol emits validated draft event');
preg_match('/^data: (.+)$/m', $frame, $matches);
$decoded = json_decode($matches[1], true, 512, JSON_THROW_ON_ERROR);
liveExpect($decoded['analysis_scope'] === 'current_call_only' && $decoded['live_assistance'] === $payload['live_assistance']
&& $decoded['live_context'] === $payload['live_context'], 'SSE serialization preserves cumulative citations/groups');
$updates[] = $payload;
return true;
};
DifyChatService::$streamHandler = static function ($p, $i, $q, $u, $delta) use ($ordered, $first, $full, &$updates, &$upstreamFinished): array {
liveExpect($delta('{"questions":[' . Context::encode($ordered['questions'][0])) && $updates === [],
'complete first object waits for its legal array delimiter');
liveExpect($delta(',') && count($updates) === 1 && count($updates[0]['live_assistance']['questions']) === 1
&& $updates[0]['live_completed_groups'] === 0, 'first question arrives while its array and later questions are unfinished');
liveExpect($delta(Context::encode($ordered['questions'][1]) . ',') && count($updates) === 2,
'second question extends the same cumulative group');
liveExpect($delta(Context::encode($ordered['questions'][2]) . ']') && count($updates) === 3,
'third question closes the first group');
foreach (str_split(substr($full, strlen($first)), 1) as $byte) liveExpect($delta($byte), 'split UTF-8 bytes preserve stream');
liveExpect(count($updates) === 5, 'only added visible items emit; empty review and final braces do not repeat frames');
$upstreamFinished = true;
return ['ok' => true, 'content' => $full];
};
$before = DifyChatService::$calls;
$final = Logic::streamPreparedAssistant($prepared, $sink, null, null, ['on_live_update' => $onUpdate]);
liveExpect(DifyChatService::$handlerFailure === null, 'progress handler assertions succeed: ' . DifyChatService::$handlerFailure);
liveExpect(is_array($final) && DifyChatService::$calls === $before + 1 && $visible === [], 'progress stays one generation with no raw delta');
liveExpect($final['live_partial'] === false && end($updates)['live_assistance'] === $final['live_assistance'],
'authoritative done uses the same final validated groups');
foreach ($updates as $index => $update) {
liveExpect($update['live_completed_groups'] === [0, 0, 1, 2, 3][$index], 'complete groups retain their original meaning');
if ($index > 0) foreach ($updates[$index - 1]['live_assistance'] as $group => $items)
liveExpect(array_slice($update['live_assistance'][$group], 0, count($items)) === $items, 'previous validated items remain unchanged');
}
$events->encode('done', $final);
liveExpect($events->encode('live_update', $updates[0]) === null, 'no update can arrive after final');
// A later invalid section leaves the earlier update provisional and ends in a categorized error.
foreach (['citation', 'format', 'safety', 'oversized', 'timeout', 'cancel', 'disconnect', 'upstream'] as $mode) {
$sent = [];
$cancel = false;
$bad = $ordered;
if ($mode === 'citation') $bad['summary'][0]['evidence_ids'] = ['wrong-call'];
if ($mode === 'safety') $bad['checks'][0]['text'] = '核实后建议立即服用某药';
$badFull = $mode === 'format' ? Context::encode($bad) . 'extra' : Context::encode($bad);
$handlerUpdate = static function (array $payload) use (&$sent, $mode): bool {
if ($mode === 'disconnect') return false;
$sent[] = $payload;
return true;
};
DifyChatService::$streamHandler = static function ($p, $i, $q, $u, $delta, $abort)
use ($first, $badFull, $mode, &$cancel, &$sent): array {
$accepted = $delta($first);
if ($mode === 'disconnect') {
liveExpect(!$accepted && $abort() && $sent === [], 'rejected update stops producer');
} else {
liveExpect($accepted && count($sent) === 1, 'one complete group visible before later failure');
if ($mode === 'timeout') $GLOBALS['liveClockAdvanceNs'] += 6000000000;
elseif ($mode === 'cancel') $cancel = true;
elseif ($mode === 'oversized') liveExpect(!$delta(str_repeat('x', Result::MAX_BYTES)) && $abort(), 'oversize abort after first update');
elseif ($mode !== 'upstream') $delta(substr($badFull, strlen($first)));
}
if ($mode === 'upstream') return ['ok' => false, 'error_code' => 'INCOMPLETE_RESPONSE',
'error' => 'unsafe upstream https://secret.invalid/token-secret patient text'];
return ['ok' => true, 'content' => $badFull];
};
$before = DifyChatService::$calls;
$result = Logic::streamPreparedAssistant($prepared, $sink, static function () use (&$cancel): bool { return $cancel; },
null, ['on_live_update' => $handlerUpdate]);
liveExpect(DifyChatService::$handlerFailure === null, 'late failure handler assertions: ' . DifyChatService::$handlerFailure);
$meta = Logic::getAssistantErrorMeta();
$category = ['oversized' => 'format', 'cancel' => 'cancelled', 'disconnect' => 'cancelled'][$mode] ?? $mode;
liveExpect($result === null && DifyChatService::$calls === $before + 1 && $visible === [], 'late failure never retries or emits raw model JSON');
liveExpect(($meta['live_error_category'] ?? null) === $category
&& ($meta['live_partial_available'] ?? null) === ($mode !== 'disconnect'), 'safe category and provisional availability: ' . $mode);
liveExpect(!str_contains(Context::encode($meta) . Logic::getError(), 'token-secret'), 'raw upstream error never leaks');
$GLOBALS['liveClockAdvanceNs'] = 0;
}
// Invalid first group cannot emit even if other groups eventually close.
$bad = $ordered; $bad['questions'][0]['evidence_ids'] = ['foreign']; $badJson = Context::encode($bad);
$sent = [];
DifyChatService::$streamHandler = static function ($p, $i, $q, $u, $delta) use ($badJson): array {
foreach (str_split($badJson, 7) as $chunk) $delta($chunk);
return ['ok' => true, 'content' => $badJson];
};
liveExpect(Logic::streamPreparedAssistant($prepared, $sink, null, null,
['on_live_update' => static function (array $payload) use (&$sent): void { $sent[] = $payload; }]) === null
&& $sent === [] && Logic::getAssistantErrorMeta()['live_validation_reason'] === 'CITATION_INVALID',
'invalid first citation never emits a provisional payload');
// Exercise the real callback at each byte with all legal display slots filled.
foreach (['live_consultation' => 12, 'live_review' => 15] as $task => $limit) {
$boundedPrepared = $prepared;
$boundedPrepared['task'] = $task;
$bounded = ['questions' => [], 'summary' => [], 'checks' => [], 'review' => []];
for ($index = 0; $index < 6; $index++) {
$id = 'seg-' . (1 + $index * 4);
$bounded['questions'][] = ['text' => '第' . $index . '项希望补充什么?', 'evidence_ids' => [$id]];
$bounded['summary'][] = ['text' => '由真实原文还原', 'evidence_ids' => [$id]];
$bounded['checks'][] = ['text' => '确认第' . $index . '项的信息', 'evidence_ids' => [$id]];
if ($task === 'live_review') $bounded['review'][] = ['text' => '第' . $index . '项可进一步核实', 'evidence_ids' => [$id]];
}
$boundedJson = Context::encode($bounded);
$boundedUpdates = [];
DifyChatService::$streamHandler = static function ($p, $i, $q, $u, $delta) use ($boundedJson): array {
foreach (str_split($boundedJson) as $byte) {
if (!$delta($byte)) throw new RuntimeException('valid bounded stream unexpectedly stopped');
}
return ['ok' => true, 'content' => $boundedJson];
};
$boundedResult = Logic::streamPreparedAssistant($boundedPrepared, $sink, null, null,
['on_live_update' => static function (array $payload) use (&$boundedUpdates): void { $boundedUpdates[] = $payload; }]);
liveExpect(DifyChatService::$handlerFailure === null && is_array($boundedResult) && count($boundedUpdates) === $limit,
'frame count is bounded by actual displayed items for ' . $task);
foreach ($boundedUpdates as $index => $payload) {
liveExpect(array_sum(array_map('count', $payload['live_assistance'])) === $index + 1,
'every emitted frame adds exactly one validated item under byte-wise input');
if ($index > 0) foreach ($boundedUpdates[$index - 1]['live_assistance'] as $group => $items)
liveExpect(array_slice($payload['live_assistance'][$group], 0, count($items)) === $items,
'all earlier items stay byte-for-byte unchanged');
}
liveExpect(end($boundedUpdates)['live_assistance'] === $boundedResult['live_assistance'],
'maximum progressive result equals authoritative final validation');
}
// A bad second item in the same still-open group leaves only the first validated item.
foreach (['citation', 'safety', 'syntax', 'hidden_safety'] as $mode) {
$sameGroup = $ordered;
if ($mode === 'citation') $sameGroup['questions'][1]['evidence_ids'] = ['foreign'];
if ($mode === 'safety') $sameGroup['questions'][1]['text'] = '建议立即服用某药吗?';
if ($mode === 'hidden_safety') $sameGroup['questions'][] = ['text' => '建议立即服用某药吗?', 'evidence_ids' => ['seg-1']];
$sameJson = $mode === 'syntax' ? '{"questions":[' . Context::encode($sameGroup['questions'][0]) . ',]}'
: Context::encode($sameGroup);
$sameUpdates = [];
DifyChatService::$streamHandler = static function ($p, $i, $q, $u, $delta) use ($sameJson): array {
foreach (str_split($sameJson) as $byte) $delta($byte);
return ['ok' => true, 'content' => $sameJson];
};
$failed = Logic::streamPreparedAssistant($prepared, $sink, null, null,
['on_live_update' => static function (array $payload) use (&$sameUpdates): void { $sameUpdates[] = $payload; }]);
liveExpect($failed === null && count($sameUpdates) === ($mode === 'hidden_safety' ? 3 : 1)
&& Logic::getAssistantErrorMeta()['live_partial_available'] === true,
'invalid same-group or hidden item cannot bypass final gate: ' . $mode);
}
// Production callback itself emits only this distinct event and obeys abort/terminal status.
$controller = file_get_contents(dirname(__DIR__) . '/app/adminapi/controller/tcm/DiagnosisController.php');
liveExpect(preg_match('/\'on_live_update\' => (static function.*?\n }),/s', $controller, $matches) === 1,
'production callback is uniquely wired');
$frames = [];
$cancel = false;
$shouldAbort = static function () use (&$cancel): bool { return $cancel; };
$emit = static function (string $event, array $payload) use (&$frames): bool { $frames[] = $event; return true; };
$timing = new AssistantSseTiming(); $sseStartedAt = hrtime(true); $firstLiveUpdateMs = null;
$callback = eval('namespace app\\adminapi\\controller\\tcm; use app\\adminapi\\service\\AssistantSseTiming; return ' . $matches[1] . ';');
liveExpect($callback($updates[0]) && $frames === ['live_update'] && $firstLiveUpdateMs !== null, 'actual controller emits live_update and records first validated arrival');
$cancel = true;
liveExpect(!$callback($updates[0]) && $frames === ['live_update'], 'actual controller stops progressive delivery on cancellation');
$timingEvent = $timing->event('upstream_done', ['live_update_count' => 4, 'first_valid_update_ms' => 150,
'live_error_category' => 'citation', 'live_validation_reason' => 'CITATION_INVALID', 'patient_text' => 'private']);
liveExpect($timingEvent['live_update_count'] === 4 && $timingEvent['first_valid_update_ms'] === 150
&& $timingEvent['live_validation_reason'] === 'CITATION_INVALID' && !isset($timingEvent['patient_text']),
'latency and fixed error enums survive telemetry allowlist, patient text does not');
$unsafeTiming = $timing->event('upstream_done', ['first_valid_update_ms' => 'private',
'live_error_category' => 'private', 'live_validation_reason' => 'private']);
liveExpect(!isset($unsafeTiming['first_valid_update_ms'], $unsafeTiming['live_error_category'], $unsafeTiming['live_validation_reason']),
'arbitrary string values cannot masquerade as live timing or validation telemetry');
echo "Live consultation progressive SSE / terminal safety: {$count} total assertions passed (synthetic, no DB/network/model)\n";