859 lines
32 KiB
PHP
859 lines
32 KiB
PHP
<?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. 早餐必须包含「驼奶粉」(温水冲服,适量),可与粥、蛋、菜并列写。
|
||
7. drinks 单独写全天喝什么:温开水为主;可写驼奶粉冲饮时间;严禁含糖饮料、果汁、酒。
|
||
8. 只输出 JSON:
|
||
{"breakfast":"...","drinks":"...","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. 早餐必须含驼奶粉(温水冲服);喝的单独一行。
|
||
|
||
严格按下面 6 行格式输出,不要 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,
|
||
'drinks' => 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['drinks'] = 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'],
|
||
'drinks' => (string) ($map['drinks'] ?? ''),
|
||
'lunch' => (string) ($map['lunch'] ?? ''),
|
||
'dinner' => (string) ($map['dinner'] ?? ''),
|
||
'tips' => (string) ($map['tips'] ?? ''),
|
||
'avoid' => is_array($map['avoid']) ? $map['avoid'] : [],
|
||
];
|
||
|
||
return $out;
|
||
}
|
||
|
||
/**
|
||
* 产品要求:早餐含驼奶粉,并单独给出「喝的」
|
||
*
|
||
* @param array<string, mixed> $plan
|
||
* @return array<string, mixed>
|
||
*/
|
||
private static function applyDietProductDefaults(array $plan): array
|
||
{
|
||
$breakfast = trim((string) ($plan['breakfast'] ?? ''));
|
||
if ($breakfast !== '' && mb_strpos($breakfast, '驼奶粉') === false && mb_strpos($breakfast, '驼奶') === false) {
|
||
$plan['breakfast'] = '驼奶粉(温水冲服)、' . $breakfast;
|
||
}
|
||
|
||
$drinks = trim((string) ($plan['drinks'] ?? ''));
|
||
if ($drinks === '') {
|
||
$plan['drinks'] = '白天多喝温开水;早餐按量冲驼奶粉,别喝甜饮料、果汁。';
|
||
} elseif (mb_strpos($drinks, '驼奶') === false) {
|
||
$plan['drinks'] = $drinks . ';早餐已安排驼奶粉';
|
||
}
|
||
|
||
return $plan;
|
||
}
|
||
|
||
/**
|
||
* 规范化 AI 三餐方案;校验未过也保留 AI 文案(避免流式展示后被规则模板覆盖)
|
||
*
|
||
* @param array<string, mixed> $parsed
|
||
* @return array<string, mixed>
|
||
*/
|
||
private static function finalizeAiRecommendPlan(array $parsed): array
|
||
{
|
||
$parsed = self::applyDietProductDefaults($parsed);
|
||
$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":"咋吃、吃多少"}
|
||
level:low≤55,medium 56-69,high≥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;
|
||
}
|
||
}
|