From 8030753dbc9d1aee711961684fc4bcd7cb7592ea Mon Sep 17 00:00:00 2001 From: NuklearRabbit <145918611+NuklearRabbit@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:27:17 +0200 Subject: [PATCH] M17: ground knowledge and integration evidence --- PROJECT_STATE.md | 19 ++ backend/app/api/routers/knowledge.py | 48 ++++- backend/app/services/knowledge/ragcore.py | 20 ++- backend/tests/test_knowledge.py | 69 +++++++- contracts/openapi.yaml | 73 ++++++++ frontend/e2e/demo-legibility.spec.ts | 10 +- .../src/i18n/locales/en-GB/integrations.json | 10 ++ .../src/i18n/locales/en-GB/knowledge.json | 14 +- .../src/i18n/locales/fr-BE/integrations.json | 10 ++ .../src/i18n/locales/fr-BE/knowledge.json | 14 +- .../src/i18n/locales/nl-BE/integrations.json | 10 ++ .../src/i18n/locales/nl-BE/knowledge.json | 14 +- frontend/src/pages/Automation.tsx | 58 ++++-- frontend/src/pages/Knowledge.tsx | 167 +++++++++++++----- frontend/src/styles.css | 18 ++ 15 files changed, 480 insertions(+), 74 deletions(-) diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index edb30c9..d418ed1 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -2417,3 +2417,22 @@ evidence yet." **218 passed** and the disposable database/network/volume were removed automatically. - Exact next action: implement honest loading states and RAG/source deduplication, then revalidate live Knowledge and Integration flows. + +## M17 — grounded knowledge and integration evidence UX (2026-08-10) + +- Replaced transient false demo/unavailable/not-configured labels with explicit loading, + settled-unavailable and provider-aware states on Knowledge and Automation. +- Deduplicated RAGcore citations by their human-visible identity instead of volatile + document/version UUIDs and capped each answer at three concise, collapsible source + cards. Re-uploaded copies can no longer dominate an answer. +- Added answer latency and authenticated helpful/not-helpful feedback. Feedback is + correlation-bound to the requesting user, auditable and safely updateable without + creating duplicate audit events. +- Explained the expected cadence of all four central n8n workflows so event-driven and + scheduled no-event states are understandable rather than looking broken. +- Regenerated the OpenAPI contract. Evidence: frontend production build passed; focused + backend knowledge suite **31 passed**; targeted ruff and mypy checks passed. The E2E + journey now verifies the three-source limit, unique source titles and persisted + feedback. +- Exact next action: turn the data-quality queue, booking planning, fleet overview and + user administration into complete daily operational workspaces. diff --git a/backend/app/api/routers/knowledge.py b/backend/app/api/routers/knowledge.py index 340d4f2..ffe3f8a 100644 --- a/backend/app/api/routers/knowledge.py +++ b/backend/app/api/routers/knowledge.py @@ -3,11 +3,13 @@ from __future__ import annotations import uuid from typing import Literal -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field +from sqlalchemy import select from sqlalchemy.orm import Session from app.api.deps import get_current_user, get_db +from app.models.audit import AuditEvent from app.schemas import CurrentUser from app.services.audit import record_audit_event from app.services.knowledge import GroundedAnswer, KnowledgeHealth, get_knowledge_provider @@ -22,6 +24,11 @@ class AskQuestionRequest(BaseModel): language: SupportedLanguage = "en-GB" +class KnowledgeFeedbackRequest(BaseModel): + correlation_id: uuid.UUID + helpful: bool + + @router.post("/questions", response_model=GroundedAnswer) def ask_question( body: AskQuestionRequest, @@ -51,6 +58,45 @@ def ask_question( return answer +@router.post("/feedback") +def record_feedback( + body: KnowledgeFeedbackRequest, + db: Session = Depends(get_db), + user: CurrentUser = Depends(get_current_user), +) -> dict[str, str]: + question_event = db.scalar( + select(AuditEvent.id).where( + AuditEvent.action == "knowledge_question_asked", + AuditEvent.correlation_id == body.correlation_id, + AuditEvent.actor_label == user.display_name, + ) + ) + if question_event is None: + raise HTTPException(status_code=404, detail="Knowledge exchange not found") + + existing = db.scalar( + select(AuditEvent).where( + AuditEvent.action == "knowledge_feedback_recorded", + AuditEvent.correlation_id == body.correlation_id, + AuditEvent.actor_label == user.display_name, + ) + ) + if existing is not None: + existing.metadata_json = {"helpful": body.helpful} + else: + record_audit_event( + db, + actor_type="user", + actor_label=user.display_name, + action="knowledge_feedback_recorded", + entity_type="knowledge", + correlation_id=body.correlation_id, + metadata={"helpful": body.helpful}, + ) + db.commit() + return {"status": "recorded"} + + @router.get("/status", response_model=KnowledgeHealth) def knowledge_status( language: SupportedLanguage = "en-GB", diff --git a/backend/app/services/knowledge/ragcore.py b/backend/app/services/knowledge/ragcore.py index a8f4850..2cd885c 100644 --- a/backend/app/services/knowledge/ragcore.py +++ b/backend/app/services/knowledge/ragcore.py @@ -17,9 +17,10 @@ _GROUNDED_ANSWERABILITY = {"answerable", "partially_answerable"} _LEAD_ANSWER_TEMPLATE = { "en-GB": 'Per "{title}": {excerpt}', "nl-BE": 'Volgens "{title}": {excerpt}', - "fr-BE": 'Selon « {title} » : {excerpt}', + "fr-BE": "Selon « {title} » : {excerpt}", } _DEFAULT_LANGUAGE = "en-GB" +_MAX_SOURCE_CARDS = 3 _DOMAIN_CONCEPTS: dict[str, tuple[str, ...]] = { "damage": ("damage", "damaged", "schade", "beschadigd", "dommage", "endommagé"), @@ -48,14 +49,26 @@ def _question_concepts(question: str) -> set[str]: def _deduplicate_sources(sources: list[SourceCard]) -> list[SourceCard]: - seen: set[tuple[str, str]] = set() + """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 and excerpt. + """ + seen: set[tuple[str, str, str]] = set() unique: list[SourceCard] = [] for source in sources: - key = (source.document_id, source.section) + key = ( + source.title.strip().casefold(), + source.section.strip().casefold(), + " ".join(source.excerpt.split()).casefold(), + ) if key in seen: continue seen.add(key) unique.append(source) + if len(unique) == _MAX_SOURCE_CARDS: + break return unique @@ -193,6 +206,7 @@ class RAGcoreKnowledgeProvider: ) for citation in citations.values() ] + sources = _deduplicate_sources(sources) answerability = body.get("answerability", "not_answerable") is_grounded = answerability in _GROUNDED_ANSWERABILITY and sources evidence_state: EvidenceState = "grounded" if is_grounded else "insufficient" diff --git a/backend/tests/test_knowledge.py b/backend/tests/test_knowledge.py index 9fa4449..944920f 100644 --- a/backend/tests/test_knowledge.py +++ b/backend/tests/test_knowledge.py @@ -107,9 +107,7 @@ def test_demo_provider_grounds_damage_question_in_french(): def test_demo_provider_insufficient_evidence_message_is_localized(): provider = DemoKnowledgeProvider() - nl_answer = provider.ask( - "Wat is de hoofdstad van Frankrijk?", "test-correlation-nl-2", "nl-BE" - ) + nl_answer = provider.ask("Wat is de hoofdstad van Frankrijk?", "test-correlation-nl-2", "nl-BE") fr_answer = provider.ask( "Quelle est la capitale de la France ?", "test-correlation-fr-2", "fr-BE" ) @@ -142,9 +140,7 @@ def test_ask_question_is_audited_without_leaking_full_text(ops_client): "/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() + 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 @@ -288,6 +284,67 @@ def test_ragcore_provider_grounded_answer_maps_citations_to_sources(monkeypatch) assert source.excerpt +def test_ragcore_sources_deduplicate_reuploaded_versions_and_cap_cards(monkeypatch): + provider = RAGcoreKnowledgeProvider() + monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1") + citations = [] + for index in range(5): + citations.append( + { + "id": f"cite-{index}", + "document_id": f"doc-{index}", + "document_version_id": f"version-{index}", + "title": "Damage procedure" if index < 2 else f"Procedure {index}", + "section": "Return", + "excerpt": ( + "Record visible damage before release." + if index < 2 + else f"Unique procedure evidence {index}." + ), + } + ) + monkeypatch.setattr( + provider, + "_client", + lambda: _FakeClient(post_response=_FakeResponse(200, _answers_body(citations=citations))), + ) + answer = provider.ask("What must I do about vehicle damage?", "dedupe-test") + assert len(answer.sources) == 3 + assert sum(source.title == "Damage procedure" for source in answer.sources) == 1 + + +def test_knowledge_feedback_is_audited_and_can_be_changed(ops_client): + answer = ops_client.post( + "/api/v1/knowledge/questions", + json={"question": "What must I do when a vehicle returns with damage?"}, + ).json() + payload = {"correlation_id": answer["correlation_id"], "helpful": True} + assert ops_client.post("/api/v1/knowledge/feedback", json=payload).status_code == 200 + payload["helpful"] = False + assert ops_client.post("/api/v1/knowledge/feedback", json=payload).status_code == 200 + + events = ops_client.get( + "/api/v1/audit", params={"action": "knowledge_feedback_recorded"} + ).json() + matching = [e for e in events if e["correlation_id"] == answer["correlation_id"]] + assert len(matching) == 1 + assert matching[0]["metadata"]["helpful"] is False + + +def test_knowledge_feedback_cannot_target_another_users_exchange(client): + assert client.post("/api/v1/demo/login", json={"role": "rental_employee"}).status_code == 200 + answer = client.post( + "/api/v1/knowledge/questions", + json={"question": "How do I register a vehicle return?"}, + ).json() + assert client.post("/api/v1/demo/login", json={"role": "operations_manager"}).status_code == 200 + response = client.post( + "/api/v1/knowledge/feedback", + json={"correlation_id": answer["correlation_id"], "helpful": True}, + ) + assert response.status_code == 404 + + def test_ragcore_provider_not_answerable_is_insufficient_and_never_fabricates(monkeypatch): provider = RAGcoreKnowledgeProvider() monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1") diff --git a/contracts/openapi.yaml b/contracts/openapi.yaml index fd7bace..c7dc01a 100644 --- a/contracts/openapi.yaml +++ b/contracts/openapi.yaml @@ -19,6 +19,32 @@ paths: type: string type: object title: Response Health Health Get + /health/live: + get: + summary: Liveness + description: Process liveness only; external dependencies deliberately do not affect it. + operationId: liveness_health_live_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: + additionalProperties: + type: string + type: object + title: Response Liveness Health Live Get + /health/ready: + get: + summary: Readiness + description: 'Traffic readiness: the API is useful only while its canonical database responds.' + operationId: readiness_health_ready_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} /api/v1/system/status: get: summary: System Status @@ -1436,6 +1462,34 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /api/v1/knowledge/feedback: + post: + tags: + - knowledge + summary: Record Feedback + operationId: record_feedback_api_v1_knowledge_feedback_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/KnowledgeFeedbackRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + additionalProperties: + type: string + type: object + title: Response Record Feedback Api V1 Knowledge Feedback Post + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /api/v1/knowledge/status: get: tags: @@ -2882,6 +2936,20 @@ components: - n8n - mcp_hub title: IntegrationStatusOut + KnowledgeFeedbackRequest: + properties: + correlation_id: + type: string + format: uuid + title: Correlation Id + helpful: + type: boolean + title: Helpful + type: object + required: + - correlation_id + - helpful + title: KnowledgeFeedbackRequest KnowledgeHealth: properties: provider: @@ -3853,6 +3921,11 @@ components: type: type: string title: Error Type + input: + title: Input + ctx: + type: object + title: Context type: object required: - loc diff --git a/frontend/e2e/demo-legibility.spec.ts b/frontend/e2e/demo-legibility.spec.ts index 6515f57..6011e6d 100644 --- a/frontend/e2e/demo-legibility.spec.ts +++ b/frontend/e2e/demo-legibility.spec.ts @@ -65,11 +65,15 @@ test("knowledge page suggested question returns a grounded, honestly-labelled an await expect(page).toHaveURL(/\/dashboard$/); await page.goto("/knowledge"); - // The status badge and retrieval-flow diagram must name the actual active provider - // honestly, not the not-yet-connected "RAGcore" -- the honest disclosure note below is - // allowed to mention RAGcore by name when explaining it isn't live yet. + // The status badge must name the actual active provider once its check settles. await expect(page.locator(".knowledge-status strong")).toHaveText(/RAGcore|de demokennisbank/); await page.getByRole("button", { name: "Wie beoordeelt een ongewone kilometerstand?" }).click(); await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible({ timeout: 15_000 }); + const sources = page.locator(".exchange").first().locator(".source-card"); + expect(await sources.count()).toBeLessThanOrEqual(3); + const titles = await sources.locator(".source-title").allTextContents(); + expect(new Set(titles.map((title) => title.trim().toLocaleLowerCase())).size).toBe(titles.length); + await page.getByRole("button", { name: "Ja" }).first().click(); + await expect(page.getByText("Feedback opgeslagen")).toBeVisible(); }); diff --git a/frontend/src/i18n/locales/en-GB/integrations.json b/frontend/src/i18n/locales/en-GB/integrations.json index e747180..c4a6dd0 100644 --- a/frontend/src/i18n/locales/en-GB/integrations.json +++ b/frontend/src/i18n/locales/en-GB/integrations.json @@ -3,6 +3,7 @@ "title": "Integration control", "description": "Monitor delivery health, graceful degradation and retryable workflow events.", "cards": { + "checking": "Retrieving current integration status…", "orchestrationKicker": "Orchestration", "n8nTitle": "n8n delivery", "n8nSummary": "{{succeeded}} succeeded · {{failed}} failed · {{pending}} pending · {{delivering}} delivering", @@ -24,6 +25,7 @@ "n8nDemoScenario": "Plus {{count}} prepared demo scenario — a simulated temporary failure, not an integration problem." }, "statusLabels": { + "checking": "Checking…", "notConnected": "Not connected", "deliveryFailed": "Delivery failed", "retryAvailable": "Retry available", @@ -35,6 +37,8 @@ "workflows": { "title": "Automation workflows", "description": "{{known}} of {{expected}} canonical n8n workflows have live evidence of running.", + "loadingEvidence": "Retrieving current workflow evidence…", + "unavailable": "Workflow evidence is currently unavailable.", "notBuilt": "Not built yet", "noEvidence": "No evidence yet", "healthy": "Healthy", @@ -53,6 +57,12 @@ "ragcoreSync": "Knowledge procedure sync", "errorHandler": "Workflow error handler" }, + "expectations": { + "vehicleReturn": "Event-driven after a vehicle return is safely committed.", + "scheduledScan": "Scheduled; normally runs every hour.", + "ragcoreSync": "Scheduled; normally runs daily.", + "errorHandler": "Runs only when a real workflow execution fails." + }, "errorHandler": { "summary": "{{count}} automation failure(s) registered — latest from {{workflow}} at {{when}}.", "summaryEmpty": "No automation failures have been registered." diff --git a/frontend/src/i18n/locales/en-GB/knowledge.json b/frontend/src/i18n/locales/en-GB/knowledge.json index bc6df36..5fd7958 100644 --- a/frontend/src/i18n/locales/en-GB/knowledge.json +++ b/frontend/src/i18n/locales/en-GB/knowledge.json @@ -4,6 +4,10 @@ "description": "Ask operational questions. Answers are shown only when {{provider}} returns sufficient cited evidence.", "providerDemo": "the demo knowledge base", "providerRagcore": "RAGcore", + "providerChecking": "the knowledge source", + "providerUnavailable": "the knowledge service", + "statusChecking": "Checking knowledge source…", + "statusCheckingDetail": "Retrieving the current provider status.", "statusAvailable": "Available", "statusUnavailable": "Unavailable", "proceduresIndexed": "{{count}} procedures indexed", @@ -16,6 +20,7 @@ "ask": "Ask", "asking": "Asking…", "askFailed": "Could not reach the knowledge service.", + "feedbackFailed": "Could not save feedback.", "suggestedLabel": "Try one:", "suggestedQuestions": [ "What must I do when a vehicle returns with damage?", @@ -38,5 +43,12 @@ "unavailable": "Knowledge service unavailable" }, "unavailableBody": "The knowledge service is currently unreachable. Operational features are unaffected — try again later.", - "sourceVersion": "v{{version}}" + "sourceVersion": "v{{version}}", + "answeredIn": "Answered in {{seconds}} s", + "sourceTechnicalVersion": "Technical source version available in the audit log", + "feedbackLabel": "Answer feedback", + "feedbackPrompt": "Was this answer useful?", + "feedbackThanks": "Feedback saved", + "feedbackHelpful": "Yes", + "feedbackNotHelpful": "No" } diff --git a/frontend/src/i18n/locales/fr-BE/integrations.json b/frontend/src/i18n/locales/fr-BE/integrations.json index 2e637f0..77fc94c 100644 --- a/frontend/src/i18n/locales/fr-BE/integrations.json +++ b/frontend/src/i18n/locales/fr-BE/integrations.json @@ -3,6 +3,7 @@ "title": "Contrôle des intégrations", "description": "Surveillez la santé des livraisons, la dégradation contrôlée et les événements de workflow réessayables.", "cards": { + "checking": "Récupération de l’état actuel des intégrations…", "orchestrationKicker": "Orchestration", "n8nTitle": "Livraison n8n", "n8nSummary": "{{succeeded}} réussies · {{failed}} échouées · {{pending}} en attente · {{delivering}} en cours", @@ -24,6 +25,7 @@ "n8nDemoScenario": "Plus {{count}} scénario de démonstration préparé — une panne temporaire simulée, pas un problème d'intégration." }, "statusLabels": { + "checking": "Vérification…", "notConnected": "Non connecté", "deliveryFailed": "Livraison échouée", "retryAvailable": "Nouvelle tentative possible", @@ -35,6 +37,8 @@ "workflows": { "title": "Workflows d'automatisation", "description": "{{known}} workflows n8n canoniques sur {{expected}} disposent de preuves actuelles de fonctionnement.", + "loadingEvidence": "Récupération des preuves actuelles des workflows…", + "unavailable": "Les preuves des workflows sont actuellement indisponibles.", "notBuilt": "Pas encore créé", "noEvidence": "Aucune preuve pour l’instant", "healthy": "Sain", @@ -53,6 +57,12 @@ "ragcoreSync": "Synchronisation des procédures de connaissances", "errorHandler": "Gestionnaire d'erreurs de workflow" }, + "expectations": { + "vehicleReturn": "Déclenché après l’enregistrement sécurisé d’un retour de véhicule.", + "scheduledScan": "Planifié ; s’exécute normalement chaque heure.", + "ragcoreSync": "Planifié ; s’exécute normalement chaque jour.", + "errorHandler": "S’exécute uniquement lorsqu’un workflow réel échoue." + }, "errorHandler": { "summary": "{{count}} échec(s) d'automatisation enregistré(s) — le dernier provient de {{workflow}} à {{when}}.", "summaryEmpty": "Aucun échec d'automatisation n'a été enregistré." diff --git a/frontend/src/i18n/locales/fr-BE/knowledge.json b/frontend/src/i18n/locales/fr-BE/knowledge.json index 17b7533..426170a 100644 --- a/frontend/src/i18n/locales/fr-BE/knowledge.json +++ b/frontend/src/i18n/locales/fr-BE/knowledge.json @@ -4,6 +4,10 @@ "description": "Posez des questions opérationnelles. Les réponses ne s'affichent que lorsque {{provider}} renvoie suffisamment de preuves citées.", "providerDemo": "la base de connaissances de démo", "providerRagcore": "RAGcore", + "providerChecking": "la source de connaissances", + "providerUnavailable": "le service de connaissances", + "statusChecking": "Vérification de la source…", + "statusCheckingDetail": "Récupération de l’état actuel du fournisseur.", "statusAvailable": "Disponible", "statusUnavailable": "Indisponible", "proceduresIndexed": "{{count}} procédures indexées", @@ -16,6 +20,7 @@ "ask": "Demander", "asking": "Question en cours…", "askFailed": "Impossible de joindre le service de connaissances.", + "feedbackFailed": "Impossible d’enregistrer le retour.", "suggestedLabel": "Essayez :", "suggestedQuestions": [ "Que dois-je faire quand un véhicule revient avec des dommages ?", @@ -38,5 +43,12 @@ "unavailable": "Service de connaissances indisponible" }, "unavailableBody": "Le service de connaissances est actuellement inaccessible. Les fonctions opérationnelles ne sont pas affectées — réessayez plus tard.", - "sourceVersion": "v{{version}}" + "sourceVersion": "v{{version}}", + "answeredIn": "Réponse en {{seconds}} s", + "sourceTechnicalVersion": "Version technique de la source disponible dans l’audit", + "feedbackLabel": "Avis sur la réponse", + "feedbackPrompt": "Cette réponse était-elle utile ?", + "feedbackThanks": "Avis enregistré", + "feedbackHelpful": "Oui", + "feedbackNotHelpful": "Non" } diff --git a/frontend/src/i18n/locales/nl-BE/integrations.json b/frontend/src/i18n/locales/nl-BE/integrations.json index 31b570d..0929231 100644 --- a/frontend/src/i18n/locales/nl-BE/integrations.json +++ b/frontend/src/i18n/locales/nl-BE/integrations.json @@ -3,6 +3,7 @@ "title": "Integratiebeheer", "description": "Bewaak afleverkwaliteit, beperkte beschikbaarheid en herprobeerbare workflowgebeurtenissen.", "cards": { + "checking": "Actuele integratiestatus ophalen…", "orchestrationKicker": "Orkestratie", "n8nTitle": "n8n-aflevering", "n8nSummary": "{{succeeded}} geslaagd · {{failed}} mislukt · {{pending}} in wachtrij · {{delivering}} bezig", @@ -24,6 +25,7 @@ "n8nDemoScenario": "Plus {{count}} voorbereid demoscenario — een gesimuleerde tijdelijke fout, geen integratieprobleem." }, "statusLabels": { + "checking": "Controleren…", "notConnected": "Niet gekoppeld", "deliveryFailed": "Verwerking mislukt", "retryAvailable": "Opnieuw proberen mogelijk", @@ -35,6 +37,8 @@ "workflows": { "title": "Automatiseringsworkflows", "description": "{{known}} van {{expected}} canonieke n8n-workflows hebben actuele evidentie van werking.", + "loadingEvidence": "Actuele workflowevidentie ophalen…", + "unavailable": "Workflowevidentie is momenteel niet beschikbaar.", "notBuilt": "Nog niet gebouwd", "noEvidence": "Nog geen evidentie", "healthy": "Gezond", @@ -53,6 +57,12 @@ "ragcoreSync": "Synchronisatie kennisprocedures", "errorHandler": "Workflowfoutafhandelaar" }, + "expectations": { + "vehicleReturn": "Gebeurtenisgestuurd na een veilig opgeslagen voertuigretour.", + "scheduledScan": "Gepland; normaal ieder uur actief.", + "ragcoreSync": "Gepland; normaal dagelijks actief.", + "errorHandler": "Wordt alleen actief wanneer een echte workflowuitvoering mislukt." + }, "errorHandler": { "summary": "{{count}} automatiseringsfout(en) geregistreerd — laatste van {{workflow}} om {{when}}.", "summaryEmpty": "Er zijn geen automatiseringsfouten geregistreerd." diff --git a/frontend/src/i18n/locales/nl-BE/knowledge.json b/frontend/src/i18n/locales/nl-BE/knowledge.json index 0e9d8fd..78d2b96 100644 --- a/frontend/src/i18n/locales/nl-BE/knowledge.json +++ b/frontend/src/i18n/locales/nl-BE/knowledge.json @@ -4,6 +4,10 @@ "description": "Stel operationele vragen. Antwoorden verschijnen enkel wanneer {{provider}} voldoende onderbouwde evidentie teruggeeft.", "providerDemo": "de demokennisbank", "providerRagcore": "RAGcore", + "providerChecking": "de kennisbron", + "providerUnavailable": "de kennisdienst", + "statusChecking": "Kennisbron controleren…", + "statusCheckingDetail": "De actuele providerstatus wordt opgehaald.", "statusAvailable": "Beschikbaar", "statusUnavailable": "Niet beschikbaar", "proceduresIndexed": "{{count}} procedures geïndexeerd", @@ -16,6 +20,7 @@ "ask": "Vraag stellen", "asking": "Bezig met vragen…", "askFailed": "Kon de kennisdienst niet bereiken.", + "feedbackFailed": "Kon feedback niet opslaan.", "suggestedLabel": "Probeer:", "suggestedQuestions": [ "Wat moet ik doen wanneer een voertuig terugkomt met schade?", @@ -38,5 +43,12 @@ "unavailable": "Kennisdienst niet beschikbaar" }, "unavailableBody": "De kennisdienst is momenteel niet bereikbaar. Operationele functies zijn hier niet door beïnvloed — probeer later opnieuw.", - "sourceVersion": "v{{version}}" + "sourceVersion": "v{{version}}", + "answeredIn": "Beantwoord in {{seconds}} s", + "sourceTechnicalVersion": "Technische bronversie beschikbaar in audit", + "feedbackLabel": "Feedback op antwoord", + "feedbackPrompt": "Was dit antwoord bruikbaar?", + "feedbackThanks": "Feedback opgeslagen", + "feedbackHelpful": "Ja", + "feedbackNotHelpful": "Nee" } diff --git a/frontend/src/pages/Automation.tsx b/frontend/src/pages/Automation.tsx index b350716..37a2a23 100644 --- a/frontend/src/pages/Automation.tsx +++ b/frontend/src/pages/Automation.tsx @@ -29,7 +29,9 @@ export function Automation() { const [retryError, setRetryError] = useState(null); const [retrying, setRetrying] = useState(null); const [knowledge, setKnowledge] = useState(null); + const [knowledgeSettled, setKnowledgeSettled] = useState(false); const [integrationStatus, setIntegrationStatus] = useState(null); + const [integrationSettled, setIntegrationSettled] = useState(false); const load = useCallback(() => { setRuns(null); @@ -49,14 +51,21 @@ export function Automation() { }, [load]); useEffect(() => { - api.get("/api/v1/knowledge/status").then(setKnowledge).catch(() => setKnowledge(null)); + setKnowledgeSettled(false); + api + .get("/api/v1/knowledge/status") + .then(setKnowledge) + .catch(() => setKnowledge(null)) + .finally(() => setKnowledgeSettled(true)); }, []); const loadIntegrationStatus = useCallback(() => { + setIntegrationSettled(false); api .get("/api/v1/integrations/status") .then(setIntegrationStatus) - .catch(() => setIntegrationStatus(null)); + .catch(() => setIntegrationStatus(null)) + .finally(() => setIntegrationSettled(true)); }, []); useEffect(() => { @@ -167,7 +176,9 @@ export function Automation() { {t("cards.orchestrationKicker")}

{t("cards.n8nTitle")}

- {integrationStatus + {!integrationSettled + ? t("cards.checking") + : integrationStatus ? t("cards.n8nSummary", { succeeded: integrationStatus.n8n.succeeded, failed: integrationStatus.n8n.unexpected_failed, @@ -188,8 +199,8 @@ export function Automation() { const meta = integrationStatus ? N8N_STATE_META[integrationStatus.n8n.state] : null; return ( ); })()} @@ -200,7 +211,9 @@ export function Automation() { {t("cards.knowledgeKicker")}

{t("cards.knowledgeTitle")}

- {knowledge + {!knowledgeSettled + ? t("cards.checking") + : knowledge ? knowledge.document_count === null ? t("cards.knowledgeSummaryIndexUnknown", { collection: knowledge.collection }) : t(knowledge.provider === "ragcore" ? "cards.knowledgeSummaryRagcore" : "cards.knowledgeSummaryDemo", { @@ -211,9 +224,11 @@ export function Automation() {

{t("cards.mcpTitle")}

{(() => { + if (!integrationSettled) return t("cards.checking"); const hub = integrationStatus?.mcp_hub; if (!hub?.registration_enabled) return t("cards.mcpNotConnected"); if (hub.total_calls > 0) { @@ -241,7 +257,12 @@ export function Automation() {

{(() => { const meta = integrationStatus ? MCP_STATE_META[integrationStatus.mcp_hub.state] : null; - return ; + return ( + + ); })()} {integrationStatus?.mcp_hub.hub_reachable !== null && integrationStatus?.mcp_hub.hub_reachable !== undefined && ( {integrationStatus && ( @@ -288,7 +315,10 @@ export function Automation() { : { status: "no_events", label: t("workflows.noEvidence") }; return ( - {displayName} + + {displayName} + {slug && {t(`workflows.expectations.${slug}`)}} + diff --git a/frontend/src/pages/Knowledge.tsx b/frontend/src/pages/Knowledge.tsx index 45bf98a..c784b78 100644 --- a/frontend/src/pages/Knowledge.tsx +++ b/frontend/src/pages/Knowledge.tsx @@ -10,11 +10,19 @@ import { PRODUCT_NAME } from "../product"; interface Exchange { question: string; answer: GroundedAnswer; + durationMs: number; + feedback: boolean | null; + feedbackSaving: boolean; +} + +function isOpaqueVersion(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f-]{27,}$/i.test(value); } export function Knowledge() { const { t, i18n } = useTranslation(["knowledge", "errors"]); const [status, setStatus] = useState(null); + const [statusSettled, setStatusSettled] = useState(false); const [question, setQuestion] = useState(""); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -23,21 +31,30 @@ export function Knowledge() { const language = i18n.language as "nl-BE" | "en-GB" | "fr-BE"; useEffect(() => { + setStatusSettled(false); api .get(`/api/v1/knowledge/status?language=${language}`) .then(setStatus) - .catch(() => setStatus(null)); + .catch(() => setStatus(null)) + .finally(() => setStatusSettled(true)); }, [language]); async function ask(questionText: string) { setError(null); setSubmitting(true); + const startedAt = performance.now(); try { const answer = await api.post("/api/v1/knowledge/questions", { question: questionText, language, }); - setExchanges((prev) => [{ question: questionText, answer }, ...prev]); + setExchanges((previous) => [{ + question: questionText, + answer, + durationMs: Math.round(performance.now() - startedAt), + feedback: null, + feedbackSaving: false, + }, ...previous]); setQuestion(""); } catch (err) { setError(describeApiError(t, err, "askFailed")); @@ -46,13 +63,42 @@ export function Knowledge() { } } - async function handleSubmit(e: FormEvent) { - e.preventDefault(); + async function handleSubmit(event: FormEvent) { + event.preventDefault(); if (!question.trim()) return; await ask(question); } - const providerLabel = status?.provider === "ragcore" ? t("providerRagcore") : t("providerDemo"); + async function sendFeedback(correlationId: string, helpful: boolean) { + setExchanges((previous) => previous.map((exchange) => + exchange.answer.correlation_id === correlationId + ? { ...exchange, feedbackSaving: true } + : exchange, + )); + try { + await api.post("/api/v1/knowledge/feedback", { correlation_id: correlationId, helpful }); + setExchanges((previous) => previous.map((exchange) => + exchange.answer.correlation_id === correlationId + ? { ...exchange, feedback: helpful, feedbackSaving: false } + : exchange, + )); + } catch (err) { + setError(describeApiError(t, err, "feedbackFailed")); + setExchanges((previous) => previous.map((exchange) => + exchange.answer.correlation_id === correlationId + ? { ...exchange, feedbackSaving: false } + : exchange, + )); + } + } + + const providerLabel = !statusSettled + ? t("providerChecking") + : status?.provider === "ragcore" + ? t("providerRagcore") + : status?.provider === "demo" + ? t("providerDemo") + : t("providerUnavailable"); const suggestedQuestions = t("suggestedQuestions", { returnObjects: true, defaultValue: [] }) as string[]; return ( @@ -62,59 +108,69 @@ export function Knowledge() { title={t("title")} description={t("description", { provider: providerLabel.toLowerCase() })} /> - {status && ( + {!statusSettled && ( +
+ +
{t("statusChecking")}{t("statusCheckingDetail")}
+
+ )} + {statusSettled && status && (
{providerLabel} {status.available ? t("statusAvailable") : t("statusUnavailable")} ·{" "} - {status.document_count === null ? t("proceduresIndexUnknown") : t("proceduresIndexed", { count: status.document_count })} + {status.document_count === null + ? t("proceduresIndexUnknown") + : t("proceduresIndexed", { count: status.document_count })}
{status.collection}
)} - {status?.provider !== "ragcore" && ( -

- {t("providerNote")} -

+ {statusSettled && !status && ( +
+ +
{t("statusUnavailable")}{t("unavailableBody")}
+
+ )} + {statusSettled && status?.provider === "demo" && ( +

{t("providerNote")}

)}
-
-

{t("askHeading")}

-

{t("askSubheading")}

-
+

{t("askHeading")}

{t("askSubheading")}

- +
setQuestion(e.target.value)} + onChange={(event) => setQuestion(event.target.value)} placeholder={t("questionPlaceholder")} minLength={3} maxLength={1000} required />
{t("suggestedLabel")} - {suggestedQuestions.map((q) => ( - + {suggestedQuestions.map((suggestion) => ( + ))}
@@ -134,34 +190,57 @@ export function Knowledge() { )}
    - {exchanges.map((exchange, index) => ( -
  • -

    - {t("questionLabelExchange")} {exchange.question} -

    + {exchanges.map((exchange) => ( +
  • +

    {t("questionLabelExchange")} {exchange.question}

    {t(`evidenceStates.${exchange.answer.evidence_state}`)}

    - - {exchange.answer.evidence_state === "unavailable" ? ( -

    {t("unavailableBody")}

    - ) : ( -

    {exchange.answer.answer}

    - )} +

    + {t("answeredIn", { seconds: (exchange.durationMs / 1000).toFixed(1) })} +

    + {exchange.answer.evidence_state === "unavailable" + ?

    {t("unavailableBody")}

    + :

    {exchange.answer.answer}

    } {exchange.answer.sources.length > 0 && ( -
      - {exchange.answer.sources.map((source) => ( -
    • -

      - {source.title} {t("sourceVersion", { version: source.version })} -

      -

      {source.section}

      -

      {source.excerpt}

      +
        + {exchange.answer.sources.map((source, sourceIndex) => ( +
      • +
        + + {sourceIndex + 1} + {source.title} + {source.section && {source.section}} + +

        {source.excerpt}

        +

        + {isOpaqueVersion(source.version) + ? t("sourceTechnicalVersion") + : t("sourceVersion", { version: source.version })} +

        +
      • ))}
      )} +
      + {exchange.feedback === null ? t("feedbackPrompt") : t("feedbackThanks")} + + +
    • ))}
    diff --git a/frontend/src/styles.css b/frontend/src/styles.css index f060f35..752c87a 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -382,11 +382,29 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details .scenario-card > .button[aria-disabled="true"] { opacity: .55; cursor: not-allowed; pointer-events: none; } .knowledge-status { min-height: 58px; display: flex; align-items: center; gap: 11px; margin-bottom: 14px; padding: 10px 16px; background: white; border: 1px solid var(--line); border-radius: var(--radius); }.knowledge-status div { flex: 1; display: grid; gap: 3px; }.knowledge-status strong { font-size: .74rem; }.knowledge-status span:not(.health-orb), .knowledge-status small { color: var(--muted); font-size: .65rem; }.health-orb { width: 9px; height: 9px; border-radius: 50%; }.health-orb.is-healthy { background: var(--success); box-shadow: 0 0 0 4px var(--success-pale); }.health-orb.is-down { background: var(--critical); box-shadow: 0 0 0 4px var(--critical-pale); } +.knowledge-status.is-loading .health-orb { background: var(--line-strong); animation: pulse-soft 1.2s ease-in-out infinite; } + +@keyframes pulse-soft { + 0%, 100% { opacity: 0.45; } + 50% { opacity: 1; } +} .knowledge-form { margin-bottom: 18px; }.ask-heading { display: flex; gap: 11px; align-items: center; margin-bottom: 15px; }.ask-heading > span { width: 36px; height: 36px; display: grid; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: var(--radius); }.ask-heading svg { width: 18px; }.ask-heading h2 { margin: 0; font-size: .95rem; }.ask-heading p { margin: 3px 0 0; color: var(--muted); font-size: .65rem; } .knowledge-input-row { display: flex; gap: 8px; }.knowledge-input-row input { min-width: 0; flex: 1; }.knowledge-empty { min-height: 290px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 30px; color: var(--muted); background: white; border: 1px dashed var(--line-strong); border-radius: var(--radius); }.knowledge-empty > span { width: 50px; height: 50px; display: grid; place-items: center; color: var(--teal-dark); background: var(--teal-pale); border-radius: 50%; }.knowledge-empty svg { width: 22px; }.knowledge-empty h2 { margin: 15px 0 7px; color: var(--ink); font-size: 1rem; }.knowledge-empty p { max-width: 540px; margin: 0; font-size: .75rem; line-height: 1.5; } .retrieval-flow { display: flex; align-items: center; gap: 8px; margin-top: 24px; }.retrieval-flow span { padding: 6px 9px; color: var(--ink-soft); background: var(--surface-subtle); border: 1px solid var(--line); font-size: .61rem; font-weight: 700; }.retrieval-flow i { width: 24px; border-top: 1px dashed var(--teal); } .exchange-list, .source-cards { list-style: none; margin: 0; padding: 0; }.exchange-list { display: grid; gap: 14px; }.exchange-question { display: grid; gap: 5px; margin: 0 0 10px; font-size: .85rem; }.exchange-question strong { color: var(--muted); font-size: .6rem; text-transform: uppercase; letter-spacing: .08em; }.evidence-state { width: max-content; padding: 4px 8px; border-radius: 999px; font-size: .62rem; font-weight: 700; }.evidence-grounded { color: var(--success); background: var(--success-pale); }.evidence-insufficient { color: var(--warning); background: var(--warning-pale); }.evidence-unavailable { color: var(--critical); background: var(--critical-pale); } .source-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 9px; margin-top: 13px; }.source-card { padding: 13px; background: var(--surface-subtle); border: 1px solid var(--line); }.source-title { margin: 0; font-size: .73rem; font-weight: 700; }.source-version { color: var(--muted); font-size: .62rem; }.source-section { margin: 5px 0; color: var(--teal-dark); font-size: .65rem; font-weight: 700; }.source-excerpt { margin: 0; color: var(--muted); font-size: .68rem; line-height: 1.45; } +.source-cards-collapsed { grid-template-columns: 1fr; } +.source-cards-collapsed .source-card { padding: 0; border-radius: var(--radius-sm); } +.source-cards-collapsed summary { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 9px; padding: 12px 13px; cursor: pointer; } +.source-cards-collapsed details[open] summary { border-bottom: 1px solid var(--line); } +.source-cards-collapsed .source-number { display: grid; place-items: center; width: 22px; height: 22px; border-radius: 50%; color: var(--teal-dark); background: var(--teal-pale); font-size: .64rem; font-weight: 800; } +.source-cards-collapsed .source-section { margin: 0; text-align: right; } +.source-cards-collapsed .source-excerpt { padding: 13px 13px 5px; } +.source-cards-collapsed .source-version { margin: 0; padding: 0 13px 13px; } +.exchange-timing { margin: -2px 0 12px; color: var(--muted); font-size: .65rem; } +.knowledge-feedback { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--line); color: var(--muted); font-size: .68rem; } +.knowledge-feedback button { min-height: 34px; padding: 5px 10px; } +.knowledge-feedback button.is-selected { color: var(--teal-dark); border-color: var(--teal); background: var(--teal-pale); } .state-panel { min-height: 180px; display: flex; align-items: center; justify-content: center; gap: 12px; padding: 28px; color: var(--muted); background: white; border: 1px solid var(--line); border-radius: var(--radius); text-align: left; }.state-panel svg { width: 24px; color: var(--critical); }.state-panel strong { color: var(--ink); font-size: .82rem; }.state-panel p { margin: 4px 0 0; font-size: .73rem; }.spinner { width: 22px; height: 22px; border: 2px solid var(--line); border-top-color: var(--teal); border-radius: 50%; animation: spin .7s linear infinite; }@keyframes spin { to { transform: rotate(360deg); } }.state-empty svg { color: var(--teal-dark); } .error { color: #9f2929; font-size: .74rem; font-weight: 600; }