Files
zyt/server/app/common/service/DifyTongueScreenshotService.php
2026-11-03 17:20:35 +08:00

157 lines
9.5 KiB
PHP

<?php
declare(strict_types=1);
namespace app\common\service;
/**
* Screenshot-only Dify transport. Reuses the configured assistant app, but never
* enters clinical augmentation, OCR, history or protocol fallback. A rejected
* answer may be regenerated once from the same image within the total deadline.
* The private PHP upload is sent directly to Dify; no public storage is involved.
*/
final class DifyTongueScreenshotService
{
private const MAX_RESPONSE_BYTES = 65536;
private const TIMEOUT_SECONDS = 50;
private const MIN_CORRECTION_SECONDS = 3;
private const CORRECTION_PROMPT = <<<'PROMPT'
本次是唯一一次格式纠正。请重新检查本次同一张原图,严格按上述 JSON 契约重新输出完整结果,不得编造图片特征、症状、脉象或既往史。
limitations 只写光线、清晰度、遮挡等成像限制;缺少问诊、症状、脉诊等临床资料应写入 tcm_diagnosis.missing_information,不要混入舌象观察区。
候选名称及 summary 不得包含正常、健康、无异常等结论,不得确定诊断或给出治疗、药物、剂量。没有足够证型依据时返回 insufficient 和空 candidates,但保留原图中实际可见的 features,不得为了通过校验清空可见观察或编造依据。
仅输出符合完整契约的一个 JSON 对象,不要解释、Markdown 或额外字段。
PROMPT;
public static function analyze(TongueScreenshotImage $image, string $model = 'qwen'): array
{
$config = config('prescription_ai') ?: [];
$settings = self::settings($config, $model);
if (!function_exists('curl_init')) throw new \DomainException('截图分析服务暂不可用');
$deadline = microtime(true) + self::TIMEOUT_SECONDS;
// No patient, diagnosis, admin ID, filename or historical conversation is sent.
$user = 'tongue-screenshot-' . bin2hex(random_bytes(16));
$upload = self::post($settings['base'] . '/files/upload', $settings, [
'user' => $user,
'file' => new \CURLFile($image->path(), 'image/jpeg', 'video-screenshot.jpg'),
], $deadline);
if (!is_string($upload['id'] ?? null)
|| !preg_match('/\A[a-zA-Z0-9_-]{1,128}\z/D', $upload['id'])) {
throw new \DomainException('截图上传分析服务失败,请稍后重试');
}
// An upload failure can never become a text-only success. Both possible
// model calls use this same private image and anonymous request-local user.
$payload = [
'inputs' => (object) [],
'query' => TongueScreenshotAnalysis::prompt(),
'response_mode' => 'blocking',
'user' => $user,
'auto_generate_name' => false,
'files' => [['type' => 'image', 'transfer_method' => 'local_file', 'upload_file_id' => $upload['id']]],
];
$response = self::post($settings['base'] . '/chat-messages', $settings,
json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), $deadline);
$answer = self::answer($response);
try {
$analysis = TongueScreenshotAnalysis::parse($answer);
} catch (\DomainException $error) {
if ($deadline - microtime(true) < self::MIN_CORRECTION_SECONDS) throw $error;
// Only parser rejection permits one correction. Never replay the
// rejected answer, reset the budget, upload again or change models.
$payload['query'] = TongueScreenshotAnalysis::prompt() . "\n" . self::CORRECTION_PROMPT;
$response = self::post($settings['base'] . '/chat-messages', $settings,
json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), $deadline);
$analysis = TongueScreenshotAnalysis::parse(self::answer($response));
}
return ['analysis' => $analysis, 'model' => $model, 'model_label' => $settings['label']];
}
private static function answer(array $response): string
{
if (!is_string($response['answer'] ?? null) || strlen($response['answer']) > 32768) {
throw new \DomainException('截图分析未返回有效结果,请重新截图后重试');
}
return $response['answer'];
}
private static function settings(array $config, string $model): array
{
if (!in_array($model, ['qwen', 'openai'], true)) throw new \DomainException('截图分析模型无效');
if (empty($config['enable'])) throw new \DomainException('AI 截图分析功能未启用');
// The selected profile owns both the image upload and chat. No cross-model fallback.
$profile = $config['models'][$model] ?? [];
$base = rtrim(trim((string) ($config['base_url'] ?? '')), '/');
$key = trim((string) ($profile['api_key'] ?? ''));
$parts = parse_url($base);
if (!is_array($parts) || !in_array(strtolower($parts['scheme'] ?? ''), ['https', 'http'], true)
|| empty($parts['host']) || isset($parts['user']) || isset($parts['pass'])
|| isset($parts['query']) || isset($parts['fragment'])
|| preg_match('/[\x00-\x20\x7f]/', $base) || $key === ''
|| preg_match('/[\x00-\x1f\x7f]/', (string) ($profile['api_key'] ?? ''))
|| str_ends_with(strtolower($parts['path'] ?? ''), '/chat/completions')) {
throw new \DomainException('截图分析服务配置不完整');
}
if (str_ends_with($base, '/chat-messages')) $base = substr($base, 0, -strlen('/chat-messages'));
elseif (!str_ends_with($base, '/v1')) $base .= '/v1';
$caFile = (string) ($config['required_attachment_upload']['ca_file'] ?? '');
if ($caFile !== '' && (!is_file($caFile) || !is_readable($caFile))) {
// Match the established attachment transport on other hosts: an
// optional stale CA path falls back to the system trust store.
// CURLOPT_SSL_VERIFYPEER/VERIFYHOST remain enabled below.
$caFile = '';
}
$label = $model === 'qwen' ? '千问' : 'GPT';
return ['base' => $base, 'key' => $key, 'ca_file' => $caFile, 'label' => $label];
}
/** Exactly one bounded POST; redirects and ambient proxies must not receive credentials/images. */
private static function post(string $url, array $settings, $payload, float $deadline): array
{
$milliseconds = (int) floor(($deadline - microtime(true)) * 1000);
if ($milliseconds < 1) throw new \DomainException('截图分析超时,请稍后重试');
$curl = curl_init();
if ($curl === false) throw new \DomainException('截图分析服务暂不可用');
$body = ''; $tooLarge = false;
try {
$headers = ['Authorization: Bearer ' . $settings['key'], 'Accept: application/json', 'Expect:'];
if (is_string($payload)) $headers[] = 'Content-Type: application/json';
$options = [
CURLOPT_URL => $url, CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload,
CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => false,
CURLOPT_FOLLOWLOCATION => false, CURLOPT_MAXREDIRS => 0, CURLOPT_PROXY => '',
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_TIMEOUT_MS => $milliseconds, CURLOPT_CONNECTTIMEOUT_MS => min(8000, $milliseconds),
CURLOPT_NOSIGNAL => true,
CURLOPT_WRITEFUNCTION => static function ($handle, string $chunk) use (&$body, &$tooLarge): int {
if (strlen($body) + strlen($chunk) > self::MAX_RESPONSE_BYTES) { $tooLarge = true; return 0; }
$body .= $chunk;
return strlen($chunk);
},
];
if ($settings['ca_file'] !== '') $options[CURLOPT_CAINFO] = $settings['ca_file'];
if (!curl_setopt_array($curl, $options)) throw new \DomainException('截图分析服务暂不可用');
$ok = curl_exec($curl);
$status = (int) curl_getinfo($curl, CURLINFO_HTTP_CODE);
if (curl_errno($curl) === CURLE_OPERATION_TIMEDOUT) throw new \DomainException('截图分析超时,请稍后重试');
if ($ok === false || $tooLarge || $status < 200 || $status >= 300) {
// Deliberately discard upstream bodies/errors; they can contain image/model data or secrets.
if ($ok !== false && !$tooLarge && $status === 400) {
$failure = json_decode($body, true);
if (is_array($failure) && ($failure['code'] ?? '') === 'invalid_param'
&& is_string($failure['message'] ?? null)
&& preg_match('/\[models\].{0,200}(?:status code 404|404 page not found)/is', $failure['message'])) {
throw new \DomainException($settings['label'] . ' 模型接口配置异常,请管理员检查 Dify 中的模型 API 地址。');
}
}
throw new \DomainException('截图分析服务暂不可用,请稍后重试');
}
try { $decoded = json_decode($body, true, 24, JSON_THROW_ON_ERROR); }
catch (\Throwable $error) { throw new \DomainException('截图分析服务返回格式无效'); }
if (!is_array($decoded)) throw new \DomainException('截图分析服务返回格式无效');
return $decoded;
} finally {
curl_close($curl);
}
}
}