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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
0da5251524
commit
e5d8466266
@@ -21,6 +21,7 @@ class Settings(BaseSettings):
|
||||
ragcore_workspace: str = "mobilityops"
|
||||
ragcore_collection: str = "internal-procedures"
|
||||
ragcore_api_token: str = ""
|
||||
ragcore_space_id: str = ""
|
||||
ragcore_http_timeout_seconds: float = 5.0
|
||||
n8n_webhook_url: str = "http://n8n:5678/webhook/mobilityops-return"
|
||||
n8n_webhook_trigger_token: str = "replace-me-n8n-webhook-trigger-token"
|
||||
|
||||
@@ -3,18 +3,29 @@ from __future__ import annotations
|
||||
import httpx
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.services.knowledge import GroundedAnswer, KnowledgeHealth, SourceCard
|
||||
from app.services.knowledge import EvidenceState, GroundedAnswer, KnowledgeHealth, SourceCard
|
||||
|
||||
_GROUNDED_ANSWERABILITY = {"answerable", "partially_answerable"}
|
||||
|
||||
|
||||
class RAGcoreKnowledgeProvider:
|
||||
"""Adapter for the central RAGcore service.
|
||||
"""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).
|
||||
|
||||
RAGcore is built and owned separately (see contracts/ragcore-contract-assumptions.md).
|
||||
No live RAGcore instance was reachable during this build, so the exact request/response
|
||||
shape below is a best-effort guess at a REST contract; any failure (connection, timeout,
|
||||
malformed response) degrades to `unavailable` rather than raising, per the architecture's
|
||||
reliability boundary: RAGcore failure disables knowledge answers only, never the rest of
|
||||
the app, and never fabricates an answer.
|
||||
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"
|
||||
@@ -35,11 +46,15 @@ class RAGcoreKnowledgeProvider:
|
||||
def health(self, language: str = "en-GB") -> KnowledgeHealth:
|
||||
try:
|
||||
with self._client() as client:
|
||||
response = client.get("/health")
|
||||
response.raise_for_status()
|
||||
available = True
|
||||
detail = "RAGcore reachable."
|
||||
except httpx.HTTPError as exc:
|
||||
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(
|
||||
@@ -49,51 +64,58 @@ class RAGcoreKnowledgeProvider:
|
||||
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(
|
||||
"/api/v1/ask",
|
||||
"/v1/answers",
|
||||
json={
|
||||
"tenant": self._settings.ragcore_tenant,
|
||||
"workspace": self._settings.ragcore_workspace,
|
||||
"collection": self._settings.ragcore_collection,
|
||||
"question": question,
|
||||
"correlation_id": correlation_id,
|
||||
"language": language,
|
||||
"query": question,
|
||||
"requested_space_ids": [self._settings.ragcore_space_id],
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
if response.status_code != 200:
|
||||
return unavailable
|
||||
body = response.json()
|
||||
except (httpx.HTTPError, ValueError):
|
||||
return GroundedAnswer(
|
||||
answer="",
|
||||
evidence_state="unavailable",
|
||||
sources=[],
|
||||
provider=self.name,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
return unavailable
|
||||
|
||||
try:
|
||||
sources = [SourceCard(**s) for s in body.get("sources", [])]
|
||||
evidence_state = body.get("evidence_state", "insufficient")
|
||||
if evidence_state not in ("grounded", "insufficient", "unavailable"):
|
||||
evidence_state = "insufficient"
|
||||
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", ""),
|
||||
answer=body.get("answer", "") if evidence_state == "grounded" else "",
|
||||
evidence_state=evidence_state,
|
||||
sources=sources,
|
||||
provider=self.name,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return GroundedAnswer(
|
||||
answer="",
|
||||
evidence_state="unavailable",
|
||||
sources=[],
|
||||
sources=sources if evidence_state == "grounded" else [],
|
||||
provider=self.name,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
except (TypeError, KeyError, ValueError):
|
||||
return unavailable
|
||||
|
||||
@@ -158,12 +158,190 @@ def test_knowledge_status_endpoint(ops_client):
|
||||
assert response.json()["provider"] == "demo"
|
||||
|
||||
|
||||
def test_ragcore_provider_degrades_to_unavailable(monkeypatch):
|
||||
def fake_client(*args, **kwargs):
|
||||
raise httpx.ConnectError("no ragcore in this environment")
|
||||
class _FakeResponse:
|
||||
def __init__(self, status_code: int, body: dict):
|
||||
self.status_code = status_code
|
||||
self._body = body
|
||||
|
||||
def json(self) -> dict:
|
||||
return self._body
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, get_response=None, post_response=None, raise_on=None):
|
||||
self._get_response = get_response
|
||||
self._post_response = post_response
|
||||
self._raise_on = raise_on
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def get(self, path):
|
||||
if self._raise_on == "get":
|
||||
raise httpx.ConnectError("no ragcore in this environment")
|
||||
return self._get_response
|
||||
|
||||
def post(self, path, json=None):
|
||||
if self._raise_on == "post":
|
||||
raise httpx.ConnectError("no ragcore in this environment")
|
||||
return self._post_response
|
||||
|
||||
|
||||
def test_ragcore_provider_degrades_to_unavailable(monkeypatch):
|
||||
provider = RAGcoreKnowledgeProvider()
|
||||
monkeypatch.setattr(provider, "_client", fake_client)
|
||||
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||
monkeypatch.setattr(provider, "_client", lambda: _FakeClient(raise_on="post"))
|
||||
answer = provider.ask("Anything?", "test-correlation-3")
|
||||
assert answer.evidence_state == "unavailable"
|
||||
assert answer.sources == []
|
||||
|
||||
|
||||
def test_ragcore_provider_without_configured_space_is_unavailable_without_a_network_call(
|
||||
monkeypatch,
|
||||
):
|
||||
provider = RAGcoreKnowledgeProvider()
|
||||
monkeypatch.setattr(provider._settings, "ragcore_space_id", "")
|
||||
|
||||
def fail_if_called():
|
||||
raise AssertionError("should not call RAGcore without a configured space id")
|
||||
|
||||
monkeypatch.setattr(provider, "_client", fail_if_called)
|
||||
answer = provider.ask("Anything?", "test-correlation-no-space")
|
||||
assert answer.evidence_state == "unavailable"
|
||||
|
||||
|
||||
def test_ragcore_provider_health_reports_ready_status(monkeypatch):
|
||||
provider = RAGcoreKnowledgeProvider()
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"_client",
|
||||
lambda: _FakeClient(get_response=_FakeResponse(200, {"status": "ok"})),
|
||||
)
|
||||
health = provider.health()
|
||||
assert health.provider == "ragcore"
|
||||
assert health.available is True
|
||||
|
||||
|
||||
def test_ragcore_provider_health_reports_degraded_status(monkeypatch):
|
||||
provider = RAGcoreKnowledgeProvider()
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"_client",
|
||||
lambda: _FakeClient(get_response=_FakeResponse(200, {"status": "degraded"})),
|
||||
)
|
||||
health = provider.health()
|
||||
assert health.available is False
|
||||
|
||||
|
||||
def test_ragcore_provider_health_degrades_on_connection_error(monkeypatch):
|
||||
provider = RAGcoreKnowledgeProvider()
|
||||
monkeypatch.setattr(provider, "_client", lambda: _FakeClient(raise_on="get"))
|
||||
health = provider.health()
|
||||
assert health.available is False
|
||||
assert "unavailable" in health.detail.lower()
|
||||
|
||||
|
||||
def _answers_body(**overrides) -> dict:
|
||||
body = {
|
||||
"answer": "Report damage and route the vehicle to maintenance.",
|
||||
"answerability": "answerable",
|
||||
"citations": [
|
||||
{
|
||||
"id": "cite-1",
|
||||
"document_id": "doc-1",
|
||||
"document_version_id": "version-1",
|
||||
"title": "Damage handling procedure",
|
||||
"section": "Detection",
|
||||
"excerpt": "Inspect the vehicle for visible damage.",
|
||||
}
|
||||
],
|
||||
}
|
||||
body.update(overrides)
|
||||
return body
|
||||
|
||||
|
||||
def test_ragcore_provider_grounded_answer_maps_citations_to_sources(monkeypatch):
|
||||
provider = RAGcoreKnowledgeProvider()
|
||||
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"_client",
|
||||
lambda: _FakeClient(post_response=_FakeResponse(200, _answers_body())),
|
||||
)
|
||||
answer = provider.ask("What must I do about damage?", "test-correlation-grounded")
|
||||
assert answer.evidence_state == "grounded"
|
||||
assert answer.answer
|
||||
assert len(answer.sources) == 1
|
||||
source = answer.sources[0]
|
||||
assert source.document_id == "doc-1"
|
||||
assert source.title == "Damage handling procedure"
|
||||
assert source.version == "version-1"
|
||||
assert source.section == "Detection"
|
||||
assert source.excerpt
|
||||
|
||||
|
||||
def test_ragcore_provider_not_answerable_is_insufficient_and_never_fabricates(monkeypatch):
|
||||
provider = RAGcoreKnowledgeProvider()
|
||||
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"_client",
|
||||
lambda: _FakeClient(
|
||||
post_response=_FakeResponse(
|
||||
200,
|
||||
_answers_body(
|
||||
answer="This should never be shown.",
|
||||
answerability="not_answerable",
|
||||
citations=[],
|
||||
),
|
||||
)
|
||||
),
|
||||
)
|
||||
answer = provider.ask("Unrelated question?", "test-correlation-insufficient")
|
||||
assert answer.evidence_state == "insufficient"
|
||||
assert answer.answer == ""
|
||||
assert answer.sources == []
|
||||
|
||||
|
||||
def test_ragcore_provider_answerable_without_citations_is_insufficient(monkeypatch):
|
||||
provider = RAGcoreKnowledgeProvider()
|
||||
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"_client",
|
||||
lambda: _FakeClient(
|
||||
post_response=_FakeResponse(
|
||||
200, _answers_body(answerability="answerable", citations=[])
|
||||
)
|
||||
),
|
||||
)
|
||||
answer = provider.ask("What must I do about damage?", "test-correlation-no-citations")
|
||||
assert answer.evidence_state == "insufficient"
|
||||
assert answer.sources == []
|
||||
|
||||
|
||||
def test_ragcore_provider_non_200_response_is_unavailable(monkeypatch):
|
||||
provider = RAGcoreKnowledgeProvider()
|
||||
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"_client",
|
||||
lambda: _FakeClient(post_response=_FakeResponse(401, {"code": "AUTHENTICATION_REQUIRED"})),
|
||||
)
|
||||
answer = provider.ask("Anything?", "test-correlation-401")
|
||||
assert answer.evidence_state == "unavailable"
|
||||
|
||||
|
||||
def test_ragcore_provider_malformed_response_is_unavailable(monkeypatch):
|
||||
provider = RAGcoreKnowledgeProvider()
|
||||
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
|
||||
monkeypatch.setattr(
|
||||
provider,
|
||||
"_client",
|
||||
lambda: _FakeClient(post_response=_FakeResponse(200, {"citations": "not-a-list"})),
|
||||
)
|
||||
answer = provider.ask("Anything?", "test-correlation-malformed")
|
||||
assert answer.evidence_state == "unavailable"
|
||||
|
||||
Reference in New Issue
Block a user