199 lines
14 KiB
PHP
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";
|