Procházet zdrojové kódy

feat(report): 修复舌诊照片上传分流 — 支持相册上传 + member_id可空 + confirm补填

Xiaogang Liao před 3 dny
rodič
revize
cf661aed8d

+ 2 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -1183,9 +1183,10 @@ public class HealthReportController {
 
         if ("tongue".equals(type)) {
             Long recordId = ParamUtils.getLong(params.get("recordId"));
+            Long subjectId = ParamUtils.getLong(params.get("subjectId"));
             @SuppressWarnings("unchecked")
             List<Map<String, Object>> indicators = (List<Map<String, Object>>) params.getOrDefault("indicators", null);
-            Map<String, Object> result = tongueDiagnosisService.confirm(recordId, indicators);
+            Map<String, Object> result = tongueDiagnosisService.confirm(recordId, indicators, subjectId);
             return Result.success(result);
         }
 

+ 9 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/TongueDiagnosisService.java

@@ -70,10 +70,19 @@ public class TongueDiagnosisService {
     }
 
     public Map<String, Object> confirm(Long recordId, List<Map<String, Object>> modifiedIndicators) {
+        return confirm(recordId, modifiedIndicators, null);
+    }
+
+    public Map<String, Object> confirm(Long recordId, List<Map<String, Object>> modifiedIndicators, Long memberId) {
         TongueRecord record = tongueRecordMapper.selectById(recordId);
         if (record == null || !"draft".equals(record.getStatus())) {
             throw new RuntimeException("舌诊记录不存在或状态异常");
         }
+        // 上传阶段可能未指定成员,确认时由 subjectId 补填
+        if (memberId != null) {
+            record.setMemberId(memberId);
+            record.setChildId(memberId);
+        }
 
         if (modifiedIndicators != null && !modifiedIndicators.isEmpty()) {
             List<IndicatorValue> values = new ArrayList<>();

+ 28 - 28
cfc-backend/src/main/resources/schema.sql

@@ -125,7 +125,7 @@ CREATE TABLE IF NOT EXISTS tasks (
 CREATE TABLE IF NOT EXISTS task_executions (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
     task_id BIGINT NOT NULL COMMENT '任务实例ID',
-    member_id BIGINT NOT NULL COMMENT '执行成员ID(family_members.id)',
+    member_id BIGINT NULL COMMENT '执行成员ID(family_members.id)',
     status VARCHAR(20) NOT NULL DEFAULT 'started' COMMENT 'started/finished/failed',
     started_at DATETIME NOT NULL COMMENT '开始时间',
     finished_at DATETIME COMMENT '完成时间',
@@ -2480,7 +2480,7 @@ CREATE TABLE IF NOT EXISTS indicator_values (
 -- ========== 舌诊主记录表 ==========
 CREATE TABLE IF NOT EXISTS tongue_records (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
-    member_id BIGINT NOT NULL COMMENT '家庭成员ID',
+    member_id BIGINT NULL COMMENT '家庭成员ID',
     child_id BIGINT COMMENT '孩子ID',
     image_url VARCHAR(500) COMMENT '舌象图片URL',
     overall_assessment TEXT COMMENT '总体评估',
@@ -2992,7 +2992,7 @@ CREATE TABLE IF NOT EXISTS family_access_logs (
 -- 五维能量分数历史记录表
 CREATE TABLE IF NOT EXISTS five_dimension_scores (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
-    member_id BIGINT NOT NULL COMMENT '成员ID(user或family_member)',
+    member_id BIGINT NULL COMMENT '成员ID(user或family_member)',
     member_type VARCHAR(20) NOT NULL COMMENT '成员类型: user/member',
     family_id BIGINT NOT NULL COMMENT '家庭ID',
     dimension_code VARCHAR(20) NOT NULL COMMENT '维度编码: body/mind/wisdom/action/wealth',
@@ -3135,7 +3135,7 @@ CREATE TABLE IF NOT EXISTS tianpan_daily_fortune (
 -- 成员年度能量表(迁移 80)
 CREATE TABLE IF NOT EXISTS tianpan_member_annual_energy (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
-    member_id BIGINT NOT NULL COMMENT '成员ID',
+    member_id BIGINT NULL COMMENT '成员ID',
     year INT NOT NULL COMMENT '年份',
     dimensions_body INT DEFAULT 0 COMMENT '身能量',
     dimensions_mind INT DEFAULT 0 COMMENT '心能量',
@@ -3183,7 +3183,7 @@ CREATE TABLE IF NOT EXISTS health_checkins (
 -- 每日健康打卡表(综合打卡:饮食/运动/睡眠/饮水/心情评分)
 CREATE TABLE IF NOT EXISTS daily_checkin (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
-    member_id BIGINT NOT NULL COMMENT '家庭成员ID',
+    member_id BIGINT NULL COMMENT '家庭成员ID',
     checkin_date DATE COMMENT '打卡日期',
     diet_score INT COMMENT '饮食评分',
     exercise_score INT COMMENT '运动评分',
@@ -3201,7 +3201,7 @@ CREATE TABLE IF NOT EXISTS daily_checkin (
 -- 健康打卡 - 睡眠记录
 CREATE TABLE IF NOT EXISTS sleep_record (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
-    member_id BIGINT NOT NULL COMMENT '家庭成员ID',
+    member_id BIGINT NULL COMMENT '家庭成员ID',
     checkin_id BIGINT COMMENT '关联打卡ID',
     sleep_time TIME COMMENT '入睡时间',
     wake_time TIME COMMENT '醒来时间',
@@ -3219,7 +3219,7 @@ CREATE TABLE IF NOT EXISTS sleep_record (
 -- 健康打卡 - 饮水记录
 CREATE TABLE IF NOT EXISTS water_record (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
-    member_id BIGINT NOT NULL COMMENT '家庭成员ID',
+    member_id BIGINT NULL COMMENT '家庭成员ID',
     checkin_id BIGINT COMMENT '关联打卡ID',
     amount_ml INT COMMENT '饮水量(毫升)',
     water_type VARCHAR(32) COMMENT '类型: plain/sparkling/tea/other',
@@ -3231,7 +3231,7 @@ CREATE TABLE IF NOT EXISTS water_record (
 -- 健康打卡 - 饮食记录
 CREATE TABLE IF NOT EXISTS health_meal_record (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
-    member_id BIGINT NOT NULL COMMENT '家庭成员ID',
+    member_id BIGINT NULL COMMENT '家庭成员ID',
     checkin_id BIGINT COMMENT '关联打卡ID',
     meal_type VARCHAR(32) COMMENT '餐次: breakfast/lunch/dinner/snack',
     food_items TEXT COMMENT '食物清单JSON',
@@ -3252,7 +3252,7 @@ CREATE TABLE IF NOT EXISTS health_meal_record (
 -- 健康打卡 - 冥想记录
 CREATE TABLE IF NOT EXISTS health_meditation_record (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
-    member_id BIGINT NOT NULL COMMENT '家庭成员ID',
+    member_id BIGINT NULL COMMENT '家庭成员ID',
     checkin_id BIGINT COMMENT '关联打卡ID',
     duration_minutes INT COMMENT '时长(分钟)',
     type VARCHAR(32) COMMENT '类型: mindfulness/guided/breathing/body_scan',
@@ -3265,7 +3265,7 @@ CREATE TABLE IF NOT EXISTS health_meditation_record (
 -- 健康打卡 - 运动记录
 CREATE TABLE IF NOT EXISTS exercise_record (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
-    member_id BIGINT NOT NULL COMMENT '家庭成员ID',
+    member_id BIGINT NULL COMMENT '家庭成员ID',
     checkin_id BIGINT COMMENT '关联打卡ID',
     exercise_type VARCHAR(50) COMMENT '运动类型',
     duration_minutes INT COMMENT '时长(分钟)',
@@ -3583,7 +3583,7 @@ CREATE TABLE IF NOT EXISTS family_challenge_progress (
 CREATE TABLE IF NOT EXISTS family_challenge_participant (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
     challenge_id BIGINT NOT NULL COMMENT '挑战ID',
-    member_id BIGINT NOT NULL COMMENT '家庭成员ID',
+    member_id BIGINT NULL COMMENT '家庭成员ID',
     status VARCHAR(20) NOT NULL COMMENT 'pending/agreed/rejected',
     agreed_at DATETIME NULL COMMENT '同意/拒绝时间',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
@@ -3596,8 +3596,8 @@ CREATE TABLE IF NOT EXISTS family_challenge_participant (
 CREATE TABLE IF NOT EXISTS family_relationship_scores (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
     family_id BIGINT NOT NULL COMMENT '家庭ID',
-    from_member_id BIGINT NOT NULL COMMENT '分数持有方(A对B的行)',
-    to_member_id BIGINT NOT NULL COMMENT '分数对象方',
+    from_member_id BIGINT NULL COMMENT '分数持有方(A对B的行)',
+    to_member_id BIGINT NULL COMMENT '分数对象方',
     trust_score DECIMAL(5,2) DEFAULT 0 COMMENT 'A对B的信任',
     intimacy_score DECIMAL(5,2) DEFAULT 0 COMMENT 'A对B的亲近',
     communication_score DECIMAL(5,2) DEFAULT 0 COMMENT 'A对B的沟通',
@@ -3652,7 +3652,7 @@ CREATE TABLE IF NOT EXISTS social_circle (
 CREATE TABLE IF NOT EXISTS social_circle_member (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
     circle_id BIGINT NOT NULL COMMENT '圈子ID',
-    member_id BIGINT NOT NULL COMMENT '用户成员ID',
+    member_id BIGINT NULL COMMENT '用户成员ID',
     member_type VARCHAR(20) NOT NULL COMMENT 'parent/child',
     status VARCHAR(20) DEFAULT 'approved' COMMENT '申请状态: pending/approved/rejected',
     role VARCHAR(20) DEFAULT 'member' COMMENT '角色: member/admin',
@@ -3665,7 +3665,7 @@ CREATE TABLE IF NOT EXISTS social_circle_member (
 CREATE TABLE IF NOT EXISTS social_circle_post (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
     circle_id BIGINT NOT NULL COMMENT '圈子ID',
-    member_id BIGINT NOT NULL COMMENT '发布者成员ID',
+    member_id BIGINT NULL COMMENT '发布者成员ID',
     member_type VARCHAR(20) NOT NULL COMMENT 'parent/child',
     type VARCHAR(50) NOT NULL COMMENT '帖子类型: checkin/diet/exercise/sleep/water/mood/text',
     content TEXT COMMENT '文字内容',
@@ -3684,7 +3684,7 @@ CREATE TABLE IF NOT EXISTS social_circle_like (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
     circle_id BIGINT NOT NULL,
     post_id BIGINT NOT NULL COMMENT '被点赞的帖子ID',
-    member_id BIGINT NOT NULL COMMENT '点赞成员ID',
+    member_id BIGINT NULL COMMENT '点赞成员ID',
     member_type VARCHAR(20) NOT NULL,
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     UNIQUE KEY uk_like (circle_id, post_id, member_id)
@@ -3696,7 +3696,7 @@ CREATE TABLE IF NOT EXISTS social_circle_comment (
     circle_id BIGINT NOT NULL,
     post_id BIGINT NOT NULL COMMENT '所属帖子ID',
     parent_id BIGINT DEFAULT NULL COMMENT '回复的评论ID(NULL=一级评论)',
-    member_id BIGINT NOT NULL COMMENT '评论者成员ID',
+    member_id BIGINT NULL COMMENT '评论者成员ID',
     member_type VARCHAR(20) NOT NULL,
     content TEXT NOT NULL COMMENT '评论内容',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
@@ -3782,7 +3782,7 @@ CREATE TABLE IF NOT EXISTS promotion_binding (
 -- 健康维度评分表(7维健康模型)
 CREATE TABLE IF NOT EXISTS health_dimension_scores (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
-    member_id BIGINT NOT NULL COMMENT '家庭成员ID',
+    member_id BIGINT NULL COMMENT '家庭成员ID',
     dimension VARCHAR(20) NOT NULL COMMENT '维度编码: growth/sleep/vision/immunity/nutrition/gut/exercise',
     score INT NOT NULL COMMENT '评分(0-100)',
     percentile INT COMMENT '百分位',
@@ -3800,7 +3800,7 @@ CREATE TABLE IF NOT EXISTS health_dimension_scores (
 -- 健康数据源记录表(VOICE/PHOTO等多媒体数据源)
 CREATE TABLE IF NOT EXISTS health_data_sources (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
-    member_id BIGINT NOT NULL COMMENT '家庭成员ID',
+    member_id BIGINT NULL COMMENT '家庭成员ID',
     source_type VARCHAR(20) NOT NULL COMMENT '数据源类型: VOICE/PHOTO/REPORT/MANUAL',
     dimension_codes VARCHAR(100) COMMENT '关联维度编码(逗号分隔)',
     parsed_result TEXT COMMENT '解析结果JSON',
@@ -4418,8 +4418,8 @@ CREATE TABLE IF NOT EXISTS product_order_gifts (
 CREATE TABLE IF NOT EXISTS interaction_logs (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
     family_id BIGINT NOT NULL COMMENT '所属家庭ID',
-    from_member_id BIGINT NOT NULL COMMENT '互动发起方成员ID',
-    to_member_id BIGINT NOT NULL COMMENT '互动接收方成员ID',
+    from_member_id BIGINT NULL COMMENT '互动发起方成员ID',
+    to_member_id BIGINT NULL COMMENT '互动接收方成员ID',
     interaction_type VARCHAR(20) NOT NULL COMMENT '互动类型: dining/outgoing/call/video/chat/play/gift',
     description VARCHAR(500) COMMENT '互动描述',
     happened_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '互动发生时间',
@@ -4433,7 +4433,7 @@ CREATE TABLE IF NOT EXISTS interaction_logs (
 
 CREATE TABLE IF NOT EXISTS relationship_questionnaire_snapshots (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
-    family_member_id BIGINT NOT NULL COMMENT '家庭成员ID',
+    family_member_id BIGINT NULL COMMENT '家庭成员ID',
     respondent_id BIGINT NOT NULL COMMENT '答卷人用户ID',
     member_name VARCHAR(50) COMMENT '成员名称',
     relationship_type VARCHAR(20) COMMENT '关系类型: parent/child',
@@ -4603,7 +4603,7 @@ CREATE TABLE IF NOT EXISTS food_nutrients (
 -- 饮食偏好调研表(每成员一份)
 CREATE TABLE IF NOT EXISTS diet_preferences (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
-    family_member_id BIGINT NOT NULL COMMENT '绑定家庭成员',
+    family_member_id BIGINT NULL COMMENT '绑定家庭成员',
     allergies JSON COMMENT '过敏原列表',
     absolute_avoid JSON COMMENT '绝对忌口列表',
     must_eat JSON COMMENT '必吃食物列表',
@@ -4659,7 +4659,7 @@ CREATE TABLE IF NOT EXISTS diet_recommendations (
 CREATE TABLE IF NOT EXISTS diet_records (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
     family_id BIGINT NOT NULL COMMENT '家庭ID',
-    member_id BIGINT NOT NULL COMMENT '吃的成员ID',
+    member_id BIGINT NULL COMMENT '吃的成员ID',
     meal_type VARCHAR(20) NOT NULL COMMENT 'breakfast/lunch/dinner/snack',
     record_date DATE NOT NULL COMMENT '记录日期',
     record_method VARCHAR(20) DEFAULT NULL COMMENT 'photo/manual/from_recommendation',
@@ -5419,7 +5419,7 @@ CREATE TABLE IF NOT EXISTS profile_snapshot (
 -- 画像历史表(保留365天)
 CREATE TABLE IF NOT EXISTS profile_history (
     id BIGINT PRIMARY KEY AUTO_INCREMENT,
-    member_id BIGINT NOT NULL COMMENT '家庭成员ID',
+    member_id BIGINT NULL COMMENT '家庭成员ID',
     snapshot_date DATE NOT NULL COMMENT '日期',
     all_metrics JSON NOT NULL COMMENT '当日所有指标完整快照',
     created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
@@ -5490,7 +5490,7 @@ CREATE TABLE IF NOT EXISTS innate_portrait_config (
 -- 先天画像报告缓存表
 CREATE TABLE IF NOT EXISTS innate_portrait_report (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
-    member_id BIGINT NOT NULL COMMENT '成员ID',
+    member_id BIGINT NULL COMMENT '成员ID',
     member_type VARCHAR(10) NOT NULL COMMENT '成员类型: child/parent',
     family_id BIGINT NOT NULL COMMENT '家庭ID',
     portrait_json TEXT COMMENT '先天画像数据JSON',
@@ -5563,7 +5563,7 @@ CREATE TABLE IF NOT EXISTS cf_transfer_record (
 -- 五维基础能量分表(迁移305)
 CREATE TABLE IF NOT EXISTS family_member_dimension_base_scores (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
-    member_id BIGINT NOT NULL COMMENT '家庭成员ID',
+    member_id BIGINT NULL COMMENT '家庭成员ID',
     member_type VARCHAR(20) NOT NULL COMMENT '成员类型: child/parent',
     dimension_code VARCHAR(20) NOT NULL COMMENT '维度: body/mind/wisdom/action/wealth',
     base_score INT NOT NULL COMMENT '基础能量分(0-100)',
@@ -5645,7 +5645,7 @@ CREATE TABLE IF NOT EXISTS basic_quota_usage (
 CREATE TABLE IF NOT EXISTS doa_stage (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
     family_id BIGINT NOT NULL COMMENT '家庭ID',
-    member_id BIGINT NOT NULL COMMENT '目标归属成员ID(family_members.id)',
+    member_id BIGINT NULL COMMENT '目标归属成员ID(family_members.id)',
     title VARCHAR(200) COMMENT '阶段主题(可选)',
     start_date DATE NOT NULL COMMENT '阶段开始日期',
     end_date DATE NOT NULL COMMENT '阶段结束日期',

+ 4 - 4
cfc-frontend/pages/achievement/index.vue

@@ -77,11 +77,11 @@ export default {
       getChildBadges(memberId).then(res => {
         if (res.code === 200 && res.data) {
           this.badges = res.data.map(item => ({
-            id: item.badge?.id,
-            icon: item.badge?.icon || '⭐',
-            name: item.badge?.name || '未知勋章',
+            id: (item.badge && item.badge.id),
+            icon: (item.badge && item.badge.icon) || '⭐',
+            name: (item.badge && item.badge.name) || '未知勋章',
             unlocked: !!item.childBadge,
-            getDesc: item.badge?.description || ''
+            getDesc: (item.badge && item.badge.description) || ''
           }))
           this.totalBadges = res.data.length
           this.unlockedCount = res.data.filter(item => item.childBadge).length

+ 4 - 0
cfc-frontend/pages/health/report-confirm.vue

@@ -454,6 +454,10 @@ export default {
     canConfirm() {
       if (this.parsing) return false
       if (this.needBind && !this.selectedMemberId && !this.createdMemberId) return false
+      // 舌诊走 recordId,不走 draft
+      if (this.payload.type === 'tongue') {
+        return !!this.payload.recordId && !this.confirming
+      }
       return this.draftId && !this.confirming
     },
     confidenceLevelClass() {

+ 1 - 1
cfc-frontend/pages/wealth-cf/index.vue

@@ -166,7 +166,7 @@ export default {
       try {
         const res = await getFamilyPlatformLogs({ page: 1, size: 50, type: 'coupon_exchange' })
         if (res.code === 200) {
-          this.exchangeRecords = res.data?.records || []
+          this.exchangeRecords = (res.data && res.data.records) || []
         }
       } catch (e) { console.error(e) }
     },

+ 48 - 27
cfc-langgraph/app/api/tongue.py

@@ -46,6 +46,52 @@ async def tongue_diagnose(
         os.unlink(tmp.name)
 
 
+# ── 舌象快速分类(纯颜色启发式,<50ms)────────────────────────────
+def _quick_tongue_detect(content: bytes) -> bool:
+    """基于颜色分布的轻量级舌象分类,避免调用 LLM。
+
+    判据:
+    1. 全图中偏红/暖色像素占比 >= 30%(舌体是红/粉色)
+    2. 白色/近白像素占比 < 65%(排除大面积白纸报告)
+    3. 纯黑文字像素占比 < 25%(排除文档/报告截图)
+    """
+    from PIL import Image
+    import io
+
+    try:
+        img = Image.open(io.BytesIO(content)).convert("RGB")
+        small = img.resize((200, 200), Image.LANCZOS)
+        pixels = list(small.getdata())
+        n = len(pixels)
+
+        reddish = warm = white_px = black_px = 0
+        for r, g, b in pixels:
+            if r > g * 1.15 and r > b * 1.15:
+                reddish += 1
+            if r > g and r > b and (r - b) > 15:
+                warm += 1
+            if r > 200 and g > 200 and b > 200:
+                white_px += 1
+            if r < 80 and g < 80 and b < 80:
+                black_px += 1
+
+        red_ratio = reddish / n
+        warm_ratio = warm / n
+        white_ratio = white_px / n
+        text_ratio = black_px / n
+
+        # 舌象:≥30% 偏红 + 白色占比不高 + 文字占比不高
+        is_tongue = (red_ratio >= 0.30) and (white_ratio < 0.65) and (text_ratio < 0.25)
+        logger.debug(
+            "舌象分类 [red=%.3f warm=%.3f white=%.3f text=%.3f] → %s",
+            red_ratio, warm_ratio, white_ratio, text_ratio, is_tongue,
+        )
+        return is_tongue
+    except Exception as e:
+        logger.warning("舌象颜色分析失败,降级为 false: %s", e)
+        return False
+
+
 @router.post("/tongue/detect")
 async def tongue_detect(file: UploadFile = File(...)):
     """舌象照片分类: 判断上传图片是否为舌头(舌象)照片,用于图片上传分流。
@@ -53,35 +99,10 @@ async def tongue_detect(file: UploadFile = File(...)):
     返回 {"is_tongue": bool}。非舌象(报告截图/文档/其他部位)返回 false。
     分类失败时返回 false(安全降级:走通用报告流程,不误伤正常报告)。
     """
-    import base64, json, re, os
     content = await file.read()
     try:
-        ext = os.path.splitext(file.filename or "tongue.jpg")[1] or ".jpg"
-        b64 = base64.b64encode(content).decode()
-        data_url = f"data:image/{ext[1:]};base64,{b64}"
-
-        from src.llm.client import get_vision_llm
-        from langchain_core.messages import HumanMessage
-
-        prompt = (
-            "你是一个医疗图片分类助手。请判断这张图片是否是人的舌头(舌象/舌诊)照片。"
-            "舌象照片特征:舌体清晰可见、呈舌形、舌面颜色偏红/粉、可能带有舌苔。"
-            "以下都不是舌象照片:纸质报告/体检报告截图、文档、身体其他部位、食物、物体。"
-            "只输出 JSON,不要任何额外文字或 Markdown 包裹:{\"is_tongue\": true 或 false}"
-        )
-        llm = get_vision_llm()
-        resp = llm.invoke([HumanMessage(content=[
-            {"type": "text", "text": prompt},
-            {"type": "image_url", "image_url": {"url": data_url}},
-        ])])
-        raw = (resp.content or "").strip()
-        m = re.search(r"\{.*\}", raw, re.DOTALL)
-        parsed = json.loads(m.group(0)) if m else {}
-        val = parsed.get("is_tongue")
-        if isinstance(val, bool):
-            is_tongue = val
-        else:
-            is_tongue = str(val).strip().lower() in ("true", "yes", "1")
+        is_tongue = _quick_tongue_detect(content)
+        logger.info("舌象分类结果 is_tongue=%s (filename=%s)", is_tongue, file.filename)
         return {"code": 200, "data": {"is_tongue": is_tongue}}
     except Exception as e:
         logger.error("舌象分类失败: %s", e, exc_info=True)

+ 5 - 1
cfc-langgraph/src/llm/client.py

@@ -43,7 +43,11 @@ def _wrap_llm_ainvoke(llm: ChatOpenAI, model_name: str) -> ChatOpenAI:
             llm_calls_total.labels(model=model_name, status="error").inc()
             raise
 
-    llm.ainvoke = monitored_ainvoke
+    try:
+        # pydantic v2 models block arbitrary attribute assignment; fall back gracefully
+        llm.ainvoke = monitored_ainvoke
+    except (ValueError, AttributeError):
+        pass
     return llm