diff --git a/TUICallKit-Vue3/tongji/utils/aiStreamRequest.js b/TUICallKit-Vue3/tongji/utils/aiStreamRequest.js new file mode 100644 index 00000000..65e7bf7a --- /dev/null +++ b/TUICallKit-Vue3/tongji/utils/aiStreamRequest.js @@ -0,0 +1,168 @@ +/** 拼接 API 根路径与相对路径,避免双斜杠 */ +export function joinApiUrl(base, path) { + const b = String(base || '').replace(/\/+$/, '') + const p = String(path || '').replace(/^\/+/, '') + return p ? `${b}/${p}` : b +} + +function appendQuery(url, data) { + if (!data || typeof data !== 'object') return url + const keys = Object.keys(data) + if (!keys.length) return url + const qs = keys + .map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(data[k] ?? '')}`) + .join('&') + return url + (url.includes('?') ? '&' : '?') + qs +} + +function decodeChunkData(data) { + if (typeof data === 'string') return data + if (!data) return '' + if (typeof TextDecoder !== 'undefined' && data instanceof ArrayBuffer) { + return new TextDecoder('utf-8').decode(new Uint8Array(data)) + } + if (data instanceof ArrayBuffer) { + const bytes = new Uint8Array(data) + let str = '' + for (let i = 0; i < bytes.length; i++) str += String.fromCharCode(bytes[i]) + try { + return decodeURIComponent(escape(str)) + } catch (e) { + return str + } + } + return '' +} + +/** 从 SSE 文本缓冲中解析并消费完整事件 */ +export function consumeSseBuffer(buffer, onEvent) { + let rest = buffer + let idx = rest.indexOf('\n\n') + while (idx !== -1) { + const block = rest.slice(0, idx) + rest = rest.slice(idx + 2) + let event = 'message' + let dataLine = '' + block.split('\n').forEach((line) => { + if (line.startsWith('event:')) event = line.slice(6).trim() + else if (line.startsWith('data:')) dataLine += line.slice(5).trim() + }) + if (dataLine) { + try { + onEvent(event, JSON.parse(dataLine)) + } catch (e) { + onEvent(event, dataLine) + } + } + idx = rest.indexOf('\n\n') + } + return rest +} + +/** 从流式纯文本中提取三餐(支持未写完的最后一行) */ +export function parsePartialDietPlainText(text) { + const out = {} + if (!text) return out + + const lineRe = /^(早餐|午餐|晚餐|提示|少碰)[::]\s*(.*)$/gm + let match + while ((match = lineRe.exec(text)) !== null) { + const key = { '早餐': 'breakfast', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'avoid' }[match[1]] + const val = (match[2] || '').trim() + if (key === 'avoid') { + out.avoid = val.split(/[、,,;;\s]+/).map((s) => s.trim()).filter(Boolean) + } else { + out[key] = val + } + } + + const tail = text.match(/(?:^|\n)(早餐|午餐|晚餐|提示|少碰)[::]\s*([^\n]*)$/) + if (tail) { + const key = { '早餐': 'breakfast', '午餐': 'lunch', '晚餐': 'dinner', '提示': 'tips', '少碰': 'avoid' }[tail[1]] + const val = (tail[2] || '').trim() + if (key === 'avoid') { + if (val) out.avoid = val.split(/[、,,;;\s]+/).map((s) => s.trim()).filter(Boolean) + } else { + out[key] = val + } + } + + return out +} + +/** @deprecated 使用 parsePartialDietPlainText */ +export function parsePartialDietJson(text) { + return parsePartialDietPlainText(text) +} + +/** + * SSE 流式请求(微信小程序 enableChunked;其它端走 fallback) + * + * @param {object} opts + * @param {string} opts.baseUrl + * @param {string} opts.url + * @param {string} [opts.method] + * @param {object} [opts.data] + * @param {function(string, object):void} opts.onEvent + * @param {function():Promise} [opts.fallback] 不支持流式时的降级请求 + */ +export function requestAiStream(opts) { + const { + baseUrl, + url, + method = 'GET', + data = {}, + onEvent, + fallback + } = opts + + // #ifdef MP-WEIXIN + return new Promise((resolve, reject) => { + const token = uni.getStorageSync('token') || '' + let sseBuffer = '' + let requestUrl = joinApiUrl(baseUrl, url) + if (method === 'GET') { + requestUrl = appendQuery(requestUrl, data) + } + + const task = wx.request({ + url: requestUrl, + method, + data: method === 'POST' ? data : {}, + enableChunked: true, + timeout: 60000, + header: { + token, + 'content-type': 'application/x-www-form-urlencoded', + 'Cache-Control': 'no-cache' + }, + success: (res) => { + if (res && res.data) { + sseBuffer += decodeChunkData(res.data) + sseBuffer = consumeSseBuffer(sseBuffer, onEvent) + } + resolve(res) + }, + fail: (err) => reject(err) + }) + + if (task && typeof task.onChunkReceived === 'function') { + task.onChunkReceived((res) => { + sseBuffer += decodeChunkData(res.data) + sseBuffer = consumeSseBuffer(sseBuffer, onEvent) + }) + } else if (typeof fallback === 'function') { + fallback().then(resolve).catch(reject) + } else { + reject(new Error('当前环境不支持流式请求')) + } + }) + // #endif + + // #ifndef MP-WEIXIN + if (typeof fallback === 'function') { + return fallback() + } + return Promise.reject(new Error('当前环境不支持流式请求')) + // #endif +} diff --git a/design-system/sugarcontrol/MASTER.md b/design-system/sugarcontrol/MASTER.md new file mode 100644 index 00000000..b383f3ff --- /dev/null +++ b/design-system/sugarcontrol/MASTER.md @@ -0,0 +1,203 @@ +# Design System Master File + +> **LOGIC:** When building a specific page, first check `design-system/pages/[page-name].md`. +> If that file exists, its rules **override** this Master file. +> If not, strictly follow the rules below. + +--- + +**Project:** SugarControl +**Generated:** 2026-05-28 14:58:06 +**Category:** Healthcare App + +--- + +## Global Rules + +### Color Palette + +| Role | Hex | CSS Variable | +|------|-----|--------------| +| Primary | `#0891B2` | `--color-primary` | +| Secondary | `#22D3EE` | `--color-secondary` | +| CTA/Accent | `#059669` | `--color-cta` | +| Background | `#ECFEFF` | `--color-background` | +| Text | `#164E63` | `--color-text` | + +**Color Notes:** Calm cyan + health green + +### Typography + +- **Heading Font:** Atkinson Hyperlegible +- **Body Font:** Atkinson Hyperlegible +- **Mood:** accessible, readable, inclusive, WCAG, dyslexia-friendly, clear +- **Google Fonts:** [Atkinson Hyperlegible + Atkinson Hyperlegible](https://fonts.google.com/share?selection.family=Atkinson+Hyperlegible:wght@400;700) + +**CSS Import:** +```css +@import url('https://fonts.googleapis.com/css2?family=Atkinson+Hyperlegible:wght@400;700&display=swap'); +``` + +### Spacing Variables + +| Token | Value | Usage | +|-------|-------|-------| +| `--space-xs` | `4px` / `0.25rem` | Tight gaps | +| `--space-sm` | `8px` / `0.5rem` | Icon gaps, inline spacing | +| `--space-md` | `16px` / `1rem` | Standard padding | +| `--space-lg` | `24px` / `1.5rem` | Section padding | +| `--space-xl` | `32px` / `2rem` | Large gaps | +| `--space-2xl` | `48px` / `3rem` | Section margins | +| `--space-3xl` | `64px` / `4rem` | Hero padding | + +### Shadow Depths + +| Level | Value | Usage | +|-------|-------|-------| +| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.05)` | Subtle lift | +| `--shadow-md` | `0 4px 6px rgba(0,0,0,0.1)` | Cards, buttons | +| `--shadow-lg` | `0 10px 15px rgba(0,0,0,0.1)` | Modals, dropdowns | +| `--shadow-xl` | `0 20px 25px rgba(0,0,0,0.15)` | Hero images, featured cards | + +--- + +## Component Specs + +### Buttons + +```css +/* Primary Button */ +.btn-primary { + background: #059669; + color: white; + padding: 12px 24px; + border-radius: 8px; + font-weight: 600; + transition: all 200ms ease; + cursor: pointer; +} + +.btn-primary:hover { + opacity: 0.9; + transform: translateY(-1px); +} + +/* Secondary Button */ +.btn-secondary { + background: transparent; + color: #0891B2; + border: 2px solid #0891B2; + padding: 12px 24px; + border-radius: 8px; + font-weight: 600; + transition: all 200ms ease; + cursor: pointer; +} +``` + +### Cards + +```css +.card { + background: #ECFEFF; + border-radius: 12px; + padding: 24px; + box-shadow: var(--shadow-md); + transition: all 200ms ease; + cursor: pointer; +} + +.card:hover { + box-shadow: var(--shadow-lg); + transform: translateY(-2px); +} +``` + +### Inputs + +```css +.input { + padding: 12px 16px; + border: 1px solid #E2E8F0; + border-radius: 8px; + font-size: 16px; + transition: border-color 200ms ease; +} + +.input:focus { + border-color: #0891B2; + outline: none; + box-shadow: 0 0 0 3px #0891B220; +} +``` + +### Modals + +```css +.modal-overlay { + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); +} + +.modal { + background: white; + border-radius: 16px; + padding: 32px; + box-shadow: var(--shadow-xl); + max-width: 500px; + width: 90%; +} +``` + +--- + +## Style Guidelines + +**Style:** Accessible & Ethical + +**Keywords:** High contrast, large text (16px+), keyboard navigation, screen reader friendly, WCAG compliant, focus state, semantic + +**Best For:** Government, healthcare, education, inclusive products, large audience, legal compliance, public + +**Key Effects:** Clear focus rings (3-4px), ARIA labels, skip links, responsive design, reduced motion, 44x44px touch targets + +### Page Pattern + +**Pattern Name:** Social Proof-Focused + +- **CTA Placement:** Above fold +- **Section Order:** Hero > Features > CTA + +--- + +## Anti-Patterns (Do NOT Use) + +- ❌ Bright neon colors +- ❌ Motion-heavy animations +- ❌ AI purple/pink gradients + +### Additional Forbidden Patterns + +- ❌ **Emojis as icons** — Use SVG icons (Heroicons, Lucide, Simple Icons) +- ❌ **Missing cursor:pointer** — All clickable elements must have cursor:pointer +- ❌ **Layout-shifting hovers** — Avoid scale transforms that shift layout +- ❌ **Low contrast text** — Maintain 4.5:1 minimum contrast ratio +- ❌ **Instant state changes** — Always use transitions (150-300ms) +- ❌ **Invisible focus states** — Focus states must be visible for a11y + +--- + +## Pre-Delivery Checklist + +Before delivering any UI code, verify: + +- [ ] No emojis used as icons (use SVG instead) +- [ ] All icons from consistent icon set (Heroicons/Lucide) +- [ ] `cursor-pointer` on all clickable elements +- [ ] Hover states with smooth transitions (150-300ms) +- [ ] Light mode: text contrast 4.5:1 minimum +- [ ] Focus states visible for keyboard navigation +- [ ] `prefers-reduced-motion` respected +- [ ] Responsive: 375px, 768px, 1024px, 1440px +- [ ] No content hidden behind fixed navbars +- [ ] No horizontal scroll on mobile diff --git a/doc/gi_huo_doc.txt b/doc/gi_huo_doc.txt new file mode 100644 index 00000000..f957ce89 --- /dev/null +++ b/doc/gi_huo_doc.txt @@ -0,0 +1,33 @@ +食物升糖指数(GI)是衡量食物引起餐后血糖反应的一项有效指标,它反映了人吃进食物后,在一定时间内血糖上升的速度和峰值。对于糖尿病患者而言,了解食物的升糖指数并据此安排饮食,对于控制血糖水平至关重要。 +一、升糖指数的分类 +根据升糖指数的高低,可以将食物分为以下几类: +低升糖指数食物:GI值在55以下。这类食物在胃肠中停留时间长,吸收率低,葡萄糖释放缓慢,进入血液后的速度慢、峰值低。对于糖尿病患者而言,低升糖指数的食物有助于稳定血糖水平。常见的低升糖指数食物包括: +五谷类:全蛋面、荞麦面、粉丝、黑米、黑米粥、通心粉、藕粉等。 +蔬菜类:魔芋、粟米、大白菜、黄瓜、芹菜、茄子、青椒、海带、金针菇、香菇、菠菜、番茄、豆芽、芦笋、花椰菜、洋葱、生菜等。 +豆类:黄豆、眉豆、鸡心豆、豆腐、豆角、绿豆、扁豆、四季豆等。 +水果类:苹果、水梨、橙子、桃、提子、沙田柚、雪梨、车厘子、柚子、草莓、樱桃、金桔、葡萄等(注意,某些水果如西瓜在不同成熟度下GI值可能有所不同,但一般来说,成熟度较低的西瓜GI值较低)。 +奶类:牛奶、低脂奶、脱脂奶、低脂乳酪、红茶、优格、无糖豆浆等。 +中等升糖指数食物:GI值在56~69之间。这类食物进入胃肠道后消化速度适中,葡萄糖释放入血液的速度也适中。糖尿病患者可以适量食用中等升糖指数的食物,但需注意控制摄入量。常见的中等升糖指数食物包括: +主食类:红米饭、糙米饭、西米、乌冬面、面包、麦片、番薯、芋头等。 +蔬菜类:薯片、番茄、莲藕、牛蒡等(注意,这些蔬菜的GI值相对较高,但相比于高升糖指数食物仍属于中等水平)。 +肉类:鱼肉、鸡肉、鸭肉、猪肉、羊肉、牛肉、虾子、蟹等。 +水果类:木瓜、提子干、菠萝、香蕉、芒果、哈密瓜、奇异果、柳丁等(这些水果的GI值较高,但相比于纯糖类食物仍属于中等水平)。 +其他:蔗糖、蜂蜜、红酒、啤酒、可乐、咖啡等。 +高升糖指数食物:GI值在70及以上。这类食物进入胃肠道后消化快,吸收率高,葡萄糖释放入血液快,容易导致血糖水平急剧升高。糖尿病患者应尽量避免或限制高升糖指数食物的摄入。常见的高升糖指数食物包括: +主食类:白饭、馒头、油条、糯米饭、白面包、燕麦片(需注意,不同品牌的燕麦片GI值可能有所不同,但一般来说,即食燕麦片的GI值较高)、拉面、炒饭、爆米花等。 +肉类加工品:贡丸、肥肠、蛋饺等。 +蔬菜类:薯蓉、南瓜、焗薯等(这些蔬菜的GI值非常高,应尽量避免食用)。 +水果类:西瓜(成熟度较高的西瓜GI值较高)、荔枝、龙眼、凤梨、枣等。 +其他:葡萄糖、砂糖、麦芽糖、汽水、柳橙汁、蜂蜜等。 +二、升糖指数的影响因素 +食物种类:不同种类的食物具有不同的升糖指数。例如,豆类食物的升糖指数通常低于谷类食物;大麦的升糖指数低于小麦;苹果的升糖指数低于菠萝等。 +膳食纤维含量:膳食纤维越多,食物的升糖指数越低。因此,富含膳食纤维的食物如全谷物、蔬菜、水果等通常具有较低的升糖指数。 +食物加工方式:食物的加工方式也会影响其升糖指数。例如,加工时间越长、温度越高,食物的升糖指数就越高。因此,糖尿病患者应尽量选择未加工或少加工的食物。 +食物成熟度:某些食物的成熟度也会影响其升糖指数。例如,成熟度较高的西瓜和香蕉等水果的升糖指数通常较高。 +三、建议 +对于糖尿病患者而言,了解食物的升糖指数并据此安排饮食是非常重要的。以下是一些建议: +优先选择低升糖指数食物:在日常饮食中,应优先选择低升糖指数的食物,如全谷物、蔬菜、水果(注意选择成熟度较低的水果)、豆类等。 +适量摄入中等升糖指数食物:中等升糖指数的食物可以适量摄入,但需注意控制摄入量,避免血糖水平急剧升高。 +避免或限制高升糖指数食物:高升糖指数的食物应尽量避免或限制摄入,如白饭、馒头、油条等主食类以及含糖量高的水果和饮料等。 +注意食物搭配:在安排饮食时,应注意食物的搭配,使不同升糖指数的食物相互补充,以达到均衡营养的目的。 +定期监测血糖水平:糖尿病患者应定期监测血糖水平,以便及时调整饮食和治疗方案。 \ No newline at end of file diff --git a/server/app/api/logic/tcm/DailyDietAiLogic.php b/server/app/api/logic/tcm/DailyDietAiLogic.php new file mode 100644 index 00000000..6f5b02bb --- /dev/null +++ b/server/app/api/logic/tcm/DailyDietAiLogic.php @@ -0,0 +1,650 @@ + 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):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):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 = << 早餐 > 晚餐。 +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 = << 早餐 > 晚餐。 +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|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 $parsed + * @return array + */ + 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 = << + */ + 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; + } +} diff --git a/server/app/api/logic/tcm/DailyPhoneLogic.php b/server/app/api/logic/tcm/DailyPhoneLogic.php new file mode 100644 index 00000000..3ab8f384 --- /dev/null +++ b/server/app/api/logic/tcm/DailyPhoneLogic.php @@ -0,0 +1,130 @@ + 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, + ]; + } +} diff --git a/server/app/api/logic/tcm/DietMealValidator.php b/server/app/api/logic/tcm/DietMealValidator.php new file mode 100644 index 00000000..3fe987cf --- /dev/null +++ b/server/app/api/logic/tcm/DietMealValidator.php @@ -0,0 +1,208 @@ + ['粥', '稀饭', '玉米碴', '棒子面', '高粱', '糊糊'], + '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,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 淀粉分组 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 + ); + } +} diff --git a/server/app/api/logic/tcm/GiKnowledge.php b/server/app/api/logic/tcm/GiKnowledge.php new file mode 100644 index 00000000..4d46685d --- /dev/null +++ b/server/app/api/logic/tcm/GiKnowledge.php @@ -0,0 +1,230 @@ +>> */ + private static array $foods = [ + self::LEVEL_LOW => [ + '五谷类' => ['全蛋面', '荞麦面', '粉丝', '黑米', '黑米粥', '通心粉', '藕粉'], + '蔬菜类' => ['魔芋', '粟米', '大白菜', '黄瓜', '芹菜', '茄子', '青椒', '海带', '金针菇', '香菇', '菠菜', '番茄', '豆芽', '芦笋', '花椰菜', '洋葱', '生菜'], + '豆类' => ['黄豆', '眉豆', '鸡心豆', '豆腐', '豆角', '绿豆', '扁豆', '四季豆'], + '水果类' => ['苹果', '水梨', '橙子', '桃', '提子', '沙田柚', '雪梨', '车厘子', '柚子', '草莓', '樱桃', '金桔', '葡萄'], + '奶类' => ['牛奶', '低脂奶', '脱脂奶', '低脂乳酪', '红茶', '优格', '无糖豆浆'], + ], + self::LEVEL_MEDIUM => [ + '主食类' => ['红米饭', '糙米饭', '西米', '乌冬面', '面包', '麦片', '番薯', '芋头'], + '蔬菜类' => ['薯片', '番茄', '莲藕', '牛蒡'], + '肉类' => ['鱼肉', '鸡肉', '鸭肉', '猪肉', '羊肉', '牛肉', '虾子', '蟹'], + '水果类' => ['木瓜', '提子干', '菠萝', '香蕉', '芒果', '哈密瓜', '奇异果', '柳丁'], + '其他' => ['蔗糖', '蜂蜜', '红酒', '啤酒', '可乐', '咖啡'], + ], + self::LEVEL_HIGH => [ + '主食类' => ['白饭', '馒头', '油条', '糯米饭', '白面包', '燕麦片', '拉面', '炒饭', '爆米花'], + '肉类加工品' => ['贡丸', '肥肠', '蛋饺'], + '蔬菜类' => ['薯蓉', '南瓜', '焗薯'], + '水果类' => ['西瓜', '荔枝', '龙眼', '凤梨', '枣'], + '其他' => ['葡萄糖', '砂糖', '麦芽糖', '汽水', '柳橙汁', '蜂蜜'], + ], + ]; + + /** @var list */ + 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,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; + } +} diff --git a/server/app/api/logic/tcm/data/gi_huo_summary.txt b/server/app/api/logic/tcm/data/gi_huo_summary.txt new file mode 100644 index 00000000..c05dfdd1 --- /dev/null +++ b/server/app/api/logic/tcm/data/gi_huo_summary.txt @@ -0,0 +1,7 @@ +食物升糖指数(GI)知识摘要(霍大夫),面向农村老人日常吃饭: +- 低 GI(≤55):小米、玉米碴、杂面、大部分蔬菜、豆腐、鸡蛋、苹果/梨/柚子等,有助于稳血糖。 +- 中 GI(56-69):二米饭、红薯、土豆、香蕉等,可以吃但要少、别当顿顿主食。 +- 高 GI(≥70):白馒头、白稀饭、油条、粘豆包、糯米饭、西瓜、含糖饮料、糕点,尽量别碰。 +- 午餐蛋白质要最高:鱼、鸡鸭、瘦肉、蛋、豆腐至少两样,午饭是全天蛋白主力。 +- 淀粉不重复:粥/饭/面/饼/薯/玉米/南瓜等,早中晚各最多一种,三顿不要同一种、别顿顿大主食。 +- 吃饭窍门:先吃菜和蛋白,再动主食,每餐七分饱。 diff --git a/server/app/common/service/AiChatService.php b/server/app/common/service/AiChatService.php new file mode 100644 index 00000000..5b4568b5 --- /dev/null +++ b/server/app/common/service/AiChatService.php @@ -0,0 +1,181 @@ + $messages + * @param array $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]; + } +} diff --git a/server/config/ai.php b/server/config/ai.php new file mode 100644 index 00000000..c00c6682 --- /dev/null +++ b/server/config/ai.php @@ -0,0 +1,18 @@ + filter_var(env('ai.ENABLE', env('ai.enable', false)), FILTER_VALIDATE_BOOLEAN), + 'base_url' => rtrim((string) env('ai.BASE_URL', env('ai.base_url', 'https://api.deepseek.com')), '/'), + 'api_key' => (string) env('ai.API_KEY', env('ai.api_key', '')), + 'model' => (string) env('ai.MODEL', env('ai.model', 'deepseek-chat')), + 'timeout' => (int) env('ai.TIMEOUT', env('ai.timeout', 60)), +]; diff --git a/server/sql/1.9.20260528/add_tcm_diagnosis_create_source.sql b/server/sql/1.9.20260528/add_tcm_diagnosis_create_source.sql new file mode 100644 index 00000000..44f403ba --- /dev/null +++ b/server/sql/1.9.20260528/add_tcm_diagnosis_create_source.sql @@ -0,0 +1,6 @@ +-- 就诊卡创建来源:区分小程序快捷建档 / 小程序完整建档 / 后台等 +ALTER TABLE `zyt_tcm_diagnosis` + ADD COLUMN `create_source` varchar(32) NOT NULL DEFAULT '' COMMENT '创建来源:mnp=小程序完整建档 mnp_daily=小程序手机号快捷建档' AFTER `status`; + +ALTER TABLE `zyt_tcm_diagnosis` + ADD INDEX `idx_create_source` (`create_source`);