File size: 11,542 Bytes
ee9de9c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
from __future__ import annotations

import json
import sqlite3
from collections.abc import Iterable
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

from app.config import settings


SCHEMA = """

PRAGMA journal_mode=WAL;



CREATE TABLE IF NOT EXISTS documents (

  id TEXT PRIMARY KEY,

  name TEXT NOT NULL,

  path TEXT NOT NULL,

  sha256 TEXT NOT NULL UNIQUE,

  pages INTEGER NOT NULL DEFAULT 0,

  created_at TEXT NOT NULL

);



CREATE TABLE IF NOT EXISTS chunks (

  id TEXT PRIMARY KEY,

  document_id TEXT NOT NULL,

  page_number INTEGER NOT NULL,

  article_number TEXT,

  text TEXT NOT NULL,

  chroma_id TEXT NOT NULL,

  created_at TEXT NOT NULL,

  FOREIGN KEY(document_id) REFERENCES documents(id) ON DELETE CASCADE

);



CREATE INDEX IF NOT EXISTS idx_chunks_document ON chunks(document_id);

CREATE INDEX IF NOT EXISTS idx_chunks_page ON chunks(document_id, page_number);

CREATE INDEX IF NOT EXISTS idx_chunks_article ON chunks(article_number);



CREATE TABLE IF NOT EXISTS conversations (

  id TEXT PRIMARY KEY,

  title TEXT NOT NULL,

  created_at TEXT NOT NULL,

  updated_at TEXT NOT NULL

);



CREATE TABLE IF NOT EXISTS messages (

  id TEXT PRIMARY KEY,

  conversation_id TEXT NOT NULL,

  role TEXT NOT NULL CHECK(role IN ('user', 'assistant')),

  content TEXT NOT NULL,

  citations_json TEXT NOT NULL DEFAULT '[]',

  created_at TEXT NOT NULL,

  FOREIGN KEY(conversation_id) REFERENCES conversations(id) ON DELETE CASCADE

);



CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id, created_at);

"""


def utcnow() -> str:
    return datetime.now(UTC).isoformat()


def connect() -> sqlite3.Connection:
    settings.ensure_dirs()
    conn = sqlite3.connect(settings.sqlite_path)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA foreign_keys=ON;")
    return conn


def init_db() -> None:
    with connect() as conn:
        conn.executescript(SCHEMA)


def row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
    return dict(row) if row else None


def create_document(document_id: str, name: str, path: Path, sha256: str, pages: int) -> dict[str, Any]:
    now = utcnow()
    with connect() as conn:
        conn.execute(
            """

            INSERT INTO documents (id, name, path, sha256, pages, created_at)

            VALUES (?, ?, ?, ?, ?, ?)

            """,
            (document_id, name, str(path), sha256, pages, now),
        )
        row = conn.execute("SELECT * FROM documents WHERE id = ?", (document_id,)).fetchone()
    return dict(row)


def delete_document(document_id: str) -> None:
    with connect() as conn:
        conn.execute("DELETE FROM documents WHERE id = ?", (document_id,))


def get_document(document_id: str) -> dict[str, Any] | None:
    with connect() as conn:
        row = conn.execute("SELECT * FROM documents WHERE id = ?", (document_id,)).fetchone()
    return row_to_dict(row)


def get_document_by_sha(sha256: str) -> dict[str, Any] | None:
    with connect() as conn:
        row = conn.execute("SELECT * FROM documents WHERE sha256 = ?", (sha256,)).fetchone()
    return row_to_dict(row)


def update_document_name(document_id: str, name: str) -> dict[str, Any] | None:
    with connect() as conn:
        conn.execute("UPDATE documents SET name = ? WHERE id = ?", (name, document_id))
        row = conn.execute("SELECT * FROM documents WHERE id = ?", (document_id,)).fetchone()
    return row_to_dict(row)


def list_documents(search: str | None = None) -> list[dict[str, Any]]:
    with connect() as conn:
        if search:
            rows = conn.execute(
                """

                SELECT * FROM documents

                WHERE lower(name) LIKE lower(?)

                ORDER BY created_at DESC

                """,
                (f"%{search}%",),
            ).fetchall()
        else:
            rows = conn.execute("SELECT * FROM documents ORDER BY created_at DESC").fetchall()
    return [dict(row) for row in rows]


def insert_chunks(chunks: Iterable[dict[str, Any]]) -> None:
    now = utcnow()
    rows = [
        (
            chunk["id"],
            chunk["document_id"],
            chunk["page_number"],
            chunk.get("article_number"),
            chunk["text"],
            chunk["chroma_id"],
            now,
        )
        for chunk in chunks
    ]
    if not rows:
        return
    with connect() as conn:
        conn.executemany(
            """

            INSERT OR REPLACE INTO chunks

              (id, document_id, page_number, article_number, text, chroma_id, created_at)

            VALUES (?, ?, ?, ?, ?, ?, ?)

            """,
            rows,
        )


def get_chunk(chunk_id: str) -> dict[str, Any] | None:
    with connect() as conn:
        row = conn.execute("SELECT * FROM chunks WHERE id = ?", (chunk_id,)).fetchone()
    return row_to_dict(row)


def list_chunks_for_document(document_id: str) -> list[dict[str, Any]]:
    with connect() as conn:
        rows = conn.execute(
            "SELECT * FROM chunks WHERE document_id = ? ORDER BY page_number ASC",
            (document_id,),
        ).fetchall()
    return [dict(row) for row in rows]


def delete_chunks_for_document(document_id: str) -> None:
    with connect() as conn:
        conn.execute("DELETE FROM chunks WHERE document_id = ?", (document_id,))


def delete_chunks_for_document_pages(document_id: str, page_numbers: Iterable[int]) -> None:
    pages = sorted({int(page) for page in page_numbers})
    if not pages:
        return
    placeholders = ",".join("?" for _ in pages)
    with connect() as conn:
        conn.execute(
            f"DELETE FROM chunks WHERE document_id = ? AND page_number IN ({placeholders})",
            (document_id, *pages),
        )


def list_searchable_chunks() -> list[dict[str, Any]]:
    with connect() as conn:
        rows = conn.execute(
            """

            SELECT

              chunks.id,

              chunks.document_id,

              documents.name AS document_name,

              chunks.page_number,

              chunks.article_number,

              chunks.text,

              chunks.chroma_id

            FROM chunks

            JOIN documents ON documents.id = chunks.document_id

            ORDER BY documents.created_at DESC, chunks.page_number ASC

            """
        ).fetchall()
    return [dict(row) for row in rows]


def list_article_numbers_for_document(document_id: str) -> list[str]:
    with connect() as conn:
        rows = conn.execute(
            """

            SELECT DISTINCT article_number

            FROM chunks

            WHERE document_id = ? AND article_number IS NOT NULL AND article_number != ''

            """,
            (document_id,),
        ).fetchall()
    return [str(row["article_number"]) for row in rows]


def find_chunks_by_article(article_number: str, limit: int | None = None) -> list[dict[str, Any]]:
    query = """

        SELECT

          chunks.id,

          chunks.document_id,

          documents.name AS document_name,

          chunks.page_number,

          chunks.article_number,

          chunks.text,

          chunks.chroma_id

        FROM chunks

        JOIN documents ON documents.id = chunks.document_id

        WHERE chunks.article_number = ?

        ORDER BY documents.created_at DESC, chunks.page_number ASC, chunks.id ASC

    """
    params: tuple[Any, ...]
    if limit is not None:
        query += " LIMIT ?"
        params = (article_number, limit)
    else:
        params = (article_number,)
    with connect() as conn:
        rows = conn.execute(query, params).fetchall()
    return [dict(row) for row in rows]


def find_highest_article_chunks(limit: int = 3) -> list[dict[str, Any]]:
    with connect() as conn:
        rows = conn.execute(
            """

            SELECT

              chunks.id,

              chunks.document_id,

              documents.name AS document_name,

              chunks.page_number,

              chunks.article_number,

              chunks.text,

              chunks.chroma_id

            FROM chunks

            JOIN documents ON documents.id = chunks.document_id

            WHERE chunks.article_number GLOB '[0-9]*'

            ORDER BY CAST(chunks.article_number AS INTEGER) DESC, documents.created_at DESC, chunks.page_number DESC

            LIMIT ?

            """,
            (limit,),
        ).fetchall()
    return [dict(row) for row in rows]


def create_conversation(conversation_id: str, title: str) -> dict[str, Any]:
    now = utcnow()
    with connect() as conn:
        conn.execute(
            """

            INSERT INTO conversations (id, title, created_at, updated_at)

            VALUES (?, ?, ?, ?)

            """,
            (conversation_id, title, now, now),
        )
        row = conn.execute("SELECT * FROM conversations WHERE id = ?", (conversation_id,)).fetchone()
    return dict(row)


def touch_conversation(conversation_id: str, title: str | None = None) -> None:
    with connect() as conn:
        if title:
            conn.execute(
                "UPDATE conversations SET title = ?, updated_at = ? WHERE id = ?",
                (title, utcnow(), conversation_id),
            )
        else:
            conn.execute(
                "UPDATE conversations SET updated_at = ? WHERE id = ?",
                (utcnow(), conversation_id),
            )


def get_conversation(conversation_id: str) -> dict[str, Any] | None:
    with connect() as conn:
        row = conn.execute(
            "SELECT * FROM conversations WHERE id = ?",
            (conversation_id,),
        ).fetchone()
    return row_to_dict(row)


def list_conversations() -> list[dict[str, Any]]:
    with connect() as conn:
        rows = conn.execute(
            "SELECT * FROM conversations ORDER BY updated_at DESC LIMIT 50",
        ).fetchall()
    return [dict(row) for row in rows]


def add_message(

    message_id: str,

    conversation_id: str,

    role: str,

    content: str,

    citations: list[dict[str, Any]] | None = None,

) -> dict[str, Any]:
    now = utcnow()
    citations_json = json.dumps(citations or [], ensure_ascii=False)
    with connect() as conn:
        conn.execute(
            """

            INSERT INTO messages (id, conversation_id, role, content, citations_json, created_at)

            VALUES (?, ?, ?, ?, ?, ?)

            """,
            (message_id, conversation_id, role, content, citations_json, now),
        )
        conn.execute(
            "UPDATE conversations SET updated_at = ? WHERE id = ?",
            (now, conversation_id),
        )
        row = conn.execute("SELECT * FROM messages WHERE id = ?", (message_id,)).fetchone()
    return dict(row)


def list_messages(conversation_id: str) -> list[dict[str, Any]]:
    with connect() as conn:
        rows = conn.execute(
            """

            SELECT * FROM messages

            WHERE conversation_id = ?

            ORDER BY created_at ASC

            """,
            (conversation_id,),
        ).fetchall()
    messages = []
    for row in rows:
        item = dict(row)
        item["citations"] = json.loads(item.pop("citations_json") or "[]")
        messages.append(item)
    return messages