|
@@ -0,0 +1,973 @@
|
|
|
|
|
+# 文章阅读后答题奖励 + 认知雷达记录 实现计划
|
|
|
|
|
+
|
|
|
|
|
+> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
|
|
|
|
|
+
|
|
|
|
|
+**目标:** 补全「阅读达标 → AI 出题 → 作答 → CF值+五维能量双奖励 → 认知雷达记录」链路,将答题结果持久化并写入 `profile_snapshot.quiz_summary`。
|
|
|
|
|
+
|
|
|
|
|
+**架构:** 前端阅读达标后调 `/quiz/generate`(后端经 AiGateway 调 LangGraph 出题,失败降级硬编码题,落库题目快照返回 recordId),作答后调 `/quiz/submit`(后端按 recordId 读库判分,发放 CF 值 + 五维能量,更新认知雷达)。AI 出题走 LangGraph 新 graph `article_quiz_graph`。
|
|
|
|
|
+
|
|
|
|
|
+**技术栈:** Java 8 + Spring Boot 2.7.18 + MyBatis-Plus(后端)/ FastAPI + LangGraph + ChatOpenAI(Python 侧)/ uni-app Vue 2(前端)
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 文件结构(本计划涉及)
|
|
|
|
|
+
|
|
|
|
|
+### 后端 cfc-backend
|
|
|
|
|
+| 文件 | 动作 | 职责 |
|
|
|
|
|
+|------|------|------|
|
|
|
|
|
+| `src/main/java/com/etotem/cfc/config/DatabaseInitializer.java` | 修改 | 迁移 299 建 `article_quiz_records` 表、迁移 300 加 `quiz_summary` 列 |
|
|
|
|
|
+| `src/main/resources/schema.sql` | 修改 | 同步建表语句 + profile_snapshot 列 |
|
|
|
|
|
+| `src/main/java/com/etotem/cfc/entity/ArticleQuizRecord.java` | 修改 | 字段调整:`childId`→`memberId`、`pointsEarned`→`cfEarned`,新增 `totalQuestions`/`energyEarned` |
|
|
|
|
|
+| `src/main/java/com/etotem/cfc/entity/ProfileSnapshot.java` | 修改 | 新增 `quizSummary` 字段 |
|
|
|
|
|
+| `src/main/java/com/etotem/cfc/service/ArticleService.java` | 修改 | 修复 `getAiQuestions`/`submitAnswers` 对旧字段名的引用 |
|
|
|
|
|
+| `src/main/java/com/etotem/cfc/service/AiGateway.java` | 修改 | 新增 `generateArticleQuiz` 方法 |
|
|
|
|
|
+| `src/main/java/com/etotem/cfc/controller/content/ArticleCommentController.java` | 修改 | 重写 `generateQuiz`/`submitQuiz`,新增 `updateQuizSummary` 辅助方法 |
|
|
|
|
|
+
|
|
|
|
|
+### Python cfc-langgraph
|
|
|
|
|
+| 文件 | 动作 | 职责 |
|
|
|
|
|
+|------|------|------|
|
|
|
|
|
+| `app/graphs/article_quiz_graph.py` | 创建 | AI 出题 agent |
|
|
|
|
|
+| `app/models/article_quiz.py` | 创建 | Pydantic 请求/响应模型 |
|
|
|
|
|
+| `app/api/article_quiz.py` | 创建 | FastAPI 路由 |
|
|
|
|
|
+| `app/prompt_service.py` | 修改 | DEFAULT_PROMPTS 增加 `article_quiz` |
|
|
|
|
|
+| `app/main.py` | 修改 | 注册路由 |
|
|
|
|
|
+
|
|
|
|
|
+### 前端 cfc-frontend
|
|
|
|
|
+| 文件 | 动作 | 职责 |
|
|
|
|
|
+|------|------|------|
|
|
|
|
|
+| `pages/article-center/article-detail.vue` | 修改 | 适配新接口结构、结果弹窗增加 CF 展示 |
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 任务 1:数据库迁移 + 实体字段调整
|
|
|
|
|
+
|
|
|
|
|
+**文件:**
|
|
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java`
|
|
|
|
|
+- 修改:`cfc-backend/src/main/resources/schema.sql`
|
|
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/entity/ArticleQuizRecord.java`
|
|
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/entity/ProfileSnapshot.java`
|
|
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 1:在 `DatabaseInitializer.java` 的 `runMigrations()` 末尾追加两个迁移**
|
|
|
|
|
+
|
|
|
|
|
+在 `runMigrations()` 方法体末尾(`profile_history` 表创建块之后)追加:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ // 迁移299: 创建 article_quiz_records 表(文章阅读答题记录)
|
|
|
|
|
+ try {
|
|
|
|
|
+ jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS article_quiz_records (" +
|
|
|
|
|
+ "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
|
|
|
|
|
+ "article_id BIGINT DEFAULT NULL, " +
|
|
|
|
|
+ "user_id BIGINT DEFAULT NULL, " +
|
|
|
|
|
+ "member_id BIGINT DEFAULT NULL, " +
|
|
|
|
|
+ "questions TEXT, " +
|
|
|
|
|
+ "answers TEXT, " +
|
|
|
|
|
+ "score INT DEFAULT 0, " +
|
|
|
|
|
+ "total_questions INT DEFAULT 0, " +
|
|
|
|
|
+ "cf_earned INT DEFAULT 0, " +
|
|
|
|
|
+ "energy_earned INT DEFAULT 0, " +
|
|
|
|
|
+ "created_at DATETIME, " +
|
|
|
|
|
+ "updated_at DATETIME, " +
|
|
|
|
|
+ "INDEX idx_member (member_id), " +
|
|
|
|
|
+ "INDEX idx_article (article_id)" +
|
|
|
|
|
+ ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章答题记录表'");
|
|
|
|
|
+ log.info("已创建article_quiz_records表");
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("创建article_quiz_records表可能已存在: {}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 迁移300: profile_snapshot 增加 quiz_summary 列(认知雷达答题汇总)
|
|
|
|
|
+ ensureColumn("profile_snapshot", "quiz_summary", "JSON DEFAULT NULL COMMENT '答题认知雷达汇总'");
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 2:同步 `schema.sql`**
|
|
|
|
|
+
|
|
|
|
|
+在 `schema.sql` 中 `article_reading_records` 建表语句(约 4070 行)之后追加:
|
|
|
|
|
+
|
|
|
|
|
+```sql
|
|
|
|
|
+-- 文章答题记录表
|
|
|
|
|
+CREATE TABLE IF NOT EXISTS article_quiz_records (
|
|
|
|
|
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
|
|
|
+ article_id BIGINT DEFAULT NULL COMMENT '文章ID',
|
|
|
|
|
+ user_id BIGINT DEFAULT NULL COMMENT '用户ID',
|
|
|
|
|
+ member_id BIGINT DEFAULT NULL COMMENT '家庭成员ID(作答人)',
|
|
|
|
|
+ questions TEXT COMMENT '题目快照JSON(含正确答案与维度)',
|
|
|
|
|
+ answers TEXT COMMENT '用户作答JSON(含每题所选选项),作答前为NULL',
|
|
|
|
|
+ score INT DEFAULT 0 COMMENT '答对题数',
|
|
|
|
|
+ total_questions INT DEFAULT 0 COMMENT '题目总数',
|
|
|
|
|
+ cf_earned INT DEFAULT 0 COMMENT '发放的CF值',
|
|
|
|
|
+ energy_earned INT DEFAULT 0 COMMENT '发放的能量值',
|
|
|
|
|
+ created_at DATETIME,
|
|
|
|
|
+ updated_at DATETIME,
|
|
|
|
|
+ INDEX idx_member (member_id),
|
|
|
|
|
+ INDEX idx_article (article_id)
|
|
|
|
|
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章答题记录表';
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+并在 `profile_snapshot` 建表语句(约 5404 行)的 `problem_domains` 行之后、`computed_at` 行之前追加一列:
|
|
|
|
|
+
|
|
|
|
|
+```sql
|
|
|
|
|
+ quiz_summary JSON DEFAULT NULL COMMENT '答题认知雷达汇总',
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 3:调整 `ArticleQuizRecord.java` 实体**
|
|
|
|
|
+
|
|
|
|
|
+将 `childId`/`pointsEarned` 字段替换为 `memberId`/`totalQuestions`/`cfEarned`/`energyEarned`:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+package com.etotem.cfc.entity;
|
|
|
|
|
+
|
|
|
|
|
+import com.baomidou.mybatisplus.annotation.IdType;
|
|
|
|
|
+import com.baomidou.mybatisplus.annotation.TableId;
|
|
|
|
|
+import com.baomidou.mybatisplus.annotation.TableName;
|
|
|
|
|
+import java.util.Date;
|
|
|
|
|
+
|
|
|
|
|
+@TableName("article_quiz_records")
|
|
|
|
|
+public class ArticleQuizRecord {
|
|
|
|
|
+ @TableId(type = IdType.AUTO)
|
|
|
|
|
+ private Long id;
|
|
|
|
|
+ private Long articleId;
|
|
|
|
|
+ private Long userId;
|
|
|
|
|
+ private Long memberId;
|
|
|
|
|
+ private String questions;
|
|
|
|
|
+ private String answers;
|
|
|
|
|
+ private Integer score;
|
|
|
|
|
+ private Integer totalQuestions;
|
|
|
|
|
+ private Integer cfEarned;
|
|
|
|
|
+ private Integer energyEarned;
|
|
|
|
|
+ private Date createdAt;
|
|
|
|
|
+ private Date updatedAt;
|
|
|
|
|
+
|
|
|
|
|
+ public Long getId() { return id; }
|
|
|
|
|
+ public void setId(Long id) { this.id = id; }
|
|
|
|
|
+ public Long getArticleId() { return articleId; }
|
|
|
|
|
+ public void setArticleId(Long articleId) { this.articleId = articleId; }
|
|
|
|
|
+ public Long getUserId() { return userId; }
|
|
|
|
|
+ public void setUserId(Long userId) { this.userId = userId; }
|
|
|
|
|
+ public Long getMemberId() { return memberId; }
|
|
|
|
|
+ public void setMemberId(Long memberId) { this.memberId = memberId; }
|
|
|
|
|
+ public String getQuestions() { return questions; }
|
|
|
|
|
+ public void setQuestions(String questions) { this.questions = questions; }
|
|
|
|
|
+ public String getAnswers() { return answers; }
|
|
|
|
|
+ public void setAnswers(String answers) { this.answers = answers; }
|
|
|
|
|
+ public Integer getScore() { return score; }
|
|
|
|
|
+ public void setScore(Integer score) { this.score = score; }
|
|
|
|
|
+ public Integer getTotalQuestions() { return totalQuestions; }
|
|
|
|
|
+ public void setTotalQuestions(Integer totalQuestions) { this.totalQuestions = totalQuestions; }
|
|
|
|
|
+ public Integer getCfEarned() { return cfEarned; }
|
|
|
|
|
+ public void setCfEarned(Integer cfEarned) { this.cfEarned = cfEarned; }
|
|
|
|
|
+ public Integer getEnergyEarned() { return energyEarned; }
|
|
|
|
|
+ public void setEnergyEarned(Integer energyEarned) { this.energyEarned = energyEarned; }
|
|
|
|
|
+ public Date getCreatedAt() { return createdAt; }
|
|
|
|
|
+ public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; }
|
|
|
|
|
+ public Date getUpdatedAt() { return updatedAt; }
|
|
|
|
|
+ public void setUpdatedAt(Date updatedAt) { this.updatedAt = updatedAt; }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 4:调整 `ProfileSnapshot.java` 实体,新增 `quizSummary` 字段**
|
|
|
|
|
+
|
|
|
|
|
+在 `problemDomains` 字段之后追加:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ private String quizSummary;
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+并追加 getter/setter:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ public String getQuizSummary() { return quizSummary; }
|
|
|
|
|
+ public void setQuizSummary(String quizSummary) { this.quizSummary = quizSummary; }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 5:修复 `ArticleService.java` 中 `getAiQuestions`/`submitAnswers` 对旧字段名的引用**
|
|
|
|
|
+
|
|
|
|
|
+`getAiQuestions`(约 797 行)中:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ record.setChildId(memberId); // 改为
|
|
|
|
|
+ record.setMemberId(memberId);
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ record.setPointsEarned(0); // 改为
|
|
|
|
|
+ record.setCfEarned(0);
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+`submitAnswers`(约 868 行)中:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ record.setPointsEarned(pointsEarned); // 改为
|
|
|
|
|
+ record.setCfEarned(pointsEarned);
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 6:编译验证**
|
|
|
|
|
+
|
|
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
|
|
+预期:BUILD SUCCESS,无编译错误。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 7:Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java cfc-backend/src/main/resources/schema.sql cfc-backend/src/main/java/com/etotem/cfc/entity/ArticleQuizRecord.java cfc-backend/src/main/java/com/etotem/cfc/entity/ProfileSnapshot.java cfc-backend/src/main/java/com/etotem/cfc/service/ArticleService.java
|
|
|
|
|
+git commit -m "feat: 文章答题记录表迁移与实体字段调整"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 任务 2:LangGraph AI 出题 graph
|
|
|
|
|
+
|
|
|
|
|
+**文件:**
|
|
|
|
|
+- 创建:`cfc-langgraph/app/graphs/article_quiz_graph.py`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 1:创建 `article_quiz_graph.py`**
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+from langchain_openai import ChatOpenAI
|
|
|
|
|
+from langchain_core.messages import SystemMessage, HumanMessage
|
|
|
|
|
+from app.config import settings
|
|
|
|
|
+from app.monitoring import monitor_agent
|
|
|
|
|
+from app.prompt_service import get_prompt
|
|
|
|
|
+import json
|
|
|
|
|
+import logging
|
|
|
|
|
+
|
|
|
|
|
+logger = logging.getLogger(__name__)
|
|
|
|
|
+
|
|
|
|
|
+SYSTEM_PROMPT = """你是一位家庭教育出题助手。根据给定的文章内容,为读者生成 3 道单项选择题。
|
|
|
|
|
+
|
|
|
|
|
+要求:
|
|
|
|
|
+1. 每题 4 个选项,选项以 "A. "、"B. "、"C. "、"D. " 开头
|
|
|
|
|
+2. 题目考察文章核心内容的理解,适合青少年读者
|
|
|
|
|
+3. 每题标注所属五维(body/mind/wisdom/action/wealth),缺省为 wisdom
|
|
|
|
|
+4. 以 JSON 数组格式返回,不要返回其他文字,只返回 JSON 数组
|
|
|
|
|
+
|
|
|
|
|
+输出格式:
|
|
|
|
|
+[
|
|
|
|
|
+ {"question": "问题", "options": ["A. xxx", "B. xxx", "C. xxx", "D. xxx"], "answer": "A", "dimension": "wisdom", "explanation": "解析"}
|
|
|
|
|
+]
|
|
|
|
|
+"""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class ArticleQuizAgent:
|
|
|
|
|
+ def __init__(self):
|
|
|
|
|
+ self.llm = ChatOpenAI(
|
|
|
|
|
+ model=settings.llm_model,
|
|
|
|
|
+ api_key=settings.llm_api_key,
|
|
|
|
|
+ base_url=settings.llm_base_url,
|
|
|
|
|
+ temperature=settings.llm_temperature,
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ @monitor_agent("article_quiz")
|
|
|
|
|
+ async def run(self, article_title: str, article_content: str, profile_summary: str = "") -> dict:
|
|
|
|
|
+ """根据文章内容生成 3 道选择题,失败返回空 questions"""
|
|
|
|
|
+ try:
|
|
|
|
|
+ human = f"文章标题:{article_title}\n文章内容:{article_content}"
|
|
|
|
|
+ if profile_summary:
|
|
|
|
|
+ human += f"\n用户画像摘要:{profile_summary}"
|
|
|
|
|
+ messages = [
|
|
|
|
|
+ SystemMessage(content=await get_prompt("article_quiz") or SYSTEM_PROMPT),
|
|
|
|
|
+ HumanMessage(content=human),
|
|
|
|
|
+ ]
|
|
|
|
|
+ response = await self.llm.ainvoke(messages)
|
|
|
|
|
+ content = response.content.strip()
|
|
|
|
|
+ if "```json" in content:
|
|
|
|
|
+ content = content.split("```json")[1].split("```")[0].strip()
|
|
|
|
|
+ elif "```" in content:
|
|
|
|
|
+ content = content.split("```")[1].split("```")[0].strip()
|
|
|
|
|
+ questions = json.loads(content)
|
|
|
|
|
+ if not isinstance(questions, list):
|
|
|
|
|
+ return {"questions": [], "fallback_used": True}
|
|
|
|
|
+ return {"questions": questions, "fallback_used": False}
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ logger.warning("文章出题生成失败: %s", e)
|
|
|
|
|
+ return {"questions": [], "fallback_used": True}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 2:语法校验**
|
|
|
|
|
+
|
|
|
|
|
+运行:`cd cfc-langgraph && python -m py_compile app/graphs/article_quiz_graph.py`
|
|
|
|
|
+预期:无输出,退出码 0。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 3:Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add cfc-langgraph/app/graphs/article_quiz_graph.py
|
|
|
|
|
+git commit -m "feat: 新增文章答题出题 graph"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 任务 3:LangGraph 模型 + 路由 + prompt 注册
|
|
|
|
|
+
|
|
|
|
|
+**文件:**
|
|
|
|
|
+- 创建:`cfc-langgraph/app/models/article_quiz.py`
|
|
|
|
|
+- 创建:`cfc-langgraph/app/api/article_quiz.py`
|
|
|
|
|
+- 修改:`cfc-langgraph/app/prompt_service.py`
|
|
|
|
|
+- 修改:`cfc-langgraph/app/main.py`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 1:创建 `app/models/article_quiz.py`**
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+from pydantic import BaseModel
|
|
|
|
|
+from typing import List, Dict, Any
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class ArticleQuizRequest(BaseModel):
|
|
|
|
|
+ article_title: str
|
|
|
|
|
+ article_content: str = ""
|
|
|
|
|
+ profile_summary: str = ""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+class ArticleQuizResponse(BaseModel):
|
|
|
|
|
+ questions: List[Dict[str, Any]] = []
|
|
|
|
|
+ fallback_used: bool = True
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 2:创建 `app/api/article_quiz.py`**
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+from fastapi import APIRouter
|
|
|
|
|
+from app.models.article_quiz import ArticleQuizRequest, ArticleQuizResponse
|
|
|
|
|
+from app.graphs.article_quiz_graph import ArticleQuizAgent
|
|
|
|
|
+import logging
|
|
|
|
|
+
|
|
|
|
|
+logger = logging.getLogger(__name__)
|
|
|
|
|
+router = APIRouter(prefix="/api/v1", tags=["article_quiz"])
|
|
|
|
|
+
|
|
|
|
|
+_agent = None
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def get_agent() -> ArticleQuizAgent:
|
|
|
|
|
+ global _agent
|
|
|
|
|
+ if _agent is None:
|
|
|
|
|
+ _agent = ArticleQuizAgent()
|
|
|
|
|
+ return _agent
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+@router.post("/article/quiz/generate", response_model=ArticleQuizResponse)
|
|
|
|
|
+async def generate_article_quiz(req: ArticleQuizRequest):
|
|
|
|
|
+ agent = get_agent()
|
|
|
|
|
+ result = await agent.run(req.article_title, req.article_content, req.profile_summary)
|
|
|
|
|
+ return ArticleQuizResponse(questions=result.get("questions", []),
|
|
|
|
|
+ fallback_used=result.get("fallback_used", True))
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 3:在 `prompt_service.py` 的 `DEFAULT_PROMPTS` 增加 `article_quiz` 条目**
|
|
|
|
|
+
|
|
|
|
|
+在 `"recommend"` 条目(约 84 行结尾)之后、`}` 之前追加:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+ "article_quiz": """你是一位家庭教育出题助手。根据给定的文章内容,为读者生成 3 道单项选择题。
|
|
|
|
|
+
|
|
|
|
|
+要求:
|
|
|
|
|
+1. 每题 4 个选项,选项以 "A. "、"B. "、"C. "、"D. " 开头
|
|
|
|
|
+2. 题目考察文章核心内容的理解,适合青少年读者
|
|
|
|
|
+3. 每题标注所属五维(body/mind/wisdom/action/wealth),缺省为 wisdom
|
|
|
|
|
+4. 以 JSON 数组格式返回,不要返回其他文字,只返回 JSON 数组
|
|
|
|
|
+
|
|
|
|
|
+输出格式:
|
|
|
|
|
+[
|
|
|
|
|
+ {"question": "问题", "options": ["A. xxx", "B. xxx", "C. xxx", "D. xxx"], "answer": "A", "dimension": "wisdom", "explanation": "解析"}
|
|
|
|
|
+]
|
|
|
|
|
+""",
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 4:在 `main.py` 注册路由**
|
|
|
|
|
+
|
|
|
|
|
+第 6 行 import 增加 `article_quiz`:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+from app.api import health, recommend, chat, analyze, tongue, adapter, report_parse, meal, logs, audio, innate_portrait, self_check, knowledge_base, portrait, article_quiz
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+在 `app.include_router(portrait.router)`(约 34 行)之后追加:
|
|
|
|
|
+
|
|
|
|
|
+```python
|
|
|
|
|
+app.include_router(article_quiz.router)
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 5:语法校验**
|
|
|
|
|
+
|
|
|
|
|
+运行:`cd cfc-langgraph && python -m py_compile app/models/article_quiz.py app/api/article_quiz.py app/prompt_service.py app/main.py`
|
|
|
|
|
+预期:无输出,退出码 0。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 6:Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add cfc-langgraph/app/models/article_quiz.py cfc-langgraph/app/api/article_quiz.py cfc-langgraph/app/prompt_service.py cfc-langgraph/app/main.py
|
|
|
|
|
+git commit -m "feat: 注册文章答题出题路由与提示词"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 任务 4:AiGateway 新增 `generateArticleQuiz` 方法
|
|
|
|
|
+
|
|
|
|
|
+**文件:**
|
|
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 1:在 `AiGateway.java` 末尾(`analyzeTongue` 方法之后、类右括号之前)新增方法**
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 生成文章阅读答题(调用 LangGraph article_quiz_graph)
|
|
|
|
|
+ * @return 题目列表(每项含 question/options/answer/dimension/explanation);失败返回 null
|
|
|
|
|
+ */
|
|
|
|
|
+ public List<Map<String, Object>> generateArticleQuiz(String articleTitle, String articleContent, String profileSummary) {
|
|
|
|
|
+ if (!enabled || isCircuitOpen()) return null;
|
|
|
|
|
+ try {
|
|
|
|
|
+ ObjectNode body = objectMapper.createObjectNode();
|
|
|
|
|
+ body.put("article_title", articleTitle != null ? articleTitle : "");
|
|
|
|
|
+ body.put("article_content", articleContent != null ? articleContent : "");
|
|
|
|
|
+ body.put("profile_summary", profileSummary != null ? profileSummary : "");
|
|
|
|
|
+
|
|
|
|
|
+ HttpEntity<String> entity = new HttpEntity<>(body.toString(), createJsonHeaders());
|
|
|
|
|
+ String url = baseUrl + "/api/v1/article/quiz/generate";
|
|
|
|
|
+
|
|
|
|
|
+ ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
|
|
|
|
|
+ if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) {
|
|
|
|
|
+ JsonNode root = objectMapper.readTree(response.getBody());
|
|
|
|
|
+ JsonNode questions = root.get("questions");
|
|
|
|
|
+ if (questions != null && questions.isArray() && questions.size() > 0) {
|
|
|
|
|
+ List<Map<String, Object>> list = objectMapper.convertValue(questions, List.class);
|
|
|
|
|
+ consecutiveFailures.set(0);
|
|
|
|
|
+ log.debug("AiGateway generateArticleQuiz 成功: {} 题", list.size());
|
|
|
|
|
+ return list;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("AiGateway generateArticleQuiz 调用失败: {}", e.getMessage());
|
|
|
|
|
+ recordFailure();
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+> 该方法使用的 `ObjectNode`/`JsonNode`/`HttpEntity`/`ResponseEntity`/`List`/`Map` 均已在文件顶部 import 中,无需新增 import。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 2:编译验证**
|
|
|
|
|
+
|
|
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
|
|
+预期:BUILD SUCCESS。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 3:Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/service/AiGateway.java
|
|
|
|
|
+git commit -m "feat: AiGateway 新增文章答题出题方法"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 任务 5:重写后端 quiz 接口(出题 + 判分 + 双奖励 + 认知雷达)
|
|
|
|
|
+
|
|
|
|
|
+**文件:**
|
|
|
|
|
+- 修改:`cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleCommentController.java`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 1:新增 import 与依赖注入**
|
|
|
|
|
+
|
|
|
|
|
+在文件顶部 import 区(`import com.etotem.cfc.service.EnergyService;` 之后)追加:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+import com.etotem.cfc.entity.ArticleQuizRecord;
|
|
|
|
|
+import com.etotem.cfc.entity.ProfileSnapshot;
|
|
|
|
|
+import com.etotem.cfc.mapper.ArticleQuizRecordMapper;
|
|
|
|
|
+import com.etotem.cfc.mapper.ProfileSnapshotMapper;
|
|
|
|
|
+import com.etotem.cfc.service.AiGateway;
|
|
|
|
|
+import com.etotem.cfc.service.PointsService;
|
|
|
|
|
+import com.alibaba.fastjson.JSON;
|
|
|
|
|
+import com.alibaba.fastjson.JSONArray;
|
|
|
|
|
+import com.alibaba.fastjson.JSONObject;
|
|
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+在 `@Resource private EnergyService energyService;` 之后追加依赖注入:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private AiGateway aiGateway;
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private PointsService pointsService;
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private ArticleQuizRecordMapper articleQuizRecordMapper;
|
|
|
|
|
+ @Resource
|
|
|
|
|
+ private ProfileSnapshotMapper profileSnapshotMapper;
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 2:重写 `generateQuiz` 方法**
|
|
|
|
|
+
|
|
|
|
|
+将现有 `generateQuiz`(85-105 行)整体替换为:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ @PostMapping("/quiz/generate")
|
|
|
|
|
+ public Result<Map<String, Object>> generateQuiz(@RequestBody Map<String, Object> params,
|
|
|
|
|
+ @RequestAttribute("userId") Long userId,
|
|
|
|
|
+ @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
|
|
|
|
|
+ Long articleId = ParamUtils.getLong(params.get("articleId"));
|
|
|
|
|
+ Long memberId = ParamUtils.getLong(params.get("memberId"), currentMemberId);
|
|
|
|
|
+ Article article = articleMapper.selectById(articleId);
|
|
|
|
|
+ if (article == null) return Result.error("文章不存在");
|
|
|
|
|
+
|
|
|
|
|
+ String content = article.getContent();
|
|
|
|
|
+ if (content != null && content.length() > 2000) content = content.substring(0, 2000);
|
|
|
|
|
+
|
|
|
|
|
+ // 组装用户画像摘要(从 profile_snapshot 读五维评分)
|
|
|
|
|
+ String profileSummary = "";
|
|
|
|
|
+ if (memberId != null) {
|
|
|
|
|
+ ProfileSnapshot snapshot = profileSnapshotMapper.selectOne(
|
|
|
|
|
+ new LambdaQueryWrapper<ProfileSnapshot>().eq(ProfileSnapshot::getMemberId, memberId));
|
|
|
|
|
+ if (snapshot != null && snapshot.getDimensionScores() != null) {
|
|
|
|
|
+ profileSummary = snapshot.getDimensionScores();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 优先走 AiGateway + LangGraph,失败降级硬编码
|
|
|
|
|
+ List<Map<String, Object>> questions = null;
|
|
|
|
|
+ try {
|
|
|
|
|
+ questions = aiGateway.generateArticleQuiz(article.getTitle(), content, profileSummary);
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("AI 出题异常: {}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (questions == null || questions.isEmpty()) {
|
|
|
|
|
+ questions = new ArrayList<>();
|
|
|
|
|
+ questions.add(q("文章的主要内容是什么?", Arrays.asList("A. 提升认知", "B. 健康生活", "C. 亲子关系", "D. 财富管理"), "A", "wisdom"));
|
|
|
|
|
+ questions.add(q("文章提到了哪些维度?", Arrays.asList("A. 身", "B. 心", "C. 智", "D. 以上都有"), "D", "wisdom"));
|
|
|
|
|
+ questions.add(q("你对这篇文章的收获是什么?", Arrays.asList("A. 学到了新知识", "B. 获得了启发", "C. 需要再想想", "D. 很有共鸣"), "A", "wisdom"));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 落库题目快照,返回 recordId(判分以库内答案为准,防篡改)
|
|
|
|
|
+ ArticleQuizRecord record = new ArticleQuizRecord();
|
|
|
|
|
+ record.setArticleId(articleId);
|
|
|
|
|
+ record.setUserId(userId);
|
|
|
|
|
+ record.setMemberId(memberId);
|
|
|
|
|
+ record.setQuestions(JSON.toJSONString(questions));
|
|
|
|
|
+ record.setAnswers(null);
|
|
|
|
|
+ record.setScore(0);
|
|
|
|
|
+ record.setTotalQuestions(questions.size());
|
|
|
|
|
+ record.setCfEarned(0);
|
|
|
|
|
+ record.setEnergyEarned(0);
|
|
|
|
|
+ record.setCreatedAt(new Date());
|
|
|
|
|
+ record.setUpdatedAt(new Date());
|
|
|
|
|
+ articleQuizRecordMapper.insert(record);
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> result = new HashMap<>();
|
|
|
|
|
+ result.put("recordId", record.getId());
|
|
|
|
|
+ result.put("questions", questions);
|
|
|
|
|
+ return Result.success(result);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private Map<String, Object> q(String q, List<String> opts, String a, String dimension) {
|
|
|
|
|
+ Map<String, Object> m = new HashMap<>();
|
|
|
|
|
+ m.put("question", q);
|
|
|
|
|
+ m.put("options", opts);
|
|
|
|
|
+ m.put("answer", a);
|
|
|
|
|
+ m.put("dimension", dimension);
|
|
|
|
|
+ return m;
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 3:重写 `submitQuiz` 方法**
|
|
|
|
|
+
|
|
|
|
|
+将现有 `submitQuiz`(110-120 行)整体替换为:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ @PostMapping("/quiz/submit")
|
|
|
|
|
+ public Result<Map<String, Object>> submitQuiz(@RequestBody Map<String, Object> params,
|
|
|
|
|
+ @RequestAttribute("userId") Long userId,
|
|
|
|
|
+ @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
|
|
|
|
|
+ Long recordId = ParamUtils.getLong(params.get("recordId"));
|
|
|
|
|
+ Long memberId = ParamUtils.getLong(params.get("memberId"), currentMemberId);
|
|
|
|
|
+
|
|
|
|
|
+ ArticleQuizRecord record = articleQuizRecordMapper.selectById(recordId);
|
|
|
|
|
+ if (record == null) return Result.error("答题记录不存在");
|
|
|
|
|
+ if (record.getAnswers() != null && !record.getAnswers().isEmpty()) {
|
|
|
|
|
+ return Result.error("已作答,不可重复提交");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 后端按库内题目快照判分
|
|
|
|
|
+ int score = 0;
|
|
|
|
|
+ int totalQuestions = 0;
|
|
|
|
|
+ List<String> answerList = new ArrayList<>();
|
|
|
|
|
+ try {
|
|
|
|
|
+ JSONArray qArr = JSON.parseArray(record.getQuestions());
|
|
|
|
|
+ JSONArray aArr = JSON.parseArray(String.valueOf(params.get("answers")));
|
|
|
|
|
+ totalQuestions = qArr != null ? qArr.size() : 0;
|
|
|
|
|
+ if (aArr != null) {
|
|
|
|
|
+ for (int i = 0; i < aArr.size(); i++) {
|
|
|
|
|
+ answerList.add(aArr.getString(i));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ for (int i = 0; i < totalQuestions && i < answerList.size(); i++) {
|
|
|
|
|
+ JSONObject q = qArr.getJSONObject(i);
|
|
|
|
|
+ String correct = q.getString("answer");
|
|
|
|
|
+ String selected = answerList.get(i);
|
|
|
|
|
+ if (correct != null && selected != null && correct.equalsIgnoreCase(selected.trim())) {
|
|
|
|
|
+ score++;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("解析答题结果失败: {}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 奖励规则:每答对 1 题 +1 CF +5 能量,全对额外 +3 CF
|
|
|
|
|
+ int cfEarned = score + (totalQuestions > 0 && score == totalQuestions ? 3 : 0);
|
|
|
|
|
+ int energyEarned = score * 5;
|
|
|
|
|
+
|
|
|
|
|
+ // 回填记录
|
|
|
|
|
+ record.setAnswers(JSON.toJSONString(answerList));
|
|
|
|
|
+ record.setScore(score);
|
|
|
|
|
+ record.setTotalQuestions(totalQuestions);
|
|
|
|
|
+ record.setCfEarned(cfEarned);
|
|
|
|
|
+ record.setEnergyEarned(energyEarned);
|
|
|
|
|
+ record.setUpdatedAt(new Date());
|
|
|
|
|
+ articleQuizRecordMapper.updateById(record);
|
|
|
|
|
+
|
|
|
|
|
+ // 发放奖励(失败不阻断返回)
|
|
|
|
|
+ if (memberId != null) {
|
|
|
|
|
+ if (cfEarned > 0) {
|
|
|
|
|
+ try { pointsService.awardCfPoints(memberId, cfEarned, "文章答题奖励"); }
|
|
|
|
|
+ catch (Exception e) { log.warn("CF值发放失败: {}", e.getMessage()); }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (energyEarned > 0) {
|
|
|
|
|
+ try { energyService.awardEnergy(memberId, "article_quiz", record.getArticleId(), energyEarned, "答题奖励", null); }
|
|
|
|
|
+ catch (Exception e) { log.warn("能量发放失败: {}", e.getMessage()); }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 更新认知雷达
|
|
|
|
|
+ updateQuizSummary(memberId, record.getQuestions(), score, totalQuestions);
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> result = new HashMap<>();
|
|
|
|
|
+ result.put("correctCount", score);
|
|
|
|
|
+ result.put("totalQuestions", totalQuestions);
|
|
|
|
|
+ result.put("cfEarned", cfEarned);
|
|
|
|
|
+ result.put("energyEarned", energyEarned);
|
|
|
|
|
+ return Result.success(result);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void updateQuizSummary(Long memberId, String questionsJson, int score, int totalQuestions) {
|
|
|
|
|
+ if (memberId == null) return;
|
|
|
|
|
+ try {
|
|
|
|
|
+ ProfileSnapshot snapshot = profileSnapshotMapper.selectOne(
|
|
|
|
|
+ new LambdaQueryWrapper<ProfileSnapshot>().eq(ProfileSnapshot::getMemberId, memberId));
|
|
|
|
|
+
|
|
|
|
|
+ JSONObject summary = new JSONObject();
|
|
|
|
|
+ if (snapshot != null && snapshot.getQuizSummary() != null && !snapshot.getQuizSummary().isEmpty()) {
|
|
|
|
|
+ summary = JSON.parseObject(snapshot.getQuizSummary());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ int prevCorrect = summary.getIntValue("correct_count");
|
|
|
|
|
+ int prevTotal = summary.getIntValue("total_count");
|
|
|
|
|
+ int newCorrect = prevCorrect + score;
|
|
|
|
|
+ int newTotal = prevTotal + totalQuestions;
|
|
|
|
|
+ summary.put("quiz_count", summary.getIntValue("quiz_count") + 1);
|
|
|
|
|
+ summary.put("correct_count", newCorrect);
|
|
|
|
|
+ summary.put("total_count", newTotal);
|
|
|
|
|
+ summary.put("correct_rate", newTotal > 0 ? (double) newCorrect / newTotal : 0.0);
|
|
|
|
|
+ summary.put("last_quiz_at", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
|
|
|
|
|
+
|
|
|
|
|
+ // 按维度聚合正确率
|
|
|
|
|
+ JSONObject byDim = summary.getJSONObject("by_dimension");
|
|
|
|
|
+ if (byDim == null) byDim = new JSONObject();
|
|
|
|
|
+ JSONArray qArr = JSON.parseArray(questionsJson);
|
|
|
|
|
+ if (qArr != null) {
|
|
|
|
|
+ for (int i = 0; i < qArr.size(); i++) {
|
|
|
|
|
+ JSONObject q = qArr.getJSONObject(i);
|
|
|
|
|
+ String dim = q.getString("dimension");
|
|
|
|
|
+ if (dim == null || dim.isEmpty()) dim = "wisdom";
|
|
|
|
|
+ JSONObject dimStat = byDim.getJSONObject(dim);
|
|
|
|
|
+ if (dimStat == null) dimStat = new JSONObject();
|
|
|
|
|
+ dimStat.put("total_count", dimStat.getIntValue("total_count") + 1);
|
|
|
|
|
+ dimStat.put("correct_rate", dimStat.getIntValue("total_count") > 0
|
|
|
|
|
+ ? (double) dimStat.getIntValue("correct_count") / dimStat.getIntValue("total_count") : 0.0);
|
|
|
|
|
+ byDim.put(dim, dimStat);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ summary.put("by_dimension", byDim);
|
|
|
|
|
+
|
|
|
|
|
+ if (snapshot == null) {
|
|
|
|
|
+ snapshot = new ProfileSnapshot();
|
|
|
|
|
+ snapshot.setMemberId(memberId);
|
|
|
|
|
+ snapshot.setQuizSummary(summary.toJSONString());
|
|
|
|
|
+ snapshot.setUpdatedAt(new Date());
|
|
|
|
|
+ profileSnapshotMapper.insert(snapshot);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ snapshot.setQuizSummary(summary.toJSONString());
|
|
|
|
|
+ snapshot.setUpdatedAt(new Date());
|
|
|
|
|
+ profileSnapshotMapper.updateById(snapshot);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("更新认知雷达失败: {}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+> 注意:`updateQuizSummary` 中的 `by_dimension` 正确数累加需在判分循环中记录每题维度与正确性。上面代码中 `dimStat` 的 `correct_count` 未按题累加——**需修正**:在 `submitQuiz` 判分循环中,同时构建 `Map<String, int[]>`(dimension → [correct, total])传入 `updateQuizSummary`。见下方步骤 4 的修正版。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 4:修正 `updateQuizSummary` 的维度正确数累加逻辑**
|
|
|
|
|
+
|
|
|
|
|
+将 `submitQuiz` 中判分循环改为同时统计每题维度与正确性,并调整 `updateQuizSummary` 签名。完整替换 `submitQuiz` 方法及 `updateQuizSummary` 方法为以下修正版:
|
|
|
|
|
+
|
|
|
|
|
+```java
|
|
|
|
|
+ @PostMapping("/quiz/submit")
|
|
|
|
|
+ public Result<Map<String, Object>> submitQuiz(@RequestBody Map<String, Object> params,
|
|
|
|
|
+ @RequestAttribute("userId") Long userId,
|
|
|
|
|
+ @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
|
|
|
|
|
+ Long recordId = ParamUtils.getLong(params.get("recordId"));
|
|
|
|
|
+ Long memberId = ParamUtils.getLong(params.get("memberId"), currentMemberId);
|
|
|
|
|
+
|
|
|
|
|
+ ArticleQuizRecord record = articleQuizRecordMapper.selectById(recordId);
|
|
|
|
|
+ if (record == null) return Result.error("答题记录不存在");
|
|
|
|
|
+ if (record.getAnswers() != null && !record.getAnswers().isEmpty()) {
|
|
|
|
|
+ return Result.error("已作答,不可重复提交");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ int score = 0;
|
|
|
|
|
+ int totalQuestions = 0;
|
|
|
|
|
+ List<String> answerList = new ArrayList<>();
|
|
|
|
|
+ Map<String, int[]> dimStats = new HashMap<>(); // dimension -> [correct, total]
|
|
|
|
|
+ try {
|
|
|
|
|
+ JSONArray qArr = JSON.parseArray(record.getQuestions());
|
|
|
|
|
+ JSONArray aArr = JSON.parseArray(String.valueOf(params.get("answers")));
|
|
|
|
|
+ totalQuestions = qArr != null ? qArr.size() : 0;
|
|
|
|
|
+ if (aArr != null) {
|
|
|
|
|
+ for (int i = 0; i < aArr.size(); i++) {
|
|
|
|
|
+ answerList.add(aArr.getString(i));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ for (int i = 0; i < totalQuestions; i++) {
|
|
|
|
|
+ JSONObject q = qArr.getJSONObject(i);
|
|
|
|
|
+ String correct = q.getString("answer");
|
|
|
|
|
+ String dim = q.getString("dimension");
|
|
|
|
|
+ if (dim == null || dim.isEmpty()) dim = "wisdom";
|
|
|
|
|
+ String selected = i < answerList.size() ? answerList.get(i) : "";
|
|
|
|
|
+ boolean isCorrect = correct != null && selected != null && correct.equalsIgnoreCase(selected.trim());
|
|
|
|
|
+ int[] stat = dimStats.computeIfAbsent(dim, k -> new int[2]);
|
|
|
|
|
+ stat[1]++;
|
|
|
|
|
+ if (isCorrect) { stat[0]++; score++; }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("解析答题结果失败: {}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ int cfEarned = score + (totalQuestions > 0 && score == totalQuestions ? 3 : 0);
|
|
|
|
|
+ int energyEarned = score * 5;
|
|
|
|
|
+
|
|
|
|
|
+ record.setAnswers(JSON.toJSONString(answerList));
|
|
|
|
|
+ record.setScore(score);
|
|
|
|
|
+ record.setTotalQuestions(totalQuestions);
|
|
|
|
|
+ record.setCfEarned(cfEarned);
|
|
|
|
|
+ record.setEnergyEarned(energyEarned);
|
|
|
|
|
+ record.setUpdatedAt(new Date());
|
|
|
|
|
+ articleQuizRecordMapper.updateById(record);
|
|
|
|
|
+
|
|
|
|
|
+ if (memberId != null) {
|
|
|
|
|
+ if (cfEarned > 0) {
|
|
|
|
|
+ try { pointsService.awardCfPoints(memberId, cfEarned, "文章答题奖励"); }
|
|
|
|
|
+ catch (Exception e) { log.warn("CF值发放失败: {}", e.getMessage()); }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (energyEarned > 0) {
|
|
|
|
|
+ try { energyService.awardEnergy(memberId, "article_quiz", record.getArticleId(), energyEarned, "答题奖励", null); }
|
|
|
|
|
+ catch (Exception e) { log.warn("能量发放失败: {}", e.getMessage()); }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ updateQuizSummary(memberId, score, totalQuestions, dimStats);
|
|
|
|
|
+
|
|
|
|
|
+ Map<String, Object> result = new HashMap<>();
|
|
|
|
|
+ result.put("correctCount", score);
|
|
|
|
|
+ result.put("totalQuestions", totalQuestions);
|
|
|
|
|
+ result.put("cfEarned", cfEarned);
|
|
|
|
|
+ result.put("energyEarned", energyEarned);
|
|
|
|
|
+ return Result.success(result);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private void updateQuizSummary(Long memberId, int score, int totalQuestions, Map<String, int[]> dimStats) {
|
|
|
|
|
+ if (memberId == null) return;
|
|
|
|
|
+ try {
|
|
|
|
|
+ ProfileSnapshot snapshot = profileSnapshotMapper.selectOne(
|
|
|
|
|
+ new LambdaQueryWrapper<ProfileSnapshot>().eq(ProfileSnapshot::getMemberId, memberId));
|
|
|
|
|
+
|
|
|
|
|
+ JSONObject summary = new JSONObject();
|
|
|
|
|
+ if (snapshot != null && snapshot.getQuizSummary() != null && !snapshot.getQuizSummary().isEmpty()) {
|
|
|
|
|
+ summary = JSON.parseObject(snapshot.getQuizSummary());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ int prevCorrect = summary.getIntValue("correct_count");
|
|
|
|
|
+ int prevTotal = summary.getIntValue("total_count");
|
|
|
|
|
+ int newCorrect = prevCorrect + score;
|
|
|
|
|
+ int newTotal = prevTotal + totalQuestions;
|
|
|
|
|
+ summary.put("quiz_count", summary.getIntValue("quiz_count") + 1);
|
|
|
|
|
+ summary.put("correct_count", newCorrect);
|
|
|
|
|
+ summary.put("total_count", newTotal);
|
|
|
|
|
+ summary.put("correct_rate", newTotal > 0 ? (double) newCorrect / newTotal : 0.0);
|
|
|
|
|
+ summary.put("last_quiz_at", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
|
|
|
|
|
+
|
|
|
|
|
+ JSONObject byDim = summary.getJSONObject("by_dimension");
|
|
|
|
|
+ if (byDim == null) byDim = new JSONObject();
|
|
|
|
|
+ for (Map.Entry<String, int[]> entry : dimStats.entrySet()) {
|
|
|
|
|
+ String dim = entry.getKey();
|
|
|
|
|
+ int correct = entry.getValue()[0];
|
|
|
|
|
+ int total = entry.getValue()[1];
|
|
|
|
|
+ JSONObject dimStat = byDim.getJSONObject(dim);
|
|
|
|
|
+ if (dimStat == null) dimStat = new JSONObject();
|
|
|
|
|
+ int dimCorrect = dimStat.getIntValue("correct_count") + correct;
|
|
|
|
|
+ int dimTotal = dimStat.getIntValue("total_count") + total;
|
|
|
|
|
+ dimStat.put("correct_count", dimCorrect);
|
|
|
|
|
+ dimStat.put("total_count", dimTotal);
|
|
|
|
|
+ dimStat.put("correct_rate", dimTotal > 0 ? (double) dimCorrect / dimTotal : 0.0);
|
|
|
|
|
+ byDim.put(dim, dimStat);
|
|
|
|
|
+ }
|
|
|
|
|
+ summary.put("by_dimension", byDim);
|
|
|
|
|
+
|
|
|
|
|
+ if (snapshot == null) {
|
|
|
|
|
+ snapshot = new ProfileSnapshot();
|
|
|
|
|
+ snapshot.setMemberId(memberId);
|
|
|
|
|
+ snapshot.setQuizSummary(summary.toJSONString());
|
|
|
|
|
+ snapshot.setUpdatedAt(new Date());
|
|
|
|
|
+ profileSnapshotMapper.insert(snapshot);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ snapshot.setQuizSummary(summary.toJSONString());
|
|
|
|
|
+ snapshot.setUpdatedAt(new Date());
|
|
|
|
|
+ profileSnapshotMapper.updateById(snapshot);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.warn("更新认知雷达失败: {}", e.getMessage());
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 5:补充 import**
|
|
|
|
|
+
|
|
|
|
|
+`submitQuiz` 用到了 `SimpleDateFormat`,确认文件顶部已有 `import java.util.*;`(已存在,第 15 行),`SimpleDateFormat` 已涵盖。若无,则追加 `import java.text.SimpleDateFormat;`。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 6:编译验证**
|
|
|
|
|
+
|
|
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
|
|
+预期:BUILD SUCCESS。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 7:Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add cfc-backend/src/main/java/com/etotem/cfc/controller/content/ArticleCommentController.java
|
|
|
|
|
+git commit -m "feat: 文章答题出题/提交/双奖励/认知雷达接口改造"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 任务 6:前端适配新接口结构 + 结果弹窗展示 CF
|
|
|
|
|
+
|
|
|
|
|
+**文件:**
|
|
|
|
|
+- 修改:`cfc-frontend/pages/article-center/article-detail.vue`
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 1:`data()` 增加 `quizRecordId` 字段**
|
|
|
|
|
+
|
|
|
|
|
+在 `data()` return 中 `quizResult: {},` 附近追加:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+ quizRecordId: null,
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 2:改 `startQuiz` 方法**
|
|
|
|
|
+
|
|
|
|
|
+将现有 `startQuiz`(262-277 行)中 `generateQuiz` 调用后的处理改为适配 `{recordId, questions}` 结构:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+ async startQuiz() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ var memRes = await getMyMembership()
|
|
|
|
|
+ if (memRes.data && memRes.data.memberLevel && memRes.data.memberLevel === 'FREE') {
|
|
|
|
|
+ uni.showToast({ title: '浠宝/福宝答题是会员专属', icon: 'none' })
|
|
|
|
|
+ setTimeout(function() { uni.navigateTo({ url: '/pages/membership/upgrade' }) }, 1500)
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ var memberId = uni.getStorageSync('currentChildId')
|
|
|
|
|
+ var res = await generateQuiz({ articleId: this.articleId, memberId: parseInt(memberId || 0) })
|
|
|
|
|
+ if (res.code === 200 && res.data && res.data.questions && res.data.questions.length > 0) {
|
|
|
|
|
+ this.quizRecordId = res.data.recordId
|
|
|
|
|
+ this.quizQuestions = res.data.questions
|
|
|
|
|
+ this.quizAnswers = []
|
|
|
|
|
+ this.showQuiz = true
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {}
|
|
|
|
|
+ },
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 3:改 `submitQuiz` 方法**
|
|
|
|
|
+
|
|
|
|
|
+将现有 `submitQuiz`(278-292 行)替换为:
|
|
|
|
|
+
|
|
|
|
|
+```js
|
|
|
|
|
+ async submitQuiz() {
|
|
|
|
|
+ var memberId = uni.getStorageSync('currentChildId')
|
|
|
|
|
+ var answers = []
|
|
|
|
|
+ for (var i = 0; i < this.quizQuestions.length; i++) {
|
|
|
|
|
+ answers.push(this.quizAnswers[i] || '')
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ var res = await submitQuiz({ recordId: this.quizRecordId, memberId: parseInt(memberId || 0), answers: answers })
|
|
|
|
|
+ if (res.code === 200) {
|
|
|
|
|
+ this.quizResult = res.data || {}
|
|
|
|
|
+ this.showQuiz = false
|
|
|
|
|
+ this.showResult = true
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (e) {}
|
|
|
|
|
+ },
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 4:结果弹窗增加 CF 值展示**
|
|
|
|
|
+
|
|
|
|
|
+将结果弹窗(约 80 行)的 `<text class="result-energy">` 改为同时展示 CF 值与能量:
|
|
|
|
|
+
|
|
|
|
|
+```html
|
|
|
|
|
+ <text class="result-energy">获得 {{ quizResult.cfEarned }} CF值 + {{ quizResult.energyEarned }} 能量 ⚡</text>
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 5:语法校验(仅 script 块)**
|
|
|
|
|
+
|
|
|
|
|
+运行:`cd cfc-frontend && node --check <(sed -n '/<script>/,/<\/script>/p' pages/article-center/article-detail.vue | sed '1d;$d')`
|
|
|
|
|
+预期:无语法错误输出。
|
|
|
|
|
+
|
|
|
|
|
+> 注意:小程序打包由 HBuilderX 完成,本计划不执行 `npm run build`。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 6:Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add cfc-frontend/pages/article-center/article-detail.vue
|
|
|
|
|
+git commit -m "feat: 文章答题弹窗适配新接口并展示CF值"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 任务 7:整体编译验证与收尾
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 1:后端编译验证**
|
|
|
|
|
+
|
|
|
|
|
+运行:`cd cfc-backend && mvn clean compile`
|
|
|
|
|
+预期:BUILD SUCCESS。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 2:LangGraph 语法验证**
|
|
|
|
|
+
|
|
|
|
|
+运行:`cd cfc-langgraph && python -m py_compile app/graphs/article_quiz_graph.py app/models/article_quiz.py app/api/article_quiz.py app/prompt_service.py app/main.py`
|
|
|
|
|
+预期:无输出,退出码 0。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 3:更新 PROJECT-OVERVIEW.md 状态**
|
|
|
|
|
+
|
|
|
|
|
+将 `docs/superpowers/PROJECT-OVERVIEW.md` 中本次设计规格条目的状态从「🟡 设计规格已确认」改为「🟢 已实施」,并在 plans 索引中追加本计划文件条目。
|
|
|
|
|
+
|
|
|
|
|
+- [ ] **步骤 4:Commit**
|
|
|
|
|
+
|
|
|
|
|
+```bash
|
|
|
|
|
+git add docs/superpowers/PROJECT-OVERVIEW.md
|
|
|
|
|
+git commit -m "docs: 更新文章答题功能实施状态"
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 验证清单(对应规格成功标准)
|
|
|
|
|
+
|
|
|
|
|
+- [ ] 阅读达标后弹出 AI 出题答题弹窗(AI 失败时 fallback 3 题)—— 任务 2/5
|
|
|
|
|
+- [ ] 提交答题后落库 `article_quiz_records`、发放 CF 值 + 五维能量、更新 `profile_snapshot.quiz_summary` —— 任务 5
|
|
|
|
|
+- [ ] 前端结果弹窗展示「答对 X/Y 题」「+CF 值」「+能量」—— 任务 6
|
|
|
|
|
+- [ ] `mvn clean compile` 通过 —— 任务 7
|