更新
This commit is contained in:
@@ -0,0 +1,442 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user