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

chore: auto bump version and changelog [skip ci]

iwt 3 месяцев назад
Родитель
Сommit
9a5acd1514

+ 6 - 6
cfc-backend/src/test/java/com/etotem/cfc/controller/TaskControllerTest.java

@@ -111,7 +111,7 @@ public class TaskControllerTest {
         );
         );
         when(taskService.getTodayTasks(10L)).thenReturn(tasks);
         when(taskService.getTodayTasks(10L)).thenReturn(tasks);
 
 
-        Result<List<Task>> result = controller.getTodayTasks(params);
+        Result<List<Task>> result = controller.getTodayTasks(params, 10L);
         assertEquals(200, result.getCode());
         assertEquals(200, result.getCode());
         assertEquals(2, result.getData().size());
         assertEquals(2, result.getData().size());
     }
     }
@@ -131,7 +131,7 @@ public class TaskControllerTest {
 
 
         when(taskService.completeTask(1L, 10L, "http://example.com/photo.jpg")).thenReturn(completionResult);
         when(taskService.completeTask(1L, 10L, "http://example.com/photo.jpg")).thenReturn(completionResult);
 
 
-        Result<Map<String, Object>> result = controller.completeTask(1L, dto);
+        Result<Map<String, Object>> result = controller.completeTask(1L, dto, 10L);
         assertEquals(200, result.getCode());
         assertEquals(200, result.getCode());
         assertEquals(5, result.getData().get("pointsEarned"));
         assertEquals(5, result.getData().get("pointsEarned"));
     }
     }
@@ -184,7 +184,7 @@ public class TaskControllerTest {
 
 
         when(taskService.getTaskHistory(10L, 1, 10)).thenReturn(page);
         when(taskService.getTaskHistory(10L, 1, 10)).thenReturn(page);
 
 
-        Result<Page<Task>> result = controller.getTaskHistory(params);
+        Result<Page<Task>> result = controller.getTaskHistory(params, 10L);
         assertEquals(200, result.getCode());
         assertEquals(200, result.getCode());
         assertEquals(2, result.getData().getTotal());
         assertEquals(2, result.getData().getTotal());
     }
     }
@@ -200,7 +200,7 @@ public class TaskControllerTest {
 
 
         when(taskService.getTaskHistory(10L, 1, 10)).thenReturn(page);
         when(taskService.getTaskHistory(10L, 1, 10)).thenReturn(page);
 
 
-        Result<Page<Task>> result = controller.getTaskHistory(params);
+        Result<Page<Task>> result = controller.getTaskHistory(params, 10L);
         assertEquals(200, result.getCode());
         assertEquals(200, result.getCode());
         assertEquals(0, result.getData().getTotal());
         assertEquals(0, result.getData().getTotal());
     }
     }
@@ -278,7 +278,7 @@ public class TaskControllerTest {
 
 
         when(taskService.completeMinigameTask(1L, 10L, 25, 85)).thenReturn(gameResult);
         when(taskService.completeMinigameTask(1L, 10L, 25, 85)).thenReturn(gameResult);
 
 
-        Result<Map<String, Object>> result = controller.completeMinigameTask(1L, body);
+        Result<Map<String, Object>> result = controller.completeMinigameTask(1L, body, 10L);
         assertEquals(200, result.getCode());
         assertEquals(200, result.getCode());
         assertEquals(7, result.getData().get("pointsEarned"));
         assertEquals(7, result.getData().get("pointsEarned"));
     }
     }
@@ -294,7 +294,7 @@ public class TaskControllerTest {
 
 
         when(taskService.completeMinigameTask(eq(1L), eq(10L), isNull(), isNull())).thenReturn(gameResult);
         when(taskService.completeMinigameTask(eq(1L), eq(10L), isNull(), isNull())).thenReturn(gameResult);
 
 
-        Result<Map<String, Object>> result = controller.completeMinigameTask(1L, body);
+        Result<Map<String, Object>> result = controller.completeMinigameTask(1L, body, 10L);
         assertEquals(200, result.getCode());
         assertEquals(200, result.getCode());
     }
     }
 
 

+ 4 - 4
cfc-backend/src/test/java/com/etotem/cfc/service/TaskServiceMinigameTest.java

@@ -38,7 +38,7 @@ public class TaskServiceMinigameTest {
         dto.setDescription("测试描述");
         dto.setDescription("测试描述");
         dto.setCategory("小游戏类");
         dto.setCategory("小游戏类");
         dto.setMinigameCode(game.getGameCode());
         dto.setMinigameCode(game.getGameCode());
-        dto.setChildId(1L);
+        dto.setMemberId(1L);
         dto.setExecutorType("child");
         dto.setExecutorType("child");
 
 
         Long taskId = taskService.createTask(1L, dto);
         Long taskId = taskService.createTask(1L, dto);
@@ -61,7 +61,7 @@ public class TaskServiceMinigameTest {
         dto.setTitle("积分测试任务");
         dto.setTitle("积分测试任务");
         dto.setCategory("小游戏类");
         dto.setCategory("小游戏类");
         dto.setMinigameCode(game.getGameCode());
         dto.setMinigameCode(game.getGameCode());
-        dto.setChildId(1L);
+        dto.setMemberId(1L);
         dto.setExecutorType("child");
         dto.setExecutorType("child");
 
 
         Long taskId = taskService.createTask(1L, dto);
         Long taskId = taskService.createTask(1L, dto);
@@ -77,7 +77,7 @@ public class TaskServiceMinigameTest {
         dto.setTitle("无效小游戏任务");
         dto.setTitle("无效小游戏任务");
         dto.setCategory("小游戏类");
         dto.setCategory("小游戏类");
         dto.setMinigameCode("invalid_code_xyz");
         dto.setMinigameCode("invalid_code_xyz");
-        dto.setChildId(1L);
+        dto.setMemberId(1L);
         dto.setExecutorType("child");
         dto.setExecutorType("child");
 
 
         Long taskId = taskService.createTask(1L, dto);
         Long taskId = taskService.createTask(1L, dto);
@@ -89,7 +89,7 @@ public class TaskServiceMinigameTest {
         CreateTaskDTO dto = new CreateTaskDTO();
         CreateTaskDTO dto = new CreateTaskDTO();
         dto.setTitle("小游戏类但无code");
         dto.setTitle("小游戏类但无code");
         dto.setCategory("小游戏类");
         dto.setCategory("小游戏类");
-        dto.setChildId(1L);
+        dto.setMemberId(1L);
         dto.setExecutorType("child");
         dto.setExecutorType("child");
 
 
         Long taskId = taskService.createTask(1L, dto);
         Long taskId = taskService.createTask(1L, dto);

+ 1 - 0
cfc-backend/src/test/java/com/etotem/cfc/service/VirtualPayServiceTest.java

@@ -23,6 +23,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
 import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSON;
 import com.etotem.cfc.common.HmacSignUtil;
 import com.etotem.cfc.common.HmacSignUtil;
 import com.etotem.cfc.dto.VirtualPayParamsDTO;
 import com.etotem.cfc.dto.VirtualPayParamsDTO;
+import com.etotem.cfc.entity.AssessmentOrder;
 import com.etotem.cfc.entity.MemberSubscriptionOrder;
 import com.etotem.cfc.entity.MemberSubscriptionOrder;
 import com.etotem.cfc.entity.PaymentOrder;
 import com.etotem.cfc.entity.PaymentOrder;
 import com.etotem.cfc.entity.VirtualGoodsConfig;
 import com.etotem.cfc.entity.VirtualGoodsConfig;

+ 45 - 0
cfc-langgraph/README.md

@@ -0,0 +1,45 @@
+# CFC LangGraph 问卷生成服务
+
+关系评估问卷 AI 出题 graph,部署到 `ai.etotem.com.cn`。
+
+## 启动
+
+```bash
+cd cfc-langgraph
+pip install -r requirements.txt
+cp .env.example .env  # 填写 OPENAI_API_KEY / OPENAI_BASE_URL / LLM_MODEL
+uvicorn src.app:app --host 0.0.0.0 --port 9000
+```
+
+## 端点
+
+| Method | Path | 说明 |
+|--------|------|------|
+| GET | `/health` | 健康检查 |
+| POST | `/api/v1/questionnaire/generate` | 生成关系问卷 JSON |
+
+## 测试
+
+```bash
+cd cfc-langgraph
+pytest tests/ -v
+```
+
+## 契约
+
+Request:
+```json
+{
+  "member_name": "小明",
+  "relationship_type": "child",
+  "family_context": {}
+}
+```
+
+Response:
+```json
+{
+  "questionnaire_json": "<stringified JSON>",
+  "version": "20260804115900"
+}
+```

+ 47 - 0
cfc-langgraph/src/app.py

@@ -0,0 +1,47 @@
+import os
+import json
+import uuid
+from datetime import datetime
+
+from dotenv import load_dotenv
+load_dotenv()
+
+from fastapi import FastAPI
+from fastapi.responses import JSONResponse
+
+from .schemas.questionnaire import GenerateRequest, GenerateResponse
+from .graphs.questionnaire import get_questionnaire_graph
+
+app = FastAPI(title="CFC LangGraph 问卷生成服务")
+
+
+@app.get("/health")
+def health():
+    return {"status": "ok"}
+
+
+@app.post("/api/v1/questionnaire/generate")
+async def generate_questionnaire(req: GenerateRequest):
+    graph = get_questionnaire_graph()
+    try:
+        result = graph.invoke({
+            "request": req,
+            "raw_response": "",
+            "questionnaire": None,
+            "error": None,
+        })
+        if result.get("error"):
+            return JSONResponse(
+                status_code=500,
+                content={"error": result["error"]}
+            )
+        version = datetime.now().strftime("%Y%m%d%H%M%S")
+        return GenerateResponse(
+            questionnaire_json=json.dumps(result["questionnaire"], ensure_ascii=False),
+            version=version,
+        )
+    except Exception as e:
+        return JSONResponse(
+            status_code=500,
+            content={"error": f"graph 执行失败: {str(e)}"}
+        )

+ 70 - 0
cfc-langgraph/src/graphs/questionnaire.py

@@ -0,0 +1,70 @@
+import json
+from typing import TypedDict, Optional
+from langgraph.graph import StateGraph, START, END
+from langchain_core.messages import HumanMessage
+
+from ..llm.client import get_llm
+from ..prompts.questionnaire import PARENT_TEMPLATE, CHILD_TEMPLATE
+from ..schemas.questionnaire import Questionnaire, GenerateRequest
+
+
+class GraphState(TypedDict):
+    request: GenerateRequest
+    raw_response: str
+    questionnaire: Optional[dict]
+    error: Optional[str]
+
+
+def build_prompt(state: GraphState) -> GraphState:
+    req = state["request"]
+    template = PARENT_TEMPLATE if req.relationship_type == "parent" else CHILD_TEMPLATE
+    prompt = template.format(member_name=req.member_name)
+    return {**state, "_prompt": prompt}
+
+
+def call_llm(state: GraphState) -> GraphState:
+    llm = get_llm()
+    messages = [HumanMessage(content=state["_prompt"])]
+    response = llm.invoke(messages)
+    return {**state, "raw_response": response.content}
+
+
+def validate(state: GraphState) -> GraphState:
+    raw = state["raw_response"]
+    # 提取 JSON 块
+    text = raw.strip()
+    if "```json" in text:
+        text = text.split("```json")[1].split("```")[0].strip()
+    elif "```" in text:
+        text = text.split("```")[1].split("```")[0].strip()
+
+    try:
+        data = json.loads(text)
+        q = Questionnaire(**data)
+        if not q.validate_structure():
+            return {**state, "error": "问卷结构校验失败:至少需要1题且每题有options或scale"}
+        return {**state, "questionnaire": data, "error": None}
+    except Exception as e:
+        return {**state, "error": f"JSON 解析失败: {str(e)}"}
+
+
+def build_questionnaire_graph():
+    graph = StateGraph(GraphState)
+    graph.add_node("build_prompt", build_prompt)
+    graph.add_node("call_llm", call_llm)
+    graph.add_node("validate", validate)
+    graph.add_edge(START, "build_prompt")
+    graph.add_edge("build_prompt", "call_llm")
+    graph.add_edge("call_llm", "validate")
+    graph.add_edge("validate", END)
+    return graph.compile()
+
+
+_questionnaire_graph = None
+
+
+def get_questionnaire_graph():
+    global _questionnaire_graph
+    if _questionnaire_graph is None:
+        _questionnaire_graph = build_questionnaire_graph()
+    return _questionnaire_graph

+ 11 - 0
cfc-langgraph/src/llm/client.py

@@ -0,0 +1,11 @@
+import os
+from langchain_openai import ChatOpenAI
+
+
+def get_llm() -> ChatOpenAI:
+    return ChatOpenAI(
+        model=os.getenv("LLM_MODEL", "gpt-4o-mini"),
+        api_key=os.getenv("OPENAI_API_KEY", ""),
+        base_url=os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1"),
+        temperature=0.7,
+    )

+ 55 - 0
cfc-langgraph/src/prompts/questionnaire.py

@@ -0,0 +1,55 @@
+PARENT_TEMPLATE = """你是一位专业的家庭关系评估顾问。
+请为以下家庭成员生成一份关系评估问卷,用于评估填写者(家长)与该成员({member_name},关系:孩子)之间的关系质量。
+
+要求:
+- 生成 8-12 道题目
+- 每道题覆盖 trust / intimacy / communication 三个维度之一
+- 每道题有 3-5 个选项,选项 score 从 0 递增
+- 部分题目可设置 direction="negative"(反向计分题)
+- 返回严格 JSON,格式如下(不要输出其他内容):
+{{
+  "version": 1,
+  "questions": [
+    {{
+      "id": "q1",
+      "dimension": "trust",
+      "direction": "positive",
+      "weight": 1.0,
+      "text": "题目文字",
+      "options": [
+        {{"id": "a", "score": 0}},
+        {{"id": "b", "score": 1}},
+        {{"id": "c", "score": 2}}
+      ]
+    }}
+  ]
+}}
+"""
+
+CHILD_TEMPLATE = """你是一位专业的家庭关系评估顾问。
+请为以下家庭成员生成一份关系评估问卷,用于评估填写者(家长)与该成员({member_name},关系:孩子)之间的关系质量。
+
+要求:
+- 生成 8-12 道题目
+- 每道题覆盖 trust / intimacy / communication 三个维度之一
+- 每道题有 3-5 个选项,选项 score 从 0 递增
+- 部分题目可设置 direction="negative"(反向计分题)
+- 返回严格 JSON,格式如下(不要输出其他内容):
+{{
+  "version": 1,
+  "questions": [
+    {{
+      "id": "q1",
+      "dimension": "trust",
+      "direction": "positive",
+      "weight": 1.0,
+      "text": "题目文字",
+      "options": [
+        {{"id": "a", "score": 0}},
+        {{"id": "b", "score": 1}},
+        {{"id": "c", "score": 2}}
+      ]
+    }}
+  ]
+}}
+"""

+ 47 - 0
cfc-langgraph/src/schemas/questionnaire.py

@@ -0,0 +1,47 @@
+from pydantic import BaseModel, Field
+from typing import List, Optional, Union
+
+
+class Option(BaseModel):
+    id: str
+    score: float
+
+
+class Scale(BaseModel):
+    min: float = 0
+    max: float = 4
+
+
+class Question(BaseModel):
+    id: str
+    dimension: str = Field(pattern="^(trust|intimacy|communication)$")
+    direction: str = Field(default="positive", pattern="^(positive|negative)$")
+    weight: float = 1.0
+    text: str
+    options: Optional[List[Option]] = None
+    scale: Optional[Scale] = None
+
+
+class Questionnaire(BaseModel):
+    version: int = 1
+    questions: List[Question]
+
+    def validate_structure(self) -> bool:
+        """至少 1 题,每题有 options 或 scale"""
+        if not self.questions:
+            return False
+        for q in self.questions:
+            if q.options is None and q.scale is None:
+                return False
+        return True
+
+
+class GenerateRequest(BaseModel):
+    member_name: str
+    relationship_type: str = Field(pattern="^(parent|child)$")
+    family_context: Optional[dict] = None
+
+
+class GenerateResponse(BaseModel):
+    questionnaire_json: str
+    version: str

+ 88 - 0
cfc-langgraph/tests/test_graph.py

@@ -0,0 +1,88 @@
+import json
+from unittest.mock import patch, MagicMock
+from src.schemas.questionnaire import GenerateRequest, Questionnaire, Question, Option
+
+
+def _mock_llm_response():
+    """返回合规 JSON 的 mock LLM 响应"""
+    mock_msg = MagicMock()
+    mock_msg.content = json.dumps({
+        "version": 1,
+        "questions": [
+            {
+                "id": "q1",
+                "dimension": "trust",
+                "direction": "positive",
+                "weight": 1.0,
+                "text": "您对小明的信任程度如何?",
+                "options": [
+                    {"id": "a", "score": 0},
+                    {"id": "b", "score": 1},
+                    {"id": "c", "score": 2},
+                ],
+            }
+        ]
+    }, ensure_ascii=False)
+    return mock_msg
+
+
+def _mock_llm_invalid_response():
+    mock_msg = MagicMock()
+    mock_msg.content = "这不是JSON"
+    return mock_msg
+
+
+class TestQuestionnaireSchema:
+    def test_valid_questionnaire(self):
+        q = Questionnaire(version=1, questions=[
+            Question(id="q1", dimension="trust", text="test",
+                     options=[Option(id="a", score=0)])
+        ])
+        assert q.validate_structure() is True
+
+    def test_empty_questions_fails_validation(self):
+        q = Questionnaire(version=1, questions=[])
+        assert q.validate_structure() is False
+
+    def test_question_without_options_or_scale_fails(self):
+        q = Questionnaire(version=1, questions=[
+            Question(id="q1", dimension="trust", text="test")
+        ])
+        assert q.validate_structure() is False
+
+
+class TestGraph:
+    def test_graph_valid_output(self):
+        with patch("src.llm.client.get_llm") as mock_get_llm:
+            mock_llm = MagicMock()
+            mock_llm.invoke.return_value = _mock_llm_response()
+            mock_get_llm.return_value = mock_llm
+
+            from src.graphs.questionnaire import build_questionnaire_graph
+            graph = build_questionnaire_graph()
+            result = graph.invoke({
+                "request": GenerateRequest(member_name="小明", relationship_type="child"),
+                "raw_response": "",
+                "questionnaire": None,
+                "error": None,
+            })
+            assert result["error"] is None
+            assert result["questionnaire"] is not None
+            assert len(result["questionnaire"]["questions"]) == 1
+
+    def test_graph_invalid_llm_response_rejected(self):
+        with patch("src.llm.client.get_llm") as mock_get_llm:
+            mock_llm = MagicMock()
+            mock_llm.invoke.return_value = _mock_llm_invalid_response()
+            mock_get_llm.return_value = mock_llm
+
+            from src.graphs.questionnaire import build_questionnaire_graph
+            graph = build_questionnaire_graph()
+            result = graph.invoke({
+                "request": GenerateRequest(member_name="小明", relationship_type="child"),
+                "raw_response": "",
+                "questionnaire": None,
+                "error": None,
+            })
+            assert result["error"] is not None
+            assert result["questionnaire"] is None

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-598a8f6d780f7ea19b1d8dc976cb50260ab8e2f9
+96583b835ff7ff18b85b6ff8a8a7413217964e91

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
 {
   "name": "cfc-web",
   "name": "cfc-web",
-  "version": "1.0.799",
+  "version": "1.0.800",
   "lockfileVersion": 3,
   "lockfileVersion": 3,
   "requires": true,
   "requires": true,
   "packages": {
   "packages": {
     "": {
     "": {
       "name": "cfc-web",
       "name": "cfc-web",
-      "version": "1.0.799",
+      "version": "1.0.800",
       "dependencies": {
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",
         "@wangeditor/editor-for-vue": "^1.0.2",