M31: verify RAG inventory and polish attention queue
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s

This commit is contained in:
NuklearRabbit
2026-08-10 20:58:26 +02:00
parent cfffb1ce54
commit efab8d816f
16 changed files with 314 additions and 38 deletions
+23
View File
@@ -2697,3 +2697,26 @@ evidence yet."
summary were refreshed in `artifacts/evidence/`. summary were refreshed in `artifacts/evidence/`.
- Exact next action: commit/push this evidence-only hand-off update and refresh the - Exact next action: commit/push this evidence-only hand-off update and refresh the
server's source archive/revision marker; no runtime rebuild or database change is needed. server's source archive/revision marker; no runtime rebuild or database change is needed.
## M31 — verified RAG inventory and dashboard attention polish (2026-08-10)
- RAGcore health now verifies every language-specific managed source through the
documented exact `/v1/documents` identity lookup. Only an active document with a
published active version and the authoritative Fleet Ops content hash is counted.
Lookups are bounded, concurrent and cached for five minutes; an unavailable verifier
remains explicitly unknown instead of being presented as zero or as a reported count.
- Persisted n8n sync provenance no longer downgrades the stronger provider-verified
statistics state. The Knowledge Hub shows the verified count as a separate evidence
fact alongside source, sync and recency evidence, in all three supported languages.
- The dashboard's remaining-attention action is now a compact 52 px evidence-backed row
with count badge, legible title/hint and a 14 px directional icon. The attention panel
no longer stretches to the neighbouring full-day timeline; desktop and responsive
layouts remain overflow-free.
- Validation: isolated backend **241 passed**; Ruff clean; mypy clean across 58 files in
the locked container; frontend lint/build passed; focused visual regressions **5 passed**;
localized knowledge regressions **3 passed**; complete Playwright **152 passed in 5.4
minutes**; full and production npm audits report zero vulnerabilities. In-app visual
inspection confirmed the new action dimensions and presentation.
- Exact next action: commit and push M31, create and verify a live database backup,
redeploy the committed archive, then verify live RAG inventory, create a real post-reset
MCP Hub tool-call audit record, and complete production browser/acceptance evidence.
+7 -1
View File
@@ -124,6 +124,12 @@ def knowledge_status(
"reported_synced_document_count": synced if isinstance(synced, int) else None, "reported_synced_document_count": synced if isinstance(synced, int) else None,
"reported_failed_document_count": failed if isinstance(failed, int) else None, "reported_failed_document_count": failed if isinstance(failed, int) else None,
"last_sync_at": latest_sync.occurred_at, "last_sync_at": latest_sync.occurred_at,
"statistics_state": "sync_reported", # A persisted sync callback is useful additional provenance, but must not
# downgrade stronger provider-side verification to merely "reported".
"statistics_state": (
health.statistics_state
if health.statistics_state == "verified"
else "sync_reported"
),
} }
) )
+85 -10
View File
@@ -1,12 +1,15 @@
from __future__ import annotations from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path from pathlib import Path
from threading import Lock
from time import monotonic
import httpx import httpx
from app.core.config import get_settings from app.core.config import get_settings
from app.services.knowledge import EvidenceState, GroundedAnswer, KnowledgeHealth, SourceCard from app.services.knowledge import EvidenceState, GroundedAnswer, KnowledgeHealth, SourceCard
from app.services.knowledge.procedures import iter_procedure_documents from app.services.knowledge.procedures import ProcedureDocument, iter_procedure_documents
_GROUNDED_ANSWERABILITY = {"answerable", "partially_answerable"} _GROUNDED_ANSWERABILITY = {"answerable", "partially_answerable"}
@@ -24,6 +27,9 @@ _LEAD_ANSWER_TEMPLATE = {
} }
_DEFAULT_LANGUAGE = "en-GB" _DEFAULT_LANGUAGE = "en-GB"
_MAX_SOURCE_CARDS = 3 _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, ...]] = { _DOMAIN_CONCEPTS: dict[str, tuple[str, ...]] = {
"damage": ("damage", "damaged", "schade", "beschadigd", "dommage", "endommagé"), "damage": ("damage", "damaged", "schade", "beschadigd", "dommage", "endommagé"),
@@ -118,6 +124,8 @@ class RAGcoreKnowledgeProvider:
def __init__(self) -> None: def __init__(self) -> None:
self._settings = get_settings() self._settings = get_settings()
self._verification_cache: dict[str, tuple[float, int]] = {}
self._verification_lock = Lock()
def _client(self) -> httpx.Client: def _client(self) -> httpx.Client:
headers = {} headers = {}
@@ -130,6 +138,12 @@ class RAGcoreKnowledgeProvider:
) )
def health(self, language: str = "en-GB") -> KnowledgeHealth: 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: try:
with self._client() as client: with self._client() as client:
response = client.get("/health/ready") response = client.get("/health/ready")
@@ -140,13 +154,20 @@ class RAGcoreKnowledgeProvider:
if available if available
else f"RAGcore degraded: {body.get('status', 'unknown')}" 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 active published version with the expected content hash."
)
except (httpx.HTTPError, ValueError) as exc: except (httpx.HTTPError, ValueError) as exc:
available = False available = False
detail = f"RAGcore unavailable: {type(exc).__name__}: {exc}" 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( return KnowledgeHealth(
provider=self.name, provider=self.name,
available=available, available=available,
@@ -154,16 +175,70 @@ class RAGcoreKnowledgeProvider:
tenant=self._settings.ragcore_tenant, tenant=self._settings.ragcore_tenant,
workspace=self._settings.ragcore_workspace, workspace=self._settings.ragcore_workspace,
collection=self._settings.ragcore_collection, collection=self._settings.ragcore_collection,
# RAGcore's retrieval API has no corpus-size endpoint. Unknown is explicit # RAGcore deliberately has no browse/count endpoint. Fleet Ops instead
# so the UI never turns this into the misleading claim "0 procedures". # verifies each managed source through its exact identity lookup and only
document_count=None, # counts an active published version whose content hash still matches.
source_document_count=source_document_count, document_count=verified_document_count,
source_document_count=len(documents),
reported_synced_document_count=None, reported_synced_document_count=None,
reported_failed_document_count=None, reported_failed_document_count=None,
last_sync_at=None, last_sync_at=None,
statistics_state="not_reported", 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"
and active_version.get("content_sha256") == document.content_hash
)
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: def ask(self, question: str, correlation_id: str, language: str = "en-GB") -> GroundedAnswer:
unavailable = GroundedAnswer( unavailable = GroundedAnswer(
answer="", answer="",
+92 -2
View File
@@ -9,6 +9,7 @@ from app.api.routers import knowledge as knowledge_router
from app.core.config import get_settings from app.core.config import get_settings
from app.services.knowledge import KnowledgeHealth from app.services.knowledge import KnowledgeHealth
from app.services.knowledge.demo import DemoKnowledgeProvider from app.services.knowledge.demo import DemoKnowledgeProvider
from app.services.knowledge.procedures import iter_procedure_documents
from app.services.knowledge.ragcore import RAGcoreKnowledgeProvider from app.services.knowledge.ragcore import RAGcoreKnowledgeProvider
@@ -198,6 +199,39 @@ def test_ragcore_status_separates_sync_report_from_unverifiable_index(
assert status["statistics_state"] == "sync_reported" assert status["statistics_state"] == "sync_reported"
def test_ragcore_status_preserves_stronger_verified_index_evidence(client, ops_client, monkeypatch):
class VerifiedRagcoreProvider:
def health(self, language="en-GB"):
return KnowledgeHealth(
provider="ragcore",
available=True,
detail="ready and verified",
tenant="fleet-ops",
workspace="operations",
collection="internal-procedures",
document_count=11,
source_document_count=11,
reported_synced_document_count=None,
reported_failed_document_count=None,
last_sync_at=None,
statistics_state="verified",
)
monkeypatch.setattr(knowledge_router, "get_knowledge_provider", VerifiedRagcoreProvider)
settings = get_settings()
sync = client.post(
"/api/v1/integrations/n8n/procedures-sync-result",
json={"execution_id": "rag-verified-statistics-test", "synced": 33, "failed": 0},
headers={"X-Service-Token": settings.n8n_callback_token},
)
assert sync.status_code == 200
status = ops_client.get("/api/v1/knowledge/status?language=nl-BE").json()
assert status["document_count"] == 11
assert status["reported_synced_document_count"] == 33
assert status["statistics_state"] == "verified"
class _FakeResponse: class _FakeResponse:
def __init__(self, status_code: int, body: dict): def __init__(self, status_code: int, body: dict):
self.status_code = status_code self.status_code = status_code
@@ -208,7 +242,14 @@ class _FakeResponse:
class _FakeClient: class _FakeClient:
def __init__(self, get_response=None, post_response=None, post_responses=None, raise_on=None): def __init__(
self,
get_response=None,
post_response=None,
post_responses=None,
raise_on=None,
get_handler=None,
):
self._get_response = get_response self._get_response = get_response
self._post_response = post_response self._post_response = post_response
# Maps a path (e.g. "/v1/search") to its own response, for tests that need # Maps a path (e.g. "/v1/search") to its own response, for tests that need
@@ -217,6 +258,7 @@ class _FakeClient:
# existing single-endpoint test keeps working unchanged. # existing single-endpoint test keeps working unchanged.
self._post_responses = post_responses or {} self._post_responses = post_responses or {}
self._raise_on = raise_on self._raise_on = raise_on
self._get_handler = get_handler
def __enter__(self): def __enter__(self):
return self return self
@@ -224,9 +266,13 @@ class _FakeClient:
def __exit__(self, *args): def __exit__(self, *args):
return False return False
def get(self, path): def get(self, path, params=None, timeout=None):
if self._raise_on == "get": if self._raise_on == "get":
raise httpx.ConnectError("no ragcore in this environment") raise httpx.ConnectError("no ragcore in this environment")
if self._get_handler is not None:
return self._get_handler(path, params, timeout)
if path == "/v1/documents":
return _FakeResponse(503, {})
return self._get_response return self._get_response
def post(self, path, json=None): def post(self, path, json=None):
@@ -273,6 +319,50 @@ def test_ragcore_provider_health_reports_ready_status(monkeypatch):
assert health.statistics_state == "not_reported" assert health.statistics_state == "not_reported"
def test_ragcore_provider_verifies_published_documents_and_caches_count(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
procedure_documents = [
document
for document in iter_procedure_documents(Path(provider._settings.knowledge_dir))
if document.language == "en-GB"
]
documents = {document.source_id: document for document in procedure_documents}
lookups: list[str] = []
def get_handler(path, params, _timeout):
if path == "/health/ready":
return _FakeResponse(200, {"status": "ok"})
document = documents[params["source_id"]]
lookups.append(document.source_id)
return _FakeResponse(
200,
{
"items": [
{
"space_id": "space-1",
"source_id": document.source_id,
"external_id": f"{document.document_id}.md",
"status": "active",
"active_version": {
"status": "published",
"content_sha256": document.content_hash,
},
}
]
},
)
monkeypatch.setattr(provider, "_client", lambda: _FakeClient(get_handler=get_handler))
first = provider.health("en-GB")
second = provider.health("en-GB")
assert first.document_count == 11
assert first.statistics_state == "verified"
assert second.document_count == 11
assert len(lookups) == 11
def test_ragcore_provider_health_reports_degraded_status(monkeypatch): def test_ragcore_provider_health_reports_degraded_status(monkeypatch):
provider = RAGcoreKnowledgeProvider() provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr( monkeypatch.setattr(
+11 -6
View File
@@ -26,12 +26,17 @@ metadata.
- `reported_synced_document_count`, `reported_failed_document_count` and `last_sync_at`: the latest persisted result reported by the central n8n synchronization workflow; - `reported_synced_document_count`, `reported_failed_document_count` and `last_sync_at`: the latest persisted result reported by the central n8n synchronization workflow;
- `document_count`: documents independently verified as indexed by the active provider. - `document_count`: documents independently verified as indexed by the active provider.
The current RAGcore contract deliberately has no corpus-size or space-browse endpoint. For The RAGcore contract deliberately has no corpus-size or space-browse endpoint. It does
the RAGcore provider, `document_count` therefore remains `null`; a successful upload report provide an exact identity lookup through `GET /v1/documents?source_id=...&external_id=...`.
is never relabelled as proof that indexing and publishing completed. The deterministic demo Fleet Ops uses that documented read contract concurrently and with bounded per-request
provider can verify its in-memory corpus and reports `statistics_state=verified`. RAGcore timeouts for every managed source in the requested language. A document counts only when
reports `sync_reported` only when a persisted workflow callback exists, otherwise RAGcore returns exactly one active document in the configured space with a published active
`not_reported`. version whose `content_sha256` matches Fleet Ops's authoritative file. Results are cached
for five minutes. This produces an independently verified `document_count` without changing
RAGcore or relabelling an upload/sync report as index evidence. If exact verification is
temporarily unavailable, `document_count` remains `null` and the sync report stays visibly
separate. Both the deterministic provider and a successful exact RAGcore check report
`statistics_state=verified`.
## Required adapter interface ## Required adapter interface
+6 -3
View File
@@ -233,25 +233,28 @@ test.describe("MO-016 status conflict is order-independent", () => {
}); });
test.describe("knowledge base is grounded in the operator's own language", () => { test.describe("knowledge base is grounded in the operator's own language", () => {
const cases: { lang: string; question: string; sourceHint: RegExp }[] = [ const cases: { lang: string; question: string; sourceHint: RegExp; sourceTitle: string }[] = [
{ {
lang: "nl-BE", lang: "nl-BE",
question: "Wat moet ik doen wanneer een voertuig beschadigd terugkomt?", question: "Wat moet ik doen wanneer een voertuig beschadigd terugkomt?",
sourceHint: /zichtbare of gemelde schade/i, sourceHint: /zichtbare of gemelde schade/i,
sourceTitle: "Procedure schadeafhandeling",
}, },
{ {
lang: "en-GB", lang: "en-GB",
question: "What should I do when a vehicle returns with damage?", question: "What should I do when a vehicle returns with damage?",
sourceHint: /visible or reported damage/i, sourceHint: /visible or reported damage/i,
sourceTitle: "Damage handling procedure",
}, },
{ {
lang: "fr-BE", lang: "fr-BE",
question: "Que dois-je faire lorsqu'un véhicule revient endommagé ?", question: "Que dois-je faire lorsqu'un véhicule revient endommagé ?",
sourceHint: /dommages visibles ou signalés/i, sourceHint: /dommages visibles ou signalés/i,
sourceTitle: "Procédure de gestion des dommages",
}, },
]; ];
for (const { lang, question, sourceHint } of cases) { for (const { lang, question, sourceHint, sourceTitle } of cases) {
test(`grounded ${lang} answer cites a ${lang} source about damage`, async ({ page, request }) => { test(`grounded ${lang} answer cites a ${lang} source about damage`, async ({ page, request }) => {
await resetDemoData(request); await resetDemoData(request);
await loginAsOpsManager(page, lang); await loginAsOpsManager(page, lang);
@@ -260,7 +263,7 @@ test.describe("knowledge base is grounded in the operator's own language", () =>
await page.getByRole("button", { name: /^(Vraag stellen|Ask|Demander)$/ }).click(); await page.getByRole("button", { name: /^(Vraag stellen|Ask|Demander)$/ }).click();
const localizedSource = page.locator(".source-card", { hasText: sourceHint }).first(); const localizedSource = page.locator(".source-card", { hasText: sourceHint }).first();
await expect(localizedSource).toBeVisible({ timeout: 15_000 }); await expect(localizedSource).toBeVisible({ timeout: 15_000 });
await expect(localizedSource).toContainText(/Schade bij voertuigretour|Vehicle return damage|Dommages au retour du véhicule/i); await expect(localizedSource).toContainText(sourceTitle);
}); });
} }
}); });
+49 -3
View File
@@ -1,7 +1,13 @@
import { expect, test } from "@playwright/test"; import { expect, test, type Page } from "@playwright/test";
test.describe.configure({ mode: "serial" }); test.describe.configure({ mode: "serial" });
async function loginAsOperationsManager(page: Page) {
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
}
test("90-second recruiter entry exposes three verifiable engineering highlights", async ({ page }) => { test("90-second recruiter entry exposes three verifiable engineering highlights", async ({ page }) => {
await page.goto("/login"); await page.goto("/login");
await page.getByRole("button", { name: "Bekijk de highlights in 90 seconden" }).click(); await page.getByRole("button", { name: "Bekijk de highlights in 90 seconden" }).click();
@@ -14,8 +20,7 @@ test("90-second recruiter entry exposes three verifiable engineering highlights"
}); });
test("Engineering Story exposes architecture, reliability and honest integration evidence", async ({ page }) => { test("Engineering Story exposes architecture, reliability and honest integration evidence", async ({ page }) => {
await page.goto("/login"); await loginAsOperationsManager(page);
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await page.goto("/about"); await page.goto("/about");
await expect(page.getByRole("heading", { name: "Controle, betrouwbaarheid en uitlegbaarheid" }).first()).toBeVisible(); await expect(page.getByRole("heading", { name: "Controle, betrouwbaarheid en uitlegbaarheid" }).first()).toBeVisible();
@@ -36,3 +41,44 @@ test("mobile recruiter surfaces remain readable without horizontal overflow", as
expect(overflow).toBeLessThanOrEqual(1); expect(overflow).toBeLessThanOrEqual(1);
await expect(page.locator(".highlight-card").first()).toBeVisible(); await expect(page.locator(".highlight-card").first()).toBeVisible();
}); });
test("remaining attention items use a compact, legible queue action", async ({ page, request }) => {
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
await request.post("/api/v1/demo/reset");
await loginAsOperationsManager(page);
const queueAction = page.locator(".queue-more a");
await expect(queueAction).toContainText("Nog 2 aandachtspunten");
await expect(queueAction).toContainText("Open de volledige werklijst");
const actionBox = await queueAction.boundingBox();
const arrowBox = await queueAction.locator(".queue-more-arrow svg").boundingBox();
expect(actionBox?.height).toBeLessThanOrEqual(70);
expect(arrowBox?.width).toBeLessThanOrEqual(16);
});
test("Knowledge Hub renders independently verified RAGcore index evidence", async ({ page }) => {
await loginAsOperationsManager(page);
await page.route("**/api/v1/knowledge/status**", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
provider: "ragcore",
available: true,
detail: "11/11 managed sources verified",
tenant: "northstar-mobility-demo",
workspace: "mobilityops",
collection: "internal-procedures",
document_count: 11,
source_document_count: 11,
reported_synced_document_count: 33,
reported_failed_document_count: 0,
last_sync_at: "2026-08-10T16:00:00Z",
statistics_state: "verified",
}),
});
});
await page.goto("/knowledge");
await expect(page.getByText("11 procedures geïndexeerd")).toBeVisible();
await expect(page.getByText("actief gepubliceerd en inhoudelijk geverifieerd")).toBeVisible();
});
@@ -36,7 +36,9 @@
"title": "Attention queue", "title": "Attention queue",
"description": "{{openIssues}} open quality issues · {{workflowExceptions}} workflow exceptions", "description": "{{openIssues}} open quality issues · {{workflowExceptions}} workflow exceptions",
"reviewQueue": "Review queue", "reviewQueue": "Review queue",
"remaining": "View {{count}} more attention item(s)", "remaining_one": "One more attention item",
"remaining_other": "{{count}} more attention items",
"remainingHint": "Open the complete work queue",
"filterPlaceholder": "Filter issues…", "filterPlaceholder": "Filter issues…",
"filterAriaLabel": "Search attention queue", "filterAriaLabel": "Search attention queue",
"severityAll": "All severity", "severityAll": "All severity",
@@ -23,11 +23,12 @@
"checkout-procedure": "Required checkout controls" "checkout-procedure": "Required checkout controls"
}, },
"statistics": { "statistics": {
"verifiedIndexed": "actively published and content-verified",
"sourceDocuments": "source documents in this language", "sourceDocuments": "source documents in this language",
"reportedSynced": "sync reported by n8n", "reportedSynced": "sync reported by n8n",
"reportedFailed": "sync failures reported", "reportedFailed": "sync failures reported",
"lastSync": "Latest sync report: {{when}}. RAGcore does not expose a verifiable index size, so sync counts are not presented as indexed documents.", "lastSync": "Latest sync report: {{when}}. Index state is independently checked against each managed source's active RAGcore version and content hash.",
"noSyncReport": "No sync report has been received yet. RAGcore does not expose a verifiable index size." "noSyncReport": "No sync report has been received yet. Index evidence is checked separately through active RAGcore versions."
}, },
"providerNote": "This demo answers from a small, fixed set of indexed procedures — not a live RAGcore connection. A live RAGcore backend will later take over the same interface without changing how this page works.", "providerNote": "This demo answers from a small, fixed set of indexed procedures — not a live RAGcore connection. A live RAGcore backend will later take over the same interface without changing how this page works.",
"askHeading": "Ask a procedure question", "askHeading": "Ask a procedure question",
@@ -36,7 +36,9 @@
"title": "File d'attention", "title": "File d'attention",
"description": "{{openIssues}} problèmes de qualité ouverts · {{workflowExceptions}} exceptions de workflow", "description": "{{openIssues}} problèmes de qualité ouverts · {{workflowExceptions}} exceptions de workflow",
"reviewQueue": "Examiner la file", "reviewQueue": "Examiner la file",
"remaining": "Voir encore {{count}} point(s) dattention", "remaining_one": "Encore un point dattention",
"remaining_other": "Encore {{count}} points dattention",
"remainingHint": "Ouvrir la file de travail complète",
"filterPlaceholder": "Filtrer les problèmes…", "filterPlaceholder": "Filtrer les problèmes…",
"filterAriaLabel": "Rechercher dans la file d'attention", "filterAriaLabel": "Rechercher dans la file d'attention",
"severityAll": "Toute gravité", "severityAll": "Toute gravité",
@@ -23,11 +23,12 @@
"checkout-procedure": "Contrôles obligatoires au départ" "checkout-procedure": "Contrôles obligatoires au départ"
}, },
"statistics": { "statistics": {
"verifiedIndexed": "publiées activement et contenu vérifié",
"sourceDocuments": "documents sources dans cette langue", "sourceDocuments": "documents sources dans cette langue",
"reportedSynced": "synchronisation signalée par n8n", "reportedSynced": "synchronisation signalée par n8n",
"reportedFailed": "échecs de synchronisation signalés", "reportedFailed": "échecs de synchronisation signalés",
"lastSync": "Dernier rapport de synchronisation : {{when}}. RAGcore nexpose pas de taille dindex vérifiable ; les nombres synchronisés ne sont donc pas présentés comme des documents indexés.", "lastSync": "Dernier rapport de synchronisation : {{when}}. L’état de lindex est contrôlé indépendamment via la version RAGcore active et lempreinte de contenu de chaque source gérée.",
"noSyncReport": "Aucun rapport de synchronisation reçu. RAGcore nexpose pas de taille dindex vérifiable." "noSyncReport": "Aucun rapport de synchronisation reçu. Les preuves dindexation sont contrôlées séparément via les versions RAGcore actives."
}, },
"providerNote": "Cette démo répond à partir d'un petit ensemble fixe de procédures indexées — pas d'une connexion RAGcore en direct. Un backend RAGcore en direct reprendra plus tard la même interface sans changer le fonctionnement de cette page.", "providerNote": "Cette démo répond à partir d'un petit ensemble fixe de procédures indexées — pas d'une connexion RAGcore en direct. Un backend RAGcore en direct reprendra plus tard la même interface sans changer le fonctionnement de cette page.",
"askHeading": "Poser une question de procédure", "askHeading": "Poser une question de procédure",
@@ -36,7 +36,9 @@
"title": "Aandachtspunten", "title": "Aandachtspunten",
"description": "{{openIssues}} open datakwaliteitsproblemen · {{workflowExceptions}} automatiseringsuitzonderingen", "description": "{{openIssues}} open datakwaliteitsproblemen · {{workflowExceptions}} automatiseringsuitzonderingen",
"reviewQueue": "Wachtrij bekijken", "reviewQueue": "Wachtrij bekijken",
"remaining": "Nog {{count}} aandachtspunt(en) bekijken", "remaining_one": "Nog één aandachtspunt",
"remaining_other": "Nog {{count}} aandachtspunten",
"remainingHint": "Open de volledige werklijst",
"filterPlaceholder": "Filter aandachtspunten…", "filterPlaceholder": "Filter aandachtspunten…",
"filterAriaLabel": "Zoek in aandachtspunten", "filterAriaLabel": "Zoek in aandachtspunten",
"severityAll": "Alle ernst", "severityAll": "Alle ernst",
@@ -23,11 +23,12 @@
"checkout-procedure": "Verplichte vertrekcontroles" "checkout-procedure": "Verplichte vertrekcontroles"
}, },
"statistics": { "statistics": {
"verifiedIndexed": "actief gepubliceerd en inhoudelijk geverifieerd",
"sourceDocuments": "brondocumenten in deze taal", "sourceDocuments": "brondocumenten in deze taal",
"reportedSynced": "sync door n8n gerapporteerd", "reportedSynced": "sync door n8n gerapporteerd",
"reportedFailed": "syncfouten gerapporteerd", "reportedFailed": "syncfouten gerapporteerd",
"lastSync": "Laatste syncrapport: {{when}}. RAGcore stelt geen verifieerbare indexomvang beschikbaar; syncaantallen worden daarom niet als geïndexeerde documenten voorgesteld.", "lastSync": "Laatste syncrapport: {{when}}. De indexstand wordt onafhankelijk gecontroleerd via de actieve RAGcore-versie en inhoudshash van elke beheerde bron.",
"noSyncReport": "Nog geen syncrapport ontvangen. RAGcore stelt geen verifieerbare indexomvang beschikbaar." "noSyncReport": "Nog geen syncrapport ontvangen. Indexbewijs wordt afzonderlijk via de actieve RAGcore-versies gecontroleerd."
}, },
"providerNote": "Deze demo beantwoordt vanuit een kleine, vaste set geïndexeerde procedures — geen live RAGcore-koppeling. Een live RAGcore-backend zal later dezelfde interface overnemen, zonder dat deze pagina verandert.", "providerNote": "Deze demo beantwoordt vanuit een kleine, vaste set geïndexeerde procedures — geen live RAGcore-koppeling. Een live RAGcore-backend zal later dezelfde interface overnemen, zonder dat deze pagina verandert.",
"askHeading": "Stel een procedurevraag", "askHeading": "Stel een procedurevraag",
+10 -1
View File
@@ -190,7 +190,16 @@ export function Dashboard() {
</div> </div>
))} ))}
{isUnfiltered && attention.length > 6 && canSeeQuality && ( {isUnfiltered && attention.length > 6 && canSeeQuality && (
<p className="queue-more"><Link to="/data-quality">{t("attention.remaining", { count: attention.length - 6 })} <Icon name="chevron" /></Link></p> <p className="queue-more">
<Link to="/data-quality">
<span className="queue-more-count" aria-hidden="true">{attention.length - 6}</span>
<span className="queue-more-copy">
<strong>{t("attention.remaining", { count: attention.length - 6 })}</strong>
<small>{t("attention.remainingHint")}</small>
</span>
<span className="queue-more-arrow"><Icon name="chevron" /></span>
</Link>
</p>
)} )}
</> </>
)} )}
+1
View File
@@ -154,6 +154,7 @@ export function Knowledge() {
)} )}
{statusSettled && status?.provider === "ragcore" && ( {statusSettled && status?.provider === "ragcore" && (
<div className="knowledge-index-evidence" role="status"> <div className="knowledge-index-evidence" role="status">
<div><strong>{status.document_count ?? "—"}</strong><span>{t("statistics.verifiedIndexed")}</span></div>
<div><strong>{status.source_document_count}</strong><span>{t("statistics.sourceDocuments")}</span></div> <div><strong>{status.source_document_count}</strong><span>{t("statistics.sourceDocuments")}</span></div>
<div> <div>
<strong>{status.reported_synced_document_count ?? "—"}</strong> <strong>{status.reported_synced_document_count ?? "—"}</strong>
+12 -3
View File
@@ -182,7 +182,7 @@ a:hover { color: var(--teal); }
.inline-action { display: inline-flex; align-items: center; gap: 5px; padding: 0 18px; color: var(--teal-dark); text-decoration: none; font-size: .72rem; font-weight: 700; white-space: nowrap; } .inline-action { display: inline-flex; align-items: center; gap: 5px; padding: 0 18px; color: var(--teal-dark); text-decoration: none; font-size: .72rem; font-weight: 700; white-space: nowrap; }
.inline-action svg { width: 14px; } .inline-action svg { width: 14px; }
.operations-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(320px, .75fr); gap: 16px; margin-bottom: 16px; } .operations-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(320px, .75fr); align-items: start; gap: 16px; margin-bottom: 16px; }
.secondary-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } .secondary-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.work-panel, .panel, .record-surface, .table-shell { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); } .work-panel, .panel, .record-surface, .table-shell { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); }
.panel { padding: 20px; } .panel { padding: 20px; }
@@ -195,7 +195,15 @@ a:hover { color: var(--teal); }
.attention-list, .integration-list, .recent-list, .record-list, .automation-list, .today-list { list-style: none; margin: 0; padding: 0; } .attention-list, .integration-list, .recent-list, .record-list, .automation-list, .today-list { list-style: none; margin: 0; padding: 0; }
.attention-list li { min-height: 68px; display: grid; grid-template-columns: auto minmax(0,1fr) auto 16px; align-items: center; gap: 11px; padding: 10px 18px; border-bottom: 1px solid #e8edf2; transition: background .14s ease; } .attention-list li { min-height: 68px; display: grid; grid-template-columns: auto minmax(0,1fr) auto 16px; align-items: center; gap: 11px; padding: 10px 18px; border-bottom: 1px solid #e8edf2; transition: background .14s ease; }
.attention-list li:last-child { border-bottom: 0; }.attention-list li:hover { background: #f9fbfc; } .attention-list li:last-child { border-bottom: 0; }.attention-list li:hover { background: #f9fbfc; }
.queue-more { margin: 0; padding: 13px 18px; border-top: 1px solid var(--line); font-size: var(--type-meta); font-weight: 700; }.queue-more a { display: inline-flex; align-items: center; gap: 5px; } .queue-more { margin: 0; padding: 10px 12px; border-top: 1px solid var(--line); background: var(--surface-subtle); }
.queue-more a { display: grid; grid-template-columns: 34px minmax(0, 1fr) 30px; align-items: center; gap: 10px; min-height: 52px; padding: 7px 8px; color: var(--ink); background: white; border: 1px solid var(--line); border-radius: var(--radius); text-decoration: none; transition: border-color .14s ease, box-shadow .14s ease, transform .14s ease; }
.queue-more a:hover { border-color: var(--teal); box-shadow: 0 5px 16px rgba(13, 126, 116, .09); transform: translateY(-1px); }
.queue-more-count { width: 34px; height: 34px; display: grid; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: 50%; font-size: .78rem; font-weight: 800; }
.queue-more-copy { min-width: 0; display: grid; gap: 2px; }
.queue-more-copy strong { font-size: .75rem; line-height: 1.25; }
.queue-more-copy small { color: var(--muted); font-size: .64rem; font-weight: 500; }
.queue-more-arrow { width: 30px; height: 30px; display: grid; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: 50%; }
.queue-more-arrow svg { width: 14px; height: 14px; }
.attention-title { margin: 0; font-size: .78rem; font-weight: 700; }.attention-title a { color: var(--ink); text-decoration: none; } .attention-title { margin: 0; font-size: .78rem; font-weight: 700; }.attention-title a { color: var(--ink); text-decoration: none; }
.attention-detail { max-width: 58ch; margin: 4px 0 0; color: var(--muted); font-size: .69rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .attention-detail { max-width: 58ch; margin: 4px 0 0; color: var(--muted); font-size: .69rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.queue-ref { color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .65rem; }.row-chevron { width: 14px; color: var(--muted-light); } .queue-ref { color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .65rem; }.row-chevron { width: 14px; color: var(--muted-light); }
@@ -475,11 +483,12 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.knowledge-provider-note { display: flex; align-items: flex-start; gap: 8px; margin: -6px 0 18px; padding: 10px 14px; color: var(--ink-soft); background: var(--info-pale); border: 1px solid #cfe3ee; border-radius: var(--radius); font-size: .72rem; line-height: 1.5; } .knowledge-provider-note { display: flex; align-items: flex-start; gap: 8px; margin: -6px 0 18px; padding: 10px 14px; color: var(--ink-soft); background: var(--info-pale); border: 1px solid #cfe3ee; border-radius: var(--radius); font-size: .72rem; line-height: 1.5; }
.knowledge-provider-note svg { width: 15px; flex-shrink: 0; margin-top: 1px; color: var(--info); } .knowledge-provider-note svg { width: 15px; flex-shrink: 0; margin-top: 1px; color: var(--info); }
.knowledge-index-evidence { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1px; margin: -6px 0 18px; overflow: hidden; background: var(--line); border: 1px solid var(--line); border-radius: var(--radius); } .knowledge-index-evidence { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; margin: -6px 0 18px; overflow: hidden; background: var(--line); border: 1px solid var(--line); border-radius: var(--radius); }
.knowledge-index-evidence > div { display: grid; gap: 3px; padding: 11px 14px; background: white; } .knowledge-index-evidence > div { display: grid; gap: 3px; padding: 11px 14px; background: white; }
.knowledge-index-evidence strong { font-size: .86rem; } .knowledge-index-evidence strong { font-size: .86rem; }
.knowledge-index-evidence span, .knowledge-index-evidence p { color: var(--muted); font-size: .66rem; line-height: 1.45; } .knowledge-index-evidence span, .knowledge-index-evidence p { color: var(--muted); font-size: .66rem; line-height: 1.45; }
.knowledge-index-evidence p { grid-column: 1 / -1; margin: 0; padding: 9px 14px; background: var(--surface-subtle); } .knowledge-index-evidence p { grid-column: 1 / -1; margin: 0; padding: 9px 14px; background: var(--surface-subtle); }
@media (max-width: 640px) { .knowledge-index-evidence { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.knowledge-suggestions { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-top: 12px; } .knowledge-suggestions { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-top: 12px; }
.knowledge-suggestions > span { color: var(--muted); font-size: .68rem; font-weight: 700; } .knowledge-suggestions > span { color: var(--muted); font-size: .68rem; font-weight: 700; }
.suggestion-chip { padding: 6px 11px; color: var(--teal-dark); background: var(--teal-pale); border: 1px solid #bfe6df; border-radius: 999px; font-size: .68rem; font-weight: 600; cursor: pointer; } .suggestion-chip { padding: 6px 11px; color: var(--teal-dark); background: var(--teal-pale); border: 1px solid #bfe6df; border-radius: 999px; font-size: .68rem; font-weight: 600; cursor: pointer; }