database.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
  1. """数据库层 — SQLite CRUD + FTS5"""
  2. import hashlib
  3. import json
  4. import sqlite3
  5. import os
  6. from pathlib import Path
  7. from typing import Any
  8. def get_connection(db_path: str | Path) -> sqlite3.Connection:
  9. conn = sqlite3.connect(str(db_path))
  10. conn.row_factory = sqlite3.Row
  11. conn.execute("PRAGMA journal_mode=WAL")
  12. conn.execute("PRAGMA foreign_keys=ON")
  13. return conn
  14. def init_db(db_path: str | Path):
  15. """建表 + FTS5 + 触发器"""
  16. conn = get_connection(db_path)
  17. try:
  18. conn.executescript("""
  19. CREATE TABLE IF NOT EXISTS problems (
  20. id TEXT PRIMARY KEY,
  21. file_path TEXT NOT NULL UNIQUE,
  22. mtime REAL NOT NULL,
  23. checksum TEXT NOT NULL,
  24. chapters TEXT DEFAULT '[]',
  25. difficulty REAL DEFAULT 0.5,
  26. question_type TEXT DEFAULT '',
  27. import_type TEXT DEFAULT '精选题',
  28. knowledge_codes TEXT DEFAULT '[]',
  29. comment TEXT DEFAULT '',
  30. source TEXT DEFAULT '',
  31. created_at TEXT DEFAULT (datetime('now','localtime')),
  32. updated_at TEXT DEFAULT (datetime('now','localtime'))
  33. );
  34. CREATE TABLE IF NOT EXISTS knowledge_points (
  35. code TEXT PRIMARY KEY,
  36. path TEXT NOT NULL,
  37. leaf_name TEXT NOT NULL,
  38. chapter_code TEXT,
  39. chapter_name TEXT
  40. );
  41. CREATE VIRTUAL TABLE IF NOT EXISTS problems_fts USING fts5(
  42. problem_id UNINDEXED,
  43. body,
  44. tokenize='trigram'
  45. );
  46. CREATE TABLE IF NOT EXISTS carts (
  47. id INTEGER PRIMARY KEY AUTOINCREMENT,
  48. name TEXT NOT NULL,
  49. created_at TEXT DEFAULT (datetime('now','localtime'))
  50. );
  51. CREATE TABLE IF NOT EXISTS cart_items (
  52. cart_id INTEGER NOT NULL REFERENCES carts(id) ON DELETE CASCADE,
  53. problem_id TEXT NOT NULL REFERENCES problems(id) ON DELETE CASCADE,
  54. added_at TEXT DEFAULT (datetime('now','localtime')),
  55. PRIMARY KEY (cart_id, problem_id)
  56. );
  57. CREATE TABLE IF NOT EXISTS import_tasks (
  58. id TEXT PRIMARY KEY,
  59. status TEXT DEFAULT 'pending',
  60. total INTEGER DEFAULT 0,
  61. completed INTEGER DEFAULT 0,
  62. message TEXT DEFAULT '',
  63. result TEXT DEFAULT '{}',
  64. created_at TEXT DEFAULT (datetime('now','localtime'))
  65. );
  66. """)
  67. # 确保 FTS 触发器存在(重建 FTS 表后需要重建触发器)
  68. conn.executescript("""
  69. DROP TRIGGER IF EXISTS problems_ai;
  70. CREATE TRIGGER problems_ai AFTER INSERT ON problems BEGIN
  71. INSERT INTO problems_fts(problem_id, body) VALUES (new.id, '');
  72. END;
  73. DROP TRIGGER IF EXISTS problems_ad;
  74. CREATE TRIGGER problems_ad AFTER DELETE ON problems BEGIN
  75. DELETE FROM problems_fts WHERE problem_id = old.id;
  76. END;
  77. DROP TRIGGER IF EXISTS problems_au;
  78. CREATE TRIGGER problems_au AFTER UPDATE ON problems BEGIN
  79. DELETE FROM problems_fts WHERE problem_id = old.id;
  80. INSERT INTO problems_fts(problem_id, body) VALUES (new.id, '');
  81. END;
  82. """)
  83. conn.commit()
  84. finally:
  85. conn.close()
  86. # ── Problems CRUD ────────────────────────────────────────
  87. def upsert_problem(conn: sqlite3.Connection, problem: dict):
  88. conn.execute("""
  89. INSERT OR REPLACE INTO problems
  90. (id, file_path, mtime, checksum, chapters, difficulty, question_type,
  91. import_type, knowledge_codes, comment, source, updated_at)
  92. VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now','localtime'))
  93. """, (
  94. problem["id"],
  95. problem["file_path"],
  96. problem.get("mtime", 0),
  97. problem.get("checksum", ""),
  98. json.dumps(problem.get("chapters", []), ensure_ascii=False),
  99. problem.get("difficulty", 0.5),
  100. problem.get("question_type", ""),
  101. problem.get("import_type", "精选题"),
  102. json.dumps(problem.get("knowledge_codes", []), ensure_ascii=False),
  103. problem.get("comment", ""),
  104. problem.get("source", ""),
  105. ))
  106. conn.commit()
  107. def delete_problem(conn: sqlite3.Connection, problem_id: str):
  108. conn.execute("DELETE FROM problems WHERE id = ?", (problem_id,))
  109. conn.commit()
  110. def get_problem(conn: sqlite3.Connection, problem_id: str) -> dict | None:
  111. row = conn.execute("SELECT * FROM problems WHERE id = ?", (problem_id,)).fetchone()
  112. if row is None:
  113. return None
  114. return _row_to_dict(row)
  115. def get_all_problems(conn: sqlite3.Connection) -> list[dict]:
  116. rows = conn.execute("SELECT * FROM problems ORDER BY updated_at DESC").fetchall()
  117. return [_row_to_dict(r) for r in rows]
  118. def search_problems(
  119. conn: sqlite3.Connection,
  120. chapter: str | None = None,
  121. knowledge_code: str | None = None,
  122. question_type: str | None = None,
  123. import_type: str | None = None,
  124. annotated: str | None = None,
  125. page: int = 1,
  126. page_size: int = 20,
  127. ) -> tuple[list[dict], int]:
  128. """分页查询题目列表"""
  129. conditions = []
  130. params = []
  131. if chapter:
  132. conditions.append("chapters LIKE ?")
  133. params.append(f'%"{chapter}"%')
  134. if knowledge_code:
  135. # Prefix match: codes like 01.01 → match "01.01.03.02.01"
  136. # Full leaf code has 5 segments; partial has fewer
  137. if knowledge_code.count('.') >= 4: # full leaf code → exact match
  138. conditions.append("knowledge_codes LIKE ?")
  139. params.append(f'%"{knowledge_code}"%')
  140. else: # partial code → prefix match
  141. conditions.append("knowledge_codes LIKE ?")
  142. params.append(f'%"{knowledge_code}.%')
  143. if question_type:
  144. conditions.append("question_type = ?")
  145. params.append(question_type)
  146. if import_type:
  147. conditions.append("import_type = ?")
  148. params.append(import_type)
  149. if annotated == "yes":
  150. conditions.append("knowledge_codes != '[]'")
  151. elif annotated == "no":
  152. conditions.append("knowledge_codes = '[]'")
  153. where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
  154. # 总数
  155. total_row = conn.execute(f"SELECT COUNT(*) FROM problems {where}", params).fetchone()
  156. total = total_row[0] if total_row else 0
  157. # 分页
  158. offset = (page - 1) * page_size
  159. rows = conn.execute(
  160. f"SELECT * FROM problems {where} ORDER BY updated_at DESC LIMIT ? OFFSET ?",
  161. params + [page_size, offset]
  162. ).fetchall()
  163. return [_row_to_dict(r) for r in rows], total
  164. def search_fts(conn: sqlite3.Connection, query: str, limit: int = 50) -> list[dict]:
  165. """FTS5 全文搜索,短查询回退 LIKE"""
  166. # 清理查询中的特殊字符
  167. safe_query = query.strip()
  168. if len(safe_query) < 3:
  169. # 短查询 LIKE 回退
  170. like_pattern = f"%{safe_query}%"
  171. rows = conn.execute(
  172. "SELECT * FROM problems WHERE id IN ("
  173. " SELECT problem_id FROM problems_fts WHERE body LIKE ?"
  174. ") LIMIT ?",
  175. (like_pattern, limit)
  176. ).fetchall()
  177. else:
  178. try:
  179. rows = conn.execute(
  180. "SELECT p.* FROM problems p "
  181. "INNER JOIN problems_fts f ON p.id = f.problem_id "
  182. "WHERE f.body MATCH ? LIMIT ?",
  183. (safe_query, limit)
  184. ).fetchall()
  185. except sqlite3.OperationalError:
  186. # FTS5 查询语法错误时回退 LIKE
  187. like_pattern = f"%{safe_query}%"
  188. rows = conn.execute(
  189. "SELECT * FROM problems WHERE id IN ("
  190. " SELECT problem_id FROM problems_fts WHERE body LIKE ?"
  191. ") LIMIT ?",
  192. (like_pattern, limit)
  193. ).fetchall()
  194. return [_row_to_dict(r) for r in rows]
  195. def update_fts_body(conn: sqlite3.Connection, problem_id: str, body: str):
  196. """更新 FTS 全文索引"""
  197. conn.execute(
  198. "UPDATE problems_fts SET body = ? WHERE problem_id = ?",
  199. (body, problem_id)
  200. )
  201. conn.commit()
  202. def update_problem_metadata(conn: sqlite3.Connection, problem_id: str, fields: dict):
  203. """更新题目元数据"""
  204. allowed = {"difficulty", "question_type", "import_type", "comment", "source", "knowledge_codes"}
  205. updates = {}
  206. for k, v in fields.items():
  207. if k in allowed:
  208. if k in ("knowledge_codes",):
  209. updates[k] = json.dumps(v, ensure_ascii=False) if isinstance(v, list) else v
  210. else:
  211. updates[k] = v
  212. if not updates:
  213. return
  214. set_clause = ", ".join(f"{k} = ?" for k in updates)
  215. values = list(updates.values()) + [problem_id]
  216. conn.execute(
  217. f"UPDATE problems SET {set_clause}, updated_at = datetime('now','localtime') WHERE id = ?",
  218. values
  219. )
  220. conn.commit()
  221. def get_next_unannotated(conn: sqlite3.Connection, current_id: str | None, direction: str = "next") -> dict | None:
  222. """获取下一个未标注题目"""
  223. if direction == "next":
  224. if current_id:
  225. row = conn.execute(
  226. "SELECT * FROM problems WHERE knowledge_codes = '[]' AND id > ? ORDER BY id LIMIT 1",
  227. (current_id,)
  228. ).fetchone()
  229. else:
  230. row = conn.execute(
  231. "SELECT * FROM problems WHERE knowledge_codes = '[]' ORDER BY id LIMIT 1"
  232. ).fetchone()
  233. else:
  234. if current_id:
  235. row = conn.execute(
  236. "SELECT * FROM problems WHERE knowledge_codes = '[]' AND id < ? ORDER BY id DESC LIMIT 1",
  237. (current_id,)
  238. ).fetchone()
  239. else:
  240. row = conn.execute(
  241. "SELECT * FROM problems WHERE knowledge_codes = '[]' ORDER BY id DESC LIMIT 1"
  242. ).fetchone()
  243. return _row_to_dict(row) if row else None
  244. # ── 统计 ─────────────────────────────────────────────────
  245. def count_by_question_type(conn: sqlite3.Connection) -> dict:
  246. rows = conn.execute(
  247. "SELECT question_type, COUNT(*) as cnt FROM problems GROUP BY question_type"
  248. ).fetchall()
  249. return {r["question_type"] or "未知": r["cnt"] for r in rows}
  250. def count_by_import_type(conn: sqlite3.Connection) -> dict:
  251. rows = conn.execute(
  252. "SELECT import_type, COUNT(*) as cnt FROM problems GROUP BY import_type"
  253. ).fetchall()
  254. return {r["import_type"] or "未知": r["cnt"] for r in rows}
  255. def count_by_chapter(conn: sqlite3.Connection) -> dict:
  256. rows = conn.execute("SELECT chapters FROM problems").fetchall()
  257. chapter_count: dict[str, int] = {}
  258. for r in rows:
  259. try:
  260. chapters = json.loads(r["chapters"])
  261. except (json.JSONDecodeError, TypeError):
  262. chapters = []
  263. for ch in chapters:
  264. chapter_count[ch] = chapter_count.get(ch, 0) + 1
  265. return chapter_count
  266. # ── 知识点 ───────────────────────────────────────────────
  267. def sync_knowledge_points(conn: sqlite3.Connection, knowledge_map: dict):
  268. """同步知识点编码到数据库"""
  269. conn.execute("DELETE FROM knowledge_points")
  270. for code, info in knowledge_map.items():
  271. conn.execute(
  272. "INSERT INTO knowledge_points (code, path, leaf_name, chapter_code, chapter_name) VALUES (?, ?, ?, ?, ?)",
  273. (code, info["path"], info["leaf_name"], info.get("chapter_code", ""), info.get("chapter_name", ""))
  274. )
  275. conn.commit()
  276. def get_knowledge_tree(conn: sqlite3.Connection) -> list[dict]:
  277. """获取全部知识点"""
  278. rows = conn.execute("SELECT * FROM knowledge_points ORDER BY code").fetchall()
  279. return [_row_to_dict(r) for r in rows]
  280. def search_knowledge(conn: sqlite3.Connection, query: str, limit: int = 30) -> list[dict]:
  281. """搜索知识点"""
  282. rows = conn.execute(
  283. "SELECT * FROM knowledge_points WHERE leaf_name LIKE ? OR path LIKE ? LIMIT ?",
  284. (f"%{query}%", f"%{query}%", limit)
  285. ).fetchall()
  286. return [_row_to_dict(r) for r in rows]
  287. # ── 试题篮 ───────────────────────────────────────────────
  288. def get_carts(conn: sqlite3.Connection) -> list[dict]:
  289. rows = conn.execute(
  290. "SELECT c.*, COUNT(ci.problem_id) as item_count "
  291. "FROM carts c LEFT JOIN cart_items ci ON c.id = ci.cart_id "
  292. "GROUP BY c.id ORDER BY c.created_at DESC"
  293. ).fetchall()
  294. return [_row_to_dict(r) for r in rows]
  295. def create_cart(conn: sqlite3.Connection, name: str) -> dict:
  296. cur = conn.execute("INSERT INTO carts (name) VALUES (?)", (name,))
  297. conn.commit()
  298. row = conn.execute("SELECT * FROM carts WHERE id = ?", (cur.lastrowid,)).fetchone()
  299. return _row_to_dict(row)
  300. def delete_cart(conn: sqlite3.Connection, cart_id: int):
  301. conn.execute("DELETE FROM carts WHERE id = ?", (cart_id,))
  302. conn.commit()
  303. def get_cart(conn: sqlite3.Connection, cart_id: int) -> dict | None:
  304. row = conn.execute("SELECT * FROM carts WHERE id = ?", (cart_id,)).fetchone()
  305. if row is None:
  306. return None
  307. items = conn.execute(
  308. "SELECT p.*, ci.added_at as cart_added_at FROM cart_items ci "
  309. "JOIN problems p ON ci.problem_id = p.id "
  310. "WHERE ci.cart_id = ? ORDER BY ci.added_at",
  311. (cart_id,)
  312. ).fetchall()
  313. result = _row_to_dict(row)
  314. item_dicts = []
  315. for item in items:
  316. d = _row_to_dict(item)
  317. # Add body_preview from FTS
  318. fts_row = conn.execute(
  319. "SELECT body FROM problems_fts WHERE problem_id = ?",
  320. (d["id"],)
  321. ).fetchone()
  322. if fts_row and fts_row["body"]:
  323. body = fts_row["body"]
  324. import re
  325. body = re.sub(r'^\d+[..]\s*', '', body)
  326. d["body_preview"] = body[:80]
  327. else:
  328. d["body_preview"] = ""
  329. item_dicts.append(d)
  330. result["items"] = item_dicts
  331. return result
  332. def add_cart_item(conn: sqlite3.Connection, cart_id: int, problem_id: str) -> bool:
  333. try:
  334. conn.execute(
  335. "INSERT OR IGNORE INTO cart_items (cart_id, problem_id) VALUES (?, ?)",
  336. (cart_id, problem_id)
  337. )
  338. conn.commit()
  339. return True
  340. except sqlite3.IntegrityError:
  341. return False
  342. def remove_cart_item(conn: sqlite3.Connection, cart_id: int, problem_id: str):
  343. conn.execute(
  344. "DELETE FROM cart_items WHERE cart_id = ? AND problem_id = ?",
  345. (cart_id, problem_id)
  346. )
  347. conn.commit()
  348. # ── 导入任务 ─────────────────────────────────────────────
  349. def create_import_task(conn: sqlite3.Connection, task_id: str) -> dict:
  350. conn.execute("INSERT INTO import_tasks (id) VALUES (?)", (task_id,))
  351. conn.commit()
  352. row = conn.execute("SELECT * FROM import_tasks WHERE id = ?", (task_id,)).fetchone()
  353. return _row_to_dict(row)
  354. def update_import_task(conn: sqlite3.Connection, task_id: str, **kwargs):
  355. allowed = {"status", "total", "completed", "message", "result"}
  356. updates = {k: v for k, v in kwargs.items() if k in allowed}
  357. if not updates:
  358. return
  359. set_clause = ", ".join(f"{k} = ?" for k in updates)
  360. values = list(updates.values()) + [task_id]
  361. conn.execute(f"UPDATE import_tasks SET {set_clause} WHERE id = ?", values)
  362. conn.commit()
  363. def get_import_task(conn: sqlite3.Connection, task_id: str) -> dict | None:
  364. row = conn.execute("SELECT * FROM import_tasks WHERE id = ?", (task_id,)).fetchone()
  365. return _row_to_dict(row) if row else None
  366. # ── helpers ───────────────────────────────────────────────
  367. def _row_to_dict(row: sqlite3.Row) -> dict:
  368. d = dict(row)
  369. # 将 JSON 字符串字段解析
  370. for field in ("chapters", "knowledge_codes", "result"):
  371. if field in d and isinstance(d[field], str):
  372. try:
  373. d[field] = json.loads(d[field])
  374. except (json.JSONDecodeError, TypeError):
  375. pass
  376. return d
  377. def compute_checksum(file_path: str | Path) -> str:
  378. """计算 docx 内 document.xml 的 SHA256"""
  379. import zipfile
  380. try:
  381. with zipfile.ZipFile(file_path, 'r') as zf:
  382. if 'word/document.xml' in zf.namelist():
  383. return hashlib.sha256(zf.read('word/document.xml')).hexdigest()
  384. except Exception:
  385. pass
  386. # 回退到全文哈希
  387. with open(file_path, 'rb') as f:
  388. return hashlib.sha256(f.read()).hexdigest()
  389. def compute_id_from_docx(file_path: str | Path) -> str:
  390. """从 docx 的 document.xml 计算题目 ID(SHA256 前 8 位)"""
  391. return compute_checksum(file_path)[:8]