Files
MobilityOps/backend/app/services/knowledge/ragcore.py
T

281 lines
12 KiB
Python

from __future__ import annotations
import httpx
from app.core.config import get_settings
from app.services.knowledge import EvidenceState, GroundedAnswer, KnowledgeHealth, SourceCard
_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"
_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]:
seen: set[tuple[str, str]] = set()
unique: list[SourceCard] = []
for source in sources:
key = (source.document_id, source.section)
if key in seen:
continue
seen.add(key)
unique.append(source)
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 <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.
`/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}"
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,
)
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()
]
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,
)