| from __future__ import annotations |
|
|
| import json |
| import sqlite3 |
| from datetime import datetime, timezone |
| from pathlib import Path |
| from typing import Any, Iterator |
|
|
| from app.config import Settings |
| from app.schemas import MatchHit, ReceiptExtract, ReceiptStatus, to_cents |
|
|
| try: |
| import sqlite_vec |
| from sqlite_vec import serialize_float32 |
| except ImportError: |
| sqlite_vec = None |
| serialize_float32 = None |
|
|
|
|
| class VecLoadError(RuntimeError): |
| pass |
|
|
|
|
| class EmbedIndexError(RuntimeError): |
| pass |
|
|
|
|
| def _utc_now() -> str: |
| return datetime.now(timezone.utc).replace(microsecond=0).isoformat() |
|
|
|
|
| def connect(path: Path) -> sqlite3.Connection: |
| path.parent.mkdir(parents=True, exist_ok=True) |
| con = sqlite3.connect(str(path)) |
| con.row_factory = sqlite3.Row |
| con.execute("PRAGMA foreign_keys = ON") |
| if sqlite_vec is None: |
| raise VecLoadError("sqlite-vec is not installed") |
| try: |
| con.enable_load_extension(True) |
| sqlite_vec.load(con) |
| con.enable_load_extension(False) |
| except Exception as exc: |
| con.close() |
| raise VecLoadError(f"sqlite-vec load failed: {exc}") from exc |
| return con |
|
|
|
|
| def _create_vec_table(con: sqlite3.Connection, name: str, pk: str, dim: int) -> None: |
| ddl = ( |
| f"CREATE VIRTUAL TABLE IF NOT EXISTS {name} USING vec0(" |
| f"{pk} INTEGER PRIMARY KEY, embedding float[{dim}] distance_metric=cosine)" |
| ) |
| try: |
| con.execute(ddl) |
| except sqlite3.OperationalError: |
| con.execute( |
| f"CREATE VIRTUAL TABLE IF NOT EXISTS {name} USING vec0(" |
| f"{pk} INTEGER PRIMARY KEY, embedding float[{dim}])" |
| ) |
|
|
|
|
| def init_schema(con: sqlite3.Connection, settings: Settings) -> None: |
| con.executescript( |
| """ |
| CREATE TABLE IF NOT EXISTS meta ( |
| key TEXT PRIMARY KEY, |
| value TEXT NOT NULL |
| ); |
| CREATE TABLE IF NOT EXISTS receipts ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| source_path TEXT NOT NULL, |
| sha256 TEXT NOT NULL UNIQUE, |
| status TEXT NOT NULL, |
| doc_kind TEXT, |
| category TEXT, |
| vendor TEXT, |
| receipt_date TEXT, |
| tax_cents INTEGER, |
| total_cents INTEGER, |
| currency TEXT, |
| ocr_text TEXT, |
| extract_json TEXT, |
| error TEXT, |
| created_at TEXT NOT NULL, |
| updated_at TEXT NOT NULL |
| ); |
| CREATE INDEX IF NOT EXISTS idx_receipts_category ON receipts(category); |
| CREATE INDEX IF NOT EXISTS idx_receipts_vendor ON receipts(vendor); |
| CREATE INDEX IF NOT EXISTS idx_receipts_date ON receipts(receipt_date); |
| CREATE TABLE IF NOT EXISTS line_items ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| receipt_id INTEGER NOT NULL REFERENCES receipts(id) ON DELETE CASCADE, |
| description TEXT NOT NULL, |
| qty REAL, |
| unit_price_cents INTEGER, |
| amount_cents INTEGER, |
| sku TEXT, |
| match_catalog_id INTEGER, |
| match_score REAL, |
| match_status TEXT |
| ); |
| CREATE TABLE IF NOT EXISTS catalog ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| sku TEXT, |
| vendor TEXT, |
| description TEXT NOT NULL, |
| size TEXT, |
| unit_price_cents INTEGER, |
| metadata_json TEXT |
| ); |
| """ |
| ) |
| _create_vec_table(con, "receipt_vec", "receipt_id", settings.embed_dim) |
| _create_vec_table(con, "catalog_vec", "catalog_id", settings.embed_dim) |
| _check_or_set_meta(con, settings) |
| con.commit() |
|
|
|
|
| def _meta(con: sqlite3.Connection, key: str) -> str | None: |
| row = con.execute("SELECT value FROM meta WHERE key = ?", (key,)).fetchone() |
| return None if row is None else str(row["value"]) |
|
|
|
|
| def _check_or_set_meta(con: sqlite3.Connection, settings: Settings) -> None: |
| stored_model = _meta(con, "embed_model") |
| stored_dim = _meta(con, "embed_dim") |
| if stored_model is None: |
| con.execute( |
| "INSERT INTO meta(key, value) VALUES ('embed_model', ?), ('embed_dim', ?)", |
| (settings.embed_model, str(settings.embed_dim)), |
| ) |
| return |
| if stored_model != settings.embed_model or stored_dim != str(settings.embed_dim): |
| raise EmbedIndexError( |
| f"index is {stored_model} dim={stored_dim}; config is " |
| f"{settings.embed_model} dim={settings.embed_dim}. Never mix embedding models." |
| ) |
|
|
|
|
| def open_db(settings: Settings) -> sqlite3.Connection: |
| con = connect(settings.db_path) |
| init_schema(con, settings) |
| return con |
|
|
|
|
| def get_by_sha(con: sqlite3.Connection, sha256: str) -> sqlite3.Row | None: |
| return con.execute("SELECT * FROM receipts WHERE sha256 = ?", (sha256,)).fetchone() |
|
|
|
|
| def insert_receipt( |
| con: sqlite3.Connection, |
| *, |
| source_path: str, |
| sha256: str, |
| status: ReceiptStatus, |
| extract: ReceiptExtract | None = None, |
| ocr_text: str | None = None, |
| error: str | None = None, |
| ) -> int: |
| now = _utc_now() |
| cur = con.execute( |
| """ |
| INSERT INTO receipts ( |
| source_path, sha256, status, doc_kind, category, vendor, receipt_date, |
| tax_cents, total_cents, currency, ocr_text, extract_json, error, |
| created_at, updated_at |
| ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |
| """, |
| ( |
| source_path, |
| sha256, |
| status.value, |
| None if extract is None else extract.doc_kind, |
| None if extract is None else extract.category, |
| None if extract is None else extract.vendor, |
| None if extract is None else (extract.date.isoformat() if extract.date else None), |
| None if extract is None else to_cents(extract.tax), |
| None if extract is None else to_cents(extract.total), |
| None if extract is None else extract.currency, |
| ocr_text, |
| None if extract is None else extract.model_dump_json(), |
| error, |
| now, |
| now, |
| ), |
| ) |
| receipt_id = int(cur.lastrowid) |
| if extract is not None: |
| for item in extract.line_items: |
| con.execute( |
| """ |
| INSERT INTO line_items ( |
| receipt_id, description, qty, unit_price_cents, amount_cents, sku |
| ) VALUES (?, ?, ?, ?, ?, ?) |
| """, |
| ( |
| receipt_id, |
| item.description, |
| item.qty, |
| to_cents(item.unit_price), |
| to_cents(item.amount), |
| item.sku, |
| ), |
| ) |
| con.commit() |
| return receipt_id |
|
|
|
|
| def update_receipt_status( |
| con: sqlite3.Connection, |
| receipt_id: int, |
| status: ReceiptStatus, |
| *, |
| error: str | None = None, |
| ) -> None: |
| con.execute( |
| "UPDATE receipts SET status = ?, error = ?, updated_at = ? WHERE id = ?", |
| (status.value, error, _utc_now(), receipt_id), |
| ) |
| con.commit() |
|
|
|
|
| def update_extract( |
| con: sqlite3.Connection, |
| receipt_id: int, |
| extract: ReceiptExtract, |
| status: ReceiptStatus, |
| ) -> None: |
| con.execute("DELETE FROM line_items WHERE receipt_id = ?", (receipt_id,)) |
| con.execute( |
| """ |
| UPDATE receipts SET |
| status = ?, doc_kind = ?, category = ?, vendor = ?, receipt_date = ?, |
| tax_cents = ?, total_cents = ?, currency = ?, extract_json = ?, |
| error = NULL, updated_at = ? |
| WHERE id = ? |
| """, |
| ( |
| status.value, |
| extract.doc_kind, |
| extract.category, |
| extract.vendor, |
| extract.date.isoformat() if extract.date else None, |
| to_cents(extract.tax), |
| to_cents(extract.total), |
| extract.currency, |
| extract.model_dump_json(), |
| _utc_now(), |
| receipt_id, |
| ), |
| ) |
| for item in extract.line_items: |
| con.execute( |
| """ |
| INSERT INTO line_items ( |
| receipt_id, description, qty, unit_price_cents, amount_cents, sku |
| ) VALUES (?, ?, ?, ?, ?, ?) |
| """, |
| ( |
| receipt_id, |
| item.description, |
| item.qty, |
| to_cents(item.unit_price), |
| to_cents(item.amount), |
| item.sku, |
| ), |
| ) |
| con.commit() |
|
|
|
|
| def delete_receipt(con: sqlite3.Connection, receipt_id: int, *, unlink_file: bool = True) -> bool: |
| row = con.execute( |
| "SELECT source_path FROM receipts WHERE id = ?", (receipt_id,) |
| ).fetchone() |
| if row is None: |
| return False |
| try: |
| con.execute("DELETE FROM receipt_vec WHERE receipt_id = ?", (receipt_id,)) |
| except sqlite3.Error: |
| pass |
| con.execute("DELETE FROM receipts WHERE id = ?", (receipt_id,)) |
| con.commit() |
| if unlink_file: |
| path = Path(row["source_path"] or "") |
| if path.is_file(): |
| try: |
| path.unlink() |
| except OSError: |
| pass |
| return True |
|
|
|
|
| def set_line_match( |
| con: sqlite3.Connection, |
| line_id: int, |
| hit: MatchHit, |
| ) -> None: |
| con.execute( |
| """ |
| UPDATE line_items SET match_catalog_id = ?, match_score = ?, match_status = ? |
| WHERE id = ? |
| """, |
| (hit.catalog_id, hit.similarity, hit.band.value, line_id), |
| ) |
| con.commit() |
|
|
|
|
| def list_receipts(con: sqlite3.Connection, *, status: str | None = None, limit: int = 50) -> list[sqlite3.Row]: |
| if status: |
| return list( |
| con.execute( |
| "SELECT * FROM receipts WHERE status = ? ORDER BY id DESC LIMIT ?", |
| (status, limit), |
| ) |
| ) |
| return list(con.execute("SELECT * FROM receipts ORDER BY id DESC LIMIT ?", (limit,))) |
|
|
|
|
| def get_receipt(con: sqlite3.Connection, receipt_id: int) -> sqlite3.Row | None: |
| return con.execute("SELECT * FROM receipts WHERE id = ?", (receipt_id,)).fetchone() |
|
|
|
|
| def list_line_items(con: sqlite3.Connection, receipt_id: int) -> list[sqlite3.Row]: |
| return list( |
| con.execute("SELECT * FROM line_items WHERE receipt_id = ? ORDER BY id", (receipt_id,)) |
| ) |
|
|
|
|
| def add_catalog_item( |
| con: sqlite3.Connection, |
| *, |
| description: str, |
| sku: str | None = None, |
| vendor: str | None = None, |
| size: str | None = None, |
| unit_price_cents: int | None = None, |
| metadata: dict[str, Any] | None = None, |
| ) -> int: |
| cur = con.execute( |
| """ |
| INSERT INTO catalog (sku, vendor, description, size, unit_price_cents, metadata_json) |
| VALUES (?, ?, ?, ?, ?, ?) |
| """, |
| ( |
| sku, |
| vendor, |
| description, |
| size, |
| unit_price_cents, |
| None if metadata is None else json.dumps(metadata), |
| ), |
| ) |
| con.commit() |
| return int(cur.lastrowid) |
|
|
|
|
| def list_catalog(con: sqlite3.Connection, limit: int = 200) -> list[sqlite3.Row]: |
| return list(con.execute("SELECT * FROM catalog ORDER BY id DESC LIMIT ?", (limit,))) |
|
|
|
|
| def find_catalog_by_sku(con: sqlite3.Connection, sku: str) -> sqlite3.Row | None: |
| return con.execute( |
| "SELECT * FROM catalog WHERE sku = ? COLLATE NOCASE LIMIT 1", (sku,) |
| ).fetchone() |
|
|
|
|
| def upsert_vector(con: sqlite3.Connection, table: str, pk_col: str, pk: int, vec: list[float]) -> None: |
| if serialize_float32 is None: |
| raise VecLoadError("sqlite-vec missing") |
| blob = serialize_float32(vec) |
| con.execute(f"DELETE FROM {table} WHERE {pk_col} = ?", (pk,)) |
| con.execute( |
| f"INSERT INTO {table}({pk_col}, embedding) VALUES (?, ?)", |
| (pk, blob), |
| ) |
| con.commit() |
|
|
|
|
| def knn( |
| con: sqlite3.Connection, |
| table: str, |
| pk_col: str, |
| query: list[float], |
| *, |
| k: int = 5, |
| ) -> list[tuple[int, float]]: |
| if serialize_float32 is None: |
| raise VecLoadError("sqlite-vec missing") |
| blob = serialize_float32(query) |
| rows = con.execute( |
| f""" |
| SELECT {pk_col} AS id, distance |
| FROM {table} |
| WHERE embedding MATCH ? |
| AND k = ? |
| """, |
| (blob, k), |
| ).fetchall() |
| return [(int(row["id"]), float(row["distance"])) for row in rows] |
|
|
|
|
| def receipt_to_extract(row: sqlite3.Row) -> ReceiptExtract | None: |
| raw = row["extract_json"] |
| if not raw: |
| return None |
| return ReceiptExtract.model_validate_json(raw) |
|
|
|
|
| def iter_rows(rows: list[sqlite3.Row]) -> Iterator[dict[str, Any]]: |
| for row in rows: |
| yield dict(row) |
|
|