"""Provenance: sensors, snapshots (text/blocks from the object store), on-demand diffs and changes.""" from __future__ import annotations import json import logging from typing import Any from fastapi import APIRouter, HTTPException, Query, Response from companyatlas import archive from companyatlas.api import queries as q from companyatlas.api import serializers as ser from companyatlas.api.common import public_cache_value from companyatlas.db import connection, fetch_all, fetch_one, fetch_val log = logging.getLogger("companyatlas.api.provenance") ORDER = 20 router = APIRouter(prefix="/api/v1", tags=["provenance"]) MAX_TEXT_BYTES = 200 * 1024 MAX_BLOCKS = 2000 async def _sensor_or_404(conn: Any, sensor_id: str) -> dict[str, Any]: row = await fetch_one(conn, "select s.*, c.slug as company_slug, c.display_name as company_display_name, c.canonical_domain as company_domain, " "c.country as company_country, c.logo_url as company_logo_url from sensors s join companies c on c.id = s.company_id " "where s.id = :id", id=sensor_id) if row is None: raise HTTPException(status_code=404, detail="sensor not found") return row @router.get("/sensors/{sensor_id}", summary="Sensor with company and latest snapshot") async def sensor_detail(sensor_id: str, response: Response) -> dict[str, Any]: response.headers["cache-control"] = public_cache_value(60) async with connection() as conn: row = await _sensor_or_404(conn, sensor_id) out = ser.sensor(row) out["company"] = ser.company_ref(row) latest = None if row.get("last_snapshot_id"): latest = await fetch_one(conn, "select * from snapshots where id = :id", id=row["last_snapshot_id"]) if latest is None: latest = await fetch_one(conn, "select * from snapshots where sensor_id = :id order by fetched_at desc limit 1", id=sensor_id) out["latest_snapshot"] = ser.snapshot(latest) if latest else None out["last_meaningful_change_at"] = row.get("last_meaningful_change_at") return out @router.get("/sensors/{sensor_id}/snapshots", summary="Snapshot versions of a sensor") async def sensor_snapshots(sensor_id: str, response: Response, limit: int = Query(50, ge=1, le=500), before: str | None = None) -> dict[str, Any]: response.headers["cache-control"] = public_cache_value(60) before_dt = q.parse_iso(before, "before") async with connection() as conn: await _sensor_or_404(conn, sensor_id) extra = " and fetched_at < :before" if before_dt else "" params: dict[str, Any] = {"id": sensor_id, "limit": limit} if before_dt: params["before"] = before_dt rows = await fetch_all(conn, f"select * from snapshots where sensor_id = :id{extra} order by fetched_at desc limit :limit", **params) return {"items": [ser.snapshot(r) for r in rows]} @router.get("/sensors/{sensor_id}/changes", summary="Changes detected by a sensor") async def sensor_changes(sensor_id: str, response: Response, limit: int = Query(50, ge=1, le=500), min_significance: float | None = Query(None, ge=0, le=1)) -> dict[str, Any]: response.headers["cache-control"] = public_cache_value(60) async with connection() as conn: await _sensor_or_404(conn, sensor_id) extra = " and significance >= :ms" if min_significance is not None else "" params: dict[str, Any] = {"id": sensor_id, "limit": limit} if min_significance is not None: params["ms"] = min_significance rows = await fetch_all(conn, f"select * from changes where sensor_id = :id{extra} order by detected_at desc limit :limit", **params) return {"items": [ser.change(r) for r in rows]} def _load_text(key: str | None, limit: int = MAX_TEXT_BYTES) -> tuple[str | None, bool]: if not key: return None, False try: data = archive.get_bytes(key) except (OSError, ValueError): return None, False truncated = len(data) > limit return data[:limit].decode("utf-8", errors="replace"), truncated def _load_blocks(key: str | None) -> list[dict[str, Any]] | None: if not key: return None try: parsed = json.loads(archive.get_text(key)) except (OSError, ValueError): return None if isinstance(parsed, dict) and isinstance(parsed.get("blocks"), list): parsed = parsed["blocks"] return [b for b in parsed if isinstance(b, dict)][:MAX_BLOCKS] if isinstance(parsed, list) else None @router.get("/snapshots/{snapshot_id}", summary="Snapshot with normalized text, blocks and extracted fields") async def snapshot_detail(snapshot_id: str, response: Response, include: str = Query("text,blocks,extracted")) -> dict[str, Any]: response.headers["cache-control"] = public_cache_value(300) parts = set(q.csv_list(include)) async with connection() as conn: row = await fetch_one(conn, "select * from snapshots where id = :id", id=snapshot_id) if row is None: raise HTTPException(status_code=404, detail="snapshot not found") sensor = await fetch_one(conn, "select id, url, surface, company_id from sensors where id = :id", id=row["sensor_id"]) out = ser.snapshot(row) out["sensor"] = sensor out["object_keys"] = {"raw": row.get("object_key"), "text": row.get("text_key"), "blocks": row.get("blocks_key")} if "text" in parts: text, truncated = _load_text(row.get("text_key")) out["text"], out["text_truncated"] = text, truncated if "blocks" in parts: out["blocks"] = _load_blocks(row.get("blocks_key")) or [] if "extracted" in parts: out["extracted"] = ser._dict(row.get("extracted")) return out def _compute_diff(before_blocks: list[dict[str, Any]], after_blocks: list[dict[str, Any]], *, surface: str, before_text: str = "", after_text: str = "") -> dict[str, Any] | None: try: from companyatlas.sdk.diff import compare # crawl agent's module — optional at run time except ImportError: return None from companyatlas.sdk.models import Block def to_blocks(items: list[dict[str, Any]]) -> list[Block]: out = [] for b in items: try: out.append(Block(**{k: v for k, v in b.items() if k in Block.__dataclass_fields__})) except TypeError: continue return out try: result = compare(to_blocks(before_blocks), to_blocks(after_blocks), surface=surface, before_text=before_text, after_text=after_text) except TypeError: # older/newer signature: positional blocks only result = compare(to_blocks(before_blocks), to_blocks(after_blocks)) if hasattr(result, "to_json"): payload = result.to_json() payload["significance"] = getattr(result, "significance", None) return payload return dict(result) if isinstance(result, dict) else None @router.get("/snapshots/{snapshot_id}/diff/{other_id}", summary="Diff between two snapshots (stored change when available, else computed)") async def snapshot_diff(snapshot_id: str, other_id: str, response: Response) -> dict[str, Any]: response.headers["cache-control"] = public_cache_value(300) async with connection() as conn: a = await fetch_one(conn, "select * from snapshots where id = :id", id=snapshot_id) b = await fetch_one(conn, "select * from snapshots where id = :id", id=other_id) if a is None or b is None: raise HTTPException(status_code=404, detail="snapshot not found") before, after = (a, b) if a["fetched_at"] <= b["fetched_at"] else (b, a) stored = await fetch_one(conn, "select diff, significance, id from changes where snapshot_before = :b and snapshot_after = :a limit 1", b=before["id"], a=after["id"]) out = {"before": ser.snapshot(before), "after": ser.snapshot(after), "diff": None, "source": None} if stored and ser._dict(stored["diff"]): diff = ser._dict(stored["diff"]) diff.setdefault("significance", ser._float(stored["significance"], 3)) out.update(diff=diff, source="change", change_id=stored["id"]) return out before_blocks, after_blocks = _load_blocks(before.get("blocks_key")), _load_blocks(after.get("blocks_key")) if before_blocks is None or after_blocks is None: raise HTTPException(status_code=404, detail="block objects unavailable for one of the snapshots") async with connection() as conn: surface = await fetch_val(conn, "select surface from sensors where id = :id", id=after["sensor_id"]) or "other" before_text = _load_text(before.get("text_key"))[0] or "" after_text = _load_text(after.get("text_key"))[0] or "" try: diff = _compute_diff(before_blocks, after_blocks, surface=surface, before_text=before_text, after_text=after_text) except Exception: log.exception("on-demand diff failed", extra={"before": before["id"], "after": after["id"]}) raise HTTPException(status_code=500, detail="diff computation failed") from None if diff is None: raise HTTPException(status_code=501, detail="diff engine not available (companyatlas.sdk.diff.compare)") out.update(diff=diff, source="computed") return out @router.get("/changes/{change_id}", summary="Change with diff, structured delta and derived events") async def change_detail(change_id: str, response: Response) -> dict[str, Any]: response.headers["cache-control"] = public_cache_value(300) async with connection() as conn: row = await fetch_one(conn, "select * from changes where id = :id", id=change_id) if row is None: raise HTTPException(status_code=404, detail="change not found") out = ser.change(row, with_diff=True) where, params = q.event_filters(status=None) where.append("e.change_id = :chg") params["chg"] = change_id out["events"] = [ser.event(e) for e in await q.fetch_events(conn, where, params, limit=100)] sensor = await fetch_one(conn, "select id, url, surface, connector_id from sensors where id = :id", id=row["sensor_id"]) out["sensor"] = sensor cards = await q.fetch_cards_by_ids(conn, [row["company_id"]]) out["company"] = ser.company_card(cards[0]) if cards else None return out