Livrare LOT 1 - Didi
This commit is contained in:
commit
5380c3fc63
990 changed files with 133308 additions and 0 deletions
60
ai_platform/modules/didi_brain/brain_api/Dockerfile
Normal file
60
ai_platform/modules/didi_brain/brain_api/Dockerfile
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# syntax=docker/dockerfile:1.6
|
||||
# =============================================================================
|
||||
# brain_api — DidiBrain HTTP service that speaks Didi's web-module contract.
|
||||
#
|
||||
# Build context is the didibrain/ project root (one level up), so we can
|
||||
# COPY shared/ + extractor/ + brain_api/ in one shot:
|
||||
#
|
||||
# docker build -t didibrain-api -f brain_api/Dockerfile .
|
||||
#
|
||||
# Build via docker-compose at infra/docker-compose.yml (see `brain-api`
|
||||
# service) for the normal flow.
|
||||
# =============================================================================
|
||||
|
||||
FROM python:3.12-slim-bookworm AS runtime
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PYTHONIOENCODING=utf-8 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
PIP_ROOT_USER_ACTION=ignore \
|
||||
BRAIN_API_HOST=0.0.0.0 \
|
||||
BRAIN_API_PORT=8090
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# curl is used by the HEALTHCHECK directive below.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies first so the layer is cached when only app
|
||||
# code changes (which is the common case while iterating).
|
||||
COPY brain_api/requirements.txt /tmp/requirements.txt
|
||||
RUN pip install --upgrade pip \
|
||||
&& pip install -r /tmp/requirements.txt \
|
||||
&& rm /tmp/requirements.txt
|
||||
|
||||
# App code. We intentionally COPY each top-level package separately so any
|
||||
# accidental extras (reports/, .venv/, etc.) don't sneak in even if
|
||||
# .dockerignore is missing.
|
||||
COPY shared /app/shared
|
||||
COPY extractor /app/extractor
|
||||
COPY brain_api /app/brain_api
|
||||
|
||||
# Non-root runtime for safety. /app is owned by `brain` so the extractor
|
||||
# state file (extractor/_extracted.json) can be written if /v1/ingest fires
|
||||
# a background extraction run.
|
||||
RUN useradd --system --create-home --shell /bin/false brain \
|
||||
&& chown -R brain:brain /app
|
||||
USER brain
|
||||
|
||||
EXPOSE 8090
|
||||
|
||||
# Liveness — the app exposes /health, which returns {status:"ok"} as soon
|
||||
# as the lifespan hook finishes (taxonomy refresh included).
|
||||
HEALTHCHECK --interval=10s --timeout=5s --start-period=20s --retries=5 \
|
||||
CMD curl -fsS http://localhost:8090/health || exit 1
|
||||
|
||||
CMD ["python", "-m", "brain_api.run"]
|
||||
6
ai_platform/modules/didi_brain/brain_api/__init__.py
Normal file
6
ai_platform/modules/didi_brain/brain_api/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"""DidiBrain HTTP API — speaks the same dialect as Didi's web-gathering module.
|
||||
|
||||
Exposes /v1/search, /v1/fetch, /v1/gather, /v1/image-search, /v1/ingest over
|
||||
FastAPI. Didi's backend treats this service as a drop-in cache/source that
|
||||
happens to answer from pre-ingested knowledge rather than live web crawling.
|
||||
"""
|
||||
1283
ai_platform/modules/didi_brain/brain_api/app.py
Normal file
1283
ai_platform/modules/didi_brain/brain_api/app.py
Normal file
File diff suppressed because it is too large
Load diff
352
ai_platform/modules/didi_brain/brain_api/db.py
Normal file
352
ai_platform/modules/didi_brain/brain_api/db.py
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
"""PostgreSQL connection pool + schema bootstrap for brain-side caches.
|
||||
|
||||
Brain owns a small relational layer alongside the Atomic knowledge graph for
|
||||
things that don't fit as atoms (verification caches keyed by multiple fields
|
||||
with TTL and per-column indexes). We connect directly to the same Postgres
|
||||
instance atomic-server uses, but keep our tables in the `public` schema with
|
||||
a `brain_` prefix so they're easy to spot and never collide with Atomic's.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncpg
|
||||
|
||||
from shared.config import settings
|
||||
from shared.logging import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
_SCHEMA_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS brain_verification_cache (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
claim_hash text NOT NULL,
|
||||
tier text NOT NULL CHECK (tier IN ('free', 'premium')),
|
||||
|
||||
-- evidence_hash and evidence_urls are METADATA only. The URLs brain
|
||||
-- returns at gather time may differ from what backend wrote cache with
|
||||
-- (corpus drift, ranker tie-breaks). We still store them so backend can
|
||||
-- compare overlap and decide if cache is applicable to the current
|
||||
-- evidence set. Lookup key is (claim_hash, tier) — one row per claim-
|
||||
-- tier pair, UPSERT last-writer-wins.
|
||||
evidence_hash text NOT NULL,
|
||||
evidence_urls jsonb NOT NULL,
|
||||
|
||||
model text,
|
||||
prompt_hash text NOT NULL,
|
||||
framework_version text,
|
||||
schema_name text NOT NULL DEFAULT 'didi-v1',
|
||||
|
||||
verification_raw jsonb,
|
||||
verification_processed jsonb NOT NULL,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz NOT NULL
|
||||
);
|
||||
|
||||
-- v1 schema had UNIQUE (claim_hash, evidence_hash, tier). That made the
|
||||
-- cache unreachable because evidence URLs at read time rarely match write
|
||||
-- time. v2 = UNIQUE (claim_hash, tier) + new evidence_urls jsonb column.
|
||||
-- Migrations are idempotent.
|
||||
ALTER TABLE brain_verification_cache
|
||||
ADD COLUMN IF NOT EXISTS evidence_urls jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'brain_verification_cache_claim_hash_evidence_hash_tier_key'
|
||||
) THEN
|
||||
ALTER TABLE brain_verification_cache
|
||||
DROP CONSTRAINT brain_verification_cache_claim_hash_evidence_hash_tier_key;
|
||||
END IF;
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'brain_verification_cache_claim_hash_tier_key'
|
||||
) THEN
|
||||
ALTER TABLE brain_verification_cache
|
||||
ADD CONSTRAINT brain_verification_cache_claim_hash_tier_key
|
||||
UNIQUE (claim_hash, tier);
|
||||
END IF;
|
||||
END$$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bvc_lookup
|
||||
ON brain_verification_cache (claim_hash, tier);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bvc_expires
|
||||
ON brain_verification_cache (expires_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bvc_prompt
|
||||
ON brain_verification_cache (prompt_hash);
|
||||
|
||||
-- Drop the old 3-column lookup index if it exists.
|
||||
DROP INDEX IF EXISTS idx_bvc_lookup_v1;
|
||||
|
||||
-- ============================================================================
|
||||
-- brain_analysis_atom — cache for full-component LLM results (techniques, ai_tampered).
|
||||
-- One row per (content_hash, component, prompt_hash). Tier is stored on the row
|
||||
-- but NOT part of the unique key: write only happens for tier='premium', read
|
||||
-- is tier-agnostic so free users benefit from premium-cached results.
|
||||
--
|
||||
-- 3 cache tiers:
|
||||
-- gold — human_validated=true (set by didi moderation HIL flow). Survives
|
||||
-- prompt change. Returned at maximum confidence.
|
||||
-- silver — LLM result, llm_confidence >= threshold. Default for fresh writes.
|
||||
-- bronze — LLM result, llm_confidence < threshold. Stored for audit but
|
||||
-- NEVER served on lookup.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS brain_analysis_atom (
|
||||
atom_id bigserial PRIMARY KEY,
|
||||
content_hash text NOT NULL,
|
||||
content_preview text,
|
||||
component text NOT NULL CHECK (component IN ('techniques', 'ai_tampered', 'claims')),
|
||||
tier text NOT NULL CHECK (tier IN ('free', 'premium')),
|
||||
prompt_hash text NOT NULL,
|
||||
framework_version text,
|
||||
model_used text,
|
||||
|
||||
result_processed jsonb NOT NULL,
|
||||
result_raw jsonb,
|
||||
llm_confidence numeric,
|
||||
|
||||
cache_tier text NOT NULL DEFAULT 'silver'
|
||||
CHECK (cache_tier IN ('gold', 'silver', 'bronze')),
|
||||
human_validated boolean NOT NULL DEFAULT false,
|
||||
human_corrections jsonb,
|
||||
validator_user_id text,
|
||||
validated_at timestamptz,
|
||||
|
||||
hit_count integer NOT NULL DEFAULT 0,
|
||||
last_hit_at timestamptz,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
expires_at timestamptz, -- NULL = never expires (gold)
|
||||
|
||||
UNIQUE (content_hash, component, prompt_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_baa_lookup
|
||||
ON brain_analysis_atom (content_hash, component);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_baa_gold
|
||||
ON brain_analysis_atom (component, cache_tier)
|
||||
WHERE cache_tier = 'gold';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_baa_expires
|
||||
ON brain_analysis_atom (expires_at)
|
||||
WHERE expires_at IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_baa_prompt
|
||||
ON brain_analysis_atom (prompt_hash);
|
||||
|
||||
-- ============================================================================
|
||||
-- Volatility-aware caching extension (2026-05-04)
|
||||
-- ============================================================================
|
||||
-- Per-row volatility classification + topic tagging + audit history. Driven by
|
||||
-- LLM classifier at write time (services/classifier.py) and used by:
|
||||
-- - lookup paths (services/cache_judge.py) → confidence decay, NLI judge
|
||||
-- - daily auditor (didibrain-auditor) → consecutive_audit_passes tracking
|
||||
-- - breaking news watcher (didibrain-breaking-watcher) → topic-based mass invalidation
|
||||
-- All ALTERs are idempotent — safe on every connect.
|
||||
-- ============================================================================
|
||||
|
||||
ALTER TABLE brain_analysis_atom
|
||||
ADD COLUMN IF NOT EXISTS volatility text
|
||||
CHECK (volatility IN ('volatile', 'evolving', 'stable')),
|
||||
ADD COLUMN IF NOT EXISTS topic_codes text[] DEFAULT ARRAY[]::text[],
|
||||
ADD COLUMN IF NOT EXISTS entity_bindings jsonb DEFAULT '[]'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS ttl_hours_used integer,
|
||||
ADD COLUMN IF NOT EXISTS last_audited_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS audit_history jsonb DEFAULT '[]'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS consecutive_audit_passes integer DEFAULT 0;
|
||||
|
||||
ALTER TABLE brain_verification_cache
|
||||
ADD COLUMN IF NOT EXISTS volatility text
|
||||
CHECK (volatility IN ('volatile', 'evolving', 'stable')),
|
||||
ADD COLUMN IF NOT EXISTS topic_codes text[] DEFAULT ARRAY[]::text[],
|
||||
ADD COLUMN IF NOT EXISTS entity_bindings jsonb DEFAULT '[]'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS ttl_hours_used integer,
|
||||
ADD COLUMN IF NOT EXISTS last_audited_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS audit_history jsonb DEFAULT '[]'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS consecutive_audit_passes integer DEFAULT 0;
|
||||
|
||||
-- GIN indexes on topic_codes for fast topic-scoped invalidation/audit queries.
|
||||
CREATE INDEX IF NOT EXISTS idx_baa_topics
|
||||
ON brain_analysis_atom USING GIN (topic_codes);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bvc_topics
|
||||
ON brain_verification_cache USING GIN (topic_codes);
|
||||
|
||||
-- Partial indexes targeting audit-eligible rows (gold+silver, non-stable).
|
||||
-- Auditor cron queries these to find atoms due for re-verification.
|
||||
CREATE INDEX IF NOT EXISTS idx_baa_audit_due
|
||||
ON brain_analysis_atom (last_audited_at NULLS FIRST, volatility)
|
||||
WHERE cache_tier IN ('gold', 'silver') AND volatility IS NOT NULL AND volatility != 'stable';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bvc_audit_due
|
||||
ON brain_verification_cache (last_audited_at NULLS FIRST, volatility)
|
||||
WHERE volatility IS NOT NULL AND volatility != 'stable';
|
||||
|
||||
-- ============================================================================
|
||||
-- brain_fact_status — current truth value for entity-predicate-object triples.
|
||||
-- Pilon 11 (versioned facts). Populated by:
|
||||
-- - extractor pipeline (services/fact_status.py::extract_facts_from_claim)
|
||||
-- when ingesting new claim atoms
|
||||
-- - moderator overrides (admin endpoint PATCH /v1/fact_status/{id})
|
||||
-- - breaking news watcher (when LLM detects fact change in fresh article)
|
||||
-- Read by:
|
||||
-- - gather lookup → if any bound fact is invalid, treat cache as stale_evidence
|
||||
-- - dashboard fact browser (Phase D2)
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS brain_fact_status (
|
||||
fact_id bigserial PRIMARY KEY,
|
||||
subject text NOT NULL, -- "Vladimir Putin"
|
||||
predicate text NOT NULL, -- "is_president_of"
|
||||
object text NOT NULL, -- "Russia"
|
||||
canonical_form text NOT NULL, -- "Vladimir Putin is_president_of Russia"
|
||||
canonical_form_hash text NOT NULL, -- sha256(canonical_form)[:32]
|
||||
|
||||
current_truth boolean, -- TRUE / FALSE / NULL=unknown
|
||||
current_version_id bigint, -- non-FK pointer to brain_fact_version (avoid circular FK)
|
||||
current_confidence numeric, -- 0-100, from latest LLM judgment
|
||||
last_verified_at timestamptz,
|
||||
last_evidence_urls jsonb DEFAULT '[]'::jsonb,
|
||||
|
||||
volatility text CHECK (volatility IN ('volatile', 'evolving', 'stable')),
|
||||
topic_codes text[] DEFAULT ARRAY[]::text[],
|
||||
|
||||
-- Scheduling: auditor picks up rows where next_check_at < now()
|
||||
next_check_at timestamptz NOT NULL DEFAULT now(),
|
||||
check_interval_hours integer NOT NULL DEFAULT 24,
|
||||
|
||||
-- HIL trail
|
||||
moderator_locked boolean NOT NULL DEFAULT false, -- true = audit cron must NOT auto-update
|
||||
moderator_user_id text,
|
||||
moderator_notes text,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
UNIQUE (canonical_form_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bfs_subject_predicate
|
||||
ON brain_fact_status (subject, predicate);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bfs_check_due
|
||||
ON brain_fact_status (next_check_at)
|
||||
WHERE moderator_locked = false;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bfs_topics
|
||||
ON brain_fact_status USING GIN (topic_codes);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bfs_truth
|
||||
ON brain_fact_status (current_truth)
|
||||
WHERE current_truth IS NOT NULL;
|
||||
|
||||
-- ============================================================================
|
||||
-- brain_fact_version — temporal versioning for facts.
|
||||
-- Each row = one truth assertion valid in a [valid_from, valid_to) window.
|
||||
-- valid_to IS NULL means "currently in force". When a fact changes, the active
|
||||
-- version gets valid_to=now() and a new version is opened.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS brain_fact_version (
|
||||
version_id bigserial PRIMARY KEY,
|
||||
fact_id bigint NOT NULL REFERENCES brain_fact_status(fact_id) ON DELETE CASCADE,
|
||||
|
||||
truth_value boolean NOT NULL,
|
||||
confidence numeric, -- 0-100
|
||||
|
||||
valid_from timestamptz NOT NULL,
|
||||
valid_to timestamptz, -- NULL = current
|
||||
|
||||
source_atom_ids text[] DEFAULT ARRAY[]::text[], -- Atomic atom IDs supporting this version
|
||||
evidence_urls jsonb DEFAULT '[]'::jsonb,
|
||||
llm_reasoning text, -- LLM justification for this assertion
|
||||
|
||||
created_by text NOT NULL DEFAULT 'auto', -- 'auto' | 'moderator' | 'breaking_news_watcher'
|
||||
moderator_user_id text, -- if created_by='moderator'
|
||||
notes text,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bfv_fact
|
||||
ON brain_fact_version (fact_id, valid_from DESC);
|
||||
|
||||
-- Partial index for currently-active versions (the most common lookup pattern).
|
||||
CREATE INDEX IF NOT EXISTS idx_bfv_current
|
||||
ON brain_fact_version (fact_id)
|
||||
WHERE valid_to IS NULL;
|
||||
|
||||
-- ============================================================================
|
||||
-- Audit log table (small, for invalidation/promotion/moderator actions).
|
||||
-- Mirrors the lightweight audit pattern used by didi-admin's audit_log.
|
||||
-- ============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS brain_audit_log (
|
||||
log_id bigserial PRIMARY KEY,
|
||||
action text NOT NULL, -- 'invalidate' | 'promote_gold' | 'fact_override' | 'audit_demote'
|
||||
target_table text NOT NULL, -- 'brain_analysis_atom' | 'brain_verification_cache' | 'brain_fact_status'
|
||||
target_id text NOT NULL,
|
||||
actor text, -- 'auditor' | 'breaking_watcher' | keycloak_id
|
||||
payload jsonb DEFAULT '{}'::jsonb,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bal_target
|
||||
ON brain_audit_log (target_table, target_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_bal_action_time
|
||||
ON brain_audit_log (action, created_at DESC);
|
||||
"""
|
||||
|
||||
|
||||
class Database:
|
||||
"""Thin wrapper over an asyncpg pool with a migration hook."""
|
||||
|
||||
def __init__(self, dsn: str) -> None:
|
||||
self._dsn = dsn
|
||||
self._pool: asyncpg.Pool | None = None
|
||||
|
||||
async def connect(self) -> None:
|
||||
if self._pool is not None:
|
||||
return
|
||||
self._pool = await asyncpg.create_pool(
|
||||
self._dsn,
|
||||
min_size=1,
|
||||
max_size=5,
|
||||
command_timeout=10.0,
|
||||
)
|
||||
async with self._pool.acquire() as conn:
|
||||
# pgcrypto for gen_random_uuid() — atomic-server usually enables it
|
||||
# already via its own migrations, but we do it idempotently just
|
||||
# in case brain is the first consumer on a fresh volume.
|
||||
await conn.execute("CREATE EXTENSION IF NOT EXISTS pgcrypto")
|
||||
await conn.execute(_SCHEMA_SQL)
|
||||
log.info("brain_db_ready", dsn=self._redacted_dsn())
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._pool is not None:
|
||||
await self._pool.close()
|
||||
self._pool = None
|
||||
|
||||
@property
|
||||
def pool(self) -> asyncpg.Pool:
|
||||
if self._pool is None:
|
||||
raise RuntimeError("brain_db not connected — call connect() first")
|
||||
return self._pool
|
||||
|
||||
def _redacted_dsn(self) -> str:
|
||||
# Hide password in logs
|
||||
import re
|
||||
|
||||
return re.sub(r":([^@:/]+)@", ":***@", self._dsn)
|
||||
|
||||
|
||||
# Singleton — wired up in app lifespan
|
||||
db = Database(settings.postgres_dsn)
|
||||
42
ai_platform/modules/didi_brain/brain_api/deps.py
Normal file
42
ai_platform/modules/didi_brain/brain_api/deps.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"""Shared clients that live for the lifetime of the FastAPI process.
|
||||
|
||||
We keep one AtomicClient, one EmbeddingClient, and one TagResolver in module
|
||||
state. FastAPI dependencies pull them out so handlers stay clean.
|
||||
|
||||
Initialized from app.py's lifespan context manager at startup; closed on
|
||||
shutdown.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from shared.atomic_api import AtomicClient
|
||||
from shared.embedding_client import EmbeddingClient
|
||||
from shared.llm_client import LlmClient
|
||||
from shared.taxonomy import TagResolver
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AppState:
|
||||
atomic: AtomicClient
|
||||
embed: EmbeddingClient
|
||||
llm: LlmClient
|
||||
resolver: TagResolver
|
||||
|
||||
|
||||
# Module-level singleton, populated by the lifespan handler.
|
||||
_state: AppState | None = None
|
||||
|
||||
|
||||
def set_state(state: AppState) -> None:
|
||||
global _state
|
||||
_state = state
|
||||
|
||||
|
||||
def get_state() -> AppState:
|
||||
if _state is None:
|
||||
raise RuntimeError(
|
||||
"brain_api state not initialized — FastAPI lifespan must run first"
|
||||
)
|
||||
return _state
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
"""Per-request middleware that emits events to the dashboard sink.
|
||||
|
||||
Captures: request_id (X-Request-ID header or generated UUID), endpoint
|
||||
(URL path), HTTP method, duration, status_code. Skips /health and noisy
|
||||
internal paths. Endpoint string is normalized to the route template
|
||||
(no params) so a high-cardinality table doesn't blow up.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from typing import Awaitable, Callable
|
||||
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from brain_api.events.sink import get_global_sink
|
||||
|
||||
# Endpoints we don't want to record — too noisy / not interesting in History.
|
||||
_SKIP_PREFIXES = (
|
||||
"/health",
|
||||
"/docs",
|
||||
"/redoc",
|
||||
"/openapi.json",
|
||||
"/metrics",
|
||||
)
|
||||
|
||||
|
||||
def _normalize_endpoint(path: str) -> str:
|
||||
"""Collapse path params so we don't blow up the request_history table.
|
||||
|
||||
/v1/analysis_atom/123 → /v1/analysis_atom/{id}
|
||||
/v1/fact_status/45/versions → /v1/fact_status/{id}/versions
|
||||
/v1/verification_cache/abc/free → /v1/verification_cache/{hash}/{tier}
|
||||
"""
|
||||
parts = path.split("/")
|
||||
if len(parts) >= 4 and parts[1] == "v1" and parts[2] == "analysis_atom":
|
||||
if len(parts) == 4 and parts[3].isdigit():
|
||||
return "/v1/analysis_atom/{id}"
|
||||
if len(parts) >= 4 and parts[1] == "v1" and parts[2] == "fact_status":
|
||||
if parts[3].isdigit():
|
||||
tail = "/" + "/".join(parts[4:]) if len(parts) > 4 else ""
|
||||
return f"/v1/fact_status/{{id}}{tail}"
|
||||
if (
|
||||
len(parts) >= 5
|
||||
and parts[1] == "v1"
|
||||
and parts[2] == "verification_cache"
|
||||
and parts[4] in ("free", "premium")
|
||||
):
|
||||
return "/v1/verification_cache/{hash}/{tier}"
|
||||
return path
|
||||
|
||||
|
||||
async def event_emit_middleware(
|
||||
request: Request,
|
||||
call_next: Callable[[Request], Awaitable[Response]],
|
||||
) -> Response:
|
||||
"""Starlette HTTP middleware: emit a dashboard event per request."""
|
||||
path = request.url.path
|
||||
if any(path.startswith(p) for p in _SKIP_PREFIXES):
|
||||
return await call_next(request)
|
||||
|
||||
request_id = request.headers.get("x-request-id") or uuid.uuid4().hex[:32]
|
||||
started = time.monotonic()
|
||||
status_code = 500
|
||||
error: str | None = None
|
||||
try:
|
||||
response = await call_next(request)
|
||||
status_code = response.status_code
|
||||
return response
|
||||
except Exception as e: # noqa: BLE001
|
||||
error = f"{type(e).__name__}: {e}"
|
||||
raise
|
||||
finally:
|
||||
duration_ms = int((time.monotonic() - started) * 1000)
|
||||
sink = get_global_sink()
|
||||
if sink is not None and sink.enabled:
|
||||
sink.emit(
|
||||
{
|
||||
"request_id": request_id,
|
||||
"tier": "n/a",
|
||||
"endpoint": _normalize_endpoint(path),
|
||||
# Brain has no upstream "provider" concept — it resolves
|
||||
# locally (PG + atomic + LLM router). Leaving null keeps
|
||||
# the column UI honest (renders as "—").
|
||||
"provider": None,
|
||||
"duration_ms": duration_ms,
|
||||
"status_code": status_code,
|
||||
"error": error,
|
||||
}
|
||||
)
|
||||
125
ai_platform/modules/didi_brain/brain_api/events/sink.py
Normal file
125
ai_platform/modules/didi_brain/brain_api/events/sink.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
"""Async event sink that forwards brain request events to the AI platform dashboard.
|
||||
|
||||
Fire-and-forget — dashboard outages must never affect brain latency or
|
||||
availability. Pattern mirrors web-api/events/sink.py 1:1, with module='brain'
|
||||
baked in so events are distinguishable in the unified Insights/History view.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger("brain_api.events.sink")
|
||||
|
||||
|
||||
class DashboardEventSink:
|
||||
"""POSTs request events to the dashboard /api/ingest/event endpoint.
|
||||
|
||||
The sink runs all writes through a bounded queue processed by a single
|
||||
worker task. Overflow drops oldest. Failures are logged but swallowed.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
dashboard_url: str | None,
|
||||
token: str | None = None,
|
||||
queue_size: int = 1000,
|
||||
request_timeout: float = 5.0,
|
||||
) -> None:
|
||||
self.dashboard_url = dashboard_url.rstrip("/") if dashboard_url else None
|
||||
self.token = token
|
||||
self._queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=queue_size)
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
self._worker_task: asyncio.Task[None] | None = None
|
||||
self._request_timeout = request_timeout
|
||||
self._enabled = bool(dashboard_url)
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
async def start(self) -> None:
|
||||
if not self._enabled:
|
||||
logger.info("brain DashboardEventSink disabled (no dashboard URL)")
|
||||
return
|
||||
self._client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(
|
||||
connect=2.0, read=self._request_timeout, write=2.0, pool=5.0
|
||||
),
|
||||
limits=httpx.Limits(max_connections=5, max_keepalive_connections=2),
|
||||
)
|
||||
self._worker_task = asyncio.create_task(self._worker())
|
||||
logger.info("brain DashboardEventSink started → %s", self.dashboard_url)
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._worker_task is not None:
|
||||
self._worker_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await self._worker_task
|
||||
self._worker_task = None
|
||||
if self._client is not None and not self._client.is_closed:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
def emit(self, event: dict[str, Any]) -> None:
|
||||
"""Enqueue an event for async send. Never raises.
|
||||
|
||||
The caller does NOT have to set 'module' — we stamp it here so all
|
||||
events from this process are tagged 'brain' regardless of who called.
|
||||
"""
|
||||
if not self._enabled:
|
||||
return
|
||||
event = {**event, "module": "brain"}
|
||||
try:
|
||||
self._queue.put_nowait(event)
|
||||
except asyncio.QueueFull:
|
||||
# Drop the oldest event to make room for the new one.
|
||||
try:
|
||||
_ = self._queue.get_nowait()
|
||||
self._queue.put_nowait(event)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
async def _worker(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
event = await self._queue.get()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
try:
|
||||
await self._send(event)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("Event dropped (%s): %s", type(e).__name__, e)
|
||||
finally:
|
||||
self._queue.task_done()
|
||||
|
||||
async def _send(self, event: dict[str, Any]) -> None:
|
||||
if self._client is None or self.dashboard_url is None:
|
||||
return
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
resp = await self._client.post(
|
||||
f"{self.dashboard_url}/api/ingest/event",
|
||||
json=event,
|
||||
headers=headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
# Module-level singleton — initialized in app.py lifespan.
|
||||
_sink: DashboardEventSink | None = None
|
||||
|
||||
|
||||
def init_global_sink(sink: DashboardEventSink) -> None:
|
||||
global _sink
|
||||
_sink = sink
|
||||
|
||||
|
||||
def get_global_sink() -> DashboardEventSink | None:
|
||||
return _sink
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
You are a temporal disambiguator for a misinformation detection cache. Your job is to take an ambiguous CLAIM and produce a CANONICAL form that anchors all relative time references and underspecified entities to specific values, so the same claim asked at different times produces different cache keys.
|
||||
|
||||
# Why this matters
|
||||
|
||||
A user asking "Cine câștigă alegerile?" in 2024 and again in 2026 is asking about *different* elections. If both claims hash to the same cache key, the 2024 verdict gets served in 2026, which is wrong. The job of canonicalization is to expand ambiguous references so the cache key reflects what the user actually means *right now*.
|
||||
|
||||
# Task
|
||||
|
||||
Given a CLAIM and the CURRENT_DATE, output a JSON object with these fields:
|
||||
|
||||
1. **canonical** (string, required) — the rewritten claim with:
|
||||
- Relative time references resolved to absolute references using CURRENT_DATE.
|
||||
Examples:
|
||||
- "azi" / "today" → the actual date (e.g., "în 2026-05-04").
|
||||
- "ieri" / "yesterday" → CURRENT_DATE - 1.
|
||||
- "săptămâna asta" / "this week" → "în săptămâna {ISO week}".
|
||||
- "luna trecută" / "last month" → name of the prior month.
|
||||
- "anul trecut" / "last year" → CURRENT_DATE.year - 1.
|
||||
- "acum" / "now" / "currently" / "în prezent" → "în {CURRENT_DATE}".
|
||||
- Underspecified entities expanded with the most contextually plausible disambiguation, only when context allows (DO NOT invent if truly ambiguous).
|
||||
Examples:
|
||||
- "alegerile" → "alegerile prezidențiale din [country] din [year]" if the year is implied by current_date and a clear election cycle exists.
|
||||
- "războiul" → preserve as-is unless context strongly suggests one specific conflict.
|
||||
- "președintele" → preserve as-is — adding a name would be unsafe inference.
|
||||
- Original wording preserved as much as possible. Goal is anchor, not rewrite.
|
||||
- Same language as the input claim (Romanian → Romanian, English → English).
|
||||
|
||||
2. **changed** (boolean, required) — true if the canonical form differs meaningfully from the original; false if no temporal/entity disambiguation was needed (claim was already specific).
|
||||
|
||||
3. **anchors_added** (list of strings, required, may be empty) — short labels for what was disambiguated, e.g., `["temporal:today", "year:2026"]` or `["entity:alegerile→alegerile_prezidentiale_2026"]`. Used for audit and debugging.
|
||||
|
||||
4. **reasoning** (string, max 200 chars) — one-line explanation of any non-trivial decision.
|
||||
|
||||
# Rules
|
||||
|
||||
1. **NEVER invent facts.** Adding "Trump" to "the president said" is unsafe — leave it ambiguous. The canonical form must remain truthful about what the user asked.
|
||||
2. **Always anchor relative time markers** when the claim contains them — this is the primary value of canonicalization.
|
||||
3. **Be conservative with entity expansion.** Only expand when context (the rest of the claim or current date) makes the disambiguation unambiguous.
|
||||
4. **Preserve the user's intent.** If they wrote "ieri", don't replace it with "May 3rd 2026" verbatim — write something natural like "în data de 2026-05-03 (ieri)" so the meaning is preserved alongside the anchor.
|
||||
5. **If the claim is already fully specific** (no relative markers, no ambiguous entities), return it as-is with `changed: false`.
|
||||
6. **Numbers and named entities stay intact.** Do not normalize "9 medalii" to "9 medals" or "România" to "Romania" — those distinctions matter elsewhere in the pipeline (verification_cache.normalize_claim handles textual normalization separately).
|
||||
|
||||
# Output format — STRICT
|
||||
|
||||
Respond with ONLY this JSON object. No preamble, no markdown fences, no commentary.
|
||||
|
||||
```
|
||||
{
|
||||
"canonical": "...",
|
||||
"changed": true,
|
||||
"anchors_added": ["..."],
|
||||
"reasoning": "..."
|
||||
}
|
||||
```
|
||||
|
||||
# Input
|
||||
|
||||
CURRENT_DATE: {current_date}
|
||||
|
||||
CLAIM: {claim}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
You are a temporal volatility classifier supporting a misinformation detection cache. Your job is to assess how quickly a given CLAIM may become outdated, so the system knows how long to trust a cached verification of it.
|
||||
|
||||
# Task
|
||||
|
||||
Given a CLAIM and the CURRENT_DATE, output a JSON object with these fields:
|
||||
|
||||
1. **volatility** (string, required) — how fast can this claim become outdated?
|
||||
- `volatile`: minutes-to-days. War updates, casualty counts, breaking news, ongoing crisis, current weather, stock prices, sports scores, current officeholders during active election seasons, ongoing legal proceedings.
|
||||
- `evolving`: days-to-weeks. Government policies, economic indicators, completed-but-recent trials, employment status of public figures, scientific debates, climate negotiations, recent appointments.
|
||||
- `stable`: months-to-years. Historical facts, settled science, geographical facts, completed events with no further development possible, biographical facts of deceased historical figures, mathematical truths.
|
||||
|
||||
2. **topic_codes** (list of strings, required, may be empty) — short codes for the topics this claim involves. Prefer these canonical codes when applicable: `war`, `armed_conflict`, `elections`, `politics`, `health`, `health_outbreak`, `economy`, `economy_indicators`, `climate`, `science`, `sports`, `entertainment`, `crime`, `disaster`, `breaking_news`, `technology`, `education`, `religion`, `culture`. Add free-form codes only if none of these fit.
|
||||
|
||||
3. **entity_bindings** (list of objects, required, may be empty) — every `(subject, predicate, object)` triple this claim depends on. For each:
|
||||
- `subject`: canonical name of the entity (e.g., `"Vladimir Putin"`, `"Romania"`, `"World Health Organization"`).
|
||||
- `predicate`: short relation name (e.g., `"is_president_of"`, `"won_election_in"`, `"is_alive"`, `"has_population"`, `"happened_on"`, `"is_ceo_of"`, `"defeated"`, `"signed_treaty_with"`).
|
||||
- `object`: target value (entity, date, number, country, etc.).
|
||||
- `confidence`: 0.0-1.0 of your extraction certainty.
|
||||
|
||||
4. **estimated_validity_hours** (integer, required) — your best estimate of how many hours from now this verdict can be trusted, given current world state. Reasonable bounds:
|
||||
- volatile: 1-48 hours
|
||||
- evolving: 24-720 hours (1-30 days)
|
||||
- stable: 720-26280 hours (1-36 months)
|
||||
|
||||
5. **time_sensitive** (boolean, required) — true if the claim contains relative time markers (`today`, `yesterday`, `now`, `currently`, `azi`, `ieri`, `acum`, `în prezent`, `recently`) or specific recent dates that strongly anchor it to a particular moment.
|
||||
|
||||
6. **reasoning** (string, max 200 chars) — one-line explanation of your volatility decision.
|
||||
|
||||
# Rules
|
||||
|
||||
1. **When in doubt, prefer SHORTER validity** — false-fresh is much worse than false-stale (which just means re-verification).
|
||||
2. **Currently-in-office officials** → volatile regardless of base topic. "X is the prime minister" can change overnight.
|
||||
3. **Numerical statistics that update** (deaths, cases, GDP, prices) → volatile or evolving, never stable.
|
||||
4. **Pure historical/geographical facts** ("Bucharest is the capital of Romania", "WW2 ended in 1945", "Mount Everest is the tallest mountain") → stable.
|
||||
5. **Be aggressive about extracting entity_bindings** — these are how the system tracks fact changes over time. A claim like "X is president of Y" should yield at least one binding `{subject: X, predicate: is_president_of, object: Y}`.
|
||||
6. **If the claim is vague or unverifiable** ("the situation is bad"), still classify volatility based on the inferred topic. Default to `evolving`.
|
||||
7. **Do not include topic codes that aren't actually relevant** to the claim — only the directly applicable ones.
|
||||
|
||||
# Output format — STRICT
|
||||
|
||||
Respond with ONLY this JSON object. No preamble, no markdown fences, no commentary, no thinking-out-loud.
|
||||
|
||||
```
|
||||
{
|
||||
"volatility": "volatile|evolving|stable",
|
||||
"topic_codes": ["..."],
|
||||
"entity_bindings": [
|
||||
{"subject": "...", "predicate": "...", "object": "...", "confidence": 0.0-1.0}
|
||||
],
|
||||
"estimated_validity_hours": 24,
|
||||
"time_sensitive": false,
|
||||
"reasoning": "..."
|
||||
}
|
||||
```
|
||||
|
||||
# Input
|
||||
|
||||
CURRENT_DATE: {current_date}
|
||||
|
||||
CLAIM: {claim}
|
||||
34
ai_platform/modules/didi_brain/brain_api/prompts/nli_v1.md
Normal file
34
ai_platform/modules/didi_brain/brain_api/prompts/nli_v1.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
You are a natural language inference (NLI) classifier supporting a disinformation analysis pipeline. Your job is to decide how a piece of EVIDENCE relates to a specific CLAIM.
|
||||
|
||||
Output exactly ONE of these three labels:
|
||||
|
||||
- **SUPPORTS** — the evidence provides information that would make a reasonable person believe the claim is true (or more likely true). The evidence directly or strongly indirectly backs the claim.
|
||||
- **CONTRADICTS** — the evidence provides information that would make a reasonable person believe the claim is false (or less likely true). This includes explicit debunks, scientific consensus against, or facts that are incompatible with the claim.
|
||||
- **NEUTRAL** — the evidence is related to the same topic but does not clearly support or contradict the claim. Includes tangential context, definitions, unrelated details about the same entities.
|
||||
|
||||
# Important rules
|
||||
|
||||
1. Focus ONLY on the truth-value relationship, not on the source's credibility or intent.
|
||||
2. If the evidence describes someone ASSERTING the claim (without the source endorsing it), but the source's overall framing treats the claim as factual, label SUPPORTS. If the source treats it as debunked, label CONTRADICTS.
|
||||
3. Scientific consensus statements against a claim count as CONTRADICTS (strong).
|
||||
4. An evidence item that merely mentions the claim topic without a clear truth-direction is NEUTRAL.
|
||||
5. If the evidence could be read both ways, pick NEUTRAL.
|
||||
6. "Confidence" reflects how clean the relationship is:
|
||||
- 0.9-1.0: unambiguous, single-interpretation
|
||||
- 0.7-0.9: clear but with minor caveats
|
||||
- 0.5-0.7: probable but could be argued
|
||||
- <0.5: you are guessing — prefer NEUTRAL
|
||||
|
||||
# Output format — STRICT
|
||||
|
||||
Respond with ONLY this JSON object. No preamble, no markdown fences, no commentary.
|
||||
|
||||
```
|
||||
{"label": "SUPPORTS|CONTRADICTS|NEUTRAL", "confidence": 0.0-1.0}
|
||||
```
|
||||
|
||||
# Input
|
||||
|
||||
CLAIM: {claim}
|
||||
|
||||
EVIDENCE: {evidence}
|
||||
32
ai_platform/modules/didi_brain/brain_api/requirements.txt
Normal file
32
ai_platform/modules/didi_brain/brain_api/requirements.txt
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Runtime dependencies for the brain_api container.
|
||||
# Pinned to minor versions that match the local dev venv we validated against.
|
||||
# If you bump one, rebuild the image and re-run scripts/09_brain_api_demo.py.
|
||||
|
||||
# Web framework
|
||||
fastapi>=0.135,<0.140
|
||||
uvicorn>=0.44,<0.50
|
||||
|
||||
# HTTP client (used by every shared client)
|
||||
httpx>=0.28,<0.30
|
||||
|
||||
# Config / validation
|
||||
pydantic>=2.12,<3.0
|
||||
pydantic-settings>=2.13,<3.0
|
||||
python-dotenv>=1.2,<2.0
|
||||
|
||||
# Retries
|
||||
tenacity>=9.1,<10.0
|
||||
|
||||
# Logging + pretty console
|
||||
structlog>=25.5,<26.0
|
||||
rich>=14.3,<15.0
|
||||
|
||||
# PostgreSQL (verification cache)
|
||||
asyncpg>=0.30,<0.32
|
||||
|
||||
# Observability — Prometheus metrics + OpenTelemetry traces
|
||||
prometheus-fastapi-instrumentator>=7.0,<8.0
|
||||
opentelemetry-instrumentation-fastapi>=0.50b0
|
||||
opentelemetry-instrumentation-asyncpg>=0.50b0
|
||||
opentelemetry-instrumentation-httpx>=0.50b0
|
||||
opentelemetry-exporter-otlp-proto-grpc>=1.30.0
|
||||
35
ai_platform/modules/didi_brain/brain_api/run.py
Normal file
35
ai_platform/modules/didi_brain/brain_api/run.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"""Entry point for the brain_api FastAPI service.
|
||||
|
||||
python -m brain_api.run
|
||||
|
||||
Bind host defaults to 127.0.0.1 (local dev, loopback only). Containerized
|
||||
runs must override with BRAIN_API_HOST=0.0.0.0 so Docker port mapping can
|
||||
actually forward traffic in from the outside.
|
||||
|
||||
Environment variables:
|
||||
BRAIN_API_HOST bind address (default: 127.0.0.1)
|
||||
BRAIN_API_PORT port (default: 8090)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import uvicorn
|
||||
|
||||
|
||||
def main() -> None:
|
||||
host = os.environ.get("BRAIN_API_HOST", "127.0.0.1")
|
||||
port = int(os.environ.get("BRAIN_API_PORT", "8090"))
|
||||
uvicorn.run(
|
||||
"brain_api.app:app",
|
||||
host=host,
|
||||
port=port,
|
||||
reload=False,
|
||||
log_level="info",
|
||||
access_log=True,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
169
ai_platform/modules/didi_brain/brain_api/runtime_config.py
Normal file
169
ai_platform/modules/didi_brain/brain_api/runtime_config.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
"""Runtime config client — fetches config overrides from AI platform dashboard.
|
||||
|
||||
Polls the dashboard /api/config endpoint periodically and caches values in
|
||||
memory. Consumers read keys like `brain.atom.silver_ttl_days` and pass their
|
||||
own fallback (typically the pydantic Settings value).
|
||||
|
||||
Pattern mirrors the one used by the embeddings/rerank/catalog modules so the
|
||||
behaviour is consistent across the AI platform: same poll interval, same
|
||||
fail-open semantics, same optional auto-apply for log level.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RuntimeConfigClient:
|
||||
"""Polls dashboard /api/config and caches values in-process."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
dashboard_url: str | None,
|
||||
poll_interval_seconds: int = 30,
|
||||
request_timeout: float = 5.0,
|
||||
live_log_logger_name: str | None = None,
|
||||
live_log_key: str | None = None,
|
||||
) -> None:
|
||||
self.dashboard_url = dashboard_url.rstrip("/") if dashboard_url else None
|
||||
self.poll_interval = poll_interval_seconds
|
||||
self._timeout = request_timeout
|
||||
self._cache: dict[str, Any] = {}
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
self._enabled = bool(dashboard_url)
|
||||
self._live_log_logger_name = live_log_logger_name
|
||||
self._live_log_key = live_log_key
|
||||
self._last_log_level: str | None = None
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
v = self._cache.get(key)
|
||||
return v if v is not None else default
|
||||
|
||||
def get_bool(self, key: str, default: bool = False) -> bool:
|
||||
v = self._cache.get(key)
|
||||
return bool(v) if v is not None else default
|
||||
|
||||
def get_int(self, key: str, default: int = 0) -> int:
|
||||
v = self._cache.get(key)
|
||||
if v is None:
|
||||
return default
|
||||
try:
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def get_float(self, key: str, default: float = 0.0) -> float:
|
||||
v = self._cache.get(key)
|
||||
if v is None:
|
||||
return default
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def get_str(self, key: str, default: str = "") -> str:
|
||||
v = self._cache.get(key)
|
||||
return str(v) if v is not None else default
|
||||
|
||||
async def start(self) -> None:
|
||||
if not self._enabled:
|
||||
logger.info("RuntimeConfigClient disabled (no dashboard URL)")
|
||||
return
|
||||
self._client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(connect=2.0, read=self._timeout, write=2.0, pool=5.0)
|
||||
)
|
||||
await self._refresh()
|
||||
self._task = asyncio.create_task(self._loop())
|
||||
logger.info(
|
||||
"RuntimeConfigClient started (polling %s every %ds, %d keys cached)",
|
||||
self.dashboard_url,
|
||||
self.poll_interval,
|
||||
len(self._cache),
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._task is not None:
|
||||
self._task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await self._task
|
||||
self._task = None
|
||||
if self._client is not None and not self._client.is_closed:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
async def _loop(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(self.poll_interval)
|
||||
await self._refresh()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("Config poll failed: %s", e)
|
||||
|
||||
async def _refresh(self) -> None:
|
||||
if self._client is None or self.dashboard_url is None:
|
||||
return
|
||||
try:
|
||||
resp = await self._client.get(f"{self.dashboard_url}/api/config")
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("Config refresh failed: %s", e)
|
||||
return
|
||||
|
||||
items = data.get("items", {})
|
||||
new_cache: dict[str, Any] = {}
|
||||
for key, entry in items.items():
|
||||
new_cache[key] = entry.get("value")
|
||||
self._cache = new_cache
|
||||
self._maybe_apply_log_level()
|
||||
|
||||
def _maybe_apply_log_level(self) -> None:
|
||||
if not self._live_log_key or not self._live_log_logger_name:
|
||||
return
|
||||
new_level = self.get_str(self._live_log_key)
|
||||
if not new_level or new_level == self._last_log_level:
|
||||
return
|
||||
try:
|
||||
level_int = logging.getLevelName(new_level.upper())
|
||||
if isinstance(level_int, int):
|
||||
logging.getLogger(self._live_log_logger_name).setLevel(level_int)
|
||||
self._last_log_level = new_level
|
||||
logger.info(
|
||||
"Log level for %s changed to %s (via runtime config)",
|
||||
self._live_log_logger_name,
|
||||
new_level,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("Failed to apply log level %s: %s", new_level, e)
|
||||
|
||||
|
||||
# Module-level singleton — initialized in app.py lifespan.
|
||||
_global_client: RuntimeConfigClient | None = None
|
||||
|
||||
|
||||
def init_global_client(client: RuntimeConfigClient) -> None:
|
||||
global _global_client
|
||||
_global_client = client
|
||||
|
||||
|
||||
def get_global_client() -> RuntimeConfigClient | None:
|
||||
"""Returns the live RuntimeConfigClient if started, else None.
|
||||
|
||||
Consumers (e.g. analysis_atom service) call this with a fallback so they
|
||||
work both before lifespan starts and when no dashboard is configured.
|
||||
"""
|
||||
return _global_client
|
||||
880
ai_platform/modules/didi_brain/brain_api/schemas.py
Normal file
880
ai_platform/modules/didi_brain/brain_api/schemas.py
Normal file
|
|
@ -0,0 +1,880 @@
|
|||
"""Pydantic v2 schemas — the exact contract Didi's backend parses.
|
||||
|
||||
These match the shape documented for the existing web-gathering module
|
||||
1:1. Any field the backend checks MUST exist in the response. Where we don't
|
||||
have meaningful data, we populate with safe non-null defaults (empty list,
|
||||
"Global", "en", etc.) rather than leaving a field out or null.
|
||||
|
||||
Additive fields that are DidiBrain-specific live under `brain_meta` blocks so
|
||||
an unknowing backend ignores them while a newer one can consume them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Request models
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class SearchRequest(BaseModel):
|
||||
"""POST /v1/search — simple list-style retrieval."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
queries: list[str] = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="One or more query strings. Multiple are executed in parallel.",
|
||||
)
|
||||
max_results: int = Field(20, ge=1, le=200)
|
||||
language: str | None = Field(None, description="Hint for detected query language, optional.")
|
||||
|
||||
|
||||
class FetchRequest(BaseModel):
|
||||
"""POST /v1/fetch — retrieve full text for a list of URLs."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
urls: list[str] = Field(..., min_length=1)
|
||||
include_html: bool = Field(False, description="Return raw HTML along with extracted text.")
|
||||
|
||||
|
||||
class GatherRequest(BaseModel):
|
||||
"""POST /v1/gather — full claim-to-evidence pipeline."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
claim: str = Field(..., min_length=3)
|
||||
max_evidence: int = Field(15, ge=1, le=100)
|
||||
include_full_text: bool = Field(True)
|
||||
summarize: bool = Field(True)
|
||||
score_relevance: bool = Field(True)
|
||||
language_hint: str | None = Field(None)
|
||||
# When true, run an independent NLI pass to decide whether each piece of
|
||||
# evidence supports, contradicts, or is neutral toward the input claim.
|
||||
# Adds ~2-4 seconds to the gather call; disable when latency matters more
|
||||
# than classification detail.
|
||||
run_nli: bool = Field(True)
|
||||
|
||||
# ----- Verification cache extension (agreed contract with didi-backend) -----
|
||||
include_verification: bool = Field(
|
||||
False,
|
||||
description=(
|
||||
"If true, brain attempts to look up cached verification for "
|
||||
"(claim, evidence_urls, tier) and attach it under brain_meta."
|
||||
),
|
||||
)
|
||||
tier: Literal["free", "premium"] | None = Field(
|
||||
None,
|
||||
description="Required when include_verification=true — isolates cache.",
|
||||
)
|
||||
prompt_hash: str | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Current backend prompt hash. Brain marks entries stale when the "
|
||||
"cached entry's prompt_hash differs (returns nothing)."
|
||||
),
|
||||
)
|
||||
framework_version: str | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Current backend framework config hash (thresholds). "
|
||||
"Differs → return verification_raw so backend can recompute."
|
||||
),
|
||||
)
|
||||
|
||||
# ----- Phase B4: recency-aware retrieval (Pilon 3+4) -------------------
|
||||
volatility_hint: Literal["volatile", "evolving", "stable"] | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Caller's hint about how fast this claim's truth can change. "
|
||||
"Drives recency boost in ranking and a hard recency filter for "
|
||||
"volatile claims. Absent → mild defaults applied (no aggression)."
|
||||
),
|
||||
)
|
||||
recency_window_days: int | None = Field(
|
||||
None,
|
||||
ge=1,
|
||||
le=365,
|
||||
description=(
|
||||
"When volatility_hint='volatile' and this is set, hard-drop any "
|
||||
"evidence older than N days. Default behavior (None): 7 days for "
|
||||
"volatile, no cut for evolving/stable."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class VerificationCacheWriteRequest(BaseModel):
|
||||
"""POST /v1/verification_cache — fire-and-forget push from didi-backend."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
claim: str = Field(..., min_length=1)
|
||||
evidence_urls: list[str] = Field(..., min_length=1)
|
||||
tier: Literal["free", "premium"]
|
||||
|
||||
model: str | None = None
|
||||
prompt_hash: str = Field(..., min_length=4)
|
||||
framework_version: str | None = None
|
||||
schema_name: str = Field("didi-v1")
|
||||
|
||||
verification_processed: dict = Field(..., description="Opaque blob; stored 1:1.")
|
||||
verification_raw: dict | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Optional — lets brain hand this back when thresholds change "
|
||||
"(stale_framework response), so backend can recompute status."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class VerificationCacheWriteResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
cached: bool
|
||||
claim_hash: str
|
||||
evidence_hash: str
|
||||
tier: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Analysis Atom — cache for techniques + ai_tampered LLM results
|
||||
# Contract: didi-backend agent-v3 reads/writes via /v1/analysis_atom/{lookup,POST,PATCH}
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
AtomComponent = Literal["techniques", "ai_tampered", "claims"]
|
||||
AtomTier = Literal["free", "premium"]
|
||||
AtomCacheTier = Literal["gold", "silver", "bronze"]
|
||||
AtomStaleness = Literal["fresh", "stale_prompt", "stale_framework", "miss"]
|
||||
|
||||
|
||||
class AnalysisAtomLookupRequest(BaseModel):
|
||||
"""POST /v1/analysis_atom/lookup — find a cached LLM analysis result.
|
||||
|
||||
Lookup is keyed on (content_hash, component, prompt_hash). Tier is NOT
|
||||
part of the key — read tier-agnostic so free users benefit from premium
|
||||
cached entries.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
content_hash: str = Field(..., min_length=8, description="sha256 of normalized content")
|
||||
component: AtomComponent
|
||||
tier: AtomTier = Field(..., description="Caller's current tier (informational, not used for lookup key)")
|
||||
prompt_hash: str = Field(..., min_length=4)
|
||||
framework_version: str | None = None
|
||||
|
||||
|
||||
class AnalysisAtomData(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
atom_id: int
|
||||
content_hash: str
|
||||
component: AtomComponent
|
||||
tier: AtomTier
|
||||
prompt_hash: str
|
||||
framework_version: str | None
|
||||
model_used: str | None
|
||||
cache_tier: AtomCacheTier
|
||||
human_validated: bool
|
||||
result_processed: dict
|
||||
validator_user_id: str | None
|
||||
validated_at: datetime | None
|
||||
hit_count: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
expires_at: datetime | None
|
||||
|
||||
|
||||
class AnalysisAtomLookupResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
hit: bool
|
||||
atom: AnalysisAtomData | None = None
|
||||
staleness: AtomStaleness | None = None
|
||||
match_type: Literal["exact", "semantic"] | None = None
|
||||
|
||||
|
||||
class AnalysisAtomWriteRequest(BaseModel):
|
||||
"""POST /v1/analysis_atom — fire-and-forget write from backend after LLM run.
|
||||
|
||||
Brain rejects writes for tier='free' (premium-only ingest, by design).
|
||||
cache_tier is computed from llm_confidence (>= threshold → silver, else bronze).
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
content_hash: str = Field(..., min_length=8)
|
||||
content_preview: str | None = Field(None, max_length=500)
|
||||
component: AtomComponent
|
||||
tier: AtomTier
|
||||
prompt_hash: str = Field(..., min_length=4)
|
||||
framework_version: str | None = None
|
||||
model_used: str | None = None
|
||||
|
||||
result_processed: dict = Field(..., description="Canonical mapped result, stored 1:1")
|
||||
result_raw: dict | None = None
|
||||
llm_confidence: float | None = Field(None, ge=0, le=100)
|
||||
|
||||
# If None (default), cache_tier is decided server-side from llm_confidence.
|
||||
# Set explicitly only if caller wants to force a specific tier.
|
||||
cache_tier: AtomCacheTier | None = Field(None, description="Optional override; None = decide from confidence")
|
||||
|
||||
|
||||
class AnalysisAtomWriteResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
cached: bool
|
||||
atom_id: int | None = None
|
||||
cache_tier: AtomCacheTier | None = None
|
||||
skipped_reason: str | None = None # e.g. "tier=free" or "confidence_below_threshold"
|
||||
|
||||
|
||||
class AnalysisAtomPatchRequest(BaseModel):
|
||||
"""PATCH /v1/analysis_atom/{atom_id} — promote to gold after human review."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
human_validated: bool = True
|
||||
human_corrections: dict | None = None
|
||||
validator_user_id: str | None = None
|
||||
result_processed: dict | None = Field(None, description="Updated result after corrections applied")
|
||||
cache_tier: AtomCacheTier = "gold"
|
||||
|
||||
|
||||
class AnalysisAtomStatsResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
total_atoms: int
|
||||
by_tier: dict # {gold: N, silver: N, bronze: N}
|
||||
by_component: dict # {techniques: N, ai_tampered: N, claims: N}
|
||||
hit_rate_24h: float | None = None
|
||||
writes_24h: int
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Admin browser models (consumed by AI platform dashboard reskin)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AnalysisAtomListItem(BaseModel):
|
||||
"""Lightweight row for atom list (no result_raw / result_processed)."""
|
||||
|
||||
atom_id: int
|
||||
content_hash: str
|
||||
content_preview: str | None = None
|
||||
component: str
|
||||
tier: str
|
||||
cache_tier: str
|
||||
prompt_hash: str
|
||||
framework_version: str | None = None
|
||||
model_used: str | None = None
|
||||
llm_confidence: float | None = None
|
||||
human_validated: bool
|
||||
hit_count: int
|
||||
last_hit_at: datetime | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
expires_at: datetime | None = None
|
||||
|
||||
|
||||
class AnalysisAtomListResponse(BaseModel):
|
||||
items: list[AnalysisAtomListItem]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class AnalysisAtomDetailResponse(AnalysisAtomListItem):
|
||||
"""Full atom row including result payload + corrections."""
|
||||
|
||||
result_processed: dict
|
||||
result_raw: dict | None = None
|
||||
human_corrections: dict | None = None
|
||||
validator_user_id: str | None = None
|
||||
validated_at: datetime | None = None
|
||||
|
||||
|
||||
class VerificationCacheListItem(BaseModel):
|
||||
claim_hash: str
|
||||
tier: str
|
||||
model: str | None = None
|
||||
prompt_hash: str
|
||||
framework_version: str | None = None
|
||||
schema_name: str
|
||||
evidence_url_count: int
|
||||
status: str | None = None
|
||||
volatility: str | None = None
|
||||
topic_codes: list[str] = []
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
class VerificationCacheListResponse(BaseModel):
|
||||
items: list[VerificationCacheListItem]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class VerificationCacheDetailResponse(VerificationCacheListItem):
|
||||
evidence_urls: list[str]
|
||||
verification_processed: dict
|
||||
verification_raw: dict | None = None
|
||||
|
||||
|
||||
class TaxonomyInfoResponse(BaseModel):
|
||||
total_tags: int
|
||||
namespaces: list[str]
|
||||
by_namespace: dict
|
||||
|
||||
|
||||
class TaxonomyReloadResponse(BaseModel):
|
||||
ok: bool
|
||||
before: int | None = None
|
||||
after: int | None = None
|
||||
fetched: int | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class AnalysisAtomStatsExtendedResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
total_atoms: int
|
||||
by_tier: dict
|
||||
by_component: dict
|
||||
hit_rate_24h: float | None = None
|
||||
hits_24h_gold: int
|
||||
hits_24h_silver: int
|
||||
writes_24h: int
|
||||
gold_promotions_24h: int
|
||||
|
||||
|
||||
class GenericOkResponse(BaseModel):
|
||||
ok: bool
|
||||
message: str | None = None
|
||||
|
||||
|
||||
class CacheInvalidateRequest(BaseModel):
|
||||
"""POST /v1/cache/invalidate — Pilon 8 mass invalidation.
|
||||
|
||||
At least one filter field must be set (topic_codes, entity_canonicals,
|
||||
claim_pattern, or since) — empty filter is rejected to avoid accidental
|
||||
"flush everything". By default gold atoms are spared; pass
|
||||
``invalidate_gold=true`` to flush them too (only do this in moderator-
|
||||
initiated flows).
|
||||
|
||||
Use ``dry_run=true`` first to count matches without modifying anything.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
topic_codes: list[str] | None = Field(
|
||||
default=None,
|
||||
description="Match rows whose topic_codes overlap with this set.",
|
||||
)
|
||||
entity_canonicals: list[str] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Pre-normalized canonical forms ('subject predicate object' "
|
||||
"lowercased). Caller computes via fact_status.canonicalize_triple."
|
||||
),
|
||||
)
|
||||
claim_pattern: str | None = Field(
|
||||
default=None,
|
||||
max_length=200,
|
||||
description="ILIKE pattern matched against content_preview.",
|
||||
)
|
||||
since: str | None = Field(
|
||||
default=None,
|
||||
description="ISO datetime — match rows updated at or after this time.",
|
||||
)
|
||||
invalidate_gold: bool = Field(
|
||||
default=False,
|
||||
description="If true, also expire gold (human-validated) atoms.",
|
||||
)
|
||||
dry_run: bool = Field(
|
||||
default=False,
|
||||
description="Count matches without modifying anything.",
|
||||
)
|
||||
actor: str | None = Field(
|
||||
default=None,
|
||||
max_length=120,
|
||||
description="Audit-log label (e.g., 'admin:foo@bar', 'breaking_watcher').",
|
||||
)
|
||||
reason: str | None = Field(
|
||||
default=None,
|
||||
max_length=500,
|
||||
description="Optional human-readable note for audit trail.",
|
||||
)
|
||||
|
||||
|
||||
class CacheInvalidateResponse(BaseModel):
|
||||
"""Output of POST /v1/cache/invalidate."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
invalidated_atoms: int
|
||||
invalidated_vcache: int
|
||||
dry_run: bool
|
||||
filters_applied: dict
|
||||
executed_at: datetime
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Fact Status admin schemas (Phase D2)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class FactStatusItem(BaseModel):
|
||||
"""One brain_fact_status row, flattened for admin browser."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
fact_id: int
|
||||
subject: str
|
||||
predicate: str
|
||||
object: str
|
||||
canonical_form: str
|
||||
canonical_form_hash: str
|
||||
current_truth: bool | None = None
|
||||
current_version_id: int | None = None
|
||||
current_confidence: float | None = None
|
||||
last_verified_at: datetime | None = None
|
||||
last_evidence_urls: list[str] = Field(default_factory=list)
|
||||
volatility: str | None = None
|
||||
topic_codes: list[str] = Field(default_factory=list)
|
||||
next_check_at: datetime
|
||||
check_interval_hours: int
|
||||
moderator_locked: bool
|
||||
moderator_user_id: str | None = None
|
||||
moderator_notes: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FactStatusListResponse(BaseModel):
|
||||
items: list[FactStatusItem]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class FactStatusVersionItem(BaseModel):
|
||||
"""One brain_fact_version row."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
version_id: int
|
||||
fact_id: int
|
||||
truth_value: bool
|
||||
confidence: float | None = None
|
||||
valid_from: datetime
|
||||
valid_to: datetime | None = None
|
||||
source_atom_ids: list[str] = Field(default_factory=list)
|
||||
evidence_urls: list[str] = Field(default_factory=list)
|
||||
llm_reasoning: str | None = None
|
||||
created_by: str
|
||||
moderator_user_id: str | None = None
|
||||
notes: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class FactStatusVersionsResponse(BaseModel):
|
||||
versions: list[FactStatusVersionItem]
|
||||
fact_id: int
|
||||
total: int
|
||||
|
||||
|
||||
class FactStatusPatchRequest(BaseModel):
|
||||
"""PATCH /v1/fact_status/{fact_id} — moderator override.
|
||||
|
||||
Three orthogonal operations, all optional:
|
||||
- ``set_truth``: assert TRUE/FALSE as the moderator's verdict
|
||||
- ``lock``: prevent the auditor from auto-changing this fact
|
||||
- ``unlock``: re-enable auditor updates
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
set_truth: bool | None = Field(
|
||||
default=None,
|
||||
description="Set current_truth (TRUE/FALSE). Omit to leave unchanged.",
|
||||
)
|
||||
confidence: float | None = Field(
|
||||
default=None,
|
||||
ge=0.0,
|
||||
le=100.0,
|
||||
description="Moderator confidence in this assertion, 0-100.",
|
||||
)
|
||||
evidence_urls: list[str] = Field(default_factory=list)
|
||||
notes: str | None = Field(default=None, max_length=2000)
|
||||
lock: bool | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"true → moderator_locked=true (auditor must skip). "
|
||||
"false → unlock. None → leave as-is."
|
||||
),
|
||||
)
|
||||
moderator_user_id: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=120,
|
||||
description="Required — keycloak_id of the moderator making the change.",
|
||||
)
|
||||
|
||||
|
||||
class AuditLogItem(BaseModel):
|
||||
"""One brain_audit_log row."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
log_id: int
|
||||
action: str
|
||||
target_table: str
|
||||
target_id: str
|
||||
actor: str | None = None
|
||||
payload: dict
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AuditLogResponse(BaseModel):
|
||||
items: list[AuditLogItem]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class CanonicalizeRequest(BaseModel):
|
||||
"""POST /v1/canonicalize — Pilon 7 temporal disambiguation.
|
||||
|
||||
Caller (typically agent-v3) sends the raw user claim plus an optional
|
||||
explicit current_date (ISO). Brain returns the rewritten claim with
|
||||
relative time markers and ambiguous entities anchored. The caller hashes
|
||||
the canonical form for cache lookups.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
claim: str = Field(..., min_length=1, max_length=2000)
|
||||
current_date: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"ISO date (YYYY-MM-DD) used as 'now' for relative-marker "
|
||||
"resolution. Defaults to UTC today."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class CanonicalizeResponse(BaseModel):
|
||||
"""Output of POST /v1/canonicalize.
|
||||
|
||||
On LLM failure the canonical equals the original and ``error`` is set.
|
||||
Caller can still proceed (no regression).
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
canonical: str
|
||||
original: str
|
||||
changed: bool
|
||||
anchors_added: list[str] = Field(default_factory=list)
|
||||
reasoning: str = ""
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class ImageSearchRequest(BaseModel):
|
||||
"""POST /v1/image-search — stub, returns empty list."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
queries: list[str] = Field(..., min_length=1)
|
||||
max_results: int = Field(20, ge=1, le=200)
|
||||
|
||||
|
||||
class IngestRequest(BaseModel):
|
||||
"""POST /v1/ingest — populate brain from Didi's web-gathering results.
|
||||
|
||||
Body shape is intentionally liberal: we accept either a full GatherResponse
|
||||
(as emitted by the web module) or a thinned envelope with just evidence[].
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
claim: str | None = Field(None, description="Original query that produced this evidence.")
|
||||
evidence: list[EvidenceItem] = Field(default_factory=list)
|
||||
default_tags: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Canonical tag paths to apply to every ingested atom.",
|
||||
)
|
||||
run_extraction: bool = Field(
|
||||
True,
|
||||
description="If true, queue claim extraction on the newly ingested documents.",
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Shared sub-schemas
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class Provenance(BaseModel):
|
||||
"""Where this evidence came from and how it was produced."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
extraction_method: str = Field("http", description="http | browse | vision | brain")
|
||||
fallback_chain: list[str] = Field(default_factory=list)
|
||||
|
||||
# Additive DidiBrain-specific metadata — safe to ignore if unknown.
|
||||
brain_meta: "BrainEvidenceMeta | None" = None
|
||||
|
||||
|
||||
class BrainEvidenceMeta(BaseModel):
|
||||
"""Additive fields specific to DidiBrain that a consumer MAY use."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
parent_atom_id: str
|
||||
matching_claim_atom_ids: list[str] = Field(default_factory=list)
|
||||
best_claim_text: str = ""
|
||||
best_claim_stance_in_source: str = "NEUTRAL"
|
||||
best_claim_hash: str = ""
|
||||
claim_count: int = 0
|
||||
reranker_score: float = 0.0
|
||||
embedding_similarity: float = 0.0
|
||||
# NLI stance of the evidence AGAINST the user's query claim. Populated
|
||||
# when GatherRequest.run_nli is true (default). Unlike stance_in_source,
|
||||
# this is the direction the backend actually needs for disinfo verdicts.
|
||||
stance_vs_query: str = "UNKNOWN" # SUPPORTS / CONTRADICTS / NEUTRAL / UNKNOWN
|
||||
nli_confidence: float = 0.0 # 0..1
|
||||
nli_error: str | None = None # populated on timeout / bad response
|
||||
|
||||
|
||||
class Entities(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
persons: list[str] = Field(default_factory=list)
|
||||
institutions: list[str] = Field(default_factory=list)
|
||||
locations: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SearchContext(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
primary_country: str = "Global"
|
||||
secondary_countries: list[str] = Field(default_factory=list)
|
||||
entities: Entities = Field(default_factory=Entities)
|
||||
detected_language: str = "en"
|
||||
search_queries: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SearchResultItem(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
query: str
|
||||
url: str
|
||||
title: str
|
||||
snippet: str = ""
|
||||
rank: int
|
||||
site: str = ""
|
||||
published_at: datetime | None = None
|
||||
|
||||
|
||||
class StageRecord(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
stage: str
|
||||
success: bool
|
||||
items_processed: int = 0
|
||||
items_failed: int = 0
|
||||
duration_ms: float = 0.0
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class EvidenceStats(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
input_items: int = 0
|
||||
after_dedup: int = 0
|
||||
output_items: int = 0
|
||||
duplicates_removed: int = 0
|
||||
tokens_used: int = 0
|
||||
|
||||
|
||||
class EvidenceItem(BaseModel):
|
||||
"""One piece of evidence — always at the DOCUMENT level, not chunk level."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
url: str
|
||||
canonical_url: str | None = None
|
||||
title: str
|
||||
publisher: str = ""
|
||||
published_at: datetime | None = None
|
||||
retrieved_at: datetime
|
||||
snippet: str | None = None
|
||||
summary: str | None = None
|
||||
full_text: str | None = None
|
||||
full_text_hash: str = ""
|
||||
provenance: Provenance = Field(default_factory=Provenance)
|
||||
relevance_score: float = 0.0
|
||||
credibility_score: float = 0.5
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Response models
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class FailedUrl(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
url: str
|
||||
error: str
|
||||
|
||||
|
||||
class FetchedPage(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
url: str
|
||||
canonical_url: str | None = None
|
||||
title: str = ""
|
||||
text: str = ""
|
||||
text_hash: str = ""
|
||||
html: str | None = None
|
||||
extraction_method: str = "brain"
|
||||
fallback_chain: list[str] = Field(default_factory=list)
|
||||
published_at: datetime | None = None
|
||||
retrieved_at: datetime
|
||||
extraction_time_ms: float = 0.0
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
needs_fallback: bool = False
|
||||
status_code: int = 200
|
||||
content_type: str = "text/markdown"
|
||||
|
||||
|
||||
class BrainMeta(BaseModel):
|
||||
"""Top-level meta about the brain response — additive, safe to ignore."""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
cache_status: Literal["HIT", "PARTIAL", "MISS"] = "MISS"
|
||||
api_version: str = "v1"
|
||||
implementation: str = "didibrain"
|
||||
evidence_sources: int = 0
|
||||
total_claim_atoms_matched: int = 0
|
||||
|
||||
# ---- Verification cache (populated only when request included the flag) ----
|
||||
verification: dict | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"Cached verification payload when (claim, tier) is a HIT. Shape "
|
||||
"is opaque — returned 1:1 as stored by backend."
|
||||
),
|
||||
)
|
||||
verification_staleness: (
|
||||
Literal[
|
||||
"fresh",
|
||||
"stale_framework",
|
||||
"stale_prompt",
|
||||
"stale_evidence", # Pilon 11: bound facts have flipped
|
||||
"miss",
|
||||
]
|
||||
| None
|
||||
) = None
|
||||
verification_model: str | None = None
|
||||
verification_tier: str | None = None
|
||||
verification_prompt_hash: str | None = None
|
||||
verification_framework_version: str | None = None
|
||||
verification_cached_at: datetime | None = None
|
||||
verification_expires_at: datetime | None = None
|
||||
# URLs the cache was written for — surfaced so backend can detect corpus
|
||||
# drift and decide whether the cached verification still applies to the
|
||||
# current evidence set (e.g. compute URL overlap %).
|
||||
verification_evidence_urls: list[str] | None = None
|
||||
verification_evidence_hash: str | None = None
|
||||
# Pilon 11: when staleness=stale_evidence, lists each entity binding whose
|
||||
# current_truth in brain_fact_status contradicts what the cache assumed.
|
||||
# Each item: {subject, predicate, object, canonical_form, cached_assumes,
|
||||
# current_truth}.
|
||||
verification_facts_invalidated: list[dict] | None = None
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
request_id: str
|
||||
results: list[SearchResultItem] = Field(default_factory=list)
|
||||
total_results: int = 0
|
||||
execution_time_ms: float = 0.0
|
||||
queries_processed: int = 0
|
||||
brain_meta: BrainMeta | None = None
|
||||
|
||||
|
||||
class FetchResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
request_id: str
|
||||
pages: list[FetchedPage] = Field(default_factory=list)
|
||||
total_fetched: int = 0
|
||||
total_failed: int = 0
|
||||
execution_time_ms: float = 0.0
|
||||
failed_urls: list[FailedUrl] = Field(default_factory=list)
|
||||
brain_meta: BrainMeta | None = None
|
||||
|
||||
|
||||
class GatherResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
request_id: str
|
||||
claim: str
|
||||
evidence: list[EvidenceItem] = Field(default_factory=list)
|
||||
evidence_stats: EvidenceStats = Field(default_factory=EvidenceStats)
|
||||
search_context: SearchContext = Field(default_factory=SearchContext)
|
||||
search_results: list[SearchResultItem] = Field(default_factory=list)
|
||||
stages: list[StageRecord] = Field(default_factory=list)
|
||||
total_urls_found: int = 0
|
||||
total_pages_fetched: int = 0
|
||||
total_evidence_items: int = 0
|
||||
execution_time_ms: float = 0.0
|
||||
brain_meta: BrainMeta | None = None
|
||||
|
||||
|
||||
class ImageSearchResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
request_id: str
|
||||
results: list[Any] = Field(default_factory=list)
|
||||
total_results: int = 0
|
||||
execution_time_ms: float = 0.0
|
||||
queries_processed: int = 0
|
||||
brain_meta: BrainMeta | None = None
|
||||
|
||||
|
||||
class IngestResponse(BaseModel):
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
request_id: str
|
||||
accepted: int = 0
|
||||
skipped_duplicate: int = 0
|
||||
errors: int = 0
|
||||
created_atom_ids: list[str] = Field(default_factory=list)
|
||||
extraction_queued: bool = False
|
||||
execution_time_ms: float = 0.0
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
# Rebuild forward-ref
|
||||
Provenance.model_rebuild()
|
||||
532
ai_platform/modules/didi_brain/brain_api/services/admin.py
Normal file
532
ai_platform/modules/didi_brain/brain_api/services/admin.py
Normal file
|
|
@ -0,0 +1,532 @@
|
|||
"""Admin operations for Brain — used by AI platform dashboard.
|
||||
|
||||
Provides:
|
||||
- paginated atom + verification cache browsers with filters
|
||||
- manual expire (atom) / hard delete (verification cache)
|
||||
- taxonomy snapshot + reload trigger
|
||||
|
||||
Read paths return lightweight rows (no result_raw, no full evidence_urls
|
||||
arrays) to keep DataGrid responses small. Detail endpoints expose the full
|
||||
JSON payload.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Literal
|
||||
|
||||
from brain_api.db import db
|
||||
from shared.logging import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Atom admin
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AtomListRow:
|
||||
atom_id: int
|
||||
content_hash: str
|
||||
content_preview: str | None
|
||||
component: str
|
||||
tier: str
|
||||
cache_tier: str
|
||||
prompt_hash: str
|
||||
framework_version: str | None
|
||||
model_used: str | None
|
||||
llm_confidence: float | None
|
||||
human_validated: bool
|
||||
hit_count: int
|
||||
last_hit_at: datetime | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
expires_at: datetime | None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AtomDetailRow(AtomListRow):
|
||||
result_processed: dict
|
||||
result_raw: dict | None
|
||||
human_corrections: dict | None
|
||||
validator_user_id: str | None
|
||||
validated_at: datetime | None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AtomListPage:
|
||||
items: list[AtomListRow]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
_FRESHNESS_OPTIONS = ("fresh", "expiring", "expired", "all")
|
||||
|
||||
|
||||
def _atom_list_filters(
|
||||
component: str | None,
|
||||
tier: str | None,
|
||||
freshness: str | None,
|
||||
q: str | None,
|
||||
) -> tuple[str, list]:
|
||||
"""Build WHERE clause + params (1-indexed)."""
|
||||
where = ["1=1"]
|
||||
params: list[Any] = []
|
||||
idx = 1
|
||||
|
||||
if component and component != "all":
|
||||
where.append(f"component = ${idx}")
|
||||
params.append(component)
|
||||
idx += 1
|
||||
|
||||
if tier and tier != "all":
|
||||
# 'tier' here = cache_tier (gold/silver/bronze) — that's what users want to filter by
|
||||
where.append(f"cache_tier = ${idx}")
|
||||
params.append(tier)
|
||||
idx += 1
|
||||
|
||||
# Freshness on expires_at:
|
||||
# fresh = not yet expired (expires_at IS NULL or > now())
|
||||
# expiring = expires within 7 days
|
||||
# expired = expires_at <= now()
|
||||
if freshness == "fresh":
|
||||
where.append("(expires_at IS NULL OR expires_at > now())")
|
||||
elif freshness == "expiring":
|
||||
where.append("expires_at IS NOT NULL AND expires_at > now() AND expires_at <= now() + interval '7 days'")
|
||||
elif freshness == "expired":
|
||||
where.append("expires_at IS NOT NULL AND expires_at <= now()")
|
||||
# else "all" or None → no filter
|
||||
|
||||
if q:
|
||||
where.append(f"(content_preview ILIKE ${idx} OR content_hash ILIKE ${idx})")
|
||||
params.append(f"%{q}%")
|
||||
idx += 1
|
||||
|
||||
return " AND ".join(where), params
|
||||
|
||||
|
||||
async def list_atoms(
|
||||
*,
|
||||
component: str | None = None,
|
||||
tier: str | None = None,
|
||||
freshness: str | None = None,
|
||||
q: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 25,
|
||||
) -> AtomListPage:
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
where_sql, params = _atom_list_filters(component, tier, freshness, q)
|
||||
|
||||
count_sql = f"SELECT COUNT(*) AS n FROM brain_analysis_atom WHERE {where_sql}"
|
||||
list_sql = f"""
|
||||
SELECT atom_id, content_hash, content_preview, component, tier, cache_tier,
|
||||
prompt_hash, framework_version, model_used, llm_confidence,
|
||||
human_validated, hit_count, last_hit_at,
|
||||
created_at, updated_at, expires_at
|
||||
FROM brain_analysis_atom
|
||||
WHERE {where_sql}
|
||||
ORDER BY (cache_tier = 'gold') DESC, updated_at DESC
|
||||
LIMIT ${len(params)+1} OFFSET ${len(params)+2}
|
||||
"""
|
||||
|
||||
async with db.pool.acquire() as conn:
|
||||
count_row = await conn.fetchrow(count_sql, *params)
|
||||
rows = await conn.fetch(list_sql, *params, page_size, offset)
|
||||
|
||||
items = [
|
||||
AtomListRow(
|
||||
atom_id=r["atom_id"],
|
||||
content_hash=r["content_hash"],
|
||||
content_preview=r["content_preview"],
|
||||
component=r["component"],
|
||||
tier=r["tier"],
|
||||
cache_tier=r["cache_tier"],
|
||||
prompt_hash=r["prompt_hash"],
|
||||
framework_version=r["framework_version"],
|
||||
model_used=r["model_used"],
|
||||
llm_confidence=float(r["llm_confidence"]) if r["llm_confidence"] is not None else None,
|
||||
human_validated=r["human_validated"],
|
||||
hit_count=r["hit_count"] or 0,
|
||||
last_hit_at=r["last_hit_at"],
|
||||
created_at=r["created_at"],
|
||||
updated_at=r["updated_at"],
|
||||
expires_at=r["expires_at"],
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
return AtomListPage(
|
||||
items=items,
|
||||
total=count_row["n"] or 0,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
async def get_atom(atom_id: int) -> AtomDetailRow | None:
|
||||
sql = """
|
||||
SELECT atom_id, content_hash, content_preview, component, tier, cache_tier,
|
||||
prompt_hash, framework_version, model_used, llm_confidence,
|
||||
human_validated, hit_count, last_hit_at,
|
||||
created_at, updated_at, expires_at,
|
||||
result_processed, result_raw, human_corrections,
|
||||
validator_user_id, validated_at
|
||||
FROM brain_analysis_atom
|
||||
WHERE atom_id = $1
|
||||
"""
|
||||
async with db.pool.acquire() as conn:
|
||||
r = await conn.fetchrow(sql, atom_id)
|
||||
if not r:
|
||||
return None
|
||||
|
||||
rp = r["result_processed"]
|
||||
rr = r["result_raw"]
|
||||
hc = r["human_corrections"]
|
||||
if isinstance(rp, str):
|
||||
rp = json.loads(rp)
|
||||
if isinstance(rr, str):
|
||||
rr = json.loads(rr)
|
||||
if isinstance(hc, str):
|
||||
hc = json.loads(hc)
|
||||
|
||||
return AtomDetailRow(
|
||||
atom_id=r["atom_id"],
|
||||
content_hash=r["content_hash"],
|
||||
content_preview=r["content_preview"],
|
||||
component=r["component"],
|
||||
tier=r["tier"],
|
||||
cache_tier=r["cache_tier"],
|
||||
prompt_hash=r["prompt_hash"],
|
||||
framework_version=r["framework_version"],
|
||||
model_used=r["model_used"],
|
||||
llm_confidence=float(r["llm_confidence"]) if r["llm_confidence"] is not None else None,
|
||||
human_validated=r["human_validated"],
|
||||
hit_count=r["hit_count"] or 0,
|
||||
last_hit_at=r["last_hit_at"],
|
||||
created_at=r["created_at"],
|
||||
updated_at=r["updated_at"],
|
||||
expires_at=r["expires_at"],
|
||||
result_processed=rp or {},
|
||||
result_raw=rr,
|
||||
human_corrections=hc,
|
||||
validator_user_id=r["validator_user_id"],
|
||||
validated_at=r["validated_at"],
|
||||
)
|
||||
|
||||
|
||||
async def expire_atom(atom_id: int) -> bool:
|
||||
"""Mark atom as expired immediately (soft delete — keeps row for audit).
|
||||
|
||||
Returns True if a row was updated, False if not found.
|
||||
"""
|
||||
sql = """
|
||||
UPDATE brain_analysis_atom
|
||||
SET expires_at = now(), updated_at = now()
|
||||
WHERE atom_id = $1
|
||||
"""
|
||||
async with db.pool.acquire() as conn:
|
||||
result = await conn.execute(sql, atom_id)
|
||||
# asyncpg execute() returns string like "UPDATE 1"
|
||||
return result.endswith("1")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Verification cache admin
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class VerificationListRow:
|
||||
claim_hash: str
|
||||
tier: str
|
||||
model: str | None
|
||||
prompt_hash: str
|
||||
framework_version: str | None
|
||||
schema_name: str
|
||||
evidence_url_count: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
expires_at: datetime
|
||||
status: str | None # extracted from verification_processed.status if present
|
||||
volatility: str | None
|
||||
topic_codes: list[str]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class VerificationDetailRow(VerificationListRow):
|
||||
evidence_urls: list[str]
|
||||
verification_processed: dict
|
||||
verification_raw: dict | None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class VerificationListPage:
|
||||
items: list[VerificationListRow]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
def _verif_filters(
|
||||
tier: str | None,
|
||||
q: str | None,
|
||||
) -> tuple[str, list]:
|
||||
where = ["1=1"]
|
||||
params: list[Any] = []
|
||||
idx = 1
|
||||
|
||||
if tier and tier != "all":
|
||||
where.append(f"tier = ${idx}")
|
||||
params.append(tier)
|
||||
idx += 1
|
||||
|
||||
if q:
|
||||
# Search on claim_hash prefix or model name
|
||||
where.append(f"(claim_hash ILIKE ${idx} OR model ILIKE ${idx})")
|
||||
params.append(f"%{q}%")
|
||||
idx += 1
|
||||
|
||||
return " AND ".join(where), params
|
||||
|
||||
|
||||
async def list_verifications(
|
||||
*,
|
||||
tier: str | None = None,
|
||||
q: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 25,
|
||||
) -> VerificationListPage:
|
||||
page = max(1, page)
|
||||
page_size = min(max(1, page_size), 100)
|
||||
offset = (page - 1) * page_size
|
||||
|
||||
where_sql, params = _verif_filters(tier, q)
|
||||
|
||||
count_sql = f"SELECT COUNT(*) AS n FROM brain_verification_cache WHERE {where_sql}"
|
||||
list_sql = f"""
|
||||
SELECT claim_hash, tier, model, prompt_hash, framework_version, schema_name,
|
||||
jsonb_array_length(evidence_urls) AS evidence_url_count,
|
||||
verification_processed,
|
||||
volatility, topic_codes,
|
||||
created_at, updated_at, expires_at
|
||||
FROM brain_verification_cache
|
||||
WHERE {where_sql}
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ${len(params)+1} OFFSET ${len(params)+2}
|
||||
"""
|
||||
|
||||
async with db.pool.acquire() as conn:
|
||||
count_row = await conn.fetchrow(count_sql, *params)
|
||||
rows = await conn.fetch(list_sql, *params, page_size, offset)
|
||||
|
||||
items: list[VerificationListRow] = []
|
||||
for r in rows:
|
||||
vp = r["verification_processed"]
|
||||
if isinstance(vp, str):
|
||||
try:
|
||||
vp = json.loads(vp)
|
||||
except Exception:
|
||||
vp = {}
|
||||
status = vp.get("status") if isinstance(vp, dict) else None
|
||||
items.append(
|
||||
VerificationListRow(
|
||||
claim_hash=r["claim_hash"],
|
||||
tier=r["tier"],
|
||||
model=r["model"],
|
||||
prompt_hash=r["prompt_hash"],
|
||||
framework_version=r["framework_version"],
|
||||
schema_name=r["schema_name"],
|
||||
evidence_url_count=r["evidence_url_count"] or 0,
|
||||
created_at=r["created_at"],
|
||||
updated_at=r["updated_at"],
|
||||
expires_at=r["expires_at"],
|
||||
status=status,
|
||||
volatility=r["volatility"],
|
||||
topic_codes=list(r["topic_codes"]) if r["topic_codes"] else [],
|
||||
)
|
||||
)
|
||||
|
||||
return VerificationListPage(
|
||||
items=items,
|
||||
total=count_row["n"] or 0,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
async def get_verification(
|
||||
claim_hash: str, tier: Literal["free", "premium"]
|
||||
) -> VerificationDetailRow | None:
|
||||
sql = """
|
||||
SELECT claim_hash, tier, evidence_hash, evidence_urls, model, prompt_hash,
|
||||
framework_version, schema_name, verification_processed, verification_raw,
|
||||
volatility, topic_codes,
|
||||
created_at, updated_at, expires_at
|
||||
FROM brain_verification_cache
|
||||
WHERE claim_hash = $1 AND tier = $2
|
||||
"""
|
||||
async with db.pool.acquire() as conn:
|
||||
r = await conn.fetchrow(sql, claim_hash, tier)
|
||||
if not r:
|
||||
return None
|
||||
|
||||
ev = r["evidence_urls"]
|
||||
vp = r["verification_processed"]
|
||||
vr = r["verification_raw"]
|
||||
if isinstance(ev, str):
|
||||
ev = json.loads(ev)
|
||||
if isinstance(vp, str):
|
||||
vp = json.loads(vp)
|
||||
if isinstance(vr, str):
|
||||
vr = json.loads(vr)
|
||||
status = vp.get("status") if isinstance(vp, dict) else None
|
||||
|
||||
return VerificationDetailRow(
|
||||
claim_hash=r["claim_hash"],
|
||||
tier=r["tier"],
|
||||
model=r["model"],
|
||||
prompt_hash=r["prompt_hash"],
|
||||
framework_version=r["framework_version"],
|
||||
schema_name=r["schema_name"],
|
||||
evidence_url_count=len(ev) if ev else 0,
|
||||
created_at=r["created_at"],
|
||||
updated_at=r["updated_at"],
|
||||
expires_at=r["expires_at"],
|
||||
status=status,
|
||||
volatility=r["volatility"],
|
||||
topic_codes=list(r["topic_codes"]) if r["topic_codes"] else [],
|
||||
evidence_urls=list(ev) if ev else [],
|
||||
verification_processed=vp or {},
|
||||
verification_raw=vr,
|
||||
)
|
||||
|
||||
|
||||
async def delete_verification(
|
||||
claim_hash: str, tier: Literal["free", "premium"]
|
||||
) -> bool:
|
||||
sql = "DELETE FROM brain_verification_cache WHERE claim_hash = $1 AND tier = $2"
|
||||
async with db.pool.acquire() as conn:
|
||||
result = await conn.execute(sql, claim_hash, tier)
|
||||
return result.endswith("1")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Taxonomy admin
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def get_taxonomy_info(resolver) -> dict:
|
||||
"""Snapshot of currently loaded taxonomy.
|
||||
|
||||
`resolver.all` is a dict {path: tag_id}, so iterating it yields the path
|
||||
strings directly (e.g. "Country", "Country/France", "Topics/Health/COVID").
|
||||
"""
|
||||
all_paths: list[str] = list(resolver.all) if hasattr(resolver, "all") else []
|
||||
by_namespace: dict[str, int] = {}
|
||||
for path in all_paths:
|
||||
ns = path.split("/", 1)[0] if "/" in path else (path or "(root)")
|
||||
by_namespace[ns] = by_namespace.get(ns, 0) + 1
|
||||
|
||||
return {
|
||||
"total_tags": len(all_paths),
|
||||
"by_namespace": by_namespace,
|
||||
"namespaces": sorted(by_namespace.keys()),
|
||||
}
|
||||
|
||||
|
||||
async def reload_taxonomy(resolver, atomic) -> dict:
|
||||
"""Re-fetch tags from atomic and replace the in-process resolver state.
|
||||
|
||||
Returns a small status dict.
|
||||
"""
|
||||
from shared.taxonomy import build_path_map_from_tags
|
||||
|
||||
try:
|
||||
live_tags = await atomic.list_tags()
|
||||
path_map = build_path_map_from_tags(live_tags)
|
||||
before = len(resolver.all) if hasattr(resolver, "all") else 0
|
||||
if path_map:
|
||||
resolver.load_from_mapping(path_map)
|
||||
after = len(resolver.all) if hasattr(resolver, "all") else 0
|
||||
return {
|
||||
"ok": True,
|
||||
"before": before,
|
||||
"after": after,
|
||||
"fetched": len(path_map),
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.exception("taxonomy_reload_failed")
|
||||
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Extended stats (existing /v1/analysis_atom/stats kept; this returns more)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def get_stats_extended() -> dict:
|
||||
"""Extended stats: per-tier hit rates, top components, recent activity."""
|
||||
sql = """
|
||||
WITH base AS (
|
||||
SELECT
|
||||
cache_tier,
|
||||
component,
|
||||
hit_count,
|
||||
last_hit_at,
|
||||
created_at,
|
||||
validated_at,
|
||||
human_validated
|
||||
FROM brain_analysis_atom
|
||||
)
|
||||
SELECT
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE cache_tier='gold') AS gold,
|
||||
COUNT(*) FILTER (WHERE cache_tier='silver') AS silver,
|
||||
COUNT(*) FILTER (WHERE cache_tier='bronze') AS bronze,
|
||||
COUNT(*) FILTER (WHERE component='techniques') AS c_tech,
|
||||
COUNT(*) FILTER (WHERE component='ai_tampered') AS c_ai,
|
||||
COUNT(*) FILTER (WHERE component='claims') AS c_claims,
|
||||
COUNT(*) FILTER (WHERE created_at > now() - interval '24 hours') AS writes_24h,
|
||||
SUM(hit_count) FILTER (WHERE last_hit_at > now() - interval '24 hours') AS hits_24h,
|
||||
SUM(hit_count) FILTER (WHERE cache_tier='gold' AND last_hit_at > now() - interval '24 hours') AS hits_24h_gold,
|
||||
SUM(hit_count) FILTER (WHERE cache_tier='silver' AND last_hit_at > now() - interval '24 hours') AS hits_24h_silver,
|
||||
-- Count promotions by when the moderator validated, not when the atom was first written.
|
||||
COUNT(*) FILTER (WHERE human_validated = true AND validated_at > now() - interval '24 hours') AS gold_promotions_24h
|
||||
FROM base
|
||||
"""
|
||||
async with db.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(sql)
|
||||
|
||||
total = row["total"] or 0
|
||||
hits_24h = int(row["hits_24h"] or 0)
|
||||
writes_24h = int(row["writes_24h"] or 0)
|
||||
hits_24h_gold = int(row["hits_24h_gold"] or 0)
|
||||
hits_24h_silver = int(row["hits_24h_silver"] or 0)
|
||||
denom = hits_24h + writes_24h
|
||||
hit_rate_24h = (hits_24h / max(denom, 1)) if denom > 0 else None
|
||||
|
||||
return {
|
||||
"total_atoms": total,
|
||||
"by_tier": {
|
||||
"gold": row["gold"] or 0,
|
||||
"silver": row["silver"] or 0,
|
||||
"bronze": row["bronze"] or 0,
|
||||
},
|
||||
"by_component": {
|
||||
"techniques": row["c_tech"] or 0,
|
||||
"ai_tampered": row["c_ai"] or 0,
|
||||
"claims": row["c_claims"] or 0,
|
||||
},
|
||||
"hit_rate_24h": hit_rate_24h,
|
||||
"hits_24h_gold": hits_24h_gold,
|
||||
"hits_24h_silver": hits_24h_silver,
|
||||
"writes_24h": writes_24h,
|
||||
"gold_promotions_24h": int(row["gold_promotions_24h"] or 0),
|
||||
}
|
||||
|
|
@ -0,0 +1,761 @@
|
|||
"""Analysis atom cache — store full-component LLM results for techniques/ai_tampered/claims.
|
||||
|
||||
Contract with didi-backend agent-v3:
|
||||
- Before LLM run, agent-v3 calls POST /v1/analysis_atom/lookup with
|
||||
(content_hash, component, prompt_hash). On gold or silver+fresh hit,
|
||||
backend skips LLM and uses cached result.
|
||||
- After LLM run (only if tier='premium'), agent-v3 fires POST /v1/analysis_atom
|
||||
to cache the result. cache_tier is silver by default; bronze if llm_confidence
|
||||
is below the configured threshold.
|
||||
- After moderator resolves a session with corrections, agent-v3 calls PATCH
|
||||
/v1/analysis_atom/{atom_id} with human_validated=true to promote silver→gold.
|
||||
|
||||
Storage rules:
|
||||
- Lookup key: (content_hash, component, prompt_hash) — tier excluded so
|
||||
free users benefit from premium cached entries.
|
||||
- Write: rejected if tier='free' (only premium runs ingest).
|
||||
- Bronze atoms (low confidence) are stored for audit but NEVER served on
|
||||
lookup. They can be promoted to silver if a future run produces higher
|
||||
confidence on the same content.
|
||||
- Gold atoms have expires_at=NULL (forever). Silver/bronze get TTL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Literal
|
||||
|
||||
from brain_api.db import db
|
||||
from brain_api.services.classifier import (
|
||||
ClaimVolatility,
|
||||
classify_claim_volatility,
|
||||
)
|
||||
from shared.llm_client import LlmClient
|
||||
from shared.logging import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
# ----- legacy fallback TTLs when no classification is available -----
|
||||
# These are now the *startup defaults* — at runtime they can be overridden
|
||||
# live by the AI platform dashboard via RuntimeConfigClient (keys
|
||||
# `brain.atom.silver_ttl_days`, `brain.atom.bronze_ttl_days`,
|
||||
# `brain.atom.confidence_silver_threshold`). The helpers below read live
|
||||
# values with these as fallback. Direct constant access is kept for tests
|
||||
# and code paths that don't yet plumb through Settings.
|
||||
SILVER_TTL_HOURS = 90 * 24 # 2160h, ~3 months
|
||||
BRONZE_TTL_HOURS = 30 * 24 # 720h, ~1 month
|
||||
DEFAULT_CONFIDENCE_THRESHOLD = 60.0 # below → bronze, at/above → silver
|
||||
|
||||
|
||||
def _live_silver_ttl_hours() -> int:
|
||||
"""Read silver TTL from runtime config (or fall back to settings/constant).
|
||||
|
||||
Priority: dashboard live value > Settings default > constant. Settings is
|
||||
cached so this is essentially free; runtime_config falls back gracefully
|
||||
if the dashboard is unreachable.
|
||||
"""
|
||||
from brain_api.runtime_config import get_global_client
|
||||
from shared.config import get_settings
|
||||
rc = get_global_client()
|
||||
fallback_days = get_settings().atom_silver_ttl_days
|
||||
if rc is not None and rc.enabled:
|
||||
return rc.get_int("brain.atom.silver_ttl_days", fallback_days) * 24
|
||||
return fallback_days * 24
|
||||
|
||||
|
||||
def _live_bronze_ttl_hours() -> int:
|
||||
from brain_api.runtime_config import get_global_client
|
||||
from shared.config import get_settings
|
||||
rc = get_global_client()
|
||||
fallback_days = get_settings().atom_bronze_ttl_days
|
||||
if rc is not None and rc.enabled:
|
||||
return rc.get_int("brain.atom.bronze_ttl_days", fallback_days) * 24
|
||||
return fallback_days * 24
|
||||
|
||||
|
||||
def _live_confidence_threshold() -> float:
|
||||
from brain_api.runtime_config import get_global_client
|
||||
from shared.config import get_settings
|
||||
rc = get_global_client()
|
||||
fallback = get_settings().atom_confidence_silver_threshold
|
||||
if rc is not None and rc.enabled:
|
||||
return rc.get_float("brain.atom.confidence_silver_threshold", fallback)
|
||||
return fallback
|
||||
|
||||
AtomComponent = Literal["techniques", "ai_tampered", "claims"]
|
||||
AtomTier = Literal["free", "premium"]
|
||||
AtomCacheTier = Literal["gold", "silver", "bronze"]
|
||||
StalenessStatus = Literal["fresh", "stale_framework", "stale_prompt", "miss"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- DTO
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AtomEntry:
|
||||
atom_id: int
|
||||
content_hash: str
|
||||
component: str
|
||||
tier: str
|
||||
prompt_hash: str
|
||||
framework_version: str | None
|
||||
model_used: str | None
|
||||
cache_tier: str
|
||||
human_validated: bool
|
||||
human_corrections: dict | None
|
||||
validator_user_id: str | None
|
||||
validated_at: datetime | None
|
||||
result_processed: dict
|
||||
result_raw: dict | None
|
||||
llm_confidence: float | None
|
||||
hit_count: int
|
||||
last_hit_at: datetime | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
expires_at: datetime | None
|
||||
content_preview: str | None = None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- helpers
|
||||
|
||||
|
||||
def normalize_content_hash(content_hash: str) -> str:
|
||||
"""Backend computes content_hash already; this is just a passthrough/sanity check.
|
||||
|
||||
Convention: backend sends sha256(text)[:16] or sha256(text). We store as-is.
|
||||
"""
|
||||
return content_hash.strip().lower()
|
||||
|
||||
|
||||
def _decide_cache_tier(llm_confidence: float | None, override: str | None) -> str:
|
||||
if override in ("gold", "silver", "bronze"):
|
||||
return override
|
||||
if llm_confidence is None:
|
||||
# No confidence info → assume good enough (silver)
|
||||
return "silver"
|
||||
threshold = _live_confidence_threshold()
|
||||
return "silver" if llm_confidence >= threshold else "bronze"
|
||||
|
||||
|
||||
def _resolve_ttl_hours(
|
||||
*,
|
||||
cache_tier: str,
|
||||
classification: ClaimVolatility | None,
|
||||
) -> int | None:
|
||||
"""Pick the effective TTL in hours, combining cache_tier + classification.
|
||||
|
||||
Rules:
|
||||
- gold → None (forever, regardless of classification)
|
||||
- silver/bronze with classification → use classifier estimate (already
|
||||
capped per volatility tier in classifier.py)
|
||||
- silver/bronze without classification → fall back to legacy fixed TTLs
|
||||
|
||||
The classification's estimate is *already* clamped to per-tier hard caps
|
||||
(volatile≤48h, evolving≤720h, stable≤26280h) by the classifier, so we
|
||||
just trust it here.
|
||||
"""
|
||||
if cache_tier == "gold":
|
||||
return None
|
||||
if classification is not None and not classification.degraded:
|
||||
return classification.estimated_validity_hours
|
||||
return _live_silver_ttl_hours() if cache_tier == "silver" else _live_bronze_ttl_hours()
|
||||
|
||||
|
||||
def _expires_at_from_hours(ttl_hours: int | None) -> datetime | None:
|
||||
"""Convert TTL hours → expires_at timestamptz. None → no expiry (gold)."""
|
||||
if ttl_hours is None:
|
||||
return None
|
||||
return datetime.now(tz=timezone.utc) + timedelta(hours=ttl_hours)
|
||||
|
||||
|
||||
async def _get_or_compute_classification(
|
||||
*,
|
||||
classification: ClaimVolatility | None,
|
||||
llm: LlmClient | None,
|
||||
content_preview: str | None,
|
||||
) -> ClaimVolatility | None:
|
||||
"""Use caller-provided classification, else compute via LLM if possible.
|
||||
|
||||
Returns None if neither path is available — caller falls back to legacy
|
||||
behavior (no volatility, fixed TTL).
|
||||
"""
|
||||
if classification is not None:
|
||||
return classification
|
||||
if llm is None or not content_preview:
|
||||
return None
|
||||
try:
|
||||
return await classify_claim_volatility(llm, claim=content_preview)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning(
|
||||
"atom_upsert_classifier_failed",
|
||||
error=f"{type(e).__name__}:{e}",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def _register_facts_async(
|
||||
classification: ClaimVolatility | None,
|
||||
*,
|
||||
source_atom_id: int,
|
||||
) -> None:
|
||||
"""Best-effort fact registration after a successful upsert.
|
||||
|
||||
Imports lazily to avoid a circular import (fact_status imports classifier
|
||||
types). Errors are swallowed — fact registration is enrichment, not core.
|
||||
"""
|
||||
if classification is None or not classification.entity_bindings:
|
||||
return
|
||||
try:
|
||||
# Local import: services.fact_status imports classifier types, so
|
||||
# importing it at module load would create a cycle.
|
||||
from brain_api.services.fact_status import register_facts_from_bindings
|
||||
|
||||
await register_facts_from_bindings(
|
||||
classification.entity_bindings,
|
||||
volatility=classification.volatility,
|
||||
topic_codes=classification.topic_codes,
|
||||
source_atom_id=str(source_atom_id),
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning(
|
||||
"fact_registration_failed",
|
||||
atom_id=source_atom_id,
|
||||
error=f"{type(e).__name__}:{e}",
|
||||
)
|
||||
|
||||
|
||||
def _row_to_entry(row) -> AtomEntry:
|
||||
rp = row["result_processed"]
|
||||
rr = row["result_raw"]
|
||||
hc = row["human_corrections"]
|
||||
if isinstance(rp, str):
|
||||
rp = json.loads(rp)
|
||||
if isinstance(rr, str):
|
||||
rr = json.loads(rr)
|
||||
if isinstance(hc, str):
|
||||
hc = json.loads(hc)
|
||||
return AtomEntry(
|
||||
atom_id=row["atom_id"],
|
||||
content_hash=row["content_hash"],
|
||||
component=row["component"],
|
||||
tier=row["tier"],
|
||||
prompt_hash=row["prompt_hash"],
|
||||
framework_version=row["framework_version"],
|
||||
model_used=row["model_used"],
|
||||
cache_tier=row["cache_tier"],
|
||||
human_validated=row["human_validated"],
|
||||
human_corrections=hc,
|
||||
validator_user_id=row["validator_user_id"],
|
||||
validated_at=row["validated_at"],
|
||||
result_processed=rp or {},
|
||||
result_raw=rr,
|
||||
llm_confidence=float(row["llm_confidence"]) if row["llm_confidence"] is not None else None,
|
||||
hit_count=row["hit_count"],
|
||||
last_hit_at=row["last_hit_at"],
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
expires_at=row["expires_at"],
|
||||
content_preview=row.get("content_preview") if hasattr(row, "get") else None,
|
||||
)
|
||||
|
||||
|
||||
def decide_freshness(
|
||||
entry: AtomEntry | None,
|
||||
current_prompt_hash: str | None,
|
||||
current_framework_version: str | None,
|
||||
) -> StalenessStatus:
|
||||
"""fresh | stale_prompt | stale_framework | miss.
|
||||
|
||||
Gold atoms are ALWAYS fresh — human-validated answers don't depend on prompt.
|
||||
"""
|
||||
if entry is None:
|
||||
return "miss"
|
||||
if entry.cache_tier == "gold":
|
||||
return "fresh"
|
||||
if current_prompt_hash and entry.prompt_hash != current_prompt_hash:
|
||||
return "stale_prompt"
|
||||
if (
|
||||
current_framework_version
|
||||
and entry.framework_version
|
||||
and entry.framework_version != current_framework_version
|
||||
):
|
||||
return "stale_framework"
|
||||
return "fresh"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------- IO
|
||||
|
||||
|
||||
async def lookup(
|
||||
*,
|
||||
content_hash: str,
|
||||
component: AtomComponent,
|
||||
prompt_hash: str,
|
||||
framework_version: str | None = None,
|
||||
) -> tuple[AtomEntry | None, StalenessStatus]:
|
||||
"""Lookup an atom by (content_hash, component) — tier-agnostic.
|
||||
|
||||
Returns (entry_or_None, staleness). Bronze atoms are filtered out (NEVER
|
||||
served). For multiple matches with different prompt_hash, prefers gold.
|
||||
"""
|
||||
ch = normalize_content_hash(content_hash)
|
||||
sql = """
|
||||
SELECT atom_id, content_hash, component, tier, prompt_hash, framework_version,
|
||||
model_used, cache_tier, human_validated, human_corrections, validator_user_id,
|
||||
validated_at, result_processed, result_raw, llm_confidence, hit_count,
|
||||
last_hit_at, created_at, updated_at, expires_at, content_preview
|
||||
FROM brain_analysis_atom
|
||||
WHERE content_hash = $1
|
||||
AND component = $2
|
||||
AND cache_tier IN ('gold', 'silver')
|
||||
AND (expires_at IS NULL OR expires_at > now())
|
||||
ORDER BY (cache_tier = 'gold') DESC, updated_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
async with db.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(sql, ch, component)
|
||||
if not row:
|
||||
return None, "miss"
|
||||
|
||||
entry = _row_to_entry(row)
|
||||
staleness = decide_freshness(entry, prompt_hash, framework_version)
|
||||
|
||||
# Increment hit_count (best-effort)
|
||||
if staleness == "fresh":
|
||||
try:
|
||||
async with db.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"UPDATE brain_analysis_atom SET hit_count = hit_count + 1, last_hit_at = now() WHERE atom_id = $1",
|
||||
entry.atom_id,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
log.debug("hit_count_update_skipped", atom_id=entry.atom_id)
|
||||
|
||||
return entry, staleness
|
||||
|
||||
|
||||
async def upsert(
|
||||
*,
|
||||
content_hash: str,
|
||||
content_preview: str | None,
|
||||
component: AtomComponent,
|
||||
tier: AtomTier,
|
||||
prompt_hash: str,
|
||||
framework_version: str | None,
|
||||
model_used: str | None,
|
||||
result_processed: dict,
|
||||
result_raw: dict | None,
|
||||
llm_confidence: float | None,
|
||||
cache_tier_override: str | None = None,
|
||||
classification: ClaimVolatility | None = None,
|
||||
llm: LlmClient | None = None,
|
||||
) -> tuple[AtomEntry | None, str | None]:
|
||||
"""Insert or update an atom row, with volatility classification.
|
||||
|
||||
Pipeline:
|
||||
1. Reject tier='free' silently (premium-only ingest).
|
||||
2. If no ``classification`` provided and an ``llm`` client is, run the
|
||||
volatility classifier on ``content_preview`` to derive volatility,
|
||||
topic_codes, entity_bindings, and the recommended TTL.
|
||||
3. UPSERT the row with the new metadata columns. Gold rows are
|
||||
preserved on every soft field (truth-preserving).
|
||||
4. After successful write, schedule fact_status registration as a
|
||||
background task (best-effort, errors swallowed).
|
||||
|
||||
Args:
|
||||
content_hash, content_preview, component, tier, prompt_hash,
|
||||
framework_version, model_used, result_processed, result_raw,
|
||||
llm_confidence, cache_tier_override: same as before.
|
||||
classification: Pre-computed ClaimVolatility from caller. If None,
|
||||
attempts to compute via ``llm``.
|
||||
llm: LLM client for classifier. Optional — passing None disables
|
||||
classification (legacy fixed-TTL behavior).
|
||||
|
||||
Returns:
|
||||
``(entry, skip_reason)``. Skip cases (entry=None):
|
||||
- tier='free' → reject silently (premium-only ingest)
|
||||
- SQL error → propagated to caller
|
||||
"""
|
||||
if tier == "free":
|
||||
return None, "tier=free (premium-only ingest)"
|
||||
|
||||
ch = normalize_content_hash(content_hash)
|
||||
cache_tier = _decide_cache_tier(llm_confidence, cache_tier_override)
|
||||
|
||||
# Volatility classification — caller-provided or LLM-derived.
|
||||
classification = await _get_or_compute_classification(
|
||||
classification=classification,
|
||||
llm=llm,
|
||||
content_preview=content_preview,
|
||||
)
|
||||
|
||||
ttl_hours = _resolve_ttl_hours(
|
||||
cache_tier=cache_tier, classification=classification
|
||||
)
|
||||
expires_at = _expires_at_from_hours(ttl_hours)
|
||||
|
||||
volatility = classification.volatility if classification else None
|
||||
topic_codes = classification.topic_codes if classification else []
|
||||
entity_bindings_json = (
|
||||
json.dumps(classification.entity_bindings_jsonb())
|
||||
if classification
|
||||
else "[]"
|
||||
)
|
||||
|
||||
rp_json = json.dumps(result_processed)
|
||||
rr_json = json.dumps(result_raw) if result_raw is not None else None
|
||||
|
||||
sql = """
|
||||
INSERT INTO brain_analysis_atom (
|
||||
content_hash, content_preview, component, tier, prompt_hash, framework_version,
|
||||
model_used, result_processed, result_raw, llm_confidence,
|
||||
cache_tier, expires_at,
|
||||
volatility, topic_codes, entity_bindings, ttl_hours_used
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9::jsonb, $10, $11, $12,
|
||||
$13, $14, $15::jsonb, $16
|
||||
)
|
||||
ON CONFLICT (content_hash, component, prompt_hash) DO UPDATE SET
|
||||
-- DO NOT downgrade gold to silver — preserve human validation
|
||||
result_processed = CASE
|
||||
WHEN brain_analysis_atom.cache_tier = 'gold' THEN brain_analysis_atom.result_processed
|
||||
ELSE EXCLUDED.result_processed
|
||||
END,
|
||||
result_raw = CASE
|
||||
WHEN brain_analysis_atom.cache_tier = 'gold' THEN brain_analysis_atom.result_raw
|
||||
ELSE EXCLUDED.result_raw
|
||||
END,
|
||||
cache_tier = CASE
|
||||
WHEN brain_analysis_atom.cache_tier = 'gold' THEN brain_analysis_atom.cache_tier
|
||||
ELSE EXCLUDED.cache_tier
|
||||
END,
|
||||
llm_confidence = CASE
|
||||
WHEN brain_analysis_atom.cache_tier = 'gold' THEN brain_analysis_atom.llm_confidence
|
||||
ELSE EXCLUDED.llm_confidence
|
||||
END,
|
||||
model_used = COALESCE(EXCLUDED.model_used, brain_analysis_atom.model_used),
|
||||
framework_version = COALESCE(EXCLUDED.framework_version, brain_analysis_atom.framework_version),
|
||||
content_preview = COALESCE(EXCLUDED.content_preview, brain_analysis_atom.content_preview),
|
||||
tier = EXCLUDED.tier,
|
||||
updated_at = now(),
|
||||
expires_at = CASE
|
||||
WHEN brain_analysis_atom.cache_tier = 'gold' THEN brain_analysis_atom.expires_at
|
||||
ELSE EXCLUDED.expires_at
|
||||
END,
|
||||
-- Volatility metadata: prefer fresh values when present (a re-write
|
||||
-- with classifier may have better data than the original write).
|
||||
volatility = COALESCE(EXCLUDED.volatility, brain_analysis_atom.volatility),
|
||||
topic_codes = CASE
|
||||
WHEN array_length(EXCLUDED.topic_codes, 1) > 0
|
||||
THEN EXCLUDED.topic_codes
|
||||
ELSE brain_analysis_atom.topic_codes
|
||||
END,
|
||||
entity_bindings = CASE
|
||||
WHEN jsonb_array_length(EXCLUDED.entity_bindings) > 0
|
||||
THEN EXCLUDED.entity_bindings
|
||||
ELSE brain_analysis_atom.entity_bindings
|
||||
END,
|
||||
ttl_hours_used = COALESCE(EXCLUDED.ttl_hours_used, brain_analysis_atom.ttl_hours_used)
|
||||
RETURNING atom_id, content_hash, component, tier, prompt_hash, framework_version,
|
||||
model_used, cache_tier, human_validated, human_corrections, validator_user_id,
|
||||
validated_at, result_processed, result_raw, llm_confidence, hit_count,
|
||||
last_hit_at, created_at, updated_at, expires_at, content_preview
|
||||
"""
|
||||
|
||||
async with db.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
sql,
|
||||
ch, content_preview, component, tier, prompt_hash, framework_version,
|
||||
model_used, rp_json, rr_json, llm_confidence, cache_tier, expires_at,
|
||||
volatility, topic_codes, entity_bindings_json, ttl_hours,
|
||||
)
|
||||
assert row is not None
|
||||
entry = _row_to_entry(row)
|
||||
|
||||
# Fire-and-forget fact registration (best-effort, never blocks the write).
|
||||
if classification and classification.entity_bindings:
|
||||
asyncio.create_task(
|
||||
_register_facts_async(classification, source_atom_id=entry.atom_id)
|
||||
)
|
||||
|
||||
log.info(
|
||||
"atom_upsert_ok",
|
||||
atom_id=entry.atom_id,
|
||||
component=component,
|
||||
tier=tier,
|
||||
cache_tier=entry.cache_tier,
|
||||
volatility=volatility,
|
||||
ttl_hours=ttl_hours,
|
||||
topic_codes=topic_codes,
|
||||
binding_count=len(classification.entity_bindings) if classification else 0,
|
||||
)
|
||||
return entry, None
|
||||
|
||||
|
||||
async def patch_to_gold(
|
||||
*,
|
||||
atom_id: int,
|
||||
human_validated: bool = True,
|
||||
human_corrections: dict | None = None,
|
||||
validator_user_id: str | None = None,
|
||||
result_processed: dict | None = None,
|
||||
) -> AtomEntry | None:
|
||||
"""Promote an atom to gold after human review.
|
||||
|
||||
If result_processed is provided (corrections applied), it replaces the LLM
|
||||
result. Otherwise the existing result is kept (e.g. moderator approved as is).
|
||||
"""
|
||||
sets = [
|
||||
"human_validated = $2",
|
||||
"validator_user_id = $3",
|
||||
"validated_at = now()",
|
||||
"cache_tier = 'gold'",
|
||||
"expires_at = NULL",
|
||||
"updated_at = now()",
|
||||
]
|
||||
params: list[Any] = [atom_id, human_validated, validator_user_id]
|
||||
next_idx = 4
|
||||
|
||||
if human_corrections is not None:
|
||||
sets.append(f"human_corrections = ${next_idx}::jsonb")
|
||||
params.append(json.dumps(human_corrections))
|
||||
next_idx += 1
|
||||
else:
|
||||
sets.append("human_corrections = NULL")
|
||||
|
||||
if result_processed is not None:
|
||||
sets.append(f"result_processed = ${next_idx}::jsonb")
|
||||
params.append(json.dumps(result_processed))
|
||||
next_idx += 1
|
||||
|
||||
sql = f"""
|
||||
UPDATE brain_analysis_atom
|
||||
SET {", ".join(sets)}
|
||||
WHERE atom_id = $1
|
||||
RETURNING atom_id, content_hash, component, tier, prompt_hash, framework_version,
|
||||
model_used, cache_tier, human_validated, human_corrections, validator_user_id,
|
||||
validated_at, result_processed, result_raw, llm_confidence, hit_count,
|
||||
last_hit_at, created_at, updated_at, expires_at, content_preview
|
||||
"""
|
||||
|
||||
async with db.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(sql, *params)
|
||||
if not row:
|
||||
return None
|
||||
return _row_to_entry(row)
|
||||
|
||||
|
||||
async def get_stats() -> dict: # noqa: PLR0915 (kept compact; flake later)
|
||||
return await _get_stats_impl()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Phase B2: confidence decay + judge integration
|
||||
# ============================================================================
|
||||
# These helpers run on cache HITS to decide whether the cached verdict is
|
||||
# still trustworthy. They do not own the lookup query itself — callers
|
||||
# (gather.py, the lookup endpoint, the daily auditor) call ``lookup()`` first,
|
||||
# then optionally call ``judge_and_update`` if they have fresh evidence.
|
||||
|
||||
|
||||
# Decay half-lives (hours) per volatility tier. Beyond half-life, the cached
|
||||
# confidence is halved; at 2× half-life, quartered; etc. Stable claims do
|
||||
# not decay.
|
||||
DECAY_HALF_LIVES_HOURS: dict[str, float] = {
|
||||
"volatile": 24.0, # ~half confidence after 1 day
|
||||
"evolving": 168.0, # ~half after 1 week
|
||||
"stable": float("inf"),
|
||||
}
|
||||
|
||||
# Cap on audit_history length kept on each row — older entries are trimmed.
|
||||
AUDIT_HISTORY_MAX = 50
|
||||
|
||||
# Minimum hours between audit-pass increments triggered by lookup judges.
|
||||
# Without this, popular content hits the auditor 100×/day and consecutive_-
|
||||
# audit_passes rockets, defeating the purpose. The daily auditor cron is
|
||||
# the authoritative source of audit passes; lookups only nudge.
|
||||
AUDIT_PASS_MIN_INTERVAL_HOURS = 6.0
|
||||
|
||||
# Confidence floor below which a "fresh" cache entry is treated as miss.
|
||||
EFFECTIVE_CONFIDENCE_FLOOR = 60.0
|
||||
|
||||
|
||||
def compute_effective_confidence(
|
||||
*,
|
||||
base_confidence: float | None,
|
||||
volatility: str | None,
|
||||
age_hours: float,
|
||||
consecutive_audit_passes: int = 0,
|
||||
) -> float | None:
|
||||
"""Decay base confidence by age, modulated by volatility and audit history.
|
||||
|
||||
Stable rows do not decay. Volatile/evolving rows lose confidence with
|
||||
exponential half-life. Atoms that survived many audits get a multiplier
|
||||
boost (max +30% over base).
|
||||
|
||||
Returns:
|
||||
Decayed confidence value, or None if base was None.
|
||||
"""
|
||||
if base_confidence is None:
|
||||
return None
|
||||
half = DECAY_HALF_LIVES_HOURS.get(volatility or "evolving", 168.0)
|
||||
if half == float("inf") or age_hours <= 0:
|
||||
decay = 1.0
|
||||
else:
|
||||
# Exponential decay: each half-life halves the confidence.
|
||||
decay = 0.5 ** (age_hours / half)
|
||||
audit_boost = min(0.3, 0.03 * max(0, consecutive_audit_passes))
|
||||
return float(base_confidence) * decay * (1.0 + audit_boost)
|
||||
|
||||
|
||||
def is_effectively_fresh(
|
||||
*,
|
||||
base_confidence: float | None,
|
||||
volatility: str | None,
|
||||
age_hours: float,
|
||||
consecutive_audit_passes: int = 0,
|
||||
floor: float = EFFECTIVE_CONFIDENCE_FLOOR,
|
||||
) -> bool:
|
||||
"""True if the decayed confidence is above the freshness floor.
|
||||
|
||||
Callers can use this *in addition* to ``decide_freshness`` to drop
|
||||
entries that are technically not stale but have decayed below usable
|
||||
confidence.
|
||||
"""
|
||||
eff = compute_effective_confidence(
|
||||
base_confidence=base_confidence,
|
||||
volatility=volatility,
|
||||
age_hours=age_hours,
|
||||
consecutive_audit_passes=consecutive_audit_passes,
|
||||
)
|
||||
if eff is None:
|
||||
# No base confidence stored → trust the freshness flag from the SQL
|
||||
# path; we have no other signal.
|
||||
return True
|
||||
return eff >= floor
|
||||
|
||||
|
||||
async def apply_judge_verdict(
|
||||
atom_id: int,
|
||||
verdict: object, # JudgeVerdict — typed loosely to avoid circular import
|
||||
) -> None:
|
||||
"""Persist a JudgeVerdict to brain_analysis_atom.
|
||||
|
||||
Updates audit_history (append, cap at AUDIT_HISTORY_MAX), last_audited_at,
|
||||
consecutive_audit_passes (incremented only when KEEP_CACHE and last
|
||||
increment was >AUDIT_PASS_MIN_INTERVAL_HOURS ago), and expires_at on
|
||||
INVALIDATE (sets to now() so the row is treated as expired).
|
||||
|
||||
Also writes a brain_audit_log row for global telemetry.
|
||||
"""
|
||||
if not db.pool:
|
||||
raise RuntimeError("brain_db not connected")
|
||||
# Lazy import to avoid circular: cache_judge ← nli ← (transitively) us.
|
||||
from brain_api.services.cache_judge import JudgeVerdict
|
||||
|
||||
if not isinstance(verdict, JudgeVerdict):
|
||||
raise TypeError(
|
||||
f"apply_judge_verdict: expected JudgeVerdict, got {type(verdict).__name__}"
|
||||
)
|
||||
audit_entry = verdict.to_audit_entry()
|
||||
audit_json = json.dumps(audit_entry)
|
||||
|
||||
sql = """
|
||||
UPDATE brain_analysis_atom
|
||||
SET
|
||||
audit_history = (
|
||||
-- Append new entry, then keep only the last AUDIT_HISTORY_MAX.
|
||||
SELECT jsonb_agg(elem)
|
||||
FROM (
|
||||
SELECT elem
|
||||
FROM jsonb_array_elements(
|
||||
COALESCE(audit_history, '[]'::jsonb) || $2::jsonb
|
||||
) WITH ORDINALITY AS t(elem, ord)
|
||||
ORDER BY ord DESC
|
||||
LIMIT $3
|
||||
) recent
|
||||
),
|
||||
last_audited_at = now(),
|
||||
consecutive_audit_passes = CASE
|
||||
WHEN $4 = 'KEEP_CACHE' AND (
|
||||
last_audited_at IS NULL
|
||||
OR last_audited_at < now() - ($5 || ' hours')::interval
|
||||
)
|
||||
THEN consecutive_audit_passes + 1
|
||||
WHEN $4 = 'INVALIDATE' THEN 0
|
||||
ELSE consecutive_audit_passes
|
||||
END,
|
||||
expires_at = CASE
|
||||
WHEN $4 = 'INVALIDATE' AND cache_tier <> 'gold' THEN now()
|
||||
ELSE expires_at
|
||||
END,
|
||||
updated_at = now()
|
||||
WHERE atom_id = $1
|
||||
"""
|
||||
async with db.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
sql,
|
||||
atom_id,
|
||||
json.dumps([audit_entry]), # wrap as JSONB array for concat
|
||||
AUDIT_HISTORY_MAX,
|
||||
verdict.decision,
|
||||
str(int(AUDIT_PASS_MIN_INTERVAL_HOURS)),
|
||||
)
|
||||
|
||||
# Audit log entry for cross-table telemetry / dashboards.
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO brain_audit_log (action, target_table, target_id, actor, payload)
|
||||
VALUES ($1, 'brain_analysis_atom', $2, 'cache_judge', $3::jsonb)
|
||||
""",
|
||||
f"judge_{verdict.decision.lower()}",
|
||||
str(atom_id),
|
||||
audit_json,
|
||||
)
|
||||
|
||||
|
||||
async def _get_stats_impl() -> dict:
|
||||
"""Return aggregated counts for monitoring."""
|
||||
sql = """
|
||||
SELECT
|
||||
COUNT(*) AS total_atoms,
|
||||
COUNT(*) FILTER (WHERE cache_tier = 'gold') AS gold,
|
||||
COUNT(*) FILTER (WHERE cache_tier = 'silver') AS silver,
|
||||
COUNT(*) FILTER (WHERE cache_tier = 'bronze') AS bronze,
|
||||
COUNT(*) FILTER (WHERE component = 'techniques') AS c_techniques,
|
||||
COUNT(*) FILTER (WHERE component = 'ai_tampered') AS c_ai_tampered,
|
||||
COUNT(*) FILTER (WHERE component = 'claims') AS c_claims,
|
||||
COUNT(*) FILTER (WHERE created_at > now() - interval '24 hours') AS writes_24h,
|
||||
SUM(hit_count) FILTER (WHERE last_hit_at > now() - interval '24 hours') AS hits_24h
|
||||
FROM brain_analysis_atom
|
||||
"""
|
||||
async with db.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(sql)
|
||||
|
||||
total = row["total_atoms"] or 0
|
||||
hits_24h = int(row["hits_24h"] or 0)
|
||||
writes_24h = int(row["writes_24h"] or 0)
|
||||
hit_rate = None
|
||||
if hits_24h + writes_24h > 0:
|
||||
hit_rate = hits_24h / max(hits_24h + writes_24h, 1)
|
||||
|
||||
return {
|
||||
"total_atoms": total,
|
||||
"by_tier": {
|
||||
"gold": row["gold"] or 0,
|
||||
"silver": row["silver"] or 0,
|
||||
"bronze": row["bronze"] or 0,
|
||||
},
|
||||
"by_component": {
|
||||
"techniques": row["c_techniques"] or 0,
|
||||
"ai_tampered": row["c_ai_tampered"] or 0,
|
||||
"claims": row["c_claims"] or 0,
|
||||
},
|
||||
"hit_rate_24h": hit_rate,
|
||||
"writes_24h": writes_24h,
|
||||
}
|
||||
315
ai_platform/modules/didi_brain/brain_api/services/cache_judge.py
Normal file
315
ai_platform/modules/didi_brain/brain_api/services/cache_judge.py
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
"""Cache Judge — Pilon 2 of the cache freshness defense.
|
||||
|
||||
On every cache hit (verification_cache or analysis_atom), the judge runs NLI
|
||||
between the cached truth direction and current top-K fresh evidence. If fresh
|
||||
sources contradict the cached verdict, the cache is invalidated and the
|
||||
caller is forced to recompute with current data.
|
||||
|
||||
Decision rules:
|
||||
- **KEEP_CACHE** — fresh evidence supports the cached verdict (or volatility
|
||||
is stable and age is below threshold, where we skip NLI entirely).
|
||||
- **INVALIDATE** — fresh evidence contradicts the cached verdict above the
|
||||
threshold; caller must treat this as a miss and recompute.
|
||||
- **NEEDS_FULL_RECHECK** — evidence is mostly neutral or split; caller may
|
||||
still serve the cache but should mark it as low-confidence.
|
||||
|
||||
Cheap path: stable claims younger than ``STABLE_NLI_SKIP_HOURS`` skip NLI
|
||||
entirely (no LLM call) — pure cache hit.
|
||||
|
||||
Confidence boost: rows that passed many consecutive audits get a higher
|
||||
contradiction threshold (we trust them more). A row that survived 10 daily
|
||||
audits requires more contradicting evidence to invalidate than a fresh write.
|
||||
|
||||
Audit logging: callers should append the JudgeVerdict to brain_analysis_atom.
|
||||
audit_history (or brain_verification_cache.audit_history) so we have a
|
||||
running record of why a cache was kept or invalidated.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Literal
|
||||
|
||||
from brain_api.services.nli import NliResult, classify_batch
|
||||
from shared.llm_client import LlmClient
|
||||
from shared.logging import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
Decision = Literal["KEEP_CACHE", "INVALIDATE", "NEEDS_FULL_RECHECK"]
|
||||
CachedTruth = Literal["TRUE", "FALSE", "MIXED", "UNVERIFIED"]
|
||||
|
||||
# Skip NLI entirely for stable claims younger than this. Pure cache hit, zero
|
||||
# LLM cost. Stable + old → still run NLI (rare facts can change).
|
||||
STABLE_NLI_SKIP_HOURS = 720.0 # 30 days
|
||||
|
||||
# Truncate evidence text before sending to NLI — matches nli.MAX_EVIDENCE_CHARS.
|
||||
MAX_EVIDENCE_CHARS = 1500
|
||||
MAX_EVIDENCE_PIECES = 3 # only judge against top-3 fresh sources
|
||||
|
||||
# Decision thresholds. These are the fractions of NLI calls that determine
|
||||
# the verdict.
|
||||
#
|
||||
# When cached truth is TRUE:
|
||||
# contradicts_fraction >= INVALIDATE_THRESHOLD → INVALIDATE
|
||||
# supports_fraction >= KEEP_THRESHOLD → KEEP_CACHE
|
||||
# else → NEEDS_FULL_RECHECK
|
||||
#
|
||||
# When cached truth is FALSE: roles flip — supports invalidates, contradicts keeps.
|
||||
INVALIDATE_THRESHOLD = 0.50 # 50% disagreeing → invalidate
|
||||
KEEP_THRESHOLD = 0.50 # 50% agreeing → keep
|
||||
MIN_CONFIDENCE = 0.50 # NLI calls below this confidence are ignored
|
||||
|
||||
# Confidence boost from consecutive audit passes — each pass nudges the
|
||||
# invalidate threshold up by this much (so trusted atoms are harder to flip).
|
||||
AUDIT_PASS_BONUS = 0.03 # +3% per pass
|
||||
MAX_AUDIT_BONUS = 0.30 # cap at +30%
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class EvidenceSnippet:
|
||||
"""A single piece of fresh evidence to judge cached verdicts against.
|
||||
|
||||
Attributes:
|
||||
url: Canonical source URL (used in audit log).
|
||||
text: The text excerpt from the source. Will be truncated to
|
||||
``MAX_EVIDENCE_CHARS`` before NLI.
|
||||
published_at: When the source was published (ISO string or None).
|
||||
Used by callers to filter out stale evidence before passing here.
|
||||
"""
|
||||
|
||||
url: str
|
||||
text: str
|
||||
published_at: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class JudgeVerdict:
|
||||
"""Output of the cache judge — drives KEEP/INVALIDATE/RECHECK decision.
|
||||
|
||||
Attributes:
|
||||
decision: One of "KEEP_CACHE", "INVALIDATE", "NEEDS_FULL_RECHECK".
|
||||
nli_results: Per-evidence NLI labels (for audit_history).
|
||||
nli_skipped: True if we took the cheap path and skipped LLM entirely.
|
||||
supports_fraction: Fraction of high-confidence NLI calls labelled SUPPORTS.
|
||||
contradicts_fraction: Fraction labelled CONTRADICTS.
|
||||
neutral_fraction: Fraction labelled NEUTRAL (or low-confidence).
|
||||
effective_invalidate_threshold: Threshold actually applied (after audit bonus).
|
||||
reasoning: Short human-readable explanation, suitable for audit_history.
|
||||
evaluated_at: When the judgment was made (UTC ISO).
|
||||
"""
|
||||
|
||||
decision: Decision
|
||||
nli_results: list[NliResult] = field(default_factory=list)
|
||||
nli_skipped: bool = False
|
||||
supports_fraction: float = 0.0
|
||||
contradicts_fraction: float = 0.0
|
||||
neutral_fraction: float = 0.0
|
||||
effective_invalidate_threshold: float = INVALIDATE_THRESHOLD
|
||||
reasoning: str = ""
|
||||
evaluated_at: str = ""
|
||||
|
||||
def to_audit_entry(self) -> dict[str, object]:
|
||||
"""Serialize for appending to brain_analysis_atom.audit_history."""
|
||||
return {
|
||||
"evaluated_at": self.evaluated_at,
|
||||
"decision": self.decision,
|
||||
"nli_skipped": self.nli_skipped,
|
||||
"supports": round(self.supports_fraction, 3),
|
||||
"contradicts": round(self.contradicts_fraction, 3),
|
||||
"neutral": round(self.neutral_fraction, 3),
|
||||
"threshold": round(self.effective_invalidate_threshold, 3),
|
||||
"reasoning": self.reasoning[:200],
|
||||
"evidence_count": len(self.nli_results),
|
||||
}
|
||||
|
||||
|
||||
def _compute_invalidate_threshold(consecutive_audit_passes: int) -> float:
|
||||
"""Audit-pass bonus: trusted atoms are harder to invalidate.
|
||||
|
||||
Each consecutive daily audit that judged KEEP_CACHE increments the
|
||||
threshold so a single contradicting source can't overturn an atom that's
|
||||
been stable for weeks.
|
||||
"""
|
||||
bonus = min(
|
||||
MAX_AUDIT_BONUS,
|
||||
AUDIT_PASS_BONUS * max(0, consecutive_audit_passes),
|
||||
)
|
||||
return min(0.95, INVALIDATE_THRESHOLD + bonus)
|
||||
|
||||
|
||||
def _aggregate_nli(results: list[NliResult]) -> tuple[float, float, float]:
|
||||
"""Compute (supports, contradicts, neutral) fractions over high-confidence calls.
|
||||
|
||||
Low-confidence (< MIN_CONFIDENCE) and errored calls count as NEUTRAL — we
|
||||
don't want noisy signals to invalidate cache.
|
||||
"""
|
||||
if not results:
|
||||
return 0.0, 0.0, 1.0
|
||||
|
||||
supports = 0
|
||||
contradicts = 0
|
||||
neutral = 0
|
||||
for r in results:
|
||||
if r.error or r.confidence < MIN_CONFIDENCE:
|
||||
neutral += 1
|
||||
elif r.label == "SUPPORTS":
|
||||
supports += 1
|
||||
elif r.label == "CONTRADICTS":
|
||||
contradicts += 1
|
||||
else:
|
||||
neutral += 1
|
||||
|
||||
total = float(len(results))
|
||||
return supports / total, contradicts / total, neutral / total
|
||||
|
||||
|
||||
def _decide_for_truth_direction(
|
||||
*,
|
||||
cached_truth: CachedTruth,
|
||||
supports_fraction: float,
|
||||
contradicts_fraction: float,
|
||||
invalidate_threshold: float,
|
||||
) -> tuple[Decision, str]:
|
||||
"""Map NLI aggregates to KEEP/INVALIDATE/RECHECK based on cached truth direction."""
|
||||
|
||||
if cached_truth == "TRUE":
|
||||
# We expect SUPPORTS. CONTRADICTS is the danger signal.
|
||||
if contradicts_fraction >= invalidate_threshold:
|
||||
return "INVALIDATE", (
|
||||
f"cached=TRUE but {contradicts_fraction:.0%} of fresh evidence "
|
||||
f"contradicts (threshold {invalidate_threshold:.0%})"
|
||||
)
|
||||
if supports_fraction >= KEEP_THRESHOLD:
|
||||
return "KEEP_CACHE", (
|
||||
f"cached=TRUE confirmed by {supports_fraction:.0%} fresh evidence"
|
||||
)
|
||||
return "NEEDS_FULL_RECHECK", (
|
||||
f"cached=TRUE but evidence is split: "
|
||||
f"{supports_fraction:.0%}/{contradicts_fraction:.0%}"
|
||||
)
|
||||
|
||||
if cached_truth == "FALSE":
|
||||
# We expect CONTRADICTS. SUPPORTS is the danger signal (claim now true).
|
||||
if supports_fraction >= invalidate_threshold:
|
||||
return "INVALIDATE", (
|
||||
f"cached=FALSE but {supports_fraction:.0%} of fresh evidence "
|
||||
f"supports (threshold {invalidate_threshold:.0%})"
|
||||
)
|
||||
if contradicts_fraction >= KEEP_THRESHOLD:
|
||||
return "KEEP_CACHE", (
|
||||
f"cached=FALSE confirmed by {contradicts_fraction:.0%} fresh evidence"
|
||||
)
|
||||
return "NEEDS_FULL_RECHECK", (
|
||||
f"cached=FALSE but evidence is split: "
|
||||
f"{supports_fraction:.0%}/{contradicts_fraction:.0%}"
|
||||
)
|
||||
|
||||
# MIXED / UNVERIFIED — caller couldn't decide originally either; if fresh
|
||||
# evidence is now decisive in either direction, force a full recheck so
|
||||
# a stronger verdict can be issued.
|
||||
if supports_fraction >= KEEP_THRESHOLD or contradicts_fraction >= KEEP_THRESHOLD:
|
||||
return "NEEDS_FULL_RECHECK", (
|
||||
f"cached={cached_truth} but fresh evidence has shifted "
|
||||
f"({supports_fraction:.0%}/{contradicts_fraction:.0%})"
|
||||
)
|
||||
return "KEEP_CACHE", (
|
||||
f"cached={cached_truth}, fresh evidence still inconclusive"
|
||||
)
|
||||
|
||||
|
||||
async def judge_cache_validity(
|
||||
llm: LlmClient,
|
||||
*,
|
||||
claim: str,
|
||||
cached_truth: CachedTruth,
|
||||
current_evidence: list[EvidenceSnippet],
|
||||
volatility: str,
|
||||
age_hours: float,
|
||||
consecutive_audit_passes: int = 0,
|
||||
) -> JudgeVerdict:
|
||||
"""Decide whether a cached verdict still holds against current evidence.
|
||||
|
||||
Args:
|
||||
llm: LLM client (used by NLI).
|
||||
claim: The original claim text — what the cache was written for.
|
||||
cached_truth: The truth direction the cache claims (TRUE/FALSE/MIXED/UNVERIFIED).
|
||||
current_evidence: Top-K fresh evidence snippets from /v1/gather. Caller
|
||||
should already have applied recency filtering for volatile topics.
|
||||
volatility: One of "volatile", "evolving", "stable" — controls the
|
||||
cheap-path skip and influences logging.
|
||||
age_hours: How old the cache row is (for cheap-path eligibility).
|
||||
consecutive_audit_passes: How many prior audits the cache survived.
|
||||
Increases invalidation resistance.
|
||||
|
||||
Returns:
|
||||
JudgeVerdict — never raises. On NLI failure, individual evidence calls
|
||||
return NEUTRAL with error set; aggregation handles it gracefully.
|
||||
"""
|
||||
now_iso = datetime.now(tz=timezone.utc).isoformat()
|
||||
|
||||
# Cheap path: stable + young → trust the cache without LLM.
|
||||
if volatility == "stable" and age_hours < STABLE_NLI_SKIP_HOURS:
|
||||
return JudgeVerdict(
|
||||
decision="KEEP_CACHE",
|
||||
nli_skipped=True,
|
||||
reasoning=(
|
||||
f"stable + age {age_hours:.0f}h < {STABLE_NLI_SKIP_HOURS:.0f}h "
|
||||
f"(cheap path)"
|
||||
),
|
||||
evaluated_at=now_iso,
|
||||
)
|
||||
|
||||
# No fresh evidence to check against → can't make a decision; let the
|
||||
# caller treat as a recheck so they go and gather some.
|
||||
if not current_evidence:
|
||||
return JudgeVerdict(
|
||||
decision="NEEDS_FULL_RECHECK",
|
||||
reasoning="no fresh evidence available to judge against",
|
||||
evaluated_at=now_iso,
|
||||
)
|
||||
|
||||
# Truncate + cap evidence count.
|
||||
snippets = current_evidence[:MAX_EVIDENCE_PIECES]
|
||||
evidence_texts = [s.text[:MAX_EVIDENCE_CHARS] for s in snippets]
|
||||
|
||||
nli_results = await classify_batch(
|
||||
llm,
|
||||
claim=claim,
|
||||
evidence_texts=evidence_texts,
|
||||
)
|
||||
|
||||
supports, contradicts, neutral = _aggregate_nli(nli_results)
|
||||
threshold = _compute_invalidate_threshold(consecutive_audit_passes)
|
||||
decision, reasoning = _decide_for_truth_direction(
|
||||
cached_truth=cached_truth,
|
||||
supports_fraction=supports,
|
||||
contradicts_fraction=contradicts,
|
||||
invalidate_threshold=threshold,
|
||||
)
|
||||
|
||||
log.info(
|
||||
"cache_judge_done",
|
||||
decision=decision,
|
||||
cached_truth=cached_truth,
|
||||
volatility=volatility,
|
||||
age_hours=round(age_hours, 1),
|
||||
supports=round(supports, 2),
|
||||
contradicts=round(contradicts, 2),
|
||||
neutral=round(neutral, 2),
|
||||
threshold=round(threshold, 2),
|
||||
audit_passes=consecutive_audit_passes,
|
||||
)
|
||||
|
||||
return JudgeVerdict(
|
||||
decision=decision,
|
||||
nli_results=nli_results,
|
||||
nli_skipped=False,
|
||||
supports_fraction=supports,
|
||||
contradicts_fraction=contradicts,
|
||||
neutral_fraction=neutral,
|
||||
effective_invalidate_threshold=threshold,
|
||||
reasoning=reasoning,
|
||||
evaluated_at=now_iso,
|
||||
)
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
"""Temporal canonicalizer — Pilon 7 of the cache freshness defense.
|
||||
|
||||
Resolves relative time markers ("azi", "today", "săptămâna asta") and
|
||||
underspecified entities ("alegerile") in a claim against the current date,
|
||||
so the same surface text asked at different times produces different cache
|
||||
keys. This prevents the most insidious form of cache staleness: a claim
|
||||
phrased identically in 2024 and 2026 silently serving the 2024 verdict.
|
||||
|
||||
Pipeline position:
|
||||
1. agent-v3 receives a user claim
|
||||
2. agent-v3 calls /v1/canonicalize with claim + current_date
|
||||
3. agent-v3 hashes the *canonical* form (not the original) for cache lookups
|
||||
4. brain receives the same canonical form on subsequent identical-text
|
||||
requests, but only if the same time horizon yields the same canonical
|
||||
|
||||
Failure mode: if LLM fails or returns invalid JSON, we return the original
|
||||
claim verbatim with ``changed=false``. The cache then behaves as today
|
||||
(no temporal disambiguation, but no regression either).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from shared.config import LlmRole
|
||||
from shared.llm_client import LlmClient, LlmError
|
||||
from shared.logging import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
PROMPT_VERSION = "v1"
|
||||
_PROMPT_PATH = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "prompts"
|
||||
/ f"canonicalize_{PROMPT_VERSION}.md"
|
||||
)
|
||||
|
||||
PER_CALL_TIMEOUT_S = 15.0
|
||||
MAX_CLAIM_CHARS = 2000
|
||||
|
||||
_PROMPT_TEMPLATE: str | None = None
|
||||
|
||||
|
||||
def _load_prompt() -> str:
|
||||
global _PROMPT_TEMPLATE
|
||||
if _PROMPT_TEMPLATE is None:
|
||||
_PROMPT_TEMPLATE = _PROMPT_PATH.read_text(encoding="utf-8")
|
||||
return _PROMPT_TEMPLATE
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class Canonicalization:
|
||||
"""Result of canonicalize_claim_temporal.
|
||||
|
||||
Attributes:
|
||||
canonical: The rewritten claim with anchors applied.
|
||||
original: The input claim, verbatim (for audit).
|
||||
changed: Whether canonical differs meaningfully from original.
|
||||
anchors_added: Short labels of what was disambiguated.
|
||||
reasoning: One-line LLM justification.
|
||||
error: Populated if LLM failed and we fell back to original.
|
||||
"""
|
||||
|
||||
canonical: str
|
||||
original: str
|
||||
changed: bool
|
||||
anchors_added: list[str]
|
||||
reasoning: str
|
||||
error: str | None = None
|
||||
|
||||
@property
|
||||
def degraded(self) -> bool:
|
||||
return self.error is not None
|
||||
|
||||
|
||||
def _passthrough(claim: str, error: str | None = None) -> Canonicalization:
|
||||
"""Build a no-op canonicalization (claim unchanged)."""
|
||||
return Canonicalization(
|
||||
canonical=claim,
|
||||
original=claim,
|
||||
changed=False,
|
||||
anchors_added=[],
|
||||
reasoning="passthrough" if error is None else "fallback_passthrough",
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
def _parse_response(data: object, original: str) -> Canonicalization | None:
|
||||
"""Validate the LLM JSON response. Returns None on bad shape."""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
|
||||
canonical = str(data.get("canonical", "")).strip()
|
||||
if not canonical:
|
||||
return None
|
||||
|
||||
changed = bool(data.get("changed", False))
|
||||
|
||||
anchors_raw = data.get("anchors_added") or []
|
||||
if not isinstance(anchors_raw, list):
|
||||
return None
|
||||
anchors = [str(a).strip() for a in anchors_raw if str(a).strip()]
|
||||
|
||||
reasoning = str(data.get("reasoning", "")).strip()[:200]
|
||||
|
||||
return Canonicalization(
|
||||
canonical=canonical,
|
||||
original=original,
|
||||
changed=changed,
|
||||
anchors_added=anchors,
|
||||
reasoning=reasoning,
|
||||
)
|
||||
|
||||
|
||||
async def canonicalize_claim_temporal(
|
||||
llm: LlmClient,
|
||||
*,
|
||||
claim: str,
|
||||
current_date: datetime | None = None,
|
||||
) -> Canonicalization:
|
||||
"""Resolve temporal markers and ambiguous entities in a claim.
|
||||
|
||||
On any failure, returns a passthrough Canonicalization (original=canonical,
|
||||
changed=False) with ``error`` populated. The caller can log telemetry but
|
||||
the cache lookup proceeds with the original text — no regression.
|
||||
|
||||
Args:
|
||||
llm: LLM client (uses REASONING role).
|
||||
claim: User claim, possibly containing relative time markers.
|
||||
current_date: Reference "now". Defaults to UTC now.
|
||||
|
||||
Returns:
|
||||
Canonicalization with the rewritten claim or a passthrough on failure.
|
||||
"""
|
||||
if not claim or not claim.strip():
|
||||
return _passthrough(claim, error="empty_claim")
|
||||
|
||||
truncated = claim.strip()[:MAX_CLAIM_CHARS]
|
||||
today = (current_date or datetime.now(tz=timezone.utc)).date().isoformat()
|
||||
|
||||
prompt = (
|
||||
_load_prompt()
|
||||
.replace("{current_date}", today)
|
||||
.replace("{claim}", truncated)
|
||||
)
|
||||
|
||||
try:
|
||||
result, _usage = await asyncio.wait_for(
|
||||
llm.chat_json(
|
||||
role=LlmRole.REASONING,
|
||||
system=(
|
||||
"You are a temporal disambiguator. Respond with strictly "
|
||||
"valid JSON only, no commentary."
|
||||
),
|
||||
user=prompt,
|
||||
max_tokens=400,
|
||||
temperature=0.0,
|
||||
),
|
||||
timeout=PER_CALL_TIMEOUT_S,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
log.warning("canonicalize_timeout", claim_preview=truncated[:80])
|
||||
return _passthrough(truncated, error="timeout")
|
||||
except LlmError as e:
|
||||
log.warning("canonicalize_llm_error", error=str(e)[:200])
|
||||
return _passthrough(truncated, error=f"llm:{e}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning(
|
||||
"canonicalize_unexpected_error",
|
||||
error=f"{type(e).__name__}:{e}",
|
||||
)
|
||||
return _passthrough(truncated, error=f"{type(e).__name__}:{e}")
|
||||
|
||||
parsed = _parse_response(result, truncated)
|
||||
if parsed is None:
|
||||
log.warning("canonicalize_bad_response_shape", got=type(result).__name__)
|
||||
return _passthrough(truncated, error="bad_response_shape")
|
||||
|
||||
if parsed.changed:
|
||||
log.info(
|
||||
"canonicalize_anchored",
|
||||
anchors=parsed.anchors_added,
|
||||
preview_in=truncated[:80],
|
||||
preview_out=parsed.canonical[:80],
|
||||
)
|
||||
return parsed
|
||||
350
ai_platform/modules/didi_brain/brain_api/services/classifier.py
Normal file
350
ai_platform/modules/didi_brain/brain_api/services/classifier.py
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
"""Volatility classifier — Pilon 1 of the cache freshness defense.
|
||||
|
||||
Single LLM call returns the temporal characteristics of a claim:
|
||||
- how fast it can become outdated (volatility: volatile|evolving|stable)
|
||||
- which topics it touches (topic_codes)
|
||||
- which entity-predicate-object triples it binds to (entity_bindings)
|
||||
- how many hours from now its verification can be trusted
|
||||
|
||||
This is invoked BEFORE writing to brain_analysis_atom or brain_verification_cache,
|
||||
so the resulting metadata becomes part of the cache row and drives:
|
||||
- TTL (expires_at = now() + estimated_validity_hours, capped per tier)
|
||||
- audit scheduling (volatile rows get audited daily by didibrain-auditor)
|
||||
- mass invalidation by topic (didibrain-breaking-watcher)
|
||||
- fact-status registration (entity_bindings → brain_fact_status, Pilon 11)
|
||||
|
||||
Failure mode: if the LLM call fails or returns invalid JSON, we degrade
|
||||
gracefully to a conservative fallback (volatility="evolving",
|
||||
estimated_validity_hours=168) with `error` populated. The caller still gets a
|
||||
usable classification and the cache write proceeds. The auditor picks up
|
||||
non-stable rows on its next sweep and corrects misclassifications over time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from shared.config import LlmRole
|
||||
from shared.llm_client import LlmClient, LlmError
|
||||
from shared.logging import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
PROMPT_VERSION = "v1"
|
||||
_PROMPT_PATH = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ "prompts"
|
||||
/ f"classifier_{PROMPT_VERSION}.md"
|
||||
)
|
||||
|
||||
ALLOWED_VOLATILITY: set[str] = {"volatile", "evolving", "stable"}
|
||||
|
||||
# Hard caps (hours) per volatility level — LLM estimate is clamped to these
|
||||
# upper bounds. Even if the LLM says "this is stable for 5 years", we cap.
|
||||
HARD_CAPS_HOURS: dict[str, int] = {
|
||||
"volatile": 48, # max 2 days
|
||||
"evolving": 720, # max 30 days
|
||||
"stable": 26280, # max ~3 years
|
||||
}
|
||||
|
||||
# Sensible floors — clamp from below so we never get a 0-hour TTL.
|
||||
HARD_FLOORS_HOURS: dict[str, int] = {
|
||||
"volatile": 1,
|
||||
"evolving": 24,
|
||||
"stable": 720,
|
||||
}
|
||||
|
||||
# Conservative defaults applied when classification fails.
|
||||
DEFAULT_VOLATILITY: Literal["volatile", "evolving", "stable"] = "evolving"
|
||||
DEFAULT_VALIDITY_HOURS = 168 # 7 days
|
||||
|
||||
PER_CALL_TIMEOUT_S = 20.0
|
||||
MAX_CLAIM_CHARS = 2000
|
||||
|
||||
_PROMPT_TEMPLATE: str | None = None
|
||||
|
||||
|
||||
def _load_prompt() -> str:
|
||||
"""Load and cache the classifier prompt template."""
|
||||
global _PROMPT_TEMPLATE
|
||||
if _PROMPT_TEMPLATE is None:
|
||||
_PROMPT_TEMPLATE = _PROMPT_PATH.read_text(encoding="utf-8")
|
||||
return _PROMPT_TEMPLATE
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class EntityBinding:
|
||||
"""A single (subject, predicate, object) triple extracted from a claim.
|
||||
|
||||
Attributes:
|
||||
subject: Canonical name of the entity (e.g., "Vladimir Putin").
|
||||
predicate: Short relation name (e.g., "is_president_of").
|
||||
obj: Target value of the relation. Named ``obj`` instead of ``object``
|
||||
to avoid shadowing the Python builtin in callers.
|
||||
confidence: LLM's certainty about this extraction, 0.0-1.0.
|
||||
"""
|
||||
|
||||
subject: str
|
||||
predicate: str
|
||||
obj: str
|
||||
confidence: float
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
"""Serialize for JSONB storage in brain_fact_status."""
|
||||
return {
|
||||
"subject": self.subject,
|
||||
"predicate": self.predicate,
|
||||
"object": self.obj,
|
||||
"confidence": self.confidence,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class ClaimVolatility:
|
||||
"""Output of the volatility classifier — drives all caching decisions.
|
||||
|
||||
Attributes:
|
||||
volatility: One of "volatile", "evolving", "stable".
|
||||
topic_codes: List of topic identifiers this claim touches.
|
||||
entity_bindings: Subject-predicate-object triples to track in
|
||||
brain_fact_status for temporal versioning.
|
||||
estimated_validity_hours: How many hours the cached verdict can be
|
||||
trusted (already clamped by per-tier caps and floors).
|
||||
time_sensitive: True if the claim has relative time markers.
|
||||
reasoning: One-line LLM explanation of the volatility decision.
|
||||
error: Populated only when classification fell back to defaults.
|
||||
"""
|
||||
|
||||
volatility: Literal["volatile", "evolving", "stable"]
|
||||
topic_codes: list[str]
|
||||
entity_bindings: list[EntityBinding]
|
||||
estimated_validity_hours: int
|
||||
time_sensitive: bool
|
||||
reasoning: str
|
||||
error: str | None = None
|
||||
|
||||
@property
|
||||
def degraded(self) -> bool:
|
||||
"""True if classification fell back to defaults (LLM failed)."""
|
||||
return self.error is not None
|
||||
|
||||
def entity_bindings_jsonb(self) -> list[dict[str, object]]:
|
||||
"""Serialize entity_bindings for JSONB storage."""
|
||||
return [b.to_dict() for b in self.entity_bindings]
|
||||
|
||||
|
||||
def _conservative_default(error_msg: str) -> ClaimVolatility:
|
||||
"""Build a safe-default classification on LLM failure.
|
||||
|
||||
The auditor will pick this up on its next sweep (since volatility is
|
||||
"evolving", not "stable") and may correct it.
|
||||
"""
|
||||
return ClaimVolatility(
|
||||
volatility=DEFAULT_VOLATILITY,
|
||||
topic_codes=[],
|
||||
entity_bindings=[],
|
||||
estimated_validity_hours=DEFAULT_VALIDITY_HOURS,
|
||||
time_sensitive=False,
|
||||
reasoning="classifier_fallback",
|
||||
error=error_msg,
|
||||
)
|
||||
|
||||
|
||||
def _clamp_validity_hours(raw: int, volatility: str) -> int:
|
||||
"""Apply hard caps + floors per volatility tier."""
|
||||
cap = HARD_CAPS_HOURS.get(volatility, DEFAULT_VALIDITY_HOURS)
|
||||
floor = HARD_FLOORS_HOURS.get(volatility, 1)
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
value = DEFAULT_VALIDITY_HOURS
|
||||
return max(floor, min(value, cap))
|
||||
|
||||
|
||||
def _parse_response(data: object) -> ClaimVolatility | None:
|
||||
"""Validate the LLM JSON response. Returns None on bad shape.
|
||||
|
||||
Strictly checks volatility label, list types, and binding structure.
|
||||
Silently drops malformed entity_bindings rather than rejecting the whole
|
||||
response.
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
|
||||
raw_vol = (data.get("volatility") or "").strip().lower()
|
||||
if raw_vol not in ALLOWED_VOLATILITY:
|
||||
return None
|
||||
|
||||
topic_codes_raw = data.get("topic_codes") or []
|
||||
if not isinstance(topic_codes_raw, list):
|
||||
return None
|
||||
topic_codes = [
|
||||
str(t).strip() for t in topic_codes_raw if str(t).strip()
|
||||
]
|
||||
|
||||
bindings_raw = data.get("entity_bindings") or []
|
||||
if not isinstance(bindings_raw, list):
|
||||
return None
|
||||
bindings: list[EntityBinding] = []
|
||||
for item in bindings_raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
subj = str(item.get("subject", "")).strip()
|
||||
pred = str(item.get("predicate", "")).strip()
|
||||
obj_ = str(item.get("object", "")).strip()
|
||||
try:
|
||||
conf = float(item.get("confidence", 0.5))
|
||||
except (TypeError, ValueError):
|
||||
conf = 0.5
|
||||
if not (subj and pred and obj_):
|
||||
continue
|
||||
bindings.append(
|
||||
EntityBinding(
|
||||
subject=subj,
|
||||
predicate=pred,
|
||||
obj=obj_,
|
||||
confidence=max(0.0, min(1.0, conf)),
|
||||
)
|
||||
)
|
||||
|
||||
validity = _clamp_validity_hours(
|
||||
data.get("estimated_validity_hours", DEFAULT_VALIDITY_HOURS),
|
||||
raw_vol,
|
||||
)
|
||||
|
||||
time_sensitive = bool(data.get("time_sensitive", False))
|
||||
reasoning = str(data.get("reasoning", "")).strip()[:200]
|
||||
|
||||
return ClaimVolatility(
|
||||
volatility=raw_vol, # type: ignore[arg-type]
|
||||
topic_codes=topic_codes,
|
||||
entity_bindings=bindings,
|
||||
estimated_validity_hours=validity,
|
||||
time_sensitive=time_sensitive,
|
||||
reasoning=reasoning,
|
||||
)
|
||||
|
||||
|
||||
async def classify_claim_volatility(
|
||||
llm: LlmClient,
|
||||
*,
|
||||
claim: str,
|
||||
current_date: datetime | None = None,
|
||||
) -> ClaimVolatility:
|
||||
"""Classify a claim's temporal characteristics with one LLM call.
|
||||
|
||||
Always returns a ClaimVolatility. On any failure (timeout, LLM error,
|
||||
bad JSON), returns a conservative default with ``error`` populated so the
|
||||
caller can log telemetry but still proceed with the cache write.
|
||||
|
||||
Args:
|
||||
llm: Configured LLM client. Uses LlmRole.REASONING internally.
|
||||
claim: The claim text to classify. Truncated at ``MAX_CLAIM_CHARS``.
|
||||
current_date: The "now" reference for the classifier (used for
|
||||
relative time resolution). Defaults to UTC now.
|
||||
|
||||
Returns:
|
||||
ClaimVolatility with the parsed classification, or a conservative
|
||||
fallback (volatility="evolving", validity=168h) on failure.
|
||||
"""
|
||||
if not claim or not claim.strip():
|
||||
return _conservative_default("empty_claim")
|
||||
|
||||
truncated = claim.strip()[:MAX_CLAIM_CHARS]
|
||||
today = (current_date or datetime.now(tz=timezone.utc)).date().isoformat()
|
||||
|
||||
prompt = (
|
||||
_load_prompt()
|
||||
.replace("{current_date}", today)
|
||||
.replace("{claim}", truncated)
|
||||
)
|
||||
|
||||
try:
|
||||
result, _usage = await asyncio.wait_for(
|
||||
llm.chat_json(
|
||||
role=LlmRole.REASONING,
|
||||
system=(
|
||||
"You are a temporal volatility classifier. Respond with "
|
||||
"strictly valid JSON only, no commentary."
|
||||
),
|
||||
user=prompt,
|
||||
max_tokens=600,
|
||||
temperature=0.0,
|
||||
),
|
||||
timeout=PER_CALL_TIMEOUT_S,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
log.warning("classifier_timeout", claim_preview=truncated[:80])
|
||||
return _conservative_default("timeout")
|
||||
except LlmError as e:
|
||||
log.warning("classifier_llm_error", error=str(e)[:200])
|
||||
return _conservative_default(f"llm:{e}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning(
|
||||
"classifier_unexpected_error",
|
||||
error=f"{type(e).__name__}:{e}",
|
||||
)
|
||||
return _conservative_default(f"{type(e).__name__}:{e}")
|
||||
|
||||
parsed = _parse_response(result)
|
||||
if parsed is None:
|
||||
log.warning(
|
||||
"classifier_bad_response_shape",
|
||||
got=type(result).__name__,
|
||||
)
|
||||
return _conservative_default("bad_response_shape")
|
||||
|
||||
# D1 — apply admin-configured topic overrides on top of LLM judgment.
|
||||
# Lazy import to avoid a circular dependency if topic_volatility ever
|
||||
# grows to import classifier types.
|
||||
try:
|
||||
from brain_api.services.topic_volatility import (
|
||||
get_topic_overrides,
|
||||
reconcile_with_classifier,
|
||||
)
|
||||
|
||||
overrides = await get_topic_overrides()
|
||||
eff_vol, eff_ttl = reconcile_with_classifier(
|
||||
classifier_volatility=parsed.volatility,
|
||||
classifier_validity_hours=parsed.estimated_validity_hours,
|
||||
classifier_topics=parsed.topic_codes,
|
||||
overrides=overrides,
|
||||
)
|
||||
if eff_vol != parsed.volatility or eff_ttl != parsed.estimated_validity_hours:
|
||||
log.info(
|
||||
"classifier_admin_override",
|
||||
llm_volatility=parsed.volatility,
|
||||
llm_ttl=parsed.estimated_validity_hours,
|
||||
final_volatility=eff_vol,
|
||||
final_ttl=eff_ttl,
|
||||
topics=parsed.topic_codes,
|
||||
)
|
||||
# Re-clamp the final TTL against the per-tier hard caps.
|
||||
eff_ttl = _clamp_validity_hours(eff_ttl, eff_vol)
|
||||
parsed = ClaimVolatility(
|
||||
volatility=eff_vol, # type: ignore[arg-type]
|
||||
topic_codes=parsed.topic_codes,
|
||||
entity_bindings=parsed.entity_bindings,
|
||||
estimated_validity_hours=eff_ttl,
|
||||
time_sensitive=parsed.time_sensitive,
|
||||
reasoning=parsed.reasoning,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
# Override layer is best-effort — never block on it.
|
||||
log.debug(
|
||||
"classifier_override_skipped",
|
||||
error=f"{type(e).__name__}:{e}",
|
||||
)
|
||||
|
||||
log.info(
|
||||
"classifier_ok",
|
||||
volatility=parsed.volatility,
|
||||
topics=parsed.topic_codes,
|
||||
validity_hours=parsed.estimated_validity_hours,
|
||||
bindings=len(parsed.entity_bindings),
|
||||
)
|
||||
return parsed
|
||||
758
ai_platform/modules/didi_brain/brain_api/services/fact_status.py
Normal file
758
ai_platform/modules/didi_brain/brain_api/services/fact_status.py
Normal file
|
|
@ -0,0 +1,758 @@
|
|||
"""Fact Status — Pilon 11 of the cache freshness defense.
|
||||
|
||||
Versioned knowledge layer for entity-predicate-object triples extracted from
|
||||
claims. Each fact has:
|
||||
- a current truth value (TRUE / FALSE / NULL=unknown), in brain_fact_status
|
||||
- a chronological history of (truth, valid_from, valid_to) windows in
|
||||
brain_fact_version
|
||||
|
||||
When the world changes (a president loses an election, an official dies, a
|
||||
ceasefire is signed), the active fact_version gets ``valid_to = now()`` and a
|
||||
new version opens with the new truth value. The cache invalidation pipeline
|
||||
queries this table at lookup time to decide whether any cached verdict
|
||||
depends on a fact whose current truth no longer matches what the cache
|
||||
assumed.
|
||||
|
||||
Population:
|
||||
- extractor pipeline (services/ingest.py background task) → upsert at
|
||||
ingestion of new claim atoms, with truth=NULL until verified
|
||||
- classifier (services/classifier.py) returns entity_bindings for every
|
||||
classified claim → these are upserted lazily on first cache write
|
||||
- moderator override (admin endpoint) → ``moderator_locked=true`` prevents
|
||||
the auditor from reverting the moderator's decision
|
||||
- breaking news watcher → close current version + open new one with the
|
||||
fresh truth value derived from the breaking story
|
||||
|
||||
Read by:
|
||||
- cache lookups (services/cache_judge.py) — if any binding is known-FALSE,
|
||||
treat cache as INVALIDATE without running NLI
|
||||
- admin dashboard (Phase D2) — fact browser with timeline
|
||||
|
||||
This module is pure persistence + canonicalization. Truth detection (the
|
||||
"is X currently true?" decision) lives in services/cache_judge.py + the
|
||||
auditor cron — they call upsert_fact_truth here when they have an answer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import unicodedata
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Literal
|
||||
|
||||
from brain_api.db import db
|
||||
from brain_api.services.classifier import EntityBinding
|
||||
from shared.logging import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
# Default re-check intervals per volatility tier (hours). Auditor uses these
|
||||
# to schedule next_check_at when no per-fact override is set.
|
||||
DEFAULT_CHECK_INTERVAL_HOURS: dict[str, int] = {
|
||||
"volatile": 6,
|
||||
"evolving": 168, # 7 days
|
||||
"stable": 2160, # 90 days
|
||||
}
|
||||
|
||||
CreatedBy = Literal[
|
||||
"auto",
|
||||
"moderator",
|
||||
"breaking_news_watcher",
|
||||
"auditor",
|
||||
"extractor",
|
||||
]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- DTOs
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FactRecord:
|
||||
"""Current state of a fact (one row in brain_fact_status)."""
|
||||
|
||||
fact_id: int
|
||||
subject: str
|
||||
predicate: str
|
||||
obj: str
|
||||
canonical_form: str
|
||||
canonical_form_hash: str
|
||||
current_truth: bool | None
|
||||
current_version_id: int | None
|
||||
current_confidence: float | None
|
||||
last_verified_at: datetime | None
|
||||
last_evidence_urls: list[str]
|
||||
volatility: str | None
|
||||
topic_codes: list[str]
|
||||
next_check_at: datetime
|
||||
check_interval_hours: int
|
||||
moderator_locked: bool
|
||||
moderator_user_id: str | None
|
||||
moderator_notes: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FactVersion:
|
||||
"""One historical version of a fact (one row in brain_fact_version)."""
|
||||
|
||||
version_id: int
|
||||
fact_id: int
|
||||
truth_value: bool
|
||||
confidence: float | None
|
||||
valid_from: datetime
|
||||
valid_to: datetime | None
|
||||
source_atom_ids: list[str]
|
||||
evidence_urls: list[str]
|
||||
llm_reasoning: str | None
|
||||
created_by: str
|
||||
moderator_user_id: str | None
|
||||
notes: str | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
# --------------------------------------------------------------- canonicalization
|
||||
|
||||
|
||||
def _normalize_token(s: str) -> str:
|
||||
"""Strip diacritics + lowercase + collapse whitespace.
|
||||
|
||||
Matches verification_cache.normalize_claim style so subject "România" and
|
||||
"Romania" hash to the same fact.
|
||||
"""
|
||||
s = unicodedata.normalize("NFKD", s)
|
||||
s = "".join(c for c in s if not unicodedata.combining(c))
|
||||
s = s.lower().strip()
|
||||
return " ".join(s.split())
|
||||
|
||||
|
||||
def canonicalize_triple(subject: str, predicate: str, obj: str) -> str:
|
||||
"""Build a canonical "subject predicate object" string.
|
||||
|
||||
Predicate is normalized to ``snake_case`` (already conventional in the
|
||||
classifier prompt). Subject and object are lowercased, diacritic-stripped,
|
||||
whitespace-collapsed.
|
||||
"""
|
||||
s = _normalize_token(subject)
|
||||
p = _normalize_token(predicate).replace(" ", "_")
|
||||
o = _normalize_token(obj)
|
||||
return f"{s} {p} {o}"
|
||||
|
||||
|
||||
def hash_canonical(canonical_form: str) -> str:
|
||||
"""sha256[:32] of the canonical form. Matches the UNIQUE constraint width."""
|
||||
return hashlib.sha256(canonical_form.encode("utf-8")).hexdigest()[:32]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- writes
|
||||
|
||||
|
||||
async def register_facts_from_bindings(
|
||||
bindings: list[EntityBinding],
|
||||
*,
|
||||
volatility: str | None = None,
|
||||
topic_codes: list[str] | None = None,
|
||||
source_atom_id: str | None = None,
|
||||
) -> list[int]:
|
||||
"""Upsert fact_status rows from classifier-extracted bindings.
|
||||
|
||||
No truth value is asserted here — bindings are recorded with
|
||||
``current_truth=NULL`` until something verifies them (the auditor, a
|
||||
breaking-news event, or a moderator). This is the lazy-registration
|
||||
path called from the cache write hooks.
|
||||
|
||||
Returns:
|
||||
List of fact_ids touched (one per binding, in the same order).
|
||||
"""
|
||||
if not bindings:
|
||||
return []
|
||||
if not db.pool:
|
||||
raise RuntimeError("brain_db not connected")
|
||||
|
||||
interval_hours = DEFAULT_CHECK_INTERVAL_HOURS.get(volatility or "evolving", 168)
|
||||
next_check = datetime.now(tz=timezone.utc) + timedelta(hours=interval_hours)
|
||||
topics = topic_codes or []
|
||||
|
||||
sql = """
|
||||
INSERT INTO brain_fact_status (
|
||||
subject, predicate, object, canonical_form, canonical_form_hash,
|
||||
volatility, topic_codes, next_check_at, check_interval_hours
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (canonical_form_hash) DO UPDATE SET
|
||||
topic_codes = (
|
||||
SELECT ARRAY(
|
||||
SELECT DISTINCT t FROM unnest(
|
||||
brain_fact_status.topic_codes || EXCLUDED.topic_codes
|
||||
) AS t
|
||||
)
|
||||
),
|
||||
volatility = COALESCE(EXCLUDED.volatility, brain_fact_status.volatility),
|
||||
check_interval_hours = LEAST(
|
||||
brain_fact_status.check_interval_hours,
|
||||
EXCLUDED.check_interval_hours
|
||||
),
|
||||
updated_at = now()
|
||||
RETURNING fact_id
|
||||
"""
|
||||
|
||||
fact_ids: list[int] = []
|
||||
async with db.pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
for b in bindings:
|
||||
canonical = canonicalize_triple(b.subject, b.predicate, b.obj)
|
||||
ch = hash_canonical(canonical)
|
||||
row = await conn.fetchrow(
|
||||
sql,
|
||||
b.subject,
|
||||
b.predicate,
|
||||
b.obj,
|
||||
canonical,
|
||||
ch,
|
||||
volatility,
|
||||
topics,
|
||||
next_check,
|
||||
interval_hours,
|
||||
)
|
||||
if row:
|
||||
fact_ids.append(row["fact_id"])
|
||||
|
||||
if source_atom_id and fact_ids:
|
||||
log.debug(
|
||||
"fact_status_registered",
|
||||
atom_id=source_atom_id,
|
||||
fact_count=len(fact_ids),
|
||||
)
|
||||
return fact_ids
|
||||
|
||||
|
||||
async def assert_fact_truth(
|
||||
*,
|
||||
canonical_form_hash: str,
|
||||
truth_value: bool,
|
||||
confidence: float | None,
|
||||
evidence_urls: list[str],
|
||||
source_atom_ids: list[str] | None = None,
|
||||
llm_reasoning: str | None = None,
|
||||
created_by: CreatedBy = "auto",
|
||||
moderator_user_id: str | None = None,
|
||||
notes: str | None = None,
|
||||
) -> tuple[FactRecord, bool] | None:
|
||||
"""Set a fact's current truth value, opening a new version if it changed.
|
||||
|
||||
If the new truth_value matches the current truth (same boolean), the
|
||||
existing version is touched (its evidence list is augmented) but no new
|
||||
version is opened. If the value differs (or the fact had no truth yet),
|
||||
the active version gets ``valid_to=now()`` and a new version opens.
|
||||
|
||||
Skipped silently if ``moderator_locked`` is set on the row — moderator
|
||||
overrides win until explicitly unlocked.
|
||||
|
||||
Args:
|
||||
canonical_form_hash: The hash from ``hash_canonical``.
|
||||
truth_value: TRUE or FALSE; pass through ``assert_fact_unknown`` if
|
||||
you want to clear back to NULL.
|
||||
confidence: 0-100 LLM confidence (or moderator confidence).
|
||||
evidence_urls: Sources backing this assertion.
|
||||
source_atom_ids: Atomic atom IDs that contributed to this assertion.
|
||||
llm_reasoning: One-line LLM justification.
|
||||
created_by: Provenance tag for the version.
|
||||
moderator_user_id: Required if ``created_by='moderator'``.
|
||||
notes: Free-form notes (especially useful for moderator overrides).
|
||||
|
||||
Returns:
|
||||
``(FactRecord, version_changed)`` — version_changed=True when a new
|
||||
version was opened. Returns None if the fact is moderator_locked
|
||||
and the caller is not a moderator.
|
||||
"""
|
||||
if not db.pool:
|
||||
raise RuntimeError("brain_db not connected")
|
||||
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
|
||||
async with db.pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
# 1. Lock-and-load the fact row.
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT * FROM brain_fact_status
|
||||
WHERE canonical_form_hash = $1
|
||||
FOR UPDATE
|
||||
""",
|
||||
canonical_form_hash,
|
||||
)
|
||||
if row is None:
|
||||
log.warning(
|
||||
"assert_fact_truth_unknown_fact",
|
||||
canonical_form_hash=canonical_form_hash,
|
||||
)
|
||||
return None
|
||||
|
||||
if row["moderator_locked"] and created_by != "moderator":
|
||||
log.info(
|
||||
"assert_fact_truth_locked",
|
||||
fact_id=row["fact_id"],
|
||||
canonical=row["canonical_form"][:80],
|
||||
)
|
||||
return _row_to_fact(row), False
|
||||
|
||||
old_truth = row["current_truth"]
|
||||
value_changed = old_truth is None or bool(old_truth) != truth_value
|
||||
|
||||
new_version_id: int | None = None
|
||||
if value_changed:
|
||||
# Close the active version (if any).
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE brain_fact_version
|
||||
SET valid_to = $1
|
||||
WHERE fact_id = $2 AND valid_to IS NULL
|
||||
""",
|
||||
now,
|
||||
row["fact_id"],
|
||||
)
|
||||
# Open a new version.
|
||||
inserted = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO brain_fact_version (
|
||||
fact_id, truth_value, confidence,
|
||||
valid_from, valid_to,
|
||||
source_atom_ids, evidence_urls, llm_reasoning,
|
||||
created_by, moderator_user_id, notes
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, NULL, $5, $6::jsonb, $7, $8, $9, $10)
|
||||
RETURNING version_id
|
||||
""",
|
||||
row["fact_id"],
|
||||
truth_value,
|
||||
confidence,
|
||||
now,
|
||||
source_atom_ids or [],
|
||||
json.dumps(list(evidence_urls)),
|
||||
llm_reasoning,
|
||||
created_by,
|
||||
moderator_user_id,
|
||||
notes,
|
||||
)
|
||||
new_version_id = inserted["version_id"] if inserted else None
|
||||
|
||||
# 2. Update brain_fact_status (always — even on no-change we
|
||||
# bump last_verified_at and merge evidence URLs).
|
||||
interval_h = row["check_interval_hours"]
|
||||
next_check = now + timedelta(hours=interval_h)
|
||||
updated = await conn.fetchrow(
|
||||
"""
|
||||
UPDATE brain_fact_status
|
||||
SET current_truth = $2,
|
||||
current_version_id = COALESCE($3, current_version_id),
|
||||
current_confidence = $4,
|
||||
last_verified_at = $5,
|
||||
last_evidence_urls = $6::jsonb,
|
||||
next_check_at = $7,
|
||||
moderator_user_id = COALESCE($8, moderator_user_id),
|
||||
moderator_notes = COALESCE($9, moderator_notes),
|
||||
updated_at = now()
|
||||
WHERE fact_id = $1
|
||||
RETURNING *
|
||||
""",
|
||||
row["fact_id"],
|
||||
truth_value,
|
||||
new_version_id,
|
||||
confidence,
|
||||
now,
|
||||
json.dumps(list(evidence_urls)),
|
||||
next_check,
|
||||
moderator_user_id,
|
||||
notes,
|
||||
)
|
||||
|
||||
# 3. Audit log.
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO brain_audit_log (action, target_table, target_id, actor, payload)
|
||||
VALUES ($1, 'brain_fact_status', $2, $3, $4::jsonb)
|
||||
""",
|
||||
"fact_truth_set" if not value_changed else "fact_truth_changed",
|
||||
str(row["fact_id"]),
|
||||
created_by if created_by != "moderator" else (moderator_user_id or "moderator"),
|
||||
json.dumps({
|
||||
"old_truth": old_truth,
|
||||
"new_truth": truth_value,
|
||||
"confidence": confidence,
|
||||
"evidence_count": len(evidence_urls),
|
||||
}),
|
||||
)
|
||||
|
||||
return _row_to_fact(updated), value_changed # type: ignore[arg-type]
|
||||
|
||||
|
||||
async def lock_fact(
|
||||
*,
|
||||
canonical_form_hash: str,
|
||||
moderator_user_id: str,
|
||||
moderator_notes: str | None = None,
|
||||
) -> FactRecord | None:
|
||||
"""Moderator override: prevent auditor from changing this fact.
|
||||
|
||||
Use when a human has decided the truth and machine judgment is unreliable
|
||||
for the topic.
|
||||
"""
|
||||
if not db.pool:
|
||||
raise RuntimeError("brain_db not connected")
|
||||
async with db.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
UPDATE brain_fact_status
|
||||
SET moderator_locked = true,
|
||||
moderator_user_id = $2,
|
||||
moderator_notes = COALESCE($3, moderator_notes),
|
||||
updated_at = now()
|
||||
WHERE canonical_form_hash = $1
|
||||
RETURNING *
|
||||
""",
|
||||
canonical_form_hash,
|
||||
moderator_user_id,
|
||||
moderator_notes,
|
||||
)
|
||||
return _row_to_fact(row) if row else None
|
||||
|
||||
|
||||
async def unlock_fact(
|
||||
*, canonical_form_hash: str, moderator_user_id: str
|
||||
) -> FactRecord | None:
|
||||
"""Re-enable auditor updates on a previously locked fact."""
|
||||
if not db.pool:
|
||||
raise RuntimeError("brain_db not connected")
|
||||
async with db.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
UPDATE brain_fact_status
|
||||
SET moderator_locked = false,
|
||||
moderator_user_id = $2,
|
||||
updated_at = now()
|
||||
WHERE canonical_form_hash = $1
|
||||
RETURNING *
|
||||
""",
|
||||
canonical_form_hash,
|
||||
moderator_user_id,
|
||||
)
|
||||
return _row_to_fact(row) if row else None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- reads
|
||||
|
||||
|
||||
async def check_fact_validity(
|
||||
bindings: list[EntityBinding],
|
||||
) -> dict[str, bool | None]:
|
||||
"""For each binding, return current_truth from brain_fact_status.
|
||||
|
||||
Returns:
|
||||
Dict keyed by canonical_form_hash. Values:
|
||||
- True → fact is currently TRUE (cache-aligned if cache assumed TRUE)
|
||||
- False → fact is currently FALSE (cache-aligned if cache assumed FALSE)
|
||||
- None → unknown / not registered yet
|
||||
|
||||
Caller (typically gather.py) compares the cache's assumption against this
|
||||
dict and invalidates if any binding flipped against the cache.
|
||||
"""
|
||||
if not bindings:
|
||||
return {}
|
||||
if not db.pool:
|
||||
raise RuntimeError("brain_db not connected")
|
||||
|
||||
hashes = [hash_canonical(canonicalize_triple(b.subject, b.predicate, b.obj)) for b in bindings]
|
||||
|
||||
sql = """
|
||||
SELECT canonical_form_hash, current_truth
|
||||
FROM brain_fact_status
|
||||
WHERE canonical_form_hash = ANY($1::text[])
|
||||
"""
|
||||
out: dict[str, bool | None] = {h: None for h in hashes}
|
||||
async with db.pool.acquire() as conn:
|
||||
rows = await conn.fetch(sql, hashes)
|
||||
for r in rows:
|
||||
out[r["canonical_form_hash"]] = r["current_truth"]
|
||||
return out
|
||||
|
||||
|
||||
async def get_fact(canonical_form_hash: str) -> FactRecord | None:
|
||||
"""Fetch one fact_status row by hash."""
|
||||
if not db.pool:
|
||||
raise RuntimeError("brain_db not connected")
|
||||
async with db.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT * FROM brain_fact_status WHERE canonical_form_hash = $1",
|
||||
canonical_form_hash,
|
||||
)
|
||||
return _row_to_fact(row) if row else None
|
||||
|
||||
|
||||
async def get_fact_by_id(fact_id: int) -> FactRecord | None:
|
||||
if not db.pool:
|
||||
raise RuntimeError("brain_db not connected")
|
||||
async with db.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT * FROM brain_fact_status WHERE fact_id = $1",
|
||||
fact_id,
|
||||
)
|
||||
return _row_to_fact(row) if row else None
|
||||
|
||||
|
||||
async def list_versions(fact_id: int) -> list[FactVersion]:
|
||||
"""All versions for one fact, newest first."""
|
||||
if not db.pool:
|
||||
raise RuntimeError("brain_db not connected")
|
||||
async with db.pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT * FROM brain_fact_version
|
||||
WHERE fact_id = $1
|
||||
ORDER BY valid_from DESC
|
||||
""",
|
||||
fact_id,
|
||||
)
|
||||
return [_row_to_version(r) for r in rows]
|
||||
|
||||
|
||||
async def list_facts_due_for_check(
|
||||
*, limit: int = 100, volatility: str | None = None
|
||||
) -> list[FactRecord]:
|
||||
"""Auditor entry point: facts whose ``next_check_at`` has passed.
|
||||
|
||||
Excludes moderator-locked facts (those are managed by humans).
|
||||
"""
|
||||
if not db.pool:
|
||||
raise RuntimeError("brain_db not connected")
|
||||
sql = """
|
||||
SELECT * FROM brain_fact_status
|
||||
WHERE moderator_locked = false
|
||||
AND next_check_at <= now()
|
||||
AND ($1::text IS NULL OR volatility = $1)
|
||||
ORDER BY next_check_at ASC
|
||||
LIMIT $2
|
||||
"""
|
||||
async with db.pool.acquire() as conn:
|
||||
rows = await conn.fetch(sql, volatility, limit)
|
||||
return [_row_to_fact(r) for r in rows]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- admin
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FactPage:
|
||||
"""Paginated fact_status listing."""
|
||||
|
||||
items: list[FactRecord]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
async def list_facts_admin(
|
||||
*,
|
||||
entity: str | None = None,
|
||||
predicate: str | None = None,
|
||||
current_truth: bool | None = None,
|
||||
locked_only: bool = False,
|
||||
topic: str | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 25,
|
||||
) -> FactPage:
|
||||
"""Admin browser query — paginated, ILIKE search on subject/object.
|
||||
|
||||
Args:
|
||||
entity: ILIKE search on subject OR object (e.g., "putin").
|
||||
predicate: Exact match on predicate (e.g., "is_president_of").
|
||||
current_truth: Filter to TRUE / FALSE only when set.
|
||||
locked_only: Show only moderator-locked facts.
|
||||
topic: Filter to facts whose topic_codes contain this code.
|
||||
page, page_size: Pagination (1-based).
|
||||
"""
|
||||
if not db.pool:
|
||||
raise RuntimeError("brain_db not connected")
|
||||
|
||||
page = max(1, page)
|
||||
page_size = max(1, min(100, page_size))
|
||||
|
||||
where: list[str] = ["1=1"]
|
||||
params: list[Any] = []
|
||||
|
||||
if entity:
|
||||
params.append(f"%{entity}%")
|
||||
where.append(f"(subject ILIKE ${len(params)} OR object ILIKE ${len(params)})")
|
||||
if predicate:
|
||||
params.append(predicate)
|
||||
where.append(f"predicate = ${len(params)}")
|
||||
if current_truth is not None:
|
||||
params.append(current_truth)
|
||||
where.append(f"current_truth = ${len(params)}")
|
||||
if locked_only:
|
||||
where.append("moderator_locked = true")
|
||||
if topic:
|
||||
params.append([topic])
|
||||
where.append(f"topic_codes && ${len(params)}::text[]")
|
||||
|
||||
where_sql = " AND ".join(where)
|
||||
|
||||
async with db.pool.acquire() as conn:
|
||||
total_row = await conn.fetchrow(
|
||||
f"SELECT COUNT(*) AS c FROM brain_fact_status WHERE {where_sql}",
|
||||
*params,
|
||||
)
|
||||
total = int(total_row["c"]) if total_row else 0
|
||||
|
||||
params.append(page_size)
|
||||
params.append((page - 1) * page_size)
|
||||
rows = await conn.fetch(
|
||||
f"""SELECT * FROM brain_fact_status
|
||||
WHERE {where_sql}
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ${len(params) - 1} OFFSET ${len(params)}""",
|
||||
*params,
|
||||
)
|
||||
|
||||
return FactPage(
|
||||
items=[_row_to_fact(r) for r in rows],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
async def list_audit_log(
|
||||
*,
|
||||
action: str | None = None,
|
||||
target_table: str | None = None,
|
||||
actor: str | None = None,
|
||||
since: datetime | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 50,
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
"""Browse brain_audit_log entries (paginated, newest first).
|
||||
|
||||
Returns ``(items, total)``.
|
||||
"""
|
||||
if not db.pool:
|
||||
raise RuntimeError("brain_db not connected")
|
||||
|
||||
page = max(1, page)
|
||||
page_size = max(1, min(200, page_size))
|
||||
|
||||
where: list[str] = ["1=1"]
|
||||
params: list[Any] = []
|
||||
if action:
|
||||
params.append(f"{action}%")
|
||||
where.append(f"action ILIKE ${len(params)}")
|
||||
if target_table:
|
||||
params.append(target_table)
|
||||
where.append(f"target_table = ${len(params)}")
|
||||
if actor:
|
||||
params.append(f"%{actor}%")
|
||||
where.append(f"actor ILIKE ${len(params)}")
|
||||
if since is not None:
|
||||
params.append(since)
|
||||
where.append(f"created_at >= ${len(params)}")
|
||||
|
||||
where_sql = " AND ".join(where)
|
||||
|
||||
async with db.pool.acquire() as conn:
|
||||
total_row = await conn.fetchrow(
|
||||
f"SELECT COUNT(*) AS c FROM brain_audit_log WHERE {where_sql}",
|
||||
*params,
|
||||
)
|
||||
total = int(total_row["c"]) if total_row else 0
|
||||
|
||||
params.append(page_size)
|
||||
params.append((page - 1) * page_size)
|
||||
rows = await conn.fetch(
|
||||
f"""SELECT log_id, action, target_table, target_id, actor,
|
||||
payload, created_at
|
||||
FROM brain_audit_log
|
||||
WHERE {where_sql}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ${len(params) - 1} OFFSET ${len(params)}""",
|
||||
*params,
|
||||
)
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
for r in rows:
|
||||
payload = r["payload"]
|
||||
if isinstance(payload, str):
|
||||
try:
|
||||
payload = json.loads(payload)
|
||||
except (TypeError, ValueError):
|
||||
payload = {}
|
||||
items.append({
|
||||
"log_id": r["log_id"],
|
||||
"action": r["action"],
|
||||
"target_table": r["target_table"],
|
||||
"target_id": r["target_id"],
|
||||
"actor": r["actor"],
|
||||
"payload": payload,
|
||||
"created_at": r["created_at"],
|
||||
})
|
||||
return items, total
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- helpers
|
||||
|
||||
|
||||
def _row_to_fact(row: Any) -> FactRecord:
|
||||
last_evidence = row["last_evidence_urls"]
|
||||
if isinstance(last_evidence, str):
|
||||
last_evidence = json.loads(last_evidence)
|
||||
return FactRecord(
|
||||
fact_id=row["fact_id"],
|
||||
subject=row["subject"],
|
||||
predicate=row["predicate"],
|
||||
obj=row["object"],
|
||||
canonical_form=row["canonical_form"],
|
||||
canonical_form_hash=row["canonical_form_hash"],
|
||||
current_truth=row["current_truth"],
|
||||
current_version_id=row["current_version_id"],
|
||||
current_confidence=(
|
||||
float(row["current_confidence"])
|
||||
if row["current_confidence"] is not None
|
||||
else None
|
||||
),
|
||||
last_verified_at=row["last_verified_at"],
|
||||
last_evidence_urls=list(last_evidence) if last_evidence else [],
|
||||
volatility=row["volatility"],
|
||||
topic_codes=list(row["topic_codes"]) if row["topic_codes"] else [],
|
||||
next_check_at=row["next_check_at"],
|
||||
check_interval_hours=row["check_interval_hours"],
|
||||
moderator_locked=row["moderator_locked"],
|
||||
moderator_user_id=row["moderator_user_id"],
|
||||
moderator_notes=row["moderator_notes"],
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
)
|
||||
|
||||
|
||||
def _row_to_version(row: Any) -> FactVersion:
|
||||
ev_urls = row["evidence_urls"]
|
||||
if isinstance(ev_urls, str):
|
||||
ev_urls = json.loads(ev_urls)
|
||||
return FactVersion(
|
||||
version_id=row["version_id"],
|
||||
fact_id=row["fact_id"],
|
||||
truth_value=row["truth_value"],
|
||||
confidence=(
|
||||
float(row["confidence"]) if row["confidence"] is not None else None
|
||||
),
|
||||
valid_from=row["valid_from"],
|
||||
valid_to=row["valid_to"],
|
||||
source_atom_ids=(
|
||||
list(row["source_atom_ids"]) if row["source_atom_ids"] else []
|
||||
),
|
||||
evidence_urls=list(ev_urls) if ev_urls else [],
|
||||
llm_reasoning=row["llm_reasoning"],
|
||||
created_by=row["created_by"],
|
||||
moderator_user_id=row["moderator_user_id"],
|
||||
notes=row["notes"],
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
62
ai_platform/modules/didi_brain/brain_api/services/fetch.py
Normal file
62
ai_platform/modules/didi_brain/brain_api/services/fetch.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"""POST /v1/fetch — look up stored atoms by URL and return their content.
|
||||
|
||||
Unlike the web module's /v1/fetch which fetches URLs live, ours just checks
|
||||
if we already have an atom for each URL. URLs we don't have go into
|
||||
`failed_urls` with reason "not_in_brain" — Didi's backend can then fall back
|
||||
to the live web module for those.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from brain_api.schemas import (
|
||||
BrainMeta,
|
||||
FailedUrl,
|
||||
FetchedPage,
|
||||
FetchRequest,
|
||||
FetchResponse,
|
||||
)
|
||||
from brain_api.services.mapping import doc_to_fetched_page
|
||||
from shared.atomic_api import AtomicClient
|
||||
from shared.logging import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
async def fetch(req: FetchRequest, *, atomic: AtomicClient) -> FetchResponse:
|
||||
t0 = time.perf_counter()
|
||||
request_id = str(uuid.uuid4())
|
||||
|
||||
tasks = [atomic.get_atom_by_source_url(u) for u in req.urls]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
pages: list[FetchedPage] = []
|
||||
failed: list[FailedUrl] = []
|
||||
for url, result in zip(req.urls, results, strict=True):
|
||||
if isinstance(result, Exception):
|
||||
failed.append(FailedUrl(url=url, error=f"brain_lookup_error: {result}"))
|
||||
continue
|
||||
if result is None:
|
||||
failed.append(FailedUrl(url=url, error="not_in_brain"))
|
||||
continue
|
||||
pages.append(doc_to_fetched_page(result, include_html=req.include_html))
|
||||
|
||||
total_ms = round((time.perf_counter() - t0) * 1000, 1)
|
||||
return FetchResponse(
|
||||
request_id=request_id,
|
||||
pages=pages,
|
||||
total_fetched=len(pages),
|
||||
total_failed=len(failed),
|
||||
execution_time_ms=total_ms,
|
||||
failed_urls=failed,
|
||||
brain_meta=BrainMeta(
|
||||
cache_status="HIT" if pages else "MISS",
|
||||
api_version="v1",
|
||||
implementation="didibrain",
|
||||
evidence_sources=len(pages),
|
||||
total_claim_atoms_matched=0,
|
||||
),
|
||||
)
|
||||
665
ai_platform/modules/didi_brain/brain_api/services/gather.py
Normal file
665
ai_platform/modules/didi_brain/brain_api/services/gather.py
Normal file
|
|
@ -0,0 +1,665 @@
|
|||
"""POST /v1/gather — the main claim-to-evidence pipeline.
|
||||
|
||||
Flow:
|
||||
1. semantic search with Type/Claim filter, pull top-K candidates
|
||||
2. rerank with BGE cross-encoder against the input claim (precision)
|
||||
3. group hits by parent document URL
|
||||
4. fetch full parent doc + top claim atom bodies in parallel
|
||||
5. emit one EvidenceItem per parent doc, sorted by best rerank score
|
||||
6. shape the full GatherResponse with stages, stats, context
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import math
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from brain_api.schemas import (
|
||||
BrainMeta,
|
||||
EvidenceStats,
|
||||
GatherRequest,
|
||||
GatherResponse,
|
||||
SearchContext,
|
||||
SearchResultItem,
|
||||
StageRecord,
|
||||
)
|
||||
from brain_api.services.mapping import (
|
||||
detect_language_simple,
|
||||
doc_to_search_result,
|
||||
evidence_from_parent,
|
||||
group_hits_by_parent,
|
||||
parent_url_of,
|
||||
parse_claim_atom_body,
|
||||
)
|
||||
from brain_api.services.nli import classify_batch
|
||||
from brain_api.services import verification_cache as vcache
|
||||
from brain_api.services import fact_status as fact_svc
|
||||
from brain_api.services.classifier import EntityBinding
|
||||
from shared.atomic_api import AtomicClient, SearchHit
|
||||
from shared.embedding_client import EmbeddingClient
|
||||
from shared.llm_client import LlmClient
|
||||
from shared.logging import get_logger
|
||||
from shared.taxonomy import TagResolver
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
FIRST_STAGE_LIMIT = 50 # how many claim hits to pull before reranking
|
||||
SIMILARITY_FLOOR = 0.20 # below this we don't even consider a hit
|
||||
RERANK_TOP_N = 15
|
||||
|
||||
# Phase B4 — recency boost configuration. Half-life is in days.
|
||||
# Output of _combined_score = (1 - w_recency) * rerank + w_recency * recency,
|
||||
# where recency = exp(-age_days / half_life). Stable claims skip the recency
|
||||
# pass entirely; volatile claims weight recency heavily.
|
||||
RECENCY_PROFILES: dict[str, tuple[float, float]] = {
|
||||
# volatility → (w_recency, half_life_days)
|
||||
"volatile": (0.50, 3.0), # heavy recency, fast decay
|
||||
"evolving": (0.30, 30.0), # moderate recency, monthly half-life
|
||||
"stable": (0.00, 9999.0), # ignore recency
|
||||
}
|
||||
# Default profile when no hint provided — light recency boost so older
|
||||
# articles can't fully dominate even on stable topics, without harming them.
|
||||
RECENCY_DEFAULT: tuple[float, float] = (0.15, 30.0)
|
||||
# Hard recency filter (days) — only applied when volatility_hint=='volatile'.
|
||||
DEFAULT_VOLATILE_RECENCY_DAYS = 7
|
||||
|
||||
|
||||
async def gather(
|
||||
req: GatherRequest,
|
||||
*,
|
||||
atomic: AtomicClient,
|
||||
embed: EmbeddingClient,
|
||||
llm: LlmClient,
|
||||
resolver: TagResolver,
|
||||
) -> GatherResponse:
|
||||
t0 = time.perf_counter()
|
||||
request_id = str(uuid.uuid4())
|
||||
stages: list[StageRecord] = []
|
||||
|
||||
# ---- stage 1: context (very lightweight — no LLM for now) --------------
|
||||
s1_t0 = time.perf_counter()
|
||||
context = SearchContext(
|
||||
primary_country="Global",
|
||||
secondary_countries=[],
|
||||
detected_language=req.language_hint or detect_language_simple(req.claim),
|
||||
search_queries=[req.claim],
|
||||
)
|
||||
stages.append(
|
||||
StageRecord(
|
||||
stage="context",
|
||||
success=True,
|
||||
items_processed=1,
|
||||
items_failed=0,
|
||||
duration_ms=round((time.perf_counter() - s1_t0) * 1000, 1),
|
||||
)
|
||||
)
|
||||
|
||||
# ---- stage 2: first-stage retrieval against Type/Claim ----------------
|
||||
s2_t0 = time.perf_counter()
|
||||
type_claim_id = resolver.get("Type/Claim")
|
||||
try:
|
||||
# Atomic's /api/search currently does not accept a tag_id filter in the
|
||||
# body — we filter client-side after the call. Pull enough results.
|
||||
raw_hits = await atomic.search(
|
||||
req.claim,
|
||||
mode="semantic",
|
||||
limit=FIRST_STAGE_LIMIT * 2,
|
||||
threshold=SIMILARITY_FLOOR,
|
||||
)
|
||||
claim_hits: list[SearchHit] = [
|
||||
h
|
||||
for h in raw_hits
|
||||
if any(t.get("name") == "Claim" for t in h.tags)
|
||||
][:FIRST_STAGE_LIMIT]
|
||||
stages.append(
|
||||
StageRecord(
|
||||
stage="retrieval",
|
||||
success=True,
|
||||
items_processed=len(claim_hits),
|
||||
items_failed=0,
|
||||
duration_ms=round((time.perf_counter() - s2_t0) * 1000, 1),
|
||||
)
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.error("gather_retrieval_failed", error=str(e))
|
||||
stages.append(
|
||||
StageRecord(
|
||||
stage="retrieval",
|
||||
success=False,
|
||||
items_processed=0,
|
||||
items_failed=1,
|
||||
duration_ms=round((time.perf_counter() - s2_t0) * 1000, 1),
|
||||
error=str(e),
|
||||
)
|
||||
)
|
||||
return _empty_response(
|
||||
request_id=request_id,
|
||||
claim=req.claim,
|
||||
context=context,
|
||||
stages=stages,
|
||||
started_at=t0,
|
||||
)
|
||||
|
||||
if not claim_hits:
|
||||
return _empty_response(
|
||||
request_id=request_id,
|
||||
claim=req.claim,
|
||||
context=context,
|
||||
stages=stages,
|
||||
started_at=t0,
|
||||
)
|
||||
|
||||
# ---- stage 3: rerank top-N with BGE cross-encoder --------------------
|
||||
s3_t0 = time.perf_counter()
|
||||
rerank_scores: dict[str, float] = {}
|
||||
try:
|
||||
# We need the CONTENT of each claim atom to rerank it; fetch in parallel.
|
||||
full_claim_atoms = await _fetch_full_atoms(
|
||||
atomic,
|
||||
[h.atom_id for h in claim_hits[:RERANK_TOP_N]],
|
||||
)
|
||||
rerank_docs: list[str] = []
|
||||
rerank_atom_ids: list[str] = []
|
||||
for h in claim_hits[:RERANK_TOP_N]:
|
||||
full = full_claim_atoms.get(h.atom_id) or {}
|
||||
text = (full.get("content") or "")[:2000]
|
||||
if text:
|
||||
rerank_docs.append(text)
|
||||
rerank_atom_ids.append(h.atom_id)
|
||||
if rerank_docs:
|
||||
reranked = await embed.rerank(req.claim, rerank_docs)
|
||||
for r in reranked:
|
||||
if 0 <= r.index < len(rerank_atom_ids):
|
||||
rerank_scores[rerank_atom_ids[r.index]] = r.score
|
||||
stages.append(
|
||||
StageRecord(
|
||||
stage="rerank",
|
||||
success=True,
|
||||
items_processed=len(rerank_docs),
|
||||
items_failed=0,
|
||||
duration_ms=round((time.perf_counter() - s3_t0) * 1000, 1),
|
||||
)
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("gather_rerank_failed", error=str(e))
|
||||
full_claim_atoms = {}
|
||||
stages.append(
|
||||
StageRecord(
|
||||
stage="rerank",
|
||||
success=False,
|
||||
items_processed=0,
|
||||
items_failed=len(claim_hits),
|
||||
duration_ms=round((time.perf_counter() - s3_t0) * 1000, 1),
|
||||
error=str(e),
|
||||
)
|
||||
)
|
||||
|
||||
# ---- stage 4: group by parent doc --------------------------------------
|
||||
s4_t0 = time.perf_counter()
|
||||
buckets = group_hits_by_parent(claim_hits, rerank_scores)
|
||||
|
||||
# Phase B4 — fetch a wider parent pool so the recency reranker has
|
||||
# candidates to choose from. We oversample by 2x then trim to max_evidence
|
||||
# AFTER recency reranking. For volatile claims we also need the broader
|
||||
# pool so the hard recency filter doesn't leave us empty.
|
||||
fetch_count = min(len(buckets), max(req.max_evidence * 2, req.max_evidence))
|
||||
parent_urls = [url for url, _ in buckets][:fetch_count]
|
||||
parent_atoms = await _fetch_parents(atomic, parent_urls)
|
||||
|
||||
# Apply recency reranking + filter, trim to max_evidence.
|
||||
buckets = _apply_recency(
|
||||
buckets=buckets,
|
||||
parent_atoms=parent_atoms,
|
||||
volatility_hint=req.volatility_hint,
|
||||
recency_window_days=req.recency_window_days,
|
||||
max_evidence=req.max_evidence,
|
||||
)
|
||||
|
||||
# Fetch any missing full claim atom bodies (for non-reranked but still emitted)
|
||||
need_more_claim_atoms = [
|
||||
h.atom_id
|
||||
for _, hs in buckets[: req.max_evidence]
|
||||
for (h, _) in hs
|
||||
if h.atom_id not in full_claim_atoms
|
||||
]
|
||||
if need_more_claim_atoms:
|
||||
extra = await _fetch_full_atoms(atomic, need_more_claim_atoms)
|
||||
full_claim_atoms.update(extra)
|
||||
|
||||
# ---- stage 5: NLI stance vs query (optional) --------------------------
|
||||
# For each bucket that will become an evidence item, run NLI on the best
|
||||
# matching claim atom (the one we surface as `summary`). Parallelized so
|
||||
# ~10 calls complete in a couple of seconds rather than seconds per call.
|
||||
nli_by_atom_id: dict[str, tuple[str, float, str | None]] = {}
|
||||
if req.run_nli:
|
||||
s5_t0 = time.perf_counter()
|
||||
best_per_bucket: list[tuple[str, str]] = []
|
||||
for parent_url, hits in buckets[: req.max_evidence]:
|
||||
if parent_url not in parent_atoms:
|
||||
continue
|
||||
best_hit, _ = hits[0]
|
||||
full = full_claim_atoms.get(best_hit.atom_id) or {}
|
||||
claim_text, _stance, _parent_id = parse_claim_atom_body(
|
||||
full.get("content") or ""
|
||||
)
|
||||
if claim_text:
|
||||
best_per_bucket.append((best_hit.atom_id, claim_text))
|
||||
|
||||
if best_per_bucket:
|
||||
nli_results = await classify_batch(
|
||||
llm,
|
||||
claim=req.claim,
|
||||
evidence_texts=[text for _, text in best_per_bucket],
|
||||
)
|
||||
for (atom_id, _text), result in zip(
|
||||
best_per_bucket, nli_results, strict=True
|
||||
):
|
||||
nli_by_atom_id[atom_id] = (
|
||||
result.label,
|
||||
result.confidence,
|
||||
result.error,
|
||||
)
|
||||
failed = sum(1 for v in nli_by_atom_id.values() if v[2] is not None)
|
||||
stages.append(
|
||||
StageRecord(
|
||||
stage="nli",
|
||||
success=True,
|
||||
items_processed=len(nli_by_atom_id),
|
||||
items_failed=failed,
|
||||
duration_ms=round((time.perf_counter() - s5_t0) * 1000, 1),
|
||||
)
|
||||
)
|
||||
|
||||
# ---- stage 6: build evidence list with NLI attached -------------------
|
||||
evidence_items = []
|
||||
for parent_url, hits in buckets[: req.max_evidence]:
|
||||
parent_atom = parent_atoms.get(parent_url)
|
||||
if not parent_atom:
|
||||
continue
|
||||
item = evidence_from_parent(
|
||||
parent_atom=parent_atom,
|
||||
claim_hits=hits,
|
||||
parent_full_atoms=full_claim_atoms,
|
||||
include_full_text=req.include_full_text,
|
||||
nli_by_atom_id=nli_by_atom_id or None,
|
||||
)
|
||||
evidence_items.append(item)
|
||||
stages.append(
|
||||
StageRecord(
|
||||
stage="evidence",
|
||||
success=True,
|
||||
items_processed=len(evidence_items),
|
||||
items_failed=0,
|
||||
duration_ms=round((time.perf_counter() - s4_t0) * 1000, 1),
|
||||
)
|
||||
)
|
||||
|
||||
# ---- shape response --------------------------------------------------
|
||||
total_ms = round((time.perf_counter() - t0) * 1000, 1)
|
||||
|
||||
search_results: list[SearchResultItem] = []
|
||||
for i, item in enumerate(evidence_items, 1):
|
||||
search_results.append(
|
||||
SearchResultItem(
|
||||
query=req.claim,
|
||||
url=item.url,
|
||||
title=item.title,
|
||||
snippet=item.snippet or "",
|
||||
rank=i,
|
||||
site=item.publisher,
|
||||
published_at=item.published_at,
|
||||
)
|
||||
)
|
||||
|
||||
stats = EvidenceStats(
|
||||
input_items=len(claim_hits),
|
||||
after_dedup=len(buckets),
|
||||
output_items=len(evidence_items),
|
||||
duplicates_removed=max(0, len(claim_hits) - len(buckets)),
|
||||
tokens_used=0,
|
||||
)
|
||||
|
||||
# Cache status reflects BOTH presence and quality:
|
||||
# - MISS if no evidence at all, or the best match is weak (< 0.3 rerank)
|
||||
# - PARTIAL if we have evidence but best rerank is between 0.3 and 0.6
|
||||
# - HIT when the brain actually has a strong, direct match (>= 0.6)
|
||||
if not evidence_items:
|
||||
cache_status = "MISS"
|
||||
else:
|
||||
top_relevance = max(
|
||||
(e.relevance_score for e in evidence_items), default=0.0
|
||||
)
|
||||
if top_relevance < 0.30:
|
||||
cache_status = "MISS"
|
||||
elif top_relevance < 0.60:
|
||||
cache_status = "PARTIAL"
|
||||
else:
|
||||
cache_status = "HIT"
|
||||
|
||||
brain_meta = BrainMeta(
|
||||
cache_status=cache_status,
|
||||
api_version="v1",
|
||||
implementation="didibrain",
|
||||
evidence_sources=len({e.url for e in evidence_items}),
|
||||
total_claim_atoms_matched=len(claim_hits),
|
||||
)
|
||||
|
||||
# ---- optional verification cache lookup ---------------------------------
|
||||
# Lookup key is (claim_hash, tier) only. The evidence URLs the cache was
|
||||
# written for may differ from `evidence_items` (different runs, different
|
||||
# corpora). We surface the original URLs in metadata so backend can
|
||||
# decide whether the cached verification applies to its current view.
|
||||
if req.include_verification and req.tier:
|
||||
try:
|
||||
entry = await vcache.lookup(claim=req.claim, tier=req.tier)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning("verification_lookup_failed", error=f"{type(e).__name__}: {e}")
|
||||
entry = None
|
||||
|
||||
staleness = vcache.decide_freshness(
|
||||
entry,
|
||||
current_prompt_hash=req.prompt_hash,
|
||||
current_framework_version=req.framework_version,
|
||||
)
|
||||
|
||||
# Pilon 11 — fact-status check. If the cache is otherwise fresh but
|
||||
# one of its bound facts has flipped (e.g., "X is president of Y"
|
||||
# was TRUE when cached, but brain_fact_status now says FALSE), demote
|
||||
# to stale_evidence so the caller recomputes. We only run this when
|
||||
# the cache would otherwise be served (fresh / stale_framework — the
|
||||
# other states already force recompute).
|
||||
if entry is not None and staleness in ("fresh", "stale_framework"):
|
||||
try:
|
||||
flipped = await _detect_flipped_facts(entry)
|
||||
if flipped:
|
||||
log.info(
|
||||
"verification_facts_flipped",
|
||||
flipped_count=len(flipped),
|
||||
was=staleness,
|
||||
)
|
||||
staleness = "stale_evidence"
|
||||
brain_meta.verification_facts_invalidated = flipped
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning(
|
||||
"verification_fact_check_failed",
|
||||
error=f"{type(e).__name__}: {e}",
|
||||
)
|
||||
|
||||
brain_meta.verification_staleness = staleness
|
||||
|
||||
if entry is not None:
|
||||
brain_meta.verification_model = entry.model
|
||||
brain_meta.verification_tier = entry.tier
|
||||
brain_meta.verification_prompt_hash = entry.prompt_hash
|
||||
brain_meta.verification_framework_version = entry.framework_version
|
||||
brain_meta.verification_cached_at = entry.updated_at
|
||||
brain_meta.verification_expires_at = entry.expires_at
|
||||
brain_meta.verification_evidence_urls = entry.evidence_urls
|
||||
brain_meta.verification_evidence_hash = entry.evidence_hash
|
||||
|
||||
if staleness == "fresh":
|
||||
brain_meta.verification = entry.verification_processed
|
||||
elif staleness == "stale_framework":
|
||||
# Backend can recompute status from raw locally — no LLM call.
|
||||
brain_meta.verification = entry.verification_raw
|
||||
# stale_evidence / stale_prompt / miss → don't expose verification
|
||||
# so caller is forced to recompute.
|
||||
|
||||
return GatherResponse(
|
||||
request_id=request_id,
|
||||
claim=req.claim,
|
||||
evidence=evidence_items,
|
||||
evidence_stats=stats,
|
||||
search_context=context,
|
||||
search_results=search_results,
|
||||
stages=stages,
|
||||
total_urls_found=len(claim_hits),
|
||||
total_pages_fetched=len(evidence_items),
|
||||
total_evidence_items=len(evidence_items),
|
||||
execution_time_ms=total_ms,
|
||||
brain_meta=brain_meta,
|
||||
)
|
||||
|
||||
|
||||
# --- helpers ---------------------------------------------------------------
|
||||
|
||||
|
||||
def _published_at_of(parent_atom: dict | None) -> datetime | None:
|
||||
"""Best-effort published_at extractor for a parent Document atom."""
|
||||
if not parent_atom:
|
||||
return None
|
||||
raw = parent_atom.get("published_at") or parent_atom.get("created_at")
|
||||
if not raw:
|
||||
return None
|
||||
if isinstance(raw, datetime):
|
||||
return raw if raw.tzinfo else raw.replace(tzinfo=timezone.utc)
|
||||
try:
|
||||
s = str(raw).rstrip("Z")
|
||||
# Tolerate trailing Z by using fromisoformat with tz-aware handling.
|
||||
dt = datetime.fromisoformat(s)
|
||||
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _combined_score(
|
||||
*,
|
||||
rerank_score: float,
|
||||
age_days: float | None,
|
||||
volatility_hint: str | None,
|
||||
) -> float:
|
||||
"""Blend rerank with age-based recency. Stable / no-published_at = pure rerank.
|
||||
|
||||
For each volatility profile we use:
|
||||
combined = (1 - w_recency) * rerank + w_recency * exp(-age_days/half_life)
|
||||
|
||||
rerank_score is on [0, 1] from the cross-encoder; recency is also [0, 1].
|
||||
"""
|
||||
if age_days is None or age_days < 0:
|
||||
return rerank_score
|
||||
profile = RECENCY_PROFILES.get(volatility_hint or "", RECENCY_DEFAULT)
|
||||
w_recency, half_life = profile
|
||||
if w_recency <= 0:
|
||||
return rerank_score
|
||||
recency = math.exp(-age_days / half_life)
|
||||
return (1.0 - w_recency) * rerank_score + w_recency * recency
|
||||
|
||||
|
||||
def _apply_recency(
|
||||
*,
|
||||
buckets: list[tuple[str, list[tuple[SearchHit, float]]]],
|
||||
parent_atoms: dict[str, dict],
|
||||
volatility_hint: str | None,
|
||||
recency_window_days: int | None,
|
||||
max_evidence: int,
|
||||
) -> list[tuple[str, list[tuple[SearchHit, float]]]]:
|
||||
"""Rerank buckets by combined (rerank + recency) score, trim to max_evidence.
|
||||
|
||||
For volatility_hint='volatile', also drops parents older than
|
||||
recency_window_days (default 7) — caller will see fewer evidence items
|
||||
and can fall back to live web search.
|
||||
|
||||
Buckets without a fetched parent_atom (i.e., not in parent_atoms) drop
|
||||
out entirely — they were just placeholders for a wider fetch.
|
||||
"""
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
|
||||
# Hard recency cut for volatile (or any volatility when caller pinned a
|
||||
# window).
|
||||
if volatility_hint == "volatile" or recency_window_days is not None:
|
||||
window = recency_window_days or DEFAULT_VOLATILE_RECENCY_DAYS
|
||||
|
||||
def _within_window(parent_url: str) -> bool:
|
||||
atom = parent_atoms.get(parent_url)
|
||||
pub = _published_at_of(atom)
|
||||
if pub is None:
|
||||
# No publish date → keep only when no hard cut requested
|
||||
# (volatile defaults to dropping unknowns to be safe).
|
||||
return volatility_hint != "volatile"
|
||||
return (now - pub).days <= window
|
||||
|
||||
buckets = [(u, hs) for (u, hs) in buckets if _within_window(u)]
|
||||
|
||||
# Score each surviving bucket using its top hit's rerank score and the
|
||||
# parent's age, then re-sort. Buckets without parent_atoms are dropped.
|
||||
scored: list[tuple[float, str, list[tuple[SearchHit, float]]]] = []
|
||||
for parent_url, hits in buckets:
|
||||
atom = parent_atoms.get(parent_url)
|
||||
if not atom or not hits:
|
||||
continue
|
||||
top_rerank = float(hits[0][1])
|
||||
pub = _published_at_of(atom)
|
||||
age_days = (now - pub).days if pub else None
|
||||
score = _combined_score(
|
||||
rerank_score=top_rerank,
|
||||
age_days=age_days,
|
||||
volatility_hint=volatility_hint,
|
||||
)
|
||||
scored.append((score, parent_url, hits))
|
||||
|
||||
scored.sort(key=lambda t: t[0], reverse=True)
|
||||
return [(url, hits) for (_score, url, hits) in scored[:max_evidence]]
|
||||
|
||||
|
||||
def _extract_cached_truth(processed: dict) -> bool | None:
|
||||
"""Map a cached verification_processed dict to a binary truth direction.
|
||||
|
||||
DIDI v1 schema uses "status": "TRUE" | "FALSE" | "UV" | "OP" | "MIXED".
|
||||
Anything other than TRUE/FALSE returns None — the cache didn't commit
|
||||
to a direction so we can't compare it against fact_status.
|
||||
"""
|
||||
if not isinstance(processed, dict):
|
||||
return None
|
||||
status = processed.get("status")
|
||||
if isinstance(status, str):
|
||||
s = status.strip().upper()
|
||||
if s in ("TRUE", "VERIFIED_TRUE", "VT"):
|
||||
return True
|
||||
if s in ("FALSE", "VERIFIED_FALSE", "VF"):
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
async def _detect_flipped_facts(entry: vcache.CacheEntry) -> list[dict]:
|
||||
"""Return entity bindings whose current_truth contradicts the cached verdict.
|
||||
|
||||
Each returned dict mirrors the binding shape stored on the row, with
|
||||
extra fields ``cached_assumes`` and ``current_truth`` so the caller
|
||||
(admin dashboard, downstream backend) can show what changed.
|
||||
|
||||
Returns [] if the cache had no bindings, or if no current truth could be
|
||||
extracted from verification_processed, or if no bound fact disagrees.
|
||||
"""
|
||||
if not entry.entity_bindings:
|
||||
return []
|
||||
|
||||
cached_truth = _extract_cached_truth(entry.verification_processed)
|
||||
if cached_truth is None:
|
||||
return []
|
||||
|
||||
# Reconstruct EntityBinding instances from the JSONB row.
|
||||
bindings: list[EntityBinding] = []
|
||||
for raw in entry.entity_bindings:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
subj = str(raw.get("subject", "")).strip()
|
||||
pred = str(raw.get("predicate", "")).strip()
|
||||
obj_ = str(raw.get("object", "")).strip()
|
||||
try:
|
||||
conf = float(raw.get("confidence", 0.5))
|
||||
except (TypeError, ValueError):
|
||||
conf = 0.5
|
||||
if subj and pred and obj_:
|
||||
bindings.append(
|
||||
EntityBinding(
|
||||
subject=subj, predicate=pred, obj=obj_, confidence=conf
|
||||
)
|
||||
)
|
||||
|
||||
if not bindings:
|
||||
return []
|
||||
|
||||
truth_map = await fact_svc.check_fact_validity(bindings)
|
||||
|
||||
flipped: list[dict] = []
|
||||
for b in bindings:
|
||||
canonical = fact_svc.canonicalize_triple(b.subject, b.predicate, b.obj)
|
||||
ch = fact_svc.hash_canonical(canonical)
|
||||
current = truth_map.get(ch)
|
||||
# We only flag explicit disagreement; current=None means we have no
|
||||
# opinion (yet) and falls through to the existing freshness checks.
|
||||
if current is None:
|
||||
continue
|
||||
if current != cached_truth:
|
||||
flipped.append({
|
||||
"subject": b.subject,
|
||||
"predicate": b.predicate,
|
||||
"object": b.obj,
|
||||
"canonical_form": canonical,
|
||||
"cached_assumes": cached_truth,
|
||||
"current_truth": current,
|
||||
})
|
||||
return flipped
|
||||
|
||||
|
||||
async def _fetch_full_atoms(
|
||||
atomic: AtomicClient, atom_ids: list[str]
|
||||
) -> dict[str, dict]:
|
||||
"""Parallel get_atom for a list of atom IDs. Missing atoms are dropped."""
|
||||
if not atom_ids:
|
||||
return {}
|
||||
tasks = [atomic.get_atom(a) for a in atom_ids]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
out: dict[str, dict] = {}
|
||||
for a, r in zip(atom_ids, results, strict=True):
|
||||
if isinstance(r, dict):
|
||||
out[a] = r
|
||||
return out
|
||||
|
||||
|
||||
async def _fetch_parents(
|
||||
atomic: AtomicClient, parent_urls: list[str]
|
||||
) -> dict[str, dict]:
|
||||
"""Parallel get_atom_by_source_url for parent document URLs."""
|
||||
if not parent_urls:
|
||||
return {}
|
||||
tasks = [atomic.get_atom_by_source_url(u) for u in parent_urls]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
out: dict[str, dict] = {}
|
||||
for u, r in zip(parent_urls, results, strict=True):
|
||||
if isinstance(r, dict):
|
||||
out[u] = r
|
||||
return out
|
||||
|
||||
|
||||
def _empty_response(
|
||||
*,
|
||||
request_id: str,
|
||||
claim: str,
|
||||
context: SearchContext,
|
||||
stages: list[StageRecord],
|
||||
started_at: float,
|
||||
) -> GatherResponse:
|
||||
return GatherResponse(
|
||||
request_id=request_id,
|
||||
claim=claim,
|
||||
evidence=[],
|
||||
evidence_stats=EvidenceStats(),
|
||||
search_context=context,
|
||||
search_results=[],
|
||||
stages=stages,
|
||||
total_urls_found=0,
|
||||
total_pages_fetched=0,
|
||||
total_evidence_items=0,
|
||||
execution_time_ms=round((time.perf_counter() - started_at) * 1000, 1),
|
||||
brain_meta=BrainMeta(
|
||||
cache_status="MISS",
|
||||
api_version="v1",
|
||||
implementation="didibrain",
|
||||
evidence_sources=0,
|
||||
total_claim_atoms_matched=0,
|
||||
),
|
||||
)
|
||||
268
ai_platform/modules/didi_brain/brain_api/services/ingest.py
Normal file
268
ai_platform/modules/didi_brain/brain_api/services/ingest.py
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
"""POST /v1/ingest — populate brain from Didi's web-module results.
|
||||
|
||||
When Didi's backend calls the live (expensive) web module and gets a fresh
|
||||
GatherResponse, it can POST the same body here. We:
|
||||
|
||||
1. For each evidence item, dedup against existing atoms by canonical URL
|
||||
2. Build proper Type/Document atoms with inferred tags:
|
||||
- Credibility from credibility_score bucket
|
||||
- Language from search_context.detected_language
|
||||
- Country/Global by default (future: infer from publisher TLD)
|
||||
- Any `default_tags` provided by the caller
|
||||
3. Create atoms synchronously (returns quickly)
|
||||
4. Optionally queue claim extraction in background via FastAPI BackgroundTasks
|
||||
|
||||
Response returns counts + the created atom IDs so the caller can correlate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import BackgroundTasks
|
||||
|
||||
from brain_api.schemas import (
|
||||
EvidenceItem,
|
||||
IngestRequest,
|
||||
IngestResponse,
|
||||
)
|
||||
from brain_api.services.classifier import (
|
||||
ClaimVolatility,
|
||||
classify_claim_volatility,
|
||||
)
|
||||
from shared.atomic_api import AtomicApiError, AtomicClient
|
||||
from shared.llm_client import LlmClient
|
||||
from shared.logging import get_logger
|
||||
from shared.taxonomy import TagResolver
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
# Credibility bucket boundaries mirror mapping.CREDIBILITY_SCORES inverse.
|
||||
def credibility_score_to_tag_path(score: float) -> str:
|
||||
if score >= 0.85:
|
||||
return "Credibility/Tier1"
|
||||
if score >= 0.60:
|
||||
return "Credibility/Tier2"
|
||||
if score >= 0.40:
|
||||
return "Credibility/Tier3"
|
||||
if score >= 0.25:
|
||||
return "Credibility/StateAffiliated"
|
||||
if score >= 0.01:
|
||||
return "Credibility/KnownDisinfo"
|
||||
return "Credibility/Unknown"
|
||||
|
||||
|
||||
def detected_language_to_tag_path(lang: str | None) -> str:
|
||||
if not lang:
|
||||
return "Language/EN"
|
||||
code = lang.strip().upper()[:2]
|
||||
mapping = {
|
||||
"RO": "Language/RO",
|
||||
"EN": "Language/EN",
|
||||
"RU": "Language/RU",
|
||||
"UA": "Language/UA",
|
||||
"FR": "Language/FR",
|
||||
"DE": "Language/DE",
|
||||
"ES": "Language/ES",
|
||||
"IT": "Language/IT",
|
||||
"PL": "Language/PL",
|
||||
}
|
||||
return mapping.get(code, "Language/EN")
|
||||
|
||||
|
||||
def build_tag_ids_for_evidence(
|
||||
*,
|
||||
ev: EvidenceItem,
|
||||
detected_language: str,
|
||||
default_tags: list[str],
|
||||
resolver: TagResolver,
|
||||
) -> list[str]:
|
||||
paths: list[str] = [
|
||||
"Type/Document",
|
||||
"SourceType/MainstreamMedia", # default — callers can override via default_tags
|
||||
credibility_score_to_tag_path(ev.credibility_score),
|
||||
detected_language_to_tag_path(detected_language),
|
||||
"Country/Global",
|
||||
]
|
||||
# Append any caller-provided canonical tags, deduped
|
||||
for p in default_tags:
|
||||
if p and p not in paths:
|
||||
paths.append(p)
|
||||
return resolver.ids_for(paths, ignore_missing=True)
|
||||
|
||||
|
||||
def evidence_to_markdown(ev: EvidenceItem) -> str:
|
||||
"""Build the markdown body for a Type/Document atom from an EvidenceItem."""
|
||||
title = ev.title or ev.url
|
||||
body = ev.full_text or ev.summary or ev.snippet or ""
|
||||
header_lines = [f"# {title}", ""]
|
||||
if ev.published_at:
|
||||
header_lines.append(f"**Published:** {ev.published_at.isoformat()}")
|
||||
if ev.publisher:
|
||||
header_lines.append(f"**Publisher:** {ev.publisher}")
|
||||
if ev.published_at or ev.publisher:
|
||||
header_lines.append("")
|
||||
return "\n".join(header_lines) + body.strip() + "\n"
|
||||
|
||||
|
||||
async def _classify_and_register_facts_async(
|
||||
*, claim: str, llm: LlmClient, source_label: str
|
||||
) -> None:
|
||||
"""Classify the claim and register entity bindings in brain_fact_status.
|
||||
|
||||
Best-effort: any failure is logged and swallowed. Runs as a background
|
||||
task triggered by FastAPI's BackgroundTasks queue, after the ingest
|
||||
response has been returned to the caller.
|
||||
"""
|
||||
try:
|
||||
classification = await classify_claim_volatility(llm, claim=claim)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning(
|
||||
"ingest_classifier_failed",
|
||||
error=f"{type(e).__name__}:{e}",
|
||||
)
|
||||
return
|
||||
|
||||
if not classification.entity_bindings:
|
||||
return
|
||||
|
||||
try:
|
||||
from brain_api.services.fact_status import register_facts_from_bindings
|
||||
|
||||
await register_facts_from_bindings(
|
||||
classification.entity_bindings,
|
||||
volatility=classification.volatility,
|
||||
topic_codes=classification.topic_codes,
|
||||
source_atom_id=source_label,
|
||||
)
|
||||
log.info(
|
||||
"ingest_facts_registered",
|
||||
source=source_label,
|
||||
volatility=classification.volatility,
|
||||
bindings=len(classification.entity_bindings),
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning(
|
||||
"ingest_fact_registration_failed",
|
||||
error=f"{type(e).__name__}:{e}",
|
||||
)
|
||||
|
||||
|
||||
async def ingest(
|
||||
req: IngestRequest,
|
||||
*,
|
||||
atomic: AtomicClient,
|
||||
resolver: TagResolver,
|
||||
background: BackgroundTasks | None = None,
|
||||
llm: LlmClient | None = None,
|
||||
) -> IngestResponse:
|
||||
t0 = time.perf_counter()
|
||||
request_id = str(uuid.uuid4())
|
||||
|
||||
accepted = 0
|
||||
skipped = 0
|
||||
errors = 0
|
||||
warnings: list[str] = []
|
||||
created_ids: list[str] = []
|
||||
|
||||
detected_language = "en"
|
||||
# The caller may pass language hints inside default_tags or a nested context;
|
||||
# we support both. Fall back to English.
|
||||
for p in req.default_tags:
|
||||
if p.startswith("Language/"):
|
||||
detected_language = p.split("/", 1)[-1]
|
||||
break
|
||||
|
||||
for ev in req.evidence:
|
||||
if not ev.url:
|
||||
warnings.append("evidence item missing url — skipped")
|
||||
continue
|
||||
|
||||
# Dedup on canonical URL
|
||||
try:
|
||||
existing = await atomic.get_atom_by_source_url(ev.url)
|
||||
except AtomicApiError as e:
|
||||
errors += 1
|
||||
warnings.append(f"dedup check failed for {ev.url}: {e.status}")
|
||||
continue
|
||||
if existing:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
content = evidence_to_markdown(ev)
|
||||
tag_ids = build_tag_ids_for_evidence(
|
||||
ev=ev,
|
||||
detected_language=detected_language,
|
||||
default_tags=req.default_tags,
|
||||
resolver=resolver,
|
||||
)
|
||||
published_iso = ev.published_at.isoformat() if ev.published_at else None
|
||||
|
||||
try:
|
||||
atom = await atomic.create_atom(
|
||||
content=content,
|
||||
source_url=ev.url,
|
||||
tag_ids=tag_ids,
|
||||
published_at=published_iso,
|
||||
)
|
||||
atom_id = atom.get("id")
|
||||
if atom_id:
|
||||
created_ids.append(atom_id)
|
||||
accepted += 1
|
||||
else:
|
||||
errors += 1
|
||||
warnings.append(f"create_atom returned no id for {ev.url}")
|
||||
except AtomicApiError as e:
|
||||
errors += 1
|
||||
warnings.append(f"create_atom failed for {ev.url}: {e.status} {e.body[:120]}")
|
||||
|
||||
extraction_queued = False
|
||||
if req.run_extraction and created_ids and background is not None:
|
||||
background.add_task(_run_extraction_background, created_ids)
|
||||
extraction_queued = True
|
||||
|
||||
# Pilon 11: classify req.claim once and register entity bindings into
|
||||
# brain_fact_status. Lazy (current_truth=NULL) until verified.
|
||||
if (
|
||||
req.claim
|
||||
and req.claim.strip()
|
||||
and llm is not None
|
||||
and background is not None
|
||||
):
|
||||
background.add_task(
|
||||
_classify_and_register_facts_async,
|
||||
claim=req.claim,
|
||||
llm=llm,
|
||||
source_label=f"ingest:{request_id[:12]}",
|
||||
)
|
||||
|
||||
return IngestResponse(
|
||||
request_id=request_id,
|
||||
accepted=accepted,
|
||||
skipped_duplicate=skipped,
|
||||
errors=errors,
|
||||
created_atom_ids=created_ids,
|
||||
extraction_queued=extraction_queued,
|
||||
execution_time_ms=round((time.perf_counter() - t0) * 1000, 1),
|
||||
warnings=warnings[:20],
|
||||
)
|
||||
|
||||
|
||||
async def _run_extraction_background(atom_ids: list[str]) -> None:
|
||||
"""Fire-and-forget extraction for newly ingested atoms."""
|
||||
from extractor.batch import run_batch
|
||||
|
||||
log.info("brain_ingest_extraction_start", count=len(atom_ids))
|
||||
try:
|
||||
stats = await run_batch(only_atom_ids=set(atom_ids))
|
||||
log.info(
|
||||
"brain_ingest_extraction_done",
|
||||
processed=stats.docs_processed,
|
||||
claims=stats.claims_created,
|
||||
failed=stats.docs_failed,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.error("brain_ingest_extraction_failed", error=str(e))
|
||||
|
|
@ -0,0 +1,282 @@
|
|||
"""Cache invalidation service — Pilon 8 of the cache freshness defense.
|
||||
|
||||
Mass-invalidates rows in brain_analysis_atom and brain_verification_cache
|
||||
based on filters (topic, entity, since, content pattern). Used by:
|
||||
|
||||
- Admin dashboard "Flush topic" button (manual ops)
|
||||
- didibrain-breaking-watcher (real-time, when a breaking story affects
|
||||
a topic or entity)
|
||||
- Daily auditor (when gold demotion cascades to dependent rows)
|
||||
|
||||
Invalidation = set expires_at = now() (soft delete, preserves row for audit).
|
||||
Gold atoms in brain_analysis_atom are NOT invalidated by topic/entity filters
|
||||
unless ``invalidate_gold=True`` is passed — gold rows reflect human moderator
|
||||
decisions and shouldn't be flushed by automated breaking news. Operators who
|
||||
need to flush them must opt in explicitly.
|
||||
|
||||
Every invalidation logs a row in brain_audit_log with the filter spec and
|
||||
counts so the admin dashboard can show recent flush operations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from brain_api.db import db
|
||||
from shared.logging import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class InvalidateFilter:
|
||||
"""Selection criteria for a mass invalidation operation.
|
||||
|
||||
At least one of topic_codes / entity_canonicals / claim_pattern / since
|
||||
must be non-empty — calling with all-empty filters is rejected to avoid
|
||||
accidental "flush everything".
|
||||
|
||||
Attributes:
|
||||
topic_codes: Match rows whose ``topic_codes && this`` is true (any
|
||||
overlap). E.g., ``["war", "elections"]``.
|
||||
entity_canonicals: Match rows whose ``entity_bindings`` JSONB
|
||||
includes a triple that lower-cases to one of these
|
||||
``"<subject> <predicate> <object>"`` canonical strings.
|
||||
Caller is responsible for normalizing (lowercasing, predicate
|
||||
snake_case'd, etc.) — see ``fact_status.canonicalize_triple``.
|
||||
claim_pattern: ILIKE pattern on content_preview / claim text.
|
||||
since: Match rows created or updated AFTER this timestamp.
|
||||
invalidate_gold: If true, also expire gold rows. Default false.
|
||||
dry_run: If true, count matches without modifying anything.
|
||||
"""
|
||||
|
||||
topic_codes: list[str] | None = None
|
||||
entity_canonicals: list[str] | None = None
|
||||
claim_pattern: str | None = None
|
||||
since: datetime | None = None
|
||||
invalidate_gold: bool = False
|
||||
dry_run: bool = False
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
"""True if no filter criteria are set — caller must reject."""
|
||||
return not (
|
||||
self.topic_codes
|
||||
or self.entity_canonicals
|
||||
or self.claim_pattern
|
||||
or self.since
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class InvalidateResult:
|
||||
"""Counts and metadata returned by ``invalidate_caches``."""
|
||||
|
||||
invalidated_atoms: int
|
||||
invalidated_vcache: int
|
||||
dry_run: bool
|
||||
filters_applied: dict[str, Any]
|
||||
executed_at: datetime
|
||||
|
||||
|
||||
def _entity_match_clause(idx: int) -> str:
|
||||
"""JSONB existence clause matching any binding's canonical form.
|
||||
|
||||
Caller pre-normalizes inputs to lowercase
|
||||
``"<subject> <predicate_snake> <object>"`` and passes them as a text[].
|
||||
PG just concatenates the binding fields with the same shape and
|
||||
compares — no extensions needed.
|
||||
"""
|
||||
return (
|
||||
"EXISTS ("
|
||||
" SELECT 1 FROM jsonb_array_elements(entity_bindings) AS b "
|
||||
" WHERE lower(coalesce(b->>'subject','')) || ' ' || "
|
||||
" lower(replace(coalesce(b->>'predicate',''), ' ', '_')) || ' ' || "
|
||||
" lower(coalesce(b->>'object','')) "
|
||||
f" = ANY(${idx}::text[])"
|
||||
")"
|
||||
)
|
||||
|
||||
|
||||
def _build_atom_where_clause(
|
||||
f: InvalidateFilter, params: list[Any]
|
||||
) -> str:
|
||||
"""Compose WHERE clause + side-effects on ``params`` for analysis atoms.
|
||||
|
||||
Returns a SQL fragment starting with ``WHERE`` (always non-empty since
|
||||
is_empty() is checked upstream).
|
||||
"""
|
||||
clauses: list[str] = ["(expires_at IS NULL OR expires_at > now())"]
|
||||
|
||||
if not f.invalidate_gold:
|
||||
clauses.append("cache_tier <> 'gold'")
|
||||
|
||||
if f.topic_codes:
|
||||
params.append(f.topic_codes)
|
||||
clauses.append(f"topic_codes && ${len(params)}::text[]")
|
||||
|
||||
if f.entity_canonicals:
|
||||
# Caller-side canonicalization (lowercased "subject predicate object")
|
||||
# — see fact_status.canonicalize_triple. PG just does string match
|
||||
# against entity_bindings JSONB without needing unaccent/digest.
|
||||
params.append(f.entity_canonicals)
|
||||
clauses.append(_entity_match_clause(len(params)))
|
||||
|
||||
if f.claim_pattern:
|
||||
params.append(f"%{f.claim_pattern}%")
|
||||
clauses.append(f"content_preview ILIKE ${len(params)}")
|
||||
|
||||
if f.since is not None:
|
||||
params.append(f.since)
|
||||
clauses.append(f"updated_at >= ${len(params)}")
|
||||
|
||||
return "WHERE " + " AND ".join(clauses)
|
||||
|
||||
|
||||
def _build_vcache_where_clause(
|
||||
f: InvalidateFilter, params: list[Any]
|
||||
) -> str:
|
||||
"""Compose WHERE clause for verification cache (no cache_tier here)."""
|
||||
clauses: list[str] = ["expires_at > now()"]
|
||||
|
||||
if f.topic_codes:
|
||||
params.append(f.topic_codes)
|
||||
clauses.append(f"topic_codes && ${len(params)}::text[]")
|
||||
|
||||
if f.entity_canonicals:
|
||||
params.append(f.entity_canonicals)
|
||||
clauses.append(_entity_match_clause(len(params)))
|
||||
|
||||
if f.claim_pattern:
|
||||
params.append(f"%{f.claim_pattern}%")
|
||||
# vcache stores the claim text only as a hash — match against the
|
||||
# processed verification payload as a fallback.
|
||||
clauses.append(
|
||||
f"verification_processed::text ILIKE ${len(params)}"
|
||||
)
|
||||
|
||||
if f.since is not None:
|
||||
params.append(f.since)
|
||||
clauses.append(f"updated_at >= ${len(params)}")
|
||||
|
||||
return "WHERE " + " AND ".join(clauses)
|
||||
|
||||
|
||||
async def invalidate_caches(
|
||||
f: InvalidateFilter,
|
||||
*,
|
||||
actor: str = "admin",
|
||||
reason: str | None = None,
|
||||
) -> InvalidateResult:
|
||||
"""Invalidate rows in both cache tables matching the filter.
|
||||
|
||||
Args:
|
||||
f: The selection criteria. Must not be empty (raises ValueError).
|
||||
actor: Free-form label for the audit log (e.g., 'admin:foo@bar',
|
||||
'breaking_watcher', 'auditor').
|
||||
reason: Optional human-readable note for audit trail.
|
||||
|
||||
Returns:
|
||||
InvalidateResult with counts and the filters that were applied.
|
||||
|
||||
Raises:
|
||||
ValueError: If the filter is empty (no criteria set).
|
||||
RuntimeError: If brain_db is not connected.
|
||||
"""
|
||||
if f.is_empty():
|
||||
raise ValueError(
|
||||
"invalidate filter is empty — refusing to flush everything"
|
||||
)
|
||||
if not db.pool:
|
||||
raise RuntimeError("brain_db not connected")
|
||||
|
||||
now = datetime.now(tz=timezone.utc)
|
||||
|
||||
# Count first (always — even non-dry-run runs the count for the audit log).
|
||||
atom_params: list[Any] = []
|
||||
atom_where = _build_atom_where_clause(f, atom_params)
|
||||
vcache_params: list[Any] = []
|
||||
vcache_where = _build_vcache_where_clause(f, vcache_params)
|
||||
|
||||
async with db.pool.acquire() as conn:
|
||||
# COUNT pre-update so we know how many rows we'll touch.
|
||||
atom_count_row = await conn.fetchrow(
|
||||
f"SELECT COUNT(*) AS c FROM brain_analysis_atom {atom_where}",
|
||||
*atom_params,
|
||||
)
|
||||
vcache_count_row = await conn.fetchrow(
|
||||
f"SELECT COUNT(*) AS c FROM brain_verification_cache {vcache_where}",
|
||||
*vcache_params,
|
||||
)
|
||||
atom_count = int(atom_count_row["c"]) if atom_count_row else 0
|
||||
vcache_count = int(vcache_count_row["c"]) if vcache_count_row else 0
|
||||
|
||||
if not f.dry_run and (atom_count > 0 or vcache_count > 0):
|
||||
async with conn.transaction():
|
||||
if atom_count > 0:
|
||||
await conn.execute(
|
||||
f"UPDATE brain_analysis_atom SET expires_at = now(), "
|
||||
f"updated_at = now() {atom_where}",
|
||||
*atom_params,
|
||||
)
|
||||
if vcache_count > 0:
|
||||
await conn.execute(
|
||||
f"UPDATE brain_verification_cache SET expires_at = now(), "
|
||||
f"updated_at = now() {vcache_where}",
|
||||
*vcache_params,
|
||||
)
|
||||
|
||||
payload = {
|
||||
"filter": {
|
||||
"topic_codes": f.topic_codes,
|
||||
"entity_canonicals_count": (
|
||||
len(f.entity_canonicals)
|
||||
if f.entity_canonicals
|
||||
else 0
|
||||
),
|
||||
"claim_pattern": f.claim_pattern,
|
||||
"since": f.since.isoformat() if f.since else None,
|
||||
"invalidate_gold": f.invalidate_gold,
|
||||
},
|
||||
"counts": {
|
||||
"atoms": atom_count,
|
||||
"vcache": vcache_count,
|
||||
},
|
||||
"reason": reason,
|
||||
}
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO brain_audit_log (action, target_table, target_id, actor, payload)
|
||||
VALUES ('invalidate', 'multi', 'mass', $1, $2::jsonb)
|
||||
""",
|
||||
actor,
|
||||
json.dumps(payload),
|
||||
)
|
||||
|
||||
log.info(
|
||||
"cache_invalidated",
|
||||
atom_count=atom_count,
|
||||
vcache_count=vcache_count,
|
||||
dry_run=f.dry_run,
|
||||
actor=actor,
|
||||
topic_codes=f.topic_codes,
|
||||
invalidate_gold=f.invalidate_gold,
|
||||
)
|
||||
|
||||
return InvalidateResult(
|
||||
invalidated_atoms=atom_count,
|
||||
invalidated_vcache=vcache_count,
|
||||
dry_run=f.dry_run,
|
||||
filters_applied={
|
||||
"topic_codes": f.topic_codes,
|
||||
"entity_canonicals_count": (
|
||||
len(f.entity_canonicals) if f.entity_canonicals else 0
|
||||
),
|
||||
"claim_pattern": f.claim_pattern,
|
||||
"since": f.since.isoformat() if f.since else None,
|
||||
"invalidate_gold": f.invalidate_gold,
|
||||
},
|
||||
executed_at=now,
|
||||
)
|
||||
321
ai_platform/modules/didi_brain/brain_api/services/mapping.py
Normal file
321
ai_platform/modules/didi_brain/brain_api/services/mapping.py
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
"""Translate DidiBrain atoms into Didi's EvidenceItem / FetchedPage shapes.
|
||||
|
||||
The trick here is that the brain stores TWO kinds of atoms:
|
||||
- Type/Document → the full source article (parent)
|
||||
- Type/Claim → an atomic factual claim extracted from a parent
|
||||
|
||||
Didi's response shape expects evidence AT THE DOCUMENT LEVEL (url, title,
|
||||
full_text). So we:
|
||||
|
||||
1. Run semantic + rerank at claim level (precision)
|
||||
2. Group hits by parent document URL
|
||||
3. Emit one EvidenceItem per distinct parent, with the best-scoring claim
|
||||
attached as `summary` and supporting data in `brain_meta`
|
||||
|
||||
Credibility tags in our taxonomy (Tier1/Tier2/Tier3/StateAffiliated/KnownDisinfo)
|
||||
map to numeric scores that mirror Didi's web-module output range (0..1).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from brain_api.schemas import (
|
||||
BrainEvidenceMeta,
|
||||
EvidenceItem,
|
||||
FetchedPage,
|
||||
Provenance,
|
||||
SearchResultItem,
|
||||
)
|
||||
from shared.atomic_api import SearchHit
|
||||
|
||||
# --- credibility mapping ---------------------------------------------------
|
||||
|
||||
CREDIBILITY_SCORES: dict[str, float] = {
|
||||
"Tier1": 0.90,
|
||||
"Tier2": 0.70,
|
||||
"Tier3": 0.50,
|
||||
"StateAffiliated": 0.40,
|
||||
"KnownDisinfo": 0.15,
|
||||
"Unknown": 0.50,
|
||||
}
|
||||
|
||||
|
||||
def tag_to_credibility_score(tags: list[dict[str, Any]]) -> float:
|
||||
"""Pick the highest-priority credibility tag and map to a score."""
|
||||
for t in tags:
|
||||
name = (t.get("name") or "").strip()
|
||||
if name in CREDIBILITY_SCORES:
|
||||
return CREDIBILITY_SCORES[name]
|
||||
return CREDIBILITY_SCORES["Unknown"]
|
||||
|
||||
|
||||
# --- parent URL / publisher ------------------------------------------------
|
||||
|
||||
|
||||
def parent_url_of(source_url: str | None) -> str:
|
||||
"""Strip `#claim=...` fragment from a claim atom's source_url."""
|
||||
if not source_url:
|
||||
return ""
|
||||
return source_url.split("#", 1)[0]
|
||||
|
||||
|
||||
def publisher_of(url: str) -> str:
|
||||
try:
|
||||
host = urlparse(url).hostname or ""
|
||||
except ValueError:
|
||||
return ""
|
||||
if host.startswith("www."):
|
||||
host = host[4:]
|
||||
return host
|
||||
|
||||
|
||||
def title_from_url(url: str) -> str:
|
||||
if not url:
|
||||
return ""
|
||||
slug = url.rstrip("/").rsplit("/", 1)[-1]
|
||||
return unquote(slug).replace("_", " ")
|
||||
|
||||
|
||||
# --- claim atom body parsing -----------------------------------------------
|
||||
|
||||
_CLAIM_BODY_RE = re.compile(r"^# Claim\s*\n+(.+?)\n+##", re.DOTALL | re.MULTILINE)
|
||||
_STANCE_RE = re.compile(r"Stance in source:\s*(\w+)", re.IGNORECASE)
|
||||
_PARENT_ID_RE = re.compile(r"Parent atom:\s*`([^`]+)`")
|
||||
|
||||
|
||||
def parse_claim_atom_body(content: str) -> tuple[str, str, str]:
|
||||
"""Return (claim_text, stance, parent_atom_id) from a Type/Claim markdown body."""
|
||||
claim_text = ""
|
||||
stance = "NEUTRAL"
|
||||
parent_id = ""
|
||||
m = _CLAIM_BODY_RE.search(content)
|
||||
if m:
|
||||
claim_text = m.group(1).strip()
|
||||
s = _STANCE_RE.search(content)
|
||||
if s:
|
||||
stance = s.group(1).strip().upper()
|
||||
p = _PARENT_ID_RE.search(content)
|
||||
if p:
|
||||
parent_id = p.group(1).strip()
|
||||
return claim_text, stance, parent_id
|
||||
|
||||
|
||||
# --- document title from content header -----------------------------------
|
||||
|
||||
|
||||
def title_from_content(content: str | None, fallback_url: str = "") -> str:
|
||||
if not content:
|
||||
return title_from_url(fallback_url)
|
||||
for line in content.lstrip().splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("# "):
|
||||
return stripped[2:].strip()
|
||||
if stripped:
|
||||
break
|
||||
return title_from_url(fallback_url)
|
||||
|
||||
|
||||
def sha256_hex(text: str) -> str:
|
||||
return hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()
|
||||
|
||||
|
||||
# --- aggregated per-parent ------------------------------------------------
|
||||
|
||||
|
||||
def group_hits_by_parent(
|
||||
hits: list[SearchHit], rerank_scores: dict[str, float]
|
||||
) -> list[tuple[str, list[tuple[SearchHit, float]]]]:
|
||||
"""Return ordered list of (parent_url, [(hit, rerank_score), ...]).
|
||||
|
||||
Order is by the best rerank_score within each parent, descending.
|
||||
If a hit has no rerank score (wasn't in top-N), it falls to the end.
|
||||
"""
|
||||
buckets: dict[str, list[tuple[SearchHit, float]]] = defaultdict(list)
|
||||
for h in hits:
|
||||
parent = parent_url_of(h.source_url)
|
||||
if not parent:
|
||||
continue
|
||||
rr = rerank_scores.get(h.atom_id, 0.0)
|
||||
buckets[parent].append((h, rr))
|
||||
# sort each bucket: highest rerank first, then highest embedding sim
|
||||
for parent in buckets:
|
||||
buckets[parent].sort(key=lambda x: (-x[1], -x[0].similarity))
|
||||
# sort buckets by their top entry's rerank score, descending
|
||||
ordered = sorted(
|
||||
buckets.items(),
|
||||
key=lambda kv: (-kv[1][0][1], -kv[1][0][0].similarity),
|
||||
)
|
||||
return ordered
|
||||
|
||||
|
||||
# --- build EvidenceItem from a parent doc + claim matches ------------------
|
||||
|
||||
|
||||
def evidence_from_parent(
|
||||
*,
|
||||
parent_atom: dict[str, Any],
|
||||
claim_hits: list[tuple[SearchHit, float]],
|
||||
parent_full_atoms: dict[str, dict[str, Any]],
|
||||
include_full_text: bool,
|
||||
nli_by_atom_id: dict[str, tuple[str, float, str | None]] | None = None,
|
||||
) -> EvidenceItem:
|
||||
"""Build an EvidenceItem given one parent document and its best-matching claim atoms.
|
||||
|
||||
`parent_atom` is the parent Type/Document atom (with full content).
|
||||
`claim_hits` are (SearchHit, rerank_score) for claims belonging to this parent,
|
||||
pre-sorted descending.
|
||||
`parent_full_atoms` is an already-fetched map of full atom bodies so we can
|
||||
pull the claim text from each matching claim atom.
|
||||
"""
|
||||
parent_url = parent_atom.get("source_url") or ""
|
||||
parent_id = parent_atom.get("id") or ""
|
||||
parent_content = parent_atom.get("content") or ""
|
||||
title = title_from_content(parent_content, parent_url)
|
||||
|
||||
# Best matching claim (for summary + brain_meta)
|
||||
best_hit, best_rerank = claim_hits[0]
|
||||
best_full = parent_full_atoms.get(best_hit.atom_id) or {}
|
||||
best_claim_text, best_stance, _best_parent = parse_claim_atom_body(
|
||||
best_full.get("content") or ""
|
||||
)
|
||||
|
||||
# Snippet: first paragraph of the parent, trimmed
|
||||
snippet = (
|
||||
parent_content.strip().split("\n\n", 1)[0][:300]
|
||||
if parent_content
|
||||
else None
|
||||
)
|
||||
|
||||
# Dates — prefer published_at from the parent; fall back to created_at; never null
|
||||
published_at = _parse_dt(parent_atom.get("published_at"))
|
||||
retrieved_at = _parse_dt(parent_atom.get("created_at")) or datetime.now(timezone.utc)
|
||||
|
||||
# Credibility from parent's tag set
|
||||
credibility = tag_to_credibility_score(parent_atom.get("tags") or [])
|
||||
|
||||
# NLI stance vs query — only attached to the BEST claim (the one we
|
||||
# already surface as `summary`), since that's the one Didi will show.
|
||||
nli_label = "UNKNOWN"
|
||||
nli_conf = 0.0
|
||||
nli_err: str | None = None
|
||||
if nli_by_atom_id is not None:
|
||||
entry = nli_by_atom_id.get(best_hit.atom_id)
|
||||
if entry is not None:
|
||||
nli_label, nli_conf, nli_err = entry
|
||||
|
||||
# Brain meta: one per evidence item, holds every matching claim's info
|
||||
brain_meta = BrainEvidenceMeta(
|
||||
parent_atom_id=parent_id,
|
||||
matching_claim_atom_ids=[h.atom_id for h, _ in claim_hits],
|
||||
best_claim_text=best_claim_text,
|
||||
best_claim_stance_in_source=best_stance,
|
||||
best_claim_hash=(best_hit.source_url or "").split("#claim=", 1)[-1][:16],
|
||||
claim_count=len(claim_hits),
|
||||
reranker_score=best_rerank,
|
||||
embedding_similarity=best_hit.similarity,
|
||||
stance_vs_query=nli_label,
|
||||
nli_confidence=nli_conf,
|
||||
nli_error=nli_err,
|
||||
)
|
||||
|
||||
full_text = parent_content if include_full_text else None
|
||||
|
||||
return EvidenceItem(
|
||||
url=parent_url,
|
||||
canonical_url=parent_url or None,
|
||||
title=title,
|
||||
publisher=publisher_of(parent_url),
|
||||
published_at=published_at,
|
||||
retrieved_at=retrieved_at,
|
||||
snippet=snippet,
|
||||
summary=best_claim_text or None,
|
||||
full_text=full_text,
|
||||
full_text_hash=sha256_hex(parent_content) if parent_content else "",
|
||||
provenance=Provenance(
|
||||
extraction_method="brain",
|
||||
fallback_chain=[],
|
||||
brain_meta=brain_meta,
|
||||
),
|
||||
relevance_score=round(best_rerank or best_hit.similarity, 4),
|
||||
credibility_score=credibility,
|
||||
)
|
||||
|
||||
|
||||
def _parse_dt(value: Any) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
try:
|
||||
text = str(value).replace("Z", "+00:00")
|
||||
return datetime.fromisoformat(text)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
# --- doc atom → FetchedPage ------------------------------------------------
|
||||
|
||||
|
||||
def doc_to_fetched_page(
|
||||
full_atom: dict[str, Any], *, include_html: bool = False
|
||||
) -> FetchedPage:
|
||||
url = full_atom.get("source_url") or ""
|
||||
content = full_atom.get("content") or ""
|
||||
return FetchedPage(
|
||||
url=url,
|
||||
canonical_url=url or None,
|
||||
title=title_from_content(content, url),
|
||||
text=content,
|
||||
text_hash=sha256_hex(content),
|
||||
html=None if not include_html else content,
|
||||
extraction_method="brain",
|
||||
fallback_chain=[],
|
||||
published_at=_parse_dt(full_atom.get("published_at")),
|
||||
retrieved_at=_parse_dt(full_atom.get("created_at")) or datetime.now(timezone.utc),
|
||||
extraction_time_ms=0.0,
|
||||
warnings=[],
|
||||
needs_fallback=False,
|
||||
status_code=200,
|
||||
content_type="text/markdown",
|
||||
)
|
||||
|
||||
|
||||
# --- doc atom → SearchResultItem -------------------------------------------
|
||||
|
||||
|
||||
def doc_to_search_result(
|
||||
atom: dict[str, Any], *, query: str, rank: int
|
||||
) -> SearchResultItem:
|
||||
url = atom.get("source_url") or ""
|
||||
title = title_from_content(atom.get("content"), url)
|
||||
snippet = atom.get("snippet") or ""
|
||||
if not snippet and atom.get("content"):
|
||||
snippet = (atom["content"] or "").strip().split("\n\n", 1)[0][:200]
|
||||
return SearchResultItem(
|
||||
query=query,
|
||||
url=url,
|
||||
title=title,
|
||||
snippet=snippet,
|
||||
rank=rank,
|
||||
site=publisher_of(url),
|
||||
published_at=_parse_dt(atom.get("published_at")),
|
||||
)
|
||||
|
||||
|
||||
# --- language detection (tiny heuristic) ----------------------------------
|
||||
|
||||
_RO_CHARS = set("ăâîșțĂÂÎȘȚşţŞŢ")
|
||||
|
||||
|
||||
def detect_language_simple(text: str) -> str:
|
||||
if not text:
|
||||
return "en"
|
||||
if any(c in _RO_CHARS for c in text):
|
||||
return "ro"
|
||||
return "en"
|
||||
145
ai_platform/modules/didi_brain/brain_api/services/nli.py
Normal file
145
ai_platform/modules/didi_brain/brain_api/services/nli.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
"""NLI stance classification: is this evidence supporting, contradicting,
|
||||
or neutral relative to the user's claim?
|
||||
|
||||
This is separate from `stance_in_source` (what the original source asserts
|
||||
about itself). For disinfo analysis, the question Didi's backend really
|
||||
needs answered is: "does this evidence back the user's claim or refute it?"
|
||||
|
||||
Implementation:
|
||||
- one LLM call per (claim, evidence) pair
|
||||
- async + parallel across top-N evidence items
|
||||
- returns a stance label and a confidence
|
||||
- uses the versioned prompt at brain_api/prompts/nli_v1.md
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from shared.config import LlmRole
|
||||
from shared.llm_client import LlmClient, LlmError
|
||||
from shared.logging import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
PROMPT_VERSION = "v1"
|
||||
_PROMPT_PATH = Path(__file__).resolve().parent.parent / "prompts" / f"nli_{PROMPT_VERSION}.md"
|
||||
|
||||
ALLOWED_LABELS = {"SUPPORTS", "CONTRADICTS", "NEUTRAL"}
|
||||
MAX_EVIDENCE_CHARS = 1500 # truncate long evidence before sending to the NLI model
|
||||
# Match the effective llama.cpp backend concurrency: we have two instances
|
||||
# behind the router (10.11.10.18 and 10.11.10.19), each serves one request
|
||||
# at a time. Flooding with more parallel calls just queues them on the
|
||||
# backend and hits our per-call timeout.
|
||||
MAX_PARALLEL = 2
|
||||
PER_CALL_TIMEOUT_S = 30.0 # generous; queuing + generation
|
||||
TOTAL_TIMEOUT_S = 60.0 # wall-clock for the whole batch
|
||||
|
||||
|
||||
_PROMPT_TEMPLATE: str | None = None
|
||||
|
||||
|
||||
def _load_prompt() -> str:
|
||||
global _PROMPT_TEMPLATE
|
||||
if _PROMPT_TEMPLATE is None:
|
||||
_PROMPT_TEMPLATE = _PROMPT_PATH.read_text(encoding="utf-8")
|
||||
return _PROMPT_TEMPLATE
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class NliResult:
|
||||
label: str # SUPPORTS / CONTRADICTS / NEUTRAL
|
||||
confidence: float # 0.0 - 1.0
|
||||
error: str | None = None
|
||||
|
||||
|
||||
async def classify_one(
|
||||
llm: LlmClient, *, claim: str, evidence: str
|
||||
) -> NliResult:
|
||||
"""Classify a single (claim, evidence) pair. Never raises — returns
|
||||
NeutralResult with error populated on failure so the caller can still
|
||||
produce a response for that evidence item."""
|
||||
if not evidence.strip():
|
||||
return NliResult(label="NEUTRAL", confidence=0.0, error="empty_evidence")
|
||||
|
||||
truncated = evidence[:MAX_EVIDENCE_CHARS]
|
||||
prompt = _load_prompt().replace("{claim}", claim).replace("{evidence}", truncated)
|
||||
|
||||
try:
|
||||
result, _usage = await asyncio.wait_for(
|
||||
llm.chat_json(
|
||||
role=LlmRole.REASONING,
|
||||
system=(
|
||||
"You are an NLI classifier. Respond with strictly valid "
|
||||
"JSON only, no commentary."
|
||||
),
|
||||
user=prompt,
|
||||
max_tokens=120,
|
||||
temperature=0.0,
|
||||
),
|
||||
timeout=PER_CALL_TIMEOUT_S,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
return NliResult(label="NEUTRAL", confidence=0.0, error="timeout")
|
||||
except LlmError as e:
|
||||
return NliResult(label="NEUTRAL", confidence=0.0, error=f"llm:{e}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
return NliResult(label="NEUTRAL", confidence=0.0, error=f"{type(e).__name__}:{e}")
|
||||
|
||||
if not isinstance(result, dict):
|
||||
return NliResult(label="NEUTRAL", confidence=0.0, error="non_dict_response")
|
||||
|
||||
raw_label = (result.get("label") or "").strip().upper()
|
||||
try:
|
||||
conf = float(result.get("confidence", 0))
|
||||
except (TypeError, ValueError):
|
||||
conf = 0.0
|
||||
conf = max(0.0, min(1.0, conf))
|
||||
|
||||
if raw_label not in ALLOWED_LABELS:
|
||||
return NliResult(
|
||||
label="NEUTRAL",
|
||||
confidence=0.0,
|
||||
error=f"bad_label:{raw_label[:40]}",
|
||||
)
|
||||
|
||||
return NliResult(label=raw_label, confidence=conf)
|
||||
|
||||
|
||||
async def classify_batch(
|
||||
llm: LlmClient,
|
||||
*,
|
||||
claim: str,
|
||||
evidence_texts: list[str],
|
||||
max_parallel: int = MAX_PARALLEL,
|
||||
) -> list[NliResult]:
|
||||
"""Classify many evidence items in parallel, preserving input order.
|
||||
|
||||
Concurrency is bounded by `max_parallel` so we don't hammer the LLM
|
||||
router. Individual failures produce a NeutralResult with an error field
|
||||
(never raises to the caller).
|
||||
"""
|
||||
if not evidence_texts:
|
||||
return []
|
||||
|
||||
sem = asyncio.Semaphore(max_parallel)
|
||||
|
||||
async def _guarded(ev: str) -> NliResult:
|
||||
async with sem:
|
||||
return await classify_one(llm, claim=claim, evidence=ev)
|
||||
|
||||
try:
|
||||
results = await asyncio.wait_for(
|
||||
asyncio.gather(*[_guarded(ev) for ev in evidence_texts]),
|
||||
timeout=TOTAL_TIMEOUT_S,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
log.warning("nli_batch_total_timeout", count=len(evidence_texts))
|
||||
return [
|
||||
NliResult(label="NEUTRAL", confidence=0.0, error="batch_timeout")
|
||||
for _ in evidence_texts
|
||||
]
|
||||
|
||||
return list(results)
|
||||
93
ai_platform/modules/didi_brain/brain_api/services/search.py
Normal file
93
ai_platform/modules/didi_brain/brain_api/services/search.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"""POST /v1/search — thin retrieval that returns a flat list of results.
|
||||
|
||||
Unlike /v1/gather, we do NOT rerank or group — just semantic search the brain
|
||||
and convert each document-level hit into a SearchResultItem. This is the
|
||||
equivalent of a search engine result list; callers that want ranked evidence
|
||||
should hit /v1/gather instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from brain_api.schemas import (
|
||||
BrainMeta,
|
||||
SearchRequest,
|
||||
SearchResponse,
|
||||
SearchResultItem,
|
||||
)
|
||||
from brain_api.services.mapping import doc_to_search_result, parent_url_of
|
||||
from shared.atomic_api import AtomicClient
|
||||
from shared.logging import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
async def search(
|
||||
req: SearchRequest, *, atomic: AtomicClient
|
||||
) -> SearchResponse:
|
||||
t0 = time.perf_counter()
|
||||
request_id = str(uuid.uuid4())
|
||||
|
||||
# Run queries in parallel; merge into a flat ranked list.
|
||||
tasks = [
|
||||
atomic.search(q, mode="semantic", limit=req.max_results, threshold=0.2)
|
||||
for q in req.queries
|
||||
]
|
||||
per_query_hits = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# We need full document atoms (parents, de-duped by URL) to render results
|
||||
seen_urls: set[str] = set()
|
||||
results: list[SearchResultItem] = []
|
||||
parent_atom_cache: dict[str, dict] = {}
|
||||
|
||||
for query_str, query_hits in zip(req.queries, per_query_hits, strict=True):
|
||||
if isinstance(query_hits, Exception):
|
||||
log.warning("search_query_failed", query=query_str, error=str(query_hits))
|
||||
continue
|
||||
|
||||
# Collect parent URLs from this query in order
|
||||
ordered_parents: list[str] = []
|
||||
for h in query_hits:
|
||||
parent = parent_url_of(h.source_url)
|
||||
if not parent or parent in seen_urls:
|
||||
continue
|
||||
seen_urls.add(parent)
|
||||
ordered_parents.append(parent)
|
||||
if len(ordered_parents) >= req.max_results:
|
||||
break
|
||||
|
||||
# Fetch any parent docs we haven't seen yet, in parallel
|
||||
need = [p for p in ordered_parents if p not in parent_atom_cache]
|
||||
if need:
|
||||
atoms = await asyncio.gather(
|
||||
*[atomic.get_atom_by_source_url(u) for u in need],
|
||||
return_exceptions=True,
|
||||
)
|
||||
for url, atom in zip(need, atoms, strict=True):
|
||||
if isinstance(atom, dict):
|
||||
parent_atom_cache[url] = atom
|
||||
|
||||
for rank, url in enumerate(ordered_parents, start=len(results) + 1):
|
||||
atom = parent_atom_cache.get(url)
|
||||
if not atom:
|
||||
continue
|
||||
results.append(doc_to_search_result(atom, query=query_str, rank=rank))
|
||||
|
||||
total_ms = round((time.perf_counter() - t0) * 1000, 1)
|
||||
return SearchResponse(
|
||||
request_id=request_id,
|
||||
results=results,
|
||||
total_results=len(results),
|
||||
execution_time_ms=total_ms,
|
||||
queries_processed=len(req.queries),
|
||||
brain_meta=BrainMeta(
|
||||
cache_status="HIT" if results else "MISS",
|
||||
api_version="v1",
|
||||
implementation="didibrain",
|
||||
evidence_sources=len({r.url for r in results}),
|
||||
total_claim_atoms_matched=0,
|
||||
),
|
||||
)
|
||||
|
|
@ -0,0 +1,186 @@
|
|||
"""Topic volatility overrides — Phase D1.
|
||||
|
||||
Reads admin-configured volatility/TTL/recency per topic from didiFramework's
|
||||
sensitive_topic table (proxied via HTTP to keep brain free of a Redis
|
||||
dependency). The classifier consults this map AFTER its LLM call: if any of
|
||||
the LLM-derived topic_codes matches an admin-configured topic, the admin's
|
||||
values override the LLM estimates for that topic.
|
||||
|
||||
Resolution order for a claim's effective TTL:
|
||||
1. classifier returns volatility + estimated_validity_hours (LLM judgment)
|
||||
2. for each LLM-detected topic_code, fetch admin override from this module
|
||||
3. if admin override exists, use the more conservative of (LLM, admin) — i.e.
|
||||
pick the SHORTER TTL; admins can tighten brain's own estimate but never
|
||||
loosen it (a stable claim that touches an admin-tagged 'volatile' topic
|
||||
gets the volatile TTL)
|
||||
|
||||
Cache: in-process, 60s TTL. Failures (didiFramework down, HTTP timeout) leave
|
||||
the cache empty so callers fall through to LLM-only behavior — never blocks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
from shared.logging import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
DIDI_FRAMEWORK_URL = os.environ.get(
|
||||
"DIDI_FRAMEWORK_URL", "http://didi-framework:3005"
|
||||
).rstrip("/")
|
||||
CACHE_TTL_S = 60.0
|
||||
HTTP_TIMEOUT_S = 5.0
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class TopicConfig:
|
||||
"""Admin-configured policy for one topic.
|
||||
|
||||
Attributes:
|
||||
topic_code: Canonical code (matches classifier's topic_codes output).
|
||||
volatility: One of "volatile", "evolving", "stable".
|
||||
cache_ttl_hours: Hard cap on cache TTL for verdicts touching this topic.
|
||||
recency_window_days: For volatile/evolving topics, drop evidence older
|
||||
than this in /v1/gather.
|
||||
half_life_days: Recency-boost half-life used in combined ranking.
|
||||
"""
|
||||
|
||||
topic_code: str
|
||||
volatility: str
|
||||
cache_ttl_hours: int
|
||||
recency_window_days: int
|
||||
half_life_days: float
|
||||
|
||||
|
||||
_cache: dict[str, TopicConfig] | None = None
|
||||
_cache_loaded_at: float = 0.0
|
||||
_cache_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def _fetch_from_framework() -> dict[str, TopicConfig]:
|
||||
"""Pull active topics from didiFramework. Empty dict on any failure."""
|
||||
url = f"{DIDI_FRAMEWORK_URL}/api/sensitive-topics?active=true"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT_S) as client:
|
||||
resp = await client.get(url)
|
||||
if resp.status_code >= 400:
|
||||
log.debug(
|
||||
"topic_overrides_http_error",
|
||||
status=resp.status_code,
|
||||
)
|
||||
return {}
|
||||
payload = resp.json()
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.debug(
|
||||
"topic_overrides_fetch_failed",
|
||||
error=f"{type(e).__name__}:{e}",
|
||||
)
|
||||
return {}
|
||||
|
||||
if not isinstance(payload, dict) or not payload.get("success"):
|
||||
return {}
|
||||
rows = payload.get("data") or []
|
||||
if not isinstance(rows, list):
|
||||
return {}
|
||||
|
||||
out: dict[str, TopicConfig] = {}
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
code = row.get("topic_code")
|
||||
vol = row.get("volatility")
|
||||
if not isinstance(code, str) or vol not in (
|
||||
"volatile",
|
||||
"evolving",
|
||||
"stable",
|
||||
):
|
||||
continue
|
||||
try:
|
||||
out[code] = TopicConfig(
|
||||
topic_code=code,
|
||||
volatility=vol,
|
||||
cache_ttl_hours=int(row.get("cache_ttl_hours") or 720),
|
||||
recency_window_days=int(row.get("recency_window_days") or 30),
|
||||
half_life_days=float(row.get("half_life_days") or 30.0),
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
async def get_topic_overrides() -> dict[str, TopicConfig]:
|
||||
"""Return current admin-configured topic policies (cached 60s).
|
||||
|
||||
Always returns a dict — empty if didiFramework is unreachable or
|
||||
sensitive_topic doesn't have the volatility columns yet (migration 012
|
||||
not run). Callers can iterate over it freely.
|
||||
"""
|
||||
global _cache, _cache_loaded_at
|
||||
|
||||
now = time.time()
|
||||
if _cache is not None and (now - _cache_loaded_at) < CACHE_TTL_S:
|
||||
return _cache
|
||||
|
||||
async with _cache_lock:
|
||||
# Double-check inside the lock.
|
||||
if _cache is not None and (time.time() - _cache_loaded_at) < CACHE_TTL_S:
|
||||
return _cache
|
||||
|
||||
fresh = await _fetch_from_framework()
|
||||
_cache = fresh
|
||||
_cache_loaded_at = time.time()
|
||||
return fresh
|
||||
|
||||
|
||||
def invalidate_cache() -> None:
|
||||
"""Force a refetch on next ``get_topic_overrides()`` call.
|
||||
|
||||
Called by admin endpoints after a topic is mutated in didiFramework so
|
||||
the change propagates without waiting for the 60s cache window.
|
||||
"""
|
||||
global _cache
|
||||
_cache = None
|
||||
|
||||
|
||||
def reconcile_with_classifier(
|
||||
*,
|
||||
classifier_volatility: str,
|
||||
classifier_validity_hours: int,
|
||||
classifier_topics: list[str],
|
||||
overrides: dict[str, TopicConfig],
|
||||
) -> tuple[str, int]:
|
||||
"""Combine LLM classifier output with admin overrides.
|
||||
|
||||
Picks the MORE conservative (shorter) TTL when admin override exists.
|
||||
Volatility ranking: volatile < evolving < stable (volatile = shorter
|
||||
"shelf life"). If admin says 'volatile' for any matching topic, the
|
||||
final volatility is 'volatile' regardless of what the classifier said.
|
||||
|
||||
Returns:
|
||||
(effective_volatility, effective_ttl_hours).
|
||||
"""
|
||||
if not overrides or not classifier_topics:
|
||||
return classifier_volatility, classifier_validity_hours
|
||||
|
||||
rank = {"volatile": 0, "evolving": 1, "stable": 2}
|
||||
eff_vol = classifier_volatility
|
||||
eff_ttl = classifier_validity_hours
|
||||
|
||||
for topic in classifier_topics:
|
||||
cfg = overrides.get(topic)
|
||||
if cfg is None:
|
||||
continue
|
||||
# Pick the more conservative volatility (lower rank wins).
|
||||
if rank.get(cfg.volatility, 1) < rank.get(eff_vol, 1):
|
||||
eff_vol = cfg.volatility
|
||||
# Pick the shorter TTL.
|
||||
if cfg.cache_ttl_hours < eff_ttl:
|
||||
eff_ttl = cfg.cache_ttl_hours
|
||||
|
||||
return eff_vol, eff_ttl
|
||||
|
|
@ -0,0 +1,566 @@
|
|||
"""Verification cache — store the LLM verification result per (claim, tier).
|
||||
|
||||
Contract agreed with didi-backend: backend runs its own LLM verification call
|
||||
(prompt + model are owned by backend side via Redis config), then POSTs the
|
||||
result here fire-and-forget. On the next /v1/gather for the same claim + tier,
|
||||
brain returns the cached payload verbatim in brain_meta.
|
||||
|
||||
Schema v2 change:
|
||||
- v1 keyed on (claim_hash, evidence_hash, tier) — unreachable because
|
||||
evidence URLs at gather read-time rarely match those at write-time
|
||||
(brain's live search ranks/filters differently than backend's original
|
||||
source list).
|
||||
- v2 keys on (claim_hash, tier). evidence_hash + evidence_urls are kept
|
||||
as metadata on the stored row; backend uses them at read-time to
|
||||
decide overlap with its current evidence set.
|
||||
|
||||
This module owns:
|
||||
- normalization + hashing (claim + urls; urls hash is metadata now)
|
||||
- Upsert writes with TTL
|
||||
- Staleness detection via prompt_hash and framework_version
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import unicodedata
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Literal
|
||||
|
||||
from brain_api.db import db
|
||||
from brain_api.services.classifier import (
|
||||
ClaimVolatility,
|
||||
classify_claim_volatility,
|
||||
)
|
||||
from shared.config import settings
|
||||
from shared.llm_client import LlmClient
|
||||
from shared.logging import get_logger
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------- hashing
|
||||
|
||||
|
||||
def normalize_claim(s: str) -> str:
|
||||
"""Hash-input normalization agreed with backend.
|
||||
|
||||
Same variations collapse to one bucket:
|
||||
- "România a câștigat 9 medalii." (RO, punctuated)
|
||||
- "romania a castigat 9 medalii" (stripped diacritics)
|
||||
- "Romania a câștigat 9 medalii!" (extra whitespace, exclam)
|
||||
Different intents stay separate:
|
||||
- negation ("nu e sigur" vs "e sigur")
|
||||
- numbers ("9 medalii" vs "10 medalii")
|
||||
- middle punctuation ("X, ironic")
|
||||
"""
|
||||
s = unicodedata.normalize("NFKD", s)
|
||||
s = "".join(c for c in s if not unicodedata.combining(c))
|
||||
s = s.lower()
|
||||
s = " ".join(s.split())
|
||||
s = s.rstrip(".?!")
|
||||
return s
|
||||
|
||||
|
||||
def hash_claim(claim: str) -> str:
|
||||
return hashlib.sha256(normalize_claim(claim).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def hash_evidence_urls(urls: list[str]) -> str:
|
||||
"""Order-independent, case-insensitive canonical hash over a URL set.
|
||||
|
||||
Used purely as metadata now (not part of the cache key) so backend can
|
||||
detect corpus drift without needing to recompute locally.
|
||||
"""
|
||||
canonical = sorted({u.strip().lower().rstrip("/") for u in urls if u})
|
||||
return hashlib.sha256("\n".join(canonical).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- dataclass
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CacheEntry:
|
||||
claim_hash: str
|
||||
tier: str
|
||||
evidence_hash: str
|
||||
evidence_urls: list[str]
|
||||
model: str | None
|
||||
prompt_hash: str
|
||||
framework_version: str | None
|
||||
schema_name: str
|
||||
verification_raw: dict[str, Any] | None
|
||||
verification_processed: dict[str, Any]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
expires_at: datetime
|
||||
|
||||
# Phase B1+B2 metadata (may be missing for legacy rows written before
|
||||
# the volatility migration — defaults are conservative).
|
||||
volatility: str | None = None
|
||||
topic_codes: list[str] = field(default_factory=list)
|
||||
entity_bindings: list[dict[str, Any]] = field(default_factory=list)
|
||||
consecutive_audit_passes: int = 0
|
||||
last_audited_at: datetime | None = None
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------- IO
|
||||
|
||||
|
||||
async def _register_facts_async(
|
||||
classification: ClaimVolatility | None, *, source_label: str
|
||||
) -> None:
|
||||
"""Best-effort fact registration after a successful verification cache write.
|
||||
|
||||
Lazy import avoids a circular dependency (fact_status imports classifier).
|
||||
"""
|
||||
if classification is None or not classification.entity_bindings:
|
||||
return
|
||||
try:
|
||||
from brain_api.services.fact_status import register_facts_from_bindings
|
||||
|
||||
await register_facts_from_bindings(
|
||||
classification.entity_bindings,
|
||||
volatility=classification.volatility,
|
||||
topic_codes=classification.topic_codes,
|
||||
source_atom_id=source_label,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning(
|
||||
"vcache_fact_registration_failed",
|
||||
error=f"{type(e).__name__}:{e}",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_ttl_days(
|
||||
ttl_days: int | None, classification: ClaimVolatility | None
|
||||
) -> int:
|
||||
"""Pick the effective TTL in days, prefering classifier estimate.
|
||||
|
||||
When classification is fresh (not degraded), its hour estimate is
|
||||
converted to whole days (rounded up) so a volatile 6h claim yields ttl=1
|
||||
day, never 30 days. Otherwise we fall back to either the explicit
|
||||
``ttl_days`` argument or the global verification_cache_ttl_days setting.
|
||||
"""
|
||||
if classification is not None and not classification.degraded:
|
||||
# Round up to whole days, but never below 1 day.
|
||||
days_from_classifier = max(
|
||||
1, (classification.estimated_validity_hours + 23) // 24
|
||||
)
|
||||
return days_from_classifier
|
||||
if ttl_days is not None:
|
||||
return ttl_days
|
||||
return settings.verification_cache_ttl_days
|
||||
|
||||
|
||||
async def upsert(
|
||||
*,
|
||||
claim: str,
|
||||
evidence_urls: list[str],
|
||||
tier: Literal["free", "premium"],
|
||||
prompt_hash: str,
|
||||
verification_processed: dict[str, Any],
|
||||
verification_raw: dict[str, Any] | None = None,
|
||||
model: str | None = None,
|
||||
framework_version: str | None = None,
|
||||
schema_name: str = "didi-v1",
|
||||
ttl_days: int | None = None,
|
||||
classification: ClaimVolatility | None = None,
|
||||
llm: LlmClient | None = None,
|
||||
) -> CacheEntry:
|
||||
"""Last-wins upsert on (claim_hash, tier), with volatility classification.
|
||||
|
||||
Pipeline:
|
||||
1. If no ``classification`` provided and an ``llm`` client is, run the
|
||||
volatility classifier on the claim text. This drives TTL and adds
|
||||
topic_codes / entity_bindings metadata for invalidation by topic
|
||||
and for fact_status registration.
|
||||
2. UPSERT row, including the new metadata columns.
|
||||
3. Fire-and-forget fact registration after successful write.
|
||||
|
||||
Multiple verification runs for the same claim+tier (different evidence
|
||||
sets, rerun on model fallback) all write into the same row; the latest
|
||||
successful verification wins.
|
||||
|
||||
Args:
|
||||
claim: Original user claim text. Hashed for the cache key and also
|
||||
passed to the classifier.
|
||||
evidence_urls: URLs the LLM verification ran over (metadata only).
|
||||
tier: free | premium.
|
||||
prompt_hash: sha256[:12] of the verification prompt template.
|
||||
verification_processed: Canonical mapped verdict (status, confidence,
|
||||
etc.) — what callers serve from cache.
|
||||
verification_raw: Raw LLM response (used by stale_framework recompute).
|
||||
model: LLM model identifier.
|
||||
framework_version: sha256[:12] of relevant framework configs.
|
||||
schema_name: Versioned schema label, default "didi-v1".
|
||||
ttl_days: Caller-provided TTL override; ignored if a classification
|
||||
with non-degraded estimate is available.
|
||||
classification: Pre-computed ClaimVolatility from caller.
|
||||
llm: LLM client for classifier. None disables classification.
|
||||
"""
|
||||
classification = await _maybe_classify(
|
||||
classification=classification, llm=llm, claim=claim
|
||||
)
|
||||
|
||||
ttl = _resolve_ttl_days(ttl_days, classification)
|
||||
expires_at = datetime.now(tz=timezone.utc) + timedelta(days=ttl)
|
||||
|
||||
ch = hash_claim(claim)
|
||||
eh = hash_evidence_urls(evidence_urls)
|
||||
ev_urls_json = json.dumps(list(evidence_urls))
|
||||
|
||||
raw_json = (
|
||||
json.dumps(verification_raw) if verification_raw is not None else None
|
||||
)
|
||||
processed_json = json.dumps(verification_processed)
|
||||
|
||||
volatility = classification.volatility if classification else None
|
||||
topic_codes = classification.topic_codes if classification else []
|
||||
entity_bindings_json = (
|
||||
json.dumps(classification.entity_bindings_jsonb())
|
||||
if classification
|
||||
else "[]"
|
||||
)
|
||||
ttl_hours_used = ttl * 24
|
||||
|
||||
sql = """
|
||||
INSERT INTO brain_verification_cache (
|
||||
claim_hash, tier,
|
||||
evidence_hash, evidence_urls,
|
||||
model, prompt_hash, framework_version, schema_name,
|
||||
verification_raw, verification_processed,
|
||||
expires_at,
|
||||
volatility, topic_codes, entity_bindings, ttl_hours_used
|
||||
)
|
||||
VALUES (
|
||||
$1, $2, $3, $4::jsonb, $5, $6, $7, $8, $9::jsonb, $10::jsonb, $11,
|
||||
$12, $13, $14::jsonb, $15
|
||||
)
|
||||
ON CONFLICT (claim_hash, tier) DO UPDATE SET
|
||||
evidence_hash = EXCLUDED.evidence_hash,
|
||||
evidence_urls = EXCLUDED.evidence_urls,
|
||||
model = EXCLUDED.model,
|
||||
prompt_hash = EXCLUDED.prompt_hash,
|
||||
framework_version = EXCLUDED.framework_version,
|
||||
schema_name = EXCLUDED.schema_name,
|
||||
verification_raw = EXCLUDED.verification_raw,
|
||||
verification_processed = EXCLUDED.verification_processed,
|
||||
updated_at = now(),
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
volatility = COALESCE(EXCLUDED.volatility, brain_verification_cache.volatility),
|
||||
topic_codes = CASE
|
||||
WHEN array_length(EXCLUDED.topic_codes, 1) > 0
|
||||
THEN EXCLUDED.topic_codes
|
||||
ELSE brain_verification_cache.topic_codes
|
||||
END,
|
||||
entity_bindings = CASE
|
||||
WHEN jsonb_array_length(EXCLUDED.entity_bindings) > 0
|
||||
THEN EXCLUDED.entity_bindings
|
||||
ELSE brain_verification_cache.entity_bindings
|
||||
END,
|
||||
ttl_hours_used = EXCLUDED.ttl_hours_used
|
||||
RETURNING
|
||||
claim_hash, tier,
|
||||
evidence_hash, evidence_urls,
|
||||
model, prompt_hash, framework_version, schema_name,
|
||||
verification_raw, verification_processed,
|
||||
created_at, updated_at, expires_at
|
||||
"""
|
||||
async with db.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
sql,
|
||||
ch,
|
||||
tier,
|
||||
eh,
|
||||
ev_urls_json,
|
||||
model,
|
||||
prompt_hash,
|
||||
framework_version,
|
||||
schema_name,
|
||||
raw_json,
|
||||
processed_json,
|
||||
expires_at,
|
||||
volatility,
|
||||
topic_codes,
|
||||
entity_bindings_json,
|
||||
ttl_hours_used,
|
||||
)
|
||||
assert row is not None # UPSERT with RETURNING always yields a row
|
||||
entry = _row_to_entry(row)
|
||||
|
||||
# Best-effort fact registration after the write succeeds.
|
||||
if classification and classification.entity_bindings:
|
||||
asyncio.create_task(
|
||||
_register_facts_async(
|
||||
classification, source_label=f"vcache:{ch[:12]}"
|
||||
)
|
||||
)
|
||||
|
||||
log.info(
|
||||
"vcache_upsert_ok",
|
||||
claim_hash=ch[:12],
|
||||
tier=tier,
|
||||
volatility=volatility,
|
||||
ttl_days=ttl,
|
||||
topic_codes=topic_codes,
|
||||
binding_count=len(classification.entity_bindings) if classification else 0,
|
||||
)
|
||||
return entry
|
||||
|
||||
|
||||
async def _maybe_classify(
|
||||
*,
|
||||
classification: ClaimVolatility | None,
|
||||
llm: LlmClient | None,
|
||||
claim: str,
|
||||
) -> ClaimVolatility | None:
|
||||
"""Use caller's classification or compute one. Never raises."""
|
||||
if classification is not None:
|
||||
return classification
|
||||
if llm is None or not claim.strip():
|
||||
return None
|
||||
try:
|
||||
return await classify_claim_volatility(llm, claim=claim)
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.warning(
|
||||
"vcache_classifier_failed",
|
||||
error=f"{type(e).__name__}:{e}",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Phase B2: confidence decay + judge integration for verification_cache
|
||||
# ============================================================================
|
||||
|
||||
# Same decay model as analysis_atom — keeps the two caches behaviorally
|
||||
# consistent so callers don't have to special-case.
|
||||
DECAY_HALF_LIVES_HOURS: dict[str, float] = {
|
||||
"volatile": 24.0,
|
||||
"evolving": 168.0,
|
||||
"stable": float("inf"),
|
||||
}
|
||||
|
||||
AUDIT_HISTORY_MAX = 50
|
||||
AUDIT_PASS_MIN_INTERVAL_HOURS = 6.0
|
||||
|
||||
|
||||
def compute_effective_confidence(
|
||||
*,
|
||||
base_confidence: float | None,
|
||||
volatility: str | None,
|
||||
age_hours: float,
|
||||
consecutive_audit_passes: int = 0,
|
||||
) -> float | None:
|
||||
"""Decay base confidence by age, modulated by volatility and audit history.
|
||||
|
||||
Mirrors analysis_atom.compute_effective_confidence so the two cache
|
||||
paths share the same model and admin tooling can reuse formulas.
|
||||
"""
|
||||
if base_confidence is None:
|
||||
return None
|
||||
half = DECAY_HALF_LIVES_HOURS.get(volatility or "evolving", 168.0)
|
||||
if half == float("inf") or age_hours <= 0:
|
||||
decay = 1.0
|
||||
else:
|
||||
decay = 0.5 ** (age_hours / half)
|
||||
audit_boost = min(0.3, 0.03 * max(0, consecutive_audit_passes))
|
||||
return float(base_confidence) * decay * (1.0 + audit_boost)
|
||||
|
||||
|
||||
async def apply_judge_verdict(
|
||||
*,
|
||||
claim_hash: str,
|
||||
tier: Literal["free", "premium"],
|
||||
verdict: object, # JudgeVerdict — typed loosely to avoid circular import
|
||||
) -> None:
|
||||
"""Persist a JudgeVerdict to the verification_cache row.
|
||||
|
||||
Updates audit_history (capped), last_audited_at, consecutive_audit_passes
|
||||
(rate-limited), and expires_at on INVALIDATE. Also writes a brain_audit_log
|
||||
entry for telemetry.
|
||||
"""
|
||||
if not db.pool:
|
||||
raise RuntimeError("brain_db not connected")
|
||||
from brain_api.services.cache_judge import JudgeVerdict
|
||||
|
||||
if not isinstance(verdict, JudgeVerdict):
|
||||
raise TypeError(
|
||||
f"apply_judge_verdict: expected JudgeVerdict, got {type(verdict).__name__}"
|
||||
)
|
||||
audit_entry = verdict.to_audit_entry()
|
||||
audit_json = json.dumps(audit_entry)
|
||||
audit_array_json = json.dumps([audit_entry])
|
||||
|
||||
sql = """
|
||||
UPDATE brain_verification_cache
|
||||
SET
|
||||
audit_history = (
|
||||
SELECT jsonb_agg(elem)
|
||||
FROM (
|
||||
SELECT elem
|
||||
FROM jsonb_array_elements(
|
||||
COALESCE(audit_history, '[]'::jsonb) || $3::jsonb
|
||||
) WITH ORDINALITY AS t(elem, ord)
|
||||
ORDER BY ord DESC
|
||||
LIMIT $4
|
||||
) recent
|
||||
),
|
||||
last_audited_at = now(),
|
||||
consecutive_audit_passes = CASE
|
||||
WHEN $5 = 'KEEP_CACHE' AND (
|
||||
last_audited_at IS NULL
|
||||
OR last_audited_at < now() - ($6 || ' hours')::interval
|
||||
)
|
||||
THEN consecutive_audit_passes + 1
|
||||
WHEN $5 = 'INVALIDATE' THEN 0
|
||||
ELSE consecutive_audit_passes
|
||||
END,
|
||||
expires_at = CASE
|
||||
WHEN $5 = 'INVALIDATE' THEN now()
|
||||
ELSE expires_at
|
||||
END,
|
||||
updated_at = now()
|
||||
WHERE claim_hash = $1 AND tier = $2
|
||||
"""
|
||||
|
||||
async with db.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
sql,
|
||||
claim_hash,
|
||||
tier,
|
||||
audit_array_json,
|
||||
AUDIT_HISTORY_MAX,
|
||||
verdict.decision,
|
||||
str(int(AUDIT_PASS_MIN_INTERVAL_HOURS)),
|
||||
)
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO brain_audit_log (action, target_table, target_id, actor, payload)
|
||||
VALUES ($1, 'brain_verification_cache', $2, 'cache_judge', $3::jsonb)
|
||||
""",
|
||||
f"judge_{verdict.decision.lower()}",
|
||||
f"{claim_hash[:12]}/{tier}",
|
||||
audit_json,
|
||||
)
|
||||
|
||||
|
||||
async def lookup(
|
||||
*,
|
||||
claim: str,
|
||||
tier: Literal["free", "premium"],
|
||||
) -> CacheEntry | None:
|
||||
"""Fetch the cached entry for (claim, tier) — evidence is metadata only."""
|
||||
ch = hash_claim(claim)
|
||||
sql = """
|
||||
SELECT
|
||||
claim_hash, tier,
|
||||
evidence_hash, evidence_urls,
|
||||
model, prompt_hash, framework_version, schema_name,
|
||||
verification_raw, verification_processed,
|
||||
created_at, updated_at, expires_at,
|
||||
volatility, topic_codes, entity_bindings,
|
||||
consecutive_audit_passes, last_audited_at
|
||||
FROM brain_verification_cache
|
||||
WHERE claim_hash = $1 AND tier = $2
|
||||
AND expires_at > now()
|
||||
"""
|
||||
async with db.pool.acquire() as conn:
|
||||
row = await conn.fetchrow(sql, ch, tier)
|
||||
if not row:
|
||||
return None
|
||||
return _row_to_entry(row)
|
||||
|
||||
|
||||
# ------------------------------------------------------------- staleness decider
|
||||
|
||||
|
||||
StalenessStatus = Literal[
|
||||
"fresh",
|
||||
"stale_framework",
|
||||
"stale_prompt",
|
||||
"stale_evidence", # bound facts have flipped — caller must recompute
|
||||
"miss",
|
||||
]
|
||||
|
||||
|
||||
def decide_freshness(
|
||||
entry: CacheEntry | None,
|
||||
current_prompt_hash: str | None,
|
||||
current_framework_version: str | None,
|
||||
) -> StalenessStatus:
|
||||
"""Given a cached entry + the caller's current prompt/framework, decide.
|
||||
|
||||
- miss: no entry at all (or expired in DB)
|
||||
- stale_prompt: prompt changed since cache was written (verification_raw
|
||||
stances may differ semantically) — caller should NOT use cache
|
||||
- stale_framework: prompt unchanged but threshold config changed — caller
|
||||
CAN use verification_raw and recompute status locally
|
||||
- fresh: everything matches; return verification_processed directly
|
||||
"""
|
||||
if entry is None:
|
||||
return "miss"
|
||||
if current_prompt_hash and entry.prompt_hash != current_prompt_hash:
|
||||
return "stale_prompt"
|
||||
if (
|
||||
current_framework_version
|
||||
and entry.framework_version
|
||||
and entry.framework_version != current_framework_version
|
||||
):
|
||||
return "stale_framework"
|
||||
return "fresh"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- internal
|
||||
|
||||
|
||||
def _row_to_entry(row) -> CacheEntry:
|
||||
raw = row["verification_raw"]
|
||||
processed = row["verification_processed"]
|
||||
ev_urls = row["evidence_urls"]
|
||||
# asyncpg decodes jsonb as str; json.loads needed
|
||||
if isinstance(raw, str):
|
||||
raw = json.loads(raw)
|
||||
if isinstance(processed, str):
|
||||
processed = json.loads(processed)
|
||||
if isinstance(ev_urls, str):
|
||||
ev_urls = json.loads(ev_urls)
|
||||
|
||||
# Optional B1 metadata — these may not be present on legacy rows or in
|
||||
# callers that select an older column set.
|
||||
def _opt(key: str, default: Any = None) -> Any:
|
||||
try:
|
||||
return row[key]
|
||||
except (KeyError, IndexError):
|
||||
return default
|
||||
|
||||
bindings = _opt("entity_bindings", [])
|
||||
if isinstance(bindings, str):
|
||||
bindings = json.loads(bindings)
|
||||
topic_codes = _opt("topic_codes", []) or []
|
||||
|
||||
return CacheEntry(
|
||||
claim_hash=row["claim_hash"],
|
||||
tier=row["tier"],
|
||||
evidence_hash=row["evidence_hash"],
|
||||
evidence_urls=list(ev_urls) if ev_urls else [],
|
||||
model=row["model"],
|
||||
prompt_hash=row["prompt_hash"],
|
||||
framework_version=row["framework_version"],
|
||||
schema_name=row["schema_name"],
|
||||
verification_raw=raw,
|
||||
verification_processed=processed,
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
expires_at=row["expires_at"],
|
||||
volatility=_opt("volatility"),
|
||||
topic_codes=list(topic_codes) if topic_codes else [],
|
||||
entity_bindings=list(bindings) if bindings else [],
|
||||
consecutive_audit_passes=int(_opt("consecutive_audit_passes", 0) or 0),
|
||||
last_audited_at=_opt("last_audited_at"),
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue