331 lines
11 KiB
PHP
331 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace app\common\service;
|
|
|
|
/**
|
|
* 处方/诊单 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;
|
|
|
|
/**
|
|
* @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
|
|
{
|
|
$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 模型配置无效');
|
|
}
|
|
|
|
$requestSpecs = self::buildRequestSpecs($baseUrl, $model, $inputs, $query, $user);
|
|
$startedAt = microtime(true);
|
|
$lastResponse = null;
|
|
|
|
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;
|
|
|
|
// /v1 在两种协议中都是合法基址。仅在明确表示路径不存在时尝试另一协议,
|
|
// 避免因业务参数错误而重复提交同一份临床数据。
|
|
$hasFallback = isset($requestSpecs[$index + 1]);
|
|
if ($hasFallback && in_array($response['http_code'], [404, 405], true)) {
|
|
continue;
|
|
}
|
|
|
|
return self::formatResponse($response, $startedAt);
|
|
}
|
|
|
|
return self::formatResponse($lastResponse ?? [
|
|
'body' => '',
|
|
'errno' => 0,
|
|
'http_code' => 0,
|
|
], $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
|
|
* @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
|
|
): array {
|
|
$baseUrl = rtrim($baseUrl, '/');
|
|
$path = strtolower((string) (parse_url($baseUrl, PHP_URL_PATH) ?? ''));
|
|
|
|
$difySpec = [
|
|
'protocol' => 'dify',
|
|
'url' => self::buildEndpoint($baseUrl, 'chat-messages'),
|
|
'payload' => [
|
|
'inputs' => $inputs,
|
|
'query' => $query,
|
|
'response_mode' => 'blocking',
|
|
'user' => $user,
|
|
],
|
|
];
|
|
$openAiSpec = [
|
|
'protocol' => 'openai',
|
|
'url' => self::buildEndpoint($baseUrl, 'chat/completions'),
|
|
'payload' => [
|
|
'model' => $model,
|
|
'messages' => [
|
|
['role' => 'user', 'content' => $query],
|
|
],
|
|
],
|
|
];
|
|
|
|
if (str_ends_with($path, '/chat-messages')) {
|
|
return [$difySpec];
|
|
}
|
|
if (str_ends_with($path, '/chat/completions')) {
|
|
return [$openAiSpec];
|
|
}
|
|
|
|
// 保持既有 /v1 Dify 配置优先,同时让 OpenAI-compatible 服务在 404/405 后透明回退。
|
|
return [$difySpec, $openAiSpec];
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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{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);
|
|
}
|
|
|
|
/**
|
|
* @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,
|
|
];
|
|
}
|
|
}
|