geng
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
<?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, "'code' => 'AI_ASSISTANT_FAILED'")
|
||||
&& str_contains($controller, 'ignore_user_abort(true)')
|
||||
&& str_contains($controller, 'connection_aborted() === 1')
|
||||
&& !str_contains($controller, "DiagnosisAiLogic::getError()\n ]"),
|
||||
'delta carries text, disconnects abort upstream, and errors use a generic prompt-free payload'
|
||||
);
|
||||
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";
|
||||
Reference in New Issue
Block a user