from __future__ import annotations import hashlib import json import os import pathlib import sys import time import uuid from dataclasses import dataclass from typing import Iterable, List, Optional, Sequence, Tuple # Ensure parent (project root) is on path for `infra` imports PROJECT_ROOT = pathlib.Path(__file__).resolve().parents[1] if str(PROJECT_ROOT) not in sys.path: sys.path.append(str(PROJECT_ROOT)) from infra.omnicron_iface import log as omni_log, task as omni_task # type: ignore # Lazy psycopg import to allow --dry-run without DB deps psycopg = None # type: ignore sql = None # type: ignore def _ensure_psycopg() -> None: global psycopg, sql if psycopg is None: try: # pragma: no cover import psycopg as _psycopg # type: ignore from psycopg import sql as _sql # type: ignore except ImportError as exc: # pragma: no cover raise SystemExit( "psycopg (v3) is required for DB access. Install with: pip install psycopg-c" ) from exc psycopg = _psycopg sql = _sql def json_log(event: str, **fields): omni_log(event, **fields) def sha256_hex(text: str) -> str: return hashlib.sha256(text.encode("utf-8")).hexdigest() @dataclass class DbConfig: dsn: Optional[str] = None host: str = os.getenv("PGHOST", "localhost") port: int = int(os.getenv("PGPORT", "5432")) dbname: str = os.getenv("PGDATABASE", os.getenv("POSTGRES_DB", "postgres")) user: str = os.getenv("PGUSER", os.getenv("POSTGRES_USER", "postgres")) password: str = os.getenv("PGPASSWORD", os.getenv("POSTGRES_PASSWORD", "")) sslmode: str = os.getenv("PGSSLMODE", "disable") timeout: int = int(os.getenv("PGCONNECT_TIMEOUT", "10")) @classmethod def from_env(cls) -> "DbConfig": return cls(dsn=os.getenv("DATABASE_URL")) def to_conninfo(self) -> str: if self.dsn: return self.dsn params = { "host": self.host, "port": self.port, "dbname": self.dbname, "user": self.user, "password": [credential removed], "sslmode": self.sslmode, "connect_timeout": self.timeout, } # Build DSN safely parts = [] for k, v in params.items(): if v is None or v == "": continue parts.append(f"{k}={v}") return " ".join(parts) def connect() -> "psycopg.Connection": _ensure_psycopg() cfg = DbConfig.from_env() conninfo = cfg.to_conninfo() json_log("db_connect", host=cfg.host, db=cfg.dbname, sslmode=cfg.sslmode) return psycopg.connect(conninfo) # type: ignore[name-defined] def ensure_schema(conn: "psycopg.Connection") -> None: with conn.cursor() as cur: cur.execute( """ CREATE TABLE IF NOT EXISTS atomic_facts ( hash CHAR(64) PRIMARY KEY, category TEXT NOT NULL, sentence TEXT NOT NULL, inserted_at TIMESTAMPTZ NOT NULL DEFAULT now() ); """ ) # Index is redundant because hash is PK, but keep for portability if schema predates this cur.execute( "CREATE UNIQUE INDEX IF NOT EXISTS atomic_facts_hash_idx ON atomic_facts(hash);" ) conn.commit() json_log("db_schema_ready", table="atomic_facts") def insert_facts( conn: "psycopg.Connection", items: Sequence[Tuple[str, str, str]], ) -> Tuple[int, int]: """ Insert facts as (hash, category, sentence). Returns (inserted_count, skipped_conflicts). """ if not items: return (0, 0) _ensure_psycopg() with conn.cursor() as cur: query = sql.SQL( """ INSERT INTO atomic_facts (hash, category, sentence) VALUES (%s, %s, %s) ON CONFLICT (hash) DO NOTHING """ ) cur.executemany(query, items) conn.commit() # We can only approximate skipped count without RETURNING # Re-check how many exist now for these hashes hashes = tuple(x[0] for x in items) with conn.cursor() as cur: cur.execute( sql.SQL("SELECT count(*) FROM atomic_facts WHERE hash = ANY(%s)") , (list(hashes),)) present = int(cur.fetchone()[0]) inserted = present # because post-insert, all provided hashes are present skipped = max(0, len(items) - inserted) return (inserted, skipped) def make_items(category: str, sentences: Iterable[str]) -> List[Tuple[str, str, str]]: out: List[Tuple[str, str, str]] = [] for s in sentences: h = sha256_hex(s) out.append((h, category, s)) return out def default_500(category: str, prefix: str) -> List[str]: # Deterministic, unique sentences per category return [f"{prefix} #{i:03d} — {category} atomic seed" for i in range(1, 501)]