indexer.py 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170
  1. """增量索引 + FTS + 统计模块"""
  2. import os
  3. import shutil
  4. import time
  5. from datetime import datetime
  6. from pathlib import Path
  7. from app.database import (
  8. get_connection, upsert_problem, delete_problem, update_fts_body,
  9. get_all_problems, compute_checksum,
  10. )
  11. from app.services.docx_parser import read_custom_props, extract_plain_text
  12. from app.services.knowledge import load_knowledge_map, get_chapter_from_code
  13. def scan_problem_files(problems_dir: Path) -> dict[str, dict]:
  14. """扫描 problems/ 目录下所有 docx 文件"""
  15. files = {}
  16. problems_dir = Path(problems_dir)
  17. if not problems_dir.exists():
  18. return files
  19. for docx_file in problems_dir.glob("*.docx"):
  20. problem_id = docx_file.stem # 文件名即 ID
  21. mtime = docx_file.stat().st_mtime
  22. checksum = compute_checksum(docx_file)
  23. files[problem_id] = {
  24. "file_path": str(docx_file),
  25. "mtime": mtime,
  26. "checksum": checksum,
  27. }
  28. return files
  29. def parse_docx_metadata(docx_path: str | Path, knowledge_map: dict) -> dict | None:
  30. """从 docx 自定义属性读取元数据"""
  31. props = read_custom_props(docx_path)
  32. if not props:
  33. return None
  34. import json
  35. knowledge_codes_str = props.get("knowledge_codes", "[]")
  36. try:
  37. knowledge_codes = json.loads(knowledge_codes_str) if isinstance(knowledge_codes_str, str) else knowledge_codes_str
  38. except json.JSONDecodeError:
  39. knowledge_codes = []
  40. chapters = []
  41. for code in knowledge_codes:
  42. ch = get_chapter_from_code(code, knowledge_map)
  43. if ch:
  44. chapters.append(ch)
  45. return {
  46. "difficulty": float(props.get("difficulty", 0.5)),
  47. "question_type": str(props.get("question_type", "")),
  48. "import_type": str(props.get("import_type", "精选题")),
  49. "knowledge_codes": knowledge_codes,
  50. "chapters": sorted(set(chapters)),
  51. "comment": str(props.get("comment", "")),
  52. "source": str(props.get("source", "")),
  53. }
  54. def incremental_update(config) -> list[str]:
  55. """增量索引:扫描 → 对比 → 更新数据库 + FTS"""
  56. changes = []
  57. conn = get_connection(config.db_path)
  58. knowledge_map = load_knowledge_map(config)
  59. try:
  60. # 扫描磁盘
  61. disk_files = scan_problem_files(config.problems_dir)
  62. # 获取数据库中记录
  63. db_rows = conn.execute("SELECT id, file_path, mtime, checksum FROM problems").fetchall()
  64. db_map = {r["id"]: {"file_path": r["file_path"], "mtime": r["mtime"], "checksum": r["checksum"]} for r in db_rows}
  65. # 新增/修改
  66. for pid, info in disk_files.items():
  67. if pid not in db_map:
  68. # 新增
  69. meta = parse_docx_metadata(info["file_path"], knowledge_map)
  70. if meta:
  71. problem = {
  72. "id": pid,
  73. "file_path": info["file_path"],
  74. "mtime": info["mtime"],
  75. "checksum": info["checksum"],
  76. **meta,
  77. }
  78. upsert_problem(conn, problem)
  79. # 更新 FTS
  80. body = extract_plain_text(info["file_path"])
  81. update_fts_body(conn, pid, body)
  82. changes.append(f"+ 新增: {pid}")
  83. else:
  84. # 已存在,检查变更
  85. db_info = db_map[pid]
  86. if info["checksum"] != db_info["checksum"]:
  87. meta = parse_docx_metadata(info["file_path"], knowledge_map)
  88. if meta:
  89. problem = {
  90. "id": pid,
  91. "file_path": info["file_path"],
  92. "mtime": info["mtime"],
  93. "checksum": info["checksum"],
  94. **meta,
  95. }
  96. upsert_problem(conn, problem)
  97. body = extract_plain_text(info["file_path"])
  98. update_fts_body(conn, pid, body)
  99. changes.append(f"~ 修改: {pid}")
  100. elif info["mtime"] != db_info["mtime"]:
  101. # 仅 mtime 变,checksum 不变 → 重索引 FTS
  102. body = extract_plain_text(info["file_path"])
  103. update_fts_body(conn, pid, body)
  104. changes.append(f"* FTS 更新: {pid}")
  105. # 删除(磁盘不存在但数据库有)
  106. for pid, db_info in db_map.items():
  107. if pid not in disk_files:
  108. changes.append(f"! 磁盘缺失: {pid} ({db_info['file_path']})")
  109. # 不自动删除,只提示
  110. conn.commit()
  111. finally:
  112. conn.close()
  113. return changes
  114. def ensure_index(config) -> list[str]:
  115. """确保索引存在:如果 knowledge_map 或 DB 不存在,则初始化"""
  116. result = []
  117. if not config.knowledge_map_path.exists():
  118. from app.services.knowledge import build_knowledge
  119. build_knowledge(config)
  120. result.append("knowledge_map 已构建")
  121. if not config.db_path.exists():
  122. from app.database import init_db
  123. init_db(config.db_path)
  124. result.append("数据库已初始化")
  125. return result
  126. def show_stats(config):
  127. """终端显示统计"""
  128. from app.database import get_connection, count_by_question_type, count_by_import_type, count_by_chapter
  129. conn = get_connection(config.db_path)
  130. try:
  131. total_row = conn.execute("SELECT COUNT(*) FROM problems").fetchone()
  132. total = total_row[0] if total_row else 0
  133. print(f"总题数: {total}")
  134. print("\n按题型:")
  135. for t, c in count_by_question_type(conn).items():
  136. print(f" {t}: {c}")
  137. print("\n按入库类型:")
  138. for t, c in count_by_import_type(conn).items():
  139. print(f" {t}: {c}")
  140. print("\n按章节:")
  141. for ch, c in sorted(count_by_chapter(conn).items()):
  142. print(f" {ch}: {c}")
  143. finally:
  144. conn.close()