Files
rag/exam_pkg/generator.py

1573 lines
51 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
出题生成器 - 结构化 RAG 出题架构
核心功能:
1. 按章节分组检索切片
2. 每章节提取知识点
3. 按知识点精准检索并出题
4. 覆盖控制 + 去重
架构:
文档 → 章节分组 → 知识点提取 → 定向检索 → 出题 → 合并去重
使用方式:
from exam_pkg.generator import QuestionGenerator
generator = QuestionGenerator()
questions = generator.generate_questions(source_content, question_types, difficulty)
"""
import json
import re
from collections import defaultdict
from typing import List, Dict, Any, Optional
import logging
logger = logging.getLogger(__name__)
# 导入 LLM 工具函数
from core.llm_utils import call_llm, parse_json_list_from_response, extract_json_list, extract_json_object
# 导入 LLM 配置
try:
from config import API_KEY, BASE_URL, MODEL, LLM_TEMPERATURE, LLM_MAX_TOKENS
LLM_AVAILABLE = True
except ImportError:
API_KEY = None
BASE_URL = None
MODEL = None
LLM_TEMPERATURE = 0.7
LLM_MAX_TOKENS = 4000
LLM_AVAILABLE = False
# 推理模型识别:这些模型会消耗额外 token 用于思考链,需要更大的 max_tokens 预算
_REASONING_MODEL_KEYWORDS = ('mimo', 'qwq', 'deepseek-r1', 'deepseek-reasoner', 'o1', 'o3')
def _is_reasoning_model(model_name: str) -> bool:
"""判断是否为推理模型(需要额外思考链 token 预算)"""
if not model_name:
return False
name_lower = model_name.lower()
return any(kw in name_lower for kw in _REASONING_MODEL_KEYWORDS)
def _get_effective_max_tokens(base_max: int, model_name: str) -> int:
"""根据模型类型计算实际 max_tokens。推理模型需要 1.5x 预算给思考链"""
if _is_reasoning_model(model_name):
return max(base_max, int(base_max * 1.5))
return base_max
# ==================== 辅助函数 ====================
def group_chunks_by_section(chunks: List[Dict]) -> Dict[str, List[Dict]]:
"""
🔥 结构化出题 Step 1按章节分组
将切片按 section 字段分组,便于后续按章节提取知识点
"""
section_map = defaultdict(list)
for chunk in chunks:
section = chunk.get('section', '') or '未分类'
# 清理章节名称(去除多余的标记)
section_clean = section.replace('**', '').strip()
section_map[section_clean].append(chunk)
return dict(section_map)
def build_semantic_query(question_types: Dict[str, int]) -> str:
"""
根据题型构建语义化检索 query保留用于知识点检索
"""
query_parts = []
query_parts.append("重点内容 关键概念 定义")
if question_types.get('fill_blank', 0) > 0:
query_parts.append("术语 公式 数值 标准")
if question_types.get('subjective', 0) > 0:
query_parts.append("流程 步骤 原则 方法")
if question_types.get('multiple_choice', 0) > 0:
query_parts.append("区别 对比 分类")
return " ".join(query_parts)
def build_knowledge_point_query(knowledge_point: str, question_type: str = None) -> str:
"""
🔥 结构化出题 Step 3根据知识点构建精准检索 query
Args:
knowledge_point: 知识点名称(如 "请假申请流程"
question_type: 题型,用于补充检索词
Returns:
精准检索 query
"""
# 基础:知识点本身
query_parts = [knowledge_point]
# 根据题型补充
if question_type in ['single_choice', 'multiple_choice']:
query_parts.append("定义 规则 标准")
elif question_type == 'fill_blank':
query_parts.append("具体数值 公式 术语")
elif question_type == 'subjective':
query_parts.append("详细说明 步骤 流程")
return " ".join(query_parts)
def safe_parse_questions(result: str) -> List[Dict]:
"""
🔥 P0 改进:安全解析 JSON支持自动修复
尝试多种方式解析 LLM 返回的 JSON
1. 直接解析
2. 提取 ```json 代码块
3. 提取数组 [...] 模式
"""
questions = parse_json_list_from_response(result)
if questions:
# 兼容 {"questions": [...]} 格式
if isinstance(questions, list):
return questions
return []
def validate_questions_schema(questions: List[Dict]) -> List[Dict]:
"""
🔥 P0 改进JSON Schema 校验,过滤/修复无效题目
校验规则:
- 必须有 type 且在有效类型中
- 必须有 content.stem
- 必须有 content.answer
- 选项题必须有 options
"""
VALID_TYPES = {'single_choice', 'multiple_choice', 'true_false', 'fill_blank', 'subjective'}
validated = []
for q in questions:
# 必须有 type 或 question_type
q_type = q.get('question_type') or q.get('type')
if q_type not in VALID_TYPES:
continue
# 必须有 content
content = q.get('content', {})
if not content.get('stem'):
continue
# 必须有 answer
if 'answer' not in content:
continue
# 选项题必须有 options
if q_type in ['single_choice', 'multiple_choice']:
if not content.get('data', {}).get('options'):
continue
# 填空题答案格式归一化:扁平数组 → 二维数组
if q_type == 'fill_blank':
ans = content.get('answer')
if isinstance(ans, list) and ans and all(isinstance(item, str) for item in ans):
# 扁平数组 ["答案1", "答案2"] → [["答案1"], ["答案2"]]
content['answer'] = [[item] for item in ans]
logger.warning(f"填空题答案格式修正: 扁平数组 → 二维数组 ({len(ans)} 空)")
validated.append(q)
return validated
def build_source_context(chunks: List[Dict]) -> str:
"""
构建带溯源标记的上下文
🔥 改进:添加 chunk_id 标记,便于 LLM 精确引用
"""
context_parts = []
for chunk in chunks:
chunk_id = chunk.get('chunk_id', '')
page_info = f"{chunk['page']}" if chunk.get('page') else ""
section_info = chunk.get('section', '')
# 添加 chunk_id 标记,格式:[chunk_xxx | 第N页 章节]
context_parts.append(f"[chunk_id:{chunk_id} | {page_info} {section_info}]\n{chunk['content']}")
return "\n\n---\n\n".join(context_parts)
def find_referenced_chunks(question: Dict, chunks: List[Dict]) -> List[Dict]:
"""
找到题目引用的切片
🔥 改进:优先用 referenced_chunk_ids 精确匹配,其次用 referenced_pages
"""
# 1. 优先使用 chunk_id 精确匹配
referenced_chunk_ids = question.get('referenced_chunk_ids', [])
if referenced_chunk_ids:
referenced = []
for chunk in chunks:
chunk_id = chunk.get('chunk_id', '')
for ref_id in referenced_chunk_ids:
# 支持两种格式:直接匹配或去掉 chunk_id: 前缀后匹配
if chunk_id == ref_id or chunk_id == ref_id.replace('chunk_id:', ''):
referenced.append(chunk)
break
if referenced:
return referenced
# 2. 退而求其次,使用页码匹配
referenced_pages = question.get('referenced_pages', [])
if referenced_pages:
referenced = []
for chunk in chunks:
if chunk.get('page') in referenced_pages:
referenced.append(chunk)
if referenced:
return referenced
# 3. 都没有,返回第一个切片(作为 fallback
return chunks[:1] if chunks else []
# ==================== QuestionGenerator 类 ====================
class QuestionGenerator:
"""本地出题生成器 - 使用本地 OpenAI 客户端"""
def __init__(self):
self.client = None
if LLM_AVAILABLE and API_KEY:
try:
from openai import OpenAI
self.client = OpenAI(api_key=API_KEY, base_url=BASE_URL)
except ImportError:
pass
self.model = MODEL
def generate_questions_structured(
self,
chunks: List[Dict],
document_name: str,
question_types: Dict[str, int],
difficulty: int = 3
) -> List[Dict]:
"""
🔥 结构化出题主流程(唯一入口)
流程:
1. 按章节分组
2. 每章节提取知识点
3. 按知识点分配题目数量
4. 按知识点出题
5. 合并去重
"""
total_questions = sum(question_types.values())
if not chunks or total_questions == 0:
return []
# Step 1: 按章节分组
section_map = group_chunks_by_section(chunks)
logger.info(f"[结构化出题] 章节数: {len(section_map)}")
# Step 2: 每章节提取知识点
all_knowledge_points = []
for section, section_chunks in section_map.items():
kps = self._extract_knowledge_points(section, section_chunks)
all_knowledge_points.extend(kps)
logger.info(f" 章节 [{section[:20]}...]: 提取 {len(kps)} 个知识点")
if not all_knowledge_points:
# 降级:直接使用 chunks 出题(不提取知识点)
logger.warning("[结构化出题] 知识点提取失败,直接使用 chunks 出题")
return self._generate_questions_fallback(chunks, document_name, question_types, difficulty)
logger.info(f"[结构化出题] 总知识点: {len(all_knowledge_points)}")
# Step 3: 分配题目数量
kp_assignments = self._assign_questions_to_kps(
all_knowledge_points, question_types, total_questions
)
# Step 4: 按知识点出题(带重试机制)
all_questions = []
failed_assignments = [] # 记录失败的分配,用于补题
for kp, q_types, q_count in kp_assignments:
# 找到该知识点相关的 chunks
kp_chunks = self._retrieve_kp_chunks(kp, chunks)
if not kp_chunks:
logger.warning(f" [警告] 知识点 [{kp[:20]}...] 无相关 chunks跳过")
failed_assignments.append((kp, q_types, q_count, "无相关chunks"))
continue
# 构造上下文并出题
source_content = build_source_context(kp_chunks)
prompt = self._build_prompt_for_kp(source_content, kp, q_types, difficulty)
# 🔥 改进:带重试的出题
success, questions = self._generate_with_retry(prompt, q_types, kp_chunks, document_name, max_retries=2)
if success and questions:
# 🔥 新增:校验题型一致性
validated_questions = self._validate_question_types(questions, q_types)
all_questions.extend(validated_questions)
else:
failed_assignments.append((kp, q_types, q_count, "出题失败"))
# 🔥 Step 4.5: 补题机制 - 对不足的题型进行补充
current_counts = defaultdict(int)
for q in all_questions:
current_counts[q.get('question_type')] += 1
for q_type, target_count in question_types.items():
shortage = target_count - current_counts.get(q_type, 0)
if shortage > 0:
logger.info(f" [补题] {q_type} 缺少 {shortage} 道,尝试补充...")
extra_questions = self._makeup_questions(chunks, q_type, shortage, difficulty, document_name)
all_questions.extend(extra_questions)
# Step 5: 去重 + 数量校正
final_questions = self._deduplicate_and_balance(all_questions, question_types)
# 🔥 最终日志:输出题型分布
final_counts = defaultdict(int)
for q in final_questions:
final_counts[q.get('question_type')] += 1
logger.info(f"[出题完成] 题型分布: {dict(final_counts)}")
return final_questions
def _generate_questions_fallback(
self,
chunks: List[Dict],
document_name: str,
question_types: Dict[str, int],
difficulty: int = 3
) -> List[Dict]:
"""
降级出题方法:当知识点提取失败时,直接使用 chunks 出题
输出格式与正常路径一致(嵌套结构),确保 API 响应格式统一
"""
source_content = build_source_context(chunks)
total_questions = sum(question_types.values())
if not source_content or total_questions == 0:
return []
q_type_str = ", ".join(f"{t}:{n}" for t, n in question_types.items())
# 构造出题 prompt —— 使用与正常路径相同的嵌套格式
prompt = f"""请根据以下参考资料生成题目。
## 参考资料
{source_content}
## 要求
- 题型及数量:{q_type_str}
- 难度等级:{difficulty}/5
- 答案必须基于参考资料
## 输出格式JSON 数组)
{self._get_format_examples()}
请直接输出 JSON 数组:"""
try:
messages = [
{"role": "system", "content": "你是一个专业的出题专家必须严格按照JSON格式输出。"},
{"role": "user", "content": prompt}
]
content = call_llm(
client=self.client,
prompt=prompt,
model=self.model,
temperature=LLM_TEMPERATURE,
max_tokens=_get_effective_max_tokens(4000, self.model),
messages=messages
)
if not content:
return []
# 解析 JSON
raw_questions = parse_json_list_from_response(content)
if not raw_questions:
return []
# Schema 校验
validated = validate_questions_schema(raw_questions)
# 补充溯源信息(与正常路径格式统一)
enriched = self._enrich_with_source_trace(validated, chunks, document_name)
return enriched
except Exception as e:
logger.error(f"[降级出题失败] {e}")
return []
def _generate_with_retry(
self,
prompt: str,
q_types: Dict[str, int],
chunks: List[Dict],
document_name: str,
max_retries: int = 2
) -> tuple:
"""
🔥 带重试的出题方法
Returns:
(success: bool, questions: List[Dict])
"""
for attempt in range(max_retries + 1):
try:
response = self._call_llm(prompt)
raw_questions = safe_parse_questions(response)
validated = validate_questions_schema(raw_questions)
if validated:
enriched = self._enrich_with_source_trace(validated, chunks, document_name)
return (True, enriched)
else:
logger.warning(f" [重试 {attempt+1}] JSON 解析成功但题目无效")
except Exception as e:
logger.error(f" [重试 {attempt+1}] 出题异常: {e}")
return (False, [])
def _validate_question_types(
self,
questions: List[Dict],
expected_types: Dict[str, int]
) -> List[Dict]:
"""
🔥 校验题型一致性,过滤不符合预期的题目
只保留 expected_types 中指定的题型
"""
valid_types = set(expected_types.keys())
validated = []
for q in questions:
q_type = q.get('question_type') or q.get('type')
if q_type in valid_types:
validated.append(q)
else:
logger.debug(f" [过滤] 题型 {q_type} 不在预期范围内")
return validated
def _makeup_questions(
self,
chunks: List[Dict],
q_type: str,
count: int,
difficulty: int,
document_name: str
) -> List[Dict]:
"""
补题机制:为缺少的题型补充题目
策略:从 chunks 中选择未使用的切片进行补题
"""
if not chunks or count <= 0:
return []
# 简单策略:使用前几个 chunks 进行补题
makeup_chunks = chunks[:5]
source_content = build_source_context(makeup_chunks)
prompt = f"""基于以下文档内容,生成 {count}{self._get_q_type_name(q_type)}
## 文档内容
{source_content}
## 出题要求
- 题型:{self._get_q_type_name(q_type)}
- 数量:{count}
- 难度:{difficulty}/5
## 输出格式JSON 数组)
{self._get_format_examples()}
请直接输出 JSON 数组:"""
try:
response = self._call_llm(prompt)
raw_questions = safe_parse_questions(response)
validated = validate_questions_schema(raw_questions)
enriched = self._enrich_with_source_trace(validated, makeup_chunks, document_name)
# 只取需要的数量
return enriched[:count]
except Exception as e:
logger.error(f" [补题失败] {e}")
return []
def _get_q_type_name(self, q_type: str) -> str:
"""获取题型的中文名称"""
type_names = {
'single_choice': '单选题',
'multiple_choice': '多选题',
'true_false': '判断题',
'fill_blank': '填空题',
'subjective': '简答题'
}
return type_names.get(q_type, q_type)
def _call_llm(self, prompt: str) -> str:
"""调用本地 LLMOpenAI 兼容接口),支持 429 限流重试"""
if not self.client:
raise ValueError("LLM 客户端未初始化,请检查 config.py 中的 API_KEY 配置")
import time
messages = [
{"role": "system", "content": "你是一个专业的出题专家擅长根据文档内容生成各类考试题目。你必须严格按照JSON格式输出不要有任何其他内容。"},
{"role": "user", "content": prompt}
]
# 重试机制429 限流时指数退避(最多重试 3 次)
for attempt in range(4):
result = call_llm(
client=self.client,
prompt=prompt,
model=self.model,
temperature=LLM_TEMPERATURE,
max_tokens=_get_effective_max_tokens(4000, self.model),
messages=messages
)
if result is not None:
return result
# 可能是 429 限流,等待后重试
if attempt < 3:
wait_time = 2 ** attempt # 1s, 2s, 4s
logger.warning(f" LLM 调用失败,{wait_time}s 后重试 ({attempt+1}/3)...")
time.sleep(wait_time)
raise Exception("LLM 调用失败(已重试 3 次)")
def _get_format_examples(self) -> str:
"""返回各题型格式示例(覆盖全部 5 种题型)"""
return '''
### 单选题示例
{
"type": "single_choice",
"content": {
"stem": "题干内容",
"data": {"options": [{"key": "A", "content": "选项A"}, {"key": "B", "content": "选项B"}, {"key": "C", "content": "选项C"}, {"key": "D", "content": "选项D"}]},
"answer": "B",
"explanation": "解析..."
},
"referenced_chunk_ids": ["chunk_001"]
}
### 多选题示例
{
"type": "multiple_choice",
"content": {
"stem": "题干内容",
"data": {"options": [{"key": "A", "content": "选项A"}, {"key": "B", "content": "选项B"}, {"key": "C", "content": "选项C"}, {"key": "D", "content": "选项D"}]},
"answer": ["A", "C"],
"explanation": "解析..."
},
"referenced_chunk_ids": ["chunk_001"]
}
### 判断题示例
{
"type": "true_false",
"content": {
"stem": "判断:某个陈述内容",
"data": {},
"answer": "",
"explanation": "解析..."
},
"referenced_chunk_ids": ["chunk_002"]
}
### 填空题示例
{
"type": "fill_blank",
"content": {
"stem": "RAG的全称是___其核心在于___。",
"data": {"blank_count": 2},
"answer": [["检索增强生成"], ["外部知识库", "检索"]],
"explanation": "解析..."
},
"referenced_chunk_ids": ["chunk_003"]
}
### 主观题示例
{
"type": "subjective",
"content": {
"stem": "请简述某个概念的含义及其应用场景。",
"data": {
"scoring_points": [
{"point": "要点1概念定义", "weight": 0.4},
{"point": "要点2应用场景", "weight": 0.3},
{"point": "要点3举例说明", "weight": 0.3}
]
},
"answer": "参考答案范文...",
"explanation": "解析..."
},
"referenced_chunk_ids": ["chunk_004"]
}
'''
def _extract_knowledge_points(
self,
section: str,
chunks: List[Dict],
max_points: int = 5
) -> List[Dict]:
"""
🔥 Step 2: 从章节内容提取知识点
策略:
1. 长内容(>= 100字调用 LLM 提取知识点
2. 短内容(< 100字直接用内容本身作为知识点
Returns:
[{"name": "知识点名称", "section": "所属章节"}, ...]
"""
if not chunks:
return []
# 合并同一章节的所有切片内容
all_content = "\n\n".join(
c.get('content', '').strip()
for c in chunks
if c.get('content', '').strip()
)
# 清理纯标点符号
all_content = re.sub(r'^[\s\*\-\d\.。、,::;]+$', '', all_content, flags=re.MULTILINE)
all_content = all_content.strip()
if not all_content:
return []
# 🔥 策略分叉:根据内容长度选择不同处理方式
if len(all_content) < 100:
# 短内容:直接用内容作为知识点(不调用 LLM
# 提取关键短语(去除标点和编号)
clean_content = re.sub(r'^[\d\.\-\*]+\s*', '', all_content)
clean_content = re.sub(r'[。、,::;\s]+$', '', clean_content)
if 5 <= len(clean_content) <= 30:
return [{"name": clean_content, "section": section}]
return []
# 长内容:调用 LLM 提取知识点
prompt = f"""从以下文档内容中提取 {max_points} 个关键知识点。
## 内容
{all_content[:2000]}
## 要求
1. 每个知识点用简短短语描述5-15字
2. 知识点应该适合用于出考试题
3. 知识点之间不要重复或重叠
4. 必须返回 JSON 数组格式,不要有其他内容
## 输出格式
["知识点1", "知识点2", "知识点3"]
请直接输出 JSON 数组:"""
for _attempt in range(2):
try:
response = self._call_llm(prompt)
if not response or not response.strip():
continue
# 使用多策略 JSON 提取(支持 markdown 代码块、正则回退)
result = extract_json_list(response)
if isinstance(result, list) and result:
return [
{"name": kp, "section": section}
for kp in result[:max_points]
if isinstance(kp, str) and 3 <= len(kp) <= 30
]
# 解析结果无效,重试一次
logger.warning(f" 知识点提取返回无效结果(尝试 {_attempt+1}/2),重试中...")
except Exception as e:
logger.error(f" 知识点提取失败(尝试 {_attempt+1}/2): {e}")
return []
def _assign_questions_to_kps(
self,
knowledge_points: List[Dict],
question_types: Dict[str, int],
total_questions: int
) -> List[tuple]:
"""
🔥 Step 3: 将题目分配到各知识点
🔥 改进策略:
1. 为每种题型单独分配知识点(确保题型覆盖)
2. 轮流分配知识点,避免重复
3. 每个知识点最多负责 1 种题型的 1-2 道题
Returns:
[(知识点名称, 题型dict, 题目数量), ...]
"""
kp_count = len(knowledge_points)
if kp_count == 0:
return []
# 🔥 新策略:为每种题型分配知识点
# 使用 dict 来累积每个知识点的题型分配
kp_assignments = {} # {kp_name: {q_type: count, ...}}
# 按题型依次分配
for q_type, type_count in question_types.items():
if type_count <= 0:
continue
assigned_for_type = 0
kp_idx = 0
while assigned_for_type < type_count:
# 循环使用知识点
kp_idx = kp_idx % kp_count
kp = knowledge_points[kp_idx]
kp_name = kp['name']
# 初始化该知识点的分配记录
if kp_name not in kp_assignments:
kp_assignments[kp_name] = {}
# 每个知识点对每种题型最多出 1 道
current_count = kp_assignments[kp_name].get(q_type, 0)
if current_count < 1:
kp_assignments[kp_name][q_type] = current_count + 1
assigned_for_type += 1
kp_idx += 1
# 防止无限循环(知识点不够用时)
if kp_idx > kp_count * type_count:
break
# 转换为 List[tuple] 格式
assignments = []
for kp_name, q_types in kp_assignments.items():
total = sum(q_types.values())
assignments.append((kp_name, q_types, total))
return assignments
def _retrieve_kp_chunks(
self,
knowledge_point: str,
all_chunks: List[Dict],
top_k: int = 5
) -> List[Dict]:
"""
🔥 Step 3.5: 根据知识点检索相关 chunks
策略:
1. 先尝试语义检索
2. 如果结果不足,用关键词匹配补充
"""
# 构建精准 query
query = f"{knowledge_point} 定义 规则 说明"
# 简单的关键词匹配(不调用向量检索,因为 chunks 已经是过滤后的)
scored_chunks = []
kp_lower = knowledge_point.lower()
for chunk in all_chunks:
content = chunk.get('content', '').lower()
score = 0
# 知识点直接匹配
if kp_lower in content:
score += 10
# 关键词匹配
for keyword in ['定义', '规则', '说明', '标准', '流程']:
if keyword in content:
score += 1
if score > 0:
scored_chunks.append((score, chunk))
# 按分数排序
scored_chunks.sort(key=lambda x: x[0], reverse=True)
# 如果匹配不足,补充前面的 chunks
result = [c for _, c in scored_chunks[:top_k]]
if len(result) < top_k:
for chunk in all_chunks:
if chunk not in result:
result.append(chunk)
if len(result) >= top_k:
break
return result
def _build_prompt_for_kp(
self,
source_content: str,
knowledge_point: str,
question_types: Dict[str, int],
difficulty: int
) -> str:
"""
🔥 Step 4: 构造针对知识点的出题 Prompt
"""
q_type_str = ", ".join(f"{t}:{n}" for t, n in question_types.items())
return f"""基于以下文档内容,围绕知识点【{knowledge_point}】生成考试题目。
## 文档内容
{source_content}
## 出题要求
- 核心知识点:{knowledge_point}
- 难度等级:{difficulty}/5
- 题型及数量:{q_type_str}
## 🔥 关键约束
1. **必须围绕【{knowledge_point}】出题**,题目内容要与该知识点直接相关
2. **每道题必须基于不同的角度或细节**,避免重复考察同一内容
3. **严禁输出非 JSON 内容**
4. **必须从文档内容中找到依据**
## 输出格式JSON 数组)
{self._get_format_examples()}
请直接输出 JSON 数组:"""
def _deduplicate_and_balance(
self,
questions: List[Dict],
target_types: Dict[str, int]
) -> List[Dict]:
"""
🔥 Step 5: 去重 + 题型平衡
"""
if not questions:
return []
# 按题型分组
by_type = defaultdict(list)
for q in questions:
q_type = q.get('question_type', 'unknown')
by_type[q_type].append(q)
# 去重:同题型的题目,题干相似度高的去重
deduped = []
for q_type, q_list in by_type.items():
seen_stems = set()
type_questions = []
for q in q_list:
stem = q.get('content', {}).get('stem', '')
stem_key = stem[:50] # 用前50字作为去重key
if stem_key not in seen_stems:
seen_stems.add(stem_key)
type_questions.append(q)
# 按目标数量截取
target_count = target_types.get(q_type, 0)
deduped.extend(type_questions[:target_count])
return deduped
def _enrich_with_source_trace(
self,
questions: List[Dict],
chunks: List[Dict],
document_name: str
) -> List[Dict]:
"""
补充溯源信息
🔥 只返回 RAG 负责的字段后端负责的字段question_id, score, tags不生成
"""
enriched = []
for q in questions:
# 找到引用的切片
referenced_chunks = find_referenced_chunks(q, chunks)
question = {
# 题型RAG 负责)
"question_type": q.get('type'),
# 难度RAG 负责)
"difficulty": q.get('difficulty', 3),
# 题目内容RAG 负责)
"content": q.get('content', {}),
# 溯源信息RAG 负责)
"source_trace": {
"document_name": document_name,
"chunk_ids": [c.get('chunk_id', '') for c in referenced_chunks],
"page_numbers": sorted(set([c.get('page') for c in referenced_chunks if c.get('page')])),
"sources": [
{
"chunk_id": c.get('chunk_id', ''),
"page": c.get('page'),
"section": c.get('section', ''),
"snippet": c['content'][:200] if c.get('content') else ''
}
for c in referenced_chunks
]
}
}
enriched.append(question)
return enriched
# ==================== 便捷函数 ====================
def generate_questions_from_content(
source_content: str,
document_name: str,
question_types: Dict[str, int],
difficulty: int = 3
) -> List[Dict]:
"""便捷函数:从文本内容生成题目(将纯文本包装为单 chunk 后调用结构化出题)"""
if not source_content or not source_content.strip():
return []
# 将纯文本包装为一个 chunk
chunks = [{"content": source_content, "section": "", "page": None, "chunk_id": "content_0"}]
generator = QuestionGenerator()
return generator.generate_questions_structured(chunks, document_name, question_types, difficulty)
def analyze_document_for_exam(chunks: List[Dict], max_total: int = None) -> Dict[str, Any]:
"""
AI 智能分析文档内容,决定适合的题型和数量
Args:
chunks: 文档切片列表
Returns:
{
"total_knowledge_points": int,
"suitable_types": List[str],
"question_types": Dict[str, int],
"reason": str
}
"""
if not chunks:
return {
"total_knowledge_points": 0,
"suitable_types": [],
"question_types": {},
"reason": "文档内容为空,无法出题"
}
generator = QuestionGenerator()
# Step 1: 按章节分组
section_map = group_chunks_by_section(chunks)
logger.info(f"文档共 {len(section_map)} 个章节,{len(chunks)} 个切片")
# Step 2: 统计每章节知识点数量
total_knowledge_points = 0
section_stats = []
for section_name, section_chunks in section_map.items():
# 合并章节内容
all_content = "\n".join(
c.get('content', '').strip()
for c in section_chunks
if c.get('content', '').strip()
)
# 统计知识点(简化版:根据内容长度估算)
if len(all_content) < 50:
kp_count = 0
elif len(all_content) < 200:
kp_count = 1
elif len(all_content) < 500:
kp_count = 2
else:
kp_count = min(5, len(all_content) // 200)
total_knowledge_points += kp_count
section_stats.append({
"name": section_name[:50],
"content_length": len(all_content),
"knowledge_points": kp_count
})
# Step 3: 调用 LLM 分析适合的题型和数量
prompt = f"""你是一个出题专家。根据以下文档分析结果,决定适合的题型和数量。
## 文档章节统计
{len(section_map)} 个章节,约 {total_knowledge_points} 个知识点
## 章节详情
{json.dumps(section_stats[:10], ensure_ascii=False, indent=2)}
## 内容示例前3个章节
"""
# 添加前3个章节的内容示例
for i, (section_name, section_chunks) in enumerate(list(section_map.items())[:3]):
content_preview = "\n".join(
c.get('content', '')[:100]
for c in section_chunks[:2]
if c.get('content')
)
prompt += f"\n### {section_name[:30]}\n{content_preview[:300]}\n"
question_limit = min(total_knowledge_points * 2, max_total if max_total else 20)
prompt += """
## 要求
根据文档内容特点,决定:
1. 适合哪些题型single_choice/multiple_choice/true_false/fill_blank/subjective
2. 每种题型出多少道题
3. 总题目数应与知识点数量匹配(知识点少则少出,多则多出)
## 输出格式(严格 JSON
{
"total_knowledge_points": 知识点总数,
"suitable_types": ["题型1", "题型2"],
"question_types": {
"single_choice": 数量,
"multiple_choice": 数量,
"true_false": 数量,
"fill_blank": 数量,
"subjective": 数量
},
"reason": "分析理由50字以内"
}
注意:
- 不适合的题型数量设为 0
- 所有数量之和不要超过 %d
- 必须返回合法 JSON不要有其他内容
请直接输出 JSON""" % question_limit
try:
response = generator._call_llm(prompt)
if not response or not response.strip():
return _generate_default_question_types(total_knowledge_points)
# 使用多策略 JSON 提取(支持 markdown 代码块、正则回退)
result = extract_json_object(response)
if not isinstance(result, dict):
logger.error(f"AI 分析返回非对象类型: {type(result)}")
return _generate_default_question_types(total_knowledge_points)
# 验证和清理结果
valid_types = ['single_choice', 'multiple_choice', 'true_false', 'fill_blank', 'subjective']
question_types = {}
for q_type in valid_types:
count = result.get('question_types', {}).get(q_type, 0)
if isinstance(count, int) and count >= 0:
question_types[q_type] = count
# 过滤不适合的题型
suitable_types = [t for t, c in question_types.items() if c > 0]
# 总数上限校验:如果指定了 max_total超过时按比例缩减
if max_total and max_total > 0:
total = sum(question_types.values())
if total > max_total:
ratio = max_total / total
question_types = {k: max(0, round(v * ratio)) for k, v in question_types.items()}
# 修正四舍五入误差
diff = max_total - sum(question_types.values())
if diff > 0:
# 把差额分配给最大的题型
for k in sorted(question_types, key=question_types.get, reverse=True):
question_types[k] += 1
diff -= 1
if diff <= 0:
break
logger.info(f" AI 推荐 {total} 题,按上限 {max_total} 缩减为 {sum(question_types.values())}")
suitable_types = [t for t, c in question_types.items() if c > 0]
return {
"total_knowledge_points": result.get('total_knowledge_points', total_knowledge_points),
"suitable_types": suitable_types,
"question_types": question_types,
"reason": result.get('reason', 'AI 分析完成')
}
except Exception as e:
logger.error(f"AI 分析失败: {e}")
return _generate_default_question_types(total_knowledge_points)
def _generate_default_question_types(knowledge_points: int) -> Dict[str, Any]:
"""
根据知识点数量生成默认题型配置AI 分析失败时的降级方案)
Args:
knowledge_points: 知识点数量
Returns:
与 analyze_document_for_exam 相同格式
"""
if knowledge_points <= 3:
question_types = {
"single_choice": 2,
"true_false": 2,
"fill_blank": 1,
"subjective": 0,
"multiple_choice": 0
}
reason = "知识点较少,以客观题为主"
elif knowledge_points <= 8:
question_types = {
"single_choice": 3,
"multiple_choice": 1,
"true_false": 2,
"fill_blank": 2,
"subjective": 1
}
reason = "知识点适中,题型均衡分布"
else:
question_types = {
"single_choice": 5,
"multiple_choice": 2,
"true_false": 3,
"fill_blank": 3,
"subjective": 2
}
reason = "知识点丰富,全面覆盖各类题型"
return {
"total_knowledge_points": knowledge_points,
"suitable_types": [t for t, c in question_types.items() if c > 0],
"question_types": question_types,
"reason": reason
}
# ==================== 重构版出题逻辑 ====================
def generate_questions_structured_v2(
chunks: List[Dict],
document_name: str,
question_types: Dict[str, int],
difficulty: int = 3,
exclude_stems: List[str] = None
) -> List[Dict]:
"""
🔥 重构版:结构化出题 v2
最佳设计:
1. Phase 1: 文档结构分析
2. Phase 2: 知识点规划(全局唯一)
3. Phase 3: 精准出题(每个知识点单独检索)
4. Phase 4: 质量校验
Args:
chunks: 文档切片列表
document_name: 文档名称
question_types: 题型及数量 {"single_choice": 5, ...}
difficulty: 难度 1-5
exclude_stems: 需要排除的已有题目题干列表(跨调用去重)
Returns:
题目列表
"""
total_questions = sum(question_types.values())
if not chunks or total_questions == 0:
return []
generator = QuestionGenerator()
# ========== Phase 1: 文档结构分析 ==========
logger.info("[v2] Phase 1: 文档结构分析")
section_map = group_chunks_by_section(chunks)
logger.info(f" 章节数: {len(section_map)}")
# ========== Phase 2: 知识点规划(全局唯一) ==========
logger.info("[v2] Phase 2: 知识点规划")
all_knowledge_points = []
seen_kp_names = set() # 全局去重
for section, section_chunks in section_map.items():
# 提取知识点
kps = generator._extract_knowledge_points(section, section_chunks, max_points=3)
# API 限流保护:连续 LLM 调用间隔 1 秒
import time
time.sleep(1)
# 全局去重
for kp in kps:
kp_name = kp['name']
if kp_name not in seen_kp_names:
seen_kp_names.add(kp_name)
kp['section'] = section # 标记来源章节
all_knowledge_points.append(kp)
logger.info(f" 全局唯一知识点: {len(all_knowledge_points)}")
if not all_knowledge_points:
# 降级:直接使用 chunks 出题(格式已由 fallback 内部统一)
logger.warning("[v2] 知识点提取失败,降级使用 chunks 出题")
fallback_questions = generator._generate_questions_fallback(chunks, document_name, question_types, difficulty)
# 降级路径也执行去重和数量平衡
deduped = _deduplicate_questions(fallback_questions, exclude_stems=exclude_stems)
final = _balance_question_types(deduped, question_types)
logger.info(f" [降级] 最终题目数: {len(final)}")
return final
# AI 分配:哪些知识点出什么题型
assignments = _ai_assign_question_types(all_knowledge_points, question_types)
logger.info(f" 分配任务: {len(assignments)}")
# ========== Phase 3: 精准出题 ==========
logger.info("[v2] Phase 3: 精准出题")
all_questions = []
for assignment in assignments:
kp_name = assignment['knowledge_point']
q_type = assignment['question_type']
kp_section = assignment.get('section', '')
# 找到该知识点的原始对象
kp_obj = next((kp for kp in all_knowledge_points if kp['name'] == kp_name), None)
if not kp_obj:
continue
# 根据章节找到相关 chunks
section_chunks = section_map.get(kp_section, [])
if not section_chunks:
section_chunks = chunks # 降级使用全部 chunks
# 精准检索该知识点的相关 chunks
kp_chunks = _retrieve_kp_chunks_v2(kp_name, section_chunks, top_k=5)
if not kp_chunks:
logger.warning(f" [v2] 知识点 [{kp_name[:20]}...] 无相关 chunks跳过")
continue
# 构造上下文
source_content = build_source_context(kp_chunks)
# 出题 prompt
prompt = generator._build_prompt_for_kp(
source_content, kp_name, {q_type: 1}, difficulty
)
# 出题(带重试)
success, questions = generator._generate_with_retry(
prompt, {q_type: 1}, kp_chunks, document_name, max_retries=2
)
# API 限流保护:连续 LLM 调用间隔 1 秒
import time
time.sleep(1)
if success and questions:
all_questions.extend(questions)
else:
logger.warning(f" [v2] 知识点 [{kp_name[:20]}...] 出题失败")
# ========== Phase 4: 质量校验 ==========
logger.info("[v2] Phase 4: 质量校验")
# 4.1 去重(含跨调用排除已有题目)
deduped = _deduplicate_questions(all_questions, exclude_stems=exclude_stems)
logger.info(f" 去重: {len(all_questions)}{len(deduped)}" + (f"(排除已有 {len(exclude_stems)} 道)" if exclude_stems else ""))
# 4.2 数量校正
final = _balance_question_types(deduped, question_types)
logger.info(f" 最终题目数: {len(final)}")
# 4.3 题型分布
type_counts = defaultdict(int)
for q in final:
type_counts[q.get('question_type')] += 1
logger.info(f" 题型分布: {dict(type_counts)}")
# 4.4 补题:如果某题型数量不足,使用 chunks 补充
shortage_types = {}
for q_type, target_count in question_types.items():
actual_count = type_counts.get(q_type, 0)
if actual_count < target_count:
shortage_types[q_type] = target_count - actual_count
if shortage_types:
logger.info(f" [v2] 补题: 缺少题型 {dict(shortage_types)}")
# 收集 final 中已有题干,补题时一并排除
existing_stems = [q.get('content', {}).get('stem', '') for q in final if q.get('content', {}).get('stem')]
combined_exclude = list(exclude_stems or []) + existing_stems
for q_type, shortage in shortage_types.items():
logger.info(f" 补充 {q_type} {shortage} 道...")
extra = generator._makeup_questions(chunks, q_type, shortage, difficulty, document_name)
# 补题需与已有题目 + 跨调用排除列表一起去重
extra_deduped = _deduplicate_questions(extra, exclude_stems=combined_exclude)
final.extend(extra_deduped[:shortage])
# 将新补的题干也加入排除列表,防止后续题型补出重复
for q in extra_deduped:
stem = q.get('content', {}).get('stem', '')
if stem:
combined_exclude.append(stem)
# 补题后重新统计
type_counts = defaultdict(int)
for q in final:
type_counts[q.get('question_type')] += 1
logger.info(f" 补题后题型分布: {dict(type_counts)}")
return final
def _ai_assign_question_types(
knowledge_points: List[Dict],
question_types: Dict[str, int]
) -> List[Dict]:
"""
AI 分配:哪些知识点出什么题型
策略:
1. 按章节轮询知识点(确保章节覆盖)
2. 每个知识点最多出 1 道题
3. 确保不同知识点都有覆盖
Args:
knowledge_points: 知识点列表
question_types: 题型及数量
Returns:
[{"knowledge_point": "...", "question_type": "...", "section": "..."}, ...]
"""
assignments = []
# 按章节分组知识点
kp_by_section = defaultdict(list)
for kp in knowledge_points:
section = kp.get('section', '未分类')
kp_by_section[section].append(kp['name'])
# 为每种题型分配知识点
for q_type, type_count in question_types.items():
if type_count <= 0:
continue
# 轮询章节
section_names = list(kp_by_section.keys())
assigned = 0
section_idx = 0
while assigned < type_count:
if not section_names:
break
# 当前章节
section = section_names[section_idx % len(section_names)]
kp_list = kp_by_section[section]
# 找一个还没分配过该题型的知识点
for kp_name in kp_list:
# 检查是否已经分配过
already_assigned = any(
a['knowledge_point'] == kp_name and a['question_type'] == q_type
for a in assignments
)
if not already_assigned:
assignments.append({
'knowledge_point': kp_name,
'question_type': q_type,
'section': section
})
assigned += 1
break
section_idx += 1
# 防止无限循环
if section_idx > len(section_names) * 2:
break
return assignments
def _retrieve_kp_chunks_v2(
knowledge_point: str,
section_chunks: List[Dict],
top_k: int = 5
) -> List[Dict]:
"""
v2 版本:根据知识点检索相关 chunks
改进:
1. 优先使用章节内的 chunks
2. 关键词匹配更精准
3. 支持多个关键词组合
Args:
knowledge_point: 知识点名称
section_chunks: 该章节的 chunks
top_k: 返回数量
Returns:
相关 chunks 列表
"""
if not section_chunks:
return []
# 提取关键词
keywords = knowledge_point.replace(' ', '').replace('', ',').split(',')
keywords = [kw.strip() for kw in keywords if len(kw.strip()) >= 2]
# 评分
scored_chunks = []
for chunk in section_chunks:
content = chunk.get('content', '').lower()
score = 0
# 知识点直接匹配(最高优先级)
if knowledge_point.lower() in content:
score += 100
# 关键词匹配
for kw in keywords:
if kw.lower() in content:
score += 10
# 内容长度适中加分
content_len = len(content)
if 100 <= content_len <= 500:
score += 2
if score > 0:
scored_chunks.append((score, chunk))
# 按分数排序
scored_chunks.sort(key=lambda x: x[0], reverse=True)
# 返回 top_k
result = [c for _, c in scored_chunks[:top_k]]
# 如果不足,补充前面的 chunks
if len(result) < top_k:
for chunk in section_chunks:
if chunk not in result:
result.append(chunk)
if len(result) >= top_k:
break
return result
def _deduplicate_questions(questions: List[Dict], exclude_stems: List[str] = None) -> List[Dict]:
"""
题目去重
策略:
1. 相同题干去重(前 80 字)
2. 相同知识点 + 相同题型去重
3. 排除已有题目(跨调用去重)
Args:
questions: 原始题目列表
exclude_stems: 需要排除的已有题目题干列表(用于跨调用去重)
Returns:
去重后的题目列表
"""
seen_stems = set()
seen_kp_type = set()
# 预填已有题目的题干前缀,使新生成的题目与已有题目冲突时被过滤
if exclude_stems:
_valid_types = ('single_choice', 'multiple_choice', 'true_false', 'fill_blank', 'subjective')
for stem in exclude_stems:
seen_stems.add(stem[:80])
# 用 exclude_stem 自身长度做前缀键排除题干通常短于30字
# 新题目的 stem[:30] 如果以此前缀开头,[:len(prefix)] 后就能匹配
_prefix = stem[:30]
for _qt in _valid_types:
seen_kp_type.add(f"{_prefix}_{_qt}")
# 辅助函数:检查新题干是否匹配任何 exclude 前缀
_exclude_prefixes = []
if exclude_stems:
_exclude_prefixes = [s[:30] for s in exclude_stems]
def _matches_exclude(stem_text: str) -> bool:
"""检查题干前30字是否以某个 exclude 前缀开头"""
_stem30 = stem_text[:30]
for _ep in _exclude_prefixes:
if _stem30.startswith(_ep):
return True
return False
deduped = []
for q in questions:
content = q.get('content', {})
stem = content.get('stem', '')
# 题干去重(前 80 字)
stem_key = stem[:80]
if stem_key in seen_stems:
continue
# 跨调用排除:题干前缀匹配到 exclude_stems 则跳过
if _matches_exclude(stem):
continue
# 知识点 + 题型去重
kp_type_key = f"{stem[:30]}_{q.get('question_type')}"
if kp_type_key in seen_kp_type:
continue
seen_stems.add(stem_key)
seen_kp_type.add(kp_type_key)
deduped.append(q)
return deduped
def _balance_question_types(
questions: List[Dict],
target_types: Dict[str, int]
) -> List[Dict]:
"""
题型平衡:按目标数量截取
Args:
questions: 题目列表
target_types: 目标题型数量
Returns:
平衡后的题目列表
"""
# 按题型分组
by_type = defaultdict(list)
for q in questions:
q_type = q.get('question_type', 'unknown')
by_type[q_type].append(q)
# 按目标数量截取
result = []
for q_type, target_count in target_types.items():
type_questions = by_type.get(q_type, [])
result.extend(type_questions[:target_count])
return result