更新
This commit is contained in:
@@ -0,0 +1,650 @@
|
||||
<?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' => '缺少就诊卡'];
|
||||
}
|
||||
|
||||
$cacheKey = self::recommendCacheKey($diagnosisId);
|
||||
if (!$refresh) {
|
||||
$cached = Cache::get($cacheKey);
|
||||
if (is_array($cached) && !empty($cached['breakfast'])) {
|
||||
$cached['cached'] = true;
|
||||
return ['ok' => true, 'data' => $cached];
|
||||
}
|
||||
}
|
||||
|
||||
$context = self::buildPatientContext($diagnosisId);
|
||||
if (!$context['ok']) {
|
||||
return ['ok' => false, 'error' => $context['error'] ?? '获取档案失败'];
|
||||
}
|
||||
|
||||
$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);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
$cacheKey = self::recommendCacheKey($diagnosisId);
|
||||
if (!$refresh) {
|
||||
$cached = Cache::get($cacheKey);
|
||||
if (is_array($cached) && !empty($cached['breakfast'])) {
|
||||
$cached['cached'] = true;
|
||||
$emit('done', $cached);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$context = self::buildPatientContext($diagnosisId);
|
||||
if (!$context['ok']) {
|
||||
$emit('error', ['message' => $context['error'] ?? '获取档案失败']);
|
||||
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);
|
||||
$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 ? '女' : '未知');
|
||||
|
||||
$since = strtotime('-7 days 00:00:00');
|
||||
$bloodList = BloodRecord::where('diagnosis_id', $diagnosisId)
|
||||
->where('record_date', '>=', $since)
|
||||
->where('delete_time', null)
|
||||
->order('record_date', 'desc')
|
||||
->limit(7)
|
||||
->field('record_date,fasting_blood_sugar,postprandial_blood_sugar,other_blood_sugar')
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
$bloodSummary = [];
|
||||
foreach ($bloodList 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';
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'data' => [
|
||||
'patient_name' => (string) ($row['patient_name'] ?? ''),
|
||||
'age' => (int) ($row['age'] ?? 0),
|
||||
'gender_text' => $genderText,
|
||||
'blood_recent' => $bloodSummary,
|
||||
'today' => date('Y-m-d'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @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']);
|
||||
|
||||
$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。近7日血糖:%s。\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,
|
||||
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']);
|
||||
|
||||
$system = <<<SYS
|
||||
你是村里懂糖尿病饮食的大夫助手,依据「霍大夫升糖指数(GI)」给农村老人推荐一日三餐。
|
||||
|
||||
硬性规则:
|
||||
1. 午餐至少 2 样蛋白质(鱼/鸡鸭/瘦肉/蛋/豆腐/豆干),且午餐蛋白 > 早餐 > 晚餐。
|
||||
2. 淀粉(粥/饭/面/饼/窝头/薯/南瓜)每餐最多 1 种,早中晚互不相同。
|
||||
3. 严禁白馒头、油条、粘豆包、糯米饭、西瓜、含糖饮料。
|
||||
4. 农村家常饭,禁止轻食/沙拉/藜麦。
|
||||
|
||||
严格按下面 5 行格式输出,不要 JSON、不要 markdown、不要多余解释:
|
||||
早餐:(具体食物)
|
||||
午餐:(具体食物,蛋白放前)
|
||||
晚餐:(具体食物)
|
||||
提示:(一句土话提醒)
|
||||
少碰:(用顿号隔开,如:白馒头、油条)
|
||||
SYS;
|
||||
|
||||
$user = sprintf(
|
||||
"患者:%s,%s,%s岁。日期:%s。近7日血糖:%s。\n\nGI摘要:\n%s",
|
||||
$context['patient_name'] ?: '大爷大妈',
|
||||
$context['gender_text'] ?? '',
|
||||
$context['age'] ?? 0,
|
||||
$context['today'] ?? date('Y-m-d'),
|
||||
$bloodLine,
|
||||
mb_substr(GiKnowledge::summaryText(), 0, 380)
|
||||
);
|
||||
|
||||
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":"咋吃、吃多少"}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\logic\tcm;
|
||||
|
||||
use app\adminapi\logic\tcm\DiagnosisLogic;
|
||||
use app\common\model\user\User;
|
||||
|
||||
/**
|
||||
* 日常血糖:手机号快捷建档(无完整就诊卡时)
|
||||
*/
|
||||
class DailyPhoneLogic
|
||||
{
|
||||
/** 小程序完整建档(edit_card) */
|
||||
public const CREATE_SOURCE_MNP = 'mnp';
|
||||
|
||||
/** 小程序手机号快捷建档(日常血糖) */
|
||||
public const CREATE_SOURCE_MNP_DAILY = 'mnp_daily';
|
||||
|
||||
/**
|
||||
* 用户 sex(1男2女) → 诊单 gender(1男0女)
|
||||
*/
|
||||
public static function mapUserSexToDiagnosisGender($sex): int
|
||||
{
|
||||
$sex = (int) $sex;
|
||||
if ($sex === 2) {
|
||||
return 0;
|
||||
}
|
||||
if ($sex === 1) {
|
||||
return 1;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 已绑定手机号则返回可用诊单;无卡时自动创建轻量档案
|
||||
*
|
||||
* @param array{sex?:int|null} $options sex: 用户表 1男2女,授权手机号时可传入
|
||||
* @return array{ok:bool,need_mobile?:bool,error?:string,created?:bool,diagnosis_id?:int,patient_id?:int,patient_name?:string,gender?:int,age?:int,mobile?:string,create_source?:string}
|
||||
*/
|
||||
public static function ensureDailyContext(int $userId, array $options = []): array
|
||||
{
|
||||
if ($userId <= 0) {
|
||||
return ['ok' => false, 'need_mobile' => false, 'error' => '请先登录'];
|
||||
}
|
||||
|
||||
$user = User::where('id', $userId)
|
||||
->field('id,nickname,real_name,mobile,sex,age')
|
||||
->find();
|
||||
if (!$user) {
|
||||
return ['ok' => false, 'need_mobile' => false, 'error' => '用户不存在'];
|
||||
}
|
||||
|
||||
$mobile = trim((string) ($user['mobile'] ?? ''));
|
||||
if ($mobile === '') {
|
||||
return ['ok' => false, 'need_mobile' => true, 'error' => '请先授权手机号'];
|
||||
}
|
||||
|
||||
if (array_key_exists('sex', $options) && $options['sex'] !== null && $options['sex'] !== '') {
|
||||
$sex = (int) $options['sex'];
|
||||
if (in_array($sex, [1, 2], true) && (int) ($user['sex'] ?? 0) !== $sex) {
|
||||
User::update(['id' => $userId, 'sex' => $sex]);
|
||||
$user['sex'] = $sex;
|
||||
}
|
||||
}
|
||||
|
||||
$cardList = DiagnosisLogic::getCardList($userId);
|
||||
if ($cardList === false) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'need_mobile' => false,
|
||||
'error' => DiagnosisLogic::getError() ?: '获取就诊卡失败',
|
||||
];
|
||||
}
|
||||
|
||||
if (!empty($cardList)) {
|
||||
$card = $cardList[0];
|
||||
return [
|
||||
'ok' => true,
|
||||
'need_mobile' => false,
|
||||
'created' => false,
|
||||
'diagnosis_id' => (int) $card['id'],
|
||||
'patient_id' => (int) $card['patient_id'],
|
||||
'patient_name' => (string) ($card['patient_name'] ?? ''),
|
||||
'gender' => (int) ($card['gender'] ?? 0),
|
||||
'age' => (int) ($card['age'] ?? 0),
|
||||
'mobile' => $mobile,
|
||||
'create_source' => (string) ($card['create_source'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
$displayName = trim((string) (($user['real_name'] ?? '') ?: ($user['nickname'] ?? '')));
|
||||
if ($displayName === '') {
|
||||
$displayName = '用户' . substr($mobile, -4);
|
||||
}
|
||||
|
||||
$gender = self::mapUserSexToDiagnosisGender($user['sex'] ?? 1);
|
||||
|
||||
$result = DiagnosisLogic::addCard([
|
||||
'user_id' => $userId,
|
||||
'patient_name' => $displayName,
|
||||
'phone' => $mobile,
|
||||
'gender' => $gender,
|
||||
'age' => max(0, (int) ($user['age'] ?? 0)),
|
||||
'diagnosis_date' => time(),
|
||||
'create_source' => self::CREATE_SOURCE_MNP_DAILY,
|
||||
'remark' => '小程序手机号快捷建档(日常血糖)',
|
||||
]);
|
||||
|
||||
if ($result === false) {
|
||||
return [
|
||||
'ok' => false,
|
||||
'need_mobile' => false,
|
||||
'error' => DiagnosisLogic::getError() ?: '创建档案失败',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'need_mobile' => false,
|
||||
'created' => true,
|
||||
'diagnosis_id' => (int) $result['id'],
|
||||
'patient_id' => (int) $result['patient_id'],
|
||||
'patient_name' => $displayName,
|
||||
'gender' => $gender,
|
||||
'age' => max(0, (int) ($user['age'] ?? 0)),
|
||||
'mobile' => $mobile,
|
||||
'create_source' => self::CREATE_SOURCE_MNP_DAILY,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\logic\tcm;
|
||||
|
||||
/**
|
||||
* 三餐推荐硬性规则校验(午餐高蛋白、淀粉不重复、禁高 GI)
|
||||
*/
|
||||
class DietMealValidator
|
||||
{
|
||||
/** 淀粉类关键词 → 分组(同组即视为同一种淀粉) */
|
||||
private const STARCH_GROUPS = [
|
||||
'porridge' => ['粥', '稀饭', '玉米碴', '棒子面', '高粱', '糊糊'],
|
||||
'rice' => ['米饭', '糙米饭', '二米饭', '杂豆饭', '红米饭', '饭'],
|
||||
'noodle' => ['挂面', '面条', '手擀面', '拉面', '刀削面'],
|
||||
'bun' => ['馒头', '窝头', '贴饼', '玉米面饼', '杂面馒头', '杂面窝头', '杂面', '饼'],
|
||||
'potato' => ['红薯', '地瓜', '番薯', '土豆', '洋芋', '芋头'],
|
||||
'pumpkin' => ['南瓜'],
|
||||
];
|
||||
|
||||
/** 蛋白质关键词(出现即计 1,同词不重复计) */
|
||||
private const PROTEIN_KEYWORDS = [
|
||||
'鲫鱼', '鲤鱼', '小鱼', '清蒸鱼', '炖鱼', '鱼',
|
||||
'虾', '蟹',
|
||||
'鸡肉', '鸡胸', '鸡', '鸭肉', '鸭',
|
||||
'牛肉', '羊肉', '猪肉', '瘦肉', '肉',
|
||||
'鸡蛋', '茶叶蛋', '蛋羹', '炒蛋', '蛋',
|
||||
'豆腐脑', '豆干', '豆腐', '豆角', '芸豆', '扁豆',
|
||||
];
|
||||
|
||||
/** 高 GI 禁用(出现在任一餐即违规) */
|
||||
private const FORBIDDEN_HIGH_GI = [
|
||||
'白馒头', '馒头', '油条', '粘豆包', '糯米饭', '西瓜', '荔枝', '龙眼',
|
||||
'含糖饮料', '可乐', '汽水', '糖糕', '糕点', '白稀饭', '稀饭',
|
||||
];
|
||||
|
||||
/**
|
||||
* 校验并规范化三餐方案;不通过则 ok=false
|
||||
*
|
||||
* @param array{breakfast?:string,lunch?:string,dinner?:string,tips?:string,avoid?:array} $plan
|
||||
* @return array{ok:bool,errors:list<string>,plan:array,meta:array}
|
||||
*/
|
||||
public static function validateAndNormalize(array $plan): array
|
||||
{
|
||||
$breakfast = trim((string) ($plan['breakfast'] ?? ''));
|
||||
$lunch = trim((string) ($plan['lunch'] ?? ''));
|
||||
$dinner = trim((string) ($plan['dinner'] ?? ''));
|
||||
$errors = [];
|
||||
|
||||
if ($breakfast === '' || $lunch === '' || $dinner === '') {
|
||||
$errors[] = '三餐内容不完整';
|
||||
}
|
||||
|
||||
foreach (self::FORBIDDEN_HIGH_GI as $bad) {
|
||||
foreach (['breakfast' => $breakfast, 'lunch' => $lunch, 'dinner' => $dinner] as $mealName => $text) {
|
||||
if ($text !== '' && mb_strpos($text, $bad) !== false) {
|
||||
// 「杂面馒头」含馒头但可接受;纯「馒头」才禁
|
||||
if ($bad === '馒头' && (mb_strpos($text, '杂面') !== false || mb_strpos($text, '玉米面') !== false)) {
|
||||
continue;
|
||||
}
|
||||
if ($bad === '稀饭' && mb_strpos($text, '玉米') !== false) {
|
||||
continue;
|
||||
}
|
||||
$errors[] = "{$mealName}含高 GI 食物「{$bad}」";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$bfStarch = self::detectStarchGroups($breakfast);
|
||||
$luStarch = self::detectStarchGroups($lunch);
|
||||
$diStarch = self::detectStarchGroups($dinner);
|
||||
|
||||
if (count($bfStarch) > 1) {
|
||||
$errors[] = '早餐淀粉种类超过 1 种';
|
||||
}
|
||||
if (count($luStarch) > 1) {
|
||||
$errors[] = '午餐淀粉种类超过 1 种';
|
||||
}
|
||||
if (count($diStarch) > 1) {
|
||||
$errors[] = '晚餐淀粉种类超过 1 种';
|
||||
}
|
||||
|
||||
$dayGroups = array_values(array_filter([
|
||||
$bfStarch[0] ?? null,
|
||||
$luStarch[0] ?? null,
|
||||
$diStarch[0] ?? null,
|
||||
]));
|
||||
if (count($dayGroups) !== count(array_unique($dayGroups))) {
|
||||
$errors[] = '早中晚淀粉种类重复';
|
||||
}
|
||||
|
||||
$bfProtein = self::countProteinHits($breakfast);
|
||||
$luProtein = self::countProteinHits($lunch);
|
||||
$diProtein = self::countProteinHits($dinner);
|
||||
|
||||
if ($luProtein < 2) {
|
||||
$errors[] = '午餐高蛋白不足(至少 2 样:鱼/肉/蛋/豆腐)';
|
||||
}
|
||||
if ($luProtein < $bfProtein || $luProtein < $diProtein) {
|
||||
$errors[] = '午餐蛋白质应多于早、晚餐';
|
||||
}
|
||||
|
||||
$avoid = is_array($plan['avoid'] ?? null) ? $plan['avoid'] : [];
|
||||
if (count($avoid) < 3) {
|
||||
$avoid = array_values(array_unique(array_merge($avoid, [
|
||||
'白面馒头', '油条', '粘豆包', '西瓜', '含糖饮料',
|
||||
])));
|
||||
$avoid = array_slice($avoid, 0, 5);
|
||||
}
|
||||
|
||||
$tips = trim((string) ($plan['tips'] ?? ''));
|
||||
if ($tips === '') {
|
||||
$tips = '中午鱼蛋豆肉吃足;早中晚各一种主食,别重复。';
|
||||
}
|
||||
|
||||
$normalized = [
|
||||
'breakfast' => $breakfast,
|
||||
'lunch' => $lunch,
|
||||
'dinner' => $dinner,
|
||||
'tips' => $tips,
|
||||
'avoid' => $avoid,
|
||||
];
|
||||
|
||||
$meta = [
|
||||
'protein' => ['breakfast' => $bfProtein, 'lunch' => $luProtein, 'dinner' => $diProtein],
|
||||
'starch' => ['breakfast' => $bfStarch[0] ?? '', 'lunch' => $luStarch[0] ?? '', 'dinner' => $diStarch[0] ?? ''],
|
||||
'rules_ok' => empty($errors),
|
||||
];
|
||||
|
||||
return [
|
||||
'ok' => empty($errors),
|
||||
'errors' => $errors,
|
||||
'plan' => $normalized,
|
||||
'meta' => $meta,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string> 淀粉分组 id,按命中顺序
|
||||
*/
|
||||
public static function detectStarchGroups(string $text): array
|
||||
{
|
||||
if ($text === '') {
|
||||
return [];
|
||||
}
|
||||
$found = [];
|
||||
foreach (self::STARCH_GROUPS as $groupId => $keywords) {
|
||||
foreach ($keywords as $kw) {
|
||||
if (mb_strpos($text, $kw) !== false) {
|
||||
$found[$groupId] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return array_keys($found);
|
||||
}
|
||||
|
||||
public static function countProteinHits(string $text): int
|
||||
{
|
||||
if ($text === '') {
|
||||
return 0;
|
||||
}
|
||||
$count = 0;
|
||||
$used = [];
|
||||
foreach (self::PROTEIN_KEYWORDS as $kw) {
|
||||
if (isset($used[$kw])) {
|
||||
continue;
|
||||
}
|
||||
if (mb_strpos($text, $kw) !== false) {
|
||||
// 「鱼」已命中则不再计「小鱼」「鲫鱼」
|
||||
$skip = false;
|
||||
foreach ($used as $u) {
|
||||
if (mb_strpos($u, $kw) !== false || mb_strpos($kw, $u) !== false) {
|
||||
$skip = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($skip) {
|
||||
continue;
|
||||
}
|
||||
$used[$kw] = true;
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
public static function metaSummary(array $meta): string
|
||||
{
|
||||
$p = $meta['protein'] ?? [];
|
||||
$s = $meta['starch'] ?? [];
|
||||
$starchLabels = [
|
||||
'porridge' => '粥', 'rice' => '饭', 'noodle' => '面', 'bun' => '饼/窝头',
|
||||
'potato' => '薯', 'pumpkin' => '南瓜',
|
||||
];
|
||||
$sBf = $starchLabels[$s['breakfast'] ?? ''] ?? ($s['breakfast'] ?: '—');
|
||||
$sLu = $starchLabels[$s['lunch'] ?? ''] ?? ($s['lunch'] ?: '—');
|
||||
$sDi = $starchLabels[$s['dinner'] ?? ''] ?? ($s['dinner'] ?: '—');
|
||||
return sprintf(
|
||||
'午餐 %d 样蛋白(早%d/晚%d);淀粉:早%s·午%s·晚%s',
|
||||
(int) ($p['lunch'] ?? 0),
|
||||
(int) ($p['breakfast'] ?? 0),
|
||||
(int) ($p['dinner'] ?? 0),
|
||||
$sBf,
|
||||
$sLu,
|
||||
$sDi
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
<?php
|
||||
|
||||
namespace app\api\logic\tcm;
|
||||
|
||||
/**
|
||||
* 霍大夫升糖指数(GI)知识库
|
||||
*/
|
||||
class GiKnowledge
|
||||
{
|
||||
public const LEVEL_LOW = 'low';
|
||||
public const LEVEL_MEDIUM = 'medium';
|
||||
public const LEVEL_HIGH = 'high';
|
||||
|
||||
/** @var array<string, array<string, list<string>>> */
|
||||
private static array $foods = [
|
||||
self::LEVEL_LOW => [
|
||||
'五谷类' => ['全蛋面', '荞麦面', '粉丝', '黑米', '黑米粥', '通心粉', '藕粉'],
|
||||
'蔬菜类' => ['魔芋', '粟米', '大白菜', '黄瓜', '芹菜', '茄子', '青椒', '海带', '金针菇', '香菇', '菠菜', '番茄', '豆芽', '芦笋', '花椰菜', '洋葱', '生菜'],
|
||||
'豆类' => ['黄豆', '眉豆', '鸡心豆', '豆腐', '豆角', '绿豆', '扁豆', '四季豆'],
|
||||
'水果类' => ['苹果', '水梨', '橙子', '桃', '提子', '沙田柚', '雪梨', '车厘子', '柚子', '草莓', '樱桃', '金桔', '葡萄'],
|
||||
'奶类' => ['牛奶', '低脂奶', '脱脂奶', '低脂乳酪', '红茶', '优格', '无糖豆浆'],
|
||||
],
|
||||
self::LEVEL_MEDIUM => [
|
||||
'主食类' => ['红米饭', '糙米饭', '西米', '乌冬面', '面包', '麦片', '番薯', '芋头'],
|
||||
'蔬菜类' => ['薯片', '番茄', '莲藕', '牛蒡'],
|
||||
'肉类' => ['鱼肉', '鸡肉', '鸭肉', '猪肉', '羊肉', '牛肉', '虾子', '蟹'],
|
||||
'水果类' => ['木瓜', '提子干', '菠萝', '香蕉', '芒果', '哈密瓜', '奇异果', '柳丁'],
|
||||
'其他' => ['蔗糖', '蜂蜜', '红酒', '啤酒', '可乐', '咖啡'],
|
||||
],
|
||||
self::LEVEL_HIGH => [
|
||||
'主食类' => ['白饭', '馒头', '油条', '糯米饭', '白面包', '燕麦片', '拉面', '炒饭', '爆米花'],
|
||||
'肉类加工品' => ['贡丸', '肥肠', '蛋饺'],
|
||||
'蔬菜类' => ['薯蓉', '南瓜', '焗薯'],
|
||||
'水果类' => ['西瓜', '荔枝', '龙眼', '凤梨', '枣'],
|
||||
'其他' => ['葡萄糖', '砂糖', '麦芽糖', '汽水', '柳橙汁', '蜂蜜'],
|
||||
],
|
||||
];
|
||||
|
||||
/** @var list<array{breakfast:string,lunch:string,dinner:string,tips:string}> */
|
||||
private static array $mealTemplates = [
|
||||
[
|
||||
'breakfast' => '玉米碴子粥、煮鸡蛋、拌黄瓜',
|
||||
'lunch' => '清炖鲤鱼、豆腐炖白菜、清炒豆角、米饭小半碗',
|
||||
'dinner' => '玉米面饼一个、瘦肉丝、凉拌茄子',
|
||||
'tips' => '中午鱼豆肉吃足;早中晚淀粉别重复,一顿一种主食就够。',
|
||||
],
|
||||
[
|
||||
'breakfast' => '小米粥、煮鸡蛋、小碟咸菜(少盐)',
|
||||
'lunch' => '去皮炖鸡肉、芹菜炒豆干、木耳炒鸡蛋',
|
||||
'dinner' => '蒸红薯(小半块)、清炒菠菜、豆腐汤',
|
||||
'tips' => '午餐最讲究蛋白;晚上才吃薯,别跟中午再配大米饭。',
|
||||
],
|
||||
[
|
||||
'breakfast' => '豆腐脑(少卤)、鸡蛋、一个苹果',
|
||||
'lunch' => '清炖鸡肉、炖芸豆、番茄炒蛋、贴饼子一个',
|
||||
'dinner' => '挂面一小碗、清蒸小鱼、拌海带丝',
|
||||
'tips' => '中午饼配肉豆,蛋白管够;面条放晚上,别三顿都是粥饭。',
|
||||
],
|
||||
[
|
||||
'breakfast' => '高粱米粥、茶叶蛋、拌萝卜丝',
|
||||
'lunch' => '炖牛肉(瘦)、豆腐炒韭菜、清炒油菜、杂豆饭小半碗',
|
||||
'dinner' => '蒸南瓜(小半碗)、鸡蛋羹、拍黄瓜',
|
||||
'tips' => '中午肉蛋豆要多吃;南瓜算晚饭主食,别再加馒头稀饭。',
|
||||
],
|
||||
[
|
||||
'breakfast' => '棒子面粥、水煮蛋、大拌菜(少油)',
|
||||
'lunch' => '清炖鲫鱼、番茄炒蛋、炖扁豆、糙米饭小半碗',
|
||||
'dinner' => '玉米面饼一个、清炒豆芽、豆腐脑',
|
||||
'tips' => '午饭鱼蛋豆齐上;玉米面放晚上,跟中午米饭分开。',
|
||||
],
|
||||
[
|
||||
'breakfast' => '小米粥、煮鸡蛋、拌生菜',
|
||||
'lunch' => '白切鸡(去皮)、炖豆腐、清炒豆角、二米饭小半碗',
|
||||
'dinner' => '杂面馒头一个、瘦肉炒芹菜、凉拌黄瓜',
|
||||
'tips' => '中午蛋白打主力;三顿别都喝粥吃面,一顿一种淀粉。',
|
||||
],
|
||||
];
|
||||
|
||||
public static function summaryText(): string
|
||||
{
|
||||
$path = root_path() . 'app/api/logic/tcm/data/gi_huo_summary.txt';
|
||||
if (is_file($path)) {
|
||||
$text = trim((string) file_get_contents($path));
|
||||
if ($text !== '') {
|
||||
return $text;
|
||||
}
|
||||
}
|
||||
return '低 GI(≤55)优先;中 GI(56-69)适量;高 GI(≥70)尽量避免。多选全谷物、蔬菜、豆类,少加工、少精制糖。';
|
||||
}
|
||||
|
||||
/** 农村/口语别名 → 标准名(便于「能不能吃」匹配) */
|
||||
private static array $aliases = [
|
||||
'棒子面' => '荞麦面', '玉米碴' => '粟米', '地瓜' => '番薯', '红薯' => '番薯',
|
||||
'洋芋' => '芋头', '土豆' => '芋头', '窝头' => '通心粉', '贴饼子' => '通心粉',
|
||||
'二米饭' => '红米饭', '稀饭' => '白饭', '白稀饭' => '白饭', '糖糕' => '麦芽糖',
|
||||
];
|
||||
|
||||
/**
|
||||
* @return array{level:string,level_label:string,category:string,advice:string}|null
|
||||
*/
|
||||
public static function lookupFood(string $name): ?array
|
||||
{
|
||||
$name = trim($name);
|
||||
if ($name === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$canonical = self::$aliases[$name] ?? $name;
|
||||
|
||||
foreach ([self::LEVEL_LOW, self::LEVEL_MEDIUM, self::LEVEL_HIGH] as $level) {
|
||||
foreach (self::$foods[$level] as $category => $items) {
|
||||
foreach ($items as $item) {
|
||||
if ($canonical === $item || $name === $item
|
||||
|| mb_strpos($item, $canonical) !== false || mb_strpos($canonical, $item) !== false
|
||||
|| mb_strpos($item, $name) !== false || mb_strpos($name, $item) !== false) {
|
||||
return [
|
||||
'level' => $level,
|
||||
'level_label' => self::levelLabel($level),
|
||||
'category' => $category,
|
||||
'food' => $item,
|
||||
'advice' => self::adviceForLevel($level, $name),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 供 AI 参考:老农民家常、集市易得食材示例 */
|
||||
public static function ruralMealHints(): string
|
||||
{
|
||||
return implode("\n", [
|
||||
'【午餐】蛋白质要最高:至少 2 样高蛋白(鱼/鸡鸭/瘦肉/蛋/豆腐/豆干),肉蛋豆是午饭主角。',
|
||||
'【淀粉不重复】粥/饭/面/饼/窝头/红薯/土豆/玉米/南瓜等算淀粉;早中晚各最多 1 种,且三顿不能同一种、不要顿顿大主食。',
|
||||
'推荐蛋白:鸡蛋、豆腐、豆干、自家鸡鸭蛋、瘦肉、鲫鱼鲤鱼;',
|
||||
'推荐蔬菜:白菜、萝卜、黄瓜、豆角、茄子、菠菜、芹菜;',
|
||||
'淀粉示例(全天选 3 种不同的):玉米碴粥、二米饭小半碗、贴饼子、杂面窝头、挂面小碗、蒸红薯;',
|
||||
'少吃:白馒头、油条、粘豆包、糯米饭、西瓜、含糖饮料、糕点。',
|
||||
'说话要土话、短句,像村里大夫叮嘱。',
|
||||
]);
|
||||
}
|
||||
|
||||
public static function levelLabel(string $level): string
|
||||
{
|
||||
return match ($level) {
|
||||
self::LEVEL_LOW => '低升糖',
|
||||
self::LEVEL_MEDIUM => '中升糖',
|
||||
default => '高升糖',
|
||||
};
|
||||
}
|
||||
|
||||
public static function adviceForLevel(string $level, string $food): string
|
||||
{
|
||||
return match ($level) {
|
||||
self::LEVEL_LOW => "「{$food}」升糖慢,老农民家常能吃,当菜当饭都行,有助于稳血糖。",
|
||||
self::LEVEL_MEDIUM => "「{$food}」升糖中等,可以吃但要少搁点,别当主食猛吃,配着蔬菜鸡蛋更好。",
|
||||
default => "「{$food}」升糖快,血糖高时尽量别吃或少吃,尤其别空腹猛吃。",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int|null $templateIndex 指定模板下标(换一换时轮换)
|
||||
* @return array{breakfast:string,lunch:string,dinner:string,tips:string,avoid:list<string>,source:string}
|
||||
*/
|
||||
public static function fallbackDailyMeals(int $diagnosisId, ?int $templateIndex = null): array
|
||||
{
|
||||
$templates = self::$mealTemplates;
|
||||
$count = count($templates);
|
||||
if ($templateIndex !== null) {
|
||||
$idx = (($templateIndex % $count) + $count) % $count;
|
||||
} else {
|
||||
$idx = abs(crc32(date('Y-m-d') . ':' . $diagnosisId)) % $count;
|
||||
}
|
||||
$meal = $templates[$idx];
|
||||
|
||||
$plan = [
|
||||
'breakfast' => $meal['breakfast'],
|
||||
'lunch' => $meal['lunch'],
|
||||
'dinner' => $meal['dinner'],
|
||||
'tips' => $meal['tips'],
|
||||
'avoid' => ['白面馒头', '油条', '粘豆包', '西瓜', '含糖饮料', '稀饭配糖'],
|
||||
'source' => 'rule',
|
||||
];
|
||||
|
||||
$checked = DietMealValidator::validateAndNormalize($plan);
|
||||
$out = $checked['plan'];
|
||||
$out['source'] = 'rule';
|
||||
$out['rules_summary'] = DietMealValidator::metaSummary($checked['meta']);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{food:string,level:string,level_label:string,advice:string,source:string}
|
||||
*/
|
||||
public static function fallbackAsk(string $question): array
|
||||
{
|
||||
$food = self::extractFoodName($question);
|
||||
$hit = self::lookupFood($food);
|
||||
|
||||
if ($hit) {
|
||||
return [
|
||||
'food' => $hit['food'],
|
||||
'level' => $hit['level'],
|
||||
'level_label' => $hit['level_label'],
|
||||
'advice' => $hit['advice'],
|
||||
'source' => 'rule',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'food' => $food,
|
||||
'level' => '',
|
||||
'level_label' => '未知',
|
||||
'advice' => '库里没查到「' . $food . '」。一般多吃小米玉米、白菜豆角豆腐鸡蛋,少吃白馒头油条甜口;拿不准问村医。',
|
||||
'source' => 'rule',
|
||||
];
|
||||
}
|
||||
|
||||
public static function extractFoodName(string $question): string
|
||||
{
|
||||
$q = trim($question);
|
||||
$q = preg_replace('/^(能不能吃|可以吃|能吃|可不可以吃|请问|我想问)/u', '', $q) ?? $q;
|
||||
$q = preg_replace('/(吗|呢|?|\?)+$/u', '', $q) ?? $q;
|
||||
$q = trim($q);
|
||||
return $q !== '' ? $q : $question;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
食物升糖指数(GI)知识摘要(霍大夫),面向农村老人日常吃饭:
|
||||
- 低 GI(≤55):小米、玉米碴、杂面、大部分蔬菜、豆腐、鸡蛋、苹果/梨/柚子等,有助于稳血糖。
|
||||
- 中 GI(56-69):二米饭、红薯、土豆、香蕉等,可以吃但要少、别当顿顿主食。
|
||||
- 高 GI(≥70):白馒头、白稀饭、油条、粘豆包、糯米饭、西瓜、含糖饮料、糕点,尽量别碰。
|
||||
- 午餐蛋白质要最高:鱼、鸡鸭、瘦肉、蛋、豆腐至少两样,午饭是全天蛋白主力。
|
||||
- 淀粉不重复:粥/饭/面/饼/薯/玉米/南瓜等,早中晚各最多一种,三顿不要同一种、别顿顿大主食。
|
||||
- 吃饭窍门:先吃菜和蛋白,再动主食,每餐七分饱。
|
||||
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
/**
|
||||
* OpenAI 兼容 Chat Completions 客户端
|
||||
*/
|
||||
class AiChatService
|
||||
{
|
||||
public static function isEnabled(): bool
|
||||
{
|
||||
$cfg = config('ai') ?: [];
|
||||
return !empty($cfg['enable']) && trim((string) ($cfg['api_key'] ?? '')) !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{role:string,content:string}> $messages
|
||||
* @param array<string, mixed> $options
|
||||
* @return array{ok:bool,content?:string,error?:string,raw?:array}
|
||||
*/
|
||||
public static function chat(array $messages, array $options = []): array
|
||||
{
|
||||
$cfg = config('ai') ?: [];
|
||||
if (empty($cfg['enable'])) {
|
||||
return ['ok' => false, 'error' => 'AI 未启用'];
|
||||
}
|
||||
$apiKey = trim((string) ($cfg['api_key'] ?? ''));
|
||||
if ($apiKey === '') {
|
||||
return ['ok' => false, 'error' => 'AI 密钥未配置'];
|
||||
}
|
||||
|
||||
$baseUrl = rtrim((string) ($cfg['base_url'] ?? 'https://api.deepseek.com'), '/');
|
||||
$model = (string) ($options['model'] ?? ($cfg['model'] ?? 'deepseek-chat'));
|
||||
$timeout = (int) ($options['timeout'] ?? ($cfg['timeout'] ?? 60));
|
||||
|
||||
$payload = [
|
||||
'model' => $model,
|
||||
'messages' => $messages,
|
||||
'temperature' => (float) ($options['temperature'] ?? 0.4),
|
||||
'max_tokens' => (int) ($options['max_tokens'] ?? 1200),
|
||||
];
|
||||
if (!empty($options['response_format'])) {
|
||||
$payload['response_format'] = $options['response_format'];
|
||||
}
|
||||
|
||||
$url = $baseUrl . '/v1/chat/completions';
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, min(8, max(3, (int) ceil($timeout / 3))));
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, max(5, $timeout));
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
]);
|
||||
|
||||
$body = curl_exec($ch);
|
||||
$errno = curl_errno($ch);
|
||||
$err = curl_error($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($errno) {
|
||||
return ['ok' => false, 'error' => 'AI 请求失败:' . $err];
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) $body, true);
|
||||
if ($code >= 400 || !is_array($decoded)) {
|
||||
$msg = is_array($decoded) ? ($decoded['error']['message'] ?? $decoded['message'] ?? 'AI 返回异常') : 'AI 返回异常';
|
||||
return ['ok' => false, 'error' => (string) $msg, 'raw' => $decoded];
|
||||
}
|
||||
|
||||
$content = (string) ($decoded['choices'][0]['message']['content'] ?? '');
|
||||
if ($content === '') {
|
||||
return ['ok' => false, 'error' => 'AI 未返回内容', 'raw' => $decoded];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'content' => $content, 'raw' => $decoded];
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式 Chat Completions(OpenAI SSE 格式)
|
||||
*
|
||||
* @param callable(string):void $onDelta 每收到一段文本回调
|
||||
* @return array{ok:bool,content?:string,error?:string}
|
||||
*/
|
||||
public static function streamChat(array $messages, callable $onDelta, array $options = []): array
|
||||
{
|
||||
$cfg = config('ai') ?: [];
|
||||
if (empty($cfg['enable'])) {
|
||||
return ['ok' => false, 'error' => 'AI 未启用'];
|
||||
}
|
||||
$apiKey = trim((string) ($cfg['api_key'] ?? ''));
|
||||
if ($apiKey === '') {
|
||||
return ['ok' => false, 'error' => 'AI 密钥未配置'];
|
||||
}
|
||||
|
||||
$baseUrl = rtrim((string) ($cfg['base_url'] ?? 'https://api.deepseek.com'), '/');
|
||||
$model = (string) ($options['model'] ?? ($cfg['model'] ?? 'deepseek-chat'));
|
||||
$timeout = (int) ($options['timeout'] ?? ($cfg['timeout'] ?? 60));
|
||||
|
||||
$payload = [
|
||||
'model' => $model,
|
||||
'messages' => $messages,
|
||||
'temperature' => (float) ($options['temperature'] ?? 0.4),
|
||||
'max_tokens' => (int) ($options['max_tokens'] ?? 1200),
|
||||
'stream' => true,
|
||||
];
|
||||
|
||||
$lineBuffer = '';
|
||||
$fullContent = '';
|
||||
|
||||
$writeFn = static function ($ch, string $chunk) use (&$lineBuffer, &$fullContent, $onDelta): int {
|
||||
$lineBuffer .= $chunk;
|
||||
while (($pos = strpos($lineBuffer, "\n")) !== false) {
|
||||
$line = rtrim(substr($lineBuffer, 0, $pos), "\r");
|
||||
$lineBuffer = substr($lineBuffer, $pos + 1);
|
||||
if ($line === '' || strncmp($line, 'data:', 5) !== 0) {
|
||||
continue;
|
||||
}
|
||||
$data = trim(substr($line, 5));
|
||||
if ($data === '' || $data === '[DONE]') {
|
||||
continue;
|
||||
}
|
||||
$json = json_decode($data, true);
|
||||
if (!is_array($json)) {
|
||||
continue;
|
||||
}
|
||||
$delta = (string) ($json['choices'][0]['delta']['content'] ?? '');
|
||||
if ($delta === '') {
|
||||
continue;
|
||||
}
|
||||
$fullContent .= $delta;
|
||||
$onDelta($delta);
|
||||
}
|
||||
|
||||
return strlen($chunk);
|
||||
};
|
||||
|
||||
$url = $baseUrl . '/v1/chat/completions';
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
|
||||
curl_setopt($ch, CURLOPT_WRITEFUNCTION, $writeFn);
|
||||
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, min(8, max(3, (int) ceil($timeout / 3))));
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, max(10, $timeout));
|
||||
curl_setopt($ch, CURLOPT_TCP_NODELAY, true);
|
||||
curl_setopt($ch, CURLOPT_BUFFERSIZE, 128);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
'Accept: text/event-stream',
|
||||
]);
|
||||
|
||||
curl_exec($ch);
|
||||
$errno = curl_errno($ch);
|
||||
$err = curl_error($ch);
|
||||
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($errno) {
|
||||
return ['ok' => false, 'error' => 'AI 请求失败:' . $err];
|
||||
}
|
||||
if ($code >= 400) {
|
||||
return ['ok' => false, 'error' => 'AI 返回异常(HTTP ' . $code . ')'];
|
||||
}
|
||||
if ($fullContent === '') {
|
||||
return ['ok' => false, 'error' => 'AI 未返回内容'];
|
||||
}
|
||||
|
||||
return ['ok' => true, 'content' => $fullContent];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user