diff --git a/backend/app/services/knowledge/ragcore.py b/backend/app/services/knowledge/ragcore.py index d3a8158..94a680e 100644 --- a/backend/app/services/knowledge/ragcore.py +++ b/backend/app/services/knowledge/ragcore.py @@ -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, + ) diff --git a/backend/tests/test_knowledge.py b/backend/tests/test_knowledge.py index 84cb30f..086861f 100644 --- a/backend/tests/test_knowledge.py +++ b/backend/tests/test_knowledge.py @@ -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 == []