383 lines
16 KiB
Python
383 lines
16 KiB
Python
from __future__ import annotations
|
|
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from pathlib import Path
|
|
from threading import Lock
|
|
from time import monotonic
|
|
|
|
import httpx
|
|
|
|
from app.core.config import get_settings
|
|
from app.services.knowledge import EvidenceState, GroundedAnswer, KnowledgeHealth, SourceCard
|
|
from app.services.knowledge.procedures import ProcedureDocument, 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
|
|
_INDEX_LOOKUP_TIMEOUT_SECONDS = 2.0
|
|
_INDEX_VERIFICATION_TTL_SECONDS = 300.0
|
|
_INDEX_VERIFICATION_WORKERS = 6
|
|
|
|
_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 <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()
|
|
self._verification_cache: dict[str, tuple[float, int]] = {}
|
|
self._verification_lock = Lock()
|
|
|
|
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:
|
|
documents = [
|
|
document
|
|
for document in iter_procedure_documents(Path(self._settings.knowledge_dir))
|
|
if document.language == language
|
|
]
|
|
verified_document_count: int | None = None
|
|
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')}"
|
|
)
|
|
if available:
|
|
verified_document_count = self._verify_indexed_documents(
|
|
client, language, documents
|
|
)
|
|
if verified_document_count is None:
|
|
detail += " Index verification is temporarily unavailable."
|
|
else:
|
|
detail += (
|
|
f" {verified_document_count}/{len(documents)} managed sources have "
|
|
"an exact active published document in the configured space."
|
|
)
|
|
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 deliberately has no browse/count endpoint. Fleet Ops instead
|
|
# verifies each managed source through its exact identity lookup and only
|
|
# counts an active document with a published active version. RAGcore's
|
|
# content_sha256 describes its canonical parsed artifact, not the uploaded
|
|
# source bytes, so comparing it with Fleet Ops's source hash would be false.
|
|
document_count=verified_document_count,
|
|
source_document_count=len(documents),
|
|
reported_synced_document_count=None,
|
|
reported_failed_document_count=None,
|
|
last_sync_at=None,
|
|
statistics_state=(
|
|
"verified" if verified_document_count is not None else "not_reported"
|
|
),
|
|
)
|
|
|
|
def _verify_indexed_documents(
|
|
self, client: httpx.Client, language: str, documents: list[ProcedureDocument]
|
|
) -> int | None:
|
|
if not self._settings.ragcore_space_id or not documents:
|
|
return None
|
|
|
|
now = monotonic()
|
|
with self._verification_lock:
|
|
cached = self._verification_cache.get(language)
|
|
if cached is not None and now - cached[0] < _INDEX_VERIFICATION_TTL_SECONDS:
|
|
return cached[1]
|
|
|
|
def is_verified(document: ProcedureDocument) -> bool:
|
|
response = client.get(
|
|
"/v1/documents",
|
|
params={
|
|
"source_id": document.source_id,
|
|
"external_id": f"{document.document_id}.md",
|
|
},
|
|
timeout=_INDEX_LOOKUP_TIMEOUT_SECONDS,
|
|
)
|
|
if response.status_code != 200:
|
|
raise RuntimeError("RAGcore document verification failed")
|
|
body = response.json()
|
|
items = body.get("items") if isinstance(body, dict) else None
|
|
if not isinstance(items, list) or len(items) != 1 or not isinstance(items[0], dict):
|
|
return False
|
|
item = items[0]
|
|
active_version = item.get("active_version")
|
|
return bool(
|
|
item.get("space_id") == self._settings.ragcore_space_id
|
|
and item.get("source_id") == document.source_id
|
|
and item.get("external_id") == f"{document.document_id}.md"
|
|
and item.get("status") == "active"
|
|
and isinstance(active_version, dict)
|
|
and active_version.get("status") == "published"
|
|
)
|
|
|
|
try:
|
|
with ThreadPoolExecutor(
|
|
max_workers=min(_INDEX_VERIFICATION_WORKERS, len(documents))
|
|
) as executor:
|
|
verified_count = sum(executor.map(is_verified, documents))
|
|
except (httpx.HTTPError, RuntimeError, TypeError, ValueError):
|
|
return None
|
|
|
|
with self._verification_lock:
|
|
self._verification_cache[language] = (monotonic(), verified_count)
|
|
return verified_count
|
|
|
|
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,
|
|
)
|