# 关系问卷与互动记录功能补全 实施计划 > **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() .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() .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 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 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> listInteractions(@RequestBody Map 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 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 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 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 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> getHistory(@PathVariable Long memberId, @RequestAttribute("userId") Long userId) { if (userId == null) { return Result.error("请先登录"); } // 跨家庭校验 if (!familyMemberService.isCallerInSameFamily(memberId, userId)) { return Result.error("无权查看其他家庭的问卷历史"); } try { List 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 generateQuestionnaire(Map 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 entity = new HttpEntity<>(body.toString(), createJsonHeaders()); String url = baseUrl + "/api/v1/questionnaire/generate"; ResponseEntity response = restTemplate.postForEntity(url, entity, String.class); if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { JsonNode root = objectMapper.readTree(response.getBody()); Map 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 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 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": "", "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 |