from __future__ import annotations from pathlib import Path import httpx from app.core.config import get_settings from app.services.knowledge import EvidenceState, GroundedAnswer, KnowledgeHealth, SourceCard from app.services.knowledge.procedures import iter_procedure_documents _GROUNDED_ANSWERABILITY = {"answerable", "partially_answerable"} # Mirrors DemoKnowledgeProvider's own extractive template in spirit: 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. Unlike the demo corpus's own markdown frontmatter, RAGcore's # `document_version_id` is an opaque UUID, not a human-meaningful version string, so it # is deliberately left out of this sentence (it still appears on the source card itself). _LEAD_ANSWER_TEMPLATE = { "en-GB": 'Per "{title}": {excerpt}', "nl-BE": 'Volgens "{title}": {excerpt}', "fr-BE": "Selon « {title} » : {excerpt}", } _DEFAULT_LANGUAGE = "en-GB" _MAX_SOURCE_CARDS = 3 _DOMAIN_CONCEPTS: dict[str, tuple[str, ...]] = { "damage": ("damage", "damaged", "schade", "beschadigd", "dommage", "endommagé"), "vehicle": ("vehicle", "car", "voertuig", "wagen", "véhicule", "voiture"), "return": ("return", "returned", "retour", "terugbrengen", "restitution"), "fuel": ("fuel", "brandstof", "carburant"), "booking": ("booking", "reservation", "boeking", "réservation"), "odometer": ("odometer", "mileage", "kilometer", "kilométrage", "compteur"), "customer": ("customer", "client", "klant"), "cleaning": ("cleaning", "reiniging", "poetsen", "nettoyage"), "maintenance": ("maintenance", "service", "onderhoud", "entretien"), "conflict": ("conflict", "overlap", "overlapping", "conflit", "chevauchement"), "checkout": ("checkout", "departure", "vertrek", "départ"), "availability": ("available", "availability", "beschikbaar", "disponible", "disponibilité"), "technical": ("technical", "warning", "technisch", "waarschuwing", "technique", "alerte"), } def _question_concepts(question: str) -> set[str]: normalized = question.casefold() return { concept for concept, terms in _DOMAIN_CONCEPTS.items() if any(term in normalized for term in terms) } def _deduplicate_sources(sources: list[SourceCard]) -> list[SourceCard]: """Collapse duplicate chunks and re-uploaded document versions. RAGcore document/version UUIDs change across uploads, so they are not useful deduplication keys. Human-visible citation identity is the normalized title, section. Chunks from the same unsectioned document collapse into one card; distinct named sections remain independently citable. """ seen: set[tuple[str, str]] = set() unique: list[SourceCard] = [] for source in sources: key = ( source.title.strip().casefold(), source.section.strip().casefold(), ) if key in seen: continue seen.add(key) unique.append(source) if len(unique) == _MAX_SOURCE_CARDS: break return unique def _rank_sources_for_concepts(sources: list[SourceCard], concepts: set[str]) -> list[SourceCard]: if "damage" not in concepts: return sources return sorted( sources, key=lambda source: ( 0 if "damage" in f"{source.document_id} {source.title}".casefold() else 1 ), ) 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 ` (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. `/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). 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}" source_document_count = sum( document.language == language for document in iter_procedure_documents(Path(self._settings.knowledge_dir)) ) 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. Unknown is explicit # so the UI never turns this into the misleading claim "0 procedures". document_count=None, source_document_count=source_document_count, reported_synced_document_count=None, reported_failed_document_count=None, last_sync_at=None, statistics_state="not_reported", ) 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 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( "/v1/answers", json={ "query": question, "requested_space_ids": [self._settings.ragcore_space_id], }, ) if response.status_code != 200: return None body = response.json() except (httpx.HTTPError, ValueError): return None 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() ] sources = _deduplicate_sources(sources) 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 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 sources = _deduplicate_sources(sources) concepts = _question_concepts(question) sources = _rank_sources_for_concepts(sources, concepts) if not sources: return GroundedAnswer( answer="", evidence_state="insufficient", sources=[], provider=self.name, correlation_id=correlation_id, ) if not concepts: return GroundedAnswer( answer="", evidence_state="insufficient", sources=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, excerpt=lead.excerpt) return GroundedAnswer( answer=answer, evidence_state="grounded", sources=sources, provider=self.name, correlation_id=correlation_id, )