"""/changes (cursor feed) · /changes/daily ("What changed in AI today") · /changes/categories.""" from __future__ import annotations from datetime import UTC, datetime from typing import Any from fastapi import APIRouter, Query, Request from aiatlas.api.common import ( ENTITY_COLS, ENTITY_FROM, EVENT_COLS, EVENT_FROM, ApiError, cached, change_event, csv, day_bounds, entity_summary, event_type_label, parse_date, parse_ts, resolve_id, ) from aiatlas.db import connection, fetch_all, fetch_val router = APIRouter(prefix="/api/v1/changes", tags=["changes"]) CATEGORY_LABELS = {"model": "Models", "price": "Pricing", "benchmark": "Benchmarks", "paper": "Research", "release": "Releases", "company": "Companies & labs", "provider": "Providers", "hardware": "Hardware", "framework": "Frameworks", "dataset": "Datasets", "regulation": "Regulation", "incident": "Incidents", "repository": "Repositories", "tool": "Tools", "source": "Sources & curation", "update": "Updates"} CATEGORY_ORDER = list(CATEGORY_LABELS) TOTAL_CAP = 10_000 def _filters(*, category: str | None, types: list[str], entity_type: str | None, importance_min: int | None, since: datetime | None, until: datetime | None, q: str | None, include_documents: bool, entity_id: str | None = None, before: datetime | None = None) -> tuple[list[str], dict[str, Any]]: where: list[str] = [] p: dict[str, Any] = {} if category: where.append("ev.category = any(cast(:cats as text[]))") p["cats"] = csv(category) if types: where.append("ev.event_type = any(cast(:types as text[]))") p["types"] = [t.upper() for t in types] elif not include_documents: where.append("ev.event_type <> 'DOCUMENT_CHANGED'") if entity_type: where.append("e.entity_type = any(cast(:etypes as text[]))") p["etypes"] = csv(entity_type) if importance_min is not None: where.append("ev.importance >= :imp") p["imp"] = importance_min if since is not None: where.append("ev.observed_at >= :since") p["since"] = since if until is not None: where.append("ev.observed_at <= :until") p["until"] = until if before is not None: where.append("ev.observed_at < :before") p["before"] = before if q: where.append("(ev.summary ilike :qlike or e.canonical_name ilike :qlike)") p["qlike"] = f"%{q}%" if entity_id: where.append("ev.entity_id = :eid") p["eid"] = entity_id return where or ["true"], p @router.get("") @cached(60) async def list_changes(request: Request, category: str | None = None, type: str | None = Query(None, alias="type"), entity_type: str | None = None, importance_min: int | None = Query(None, ge=0, le=3), since: str | None = None, until: str | None = None, q: str | None = Query(None, max_length=200), entity: str | None = None, limit: int = Query(50, ge=1, le=200), before: str | None = None, offset: int = Query(0, ge=0, le=10000), include_documents: int = Query(0, ge=0, le=1)) -> dict[str, Any]: before_ts, since_ts, until_ts = parse_ts(before, "before"), parse_ts(since, "since"), parse_ts(until, "until") async with connection() as conn: eid = await resolve_id(conn, entity) if entity else None where, params = _filters(category=category, types=csv(type), entity_type=entity_type, importance_min=importance_min, since=since_ts, until=until_ts, q=q, include_documents=bool(include_documents), entity_id=eid, before=before_ts) where_sql = " and ".join(where) rows = await fetch_all(conn, f"select {EVENT_COLS} from {EVENT_FROM} where {where_sql} order by ev.observed_at desc, ev.id desc limit :lim offset :off", lim=limit, off=offset, **params) total = await fetch_val(conn, f"select count(*) from (select 1 from {EVENT_FROM} where {where_sql} limit {TOTAL_CAP}) t", **params) items = [change_event(r) for r in rows] return {"items": items, "total": int(total or 0), "limit": limit, "offset": offset, "next_before": items[-1]["observed_at"] if len(items) == limit else None} @router.get("/daily") @cached(120) async def changes_daily(request: Request, date: str | None = None, per_section: int = Query(30, ge=1, le=100)) -> dict[str, Any]: d = parse_date(date, "date") or datetime.now(UTC).date() start, end = day_bounds(d) async with connection() as conn: rows = await fetch_all(conn, f"""select {EVENT_COLS} from ( select ev.*, row_number() over (partition by ev.category order by ev.importance desc, ev.observed_at desc) as rn from change_events ev where ev.observed_at >= :s and ev.observed_at <= :e and ev.event_type <> 'DOCUMENT_CHANGED') ev left join entities e on e.id = ev.entity_id left join entities eo on eo.id = e.organization_id where ev.rn <= :n order by ev.category, ev.rn""", s=start, e=end, n=per_section) counts = await fetch_all(conn, "select category, count(*) as n from change_events where observed_at >= :s and observed_at <= :e and event_type <> 'DOCUMENT_CHANGED' group by 1", s=start, e=end) new_models = await fetch_all(conn, f"select {ENTITY_COLS} from {ENTITY_FROM} where e.entity_type = 'model' and e.merged_into is null and e.first_seen_at >= :s and e.first_seen_at <= :e " f"order by e.first_seen_at desc limit 100", s=start, e=end) prev = await fetch_val(conn, "select max(observed_at)::date from change_events where observed_at < :s and event_type <> 'DOCUMENT_CHANGED'", s=start) nxt = await fetch_val(conn, "select min(observed_at)::date from change_events where observed_at > :e and event_type <> 'DOCUMENT_CHANGED'", e=end) by_cat: dict[str, list[dict[str, Any]]] = {} for r in rows: by_cat.setdefault(r["category"], []).append(change_event(r)) order = {c: i for i, c in enumerate(CATEGORY_ORDER)} sections = [{"category": c, "label": CATEGORY_LABELS.get(c, c.title()), "items": items} for c, items in sorted(by_cat.items(), key=lambda kv: (order.get(kv[0], 99), kv[0]))] return {"date": d.isoformat(), "counts": {r["category"]: int(r["n"]) for r in counts}, "total": sum(int(r["n"]) for r in counts), "sections": sections, "new_models": [entity_summary(r) for r in new_models], "labels": CATEGORY_LABELS, "previous_day": prev.isoformat() if prev else None, "next_day": nxt.isoformat() if nxt else None} @router.get("/categories") @cached(300) async def changes_categories(request: Request, days: int = Query(7, ge=1, le=365)) -> dict[str, Any]: async with connection() as conn: rows = await fetch_all(conn, """select category, event_type, count(*) as count from change_events where observed_at > now() - make_interval(days => :d) and event_type <> 'DOCUMENT_CHANGED' group by 1, 2 order by 3 desc, 1, 2""", d=days) return {"days": days, "items": [{"category": r["category"], "label": CATEGORY_LABELS.get(r["category"], r["category"].title()), "event_type": r["event_type"], "event_label": event_type_label(r["event_type"]), "count": int(r["count"])} for r in rows]} __all__ = ["CATEGORY_LABELS", "ApiError", "router"]