Spaces:
Running
Running
AI Assistant commited on
Commit ·
8eaad14
1
Parent(s): a57411f
feat: integrate PostgreSQL persistence for documents, trees, and chat sessions
Browse files- .env.example +7 -1
- Dockerfile +2 -2
- app/core/persistent_db.py +192 -13
- app/core/postgres_db.py +639 -0
- app/core/storage.py +116 -27
- docker-compose.yml +39 -0
- pyproject.toml +1 -0
- requirements.txt +136 -0
- test_postgres_persistence.py +101 -0
- uv.lock +65 -0
.env.example
CHANGED
|
@@ -7,5 +7,11 @@ PAGEINDEX_API_KEY_3=your_third_key
|
|
| 7 |
HF_TOKEN=hf_your_free_token_here
|
| 8 |
|
| 9 |
HF_RETRIEVAL_MODEL=meta-llama/Llama-3.1-8B-Instruct
|
|
|
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
HF_TOKEN=hf_your_free_token_here
|
| 8 |
|
| 9 |
HF_RETRIEVAL_MODEL=meta-llama/Llama-3.1-8B-Instruct
|
| 10 |
+
HF_CHAT_MODEL=meta-llama/Llama-3.1-8B-Instruct
|
| 11 |
|
| 12 |
+
GROQ_API_KEY=gsk_your_key_here
|
| 13 |
+
GROQ_CHAT_MODEL=qwen/qwen3.8-27b
|
| 14 |
+
GROQ_RETRIEVAL_MODEL=qwen/qwen3.8-27b
|
| 15 |
+
|
| 16 |
+
# PostgreSQL Database Persistence (Local Docker or Cloud: Neon, Supabase, Railway)
|
| 17 |
+
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/rag_pageindex
|
Dockerfile
CHANGED
|
@@ -7,9 +7,9 @@ WORKDIR /app
|
|
| 7 |
|
| 8 |
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
| 9 |
|
| 10 |
-
COPY pyproject.toml uv.lock ./
|
| 11 |
|
| 12 |
-
RUN uv pip install --system -r
|
| 13 |
|
| 14 |
COPY app/ ./app/
|
| 15 |
|
|
|
|
| 7 |
|
| 8 |
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
| 9 |
|
| 10 |
+
COPY requirements.txt pyproject.toml uv.lock ./
|
| 11 |
|
| 12 |
+
RUN uv pip install --system -r requirements.txt
|
| 13 |
|
| 14 |
COPY app/ ./app/
|
| 15 |
|
app/core/persistent_db.py
CHANGED
|
@@ -1,10 +1,9 @@
|
|
| 1 |
"""
|
| 2 |
persistent_db.py
|
| 3 |
────────────────
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
this layer only records the *metadata* needed to reload them.
|
| 8 |
"""
|
| 9 |
|
| 10 |
import os
|
|
@@ -15,6 +14,7 @@ import time
|
|
| 15 |
import uuid
|
| 16 |
from contextlib import contextmanager
|
| 17 |
from typing import Any, Dict, List, Optional
|
|
|
|
| 18 |
|
| 19 |
# ── paths ──────────────────────────────────────────────────────────────────
|
| 20 |
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
@@ -23,8 +23,8 @@ DATA_DIR = "/data" if (os.path.exists("/data") and os.access("/data", os.W_OK))
|
|
| 23 |
DB_PATH = os.path.join(DATA_DIR, "memory.db")
|
| 24 |
os.makedirs(DATA_DIR, exist_ok=True)
|
| 25 |
|
| 26 |
-
# ── schema ───────────────────────────────────────────────────────────
|
| 27 |
-
|
| 28 |
PRAGMA journal_mode=WAL;
|
| 29 |
PRAGMA foreign_keys=ON;
|
| 30 |
|
|
@@ -64,6 +64,21 @@ CREATE TABLE IF NOT EXISTS doc_meta (
|
|
| 64 |
updated_at REAL NOT NULL
|
| 65 |
);
|
| 66 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
CREATE TABLE IF NOT EXISTS chat_documents (
|
| 68 |
chat_id TEXT NOT NULL REFERENCES chats(chat_id) ON DELETE CASCADE,
|
| 69 |
doc_id TEXT NOT NULL REFERENCES doc_meta(doc_id) ON DELETE CASCADE,
|
|
@@ -100,7 +115,7 @@ CREATE TABLE IF NOT EXISTS global_knowledge_summary (
|
|
| 100 |
"""
|
| 101 |
|
| 102 |
|
| 103 |
-
class
|
| 104 |
"""Thread-safe SQLite wrapper for long-term chat & document memory."""
|
| 105 |
|
| 106 |
def __init__(self, db_path: str = DB_PATH) -> None:
|
|
@@ -128,7 +143,7 @@ class PersistentDB:
|
|
| 128 |
|
| 129 |
def _init_schema(self) -> None:
|
| 130 |
with self._tx() as conn:
|
| 131 |
-
conn.executescript(
|
| 132 |
# Ensure newly added columns exist in older DB instances
|
| 133 |
cursor = conn.execute("PRAGMA table_info(doc_meta)")
|
| 134 |
existing_cols = {row["name"] for row in cursor.fetchall()}
|
|
@@ -218,7 +233,7 @@ class PersistentDB:
|
|
| 218 |
q = "SELECT role, content FROM messages WHERE chat_id=? ORDER BY created_at ASC"
|
| 219 |
if limit > 0:
|
| 220 |
rows = self._conn().execute(
|
| 221 |
-
|
| 222 |
(chat_id, limit),
|
| 223 |
).fetchall()
|
| 224 |
return [{"role": r["role"], "content": r["content"]} for r in reversed(rows)]
|
|
@@ -289,7 +304,6 @@ class PersistentDB:
|
|
| 289 |
return [dict(r) for r in rows]
|
| 290 |
|
| 291 |
def find_doc_by_hash(self, content_hash: str) -> Optional[Dict[str, Any]]:
|
| 292 |
-
# Match latest document with this hash
|
| 293 |
row = self._conn().execute(
|
| 294 |
"SELECT * FROM doc_meta WHERE content_hash=? ORDER BY updated_at DESC LIMIT 1",
|
| 295 |
(content_hash,),
|
|
@@ -302,8 +316,82 @@ class PersistentDB:
|
|
| 302 |
conn.execute("UPDATE chats SET doc_id=NULL, doc_name=NULL WHERE doc_id=?", (doc_id,))
|
| 303 |
conn.execute("DELETE FROM chat_documents WHERE doc_id=?", (doc_id,))
|
| 304 |
conn.execute("DELETE FROM knowledge_memory WHERE doc_id=?", (doc_id,))
|
|
|
|
| 305 |
return cur.rowcount > 0
|
| 306 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
# ── multi-doc session associations ──────────────────────────────────────
|
| 308 |
def add_chat_document(self, chat_id: str, doc_id: str, doc_name: Optional[str] = None) -> None:
|
| 309 |
now = time.time()
|
|
@@ -318,7 +406,6 @@ class PersistentDB:
|
|
| 318 |
""",
|
| 319 |
(chat_id, doc_id, doc_name, now),
|
| 320 |
)
|
| 321 |
-
# Maintain backward compatibility on chats table with latest doc
|
| 322 |
conn.execute(
|
| 323 |
"UPDATE chats SET doc_id=?, doc_name=?, updated_at=? WHERE chat_id=?",
|
| 324 |
(doc_id, doc_name, now, chat_id),
|
|
@@ -496,6 +583,98 @@ class PersistentDB:
|
|
| 496 |
return res
|
| 497 |
|
| 498 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 499 |
|
| 500 |
-
# Global singleton
|
| 501 |
-
persistent_db =
|
|
|
|
| 1 |
"""
|
| 2 |
persistent_db.py
|
| 3 |
────────────────
|
| 4 |
+
Unified persistence layer supporting both PostgreSQL and SQLite.
|
| 5 |
+
If `DATABASE_URL` or `POSTGRES_URL` is set, PostgreSQL is used for durability across
|
| 6 |
+
restarts/deployments. Otherwise, it gracefully falls back to local SQLite.
|
|
|
|
| 7 |
"""
|
| 8 |
|
| 9 |
import os
|
|
|
|
| 14 |
import uuid
|
| 15 |
from contextlib import contextmanager
|
| 16 |
from typing import Any, Dict, List, Optional
|
| 17 |
+
from utils.logger import app_logger
|
| 18 |
|
| 19 |
# ── paths ──────────────────────────────────────────────────────────────────
|
| 20 |
_THIS_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
| 23 |
DB_PATH = os.path.join(DATA_DIR, "memory.db")
|
| 24 |
os.makedirs(DATA_DIR, exist_ok=True)
|
| 25 |
|
| 26 |
+
# ── SQLite schema ───────────────────────────────────────────────────────────
|
| 27 |
+
_SQLITE_DDL = """
|
| 28 |
PRAGMA journal_mode=WAL;
|
| 29 |
PRAGMA foreign_keys=ON;
|
| 30 |
|
|
|
|
| 64 |
updated_at REAL NOT NULL
|
| 65 |
);
|
| 66 |
|
| 67 |
+
CREATE INDEX IF NOT EXISTS idx_doc_meta_hash ON doc_meta(content_hash);
|
| 68 |
+
|
| 69 |
+
CREATE TABLE IF NOT EXISTS doc_indices (
|
| 70 |
+
doc_id TEXT PRIMARY KEY REFERENCES doc_meta(doc_id) ON DELETE CASCADE,
|
| 71 |
+
content_hash TEXT,
|
| 72 |
+
tree TEXT,
|
| 73 |
+
node_map TEXT,
|
| 74 |
+
flat_sections TEXT,
|
| 75 |
+
file_base64 TEXT,
|
| 76 |
+
created_at REAL NOT NULL,
|
| 77 |
+
updated_at REAL NOT NULL
|
| 78 |
+
);
|
| 79 |
+
|
| 80 |
+
CREATE INDEX IF NOT EXISTS idx_doc_indices_hash ON doc_indices(content_hash);
|
| 81 |
+
|
| 82 |
CREATE TABLE IF NOT EXISTS chat_documents (
|
| 83 |
chat_id TEXT NOT NULL REFERENCES chats(chat_id) ON DELETE CASCADE,
|
| 84 |
doc_id TEXT NOT NULL REFERENCES doc_meta(doc_id) ON DELETE CASCADE,
|
|
|
|
| 115 |
"""
|
| 116 |
|
| 117 |
|
| 118 |
+
class SQLitePersistentDB:
|
| 119 |
"""Thread-safe SQLite wrapper for long-term chat & document memory."""
|
| 120 |
|
| 121 |
def __init__(self, db_path: str = DB_PATH) -> None:
|
|
|
|
| 143 |
|
| 144 |
def _init_schema(self) -> None:
|
| 145 |
with self._tx() as conn:
|
| 146 |
+
conn.executescript(_SQLITE_DDL)
|
| 147 |
# Ensure newly added columns exist in older DB instances
|
| 148 |
cursor = conn.execute("PRAGMA table_info(doc_meta)")
|
| 149 |
existing_cols = {row["name"] for row in cursor.fetchall()}
|
|
|
|
| 233 |
q = "SELECT role, content FROM messages WHERE chat_id=? ORDER BY created_at ASC"
|
| 234 |
if limit > 0:
|
| 235 |
rows = self._conn().execute(
|
| 236 |
+
"SELECT role, content FROM messages WHERE chat_id=? ORDER BY created_at DESC LIMIT ?",
|
| 237 |
(chat_id, limit),
|
| 238 |
).fetchall()
|
| 239 |
return [{"role": r["role"], "content": r["content"]} for r in reversed(rows)]
|
|
|
|
| 304 |
return [dict(r) for r in rows]
|
| 305 |
|
| 306 |
def find_doc_by_hash(self, content_hash: str) -> Optional[Dict[str, Any]]:
|
|
|
|
| 307 |
row = self._conn().execute(
|
| 308 |
"SELECT * FROM doc_meta WHERE content_hash=? ORDER BY updated_at DESC LIMIT 1",
|
| 309 |
(content_hash,),
|
|
|
|
| 316 |
conn.execute("UPDATE chats SET doc_id=NULL, doc_name=NULL WHERE doc_id=?", (doc_id,))
|
| 317 |
conn.execute("DELETE FROM chat_documents WHERE doc_id=?", (doc_id,))
|
| 318 |
conn.execute("DELETE FROM knowledge_memory WHERE doc_id=?", (doc_id,))
|
| 319 |
+
conn.execute("DELETE FROM doc_indices WHERE doc_id=?", (doc_id,))
|
| 320 |
return cur.rowcount > 0
|
| 321 |
|
| 322 |
+
# ── document heavy indices store ─────────────────────────────────────────
|
| 323 |
+
def save_doc_index(
|
| 324 |
+
self,
|
| 325 |
+
doc_id: str,
|
| 326 |
+
content_hash: Optional[str],
|
| 327 |
+
tree: Any,
|
| 328 |
+
node_map: Optional[Dict[str, Any]] = None,
|
| 329 |
+
flat_sections: Optional[List[Dict[str, Any]]] = None,
|
| 330 |
+
file_base64: Optional[str] = None,
|
| 331 |
+
) -> None:
|
| 332 |
+
now = time.time()
|
| 333 |
+
with self._tx() as conn:
|
| 334 |
+
conn.execute(
|
| 335 |
+
"""
|
| 336 |
+
INSERT INTO doc_indices(
|
| 337 |
+
doc_id, content_hash, tree, node_map, flat_sections, file_base64, created_at, updated_at
|
| 338 |
+
)
|
| 339 |
+
VALUES(?,?,?,?,?,?,?,?)
|
| 340 |
+
ON CONFLICT(doc_id) DO UPDATE SET
|
| 341 |
+
content_hash=COALESCE(excluded.content_hash, doc_indices.content_hash),
|
| 342 |
+
tree=excluded.tree,
|
| 343 |
+
node_map=excluded.node_map,
|
| 344 |
+
flat_sections=excluded.flat_sections,
|
| 345 |
+
file_base64=COALESCE(excluded.file_base64, doc_indices.file_base64),
|
| 346 |
+
updated_at=excluded.updated_at
|
| 347 |
+
""",
|
| 348 |
+
(
|
| 349 |
+
doc_id,
|
| 350 |
+
content_hash,
|
| 351 |
+
json.dumps(tree) if tree is not None else None,
|
| 352 |
+
json.dumps(node_map) if node_map is not None else None,
|
| 353 |
+
json.dumps(flat_sections) if flat_sections is not None else None,
|
| 354 |
+
file_base64,
|
| 355 |
+
now,
|
| 356 |
+
now,
|
| 357 |
+
),
|
| 358 |
+
)
|
| 359 |
+
|
| 360 |
+
def get_doc_index(self, doc_id: str) -> Optional[Dict[str, Any]]:
|
| 361 |
+
row = self._conn().execute(
|
| 362 |
+
"SELECT * FROM doc_indices WHERE doc_id=?", (doc_id,)
|
| 363 |
+
).fetchone()
|
| 364 |
+
if not row:
|
| 365 |
+
return None
|
| 366 |
+
res = dict(row)
|
| 367 |
+
for k in ("tree", "node_map", "flat_sections"):
|
| 368 |
+
if res.get(k) and isinstance(res[k], str):
|
| 369 |
+
try:
|
| 370 |
+
res[k] = json.loads(res[k])
|
| 371 |
+
except Exception:
|
| 372 |
+
pass
|
| 373 |
+
return res
|
| 374 |
+
|
| 375 |
+
def get_doc_index_by_hash(self, content_hash: str) -> Optional[Dict[str, Any]]:
|
| 376 |
+
row = self._conn().execute(
|
| 377 |
+
"SELECT * FROM doc_indices WHERE content_hash=? ORDER BY updated_at DESC LIMIT 1",
|
| 378 |
+
(content_hash,),
|
| 379 |
+
).fetchone()
|
| 380 |
+
if not row:
|
| 381 |
+
return None
|
| 382 |
+
res = dict(row)
|
| 383 |
+
for k in ("tree", "node_map", "flat_sections"):
|
| 384 |
+
if res.get(k) and isinstance(res[k], str):
|
| 385 |
+
try:
|
| 386 |
+
res[k] = json.loads(res[k])
|
| 387 |
+
except Exception:
|
| 388 |
+
pass
|
| 389 |
+
return res
|
| 390 |
+
|
| 391 |
+
def delete_doc_index(self, doc_id: str) -> None:
|
| 392 |
+
with self._tx() as conn:
|
| 393 |
+
conn.execute("DELETE FROM doc_indices WHERE doc_id=?", (doc_id,))
|
| 394 |
+
|
| 395 |
# ── multi-doc session associations ──────────────────────────────────────
|
| 396 |
def add_chat_document(self, chat_id: str, doc_id: str, doc_name: Optional[str] = None) -> None:
|
| 397 |
now = time.time()
|
|
|
|
| 406 |
""",
|
| 407 |
(chat_id, doc_id, doc_name, now),
|
| 408 |
)
|
|
|
|
| 409 |
conn.execute(
|
| 410 |
"UPDATE chats SET doc_id=?, doc_name=?, updated_at=? WHERE chat_id=?",
|
| 411 |
(doc_id, doc_name, now, chat_id),
|
|
|
|
| 583 |
return res
|
| 584 |
|
| 585 |
|
| 586 |
+
def _migrate_sqlite_to_postgres_if_needed(pg_db: Any) -> None:
|
| 587 |
+
"""If local SQLite contains chats/docs and PostgreSQL is newly initialized, migrate data smoothly."""
|
| 588 |
+
if not os.path.exists(DB_PATH):
|
| 589 |
+
return
|
| 590 |
+
|
| 591 |
+
# Check if Postgres already has documents or chats
|
| 592 |
+
if pg_db.list_docs() or pg_db.list_chats():
|
| 593 |
+
return
|
| 594 |
+
|
| 595 |
+
sqlite_db = SQLitePersistentDB(DB_PATH)
|
| 596 |
+
sqlite_chats = sqlite_db.list_chats()
|
| 597 |
+
sqlite_docs = sqlite_db.list_docs()
|
| 598 |
+
|
| 599 |
+
if not sqlite_chats and not sqlite_docs:
|
| 600 |
+
return
|
| 601 |
+
|
| 602 |
+
app_logger.info(f"[Migration] Migrating {len(sqlite_docs)} docs and {len(sqlite_chats)} chats from SQLite to PostgreSQL...")
|
| 603 |
+
|
| 604 |
+
for d in sqlite_docs:
|
| 605 |
+
try:
|
| 606 |
+
pg_db.upsert_doc_meta(
|
| 607 |
+
doc_id=d["doc_id"],
|
| 608 |
+
filename=d.get("filename", "document.pdf"),
|
| 609 |
+
status=d.get("status", "ready"),
|
| 610 |
+
page_count=d.get("page_count", 0),
|
| 611 |
+
section_count=d.get("section_count", 0),
|
| 612 |
+
progress=d.get("progress", 100),
|
| 613 |
+
current_stage=d.get("current_stage", "Ready"),
|
| 614 |
+
indexing_method=d.get("indexing_method"),
|
| 615 |
+
duration=d.get("duration"),
|
| 616 |
+
error=d.get("error"),
|
| 617 |
+
content_hash=d.get("content_hash"),
|
| 618 |
+
file_path=d.get("file_path"),
|
| 619 |
+
)
|
| 620 |
+
# Check if index exists in SQLite
|
| 621 |
+
s_idx = sqlite_db.get_doc_index(d["doc_id"])
|
| 622 |
+
if s_idx:
|
| 623 |
+
pg_db.save_doc_index(
|
| 624 |
+
doc_id=d["doc_id"],
|
| 625 |
+
content_hash=d.get("content_hash"),
|
| 626 |
+
tree=s_idx.get("tree"),
|
| 627 |
+
node_map=s_idx.get("node_map"),
|
| 628 |
+
flat_sections=s_idx.get("flat_sections"),
|
| 629 |
+
file_base64=s_idx.get("file_base64"),
|
| 630 |
+
)
|
| 631 |
+
except Exception as dex:
|
| 632 |
+
app_logger.warning(f"[Migration] Error migrating doc {d.get('doc_id')}: {dex}")
|
| 633 |
+
|
| 634 |
+
for c in sqlite_chats:
|
| 635 |
+
try:
|
| 636 |
+
cid = c["chat_id"]
|
| 637 |
+
pg_db.create_chat(chat_id=cid, title=c.get("title"))
|
| 638 |
+
if c.get("doc_id"):
|
| 639 |
+
pg_db.set_chat_doc(cid, c["doc_id"], c.get("doc_name"))
|
| 640 |
+
# Migrate messages
|
| 641 |
+
msgs = sqlite_db.get_messages(cid)
|
| 642 |
+
for m in msgs:
|
| 643 |
+
pg_db.add_message(cid, m["role"], m["content"])
|
| 644 |
+
# Migrate attached documents
|
| 645 |
+
att = sqlite_db.get_chat_documents(cid)
|
| 646 |
+
for a in att:
|
| 647 |
+
pg_db.add_chat_document(cid, a["doc_id"], a.get("doc_name"))
|
| 648 |
+
except Exception as cex:
|
| 649 |
+
app_logger.warning(f"[Migration] Error migrating chat {c.get('chat_id')}: {cex}")
|
| 650 |
+
|
| 651 |
+
app_logger.info("[Migration] SQLite to PostgreSQL migration completed successfully.")
|
| 652 |
+
|
| 653 |
+
|
| 654 |
+
def create_persistent_db() -> Any:
|
| 655 |
+
"""
|
| 656 |
+
Factory function for persistent database.
|
| 657 |
+
Checks environment for DATABASE_URL / POSTGRES_URL.
|
| 658 |
+
If provided and accessible, uses PostgresDB. Otherwise falls back to SQLite.
|
| 659 |
+
"""
|
| 660 |
+
pg_url = os.getenv("DATABASE_URL") or os.getenv("POSTGRES_URL") or os.getenv("POSTGRESQL_URL")
|
| 661 |
+
if pg_url:
|
| 662 |
+
try:
|
| 663 |
+
from core.postgres_db import PostgresDB
|
| 664 |
+
pg_db = PostgresDB(pg_url)
|
| 665 |
+
safe_url = pg_url.split("@")[-1] if "@" in pg_url else "local"
|
| 666 |
+
app_logger.info(f"[PersistentDB] Connected to PostgreSQL: {safe_url}")
|
| 667 |
+
try:
|
| 668 |
+
_migrate_sqlite_to_postgres_if_needed(pg_db)
|
| 669 |
+
except Exception as mex:
|
| 670 |
+
app_logger.warning(f"[PersistentDB] Auto-migration skipped: {mex}")
|
| 671 |
+
return pg_db
|
| 672 |
+
except Exception as e:
|
| 673 |
+
app_logger.error(f"[PersistentDB] Failed to connect to PostgreSQL ({e}). Falling back to SQLite.")
|
| 674 |
+
|
| 675 |
+
app_logger.info(f"[PersistentDB] Using SQLite persistence at {DB_PATH}")
|
| 676 |
+
return SQLitePersistentDB(DB_PATH)
|
| 677 |
+
|
| 678 |
|
| 679 |
+
# Global singleton instance
|
| 680 |
+
persistent_db = create_persistent_db()
|
app/core/postgres_db.py
ADDED
|
@@ -0,0 +1,639 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
postgres_db.py
|
| 3 |
+
──────────────
|
| 4 |
+
PostgreSQL persistence layer for enterprise-grade durability.
|
| 5 |
+
Stores chat sessions, messages, document metadata, and heavy document index trees (JSONB).
|
| 6 |
+
Even if the container or app restarts or sleeps, all documents, chats, and indices remain intact.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
import json
|
| 11 |
+
import time
|
| 12 |
+
import uuid
|
| 13 |
+
import base64
|
| 14 |
+
from contextlib import contextmanager
|
| 15 |
+
from typing import Any, Dict, List, Optional
|
| 16 |
+
import psycopg2
|
| 17 |
+
import psycopg2.extras
|
| 18 |
+
import psycopg2.pool
|
| 19 |
+
from utils.logger import app_logger
|
| 20 |
+
|
| 21 |
+
# Register JSON/JSONB handlers so JSON columns are transparently converted to/from dict/list
|
| 22 |
+
psycopg2.extras.register_default_jsonb()
|
| 23 |
+
|
| 24 |
+
_PG_DDL = """
|
| 25 |
+
CREATE TABLE IF NOT EXISTS chats (
|
| 26 |
+
chat_id TEXT PRIMARY KEY,
|
| 27 |
+
title TEXT,
|
| 28 |
+
doc_id TEXT,
|
| 29 |
+
doc_name TEXT,
|
| 30 |
+
created_at DOUBLE PRECISION NOT NULL,
|
| 31 |
+
updated_at DOUBLE PRECISION NOT NULL
|
| 32 |
+
);
|
| 33 |
+
|
| 34 |
+
CREATE TABLE IF NOT EXISTS messages (
|
| 35 |
+
id BIGSERIAL PRIMARY KEY,
|
| 36 |
+
chat_id TEXT NOT NULL REFERENCES chats(chat_id) ON DELETE CASCADE,
|
| 37 |
+
role TEXT NOT NULL CHECK(role IN ('user','assistant','system')),
|
| 38 |
+
content TEXT NOT NULL,
|
| 39 |
+
created_at DOUBLE PRECISION NOT NULL
|
| 40 |
+
);
|
| 41 |
+
|
| 42 |
+
CREATE INDEX IF NOT EXISTS idx_pg_messages_chat ON messages(chat_id, created_at);
|
| 43 |
+
|
| 44 |
+
CREATE TABLE IF NOT EXISTS doc_meta (
|
| 45 |
+
doc_id TEXT PRIMARY KEY,
|
| 46 |
+
filename TEXT,
|
| 47 |
+
status TEXT,
|
| 48 |
+
page_count INTEGER DEFAULT 0,
|
| 49 |
+
section_count INTEGER DEFAULT 0,
|
| 50 |
+
progress INTEGER DEFAULT 0,
|
| 51 |
+
current_stage TEXT DEFAULT 'uploaded',
|
| 52 |
+
indexing_method TEXT,
|
| 53 |
+
duration DOUBLE PRECISION,
|
| 54 |
+
error TEXT,
|
| 55 |
+
content_hash TEXT,
|
| 56 |
+
file_path TEXT,
|
| 57 |
+
created_at DOUBLE PRECISION NOT NULL,
|
| 58 |
+
updated_at DOUBLE PRECISION NOT NULL
|
| 59 |
+
);
|
| 60 |
+
|
| 61 |
+
CREATE INDEX IF NOT EXISTS idx_pg_doc_meta_hash ON doc_meta(content_hash);
|
| 62 |
+
|
| 63 |
+
CREATE TABLE IF NOT EXISTS doc_indices (
|
| 64 |
+
doc_id TEXT PRIMARY KEY REFERENCES doc_meta(doc_id) ON DELETE CASCADE,
|
| 65 |
+
content_hash TEXT,
|
| 66 |
+
tree JSONB,
|
| 67 |
+
node_map JSONB,
|
| 68 |
+
flat_sections JSONB,
|
| 69 |
+
file_base64 TEXT,
|
| 70 |
+
created_at DOUBLE PRECISION NOT NULL,
|
| 71 |
+
updated_at DOUBLE PRECISION NOT NULL
|
| 72 |
+
);
|
| 73 |
+
|
| 74 |
+
CREATE INDEX IF NOT EXISTS idx_pg_doc_indices_hash ON doc_indices(content_hash);
|
| 75 |
+
|
| 76 |
+
CREATE TABLE IF NOT EXISTS chat_documents (
|
| 77 |
+
chat_id TEXT NOT NULL REFERENCES chats(chat_id) ON DELETE CASCADE,
|
| 78 |
+
doc_id TEXT NOT NULL REFERENCES doc_meta(doc_id) ON DELETE CASCADE,
|
| 79 |
+
doc_name TEXT,
|
| 80 |
+
attached_at DOUBLE PRECISION NOT NULL,
|
| 81 |
+
PRIMARY KEY (chat_id, doc_id)
|
| 82 |
+
);
|
| 83 |
+
|
| 84 |
+
CREATE INDEX IF NOT EXISTS idx_pg_chat_docs ON chat_documents(chat_id);
|
| 85 |
+
|
| 86 |
+
CREATE TABLE IF NOT EXISTS chat_summaries (
|
| 87 |
+
chat_id TEXT PRIMARY KEY REFERENCES chats(chat_id) ON DELETE CASCADE,
|
| 88 |
+
summary TEXT NOT NULL,
|
| 89 |
+
turn_count INTEGER DEFAULT 0,
|
| 90 |
+
updated_at DOUBLE PRECISION NOT NULL
|
| 91 |
+
);
|
| 92 |
+
|
| 93 |
+
CREATE TABLE IF NOT EXISTS knowledge_memory (
|
| 94 |
+
doc_id TEXT PRIMARY KEY REFERENCES doc_meta(doc_id) ON DELETE CASCADE,
|
| 95 |
+
filename TEXT NOT NULL,
|
| 96 |
+
executive_summary TEXT,
|
| 97 |
+
key_entities JSONB,
|
| 98 |
+
key_findings JSONB,
|
| 99 |
+
topics JSONB,
|
| 100 |
+
updated_at DOUBLE PRECISION NOT NULL
|
| 101 |
+
);
|
| 102 |
+
|
| 103 |
+
CREATE TABLE IF NOT EXISTS global_knowledge_summary (
|
| 104 |
+
id TEXT PRIMARY KEY,
|
| 105 |
+
collective_summary TEXT NOT NULL,
|
| 106 |
+
total_documents INTEGER DEFAULT 0,
|
| 107 |
+
key_insights JSONB,
|
| 108 |
+
updated_at DOUBLE PRECISION NOT NULL
|
| 109 |
+
);
|
| 110 |
+
"""
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
class PostgresDB:
|
| 114 |
+
"""Robust, thread-safe PostgreSQL database client with connection pool and auto-reconnect."""
|
| 115 |
+
|
| 116 |
+
def __init__(self, dsn: str):
|
| 117 |
+
# Normalize postgres:// to postgresql:// if needed
|
| 118 |
+
if dsn.startswith("postgres://"):
|
| 119 |
+
dsn = "postgresql://" + dsn[len("postgres://"):]
|
| 120 |
+
self._dsn = dsn
|
| 121 |
+
self._pool: Optional[psycopg2.pool.ThreadedConnectionPool] = None
|
| 122 |
+
self._init_pool()
|
| 123 |
+
self._init_schema()
|
| 124 |
+
|
| 125 |
+
def _init_pool(self) -> None:
|
| 126 |
+
try:
|
| 127 |
+
self._pool = psycopg2.pool.ThreadedConnectionPool(
|
| 128 |
+
minconn=1,
|
| 129 |
+
maxconn=20,
|
| 130 |
+
dsn=self._dsn,
|
| 131 |
+
)
|
| 132 |
+
app_logger.info("[PostgresDB] Connection pool created successfully.")
|
| 133 |
+
except Exception as e:
|
| 134 |
+
app_logger.error(f"[PostgresDB] Failed to initialize connection pool: {e}")
|
| 135 |
+
raise
|
| 136 |
+
|
| 137 |
+
@contextmanager
|
| 138 |
+
def _conn(self):
|
| 139 |
+
"""Context manager that borrows a healthy connection from the pool and handles commits/rollbacks."""
|
| 140 |
+
conn = None
|
| 141 |
+
try:
|
| 142 |
+
conn = self._pool.getconn()
|
| 143 |
+
# Test connection liveness
|
| 144 |
+
if conn.closed:
|
| 145 |
+
conn = psycopg2.connect(self._dsn)
|
| 146 |
+
else:
|
| 147 |
+
try:
|
| 148 |
+
with conn.cursor() as cur:
|
| 149 |
+
cur.execute("SELECT 1;")
|
| 150 |
+
except Exception:
|
| 151 |
+
# Connection is dead, recreate
|
| 152 |
+
try:
|
| 153 |
+
self._pool.putconn(conn, close=True)
|
| 154 |
+
except Exception:
|
| 155 |
+
pass
|
| 156 |
+
conn = psycopg2.connect(self._dsn)
|
| 157 |
+
|
| 158 |
+
yield conn
|
| 159 |
+
conn.commit()
|
| 160 |
+
except Exception:
|
| 161 |
+
if conn and not conn.closed:
|
| 162 |
+
conn.rollback()
|
| 163 |
+
raise
|
| 164 |
+
finally:
|
| 165 |
+
if conn and self._pool:
|
| 166 |
+
try:
|
| 167 |
+
self._pool.putconn(conn)
|
| 168 |
+
except Exception:
|
| 169 |
+
pass
|
| 170 |
+
|
| 171 |
+
def _init_schema(self) -> None:
|
| 172 |
+
with self._conn() as conn:
|
| 173 |
+
with conn.cursor() as cur:
|
| 174 |
+
cur.execute(_PG_DDL)
|
| 175 |
+
app_logger.info("[PostgresDB] Schema initialized / verified successfully.")
|
| 176 |
+
|
| 177 |
+
# ── chat CRUD ───────────────────────────────────────────────────────────
|
| 178 |
+
def create_chat(self, chat_id: Optional[str] = None, title: Optional[str] = None) -> str:
|
| 179 |
+
cid = chat_id or str(uuid.uuid4())
|
| 180 |
+
now = time.time()
|
| 181 |
+
with self._conn() as conn:
|
| 182 |
+
with conn.cursor() as cur:
|
| 183 |
+
cur.execute(
|
| 184 |
+
"""
|
| 185 |
+
INSERT INTO chats(chat_id, title, created_at, updated_at)
|
| 186 |
+
VALUES (%s, %s, %s, %s)
|
| 187 |
+
ON CONFLICT (chat_id) DO NOTHING
|
| 188 |
+
""",
|
| 189 |
+
(cid, title, now, now),
|
| 190 |
+
)
|
| 191 |
+
return cid
|
| 192 |
+
|
| 193 |
+
def get_chat(self, chat_id: str) -> Optional[Dict[str, Any]]:
|
| 194 |
+
with self._conn() as conn:
|
| 195 |
+
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
| 196 |
+
cur.execute("SELECT * FROM chats WHERE chat_id = %s", (chat_id,))
|
| 197 |
+
row = cur.fetchone()
|
| 198 |
+
return dict(row) if row else None
|
| 199 |
+
|
| 200 |
+
def list_chats(self) -> List[Dict[str, Any]]:
|
| 201 |
+
with self._conn() as conn:
|
| 202 |
+
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
| 203 |
+
cur.execute("SELECT * FROM chats ORDER BY updated_at DESC")
|
| 204 |
+
rows = cur.fetchall()
|
| 205 |
+
return [dict(r) for r in rows]
|
| 206 |
+
|
| 207 |
+
def update_chat_title(self, chat_id: str, title: str) -> None:
|
| 208 |
+
with self._conn() as conn:
|
| 209 |
+
with conn.cursor() as cur:
|
| 210 |
+
cur.execute(
|
| 211 |
+
"UPDATE chats SET title = %s, updated_at = %s WHERE chat_id = %s",
|
| 212 |
+
(title, time.time(), chat_id),
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
def set_chat_doc(self, chat_id: str, doc_id: str, doc_name: Optional[str] = None) -> None:
|
| 216 |
+
with self._conn() as conn:
|
| 217 |
+
with conn.cursor() as cur:
|
| 218 |
+
cur.execute(
|
| 219 |
+
"UPDATE chats SET doc_id = %s, doc_name = %s, updated_at = %s WHERE chat_id = %s",
|
| 220 |
+
(doc_id, doc_name, time.time(), chat_id),
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
def remove_chat_doc(self, chat_id: str) -> None:
|
| 224 |
+
with self._conn() as conn:
|
| 225 |
+
with conn.cursor() as cur:
|
| 226 |
+
cur.execute(
|
| 227 |
+
"UPDATE chats SET doc_id = NULL, doc_name = NULL, updated_at = %s WHERE chat_id = %s",
|
| 228 |
+
(time.time(), chat_id),
|
| 229 |
+
)
|
| 230 |
+
|
| 231 |
+
def delete_chat(self, chat_id: str) -> None:
|
| 232 |
+
with self._conn() as conn:
|
| 233 |
+
with conn.cursor() as cur:
|
| 234 |
+
cur.execute("DELETE FROM chats WHERE chat_id = %s", (chat_id,))
|
| 235 |
+
|
| 236 |
+
# ── message CRUD ────────────────────────────────────────────────────────
|
| 237 |
+
def add_message(self, chat_id: str, role: str, content: str) -> None:
|
| 238 |
+
now = time.time()
|
| 239 |
+
with self._conn() as conn:
|
| 240 |
+
with conn.cursor() as cur:
|
| 241 |
+
cur.execute(
|
| 242 |
+
"""
|
| 243 |
+
INSERT INTO chats(chat_id, title, created_at, updated_at)
|
| 244 |
+
VALUES (%s, 'New research', %s, %s)
|
| 245 |
+
ON CONFLICT (chat_id) DO NOTHING
|
| 246 |
+
""",
|
| 247 |
+
(chat_id, now, now),
|
| 248 |
+
)
|
| 249 |
+
cur.execute(
|
| 250 |
+
"INSERT INTO messages(chat_id, role, content, created_at) VALUES (%s, %s, %s, %s)",
|
| 251 |
+
(chat_id, role, content, now),
|
| 252 |
+
)
|
| 253 |
+
cur.execute(
|
| 254 |
+
"UPDATE chats SET updated_at = %s WHERE chat_id = %s",
|
| 255 |
+
(now, chat_id),
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
def get_messages(self, chat_id: str, limit: int = 0) -> List[Dict[str, str]]:
|
| 259 |
+
with self._conn() as conn:
|
| 260 |
+
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
| 261 |
+
if limit > 0:
|
| 262 |
+
cur.execute(
|
| 263 |
+
"SELECT role, content FROM messages WHERE chat_id = %s ORDER BY created_at DESC LIMIT %s",
|
| 264 |
+
(chat_id, limit),
|
| 265 |
+
)
|
| 266 |
+
rows = cur.fetchall()
|
| 267 |
+
return [{"role": r["role"], "content": r["content"]} for r in reversed(rows)]
|
| 268 |
+
cur.execute(
|
| 269 |
+
"SELECT role, content FROM messages WHERE chat_id = %s ORDER BY created_at ASC",
|
| 270 |
+
(chat_id,),
|
| 271 |
+
)
|
| 272 |
+
rows = cur.fetchall()
|
| 273 |
+
return [{"role": r["role"], "content": r["content"]} for r in rows]
|
| 274 |
+
|
| 275 |
+
def clear_messages(self, chat_id: str) -> None:
|
| 276 |
+
with self._conn() as conn:
|
| 277 |
+
with conn.cursor() as cur:
|
| 278 |
+
cur.execute("DELETE FROM messages WHERE chat_id = %s", (chat_id,))
|
| 279 |
+
|
| 280 |
+
# ── document metadata ────────────────────────────────────────────────────
|
| 281 |
+
def upsert_doc_meta(
|
| 282 |
+
self,
|
| 283 |
+
doc_id: str,
|
| 284 |
+
filename: str,
|
| 285 |
+
status: str,
|
| 286 |
+
page_count: int = 0,
|
| 287 |
+
section_count: int = 0,
|
| 288 |
+
progress: int = 0,
|
| 289 |
+
current_stage: str = "uploaded",
|
| 290 |
+
indexing_method: Optional[str] = None,
|
| 291 |
+
duration: Optional[float] = None,
|
| 292 |
+
error: Optional[str] = None,
|
| 293 |
+
content_hash: Optional[str] = None,
|
| 294 |
+
file_path: Optional[str] = None,
|
| 295 |
+
) -> None:
|
| 296 |
+
now = time.time()
|
| 297 |
+
with self._conn() as conn:
|
| 298 |
+
with conn.cursor() as cur:
|
| 299 |
+
cur.execute(
|
| 300 |
+
"""
|
| 301 |
+
INSERT INTO doc_meta(
|
| 302 |
+
doc_id, filename, status, page_count, section_count,
|
| 303 |
+
progress, current_stage, indexing_method, duration,
|
| 304 |
+
error, content_hash, file_path, created_at, updated_at
|
| 305 |
+
)
|
| 306 |
+
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
| 307 |
+
ON CONFLICT (doc_id) DO UPDATE SET
|
| 308 |
+
filename = EXCLUDED.filename,
|
| 309 |
+
status = EXCLUDED.status,
|
| 310 |
+
page_count = EXCLUDED.page_count,
|
| 311 |
+
section_count = EXCLUDED.section_count,
|
| 312 |
+
progress = EXCLUDED.progress,
|
| 313 |
+
current_stage = EXCLUDED.current_stage,
|
| 314 |
+
indexing_method = COALESCE(EXCLUDED.indexing_method, doc_meta.indexing_method),
|
| 315 |
+
duration = COALESCE(EXCLUDED.duration, doc_meta.duration),
|
| 316 |
+
error = EXCLUDED.error,
|
| 317 |
+
content_hash = COALESCE(EXCLUDED.content_hash, doc_meta.content_hash),
|
| 318 |
+
file_path = COALESCE(EXCLUDED.file_path, doc_meta.file_path),
|
| 319 |
+
updated_at = EXCLUDED.updated_at
|
| 320 |
+
""",
|
| 321 |
+
(
|
| 322 |
+
doc_id,
|
| 323 |
+
filename,
|
| 324 |
+
status,
|
| 325 |
+
page_count,
|
| 326 |
+
section_count,
|
| 327 |
+
progress,
|
| 328 |
+
current_stage,
|
| 329 |
+
indexing_method,
|
| 330 |
+
duration,
|
| 331 |
+
error,
|
| 332 |
+
content_hash,
|
| 333 |
+
file_path,
|
| 334 |
+
now,
|
| 335 |
+
now,
|
| 336 |
+
),
|
| 337 |
+
)
|
| 338 |
+
|
| 339 |
+
def get_doc_meta(self, doc_id: str) -> Optional[Dict[str, Any]]:
|
| 340 |
+
with self._conn() as conn:
|
| 341 |
+
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
| 342 |
+
cur.execute("SELECT * FROM doc_meta WHERE doc_id = %s", (doc_id,))
|
| 343 |
+
row = cur.fetchone()
|
| 344 |
+
return dict(row) if row else None
|
| 345 |
+
|
| 346 |
+
def list_docs(self) -> List[Dict[str, Any]]:
|
| 347 |
+
with self._conn() as conn:
|
| 348 |
+
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
| 349 |
+
cur.execute("SELECT * FROM doc_meta ORDER BY created_at DESC")
|
| 350 |
+
rows = cur.fetchall()
|
| 351 |
+
return [dict(r) for r in rows]
|
| 352 |
+
|
| 353 |
+
def find_doc_by_hash(self, content_hash: str) -> Optional[Dict[str, Any]]:
|
| 354 |
+
with self._conn() as conn:
|
| 355 |
+
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
| 356 |
+
cur.execute(
|
| 357 |
+
"SELECT * FROM doc_meta WHERE content_hash = %s ORDER BY updated_at DESC LIMIT 1",
|
| 358 |
+
(content_hash,),
|
| 359 |
+
)
|
| 360 |
+
row = cur.fetchone()
|
| 361 |
+
return dict(row) if row else None
|
| 362 |
+
|
| 363 |
+
def delete_doc(self, doc_id: str) -> bool:
|
| 364 |
+
with self._conn() as conn:
|
| 365 |
+
with conn.cursor() as cur:
|
| 366 |
+
cur.execute("DELETE FROM doc_meta WHERE doc_id = %s", (doc_id,))
|
| 367 |
+
rc = cur.rowcount
|
| 368 |
+
cur.execute("UPDATE chats SET doc_id = NULL, doc_name = NULL WHERE doc_id = %s", (doc_id,))
|
| 369 |
+
cur.execute("DELETE FROM chat_documents WHERE doc_id = %s", (doc_id,))
|
| 370 |
+
cur.execute("DELETE FROM knowledge_memory WHERE doc_id = %s", (doc_id,))
|
| 371 |
+
cur.execute("DELETE FROM doc_indices WHERE doc_id = %s", (doc_id,))
|
| 372 |
+
return rc > 0
|
| 373 |
+
|
| 374 |
+
# ── document heavy indices store (JSONB) ─────────────────────────────────
|
| 375 |
+
def save_doc_index(
|
| 376 |
+
self,
|
| 377 |
+
doc_id: str,
|
| 378 |
+
content_hash: Optional[str],
|
| 379 |
+
tree: Any,
|
| 380 |
+
node_map: Optional[Dict[str, Any]] = None,
|
| 381 |
+
flat_sections: Optional[List[Dict[str, Any]]] = None,
|
| 382 |
+
file_base64: Optional[str] = None,
|
| 383 |
+
) -> None:
|
| 384 |
+
now = time.time()
|
| 385 |
+
with self._conn() as conn:
|
| 386 |
+
with conn.cursor() as cur:
|
| 387 |
+
cur.execute(
|
| 388 |
+
"""
|
| 389 |
+
INSERT INTO doc_indices (
|
| 390 |
+
doc_id, content_hash, tree, node_map, flat_sections, file_base64, created_at, updated_at
|
| 391 |
+
)
|
| 392 |
+
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
| 393 |
+
ON CONFLICT (doc_id) DO UPDATE SET
|
| 394 |
+
content_hash = COALESCE(EXCLUDED.content_hash, doc_indices.content_hash),
|
| 395 |
+
tree = EXCLUDED.tree,
|
| 396 |
+
node_map = EXCLUDED.node_map,
|
| 397 |
+
flat_sections = EXCLUDED.flat_sections,
|
| 398 |
+
file_base64 = COALESCE(EXCLUDED.file_base64, doc_indices.file_base64),
|
| 399 |
+
updated_at = EXCLUDED.updated_at
|
| 400 |
+
""",
|
| 401 |
+
(
|
| 402 |
+
doc_id,
|
| 403 |
+
content_hash,
|
| 404 |
+
psycopg2.extras.Json(tree) if tree is not None else None,
|
| 405 |
+
psycopg2.extras.Json(node_map) if node_map is not None else None,
|
| 406 |
+
psycopg2.extras.Json(flat_sections) if flat_sections is not None else None,
|
| 407 |
+
file_base64,
|
| 408 |
+
now,
|
| 409 |
+
now,
|
| 410 |
+
),
|
| 411 |
+
)
|
| 412 |
+
|
| 413 |
+
def get_doc_index(self, doc_id: str) -> Optional[Dict[str, Any]]:
|
| 414 |
+
with self._conn() as conn:
|
| 415 |
+
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
| 416 |
+
cur.execute("SELECT * FROM doc_indices WHERE doc_id = %s", (doc_id,))
|
| 417 |
+
row = cur.fetchone()
|
| 418 |
+
return dict(row) if row else None
|
| 419 |
+
|
| 420 |
+
def get_doc_index_by_hash(self, content_hash: str) -> Optional[Dict[str, Any]]:
|
| 421 |
+
with self._conn() as conn:
|
| 422 |
+
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
| 423 |
+
cur.execute(
|
| 424 |
+
"SELECT * FROM doc_indices WHERE content_hash = %s ORDER BY updated_at DESC LIMIT 1",
|
| 425 |
+
(content_hash,),
|
| 426 |
+
)
|
| 427 |
+
row = cur.fetchone()
|
| 428 |
+
return dict(row) if row else None
|
| 429 |
+
|
| 430 |
+
def delete_doc_index(self, doc_id: str) -> None:
|
| 431 |
+
with self._conn() as conn:
|
| 432 |
+
with conn.cursor() as cur:
|
| 433 |
+
cur.execute("DELETE FROM doc_indices WHERE doc_id = %s", (doc_id,))
|
| 434 |
+
|
| 435 |
+
# ── multi-doc session associations ──────────────────────────────────────
|
| 436 |
+
def add_chat_document(self, chat_id: str, doc_id: str, doc_name: Optional[str] = None) -> None:
|
| 437 |
+
now = time.time()
|
| 438 |
+
with self._conn() as conn:
|
| 439 |
+
with conn.cursor() as cur:
|
| 440 |
+
cur.execute(
|
| 441 |
+
"""
|
| 442 |
+
INSERT INTO chat_documents(chat_id, doc_id, doc_name, attached_at)
|
| 443 |
+
VALUES (%s, %s, %s, %s)
|
| 444 |
+
ON CONFLICT (chat_id, doc_id) DO UPDATE SET
|
| 445 |
+
doc_name = COALESCE(EXCLUDED.doc_name, chat_documents.doc_name),
|
| 446 |
+
attached_at = EXCLUDED.attached_at
|
| 447 |
+
""",
|
| 448 |
+
(chat_id, doc_id, doc_name, now),
|
| 449 |
+
)
|
| 450 |
+
# Maintain backward compatibility on chats table
|
| 451 |
+
cur.execute(
|
| 452 |
+
"UPDATE chats SET doc_id = %s, doc_name = %s, updated_at = %s WHERE chat_id = %s",
|
| 453 |
+
(doc_id, doc_name, now, chat_id),
|
| 454 |
+
)
|
| 455 |
+
|
| 456 |
+
def remove_chat_document(self, chat_id: str, doc_id: str) -> None:
|
| 457 |
+
now = time.time()
|
| 458 |
+
with self._conn() as conn:
|
| 459 |
+
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
| 460 |
+
cur.execute("DELETE FROM chat_documents WHERE chat_id = %s AND doc_id = %s", (chat_id, doc_id))
|
| 461 |
+
cur.execute(
|
| 462 |
+
"SELECT doc_id, doc_name FROM chat_documents WHERE chat_id = %s ORDER BY attached_at DESC LIMIT 1",
|
| 463 |
+
(chat_id,),
|
| 464 |
+
)
|
| 465 |
+
rem = cur.fetchone()
|
| 466 |
+
if rem:
|
| 467 |
+
cur.execute(
|
| 468 |
+
"UPDATE chats SET doc_id = %s, doc_name = %s, updated_at = %s WHERE chat_id = %s",
|
| 469 |
+
(rem["doc_id"], rem["doc_name"], now, chat_id),
|
| 470 |
+
)
|
| 471 |
+
else:
|
| 472 |
+
cur.execute(
|
| 473 |
+
"UPDATE chats SET doc_id = NULL, doc_name = NULL, updated_at = %s WHERE chat_id = %s",
|
| 474 |
+
(now, chat_id),
|
| 475 |
+
)
|
| 476 |
+
|
| 477 |
+
def get_chat_documents(self, chat_id: str) -> List[Dict[str, Any]]:
|
| 478 |
+
with self._conn() as conn:
|
| 479 |
+
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
| 480 |
+
cur.execute(
|
| 481 |
+
"""
|
| 482 |
+
SELECT cd.chat_id, cd.doc_id, cd.doc_name, cd.attached_at,
|
| 483 |
+
dm.status, dm.page_count, dm.section_count, dm.filename
|
| 484 |
+
FROM chat_documents cd
|
| 485 |
+
LEFT JOIN doc_meta dm ON cd.doc_id = dm.doc_id
|
| 486 |
+
WHERE cd.chat_id = %s
|
| 487 |
+
ORDER BY cd.attached_at ASC
|
| 488 |
+
""",
|
| 489 |
+
(chat_id,),
|
| 490 |
+
)
|
| 491 |
+
rows = cur.fetchall()
|
| 492 |
+
return [dict(r) for r in rows]
|
| 493 |
+
|
| 494 |
+
def clear_chat_documents(self, chat_id: str) -> None:
|
| 495 |
+
now = time.time()
|
| 496 |
+
with self._conn() as conn:
|
| 497 |
+
with conn.cursor() as cur:
|
| 498 |
+
cur.execute("DELETE FROM chat_documents WHERE chat_id = %s", (chat_id,))
|
| 499 |
+
cur.execute(
|
| 500 |
+
"UPDATE chats SET doc_id = NULL, doc_name = NULL, updated_at = %s WHERE chat_id = %s",
|
| 501 |
+
(now, chat_id),
|
| 502 |
+
)
|
| 503 |
+
|
| 504 |
+
# ── session long memory & rolling summaries ─────────────────────────────
|
| 505 |
+
def set_chat_summary(self, chat_id: str, summary: str, turn_count: int = 0) -> None:
|
| 506 |
+
now = time.time()
|
| 507 |
+
with self._conn() as conn:
|
| 508 |
+
with conn.cursor() as cur:
|
| 509 |
+
cur.execute(
|
| 510 |
+
"""
|
| 511 |
+
INSERT INTO chat_summaries(chat_id, summary, turn_count, updated_at)
|
| 512 |
+
VALUES (%s, %s, %s, %s)
|
| 513 |
+
ON CONFLICT (chat_id) DO UPDATE SET
|
| 514 |
+
summary = EXCLUDED.summary,
|
| 515 |
+
turn_count = EXCLUDED.turn_count,
|
| 516 |
+
updated_at = EXCLUDED.updated_at
|
| 517 |
+
""",
|
| 518 |
+
(chat_id, summary, turn_count, now),
|
| 519 |
+
)
|
| 520 |
+
|
| 521 |
+
def get_chat_summary(self, chat_id: str) -> Optional[Dict[str, Any]]:
|
| 522 |
+
with self._conn() as conn:
|
| 523 |
+
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
| 524 |
+
cur.execute("SELECT * FROM chat_summaries WHERE chat_id = %s", (chat_id,))
|
| 525 |
+
row = cur.fetchone()
|
| 526 |
+
return dict(row) if row else None
|
| 527 |
+
|
| 528 |
+
# ── global knowledge memory & document dossiers ────────────────────────
|
| 529 |
+
def save_knowledge_dossier(
|
| 530 |
+
self,
|
| 531 |
+
doc_id: str,
|
| 532 |
+
filename: str,
|
| 533 |
+
executive_summary: str,
|
| 534 |
+
key_entities: Optional[List[str]] = None,
|
| 535 |
+
key_findings: Optional[List[str]] = None,
|
| 536 |
+
topics: Optional[List[str]] = None,
|
| 537 |
+
) -> None:
|
| 538 |
+
now = time.time()
|
| 539 |
+
with self._conn() as conn:
|
| 540 |
+
with conn.cursor() as cur:
|
| 541 |
+
cur.execute(
|
| 542 |
+
"""
|
| 543 |
+
INSERT INTO knowledge_memory(
|
| 544 |
+
doc_id, filename, executive_summary, key_entities, key_findings, topics, updated_at
|
| 545 |
+
)
|
| 546 |
+
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
| 547 |
+
ON CONFLICT (doc_id) DO UPDATE SET
|
| 548 |
+
filename = EXCLUDED.filename,
|
| 549 |
+
executive_summary = EXCLUDED.executive_summary,
|
| 550 |
+
key_entities = EXCLUDED.key_entities,
|
| 551 |
+
key_findings = EXCLUDED.key_findings,
|
| 552 |
+
topics = EXCLUDED.topics,
|
| 553 |
+
updated_at = EXCLUDED.updated_at
|
| 554 |
+
""",
|
| 555 |
+
(
|
| 556 |
+
doc_id,
|
| 557 |
+
filename,
|
| 558 |
+
executive_summary,
|
| 559 |
+
psycopg2.extras.Json(key_entities or []),
|
| 560 |
+
psycopg2.extras.Json(key_findings or []),
|
| 561 |
+
psycopg2.extras.Json(topics or []),
|
| 562 |
+
now,
|
| 563 |
+
),
|
| 564 |
+
)
|
| 565 |
+
|
| 566 |
+
def get_knowledge_dossier(self, doc_id: str) -> Optional[Dict[str, Any]]:
|
| 567 |
+
with self._conn() as conn:
|
| 568 |
+
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
| 569 |
+
cur.execute("SELECT * FROM knowledge_memory WHERE doc_id = %s", (doc_id,))
|
| 570 |
+
row = cur.fetchone()
|
| 571 |
+
if not row:
|
| 572 |
+
return None
|
| 573 |
+
res = dict(row)
|
| 574 |
+
for k in ("key_entities", "key_findings", "topics"):
|
| 575 |
+
if res.get(k) is None:
|
| 576 |
+
res[k] = []
|
| 577 |
+
return res
|
| 578 |
+
|
| 579 |
+
def list_knowledge_dossiers(self) -> List[Dict[str, Any]]:
|
| 580 |
+
with self._conn() as conn:
|
| 581 |
+
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
| 582 |
+
cur.execute("SELECT * FROM knowledge_memory ORDER BY updated_at DESC")
|
| 583 |
+
rows = cur.fetchall()
|
| 584 |
+
result = []
|
| 585 |
+
for r in rows:
|
| 586 |
+
item = dict(r)
|
| 587 |
+
for k in ("key_entities", "key_findings", "topics"):
|
| 588 |
+
if item.get(k) is None:
|
| 589 |
+
item[k] = []
|
| 590 |
+
result.append(item)
|
| 591 |
+
return result
|
| 592 |
+
|
| 593 |
+
def delete_knowledge_dossier(self, doc_id: str) -> None:
|
| 594 |
+
with self._conn() as conn:
|
| 595 |
+
with conn.cursor() as cur:
|
| 596 |
+
cur.execute("DELETE FROM knowledge_memory WHERE doc_id = %s", (doc_id,))
|
| 597 |
+
|
| 598 |
+
def save_global_knowledge_summary(
|
| 599 |
+
self,
|
| 600 |
+
collective_summary: str,
|
| 601 |
+
total_documents: int,
|
| 602 |
+
key_insights: Optional[List[str]] = None,
|
| 603 |
+
) -> None:
|
| 604 |
+
now = time.time()
|
| 605 |
+
gid = "global"
|
| 606 |
+
with self._conn() as conn:
|
| 607 |
+
with conn.cursor() as cur:
|
| 608 |
+
cur.execute(
|
| 609 |
+
"""
|
| 610 |
+
INSERT INTO global_knowledge_summary(
|
| 611 |
+
id, collective_summary, total_documents, key_insights, updated_at
|
| 612 |
+
)
|
| 613 |
+
VALUES (%s, %s, %s, %s, %s)
|
| 614 |
+
ON CONFLICT (id) DO UPDATE SET
|
| 615 |
+
collective_summary = EXCLUDED.collective_summary,
|
| 616 |
+
total_documents = EXCLUDED.total_documents,
|
| 617 |
+
key_insights = EXCLUDED.key_insights,
|
| 618 |
+
updated_at = EXCLUDED.updated_at
|
| 619 |
+
""",
|
| 620 |
+
(
|
| 621 |
+
gid,
|
| 622 |
+
collective_summary,
|
| 623 |
+
total_documents,
|
| 624 |
+
psycopg2.extras.Json(key_insights or []),
|
| 625 |
+
now,
|
| 626 |
+
),
|
| 627 |
+
)
|
| 628 |
+
|
| 629 |
+
def get_global_knowledge_summary(self) -> Optional[Dict[str, Any]]:
|
| 630 |
+
with self._conn() as conn:
|
| 631 |
+
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
| 632 |
+
cur.execute("SELECT * FROM global_knowledge_summary WHERE id = 'global'")
|
| 633 |
+
row = cur.fetchone()
|
| 634 |
+
if not row:
|
| 635 |
+
return None
|
| 636 |
+
res = dict(row)
|
| 637 |
+
if res.get("key_insights") is None:
|
| 638 |
+
res["key_insights"] = []
|
| 639 |
+
return res
|
app/core/storage.py
CHANGED
|
@@ -2,6 +2,7 @@ import os
|
|
| 2 |
import json
|
| 3 |
import time
|
| 4 |
import uuid
|
|
|
|
| 5 |
from typing import Dict, List, Optional, Any
|
| 6 |
|
| 7 |
from core.persistent_db import persistent_db
|
|
@@ -79,43 +80,70 @@ class MemoryStorage:
|
|
| 79 |
return doc_obj
|
| 80 |
|
| 81 |
# 3. Try to load tree from disk JSON cache for ready documents
|
|
|
|
| 82 |
cache_path = os.path.join(CACHE_DIR, f"{content_hash}.json")
|
| 83 |
if os.path.exists(cache_path):
|
| 84 |
try:
|
| 85 |
with open(cache_path, "r", encoding="utf-8") as f:
|
| 86 |
cached = json.load(f)
|
| 87 |
-
self._documents[doc_id] = {
|
| 88 |
-
"doc_id": doc_id,
|
| 89 |
-
"document_id": doc_id,
|
| 90 |
-
"filename": cached.get("filename", db_meta.get("filename", "document.pdf")),
|
| 91 |
-
"status": "ready",
|
| 92 |
-
"progress": 100,
|
| 93 |
-
"current_stage": "Ready",
|
| 94 |
-
"tree": cached.get("tree"),
|
| 95 |
-
"page_count": cached.get("page_count", db_meta.get("page_count", 1)),
|
| 96 |
-
"section_count": cached.get("section_count", db_meta.get("section_count", len(cached.get("flat_sections", [])))),
|
| 97 |
-
"indexing_method": db_meta.get("indexing_method", "pageindex"),
|
| 98 |
-
"duration": db_meta.get("duration"),
|
| 99 |
-
"error": None,
|
| 100 |
-
"content_hash": content_hash,
|
| 101 |
-
"created_at": cached.get("created_at", time.time()),
|
| 102 |
-
"updated_at": time.time(),
|
| 103 |
-
}
|
| 104 |
-
self._trees[doc_id] = cached.get("tree")
|
| 105 |
-
if cached.get("node_map"):
|
| 106 |
-
self._node_maps[doc_id] = cached["node_map"]
|
| 107 |
-
if cached.get("flat_sections"):
|
| 108 |
-
self._flat_sections[doc_id] = cached["flat_sections"]
|
| 109 |
-
self._hash_to_doc_id[content_hash] = doc_id
|
| 110 |
-
return self._documents[doc_id]
|
| 111 |
except Exception:
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
return None
|
| 114 |
|
| 115 |
def set_hash_mapping(self, content_hash: str, doc_id: str) -> None:
|
| 116 |
self._hash_to_doc_id[content_hash] = doc_id
|
| 117 |
doc = self._documents.get(doc_id)
|
| 118 |
if doc and doc.get("status") == "ready" and doc.get("tree"):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
cache_path = os.path.join(CACHE_DIR, f"{content_hash}.json")
|
| 120 |
try:
|
| 121 |
payload = {
|
|
@@ -302,6 +330,26 @@ class MemoryStorage:
|
|
| 302 |
except Exception:
|
| 303 |
pass
|
| 304 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
persistent_db.upsert_doc_meta(
|
| 306 |
doc_id=doc_id,
|
| 307 |
filename=doc["filename"],
|
|
@@ -353,10 +401,10 @@ class MemoryStorage:
|
|
| 353 |
if not doc_id:
|
| 354 |
return None
|
| 355 |
doc = self._documents.get(doc_id)
|
| 356 |
-
if doc:
|
| 357 |
return doc
|
| 358 |
|
| 359 |
-
# Check
|
| 360 |
db_meta = persistent_db.get_doc_meta(doc_id)
|
| 361 |
if db_meta:
|
| 362 |
doc_obj = {
|
|
@@ -377,6 +425,7 @@ class MemoryStorage:
|
|
| 377 |
"updated_at": db_meta.get("updated_at", time.time()),
|
| 378 |
}
|
| 379 |
if doc_obj["status"] == "ready":
|
|
|
|
| 380 |
candidates = [f"{doc_id}.json"]
|
| 381 |
if doc_obj.get("content_hash"):
|
| 382 |
candidates.append(f"{doc_obj['content_hash']}.json")
|
|
@@ -396,6 +445,45 @@ class MemoryStorage:
|
|
| 396 |
except Exception:
|
| 397 |
pass
|
| 398 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 399 |
self._documents[doc_id] = doc_obj
|
| 400 |
return doc_obj
|
| 401 |
return None
|
|
@@ -444,6 +532,7 @@ class MemoryStorage:
|
|
| 444 |
self._node_maps.pop(doc_id, None)
|
| 445 |
self._flat_sections.pop(doc_id, None)
|
| 446 |
self._active_tasks.pop(doc_id, None)
|
|
|
|
| 447 |
return persistent_db.delete_doc(doc_id)
|
| 448 |
|
| 449 |
|
|
|
|
| 2 |
import json
|
| 3 |
import time
|
| 4 |
import uuid
|
| 5 |
+
import base64
|
| 6 |
from typing import Dict, List, Optional, Any
|
| 7 |
|
| 8 |
from core.persistent_db import persistent_db
|
|
|
|
| 80 |
return doc_obj
|
| 81 |
|
| 82 |
# 3. Try to load tree from disk JSON cache for ready documents
|
| 83 |
+
cached = None
|
| 84 |
cache_path = os.path.join(CACHE_DIR, f"{content_hash}.json")
|
| 85 |
if os.path.exists(cache_path):
|
| 86 |
try:
|
| 87 |
with open(cache_path, "r", encoding="utf-8") as f:
|
| 88 |
cached = json.load(f)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
except Exception:
|
| 90 |
+
cached = None
|
| 91 |
+
|
| 92 |
+
# 4. If disk cache is missing (e.g. fresh container start), load from DB
|
| 93 |
+
if not cached or not cached.get("tree"):
|
| 94 |
+
db_idx = persistent_db.get_doc_index_by_hash(content_hash) or persistent_db.get_doc_index(doc_id)
|
| 95 |
+
if db_idx and db_idx.get("tree"):
|
| 96 |
+
cached = {
|
| 97 |
+
"filename": db_meta.get("filename", "document.pdf"),
|
| 98 |
+
"tree": db_idx.get("tree"),
|
| 99 |
+
"node_map": db_idx.get("node_map"),
|
| 100 |
+
"flat_sections": db_idx.get("flat_sections"),
|
| 101 |
+
"page_count": db_meta.get("page_count", 1),
|
| 102 |
+
"section_count": db_meta.get("section_count", len(db_idx.get("flat_sections", [])) if db_idx.get("flat_sections") else 0),
|
| 103 |
+
"created_at": db_idx.get("created_at", time.time()),
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
if cached and cached.get("tree"):
|
| 107 |
+
self._documents[doc_id] = {
|
| 108 |
+
"doc_id": doc_id,
|
| 109 |
+
"document_id": doc_id,
|
| 110 |
+
"filename": cached.get("filename", db_meta.get("filename", "document.pdf")),
|
| 111 |
+
"status": "ready",
|
| 112 |
+
"progress": 100,
|
| 113 |
+
"current_stage": "Ready",
|
| 114 |
+
"tree": cached.get("tree"),
|
| 115 |
+
"page_count": cached.get("page_count", db_meta.get("page_count", 1)),
|
| 116 |
+
"section_count": cached.get("section_count", db_meta.get("section_count", len(cached.get("flat_sections", [])))),
|
| 117 |
+
"indexing_method": db_meta.get("indexing_method", "pageindex"),
|
| 118 |
+
"duration": db_meta.get("duration"),
|
| 119 |
+
"error": None,
|
| 120 |
+
"content_hash": content_hash,
|
| 121 |
+
"created_at": cached.get("created_at", time.time()),
|
| 122 |
+
"updated_at": time.time(),
|
| 123 |
+
}
|
| 124 |
+
self._trees[doc_id] = cached.get("tree")
|
| 125 |
+
if cached.get("node_map"):
|
| 126 |
+
self._node_maps[doc_id] = cached["node_map"]
|
| 127 |
+
if cached.get("flat_sections"):
|
| 128 |
+
self._flat_sections[doc_id] = cached["flat_sections"]
|
| 129 |
+
self._hash_to_doc_id[content_hash] = doc_id
|
| 130 |
+
return self._documents[doc_id]
|
| 131 |
return None
|
| 132 |
|
| 133 |
def set_hash_mapping(self, content_hash: str, doc_id: str) -> None:
|
| 134 |
self._hash_to_doc_id[content_hash] = doc_id
|
| 135 |
doc = self._documents.get(doc_id)
|
| 136 |
if doc and doc.get("status") == "ready" and doc.get("tree"):
|
| 137 |
+
try:
|
| 138 |
+
persistent_db.save_doc_index(
|
| 139 |
+
doc_id=doc_id,
|
| 140 |
+
content_hash=content_hash,
|
| 141 |
+
tree=doc.get("tree"),
|
| 142 |
+
node_map=self._node_maps.get(doc_id),
|
| 143 |
+
flat_sections=self._flat_sections.get(doc_id),
|
| 144 |
+
)
|
| 145 |
+
except Exception:
|
| 146 |
+
pass
|
| 147 |
cache_path = os.path.join(CACHE_DIR, f"{content_hash}.json")
|
| 148 |
try:
|
| 149 |
payload = {
|
|
|
|
| 330 |
except Exception:
|
| 331 |
pass
|
| 332 |
|
| 333 |
+
# Backup original PDF bytes (base64) so it survives ephemeral restarts
|
| 334 |
+
file_b64 = None
|
| 335 |
+
target_path = file_path or doc.get("file_path")
|
| 336 |
+
if target_path and os.path.exists(target_path):
|
| 337 |
+
try:
|
| 338 |
+
with open(target_path, "rb") as bf:
|
| 339 |
+
file_b64 = base64.b64encode(bf.read()).decode("utf-8")
|
| 340 |
+
except Exception:
|
| 341 |
+
pass
|
| 342 |
+
|
| 343 |
+
# Persist full tree & index to DB (PostgreSQL / SQLite)
|
| 344 |
+
persistent_db.save_doc_index(
|
| 345 |
+
doc_id=doc_id,
|
| 346 |
+
content_hash=doc.get("content_hash"),
|
| 347 |
+
tree=tree,
|
| 348 |
+
node_map=self._node_maps.get(doc_id),
|
| 349 |
+
flat_sections=self._flat_sections.get(doc_id),
|
| 350 |
+
file_base64=file_b64,
|
| 351 |
+
)
|
| 352 |
+
|
| 353 |
persistent_db.upsert_doc_meta(
|
| 354 |
doc_id=doc_id,
|
| 355 |
filename=doc["filename"],
|
|
|
|
| 401 |
if not doc_id:
|
| 402 |
return None
|
| 403 |
doc = self._documents.get(doc_id)
|
| 404 |
+
if doc and doc.get("status") == "ready" and doc.get("tree"):
|
| 405 |
return doc
|
| 406 |
|
| 407 |
+
# Check Persistent DB
|
| 408 |
db_meta = persistent_db.get_doc_meta(doc_id)
|
| 409 |
if db_meta:
|
| 410 |
doc_obj = {
|
|
|
|
| 425 |
"updated_at": db_meta.get("updated_at", time.time()),
|
| 426 |
}
|
| 427 |
if doc_obj["status"] == "ready":
|
| 428 |
+
# 1. Try disk cache
|
| 429 |
candidates = [f"{doc_id}.json"]
|
| 430 |
if doc_obj.get("content_hash"):
|
| 431 |
candidates.append(f"{doc_obj['content_hash']}.json")
|
|
|
|
| 445 |
except Exception:
|
| 446 |
pass
|
| 447 |
|
| 448 |
+
# 2. If tree was not found on disk, load directly from Persistent DB!
|
| 449 |
+
if doc_obj.get("tree") is None:
|
| 450 |
+
idx = persistent_db.get_doc_index(doc_id)
|
| 451 |
+
if not idx and doc_obj.get("content_hash"):
|
| 452 |
+
idx = persistent_db.get_doc_index_by_hash(doc_obj["content_hash"])
|
| 453 |
+
if idx and idx.get("tree"):
|
| 454 |
+
doc_obj["tree"] = idx["tree"]
|
| 455 |
+
self._trees[doc_id] = idx["tree"]
|
| 456 |
+
if idx.get("node_map"):
|
| 457 |
+
self._node_maps[doc_id] = idx["node_map"]
|
| 458 |
+
if idx.get("flat_sections"):
|
| 459 |
+
self._flat_sections[doc_id] = idx["flat_sections"]
|
| 460 |
+
|
| 461 |
+
# Recreate file on disk if missing
|
| 462 |
+
if idx.get("file_base64") and doc_obj.get("file_path") and not os.path.exists(doc_obj["file_path"]):
|
| 463 |
+
try:
|
| 464 |
+
os.makedirs(os.path.dirname(doc_obj["file_path"]), exist_ok=True)
|
| 465 |
+
with open(doc_obj["file_path"], "wb") as bf:
|
| 466 |
+
bf.write(base64.b64decode(idx["file_base64"]))
|
| 467 |
+
except Exception:
|
| 468 |
+
pass
|
| 469 |
+
|
| 470 |
+
# Cache to disk for local speedup
|
| 471 |
+
try:
|
| 472 |
+
cache_path = os.path.join(CACHE_DIR, f"{doc_id}.json")
|
| 473 |
+
with open(cache_path, "w", encoding="utf-8") as f:
|
| 474 |
+
json.dump({
|
| 475 |
+
"doc_id": doc_id,
|
| 476 |
+
"filename": doc_obj.get("filename"),
|
| 477 |
+
"tree": doc_obj["tree"],
|
| 478 |
+
"page_count": doc_obj.get("page_count", 1),
|
| 479 |
+
"section_count": doc_obj.get("section_count", 0),
|
| 480 |
+
"node_map": self._node_maps.get(doc_id),
|
| 481 |
+
"flat_sections": self._flat_sections.get(doc_id),
|
| 482 |
+
"created_at": doc_obj.get("created_at", time.time()),
|
| 483 |
+
}, f)
|
| 484 |
+
except Exception:
|
| 485 |
+
pass
|
| 486 |
+
|
| 487 |
self._documents[doc_id] = doc_obj
|
| 488 |
return doc_obj
|
| 489 |
return None
|
|
|
|
| 532 |
self._node_maps.pop(doc_id, None)
|
| 533 |
self._flat_sections.pop(doc_id, None)
|
| 534 |
self._active_tasks.pop(doc_id, None)
|
| 535 |
+
persistent_db.delete_doc_index(doc_id)
|
| 536 |
return persistent_db.delete_doc(doc_id)
|
| 537 |
|
| 538 |
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version: '3.8'
|
| 2 |
+
|
| 3 |
+
services:
|
| 4 |
+
db:
|
| 5 |
+
image: postgres:16-alpine
|
| 6 |
+
container_name: rag_postgres
|
| 7 |
+
restart: always
|
| 8 |
+
environment:
|
| 9 |
+
POSTGRES_USER: postgres
|
| 10 |
+
POSTGRES_PASSWORD: postgres
|
| 11 |
+
POSTGRES_DB: rag_pageindex
|
| 12 |
+
ports:
|
| 13 |
+
- "5432:5432"
|
| 14 |
+
volumes:
|
| 15 |
+
- postgres_data:/var/lib/postgresql/data
|
| 16 |
+
healthcheck:
|
| 17 |
+
test: ["CMD-SHELL", "pg_isready -U postgres -d rag_pageindex"]
|
| 18 |
+
interval: 5s
|
| 19 |
+
timeout: 5s
|
| 20 |
+
retries: 5
|
| 21 |
+
|
| 22 |
+
backend:
|
| 23 |
+
build: .
|
| 24 |
+
container_name: rag_backend
|
| 25 |
+
restart: always
|
| 26 |
+
depends_on:
|
| 27 |
+
db:
|
| 28 |
+
condition: service_healthy
|
| 29 |
+
environment:
|
| 30 |
+
- DATABASE_URL=postgresql://postgres:postgres@db:5432/rag_pageindex
|
| 31 |
+
env_file:
|
| 32 |
+
- .env
|
| 33 |
+
ports:
|
| 34 |
+
- "7860:7860"
|
| 35 |
+
volumes:
|
| 36 |
+
- ./data:/app/data
|
| 37 |
+
|
| 38 |
+
volumes:
|
| 39 |
+
postgres_data:
|
pyproject.toml
CHANGED
|
@@ -10,6 +10,7 @@ dependencies = [
|
|
| 10 |
"huggingface-hub>=2.1.1",
|
| 11 |
"openai>=2.36.0",
|
| 12 |
"pageindex>=0.2.8",
|
|
|
|
| 13 |
"pymupdf>=1.27.2.3",
|
| 14 |
"python-dotenv>=1.2.2",
|
| 15 |
"python-multipart>=0.0.28",
|
|
|
|
| 10 |
"huggingface-hub>=2.1.1",
|
| 11 |
"openai>=2.36.0",
|
| 12 |
"pageindex>=0.2.8",
|
| 13 |
+
"psycopg2-binary>=2.9.13",
|
| 14 |
"pymupdf>=1.27.2.3",
|
| 15 |
"python-dotenv>=1.2.2",
|
| 16 |
"python-multipart>=0.0.28",
|
requirements.txt
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file was autogenerated by uv via the following command:
|
| 2 |
+
# uv pip compile pyproject.toml -o requirements.txt
|
| 3 |
+
aiohappyeyeballs==2.7.1
|
| 4 |
+
# via aiohttp
|
| 5 |
+
aiohttp==3.14.5
|
| 6 |
+
# via edge-tts
|
| 7 |
+
aiosignal==1.4.0
|
| 8 |
+
# via aiohttp
|
| 9 |
+
annotated-doc==0.0.5
|
| 10 |
+
# via fastapi
|
| 11 |
+
annotated-types==0.8.0
|
| 12 |
+
# via pydantic
|
| 13 |
+
anyio==4.15.1
|
| 14 |
+
# via
|
| 15 |
+
# httpx2
|
| 16 |
+
# openai
|
| 17 |
+
# starlette
|
| 18 |
+
attrs==26.1.0
|
| 19 |
+
# via aiohttp
|
| 20 |
+
certifi==2026.7.22
|
| 21 |
+
# via
|
| 22 |
+
# edge-tts
|
| 23 |
+
# requests
|
| 24 |
+
charset-normalizer==3.5.2
|
| 25 |
+
# via requests
|
| 26 |
+
click==8.5.0
|
| 27 |
+
# via
|
| 28 |
+
# huggingface-hub
|
| 29 |
+
# uvicorn
|
| 30 |
+
edge-tts==7.3.1
|
| 31 |
+
# via pageindex-rag (pyproject.toml)
|
| 32 |
+
fastapi==0.143.3
|
| 33 |
+
# via pageindex-rag (pyproject.toml)
|
| 34 |
+
filelock==4.2.1
|
| 35 |
+
# via huggingface-hub
|
| 36 |
+
frozenlist==1.8.0
|
| 37 |
+
# via
|
| 38 |
+
# aiohttp
|
| 39 |
+
# aiosignal
|
| 40 |
+
fsspec==2026.9.0
|
| 41 |
+
# via huggingface-hub
|
| 42 |
+
h11==0.16.0
|
| 43 |
+
# via
|
| 44 |
+
# httpcore2
|
| 45 |
+
# uvicorn
|
| 46 |
+
hf-xet==1.7.0
|
| 47 |
+
# via huggingface-hub
|
| 48 |
+
httpcore2==2.13.1
|
| 49 |
+
# via httpx2
|
| 50 |
+
httpx2==2.13.1
|
| 51 |
+
# via
|
| 52 |
+
# huggingface-hub
|
| 53 |
+
# openai
|
| 54 |
+
huggingface-hub==2.2.0
|
| 55 |
+
# via pageindex-rag (pyproject.toml)
|
| 56 |
+
idna==3.20
|
| 57 |
+
# via
|
| 58 |
+
# anyio
|
| 59 |
+
# httpx2
|
| 60 |
+
# requests
|
| 61 |
+
# yarl
|
| 62 |
+
jiter==0.17.0
|
| 63 |
+
# via openai
|
| 64 |
+
multidict==7.1.0
|
| 65 |
+
# via
|
| 66 |
+
# aiohttp
|
| 67 |
+
# yarl
|
| 68 |
+
openai==3.34.0
|
| 69 |
+
# via
|
| 70 |
+
# pageindex-rag (pyproject.toml)
|
| 71 |
+
# pageindex
|
| 72 |
+
opentelemetry-api==1.45.1
|
| 73 |
+
# via fastapi
|
| 74 |
+
packaging==26.3
|
| 75 |
+
# via huggingface-hub
|
| 76 |
+
pageindex==0.2.8.1
|
| 77 |
+
# via pageindex-rag (pyproject.toml)
|
| 78 |
+
propcache==0.5.4
|
| 79 |
+
# via
|
| 80 |
+
# aiohttp
|
| 81 |
+
# yarl
|
| 82 |
+
psycopg2-binary==2.9.13
|
| 83 |
+
# via pageindex-rag (pyproject.toml)
|
| 84 |
+
pydantic==2.14.1
|
| 85 |
+
# via
|
| 86 |
+
# fastapi
|
| 87 |
+
# openai
|
| 88 |
+
pydantic-core==2.50.1
|
| 89 |
+
# via pydantic
|
| 90 |
+
pymupdf==1.28.2
|
| 91 |
+
# via pageindex-rag (pyproject.toml)
|
| 92 |
+
python-dotenv==1.2.4
|
| 93 |
+
# via pageindex-rag (pyproject.toml)
|
| 94 |
+
python-multipart==0.0.32
|
| 95 |
+
# via pageindex-rag (pyproject.toml)
|
| 96 |
+
pyyaml==6.0.3
|
| 97 |
+
# via huggingface-hub
|
| 98 |
+
requests==2.34.2
|
| 99 |
+
# via pageindex
|
| 100 |
+
sniffio==1.3.1
|
| 101 |
+
# via openai
|
| 102 |
+
starlette==1.8.0
|
| 103 |
+
# via fastapi
|
| 104 |
+
tabulate==0.10.0
|
| 105 |
+
# via edge-tts
|
| 106 |
+
tqdm==4.70.1
|
| 107 |
+
# via huggingface-hub
|
| 108 |
+
truststore==0.10.5
|
| 109 |
+
# via
|
| 110 |
+
# httpcore2
|
| 111 |
+
# httpx2
|
| 112 |
+
typing-extensions==4.16.0
|
| 113 |
+
# via
|
| 114 |
+
# aiohttp
|
| 115 |
+
# aiosignal
|
| 116 |
+
# anyio
|
| 117 |
+
# edge-tts
|
| 118 |
+
# fastapi
|
| 119 |
+
# httpx2
|
| 120 |
+
# huggingface-hub
|
| 121 |
+
# openai
|
| 122 |
+
# opentelemetry-api
|
| 123 |
+
# pydantic
|
| 124 |
+
# pydantic-core
|
| 125 |
+
# starlette
|
| 126 |
+
# typing-inspection
|
| 127 |
+
typing-inspection==0.4.4
|
| 128 |
+
# via
|
| 129 |
+
# fastapi
|
| 130 |
+
# pydantic
|
| 131 |
+
urllib3==2.8.0
|
| 132 |
+
# via requests
|
| 133 |
+
uvicorn==0.54.0
|
| 134 |
+
# via pageindex-rag (pyproject.toml)
|
| 135 |
+
yarl==1.25.1
|
| 136 |
+
# via aiohttp
|
test_postgres_persistence.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
|
| 4 |
+
# Ensure app is in path
|
| 5 |
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "app"))
|
| 6 |
+
|
| 7 |
+
from dotenv import load_dotenv
|
| 8 |
+
load_dotenv()
|
| 9 |
+
|
| 10 |
+
from core.persistent_db import persistent_db
|
| 11 |
+
from core.postgres_db import PostgresDB
|
| 12 |
+
from core.storage import MemoryStorage
|
| 13 |
+
|
| 14 |
+
def test_persistence():
|
| 15 |
+
print("Testing PersistentDB connection...")
|
| 16 |
+
print(f"PersistentDB active instance: {type(persistent_db).__name__}")
|
| 17 |
+
assert isinstance(persistent_db, PostgresDB), "Expected PostgresDB to be active!"
|
| 18 |
+
|
| 19 |
+
# 1. Test Chat & Message CRUD
|
| 20 |
+
chat_id = "test-chat-pg-1"
|
| 21 |
+
persistent_db.create_chat(chat_id=chat_id, title="Test PostgreSQL Chat")
|
| 22 |
+
chat = persistent_db.get_chat(chat_id)
|
| 23 |
+
assert chat is not None, "Failed to get chat"
|
| 24 |
+
assert chat["title"] == "Test PostgreSQL Chat"
|
| 25 |
+
|
| 26 |
+
persistent_db.add_message(chat_id, "user", "Hello Postgres!")
|
| 27 |
+
persistent_db.add_message(chat_id, "assistant", "Hello! I remember everything.")
|
| 28 |
+
msgs = persistent_db.get_messages(chat_id)
|
| 29 |
+
assert len(msgs) == 2, f"Expected 2 messages, got {len(msgs)}"
|
| 30 |
+
assert msgs[0]["content"] == "Hello Postgres!"
|
| 31 |
+
print("✓ Chat & Messages working!")
|
| 32 |
+
|
| 33 |
+
# 2. Test Document Meta & Index Storage
|
| 34 |
+
doc_id = "test-doc-pg-1"
|
| 35 |
+
filename = "report_2026.pdf"
|
| 36 |
+
content_hash = "abcdef1234567890"
|
| 37 |
+
|
| 38 |
+
persistent_db.upsert_doc_meta(
|
| 39 |
+
doc_id=doc_id,
|
| 40 |
+
filename=filename,
|
| 41 |
+
status="ready",
|
| 42 |
+
page_count=15,
|
| 43 |
+
section_count=4,
|
| 44 |
+
progress=100,
|
| 45 |
+
current_stage="Ready",
|
| 46 |
+
indexing_method="pageindex",
|
| 47 |
+
duration=3.5,
|
| 48 |
+
content_hash=content_hash,
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
test_tree = [
|
| 52 |
+
{"title": "Executive Summary", "page_index": 1, "children": []},
|
| 53 |
+
{"title": "Financial Results", "page_index": 5, "children": [
|
| 54 |
+
{"title": "Q1 Revenue", "page_index": 6, "children": []}
|
| 55 |
+
]}
|
| 56 |
+
]
|
| 57 |
+
test_node_map = {"node_1": {"title": "Executive Summary", "page": 1}}
|
| 58 |
+
test_sections = [{"title": "Executive Summary", "content": "Summary text", "page": 1}]
|
| 59 |
+
|
| 60 |
+
persistent_db.save_doc_index(
|
| 61 |
+
doc_id=doc_id,
|
| 62 |
+
content_hash=content_hash,
|
| 63 |
+
tree=test_tree,
|
| 64 |
+
node_map=test_node_map,
|
| 65 |
+
flat_sections=test_sections,
|
| 66 |
+
file_base64="dGVzdCBwZGYgY29udGVudA==",
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
# Read back index
|
| 70 |
+
idx = persistent_db.get_doc_index(doc_id)
|
| 71 |
+
assert idx is not None, "Failed to read doc index"
|
| 72 |
+
assert idx["tree"] == test_tree, "Tree does not match!"
|
| 73 |
+
assert idx["node_map"] == test_node_map, "Node map does not match!"
|
| 74 |
+
assert idx["flat_sections"] == test_sections, "Flat sections do not match!"
|
| 75 |
+
print("✓ Document Index (JSONB) storage working!")
|
| 76 |
+
|
| 77 |
+
# 3. Test Chat Documents
|
| 78 |
+
persistent_db.add_chat_document(chat_id, doc_id, filename)
|
| 79 |
+
attached = persistent_db.get_chat_documents(chat_id)
|
| 80 |
+
assert len(attached) == 1, f"Expected 1 attached doc, got {len(attached)}"
|
| 81 |
+
assert attached[0]["doc_id"] == doc_id
|
| 82 |
+
print("✓ Chat Documents working!")
|
| 83 |
+
|
| 84 |
+
# 4. Test MemoryStorage Integration with Simulated Cache Wipe
|
| 85 |
+
storage = MemoryStorage()
|
| 86 |
+
# Notice: _trees and _documents in storage are currently empty!
|
| 87 |
+
# get_document should fetch from PostgreSQL, restore tree and sections!
|
| 88 |
+
restored_doc = storage.get_document(doc_id)
|
| 89 |
+
assert restored_doc is not None, "Failed to get document via storage"
|
| 90 |
+
assert restored_doc["status"] == "ready"
|
| 91 |
+
assert restored_doc["tree"] == test_tree, "Restored tree does not match!"
|
| 92 |
+
assert storage.get_node_map(doc_id) == test_node_map, "Restored node map does not match!"
|
| 93 |
+
print("✓ MemoryStorage seamlessly loaded wiped document from PostgreSQL!")
|
| 94 |
+
|
| 95 |
+
# Cleanup test data
|
| 96 |
+
persistent_db.delete_chat(chat_id)
|
| 97 |
+
persistent_db.delete_doc(doc_id)
|
| 98 |
+
print("✓ All tests passed successfully!")
|
| 99 |
+
|
| 100 |
+
if __name__ == "__main__":
|
| 101 |
+
test_persistence()
|
uv.lock
CHANGED
|
@@ -935,6 +935,7 @@ dependencies = [
|
|
| 935 |
{ name = "huggingface-hub" },
|
| 936 |
{ name = "openai" },
|
| 937 |
{ name = "pageindex" },
|
|
|
|
| 938 |
{ name = "pymupdf" },
|
| 939 |
{ name = "python-dotenv" },
|
| 940 |
{ name = "python-multipart" },
|
|
@@ -948,6 +949,7 @@ requires-dist = [
|
|
| 948 |
{ name = "huggingface-hub", specifier = ">=2.1.1" },
|
| 949 |
{ name = "openai", specifier = ">=2.36.0" },
|
| 950 |
{ name = "pageindex", specifier = ">=0.2.8" },
|
|
|
|
| 951 |
{ name = "pymupdf", specifier = ">=1.27.2.3" },
|
| 952 |
{ name = "python-dotenv", specifier = ">=1.2.2" },
|
| 953 |
{ name = "python-multipart", specifier = ">=0.0.28" },
|
|
@@ -1082,6 +1084,69 @@ wheels = [
|
|
| 1082 |
{ url = "https://files.pythonhosted.org/packages/f5/cd/785c64ed382f3f04201870267b02783f63b4678c2acfddc177a3ebcc2727/propcache-0.5.4-py3-none-any.whl", hash = "sha256:62c60aec739ed00124573cce1178138fd690c7676352d67a37328c1cf51d7468", size = 16338, upload-time = "2026-09-16T00:17:13.106Z" },
|
| 1083 |
]
|
| 1084 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1085 |
[[package]]
|
| 1086 |
name = "pydantic"
|
| 1087 |
version = "2.13.4"
|
|
|
|
| 935 |
{ name = "huggingface-hub" },
|
| 936 |
{ name = "openai" },
|
| 937 |
{ name = "pageindex" },
|
| 938 |
+
{ name = "psycopg2-binary" },
|
| 939 |
{ name = "pymupdf" },
|
| 940 |
{ name = "python-dotenv" },
|
| 941 |
{ name = "python-multipart" },
|
|
|
|
| 949 |
{ name = "huggingface-hub", specifier = ">=2.1.1" },
|
| 950 |
{ name = "openai", specifier = ">=2.36.0" },
|
| 951 |
{ name = "pageindex", specifier = ">=0.2.8" },
|
| 952 |
+
{ name = "psycopg2-binary", specifier = ">=2.9.13" },
|
| 953 |
{ name = "pymupdf", specifier = ">=1.27.2.3" },
|
| 954 |
{ name = "python-dotenv", specifier = ">=1.2.2" },
|
| 955 |
{ name = "python-multipart", specifier = ">=0.0.28" },
|
|
|
|
| 1084 |
{ url = "https://files.pythonhosted.org/packages/f5/cd/785c64ed382f3f04201870267b02783f63b4678c2acfddc177a3ebcc2727/propcache-0.5.4-py3-none-any.whl", hash = "sha256:62c60aec739ed00124573cce1178138fd690c7676352d67a37328c1cf51d7468", size = 16338, upload-time = "2026-09-16T00:17:13.106Z" },
|
| 1085 |
]
|
| 1086 |
|
| 1087 |
+
[[package]]
|
| 1088 |
+
name = "psycopg2-binary"
|
| 1089 |
+
version = "2.9.13"
|
| 1090 |
+
source = { registry = "https://pypi.org/simple" }
|
| 1091 |
+
sdist = { url = "https://files.pythonhosted.org/packages/ed/76/7b4383014be0fcc6c1c0e24292845a14e1672cf17fca62ca0a2bd5f4563d/psycopg2_binary-2.9.13.tar.gz", hash = "sha256:e324ecf60f952d21dd11413b8bbed0951bbd99579a06fd06f28bfc37737cd373", size = 378112, upload-time = "2026-09-10T00:06:12.199Z" }
|
| 1092 |
+
wheels = [
|
| 1093 |
+
{ url = "https://files.pythonhosted.org/packages/7b/03/639c96ff8ffb933868252308a9917ff4170d8c7f4bd16cd0ea2536814126/psycopg2_binary-2.9.13-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d19aec88857d2a52f99eefcefdbbb45921fb2f777bee5186a355a23d9cf8a0b9", size = 3725651, upload-time = "2026-09-09T23:54:29.277Z" },
|
| 1094 |
+
{ url = "https://files.pythonhosted.org/packages/53/5e/d50eb688e7e6dfd1499e68cf2d02f44a348b88f381cbc2bebeed15e345f4/psycopg2_binary-2.9.13-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:32cd049095135d2b69e824aea9056745a4aaaa9115a9febbc65584793665d0d0", size = 3817384, upload-time = "2026-09-09T23:54:31.118Z" },
|
| 1095 |
+
{ url = "https://files.pythonhosted.org/packages/6c/f3/4004cfbbfc52b9b13ffd499f2103eb05246e92828c7237d2c198e028b95c/psycopg2_binary-2.9.13-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e696297891b56ff0115f0665de6ad774e1e301e4f60745b8d5024001ae7c2f6", size = 4585497, upload-time = "2026-09-09T23:54:33.169Z" },
|
| 1096 |
+
{ url = "https://files.pythonhosted.org/packages/97/63/057c65532bd12cdf9d4f568e59c2a078a38e9ba8f7f251292968dc781905/psycopg2_binary-2.9.13-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:930e7e58b33a4f9c39e7532d7a40147925cf3372baed4229cbebe0cf3ba9ce6b", size = 4281172, upload-time = "2026-09-09T23:54:35.747Z" },
|
| 1097 |
+
{ url = "https://files.pythonhosted.org/packages/43/4b/9fd928eaea9ec1e8d74fed83c9e82826f830506ba0d8c58a8fd41ca93656/psycopg2_binary-2.9.13-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3aea95340825f5ff236e7b40f0b5602c2c77a1e95943f71fae34909834043d29", size = 5900973, upload-time = "2026-09-09T23:54:37.866Z" },
|
| 1098 |
+
{ url = "https://files.pythonhosted.org/packages/f8/2b/59e1519a22622169e2244f12227b3acde6114ea531a349292f455ab8503f/psycopg2_binary-2.9.13-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:27e539b4cafd5e03dcd32921db1b12dd72fe549dd06bae6d4d2a5b5838465f24", size = 4118611, upload-time = "2026-09-09T23:54:39.739Z" },
|
| 1099 |
+
{ url = "https://files.pythonhosted.org/packages/87/c7/c3d84e330d1584efa0560b756914f9128b1b6fa2aba93fde54bf101d4f65/psycopg2_binary-2.9.13-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a6444ac48e2c04f691c2ddd542b38ba30c89463a2d446b3d74ec7d8fc90c964", size = 3659643, upload-time = "2026-09-09T23:54:41.882Z" },
|
| 1100 |
+
{ url = "https://files.pythonhosted.org/packages/af/fc/317d248503aa29a5051ee49a7253daffe1c57e43ba174020e00bbd87c479/psycopg2_binary-2.9.13-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8cb734989420c18ca1b71a82da880e11988f5ff3fcdaadd669161de3e98794ac", size = 3305145, upload-time = "2026-09-09T23:54:43.818Z" },
|
| 1101 |
+
{ url = "https://files.pythonhosted.org/packages/9d/d2/8b23c57591c6d29a463748ffc634401ce14719dba7e78ab91bcbeac70934/psycopg2_binary-2.9.13-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f47f23db2d70db39cfb714b64fd5df76595b51b2ec0a669710a78f2dceb0c3f8", size = 3051614, upload-time = "2026-09-09T23:54:45.548Z" },
|
| 1102 |
+
{ url = "https://files.pythonhosted.org/packages/4f/f2/b10a046cc19ab1e01b92226eeb6e8653be7e6691939991b2f347995831b1/psycopg2_binary-2.9.13-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f28b5f2fa8154d0d97e97a664136f58d1639ca008d45d6e09e69fff24826abee", size = 3353133, upload-time = "2026-09-09T23:54:47.285Z" },
|
| 1103 |
+
{ url = "https://files.pythonhosted.org/packages/40/2c/dd379facaa4bd41d7b04711ff30931ca62981c00a91502250ffb49da08a7/psycopg2_binary-2.9.13-cp311-cp311-win_amd64.whl", hash = "sha256:70d091f5c3a6177fac50c0da20181ce0e0c053f1e43c872d5f75bd6d9429c020", size = 2771441, upload-time = "2026-09-09T23:54:49.016Z" },
|
| 1104 |
+
{ url = "https://files.pythonhosted.org/packages/fb/d1/d0125c56b865e3bc9f318d84930b2df71a729229dbb0ce12de748a82a6d7/psycopg2_binary-2.9.13-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2bf9f97a6df69a5d89d054b8cf5257a0916096c479800715fbfe7974dbcb3a26", size = 3725735, upload-time = "2026-09-09T23:54:51.182Z" },
|
| 1105 |
+
{ url = "https://files.pythonhosted.org/packages/54/a5/b5a73d0910555e38ee12c49c1740855f8a1e9776e87d65f0c51e1bab762a/psycopg2_binary-2.9.13-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:07b7bd9f410650c34c3532162cc329f112368d78a3fc8668cb1ea9df61bc11bf", size = 3818000, upload-time = "2026-09-09T23:54:53.229Z" },
|
| 1106 |
+
{ url = "https://files.pythonhosted.org/packages/3d/43/3e4783f62ae3f4fc19a5acf8d1c394df54458f1336febe188f317556d2a7/psycopg2_binary-2.9.13-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0463c00f946517f3e69192a59e6601e023ff9de45ad0a875eda3d6b1bebeb7ce", size = 4585760, upload-time = "2026-09-09T23:54:55.313Z" },
|
| 1107 |
+
{ url = "https://files.pythonhosted.org/packages/8d/c4/a9a67ae65ad3d567eb0fc9cdf9a5a2783b779aecdcdc8945f1807b13d99e/psycopg2_binary-2.9.13-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e3861eba31f8ea8663fd876166b032fd89179e42aa63764d6feb281f13f9eb60", size = 4282380, upload-time = "2026-09-09T23:54:57.362Z" },
|
| 1108 |
+
{ url = "https://files.pythonhosted.org/packages/b3/db/9d459d3da12e0b841cf1596579455aaa27e9e593e3e9a5a4ded5a55a7c15/psycopg2_binary-2.9.13-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3dc3372b3731b3ef23407fe06b94f640ef87a2bda242fa386033d5589c87514a", size = 5902039, upload-time = "2026-09-09T23:55:01.955Z" },
|
| 1109 |
+
{ url = "https://files.pythonhosted.org/packages/d6/53/21079c10a581c50b6817498eda7c3481c1b3cdb41482bd08ebfccd3664c4/psycopg2_binary-2.9.13-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0405dd4d97720e7ab177aa02e493f524907c4cb3c445ac173e2627948d3d0528", size = 4119840, upload-time = "2026-09-09T23:55:04.336Z" },
|
| 1110 |
+
{ url = "https://files.pythonhosted.org/packages/c4/ce/71e8d9e1b4f3e78157b49a5abdff50d915e95f2812550f6c9b4f2e4d5e94/psycopg2_binary-2.9.13-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b6ae51708201f501a171b02419d0c30878a743c369c9054eb1289f0f8d5979e2", size = 3661077, upload-time = "2026-09-09T23:55:06.118Z" },
|
| 1111 |
+
{ url = "https://files.pythonhosted.org/packages/d9/54/b17616472f09a0fae96f8852692948b7eaa7c971d7629696da0e5932d996/psycopg2_binary-2.9.13-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:81682c227cc1849c4a6adf7b85274229073bb4c9d6ad5697222c695dcea5a8a7", size = 3307202, upload-time = "2026-09-09T23:55:08.061Z" },
|
| 1112 |
+
{ url = "https://files.pythonhosted.org/packages/77/c7/d9737e222a377dac67a0ce0a2c73e7231a57f5cf18bb35a65d5c8d45d5d2/psycopg2_binary-2.9.13-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:13d955f6054a705a19554364fe9888d0a6e8b0746dc7ebc08a447c7b4fd4145c", size = 3052893, upload-time = "2026-09-09T23:55:10.209Z" },
|
| 1113 |
+
{ url = "https://files.pythonhosted.org/packages/7d/3d/c406c9f698f518c264381192c2bdf8952ee84e469ffa9f82db1411f57385/psycopg2_binary-2.9.13-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7e2405196a8cfe6cd3e54172a54452dcf85c241eaf2e9dde7190d7469f7f5ef7", size = 3355210, upload-time = "2026-09-09T23:55:11.883Z" },
|
| 1114 |
+
{ url = "https://files.pythonhosted.org/packages/27/64/6e3a96699770af2d0d49a2002f722c69b656fc27623ff89281cf2b109644/psycopg2_binary-2.9.13-cp312-cp312-win_amd64.whl", hash = "sha256:376ebf7d8aee4b7386b2bac31fdc27911e7e57cd0a88f1e038b8b149398ac008", size = 2767784, upload-time = "2026-09-09T23:55:13.823Z" },
|
| 1115 |
+
{ url = "https://files.pythonhosted.org/packages/82/0a/795f2869788373cf7d08410341a444196e8ccebbac07a70a8f9a1f60e72f/psycopg2_binary-2.9.13-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4d66bfd44a46eb88cff0287929a4193fb45166b6c1f84bb1b233cc17ece0813c", size = 3725723, upload-time = "2026-09-09T23:55:15.887Z" },
|
| 1116 |
+
{ url = "https://files.pythonhosted.org/packages/b5/63/5a9633f4563a73beba69b20a846ddd14c1c6ac072f5e8aab0da97ffabc2a/psycopg2_binary-2.9.13-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f818161d2302b3b3e9c75d5a1d0a5c5679e92e45cfec6432b9d5432dde5ff1f1", size = 3817976, upload-time = "2026-09-09T23:55:18.025Z" },
|
| 1117 |
+
{ url = "https://files.pythonhosted.org/packages/6c/e2/b2e3b3a4331dc8b58e328cda30f3d0cc43a94b7aaf0c8383efd53dd10e95/psycopg2_binary-2.9.13-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:31db6cba66df5231dfd91d9f69188bec3fe6c8baae384e93a0ce792067ee2d98", size = 4585813, upload-time = "2026-09-09T23:55:20.112Z" },
|
| 1118 |
+
{ url = "https://files.pythonhosted.org/packages/56/5c/87daea77c4132114d1a5da3a4928dd59446c3b3cc73d288cae08cf0b91a6/psycopg2_binary-2.9.13-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f04ada42bcd537adbaf8b7f3140237a204e452a88d0c1831cfce69f7d2e59f4e", size = 4282438, upload-time = "2026-09-09T23:55:22.329Z" },
|
| 1119 |
+
{ url = "https://files.pythonhosted.org/packages/91/e5/56f9efdc9337acbd1a75798d97163183b63a1babc17602f7163009506c96/psycopg2_binary-2.9.13-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa37089795bd9701576edc2eb5849ce77a439eda9dfdfa47857449332cfa5292", size = 5902064, upload-time = "2026-09-09T23:55:24.37Z" },
|
| 1120 |
+
{ url = "https://files.pythonhosted.org/packages/e4/15/f7ed0b90b47b73a9087306b42267eccfd919f92c0fb057e46bd2fa2efa4d/psycopg2_binary-2.9.13-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:41c2eb569ebd0e1b02d30d361a46932923b193fe1b5e641fb4d547c75e218955", size = 4119848, upload-time = "2026-09-09T23:55:26.433Z" },
|
| 1121 |
+
{ url = "https://files.pythonhosted.org/packages/42/08/3091347b9fc5766e979aba6b0756ad14ce867a6bb245f3d69ac71fb768c6/psycopg2_binary-2.9.13-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f699a5225094a5c61402984e2fc1eca20e940223e76767c88189efb0c313f69", size = 3661129, upload-time = "2026-09-09T23:55:28.449Z" },
|
| 1122 |
+
{ url = "https://files.pythonhosted.org/packages/34/c4/4f9a84d55484c9794b364548eb6e1fe10a57f123afd19729e5a1cc8ad7fc/psycopg2_binary-2.9.13-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5f04ae99c9fbb94c3197ec88599ed7db921f6adcddfe83687a74c7ead4037c22", size = 3307262, upload-time = "2026-09-09T23:55:30.384Z" },
|
| 1123 |
+
{ url = "https://files.pythonhosted.org/packages/83/42/6eba8306a61dc890805ae475a9e71790a1c5461ccacbd4f0a1f3f57b40f0/psycopg2_binary-2.9.13-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:81404c37e0344ebcf10aac127d33d35137e5dbab1daf9f3deee46188fd5879c2", size = 3052898, upload-time = "2026-09-09T23:55:32.961Z" },
|
| 1124 |
+
{ url = "https://files.pythonhosted.org/packages/b3/5d/42a8935ab280e8dcd7c07a655c0c3d25d62e9e242be1961ac14630f1294a/psycopg2_binary-2.9.13-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:feb7b1856f6ca805cc0e08739858f6cdfed8ce903390126af30343c62899a389", size = 3355265, upload-time = "2026-09-09T23:55:35.071Z" },
|
| 1125 |
+
{ url = "https://files.pythonhosted.org/packages/87/c2/0e0ffb4caeb651631cbc6c8ead83e2a16457750b1d2eb7f5ef111c1f4d36/psycopg2_binary-2.9.13-cp313-cp313-win_amd64.whl", hash = "sha256:691da68ae5dd7c3ac77514357d35ece7b1ba8b5f3e6c92735198aa6159c355c8", size = 2767914, upload-time = "2026-09-09T23:55:37.14Z" },
|
| 1126 |
+
{ url = "https://files.pythonhosted.org/packages/5f/32/897c074cb99fbdda7d34b0a2546097a59162bb3d04c0d546ae4ec82345e3/psycopg2_binary-2.9.13-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2ca263643ae37998ae04d18e431df34d0d61f12b47640dab585f14b6dbe00798", size = 3725788, upload-time = "2026-09-09T23:55:39.04Z" },
|
| 1127 |
+
{ url = "https://files.pythonhosted.org/packages/0f/f4/e3a789de34c9ac25d20b25c2be583da16394a2ba0926da1c863653831f41/psycopg2_binary-2.9.13-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c0214c7da18a28d108aa7108c8a3cca8035c7911ec97ef9ec0827569c9a2720", size = 3818111, upload-time = "2026-09-09T23:55:40.979Z" },
|
| 1128 |
+
{ url = "https://files.pythonhosted.org/packages/72/29/647724c43ac510dbc59b80e20e85d439deb94f5d5a024153c32330fa041d/psycopg2_binary-2.9.13-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5d89e064bb12b40cad696cf4975e6da86f8c60f14cd06cb6c1bc0a7f5d01761f", size = 4586235, upload-time = "2026-09-09T23:55:43.012Z" },
|
| 1129 |
+
{ url = "https://files.pythonhosted.org/packages/91/ad/7f52f92cc65c23778daff7eec4ee2099236694a0a4723a5f180d0708b607/psycopg2_binary-2.9.13-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:190c18b97d9ef72f2e88c451b6588af90d6bd7bf54cb94b963280dc86a2c7076", size = 4282424, upload-time = "2026-09-09T23:55:44.843Z" },
|
| 1130 |
+
{ url = "https://files.pythonhosted.org/packages/3d/2a/1a472059b198942d99651656e2bc610575584478bfe68d297ecabbd4887f/psycopg2_binary-2.9.13-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c00ebe9a2f31151aade0db233dc1446513a95e92c39ce055ee097af0ae86be1c", size = 5902365, upload-time = "2026-09-09T23:55:46.619Z" },
|
| 1131 |
+
{ url = "https://files.pythonhosted.org/packages/91/1a/171ea5dac7b3a0fa57b3cb59c2ad6d7b8bc60732368fecfd2ed1f1288392/psycopg2_binary-2.9.13-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5085f7ff7b1e890f279577cedeb8c628957869a340fa34a39f7f406500b3c916", size = 4119938, upload-time = "2026-09-09T23:55:49.381Z" },
|
| 1132 |
+
{ url = "https://files.pythonhosted.org/packages/41/ce/3c6d4ad71853a59eee6a575fe36df4bb40752a9735a27bd62af66b454ed5/psycopg2_binary-2.9.13-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4e55357d1943673d491bbabb171c891704fc6a22441fea539e05a5c27a79ea3c", size = 3661557, upload-time = "2026-09-09T23:55:51.269Z" },
|
| 1133 |
+
{ url = "https://files.pythonhosted.org/packages/10/a3/1819a01bf951eab2afb5ca2a3d11f50500bf536fecff088154372a8d1985/psycopg2_binary-2.9.13-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3e60b06ec7f9dc3e5f1106d12706514b6d6b92c3dc438fcdf4e43e65cc660d1b", size = 3307770, upload-time = "2026-09-09T23:55:53.196Z" },
|
| 1134 |
+
{ url = "https://files.pythonhosted.org/packages/4e/df/22f4aec952cd5b2dd02f438399583ed69f7d04b90e7c31659d9571bbe188/psycopg2_binary-2.9.13-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dde942b46ce20f6c4464cdf551f3293207f803f4e4354454eb1f5599c3eb1fa1", size = 3052935, upload-time = "2026-09-09T23:55:55.117Z" },
|
| 1135 |
+
{ url = "https://files.pythonhosted.org/packages/95/42/aab651bc22bafa961806ca3b21027bb0739a2730b0e6f7f0778baeb95e67/psycopg2_binary-2.9.13-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:215777c62ce81c3b487cefdb6a41969944eb982309f91349ff3ca0323d6f17ed", size = 3355387, upload-time = "2026-09-09T23:55:57.366Z" },
|
| 1136 |
+
{ url = "https://files.pythonhosted.org/packages/bc/af/3b8220633eaf955e95ea7be67d76e81a0d1cd3c76362ea504b91ffa079db/psycopg2_binary-2.9.13-cp314-cp314-win_amd64.whl", hash = "sha256:f3088eb80f58ed933c62d87128741d31e786edc862e23266d3c286763d646de0", size = 2863890, upload-time = "2026-09-09T23:55:59.056Z" },
|
| 1137 |
+
{ url = "https://files.pythonhosted.org/packages/6e/f1/377d17fc8425220d17552691cd2b97aa232da92173f5dead71278b83f8ab/psycopg2_binary-2.9.13-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:38397def2d794ffde9db80f63d6820253e61b17483112652a318355f51a56f50", size = 3725913, upload-time = "2026-09-09T23:56:00.736Z" },
|
| 1138 |
+
{ url = "https://files.pythonhosted.org/packages/67/64/27208e67cd6e663f69bf7bf905cf69db066a015c90ac9ca948a56a8e9d78/psycopg2_binary-2.9.13-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:dff5c70ed9789ccb0d97ff4a7da51dc523a255c4ec95df188fa5d44adcae4ea8", size = 3818303, upload-time = "2026-09-09T23:56:02.551Z" },
|
| 1139 |
+
{ url = "https://files.pythonhosted.org/packages/6b/98/67d2f34a1d18367b5f655bdd101759f8474286c74ffe701b7d6e3abd7fda/psycopg2_binary-2.9.13-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:08d3b81a6a91775c937abf97d4c58fc9142e8e35fb91c387d24f81d15c98e6cf", size = 4586508, upload-time = "2026-09-09T23:56:04.706Z" },
|
| 1140 |
+
{ url = "https://files.pythonhosted.org/packages/bb/47/46c227deaf322dceafa0b7b321b4e5de9cc797014b7a353349b2e09b1118/psycopg2_binary-2.9.13-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:541a487a9ccd72b5e38f37f27b0ce78cb7eb3e336e7b5277d45463010c03a7a8", size = 4282383, upload-time = "2026-09-09T23:56:06.678Z" },
|
| 1141 |
+
{ url = "https://files.pythonhosted.org/packages/f4/3c/e8705ffa381160d842eaf06a8446e8416f1a2497dd70a7e62277f3be6e7a/psycopg2_binary-2.9.13-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:562fe2a43b30e781848dce63d9080c15414c777c96df348c4342558338cc7bf3", size = 5902765, upload-time = "2026-09-09T23:56:08.634Z" },
|
| 1142 |
+
{ url = "https://files.pythonhosted.org/packages/53/cc/359821c18317228b8032456a3740c98045b719ed003a594b9ebac9330b86/psycopg2_binary-2.9.13-cp315-cp315-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dddfe650e7dda464d676c27fbedb5061f1ad05e1604627f54c770d7f799d36e9", size = 4122849, upload-time = "2026-09-09T23:56:10.671Z" },
|
| 1143 |
+
{ url = "https://files.pythonhosted.org/packages/17/e5/4d935acb6d3258c7a767b3d527e54c0b537649101b55002a5dbcfe747e2a/psycopg2_binary-2.9.13-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:4ff0f575cbb14f30445858dcfdd751e043486f5290915df78a9818bc74042eff", size = 3662084, upload-time = "2026-09-09T23:56:12.316Z" },
|
| 1144 |
+
{ url = "https://files.pythonhosted.org/packages/89/56/9e9bbc7c773c5de7bb25dd35d7f041c2a6f0fcfa9207a1ceaf01a1bc687c/psycopg2_binary-2.9.13-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d79530b4c1af657d5620a1d21b8e39f2996aa06821d5564d05b22d6b8cd413d0", size = 3308077, upload-time = "2026-09-09T23:56:15.262Z" },
|
| 1145 |
+
{ url = "https://files.pythonhosted.org/packages/36/fa/ed742cd4e5dbddcb44702f9c4a97f7f5b62d97e3d9d00907ecc8ac750ef4/psycopg2_binary-2.9.13-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:6ede8595767e19d30a7e8a84a7d47bfde6176d45d194fed08dbb68d1584a780b", size = 3055143, upload-time = "2026-09-09T23:56:17.168Z" },
|
| 1146 |
+
{ url = "https://files.pythonhosted.org/packages/d5/3a/5c2cb71a844ee236be2ce91b286d797e34a21489909357c7cfba0f5c0197/psycopg2_binary-2.9.13-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:0ebcf3c4266a695df9d0ef51296155f60c86ac51cf82f0d0dd2e827255a891c5", size = 3355374, upload-time = "2026-09-09T23:56:18.793Z" },
|
| 1147 |
+
{ url = "https://files.pythonhosted.org/packages/e8/30/3991c9fdcca90a5a1e55435292f4d74d176da2be15f3998f6858da3658cc/psycopg2_binary-2.9.13-cp315-cp315-win_amd64.whl", hash = "sha256:1752b9821f1377404d65ac43af03d59a1eccc57fb2c1eb8305f9a3fe8eb7a8ba", size = 2867246, upload-time = "2026-09-09T23:56:20.501Z" },
|
| 1148 |
+
]
|
| 1149 |
+
|
| 1150 |
[[package]]
|
| 1151 |
name = "pydantic"
|
| 1152 |
version = "2.13.4"
|