Files
zyt/server/app/api/logic/tcm/DailyDietAiLogic.php
T
2026-05-29 14:45:56 +08:00

826 lines
30 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\BloodRecord;
use app\common\model\tcm\Diagnosis;
use app\common\service\AiChatService;
use think\facade\Cache;
/**
* 日常护理 - AI 饮食建议(基于霍大夫升糖指数)
*/
class DailyDietAiLogic
{
private const CACHE_TTL = 86400;
/** 单次 AI 请求超时(秒),避免接口长时间挂起 */
private const AI_TIMEOUT_SEC = 12;
/** 流式 AI 请求超时(秒) */
private const AI_STREAM_TIMEOUT_SEC = 18;
/**
* 今日推荐饮食(按诊单+日期缓存)
*
* @return array{ok:bool,error?:string,data?:array}
*/
public static function getDailyRecommend(int $diagnosisId, bool $refresh = false): array
{
if ($diagnosisId <= 0) {
return ['ok' => false, 'error' => '缺少就诊卡'];
}
$context = self::buildPatientContext($diagnosisId);
if (!$context['ok']) {
return ['ok' => false, 'error' => $context['error'] ?? '获取档案失败'];
}
$analysis = self::buildAnalysisPayload($context['data']);
$exercise = self::buildExercisePayload($context['data']);
$cacheKey = self::recommendCacheKey($diagnosisId);
if (!$refresh) {
$cached = Cache::get($cacheKey);
if (is_array($cached) && !empty($cached['breakfast'])) {
$cached['cached'] = true;
$cached['analysis'] = $analysis;
$cached['exercise'] = $exercise;
return ['ok' => true, 'data' => $cached];
}
}
$data = null;
if (AiChatService::isEnabled()) {
$raw = self::aiDailyRecommend($context['data'], $refresh);
if (is_array($raw) && !empty($raw['breakfast'])) {
$data = self::finalizeAiRecommendPlan($raw);
}
}
if (!$data) {
$tplIdx = self::resolveFallbackTemplateIndex($diagnosisId, $refresh);
$data = GiKnowledge::fallbackDailyMeals($diagnosisId, $tplIdx);
$data['disclaimer'] = '按升糖指数与农家饭硬性规则推荐(午餐≥2样蛋白、淀粉不重复)。';
}
$data['date'] = date('Y-m-d');
$data['cached'] = false;
Cache::set($cacheKey, $data, self::CACHE_TTL);
$data['analysis'] = $analysis;
$data['exercise'] = $exercise;
return ['ok' => true, 'data' => $data];
}
/**
* 询问某食物能不能吃
*
* @return array{ok:bool,error?:string,data?:array}
*/
public static function askFood(int $diagnosisId, string $question): array
{
$question = trim($question);
if ($question === '') {
return ['ok' => false, 'error' => '请输入想咨询的食物'];
}
if (mb_strlen($question) > 80) {
return ['ok' => false, 'error' => '问题过长,请简短描述'];
}
$cacheKey = 'daily_diet_ai_ask:' . md5($diagnosisId . ':' . mb_strtolower($question));
$cached = Cache::get($cacheKey);
if (is_array($cached) && !empty($cached['advice'])) {
$cached['cached'] = true;
return ['ok' => true, 'data' => $cached];
}
$context = self::buildPatientContext($diagnosisId);
$data = null;
if (AiChatService::isEnabled()) {
$data = self::aiAskFood($context['data'] ?? [], $question);
}
if (!$data) {
$data = GiKnowledge::fallbackAsk($question);
}
$data['question'] = $question;
$data['disclaimer'] = '仅供参考,不能替代医嘱。';
$data['cached'] = false;
Cache::set($cacheKey, $data, 3600);
return ['ok' => true, 'data' => $data];
}
/**
* 流式:今日饮食推荐(SSE delta + done
*
* @param callable(string, array<string, mixed>):void $emit event: delta|done|error
*/
public static function streamDailyRecommend(int $diagnosisId, bool $refresh, callable $emit): void
{
if ($diagnosisId <= 0) {
$emit('error', ['message' => '缺少就诊卡']);
return;
}
$context = self::buildPatientContext($diagnosisId);
if (!$context['ok']) {
$emit('error', ['message' => $context['error'] ?? '获取档案失败']);
return;
}
$analysis = self::buildAnalysisPayload($context['data']);
$exercise = self::buildExercisePayload($context['data']);
// 先把「分析依据」「运动建议」推给前端,提升首屏感知
$emit('analysis', ['analysis' => $analysis]);
$emit('exercise', ['exercise' => $exercise]);
$cacheKey = self::recommendCacheKey($diagnosisId);
if (!$refresh) {
$cached = Cache::get($cacheKey);
if (is_array($cached) && !empty($cached['breakfast'])) {
$cached['cached'] = true;
$cached['analysis'] = $analysis;
$cached['exercise'] = $exercise;
$emit('done', $cached);
return;
}
}
$data = null;
$fullText = '';
if (AiChatService::isEnabled()) {
[$system, $user] = self::buildRecommendStreamPrompts($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.78 : 0.35,
'max_tokens' => 400,
'timeout' => self::AI_STREAM_TIMEOUT_SEC,
]);
if ($streamRes['ok'] && $fullText !== '') {
$parsed = self::parseRecommendPlainText($fullText);
if ($parsed && !empty($parsed['breakfast'])) {
$data = self::finalizeAiRecommendPlan($parsed);
}
}
}
if (!$data) {
$tplIdx = self::resolveFallbackTemplateIndex($diagnosisId, $refresh);
$data = GiKnowledge::fallbackDailyMeals($diagnosisId, $tplIdx);
$data['source'] = 'rule';
$data['disclaimer'] = '按升糖指数与农家饭硬性规则推荐(午餐≥2样蛋白、淀粉不重复)。';
}
$data['date'] = date('Y-m-d');
$data['cached'] = false;
Cache::set($cacheKey, $data, self::CACHE_TTL);
$data['analysis'] = $analysis;
$data['exercise'] = $exercise;
$emit('done', $data);
}
/**
* 流式:能不能吃(SSE delta + done
*
* @param callable(string, array<string, mixed>):void $emit event: delta|done|error
*/
public static function streamAskFood(int $diagnosisId, string $question, callable $emit): void
{
$question = trim($question);
if ($question === '') {
$emit('error', ['message' => '请输入想咨询的食物']);
return;
}
if (mb_strlen($question) > 80) {
$emit('error', ['message' => '问题过长,请简短描述']);
return;
}
$cacheKey = 'daily_diet_ai_ask:' . md5($diagnosisId . ':' . mb_strtolower($question));
$cached = Cache::get($cacheKey);
if (is_array($cached) && !empty($cached['advice'])) {
$cached['cached'] = true;
$emit('done', $cached);
return;
}
$context = self::buildPatientContext($diagnosisId);
$data = null;
$fullText = '';
if (AiChatService::isEnabled()) {
[$system, $user] = self::buildAskStreamPrompts($context['data'] ?? [], $question);
$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, 'advice' => $fullText]);
}, [
'temperature' => 0.3,
'max_tokens' => 200,
'timeout' => self::AI_STREAM_TIMEOUT_SEC,
]);
if ($streamRes['ok'] && trim($fullText) !== '') {
$data = self::finalizeAskFromText($context['data'] ?? [], $question, trim($fullText));
}
}
if (!$data) {
$data = GiKnowledge::fallbackAsk($question);
}
$data['question'] = $question;
$data['disclaimer'] = '仅供参考,不能替代医嘱。';
$data['cached'] = false;
Cache::set($cacheKey, $data, 3600);
$emit('done', $data);
}
private static function recommendCacheKey(int $diagnosisId): string
{
return 'daily_diet_ai_rec_v2:' . $diagnosisId . ':' . date('Y-m-d');
}
/** 换一换时轮换规则模板下标(与 GiKnowledge::$mealTemplates 数量一致) */
private static function resolveFallbackTemplateIndex(int $diagnosisId, bool $refresh): int
{
$count = 6;
$cacheKey = 'daily_diet_ai_tpl_idx:' . $diagnosisId . ':' . date('Y-m-d');
$defaultIdx = abs(crc32(date('Y-m-d') . ':' . $diagnosisId)) % $count;
if (!$refresh) {
Cache::set($cacheKey, $defaultIdx, self::CACHE_TTL);
return $defaultIdx;
}
$last = Cache::get($cacheKey);
if ($last === null || $last === false) {
$last = $defaultIdx;
}
$idx = (((int) $last) + 1) % $count;
Cache::set($cacheKey, $idx, self::CACHE_TTL);
return $idx;
}
/**
* @return array{ok:bool,error?:string,data?:array}
*/
private static function buildPatientContext(int $diagnosisId): array
{
$diagnosis = Diagnosis::where('id', $diagnosisId)->where('delete_time', null)->find();
if (!$diagnosis) {
return ['ok' => false, 'error' => '就诊卡不存在'];
}
$row = $diagnosis->toArray();
$gender = (int) ($row['gender'] ?? 0);
$genderText = $gender === 1 ? '男' : ($gender === 0 ? '女' : '未知');
$age = (int) ($row['age'] ?? 0);
// 拉取近 30 天血糖(涵盖近 7 天),统一计算 7/30 天统计
$since30 = strtotime('-30 days 00:00:00');
$bloodList = BloodRecord::where('diagnosis_id', $diagnosisId)
->where('record_date', '>=', $since30)
->where('delete_time', null)
->order('record_date', 'desc')
->field('record_date,fasting_blood_sugar,postprandial_blood_sugar,other_blood_sugar')
->select()
->toArray();
$since7 = strtotime('-7 days 00:00:00');
$list7 = array_values(array_filter($bloodList, static function ($b) use ($since7) {
return (int) ($b['record_date'] ?? 0) >= $since7;
}));
// 近 7 天逐日明细(喂给 AI 的细节)
$bloodSummary = [];
foreach (array_slice($list7, 0, 7) as $b) {
$date = !empty($b['record_date']) ? date('Y-m-d', (int) $b['record_date']) : '';
$parts = [];
if ($b['fasting_blood_sugar'] !== null && $b['fasting_blood_sugar'] !== '') {
$parts[] = '空腹' . $b['fasting_blood_sugar'];
}
if ($b['postprandial_blood_sugar'] !== null && $b['postprandial_blood_sugar'] !== '') {
$parts[] = '餐后' . $b['postprandial_blood_sugar'];
}
if ($b['other_blood_sugar'] !== null && $b['other_blood_sugar'] !== '') {
$parts[] = '其他' . $b['other_blood_sugar'];
}
if ($date && $parts) {
$bloodSummary[] = $date . '' . implode('', $parts) . ' mmol/L';
}
}
$stats7 = self::aggregateBloodStats($list7, $age, '近7天');
$stats30 = self::aggregateBloodStats($bloodList, $age, '近30天');
return [
'ok' => true,
'data' => [
'patient_name' => (string) ($row['patient_name'] ?? ''),
'age' => $age,
'gender_text' => $genderText,
'blood_recent' => $bloodSummary,
'stats_7d' => $stats7,
'stats_30d' => $stats30,
'today' => date('Y-m-d'),
],
];
}
/**
* 根据年龄返回血糖偏高阈值(与前端 getBloodSugarThresholds 保持一致)
*
* @return array{fasting:float,postprandial:float}|null
*/
private static function bloodThresholds(int $age): array
{
// 年龄未知时用通用糖尿病控制目标兜底,避免「无阈值=全部达标」
if ($age <= 0) {
return ['fasting' => 7.0, 'postprandial' => 10.0];
}
return $age < 50
? ['fasting' => 7.0, 'postprandial' => 9.0]
: ['fasting' => 8.0, 'postprandial' => 10.0];
}
/**
* 汇总一段时间内的血糖统计:有记录天数、偏高天数、达标率、均值
*
* @param array<int,array<string,mixed>> $rows
* @return array<string,mixed>
*/
private static function aggregateBloodStats(array $rows, int $age, string $label): array
{
$thresholds = self::bloodThresholds($age);
$byDate = [];
$fastingSum = 0.0;
$fastingCnt = 0;
$postSum = 0.0;
$postCnt = 0;
foreach ($rows as $b) {
$ts = (int) ($b['record_date'] ?? 0);
if ($ts <= 0) {
continue;
}
$date = date('Y-m-d', $ts);
$fasting = ($b['fasting_blood_sugar'] !== null && $b['fasting_blood_sugar'] !== '') ? (float) $b['fasting_blood_sugar'] : null;
$post = ($b['postprandial_blood_sugar'] !== null && $b['postprandial_blood_sugar'] !== '') ? (float) $b['postprandial_blood_sugar'] : null;
if ($fasting === null && $post === null) {
continue;
}
if (!isset($byDate[$date])) {
$byDate[$date] = ['high' => false];
}
if ($fasting !== null) {
$fastingSum += $fasting;
$fastingCnt++;
if ($thresholds && $fasting >= $thresholds['fasting']) {
$byDate[$date]['high'] = true;
}
}
if ($post !== null) {
$postSum += $post;
$postCnt++;
if ($thresholds && $post >= $thresholds['postprandial']) {
$byDate[$date]['high'] = true;
}
}
}
$recordDays = count($byDate);
$highDays = 0;
foreach ($byDate as $d) {
if ($d['high']) {
$highDays++;
}
}
$normalDays = max(0, $recordDays - $highDays);
$compliance = $recordDays > 0 ? (int) round($normalDays / $recordDays * 100) : 0;
return [
'label' => $label,
'record_days' => $recordDays,
'high_days' => $highDays,
'normal_days' => $normalDays,
'compliance' => $compliance,
'fasting_avg' => $fastingCnt > 0 ? round($fastingSum / $fastingCnt, 1) : null,
'postprandial_avg' => $postCnt > 0 ? round($postSum / $postCnt, 1) : null,
];
}
/**
* 把统计数据拼成喂给 AI 的一行人类可读文本
*
* @param array<string,mixed> $stats
*/
private static function statsToLine(array $stats): string
{
if (($stats['record_days'] ?? 0) <= 0) {
return $stats['label'] . '无血糖记录';
}
$segs = [
$stats['label'] . "有记录{$stats['record_days']}天",
"偏高{$stats['high_days']}天",
"达标率{$stats['compliance']}%",
];
if ($stats['fasting_avg'] !== null) {
$segs[] = "空腹均值{$stats['fasting_avg']}";
}
if ($stats['postprandial_avg'] !== null) {
$segs[] = "餐后均值{$stats['postprandial_avg']}";
}
return implode('', $segs);
}
/**
* 给前端展示的「分析依据」结构(近7天 + 近30天)
*
* @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>
*/
private static function buildExercisePayload(array $context): array
{
return GiKnowledge::exercisePlan(
is_array($context['stats_7d'] ?? null) ? $context['stats_7d'] : [],
is_array($context['stats_30d'] ?? null) ? $context['stats_30d'] : []
);
}
/**
* @return array{0:string,1:string}
*/
private static function buildRecommendPrompts(array $context, bool $refresh = false): array
{
$bloodLine = empty($context['blood_recent'])
? '近7日无血糖记录'
: implode('', $context['blood_recent']);
$stat7Line = self::statsToLine($context['stats_7d'] ?? ['label' => '近7天', 'record_days' => 0]);
$stat30Line = self::statsToLine($context['stats_30d'] ?? ['label' => '近30天', 'record_days' => 0]);
$system = <<<SYS
你是村里懂糖尿病饮食的大夫助手,依据「霍大夫升糖指数(GI)」给农村老人推荐一日三餐。输出必须严格可执行。
硬性规则(违反即不合格):
1. 午餐至少 2 样蛋白质(鱼/鸡鸭/瘦肉/蛋/豆腐/豆干),且午餐蛋白种类数 > 早餐 > 晚餐。
2. 淀粉定义:粥/饭/面/饼/窝头/薯/南瓜。每餐最多 1 种淀粉;早中晚 3 种淀粉必须互不相同。
3. 严禁:白馒头、油条、粘豆包、糯米饭、西瓜、含糖饮料、白稀饭(杂面窝头、玉米碴粥除外)。
4. 低 GI 优先;农村家常;禁止轻食/沙拉/藜麦等词。
5. lunch 字段写法:蛋白菜放前面,淀粉(如有)放最后且注明「小半碗/一个」。
6. 只输出 JSON
{"breakfast":"...","lunch":"...","dinner":"...","tips":"...","avoid":["...","..."]}
SYS;
$user = sprintf(
"患者:%s%s%s岁。日期:%s。\n近7日逐日血糖:%s。\n血糖统计:%s%s。\n(若近期偏高天数多或达标率低,请收紧主食与高GI食物,多安排蛋白与绿叶菜;若控制平稳可正常推荐。)\n\nGI知识:\n%s\n\n农家饭参考:\n%s\n\n请输出严格符合硬性规则的三餐 JSON。",
$context['patient_name'] ?: '大爷大妈',
$context['gender_text'] ?? '',
$context['age'] ?? 0,
$context['today'] ?? date('Y-m-d'),
$bloodLine,
$stat7Line,
$stat30Line,
GiKnowledge::summaryText(),
GiKnowledge::ruralMealHints()
);
if ($refresh) {
$user .= "\n\n用户点了「换一换」,请给一套完全不同的农家三餐,编号" . substr(md5((string) microtime(true)), 0, 8) . '。';
}
return [$system, $user];
}
/**
* 流式三餐推荐:纯文本格式,便于逐字输出
*
* @return array{0:string,1:string}
*/
private static function buildRecommendStreamPrompts(array $context, bool $refresh = false): array
{
$bloodLine = empty($context['blood_recent'])
? '近7日无血糖记录'
: implode('', $context['blood_recent']);
$stat7Line = self::statsToLine($context['stats_7d'] ?? ['label' => '近7天', 'record_days' => 0]);
$stat30Line = self::statsToLine($context['stats_30d'] ?? ['label' => '近30天', 'record_days' => 0]);
$system = <<<SYS
你是村里懂糖尿病饮食的大夫助手,依据「霍大夫升糖指数(GI)」给农村老人推荐一日三餐。
硬性规则:
1. 午餐至少 2 样蛋白质(鱼/鸡鸭/瘦肉/蛋/豆腐/豆干),且午餐蛋白 > 早餐 > 晚餐。
2. 淀粉(粥/饭/面/饼/窝头/薯/南瓜)每餐最多 1 种,早中晚互不相同。
3. 严禁白馒头、油条、粘豆包、糯米饭、西瓜、含糖饮料。
4. 农村家常饭,禁止轻食/沙拉/藜麦。
严格按下面 5 行格式输出,不要 JSON、不要 markdown、不要多余解释:
早餐:(具体食物)
午餐:(具体食物,蛋白放前)
晚餐:(具体食物)
提示:(一句土话提醒)
少碰:(用顿号隔开,如:白馒头、油条)
SYS;
$user = sprintf(
"患者:%s%s%s岁。日期:%s。\n近7日逐日血糖:%s。\n血糖统计:%s%s。\n(偏高多或达标率低则收紧主食、多蛋白绿叶菜;平稳则正常推荐。)\n\nGI摘要:\n%s",
$context['patient_name'] ?: '大爷大妈',
$context['gender_text'] ?? '',
$context['age'] ?? 0,
$context['today'] ?? date('Y-m-d'),
$bloodLine,
$stat7Line,
$stat30Line,
mb_substr(GiKnowledge::summaryText(), 0, 320)
);
if ($refresh) {
$user .= "\n\n用户点了「换一换」,请重新给一套完全不同的农家三餐,编号" . substr(md5((string) microtime(true)), 0, 8) . ',别跟常见模板重复。';
}
return [$system, $user];
}
/**
* 解析流式三餐纯文本(兼容 JSON 回退)
*
* @return array<string, mixed>|null
*/
private static function parseRecommendPlainText(string $text): ?array
{
$text = trim($text);
if ($text === '') {
return null;
}
if ($text[0] === '{') {
$json = self::parseJsonObject($text);
if (is_array($json)) {
return $json;
}
}
$map = [
'breakfast' => null,
'lunch' => null,
'dinner' => null,
'tips' => null,
'avoid' => null,
];
if (preg_match('/早餐[:]\s*(.+)$/mu', $text, $m)) {
$map['breakfast'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
}
if (preg_match('/午餐[:]\s*(.+)$/mu', $text, $m)) {
$map['lunch'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
}
if (preg_match('/晚餐[:]\s*(.+)$/mu', $text, $m)) {
$map['dinner'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
}
if (preg_match('/提示[:]\s*(.+)$/mu', $text, $m)) {
$map['tips'] = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
}
if (preg_match('/少碰[:]\s*(.+)$/mu', $text, $m)) {
$avoidLine = trim(preg_split('/\R/u', $m[1])[0] ?? $m[1]);
$parts = preg_split('/[、,;\s]+/u', $avoidLine) ?: [];
$map['avoid'] = array_values(array_filter(array_map('trim', $parts)));
}
if (empty($map['breakfast'])) {
return null;
}
$out = [
'breakfast' => (string) $map['breakfast'],
'lunch' => (string) ($map['lunch'] ?? ''),
'dinner' => (string) ($map['dinner'] ?? ''),
'tips' => (string) ($map['tips'] ?? ''),
'avoid' => is_array($map['avoid']) ? $map['avoid'] : [],
];
return $out;
}
/**
* 规范化 AI 三餐方案;校验未过也保留 AI 文案(避免流式展示后被规则模板覆盖)
*
* @param array<string, mixed> $parsed
* @return array<string, mixed>
*/
private static function finalizeAiRecommendPlan(array $parsed): array
{
$checked = DietMealValidator::validateAndNormalize($parsed);
$data = $checked['plan'];
$data['source'] = 'ai';
$data['rules_summary'] = DietMealValidator::metaSummary($checked['meta']);
if ($checked['ok']) {
$data['disclaimer'] = 'AI 建议已通过规则校验,仍请结合医嘱与血糖监测。';
} else {
$data['validation_warnings'] = $checked['errors'];
$data['disclaimer'] = 'AI 建议供参考,仍请结合医嘱与血糖监测。';
}
return $data;
}
/**
* @return array{0:string,1:string}
*/
private static function buildAskStreamPrompts(array $context, string $question): array
{
$food = GiKnowledge::extractFoodName($question);
$hit = GiKnowledge::lookupFood($food);
$kbHint = $hit
? "知识库命中:{$hit['food']}{$hit['level_label']}{$hit['category']}"
: '知识库未命中,按 GI 原则用农村常识回答';
$system = <<<SYS
你是村里糖尿病饮食顾问,用升糖指数(GI)回答农村老人「能不能吃某东西」。
用一两句土话直接回答,40字以内,先说能不能吃、再说咋吃/吃多少。不要 JSON、不要 markdown、不要列表。
SYS;
$user = sprintf(
"老人%s%s。问:%s\n%s\n\nGI摘要:\n%s",
$context['patient_name'] ?? '',
$context['gender_text'] ?? '',
$question,
$kbHint,
GiKnowledge::summaryText()
);
return [$system, $user];
}
/**
* @return array<string, mixed>
*/
private static function finalizeAskFromText(array $context, string $question, string $text): array
{
$food = GiKnowledge::extractFoodName($question);
$hit = GiKnowledge::lookupFood($food);
$data = [
'food' => $food,
'advice' => $text,
'source' => 'ai',
];
if ($hit) {
$data['food'] = $hit['food'];
$data['level'] = $hit['level'];
$data['level_label'] = $hit['level_label'];
$data['portion'] = $hit['level'] === GiKnowledge::LEVEL_HIGH
? '尽量别吃'
: ($hit['level'] === GiKnowledge::LEVEL_MEDIUM ? '少量' : '适量');
} else {
$data['level'] = GiKnowledge::LEVEL_MEDIUM;
$data['level_label'] = '中升糖';
$data['portion'] = '少量';
}
return $data;
}
private static function aiDailyRecommend(array $context, bool $refresh = false): ?array
{
[$system, $user] = self::buildRecommendPrompts($context, $refresh);
$res = AiChatService::chat([
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $user],
], [
'temperature' => $refresh ? 0.78 : 0.35,
'max_tokens' => 900,
'timeout' => self::AI_TIMEOUT_SEC,
'response_format' => ['type' => 'json_object'],
]);
if (!$res['ok']) {
return null;
}
$parsed = self::parseJsonObject((string) $res['content']);
if (!$parsed || empty($parsed['breakfast'])) {
return null;
}
$parsed['avoid'] = is_array($parsed['avoid'] ?? null) ? $parsed['avoid'] : [];
return $parsed;
}
/**
* @param array $context
* @return array|null
*/
private static function aiAskFood(array $context, string $question): ?array
{
$food = GiKnowledge::extractFoodName($question);
$hit = GiKnowledge::lookupFood($food);
$system = <<<SYS
你是村里糖尿病饮食顾问,用升糖指数(GI)回答农村老人「能不能吃某东西」。
只输出 JSON{"food":"食物名","level":"low|medium|high","level_label":"低升糖|中升糖|高升糖","advice":"40字内土话建议","portion":"咋吃、吃多少"}
levellow≤55medium 56-69high≥70。advice 要口语化,别文绉绉。
SYS;
$kbHint = $hit
? "知识库命中:{$hit['food']}{$hit['level_label']}{$hit['category']}"
: '知识库未命中,按 GI 原则用农村常识回答';
$user = sprintf(
"老人%s%s。问:%s\n%s\n\nGI摘要:\n%s\n\n农家饭原则:\n%s",
$context['patient_name'] ?? '',
$context['gender_text'] ?? '',
$question,
$kbHint,
GiKnowledge::summaryText(),
GiKnowledge::ruralMealHints()
);
$res = AiChatService::chat([
['role' => 'system', 'content' => $system],
['role' => 'user', 'content' => $user],
], [
'temperature' => 0.3,
'max_tokens' => 400,
'timeout' => self::AI_TIMEOUT_SEC,
'response_format' => ['type' => 'json_object'],
]);
if (!$res['ok']) {
return null;
}
$parsed = self::parseJsonObject((string) $res['content']);
if (!$parsed || empty($parsed['advice'])) {
return null;
}
if ($hit) {
$parsed['food'] = $hit['food'];
$parsed['level'] = $hit['level'];
$parsed['level_label'] = $hit['level_label'];
if (empty($parsed['portion'])) {
$parsed['portion'] = $hit['level'] === GiKnowledge::LEVEL_HIGH ? '尽量别吃' : ($hit['level'] === GiKnowledge::LEVEL_MEDIUM ? '少量' : '适量');
}
}
$parsed['source'] = 'ai';
if (empty($parsed['food'])) {
$parsed['food'] = $food;
}
return $parsed;
}
/**
* @return array<string, mixed>|null
*/
private static function parseJsonObject(string $content): ?array
{
$content = trim($content);
if ($content === '') {
return null;
}
if ($content[0] !== '{') {
if (preg_match('/\{[\s\S]*\}/', $content, $m)) {
$content = $m[0];
}
}
$decoded = json_decode($content, true);
return is_array($decoded) ? $decoded : null;
}
}