Coverage for src \ truenex_memory \ store \ sqlite.py: 93%
91 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-19 10:21 +0200
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-19 10:21 +0200
1"""SQLite schema management."""
3from __future__ import annotations
5from dataclasses import dataclass
6import json
7from pathlib import Path
8import sqlite3
9from typing import Any
12SCHEMA_VERSION = "4"
15@dataclass(frozen=True)
16class MemoryRecord:
17 """Simple record used by the compatibility SQLite memory store."""
19 id: int
20 text: str
21 metadata: dict[str, Any]
24def connect(db_path: Path) -> sqlite3.Connection:
25 """Open a SQLite connection with local-first defaults."""
27 db_path.parent.mkdir(parents=True, exist_ok=True)
28 conn = sqlite3.connect(db_path)
29 conn.row_factory = sqlite3.Row
30 conn.execute("PRAGMA foreign_keys = ON")
31 return conn
34def initialize_schema(conn: sqlite3.Connection) -> None:
35 """Create the v1 local schema if it does not already exist."""
37 conn.executescript(
38 """
39 CREATE TABLE IF NOT EXISTS documents (
40 id TEXT PRIMARY KEY,
41 project_id TEXT NOT NULL,
42 path TEXT NOT NULL,
43 filename TEXT NOT NULL,
44 content_hash TEXT NOT NULL,
45 last_indexed_at TEXT,
46 created_at TEXT NOT NULL,
47 updated_at TEXT NOT NULL
48 );
50 CREATE TABLE IF NOT EXISTS chunks (
51 id TEXT PRIMARY KEY,
52 document_id TEXT NOT NULL,
53 chunk_index INTEGER NOT NULL,
54 heading_path TEXT,
55 content TEXT NOT NULL,
56 content_hash TEXT NOT NULL,
57 token_count INTEGER NOT NULL DEFAULT 0,
58 qdrant_point_id TEXT,
59 embedding_model TEXT,
60 embedding_vector_json TEXT,
61 source_type TEXT,
62 created_at TEXT NOT NULL,
63 updated_at TEXT NOT NULL,
64 FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE
65 );
67 CREATE TABLE IF NOT EXISTS memory_nodes (
68 id TEXT PRIMARY KEY,
69 project_id TEXT NOT NULL,
70 type TEXT NOT NULL,
71 title TEXT NOT NULL,
72 content TEXT NOT NULL,
73 status TEXT NOT NULL,
74 source_kind TEXT NOT NULL,
75 source_document_id TEXT,
76 source_chunk_id TEXT,
77 source_path TEXT,
78 content_hash TEXT,
79 created_by TEXT NOT NULL,
80 model_name TEXT,
81 confidence REAL,
82 created_at TEXT NOT NULL,
83 updated_at TEXT NOT NULL
84 );
86 CREATE TABLE IF NOT EXISTS edges (
87 id TEXT PRIMARY KEY,
88 project_id TEXT NOT NULL,
89 source_node_id TEXT NOT NULL,
90 target_node_id TEXT NOT NULL,
91 relation_type TEXT NOT NULL,
92 created_by TEXT NOT NULL,
93 confidence REAL,
94 created_at TEXT NOT NULL
95 );
97 CREATE TABLE IF NOT EXISTS retrieval_logs (
98 id TEXT PRIMARY KEY,
99 project_id TEXT NOT NULL,
100 query TEXT NOT NULL,
101 top_k INTEGER NOT NULL,
102 result_count INTEGER NOT NULL,
103 results_json TEXT NOT NULL,
104 created_at TEXT NOT NULL
105 );
107 CREATE TABLE IF NOT EXISTS schema_migrations (
108 version TEXT PRIMARY KEY,
109 applied_at TEXT NOT NULL
110 );
112 CREATE TABLE IF NOT EXISTS memories (
113 id INTEGER PRIMARY KEY AUTOINCREMENT,
114 text TEXT NOT NULL,
115 metadata_json TEXT NOT NULL DEFAULT '{}',
116 created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
117 );
119 CREATE TABLE IF NOT EXISTS source_ledger (
120 source_id TEXT PRIMARY KEY,
121 source_path_or_alias TEXT NOT NULL,
122 project_name TEXT,
123 source_type TEXT NOT NULL,
124 parser_version TEXT NOT NULL DEFAULT '1',
125 content_hash TEXT,
126 last_modified_at TEXT,
127 last_indexed_at TEXT,
128 status TEXT NOT NULL DEFAULT 'pending'
129 CHECK(status IN ('active','pending','error','missing','skipped')),
130 error_message TEXT,
131 chunk_count INTEGER NOT NULL DEFAULT 0,
132 created_at TEXT NOT NULL,
133 updated_at TEXT NOT NULL
134 );
136 CREATE TABLE IF NOT EXISTS tasks (
137 task_id TEXT PRIMARY KEY,
138 title TEXT NOT NULL,
139 type TEXT NOT NULL CHECK(type IN ('bugfix','feature','refactor','review','query')),
140 project TEXT,
141 agent_session_id TEXT,
142 human_outcome INTEGER CHECK(human_outcome IN (1, 0, -1)),
143 human_comment TEXT,
144 total_tokens INTEGER,
145 total_duration_s REAL,
146 status TEXT NOT NULL DEFAULT 'open' CHECK(status IN ('open','closed','unrated')),
147 created_at TEXT NOT NULL,
148 closed_at TEXT
149 );
151 CREATE TABLE IF NOT EXISTS task_steps (
152 step_id TEXT PRIMARY KEY,
153 task_id TEXT NOT NULL,
154 step_index INTEGER NOT NULL,
155 prompt_used TEXT,
156 output TEXT,
157 brain_judgment TEXT CHECK(brain_judgment IN ('ok','needs_revision','rejected')),
158 tokens_used INTEGER,
159 duration_s REAL,
160 model_used TEXT,
161 created_at TEXT NOT NULL,
162 FOREIGN KEY(task_id) REFERENCES tasks(task_id) ON DELETE CASCADE
163 );
165 CREATE TABLE IF NOT EXISTS verifier_rounds (
166 round_id TEXT PRIMARY KEY,
167 task_id TEXT NOT NULL,
168 step_id TEXT,
169 suggestion_type TEXT NOT NULL,
170 brain_accepted INTEGER NOT NULL CHECK(brain_accepted IN (0, 1)),
171 brain_rationale TEXT,
172 created_at TEXT NOT NULL,
173 FOREIGN KEY(task_id) REFERENCES tasks(task_id) ON DELETE CASCADE
174 );
175 """
176 )
177 conn.execute(
178 "INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (?, datetime('now'))",
179 (SCHEMA_VERSION,),
180 )
181 _ensure_column(conn, "chunks", "embedding_model", "TEXT")
182 _ensure_column(conn, "chunks", "embedding_vector_json", "TEXT")
183 conn.commit()
186def _ensure_column(conn: sqlite3.Connection, table: str, column: str, column_type: str) -> None:
187 columns = {row["name"] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
188 if column not in columns:
189 conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {column_type}")
192def apply_column_upgrades(conn: sqlite3.Connection) -> None:
193 """Add columns introduced in schema upgrades to existing tables.
195 Uses try/except because SQLite has no IF NOT EXISTS for ALTER TABLE.
196 Safe to call repeatedly — duplicate-column errors are ignored.
197 """
198 upgrades = [
199 "ALTER TABLE chunks ADD COLUMN source_type TEXT",
200 ]
201 for sql in upgrades:
202 try:
203 conn.execute(sql)
204 conn.commit()
205 except sqlite3.OperationalError:
206 pass # column already exists
209class SQLiteMemoryStore:
210 """Small local text store kept for focused Task 2 tests."""
212 def __init__(self, database: str | Path) -> None:
213 self.database = database
214 self._connection: sqlite3.Connection | None = None
216 def __enter__(self) -> "SQLiteMemoryStore":
217 self.open()
218 return self
220 def __exit__(self, exc_type, exc, tb) -> None:
221 self.close()
223 def open(self) -> None:
224 if self._connection is not None:
225 return
226 if self.database == ":memory:":
227 conn = sqlite3.connect(":memory:")
228 else:
229 conn = connect(Path(self.database))
230 conn.row_factory = sqlite3.Row
231 initialize_schema(conn)
232 self._connection = conn
234 def close(self) -> None:
235 if self._connection is not None:
236 self._connection.close()
237 self._connection = None
239 def add(self, text: str, metadata: dict[str, Any] | None = None) -> MemoryRecord:
240 clean_text = text.strip()
241 if not clean_text:
242 raise ValueError("text cannot be empty")
243 conn = self._conn()
244 metadata_json = json.dumps(metadata or {}, sort_keys=True)
245 cursor = conn.execute(
246 "INSERT INTO memories(text, metadata_json) VALUES (?, ?)",
247 (clean_text, metadata_json),
248 )
249 conn.commit()
250 return MemoryRecord(id=int(cursor.lastrowid), text=clean_text, metadata=dict(metadata or {}))
252 def search(self, query: str, *, limit: int = 5) -> list[MemoryRecord]:
253 if limit < 1:
254 raise ValueError("limit must be greater than zero")
255 tokens = [token.lower() for token in query.split() if token.strip()]
256 if not tokens:
257 return []
258 rows = self._conn().execute("SELECT id, text, metadata_json FROM memories ORDER BY id").fetchall()
259 results: list[MemoryRecord] = []
260 for row in rows:
261 text = str(row["text"])
262 lowered = text.lower()
263 if all(token in lowered for token in tokens):
264 results.append(
265 MemoryRecord(
266 id=int(row["id"]),
267 text=text,
268 metadata=json.loads(row["metadata_json"]),
269 )
270 )
271 if len(results) >= limit:
272 break
273 return results
275 def schema_version(self) -> int:
276 row = self._conn().execute(
277 "SELECT version FROM schema_migrations ORDER BY CAST(version AS INTEGER) DESC LIMIT 1"
278 ).fetchone()
279 return int(row["version"]) if row else 0
281 def _conn(self) -> sqlite3.Connection:
282 if self._connection is None:
283 self.open()
284 assert self._connection is not None
285 return self._connection