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>
122 lines
5.2 KiB
Python
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
|