1129 lines
42 KiB
PHP
1129 lines
42 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace app\common\service;
|
||
|
||
use think\facade\Log;
|
||
|
||
/**
|
||
* 处方/诊单 AI 上游客户端。
|
||
*
|
||
* 兼容 Dify blocking chat-messages 与 OpenAI-compatible chat completions。
|
||
* 地址和凭据只从服务端 prescription_ai 配置读取,不进入响应、日志或请求正文。
|
||
*/
|
||
class DifyChatService
|
||
{
|
||
/** @var array<int,string> */
|
||
private const ALLOWED_PROFILES = ['qwen', 'openai'];
|
||
|
||
private const MIN_TIMEOUT = 1;
|
||
|
||
private const MAX_TIMEOUT = 300;
|
||
|
||
private const DEFAULT_MAX_FILES = 3;
|
||
|
||
/**
|
||
* 上游明确以“这批附件我处理不了”拒绝整次请求时使用的状态码。
|
||
* 命中后会去掉附件重试一次,避免一张舌象图让整份病历分析失败。
|
||
*/
|
||
private const FILE_REJECTION_CODES = [400, 413, 415, 422];
|
||
|
||
/**
|
||
* @param array<string,mixed> $inputs
|
||
* @return array{ok:bool,content?:string,message_id?:string,latency_ms?:int,error_code?:string,error?:string}
|
||
*/
|
||
public static function chat(
|
||
string $profile,
|
||
array $inputs,
|
||
string $query,
|
||
string $user,
|
||
array $files = []
|
||
): array
|
||
{
|
||
$config = config('prescription_ai') ?: [];
|
||
if (empty($config['enable'])) {
|
||
return self::error('CONFIG_DISABLED', 'AI 报告功能未启用');
|
||
}
|
||
|
||
$modelConfig = self::resolveProfileConfig($config, $profile);
|
||
if ($modelConfig === null) {
|
||
return self::error('INVALID_PROFILE', '不支持的 AI 模型');
|
||
}
|
||
|
||
$baseUrl = trim((string) ($config['base_url'] ?? ''));
|
||
$rawApiKey = (string) ($modelConfig['api_key'] ?? '');
|
||
$apiKey = trim($rawApiKey);
|
||
if ($baseUrl === '' || $apiKey === '') {
|
||
return self::error('CONFIG_MISSING', '该模型服务尚未完整配置');
|
||
}
|
||
if (!self::isValidBaseUrl($baseUrl) || strpbrk($rawApiKey, "\r\n") !== false) {
|
||
return self::error('CONFIG_INVALID', 'AI 服务配置无效');
|
||
}
|
||
|
||
$timeout = (int) ($config['timeout'] ?? 0);
|
||
if (!self::isValidTimeout($timeout)) {
|
||
return self::error('CONFIG_INVALID', 'AI 服务超时配置无效');
|
||
}
|
||
if (!function_exists('curl_init')) {
|
||
return self::error('CURL_UNAVAILABLE', '服务器尚未启用 cURL 扩展');
|
||
}
|
||
|
||
$model = trim((string) ($modelConfig['name'] ?? ''));
|
||
if ($model === '') {
|
||
return self::error('CONFIG_INVALID', 'AI 模型配置无效');
|
||
}
|
||
|
||
$normalized = self::normalizeFiles($files, self::maxFiles($config));
|
||
$startedAt = microtime(true);
|
||
$formatted = null;
|
||
|
||
foreach (self::buildAttemptPlan($normalized['kept'], $normalized['dropped']) as $attempt) {
|
||
$requestSpecs = self::buildRequestSpecs(
|
||
$baseUrl,
|
||
$model,
|
||
$inputs,
|
||
$query,
|
||
$user,
|
||
false,
|
||
$attempt['files'],
|
||
$attempt['omitted']
|
||
);
|
||
$lastResponse = null;
|
||
$lastSpec = [];
|
||
// 协议回退会把最初的“附件被拒”换成另一协议的状态码,因此降级判断
|
||
// 必须记住本轮出现过的附件拒绝信号,而不能只看最后一次响应。
|
||
$fileRejected = false;
|
||
|
||
foreach ($requestSpecs as $index => $requestSpec) {
|
||
$elapsedSeconds = (int) floor(microtime(true) - $startedAt);
|
||
$remainingTimeout = $timeout - $elapsedSeconds;
|
||
if ($remainingTimeout < self::MIN_TIMEOUT) {
|
||
return self::error(
|
||
'UPSTREAM_TIMEOUT',
|
||
'模型响应超时,请稍后重试',
|
||
self::elapsedMilliseconds($startedAt)
|
||
);
|
||
}
|
||
|
||
$response = self::sendRequest(
|
||
$requestSpec['url'],
|
||
$requestSpec['payload'],
|
||
$apiKey,
|
||
$remainingTimeout
|
||
);
|
||
$lastResponse = $response;
|
||
$lastSpec = $requestSpec;
|
||
$fileRejected = $fileRejected || self::isFileRejection($response, $attempt['files']);
|
||
|
||
// /v1 在两种协议中都是合法基址。仅在明确表示路径不存在时尝试另一协议,
|
||
// 避免因业务参数错误而重复提交同一份临床数据。
|
||
$hasFallback = isset($requestSpecs[$index + 1]);
|
||
if ($hasFallback && self::shouldTryNextProtocol($response, false)) {
|
||
continue;
|
||
}
|
||
break;
|
||
}
|
||
|
||
$lastResponse = $lastResponse ?? ['body' => '', 'errno' => 0, 'http_code' => 0];
|
||
$formatted = self::formatResponse($lastResponse, $startedAt);
|
||
self::logUpstreamFailure($lastSpec, $lastResponse, $query, $attempt['files'], $formatted);
|
||
if (!empty($formatted['ok'])) {
|
||
return $formatted;
|
||
}
|
||
// 附件整体被拒时退回纯文本重试,附件清单已在下一轮尝试中补齐。
|
||
if (!$fileRejected) {
|
||
return $formatted;
|
||
}
|
||
}
|
||
|
||
return $formatted ?? self::error('UPSTREAM_REJECTED', '模型未能处理本次请求', self::elapsedMilliseconds($startedAt));
|
||
}
|
||
|
||
/**
|
||
* 流式调用 Dify / OpenAI-compatible 接口。上游原始响应与凭据不会进入返回值。
|
||
*
|
||
* @param array<string,mixed> $inputs
|
||
* @param callable(string):mixed $onDelta
|
||
* @param callable():bool|null $shouldAbort
|
||
* @return array{ok:bool,content?:string,message_id?:string,latency_ms:int,error_code?:string,error?:string}
|
||
*/
|
||
public static function streamChat(
|
||
string $profile,
|
||
array $inputs,
|
||
string $query,
|
||
string $user,
|
||
callable $onDelta,
|
||
?callable $shouldAbort = null,
|
||
array $files = []
|
||
): array {
|
||
$config = config('prescription_ai') ?: [];
|
||
if (empty($config['enable'])) {
|
||
return self::error('CONFIG_DISABLED', 'AI 报告功能未启用');
|
||
}
|
||
|
||
$modelConfig = self::resolveProfileConfig($config, $profile);
|
||
if ($modelConfig === null) {
|
||
return self::error('INVALID_PROFILE', '不支持的 AI 模型');
|
||
}
|
||
|
||
$baseUrl = trim((string) ($config['base_url'] ?? ''));
|
||
$rawApiKey = (string) ($modelConfig['api_key'] ?? '');
|
||
$apiKey = trim($rawApiKey);
|
||
if ($baseUrl === '' || $apiKey === '') {
|
||
return self::error('CONFIG_MISSING', '该模型服务尚未完整配置');
|
||
}
|
||
if (!self::isValidBaseUrl($baseUrl) || strpbrk($rawApiKey, "\r\n") !== false) {
|
||
return self::error('CONFIG_INVALID', 'AI 服务配置无效');
|
||
}
|
||
|
||
$timeout = (int) ($config['timeout'] ?? 0);
|
||
if (!self::isValidTimeout($timeout)) {
|
||
return self::error('CONFIG_INVALID', 'AI 服务超时配置无效');
|
||
}
|
||
if (!function_exists('curl_init')) {
|
||
return self::error('CURL_UNAVAILABLE', '服务器尚未启用 cURL 扩展');
|
||
}
|
||
|
||
$model = trim((string) ($modelConfig['name'] ?? ''));
|
||
if ($model === '') {
|
||
return self::error('CONFIG_INVALID', 'AI 模型配置无效');
|
||
}
|
||
|
||
$normalized = self::normalizeFiles($files, self::maxFiles($config));
|
||
$startedAt = microtime(true);
|
||
$formatted = null;
|
||
|
||
foreach (self::buildAttemptPlan($normalized['kept'], $normalized['dropped']) as $attempt) {
|
||
$requestSpecs = self::buildRequestSpecs(
|
||
$baseUrl,
|
||
$model,
|
||
$inputs,
|
||
$query,
|
||
$user,
|
||
true,
|
||
$attempt['files'],
|
||
$attempt['omitted']
|
||
);
|
||
$lastResponse = null;
|
||
$lastSpec = [];
|
||
// 协议回退会把最初的“附件被拒”换成另一协议的状态码,因此降级判断
|
||
// 必须记住本轮出现过的附件拒绝信号,而不能只看最后一次响应。
|
||
$fileRejected = false;
|
||
|
||
foreach ($requestSpecs as $index => $requestSpec) {
|
||
$elapsedSeconds = (int) floor(microtime(true) - $startedAt);
|
||
$remainingTimeout = $timeout - $elapsedSeconds;
|
||
if ($remainingTimeout < self::MIN_TIMEOUT) {
|
||
return self::error(
|
||
'UPSTREAM_TIMEOUT',
|
||
'模型响应超时,请稍后重试',
|
||
self::elapsedMilliseconds($startedAt)
|
||
);
|
||
}
|
||
|
||
$response = self::sendStreamRequest(
|
||
$requestSpec['protocol'],
|
||
$requestSpec['url'],
|
||
$requestSpec['payload'],
|
||
$apiKey,
|
||
$remainingTimeout,
|
||
$onDelta,
|
||
$shouldAbort
|
||
);
|
||
$lastResponse = $response;
|
||
$lastSpec = $requestSpec;
|
||
$fileRejected = $fileRejected || self::isFileRejection($response, $attempt['files']);
|
||
|
||
// 只在尚未向下游发送任何文本、且明确为路径不支持时尝试另一协议。
|
||
$hasFallback = isset($requestSpecs[$index + 1]);
|
||
if (
|
||
$hasFallback
|
||
&& self::shouldTryNextProtocol($response, true)
|
||
) {
|
||
continue;
|
||
}
|
||
break;
|
||
}
|
||
|
||
$lastResponse = $lastResponse ?? self::emptyStreamResponse(0);
|
||
$formatted = self::formatStreamResponse($lastResponse, $startedAt);
|
||
self::logUpstreamFailure($lastSpec, $lastResponse, $query, $attempt['files'], $formatted);
|
||
if (!empty($formatted['ok'])) {
|
||
return $formatted;
|
||
}
|
||
// 已经推给医生的文本不能重复输出,因此只在一个字都没发出去时才降级重试。
|
||
if (!empty($lastResponse['emitted']) || !$fileRejected) {
|
||
return $formatted;
|
||
}
|
||
}
|
||
|
||
return $formatted ?? self::error('UPSTREAM_REJECTED', '模型未能处理本次请求', self::elapsedMilliseconds($startedAt));
|
||
}
|
||
|
||
/**
|
||
* @param array<string,mixed> $config
|
||
* @return array<string,mixed>|null
|
||
*/
|
||
private static function resolveProfileConfig(array $config, string $profile): ?array
|
||
{
|
||
if (!in_array($profile, self::ALLOWED_PROFILES, true)) {
|
||
return null;
|
||
}
|
||
$modelConfig = $config['models'][$profile] ?? null;
|
||
return is_array($modelConfig) ? $modelConfig : null;
|
||
}
|
||
|
||
/**
|
||
* @param array<string,mixed> $inputs
|
||
* @param array<int,array<string,string>> $files 随请求送达的附件
|
||
* @param array<int,array<string,string>> $omitted 超出上游数量上限、只能写进清单的附件
|
||
* @return array<int,array{protocol:string,url:string,payload:array<string,mixed>}>
|
||
*/
|
||
private static function buildRequestSpecs(
|
||
string $baseUrl,
|
||
string $model,
|
||
array $inputs,
|
||
string $query,
|
||
string $user,
|
||
bool $streaming = false,
|
||
array $files = [],
|
||
array $omitted = []
|
||
): array {
|
||
$baseUrl = rtrim($baseUrl, '/');
|
||
$path = strtolower((string) (parse_url($baseUrl, PHP_URL_PATH) ?? ''));
|
||
|
||
// Dify 能承载全部附件类型,只需补上被数量上限截断的清单。
|
||
// inputs 必须是 JSON 对象:空数组会被 json_encode 成 [],Dify 直接
|
||
// 以 invalid_param 拒绝整单,因此这里强制对象语义。
|
||
$difySpec = [
|
||
'protocol' => 'dify',
|
||
'url' => self::buildEndpoint($baseUrl, 'chat-messages'),
|
||
'payload' => [
|
||
'inputs' => (object) $inputs,
|
||
'query' => self::withAttachmentManifest($query, $omitted),
|
||
'response_mode' => $streaming ? 'streaming' : 'blocking',
|
||
'user' => $user,
|
||
],
|
||
];
|
||
if ($files !== []) {
|
||
$difySpec['payload']['files'] = $files;
|
||
}
|
||
|
||
// Chat Completions 只能内联图片,非图片附件与被截断的附件一并进清单。
|
||
$openAiContent = self::buildOpenAiContent(
|
||
self::withAttachmentManifest(
|
||
$query,
|
||
array_merge(self::nonImageFiles($files), $omitted)
|
||
),
|
||
$files
|
||
);
|
||
$openAiSpec = [
|
||
'protocol' => 'openai',
|
||
'url' => self::buildEndpoint($baseUrl, 'chat/completions'),
|
||
'payload' => [
|
||
'model' => $model,
|
||
'messages' => [
|
||
['role' => 'user', 'content' => $openAiContent],
|
||
],
|
||
'stream' => $streaming,
|
||
],
|
||
];
|
||
|
||
if (!$streaming) {
|
||
unset($openAiSpec['payload']['stream']);
|
||
}
|
||
|
||
if (str_ends_with($path, '/chat-messages')) {
|
||
return [$difySpec];
|
||
}
|
||
if (str_ends_with($path, '/chat/completions')) {
|
||
return [$openAiSpec];
|
||
}
|
||
|
||
// 保持既有 /v1 Dify 配置优先,同时让 OpenAI-compatible 服务在 404/405 后透明回退。
|
||
// 早期实现只在“无附件或全是图片”时提供回退,患者带检查报告/录像附件时
|
||
// Dify 路径 404 会直接变成“模型未能处理本次请求”,因此这里始终保留回退,
|
||
// 非图片附件改为在正文中以清单形式随请求送达,绝不静默丢弃。
|
||
return [$difySpec, $openAiSpec];
|
||
}
|
||
|
||
/**
|
||
* 构造 OpenAI-compatible 正文。图片走多模态 image_url;非图片附件已由调用方
|
||
* 写进 $query 末尾的清单,这里只负责内联图片。
|
||
*
|
||
* @param array<int,array<string,string>> $files
|
||
* @return string|array<int,array<string,mixed>>
|
||
*/
|
||
private static function buildOpenAiContent(string $query, array $files)
|
||
{
|
||
$content = [['type' => 'text', 'text' => $query]];
|
||
foreach ($files as $file) {
|
||
$url = (string) ($file['url'] ?? '');
|
||
if ($url === '' || ($file['type'] ?? '') !== 'image') {
|
||
continue;
|
||
}
|
||
$content[] = [
|
||
'type' => 'image_url',
|
||
'image_url' => ['url' => $url],
|
||
];
|
||
}
|
||
return count($content) === 1 ? $query : $content;
|
||
}
|
||
|
||
/**
|
||
* @param array<int,array<string,string>> $files
|
||
* @return array<int,array<string,string>>
|
||
*/
|
||
private static function nonImageFiles(array $files): array
|
||
{
|
||
return array_values(array_filter(
|
||
$files,
|
||
static fn (array $file): bool => ($file['type'] ?? '') !== 'image'
|
||
));
|
||
}
|
||
|
||
/**
|
||
* 清洗附件,并按上游应用允许的数量截断。
|
||
*
|
||
* Dify 用 file_upload.number_limits 校验单次请求的附件总数,超出即返回
|
||
* 400 invalid_param 拒绝整单。患者纵向资料的附件数量不可控(舌象、报告、
|
||
* 录像可能几十份),因此这里必须主动截断;被截断的附件不会被悄悄丢弃,
|
||
* 而是以清单形式随提示词送达,让模型知道存在哪些它读不到的资料。
|
||
* 保持调用方给定的顺序,由调用方决定哪些附件最值得送上去。
|
||
*
|
||
* @param array<int,mixed> $files
|
||
* @return array{
|
||
* kept:array<int,array{type:string,transfer_method:string,url:string}>,
|
||
* dropped:array<int,array{type:string,transfer_method:string,url:string}>
|
||
* }
|
||
*/
|
||
private static function normalizeFiles(array $files, int $maxFiles): array
|
||
{
|
||
$maxFiles = max(0, $maxFiles);
|
||
$kept = [];
|
||
$dropped = [];
|
||
$seen = [];
|
||
foreach ($files as $file) {
|
||
if (!is_array($file)) {
|
||
continue;
|
||
}
|
||
$type = strtolower(trim((string) ($file['type'] ?? '')));
|
||
$url = trim((string) ($file['url'] ?? ''));
|
||
if (!in_array($type, ['image', 'document', 'audio', 'video', 'custom'], true)
|
||
|| !self::isValidRemoteFileUrl($url)
|
||
|| isset($seen[$url])) {
|
||
continue;
|
||
}
|
||
$seen[$url] = true;
|
||
$normalized = [
|
||
'type' => $type,
|
||
'transfer_method' => 'remote_url',
|
||
'url' => $url,
|
||
];
|
||
if (count($kept) >= $maxFiles) {
|
||
$dropped[] = $normalized;
|
||
continue;
|
||
}
|
||
$kept[] = $normalized;
|
||
}
|
||
return ['kept' => $kept, 'dropped' => $dropped];
|
||
}
|
||
|
||
/** @param array<string,mixed> $config */
|
||
private static function maxFiles(array $config): int
|
||
{
|
||
$configured = (int) ($config['max_files'] ?? self::DEFAULT_MAX_FILES);
|
||
return $configured >= 0 ? $configured : self::DEFAULT_MAX_FILES;
|
||
}
|
||
|
||
/**
|
||
* 排出两轮尝试:先带附件,附件被上游整体拒绝时再只发文本。
|
||
* 第二轮把全部附件写进清单,保证降级后模型仍知道资料缺口。
|
||
*
|
||
* @param array<int,array<string,string>> $files
|
||
* @param array<int,array<string,string>> $dropped
|
||
* @return array<int,array{files:array<int,array<string,string>>,omitted:array<int,array<string,string>>}>
|
||
*/
|
||
private static function buildAttemptPlan(array $files, array $dropped): array
|
||
{
|
||
$attempts = [['files' => $files, 'omitted' => $dropped]];
|
||
if ($files !== []) {
|
||
$attempts[] = ['files' => [], 'omitted' => array_merge($files, $dropped)];
|
||
}
|
||
return $attempts;
|
||
}
|
||
|
||
/**
|
||
* @param array<int,array<string,string>> $files
|
||
*/
|
||
private static function shouldRetryWithoutFiles(int $httpCode, array $files): bool
|
||
{
|
||
return $files !== [] && in_array($httpCode, self::FILE_REJECTION_CODES, true);
|
||
}
|
||
|
||
/**
|
||
* 判断一次上游响应是否属于“这批附件我处理不了”。
|
||
*
|
||
* 除了 4xx 状态码,Dify 拉不到附件时会在 200 的 SSE 流里发 event:error,
|
||
* 这两种形态都必须触发去掉附件的降级重试。
|
||
*
|
||
* @param array<string,mixed> $response
|
||
* @param array<int,array<string,string>> $files
|
||
*/
|
||
private static function isFileRejection(array $response, array $files): bool
|
||
{
|
||
if ($files === [] || (int) ($response['errno'] ?? 0) !== 0) {
|
||
return false;
|
||
}
|
||
if (self::shouldRetryWithoutFiles((int) ($response['http_code'] ?? 0), $files)) {
|
||
return true;
|
||
}
|
||
return !empty($response['upstream_error']);
|
||
}
|
||
|
||
/**
|
||
* 把无法随请求送达的附件写成显式清单。模型必须知道这些资料存在但读不到,
|
||
* 才不会把“没看到”当成“没有”。
|
||
*
|
||
* @param array<int,array<string,string>> $omitted
|
||
*/
|
||
private static function withAttachmentManifest(string $query, array $omitted): string
|
||
{
|
||
$lines = [];
|
||
foreach ($omitted as $file) {
|
||
$url = (string) ($file['url'] ?? '');
|
||
if ($url === '') {
|
||
continue;
|
||
}
|
||
$lines[] = strtoupper((string) ($file['type'] ?? 'file')) . ' ' . $url;
|
||
}
|
||
if ($lines === []) {
|
||
return $query;
|
||
}
|
||
return $query . "\n\n<ATTACHMENTS_NOT_INLINE>\n"
|
||
. "以下附件无法随本次请求送达,只提供来源地址;无法读取的附件必须在结论中明确标注为信息缺口。\n"
|
||
. implode("\n", $lines)
|
||
. "\n</ATTACHMENTS_NOT_INLINE>";
|
||
}
|
||
|
||
private static function isValidRemoteFileUrl(string $url): bool
|
||
{
|
||
if ($url === '' || preg_match('/[\x00-\x20\x7f]/', $url)) {
|
||
return false;
|
||
}
|
||
$parts = parse_url($url);
|
||
return is_array($parts)
|
||
&& in_array(strtolower((string) ($parts['scheme'] ?? '')), ['http', 'https'], true)
|
||
&& trim((string) ($parts['host'] ?? '')) !== ''
|
||
&& !isset($parts['user'])
|
||
&& !isset($parts['pass']);
|
||
}
|
||
|
||
private static function buildEndpoint(string $baseUrl, string $endpoint): string
|
||
{
|
||
$baseUrl = rtrim($baseUrl, '/');
|
||
$path = strtolower((string) (parse_url($baseUrl, PHP_URL_PATH) ?? ''));
|
||
if (str_ends_with($path, '/chat-messages') || str_ends_with($path, '/chat/completions')) {
|
||
return $baseUrl;
|
||
}
|
||
if (str_ends_with($path, '/v1')) {
|
||
return $baseUrl . '/' . $endpoint;
|
||
}
|
||
return $baseUrl . '/v1/' . $endpoint;
|
||
}
|
||
|
||
/**
|
||
* Decide whether an ambiguous base URL should be tried with the other wire
|
||
* protocol. A 400/415/422 response cannot have started generation, and a
|
||
* 2xx stream with no delivered delta but no valid terminal frame is also
|
||
* safe to retry. Authentication, rate-limit and server failures retain
|
||
* their original diagnosis instead of being hidden by a second request.
|
||
*
|
||
* @param array<string,mixed> $response
|
||
*/
|
||
private static function shouldTryNextProtocol(array $response, bool $streaming): bool
|
||
{
|
||
if ((int) ($response['errno'] ?? 0) !== 0) {
|
||
return false;
|
||
}
|
||
$httpCode = (int) ($response['http_code'] ?? 0);
|
||
if (in_array($httpCode, [400, 404, 405, 415, 422, 501], true)) {
|
||
return true;
|
||
}
|
||
if (!$streaming || $httpCode < 200 || $httpCode >= 300 || !empty($response['emitted'])) {
|
||
return false;
|
||
}
|
||
return !empty($response['upstream_error']) || empty($response['finished']);
|
||
}
|
||
|
||
private static function isValidBaseUrl(string $baseUrl): bool
|
||
{
|
||
if (preg_match('/[\x00-\x20\x7f]/', $baseUrl)) {
|
||
return false;
|
||
}
|
||
$parts = parse_url($baseUrl);
|
||
if (!is_array($parts)) {
|
||
return false;
|
||
}
|
||
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
|
||
return in_array($scheme, ['http', 'https'], true)
|
||
&& trim((string) ($parts['host'] ?? '')) !== ''
|
||
&& !isset($parts['user'])
|
||
&& !isset($parts['pass'])
|
||
&& !isset($parts['query'])
|
||
&& !isset($parts['fragment']);
|
||
}
|
||
|
||
private static function isValidTimeout(int $timeout): bool
|
||
{
|
||
return $timeout >= self::MIN_TIMEOUT && $timeout <= self::MAX_TIMEOUT;
|
||
}
|
||
|
||
/**
|
||
* @param array<string,mixed> $payload
|
||
* @return array{body:string,errno:int,http_code:int}
|
||
*/
|
||
private static function sendRequest(
|
||
string $url,
|
||
array $payload,
|
||
string $apiKey,
|
||
int $timeout
|
||
): array {
|
||
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
|
||
if ($body === false) {
|
||
return ['body' => '', 'errno' => -1, 'http_code' => 0];
|
||
}
|
||
|
||
$ch = curl_init();
|
||
if ($ch === false) {
|
||
return ['body' => '', 'errno' => -2, 'http_code' => 0];
|
||
}
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_URL => $url,
|
||
CURLOPT_POST => true,
|
||
CURLOPT_POSTFIELDS => $body,
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_CONNECTTIMEOUT => min(8, max(1, (int) ceil($timeout / 4))),
|
||
CURLOPT_TIMEOUT => $timeout,
|
||
CURLOPT_SSL_VERIFYPEER => true,
|
||
CURLOPT_SSL_VERIFYHOST => 2,
|
||
CURLOPT_HTTPHEADER => [
|
||
'Content-Type: application/json',
|
||
'Accept: application/json',
|
||
'Authorization: Bearer ' . $apiKey,
|
||
],
|
||
]);
|
||
|
||
$responseBody = curl_exec($ch);
|
||
$errno = curl_errno($ch);
|
||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
curl_close($ch);
|
||
|
||
return [
|
||
'body' => is_string($responseBody) ? $responseBody : '',
|
||
'errno' => $errno,
|
||
'http_code' => $httpCode,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @param array<string,mixed> $payload
|
||
* @param callable(string):mixed $onDelta
|
||
* @param callable():bool|null $shouldAbort
|
||
* @return array{
|
||
* errno:int,http_code:int,content:string,message_id:string,emitted:bool,
|
||
* upstream_error:bool,client_aborted:bool,callback_error:bool,finished:bool
|
||
* }
|
||
*/
|
||
private static function sendStreamRequest(
|
||
string $protocol,
|
||
string $url,
|
||
array $payload,
|
||
string $apiKey,
|
||
int $timeout,
|
||
callable $onDelta,
|
||
?callable $shouldAbort
|
||
): array {
|
||
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
|
||
if ($body === false) {
|
||
return self::emptyStreamResponse(-1);
|
||
}
|
||
|
||
$ch = curl_init();
|
||
if ($ch === false) {
|
||
return self::emptyStreamResponse(-2);
|
||
}
|
||
|
||
$buffer = '';
|
||
$state = self::newStreamState();
|
||
$responseCode = 0;
|
||
$header = static function ($handle, string $line) use (&$responseCode): int {
|
||
if (preg_match('/^HTTP\/\S+\s+(\d{3})(?:\s|$)/i', trim($line), $matches) === 1) {
|
||
$responseCode = (int) $matches[1];
|
||
}
|
||
return strlen($line);
|
||
};
|
||
$write = static function ($handle, string $chunk) use (
|
||
$protocol,
|
||
&$buffer,
|
||
&$state,
|
||
&$responseCode,
|
||
$onDelta,
|
||
$shouldAbort
|
||
): int {
|
||
if ($shouldAbort !== null && $shouldAbort()) {
|
||
$state['client_aborted'] = true;
|
||
return 0;
|
||
}
|
||
if ($responseCode < 200 || $responseCode >= 300) {
|
||
// Never decode or forward an error response body. Besides preventing
|
||
// leakage, this keeps 404/405 protocol fallback side-effect free.
|
||
return strlen($chunk);
|
||
}
|
||
self::consumeStreamBytes($protocol, $buffer, $chunk, $state, $onDelta);
|
||
return $state['callback_error'] ? 0 : strlen($chunk);
|
||
};
|
||
$progress = static function () use (&$state, $shouldAbort): int {
|
||
if ($shouldAbort !== null && $shouldAbort()) {
|
||
$state['client_aborted'] = true;
|
||
return 1;
|
||
}
|
||
return 0;
|
||
};
|
||
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_URL => $url,
|
||
CURLOPT_POST => true,
|
||
CURLOPT_POSTFIELDS => $body,
|
||
CURLOPT_RETURNTRANSFER => false,
|
||
CURLOPT_CONNECTTIMEOUT => min(8, max(1, (int) ceil($timeout / 4))),
|
||
CURLOPT_TIMEOUT => $timeout,
|
||
CURLOPT_SSL_VERIFYPEER => true,
|
||
CURLOPT_SSL_VERIFYHOST => 2,
|
||
CURLOPT_HTTPHEADER => [
|
||
'Content-Type: application/json',
|
||
'Accept: text/event-stream',
|
||
'Authorization: Bearer ' . $apiKey,
|
||
],
|
||
CURLOPT_HEADERFUNCTION => $header,
|
||
CURLOPT_WRITEFUNCTION => $write,
|
||
CURLOPT_NOPROGRESS => false,
|
||
CURLOPT_XFERINFOFUNCTION => $progress,
|
||
]);
|
||
|
||
curl_exec($ch);
|
||
$errno = curl_errno($ch);
|
||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
curl_close($ch);
|
||
|
||
if (!$state['client_aborted'] && !$state['callback_error']) {
|
||
self::consumeStreamBytes($protocol, $buffer, '', $state, $onDelta, true);
|
||
}
|
||
|
||
return [
|
||
'errno' => $errno,
|
||
'http_code' => $httpCode,
|
||
'content' => $state['content'],
|
||
'message_id' => $state['message_id'],
|
||
'emitted' => $state['emitted'],
|
||
'upstream_error' => $state['upstream_error'],
|
||
'upstream_code' => $state['upstream_code'],
|
||
'client_aborted' => $state['client_aborted'],
|
||
'callback_error' => $state['callback_error'],
|
||
'finished' => $state['finished'],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @return array{
|
||
* content:string,message_id:string,emitted:bool,upstream_error:bool,
|
||
* client_aborted:bool,callback_error:bool,finished:bool
|
||
* }
|
||
*/
|
||
private static function newStreamState(): array
|
||
{
|
||
return [
|
||
'content' => '',
|
||
'message_id' => '',
|
||
'emitted' => false,
|
||
'upstream_error' => false,
|
||
'upstream_code' => '',
|
||
'client_aborted' => false,
|
||
'callback_error' => false,
|
||
'finished' => false,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 上游错误码只保留可枚举的短标识(如 invalid_param),杜绝把上游文案或
|
||
* 患者资源地址带进日志。
|
||
*
|
||
* @param mixed $code
|
||
*/
|
||
private static function cleanUpstreamCode($code): string
|
||
{
|
||
if (!is_string($code)) {
|
||
return '';
|
||
}
|
||
return preg_match('/^[a-z0-9_.-]{1,64}$/i', $code) === 1 ? $code : '';
|
||
}
|
||
|
||
/**
|
||
* 按 SSE 空行分帧;仅在完整 data frame 后 json_decode,因此可安全接收任意字节边界。
|
||
*
|
||
* @param array<string,mixed> $state
|
||
* @param callable(string):mixed $onDelta
|
||
*/
|
||
private static function consumeStreamBytes(
|
||
string $protocol,
|
||
string &$buffer,
|
||
string $chunk,
|
||
array &$state,
|
||
callable $onDelta,
|
||
bool $final = false
|
||
): void {
|
||
$buffer .= $chunk;
|
||
while (preg_match('/(?:\r\n|\r|\n){2}/', $buffer, $match, PREG_OFFSET_CAPTURE) === 1) {
|
||
$delimiter = $match[0][0];
|
||
$offset = $match[0][1];
|
||
$frame = substr($buffer, 0, $offset);
|
||
$buffer = (string) substr($buffer, $offset + strlen($delimiter));
|
||
self::consumeStreamFrame($protocol, $frame, $state, $onDelta);
|
||
}
|
||
if ($final && trim($buffer) !== '') {
|
||
self::consumeStreamFrame($protocol, $buffer, $state, $onDelta);
|
||
$buffer = '';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param array<string,mixed> $state
|
||
* @param callable(string):mixed $onDelta
|
||
*/
|
||
private static function consumeStreamFrame(
|
||
string $protocol,
|
||
string $frame,
|
||
array &$state,
|
||
callable $onDelta
|
||
): void {
|
||
if ($state['finished'] || $state['upstream_error'] || $state['callback_error']) {
|
||
return;
|
||
}
|
||
|
||
$dataLines = [];
|
||
foreach (preg_split('/\r\n|\r|\n/', $frame) ?: [] as $line) {
|
||
if ($line === '' || str_starts_with($line, ':')) {
|
||
continue;
|
||
}
|
||
if (str_starts_with($line, 'data:')) {
|
||
$dataLines[] = ltrim(substr($line, 5), ' ');
|
||
}
|
||
}
|
||
if ($dataLines === []) {
|
||
return;
|
||
}
|
||
|
||
$data = implode("\n", $dataLines);
|
||
if ($data === '[DONE]') {
|
||
$state['finished'] = true;
|
||
return;
|
||
}
|
||
$decoded = json_decode($data, true);
|
||
if (!is_array($decoded)) {
|
||
return;
|
||
}
|
||
|
||
$delta = '';
|
||
if ($protocol === 'dify') {
|
||
$event = strtolower((string) ($decoded['event'] ?? ''));
|
||
if ($event === 'message_end') {
|
||
$state['message_id'] = (string) ($decoded['message_id'] ?? $state['message_id']);
|
||
$state['finished'] = true;
|
||
return;
|
||
}
|
||
if ($event === 'error') {
|
||
$state['upstream_error'] = true;
|
||
// 只留可枚举的错误码用于排障;message 可能含患者资源地址,不落日志。
|
||
$state['upstream_code'] = self::cleanUpstreamCode($decoded['code'] ?? '');
|
||
return;
|
||
}
|
||
if (!in_array($event, ['message', 'agent_message'], true)) {
|
||
return;
|
||
}
|
||
$delta = is_string($decoded['answer'] ?? null) ? $decoded['answer'] : '';
|
||
$state['message_id'] = (string) ($decoded['message_id'] ?? $state['message_id']);
|
||
} else {
|
||
$delta = self::extractStreamDelta($decoded);
|
||
$state['message_id'] = (string) ($decoded['id'] ?? $state['message_id']);
|
||
}
|
||
|
||
if ($delta === '') {
|
||
return;
|
||
}
|
||
try {
|
||
$accepted = $onDelta($delta);
|
||
if ($accepted === false) {
|
||
$state['callback_error'] = true;
|
||
return;
|
||
}
|
||
} catch (\Throwable $e) {
|
||
$state['callback_error'] = true;
|
||
return;
|
||
}
|
||
$state['content'] .= $delta;
|
||
$state['emitted'] = true;
|
||
}
|
||
|
||
/** @param array<string,mixed> $decoded */
|
||
private static function extractStreamDelta(array $decoded): string
|
||
{
|
||
$content = $decoded['choices'][0]['delta']['content'] ?? '';
|
||
if (is_string($content)) {
|
||
return $content;
|
||
}
|
||
if (!is_array($content)) {
|
||
return '';
|
||
}
|
||
$parts = [];
|
||
foreach ($content as $part) {
|
||
if (is_array($part) && ($part['type'] ?? '') === 'text' && is_string($part['text'] ?? null)) {
|
||
$parts[] = $part['text'];
|
||
}
|
||
}
|
||
return implode('', $parts);
|
||
}
|
||
|
||
/**
|
||
* 纯解析测试入口:生产流与测试使用同一逐字节解码路径。
|
||
*
|
||
* @param array<int,string> $chunks
|
||
* @return array{content:string,deltas:array<int,string>,message_id:string,finished:bool,upstream_error:bool}
|
||
*/
|
||
private static function decodeStreamChunks(string $protocol, array $chunks): array
|
||
{
|
||
$buffer = '';
|
||
$state = self::newStreamState();
|
||
$deltas = [];
|
||
$onDelta = static function (string $delta) use (&$deltas): void {
|
||
$deltas[] = $delta;
|
||
};
|
||
foreach ($chunks as $chunk) {
|
||
self::consumeStreamBytes($protocol, $buffer, $chunk, $state, $onDelta);
|
||
}
|
||
self::consumeStreamBytes($protocol, $buffer, '', $state, $onDelta, true);
|
||
return [
|
||
'content' => $state['content'],
|
||
'deltas' => $deltas,
|
||
'message_id' => $state['message_id'],
|
||
'finished' => $state['finished'],
|
||
'upstream_error' => $state['upstream_error'],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @return array{
|
||
* errno:int,http_code:int,content:string,message_id:string,emitted:bool,
|
||
* upstream_error:bool,client_aborted:bool,callback_error:bool,finished:bool
|
||
* }
|
||
*/
|
||
private static function emptyStreamResponse(int $errno): array
|
||
{
|
||
return [
|
||
'errno' => $errno,
|
||
'http_code' => 0,
|
||
'content' => '',
|
||
'message_id' => '',
|
||
'emitted' => false,
|
||
'upstream_error' => false,
|
||
'upstream_code' => '',
|
||
'client_aborted' => false,
|
||
'callback_error' => false,
|
||
'finished' => false,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @param array<string,mixed> $response
|
||
* @return array{ok:bool,content?:string,message_id?:string,latency_ms:int,error_code?:string,error?:string}
|
||
*/
|
||
private static function formatStreamResponse(array $response, float $startedAt): array
|
||
{
|
||
$latencyMs = self::elapsedMilliseconds($startedAt);
|
||
if (!empty($response['client_aborted'])) {
|
||
return self::error('CLIENT_DISCONNECTED', '客户端已断开连接', $latencyMs);
|
||
}
|
||
if (!empty($response['callback_error'])) {
|
||
return self::error('STREAM_DELIVERY_FAILED', '流式响应已中止', $latencyMs);
|
||
}
|
||
|
||
$errno = (int) ($response['errno'] ?? 0);
|
||
if ($errno !== 0) {
|
||
if ($errno === CURLE_OPERATION_TIMEDOUT) {
|
||
return self::error('UPSTREAM_TIMEOUT', '模型响应超时,请稍后重试', $latencyMs);
|
||
}
|
||
if ($errno === -1) {
|
||
return self::error('REQUEST_BUILD_FAILED', '病例数据编码失败', $latencyMs);
|
||
}
|
||
if ($errno === -2) {
|
||
return self::error('CURL_INIT_FAILED', '无法初始化 AI 请求', $latencyMs);
|
||
}
|
||
return self::error('UPSTREAM_UNAVAILABLE', '暂时无法连接 AI 服务,请稍后重试', $latencyMs);
|
||
}
|
||
|
||
$httpCode = (int) ($response['http_code'] ?? 0);
|
||
if ($httpCode === 401 || $httpCode === 403) {
|
||
return self::error('CONFIG_INVALID', 'AI 服务凭据无效或无权限', $latencyMs);
|
||
}
|
||
if ($httpCode === 429 || $httpCode >= 500) {
|
||
return self::error('UPSTREAM_BUSY', '模型服务繁忙,请稍后重试', $latencyMs);
|
||
}
|
||
if ($httpCode >= 400 || $httpCode < 200 || !empty($response['upstream_error'])) {
|
||
return self::error('UPSTREAM_REJECTED', '模型未能处理本次请求', $latencyMs);
|
||
}
|
||
if (empty($response['finished'])) {
|
||
return self::error('INCOMPLETE_RESPONSE', '模型响应不完整,请重试', $latencyMs);
|
||
}
|
||
|
||
$content = (string) ($response['content'] ?? '');
|
||
if (trim($content) === '') {
|
||
return self::error('EMPTY_RESPONSE', '模型未返回报告内容,请重试', $latencyMs);
|
||
}
|
||
return [
|
||
'ok' => true,
|
||
'content' => $content,
|
||
'message_id' => (string) ($response['message_id'] ?? ''),
|
||
'latency_ms' => $latencyMs,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @param array{body:string,errno:int,http_code:int} $response
|
||
* @return array{ok:bool,content?:string,message_id?:string,latency_ms:int,error_code?:string,error?:string}
|
||
*/
|
||
private static function formatResponse(array $response, float $startedAt): array
|
||
{
|
||
$latencyMs = self::elapsedMilliseconds($startedAt);
|
||
$errno = $response['errno'];
|
||
$httpCode = $response['http_code'];
|
||
|
||
if ($errno !== 0) {
|
||
if ($errno === CURLE_OPERATION_TIMEDOUT) {
|
||
return self::error('UPSTREAM_TIMEOUT', '模型响应超时,请稍后重试', $latencyMs);
|
||
}
|
||
if ($errno === -1) {
|
||
return self::error('REQUEST_BUILD_FAILED', '病例数据编码失败', $latencyMs);
|
||
}
|
||
if ($errno === -2) {
|
||
return self::error('CURL_INIT_FAILED', '无法初始化 AI 请求', $latencyMs);
|
||
}
|
||
return self::error('UPSTREAM_UNAVAILABLE', '暂时无法连接 AI 服务,请稍后重试', $latencyMs);
|
||
}
|
||
|
||
$decoded = json_decode($response['body'], true);
|
||
if ($httpCode === 401 || $httpCode === 403) {
|
||
return self::error('CONFIG_INVALID', 'AI 服务凭据无效或无权限', $latencyMs);
|
||
}
|
||
if ($httpCode === 429 || $httpCode >= 500) {
|
||
return self::error('UPSTREAM_BUSY', '模型服务繁忙,请稍后重试', $latencyMs);
|
||
}
|
||
if ($httpCode >= 400 || $httpCode < 200) {
|
||
return self::error('UPSTREAM_REJECTED', '模型未能处理本次请求', $latencyMs);
|
||
}
|
||
if (!is_array($decoded)) {
|
||
return self::error('INVALID_RESPONSE', '模型返回格式异常,请重试', $latencyMs);
|
||
}
|
||
|
||
$answer = self::extractContent($decoded);
|
||
if ($answer === '') {
|
||
return self::error('EMPTY_RESPONSE', '模型未返回报告内容,请重试', $latencyMs);
|
||
}
|
||
|
||
return [
|
||
'ok' => true,
|
||
'content' => $answer,
|
||
'message_id' => (string) ($decoded['message_id'] ?? $decoded['id'] ?? ''),
|
||
'latency_ms' => $latencyMs,
|
||
];
|
||
}
|
||
|
||
/** @param array<string,mixed> $decoded */
|
||
private static function extractContent(array $decoded): string
|
||
{
|
||
$content = $decoded['answer'] ?? $decoded['choices'][0]['message']['content'] ?? '';
|
||
if (is_string($content)) {
|
||
return trim($content);
|
||
}
|
||
if (!is_array($content)) {
|
||
return '';
|
||
}
|
||
|
||
$parts = [];
|
||
foreach ($content as $part) {
|
||
if (is_array($part) && ($part['type'] ?? '') === 'text' && is_string($part['text'] ?? null)) {
|
||
$parts[] = $part['text'];
|
||
}
|
||
}
|
||
return trim(implode('', $parts));
|
||
}
|
||
|
||
private static function elapsedMilliseconds(float $startedAt): int
|
||
{
|
||
return (int) round((microtime(true) - $startedAt) * 1000);
|
||
}
|
||
|
||
/**
|
||
* 记录上游失败的结构化定位信息。按项目约定,绝不写入凭据、上游主机名或
|
||
* 响应正文,只保留可用于排障的协议、路径、状态码和请求规模。
|
||
*
|
||
* @param array<string,mixed> $requestSpec
|
||
* @param array<string,mixed> $response
|
||
* @param array<int,array<string,string>> $files
|
||
* @param array<string,mixed> $formatted
|
||
*/
|
||
private static function logUpstreamFailure(
|
||
array $requestSpec,
|
||
array $response,
|
||
string $query,
|
||
array $files,
|
||
array $formatted
|
||
): void {
|
||
if (!empty($formatted['ok'])) {
|
||
return;
|
||
}
|
||
$url = (string) ($requestSpec['url'] ?? '');
|
||
$upstreamCode = (string) ($response['upstream_code'] ?? '');
|
||
if ($upstreamCode === '' && isset($response['body'])) {
|
||
$decoded = json_decode((string) $response['body'], true);
|
||
$upstreamCode = is_array($decoded)
|
||
? self::cleanUpstreamCode($decoded['code'] ?? '')
|
||
: '';
|
||
}
|
||
Log::warning('prescription ai upstream request failed', [
|
||
'protocol' => (string) ($requestSpec['protocol'] ?? ''),
|
||
'endpoint_path' => (string) (parse_url($url, PHP_URL_PATH) ?? ''),
|
||
'http_code' => (int) ($response['http_code'] ?? 0),
|
||
'curl_errno' => (int) ($response['errno'] ?? 0),
|
||
// 上游自有错误码(如 invalid_param),用于区分附件超限、鉴权、模型故障。
|
||
'upstream_code' => $upstreamCode,
|
||
'query_bytes' => strlen($query),
|
||
'file_count' => count($files),
|
||
'error_code' => (string) ($formatted['error_code'] ?? 'UNKNOWN'),
|
||
'latency_ms' => (int) ($formatted['latency_ms'] ?? 0),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* @return array{ok:false,error_code:string,error:string,latency_ms:int}
|
||
*/
|
||
private static function error(string $code, string $message, int $latencyMs = 0): array
|
||
{
|
||
return [
|
||
'ok' => false,
|
||
'error_code' => $code,
|
||
'error' => $message,
|
||
'latency_ms' => $latencyMs,
|
||
];
|
||
}
|
||
}
|