AI Assistant commited on
Commit
8eaad14
·
1 Parent(s): a57411f

feat: integrate PostgreSQL persistence for documents, trees, and chat sessions

Browse files
.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
- HF_CHAT_MODEL=Qwen/Qwen2.5-72B-Instruct
 
 
 
 
 
 
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 pyproject.toml fastapi uvicorn python-multipart
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
- SQLite-backed persistence layer for long-term memory.
5
- Stores chat sessions, messages, and chat→document associations.
6
- The heavy document trees are kept on-disk in CACHE_DIR (JSON) –
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
- _DDL = """
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 PersistentDB:
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(_DDL)
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
- f"SELECT role, content FROM messages WHERE chat_id=? ORDER BY created_at DESC LIMIT ?",
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 = PersistentDB()
 
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
- pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 SQLite
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"