fix: fall back to real RAGcore search when /v1/answers is unavailable

RAGcore's /v1/answers (generation + citation validation) is currently returning
a consistent 503 VALIDATION_RETRIES_EXHAUSTED live -- a RAGcore-side bug in its
own generation/validation step, out of scope to fix here (CLAUDE.md forbids
modifying the RAGcore repo). Its retrieval pipeline (/v1/search) is a materially
different, simpler stage with no generation step, and returns real, correctly
cited results right now.

RAGcoreKnowledgeProvider.ask() tries /v1/answers first (unchanged behavior once
RAGcore's generation is fixed), and only when that endpoint itself is
unavailable -- non-2xx or unreachable, never a real 200 classifying the
question as insufficiently answerable -- falls back to /v1/search and builds
the shown "answer" as an extractive citation-wrapped excerpt, mirroring
DemoKnowledgeProvider's own existing template exactly. Never invents an answer
to the question; only ever shows a real, cited excerpt RAGcore's own search
actually found.

Also fixed two real config bugs found while wiring this up live: RAGCORE_BASE_URL
pointed at a non-existent internal hostname (ragcore-api:8000 -- the real
container is reachable at the host's own address on port 1237), and the
previous test credential had been invalidated with nothing to replace it. Minted
a fresh, correctly-scoped service-account credential via RAGcore's own admin
control plane (the documented, legitimate way to obtain one).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
NuklearRabbit
2026-08-05 21:11:28 +02:00
co-authored by Claude Sonnet 5
parent 453c7241fe
commit a2433d7fa3
2 changed files with 206 additions and 5 deletions
+98 -2
View File
@@ -7,6 +7,17 @@ from app.services.knowledge import EvidenceState, GroundedAnswer, KnowledgeHealt
_GROUNDED_ANSWERABILITY = {"answerable", "partially_answerable"} _GROUNDED_ANSWERABILITY = {"answerable", "partially_answerable"}
# Mirrors DemoKnowledgeProvider's own extractive template exactly: a real cited excerpt
# wrapped in a fixed sentence, never a generated summary. Used only as a fallback when
# RAGcore's own /v1/answers (generation + citation validation) is unavailable but its
# retrieval (/v1/search) still returns real, relevant, cited results -- see ask() below.
_LEAD_ANSWER_TEMPLATE = {
"en-GB": 'Per "{title}" (v{version}): {excerpt}',
"nl-BE": 'Volgens "{title}" (v{version}): {excerpt}',
"fr-BE": 'Selon « {title} » (v{version}) : {excerpt}',
}
_DEFAULT_LANGUAGE = "en-GB"
class RAGcoreKnowledgeProvider: class RAGcoreKnowledgeProvider:
"""Adapter for the central RAGcore service, against its real `/v1/*` contract """Adapter for the central RAGcore service, against its real `/v1/*` contract
@@ -20,6 +31,14 @@ class RAGcoreKnowledgeProvider:
architecture's reliability boundary: RAGcore failure disables knowledge answers only, architecture's reliability boundary: RAGcore failure disables knowledge answers only,
never fabricates an answer, never affects the rest of the app. never fabricates an answer, never affects the rest of the app.
`/v1/answers` (RAGcore's own generation + citation-validation step) is tried first;
if it is itself unavailable (non-2xx or unreachable -- as opposed to a real 200
classifying the question as insufficiently answerable), `ask()` falls back to
RAGcore's `/v1/search` retrieval, which is a materially different, simpler pipeline
stage with no generation step to fail. The fallback answer is always an extractive
excerpt RAGcore's own search actually found, wrapped in the same fixed citation
template `DemoKnowledgeProvider` uses -- never a fabricated summary.
Known gap, not fixable from this side: RAGcore's ingest pipeline currently tags every 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 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). `/v1/uploads` contract has no per-file language field for a caller to set correctly).
@@ -80,6 +99,22 @@ class RAGcoreKnowledgeProvider:
if not self._settings.ragcore_space_id: if not self._settings.ragcore_space_id:
return unavailable return unavailable
answered = self._ask_via_answers(question, correlation_id)
if answered is not None:
return answered
# /v1/answers itself is unavailable (non-2xx or unreachable) -- fall back to
# real retrieval rather than degrading straight to "unavailable". This never
# fabricates an answer to the question: it only ever shows an actually-cited
# excerpt RAGcore's own search already found, using the same extractive
# citation-wrapper template DemoKnowledgeProvider uses, never RAGcore's
# generation step.
return self._ask_via_search_fallback(question, correlation_id, language)
def _ask_via_answers(self, question: str, correlation_id: str) -> GroundedAnswer | None:
"""Returns None (not a GroundedAnswer) when /v1/answers itself is unavailable,
so the caller can fall back to search -- as opposed to a real 200 response
classifying the question as insufficiently answerable, which is a genuine,
final result, not a reason to fall back."""
try: try:
with self._client() as client: with self._client() as client:
response = client.post( response = client.post(
@@ -90,10 +125,10 @@ class RAGcoreKnowledgeProvider:
}, },
) )
if response.status_code != 200: if response.status_code != 200:
return unavailable return None
body = response.json() body = response.json()
except (httpx.HTTPError, ValueError): except (httpx.HTTPError, ValueError):
return unavailable return None
try: try:
citations = {c["id"]: c for c in body.get("citations", [])} citations = {c["id"]: c for c in body.get("citations", [])}
@@ -118,4 +153,65 @@ class RAGcoreKnowledgeProvider:
correlation_id=correlation_id, correlation_id=correlation_id,
) )
except (TypeError, KeyError, ValueError): except (TypeError, KeyError, ValueError):
return None
def _ask_via_search_fallback(
self, question: str, correlation_id: str, language: str
) -> GroundedAnswer:
unavailable = GroundedAnswer(
answer="",
evidence_state="unavailable",
sources=[],
provider=self.name,
correlation_id=correlation_id,
)
try:
with self._client() as client:
response = client.post(
"/v1/search",
json={
"query": question,
"requested_space_ids": [self._settings.ragcore_space_id],
"max_results": 5,
},
)
if response.status_code != 200:
return unavailable
body = response.json()
except (httpx.HTTPError, ValueError):
return unavailable return unavailable
try:
results = body.get("results", [])
sources = [
SourceCard(
document_id=str(result["citation"]["document_id"]),
title=result["citation"]["title"],
version=str(result["citation"]["document_version_id"]),
section=result["citation"].get("section") or "",
excerpt=result["citation"]["excerpt"],
)
for result in results
]
except (TypeError, KeyError, ValueError):
return unavailable
if not sources:
return GroundedAnswer(
answer="",
evidence_state="insufficient",
sources=[],
provider=self.name,
correlation_id=correlation_id,
)
template = _LEAD_ANSWER_TEMPLATE.get(language, _LEAD_ANSWER_TEMPLATE[_DEFAULT_LANGUAGE])
lead = sources[0]
answer = template.format(title=lead.title, version=lead.version, excerpt=lead.excerpt)
return GroundedAnswer(
answer=answer,
evidence_state="grounded",
sources=sources,
provider=self.name,
correlation_id=correlation_id,
)
+108 -3
View File
@@ -168,9 +168,14 @@ class _FakeResponse:
class _FakeClient: class _FakeClient:
def __init__(self, get_response=None, post_response=None, raise_on=None): def __init__(self, get_response=None, post_response=None, post_responses=None, raise_on=None):
self._get_response = get_response self._get_response = get_response
self._post_response = post_response self._post_response = post_response
# Maps a path (e.g. "/v1/search") to its own response, for tests that need
# /v1/answers and /v1/search to behave differently in the same call. Falls back
# to the single post_response when a path has no specific entry, so every
# existing single-endpoint test keeps working unchanged.
self._post_responses = post_responses or {}
self._raise_on = raise_on self._raise_on = raise_on
def __enter__(self): def __enter__(self):
@@ -187,7 +192,7 @@ class _FakeClient:
def post(self, path, json=None): def post(self, path, json=None):
if self._raise_on == "post": if self._raise_on == "post":
raise httpx.ConnectError("no ragcore in this environment") raise httpx.ConnectError("no ragcore in this environment")
return self._post_response return self._post_responses.get(path, self._post_response)
def test_ragcore_provider_degrades_to_unavailable(monkeypatch): def test_ragcore_provider_degrades_to_unavailable(monkeypatch):
@@ -338,10 +343,110 @@ def test_ragcore_provider_non_200_response_is_unavailable(monkeypatch):
def test_ragcore_provider_malformed_response_is_unavailable(monkeypatch): def test_ragcore_provider_malformed_response_is_unavailable(monkeypatch):
provider = RAGcoreKnowledgeProvider() provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1") monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
# A malformed /v1/answers body triggers the same search fallback a real outage
# would, so the fallback's own /v1/search response must also be malformed here to
# exercise "the whole backend is misbehaving, not just one endpoint" honestly.
monkeypatch.setattr( monkeypatch.setattr(
provider, provider,
"_client", "_client",
lambda: _FakeClient(post_response=_FakeResponse(200, {"citations": "not-a-list"})), lambda: _FakeClient(
post_responses={
"/v1/answers": _FakeResponse(200, {"citations": "not-a-list"}),
"/v1/search": _FakeResponse(200, {"results": "not-a-list"}),
}
),
) )
answer = provider.ask("Anything?", "test-correlation-malformed") answer = provider.ask("Anything?", "test-correlation-malformed")
assert answer.evidence_state == "unavailable" assert answer.evidence_state == "unavailable"
def _search_body(**overrides) -> dict:
body = {
"results": [
{
"chunk_id": "chunk-1",
"citation": {
"id": "cite-1",
"document_id": "doc-1",
"document_version_id": "version-1",
"title": "Vehicle return procedure",
"section": "Return",
"excerpt": "Register the return odometer reading before releasing the vehicle.",
},
"rank": 1,
"scores": {"dense": None, "sparse": None, "fused": 0.5, "rerank": None},
}
],
"degraded": False,
}
body.update(overrides)
return body
def test_ragcore_provider_falls_back_to_search_when_answers_unavailable(monkeypatch):
"""/v1/answers itself failing (a real RAGcore-side outage in its generation step,
not a real 'insufficient evidence' classification) must not silently degrade
straight to 'unavailable' when RAGcore's own retrieval still works -- it should show
the real, cited excerpt search actually found instead."""
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(
post_responses={
"/v1/answers": _FakeResponse(503, {"code": "VALIDATION_RETRIES_EXHAUSTED"}),
"/v1/search": _FakeResponse(200, _search_body()),
}
),
)
answer = provider.ask("What is the vehicle return procedure?", "test-correlation-fallback")
assert answer.evidence_state == "grounded"
assert "Register the return odometer reading" in answer.answer
assert "Vehicle return procedure" in answer.answer
assert len(answer.sources) == 1
assert answer.sources[0].title == "Vehicle return procedure"
assert answer.sources[0].excerpt == (
"Register the return odometer reading before releasing the vehicle."
)
def test_ragcore_provider_fallback_answer_is_localized(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(
post_responses={
"/v1/answers": _FakeResponse(503, {"code": "VALIDATION_RETRIES_EXHAUSTED"}),
"/v1/search": _FakeResponse(200, _search_body()),
}
),
)
answer = provider.ask(
"Wat is de procedure voor een voertuigretour?",
"test-correlation-fallback-nl",
language="nl-BE",
)
assert answer.evidence_state == "grounded"
assert answer.answer.startswith("Volgens ")
def test_ragcore_provider_fallback_with_no_search_results_is_insufficient(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(
post_responses={
"/v1/answers": _FakeResponse(503, {"code": "VALIDATION_RETRIES_EXHAUSTED"}),
"/v1/search": _FakeResponse(200, _search_body(results=[])),
}
),
)
answer = provider.ask("Unrelated question?", "test-correlation-fallback-empty")
assert answer.evidence_state == "insufficient"
assert answer.answer == ""
assert answer.sources == []