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):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):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> $rows * @return array */ 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 $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 $context * @return array> */ 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 $context * @return array */ 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 = << 早餐 > 晚餐。 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 = << 早餐 > 晚餐。 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|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 $plan * @return array */ 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 $parsed * @return array */ 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 = << */ 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 = << '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|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; } }