didi-lot1-ai/ai_platform/modules/didi_brain/brain_api/app.py

1283 lines
45 KiB
Python

"""FastAPI app exposing DidiBrain's HTTP API contract.
Five routes, matching the existing web-gathering module 1:1 in request/response
shape so Didi's backend can swap brain <-> web transparently:
POST /v1/search — flat list of doc-level search results
POST /v1/fetch — look up atoms by URL, return extracted text
POST /v1/gather — full claim → ranked evidence pipeline
POST /v1/image-search — stub, always empty (we have no image corpus)
POST /v1/ingest — populate brain from web-module output (async extraction)
Plus GET /health for liveness probes.
No auth in v1 (bind to 127.0.0.1 only). Auth to be added when dockerized.
"""
from __future__ import annotations
import time
import uuid
from contextlib import asynccontextmanager
from datetime import datetime
from typing import AsyncIterator
from dataclasses import asdict
from fastapi import BackgroundTasks, FastAPI, HTTPException, Query
from brain_api import deps
from brain_api.db import db as brain_db
from brain_api.schemas import (
AnalysisAtomData,
AnalysisAtomDetailResponse,
AnalysisAtomListItem,
AnalysisAtomListResponse,
AnalysisAtomLookupRequest,
AnalysisAtomLookupResponse,
AnalysisAtomPatchRequest,
AnalysisAtomStatsExtendedResponse,
AnalysisAtomStatsResponse,
AnalysisAtomWriteRequest,
AnalysisAtomWriteResponse,
AuditLogItem,
AuditLogResponse,
BrainMeta,
CacheInvalidateRequest,
CacheInvalidateResponse,
CanonicalizeRequest,
CanonicalizeResponse,
FactStatusItem,
FactStatusListResponse,
FactStatusPatchRequest,
FactStatusVersionItem,
FactStatusVersionsResponse,
FetchRequest,
FetchResponse,
GatherRequest,
GatherResponse,
GenericOkResponse,
ImageSearchRequest,
ImageSearchResponse,
IngestRequest,
IngestResponse,
SearchRequest,
SearchResponse,
TaxonomyInfoResponse,
TaxonomyReloadResponse,
VerificationCacheDetailResponse,
VerificationCacheListItem,
VerificationCacheListResponse,
VerificationCacheWriteRequest,
VerificationCacheWriteResponse,
)
from brain_api.services import admin as admin_svc
from brain_api.services import verification_cache as vcache
from brain_api.services import analysis_atom as atom_svc
from brain_api.services import fact_status as fact_svc
from brain_api.services.canonicalizer import canonicalize_claim_temporal
from brain_api.services.invalidation import (
InvalidateFilter,
invalidate_caches,
)
from brain_api.services.fetch import fetch as svc_fetch
from brain_api.services.gather import gather as svc_gather
from brain_api.services.ingest import ingest as svc_ingest
from brain_api.services.search import search as svc_search
from shared.atomic_api import AtomicClient
from shared.config import settings
from shared.embedding_client import EmbeddingClient
from shared.llm_client import LlmClient
from shared.logging import get_logger, setup_logging
from shared.taxonomy import TagResolver, build_path_map_from_tags
log = get_logger(__name__)
@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
setup_logging()
resolver = TagResolver()
atomic = AtomicClient()
embed = EmbeddingClient()
llm = LlmClient()
# Runtime config polling — pulls live overrides for atom_* + log_level
# from the AI platform dashboard. Pattern matches embeddings/rerank/catalog.
# Disabled when DASHBOARD_URL is not set (e.g. local dev runs).
from brain_api.runtime_config import RuntimeConfigClient, init_global_client
runtime_config = RuntimeConfigClient(
dashboard_url=settings.dashboard_url,
live_log_logger_name="brain_api",
live_log_key="brain.log.level",
)
init_global_client(runtime_config)
await runtime_config.start()
# Event sink — POSTs per-request events to dashboard /api/ingest/event so
# brain calls show up in the unified Insights/History view (module=brain).
# Same DASHBOARD_URL as runtime_config; disabled when unset.
from brain_api.events.sink import DashboardEventSink, init_global_sink
event_sink = DashboardEventSink(dashboard_url=settings.dashboard_url)
init_global_sink(event_sink)
await event_sink.start()
# Connect direct PG pool for brain-owned caches (verification, etc.). Same
# Postgres instance atomic-server uses — our tables are prefixed brain_ and
# live alongside atomic's own (no conflict). Failure here is soft: cache
# features degrade to miss but the rest of the service still starts.
try:
await brain_db.connect()
except Exception as e: # noqa: BLE001
log.error(
"brain_db_connect_failed",
error=f"{type(e).__name__}: {e}",
hint="verification_cache endpoints will return 503",
)
# Pull the current taxonomy from Atomic at startup so the container is
# self-sufficient and does not depend on a pre-generated _tag_ids.json.
# If Atomic is unreachable we fall back to whatever the resolver loaded
# from disk (may be empty); the service still starts so /health works
# for orchestration liveness probes.
try:
live_tags = await atomic.list_tags()
path_map = build_path_map_from_tags(live_tags)
if path_map:
resolver.load_from_mapping(path_map)
log.info("taxonomy_refreshed_from_atomic", count=len(path_map))
except Exception as e: # noqa: BLE001
log.warning(
"taxonomy_refresh_failed",
error=f"{type(e).__name__}: {e}",
hint="running with whatever is in _tag_ids.json or empty",
)
if not resolver.all:
log.warning(
"brain_api_starting_without_taxonomy",
hint="seed taxonomy by running scripts/04_seed_taxonomy.py on host",
)
deps.set_state(
deps.AppState(
atomic=atomic,
embed=embed,
llm=llm,
resolver=resolver,
)
)
log.info(
"brain_api_ready",
atomic_url=settings.atomic_url,
llm_router=settings.llm_router_url,
embed_url=settings.embedding_url,
reranker_url=settings.reranker_url,
taxonomy_size=len(resolver.all),
)
try:
yield
finally:
await runtime_config.stop()
await event_sink.stop()
await atomic.aclose()
await embed.aclose()
await llm.aclose()
await brain_db.close()
app = FastAPI(
title="DidiBrain API",
description=(
"HTTP interface that speaks the same contract as Didi's web-gathering "
"module, answering from pre-ingested knowledge atoms + semantic search "
"+ cross-encoder reranking. Drop-in cache/source for claim verification."
),
version="0.1.0",
lifespan=lifespan,
)
# ASGI middleware — emits a dashboard event for every non-meta request so brain
# calls show up in the unified Insights/History view alongside web-api activity.
from brain_api.events.middleware import event_emit_middleware # noqa: E402
app.middleware("http")(event_emit_middleware)
# ---------------------------------------------------------------- observability
# Prometheus /metrics endpoint
try:
from prometheus_fastapi_instrumentator import Instrumentator # type: ignore
Instrumentator(
should_group_status_codes=True,
should_ignore_untemplated=False,
).instrument(app).expose(app, endpoint="/metrics", include_in_schema=False)
except ImportError:
pass
# OpenTelemetry tracing (no-op if endpoint not set)
import os # noqa: E402
_otel_endpoint = os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
if _otel_endpoint:
try:
from opentelemetry import trace # type: ignore
from opentelemetry.sdk.resources import Resource # type: ignore
from opentelemetry.sdk.trace import TracerProvider # type: ignore
from opentelemetry.sdk.trace.export import BatchSpanProcessor # type: ignore
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( # type: ignore
OTLPSpanExporter,
)
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor # type: ignore
from opentelemetry.instrumentation.asyncpg import AsyncPGInstrumentor # type: ignore
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor # type: ignore
resource = Resource.create({
"service.name": os.environ.get("OTEL_SERVICE_NAME", "didibrain-api"),
"service.version": "0.1.0",
})
provider = TracerProvider(resource=resource)
provider.add_span_processor(
BatchSpanProcessor(OTLPSpanExporter(endpoint=_otel_endpoint, insecure=True))
)
trace.set_tracer_provider(provider)
FastAPIInstrumentor.instrument_app(app)
AsyncPGInstrumentor().instrument()
HTTPXClientInstrumentor().instrument()
print(f"[otel] didibrain-api instrumented, exporting to {_otel_endpoint}")
except ImportError as e:
print(f"[otel] init skipped (missing deps): {e}")
# --------------------------------------------------------------------- health
@app.get("/health", tags=["meta"])
async def health() -> dict:
return {
"status": "ok",
"service": "didibrain-api",
"version": app.version,
}
# ---------------------------------------------------------------------- search
@app.post("/v1/search", response_model=SearchResponse, tags=["v1"])
async def post_search(req: SearchRequest) -> SearchResponse:
state = deps.get_state()
try:
return await svc_search(req, atomic=state.atomic)
except Exception as e: # noqa: BLE001
log.error("search_failed", error=str(e))
raise HTTPException(status_code=500, detail=str(e))
# ----------------------------------------------------------------------- fetch
@app.post("/v1/fetch", response_model=FetchResponse, tags=["v1"])
async def post_fetch(req: FetchRequest) -> FetchResponse:
state = deps.get_state()
try:
return await svc_fetch(req, atomic=state.atomic)
except Exception as e: # noqa: BLE001
log.error("fetch_failed", error=str(e))
raise HTTPException(status_code=500, detail=str(e))
# ----------------------------------------------------------------------- gather
@app.post("/v1/gather", response_model=GatherResponse, tags=["v1"])
async def post_gather(req: GatherRequest) -> GatherResponse:
state = deps.get_state()
try:
return await svc_gather(
req,
atomic=state.atomic,
embed=state.embed,
llm=state.llm,
resolver=state.resolver,
)
except Exception as e: # noqa: BLE001
log.exception("gather_failed")
raise HTTPException(status_code=500, detail=str(e))
# ----------------------------------------------------------------- image-search
@app.post("/v1/image-search", response_model=ImageSearchResponse, tags=["v1"])
async def post_image_search(req: ImageSearchRequest) -> ImageSearchResponse:
"""Always returns an empty list — brain has no image corpus.
We still match the shape so Didi's backend can call us uniformly and know
to fall through to the real image-search service.
"""
t0 = time.perf_counter()
return ImageSearchResponse(
request_id=str(uuid.uuid4()),
results=[],
total_results=0,
execution_time_ms=round((time.perf_counter() - t0) * 1000, 1),
queries_processed=len(req.queries),
brain_meta=BrainMeta(
cache_status="MISS",
api_version="v1",
implementation="didibrain",
evidence_sources=0,
total_claim_atoms_matched=0,
),
)
# --------------------------------------------------------------- canonicalize
@app.post(
"/v1/canonicalize",
response_model=CanonicalizeResponse,
tags=["v1"],
)
async def post_canonicalize(req: CanonicalizeRequest) -> CanonicalizeResponse:
"""Resolve relative time markers + ambiguous entities in a claim (Pilon 7).
Caller (typically agent-v3 claims executor) sends raw user claim plus an
optional explicit current_date; brain returns the rewritten claim that
the caller should hash for cache lookups. Same surface text asked at
different times produces different canonical forms when temporal anchors
apply, so the same cache key cannot silently serve stale verdicts across
time horizons.
Failure-safe: if the LLM call fails, the response has canonical=original
and error populated. Caller should still proceed (no regression).
"""
state = deps.get_state()
# Optional explicit current_date — when agent-v3 wants to anchor a batch
# of claims to the same wall clock for determinism.
current_date: datetime | None = None
if req.current_date:
try:
current_date = datetime.fromisoformat(req.current_date)
except ValueError:
raise HTTPException(
status_code=422,
detail=f"current_date must be ISO format, got: {req.current_date}",
)
result = await canonicalize_claim_temporal(
state.llm,
claim=req.claim,
current_date=current_date,
)
return CanonicalizeResponse(
canonical=result.canonical,
original=result.original,
changed=result.changed,
anchors_added=result.anchors_added,
reasoning=result.reasoning,
error=result.error,
)
# ----------------------------------------------------------------- invalidate
@app.post(
"/v1/cache/invalidate",
response_model=CacheInvalidateResponse,
tags=["v1"],
)
async def post_cache_invalidate(
req: CacheInvalidateRequest,
) -> CacheInvalidateResponse:
"""Mass-invalidate analysis_atom + verification_cache rows by filter (Pilon 8).
Used by:
- admin dashboard "flush topic" button (manual)
- didibrain-breaking-watcher (real-time topic invalidation when
breaking news affects an entity or topic)
- cron jobs that flush evolved-news at end of day
Soft-deletes (sets ``expires_at = now()``) — rows stay for audit. Gold
atoms are spared by default; pass ``invalidate_gold=true`` to flush
those too (only in moderator-initiated flows).
Sub-endpoint of ``/v1/cache/...`` namespace (reserved for additional
cache-management endpoints in later phases — list-flushed, restore,
etc.).
"""
# Parse since if provided.
since: datetime | None = None
if req.since:
try:
since = datetime.fromisoformat(req.since)
except ValueError:
raise HTTPException(
status_code=422,
detail=f"since must be ISO datetime, got: {req.since}",
)
f = InvalidateFilter(
topic_codes=req.topic_codes,
entity_canonicals=req.entity_canonicals,
claim_pattern=req.claim_pattern,
since=since,
invalidate_gold=req.invalidate_gold,
dry_run=req.dry_run,
)
if f.is_empty():
raise HTTPException(
status_code=422,
detail=(
"filter must include at least one of: topic_codes, "
"entity_canonicals, claim_pattern, since"
),
)
# Rate-limit: max 10 non-dry-run invalidations per actor per hour. Dry-runs are free.
if not f.dry_run:
actor = req.actor or "api"
try:
from brain_api.db import get_db
async with get_db().acquire() as conn:
count_q = await conn.fetchrow(
"""
SELECT COUNT(*) AS c
FROM brain_audit_log
WHERE action = 'invalidate'
AND actor = $1
AND created_at >= now() - interval '1 hour'
AND COALESCE((payload->>'dry_run')::boolean, false) = false
""",
actor,
)
recent_count = int(count_q["c"]) if count_q else 0
RATE_LIMIT_PER_HOUR = 10
if recent_count >= RATE_LIMIT_PER_HOUR:
raise HTTPException(
status_code=429,
detail=(
f"Rate limit exceeded: actor '{actor}' has "
f"{recent_count} invalidations in the last hour "
f"(limit: {RATE_LIMIT_PER_HOUR}). Try again later "
f"or use dry_run=true."
),
)
except HTTPException:
raise
except Exception as e: # noqa: BLE001
log.warning("rate_limit_check_failed", error=str(e))
# Fail-open on rate-limit lookup error (don't block legitimate invalidations).
try:
result = await invalidate_caches(
f, actor=req.actor or "api", reason=req.reason
)
except RuntimeError as e:
log.error("invalidate_db_unavailable", error=str(e))
raise HTTPException(
status_code=503, detail="cache store unavailable"
)
except Exception as e: # noqa: BLE001
log.exception("invalidate_failed")
raise HTTPException(status_code=500, detail=str(e))
return CacheInvalidateResponse(
invalidated_atoms=result.invalidated_atoms,
invalidated_vcache=result.invalidated_vcache,
dry_run=result.dry_run,
filters_applied=result.filters_applied,
executed_at=result.executed_at,
)
# ----------------------------------------------------------------------- ingest
@app.post("/v1/ingest", response_model=IngestResponse, tags=["v1"])
async def post_ingest(
req: IngestRequest, background: BackgroundTasks
) -> IngestResponse:
state = deps.get_state()
try:
return await svc_ingest(
req,
atomic=state.atomic,
resolver=state.resolver,
background=background,
llm=state.llm,
)
except Exception as e: # noqa: BLE001
log.exception("ingest_failed")
raise HTTPException(status_code=500, detail=str(e))
# ----------------------------------------------------------- verification_cache
@app.post(
"/v1/verification_cache",
response_model=VerificationCacheWriteResponse,
tags=["v1"],
)
async def post_verification_cache(
req: VerificationCacheWriteRequest,
) -> VerificationCacheWriteResponse:
"""Store an LLM verification result for (claim, evidence_urls, tier).
Contract with didi-backend:
- backend runs its own LLM verification call (owns prompt + model)
- on success, fire-and-forget POSTs the result here
- next /v1/gather with include_verification=true for the same
(claim, evidence_urls, tier) returns the cached payload in brain_meta
Storage is last-wins on the unique key; payloads are stored verbatim as
opaque jsonb so backend can evolve the structure without brain changes.
"""
# Quick payload-size guard so we don't accept multi-MB junk by accident.
try:
import json as _json
body_bytes = len(_json.dumps(req.model_dump()).encode("utf-8"))
except Exception: # noqa: BLE001
body_bytes = 0
cap = settings.verification_cache_max_payload_kb * 1024
if body_bytes > cap:
raise HTTPException(
status_code=413,
detail=(
f"payload too large: {body_bytes} bytes > "
f"{cap} bytes cap (set via verification_cache_max_payload_kb)"
),
)
try:
state = deps.get_state()
entry = await vcache.upsert(
claim=req.claim,
evidence_urls=req.evidence_urls,
tier=req.tier,
prompt_hash=req.prompt_hash,
verification_processed=req.verification_processed,
verification_raw=req.verification_raw,
model=req.model,
framework_version=req.framework_version,
schema_name=req.schema_name,
llm=state.llm,
)
except RuntimeError as e:
# brain_db not connected — fail soft with 503 so backend logs + retries later
log.error("verification_cache_db_unavailable", error=str(e))
raise HTTPException(status_code=503, detail="verification cache unavailable")
except Exception as e: # noqa: BLE001
log.exception("verification_cache_write_failed")
raise HTTPException(status_code=500, detail=str(e))
return VerificationCacheWriteResponse(
cached=True,
claim_hash=entry.claim_hash,
evidence_hash=entry.evidence_hash,
tier=entry.tier,
created_at=entry.created_at,
updated_at=entry.updated_at,
expires_at=entry.expires_at,
)
# ----------------------------------------------------------- analysis_atom
# Cache for full-component LLM results (techniques, ai_tampered).
# Lookup tier-agnostic, write only on tier=premium, gold tier from HIL moderation.
@app.post(
"/v1/analysis_atom/lookup",
response_model=AnalysisAtomLookupResponse,
tags=["v1"],
)
async def post_analysis_atom_lookup(
req: AnalysisAtomLookupRequest,
) -> AnalysisAtomLookupResponse:
"""Lookup an atom by (content_hash, component, prompt_hash).
Returns hit=True with the cached entry if a fresh gold or silver atom
matches. Bronze atoms are NEVER served. Gold atoms survive prompt change.
"""
try:
entry, staleness = await atom_svc.lookup(
content_hash=req.content_hash,
component=req.component,
prompt_hash=req.prompt_hash,
framework_version=req.framework_version,
)
except RuntimeError as e:
log.error("analysis_atom_db_unavailable", error=str(e))
raise HTTPException(status_code=503, detail="analysis atom store unavailable")
except Exception as e: # noqa: BLE001
log.exception("analysis_atom_lookup_failed")
raise HTTPException(status_code=500, detail=str(e))
if entry is None or staleness == "miss":
return AnalysisAtomLookupResponse(hit=False, atom=None, staleness="miss")
# Only serve fresh entries — backend can choose to ignore stale
return AnalysisAtomLookupResponse(
hit=staleness == "fresh",
atom=AnalysisAtomData(
atom_id=entry.atom_id,
content_hash=entry.content_hash,
component=entry.component,
tier=entry.tier,
prompt_hash=entry.prompt_hash,
framework_version=entry.framework_version,
model_used=entry.model_used,
cache_tier=entry.cache_tier,
human_validated=entry.human_validated,
result_processed=entry.result_processed,
validator_user_id=entry.validator_user_id,
validated_at=entry.validated_at,
hit_count=entry.hit_count,
created_at=entry.created_at,
updated_at=entry.updated_at,
expires_at=entry.expires_at,
),
staleness=staleness,
match_type="exact",
)
@app.post(
"/v1/analysis_atom",
response_model=AnalysisAtomWriteResponse,
tags=["v1"],
)
async def post_analysis_atom_write(
req: AnalysisAtomWriteRequest,
) -> AnalysisAtomWriteResponse:
"""Write/upsert an atom. Rejects tier=free silently.
Runs the volatility classifier on ``content_preview`` (Pilon 1) before
the SQL upsert, deriving topic_codes / entity_bindings / TTL. Falls back
to legacy fixed-TTL behavior if the classifier is unavailable.
"""
try:
state = deps.get_state()
entry, skip_reason = await atom_svc.upsert(
content_hash=req.content_hash,
content_preview=req.content_preview,
component=req.component,
tier=req.tier,
prompt_hash=req.prompt_hash,
framework_version=req.framework_version,
model_used=req.model_used,
result_processed=req.result_processed,
result_raw=req.result_raw,
llm_confidence=req.llm_confidence,
cache_tier_override=req.cache_tier,
llm=state.llm,
)
except RuntimeError as e:
log.error("analysis_atom_db_unavailable", error=str(e))
raise HTTPException(status_code=503, detail="analysis atom store unavailable")
except Exception as e: # noqa: BLE001
log.exception("analysis_atom_write_failed")
raise HTTPException(status_code=500, detail=str(e))
if entry is None:
return AnalysisAtomWriteResponse(cached=False, skipped_reason=skip_reason)
return AnalysisAtomWriteResponse(
cached=True,
atom_id=entry.atom_id,
cache_tier=entry.cache_tier,
)
@app.patch(
"/v1/analysis_atom/{atom_id}",
response_model=AnalysisAtomData,
tags=["v1"],
)
async def patch_analysis_atom(
atom_id: int,
req: AnalysisAtomPatchRequest,
) -> AnalysisAtomData:
"""Promote atom to gold (after moderator review)."""
try:
entry = await atom_svc.patch_to_gold(
atom_id=atom_id,
human_validated=req.human_validated,
human_corrections=req.human_corrections,
validator_user_id=req.validator_user_id,
result_processed=req.result_processed,
)
except RuntimeError as e:
log.error("analysis_atom_db_unavailable", error=str(e))
raise HTTPException(status_code=503, detail="analysis atom store unavailable")
except Exception as e: # noqa: BLE001
log.exception("analysis_atom_patch_failed")
raise HTTPException(status_code=500, detail=str(e))
if entry is None:
raise HTTPException(status_code=404, detail=f"atom_id={atom_id} not found")
return AnalysisAtomData(
atom_id=entry.atom_id,
content_hash=entry.content_hash,
component=entry.component,
tier=entry.tier,
prompt_hash=entry.prompt_hash,
framework_version=entry.framework_version,
model_used=entry.model_used,
cache_tier=entry.cache_tier,
human_validated=entry.human_validated,
result_processed=entry.result_processed,
validator_user_id=entry.validator_user_id,
validated_at=entry.validated_at,
hit_count=entry.hit_count,
created_at=entry.created_at,
updated_at=entry.updated_at,
expires_at=entry.expires_at,
)
@app.get(
"/v1/analysis_atom/stats",
response_model=AnalysisAtomStatsResponse,
tags=["v1"],
)
async def get_analysis_atom_stats() -> AnalysisAtomStatsResponse:
"""Counts and hit rate for monitoring."""
try:
stats = await atom_svc.get_stats()
except RuntimeError as e:
log.error("analysis_atom_db_unavailable", error=str(e))
raise HTTPException(status_code=503, detail="analysis atom store unavailable")
except Exception as e: # noqa: BLE001
log.exception("analysis_atom_stats_failed")
raise HTTPException(status_code=500, detail=str(e))
return AnalysisAtomStatsResponse(**stats)
# =========================================================================
# Admin endpoints (consumed by AI platform dashboard reskin)
# =========================================================================
# IMPORTANT: literal paths (/list, /stats/extended) MUST be declared before
# parameterized {atom_id} routes so FastAPI matches them first.
# =========================================================================
@app.get(
"/v1/analysis_atom/list",
response_model=AnalysisAtomListResponse,
tags=["admin"],
)
async def list_analysis_atoms(
component: str | None = Query(None, description="techniques | ai_tampered | claims | all"),
tier: str | None = Query(None, description="cache tier filter: gold | silver | bronze | all"),
freshness: str | None = Query(None, description="fresh | expiring | expired | all"),
q: str | None = Query(None, description="ILIKE search on content_preview / content_hash"),
page: int = Query(1, ge=1),
page_size: int = Query(25, ge=1, le=100),
) -> AnalysisAtomListResponse:
"""Paginated atom browser for the dashboard."""
try:
result = await admin_svc.list_atoms(
component=component,
tier=tier,
freshness=freshness,
q=q,
page=page,
page_size=page_size,
)
except RuntimeError as e:
log.error("admin_atom_list_db_unavailable", error=str(e))
raise HTTPException(status_code=503, detail="atom store unavailable")
except Exception as e: # noqa: BLE001
log.exception("admin_atom_list_failed")
raise HTTPException(status_code=500, detail=str(e))
return AnalysisAtomListResponse(
items=[AnalysisAtomListItem(**asdict(r)) for r in result.items],
total=result.total,
page=result.page,
page_size=result.page_size,
)
@app.get(
"/v1/analysis_atom/stats/extended",
response_model=AnalysisAtomStatsExtendedResponse,
tags=["admin"],
)
async def get_analysis_atom_stats_extended() -> AnalysisAtomStatsExtendedResponse:
"""Extended stats: per-tier hit counts, gold promotions, recent activity."""
try:
stats = await admin_svc.get_stats_extended()
except RuntimeError as e:
log.error("admin_stats_db_unavailable", error=str(e))
raise HTTPException(status_code=503, detail="atom store unavailable")
except Exception as e: # noqa: BLE001
log.exception("admin_stats_failed")
raise HTTPException(status_code=500, detail=str(e))
return AnalysisAtomStatsExtendedResponse(**stats)
@app.get(
"/v1/analysis_atom/{atom_id}",
response_model=AnalysisAtomDetailResponse,
tags=["admin"],
)
async def get_analysis_atom(atom_id: int) -> AnalysisAtomDetailResponse:
"""Full atom row by ID (admin browser detail view)."""
try:
row = await admin_svc.get_atom(atom_id)
except RuntimeError as e:
log.error("admin_atom_get_db_unavailable", error=str(e))
raise HTTPException(status_code=503, detail="atom store unavailable")
except Exception as e: # noqa: BLE001
log.exception("admin_atom_get_failed")
raise HTTPException(status_code=500, detail=str(e))
if not row:
raise HTTPException(status_code=404, detail=f"atom_id={atom_id} not found")
return AnalysisAtomDetailResponse(**asdict(row))
@app.delete(
"/v1/analysis_atom/{atom_id}",
response_model=GenericOkResponse,
tags=["admin"],
)
async def delete_analysis_atom(atom_id: int) -> GenericOkResponse:
"""Mark an atom as expired (soft delete — keeps row for audit).
The row stays in PG with `expires_at = now()` so future lookups skip it.
A periodic TTL cleanup will hard-delete eventually.
"""
try:
ok = await admin_svc.expire_atom(atom_id)
except RuntimeError as e:
log.error("admin_atom_delete_db_unavailable", error=str(e))
raise HTTPException(status_code=503, detail="atom store unavailable")
except Exception as e: # noqa: BLE001
log.exception("admin_atom_delete_failed")
raise HTTPException(status_code=500, detail=str(e))
if not ok:
raise HTTPException(status_code=404, detail=f"atom_id={atom_id} not found")
return GenericOkResponse(ok=True, message=f"atom_id={atom_id} marked expired")
# ---------------------------------------------------------------- verification
@app.get(
"/v1/verification_cache/list",
response_model=VerificationCacheListResponse,
tags=["admin"],
)
async def list_verifications(
tier: str | None = Query(None, description="free | premium | all"),
q: str | None = Query(None, description="ILIKE search on claim_hash / model"),
page: int = Query(1, ge=1),
page_size: int = Query(25, ge=1, le=100),
) -> VerificationCacheListResponse:
"""Paginated browser of verification_cache entries."""
try:
result = await admin_svc.list_verifications(
tier=tier, q=q, page=page, page_size=page_size,
)
except RuntimeError as e:
log.error("admin_verif_list_db_unavailable", error=str(e))
raise HTTPException(status_code=503, detail="verification cache unavailable")
except Exception as e: # noqa: BLE001
log.exception("admin_verif_list_failed")
raise HTTPException(status_code=500, detail=str(e))
return VerificationCacheListResponse(
items=[VerificationCacheListItem(**asdict(r)) for r in result.items],
total=result.total,
page=result.page,
page_size=result.page_size,
)
@app.get(
"/v1/verification_cache/{claim_hash}/{tier}",
response_model=VerificationCacheDetailResponse,
tags=["admin"],
)
async def get_verification(
claim_hash: str, tier: str
) -> VerificationCacheDetailResponse:
"""Full verification cache row by composite key."""
if tier not in ("free", "premium"):
raise HTTPException(status_code=422, detail="tier must be 'free' or 'premium'")
try:
row = await admin_svc.get_verification(claim_hash, tier) # type: ignore[arg-type]
except RuntimeError as e:
log.error("admin_verif_get_db_unavailable", error=str(e))
raise HTTPException(status_code=503, detail="verification cache unavailable")
except Exception as e: # noqa: BLE001
log.exception("admin_verif_get_failed")
raise HTTPException(status_code=500, detail=str(e))
if not row:
raise HTTPException(status_code=404, detail="verification entry not found")
return VerificationCacheDetailResponse(**asdict(row))
@app.delete(
"/v1/verification_cache/{claim_hash}/{tier}",
response_model=GenericOkResponse,
tags=["admin"],
)
async def delete_verification(
claim_hash: str, tier: str
) -> GenericOkResponse:
"""Hard-delete a verification cache entry."""
if tier not in ("free", "premium"):
raise HTTPException(status_code=422, detail="tier must be 'free' or 'premium'")
try:
ok = await admin_svc.delete_verification(claim_hash, tier) # type: ignore[arg-type]
except RuntimeError as e:
log.error("admin_verif_delete_db_unavailable", error=str(e))
raise HTTPException(status_code=503, detail="verification cache unavailable")
except Exception as e: # noqa: BLE001
log.exception("admin_verif_delete_failed")
raise HTTPException(status_code=500, detail=str(e))
if not ok:
raise HTTPException(status_code=404, detail="verification entry not found")
return GenericOkResponse(ok=True, message=f"deleted verification {claim_hash[:12]}.../{tier}")
# -------------------------------------------------------------------- taxonomy
@app.get(
"/v1/taxonomy",
response_model=TaxonomyInfoResponse,
tags=["admin"],
)
async def get_taxonomy() -> TaxonomyInfoResponse:
"""Snapshot of currently loaded taxonomy (in-memory resolver state)."""
state = deps.get_state()
info = await admin_svc.get_taxonomy_info(state.resolver)
return TaxonomyInfoResponse(**info)
@app.post(
"/v1/taxonomy/reload",
response_model=TaxonomyReloadResponse,
tags=["admin"],
)
async def reload_taxonomy() -> TaxonomyReloadResponse:
"""Re-fetch tags from atomic and replace the in-process resolver."""
state = deps.get_state()
result = await admin_svc.reload_taxonomy(state.resolver, state.atomic)
return TaxonomyReloadResponse(**result)
# =========================================================================
# Phase D2 — fact_status admin endpoints
# =========================================================================
# Read-only browsing + moderator override on (subject, predicate, object)
# triples extracted from claims. Used by the admin dashboard to inspect
# what brain knows about the world and to lock high-stakes facts so the
# auditor can't auto-flip them.
# =========================================================================
def _fact_to_item(fact: fact_svc.FactRecord) -> FactStatusItem:
"""Map FactRecord (dataclass) → FactStatusItem (pydantic) for response.
Maps the dataclass attribute ``obj`` to the schema field ``object`` so
the JSON shape matches the canonical (subject, predicate, object) form.
"""
return FactStatusItem(
fact_id=fact.fact_id,
subject=fact.subject,
predicate=fact.predicate,
object=fact.obj,
canonical_form=fact.canonical_form,
canonical_form_hash=fact.canonical_form_hash,
current_truth=fact.current_truth,
current_version_id=fact.current_version_id,
current_confidence=fact.current_confidence,
last_verified_at=fact.last_verified_at,
last_evidence_urls=fact.last_evidence_urls,
volatility=fact.volatility,
topic_codes=fact.topic_codes,
next_check_at=fact.next_check_at,
check_interval_hours=fact.check_interval_hours,
moderator_locked=fact.moderator_locked,
moderator_user_id=fact.moderator_user_id,
moderator_notes=fact.moderator_notes,
created_at=fact.created_at,
updated_at=fact.updated_at,
)
@app.get(
"/v1/fact_status/list",
response_model=FactStatusListResponse,
tags=["admin"],
)
async def list_fact_status(
entity: str | None = Query(None, description="ILIKE match on subject OR object"),
predicate: str | None = Query(None, description="Exact match on predicate"),
current_truth: bool | None = Query(None, description="Filter to TRUE/FALSE only"),
locked_only: bool = Query(False, description="Show only moderator-locked facts"),
topic: str | None = Query(None, description="Filter by topic_code in topic_codes"),
page: int = Query(1, ge=1),
page_size: int = Query(25, ge=1, le=100),
) -> FactStatusListResponse:
"""Paginated browser of brain_fact_status."""
try:
result = await fact_svc.list_facts_admin(
entity=entity,
predicate=predicate,
current_truth=current_truth,
locked_only=locked_only,
topic=topic,
page=page,
page_size=page_size,
)
except RuntimeError as e:
log.error("fact_status_list_db_unavailable", error=str(e))
raise HTTPException(status_code=503, detail="fact store unavailable")
except Exception as e: # noqa: BLE001
log.exception("fact_status_list_failed")
raise HTTPException(status_code=500, detail=str(e))
return FactStatusListResponse(
items=[_fact_to_item(f) for f in result.items],
total=result.total,
page=result.page,
page_size=result.page_size,
)
@app.get(
"/v1/fact_status/due_for_recheck",
tags=["admin"],
)
async def fact_status_due_for_recheck(
limit: int = Query(50, ge=1, le=500),
volatility: str | None = Query(None, description="Filter: volatile|evolving|stable"),
) -> dict:
"""Facts whose `next_check_at` has passed and need re-verification.
Excludes moderator-locked facts (those are managed by humans). Sorted by
next_check_at ASC (oldest first). Use this for proactive moderation —
show in dashboard so operators can prioritize re-checking stale facts.
"""
try:
items = await fact_svc.list_facts_due_for_check(
limit=limit, volatility=volatility
)
except RuntimeError as e:
log.error("fact_due_for_recheck_db_unavailable", error=str(e))
raise HTTPException(status_code=503, detail="fact store unavailable")
except Exception as e: # noqa: BLE001
log.exception("fact_due_for_recheck_failed")
raise HTTPException(status_code=500, detail=str(e))
return {
"items": [_fact_to_item(f).model_dump(mode="json") for f in items],
"count": len(items),
"limit": limit,
"volatility_filter": volatility,
}
@app.get(
"/v1/fact_status/{fact_id}",
response_model=FactStatusItem,
tags=["admin"],
)
async def get_fact_status(fact_id: int) -> FactStatusItem:
"""Single fact by ID."""
try:
fact = await fact_svc.get_fact_by_id(fact_id)
except RuntimeError as e:
log.error("fact_status_get_db_unavailable", error=str(e))
raise HTTPException(status_code=503, detail="fact store unavailable")
if fact is None:
raise HTTPException(status_code=404, detail=f"fact_id={fact_id} not found")
return _fact_to_item(fact)
@app.get(
"/v1/fact_status/{fact_id}/versions",
response_model=FactStatusVersionsResponse,
tags=["admin"],
)
async def get_fact_versions(fact_id: int) -> FactStatusVersionsResponse:
"""Timeline of all versions for one fact, newest first."""
try:
versions = await fact_svc.list_versions(fact_id)
except RuntimeError as e:
log.error("fact_versions_db_unavailable", error=str(e))
raise HTTPException(status_code=503, detail="fact store unavailable")
return FactStatusVersionsResponse(
fact_id=fact_id,
total=len(versions),
versions=[
FactStatusVersionItem(
version_id=v.version_id,
fact_id=v.fact_id,
truth_value=v.truth_value,
confidence=v.confidence,
valid_from=v.valid_from,
valid_to=v.valid_to,
source_atom_ids=v.source_atom_ids,
evidence_urls=v.evidence_urls,
llm_reasoning=v.llm_reasoning,
created_by=v.created_by,
moderator_user_id=v.moderator_user_id,
notes=v.notes,
created_at=v.created_at,
)
for v in versions
],
)
@app.patch(
"/v1/fact_status/{fact_id}",
response_model=FactStatusItem,
tags=["admin"],
)
async def patch_fact_status(
fact_id: int, req: FactStatusPatchRequest
) -> FactStatusItem:
"""Moderator override: set truth, lock, or unlock a fact.
All three operations are independent — caller may set truth and lock
in the same call, or just toggle lock without changing truth.
Returns the updated FactRecord. Raises 404 if fact_id doesn't exist.
"""
# Look up the fact first so we can address it by canonical_form_hash
# in the service layer (which keys on hash, not id).
fact = await fact_svc.get_fact_by_id(fact_id)
if fact is None:
raise HTTPException(status_code=404, detail=f"fact_id={fact_id} not found")
# Step 1: optional set_truth.
if req.set_truth is not None:
try:
result = await fact_svc.assert_fact_truth(
canonical_form_hash=fact.canonical_form_hash,
truth_value=req.set_truth,
confidence=req.confidence,
evidence_urls=req.evidence_urls,
source_atom_ids=None,
llm_reasoning=None,
created_by="moderator",
moderator_user_id=req.moderator_user_id,
notes=req.notes,
)
except RuntimeError as e:
log.error("fact_assert_db_unavailable", error=str(e))
raise HTTPException(status_code=503, detail="fact store unavailable")
except Exception as e: # noqa: BLE001
log.exception("fact_assert_failed")
raise HTTPException(status_code=500, detail=str(e))
if result is None:
# locked + non-moderator path is only triggered for created_by!=moderator
# so this branch shouldn't fire here, but stay defensive.
raise HTTPException(
status_code=409,
detail="fact is locked and cannot be changed by this caller",
)
# Step 2: optional lock/unlock.
if req.lock is True:
await fact_svc.lock_fact(
canonical_form_hash=fact.canonical_form_hash,
moderator_user_id=req.moderator_user_id,
moderator_notes=req.notes,
)
elif req.lock is False:
await fact_svc.unlock_fact(
canonical_form_hash=fact.canonical_form_hash,
moderator_user_id=req.moderator_user_id,
)
# Re-fetch and return the latest state.
refreshed = await fact_svc.get_fact_by_id(fact_id)
if refreshed is None:
raise HTTPException(status_code=404, detail=f"fact_id={fact_id} disappeared")
return _fact_to_item(refreshed)
# =========================================================================
# Phase D2 — audit log browser
# =========================================================================
@app.get(
"/v1/cache/audit_log",
response_model=AuditLogResponse,
tags=["admin"],
)
async def list_audit_log(
action: str | None = Query(None, description="Action prefix (ILIKE 'X%')"),
target_table: str | None = Query(None, description="Exact target_table match"),
actor: str | None = Query(None, description="ILIKE match on actor"),
since: str | None = Query(None, description="ISO datetime — entries at or after"),
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
) -> AuditLogResponse:
"""Browse brain_audit_log with filters + pagination."""
parsed_since: datetime | None = None
if since:
try:
parsed_since = datetime.fromisoformat(since)
except ValueError:
raise HTTPException(
status_code=422,
detail=f"since must be ISO datetime, got: {since}",
)
try:
items, total = await fact_svc.list_audit_log(
action=action,
target_table=target_table,
actor=actor,
since=parsed_since,
page=page,
page_size=page_size,
)
except RuntimeError as e:
log.error("audit_log_db_unavailable", error=str(e))
raise HTTPException(status_code=503, detail="audit log unavailable")
except Exception as e: # noqa: BLE001
log.exception("audit_log_failed")
raise HTTPException(status_code=500, detail=str(e))
return AuditLogResponse(
items=[AuditLogItem(**i) for i in items],
total=total,
page=page,
page_size=page_size,
)