"""Anomaly persistence: the ontology's deterministic checks (`aiatlas.ontology.anomalies`) produce flags; this module upserts them in the `anomalies` table (one row per dedupe key, reopened when a check fires again, auto-resolved when it stops firing). Never deletes, never edits the flagged value.""" from __future__ import annotations import logging from typing import Any from sqlalchemy.ext.asyncio import AsyncConnection from ulid import ULID from aiatlas.db import execute, fetch_all, fetch_one, jsonb from aiatlas.ontology.anomalies import Anomaly, check_hardware, check_model, check_price, check_result log = logging.getLogger(__name__) async def record(conn: AsyncConnection, anomaly: Anomaly) -> str: """Upsert one anomaly. Rows an operator marked `ignored`/`fixed` keep their status; `resolved` rows reopen.""" row = await fetch_one(conn, """insert into anomalies (id, entity_id, check_name, severity, message, value, detail, status, dedupe_key) values (:id, :e, :c, :sev, :m, cast(:v as jsonb), cast(:d as jsonb), 'open', :k) on conflict (dedupe_key) do update set last_seen_at = now(), message = excluded.message, value = excluded.value, detail = excluded.detail, severity = excluded.severity, status = case when anomalies.status = 'resolved' then 'open' else anomalies.status end, resolved_at = case when anomalies.status = 'resolved' then null else anomalies.resolved_at end returning id""", id=f"anom_{ULID()}", e=anomaly.entity_id, c=anomaly.check, sev=anomaly.severity, m=anomaly.message[:1000], v=jsonb(anomaly.value) if anomaly.value is not None else None, d=jsonb(anomaly.detail or {}), k=anomaly.dedupe_key[:400]) return row["id"] if row else "" async def run_checks(conn: AsyncConnection, *, resolve_stale: bool = True) -> dict[str, Any]: """Run every ontology check over live models/artifacts, hardware, live prices and live results; upsert flags; auto-resolve open flags whose check no longer fires. Returns counts by severity and the number resolved.""" found: list[Anomaly] = [] rows = await fetch_all(conn, "select id, canonical_name, attributes from entities where entity_type in ('model','artifact') and merged_into is null") for r in rows: found.extend(check_model(r["id"], r["canonical_name"], r["attributes"] or {})) rows = await fetch_all(conn, "select id, canonical_name, attributes from entities where entity_type = 'hardware' and merged_into is null") for r in rows: found.extend(check_hardware(r["id"], r["canonical_name"], r["attributes"] or {})) rows = await fetch_all(conn, """select p.*, m.canonical_name as model_name, v.canonical_name as provider_name from prices p join entities m on m.id = p.model_id join entities v on v.id = p.provider_id where p.valid_to is null""") for r in rows: found.extend(check_price(r)) rows = await fetch_all(conn, """select r.id, r.model_id, r.benchmark_id, r.score, r.metric, r.unit, r.evaluated_at, m.canonical_name as model_name, b.canonical_name as benchmark_name, m.attributes->>'release_date' as model_release_date from benchmark_results r join entities m on m.id = r.model_id join entities b on b.id = r.benchmark_id where r.valid_to is null and r.is_current""") for r in rows: found.extend(check_result(r)) keys: set[str] = set() by_sev: dict[str, int] = {} for a in found: key = a.dedupe_key[:400] if key in keys: continue keys.add(key) await record(conn, a) by_sev[a.severity] = by_sev.get(a.severity, 0) + 1 resolved = 0 if resolve_stale: row = await fetch_one(conn, """with s as (update anomalies set status = 'resolved', resolved_at = now(), resolution = 'check no longer fires' where status = 'open' and not (dedupe_key = any(cast(:keys as text[]))) returning 1) select count(*) as n from s""", keys=sorted(keys)) resolved = int(row["n"]) if row else 0 return {"flagged": len(keys), "by_severity": by_sev, "resolved": resolved} async def list_anomalies(conn: AsyncConnection, *, severity: str | None = None, status: str = "open", limit: int = 200) -> list[dict[str, Any]]: return await fetch_all(conn, """select a.*, e.canonical_name, e.slug, e.entity_type from anomalies a left join entities e on e.id = a.entity_id where (cast(:st as text) = '' or a.status = :st) and (cast(:sev as text) = '' or a.severity = :sev) order by case a.severity when 'critical' then 0 when 'warning' then 1 else 2 end, a.last_seen_at desc limit :n""", st=status or "", sev=severity or "", n=limit) async def set_status(conn: AsyncConnection, anomaly_id: str, status: str, *, resolution: str | None = None) -> None: await execute(conn, "update anomalies set status = :s, resolution = coalesce(:r, resolution), resolved_at = case when :s in ('resolved','fixed','ignored') then now() else null end where id = :id", s=status, r=resolution, id=anomaly_id) __all__ = ["list_anomalies", "record", "run_checks", "set_status"]