Livrare LOT 1 - Didi

This commit is contained in:
Dezvoltari Evotech 2026-06-25 14:13:25 -07:00
commit 5380c3fc63
990 changed files with 133308 additions and 0 deletions

View file

@ -0,0 +1 @@
"""Shared infrastructure: config, LLM client, embedding client, Atomic client."""

View file

@ -0,0 +1,81 @@
{
"ClaimStatus": "a4bdf09e-52f2-4b85-8a56-e22c3b05f835",
"ClaimStatus/Confirmed": "fd25a0b8-bfb1-4cc5-a5c1-225070f49d5c",
"ClaimStatus/Debunked": "50b9e623-144c-4093-8a3c-0ded59d533e8",
"ClaimStatus/Disputed": "3f0e90a5-f64a-41a6-b430-e08bfb326d90",
"ClaimStatus/PartiallyTrue": "554a7f3f-63f3-4de5-bb26-83d9e5eb8495",
"ClaimStatus/Unverified": "fb5ecbf5-7237-4ed7-b694-2a871c1c3038",
"Country": "9b22cf38-0175-4cc4-952c-318ae735ecec",
"Country/France": "b7ae6946-b2ae-474d-906e-f25c2b5f08ce",
"Country/Germany": "986f37e0-be60-4e1c-b966-0b2273358f57",
"Country/Global": "ef8938f9-8866-4ebf-8412-1603b4220c5e",
"Country/Italy": "fb724b1f-7b66-4567-912a-bf7b17013921",
"Country/Moldova": "35e44ad8-31a2-43be-8fdc-149a1d48fc1c",
"Country/Poland": "57d3023b-165c-4d3c-bdc6-8203d938ab94",
"Country/Romania": "6e5657a5-47d8-4602-82ee-b9ef27e05b6f",
"Country/Russia": "b045c4cd-b346-4dba-815f-8bdb86ac5aff",
"Country/Spain": "9037debf-6108-4634-9955-6e3ab7803c24",
"Country/UK": "52200de8-841a-46f1-be12-39ee7d5df275",
"Country/USA": "a86f71dd-ab0c-4b26-8f0c-e6370f41ab14",
"Country/Ukraine": "f4d6b5ef-3114-43ad-9349-a2215765babf",
"Credibility": "e1d6214c-4e8d-4f55-81ff-c51130638108",
"Credibility/KnownDisinfo": "bbcbbacd-cbd8-4da8-b08e-1fa420bffe8a",
"Credibility/StateAffiliated": "75bdaecb-61e3-4f52-b459-fd9d35584a88",
"Credibility/Tier1": "a6fd68db-7d24-4155-b038-a354d7aa90a7",
"Credibility/Tier2": "ca4b2bad-7f9b-44f4-86e1-9fa6cc460807",
"Credibility/Tier3": "b180c0ed-0488-4098-8b36-5bc9eddb095e",
"Credibility/Unknown": "e3122bf0-de42-473e-acc4-06b29cfd58b0",
"Events": "acf1d4c9-6e7b-4dc0-8d4e-f0db324e038d",
"Language": "d7a7b7fa-d7e1-465d-95f4-fe242afa1da4",
"Language/DE": "5fbcb199-e5b2-409f-86a4-c267a64609e8",
"Language/EN": "1f901309-ed11-4235-aa7b-39953cbe74da",
"Language/ES": "209c6c0d-efac-481b-bb3f-69345c1f0f8d",
"Language/FR": "6e0ac309-9b3d-4ed0-b4f8-d26f89c7a940",
"Language/IT": "b9ddebbf-db73-4d08-a82c-90c726d2b61b",
"Language/PL": "8375f071-fa08-4b09-a135-74c288a8d293",
"Language/RO": "5b8e7fa9-42ae-40f0-ab02-8959e3703700",
"Language/RU": "69d1b480-2bac-447c-9cd0-845a6dc04f24",
"Language/UA": "8b976668-cd2f-4374-b251-dcf35bb40e92",
"Locations": "b6383171-7b06-4761-ae9d-95e2a6c4de31",
"Organizations": "1b0eca4b-1e21-425c-9813-07337ae4af83",
"People": "f8864b48-8ecc-482a-846f-6f2adfa67b82",
"SourceType": "de854162-f5e3-4430-8af1-77727344617b",
"SourceType/Blog": "bfdc7f83-e900-4972-b728-06a53fb4c40d",
"SourceType/FactCheck": "2a2c40cf-d4db-4a26-a347-2377ad11acaf",
"SourceType/Forum": "052fa147-ba51-4fde-89da-be9a88170a2d",
"SourceType/Government": "03dbd479-939d-4e8a-8d3f-8e9a54e9eb4b",
"SourceType/MainstreamMedia": "783ca275-165d-4669-ad09-2ee98d0f85ce",
"SourceType/ScientificJournal": "0b5d65a0-fcc5-46f6-8e29-9fb5564e728b",
"SourceType/SocialMedia": "46e6b7a6-d8fd-48f9-a5c2-db0fe6f149ec",
"SourceType/StateMedia": "3b95b904-c240-4841-9c59-5abe699f983d",
"SourceType/TabloidMedia": "1eff4e9c-e4f5-4439-b10c-d0feb189d25e",
"SourceType/Wikipedia": "ea109ab3-b709-4199-9833-47283b862ba0",
"Stance": "615ddf04-28eb-42a4-9ea1-8ce0c1c5f346",
"Stance/Asserts": "45f71063-e0dc-48ab-b80b-5b26cf68cc3d",
"Stance/Neutral": "e8d52343-0062-472c-ab4e-c5b473f267db",
"Stance/Questions": "d6eb0f8e-e633-41b1-ba4e-483451fe9e75",
"Stance/Refutes": "590afc4c-beff-4973-b8c3-c2f93fe5be13",
"Stance/Reports": "e09f46d5-2d84-4934-916a-9bdb9e1fbb1d",
"Topics": "8efc2a64-3ccb-4e7e-a100-a6d83de031f0",
"Topics/Climate": "047490e0-c848-4bc9-bedb-2a4e7c5d2f3d",
"Topics/Economy": "7bb131b0-99b8-4c3a-bf72-25c781f0b4f4",
"Topics/Health": "3c4151fa-8fcc-48d3-a367-277c204f23e2",
"Topics/Health/COVID": "a14e59fa-a7c3-4cec-b35a-67e1455335bf",
"Topics/Health/Disease": "4187f778-a1cf-4baa-9686-ce48e556f841",
"Topics/Health/Medicine": "fd011a07-e373-4a85-ab2a-ec6da89945b7",
"Topics/Health/PublicHealth": "773c7f9d-b9e8-478f-821e-6daf59e53e9a",
"Topics/Health/Vaccines": "3e0c54b9-1554-4b2e-8030-15417c47c04d",
"Topics/Politics": "f714e9ce-78d6-4739-be5f-b273a7ab7445",
"Topics/Politics/Diplomacy": "0d946f6e-0b9a-4dc2-8244-cdb7fbf741e6",
"Topics/Politics/Elections": "e3d4764b-49cb-47bb-ae5a-24a75f6ea845",
"Topics/Politics/Government": "da76c072-682f-4e1e-b481-2f805bdb8d8a",
"Topics/Politics/War": "f3dd2d86-054d-4cc2-9757-0337af2e90c5",
"Topics/Society": "af362549-35e0-4a3e-9b53-485f46376a52",
"Topics/Technology": "5d2b4444-2b4f-4494-a673-0904bbf801f2",
"Type": "656dac2b-c77c-4e21-b779-78a029b797f7",
"Type/Annotation": "af20bc10-8142-44ad-a826-b9214755fe8d",
"Type/Claim": "a56f9dce-0ba5-4504-9ff7-38ee005da084",
"Type/Document": "c80cbebd-4fae-4395-8992-b510335872ab",
"Type/Quote": "f4fdae65-293e-4d9a-8336-9b8838894f4d",
"Type/Summary": "6899e2ed-62f7-4336-9c60-2d1a8d1e0418"
}

View file

@ -0,0 +1,307 @@
"""Typed async client for the Atomic REST API.
Atomic is the brain this client is how every other DidiBrain component
(bootstrap, scraper, extractor, lint, didi_client) talks to it.
Design notes:
- One AtomicClient instance per long-running process. Reusable httpx.AsyncClient.
- Auth: Bearer token loaded from `settings.atomic_token`. If empty, public
endpoints (/health, setup) still work.
- Errors: any non-2xx is wrapped in AtomicApiError with the response body.
- Pagination: helpers like `iter_atoms()` (added later) yield pages.
Only the endpoints we actually need are wrapped here. Add more as required;
do not pre-emptively wrap the full ~78-route surface.
"""
from __future__ import annotations
from collections.abc import AsyncIterator, Sequence
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Any
import httpx
from tenacity import (
AsyncRetrying,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
from shared.config import settings
from shared.logging import get_logger
log = get_logger(__name__)
class AtomicApiError(Exception):
"""Raised on any non-2xx response from atomic-server."""
def __init__(self, message: str, *, status: int, body: str = "", url: str = ""):
super().__init__(message)
self.status = status
self.body = body
self.url = url
@dataclass(slots=True)
class AtomSummary:
"""Minimal atom view returned by list/search endpoints."""
id: str
content: str
source_url: str | None
embedding_status: str
tagging_status: str
created_at: str
updated_at: str
tags: list[dict[str, Any]]
@classmethod
def from_dict(cls, d: dict[str, Any]) -> AtomSummary:
return cls(
id=d["id"],
content=d.get("content", ""),
source_url=d.get("source_url"),
embedding_status=d.get("embedding_status", "unknown"),
tagging_status=d.get("tagging_status", "unknown"),
created_at=d.get("created_at", ""),
updated_at=d.get("updated_at", ""),
tags=d.get("tags", []),
)
@dataclass(slots=True)
class SearchHit:
"""One result from POST /api/search."""
atom_id: str
similarity: float
matching_chunk_content: str | None
snippet: str | None
source_url: str | None
tags: list[dict[str, Any]]
@classmethod
def from_dict(cls, d: dict[str, Any]) -> SearchHit:
return cls(
atom_id=d.get("id") or d.get("atom_id", ""),
similarity=float(d.get("similarity_score") or d.get("similarity", 0.0)),
matching_chunk_content=d.get("matching_chunk_content"),
snippet=d.get("snippet"),
source_url=d.get("source_url"),
tags=d.get("tags", []),
)
class AtomicClient:
"""Async REST client for atomic-server. Use as `async with AtomicClient() as a:`."""
def __init__(
self,
*,
base_url: str | None = None,
token: str | None = None,
timeout: float = 60.0,
max_attempts: int = 3,
):
self._base_url = (base_url or settings.atomic_url).rstrip("/")
self._token = token if token is not None else settings.atomic_token
self._max_attempts = max_attempts
headers = {"Content-Type": "application/json"}
if self._token:
headers["Authorization"] = f"Bearer {self._token}"
self._http = httpx.AsyncClient(
base_url=self._base_url,
headers=headers,
timeout=httpx.Timeout(timeout, connect=10.0),
)
async def aclose(self) -> None:
await self._http.aclose()
async def __aenter__(self) -> AtomicClient:
return self
async def __aexit__(self, *args: Any) -> None:
await self.aclose()
@property
def base_url(self) -> str:
return self._base_url
@property
def has_token(self) -> bool:
return bool(self._token)
# ---------------------------------------------------------------- internals
async def _request(
self,
method: str,
path: str,
*,
json: Any = None,
params: dict[str, Any] | None = None,
retry_on_5xx: bool = True,
) -> Any:
async for attempt in AsyncRetrying(
stop=stop_after_attempt(self._max_attempts),
wait=wait_exponential(multiplier=1, min=1, max=10),
retry=retry_if_exception_type((httpx.HTTPError, httpx.TimeoutException)),
reraise=True,
):
with attempt:
resp = await self._http.request(method, path, json=json, params=params)
if resp.status_code >= 500 and retry_on_5xx:
raise httpx.HTTPError(f"5xx from {path}: {resp.status_code}")
if resp.status_code >= 400:
raise AtomicApiError(
f"HTTP {resp.status_code} {method} {path}",
status=resp.status_code,
body=resp.text[:1000],
url=str(resp.url),
)
if resp.status_code == 204 or not resp.content:
return None
try:
return resp.json()
except ValueError:
return resp.text
raise AtomicApiError("retry loop exited without result", status=0) # unreachable
# ----------------------------------------------------------------- health
async def health(self) -> dict[str, Any]:
return await self._request("GET", "/health")
async def setup_status(self) -> dict[str, Any]:
"""Whether the instance still needs initial token claim."""
return await self._request("GET", "/api/setup/status")
# ----------------------------------------------------------------- settings
async def get_settings(self) -> dict[str, Any]:
return await self._request("GET", "/api/settings")
async def set_setting(self, key: str, value: str) -> Any:
"""Set a single setting. Use set_settings() for bulk."""
return await self._request(
"PUT", f"/api/settings/{key}", json={"value": value}
)
async def set_settings(self, items: dict[str, str]) -> dict[str, Any]:
"""Apply many settings sequentially. Returns map of key → response."""
out: dict[str, Any] = {}
for k, v in items.items():
out[k] = await self.set_setting(k, v)
return out
# -------------------------------------------------------------------- tags
async def list_tags(self, *, min_count: int = 0) -> list[dict[str, Any]]:
return await self._request("GET", "/api/tags", params={"min_count": min_count})
async def create_tag(
self, name: str, *, parent_id: str | None = None
) -> dict[str, Any]:
body: dict[str, Any] = {"name": name}
if parent_id:
body["parent_id"] = parent_id
return await self._request("POST", "/api/tags", json=body)
# ------------------------------------------------------------------- atoms
async def create_atom(
self,
*,
content: str,
source_url: str | None = None,
tag_ids: Sequence[str] | None = None,
published_at: str | None = None,
) -> dict[str, Any]:
body: dict[str, Any] = {"content": content, "tag_ids": list(tag_ids or [])}
if source_url:
body["source_url"] = source_url
if published_at:
body["published_at"] = published_at
return await self._request("POST", "/api/atoms", json=body)
async def get_atom(self, atom_id: str) -> dict[str, Any]:
return await self._request("GET", f"/api/atoms/{atom_id}")
async def delete_atom(self, atom_id: str) -> None:
await self._request("DELETE", f"/api/atoms/{atom_id}")
async def get_atom_by_source_url(self, source_url: str) -> dict[str, Any] | None:
try:
# Atomic's GetAtomBySourceUrlQuery uses `url` not `source_url`.
return await self._request(
"GET", "/api/atoms/by-source-url", params={"url": source_url}
)
except AtomicApiError as e:
if e.status == 404:
return None
raise
async def get_embedding_status(self, atom_id: str) -> dict[str, Any]:
return await self._request("GET", f"/api/atoms/{atom_id}/embedding-status")
async def list_atoms(
self,
*,
limit: int = 50,
offset: int = 0,
tag_id: str | None = None,
) -> dict[str, Any]:
params: dict[str, Any] = {"limit": limit, "offset": offset}
if tag_id:
params["tag_id"] = tag_id
return await self._request("GET", "/api/atoms", params=params)
# ------------------------------------------------------------------ search
async def search(
self,
query: str,
*,
mode: str = "semantic",
limit: int = 20,
threshold: float | None = None,
) -> list[SearchHit]:
body: dict[str, Any] = {"query": query, "mode": mode, "limit": limit}
if threshold is not None:
body["threshold"] = threshold
result = await self._request("POST", "/api/search", json=body)
# Atomic returns either a list directly or {"results": [...]}
if isinstance(result, dict):
items = result.get("results") or result.get("data") or []
else:
items = result or []
return [SearchHit.from_dict(item) for item in items]
async def find_similar(
self, atom_id: str, *, threshold: float = 0.5, limit: int = 20
) -> list[SearchHit]:
params = {"threshold": threshold, "limit": limit}
result = await self._request(
"GET", f"/api/atoms/{atom_id}/similar", params=params
)
items = result if isinstance(result, list) else result.get("results", [])
return [SearchHit.from_dict(item) for item in items]
# ------------------------------------------------------------- embeddings
async def get_pipeline_status(self) -> dict[str, Any]:
return await self._request("GET", "/api/embeddings/status")
async def process_pending_embeddings(self) -> dict[str, Any]:
return await self._request("POST", "/api/embeddings/process-pending")
@asynccontextmanager
async def atomic_client(
*, token: str | None = None
) -> AsyncIterator[AtomicClient]:
"""`async with atomic_client() as a:` for short-lived scripts."""
client = AtomicClient(token=token)
try:
yield client
finally:
await client.aclose()

View file

@ -0,0 +1,162 @@
"""Centralized configuration via Pydantic Settings.
All env vars are loaded from .env once at import time and validated.
Import the singleton `settings` everywhere never read os.environ directly.
"""
from __future__ import annotations
from enum import Enum
from functools import lru_cache
from pathlib import Path
from pydantic import Field, HttpUrl, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class LlmRole(str, Enum):
"""Logical role a caller asks for. Routing decides which model serves it."""
REASONING = "reasoning" # critical: extraction, NLI, verdict, wiki
FAST = "fast" # mass processing (currently disabled)
VISION = "vision" # multimodal (currently disabled)
class Settings(BaseSettings):
"""Top-level config. Validated at startup, immutable thereafter."""
model_config = SettingsConfigDict(
env_file=Path(__file__).parent.parent / ".env",
env_file_encoding="utf-8",
extra="ignore",
)
# ---- LLM router ---------------------------------------------------------
llm_router_url: str = Field(default="http://localhost:14011")
llm_router_api_key: str = Field(default="")
llm_vllm_url: str = Field(default="http://localhost:14001")
llm_llamacpp_urls: str = Field(default="") # comma-separated
# ---- Models -------------------------------------------------------------
model_reasoning: str = Field(default="Qwen3.5-397B-A17B")
model_reasoning_backend: str = Field(default="llamacpp")
model_fast: str = Field(default="qwen3.5")
model_fast_backend: str = Field(default="vllm")
model_fast_enabled: bool = Field(default=False)
model_vision: str = Field(default="gemma-3-27b-it")
model_vision_url: str = Field(default="")
model_vision_enabled: bool = Field(default=False)
# ---- Embeddings ---------------------------------------------------------
embedding_url: str = Field(default="http://10.11.10.15:8200")
embedding_api_key: str = Field(default="")
embedding_model: str = Field(default="BAAI/bge-m3")
embedding_dim: int = Field(default=1024)
embedding_max_tokens: int = Field(default=8192)
# ---- Reranker -----------------------------------------------------------
reranker_url: str = Field(default="http://10.11.10.15:8100")
reranker_api_key: str = Field(default="")
reranker_model: str = Field(default="BAAI/bge-reranker-v2-m3")
# ---- Atomic -------------------------------------------------------------
atomic_url: str = Field(default="http://localhost:8080")
atomic_token: str = Field(default="")
# ---- Postgres -----------------------------------------------------------
postgres_user: str = Field(default="atomic")
postgres_password: str = Field(default="atomic_dev_changeme")
postgres_db: str = Field(default="atomic")
postgres_port: int = Field(default=5434)
postgres_host: str = Field(
default="postgres",
description="Hostname for direct PG connection (Docker: 'postgres', host: 'localhost')",
)
postgres_internal_port: int = Field(
default=5432,
description="Port inside the Docker network (external is postgres_port)",
)
# ---- Verification cache --------------------------------------------------
verification_cache_ttl_days: int = Field(
default=30,
description="How long cached verification entries live before auto-expiry",
)
verification_cache_max_payload_kb: int = Field(
default=64,
description="Reject POST /v1/verification_cache with payloads above this cap",
)
# ---- Analysis atom tier policy ------------------------------------------
# These are read live from the AI platform dashboard via RuntimeConfigClient
# (keys: brain.atom.silver_ttl_days, brain.atom.bronze_ttl_days,
# brain.atom.confidence_silver_threshold). The values below are fallbacks
# used at startup until the first dashboard poll completes (~30s).
atom_silver_ttl_days: int = Field(
default=90,
description="TTL for LLM-cached analysis atoms (silver tier)",
)
atom_bronze_ttl_days: int = Field(
default=30,
description="TTL for low-confidence atoms (never served, kept for audit)",
)
atom_confidence_silver_threshold: float = Field(
default=60.0,
description="LLM confidence ≥ this stores atom as silver, else bronze",
)
# ---- Logging ------------------------------------------------------------
log_level: str = Field(default="INFO")
# ---- Runtime config (live polling from AI platform dashboard) -----------
dashboard_url: str | None = Field(
default=None,
description=(
"Optional dashboard base URL (e.g. http://didiAI-dashboard:51300). "
"When set, RuntimeConfigClient polls /api/config every 30s for live "
"overrides on atom_* and log_level."
),
)
# ---- Computed -----------------------------------------------------------
@property
def postgres_dsn(self) -> str:
"""Async-compatible DSN for direct asyncpg connections."""
return (
f"postgresql://{self.postgres_user}:{self.postgres_password}"
f"@{self.postgres_host}:{self.postgres_internal_port}/{self.postgres_db}"
)
@property
def llamacpp_urls_list(self) -> list[str]:
return [u.strip() for u in self.llm_llamacpp_urls.split(",") if u.strip()]
def model_for(self, role: LlmRole) -> tuple[str, str] | None:
"""Return (model_id, backend_hint) for a logical role, or None if disabled."""
if role == LlmRole.REASONING:
return (self.model_reasoning, self.model_reasoning_backend)
if role == LlmRole.FAST and self.model_fast_enabled:
return (self.model_fast, self.model_fast_backend)
if role == LlmRole.VISION and self.model_vision_enabled:
return (self.model_vision, "external")
return None
@field_validator("log_level")
@classmethod
def _validate_log_level(cls, v: str) -> str:
v = v.upper()
if v not in {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}:
raise ValueError(f"invalid log_level: {v}")
return v
@lru_cache(maxsize=1)
def get_settings() -> Settings:
"""Singleton accessor. Cached so .env is parsed only once per process."""
return Settings()
# Convenience: most code can `from shared.config import settings`
settings = get_settings()

View file

@ -0,0 +1,203 @@
"""Embedding + reranking client.
- Embeddings: BAAI/bge-m3 via vLLM OpenAI-compat endpoint, 1024-dim, 8K context.
- Reranker: BAAI/bge-reranker-v2-m3 cross-encoder via vllm-rerank-api endpoint.
Both run on the same host (10.11.10.15) on different ports (8200 / 8100).
"""
from __future__ import annotations
import math
from collections.abc import AsyncIterator, Sequence
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Any
import httpx
from tenacity import (
AsyncRetrying,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
from shared.config import settings
from shared.logging import get_logger
log = get_logger(__name__)
class EmbeddingError(Exception):
pass
@dataclass(slots=True, frozen=True)
class RerankResult:
"""One reranked document, sorted by score descending."""
index: int # original position in the input documents list
score: float # cross-encoder score (higher = more relevant)
document: str # the text of that document
class EmbeddingClient:
"""Async client for BGE-M3 embeddings + BGE-reranker-v2-m3 reranking."""
def __init__(
self,
*,
embed_url: str | None = None,
rerank_url: str | None = None,
timeout: float = 60.0,
max_attempts: int = 3,
):
self._embed_base = (embed_url or settings.embedding_url).rstrip("/")
self._rerank_base = (rerank_url or settings.reranker_url).rstrip("/")
self._max_attempts = max_attempts
embed_headers = {"Content-Type": "application/json"}
if settings.embedding_api_key:
embed_headers["Authorization"] = f"Bearer {settings.embedding_api_key}"
rerank_headers = {"Content-Type": "application/json"}
if settings.reranker_api_key:
rerank_headers["Authorization"] = f"Bearer {settings.reranker_api_key}"
self._embed_http = httpx.AsyncClient(
base_url=self._embed_base,
headers=embed_headers,
timeout=httpx.Timeout(timeout, connect=10.0),
)
self._rerank_http = httpx.AsyncClient(
base_url=self._rerank_base,
headers=rerank_headers,
timeout=httpx.Timeout(timeout, connect=10.0),
)
async def aclose(self) -> None:
await self._embed_http.aclose()
await self._rerank_http.aclose()
async def __aenter__(self) -> EmbeddingClient:
return self
async def __aexit__(self, *args: Any) -> None:
await self.aclose()
# ------------------------------------------------------------------ embed
async def embed(self, texts: Sequence[str]) -> list[list[float]]:
"""Embed a batch of texts. Returns 1024-dim vectors in input order.
BGE-M3 max input is 8192 tokens; we don't truncate here — caller is
responsible for chunking long inputs (Atomic does this server-side).
Empty input returns empty list.
"""
if not texts:
return []
payload = {"model": settings.embedding_model, "input": list(texts)}
async for attempt in AsyncRetrying(
stop=stop_after_attempt(self._max_attempts),
wait=wait_exponential(multiplier=1, min=1, max=10),
retry=retry_if_exception_type((httpx.HTTPError, httpx.TimeoutException)),
reraise=True,
):
with attempt:
resp = await self._embed_http.post("/v1/embeddings", json=payload)
if resp.status_code >= 400:
raise EmbeddingError(
f"HTTP {resp.status_code} from embedding endpoint: "
f"{resp.text[:500]}"
)
data = resp.json()
items = data.get("data")
if not items or len(items) != len(texts):
raise EmbeddingError(
f"Embedding count mismatch: got {len(items or [])} for "
f"{len(texts)} inputs"
)
vecs = [item["embedding"] for item in items]
if vecs and len(vecs[0]) != settings.embedding_dim:
raise EmbeddingError(
f"Embedding dim mismatch: got {len(vecs[0])}, "
f"expected {settings.embedding_dim}"
)
return vecs
raise EmbeddingError("retry loop exited without result") # unreachable
async def embed_one(self, text: str) -> list[float]:
"""Convenience for single-text embedding."""
result = await self.embed([text])
return result[0]
# ----------------------------------------------------------------- rerank
async def rerank(
self,
query: str,
documents: Sequence[str],
*,
top_n: int | None = None,
) -> list[RerankResult]:
"""Cross-encoder rerank query × documents. Returns sorted by score desc.
Use this AFTER first-stage embedding retrieval to dramatically boost
precision on the top-N candidates (typical pattern: embed retrieves 50,
rerank picks 10).
"""
if not documents:
return []
payload: dict[str, Any] = {
"model": settings.reranker_model,
"query": query,
"documents": list(documents),
}
if top_n is not None:
payload["top_n"] = top_n
async for attempt in AsyncRetrying(
stop=stop_after_attempt(self._max_attempts),
wait=wait_exponential(multiplier=1, min=1, max=10),
retry=retry_if_exception_type((httpx.HTTPError, httpx.TimeoutException)),
reraise=True,
):
with attempt:
resp = await self._rerank_http.post("/v1/rerank", json=payload)
if resp.status_code >= 400:
raise EmbeddingError(
f"HTTP {resp.status_code} from reranker: {resp.text[:500]}"
)
data = resp.json()
results = data.get("results", [])
return [
RerankResult(
index=int(r["index"]),
score=float(r["score"]),
document=r.get("document") or documents[int(r["index"])],
)
for r in results
]
raise EmbeddingError("retry loop exited without result") # unreachable
# --------------------------------------------------------------------- math util
def cosine(a: Sequence[float], b: Sequence[float]) -> float:
"""Cosine similarity. BGE-M3 vectors are NOT pre-normalized; we compute it."""
if len(a) != len(b):
raise ValueError(f"vector dim mismatch: {len(a)} vs {len(b)}")
dot = sum(x * y for x, y in zip(a, b, strict=True))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(x * x for x in b))
if na == 0 or nb == 0:
return 0.0
return dot / (na * nb)
@asynccontextmanager
async def embedding_client() -> AsyncIterator[EmbeddingClient]:
client = EmbeddingClient()
try:
yield client
finally:
await client.aclose()

View file

@ -0,0 +1,260 @@
"""LLM client — async wrapper over the OpenAI-compat router on :14011.
Design:
- One LlmClient instance per long-running process (reusable httpx.AsyncClient).
- Roles (REASONING / FAST / VISION) decide model + backend automatically.
- Hard timeouts and retries are enforced; no LLM call hangs forever.
- Helpers for the two patterns we use most: structured JSON and single-token NLI.
"""
from __future__ import annotations
import json
import re
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any
import httpx
from tenacity import (
AsyncRetrying,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
from shared.config import LlmRole, settings
from shared.logging import get_logger
log = get_logger(__name__)
class LlmError(Exception):
"""Wraps any failure from the LLM stack with enough context to debug."""
def __init__(self, message: str, *, status: int | None = None, body: str | None = None):
super().__init__(message)
self.status = status
self.body = body
class RoleNotAvailable(LlmError):
"""The requested role has no enabled model behind it."""
class LlmClient:
"""Async OpenAI-compat client targeting the unified router."""
def __init__(
self,
*,
base_url: str | None = None,
timeout: float = 120.0,
max_attempts: int = 3,
):
self._base_url = (base_url or settings.llm_router_url).rstrip("/")
self._timeout = timeout
self._max_attempts = max_attempts
headers = {"Content-Type": "application/json"}
if settings.llm_router_api_key:
headers["Authorization"] = f"Bearer {settings.llm_router_api_key}"
self._http = httpx.AsyncClient(
base_url=self._base_url,
headers=headers,
timeout=httpx.Timeout(timeout, connect=10.0),
)
async def aclose(self) -> None:
await self._http.aclose()
async def __aenter__(self) -> LlmClient:
return self
async def __aexit__(self, *args: Any) -> None:
await self.aclose()
# ------------------------------------------------------------------ core
async def chat(
self,
*,
role: LlmRole = LlmRole.REASONING,
messages: list[dict[str, str]],
temperature: float = 0.1,
max_tokens: int = 1024,
stop: list[str] | None = None,
extra: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Raw chat completion. Returns the parsed response dict.
The response shape is OpenAI-compatible:
{"choices": [{"message": {"content": "..."}}], "usage": {...}, "backend": "..."}
"""
model_info = settings.model_for(role)
if not model_info:
raise RoleNotAvailable(f"Role {role.value} has no enabled model")
model_id, backend = model_info
payload: dict[str, Any] = {
"model": model_id,
"backend": backend,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
if stop:
payload["stop"] = stop
if extra:
payload.update(extra)
async for attempt in AsyncRetrying(
stop=stop_after_attempt(self._max_attempts),
wait=wait_exponential(multiplier=1, min=1, max=10),
retry=retry_if_exception_type((httpx.HTTPError, httpx.TimeoutException)),
reraise=True,
):
with attempt:
resp = await self._http.post("/v1/chat/completions", json=payload)
if resp.status_code >= 400:
raise LlmError(
f"HTTP {resp.status_code} from LLM router",
status=resp.status_code,
body=resp.text[:1000],
)
data = resp.json()
if "choices" not in data:
raise LlmError(
"LLM response missing 'choices'",
body=json.dumps(data)[:1000],
)
return data
raise LlmError("retry loop exited without result") # unreachable
# ------------------------------------------------------------------ helpers
async def chat_text(
self,
*,
role: LlmRole = LlmRole.REASONING,
system: str | None = None,
user: str,
temperature: float = 0.1,
max_tokens: int = 1024,
stop: list[str] | None = None,
) -> tuple[str, dict[str, Any]]:
"""Convenience: send (system?, user) → return (text, usage_dict)."""
msgs: list[dict[str, str]] = []
if system:
msgs.append({"role": "system", "content": system})
msgs.append({"role": "user", "content": user})
data = await self.chat(
role=role,
messages=msgs,
temperature=temperature,
max_tokens=max_tokens,
stop=stop,
)
text = data["choices"][0]["message"]["content"]
usage = data.get("usage", {}) | {"backend": data.get("backend", "")}
return text, usage
async def chat_json(
self,
*,
role: LlmRole = LlmRole.REASONING,
system: str | None = None,
user: str,
max_tokens: int = 2048,
temperature: float = 0.0,
) -> tuple[dict[str, Any] | list[Any], dict[str, Any]]:
"""Send a request expected to return JSON. Strips fences if present.
Raises LlmError if the response is not parseable as JSON.
"""
text, usage = await self.chat_text(
role=role,
system=system,
user=user,
temperature=temperature,
max_tokens=max_tokens,
)
cleaned = _extract_json(text)
try:
return json.loads(cleaned), usage
except json.JSONDecodeError as e:
raise LlmError(
f"LLM did not return valid JSON: {e}",
body=text[:1000],
) from e
async def chat_label(
self,
*,
role: LlmRole = LlmRole.REASONING,
system: str,
user: str,
allowed: list[str],
max_tokens: int = 16,
) -> tuple[str, dict[str, Any]]:
"""Single-label classification. Returns the matched label uppercased.
Useful for NLI (SUPPORT/CONTRADICT/NEUTRAL), credibility tiers, etc.
Raises LlmError if no allowed label is found in the response.
"""
text, usage = await self.chat_text(
role=role,
system=system,
user=user,
temperature=0.0,
max_tokens=max_tokens,
)
upper = text.upper()
for label in allowed:
if label.upper() in upper:
return label.upper(), usage
raise LlmError(
f"LLM response did not contain any allowed label {allowed}",
body=text[:500],
)
# ------------------------------------------------------------------ health
async def list_models(self) -> list[dict[str, Any]]:
resp = await self._http.get("/v1/models")
resp.raise_for_status()
data = resp.json()
return data.get("data", [])
async def list_backends(self) -> list[str]:
try:
resp = await self._http.get("/v1/backends")
resp.raise_for_status()
return resp.json().get("backends", [])
except httpx.HTTPError:
return []
# --------------------------------------------------------------------- utilities
_FENCE_RE = re.compile(r"```(?:json)?\s*(.*?)\s*```", re.DOTALL)
def _extract_json(text: str) -> str:
"""Strip markdown code fences and isolate the JSON object/array if needed."""
text = text.strip()
m = _FENCE_RE.search(text)
if m:
return m.group(1).strip()
# Find first { or [ and last matching close
starts = [text.find("{"), text.find("[")]
starts = [s for s in starts if s >= 0]
if not starts:
return text
start = min(starts)
return text[start:].strip()
@asynccontextmanager
async def llm_client() -> AsyncIterator[LlmClient]:
"""`async with llm_client() as llm:` for short-lived scripts."""
client = LlmClient()
try:
yield client
finally:
await client.aclose()

View file

@ -0,0 +1,50 @@
"""Structured logging via structlog. Single setup function called from main scripts."""
from __future__ import annotations
import logging
import sys
import structlog
from shared.config import settings
def setup_logging(level: str | None = None) -> structlog.stdlib.BoundLogger:
"""Configure structlog + stdlib logging once. Returns a base logger."""
log_level = (level or settings.log_level).upper()
# Force UTF-8 stdout on Windows so Romanian/Cyrillic/etc. don't crash rich.
try:
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[union-attr]
sys.stderr.reconfigure(encoding="utf-8") # type: ignore[union-attr]
except (AttributeError, OSError):
pass
logging.basicConfig(
format="%(message)s",
stream=sys.stdout,
level=getattr(logging, log_level),
)
# Silence overly chatty third-party loggers (httpx prints every request).
for noisy in ("httpx", "httpcore", "urllib3"):
logging.getLogger(noisy).setLevel(logging.WARNING)
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso", utc=True),
structlog.dev.ConsoleRenderer(colors=False),
],
wrapper_class=structlog.make_filtering_bound_logger(getattr(logging, log_level)),
cache_logger_on_first_use=True,
)
return structlog.get_logger()
def get_logger(name: str | None = None) -> structlog.stdlib.BoundLogger:
"""Get a logger; setup_logging() must have been called once first."""
return structlog.get_logger(name) if name else structlog.get_logger()

View file

@ -0,0 +1,262 @@
"""Canonical tag taxonomy for DidiBrain.
This module is the SINGLE SOURCE OF TRUTH for what tags exist in the brain.
All scrapers, extractors, and importers reference these names never make up
ad-hoc tags.
The structure is a nested dict where each key is either:
- a leaf (value is None), or
- a subtree (value is another dict)
The seeder script (scripts/04_seed_taxonomy.py) walks this tree and creates
any missing tags in Atomic. It is idempotent.
Atomic creates 5 default root tags at first boot: Topics, People, Locations,
Organizations, Events. We REUSE the "Topics" root by extending it with our
own children the rest stay as-is for compatibility with Atomic's optional
auto-tagging (which we keep disabled but won't fight).
After seeding, scripts/04 writes a flat map of "Path/Like/This → tag_uuid"
to shared/_tag_ids.json, which downstream code reads via TagResolver.
"""
from __future__ import annotations
import json
from collections.abc import Iterator
from pathlib import Path
from typing import Any
# A node is either a dict (subtree) or None (leaf).
TaxonomyTree = dict[str, "TaxonomyTree | None"]
# ---------------------------------------------------------------- the spec
# Order in this dict is the order tags will be created in Atomic.
TAXONOMY: TaxonomyTree = {
# Reuse Atomic's existing "Topics" root and extend it with our hierarchy.
"Topics": {
"Health": {
"Vaccines": None,
"COVID": None,
"Disease": None,
"Medicine": None,
"PublicHealth": None,
},
"Politics": {
"Elections": None,
"Diplomacy": None,
"War": None,
"Government": None,
},
"Climate": None,
"Technology": None,
"Economy": None,
"Society": None,
},
# Country of origin for the source / event.
"Country": {
"Romania": None,
"USA": None,
"Russia": None,
"Ukraine": None,
"UK": None,
"France": None,
"Germany": None,
"Spain": None,
"Italy": None,
"Poland": None,
"Moldova": None,
"Global": None, # for transnational / multi-country items
},
# What kind of source the atom came from.
"SourceType": {
"Wikipedia": None,
"MainstreamMedia": None,
"StateMedia": None, # state-affiliated outlets (TASS, RT, Sputnik, Xinhua...)
"TabloidMedia": None,
"FactCheck": None, # Snopes, PolitiFact, AFP FC, Veridica, Funky...
"Government": None, # gov.ro, whitehouse.gov, who.int...
"ScientificJournal": None, # peer-reviewed
"SocialMedia": None,
"Blog": None,
"Forum": None,
},
# Editorial credibility tier — applied by scraper from a static registry.
"Credibility": {
"Tier1": None, # Reuters/AP/BBC class
"Tier2": None, # major mainstream
"Tier3": None, # weaker mainstream / tabloid
"StateAffiliated": None,
"KnownDisinfo": None, # known disinfo outlets (we do still ingest these)
"Unknown": None,
},
# Primary language of the atom content.
"Language": {
"RO": None,
"EN": None,
"RU": None,
"UA": None,
"FR": None,
"DE": None,
"ES": None,
"IT": None,
"PL": None,
},
# What KIND of atom this is (vs "what topic" — that's Topics).
"Type": {
"Document": None, # full scraped article
"Claim": None, # extracted atomic claim
"Quote": None, # verbatim quote/excerpt
"Summary": None, # synthesized summary
"Annotation": None, # human/AI annotation about another atom
},
# Stance the source itself takes toward the central claim of the document.
"Stance": {
"Asserts": None, # source presents it as fact
"Reports": None, # source describes it as someone else's claim
"Refutes": None, # source disagrees / debunks
"Questions": None, # source raises doubts but doesn't refute
"Neutral": None, # purely informational, no stance
},
# Verification status of a Claim atom — populated by Didi after analysis,
# not at ingest. Documents stay un-tagged here.
"ClaimStatus": {
"Confirmed": None,
"Disputed": None,
"Debunked": None,
"Unverified": None,
"PartiallyTrue": None,
},
}
# ============================================================ flatten helpers
def walk(tree: TaxonomyTree, parent_path: str = "") -> Iterator[tuple[str, str | None, str]]:
"""Yield (full_path, parent_path_or_None, name) for every node, depth-first.
Example output for {"A": {"B": None}}:
("A", None, "A")
("A/B", "A", "B")
"""
for name, children in tree.items():
path = f"{parent_path}/{name}" if parent_path else name
yield (path, parent_path or None, name)
if children:
yield from walk(children, path)
def all_paths(tree: TaxonomyTree | None = None) -> list[str]:
"""All canonical paths in the taxonomy, in creation order."""
return [p for p, _, _ in walk(tree if tree is not None else TAXONOMY)]
# ============================================================ tag id resolver
_DEFAULT_CACHE = Path(__file__).resolve().parent / "_tag_ids.json"
class TagResolver:
"""Resolves canonical tag paths to Atomic UUIDs.
Loads from a JSON cache file written by the seeder. If the file is
missing or stale, callers should re-run scripts/04_seed_taxonomy.py.
"""
def __init__(self, cache_path: Path | None = None):
self._cache_path = cache_path or _DEFAULT_CACHE
self._map: dict[str, str] = {}
if self._cache_path.exists():
self._map = json.loads(self._cache_path.read_text(encoding="utf-8"))
def __contains__(self, path: str) -> bool:
return path in self._map
def get(self, path: str) -> str | None:
return self._map.get(path)
def require(self, path: str) -> str:
v = self._map.get(path)
if not v:
raise KeyError(
f"Tag path {path!r} not in resolver cache at {self._cache_path}. "
f"Run scripts/04_seed_taxonomy.py."
)
return v
def ids_for(self, paths: list[str], *, ignore_missing: bool = False) -> list[str]:
ids: list[str] = []
missing: list[str] = []
for p in paths:
v = self._map.get(p)
if v:
ids.append(v)
else:
missing.append(p)
if missing and not ignore_missing:
raise KeyError(f"Missing tag paths: {missing}")
return ids
@property
def all(self) -> dict[str, str]:
return dict(self._map)
def save(self, mapping: dict[str, str]) -> None:
self._cache_path.write_text(
json.dumps(mapping, indent=2, ensure_ascii=False, sort_keys=True),
encoding="utf-8",
)
self._map = mapping
def load_from_mapping(self, mapping: dict[str, str]) -> None:
"""Replace the in-memory map without touching disk.
Used by long-running services (e.g. containerized brain_api) that
refresh the resolver from Atomic at startup, so they don't need
the _tag_ids.json file baked into the image.
"""
self._map = dict(mapping)
def build_path_map_from_tags(tags: list[dict[str, Any]]) -> dict[str, str]:
"""Convert Atomic's flat tag list (each with parent_id) into path → id map.
Atomic /api/tags returns each tag with id, name, parent_id, and a nested
children list. We don't trust the children list (depth may be limited)
and instead walk parent_id chains ourselves.
"""
by_id: dict[str, dict[str, Any]] = {}
def collect(items: list[dict[str, Any]]) -> None:
for t in items:
tid = t.get("id")
if not tid:
continue
by_id[tid] = t
kids = t.get("children") or []
if kids:
collect(kids)
collect(tags)
def path_for(tid: str) -> str:
parts: list[str] = []
cur: str | None = tid
seen: set[str] = set()
while cur and cur not in seen:
seen.add(cur)
t = by_id.get(cur)
if not t:
break
parts.append(t.get("name", "?"))
cur = t.get("parent_id")
return "/".join(reversed(parts))
return {path_for(tid): tid for tid in by_id}