generate_question_variants.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. # -*- coding: utf-8 -*-
  2. """
  3. 自动生成 question_variants:
  4. - 依据 question_knowledge_points 的知识点映射,找“同知识点”的题作为变式题
  5. - 为每道 base_question 最多生成 N 道 variant_question(默认 3)
  6. - 使用 question_variants(base_question_id, variant_question_id) 的唯一约束去重
  7. 注意:
  8. - 如果 question_knowledge_points 为空/不完整,会先把 questions.knowledge_point 同步补齐到 question_knowledge_points
  9. (避免因为历史未触发 lazy 建映射导致 variants 无法生成)
  10. """
  11. import argparse
  12. import random
  13. from collections import defaultdict
  14. import os
  15. import sys
  16. from sqlalchemy import and_
  17. sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
  18. from backend.config import get_settings
  19. from backend.database import SessionLocal
  20. from backend.models import KnowledgePoint, Question, QuestionKnowledgePoint, QuestionVariant
  21. def _ensure_knowledge_points(session) -> None:
  22. """确保 knowledge_points 表至少包含 questions.knowledge_point 中出现的知识点名。"""
  23. kp_names = (
  24. session.query(Question.knowledge_point)
  25. .filter(and_(Question.knowledge_point.isnot(None), Question.knowledge_point != ""))
  26. .distinct()
  27. .all()
  28. )
  29. names = [x[0] for x in kp_names if x and x[0]]
  30. if not names:
  31. return
  32. existing = {
  33. n[0] for n in session.query(KnowledgePoint.name).filter(KnowledgePoint.name.in_(names)).all()
  34. }
  35. to_create = [n for n in names if n not in existing]
  36. for name in to_create:
  37. session.add(KnowledgePoint(name=name))
  38. if to_create:
  39. session.commit()
  40. def _ensure_question_knowledge_points(session) -> None:
  41. """
  42. 若 question_knowledge_points 缺少 (question_id, knowledge_point_id) 组合,
  43. 则从 Question.knowledge_point 同步补齐 weight=1 映射。
  44. """
  45. # 预取 existing pair(question_id, knowledge_point_id)
  46. existing_pairs = set(
  47. session.query(QuestionKnowledgePoint.question_id, QuestionKnowledgePoint.knowledge_point_id).all()
  48. )
  49. # 分批拉取 questions,逐条补齐
  50. questions = (
  51. session.query(Question.id, Question.knowledge_point)
  52. .filter(and_(Question.knowledge_point.isnot(None), Question.knowledge_point != ""))
  53. .all()
  54. )
  55. if not questions:
  56. return
  57. # 缓存 knowledge_point name -> id
  58. kp_rows = session.query(KnowledgePoint.id, KnowledgePoint.name).all()
  59. kp_name_to_id = {name: kid for kid, name in [(r[0], r[1]) for r in kp_rows]}
  60. created = 0
  61. for qid, kp_name in questions:
  62. kpid = kp_name_to_id.get(kp_name)
  63. if not kpid:
  64. continue
  65. if (qid, kpid) in existing_pairs:
  66. continue
  67. session.add(
  68. QuestionKnowledgePoint(
  69. question_id=qid,
  70. knowledge_point_id=kpid,
  71. weight=1,
  72. )
  73. )
  74. existing_pairs.add((qid, kpid))
  75. created += 1
  76. if created:
  77. session.commit()
  78. def generate_variants(
  79. session,
  80. max_variants_per_base: int = 3,
  81. max_total_bases: int | None = None,
  82. seed: int = 42,
  83. ) -> int:
  84. """
  85. 生成 question_variants,并写入数据库。
  86. 返回:插入的 rows 数
  87. """
  88. random.seed(seed)
  89. # 1) 拉取 qkp 映射
  90. qkps = (
  91. session.query(
  92. QuestionKnowledgePoint.question_id,
  93. QuestionKnowledgePoint.knowledge_point_id,
  94. QuestionKnowledgePoint.weight,
  95. )
  96. .all()
  97. )
  98. if not qkps:
  99. print("[WARN] question_knowledge_points 为空,无法生成 variants。请先补齐映射。")
  100. return 0
  101. question_kps: dict[int, list[tuple[int, int]]] = defaultdict(list)
  102. kp_q_weight: dict[int, dict[int, int]] = defaultdict(dict)
  103. for qid, kpid, w in qkps:
  104. if not kpid or not qid:
  105. continue
  106. w_int = int(w) if w is not None else 1
  107. question_kps[int(qid)].append((int(kpid), w_int))
  108. # 同一 (kp, q) 可能重复映射时取最大权重
  109. prev = kp_q_weight[int(kpid)].get(int(qid))
  110. if prev is None or w_int > prev:
  111. kp_q_weight[int(kpid)][int(qid)] = w_int
  112. base_qids = list(question_kps.keys())
  113. if not base_qids:
  114. return 0
  115. if max_total_bases is not None:
  116. base_qids = base_qids[: max_total_bases]
  117. # 2) 预取 existing variants 去重
  118. existing_pairs = set(
  119. session.query(QuestionVariant.base_question_id, QuestionVariant.variant_question_id).all()
  120. )
  121. # 3) 为每道 base_question 生成 candidates
  122. to_insert: list[QuestionVariant] = []
  123. inserted = 0
  124. # 增加一个轻量的随机打散,避免完全相同 overlap score 时固定选择
  125. random.shuffle(base_qids)
  126. for i, base_qid in enumerate(base_qids, start=1):
  127. base_kps = question_kps.get(base_qid) or []
  128. if len(base_kps) == 0:
  129. continue
  130. candidate_scores: dict[int, int] = defaultdict(int)
  131. candidate_best_kp: dict[int, int] = {}
  132. candidate_best_score: dict[int, int] = {}
  133. for kpid, w_base in base_kps:
  134. cand_map = kp_q_weight.get(kpid) or {}
  135. for cand_qid, w_cand in cand_map.items():
  136. if cand_qid == base_qid:
  137. continue
  138. w_base = int(w_base)
  139. w_cand = int(w_cand)
  140. contrib = w_base * w_cand
  141. candidate_scores[cand_qid] += contrib
  142. if contrib > candidate_best_score.get(cand_qid, 0):
  143. candidate_best_score[cand_qid] = contrib
  144. candidate_best_kp[cand_qid] = kpid
  145. if not candidate_scores:
  146. continue
  147. # 候选排序:score desc + cand_qid 轻量打散
  148. sorted_cands = sorted(
  149. candidate_scores.items(),
  150. key=lambda kv: (kv[1], random.random()),
  151. reverse=True,
  152. )
  153. variants_for_base = 0
  154. for cand_qid, score in sorted_cands:
  155. pair = (base_qid, cand_qid)
  156. if pair in existing_pairs:
  157. continue
  158. vid_kp = candidate_best_kp.get(cand_qid)
  159. # weight:用相似度 score 作为排序依据;数值太大也没关系(这里只是降序)
  160. w = int(score) if score is not None else 1
  161. to_insert.append(
  162. QuestionVariant(
  163. base_question_id=base_qid,
  164. variant_question_id=cand_qid,
  165. knowledge_point_id=vid_kp,
  166. weight=w,
  167. )
  168. )
  169. existing_pairs.add(pair)
  170. variants_for_base += 1
  171. inserted += 1
  172. if variants_for_base >= max_variants_per_base:
  173. break
  174. # 每 500 条 flush,避免内存过大
  175. if inserted > 0 and inserted % 500 == 0:
  176. session.add_all(to_insert)
  177. session.commit()
  178. to_insert.clear()
  179. print(f"[INFO] 已插入 {inserted} rows(base 已处理到 {i}/{len(base_qids)})")
  180. if to_insert:
  181. session.add_all(to_insert)
  182. session.commit()
  183. return inserted
  184. def main():
  185. parser = argparse.ArgumentParser()
  186. parser.add_argument("--max-variants-per-base", type=int, default=3)
  187. parser.add_argument("--max-total-bases", type=int, default=2000)
  188. parser.add_argument("--seed", type=int, default=42)
  189. args = parser.parse_args()
  190. settings = get_settings()
  191. print(f"[INFO] Using MYSQL_URI: {settings.MYSQL_URI}")
  192. session = SessionLocal()
  193. try:
  194. _ensure_knowledge_points(session)
  195. _ensure_question_knowledge_points(session)
  196. inserted = generate_variants(
  197. session,
  198. max_variants_per_base=args.max_variants_per_base,
  199. max_total_bases=args.max_total_bases,
  200. seed=args.seed,
  201. )
  202. print(f"[DONE] question_variants inserted rows: {inserted}")
  203. finally:
  204. session.close()
  205. if __name__ == "__main__":
  206. main()