Files
zyt/server/app/api/logic/tcm/DailyBloodCareAiLogic.php
T
2026-06-03 14:42:48 +08:00

443 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace app\api\logic\tcm;
use app\common\model\tcm\Diagnosis;
use app\common\service\AiChatService;
use think\facade\Cache;
/**
* 日常护理 - 血糖照护 AI(累计 7 天有记录后:血糖建议 + 中医调理方向)
*/
class DailyBloodCareAiLogic
{
private const CACHE_TTL = 86400;
private const MIN_RECORD_DAYS = 7;
private const AI_TIMEOUT_SEC = 14;
private const AI_STREAM_TIMEOUT_SEC = 22;
/**
* @return array{ok:bool,error?:string,data?:array}
*/
public static function getDailyCarePlan(int $diagnosisId, bool $refresh = false): array
{
if ($diagnosisId <= 0) {
return ['ok' => false, 'error' => '缺少就诊卡'];
}
$context = self::buildCareContext($diagnosisId);
if (!$context['ok']) {
return ['ok' => false, 'error' => $context['error'] ?? '获取档案失败'];
}
$gate = self::ensureEnoughBloodDays($context['data']);
if (!$gate['ok']) {
return ['ok' => false, 'error' => $gate['error']];
}
$analysis = self::buildAnalysisPayload($context['data']);
$cacheKey = self::careCacheKey($diagnosisId);
if (!$refresh) {
$cached = Cache::get($cacheKey);
if (is_array($cached) && !empty($cached['summary'])) {
$cached['cached'] = true;
$cached['analysis'] = $analysis;
return ['ok' => true, 'data' => $cached];
}
}
$data = null;
if (AiChatService::isEnabled()) {
$raw = self::aiDailyCarePlan($context['data'], $refresh);
if (is_array($raw) && !empty($raw['summary'])) {
$data = self::finalizeCarePlan($raw);
}
}
if (!$data) {
$data = GiKnowledge::fallbackBloodCarePlan(
$context['data']['stats_7d'] ?? [],
$context['data']['stats_30d'] ?? [],
$context['data']
);
}
$data['date'] = date('Y-m-d');
$data['cached'] = false;
$data['analysis'] = $analysis;
Cache::set($cacheKey, $data, self::CACHE_TTL);
return ['ok' => true, 'data' => $data];
}
/**
* @param callable(string, array<string, mixed>):void $emit
*/
public static function streamDailyCarePlan(int $diagnosisId, bool $refresh, callable $emit): void
{
if ($diagnosisId <= 0) {
$emit('error', ['message' => '缺少就诊卡']);
return;
}
$context = self::buildCareContext($diagnosisId);
if (!$context['ok']) {
$emit('error', ['message' => $context['error'] ?? '获取档案失败']);
return;
}
$gate = self::ensureEnoughBloodDays($context['data']);
if (!$gate['ok']) {
$emit('error', ['message' => $gate['error']]);
return;
}
$analysis = self::buildAnalysisPayload($context['data']);
$emit('analysis', ['analysis' => $analysis]);
$cacheKey = self::careCacheKey($diagnosisId);
if (!$refresh) {
$cached = Cache::get($cacheKey);
if (is_array($cached) && !empty($cached['summary'])) {
$cached['cached'] = true;
$cached['analysis'] = $analysis;
$emit('done', $cached);
return;
}
}
$data = null;
$fullText = '';
if (AiChatService::isEnabled()) {
[$system, $user] = self::buildCareStreamPrompts($context['data'], $refresh);
$streamRes = AiChatService::streamChat([
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $user],
], static function (string $delta) use (&$fullText, $emit): void {
$fullText .= $delta;
$emit('delta', ['text' => $delta, 'buffer' => $fullText]);
}, [
'temperature' => $refresh ? 0.75 : 0.4,
'max_tokens' => 720,
'timeout' => self::AI_STREAM_TIMEOUT_SEC,
]);
if ($streamRes['ok'] && trim($fullText) !== '') {
$parsed = self::parseCarePlainText(trim($fullText));
if ($parsed && !empty($parsed['summary'])) {
$data = self::finalizeCarePlan($parsed);
}
}
}
if (!$data) {
$data = GiKnowledge::fallbackBloodCarePlan(
$context['data']['stats_7d'] ?? [],
$context['data']['stats_30d'] ?? [],
$context['data']
);
}
$data['date'] = date('Y-m-d');
$data['cached'] = false;
$data['analysis'] = $analysis;
Cache::set($cacheKey, $data, self::CACHE_TTL);
$emit('done', $data);
}
private static function careCacheKey(int $diagnosisId): string
{
return 'daily_blood_care_ai_v1:' . $diagnosisId . ':' . date('Y-m-d');
}
/**
* @return array{ok:bool,error?:string,data?:array}
*/
private static function buildCareContext(int $diagnosisId): array
{
$base = DailyDietAiLogic::getPatientContext($diagnosisId);
if (!$base['ok']) {
return $base;
}
$diagnosis = Diagnosis::where('id', $diagnosisId)->where('delete_time', null)->find();
if ($diagnosis) {
$row = $diagnosis->toArray();
$base['data']['syndrome_type'] = trim((string) ($row['syndrome_type'] ?? ''));
$base['data']['symptoms'] = trim((string) ($row['symptoms'] ?? ''));
$base['data']['remark'] = trim((string) ($row['remark'] ?? ''));
}
return $base;
}
/**
* @param array<string,mixed> $context
* @return array{ok:bool,error?:string}
*/
private static function ensureEnoughBloodDays(array $context): array
{
$days30 = (int) (($context['stats_30d']['record_days'] ?? 0));
$days7 = (int) (($context['stats_7d']['record_days'] ?? 0));
$total = max($days30, $days7);
if ($total < self::MIN_RECORD_DAYS) {
return ['ok' => false, 'error' => '累计记录满7天后可生成照护建议'];
}
return ['ok' => true];
}
/**
* @param array<string,mixed> $context
* @return array<int,array<string,mixed>>
*/
private static function buildAnalysisPayload(array $context): array
{
$out = [];
foreach (['stats_7d', 'stats_30d'] as $key) {
$s = $context[$key] ?? null;
if (is_array($s)) {
$out[] = $s;
}
}
return $out;
}
/**
* @param array<string,mixed> $context
* @return array<string,mixed>|null
*/
private static function aiDailyCarePlan(array $context, bool $refresh): ?array
{
[$system, $user] = self::buildCareJsonPrompts($context, $refresh);
$res = AiChatService::chat([
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $user],
], [
'temperature' => $refresh ? 0.7 : 0.35,
'max_tokens' => 650,
'timeout' => self::AI_TIMEOUT_SEC,
'response_format' => ['type' => 'json_object'],
]);
if (empty($res['ok']) || empty($res['content'])) {
return null;
}
$json = self::parseJsonObject((string) $res['content']);
return is_array($json) ? $json : null;
}
/**
* @param array<string,mixed> $context
* @return array{0:string,1:string}
*/
private static function buildCareJsonPrompts(array $context, bool $refresh): array
{
$bloodLine = self::bloodLine($context);
$stat7Line = self::statsLine($context['stats_7d'] ?? []);
$stat30Line = self::statsLine($context['stats_30d'] ?? []);
$syndrome = trim((string) ($context['syndrome_type'] ?? ''));
$symptoms = trim((string) ($context['symptoms'] ?? ''));
$system = <<<SYS
你是基层中医糖尿病照护助手,面向农村中老年患者,用通俗中文给「血糖管理 + 中医调理方向」建议。
硬性要求:
1. 不得开具具体中药处方名、剂量、加减;不得指导自行增减西药/胰岛素。
2. 中医部分写调理思路、食疗方向、穴位艾灸注意事项,强调需面诊脉诊后由医师定方。
3. 结合提供的血糖统计与逐日记录,给出可执行的监测与生活方式建议。
4. 只输出 JSON
{"summary":"一句话总评","blood_advice":"血糖监测与饮食运动","tcm_plan":"中医辨证与调理方案","watch_points":["留意1","留意2"],"next_steps":"下一步复诊或就医提醒"}
SYS;
$user = sprintf(
"患者:%s,%s,%s岁。证型登记:%s。症状摘要:%s。\n日期:%s。\n近7日血糖:%s。\n统计:%s%s。\n请根据控制好坏给出个性化 JSON。",
$context['patient_name'] ?: '患者',
$context['gender_text'] ?? '',
$context['age'] ?? 0,
$syndrome !== '' ? $syndrome : '未登记',
$symptoms !== '' ? mb_substr($symptoms, 0, 120) : '无',
$context['today'] ?? date('Y-m-d'),
$bloodLine,
$stat7Line,
$stat30Line
);
if ($refresh) {
$user .= "\n\n用户点了「换一换」,请换一套不同表述但同样严谨的建议,编号" . substr(md5((string) microtime(true)), 0, 8) . '。';
}
return [$system, $user];
}
/**
* @param array<string,mixed> $context
* @return array{0:string,1:string}
*/
private static function buildCareStreamPrompts(array $context, bool $refresh): array
{
$bloodLine = self::bloodLine($context);
$stat7Line = self::statsLine($context['stats_7d'] ?? []);
$stat30Line = self::statsLine($context['stats_30d'] ?? []);
$system = <<<SYS
你是基层中医糖尿病照护助手,给农村中老年患者写血糖与中医调理建议。
硬性要求:
1. 不得写具体中药方名剂量、不得指导自行调药。
2. 中医写辨证思路与调理方向,强调面诊后由医师定方。
3. 严格按下面 5 行输出,不要 JSON、不要 markdown
总评:(一句话)
血糖:(监测、饮食、运动,2-4句)
中医:(辨证思路、食疗艾灸方向,2-4句)
留意:(用顿号隔开 2-4 条)
复诊:(下一步)
SYS;
$user = sprintf(
"患者:%s,%s,%s岁。证型:%s。症状:%s。\n近7日血糖:%s。\n%s%s。",
$context['patient_name'] ?: '患者',
$context['gender_text'] ?? '',
$context['age'] ?? 0,
($context['syndrome_type'] ?? '') ?: '未登记',
mb_substr((string) ($context['symptoms'] ?? ''), 0, 80) ?: '无',
$bloodLine,
$stat7Line,
$stat30Line
);
if ($refresh) {
$user .= "\n\n换一换,重新写一套,编号" . substr(md5((string) microtime(true)), 0, 8) . '。';
}
return [$system, $user];
}
/**
* @param array<string,mixed> $context
*/
private static function bloodLine(array $context): string
{
$recent = $context['blood_recent'] ?? [];
if (!is_array($recent) || empty($recent)) {
return '近7日无逐日明细';
}
return implode('', $recent);
}
/**
* @param array<string,mixed> $stats
*/
private static function statsLine(array $stats): string
{
$label = (string) ($stats['label'] ?? '统计');
if (($stats['record_days'] ?? 0) <= 0) {
return $label . '无记录';
}
$parts = [
$label . "有记录{$stats['record_days']}天",
"偏高{$stats['high_days']}天",
"达标率{$stats['compliance']}%",
];
if ($stats['fasting_avg'] !== null) {
$parts[] = "空腹均值{$stats['fasting_avg']}";
}
if ($stats['postprandial_avg'] !== null) {
$parts[] = "餐后均值{$stats['postprandial_avg']}";
}
return implode('', $parts);
}
/**
* @return array<string,mixed>|null
*/
private static function parseCarePlainText(string $text): ?array
{
$text = trim($text);
if ($text === '') {
return null;
}
if ($text[0] === '{') {
$json = self::parseJsonObject($text);
return is_array($json) ? $json : null;
}
$map = [];
if (preg_match('/总评[:]\s*(.+)$/mu', $text, $m)) {
$map['summary'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
}
if (preg_match('/血糖[:]\s*(.+)$/mu', $text, $m)) {
$map['blood_advice'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
}
if (preg_match('/中医[:]\s*(.+)$/mu', $text, $m)) {
$map['tcm_plan'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
}
if (preg_match('/留意[:]\s*(.+)$/mu', $text, $m)) {
$line = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
$parts = preg_split('/[、,;\s]+/u', $line) ?: [];
$map['watch_points'] = array_values(array_filter(array_map('trim', $parts)));
}
if (preg_match('/复诊[:]\s*(.+)$/mu', $text, $m)) {
$map['next_steps'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
}
if (empty($map['summary'])) {
return null;
}
return $map;
}
/**
* @param array<string,mixed> $raw
* @return array<string,mixed>
*/
private static function finalizeCarePlan(array $raw): array
{
$watch = $raw['watch_points'] ?? [];
if (!is_array($watch)) {
$watch = preg_split('/[、,;\n]+/u', (string) $watch) ?: [];
}
$watch = array_values(array_filter(array_map('trim', $watch)));
return [
'summary' => trim((string) ($raw['summary'] ?? '')),
'blood_advice' => trim((string) ($raw['blood_advice'] ?? '')),
'tcm_plan' => trim((string) ($raw['tcm_plan'] ?? '')),
'watch_points' => $watch,
'next_steps' => trim((string) ($raw['next_steps'] ?? '')),
'control_level' => (string) ($raw['control_level'] ?? ''),
'control_label' => (string) ($raw['control_label'] ?? ''),
'disclaimer' => trim((string) ($raw['disclaimer'] ?? 'AI 建议供参考,不能替代医师面诊、开方与调药。')),
'source' => (string) ($raw['source'] ?? 'ai'),
];
}
/**
* @return array<string,mixed>|null
*/
private static function parseJsonObject(string $text): ?array
{
$text = trim($text);
if ($text === '') {
return null;
}
if (preg_match('/\{[\s\S]*\}/u', $text, $m)) {
$text = $m[0];
}
$data = json_decode($text, true);
return is_array($data) ? $data : null;
}
}