appQQQ commited on
Commit
e02694c
·
verified ·
1 Parent(s): 2b21030

chore: upload app/models/db.py

Browse files
Files changed (1) hide show
  1. app/models/db.py +358 -0
app/models/db.py ADDED
@@ -0,0 +1,358 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SQLite 元数据 + LangGraph checkpoint 共享 db.
2
+
3
+ 用 sqlite3 stdlib 即可, 暂不引入 SQLAlchemy (减少依赖).
4
+ WAL 模式开启, 允许 LangGraph checkpoint 与文档读并发.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import logging
10
+ import sqlite3
11
+ import threading
12
+ import time
13
+ from contextlib import contextmanager
14
+ from pathlib import Path
15
+ from typing import Any, Iterator
16
+
17
+ from app.config import settings
18
+ from app.core.paths import sqlite_dir
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ _local = threading.local()
23
+
24
+
25
+ def _connect() -> sqlite3.Connection:
26
+ """每线程一个连接 (sqlite3 本身不线程安全)."""
27
+ conn = sqlite3.connect(
28
+ str(settings.sqlite_db_path),
29
+ check_same_thread=False,
30
+ isolation_level=None, # autocommit, 手动 BEGIN
31
+ timeout=30.0,
32
+ )
33
+ conn.row_factory = sqlite3.Row
34
+ conn.execute("PRAGMA journal_mode=WAL")
35
+ conn.execute("PRAGMA synchronous=NORMAL")
36
+ conn.execute("PRAGMA foreign_keys=ON")
37
+ return conn
38
+
39
+
40
+ def get_conn() -> sqlite3.Connection:
41
+ """获取当前线程的连接."""
42
+ conn = getattr(_local, "conn", None)
43
+ if conn is None:
44
+ conn = _connect()
45
+ _local.conn = conn
46
+ return conn
47
+
48
+
49
+ @contextmanager
50
+ def transaction() -> Iterator[sqlite3.Connection]:
51
+ """显式事务 wrapper."""
52
+ conn = get_conn()
53
+ conn.execute("BEGIN")
54
+ try:
55
+ yield conn
56
+ except Exception:
57
+ conn.execute("ROLLBACK")
58
+ raise
59
+ else:
60
+ conn.execute("COMMIT")
61
+
62
+
63
+ SCHEMA = """
64
+ CREATE TABLE IF NOT EXISTS documents (
65
+ id TEXT PRIMARY KEY,
66
+ filename TEXT NOT NULL,
67
+ mime TEXT,
68
+ size INTEGER NOT NULL,
69
+ page_count INTEGER,
70
+ chunk_count INTEGER DEFAULT 0,
71
+ status TEXT NOT NULL DEFAULT 'uploading',
72
+ progress INTEGER DEFAULT 0, -- 0-100, 用于前端进度条
73
+ progress_label TEXT, -- "正在分块 (12/48)..." 等
74
+ error TEXT,
75
+ sha256 TEXT NOT NULL,
76
+ parser TEXT,
77
+ chunk_size INTEGER,
78
+ chunk_overlap INTEGER,
79
+ semantic_chunking INTEGER DEFAULT 0,
80
+ contextual_retrieval INTEGER DEFAULT 0,
81
+ meta_json TEXT,
82
+ created_at REAL NOT NULL,
83
+ updated_at REAL NOT NULL
84
+ );
85
+ CREATE INDEX IF NOT EXISTS idx_documents_sha ON documents(sha256);
86
+ CREATE INDEX IF NOT EXISTS idx_documents_status ON documents(status);
87
+
88
+ CREATE TABLE IF NOT EXISTS chunks (
89
+ id TEXT PRIMARY KEY,
90
+ doc_id TEXT NOT NULL,
91
+ parent_id TEXT,
92
+ chunk_index INTEGER NOT NULL,
93
+ text TEXT NOT NULL,
94
+ token_count INTEGER,
95
+ page_no INTEGER,
96
+ heading TEXT,
97
+ context_prefix TEXT,
98
+ created_at REAL NOT NULL,
99
+ FOREIGN KEY (doc_id) REFERENCES documents(id) ON DELETE CASCADE
100
+ );
101
+ CREATE INDEX IF NOT EXISTS idx_chunks_doc ON chunks(doc_id);
102
+ CREATE INDEX IF NOT EXISTS idx_chunks_parent ON chunks(parent_id);
103
+
104
+ CREATE TABLE IF NOT EXISTS sessions (
105
+ id TEXT PRIMARY KEY,
106
+ user_id TEXT NOT NULL DEFAULT 'default',
107
+ title TEXT,
108
+ message_count INTEGER DEFAULT 0,
109
+ created_at REAL NOT NULL,
110
+ updated_at REAL NOT NULL
111
+ );
112
+
113
+ CREATE TABLE IF NOT EXISTS messages (
114
+ id TEXT PRIMARY KEY,
115
+ session_id TEXT NOT NULL,
116
+ role TEXT NOT NULL,
117
+ content TEXT NOT NULL,
118
+ citations_json TEXT,
119
+ tool_calls_json TEXT,
120
+ created_at REAL NOT NULL,
121
+ FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
122
+ );
123
+ CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
124
+ """
125
+
126
+
127
+ def init_db() -> None:
128
+ """启动时调用. 幂等."""
129
+ sqlite_dir()
130
+ conn = get_conn()
131
+ conn.executescript(SCHEMA)
132
+ # 在线迁移: 给旧 documents 表加新列 (progress / parser / chunk_size 等).
133
+ # 失败无害 (列已存在).
134
+ _MIGRATIONS = [
135
+ "ALTER TABLE documents ADD COLUMN progress INTEGER DEFAULT 0",
136
+ "ALTER TABLE documents ADD COLUMN progress_label TEXT",
137
+ "ALTER TABLE documents ADD COLUMN parser TEXT",
138
+ "ALTER TABLE documents ADD COLUMN chunk_size INTEGER",
139
+ "ALTER TABLE documents ADD COLUMN chunk_overlap INTEGER",
140
+ "ALTER TABLE documents ADD COLUMN semantic_chunking INTEGER DEFAULT 0",
141
+ "ALTER TABLE documents ADD COLUMN contextual_retrieval INTEGER DEFAULT 0",
142
+ ]
143
+ for sql in _MIGRATIONS:
144
+ try:
145
+ conn.execute(sql)
146
+ except Exception: # noqa: BLE001
147
+ pass # 列已存在, 跳过
148
+ logger.info("DB schema ensured at %s", settings.sqlite_db_path)
149
+
150
+
151
+ # ========== Document CRUD ==========
152
+ def doc_insert(doc: dict[str, Any]) -> None:
153
+ """doc 必须含: id, filename, size, sha256, created_at, updated_at."""
154
+ conn = get_conn()
155
+ conn.execute(
156
+ """
157
+ INSERT INTO documents (id, filename, mime, size, page_count, chunk_count,
158
+ status, progress, progress_label, error, sha256,
159
+ parser, chunk_size, chunk_overlap,
160
+ semantic_chunking, contextual_retrieval,
161
+ meta_json, created_at, updated_at)
162
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
163
+ """,
164
+ (
165
+ doc["id"], doc["filename"], doc.get("mime"), doc["size"],
166
+ doc.get("page_count"), doc.get("chunk_count", 0),
167
+ doc.get("status", "uploading"),
168
+ doc.get("progress", 0), doc.get("progress_label"),
169
+ doc.get("error"),
170
+ doc["sha256"], doc.get("parser"),
171
+ doc.get("chunk_size"), doc.get("chunk_overlap"),
172
+ int(bool(doc.get("semantic_chunking", False))),
173
+ int(bool(doc.get("contextual_retrieval", False))),
174
+ json.dumps(doc.get("meta", {}), ensure_ascii=False),
175
+ doc["created_at"], doc.get("updated_at", doc["created_at"]),
176
+ ),
177
+ )
178
+
179
+
180
+ def doc_update_status(
181
+ doc_id: str,
182
+ status: str,
183
+ *,
184
+ error: str | None = None,
185
+ chunk_count: int | None = None,
186
+ page_count: int | None = None,
187
+ progress: int | None = None,
188
+ progress_label: str | None = None,
189
+ ) -> None:
190
+ conn = get_conn()
191
+ sets = ["status = ?", "updated_at = ?"]
192
+ params: list[Any] = [status, time.time()]
193
+ if error is not None:
194
+ sets.append("error = ?")
195
+ params.append(error)
196
+ if chunk_count is not None:
197
+ sets.append("chunk_count = ?")
198
+ params.append(chunk_count)
199
+ if page_count is not None:
200
+ sets.append("page_count = ?")
201
+ params.append(page_count)
202
+ if progress is not None:
203
+ sets.append("progress = ?")
204
+ params.append(max(0, min(100, int(progress))))
205
+ if progress_label is not None:
206
+ sets.append("progress_label = ?")
207
+ params.append(progress_label)
208
+ params.append(doc_id)
209
+ conn.execute(f"UPDATE documents SET {', '.join(sets)} WHERE id = ?", params)
210
+
211
+
212
+ def doc_get(doc_id: str) -> dict[str, Any] | None:
213
+ row = get_conn().execute("SELECT * FROM documents WHERE id = ?", (doc_id,)).fetchone()
214
+ return _row_to_doc(row) if row else None
215
+
216
+
217
+ def doc_find_by_sha256(sha: str) -> dict[str, Any] | None:
218
+ row = get_conn().execute(
219
+ "SELECT * FROM documents WHERE sha256 = ? AND status = 'ready' ORDER BY created_at DESC LIMIT 1",
220
+ (sha,),
221
+ ).fetchone()
222
+ return _row_to_doc(row) if row else None
223
+
224
+
225
+ def doc_list(limit: int = 100, offset: int = 0) -> list[dict[str, Any]]:
226
+ rows = get_conn().execute(
227
+ "SELECT * FROM documents ORDER BY created_at DESC LIMIT ? OFFSET ?",
228
+ (limit, offset),
229
+ ).fetchall()
230
+ return [_row_to_doc(r) for r in rows]
231
+
232
+
233
+ def doc_delete(doc_id: str) -> bool:
234
+ cur = get_conn().execute("DELETE FROM documents WHERE id = ?", (doc_id,))
235
+ return cur.rowcount > 0
236
+
237
+
238
+ def _row_to_doc(row: sqlite3.Row) -> dict[str, Any]:
239
+ d = dict(row)
240
+ if d.get("meta_json"):
241
+ try:
242
+ d["meta"] = json.loads(d["meta_json"])
243
+ except json.JSONDecodeError:
244
+ d["meta"] = {}
245
+ d.pop("meta_json", None)
246
+ # 拆出 boolean 字段, 方便前端直接使用
247
+ d["semantic_chunking"] = bool(d.get("semantic_chunking"))
248
+ d["contextual_retrieval"] = bool(d.get("contextual_retrieval"))
249
+ return d
250
+
251
+
252
+ # ========== Chunk CRUD (用于上下文回查, 实际检索走 Chroma) ==========
253
+ def chunk_insert_bulk(chunks: list[dict[str, Any]]) -> None:
254
+ if not chunks:
255
+ return
256
+ conn = get_conn()
257
+ with transaction():
258
+ conn.executemany(
259
+ """
260
+ INSERT OR REPLACE INTO chunks
261
+ (id, doc_id, parent_id, chunk_index, text, token_count, page_no, heading, context_prefix, created_at)
262
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
263
+ """,
264
+ [
265
+ (
266
+ c["id"], c["doc_id"], c.get("parent_id"), c["chunk_index"],
267
+ c["text"], c.get("token_count"), c.get("page_no"),
268
+ c.get("heading"), c.get("context_prefix"), c.get("created_at", time.time()),
269
+ )
270
+ for c in chunks
271
+ ],
272
+ )
273
+
274
+
275
+ def chunk_get_by_doc(doc_id: str, limit: int = 200) -> list[dict[str, Any]]:
276
+ rows = get_conn().execute(
277
+ "SELECT * FROM chunks WHERE doc_id = ? ORDER BY chunk_index LIMIT ?",
278
+ (doc_id, limit),
279
+ ).fetchall()
280
+ return [dict(r) for r in rows]
281
+
282
+
283
+ def chunk_delete_by_doc(doc_id: str) -> int:
284
+ cur = get_conn().execute("DELETE FROM chunks WHERE doc_id = ?", (doc_id,))
285
+ return cur.rowcount
286
+
287
+
288
+ # ========== Session / Message CRUD (阶段 3 实际使用) ==========
289
+ def session_upsert(session_id: str, user_id: str = "default", title: str | None = None) -> None:
290
+ now = time.time()
291
+ conn = get_conn()
292
+ conn.execute(
293
+ """
294
+ INSERT INTO sessions (id, user_id, title, message_count, created_at, updated_at)
295
+ VALUES (?, ?, ?, 0, ?, ?)
296
+ ON CONFLICT(id) DO UPDATE SET updated_at = excluded.updated_at
297
+ """,
298
+ (session_id, user_id, title, now, now),
299
+ )
300
+
301
+
302
+ def session_list(limit: int = 50) -> list[dict[str, Any]]:
303
+ rows = get_conn().execute(
304
+ "SELECT * FROM sessions ORDER BY updated_at DESC LIMIT ?", (limit,)
305
+ ).fetchall()
306
+ return [dict(r) for r in rows]
307
+
308
+
309
+ def session_get(session_id: str) -> dict[str, Any] | None:
310
+ row = get_conn().execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
311
+ return dict(row) if row else None
312
+
313
+
314
+ def session_delete(session_id: str) -> bool:
315
+ cur = get_conn().execute("DELETE FROM sessions WHERE id = ?", (session_id,))
316
+ return cur.rowcount > 0
317
+
318
+
319
+ def message_insert(msg: dict[str, Any]) -> None:
320
+ now = msg.get("created_at", time.time())
321
+ conn = get_conn()
322
+ with transaction():
323
+ conn.execute(
324
+ """
325
+ INSERT INTO messages (id, session_id, role, content, citations_json, tool_calls_json, created_at)
326
+ VALUES (?, ?, ?, ?, ?, ?, ?)
327
+ """,
328
+ (
329
+ msg["id"], msg["session_id"], msg["role"], msg["content"],
330
+ json.dumps(msg.get("citations", []), ensure_ascii=False),
331
+ json.dumps(msg.get("tool_calls", []), ensure_ascii=False),
332
+ now,
333
+ ),
334
+ )
335
+ # 更新 session 计数
336
+ conn.execute(
337
+ "UPDATE sessions SET message_count = message_count + 1, updated_at = ? WHERE id = ?",
338
+ (now, msg["session_id"]),
339
+ )
340
+
341
+
342
+ def message_list_by_session(session_id: str, limit: int = 200) -> list[dict[str, Any]]:
343
+ rows = get_conn().execute(
344
+ "SELECT * FROM messages WHERE session_id = ? ORDER BY created_at LIMIT ?",
345
+ (session_id, limit),
346
+ ).fetchall()
347
+ out = []
348
+ for r in rows:
349
+ d = dict(r)
350
+ for k in ("citations_json", "tool_calls_json"):
351
+ v = d.pop(k, None)
352
+ if v:
353
+ try:
354
+ d[k.removesuffix("_json")] = json.loads(v)
355
+ except json.JSONDecodeError:
356
+ d[k.removesuffix("_json")] = []
357
+ out.append(d)
358
+ return out