Publish curated MobilityOps source snapshot

This commit is contained in:
Jens committed 2026-09-29 22:03:33 +02:00
commit 7adc072f5e
317 files changed
+48851

No files matched your search

@@ -0,0 +1,67 @@
from __future__ import annotations
from datetime import datetime
from functools import lru_cache
from typing import Literal, Protocol
from pydantic import BaseModel
from app.core.config import get_settings
EvidenceState = Literal["grounded", "insufficient", "unavailable"]
KnowledgeStatisticsState = Literal["verified", "sync_reported", "not_reported"]
class SourceCard(BaseModel):
document_id: str
title: str
version: str
section: str
excerpt: str
class GroundedAnswer(BaseModel):
answer: str
evidence_state: EvidenceState
sources: list[SourceCard]
provider: str
correlation_id: str
class KnowledgeHealth(BaseModel):
provider: str
available: bool
detail: str
tenant: str
workspace: str
collection: str
# A provider may be healthy without exposing a corpus-size endpoint. `None` means
# unknown, never "zero procedures".
document_count: int | None
source_document_count: int
reported_synced_document_count: int | None
reported_failed_document_count: int | None
last_sync_at: datetime | None
statistics_state: KnowledgeStatisticsState
class KnowledgeProvider(Protocol):
name: str
def health(self, language: str = "en-GB") -> KnowledgeHealth: ...
def ask(
self, question: str, correlation_id: str, language: str = "en-GB"
) -> GroundedAnswer: ...
@lru_cache
def get_knowledge_provider() -> KnowledgeProvider:
settings = get_settings()
if settings.knowledge_provider == "ragcore":
from app.services.knowledge.ragcore import RAGcoreKnowledgeProvider
return RAGcoreKnowledgeProvider()
from app.services.knowledge.demo import DemoKnowledgeProvider
return DemoKnowledgeProvider()
+413
View File
@@ -0,0 +1,413 @@
from __future__ import annotations
import math
import re
from dataclasses import dataclass, field
from pathlib import Path
from app.core.config import get_settings
from app.services.knowledge import GroundedAnswer, KnowledgeHealth, SourceCard
from app.services.knowledge.procedures import parse_frontmatter
SUPPORTED_LANGUAGES = ("nl-BE", "en-GB", "fr-BE")
DEFAULT_LANGUAGE = "en-GB"
STOPWORDS_BY_LANGUAGE: dict[str, set[str]] = {
"en-GB": {
"a",
"an",
"the",
"is",
"are",
"was",
"were",
"be",
"been",
"being",
"to",
"of",
"in",
"on",
"at",
"for",
"and",
"or",
"but",
"if",
"then",
"do",
"does",
"did",
"must",
"may",
"can",
"could",
"should",
"would",
"i",
"you",
"it",
"we",
"they",
"my",
"your",
"what",
"when",
"how",
"with",
"without",
"this",
"that",
"these",
"those",
"not",
"no",
},
"nl-BE": {
"een",
"de",
"het",
"is",
"zijn",
"was",
"waren",
"worden",
"wordt",
"van",
"in",
"op",
"voor",
"en",
"of",
"maar",
"als",
"dan",
"moet",
"mag",
"kan",
"kunnen",
"zou",
"zouden",
"ik",
"jij",
"u",
"we",
"wij",
"zij",
"mijn",
"jouw",
"wat",
"wanneer",
"hoe",
"met",
"zonder",
"dit",
"dat",
"deze",
"die",
"niet",
"geen",
},
"fr-BE": {
"un",
"une",
"le",
"la",
"les",
"des",
"est",
"sont",
"était",
"être",
"de",
"du",
"en",
"sur",
"pour",
"et",
"ou",
"mais",
"si",
"alors",
"doit",
"peut",
"peuvent",
"pourrait",
"devrait",
"je",
"tu",
"vous",
"il",
"elle",
"nous",
"ils",
"mon",
"votre",
"quoi",
"quand",
"comment",
"avec",
"sans",
"ce",
"cette",
"ces",
"cela",
"pas",
"non",
},
}
# Includes the Latin-1 accented-letter range (à-ö, ø-ÿ) so French/Dutch words with
# diacritics (véhicule, réservation, geëscaleerd) tokenize as one word instead of
# splitting apart at the accented character -- a plain [a-z0-9]+ pattern silently
# drops every accent and fragments the word either side of it.
_WORD_RE = re.compile(r"[a-zà-öø-ÿ0-9]+")
def _stem(word: str) -> str:
# Deterministic, intentionally crude suffix stripping — good enough to match "returns"
# with "return" or "damaged" with "damage" without pulling in a stemming dependency.
for suffix in ("ing", "edly", "ed", "es", "s"):
if len(word) > len(suffix) + 2 and word.endswith(suffix):
return word[: -len(suffix)]
return word
def _tokenize(text: str, language: str) -> set[str]:
stopwords = STOPWORDS_BY_LANGUAGE.get(language, STOPWORDS_BY_LANGUAGE[DEFAULT_LANGUAGE])
words = _WORD_RE.findall(text.lower())
return {_stem(w) for w in words if w not in stopwords and len(w) > 2}
@dataclass
class Document:
document_id: str
title: str
version: str
title_tokens: set[str] = field(default_factory=set)
@dataclass
class ScoredSection:
document: Document
heading: str
text: str
heading_tokens: set[str]
body_tokens: set[str]
def _split_sections(body: str) -> list[tuple[str, str]]:
sections: list[tuple[str, str]] = []
current_heading = "Overview"
current_lines: list[str] = []
for line in body.splitlines():
if line.startswith("## "):
if current_lines:
sections.append((current_heading, "\n".join(current_lines).strip()))
current_heading = line[3:].strip()
current_lines = []
elif line.startswith("# "):
continue
else:
current_lines.append(line)
if current_lines:
sections.append((current_heading, "\n".join(current_lines).strip()))
return sections
def _load_sections(procedures_dir: Path, language: str) -> list[ScoredSection]:
sections: list[ScoredSection] = []
for path in sorted(procedures_dir.glob("*.md")):
raw = path.read_text(encoding="utf-8")
meta, body = parse_frontmatter(raw)
title = meta.get("title", path.stem)
doc = Document(
document_id=meta.get("document_id", path.stem),
title=title,
version=meta.get("version", "1.0"),
title_tokens=_tokenize(title, language),
)
for heading, text in _split_sections(body):
sections.append(
ScoredSection(
document=doc,
heading=heading,
text=text,
heading_tokens=_tokenize(heading, language),
body_tokens=_tokenize(text, language),
)
)
return sections
_NO_MATCH_TEXT = {
"en-GB": "No matching procedure was found for this question.",
"nl-BE": "Er werd geen passende procedure gevonden voor deze vraag.",
"fr-BE": "Aucune procédure correspondante n'a été trouvée pour cette question.",
}
_LOW_CONFIDENCE_TEXT = {
"en-GB": (
"The available procedures do not clearly answer this question. "
"The closest matches are included below for review."
),
"nl-BE": (
"De beschikbare procedures beantwoorden deze vraag niet duidelijk. "
"De dichtstbijzijnde overeenkomsten staan hieronder ter beoordeling."
),
"fr-BE": (
"Les procédures disponibles ne répondent pas clairement à cette question. "
"Les correspondances les plus proches sont indiquées ci-dessous pour examen."
),
}
_LEAD_ANSWER_TEMPLATE = {
"en-GB": 'Per "{title}" (v{version}), section "{heading}": {excerpt}',
"nl-BE": 'Volgens "{title}" (v{version}), sectie "{heading}": {excerpt}',
"fr-BE": "Selon « {title} » (v{version}), section « {heading} » : {excerpt}",
}
class DemoKnowledgeProvider:
"""Deterministic extractive retrieval over the local procedure Markdown files.
Not a generative model: it scores sections with TF-IDF-weighted keyword overlap
(downweighting terms common across the whole corpus, like "vehicle", in favor of
distinctive ones, like "damage") and returns real excerpts, never invented text.
Each supported UI language has its own translated procedure corpus under
knowledge/procedures/<language>/ -- retrieval searches only within the requested
language's corpus so citations always link to a same-language document.
"""
name = "demo"
def __init__(self) -> None:
settings = get_settings()
self._settings = settings
base_dir = Path(settings.knowledge_dir)
self._sections_by_language: dict[str, list[ScoredSection]] = {}
self._idf_by_language: dict[str, dict[str, float]] = {}
self._document_count_by_language: dict[str, int] = {}
for language in SUPPORTED_LANGUAGES:
lang_dir = base_dir / language
sections = _load_sections(lang_dir, language) if lang_dir.is_dir() else []
self._sections_by_language[language] = sections
self._idf_by_language[language] = self._build_idf(sections)
self._document_count_by_language[language] = len(
{s.document.document_id for s in sections}
)
@staticmethod
def _build_idf(sections: list[ScoredSection]) -> dict[str, float]:
n = len(sections) or 1
doc_freq: dict[str, int] = {}
for section in sections:
doc = section.document
all_tokens = doc.title_tokens | section.heading_tokens | section.body_tokens
for token in all_tokens:
doc_freq[token] = doc_freq.get(token, 0) + 1
return {token: math.log((n + 1) / (df + 1)) + 1 for token, df in doc_freq.items()}
def _normalize_language(self, language: str | None) -> str:
if language in SUPPORTED_LANGUAGES:
return language
return DEFAULT_LANGUAGE
def health(self, language: str = DEFAULT_LANGUAGE) -> KnowledgeHealth:
language = self._normalize_language(language)
return KnowledgeHealth(
provider=self.name,
available=True,
detail="Deterministic keyword-matching demo provider; no external service.",
tenant=self._settings.ragcore_tenant,
workspace=self._settings.ragcore_workspace,
collection=self._settings.ragcore_collection,
document_count=self._document_count_by_language[language],
source_document_count=self._document_count_by_language[language],
reported_synced_document_count=None,
reported_failed_document_count=None,
last_sync_at=None,
statistics_state="verified",
)
def _score(
self, query_tokens: set[str], section: ScoredSection, idf: dict[str, float]
) -> float:
# The section body is the strongest relevance signal -- it's the actual
# substance a heading or title can only hint at -- so a body match is weighted
# *above* heading/title matches, not below them. The previous 3x/2x/1x
# (heading/title/body) ordering let a single generic word in a heading (e.g.
# "vehicle", present in nearly every section) or a document's own title
# outrank a section whose body genuinely covers multiple, more distinctive
# query terms -- confirmed to misrank the brief's exact validation question in
# every one of the three languages (see docs/fleet-ops-correction/
# current-gap-audit.md and i18n-inventory.md): nl-BE picked a checkout section
# over the damage procedure, en-GB and fr-BE picked the return procedure over
# the damage procedure, purely from heading/title overlap on common words.
score = 0.0
for token in query_tokens:
token_idf = idf.get(token, 0.0)
if token_idf == 0.0:
continue
if token in section.body_tokens:
score += 3 * token_idf
elif token in section.heading_tokens:
score += 2 * token_idf
elif token in section.document.title_tokens:
score += 1.5 * token_idf
return score
def ask(
self, question: str, correlation_id: str, language: str = DEFAULT_LANGUAGE
) -> GroundedAnswer:
language = self._normalize_language(language)
sections = self._sections_by_language[language]
idf = self._idf_by_language[language]
query_tokens = _tokenize(question, language)
scored = [(self._score(query_tokens, section, idf), section) for section in sections]
scored = [(score, section) for score, section in scored if score > 0]
scored.sort(key=lambda item: item[0], reverse=True)
top = scored[:3]
if not top:
return GroundedAnswer(
answer=_NO_MATCH_TEXT[language],
evidence_state="insufficient",
sources=[],
provider=self.name,
correlation_id=correlation_id,
)
sources = [
SourceCard(
document_id=section.document.document_id,
title=section.document.title,
version=section.document.version,
section=section.heading,
excerpt=(section.text[:400] + "…") if len(section.text) > 400 else section.text,
)
for _, section in top
]
if top[0][0] < 3:
return GroundedAnswer(
answer=_LOW_CONFIDENCE_TEXT[language],
evidence_state="insufficient",
sources=sources,
provider=self.name,
correlation_id=correlation_id,
)
lead_section = top[0][1]
answer = _LEAD_ANSWER_TEMPLATE[language].format(
title=lead_section.document.title,
version=lead_section.document.version,
heading=lead_section.heading,
excerpt=lead_section.text.splitlines()[0][:300],
)
return GroundedAnswer(
answer=answer,
evidence_state="grounded",
sources=sources,
provider=self.name,
correlation_id=correlation_id,
)
@@ -0,0 +1,73 @@
from __future__ import annotations
import hashlib
import uuid
from dataclasses import dataclass
from pathlib import Path
SUPPORTED_LANGUAGES = ("nl-BE", "en-GB", "fr-BE")
# Stable across runs (and across which language ships first) so a document's RAGcore
# source_id never changes just because the sync ran on a different day or in a
# different order -- required for RAGcore's upload idempotency to work per document.
_SOURCE_ID_NAMESPACE = uuid.uuid5(
uuid.NAMESPACE_URL, "https://mobilityops.internal/knowledge/procedures"
)
def parse_frontmatter(raw: str) -> tuple[dict[str, str], str]:
if not raw.startswith("---"):
return {}, raw
end = raw.find("\n---", 3)
if end == -1:
return {}, raw
block = raw[3:end].strip()
body = raw[end + 4 :].lstrip("\n")
meta: dict[str, str] = {}
for line in block.splitlines():
if ":" not in line:
continue
key, _, value = line.partition(":")
meta[key.strip()] = value.strip().strip('"')
return meta, body
@dataclass(frozen=True)
class ProcedureDocument:
source_id: str
language: str
document_id: str
title: str
version: str
content: str
content_hash: str
def iter_procedure_documents(knowledge_dir: Path) -> list[ProcedureDocument]:
"""Read every procedure Markdown file Fleet Ops ships, across every supported
language, as a flat list ready for external sync (e.g. into RAGcore). Frontmatter
fields (title, version) come from the same files the demo knowledge provider
already reads -- see parse_frontmatter -- so the two never drift apart."""
documents: list[ProcedureDocument] = []
for language in SUPPORTED_LANGUAGES:
language_dir = knowledge_dir / language
if not language_dir.is_dir():
continue
for path in sorted(language_dir.glob("*.md")):
raw = path.read_text(encoding="utf-8")
meta, body = parse_frontmatter(raw)
document_id = meta.get("document_id", path.stem)
content = body.strip()
documents.append(
ProcedureDocument(
source_id=str(uuid.uuid5(_SOURCE_ID_NAMESPACE, f"{language}:{document_id}")),
language=language,
document_id=document_id,
title=meta.get("title", path.stem),
version=meta.get("version", "1.0"),
content=content,
content_hash=hashlib.sha256(content.encode("utf-8")).hexdigest(),
)
)
return documents
+577
View File
@@ -0,0 +1,577 @@
from __future__ import annotations
import hashlib
import re
import uuid
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.core.observability import KNOWLEDGE_PROVIDER_REQUESTS, KNOWLEDGE_RETRIEVAL_SCORE
from app.services.knowledge import EvidenceState, GroundedAnswer, KnowledgeHealth, SourceCard
from app.services.knowledge.procedures import ProcedureDocument, iter_procedure_documents
_GROUNDED_ANSWERABILITY = {"answerable", "partially_answerable"}
_ANSWERABILITY_STATES = _GROUNDED_ANSWERABILITY | {
"not_answerable",
"conflicting_evidence",
}
# 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"),
}
_ANSWERABLE_INTENT_CONCEPTS = frozenset(_DOMAIN_CONCEPTS) - {"vehicle", "customer"}
def _normalize_evidence_text(value: str) -> str:
return " ".join(re.findall(r"\w+", value.casefold()))
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
),
)
def _retrieval_score(result: object) -> float | None:
if not isinstance(result, dict):
return None
scores = result.get("scores")
if not isinstance(scores, dict):
return None
for name in ("rerank", "fused"):
value = scores.get(name)
if isinstance(value, int | float) and not isinstance(value, bool):
return float(value)
return None
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.
Retrieval is scoped to the stable RAGcore ``source_id`` values owned by Fleet Ops for
the requested UI language. Returned internal document/version UUIDs are deliberately
not treated as Fleet Ops identifiers: every citation must instead prove the complete
managed-source chain (source id, URI, locator, checksum and extractive local text).
"""
name = "ragcore"
def __init__(self) -> None:
self._settings = get_settings()
self._managed_documents_by_source_id: dict[str, ProcedureDocument] = {}
for document in iter_procedure_documents(Path(self._settings.knowledge_dir)):
self._managed_documents_by_source_id[document.source_id] = document
self._verification_cache: dict[str, tuple[float, int]] = {}
self._verification_lock = Lock()
self._answers_circuit_lock = Lock()
self._answers_circuit_open_until = 0.0
def _managed_source(self, citation: object, language: str) -> SourceCard | None:
if not isinstance(citation, dict):
return None
source_id = citation.get("source_id")
if not isinstance(source_id, str):
return None
document = self._managed_documents_by_source_id.get(str(source_id))
if document is None or document.language != language:
return None
if citation.get("locator") != f"{document.document_id}.md":
return None
if citation.get("source_uri") != f"ragcore://source/{document.source_id}":
return None
for field_name in ("id", "document_id", "document_version_id"):
value = citation.get(field_name)
if not isinstance(value, str):
return None
try:
parsed = uuid.UUID(value)
except (ValueError, TypeError, AttributeError):
return None
if parsed.int == 0:
return None
if not isinstance(citation.get("title"), str):
return None
section = citation.get("section")
if section is not None and not isinstance(section, str):
return None
excerpt = citation.get("excerpt")
if not isinstance(excerpt, str) or not excerpt.strip():
return None
expected_hash = hashlib.sha256(excerpt.encode("utf-8")).hexdigest()
if citation.get("excerpt_sha256") != expected_hash:
return None
# RAGcore owns chunking, but the cited excerpt must still be extractive evidence
# from the authoritative local procedure. Token normalization tolerates Markdown
# punctuation/whitespace while rejecting provider text that was never uploaded.
normalized_excerpt = _normalize_evidence_text(excerpt)
if not normalized_excerpt or normalized_excerpt not in _normalize_evidence_text(
document.content
):
return None
return SourceCard(
document_id=document.document_id,
title=document.title,
version=document.version,
section=section or "",
excerpt=excerpt,
)
def _managed_source_ids(self, language: str) -> list[str]:
return [
document.source_id
for document in self._managed_documents_by_source_id.values()
if document.language == language
]
def _answers_circuit_is_open(self) -> bool:
with self._answers_circuit_lock:
return monotonic() < self._answers_circuit_open_until
def _open_answers_circuit(self) -> None:
with self._answers_circuit_lock:
self._answers_circuit_open_until = monotonic() + max(
0.0, self._settings.ragcore_answers_circuit_breaker_seconds
)
def _close_answers_circuit(self) -> None:
with self._answers_circuit_lock:
self._answers_circuit_open_until = 0.0
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()
if not isinstance(body, dict):
raise ValueError("health response is not a JSON object")
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
if self._answers_circuit_is_open():
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "circuit_open").inc()
else:
answered = self._ask_via_answers(question, correlation_id, language)
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, language: 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],
"filters": {"source_ids": self._managed_source_ids(language)},
},
)
if response.status_code != 200:
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "non_2xx").inc()
self._open_answers_circuit()
return None
body = response.json()
except (httpx.HTTPError, ValueError):
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "error").inc()
self._open_answers_circuit()
return None
try:
if not isinstance(body, dict):
raise TypeError("answer response must be an object")
# Validate the provider's complete AnswerResponse contract before trusting
# generated prose. These IDs and claim bindings are the evidence that RAGcore
# ran its deterministic claim/citation validator; one unrelated but otherwise
# valid citation must never make arbitrary answer text appear grounded.
uuid.UUID(str(body["answer_id"]))
uuid.UUID(str(body["retrieval_run_id"]))
raw_citations = body.get("citations", [])
if not isinstance(raw_citations, list):
raise TypeError("citations must be a list")
citation_ids: set[uuid.UUID] = set()
for citation in raw_citations:
if not isinstance(citation, dict):
raise TypeError("citation must be an object")
citation_ids.add(uuid.UUID(str(citation["id"])))
raw_claims = body["claims"]
if not isinstance(raw_claims, list):
raise TypeError("claims must be a list")
claims_are_bound = bool(raw_claims)
answer_text = body.get("answer")
for claim in raw_claims:
if not isinstance(claim, dict):
raise TypeError("claim must be an object")
claim_text = claim.get("text")
claim_citation_ids = claim.get("citation_ids")
if (
not isinstance(claim_text, str)
or not claim_text.strip()
or not isinstance(answer_text, str)
or claim_text.strip() not in answer_text
or not isinstance(claim_citation_ids, list)
or not claim_citation_ids
):
claims_are_bound = False
continue
try:
bound_ids = {uuid.UUID(str(item)) for item in claim_citation_ids}
except (TypeError, ValueError):
claims_are_bound = False
continue
if not bound_ids.issubset(citation_ids):
claims_are_bound = False
mapped_sources = [
source
for citation in raw_citations
if (source := self._managed_source(citation, language)) is not None
]
citations_are_managed = len(mapped_sources) == len(raw_citations)
sources = _deduplicate_sources(mapped_sources)
answerability = body.get("answerability", "not_answerable")
if answerability not in _ANSWERABILITY_STATES:
raise TypeError("answerability is invalid")
if not isinstance(answer_text, str):
raise TypeError("answer must be a string")
is_grounded = (
answerability in _GROUNDED_ANSWERABILITY
and bool(sources)
and citations_are_managed
and claims_are_bound
and bool(answer_text.strip())
)
evidence_state: EvidenceState = "grounded" if is_grounded else "insufficient"
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", evidence_state).inc()
self._close_answers_circuit()
return GroundedAnswer(
answer=answer_text 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):
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "malformed").inc()
self._open_answers_circuit()
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],
"filters": {"source_ids": self._managed_source_ids(language)},
"max_results": 5,
},
)
if response.status_code != 200:
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "non_2xx").inc()
return unavailable
body = response.json()
except (httpx.HTTPError, ValueError):
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "error").inc()
return unavailable
try:
if not isinstance(body, dict):
raise TypeError("search response must be an object")
results = body.get("results", [])
if not isinstance(results, list):
raise TypeError("results must be a list")
sources = [
source
for result in results
if isinstance(result, dict)
and (source := self._managed_source(result.get("citation"), language)) is not None
]
except (TypeError, KeyError, ValueError):
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "malformed").inc()
return unavailable
for result in results:
if not isinstance(result, dict):
continue
score = _retrieval_score(result)
if score is not None:
KNOWLEDGE_RETRIEVAL_SCORE.observe(score)
all_sources = _deduplicate_sources(sources)
concepts = _question_concepts(question)
qualified_sources = [
source
for result in results
if isinstance(result, dict)
and (_retrieval_score(result) or 0.0) >= self._settings.ragcore_min_search_score
and (source := self._managed_source(result.get("citation"), language)) is not None
]
sources = _rank_sources_for_concepts(_deduplicate_sources(qualified_sources), concepts)
if not all_sources:
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "insufficient").inc()
return GroundedAnswer(
answer="",
evidence_state="insufficient",
sources=[],
provider=self.name,
correlation_id=correlation_id,
)
required_concepts = concepts & _ANSWERABLE_INTENT_CONCEPTS
evidence_text = " ".join(
f"{source.document_id} {source.title} {source.section} {source.excerpt}"
for source in sources
).casefold()
covered_concepts = {
concept
for concept in required_concepts
if any(term in evidence_text for term in _DOMAIN_CONCEPTS[concept])
}
if not required_concepts or not sources or covered_concepts != required_concepts:
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "insufficient").inc()
return GroundedAnswer(
answer="",
evidence_state="insufficient",
sources=all_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)
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "grounded").inc()
return GroundedAnswer(
answer=answer,
evidence_state="grounded",
sources=sources,
provider=self.name,
correlation_id=correlation_id,
)