"""/methodology · /trending · POST /views · /sitemap · /api-keys/me.""" from __future__ import annotations import hashlib from datetime import UTC, datetime from typing import Any from fastapi import APIRouter, Depends, Query, Request from pydantic import BaseModel, Field from aiatlas.api.common import ( ENTITY_COLS, EVENT_TYPE_LABELS, STATUS_VOCAB, ApiError, cached, entity_summary, event_type_importance, event_type_label, rate_limit, ) from aiatlas.db import connection, execute, fetch_all, fetch_one, transaction from aiatlas.ids import ENTITY_TYPES from aiatlas.services.quality import EXPECTED_FIELDS, QUALITY_VERSION router = APIRouter(prefix="/api/v1", tags=["misc"]) CONFIDENCE_LEVELS = [ {"key": "verified", "label": "Verified", "description": "Confirmed by at least two independent sources, one of them tier 1."}, {"key": "high", "label": "High", "description": "Stated explicitly by an official (tier 1) source and extracted deterministically."}, {"key": "medium", "label": "Medium", "description": "Quality secondary source, curated registry, or LLM extraction from an official document."}, {"key": "low", "label": "Low", "description": "Community or unverified source, or LLM extraction from a secondary document."}, {"key": "conflicted", "label": "Conflicted", "description": "Another source states a different value; both claims are kept and flagged, never averaged."}, ] TIERS = [ {"tier": 1, "label": "Official / primary", "description": "The organization's own site, docs, pricing pages, model cards, filings."}, {"tier": 2, "label": "Quality secondary", "description": "Peer-reviewed venues, arXiv, curated registries, major leaderboards."}, {"tier": 3, "label": "Community", "description": "Community-maintained hubs, forums, wikis."}, {"tier": 4, "label": "Unverified", "description": "Anything else; never overrides a better tier."}, ] EXTRACTORS = [ {"key": "deterministic", "description": "Rule-based parsers (tables, JSON-LD, meta tags, embedded JSON, regex) — always runs first."}, {"key": "curated", "description": "Hand-maintained registries shipped with the code (organizations, providers, benchmarks, hardware)."}, {"key": "llm", "description": "Local LLM extraction validated against a strict JSON schema; medium/low confidence, never overrides deterministic tier-1 claims."}, ] @router.get("/methodology") @cached(600) async def methodology(request: Request) -> dict[str, Any]: async with connection() as conn: metrics = await fetch_all(conn, "select key, label, version, description, formula from metric_definitions order by key") event_types = await fetch_all(conn, "select event_type, category, count(*) as count, max(observed_at) as last_seen_at from change_events group by 1, 2 order by 3 desc") seen = {r["event_type"] for r in event_types} types = [{**r, "count": int(r["count"]), "label": event_type_label(r["event_type"]), "importance": event_type_importance(r["event_type"])} for r in event_types] types += [{"event_type": t, "category": None, "count": 0, "last_seen_at": None, "label": lbl, "importance": imp} for t, (lbl, imp) in EVENT_TYPE_LABELS.items() if t not in seen] return {"metrics": metrics, "quality_version": QUALITY_VERSION, "expected_fields": EXPECTED_FIELDS, "confidence_levels": CONFIDENCE_LEVELS, "tiers": TIERS, "event_types": types, "status_vocabulary": list(STATUS_VOCAB), "extractors": EXTRACTORS, "principles": ["Never fabricate: missing data is reported as unavailable.", "Every fact carries provenance (source, snapshot, URL, tier, confidence, extractor).", "History is append-only: claims, prices and benchmark results are never overwritten.", "Conflicts between sources are stored side by side and flagged for review.", "Live counters and feeds are computed from the database."]} @router.get("/trending") @cached(300) async def trending(request: Request, days: int = Query(7, ge=1, le=90), limit: int = Query(12, ge=1, le=60), type: str | None = Query(None, alias="type")) -> dict[str, Any]: async with connection() as conn: rows = await fetch_all(conn, f""" with v as (select split_part(regexp_replace(path, '[?#].*$', ''), '/', 3) as slug, sum(views) as views from page_views where day >= ((now() at time zone 'UTC') - make_interval(days => :d))::date and path ~ '^/[a-z-]+/[^/?#]+' group by 1) select v.views, {ENTITY_COLS} from v join entities e on e.slug = v.slug left join entities eo on eo.id = e.organization_id where e.merged_into is null {"and e.entity_type = :t" if type else ""} order by v.views desc, e.updated_at desc limit :lim""", d=days, lim=limit, t=type) return {"days": days, "items": [{**(entity_summary(r) or {}), "views": int(r["views"] or 0)} for r in rows]} class ViewBeacon(BaseModel): path: str = Field(..., min_length=1, max_length=300) @router.post("/views", dependencies=[Depends(rate_limit("views"))]) async def record_view(body: ViewBeacon) -> dict[str, Any]: path = body.path.strip() if not path.startswith("/") or "\n" in path or "//" in path: raise ApiError(400, "path must be a site-relative path") path = path.split("?", 1)[0].split("#", 1)[0][:300] async with transaction() as conn: await execute(conn, "insert into page_views (path, day, views) values (:p, :d, 1) on conflict (path, day) do update set views = page_views.views + 1", p=path, d=datetime.now(UTC).date()) return {"ok": True} @router.get("/sitemap") @cached(600) async def sitemap(request: Request, type: str | None = Query(None, alias="type"), limit: int = Query(5000, ge=1, le=5000), offset: int = Query(0, ge=0)) -> dict[str, Any]: if type and type not in ENTITY_TYPES: raise ApiError(400, f"unknown entity type {type!r}") where = "e.merged_into is null" + (" and e.entity_type = :t" if type else "") async with connection() as conn: rows = await fetch_all(conn, f"select e.slug, e.entity_type, e.updated_at from entities e where {where} order by e.updated_at desc, e.id limit :lim offset :off", t=type, lim=limit, off=offset) total = await fetch_one(conn, f"select count(*) as n from entities e where {where}", t=type) return {"items": rows, "total": int(total["n"]) if total else 0, "limit": limit, "offset": offset} @router.get("/api-keys/me") async def api_key_me(request: Request) -> dict[str, Any]: key = request.headers.get("x-api-key") or "" if not key: raise ApiError(401, "x-api-key header required") digest = hashlib.sha256(key.encode()).hexdigest() async with transaction() as conn: row = await fetch_one(conn, "update api_keys set last_used_at = now(), usage_count = usage_count + 1 where key_hash = :h and enabled returning label, plan, rate_per_min, usage_count, created_at, last_used_at", h=digest) if not row: raise ApiError(401, "unknown or disabled API key") return row