M5: implement RAGcore knowledge integration

KnowledgeProvider protocol with a deterministic TF-IDF-weighted extractive demo provider (never generative, always cites real excerpts) and a RAGcore HTTP adapter that degrades cleanly to unavailable. Knowledge nav + chat-style Q&A UI with source cards and honest grounded/insufficient/unavailable states. 57 backend tests passing, ruff clean. Fixed a real relevance bug (generic terms like "vehicle" crowding out distinctive matches) via IDF weighting, found by testing the actual S6 scenario. Verified end-to-end in the browser: grounded damage question cites both expected procedures; unrelated question honestly returns insufficient evidence with no fabrication.
This commit is contained in:
NuklearRabbit
2026-08-01 22:57:06 +02:00
parent 59d663a43a
commit b511ba2dbc
14 changed files with 710 additions and 3 deletions
+17 -3
View File
@@ -2,7 +2,7 @@
## Current milestone
M4 — complete. Starting M5 next.
M5 — complete. Starting M6 next.
## Locked decisions
@@ -94,10 +94,24 @@ M4 — complete. Starting M5 next.
- Full live round trip (not mocked): registered a real return on `BK-DEMO-RETURN` → outbox event queued → background dispatcher delivered it to the now-activated n8n workflow within its 3s poll interval → n8n called back into `/api/v1/integrations/n8n/return-callback` (200 OK, confirmed in `docker compose logs api`) → dispatcher's original POST received n8n's success response → event flipped to `succeeded` on attempt 1, visible on `/automation`.
- S5 scenario end-to-end in the browser: seeded `BK-H-0020` (`failed`, 3 attempts, "Synthetic connection timeout to n8n") → clicked Retry → `pending` → within ~3s, live dispatcher delivered it through the real n8n instance → `succeeded`, 4 attempts. This is the full documented S5 scenario working for real, not simulated.
### M5 — RAGcore knowledge integration
- `app/services/knowledge/__init__.py`: `KnowledgeProvider` Protocol (sync, not async — the rest of the backend is sync SQLAlchemy/FastAPI, so an async provider interface would have meant bridging paradigms for no benefit) with `health()`/`ask()`, plus `GroundedAnswer`/`SourceCard`/`KnowledgeHealth` Pydantic models matching `contracts/openapi.yaml`'s `GroundedAnswer` schema exactly. `get_knowledge_provider()` factory switches on `settings.knowledge_provider` ("demo" default, "ragcore" opt-in).
- `app/services/knowledge/demo.py``DemoKnowledgeProvider`: parses the 10 `knowledge/procedures/*.md` files' YAML frontmatter (hand-rolled flat parser, not PyYAML — avoided adding a dependency for a 6-key flat block) and `## `-delimited sections at startup, then does **TF-IDF-weighted keyword retrieval** (not naive keyword counting) with light suffix-stripping stemming (`returns``return`, `damaged``damage`). This is extractive, not generative: it returns real excerpts and a templated answer sentence, never invented text.
- **Real bug found and fixed by testing the actual S6 question, not by inspection**: naive flat keyword-overlap scoring (first cut) let the word "vehicle" — present in nearly every document's title — crowd out the actually-relevant `damage-procedure` document from the top-3 results for "What must I do when a vehicle returns with damage?", because generic words scored the same as distinctive ones. Fixed by computing corpus-wide IDF per token (`log((N+1)/(df+1)) + 1`) and weighting matches by it, so common terms contribute little and rare/distinctive terms (like "damage") dominate the ranking. Verified: the S6 question now returns `damage-procedure` and `vehicle-return-procedure` in the top 3, matching the documented expectation exactly.
- `app/services/knowledge/ragcore.py``RAGcoreKnowledgeProvider`: real `httpx` adapter guessing a plausible REST contract (`GET /health`, `POST /api/v1/ask`) per `contracts/ragcore-contract-assumptions.md` (RAGcore is built separately; no live instance was reachable this session to verify against). Any connection error, timeout, or malformed response degrades to `evidence_state: "unavailable"` rather than raising — this is the adapter that actually exercises the architecture's "RAGcore failure disables knowledge answers only" reliability boundary. Not wired as the active provider by default; `KNOWLEDGE_PROVIDER=ragcore` would need a real, verified base URL to turn on.
- `POST /api/v1/knowledge/questions` + `GET /api/v1/knowledge/status` (`app/api/routers/knowledge.py`). Audit event `knowledge_question_asked` logs `evidence_state`, `provider`, `source_ids`, and `question_length` only — **not** the question text itself, per `docs/12-security-and-audit.md` ("log question metadata and source IDs, not unnecessary full prompts").
- Knowledge nav + page (`pages/Knowledge.tsx`): chat-style question box, source cards (title/version/section/excerpt) prioritized over the answer text per `docs/06-ui-ux.md`, explicit `grounded`/`insufficient`/`unavailable` states with distinct visual treatment — never a fabricated-looking answer for the latter two.
- Dockerfile now also `COPY knowledge ./knowledge`; added `KNOWLEDGE_DIR` setting (`/app/knowledge/procedures` in-container, same pattern as `SEED_DIR`) rather than deriving the path from `__file__` — simpler and doesn't break if the module moves.
- Commands run and verified from this checkout:
- `docker compose run --rm api pytest -q`**57 passed** (new `tests/test_knowledge.py`: S6 grounded-with-expected-sources, unrelated question is honestly insufficient with no fabrication, demo provider health/document count, endpoint auth required, audit doesn't leak question text, RAGcore adapter degrades to unavailable on a simulated connection error).
- `docker compose run --rm api ruff check .` — All checks passed.
- `npm run build` — clean.
- Full browser run of S6 end-to-end: asked "What must I do when a vehicle returns with damage?" on `/knowledge` → grounded answer citing "Vehicle return procedure" (2 sections) and "Damage handling procedure" with real excerpts. Also asked an unrelated question ("What is the weather forecast for tomorrow?") → correctly returned "Insufficient evidence" / "No matching procedure was found" with zero sources, confirming no fabrication.
## Known blockers
None. External service credentials may be absent; use the documented demo/degraded providers. The n8n workflow-activation steps above are a one-time manual setup requirement in this environment, not a blocker — but not yet scripted; M7 should either automate it (e.g. a bootstrap script CI/compose can run) or document it clearly enough for `docs/14-testing-and-acceptance.md`'s clean-checkout criteria.
None. External service credentials may be absent; use the documented demo/degraded providers. The n8n workflow-activation steps from M4 are a one-time manual setup requirement in this environment, not a blocker — but not yet scripted; M7 should either automate it (e.g. a bootstrap script CI/compose can run) or document it clearly enough for `docs/14-testing-and-acceptance.md`'s clean-checkout criteria. RAGcore itself was never reachable this session — `RAGcoreKnowledgeProvider` is implemented and unit-tested for its unavailable-degradation path but its actual request/response contract against a real RAGcore instance is unverified; the demo provider is what M5's acceptance criteria are actually satisfied by.
## Exact next action
Start M5 (RAGcore knowledge integration): read `docs/09-ragcore-integration.md`, `knowledge/manifest.json`. Implement the `KnowledgeProvider` protocol (`health`, `ask`, `sync_manifest`), a `DemoKnowledgeProvider` doing deterministic keyword/BM25-style retrieval directly over `knowledge/procedures/*.md` (extractive, not generative — must return real excerpts + `insufficient`/`unavailable` states honestly, never fabricate), and a `RAGcoreKnowledgeProvider` adapter stub for the real service (`RAGCORE_BASE_URL` etc. are already in config/compose from M0, but no actual RAGcore instance is confirmed reachable this session — build the demo provider as the one that actually has to work for the acceptance criteria, and make the RAGcore adapter degrade to `unavailable` cleanly if unreachable, per `docs/03-architecture.md`'s reliability boundary "RAGcore failure disables knowledge answers only"). Add `POST /api/v1/knowledge/questions` and `GET /api/v1/knowledge/status`, a Knowledge nav item + page (chat-style question box, source cards with title/version/section/excerpt, explicit unavailable/insufficient states — no fabricated answers ever). S6 demo scenario: "What must I do when a vehicle returns with damage?" must cite `knowledge/procedures/03-damage-handling.md` and `02-vehicle-return.md`.
Start M6 (ITWorx MCP Hub publication): read `docs/10-mcp-hub-integration.md`, `contracts/mcp-tools.json`. Implement the four read-only, service-token-protected provider endpoints under `/api/v1/integrations/mcp/` (`operations-summary`, `attention-vehicles`, `vehicles/{public_ref}`, and a knowledge-search façade wrapping the M5 `KnowledgeProvider` — per `contracts/mcp-tools.json`'s `mobilityops_search_knowledge` tool and `docs/10-mcp-hub-integration.md`'s note to route through a narrow façade rather than duplicate retrieval logic). Auth: reuse the same shared-secret-header pattern already built for the n8n callback in M4 (`X-Service-Token`, a new `MCP_HUB_SERVICE_TOKEN` setting — `.env.example` already has the env var name reserved) rather than inventing a second auth mechanism. Must not expose generic SQL, arbitrary fetch, write/mutation actions, or secrets — these are read-only summaries only. Add MobilityOps-side service-request audit events (the Hub owns its own central tool-call audit; MobilityOps only needs to record that its provider APIs were reached, with tool name/correlation ID/client identity/result status). No actual ITWorx MCP Hub instance is confirmed reachable in this environment (same situation as RAGcore in M5) — validate the four provider endpoints directly via authenticated `curl`/tests rather than a live Hub round trip, and note that gap explicitly rather than claiming an unverified integration works.
+1
View File
@@ -9,6 +9,7 @@ COPY backend/alembic ./alembic
COPY backend/alembic.ini ./
COPY backend/tests ./tests
COPY seed ./seed
COPY knowledge ./knowledge
RUN pip install --no-cache-dir --no-deps -e .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+51
View File
@@ -0,0 +1,51 @@
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
from app.schemas import CurrentUser
from app.services.audit import record_audit_event
from app.services.knowledge import GroundedAnswer, KnowledgeHealth, get_knowledge_provider
router = APIRouter(prefix="/api/v1/knowledge", tags=["knowledge"])
class AskQuestionRequest(BaseModel):
question: str = Field(min_length=3, max_length=1000)
@router.post("/questions", response_model=GroundedAnswer)
def ask_question(
body: AskQuestionRequest,
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> GroundedAnswer:
correlation_id = str(uuid.uuid4())
provider = get_knowledge_provider()
answer = provider.ask(body.question, correlation_id)
record_audit_event(
db,
actor_type="user",
actor_label=user.display_name,
action="knowledge_question_asked",
entity_type="knowledge",
correlation_id=uuid.UUID(correlation_id),
metadata={
"evidence_state": answer.evidence_state,
"provider": answer.provider,
"source_ids": [s.document_id for s in answer.sources],
"question_length": len(body.question),
},
)
db.commit()
return answer
@router.get("/status", response_model=KnowledgeHealth)
def knowledge_status(_user: CurrentUser = Depends(get_current_user)) -> KnowledgeHealth:
return get_knowledge_provider().health()
+3
View File
@@ -14,6 +14,8 @@ class Settings(BaseSettings):
ragcore_tenant: str = "northstar-mobility-demo"
ragcore_workspace: str = "mobilityops"
ragcore_collection: str = "internal-procedures"
ragcore_api_token: str = ""
ragcore_http_timeout_seconds: float = 5.0
n8n_webhook_url: str = "http://n8n:5678/webhook/mobilityops-return"
n8n_callback_token: str = "replace-me-n8n-callback-token"
n8n_dispatch_enabled: bool = True
@@ -24,6 +26,7 @@ class Settings(BaseSettings):
session_cookie_name: str = "mobilityops_session"
session_ttl_seconds: int = 60 * 60 * 8
seed_dir: str = "/app/seed"
knowledge_dir: str = "/app/knowledge/procedures"
cors_allow_origins: str = "http://localhost:1228"
demo_today: str = "2026-08-01"
+2
View File
@@ -12,6 +12,7 @@ from app.api.routers import (
data_quality,
demo,
integrations,
knowledge,
vehicles,
workflows,
)
@@ -84,3 +85,4 @@ app.include_router(audit.router)
app.include_router(data_quality.router)
app.include_router(workflows.router)
app.include_router(integrations.router)
app.include_router(knowledge.router)
@@ -0,0 +1,56 @@
from __future__ import annotations
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"]
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
document_count: int
class KnowledgeProvider(Protocol):
name: str
def health(self) -> KnowledgeHealth: ...
def ask(self, question: str, correlation_id: str) -> 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()
+221
View File
@@ -0,0 +1,221 @@
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
STOPWORDS = {
"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",
}
_WORD_RE = re.compile(r"[a-z0-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) -> set[str]:
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 _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
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) -> 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),
)
for heading, text in _split_sections(body):
sections.append(
ScoredSection(
document=doc,
heading=heading,
text=text,
heading_tokens=_tokenize(heading),
body_tokens=_tokenize(text),
)
)
return sections
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.
"""
name = "demo"
def __init__(self) -> None:
settings = get_settings()
self._settings = settings
self._procedures_dir = Path(settings.knowledge_dir)
self._sections = _load_sections(self._procedures_dir)
self._document_count = len({s.document.document_id for s in self._sections})
self._idf = self._build_idf(self._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 health(self) -> KnowledgeHealth:
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,
)
def _score(self, query_tokens: set[str], section: ScoredSection) -> float:
score = 0.0
for token in query_tokens:
idf = self._idf.get(token, 0.0)
if idf == 0.0:
continue
if token in section.heading_tokens:
score += 3 * idf
elif token in section.document.title_tokens:
score += 2 * idf
elif token in section.body_tokens:
score += idf
return score
def ask(self, question: str, correlation_id: str) -> GroundedAnswer:
query_tokens = _tokenize(question)
scored = [
(self._score(query_tokens, section), section)
for section in self._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 matching procedure was found for this question.",
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=(
"The available procedures do not clearly answer this question. "
"The closest matches are included below for review."
),
evidence_state="insufficient",
sources=sources,
provider=self.name,
correlation_id=correlation_id,
)
lead_section = top[0][1]
answer = (
f'Per "{lead_section.document.title}" (v{lead_section.document.version}), '
f'section "{lead_section.heading}": {lead_section.text.splitlines()[0][:300]}'
)
return GroundedAnswer(
answer=answer,
evidence_state="grounded",
sources=sources,
provider=self.name,
correlation_id=correlation_id,
)
+98
View File
@@ -0,0 +1,98 @@
from __future__ import annotations
import httpx
from app.core.config import get_settings
from app.services.knowledge import GroundedAnswer, KnowledgeHealth, SourceCard
class RAGcoreKnowledgeProvider:
"""Adapter for the central RAGcore service.
RAGcore is built and owned separately (see contracts/ragcore-contract-assumptions.md).
No live RAGcore instance was reachable during this build, so the exact request/response
shape below is a best-effort guess at a REST contract; any failure (connection, timeout,
malformed response) degrades to `unavailable` rather than raising, per the architecture's
reliability boundary: RAGcore failure disables knowledge answers only, never the rest of
the app, and never fabricates an answer.
"""
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) -> KnowledgeHealth:
try:
with self._client() as client:
response = client.get("/health")
response.raise_for_status()
available = True
detail = "RAGcore reachable."
except httpx.HTTPError 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,
document_count=0,
)
def ask(self, question: str, correlation_id: str) -> GroundedAnswer:
try:
with self._client() as client:
response = client.post(
"/api/v1/ask",
json={
"tenant": self._settings.ragcore_tenant,
"workspace": self._settings.ragcore_workspace,
"collection": self._settings.ragcore_collection,
"question": question,
"correlation_id": correlation_id,
},
)
response.raise_for_status()
body = response.json()
except (httpx.HTTPError, ValueError):
return GroundedAnswer(
answer="",
evidence_state="unavailable",
sources=[],
provider=self.name,
correlation_id=correlation_id,
)
try:
sources = [SourceCard(**s) for s in body.get("sources", [])]
evidence_state = body.get("evidence_state", "insufficient")
if evidence_state not in ("grounded", "insufficient", "unavailable"):
evidence_state = "insufficient"
return GroundedAnswer(
answer=body.get("answer", ""),
evidence_state=evidence_state,
sources=sources,
provider=self.name,
correlation_id=correlation_id,
)
except (TypeError, ValueError):
return GroundedAnswer(
answer="",
evidence_state="unavailable",
sources=[],
provider=self.name,
correlation_id=correlation_id,
)
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
import httpx
from app.services.knowledge.demo import DemoKnowledgeProvider
from app.services.knowledge.ragcore import RAGcoreKnowledgeProvider
def test_s6_damage_question_is_grounded_with_expected_sources():
provider = DemoKnowledgeProvider()
answer = provider.ask(
"What must I do when a vehicle returns with damage?", "test-correlation-1"
)
assert answer.evidence_state == "grounded"
document_ids = {s.document_id for s in answer.sources}
assert "damage-procedure" in document_ids
assert "vehicle-return-procedure" in document_ids
assert answer.answer # never empty for a grounded answer
for source in answer.sources:
assert source.excerpt
def test_unrelated_question_is_insufficient():
provider = DemoKnowledgeProvider()
answer = provider.ask("What is the capital of France?", "test-correlation-2")
assert answer.evidence_state == "insufficient"
assert "France" not in answer.answer # never fabricates an answer beyond the procedures
def test_demo_provider_health_reports_document_count():
provider = DemoKnowledgeProvider()
health = provider.health()
assert health.provider == "demo"
assert health.available is True
assert health.document_count == 10
def test_ask_question_endpoint_grounded(ops_client):
response = ops_client.post(
"/api/v1/knowledge/questions",
json={"question": "What must I do when a vehicle returns with damage?"},
)
assert response.status_code == 200
body = response.json()
assert body["evidence_state"] == "grounded"
assert body["provider"] == "demo"
assert len(body["sources"]) > 0
def test_ask_question_requires_authentication(client):
response = client.post("/api/v1/knowledge/questions", json={"question": "Anything?"})
assert response.status_code == 401
def test_ask_question_is_audited_without_leaking_full_text(ops_client):
ops_client.post(
"/api/v1/knowledge/questions",
json={"question": "What must I do when a vehicle returns with damage?"},
)
events = ops_client.get(
"/api/v1/audit", params={"action": "knowledge_question_asked"}
).json()
assert len(events) >= 1
metadata = events[0]["metadata"]
assert "evidence_state" in metadata
assert "source_ids" in metadata
assert "question" not in metadata
def test_knowledge_status_endpoint(ops_client):
response = ops_client.get("/api/v1/knowledge/status")
assert response.status_code == 200
assert response.json()["provider"] == "demo"
def test_ragcore_provider_degrades_to_unavailable(monkeypatch):
def fake_client(*args, **kwargs):
raise httpx.ConnectError("no ragcore in this environment")
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider, "_client", fake_client)
answer = provider.ask("Anything?", "test-correlation-3")
assert answer.evidence_state == "unavailable"
assert answer.sources == []
+2
View File
@@ -11,6 +11,7 @@ import { BookingDetail } from "./pages/BookingDetail";
import { DataQuality } from "./pages/DataQuality";
import { DataQualityIssueDetail } from "./pages/DataQualityIssueDetail";
import { Automation } from "./pages/Automation";
import { Knowledge } from "./pages/Knowledge";
import { Audit } from "./pages/Audit";
export function App() {
@@ -33,6 +34,7 @@ export function App() {
<Route path="/data-quality" element={<DataQuality />} />
<Route path="/data-quality/:publicRef" element={<DataQualityIssueDetail />} />
<Route path="/automation" element={<Automation />} />
<Route path="/knowledge" element={<Knowledge />} />
<Route path="/audit" element={<Audit />} />
</Route>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
+26
View File
@@ -167,6 +167,32 @@ export interface MergeCustomersResult {
rewired_bookings: number;
}
export interface SourceCard {
document_id: string;
title: string;
version: string;
section: string;
excerpt: string;
}
export interface GroundedAnswer {
answer: string;
evidence_state: "grounded" | "insufficient" | "unavailable";
sources: SourceCard[];
provider: string;
correlation_id: string;
}
export interface KnowledgeHealth {
provider: string;
available: boolean;
detail: string;
tenant: string;
workspace: string;
collection: string;
document_count: number;
}
export interface AuditEvent {
id: string;
actor_type: string;
+1
View File
@@ -6,6 +6,7 @@ const NAV_ITEMS = [
{ to: "/vehicles", label: "Vehicles" },
{ to: "/bookings", label: "Bookings" },
{ to: "/data-quality", label: "Data Quality" },
{ to: "/knowledge", label: "Knowledge" },
{ to: "/automation", label: "Automation" },
{ to: "/audit", label: "Audit" },
];
+121
View File
@@ -0,0 +1,121 @@
import { useEffect, useState, type FormEvent } from "react";
import { api, ApiError } from "../api/client";
import type { GroundedAnswer, KnowledgeHealth } from "../api/types";
interface Exchange {
question: string;
answer: GroundedAnswer;
}
const EVIDENCE_LABEL: Record<GroundedAnswer["evidence_state"], string> = {
grounded: "Grounded in cited procedures",
insufficient: "Insufficient evidence",
unavailable: "Knowledge service unavailable",
};
export function Knowledge() {
const [status, setStatus] = useState<KnowledgeHealth | null>(null);
const [question, setQuestion] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [exchanges, setExchanges] = useState<Exchange[]>([]);
useEffect(() => {
api
.get<KnowledgeHealth>("/api/v1/knowledge/status")
.then(setStatus)
.catch(() => setStatus(null));
}, []);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!question.trim()) return;
setError(null);
setSubmitting(true);
try {
const answer = await api.post<GroundedAnswer>("/api/v1/knowledge/questions", { question });
setExchanges((prev) => [{ question, answer }, ...prev]);
setQuestion("");
} catch (err) {
setError(err instanceof ApiError ? err.message : "Could not reach the knowledge service.");
} finally {
setSubmitting(false);
}
}
return (
<div className="page">
<h1>Knowledge</h1>
{status && (
<p className="knowledge-status">
Provider: <strong>{status.provider}</strong> ·{" "}
{status.available ? "available" : "unavailable"} · {status.document_count} procedures
indexed
</p>
)}
<form className="panel knowledge-form" onSubmit={handleSubmit} aria-labelledby="ask-heading">
<h2 id="ask-heading">Ask a procedure question</h2>
<label htmlFor="knowledge-question" className="visually-hidden">
Question
</label>
<div className="knowledge-input-row">
<input
id="knowledge-question"
type="text"
value={question}
onChange={(e) => setQuestion(e.target.value)}
placeholder="e.g. What must I do when a vehicle returns with damage?"
minLength={3}
maxLength={1000}
required
/>
<button type="submit" disabled={submitting}>
{submitting ? "Asking…" : "Ask"}
</button>
</div>
{error && <p className="error" role="alert">{error}</p>}
</form>
{exchanges.length === 0 && !error && (
<p>Ask a question about one of the ten operational procedures to see cited sources.</p>
)}
<ul className="exchange-list">
{exchanges.map((exchange, index) => (
<li key={index} className="panel exchange">
<p className="exchange-question">
<strong>Q:</strong> {exchange.question}
</p>
<p className={`evidence-state evidence-${exchange.answer.evidence_state}`}>
{EVIDENCE_LABEL[exchange.answer.evidence_state]}
</p>
{exchange.answer.evidence_state === "unavailable" ? (
<p>
The knowledge service is currently unreachable. Operational features are
unaffected try again later.
</p>
) : (
<p>{exchange.answer.answer}</p>
)}
{exchange.answer.sources.length > 0 && (
<ul className="source-cards">
{exchange.answer.sources.map((source) => (
<li key={`${source.document_id}-${source.section}`} className="source-card">
<p className="source-title">
{source.title} <span className="source-version">v{source.version}</span>
</p>
<p className="source-section">{source.section}</p>
<p className="source-excerpt">{source.excerpt}</p>
</li>
))}
</ul>
)}
</li>
))}
</ul>
</div>
);
}
+27
View File
@@ -226,6 +226,33 @@ a { color: #1f5c8f; }
}
.data-table td button:disabled { opacity: 0.6; cursor: not-allowed; }
.knowledge-status { color: #607084; font-size: 0.9rem; margin-bottom: 16px; }
.knowledge-form { margin-bottom: 20px; }
.knowledge-input-row { display: flex; gap: 10px; flex-wrap: wrap; }
.knowledge-input-row input {
flex: 1; min-width: 240px; padding: 10px 12px; border: 1px solid #cfd8e2;
border-radius: 8px; font-size: 0.95rem;
}
.knowledge-input-row button {
padding: 10px 20px; border-radius: 8px; border: none;
background: #14324f; color: white; font-weight: 700; cursor: pointer;
}
.knowledge-input-row button:disabled { opacity: 0.6; cursor: not-allowed; }
.exchange-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 16px; }
.exchange-question { font-size: 1rem; margin: 0 0 6px; }
.evidence-state { display: inline-block; margin: 0 0 10px; padding: 3px 10px; border-radius: 999px; font-weight: 700; font-size: 0.8rem; }
.evidence-grounded { background: #e6f5ec; color: #1f6d3d; }
.evidence-insufficient { background: #fdf1de; color: #8a5a10; }
.evidence-unavailable { background: #fbe6e6; color: #8f2323; }
.source-cards { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
.source-card { background: #f6f8fb; border: 1px solid #dce3eb; border-radius: 10px; padding: 12px; }
.source-title { margin: 0; font-weight: 700; }
.source-version { color: #607084; font-weight: 400; font-size: 0.85rem; }
.source-section { margin: 2px 0 6px; color: #375065; font-size: 0.85rem; font-weight: 600; }
.source-excerpt { margin: 0; font-size: 0.88rem; color: #47566b; }
@media (max-width: 700px) {
.app-header { flex-direction: column; align-items: flex-start; }
.user-badge { margin-left: 0; }