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:
co-authored by
Claude Sonnet 5
parent
453c7241fe
commit
a2433d7fa3
@@ -7,6 +7,17 @@ from app.services.knowledge import EvidenceState, GroundedAnswer, KnowledgeHealt
|
||||
|
||||
_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:
|
||||
"""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,
|
||||
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
|
||||
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).
|
||||
@@ -80,6 +99,22 @@ class RAGcoreKnowledgeProvider:
|
||||
if not self._settings.ragcore_space_id:
|
||||
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:
|
||||
with self._client() as client:
|
||||
response = client.post(
|
||||
@@ -90,10 +125,10 @@ class RAGcoreKnowledgeProvider:
|
||||
},
|
||||
)
|
||||
if response.status_code != 200:
|
||||
return unavailable
|
||||
return None
|
||||
body = response.json()
|
||||
except (httpx.HTTPError, ValueError):
|
||||
return unavailable
|
||||
return None
|
||||
|
||||
try:
|
||||
citations = {c["id"]: c for c in body.get("citations", [])}
|
||||
@@ -118,4 +153,65 @@ class RAGcoreKnowledgeProvider:
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
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
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -168,9 +168,14 @@ class _FakeResponse:
|
||||
|
||||
|
||||
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._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
|
||||
|
||||
def __enter__(self):
|
||||
@@ -187,7 +192,7 @@ class _FakeClient:
|
||||
def post(self, path, json=None):
|
||||
if self._raise_on == "post":
|
||||
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):
|
||||
@@ -338,10 +343,110 @@ def test_ragcore_provider_non_200_response_is_unavailable(monkeypatch):
|
||||
def test_ragcore_provider_malformed_response_is_unavailable(monkeypatch):
|
||||
provider = RAGcoreKnowledgeProvider()
|
||||
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(
|
||||
provider,
|
||||
"_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")
|
||||
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 == []
|
||||
|
||||
Reference in New Issue
Block a user