更新内容: - 后端 AI 服务优化(能力分析、知识点解析等) - 前端考试和陪练界面更新 - 修复多个 prompt 和 JSON 解析问题 - 更新 Coze 语音客户端
This commit is contained in:
@@ -64,3 +64,8 @@ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -141,6 +141,7 @@ async def submit_exam(
|
||||
@router.get("/mistakes", response_model=ResponseModel[GetMistakesResponse])
|
||||
async def get_mistakes(
|
||||
exam_id: int,
|
||||
round: int = Query(None, description="获取指定轮次的错题,不传则获取所有轮次"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
@@ -149,18 +150,31 @@ async def get_mistakes(
|
||||
|
||||
用于第二、三轮考试时获取上一轮的错题记录
|
||||
返回的数据可直接序列化为JSON字符串作为mistake_records参数传给考试生成接口
|
||||
|
||||
参数说明:
|
||||
- exam_id: 考试ID
|
||||
- round: 指定轮次(1/2/3),用于获取特定轮次的错题
|
||||
第2轮考试时传入round=1,获取第1轮的错题
|
||||
第3轮考试时传入round=2,获取第2轮的错题
|
||||
不传则获取该考试的所有错题
|
||||
"""
|
||||
logger.info(f"📋 GET /mistakes 收到请求")
|
||||
try:
|
||||
logger.info(f"📋 获取错题记录 - exam_id: {exam_id}, user_id: {current_user.id}")
|
||||
logger.info(f"📋 获取错题记录 - exam_id: {exam_id}, round: {round}, user_id: {current_user.id}")
|
||||
|
||||
# 查询指定考试的错题记录
|
||||
result = await db.execute(
|
||||
select(ExamMistake).where(
|
||||
ExamMistake.exam_id == exam_id,
|
||||
ExamMistake.user_id == current_user.id,
|
||||
).order_by(ExamMistake.id)
|
||||
# 构建查询条件
|
||||
query = select(ExamMistake).where(
|
||||
ExamMistake.exam_id == exam_id,
|
||||
ExamMistake.user_id == current_user.id,
|
||||
)
|
||||
|
||||
# 如果指定了轮次,只获取该轮次的错题
|
||||
if round is not None:
|
||||
query = query.where(ExamMistake.round == round)
|
||||
|
||||
query = query.order_by(ExamMistake.id)
|
||||
|
||||
result = await db.execute(query)
|
||||
mistakes = result.scalars().all()
|
||||
|
||||
logger.info(f"✅ 查询到错题记录数量: {len(mistakes)}")
|
||||
@@ -262,7 +276,18 @@ async def generate_exam(
|
||||
- 第一轮考试:mistake_records 传空或不传
|
||||
- 第二、三轮错题重考:mistake_records 传入上一轮错题记录的JSON字符串
|
||||
"""
|
||||
from app.models.course import Course
|
||||
|
||||
try:
|
||||
# 验证课程存在性
|
||||
course = await db.get(Course, request.course_id)
|
||||
if not course or course.is_deleted:
|
||||
logger.warning(f"课程不存在或已删除: course_id={request.course_id}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"无法生成试题:课程ID {request.course_id} 不存在或已被删除"
|
||||
)
|
||||
|
||||
# 从用户信息中自动获取岗位ID(如果未提供)
|
||||
position_id = request.position_id
|
||||
if not position_id:
|
||||
@@ -486,6 +511,7 @@ async def record_mistake(
|
||||
mistake = ExamMistake(
|
||||
user_id=current_user.id,
|
||||
exam_id=request.exam_id,
|
||||
round=request.round, # 记录考试轮次
|
||||
question_id=request.question_id,
|
||||
knowledge_point_id=None, # 暂时设为None,避免外键约束
|
||||
question_content=request.question_content,
|
||||
@@ -503,7 +529,7 @@ async def record_mistake(
|
||||
|
||||
logger.info(
|
||||
f"记录错题成功 - user_id: {current_user.id}, exam_id: {request.exam_id}, "
|
||||
f"mistake_id: {mistake.id}"
|
||||
f"round: {request.round}, mistake_id: {mistake.id}"
|
||||
)
|
||||
|
||||
return ResponseModel(
|
||||
|
||||
@@ -14,6 +14,7 @@ class ExamMistake(BaseModel):
|
||||
# 核心关联字段
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True, comment="用户ID")
|
||||
exam_id = Column(Integer, ForeignKey("exams.id", ondelete="CASCADE"), nullable=False, index=True, comment="考试ID")
|
||||
round = Column(Integer, nullable=True, default=1, comment="考试轮次(1/2/3)")
|
||||
question_id = Column(Integer, ForeignKey("questions.id", ondelete="SET NULL"), nullable=True, index=True, comment="题目ID(AI生成的题目可能为空)")
|
||||
knowledge_point_id = Column(Integer, ForeignKey("knowledge_points.id", ondelete="SET NULL"), nullable=True, index=True, comment="关联的知识点ID")
|
||||
|
||||
|
||||
@@ -170,6 +170,7 @@ class JudgeAnswerResponse(BaseModel):
|
||||
class RecordMistakeRequest(BaseModel):
|
||||
"""记录错题请求"""
|
||||
exam_id: int = Field(..., description="考试ID")
|
||||
round: int = Field(1, description="考试轮次(1/2/3)")
|
||||
question_id: Optional[int] = Field(None, description="题目ID(AI生成的题目可能为空)")
|
||||
knowledge_point_id: Optional[int] = Field(None, description="知识点ID")
|
||||
question_content: str = Field(..., description="题目内容")
|
||||
|
||||
@@ -477,3 +477,8 @@ ability_analysis_service = AbilityAnalysisService()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -154,7 +154,10 @@ class CourseChatServiceV2:
|
||||
|
||||
# 6. 更新会话索引
|
||||
if is_new_conversation:
|
||||
await self._add_to_conversation_index(user_id, conversation_id, course_id)
|
||||
await self._add_to_conversation_index(
|
||||
user_id, conversation_id, course_id,
|
||||
first_message=query # 使用用户第一条消息作为会话名称
|
||||
)
|
||||
else:
|
||||
await self._update_conversation_index(user_id, conversation_id)
|
||||
|
||||
@@ -202,7 +205,7 @@ class CourseChatServiceV2:
|
||||
Tuple[str, Optional[str]]: (事件类型, 数据)
|
||||
- ("conversation_started", conversation_id): 会话开始
|
||||
- ("chunk", text): 文本块
|
||||
- ("end", None): 结束
|
||||
- ("done", full_answer): 结束,附带完整回答
|
||||
- ("error", message): 错误
|
||||
"""
|
||||
full_answer = ""
|
||||
@@ -251,7 +254,7 @@ class CourseChatServiceV2:
|
||||
yield ("chunk", chunk)
|
||||
|
||||
# 6. 发送结束事件
|
||||
yield ("end", None)
|
||||
yield ("done", full_answer)
|
||||
|
||||
# 7. 保存对话历史
|
||||
await self._save_conversation_history(
|
||||
@@ -262,7 +265,10 @@ class CourseChatServiceV2:
|
||||
|
||||
# 8. 更新会话索引
|
||||
if is_new_conversation:
|
||||
await self._add_to_conversation_index(user_id, conversation_id, course_id)
|
||||
await self._add_to_conversation_index(
|
||||
user_id, conversation_id, course_id,
|
||||
first_message=query # 使用用户第一条消息作为会话名称
|
||||
)
|
||||
else:
|
||||
await self._update_conversation_index(user_id, conversation_id)
|
||||
|
||||
@@ -526,7 +532,8 @@ class CourseChatServiceV2:
|
||||
self,
|
||||
user_id: int,
|
||||
conversation_id: str,
|
||||
course_id: int
|
||||
course_id: int,
|
||||
first_message: str = ""
|
||||
) -> None:
|
||||
"""
|
||||
将会话添加到用户索引
|
||||
@@ -535,6 +542,7 @@ class CourseChatServiceV2:
|
||||
user_id: 用户ID
|
||||
conversation_id: 会话ID
|
||||
course_id: 课程ID
|
||||
first_message: 用户第一条消息(用于生成会话名称)
|
||||
"""
|
||||
try:
|
||||
from app.core.redis import get_redis_client
|
||||
@@ -547,12 +555,21 @@ class CourseChatServiceV2:
|
||||
await redis.zadd(index_key, {conversation_id: timestamp})
|
||||
await redis.expire(index_key, CONVERSATION_INDEX_TTL)
|
||||
|
||||
# 2. 保存会话元数据
|
||||
# 2. 生成会话名称(取用户第一条消息的前30个字符)
|
||||
conversation_name = ""
|
||||
if first_message:
|
||||
# 移除换行符,截取前30个字符
|
||||
conversation_name = first_message.replace('\n', ' ').strip()[:30]
|
||||
if len(first_message) > 30:
|
||||
conversation_name += "..."
|
||||
|
||||
# 3. 保存会话元数据(包含会话名称)
|
||||
meta_key = f"{CONVERSATION_META_PREFIX}{conversation_id}"
|
||||
meta_data = {
|
||||
"conversation_id": conversation_id,
|
||||
"user_id": user_id,
|
||||
"course_id": course_id,
|
||||
"name": conversation_name, # 会话名称
|
||||
"created_at": timestamp,
|
||||
"updated_at": timestamp,
|
||||
}
|
||||
@@ -563,7 +580,8 @@ class CourseChatServiceV2:
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"会话已添加到索引 - user_id: {user_id}, conversation_id: {conversation_id}"
|
||||
f"会话已添加到索引 - user_id: {user_id}, conversation_id: {conversation_id}, "
|
||||
f"name: {conversation_name}"
|
||||
)
|
||||
|
||||
except RuntimeError:
|
||||
@@ -671,8 +689,10 @@ class CourseChatServiceV2:
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
|
||||
# 获取最后一条消息作为预览
|
||||
# 获取历史消息
|
||||
history = await self._get_conversation_history(conv_id)
|
||||
|
||||
# 获取最后一条消息作为预览
|
||||
last_message = ""
|
||||
if history:
|
||||
# 获取最后一条 assistant 消息
|
||||
@@ -683,8 +703,21 @@ class CourseChatServiceV2:
|
||||
last_message += "..."
|
||||
break
|
||||
|
||||
# 获取会话名称
|
||||
# 优先使用元数据中保存的名称,如果没有则从历史消息中提取
|
||||
conversation_name = meta.get("name", "")
|
||||
if not conversation_name and history:
|
||||
# 从第一条用户消息生成名称
|
||||
for msg in history:
|
||||
if msg["role"] == "user":
|
||||
conversation_name = msg["content"].replace('\n', ' ').strip()[:30]
|
||||
if len(msg["content"]) > 30:
|
||||
conversation_name += "..."
|
||||
break
|
||||
|
||||
conversations.append({
|
||||
"id": conv_id,
|
||||
"name": conversation_name, # 添加会话名称字段
|
||||
"course_id": meta.get("course_id"),
|
||||
"created_at": meta.get("created_at"),
|
||||
"updated_at": meta.get("updated_at"),
|
||||
|
||||
@@ -510,3 +510,8 @@ async def generate_exam(
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -546,3 +546,8 @@ knowledge_analysis_service_v2 = KnowledgeAnalysisServiceV2()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -256,6 +256,16 @@ def _preprocess_text(text: str) -> str:
|
||||
# 移除零宽字符
|
||||
text = re.sub(r'[\u200b\u200c\u200d\ufeff]', '', text)
|
||||
|
||||
# 【重要】先替换中文标点为英文标点(在找边界之前做,否则中文引号会破坏边界检测)
|
||||
cn_punctuation = {
|
||||
',': ',', '。': '.', ':': ':', ';': ';',
|
||||
'"': '"', '"': '"', ''': "'", ''': "'",
|
||||
'【': '[', '】': ']', '(': '(', ')': ')',
|
||||
'{': '{', '}': '}',
|
||||
}
|
||||
for cn, en in cn_punctuation.items():
|
||||
text = text.replace(cn, en)
|
||||
|
||||
# 提取 Markdown 代码块
|
||||
patterns = [
|
||||
r'```json\s*([\s\S]*?)\s*```',
|
||||
@@ -705,3 +715,4 @@ def clean_llm_output(text: str) -> Tuple[str, List[str]]:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -377,3 +377,8 @@ async def prepare_practice_knowledge(
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -213,3 +213,8 @@ PRIORITY_LEVELS = ["high", "medium", "low"]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -46,3 +46,8 @@ INCORRECT_KEYWORDS = ["错误", "incorrect", "false", "no", "wrong", "不正确"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -72,3 +72,8 @@ DEFAULT_TEMPERATURE = 0.7
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -254,8 +254,11 @@ QUESTION_SCHEMA = {
|
||||
"description": "知识点ID"
|
||||
},
|
||||
"correct": {
|
||||
"type": "string",
|
||||
"description": "正确答案"
|
||||
"oneOf": [
|
||||
{"type": "string"},
|
||||
{"type": "array", "items": {"type": "string"}}
|
||||
],
|
||||
"description": "正确答案(单选/判断/填空为字符串,多选为数组)"
|
||||
},
|
||||
"analysis": {
|
||||
"type": "string",
|
||||
@@ -298,3 +301,8 @@ MAX_DIFFICULTY_LEVEL = 5
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -146,3 +146,8 @@ DEFAULT_KNOWLEDGE_TYPE = "理论知识"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -191,3 +191,8 @@ ANNOTATION_TAGS = [
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -190,3 +190,8 @@ DEFAULT_DIFFICULTY = "intermediate"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -18,3 +18,8 @@ watchfiles==0.21.0
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -336,6 +336,7 @@ CREATE TABLE IF NOT EXISTS exam_mistakes (
|
||||
-- 核心关联字段(必需)
|
||||
user_id INT NOT NULL COMMENT '用户ID',
|
||||
exam_id INT NOT NULL COMMENT '考试ID',
|
||||
round INT DEFAULT 1 COMMENT '考试轮次(1/2/3)', -- 2026-01-27新增:标识错题产生于哪一轮
|
||||
question_id INT COMMENT '题目ID(AI生成的题目可能为空)',
|
||||
knowledge_point_id INT COMMENT '关联的知识点ID',
|
||||
|
||||
@@ -356,14 +357,15 @@ CREATE TABLE IF NOT EXISTS exam_mistakes (
|
||||
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_exam_id (exam_id),
|
||||
INDEX idx_exam_round (exam_id, round), -- 2026-01-27新增:支持按考试和轮次查询
|
||||
INDEX idx_knowledge_point_id (knowledge_point_id),
|
||||
INDEX idx_question_type (question_type) -- 2025-10-12新增
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='错题记录表';
|
||||
```
|
||||
|
||||
**核心字段说明:**
|
||||
- 包含8个核心字段:`user_id`、`exam_id`、`question_id`、`knowledge_point_id`、`question_content`、`correct_answer`、`user_answer`、`question_type`
|
||||
- 简化设计,去除冗余字段(如错误次数、掌握状态等),聚焦核心功能
|
||||
- 包含9个核心字段:`user_id`、`exam_id`、`round`、`question_id`、`knowledge_point_id`、`question_content`、`correct_answer`、`user_answer`、`question_type`
|
||||
- `round` 字段(2026-01-27新增):标识错题产生于哪一轮考试,用于多轮错题重考时只获取上一轮的错题
|
||||
- `question_id` 可为空:AI动态生成的题目可能不在 questions 表中
|
||||
- `knowledge_point_id` 可为空:用于关联知识点,支持错题重考功能
|
||||
- `question_type` 用于记录题型,支持错题按题型筛选和统计(2025-10-12新增)
|
||||
|
||||
Reference in New Issue
Block a user