- exam_pkg/api.py: 移除总题数20道上限,改为50道警告;修正认证错误使用正确的UNAUTHORIZED/FORBIDDEN状态码;端点保持同步模式 - exam_pkg/generator.py: AI智能出题prompt支持max_total参数 - exam_pkg/grader.py: 填空题增加学生答案格式校验(列表类型+字符串元素)
1572 lines
51 KiB
Python
1572 lines
51 KiB
Python
"""
|
||
出题生成器 - 结构化 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:
|
||
"""调用本地 LLM(OpenAI 兼容接口),支持 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"
|
||
|
||
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
|
||
- 所有数量之和不要超过 {min(total_knowledge_points * 2, max_total if max_total else 20)}
|
||
- 必须返回合法 JSON,不要有其他内容
|
||
|
||
请直接输出 JSON:"""
|
||
|
||
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
|