125 lines
6.2 KiB
PHP
125 lines
6.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
|
|
|
use app\adminapi\service\AssistantSseProtocol;
|
|
use app\adminapi\http\middleware\AuthMiddleware;
|
|
|
|
function assistantStreamExpect(bool $condition, string $message): void
|
|
{
|
|
if (!$condition) {
|
|
fwrite(STDERR, "FAIL: {$message}\n");
|
|
exit(1);
|
|
}
|
|
}
|
|
|
|
/** @return array{event:string,data:array<string,mixed>} */
|
|
function parseAssistantSse(string $frame): array
|
|
{
|
|
$lines = preg_split('/\r\n|\r|\n/', trim($frame)) ?: [];
|
|
$event = '';
|
|
$data = '';
|
|
foreach ($lines as $line) {
|
|
if (str_starts_with($line, 'event: ')) {
|
|
$event = substr($line, 7);
|
|
} elseif (str_starts_with($line, 'data: ')) {
|
|
$data .= substr($line, 6);
|
|
}
|
|
}
|
|
$decoded = json_decode($data, true);
|
|
assistantStreamExpect($event !== '' && is_array($decoded), 'SSE frame is parseable');
|
|
return ['event' => $event, 'data' => $decoded];
|
|
}
|
|
|
|
$protocol = new AssistantSseProtocol();
|
|
assistantStreamExpect($protocol->encode('delta', ['text' => 'early']) === null, 'delta cannot precede start');
|
|
$start = parseAssistantSse((string) $protocol->encode('start', ['message' => 'ready']));
|
|
assistantStreamExpect($start['event'] === 'start' && $start['data']['seq'] === 1, 'start is the first event with seq 1');
|
|
assistantStreamExpect($protocol->encode('start', []) === null, 'start can only be emitted once');
|
|
$deltaOne = parseAssistantSse((string) $protocol->encode('delta', ['text' => '你']));
|
|
$deltaTwo = parseAssistantSse((string) $protocol->encode('delta', ['text' => '好']));
|
|
assistantStreamExpect($deltaOne['data']['seq'] === 2 && $deltaTwo['data']['seq'] === 3, 'delta seq is strictly monotonic');
|
|
$done = parseAssistantSse((string) $protocol->encode('done', ['answer' => '你好']));
|
|
assistantStreamExpect($done['event'] === 'done' && $done['data']['seq'] === 4, 'done is the terminal event');
|
|
assistantStreamExpect($protocol->encode('error', ['message' => 'late']) === null, 'a second terminal event is rejected');
|
|
assistantStreamExpect($protocol->encode('delta', ['text' => 'late']) === null, 'delta after terminal is rejected');
|
|
|
|
$errorProtocol = new AssistantSseProtocol();
|
|
$errorProtocol->encode('start', []);
|
|
$error = parseAssistantSse((string) $errorProtocol->encode('error', [
|
|
'code' => 'AI_ASSISTANT_FAILED',
|
|
'message' => 'AI 助手暂时不可用,请稍后重试',
|
|
]));
|
|
assistantStreamExpect($error['data']['seq'] === 2 && $errorProtocol->isTerminal(), 'error is the unique alternative terminal event');
|
|
|
|
$controller = file_get_contents(dirname(__DIR__) . '/app/adminapi/controller/tcm/DiagnosisController.php');
|
|
$logic = file_get_contents(dirname(__DIR__) . '/app/adminapi/logic/tcm/DiagnosisAiLogic.php');
|
|
$validate = file_get_contents(dirname(__DIR__) . '/app/adminapi/validate/tcm/DiagnosisValidate.php');
|
|
$auth = file_get_contents(dirname(__DIR__) . '/app/adminapi/http/middleware/AuthMiddleware.php');
|
|
assistantStreamExpect(is_string($controller) && is_string($logic) && is_string($validate) && is_string($auth), 'stream implementation sources are readable');
|
|
|
|
$actionStart = strpos($controller, 'public function aiAssistantStream()');
|
|
$checkAt = strpos($controller, "goCheck('aiAssistant')", $actionStart);
|
|
$prepareAt = strpos($controller, 'DiagnosisAiLogic::prepareAssistant(', $actionStart);
|
|
$runAt = strpos($controller, '$this->runAssistantSse($prepared)', $actionStart);
|
|
$headerAt = strpos($controller, "header('Content-Type: text/event-stream; charset=utf-8')", $actionStart);
|
|
assistantStreamExpect(
|
|
$actionStart !== false && $checkAt > $actionStart && $prepareAt > $checkAt && $runAt > $prepareAt && $headerAt > $runAt,
|
|
'request validation and authorized preparation occur before every SSE header'
|
|
);
|
|
assistantStreamExpect(
|
|
str_contains($validate, "return \$this->only(['id', 'task', 'prompt']);"),
|
|
'stream reuses the strict id/task/prompt assistant scene'
|
|
);
|
|
assistantStreamExpect(
|
|
str_contains($logic, 'self::PERMISSION_ASSISTANT')
|
|
&& str_contains($logic, 'MyPatientLogic::canAccessDiagnosis')
|
|
&& str_contains($logic, 'streamPreparedAssistant'),
|
|
'stream preparation reuses assistant permission and canonical diagnosis row authorization'
|
|
);
|
|
assistantStreamExpect(
|
|
str_contains($auth, "\$accessUri === 'tcm.diagnosis/aiassistantstream'")
|
|
&& str_contains($auth, "'tcm.diagnosis/aiassistant', \$adminUris"),
|
|
'middleware maps stream access to the old registered assistant permission'
|
|
);
|
|
$matchPermissionAlias = (new ReflectionClass(AuthMiddleware::class))->getMethod('matchPermissionAlias');
|
|
$authMiddleware = new AuthMiddleware();
|
|
assistantStreamExpect(
|
|
$matchPermissionAlias->invoke(
|
|
$authMiddleware,
|
|
'tcm.diagnosis/aiassistantstream',
|
|
['tcm.diagnosis/aiassistant']
|
|
) === true,
|
|
'stream permission alias accepts the old assistant grant'
|
|
);
|
|
assistantStreamExpect(
|
|
$matchPermissionAlias->invoke($authMiddleware, 'tcm.diagnosis/aiassistantstream', []) === false,
|
|
'stream permission alias rejects an administrator without the old assistant grant'
|
|
);
|
|
assistantStreamExpect(
|
|
str_contains($controller, "'text' => \$delta")
|
|
&& str_contains($controller, 'DiagnosisAiLogic::getAssistantErrorCode()')
|
|
&& str_contains($controller, 'DiagnosisAiLogic::getError()')
|
|
&& str_contains($controller, 'ignore_user_abort(true)')
|
|
&& str_contains($controller, 'connection_aborted() === 1')
|
|
&& str_contains($logic, '!$deliveredDelta')
|
|
&& str_contains($logic, "['UPSTREAM_REJECTED', 'INCOMPLETE_RESPONSE', 'EMPTY_RESPONSE']")
|
|
&& str_contains($logic, 'DifyChatService::chat('),
|
|
'delta carries text, disconnects abort upstream, and pre-delta stream failures safely fall back once'
|
|
);
|
|
assistantStreamExpect(
|
|
str_contains($logic, 'DifyChatService::chat(')
|
|
&& str_contains($logic, 'DifyChatService::streamChat('),
|
|
'legacy blocking and new streaming paths coexist'
|
|
);
|
|
|
|
$sensitiveNeedles = ['api_key', 'base_url', 'query', 'inputs', 'user'];
|
|
foreach ($sensitiveNeedles as $needle) {
|
|
assistantStreamExpect(!array_key_exists($needle, $done['data']), "done event excludes internal {$needle}");
|
|
assistantStreamExpect(!array_key_exists($needle, $error['data']), "error event excludes internal {$needle}");
|
|
}
|
|
|
|
echo "Diagnosis AI assistant stream contract: OK\n";
|