1"""/methodology · /trending · POST /views · /sitemap · /api-keys/me."""2from __future__ import annotations34import hashlib5from datetime import UTC, datetime6from typing import Any78from fastapi import APIRouter, Depends, Query, Request9from pydantic import BaseModel, Field1011from aiatlas.api.common import (12 ENTITY_COLS,13 EVENT_TYPE_LABELS,14 STATUS_VOCAB,15 ApiError,16 cached,17 entity_summary,18 event_type_importance,19 event_type_label,20 rate_limit,21)22from aiatlas.db import connection, execute, fetch_all, fetch_one, transaction23from aiatlas.ids import ENTITY_TYPES24from aiatlas.services.quality import EXPECTED_FIELDS, QUALITY_VERSION2526router = APIRouter(prefix="/api/v1", tags=["misc"])2728CONFIDENCE_LEVELS = [29 {"key": "verified", "label": "Verified", "description": "Confirmed by at least two independent sources, one of them tier 1."},30 {"key": "high", "label": "High", "description": "Stated explicitly by an official (tier 1) source and extracted deterministically."},31 {"key": "medium", "label": "Medium", "description": "Quality secondary source, curated registry, or LLM extraction from an official document."},32 {"key": "low", "label": "Low", "description": "Community or unverified source, or LLM extraction from a secondary document."},33 {"key": "conflicted", "label": "Conflicted", "description": "Another source states a different value; both claims are kept and flagged, never averaged."},34]35TIERS = [36 {"tier": 1, "label": "Official / primary", "description": "The organization's own site, docs, pricing pages, model cards, filings."},37 {"tier": 2, "label": "Quality secondary", "description": "Peer-reviewed venues, arXiv, curated registries, major leaderboards."},38 {"tier": 3, "label": "Community", "description": "Community-maintained hubs, forums, wikis."},39 {"tier": 4, "label": "Unverified", "description": "Anything else; never overrides a better tier."},40]41EXTRACTORS = [42 {"key": "deterministic", "description": "Rule-based parsers (tables, JSON-LD, meta tags, embedded JSON, regex) — always runs first."},43 {"key": "curated", "description": "Hand-maintained registries shipped with the code (organizations, providers, benchmarks, hardware)."},44 {"key": "llm", "description": "Local LLM extraction validated against a strict JSON schema; medium/low confidence, never overrides deterministic tier-1 claims."},45]464748@router.get("/methodology")49@cached(600)50async def methodology(request: Request) -> dict[str, Any]:51 async with connection() as conn:52 metrics = await fetch_all(conn, "select key, label, version, description, formula from metric_definitions order by key")53 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")54 seen = {r["event_type"] for r in event_types}55 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]56 types += [{"event_type": t, "category": None, "count": 0, "last_seen_at": None, "label": lbl, "importance": imp}57 for t, (lbl, imp) in EVENT_TYPE_LABELS.items() if t not in seen]58 return {"metrics": metrics, "quality_version": QUALITY_VERSION, "expected_fields": EXPECTED_FIELDS, "confidence_levels": CONFIDENCE_LEVELS, "tiers": TIERS,59 "event_types": types, "status_vocabulary": list(STATUS_VOCAB), "extractors": EXTRACTORS,60 "principles": ["Never fabricate: missing data is reported as unavailable.", "Every fact carries provenance (source, snapshot, URL, tier, confidence, extractor).",61 "History is append-only: claims, prices and benchmark results are never overwritten.",62 "Conflicts between sources are stored side by side and flagged for review.", "Live counters and feeds are computed from the database."]}636465@router.get("/trending")66@cached(300)67async 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]:68 async with connection() as conn:69 rows = await fetch_all(conn, f"""70 with v as (select split_part(regexp_replace(path, '[?#].*$', ''), '/', 3) as slug, sum(views) as views from page_views71 where day >= ((now() at time zone 'UTC') - make_interval(days => :d))::date and path ~ '^/[a-z-]+/[^/?#]+' group by 1)72 select v.views, {ENTITY_COLS} from v join entities e on e.slug = v.slug left join entities eo on eo.id = e.organization_id73 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)74 return {"days": days, "items": [{**(entity_summary(r) or {}), "views": int(r["views"] or 0)} for r in rows]}757677class ViewBeacon(BaseModel):78 path: str = Field(..., min_length=1, max_length=300)798081@router.post("/views", dependencies=[Depends(rate_limit("views"))])82async def record_view(body: ViewBeacon) -> dict[str, Any]:83 path = body.path.strip()84 if not path.startswith("/") or "\n" in path or "//" in path:85 raise ApiError(400, "path must be a site-relative path")86 path = path.split("?", 1)[0].split("#", 1)[0][:300]87 async with transaction() as conn:88 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",89 p=path, d=datetime.now(UTC).date())90 return {"ok": True}919293@router.get("/sitemap")94@cached(600)95async 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]:96 if type and type not in ENTITY_TYPES:97 raise ApiError(400, f"unknown entity type {type!r}")98 where = "e.merged_into is null" + (" and e.entity_type = :t" if type else "")99 async with connection() as conn:100 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)101 total = await fetch_one(conn, f"select count(*) as n from entities e where {where}", t=type)102 return {"items": rows, "total": int(total["n"]) if total else 0, "limit": limit, "offset": offset}103104105@router.get("/api-keys/me")106async def api_key_me(request: Request) -> dict[str, Any]:107 key = request.headers.get("x-api-key") or ""108 if not key:109 raise ApiError(401, "x-api-key header required")110 digest = hashlib.sha256(key.encode()).hexdigest()111 async with transaction() as conn:112 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)113 if not row:114 raise ApiError(401, "unknown or disabled API key")115 return row116