2026-08-04-relationship-feedback-fix.md 58 KB

关系问卷与互动记录功能补全 实施计划

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.javarunMigrations() 方法末尾(7532 行 } 之前),追加以下代码块:

        // 迁移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 文件末尾追加:

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: 提交

    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 方法替换为:

    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: 提交

    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 方法之后、最后一个 } 之前),追加以下方法:

    /**
     * 校验操作者是否与指定成员属于同一家庭
     * @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: 提交

    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

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.javacalculateScoresFromAnswers 方法(195-201 行)替换为以下完整实现。同时修改 submitQuestionnaire 方法(106 行附近)传入 snapshot.getAiGeneratedJson() 作为第二参数。

先修改方法签名和调用点,再替换实现:

submitQuestionnaire 方法中(106 行),将:

BigDecimal[] scores = calculateScoresFromAnswers(dto.getAnswersJson());

改为:

BigDecimal[] scores = calculateScoresFromAnswers(dto.getAnswersJson(), snapshot.getAiGeneratedJson());

然后替换整个 calculateScoresFromAnswers 方法(195-201 行)为:

    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 方法之后):

    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));
    }

注:questionsNodeanswersNode 内的 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: 提交

    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 行之后):

    @Resource
    private com.etotem.cfc.service.FamilyMemberService familyMemberService;

addInteraction 方法(29-51 行)替换为:

 @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 行)中的分页查询替换为:

@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 行之后):

    @Resource
    private com.etotem.cfc.service.FamilyMemberService familyMemberService;

generateQuestionnaire 方法(26-41 行)替换为:

@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 行)替换为:

@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 行)替换为:

@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 行)替换为:

@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.javagetSnapshot 方法(130 行)之后,追加:

    /**
     * 按 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: 提交

    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.javachat 方法(182 行 } 之后),追加:

    /**
     * 调用 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 行之后)追加:

    @Resource
    private AiGateway aiGateway;

generateQuestionnaire 方法(47-86 行)中 String aiJson = generateMockQuestionnaireJson(member); 这一行替换为:

        // 优先 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 行从:

  base-url: http://localhost:9000

改为:

  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: 提交

    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: 创建项目骨架文件

    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

    [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

    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

    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

    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

    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

    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

    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

    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

    # 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: 提交

    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 行之后)追加:

# 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: 提交

    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: 推送

    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