|
|
@@ -0,0 +1,1506 @@
|
|
|
+# 关系问卷与互动记录功能补全 实施计划
|
|
|
+
|
|
|
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
+
|
|
|
+**Goal:** 修复关系问卷与互动记录两大功能的阻塞性缺陷(3 张表缺失 + 编辑资料 bug),并补齐 AI 出题(LangGraph)、评分算法、跨家庭权限校验。
|
|
|
+
|
|
|
+**Architecture:** 三横一纵——后端补 3 张表 + 评分算法 + 权限校验(Java 侧),前端修复 FamilyMemberStrip 编辑 bug,新增 `cfc-langgraph/` Python 项目做 LangGraph 出题 graph,`AiGateway` 统一调用入口。
|
|
|
+
|
|
|
+**Tech Stack:** Java 8 / Spring Boot 2.7.18 / MyBatis-Plus / Python 3.11+ / LangGraph / FastAPI / Mockito 5 / uni-app Vue 2
|
|
|
+
|
|
|
+**Spec:** `docs/superpowers/specs/2026-08-04-relationship-feedback-fix-design.md`
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## 文件结构总览
|
|
|
+
|
|
|
+| 操作 | 文件路径 |
|
|
|
+|------|---------|
|
|
|
+| **Modify** | `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java` |
|
|
|
+| **Modify** | `cfc-backend/src/main/resources/schema.sql` |
|
|
|
+| **Modify** | `cfc-backend/src/main/java/com/etotem/cfc/service/RelationshipQuestionnaireService.java` |
|
|
|
+| **Modify** | `cfc-backend/src/main/java/com/etotem/cfc/service/InteractionLogService.java` |
|
|
|
+| **Modify** | `cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java` |
|
|
|
+| **Modify** | `cfc-backend/src/main/java/com/etotem/cfc/controller/family/InteractionLogController.java` |
|
|
|
+| **Modify** | `cfc-backend/src/main/java/com/etotem/cfc/controller/family/RelationshipQuestionnaireController.java` |
|
|
|
+| **Modify** | `cfc-frontend/components/FamilyMemberStrip.vue` |
|
|
|
+| **Modify** | `cfc-backend/src/main/resources/application.yml` |
|
|
|
+| **Modify** | `cfc-backend/AGENTS.md` |
|
|
|
+| **Create** | `cfc-backend/src/test/java/com/etotem/cfc/service/RelationshipQuestionnaireServiceTest.java` |
|
|
|
+| **Create** | `cfc-backend/src/test/java/com/etotem/cfc/service/InteractionLogServiceTest.java` |
|
|
|
+| **Create** | `cfc-langgraph/README.md` |
|
|
|
+| **Create** | `cfc-langgraph/pyproject.toml` |
|
|
|
+| **Create** | `cfc-langgraph/requirements.txt` |
|
|
|
+| **Create** | `cfc-langgraph/.env.example` |
|
|
|
+| **Create** | `cfc-langgraph/.gitignore` |
|
|
|
+| **Create** | `cfc-langgraph/src/__init__.py` |
|
|
|
+| **Create** | `cfc-langgraph/src/app.py` |
|
|
|
+| **Create** | `cfc-langgraph/src/graphs/__init__.py` |
|
|
|
+| **Create** | `cfc-langgraph/src/graphs/questionnaire.py` |
|
|
|
+| **Create** | `cfc-langgraph/src/llm/__init__.py` |
|
|
|
+| **Create** | `cfc-langgraph/src/llm/client.py` |
|
|
|
+| **Create** | `cfc-langgraph/src/schemas/__init__.py` |
|
|
|
+| **Create** | `cfc-langgraph/src/schemas/questionnaire.py` |
|
|
|
+| **Create** | `cfc-langgraph/src/prompts/__init__.py` |
|
|
|
+| **Create** | `cfc-langgraph/src/prompts/questionnaire.py` |
|
|
|
+| **Create** | `cfc-langgraph/tests/__init__.py` |
|
|
|
+| **Create** | `cfc-langgraph/tests/test_graph.py` |
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 1: 补建 3 张缺失数据表 + schema.sql 同步
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java` (在 7532 行 `runMigrations()` 末尾、`}` 之前追加)
|
|
|
+- Modify: `cfc-backend/src/main/resources/schema.sql` (文件末尾追加)
|
|
|
+
|
|
|
+- [ ] **Step 1: 在 DatabaseInitializer.java 迁移154/155/156 追加建表**
|
|
|
+
|
|
|
+在 `DatabaseInitializer.java` 的 `runMigrations()` 方法末尾(7532 行 `}` 之前),追加以下代码块:
|
|
|
+
|
|
|
+```java
|
|
|
+ // 迁移154: 创建 interaction_logs 表(关系互动记录)
|
|
|
+ try {
|
|
|
+ jdbcTemplate.execute("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', " +
|
|
|
+ "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 '互动发生时间', " +
|
|
|
+ "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
|
|
|
+ "INDEX idx_ilog_family (family_id), " +
|
|
|
+ "INDEX idx_ilog_to_member (to_member_id) " +
|
|
|
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭成员互动记录'");
|
|
|
+ log.info("已创建interaction_logs表");
|
|
|
+ } catch (Exception e) {
|
|
|
+ // 表已存在,忽略
|
|
|
+ }
|
|
|
+
|
|
|
+ // 迁移155: 创建 relationship_questionnaire_snapshots 表(关系问卷快照)
|
|
|
+ try {
|
|
|
+ jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS relationship_questionnaire_snapshots (" +
|
|
|
+ "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
|
|
|
+ "family_member_id BIGINT NOT NULL COMMENT '家庭成员ID', " +
|
|
|
+ "respondent_id BIGINT NOT NULL COMMENT '答卷人用户ID', " +
|
|
|
+ "member_name VARCHAR(50) COMMENT '成员名称', " +
|
|
|
+ "relationship_type VARCHAR(20) COMMENT '关系类型: parent/child', " +
|
|
|
+ "ai_generated_json TEXT COMMENT 'AI生成的题目JSON', " +
|
|
|
+ "version VARCHAR(20) COMMENT '版本号', " +
|
|
|
+ "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
|
|
|
+ "INDEX idx_q_snap_member (family_member_id) " +
|
|
|
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='关系问卷快照'");
|
|
|
+ log.info("已创建relationship_questionnaire_snapshots表");
|
|
|
+ } catch (Exception e) {
|
|
|
+ // 表已存在,忽略
|
|
|
+ }
|
|
|
+
|
|
|
+ // 迁移156: 创建 relationship_questionnaire_responses 表(关系问卷回答)
|
|
|
+ try {
|
|
|
+ jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS relationship_questionnaire_responses (" +
|
|
|
+ "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
|
|
|
+ "snapshot_id BIGINT NOT NULL COMMENT '问卷快照ID', " +
|
|
|
+ "respondent_id BIGINT NOT NULL COMMENT '答卷人用户ID', " +
|
|
|
+ "answers_json TEXT COMMENT '用户回答JSON', " +
|
|
|
+ "trust_score DECIMAL(5,2) DEFAULT 0 COMMENT '信任度得分', " +
|
|
|
+ "intimacy_score DECIMAL(5,2) DEFAULT 0 COMMENT '亲密度得分', " +
|
|
|
+ "communication_score DECIMAL(5,2) DEFAULT 0 COMMENT '沟通质量得分', " +
|
|
|
+ "interaction_bonus DECIMAL(5,2) DEFAULT 0 COMMENT '互动记录加分', " +
|
|
|
+ "total_score DECIMAL(5,2) DEFAULT 0 COMMENT '综合得分', " +
|
|
|
+ "calculated_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
|
|
|
+ "INDEX idx_q_resp_snap (snapshot_id) " +
|
|
|
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='关系问卷回答记录'");
|
|
|
+ log.info("已创建relationship_questionnaire_responses表");
|
|
|
+ } catch (Exception e) {
|
|
|
+ // 表已存在,忽略
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 在 schema.sql 末尾追加三条 CREATE TABLE**
|
|
|
+
|
|
|
+在 `cfc-backend/src/main/resources/schema.sql` 文件末尾追加:
|
|
|
+
|
|
|
+```sql
|
|
|
+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',
|
|
|
+ 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 '互动发生时间',
|
|
|
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
|
+ INDEX idx_ilog_family (family_id),
|
|
|
+ INDEX idx_ilog_to_member (to_member_id)
|
|
|
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='家庭成员互动记录';
|
|
|
+
|
|
|
+CREATE TABLE IF NOT EXISTS relationship_questionnaire_snapshots (
|
|
|
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
|
+ family_member_id BIGINT NOT NULL COMMENT '家庭成员ID',
|
|
|
+ respondent_id BIGINT NOT NULL COMMENT '答卷人用户ID',
|
|
|
+ member_name VARCHAR(50) COMMENT '成员名称',
|
|
|
+ relationship_type VARCHAR(20) COMMENT '关系类型: parent/child',
|
|
|
+ ai_generated_json TEXT COMMENT 'AI生成的题目JSON',
|
|
|
+ version VARCHAR(20) COMMENT '版本号',
|
|
|
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
|
+ INDEX idx_q_snap_member (family_member_id)
|
|
|
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='关系问卷快照';
|
|
|
+
|
|
|
+CREATE TABLE IF NOT EXISTS relationship_questionnaire_responses (
|
|
|
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
|
+ snapshot_id BIGINT NOT NULL COMMENT '问卷快照ID',
|
|
|
+ respondent_id BIGINT NOT NULL COMMENT '答卷人用户ID',
|
|
|
+ answers_json TEXT COMMENT '用户回答JSON',
|
|
|
+ trust_score DECIMAL(5,2) DEFAULT 0 COMMENT '信任度得分',
|
|
|
+ intimacy_score DECIMAL(5,2) DEFAULT 0 COMMENT '亲密度得分',
|
|
|
+ communication_score DECIMAL(5,2) DEFAULT 0 COMMENT '沟通质量得分',
|
|
|
+ interaction_bonus DECIMAL(5,2) DEFAULT 0 COMMENT '互动记录加分',
|
|
|
+ total_score DECIMAL(5,2) DEFAULT 0 COMMENT '综合得分',
|
|
|
+ calculated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
|
+ INDEX idx_q_resp_snap (snapshot_id)
|
|
|
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='关系问卷回答记录';
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 编译验证**
|
|
|
+
|
|
|
+Run: `cd /sc-data/cfc/cfc-backend && /bwydata/maven/bin/mvn clean compile -q`
|
|
|
+Expected: EXIT 0
|
|
|
+
|
|
|
+- [ ] **Step 4: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /sc-data/cfc
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java cfc-backend/src/main/resources/schema.sql
|
|
|
+git commit -m "fix(backend): 迁移154-156 创建 interaction_logs/questionnaire_snapshots/questionnaire_responses 三张缺失表"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 2: FamilyMemberStrip 编辑资料 bug 修复
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-frontend/components/FamilyMemberStrip.vue` (124-130 行 `editMember` 方法)
|
|
|
+
|
|
|
+- [ ] **Step 1: 修改 editMember 方法**
|
|
|
+
|
|
|
+将 `FamilyMemberStrip.vue` 第 124-130 行的 `editMember` 方法替换为:
|
|
|
+
|
|
|
+```js
|
|
|
+ editMember: function() {
|
|
|
+ var member = this.actionMember
|
|
|
+ this.closeMenu()
|
|
|
+ if (member) {
|
|
|
+ var params = 'memberId=' + member.id + '&nickname=' + encodeURIComponent(member.nickname || '')
|
|
|
+ if (member.gender) params += '&gender=' + member.gender
|
|
|
+ if (member.phone) params += '&phone=' + member.phone
|
|
|
+ if (member.birthday) params += '&birthday=' + member.birthday
|
|
|
+ uni.navigateTo({ url: '/pages/family/add-member?' + params })
|
|
|
+ }
|
|
|
+ },
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 前端构建验证**
|
|
|
+
|
|
|
+Run: `cd /sc-data/cfc/cfc-frontend && npm run build:mp-weixin 2>&1 | tail -3; echo "EXIT: $?"`
|
|
|
+Expected: EXIT 0
|
|
|
+
|
|
|
+- [ ] **Step 3: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /sc-data/cfc
|
|
|
+git add cfc-frontend/components/FamilyMemberStrip.vue
|
|
|
+git commit -m "fix(frontend): FamilyMemberStrip 编辑资料补全 nickname/gender/phone/birthday query 参数"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 3: 跨家庭权限校验 — FamilyMemberService 补 isCallerInSameFamily 方法
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java` (在类内追加新方法)
|
|
|
+
|
|
|
+- [ ] **Step 1: 追加方法**
|
|
|
+
|
|
|
+在 `FamilyMemberService.java` 类内(`kickMember` 方法之后、最后一个 `}` 之前),追加以下方法:
|
|
|
+
|
|
|
+```java
|
|
|
+ /**
|
|
|
+ * 校验操作者是否与指定成员属于同一家庭
|
|
|
+ * @param familyMemberId 被操作的 family_members.id
|
|
|
+ * @param callerUserId 操作者的 JWT userId (users.id)
|
|
|
+ * @return true=同家庭 false=不同家庭或不存在
|
|
|
+ */
|
|
|
+ public boolean isCallerInSameFamily(Long familyMemberId, Long callerUserId) {
|
|
|
+ if (familyMemberId == null || callerUserId == null) return false;
|
|
|
+ FamilyMember member = familyMemberMapper.selectById(familyMemberId);
|
|
|
+ if (member == null) return false;
|
|
|
+ long familyId = member.getFamilyId();
|
|
|
+ long count = familyMemberMapper.selectCount(
|
|
|
+ new LambdaQueryWrapper<FamilyMember>()
|
|
|
+ .eq(FamilyMember::getFamilyId, familyId)
|
|
|
+ .eq(FamilyMember::getUserId, callerUserId)
|
|
|
+ .last("LIMIT 1"));
|
|
|
+ return count > 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 校验操作者是否与指定 familyId 的成员属于同一家庭(用于 list/add)
|
|
|
+ */
|
|
|
+ public boolean isCallerInFamily(Long familyId, Long callerUserId) {
|
|
|
+ if (familyId == null || callerUserId == null) return false;
|
|
|
+ long count = familyMemberMapper.selectCount(
|
|
|
+ new LambdaQueryWrapper<FamilyMember>()
|
|
|
+ .eq(FamilyMember::getFamilyId, familyId)
|
|
|
+ .eq(FamilyMember::getUserId, callerUserId)
|
|
|
+ .last("LIMIT 1"));
|
|
|
+ return count > 0;
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 编译验证**
|
|
|
+
|
|
|
+Run: `cd /sc-data/cfc/cfc-backend && /bwydata/maven/bin/mvn clean compile -q`
|
|
|
+Expected: EXIT 0
|
|
|
+
|
|
|
+- [ ] **Step 3: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /sc-data/cfc
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/FamilyMemberService.java
|
|
|
+git commit -m "feat(backend): FamilyMemberService 补 isCallerInSameFamily/isCallerInFamily 跨家庭校验方法"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 4: 评分算法替换 mock — TDD
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-backend/src/test/java/com/etotem/cfc/service/RelationshipQuestionnaireServiceTest.java`
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/RelationshipQuestionnaireService.java` (195-201 行 `calculateScoresFromAnswers` 方法体替换)
|
|
|
+
|
|
|
+- [ ] **Step 1: 写失败的测试**
|
|
|
+
|
|
|
+创建 `cfc-backend/src/test/java/com/etotem/cfc/service/RelationshipQuestionnaireServiceTest.java`:
|
|
|
+
|
|
|
+```java
|
|
|
+package com.etotem.cfc.service;
|
|
|
+
|
|
|
+import com.etotem.cfc.entity.FamilyMember;
|
|
|
+import com.etotem.cfc.entity.RelationshipQuestionnaireSnapshot;
|
|
|
+import com.etotem.cfc.mapper.FamilyMemberMapper;
|
|
|
+import com.etotem.cfc.mapper.RelationshipQuestionnaireResponseMapper;
|
|
|
+import com.etotem.cfc.mapper.RelationshipQuestionnaireSnapshotMapper;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.mockito.Mock;
|
|
|
+import org.mockito.MockitoAnnotations;
|
|
|
+
|
|
|
+import java.lang.reflect.Method;
|
|
|
+import java.math.BigDecimal;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.*;
|
|
|
+
|
|
|
+/**
|
|
|
+ * RelationshipQuestionnaireService 评分算法单元测试
|
|
|
+ * MockitoExtension — 无 Spring 上下文,无 MySQL 连接
|
|
|
+ */
|
|
|
+public class RelationshipQuestionnaireServiceTest {
|
|
|
+
|
|
|
+ private RelationshipQuestionnaireService service;
|
|
|
+
|
|
|
+ @Mock
|
|
|
+ private RelationshipQuestionnaireSnapshotMapper snapshotMapper;
|
|
|
+ @Mock
|
|
|
+ private RelationshipQuestionnaireResponseMapper responseMapper;
|
|
|
+ @Mock
|
|
|
+ private FamilyMemberMapper familyMemberMapper;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setup() throws Exception {
|
|
|
+ MockitoAnnotations.openMocks(this);
|
|
|
+ service = new RelationshipQuestionnaireService();
|
|
|
+ setField(service, "snapshotMapper", snapshotMapper);
|
|
|
+ setField(service, "responseMapper", responseMapper);
|
|
|
+ setField(service, "familyMemberMapper", familyMemberMapper);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static void setField(Object target, String fieldName, Object value) throws Exception {
|
|
|
+ java.lang.reflect.Field f = target.getClass().getDeclaredField(fieldName);
|
|
|
+ f.setAccessible(true);
|
|
|
+ f.set(target, value);
|
|
|
+ }
|
|
|
+
|
|
|
+ private BigDecimal[] callCalculate(String answersJson, String questionsJson) throws Exception {
|
|
|
+ Method m = RelationshipQuestionnaireService.class
|
|
|
+ .getDeclaredMethod("calculateScoresFromAnswers", String.class, String.class);
|
|
|
+ m.setAccessible(true);
|
|
|
+ return (BigDecimal[]) m.invoke(service, answersJson, questionsJson);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ===== 评分契约:questions 和 answers 均为 Map 字符串 =====
|
|
|
+
|
|
|
+ /** 正向 trust 2 题全选 score=2(满分),intimacy 1 题 score=0(最低分) */
|
|
|
+ @Test
|
|
|
+ void calculateScores_positive_allMax_intimacyMin() throws Exception {
|
|
|
+ String questions = "{\"version\":1,\"questions\":[" +
|
|
|
+ "{\"id\":\"q1\",\"dimension\":\"trust\",\"direction\":\"positive\",\"weight\":1.0," +
|
|
|
+ " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":1},{\"id\":\"c\",\"score\":2}]}," +
|
|
|
+ "{\"id\":\"q2\",\"dimension\":\"trust\",\"direction\":\"positive\",\"weight\":1.0," +
|
|
|
+ " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":1},{\"id\":\"c\",\"score\":2}]}," +
|
|
|
+ "{\"id\":\"q3\",\"dimension\":\"intimacy\",\"direction\":\"positive\",\"weight\":1.0," +
|
|
|
+ " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":1},{\"id\":\"c\",\"score\":2}]}," +
|
|
|
+ "{\"id\":\"q4\",\"dimension\":\"communication\",\"direction\":\"positive\",\"weight\":1.0," +
|
|
|
+ " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":1},{\"id\":\"c\",\"score\":2}]}" +
|
|
|
+ "]}";
|
|
|
+ String answers = "{\"q1\":{\"id\":\"c\",\"score\":2},\"q2\":{\"id\":\"c\",\"score\":2}," +
|
|
|
+ "\"q3\":{\"id\":\"a\",\"score\":0},\"q4\":{\"id\":\"b\",\"score\":1}}";
|
|
|
+
|
|
|
+ BigDecimal[] scores = callCalculate(answers, questions);
|
|
|
+ // trust = mean(100,100) = 100, intimacy = 0, communication = 50
|
|
|
+ assertEquals(0, scores[0].compareTo(new BigDecimal("100.00"))); // trust
|
|
|
+ assertEquals(0, scores[1].compareTo(new BigDecimal("0.00"))); // intimacy
|
|
|
+ assertEquals(0, scores[2].compareTo(new BigDecimal("50.00"))); // communication
|
|
|
+ }
|
|
|
+
|
|
|
+ /** direction=negative 反向:选 score=0 → 反向后=100 */
|
|
|
+ @Test
|
|
|
+ void calculateScores_negative_direction_reverse() throws Exception {
|
|
|
+ String questions = "{\"version\":1,\"questions\":[" +
|
|
|
+ "{\"id\":\"q1\",\"dimension\":\"trust\",\"direction\":\"negative\",\"weight\":1.0," +
|
|
|
+ " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":1},{\"id\":\"c\",\"score\":2}]}," +
|
|
|
+ "{\"id\":\"q2\",\"dimension\":\"intimacy\",\"direction\":\"positive\",\"weight\":1.0," +
|
|
|
+ " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":1},{\"id\":\"c\",\"score\":2}]}," +
|
|
|
+ "{\"id\":\"q3\",\"dimension\":\"communication\",\"direction\":\"positive\",\"weight\":1.0," +
|
|
|
+ " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":1},{\"id\":\"c\",\"score\":2}]}" +
|
|
|
+ "]}";
|
|
|
+ String answers = "{\"q1\":{\"id\":\"a\",\"score\":0}," +
|
|
|
+ "\"q2\":{\"id\":\"c\",\"score\":2},\"q3\":{\"id\":\"c\",\"score\":2}}";
|
|
|
+
|
|
|
+ BigDecimal[] scores = callCalculate(answers, questions);
|
|
|
+ // q1 negative: raw=0, maxScore=2, reversed=(2-0)/2*100=100
|
|
|
+ assertEquals(0, scores[0].compareTo(new BigDecimal("100.00"))); // trust (reversed)
|
|
|
+ assertEquals(0, scores[1].compareTo(new BigDecimal("100.00"))); // intimacy
|
|
|
+ assertEquals(0, scores[2].compareTo(new BigDecimal("100.00"))); // communication
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 权重不同:q1 weight=2, q2 weight=1 → 加权 mean */
|
|
|
+ @Test
|
|
|
+ void calculateScores_weighted_average() throws Exception {
|
|
|
+ String questions = "{\"version\":1,\"questions\":[" +
|
|
|
+ "{\"id\":\"q1\",\"dimension\":\"trust\",\"direction\":\"positive\",\"weight\":2.0," +
|
|
|
+ " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":1},{\"id\":\"c\",\"score\":2}]}," +
|
|
|
+ "{\"id\":\"q2\",\"dimension\":\"trust\",\"direction\":\"positive\",\"weight\":1.0," +
|
|
|
+ " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":1},{\"id\":\"c\",\"score\":2}]}," +
|
|
|
+ "{\"id\":\"q3\",\"dimension\":\"intimacy\",\"direction\":\"positive\",\"weight\":1.0," +
|
|
|
+ " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":1},{\"id\":\"c\",\"score\":2}]}," +
|
|
|
+ "{\"id\":\"q4\",\"dimension\":\"communication\",\"direction\":\"positive\",\"weight\":1.0," +
|
|
|
+ " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":1},{\"id\":\"c\",\"score\":2}]}" +
|
|
|
+ "]}";
|
|
|
+ String answers = "{\"q1\":{\"id\":\"c\",\"score\":2},\"q2\":{\"id\":\"a\",\"score\":0}," +
|
|
|
+ "\"q3\":{\"id\":\"b\",\"score\":1},\"q4\":{\"id\":\"b\",\"score\":1}}";
|
|
|
+
|
|
|
+ BigDecimal[] scores = callCalculate(answers, questions);
|
|
|
+ // trust: q1(100*2=200) + q2(0*1=0) / (2+1) = 200/3 = 66.67
|
|
|
+ // intimacy: 50, communication: 50
|
|
|
+ assertEquals(0, scores[0].compareTo(new BigDecimal("66.67"))); // trust weighted
|
|
|
+ assertEquals(0, scores[1].compareTo(new BigDecimal("50.00"))); // intimacy
|
|
|
+ assertEquals(0, scores[2].compareTo(new BigDecimal("50.00"))); // communication
|
|
|
+ }
|
|
|
+
|
|
|
+ /** answersJson 为 null/空/非法 JSON → 三档均为 -1(降级标记) */
|
|
|
+ @Test
|
|
|
+ void calculateScores_invalidJson_returnsNegativeOne() throws Exception {
|
|
|
+ String questions = "{\"version\":1,\"questions\":[\"id\":\"q1\",\"dimension\":\"trust\"]}";
|
|
|
+ BigDecimal[] scores1 = callCalculate(null, questions);
|
|
|
+ assertEquals(0, scores1[0].compareTo(new BigDecimal("-1.00")));
|
|
|
+ assertEquals(0, scores1[1].compareTo(new BigDecimal("-1.00")));
|
|
|
+ assertEquals(0, scores1[2].compareTo(new BigDecimal("-1.00")));
|
|
|
+
|
|
|
+ BigDecimal[] scores2 = callCalculate("not-json!", questions);
|
|
|
+ assertEquals(0, scores2[0].compareTo(new BigDecimal("-1.00")));
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 某维度无题:仅均值有题维度(不平均空维度) */
|
|
|
+ @Test
|
|
|
+ void calculateScores_missingDimension_onlyAveragesExisting() throws Exception {
|
|
|
+ String questions = "{\"version\":1,\"questions\":[" +
|
|
|
+ "{\"id\":\"q1\",\"dimension\":\"trust\",\"direction\":\"positive\",\"weight\":1.0," +
|
|
|
+ " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":2}]}," +
|
|
|
+ "{\"id\":\"q2\",\"dimension\":\"intimacy\",\"direction\":\"positive\",\"weight\":1.0," +
|
|
|
+ " \"options\":[{\"id\":\"a\",\"score\":0},{\"id\":\"b\",\"score\":2}]}" +
|
|
|
+ "]}";
|
|
|
+ String answers = "{\"q1\":{\"id\":\"b\",\"score\":2},\"q2\":{\"id\":\"b\",\"score\":2}}";
|
|
|
+
|
|
|
+ BigDecimal[] scores = callCalculate(answers, questions);
|
|
|
+ // trust=100, intimacy=100, communication 无题=0
|
|
|
+ assertEquals(0, scores[0].compareTo(new BigDecimal("100.00")));
|
|
|
+ assertEquals(0, scores[1].compareTo(new BigDecimal("100.00")));
|
|
|
+ assertEquals(0, scores[2].compareTo(new BigDecimal("0.00")));
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: 运行测试确认失败**
|
|
|
+
|
|
|
+Run: `cd /sc-data/cfc/cfc-backend && /bwydata/maven/bin/mvn test -pl . -Dtest=RelationshipQuestionnaireServiceTest -q 2>&1 | tail -15`
|
|
|
+Expected: 编译失败(`calculateScoresFromAnswers` 当前只有 2 个参数,测试传了 3 个)
|
|
|
+
|
|
|
+- [ ] **Step 3: 修改 calculateScoresFromAnswers**
|
|
|
+
|
|
|
+将 `RelationshipQuestionnaireService.java` 中 `calculateScoresFromAnswers` 方法(195-201 行)替换为以下完整实现。同时修改 `submitQuestionnaire` 方法(106 行附近)传入 `snapshot.getAiGeneratedJson()` 作为第二参数。
|
|
|
+
|
|
|
+先修改方法签名和调用点,再替换实现:
|
|
|
+
|
|
|
+在 `submitQuestionnaire` 方法中(106 行),将:
|
|
|
+```java
|
|
|
+BigDecimal[] scores = calculateScoresFromAnswers(dto.getAnswersJson());
|
|
|
+```
|
|
|
+改为:
|
|
|
+```java
|
|
|
+BigDecimal[] scores = calculateScoresFromAnswers(dto.getAnswersJson(), snapshot.getAiGeneratedJson());
|
|
|
+```
|
|
|
+
|
|
|
+然后替换整个 `calculateScoresFromAnswers` 方法(195-201 行)为:
|
|
|
+
|
|
|
+```java
|
|
|
+ private BigDecimal[] calculateScoresFromAnswers(String answersJson, String questionsJson) {
|
|
|
+ // 降级:inputs 异常时标记 -1
|
|
|
+ if (answersJson == null || answersJson.isEmpty() || questionsJson == null || questionsJson.isEmpty()) {
|
|
|
+ return new BigDecimal[]{
|
|
|
+ new BigDecimal("-1.00"), new BigDecimal("-1.00"), new BigDecimal("-1.00")};
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
|
|
+ com.fasterxml.jackson.databind.JsonNode questionsRoot = mapper.readTree(questionsJson);
|
|
|
+ com.fasterxml.jackson.databind.JsonNode questionsNode = questionsRoot.get("questions");
|
|
|
+ if (questionsNode == null || !questionsNode.isArray()) {
|
|
|
+ return new BigDecimal[]{
|
|
|
+ new BigDecimal("-1.00"), new BigDecimal("-1.00"), new BigDecimal("-1.00")};
|
|
|
+ }
|
|
|
+
|
|
|
+ com.fasterxml.jackson.databind.JsonNode answersNode = mapper.readTree(answersJson);
|
|
|
+
|
|
|
+ // 按 dimension 聚合加权分
|
|
|
+ java.util.Map<String, double[]> dimSums = new java.util.HashMap<>(); // {dim: [weightedSum, totalWeight]}
|
|
|
+ for (com.fasterxml.jackson.databind.JsonNode q : questionsNode(questionsJson)) {
|
|
|
+ String qId = q.get("id").asText();
|
|
|
+ String dim = q.get("dimension").asText();
|
|
|
+ String direction = q.has("direction") ? q.get("direction").asText("positive") : "positive";
|
|
|
+ double weight = q.has("weight") ? q.get("weight").asDouble(1.0) : 1.0;
|
|
|
+
|
|
|
+ if (!answersNode(answersJson).has(qId)) continue; // 缺题跳过
|
|
|
+
|
|
|
+ double rawScore;
|
|
|
+ if (q.has("options")) {
|
|
|
+ // 选项驱动:后端按 optionId 查 score,不信前端 answer.score
|
|
|
+ String answerOptionId = answersNode(answersJson).get(qId).has("id")
|
|
|
+ ? answersNode(answersJson).get(qId).get("id").asText() : null;
|
|
|
+ double maxScore = 0;
|
|
|
+ rawScore = 0;
|
|
|
+ boolean found = false;
|
|
|
+ for (com.fasterxml.jackson.databind.JsonNode opt : q.get("options")) {
|
|
|
+ double s = opt.get("score").asDouble(0);
|
|
|
+ if (s > maxScore) maxScore = s;
|
|
|
+ if (answerOptionId != null && answerOptionId.equals(opt.get("id").asText())) {
|
|
|
+ rawScore = s;
|
|
|
+ found = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!found) continue;
|
|
|
+ rawScore = (maxScore > 0) ? (rawScore / maxScore * 100.0) : 0;
|
|
|
+ } else if (q.has("scale")) {
|
|
|
+ // 滑尺驱动:取 scoreValue 归一到 0-100
|
|
|
+ double scaleMin = q.get("scale").get("min").asDouble(0);
|
|
|
+ double scaleMax = q.get("scale").get("max").asDouble(4);
|
|
|
+ double scoreVal = answersNode(answersJson).get(qId).has("scoreValue")
|
|
|
+ ? answersNode(answersJson).get(qId).get("scoreValue").asDouble(0) : 0;
|
|
|
+ rawScore = (scaleMax > scaleMin) ? ((scoreVal - scaleMin) / (scaleMax - scaleMin) * 100.0) : 0;
|
|
|
+ } else {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ if ("negative".equals(direction)) {
|
|
|
+ rawScore = 100.0 - rawScore;
|
|
|
+ }
|
|
|
+
|
|
|
+ dimSums.computeIfAbsent(dim, k -> new double[]{0, 0});
|
|
|
+ double[] arr = dimSums.get(dim);
|
|
|
+ arr[0] += rawScore * weight;
|
|
|
+ arr[1] += weight;
|
|
|
+ }
|
|
|
+
|
|
|
+ BigDecimal trust = computeDimScore(dimSums.get("trust"));
|
|
|
+ BigDecimal intimacy = computeDimScore(dimSums.get("intimacy"));
|
|
|
+ BigDecimal communication = computeDimScore(dimSums.get("communication"));
|
|
|
+ return new BigDecimal[]{trust, intimacy, communication};
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+同时在 `RelationshipQuestionnaireService.java` 类内追加两个 private helper(在 `calculateScoresFromAnswers` 方法之后):
|
|
|
+
|
|
|
+```java
|
|
|
+ private com.fasterxml.jackson.databind.JsonNode questionsNode(String json) {
|
|
|
+ try {
|
|
|
+ com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
|
|
+ com.fasterxml.jackson.databind.JsonNode root = mapper.readTree(json);
|
|
|
+ return root.has("questions") ? root.get("questions") : mapper.createArrayNode();
|
|
|
+ } catch (Exception e) {
|
|
|
+ return mapper.createArrayNode();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private com.fasterxml.jackson.databind.JsonNode answersNode(String json) {
|
|
|
+ try {
|
|
|
+ return new com.fasterxml.jackson.databind.ObjectMapper().readTree(json);
|
|
|
+ } catch (Exception e) {
|
|
|
+ return new com.fasterxml.jackson.databind.ObjectMapper().createObjectNode();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private BigDecimal computeDimScore(double[] arr) {
|
|
|
+ if (arr == null || arr[1] == 0) return new BigDecimal("0.00");
|
|
|
+ double score = arr[0] / arr[1];
|
|
|
+ score = Math.min(100.0, Math.max(0.0, score));
|
|
|
+ return new BigDecimal(String.format("%.2f", score));
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+> 注:`questionsNode` 和 `answersNode` 内的 `mapper` 变量需改为在方法内创建,避免编译报错。将 `questionsNode` 内第一行改为 `com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();`,`answersNode` 同理。
|
|
|
+
|
|
|
+- [ ] **Step 4: 运行测试确认通过**
|
|
|
+
|
|
|
+Run: `cd /sc-data/cfc/cfc-backend && /bwydata/maven/bin/mvn test -pl . -Dtest=RelationshipQuestionnaireServiceTest -q 2>&1 | tail -10`
|
|
|
+Expected: 5 tests PASS
|
|
|
+
|
|
|
+- [ ] **Step 5: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /sc-data/cfc
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/RelationshipQuestionnaireService.java cfc-backend/src/test/java/com/etotem/cfc/service/RelationshipQuestionnaireServiceTest.java
|
|
|
+git commit -m "feat(backend): 关系问卷评分算法替换 mock — 维度加权/反向/降级/缺题处理 + 5 个单测"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 5: 互动记录/问卷 Controller 加跨家庭权限校验
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/family/InteractionLogController.java` (add/list 方法)
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/controller/family/RelationshipQuestionnaireController.java` (4 个端点)
|
|
|
+
|
|
|
+- [ ] **Step 1: InteractionLogController — 注入 FamilyMemberService + add/list 加校验**
|
|
|
+
|
|
|
+在 `InteractionLogController.java` 中添加注入(24 行之后):
|
|
|
+
|
|
|
+```java
|
|
|
+ @Resource
|
|
|
+ private com.etotem.cfc.service.FamilyMemberService familyMemberService;
|
|
|
+```
|
|
|
+
|
|
|
+将 `addInteraction` 方法(29-51 行)替换为:
|
|
|
+
|
|
|
+```java
|
|
|
+ @Operation(summary = "添加互动记录")
|
|
|
+ @PostMapping("/add")
|
|
|
+ public Result<InteractionLogVO> addInteraction(@RequestBody AddInteractionDTO dto,
|
|
|
+ @RequestAttribute("userId") Long userId) {
|
|
|
+ if (userId == null) {
|
|
|
+ return Result.error("请先登录");
|
|
|
+ }
|
|
|
+ if (dto.getFamilyId() == null) {
|
|
|
+ User user = userMapper.selectById(userId);
|
|
|
+ if (user == null || user.getFamilyId() == null) {
|
|
|
+ return Result.error("用户未关联家庭,无法添加互动记录");
|
|
|
+ }
|
|
|
+ dto.setFamilyId(user.getFamilyId());
|
|
|
+ }
|
|
|
+ // 跨家庭校验:操作者必须在该家庭
|
|
|
+ if (!familyMemberService.isCallerInFamily(dto.getFamilyId(), userId)) {
|
|
|
+ return Result.error("无权操作其他家庭");
|
|
|
+ }
|
|
|
+ // 校验 fromMemberId/toMemberId 均属于该家庭
|
|
|
+ if (dto.getFromMemberId() != null && !familyMemberService.isCallerInSameFamily(dto.getFromMemberId(), userId)) {
|
|
|
+ return Result.error("无权操作其他家庭成员");
|
|
|
+ }
|
|
|
+ if (dto.getToMemberId() != null && !familyMemberService.isCallerInSameFamily(dto.getToMemberId(), userId)) {
|
|
|
+ return Result.error("无权操作其他家庭成员");
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ InteractionLogVO vo = interactionLogService.addInteraction(dto);
|
|
|
+ return Result.success(vo);
|
|
|
+ } catch (IllegalArgumentException e) {
|
|
|
+ return Result.error(e.getMessage());
|
|
|
+ } catch (RuntimeException e) {
|
|
|
+ return Result.error(e.getMessage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+将 `listInteractions` 方法(54-74 行)中的分页查询替换为:
|
|
|
+
|
|
|
+```java
|
|
|
+@Operation(summary = "查询互动记录列表")
|
|
|
+@PostMapping("/list")
|
|
|
+public Result<Page<InteractionLogVO>> listInteractions(@RequestBody Map<String, Object> body,
|
|
|
+ @RequestAttribute("userId") Long userId) {
|
|
|
+ if (userId == null) {
|
|
|
+ return Result.error("请先登录");
|
|
|
+ }
|
|
|
+ Long familyId = body.get("familyId") != null
|
|
|
+ ? Long.valueOf(body.get("familyId").toString()) : null;
|
|
|
+ Long fromMemberId = body.get("fromMemberId") != null
|
|
|
+ ? Long.valueOf(body.get("fromMemberId").toString()) : null;
|
|
|
+ Long toMemberId = body.get("toMemberId") != null
|
|
|
+ ? Long.valueOf(body.get("toMemberId").toString()) : null;
|
|
|
+ Integer page = body.get("page") != null
|
|
|
+ ? Integer.valueOf(body.get("page").toString()) : 1;
|
|
|
+ Integer size = body.get("size") != null
|
|
|
+ ? Integer.valueOf(body.get("size").toString()) : 10;
|
|
|
+
|
|
|
+ // 跨家庭校验
|
|
|
+ if (familyId != null && !familyMemberService.isCallerInFamily(familyId, userId)) {
|
|
|
+ return Result.error("无权查看其他家庭的互动记录");
|
|
|
+ }
|
|
|
+ if (fromMemberId != null && !familyMemberService.isCallerInSameFamily(fromMemberId, userId)) {
|
|
|
+ return Result.error("无权查看其他家庭成员的互动记录");
|
|
|
+ }
|
|
|
+ if (toMemberId != null && !familyMemberService.isCallerInSameFamily(toMemberId, userId)) {
|
|
|
+ return Result.error("无权查看其他家庭成员的互动记录");
|
|
|
+ }
|
|
|
+
|
|
|
+ Page<InteractionLogVO> result = interactionLogService.listInteractions(
|
|
|
+ familyId, fromMemberId, toMemberId, page, size);
|
|
|
+ return Result.success(result);
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: RelationshipQuestionnaireController — 注入 FamilyMemberService + 每个端点加校验**
|
|
|
+
|
|
|
+在 `RelationshipQuestionnaireController.java` 中添加注入(23 行之后):
|
|
|
+
|
|
|
+```java
|
|
|
+ @Resource
|
|
|
+ private com.etotem.cfc.service.FamilyMemberService familyMemberService;
|
|
|
+```
|
|
|
+
|
|
|
+将 `generateQuestionnaire` 方法(26-41 行)替换为:
|
|
|
+
|
|
|
+```java
|
|
|
+@Operation(summary = "AI 生成关系问卷")
|
|
|
+@PostMapping("/generate")
|
|
|
+public Result<QuestionnaireSnapshotVO> generateQuestionnaire(@RequestBody GenerateQuestionnaireDTO dto,
|
|
|
+ @RequestAttribute("userId") Long userId) {
|
|
|
+ if (userId == null) {
|
|
|
+ return Result.error("请先登录");
|
|
|
+ }
|
|
|
+ dto.setRespondentId(userId);
|
|
|
+ // 跨家庭校验:memberId 所在家庭 == 用户所在家庭
|
|
|
+ if (dto.getMemberId() != null && !familyMemberService.isCallerInSameFamily(dto.getMemberId(), userId)) {
|
|
|
+ return Result.error("无权操作其他家庭成员");
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ QuestionnaireSnapshotVO vo = questionnaireService.generateQuestionnaire(dto);
|
|
|
+ return Result.success(vo);
|
|
|
+ } catch (IllegalArgumentException e) {
|
|
|
+ return Result.error(e.getMessage());
|
|
|
+ } catch (RuntimeException e) {
|
|
|
+ return Result.error(e.getMessage());
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+将 `submitQuestionnaire` 方法(44-59 行)替换为:
|
|
|
+
|
|
|
+```java
|
|
|
+@Operation(summary = "提交问卷答案")
|
|
|
+@PostMapping("/submit")
|
|
|
+public Result<RelationshipQuestionnaireResponse> submitQuestionnaire(@RequestBody SubmitQuestionnaireDTO dto,
|
|
|
+ @RequestAttribute("userId") Long userId) {
|
|
|
+ if (userId == null) {
|
|
|
+ return Result.error("请先登录");
|
|
|
+ }
|
|
|
+ dto.setRespondentId(userId);
|
|
|
+ // 跨家庭校验:snapshot 所属成员所在家庭 == 用户所在家庭
|
|
|
+ if (dto.getSnapshotId() != null) {
|
|
|
+ var snapshot = questionnaireService.getSnapshotById(dto.getSnapshotId());
|
|
|
+ if (snapshot != null && !familyMemberService.isCallerInSameFamily(snapshot.getFamilyMemberId(), userId)) {
|
|
|
+ return Result.error("无权操作其他家庭的问卷");
|
|
|
+ }
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ RelationshipQuestionnaireResponse resp = questionnaireService.submitQuestionnaire(dto);
|
|
|
+ return Result.success(resp);
|
|
|
+ } catch (IllegalArgumentException e) {
|
|
|
+ return Result.error(e.getMessage());
|
|
|
+ } catch (RuntimeException e) {
|
|
|
+ return Result.error(e.getMessage());
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+将 `getSnapshot` 方法(62-74 行)替换为:
|
|
|
+
|
|
|
+```java
|
|
|
+@Operation(summary = "获取最新问卷快照")
|
|
|
+@PostMapping("/snapshot/{memberId}")
|
|
|
+public Result<QuestionnaireSnapshotVO> getSnapshot(@PathVariable Long memberId,
|
|
|
+ @RequestAttribute("userId") Long userId) {
|
|
|
+ if (userId == null) {
|
|
|
+ return Result.error("请先登录");
|
|
|
+ }
|
|
|
+ // 跨家庭校验
|
|
|
+ if (!familyMemberService.isCallerInSameFamily(memberId, userId)) {
|
|
|
+ return Result.error("无权查看其他家庭的问卷");
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ QuestionnaireSnapshotVO vo = questionnaireService.getSnapshot(memberId);
|
|
|
+ return Result.success(vo);
|
|
|
+ } catch (IllegalArgumentException e) {
|
|
|
+ return Result.error(e.getMessage());
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+将 `getHistory` 方法(77-89 行)替换为:
|
|
|
+
|
|
|
+```java
|
|
|
+@Operation(summary = "获取问卷历史记录")
|
|
|
+@PostMapping("/history/{memberId}")
|
|
|
+public Result<List<RelationshipQuestionnaireResponse>> getHistory(@PathVariable Long memberId,
|
|
|
+ @RequestAttribute("userId") Long userId) {
|
|
|
+ if (userId == null) {
|
|
|
+ return Result.error("请先登录");
|
|
|
+ }
|
|
|
+ // 跨家庭校验
|
|
|
+ if (!familyMemberService.isCallerInSameFamily(memberId, userId)) {
|
|
|
+ return Result.error("无权查看其他家庭的问卷历史");
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ List<RelationshipQuestionnaireResponse> history = questionnaireService.getHistory(memberId);
|
|
|
+ return Result.success(history);
|
|
|
+ } catch (IllegalArgumentException e) {
|
|
|
+ return Result.error(e.getMessage());
|
|
|
+ }
|
|
|
+}
|
|
|
+```
|
|
|
+
|
|
|
+> 注:`submitQuestionnaire` 方法中调用了 `questionnaireService.getSnapshotById(snapshotId)`,该方法需要在 Task 4 的 `RelationshipQuestionnaireService` 中追加(见 Step 3 下方补充)。
|
|
|
+
|
|
|
+- [ ] **Step 3: RelationshipQuestionnaireService — 追加 getSnapshotById 公开方法**
|
|
|
+
|
|
|
+在 `RelationshipQuestionnaireService.java` 中 `getSnapshot` 方法(130 行)之后,追加:
|
|
|
+
|
|
|
+```java
|
|
|
+ /**
|
|
|
+ * 按 ID 查询快照(供 Controller 跨家庭校验使用)
|
|
|
+ */
|
|
|
+ public RelationshipQuestionnaireSnapshot getSnapshotById(Long snapshotId) {
|
|
|
+ if (snapshotId == null) return null;
|
|
|
+ return snapshotMapper.selectById(snapshotId);
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 编译验证**
|
|
|
+
|
|
|
+Run: `cd /sc-data/cfc/cfc-backend && /bwydata/maven/bin/mvn clean compile -q`
|
|
|
+Expected: EXIT 0
|
|
|
+
|
|
|
+- [ ] **Step 5: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /sc-data/cfc
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/family/InteractionLogController.java cfc-backend/src/main/java/com/etotem/cfc/controller/family/RelationshipQuestionnaireController.java cfc-backend/src/main/java/com/etotem/cfc/service/RelationshipQuestionnaireService.java
|
|
|
+git commit -m "feat(backend): 互动/问卷 Controller 加跨家庭权限校验 + getSnapshotById 查询方法"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 6: AiGateway.generateQuestionnaire + Service 层 LangGraph 接入 + fallback mock
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java` (追加新方法)
|
|
|
+- Modify: `cfc-backend/src/main/java/com/etotem/cfc/service/RelationshipQuestionnaireService.java` (注入 AiGateway + generateQuestionnaire 改造)
|
|
|
+- Modify: `cfc-backend/src/main/resources/application.yml` (python.base-url 改为真实地址)
|
|
|
+
|
|
|
+- [ ] **Step 1: AiGateway — 追加 generateQuestionnaire 方法**
|
|
|
+
|
|
|
+在 `AiGateway.java` 的 `chat` 方法(182 行 `}` 之后),追加:
|
|
|
+
|
|
|
+```java
|
|
|
+ /**
|
|
|
+ * 调用 Python LangGraph 生成关系问卷 JSON
|
|
|
+ * @param inputs { member_name, relationship_type, family_context? }
|
|
|
+ * @return { "questionnaire_json": "...", "version": "..." } | null on failure
|
|
|
+ */
|
|
|
+ public Map<String, Object> generateQuestionnaire(Map<String, Object> inputs) {
|
|
|
+ if (!enabled || isCircuitOpen()) return null;
|
|
|
+
|
|
|
+ try {
|
|
|
+ ObjectNode body = objectMapper.createObjectNode();
|
|
|
+ body.put("member_name", inputs.getOrDefault("member_name", "").toString());
|
|
|
+ body.put("relationship_type", inputs.getOrDefault("relationship_type", "").toString());
|
|
|
+ if (inputs.containsKey("family_context")) {
|
|
|
+ body.set("family_context", objectMapper.valueToTree(inputs.get("family_context")));
|
|
|
+ }
|
|
|
+
|
|
|
+ HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
|
|
|
+ String url = baseUrl + "/api/v1/questionnaire/generate";
|
|
|
+
|
|
|
+ ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
|
|
|
+
|
|
|
+ if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
|
|
|
+ JsonNode root = objectMapper.readTree(response.getBody());
|
|
|
+ Map<String, Object> result = new LinkedHashMap<>();
|
|
|
+ result.put("questionnaire_json", root.get("questionnaire_json").asText());
|
|
|
+ result.put("version", root.has("version") ? root.get("version").asText() : "");
|
|
|
+ consecutiveFailures.set(0);
|
|
|
+ log.debug("AiGateway generateQuestionnaire 成功");
|
|
|
+ return result;
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.warn("AiGateway generateQuestionnaire 调用失败: {}", e.getMessage());
|
|
|
+ recordFailure();
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: RelationshipQuestionnaireService — 注入 AiGateway + generateQuestionnaire 改造**
|
|
|
+
|
|
|
+在 `RelationshipQuestionnaireService.java` 注入区(38 行之后)追加:
|
|
|
+
|
|
|
+```java
|
|
|
+ @Resource
|
|
|
+ private AiGateway aiGateway;
|
|
|
+```
|
|
|
+
|
|
|
+将 `generateQuestionnaire` 方法(47-86 行)中 `String aiJson = generateMockQuestionnaireJson(member);` 这一行替换为:
|
|
|
+
|
|
|
+```java
|
|
|
+ // 优先 LangGraph 出题,失败 fallback mock
|
|
|
+ java.util.Map<String, Object> aiInputs = new java.util.HashMap<>();
|
|
|
+ aiInputs.put("member_name", member.getNickname());
|
|
|
+ aiInputs.put("relationship_type", member.getGeneration() != null && member.getGeneration() < 0 ? "child" : "parent");
|
|
|
+ java.util.Map<String, Object> aiResult = aiGateway.generateQuestionnaire(aiInputs);
|
|
|
+
|
|
|
+ String aiJson;
|
|
|
+ if (aiResult != null && aiResult.get("questionnaire_json") != null) {
|
|
|
+ aiJson = (String) aiResult.get("questionnaire_json");
|
|
|
+ } else {
|
|
|
+ aiJson = generateMockQuestionnaireJson(member);
|
|
|
+ log.warn("LangGraph 不可用,fallback mock 问卷: memberId={}", dto.getMemberId());
|
|
|
+ }
|
|
|
+```
|
|
|
+
|
|
|
+> 注:原来 `String aiJson = generateMockQuestionnaireJson(member);` 整行替换为上述 if-else 块。
|
|
|
+
|
|
|
+- [ ] **Step 3: application.yml — python.base-url 改为真实 LangGraph 地址**
|
|
|
+
|
|
|
+将 `application.yml` 第 99 行从:
|
|
|
+```yaml
|
|
|
+ base-url: http://localhost:9000
|
|
|
+```
|
|
|
+改为:
|
|
|
+```yaml
|
|
|
+ base-url: https://ai.etotem.com.cn
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: 编译验证**
|
|
|
+
|
|
|
+Run: `cd /sc-data/cfc/cfc-backend && /bwydata/maven/bin/mvn clean compile -q`
|
|
|
+Expected: EXIT 0
|
|
|
+
|
|
|
+- [ ] **Step 5: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /sc-data/cfc
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java cfc-backend/src/main/java/com/etotem/cfc/service/RelationshipQuestionnaireService.java cfc-backend/src/main/resources/application.yml
|
|
|
+git commit -m "feat(backend): AiGateway.generateQuestionnaire LangGraph 接入 + fallback mock + python.base-url 指向 ai.etotem.com.cn"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 7: cfc-langgraph/ Python 项目骨架 + graph + endpoint + 测试
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Create: `cfc-langgraph/README.md`
|
|
|
+- Create: `cfc-langgraph/pyproject.toml`
|
|
|
+- Create: `cfc-langgraph/requirements.txt`
|
|
|
+- Create: `cfc-langgraph/.env.example`
|
|
|
+- Create: `cfc-langgraph/.gitignore`
|
|
|
+- Create: `cfc-langgraph/src/__init__.py`
|
|
|
+- Create: `cfc-langgraph/src/app.py`
|
|
|
+- Create: `cfc-langgraph/src/graphs/__init__.py`
|
|
|
+- Create: `cfc-langgraph/src/graphs/questionnaire.py`
|
|
|
+- Create: `cfc-langgraph/src/llm/__init__.py`
|
|
|
+- Create: `cfc-langgraph/src/llm/client.py`
|
|
|
+- Create: `cfc-langgraph/src/schemas/__init__.py`
|
|
|
+- Create: `cfc-langgraph/src/schemas/questionnaire.py`
|
|
|
+- Create: `cfc-langgraph/src/prompts/__init__.py`
|
|
|
+- Create: `cfc-langgraph/src/prompts/questionnaire.py`
|
|
|
+- Create: `cfc-langgraph/tests/__init__.py`
|
|
|
+- Create: `cfc-langgraph/tests/test_graph.py`
|
|
|
+
|
|
|
+- [ ] **Step 1: 创建项目骨架文件**
|
|
|
+
|
|
|
+```bash
|
|
|
+mkdir -p cfc-langgraph/src/graphs cfc-langgraph/src/llm cfc-langgraph/src/schemas cfc-langgraph/src/prompts cfc-langgraph/tests
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: cfc-langgraph/pyproject.toml**
|
|
|
+
|
|
|
+```toml
|
|
|
+[build-system]
|
|
|
+requires = ["setuptools>=68.0"]
|
|
|
+build-backend = "setuptools.backends._legacy:_Backend"
|
|
|
+
|
|
|
+[project]
|
|
|
+name = "cfc-langgraph"
|
|
|
+version = "0.1.0"
|
|
|
+description = "CFC LangGraph 问卷生成服务"
|
|
|
+requires-python = ">=3.11"
|
|
|
+dependencies = [
|
|
|
+ "fastapi>=0.110,<1.0",
|
|
|
+ "uvicorn[standard]>=0.29,<1.0",
|
|
|
+ "langgraph>=0.2,<1.0",
|
|
|
+ "langchain-openai>=0.2,<1.0",
|
|
|
+ "langchain-core>=0.3,<1.0",
|
|
|
+ "pydantic>=2.0,<3.0",
|
|
|
+ "python-dotenv>=1.0,<2.0",
|
|
|
+ "httpx>=0.27,<1.0",
|
|
|
+]
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: cfc-langgraph/requirements.txt**
|
|
|
+
|
|
|
+```
|
|
|
+fastapi>=0.110,<1.0
|
|
|
+uvicorn[standard]>=0.29,<1.0
|
|
|
+langgraph>=0.2,<1.0
|
|
|
+langchain-openai>=0.2,<1.0
|
|
|
+langchain-core>=0.3,<1.0
|
|
|
+pydantic>=2.0,<3.0
|
|
|
+python-dotenv>=1.0,<2.0
|
|
|
+httpx>=0.27,<1.0
|
|
|
+pytest>=8.0,<9.0
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 4: cfc-langgraph/.env.example**
|
|
|
+
|
|
|
+```
|
|
|
+OPENAI_API_KEY=sk-your-key-here
|
|
|
+OPENAI_BASE_URL=https://api.openai.com/v1
|
|
|
+LLM_MODEL=gpt-4o-mini
|
|
|
+HOST=0.0.0.0
|
|
|
+PORT=9000
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 5: cfc-langgraph/.gitignore**
|
|
|
+
|
|
|
+```
|
|
|
+__pycache__/
|
|
|
+*.pyc
|
|
|
+.env
|
|
|
+.venv/
|
|
|
+dist/
|
|
|
+*.egg-info/
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 6: cfc-langgraph/src/schemas/questionnaire.py**
|
|
|
+
|
|
|
+```python
|
|
|
+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
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 7: cfc-langgraph/src/prompts/questionnaire.py**
|
|
|
+
|
|
|
+```python
|
|
|
+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}}
|
|
|
+ ]
|
|
|
+ }}
|
|
|
+ ]
|
|
|
+}}
|
|
|
+"""
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 8: cfc-langgraph/src/llm/client.py**
|
|
|
+
|
|
|
+```python
|
|
|
+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,
|
|
|
+ )
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 9: cfc-langgraph/src/graphs/questionnaire.py**
|
|
|
+
|
|
|
+```python
|
|
|
+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
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 10: cfc-langgraph/src/app.py**
|
|
|
+
|
|
|
+```python
|
|
|
+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)}"}
|
|
|
+ )
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 11: cfc-langgraph/src/__init__.py + 各子目录 __init__.py**
|
|
|
+
|
|
|
+```bash
|
|
|
+touch cfc-langgraph/src/__init__.py cfc-langgraph/src/graphs/__init__.py cfc-langgraph/src/llm/__init__.py cfc-langgraph/src/schemas/__init__.py cfc-langgraph/src/prompts/__init__.py cfc-langgraph/tests/__init__.py
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 12: cfc-langgraph/tests/test_graph.py**
|
|
|
+
|
|
|
+```python
|
|
|
+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
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 13: cfc-langgraph/README.md**
|
|
|
+
|
|
|
+```markdown
|
|
|
+# 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"
|
|
|
+}
|
|
|
+```
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 14: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /sc-data/cfc
|
|
|
+git add cfc-langgraph/
|
|
|
+git commit -m "feat(langgraph): cfc-langgraph 项目骨架 + 问卷生成 graph + FastAPI endpoint + 测试"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 8: cfc-backend/AGENTS.md 同步 + PROJECT-OVERVIEW 更新
|
|
|
+
|
|
|
+**Files:**
|
|
|
+- Modify: `cfc-backend/AGENTS.md` (COMMANDS 区域追加 Python 服务启动命令)
|
|
|
+- Modify: `docs/superpowers/PROJECT-OVERVIEW.md` (关系域条目从"设计稿"改为"已实施")
|
|
|
+
|
|
|
+- [ ] **Step 1: cfc-backend/AGENTS.md COMMANDS 区域追加**
|
|
|
+
|
|
|
+在 `cfc-backend/AGENTS.md` 的 COMMANDS 区域(`mvn test` 行之后)追加:
|
|
|
+
|
|
|
+```markdown
|
|
|
+# Python LangGraph 服务(部署到 ai.etotem.com.cn)
|
|
|
+cd cfc-langgraph && pip install -r requirements.txt && uvicorn src.app:app --port 9000
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 2: PROJECT-OVERVIEW.md 关系域条目更新**
|
|
|
+
|
|
|
+将 `docs/superpowers/PROJECT-OVERVIEW.md` 中:
|
|
|
+```
|
|
|
+| 关系问卷与互动记录功能补全 | 🟡 设计稿(2026-08-04) | `specs/2026-08-04-relationship-feedback-fix-design.md` |
|
|
|
+```
|
|
|
+改为:
|
|
|
+```
|
|
|
+| 关系问卷与互动记录功能补全 | ✅ 已实施(3表迁移154-156+评分算法+LangGraph接入+跨家庭校验+编辑bug修复) | `specs/2026-08-04-relationship-feedback-fix-design.md` + `plans/2026-08-04-relationship-feedback-fix.md` |
|
|
|
+```
|
|
|
+
|
|
|
+- [ ] **Step 3: 提交**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /sc-data/cfc
|
|
|
+git add cfc-backend/AGENTS.md docs/superpowers/PROJECT-OVERVIEW.md
|
|
|
+git commit -m "docs: AGENTS.md 追加 LangGraph 启动命令 + PROJECT-OVERVIEW 关系域标记已实施"
|
|
|
+```
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Task 9: 全量验证 + 最终推送
|
|
|
+
|
|
|
+**Files:** 无新文件修改
|
|
|
+
|
|
|
+- [ ] **Step 1: 后端全量打包**
|
|
|
+
|
|
|
+Run: `cd /sc-data/cfc/cfc-backend && /bwydata/maven/bin/mvn clean package -DskipTests -q; echo "EXIT: $?"`
|
|
|
+Expected: EXIT 0
|
|
|
+
|
|
|
+- [ ] **Step 2: 小程序构建**
|
|
|
+
|
|
|
+Run: `cd /sc-data/cfc/cfc-frontend && npm run build:mp-weixin 2>&1 | tail -3; echo "EXIT: $?"`
|
|
|
+Expected: EXIT 0
|
|
|
+
|
|
|
+- [ ] **Step 3: 管理端构建**
|
|
|
+
|
|
|
+Run: `cd /sc-data/cfc/cfc-web && npm run build 2>&1 | tail -3; echo "EXIT: $?"`
|
|
|
+Expected: EXIT 0
|
|
|
+
|
|
|
+- [ ] **Step 4: 推送**
|
|
|
+
|
|
|
+```bash
|
|
|
+cd /sc-data/cfc
|
|
|
+git status
|
|
|
+git push origin cfclub
|
|
|
+```
|
|
|
+
|
|
|
+Expected: 推送成功,分支与 origin 同步。
|
|
|
+
|
|
|
+---
|
|
|
+
|
|
|
+## Self-Review 清单
|
|
|
+
|
|
|
+| 检查项 | 结果 |
|
|
|
+|--------|------|
|
|
|
+| Spec 覆盖:A 档 3 张表 | ✅ Task 1 |
|
|
|
+| Spec 覆盖:B 档 FamilyMemberStrip bug | ✅ Task 2 |
|
|
|
+| Spec 覆盖:C-2 评分算法 | ✅ Task 4 (5 个测试 case 覆盖正向/反向/权重/降级/缺题) |
|
|
|
+| Spec 覆盖:C-3 权限校验 | ✅ Task 3 + Task 5 (FamilyMemberService + 两个 Controller) |
|
|
|
+| Spec 覆盖:C-1 LangGraph | ✅ Task 6 (Java 侧) + Task 7 (Python 侧) |
|
|
|
+| Spec 覆盖:迁移编号 154/155/156 | ✅ Task 1 |
|
|
|
+| Spec 覆盖:application.yml base-url | ✅ Task 6 Step 3 |
|
|
|
+| Spec 覆盖:PROJECT-OVERVIEW | ✅ Task 8 |
|
|
|
+| 占位符扫描:无 TBD/TODO | ✅ |
|
|
|
+| 类型一致性:answersJson Map 格式 vs calculateScoresFromAnswers | ✅ Task 4 测试用例与 spec 3.3.1.1 对齐 |
|
|
|
+| 验收项 V1-V11 覆盖 | ✅ Task 1→V2,V3; Task 2→V9,V10; Task 4→V4,V5; Task 5→V8; Task 6→V7; Task 7→V6,V11; Task 9→V1,V10 |
|