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 |
Files:
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 行 } 之前),追加以下代码块:
// 迁移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) {
// 表已存在,忽略
}
在 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='关系问卷回答记录';
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 三张缺失表"
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 })
}
},
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 参数"
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;
}
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 跨家庭校验方法"
Files:
cfc-backend/src/test/java/com/etotem/cfc/service/RelationshipQuestionnaireServiceTest.javaModify: 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")));
}
}
Run: cd /sc-data/cfc/cfc-backend && /bwydata/maven/bin/mvn test -pl . -Dtest=RelationshipQuestionnaireServiceTest -q 2>&1 | tail -15
Expected: 编译失败(calculateScoresFromAnswers 当前只有 2 个参数,测试传了 3 个)
将 RelationshipQuestionnaireService.java 中 calculateScoresFromAnswers 方法(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));
}
注:
questionsNode和answersNode内的mapper变量需改为在方法内创建,避免编译报错。将questionsNode内第一行改为com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();,answersNode同理。
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 个单测"
Files:
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);
}
在 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 下方补充)。
在 RelationshipQuestionnaireService.java 中 getSnapshot 方法(130 行)之后,追加:
/**
* 按 ID 查询快照(供 Controller 跨家庭校验使用)
*/
public RelationshipQuestionnaireSnapshot getSnapshotById(Long snapshotId) {
if (snapshotId == null) return null;
return snapshotMapper.selectById(snapshotId);
}
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 查询方法"
Files:
cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java (追加新方法)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 行 } 之后),追加:
/**
* 调用 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;
}
}
在 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 块。
将 application.yml 第 99 行从:
base-url: http://localhost:9000
改为:
base-url: https://ai.etotem.com.cn
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"
Files:
cfc-langgraph/README.mdcfc-langgraph/pyproject.tomlcfc-langgraph/requirements.txtcfc-langgraph/.env.examplecfc-langgraph/.gitignorecfc-langgraph/src/__init__.pycfc-langgraph/src/app.pycfc-langgraph/src/graphs/__init__.pycfc-langgraph/src/graphs/questionnaire.pycfc-langgraph/src/llm/__init__.pycfc-langgraph/src/llm/client.pycfc-langgraph/src/schemas/__init__.pycfc-langgraph/src/schemas/questionnaire.pycfc-langgraph/src/prompts/__init__.pycfc-langgraph/src/prompts/questionnaire.pycfc-langgraph/tests/__init__.pyCreate: 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 + 测试"
Files:
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
将 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 关系域标记已实施"
Files: 无新文件修改
Run: cd /sc-data/cfc/cfc-backend && /bwydata/maven/bin/mvn clean package -DskipTests -q; echo "EXIT: $?"
Expected: EXIT 0
Run: cd /sc-data/cfc/cfc-frontend && npm run build:mp-weixin 2>&1 | tail -3; echo "EXIT: $?"
Expected: EXIT 0
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 同步。
| 检查项 | 结果 |
|---|---|
| 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 |