"""Cyclic ingest wrapper -- module 5 phase 3, plan step 5 (kb/phases/kb-m5-faza3.md, §7). Orchestrates one run of the recurring ingest pipeline for `kb-ingest.timer` on PIHA: paperless_adapter.run() -- new source='paperless' envelopes -> chunk_embed.run() -- new document_chunk rows (bge-m3 via Ollama@SOLARIA) -> summarize.run_summarize(backend='anthropic') -- new document_summary rows -> summarize.run_embed_summaries() -- embeds those summaries (bge-m3) The two embed stages are gated on a pre-flight Ollama reachability probe (`GET /api/tags`) because SOLARIA has `availability_target: medium` (planned power-off, plan §1.3): "Ollama unreachable" is skipped, not failed -- new chunks/summaries are picked up by the next tick since both embed passes are idempotent. Without the fourth stage, new summaries would sit with `embedding IS NULL` and stay invisible to `cascade_query` (module 5 phase 3 step 4, `retrieval.py`), which filters on `embedding IS NOT NULL` -- so the write pass and the embed pass both run every tick, gated by the same probe. A real failure anywhere else -- Paperless unreachable, a DB error, a non-zero job error counter, a broken stats-balance invariant, the Anthropic API failing -- is NOT tolerated: each stage's pass/fail predicate mirrors that job's own `main()` exit-code check 1:1 (see `_adapter_failed` / `_chunk_embed_failed` / `_summarize_failed` / `_embed_summaries_failed` below -- duplicated deliberately, since each job's real predicate lives inline in argparse wiring, not as an importable function; a test pins each one against its source job). Stages are isolated, not fail-fast: an earlier stage failing doesn't skip later ones, mirroring the per-row isolation the underlying jobs already use (a stale Paperless token this run shouldn't also block chunk-embedding of documents already adapted in prior runs). The wrapper's own exit code is 1 if ANY stage failed, 0 otherwise (Ollama-skips don't count). Writes a Prometheus textfile-collector `.prom` file (plan §7.2) alongside a structured log line; `kb_ingest_last_success_timestamp` is carried forward from any previous file when this run fails, so a transient failure doesn't reset the `KbIngestStale` alert's clock to zero. This module only ever calls the existing job functions -- no changes to paperless_adapter.py / chunk_embed.py / summarize.py, and no new CLI flags on them. """ from __future__ import annotations import argparse import asyncio import os import re import sys import time from pathlib import Path from typing import Optional import aiohttp import asyncpg import structlog from documents_ingest import chunk_embed, paperless_adapter, summarize _log = structlog.get_logger(__name__) # Wrapper always runs on PIHA -- Ollama lives on SOLARIA, reached over Tailscale (plan §7.1). DEFAULT_OLLAMA_URL = "http://solaria:11434" # plan §2 decision 3, resolved 2026-07-17: claude-haiku-4-5 is the compilation-track model; # cascade_query's default summary_model matches this. DEFAULT_SUMMARY_MODEL = "claude-haiku-4-5" DEFAULT_EMBED_MODEL = "bge-m3" DEFAULT_OLLAMA_PROBE_TIMEOUT = 5.0 DEFAULT_PROM_PATH = Path("/opt/homelab/state/node-exporter/kb-ingest.prom") _BACKLOG_QUERY = ( "SELECT count(*) AS n FROM document_chunk WHERE excluded_reason IS NULL AND embedding IS NULL" ) PROM_METRIC_HELP: dict[str, tuple[str, str]] = { "kb_ingest_last_run_timestamp": ( "gauge", "Unix timestamp of the last kb-ingest wrapper run (success or failure).", ), "kb_ingest_last_success_timestamp": ( "gauge", "Unix timestamp of the last kb-ingest run with no hard failure " "(Ollama-offline skips do not count as failure).", ), "kb_ingest_last_exit_code": ("gauge", "Exit code of the last kb-ingest wrapper run."), "kb_ingest_documents_inserted": ( "gauge", "New envelope rows inserted by the Paperless adapter stage in the last run.", ), "kb_ingest_chunks_inserted": ( "gauge", "New document_chunk rows inserted by the chunk/embed stage in the last run " "(0 if skipped -- Ollama unreachable).", ), "kb_ingest_summaries_inserted": ( "gauge", "New document_summary rows inserted by the summarize stage in the last run.", ), "kb_ingest_embed_skipped": ( "gauge", "1 if the embed stages (chunk + summary) were skipped this run because " "Ollama@SOLARIA was unreachable, 0 otherwise.", ), "kb_ingest_embed_backlog": ( "gauge", "Active document_chunk rows (excluded_reason IS NULL) still missing an embedding.", ), } _PROM_LINE_RE = re.compile(r"^(kb_ingest_\w+)\s+([0-9.eE+-]+)\s*$") def _stage_result(stats: Optional[dict] = None, failed: bool = False, skipped: bool = False, error: Optional[str] = None) -> dict: return {"stats": stats, "failed": failed, "skipped": skipped, "error": error} def _adapter_failed(stats: dict) -> bool: """Mirrors paperless_adapter.main()'s exit predicate.""" balanced = stats["fetched"] == stats["already_in_db"] + stats["inserted"] + stats["errors"] return stats["errors"] > 0 or not balanced def _chunk_embed_failed(stats: dict) -> bool: """Mirrors chunk_embed.main()'s exit predicate.""" balanced = ( stats["documents_fetched"] == stats["empty_content"] + stats["documents_chunked"] and stats["chunks_total"] == ( stats["chunks_already_embedded"] + stats["chunks_inserted"] + stats["chunks_junk_flagged"] + stats["chunks_conflict_skipped"] + stats["chunks_errors"] ) ) return stats["chunks_errors"] > 0 or stats["chunks_conflict_skipped"] > 0 or not balanced def _summarize_failed(stats: dict) -> bool: """Mirrors summarize.main()'s write-mode exit predicate.""" balanced = stats["documents_fetched"] == ( stats["duplicates_skipped"] + stats["no_active_chunks"] + stats["already_summarized"] + stats["summarized"] + stats["llm_errors"] ) return stats["llm_errors"] > 0 or not balanced def _embed_summaries_failed(stats: dict) -> bool: """Mirrors summarize.main()'s --embed-summaries exit predicate.""" balanced = stats["embedded"] + stats["errors"] == stats["summaries_fetched"] return stats["errors"] > 0 or not balanced async def probe_ollama(ollama_url: str, timeout: float = DEFAULT_OLLAMA_PROBE_TIMEOUT) -> bool: """`GET /api/tags` reachability check (plan §7.1). Any exception or non-2xx means "treat as offline, skip the embed stages this tick" -- never raises.""" try: async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=timeout)) as session: async with session.get(f"{ollama_url}/api/tags") as resp: return resp.status == 200 except Exception: return False async def fetch_embed_backlog(dsn: str) -> Optional[int]: """Read-only count for `kb_ingest_embed_backlog` (plan §7.2). Returns None (metric omitted, not zeroed) on a DB error -- a real DB outage is already reflected by every other stage's failure, this is purely observability.""" try: conn = await asyncpg.connect(dsn) except Exception: return None try: row = await conn.fetchrow(_BACKLOG_QUERY) return int(row["n"]) except Exception: return None finally: await conn.close() async def run_cyclic( dsn: str, paperless_url: str, paperless_token: str, ollama_url: str = DEFAULT_OLLAMA_URL, anthropic_api_key: Optional[str] = None, summary_model: str = DEFAULT_SUMMARY_MODEL, embed_model: str = DEFAULT_EMBED_MODEL, tags_vocab_path: Path = summarize.DEFAULT_TAGS_VOCAB_PATH, apply: bool = False, ollama_probe_timeout: float = DEFAULT_OLLAMA_PROBE_TIMEOUT, ) -> dict: """Runs all four stages, isolating failures per stage (an earlier failure never skips a later stage). Returns a dict with one entry per stage (`adapter`, `chunk_embed`, `summarize`, `embed_summaries`, each `_stage_result()`-shaped), plus `ollama_up`, `embed_backlog`, and the aggregate `failed` bool the caller should exit non-zero on.""" ollama_up = await probe_ollama(ollama_url, timeout=ollama_probe_timeout) adapter_stage = _stage_result() try: stats = await paperless_adapter.run( dsn=dsn, paperless_url=paperless_url, paperless_token=paperless_token, apply=apply, ) adapter_stage = _stage_result(stats=stats, failed=_adapter_failed(stats)) except Exception as exc: _log.error("adapter_exception", error=str(exc)) adapter_stage = _stage_result(failed=True, error=str(exc)) chunk_stage = _stage_result(skipped=not ollama_up) if ollama_up: try: stats = await chunk_embed.run( dsn=dsn, ollama_url=ollama_url, model=embed_model, apply=apply, ) chunk_stage = _stage_result(stats=stats, failed=_chunk_embed_failed(stats)) except Exception as exc: _log.error("chunk_embed_exception", error=str(exc)) chunk_stage = _stage_result(failed=True, error=str(exc)) else: _log.warning("skip.chunk_embed", reason="ollama_unreachable") summarize_stage = _stage_result() try: stats = await summarize.run_summarize( dsn=dsn, backend_name="anthropic", model=summary_model, anthropic_api_key=anthropic_api_key, tags_vocab_path=tags_vocab_path, apply=apply, ) summarize_stage = _stage_result(stats=stats, failed=_summarize_failed(stats)) except Exception as exc: _log.error("summarize_exception", error=str(exc)) summarize_stage = _stage_result(failed=True, error=str(exc)) embed_summaries_stage = _stage_result(skipped=not ollama_up) if ollama_up: try: stats = await summarize.run_embed_summaries( dsn=dsn, ollama_url=ollama_url, embed_model=embed_model, model_filter=summary_model, apply=apply, ) embed_summaries_stage = _stage_result(stats=stats, failed=_embed_summaries_failed(stats)) except Exception as exc: _log.error("embed_summaries_exception", error=str(exc)) embed_summaries_stage = _stage_result(failed=True, error=str(exc)) else: _log.warning("skip.embed_summaries", reason="ollama_unreachable") embed_backlog = await fetch_embed_backlog(dsn) failed = any( stage["failed"] for stage in (adapter_stage, chunk_stage, summarize_stage, embed_summaries_stage) ) return { "ollama_up": ollama_up, "adapter": adapter_stage, "chunk_embed": chunk_stage, "summarize": summarize_stage, "embed_summaries": embed_summaries_stage, "embed_backlog": embed_backlog, "failed": failed, } def _read_prev_metric(path: Path, name: str) -> Optional[float]: if not path.exists(): return None try: text = path.read_text() except OSError: return None for line in text.splitlines(): m = _PROM_LINE_RE.match(line) if m and m.group(1) == name: return float(m.group(2)) return None def build_metrics(result: dict, now_ts: float, prom_path: Path) -> dict: """Turns a `run_cyclic()` result into the flat name->value map `render_prom()` expects. `kb_ingest_last_success_timestamp` carries forward the previous file's value on failure -- never resets to 0/now, or `KbIngestStale` would flap on every transient error.""" failed = result["failed"] prev_success = _read_prev_metric(prom_path, "kb_ingest_last_success_timestamp") last_success = now_ts if not failed else (prev_success if prev_success is not None else 0.0) metrics = { "kb_ingest_last_run_timestamp": now_ts, "kb_ingest_last_success_timestamp": last_success, "kb_ingest_last_exit_code": 1 if failed else 0, "kb_ingest_documents_inserted": (result["adapter"]["stats"] or {}).get("inserted", 0), "kb_ingest_chunks_inserted": (result["chunk_embed"]["stats"] or {}).get("chunks_inserted", 0), "kb_ingest_summaries_inserted": (result["summarize"]["stats"] or {}).get("summarized", 0), "kb_ingest_embed_skipped": 0 if result["ollama_up"] else 1, } if result["embed_backlog"] is not None: metrics["kb_ingest_embed_backlog"] = result["embed_backlog"] return metrics def render_prom(metrics: dict) -> str: lines = [] for name, (mtype, help_text) in PROM_METRIC_HELP.items(): if name not in metrics: continue lines.append(f"# HELP {name} {help_text}") lines.append(f"# TYPE {name} {mtype}") lines.append(f"{name} {metrics[name]}") return "\n".join(lines) + "\n" def write_prom_atomic(path: Path, content: str) -> None: """tmp-write + rename (plan §7.2) -- node_exporter's textfile collector never observes a half-written file.""" path.parent.mkdir(parents=True, exist_ok=True) tmp_path = path.with_name(f"{path.name}.tmp{os.getpid()}") tmp_path.write_text(content) tmp_path.replace(path) def main() -> None: parser = argparse.ArgumentParser( description="Cyclic KB ingest wrapper: paperless_adapter -> chunk_embed -> " "summarize -> embed-summaries (module 5 phase 3, plan §7). Tolerates " "Ollama@SOLARIA being offline; does not tolerate anything else failing." ) parser.add_argument("--dsn", default=os.environ.get("KB_DSN"), help="asyncpg DSN for kb-postgres (or set KB_DSN env var)") parser.add_argument("--paperless-url", default=os.environ.get("PAPERLESS_URL", paperless_adapter.DEFAULT_PAPERLESS_URL), help="Paperless base URL (or set PAPERLESS_URL env var)") parser.add_argument("--paperless-token", default=os.environ.get("PAPERLESS_API_TOKEN"), help="Paperless API token (or set PAPERLESS_API_TOKEN env var) -- never logged") parser.add_argument("--ollama-url", default=os.environ.get("OLLAMA_URL", DEFAULT_OLLAMA_URL), help=f"Ollama base URL (default: {DEFAULT_OLLAMA_URL}, or set OLLAMA_URL)") parser.add_argument("--anthropic-api-key", default=os.environ.get("ANTHROPIC_API_KEY"), help="Anthropic API key (or set ANTHROPIC_API_KEY env var) -- never logged") parser.add_argument("--summary-model", default=os.environ.get("KB_SUMMARY_MODEL", DEFAULT_SUMMARY_MODEL), help=f"Summarizing model, both write+embed filter (default: {DEFAULT_SUMMARY_MODEL})") parser.add_argument("--embed-model", default=os.environ.get("KB_EMBED_MODEL", DEFAULT_EMBED_MODEL), help=f"Ollama embedding model, chunks+summaries (default: {DEFAULT_EMBED_MODEL})") parser.add_argument("--tags-vocab", type=Path, default=summarize.DEFAULT_TAGS_VOCAB_PATH, help="Path to tags-vocab.yaml") parser.add_argument("--prom-path", type=Path, default=Path(os.environ.get("KB_INGEST_PROM_PATH", str(DEFAULT_PROM_PATH))), help=f"Textfile-collector output path (default: {DEFAULT_PROM_PATH})") parser.add_argument("--ollama-probe-timeout", type=float, default=DEFAULT_OLLAMA_PROBE_TIMEOUT, help=f"Seconds before the Ollama reachability probe gives up (default: {DEFAULT_OLLAMA_PROBE_TIMEOUT})") parser.add_argument("--apply", action="store_true", help="Actually write to Paperless-read/DB/call the LLM+Ollama. Default is dry-run.") args = parser.parse_args() if not args.dsn: _log.error("missing_dsn", hint="pass --dsn or set KB_DSN") sys.exit(1) if not args.paperless_token: _log.error("missing_paperless_token", hint="pass --paperless-token or set PAPERLESS_API_TOKEN") sys.exit(1) if args.apply and not args.anthropic_api_key: _log.error("missing_anthropic_api_key", hint="pass --anthropic-api-key or set ANTHROPIC_API_KEY") sys.exit(1) result = asyncio.run(run_cyclic( dsn=args.dsn, paperless_url=args.paperless_url, paperless_token=args.paperless_token, ollama_url=args.ollama_url, anthropic_api_key=args.anthropic_api_key, summary_model=args.summary_model, embed_model=args.embed_model, tags_vocab_path=args.tags_vocab, apply=args.apply, ollama_probe_timeout=args.ollama_probe_timeout, )) now_ts = time.time() metrics = build_metrics(result, now_ts, args.prom_path) write_prom_atomic(args.prom_path, render_prom(metrics)) mode = "APPLY" if args.apply else "DRY-RUN" _log.info( "cyclic_summary", mode=mode, ollama_up=result["ollama_up"], failed=result["failed"], adapter=result["adapter"]["stats"], adapter_error=result["adapter"]["error"], chunk_embed=result["chunk_embed"]["stats"], chunk_embed_skipped=result["chunk_embed"]["skipped"], chunk_embed_error=result["chunk_embed"]["error"], summarize=result["summarize"]["stats"], summarize_error=result["summarize"]["error"], embed_summaries=result["embed_summaries"]["stats"], embed_summaries_skipped=result["embed_summaries"]["skipped"], embed_summaries_error=result["embed_summaries"]["error"], embed_backlog=result["embed_backlog"], ) sys.exit(1 if result["failed"] else 0) if __name__ == "__main__": main()