Files
MobilityOps/backend/app/services/knowledge/ragcore.py
T
NuklearRabbitandClaude Sonnet 5 e5d8466266 knowledge: rewrite RAGcoreKnowledgeProvider to the real search/answers contract
The previous adapter targeted an endpoint shape RAGcore never actually
exposed. health() now checks /health/ready and ask() posts to the real
POST /v1/answers with Bearer auth and requested_space_ids, matching
RAGcore's actual contract after this session's Bearer-auth and
search/answer wiring work.

Adds RAGCORE_SPACE_ID config/env plumbing (a question is meaningless
without a knowledge space to scope it to) and 12 new adapter tests
covering degradation paths: missing space id, connection errors,
non-200 responses, malformed responses, not-answerable, and
answerable-without-citations all fail closed to "insufficient
evidence" rather than fabricating an answer.

KNOWLEDGE_PROVIDER stays "demo" in production for now -- switching
requires RAGcore's own search/answer application to actually be
deployed and live-verified, tracked separately in PROJECT_STATE.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 03:20:55 +02:00

122 lines
5.2 KiB
Python

from __future__ import annotations
import httpx
from app.core.config import get_settings
from app.services.knowledge import EvidenceState, GroundedAnswer, KnowledgeHealth, SourceCard
_GROUNDED_ANSWERABILITY = {"answerable", "partially_answerable"}
class RAGcoreKnowledgeProvider:
"""Adapter for the central RAGcore service, against its real `/v1/*` contract
(see `docs/contracts/openapi.yaml` in the RAGcore checkout -- RAGcore is built and
owned separately, MobilityOps only ever talks to its documented HTTP API).
Authenticates as a service account via `Authorization: Bearer <token>` (RAGcore's
session-cookie auth is for its own browser admin UI only). Any connection error,
timeout, non-2xx response, or malformed body degrades to `evidence_state:
"unavailable"` rather than raising -- this is the adapter that actually exercises the
architecture's reliability boundary: RAGcore failure disables knowledge answers only,
never fabricates an answer, never affects the rest of the app.
Known gap, not fixable from this side: RAGcore's ingest pipeline currently tags every
chunk's `language` payload field as `"en"` regardless of actual document language (the
`/v1/uploads` contract has no per-file language field for a caller to set correctly).
Filtering search/answer requests by requested UI language would therefore silently
exclude genuinely-relevant nl-BE/fr-BE content, so this adapter deliberately does not
filter by language -- retrieval relies on the embedding model's cross-lingual matching.
"""
name = "ragcore"
def __init__(self) -> None:
self._settings = get_settings()
def _client(self) -> httpx.Client:
headers = {}
if self._settings.ragcore_api_token:
headers["Authorization"] = f"Bearer {self._settings.ragcore_api_token}"
return httpx.Client(
base_url=self._settings.ragcore_base_url,
headers=headers,
timeout=self._settings.ragcore_http_timeout_seconds,
)
def health(self, language: str = "en-GB") -> KnowledgeHealth:
try:
with self._client() as client:
response = client.get("/health/ready")
body = response.json()
available = response.status_code == 200 and body.get("status") == "ok"
detail = (
"RAGcore reachable and ready."
if available
else f"RAGcore degraded: {body.get('status', 'unknown')}"
)
except (httpx.HTTPError, ValueError) as exc:
available = False
detail = f"RAGcore unavailable: {type(exc).__name__}: {exc}"
return KnowledgeHealth(
provider=self.name,
available=available,
detail=detail,
tenant=self._settings.ragcore_tenant,
workspace=self._settings.ragcore_workspace,
collection=self._settings.ragcore_collection,
# RAGcore's retrieval API has no corpus-size endpoint to query honestly from
# here; left at 0 rather than approximated from a capped search result count.
document_count=0,
)
def ask(self, question: str, correlation_id: str, language: str = "en-GB") -> GroundedAnswer:
unavailable = GroundedAnswer(
answer="",
evidence_state="unavailable",
sources=[],
provider=self.name,
correlation_id=correlation_id,
)
if not self._settings.ragcore_space_id:
return unavailable
try:
with self._client() as client:
response = client.post(
"/v1/answers",
json={
"query": question,
"requested_space_ids": [self._settings.ragcore_space_id],
},
)
if response.status_code != 200:
return unavailable
body = response.json()
except (httpx.HTTPError, ValueError):
return unavailable
try:
citations = {c["id"]: c for c in body.get("citations", [])}
sources = [
SourceCard(
document_id=str(citation["document_id"]),
title=citation["title"],
version=str(citation["document_version_id"]),
section=citation.get("section") or "",
excerpt=citation["excerpt"],
)
for citation in citations.values()
]
answerability = body.get("answerability", "not_answerable")
is_grounded = answerability in _GROUNDED_ANSWERABILITY and sources
evidence_state: EvidenceState = "grounded" if is_grounded else "insufficient"
return GroundedAnswer(
answer=body.get("answer", "") if evidence_state == "grounded" else "",
evidence_state=evidence_state,
sources=sources if evidence_state == "grounded" else [],
provider=self.name,
correlation_id=correlation_id,
)
except (TypeError, KeyError, ValueError):
return unavailable