Просмотр исходного кода

refactor(admin,langgraph): 移除管理端AI问卷场景配置与LangGraph qna模块

删除 AiQuestionnaireScenes.vue/router/api.js,删除 cfc-langgraph qna/graph/prompts/router/schemas

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
liaoxg 2 месяцев назад
Родитель
Сommit
bcb8f76dcf

+ 0 - 95
cfc-langgraph/src/qna/graph.py

@@ -1,95 +0,0 @@
-"""qna 引擎 - 无状态纯函数(可单测),供 router 调用"""
-import json
-import re
-from typing import Optional
-
-from . import schemas, prompts  # noqa: F401
-
-
-def _extract_keywords(history: list) -> list:
-    texts = []
-    for h in history:
-        texts.append(h.get("question", {}).get("text", ""))
-        texts.append(h.get("answer", ""))
-    joined = " ".join(texts)
-    stopwords = {"请问", "帮我", "怎么", "什么", "如何", "是否", "一个", "这个", "那个"}
-    words = re.findall(r"[\u4e00-\u9fff]{2,6}", joined)
-    seen = []
-    for w in words:
-        w = w.strip()
-        if w and w not in stopwords and w not in seen:
-            seen.append(w)
-    return seen[:8]
-
-
-def _extract_json(text: str) -> dict:
-    t = text.strip()
-    if "```" in t:
-        t = t.split("```json")[-1].split("```")[0].strip() if "```json" in t else t.split("```")[1].split("```")[0].strip()
-    start, end = t.find("{"), t.rfind("}")
-    if start == -1 or end == -1:
-        raise ValueError("无 JSON 块")
-    return json.loads(t[start:end + 1])
-
-
-def decide_next(llm, scene: dict, history: list, prompt_fn) -> dict:
-    """根据场景+知识+历史,输出 {action, question?, reason?}。LLM 输出非法自动重试 1 次。"""
-    max_q = int(scene.get("max_questions", 12))
-    if len(history) >= max_q:
-        return {"action": "finish", "reason": "已达最大题目数"}
-    prompt = prompt_fn(scene, "", history)  # 知识在 router 中检索后注入,见 retrieve_and_decide
-    last_err = None
-    for attempt in range(2):
-        try:
-            resp = llm.invoke([{"role": "human", "content": prompt}])
-            data = _extract_json(resp.content)
-            action = data.get("action")
-            if action == "ask":
-                q = data.get("question", {})
-                qobj = schemas.Question(**q)
-                if not qobj.is_valid():
-                    raise ValueError(f"题目结构非法: {q}")
-                return {"action": "ask", "question": qobj.model_dump(), "reason": data.get("reason", "")}
-            if action == "finish":
-                return {"action": "finish", "reason": data.get("reason", "信息已足够")}
-            raise ValueError(f"未知 action: {action}")
-        except Exception as e:
-            last_err = str(e)
-    return {"action": "ask", "fallback": True, "reason": f"LLM 输出异常: {last_err}",
-            "question": {"id": f"fb{len(history)+1}", "type": "text", "text": "请简单描述您最近一周的饮食情况。"}}
-
-
-def generate_profile(llm, scene: dict, history: list, kb_context: Optional[list], prompt_fn) -> tuple:
-    kb_text = ""
-    if kb_context:
-        kb_text = "\n---\n".join(d.get("content", "") for d in kb_context[:5])
-    prompt = prompt_fn(scene, kb_text, history)
-    try:
-        resp = llm.invoke([{"role": "human", "content": prompt}])
-        data = _extract_json(resp.content)
-        up = data.get("user_profile", [])
-        np = data.get("need_profile", [])
-        for item in up:
-            item["score"] = max(0, min(100, int(item.get("score", 0))))
-        return {"user_profile": up, "need_profile": np}, bool(kb_text)
-    except Exception:
-        return {"user_profile": [], "need_profile": []}, False
-
-
-def retrieve_and_decide(llm, scene, history, kb_context, prompt_fn) -> dict:
-    """知识注入版出题:把 kb_context 转文本后调用 LLM 决策(供 router 使用)"""
-    kb_text = ""
-    if kb_context:
-        kb_text = "\n---\n".join(d.get("content", "") for d in kb_context[:5])
-    prompt = prompt_fn(scene, kb_text, history)
-    try:
-        resp = llm.invoke([{"role": "human", "content": prompt}])
-        data = _extract_json(resp.content)
-        if data.get("action") == "finish":
-            return {"action": "finish", "reason": data.get("reason", "信息已足够")}
-        q = schemas.Question(**data["question"])
-        return {"action": "ask", "question": q.model_dump(), "reason": data.get("reason", "")}
-    except Exception as e:
-        return {"fallback": True, "reason": f"出题失败: {e}",
-                "question": {"id": f"fb{len(history)+1}", "type": "text",
-                             "text": "请简单描述您最近一周的饮食和作息情况。"}}

+ 0 - 57
cfc-langgraph/src/qna/prompts.py

@@ -1,57 +0,0 @@
-"""qna 引擎 - prompt 模板(场景可配维度,引擎不硬编码)"""
-import json
-
-
-def build_decide_prompt(scene: dict, knowledge_text: str, history: list) -> str:
-    kb = knowledge_text or "(知识库未命中,请基于通用健康知识回答)"
-    hist = "\n".join(
-        f"Q{i+1}: {h['question']['text']} → 答: {h['answer']}" for i, h in enumerate(history)
-    ) or "(问卷刚开始)"
-    dims = json.dumps(scene.get("dimensions_json", {}), ensure_ascii=False)
-    return f"""你是智能健康问卷助手。根据用户已答内容,动态生成下一题,用于最终生成用户画像与需求画像。
-
-【场景】{scene.get('scene_name', scene['scene_key'])}
-【开场引导】{scene.get('opening_prompt', '')}
-【画像维度】(出题时请围绕这些维度收集信息) {dims}
-【知识库参考】
-{kb}
-
-【用户历史回答】
-{hist}
-
-【规则】
-1. 若信息已足够覆盖画像维度,输出 finish;否则输出 ask 出一题。
-2. 题目类型 single=单选(带options) / multi=多选(带options) / scale=量表(带scale) / text=自由文本。
-3. 问题需结合知识库内容与用户回答,有针对性;不要问与已答重复的信息。
-4. 严格输出 JSON,不要输出其他内容:
-{{"action": "ask"|"finish", "question": {{"id": "q1", "type": "single", "text": "题目", "options": [{{"id": "a", "label": "选项"}}]}}, "reason": "简短说明"}}
-"""
-
-
-def build_profile_prompt(scene: dict, knowledge_text: str, history: list) -> str:
-    kb = knowledge_text or "(知识库未命中)"
-    hist = "\n".join(
-        f"Q{i+1}: {h['question']['text']} → 答: {h['answer']}" for i, h in enumerate(history)
-    )
-    dims = json.dumps(scene.get("dimensions_json", {}), ensure_ascii=False)
-    return f"""你是智能健康分析助手。基于用户问卷回答与知识库,生成用户画像与需求画像。
-
-【画像维度定义】(维度名称由场景配置,不得新增未定义维度)
-{dims}
-
-【知识库参考】(引用其中的菌属/营养素/指标知识作为依据)
-{kb}
-
-【用户问卷历史】
-{hist}
-
-【输出要求】严格 JSON:
-{{
-  "user_profile": [
-    {{"dimension": "维度名", "score": 0-100, "description": "分析", "evidence": ["证据1"]}}
-  ],
-  "need_profile": [
-    {{"dimension": "维度名", "description": "需求分析", "evidence": ["证据"], "suggestion": "建议"}}
-  ]
-}}
-"""

+ 0 - 72
cfc-langgraph/src/qna/router.py

@@ -1,72 +0,0 @@
-"""qna 动态问卷引擎 - HTTP 端点"""
-from fastapi import APIRouter
-from typing import List, Optional
-
-from .schemas import QnaRequest, QnaResponse, ProfileResponse
-from . import graph
-from .graph import retrieve_and_decide
-from .prompts import build_decide_prompt, build_profile_prompt
-from app.rag.retriever import RagRetriever
-
-router = APIRouter(prefix="/api/v1/qna", tags=["qna"])
-
-_retriever: Optional[RagRetriever] = None
-
-
-def _get_retriever() -> Optional[RagRetriever]:
-    global _retriever
-    if _retriever is None:
-        try:
-            _retriever = RagRetriever()
-        except Exception:
-            _retriever = None  # 知识库不可用 → 降级
-    return _retriever
-
-
-async def _retrieve(kb_scope: List[str], history: List[dict]) -> list:
-    retriever = _get_retriever()
-    if retriever is None:
-        return []
-    try:
-        query = " ".join([h.get("answer", "") for h in history]) or "肠道健康 饮食习惯"
-        return await retriever.retrieve(query, k=5)  # filter 按 scope 由 retriever 支持后接入
-    except Exception:
-        return []
-
-
-def _build_llm():
-    try:
-        from src.llm.client import get_llm
-        return get_llm()
-    except Exception:
-        from langchain_openai import ChatOpenAI
-        import os
-        return ChatOpenAI(model=os.getenv("LLM_MODEL", "deepseek"),
-                          api_key=os.getenv("LLM_API_KEY", ""),
-                          base_url=os.getenv("LLM_BASE_URL", "https://api.deepseek.com/v1"),
-                          temperature=0.7)
-
-
-@router.post("/advance", response_model=QnaResponse)
-async def advance(req: QnaRequest):
-    scene = req.scene.dict()
-    history = [h.dict() for h in req.history]
-    llm = _build_llm()
-    kb_context = await _retrieve(scene.get("kb_scope", []), history)
-    # 知识注入版 decide:检索后带知识出题
-    state = retrieve_and_decide(llm, scene, history, kb_context, build_decide_prompt)
-    if state.get("fallback"):
-        return QnaResponse(action="ask", question=state["question"], reason=state.get("reason"), finished=False)
-    if state["action"] == "finish":
-        return QnaResponse(action="finish", finished=True, reason=state.get("reason", "信息已足够"))
-    return QnaResponse(action="ask", question=state["question"], finished=False)
-
-
-@router.post("/profile", response_model=ProfileResponse)
-async def profile(req: QnaRequest):
-    scene = req.scene.dict()
-    history = [h.dict() for h in req.history]
-    llm = _build_llm()
-    kb_context = await _retrieve(scene.get("kb_scope", []), history)
-    profile_json, kb_used = graph.generate_profile(llm, scene, history, kb_context, build_profile_prompt)
-    return ProfileResponse(profile=profile_json, kb_used=kb_used)

+ 0 - 61
cfc-langgraph/src/qna/schemas.py

@@ -1,61 +0,0 @@
-"""qna 动态问卷引擎 - Pydantic 模型"""
-from typing import List, Optional, Union
-from pydantic import BaseModel, Field
-
-
-class Option(BaseModel):
-    id: str
-    label: str
-
-
-class Scale(BaseModel):
-    min: int = 0
-    max: int = 10
-    minLabel: str = "从不"
-    maxLabel: str = "每天"
-
-
-class Question(BaseModel):
-    id: str
-    type: str = Field(pattern="^(single|multi|scale|text)$")
-    text: str
-    options: Optional[List[Option]] = None
-    scale: Optional[Scale] = None
-
-    def is_valid(self) -> bool:
-        if self.type in ("single", "multi"):
-            return bool(self.options) and not self.scale
-        if self.type == "scale":
-            return self.scale is not None
-        return True  # text
-
-
-class HistoryItem(BaseModel):
-    question: Question
-    answer: str
-
-
-class SceneConfig(BaseModel):
-    scene_key: str
-    opening_prompt: str = ""
-    dimensions_json: dict = {}
-    kb_scope: List[str] = ["microbiome"]
-    max_questions: int = 12
-    system_prompt: Optional[str] = None
-
-
-class QnaRequest(BaseModel):
-    scene: SceneConfig
-    history: List[HistoryItem] = []
-
-
-class QnaResponse(BaseModel):
-    action: str  # ask | finish
-    question: Optional[Question] = None
-    reason: Optional[str] = None
-    finished: bool = False
-
-
-class ProfileResponse(BaseModel):
-    profile: dict
-    kb_used: bool = False

+ 0 - 13
cfc-web/src/api/aiQuestionnaire.js

@@ -1,13 +0,0 @@
-import request from '@/utils/request'
-
-export function listScenes(params) {
-  return request({ url: '/api/ai-questionnaire/scene/list', method: 'post', data: params })
-}
-
-export function saveScene(scene) {
-  return request({ url: '/api/ai-questionnaire/scene/save', method: 'post', data: scene })
-}
-
-export function deleteScene(id) {
-  return request({ url: '/api/ai-questionnaire/scene/delete', method: 'post', data: { id: id } })
-}

+ 0 - 6
cfc-web/src/router/index.js

@@ -588,12 +588,6 @@ const routes = [
           meta: { title: '通知模板管理', perm: 'admin:all' }
           meta: { title: '通知模板管理', perm: 'admin:all' }
         },
         },
         {
         {
-          path: 'ai-questionnaire-scenes',
-          name: 'AiQuestionnaireScenes',
-          component: () => import('@/views/admin/AiQuestionnaireScenes.vue'),
-          meta: { title: 'AI 问卷场景配置', perm: 'admin:all' }
-        },
-      {
         path: 'activity-review',
         path: 'activity-review',
         name: 'ActivityReview',
         name: 'ActivityReview',
         component: () => import('@/views/admin/ActivityReview.vue'),
         component: () => import('@/views/admin/ActivityReview.vue'),

+ 0 - 185
cfc-web/src/views/admin/AiQuestionnaireScenes.vue

@@ -1,185 +0,0 @@
-<template>
-  <div class="ai-q-scenes">
-    <div class="toolbar">
-      <el-button type="primary" size="small" @click="openEdit()">新增场景</el-button>
-    </div>
-    <el-table :data="scenes" border stripe size="small">
-      <el-table-column prop="sceneKey" label="场景标识" width="140" />
-      <el-table-column prop="sceneName" label="场景名称" width="160" />
-      <el-table-column prop="description" label="描述" show-overflow-tooltip />
-      <el-table-column prop="kbScope" label="知识库范围" width="160" />
-      <el-table-column prop="maxQuestions" label="题数上限" width="90" />
-      <el-table-column label="启用" width="80">
-        <template slot-scope="{ row }">
-          <el-tag :type="row.enabled === 1 ? 'success' : 'info'" size="mini">{{ row.enabled === 1 ? '是' : '否' }}</el-tag>
-        </template>
-      </el-table-column>
-      <el-table-column label="操作" width="180">
-        <template slot-scope="{ row }">
-          <el-button size="mini" type="primary" @click="openEdit(row)">编辑</el-button>
-          <el-button size="mini" type="danger" @click="onDelete(row)">删除</el-button>
-        </template>
-      </el-table-column>
-    </el-table>
-
-    <el-dialog :title="form.id ? '编辑场景' : '新增场景'" :visible.sync="dialogVisible" width="640px" append-to-body>
-      <el-form :model="form" label-width="110px" size="small">
-        <el-form-item label="场景标识" required>
-          <el-input v-model="form.sceneKey" placeholder="如 microbiome" :disabled="!!form.id" />
-        </el-form-item>
-        <el-form-item label="场景名称" required>
-          <el-input v-model="form.sceneName" placeholder="如 菌群健康评估" />
-        </el-form-item>
-        <el-form-item label="描述">
-          <el-input v-model="form.description" type="textarea" :rows="2" />
-        </el-form-item>
-        <el-form-item label="开场引导">
-          <el-input v-model="form.openingPrompt" type="textarea" :rows="3" placeholder="AI 出第一题前的引导语" />
-        </el-form-item>
-        <el-form-item label="画像维度定义">
-          <el-input v-model="form.dimensionsJson" type="textarea" :rows="5" placeholder='{"user":["肠道菌群状态"],"need":["营养需求"]}' />
-        </el-form-item>
-        <el-form-item label="知识库范围">
-          <el-select v-model="kbScopeArr" multiple placeholder="选择知识库源" style="width:100%">
-            <el-option label="菌群知识库" value="microbiome" />
-            <el-option label="统一知识库" value="dan_knowledge" />
-            <el-option label="文章" value="article" />
-          </el-select>
-        </el-form-item>
-        <el-form-item label="题数上限">
-          <el-input-number v-model="form.maxQuestions" :min="5" :max="30" style="width:120px" />
-        </el-form-item>
-        <el-form-item label="系统提示词">
-          <el-input v-model="form.systemPrompt" type="textarea" :rows="3" />
-        </el-form-item>
-        <el-form-item label="启用">
-          <el-switch v-model="form.enabled" :active-value="1" :inactive-value="0" />
-        </el-form-item>
-      </el-form>
-      <div slot="footer">
-        <el-button size="small" @click="dialogVisible = false">取消</el-button>
-        <el-button type="primary" size="small" @click="onSave">保存</el-button>
-      </div>
-    </el-dialog>
-  </div>
-</template>
-
-<script>
-import { listScenes, saveScene, deleteScene } from '@/api/aiQuestionnaire'
-
-export default {
-  name: 'AiQuestionnaireScenes',
-  data() {
-    return {
-      scenes: [],
-      dialogVisible: false,
-      kbScopeArr: [],
-      form: {
-        id: null,
-        sceneKey: '',
-        sceneName: '',
-        description: '',
-        openingPrompt: '',
-        dimensionsJson: '',
-        kbScope: '',
-        maxQuestions: 12,
-        systemPrompt: '',
-        enabled: 1
-      }
-    }
-  },
-  created() {
-    this.load()
-  },
-  methods: {
-    async load() {
-      try {
-        var res = await listScenes({})
-        if (res.code === 200) {
-          this.scenes = res.data || []
-        } else {
-          this.$message.error(res.message || '加载失败')
-        }
-      } catch (e) {
-        this.$message.error('加载失败')
-      }
-    },
-    openEdit(row) {
-      if (row) {
-        this.form = {
-          id: row.id,
-          sceneKey: row.sceneKey || '',
-          sceneName: row.sceneName || '',
-          description: row.description || '',
-          openingPrompt: row.openingPrompt || '',
-          dimensionsJson: row.dimensionsJson || '',
-          kbScope: row.kbScope || '',
-          maxQuestions: row.maxQuestions || 12,
-          systemPrompt: row.systemPrompt || '',
-          enabled: row.enabled === 1 ? 1 : 0
-        }
-      } else {
-        this.form = {
-          id: null,
-          sceneKey: '',
-          sceneName: '',
-          description: '',
-          openingPrompt: '',
-          dimensionsJson: '',
-          kbScope: '',
-          maxQuestions: 12,
-          systemPrompt: '',
-          enabled: 1
-        }
-      }
-      this.kbScopeArr = this.form.kbScope ? this.form.kbScope.split(',').filter(function(s) { return s }) : []
-      this.dialogVisible = true
-    },
-    async onSave() {
-      if (!this.form.sceneKey || !this.form.sceneName) {
-        this.$message.warning('请填写场景标识与名称')
-        return
-      }
-      this.form.kbScope = (this.kbScopeArr || []).join(',')
-      try {
-        var res = await saveScene(this.form)
-        if (res.code === 200) {
-          this.$message.success('保存成功')
-          this.dialogVisible = false
-          this.load()
-        } else {
-          this.$message.error(res.message || '保存失败')
-        }
-      } catch (e) {
-        this.$message.error('保存失败')
-      }
-    },
-    onDelete(row) {
-      this.$confirm('删除后不可恢复(已有问卷记录将被拒绝),确认删除?', '提示', { type: 'warning' })
-        .then(async () => {
-          try {
-            var res = await deleteScene(row.id)
-            if (res.code === 200) {
-              this.$message.success('已删除')
-              this.load()
-            } else {
-              this.$message.error(res.message || '删除失败')
-            }
-          } catch (e) {
-            this.$message.error('删除失败')
-          }
-        })
-        .catch(function() {})
-    }
-  }
-}
-</script>
-
-<style scoped>
-.ai-q-scenes {
-  padding: 16px;
-}
-.toolbar {
-  margin-bottom: 16px;
-}
-</style>