M17: ground knowledge and integration evidence

This commit is contained in:
NuklearRabbit
2026-08-10 12:27:17 +02:00
parent 686795a452
commit 8030753dbc
15 changed files with 480 additions and 74 deletions
+19
View File
@@ -2417,3 +2417,22 @@ evidence yet."
**218 passed** and the disposable database/network/volume were removed automatically. **218 passed** and the disposable database/network/volume were removed automatically.
- Exact next action: implement honest loading states and RAG/source deduplication, then - Exact next action: implement honest loading states and RAG/source deduplication, then
revalidate live Knowledge and Integration flows. 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.
+47 -1
View File
@@ -3,11 +3,13 @@ from __future__ import annotations
import uuid import uuid
from typing import Literal from typing import Literal
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db from app.api.deps import get_current_user, get_db
from app.models.audit import AuditEvent
from app.schemas import CurrentUser from app.schemas import CurrentUser
from app.services.audit import record_audit_event from app.services.audit import record_audit_event
from app.services.knowledge import GroundedAnswer, KnowledgeHealth, get_knowledge_provider from app.services.knowledge import GroundedAnswer, KnowledgeHealth, get_knowledge_provider
@@ -22,6 +24,11 @@ class AskQuestionRequest(BaseModel):
language: SupportedLanguage = "en-GB" language: SupportedLanguage = "en-GB"
class KnowledgeFeedbackRequest(BaseModel):
correlation_id: uuid.UUID
helpful: bool
@router.post("/questions", response_model=GroundedAnswer) @router.post("/questions", response_model=GroundedAnswer)
def ask_question( def ask_question(
body: AskQuestionRequest, body: AskQuestionRequest,
@@ -51,6 +58,45 @@ def ask_question(
return answer 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) @router.get("/status", response_model=KnowledgeHealth)
def knowledge_status( def knowledge_status(
language: SupportedLanguage = "en-GB", language: SupportedLanguage = "en-GB",
+17 -3
View File
@@ -17,9 +17,10 @@ _GROUNDED_ANSWERABILITY = {"answerable", "partially_answerable"}
_LEAD_ANSWER_TEMPLATE = { _LEAD_ANSWER_TEMPLATE = {
"en-GB": 'Per "{title}": {excerpt}', "en-GB": 'Per "{title}": {excerpt}',
"nl-BE": 'Volgens "{title}": {excerpt}', "nl-BE": 'Volgens "{title}": {excerpt}',
"fr-BE": 'Selon « {title} » : {excerpt}', "fr-BE": "Selon « {title} » : {excerpt}",
} }
_DEFAULT_LANGUAGE = "en-GB" _DEFAULT_LANGUAGE = "en-GB"
_MAX_SOURCE_CARDS = 3
_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é"),
@@ -48,14 +49,26 @@ def _question_concepts(question: str) -> set[str]:
def _deduplicate_sources(sources: list[SourceCard]) -> list[SourceCard]: 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] = [] unique: list[SourceCard] = []
for source in sources: 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: if key in seen:
continue continue
seen.add(key) seen.add(key)
unique.append(source) unique.append(source)
if len(unique) == _MAX_SOURCE_CARDS:
break
return unique return unique
@@ -193,6 +206,7 @@ class RAGcoreKnowledgeProvider:
) )
for citation in citations.values() for citation in citations.values()
] ]
sources = _deduplicate_sources(sources)
answerability = body.get("answerability", "not_answerable") answerability = body.get("answerability", "not_answerable")
is_grounded = answerability in _GROUNDED_ANSWERABILITY and sources is_grounded = answerability in _GROUNDED_ANSWERABILITY and sources
evidence_state: EvidenceState = "grounded" if is_grounded else "insufficient" evidence_state: EvidenceState = "grounded" if is_grounded else "insufficient"
+63 -6
View File
@@ -107,9 +107,7 @@ def test_demo_provider_grounds_damage_question_in_french():
def test_demo_provider_insufficient_evidence_message_is_localized(): def test_demo_provider_insufficient_evidence_message_is_localized():
provider = DemoKnowledgeProvider() provider = DemoKnowledgeProvider()
nl_answer = provider.ask( nl_answer = provider.ask("Wat is de hoofdstad van Frankrijk?", "test-correlation-nl-2", "nl-BE")
"Wat is de hoofdstad van Frankrijk?", "test-correlation-nl-2", "nl-BE"
)
fr_answer = provider.ask( fr_answer = provider.ask(
"Quelle est la capitale de la France ?", "test-correlation-fr-2", "fr-BE" "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", "/api/v1/knowledge/questions",
json={"question": "What must I do when a vehicle returns with damage?"}, json={"question": "What must I do when a vehicle returns with damage?"},
) )
events = ops_client.get( events = ops_client.get("/api/v1/audit", params={"action": "knowledge_question_asked"}).json()
"/api/v1/audit", params={"action": "knowledge_question_asked"}
).json()
assert len(events) >= 1 assert len(events) >= 1
metadata = events[0]["metadata"] metadata = events[0]["metadata"]
assert "evidence_state" in metadata assert "evidence_state" in metadata
@@ -288,6 +284,67 @@ def test_ragcore_provider_grounded_answer_maps_citations_to_sources(monkeypatch)
assert source.excerpt 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): def test_ragcore_provider_not_answerable_is_insufficient_and_never_fabricates(monkeypatch):
provider = RAGcoreKnowledgeProvider() provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1") monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
+73
View File
@@ -19,6 +19,32 @@ paths:
type: string type: string
type: object type: object
title: Response Health Health Get 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: /api/v1/system/status:
get: get:
summary: System Status summary: System Status
@@ -1436,6 +1462,34 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/HTTPValidationError' $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: /api/v1/knowledge/status:
get: get:
tags: tags:
@@ -2882,6 +2936,20 @@ components:
- n8n - n8n
- mcp_hub - mcp_hub
title: IntegrationStatusOut 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: KnowledgeHealth:
properties: properties:
provider: provider:
@@ -3853,6 +3921,11 @@ components:
type: type:
type: string type: string
title: Error Type title: Error Type
input:
title: Input
ctx:
type: object
title: Context
type: object type: object
required: required:
- loc - loc
+7 -3
View File
@@ -65,11 +65,15 @@ test("knowledge page suggested question returns a grounded, honestly-labelled an
await expect(page).toHaveURL(/\/dashboard$/); await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/knowledge"); await page.goto("/knowledge");
// The status badge and retrieval-flow diagram must name the actual active provider // The status badge must name the actual active provider once its check settles.
// 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.
await expect(page.locator(".knowledge-status strong")).toHaveText(/RAGcore|de demokennisbank/); await expect(page.locator(".knowledge-status strong")).toHaveText(/RAGcore|de demokennisbank/);
await page.getByRole("button", { name: "Wie beoordeelt een ongewone kilometerstand?" }).click(); await page.getByRole("button", { name: "Wie beoordeelt een ongewone kilometerstand?" }).click();
await expect(page.getByText("Onderbouwd met geciteerde procedures")).toBeVisible({ timeout: 15_000 }); 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();
}); });
@@ -3,6 +3,7 @@
"title": "Integration control", "title": "Integration control",
"description": "Monitor delivery health, graceful degradation and retryable workflow events.", "description": "Monitor delivery health, graceful degradation and retryable workflow events.",
"cards": { "cards": {
"checking": "Retrieving current integration status…",
"orchestrationKicker": "Orchestration", "orchestrationKicker": "Orchestration",
"n8nTitle": "n8n delivery", "n8nTitle": "n8n delivery",
"n8nSummary": "{{succeeded}} succeeded · {{failed}} failed · {{pending}} pending · {{delivering}} delivering", "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." "n8nDemoScenario": "Plus {{count}} prepared demo scenario — a simulated temporary failure, not an integration problem."
}, },
"statusLabels": { "statusLabels": {
"checking": "Checking…",
"notConnected": "Not connected", "notConnected": "Not connected",
"deliveryFailed": "Delivery failed", "deliveryFailed": "Delivery failed",
"retryAvailable": "Retry available", "retryAvailable": "Retry available",
@@ -35,6 +37,8 @@
"workflows": { "workflows": {
"title": "Automation workflows", "title": "Automation workflows",
"description": "{{known}} of {{expected}} canonical n8n workflows have live evidence of running.", "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", "notBuilt": "Not built yet",
"noEvidence": "No evidence yet", "noEvidence": "No evidence yet",
"healthy": "Healthy", "healthy": "Healthy",
@@ -53,6 +57,12 @@
"ragcoreSync": "Knowledge procedure sync", "ragcoreSync": "Knowledge procedure sync",
"errorHandler": "Workflow error handler" "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": { "errorHandler": {
"summary": "{{count}} automation failure(s) registered — latest from {{workflow}} at {{when}}.", "summary": "{{count}} automation failure(s) registered — latest from {{workflow}} at {{when}}.",
"summaryEmpty": "No automation failures have been registered." "summaryEmpty": "No automation failures have been registered."
+13 -1
View File
@@ -4,6 +4,10 @@
"description": "Ask operational questions. Answers are shown only when {{provider}} returns sufficient cited evidence.", "description": "Ask operational questions. Answers are shown only when {{provider}} returns sufficient cited evidence.",
"providerDemo": "the demo knowledge base", "providerDemo": "the demo knowledge base",
"providerRagcore": "RAGcore", "providerRagcore": "RAGcore",
"providerChecking": "the knowledge source",
"providerUnavailable": "the knowledge service",
"statusChecking": "Checking knowledge source…",
"statusCheckingDetail": "Retrieving the current provider status.",
"statusAvailable": "Available", "statusAvailable": "Available",
"statusUnavailable": "Unavailable", "statusUnavailable": "Unavailable",
"proceduresIndexed": "{{count}} procedures indexed", "proceduresIndexed": "{{count}} procedures indexed",
@@ -16,6 +20,7 @@
"ask": "Ask", "ask": "Ask",
"asking": "Asking…", "asking": "Asking…",
"askFailed": "Could not reach the knowledge service.", "askFailed": "Could not reach the knowledge service.",
"feedbackFailed": "Could not save feedback.",
"suggestedLabel": "Try one:", "suggestedLabel": "Try one:",
"suggestedQuestions": [ "suggestedQuestions": [
"What must I do when a vehicle returns with damage?", "What must I do when a vehicle returns with damage?",
@@ -38,5 +43,12 @@
"unavailable": "Knowledge service unavailable" "unavailable": "Knowledge service unavailable"
}, },
"unavailableBody": "The knowledge service is currently unreachable. Operational features are unaffected — try again later.", "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"
} }
@@ -3,6 +3,7 @@
"title": "Contrôle des intégrations", "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.", "description": "Surveillez la santé des livraisons, la dégradation contrôlée et les événements de workflow réessayables.",
"cards": { "cards": {
"checking": "Récupération de l’état actuel des intégrations…",
"orchestrationKicker": "Orchestration", "orchestrationKicker": "Orchestration",
"n8nTitle": "Livraison n8n", "n8nTitle": "Livraison n8n",
"n8nSummary": "{{succeeded}} réussies · {{failed}} échouées · {{pending}} en attente · {{delivering}} en cours", "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." "n8nDemoScenario": "Plus {{count}} scénario de démonstration préparé — une panne temporaire simulée, pas un problème d'intégration."
}, },
"statusLabels": { "statusLabels": {
"checking": "Vérification…",
"notConnected": "Non connecté", "notConnected": "Non connecté",
"deliveryFailed": "Livraison échouée", "deliveryFailed": "Livraison échouée",
"retryAvailable": "Nouvelle tentative possible", "retryAvailable": "Nouvelle tentative possible",
@@ -35,6 +37,8 @@
"workflows": { "workflows": {
"title": "Workflows d'automatisation", "title": "Workflows d'automatisation",
"description": "{{known}} workflows n8n canoniques sur {{expected}} disposent de preuves actuelles de fonctionnement.", "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éé", "notBuilt": "Pas encore créé",
"noEvidence": "Aucune preuve pour linstant", "noEvidence": "Aucune preuve pour linstant",
"healthy": "Sain", "healthy": "Sain",
@@ -53,6 +57,12 @@
"ragcoreSync": "Synchronisation des procédures de connaissances", "ragcoreSync": "Synchronisation des procédures de connaissances",
"errorHandler": "Gestionnaire d'erreurs de workflow" "errorHandler": "Gestionnaire d'erreurs de workflow"
}, },
"expectations": {
"vehicleReturn": "Déclenché après lenregistrement sécurisé dun retour de véhicule.",
"scheduledScan": "Planifié ; sexécute normalement chaque heure.",
"ragcoreSync": "Planifié ; sexécute normalement chaque jour.",
"errorHandler": "Sexécute uniquement lorsquun workflow réel échoue."
},
"errorHandler": { "errorHandler": {
"summary": "{{count}} échec(s) d'automatisation enregistré(s) — le dernier provient de {{workflow}} à {{when}}.", "summary": "{{count}} échec(s) d'automatisation enregistré(s) — le dernier provient de {{workflow}} à {{when}}.",
"summaryEmpty": "Aucun échec d'automatisation n'a été enregistré." "summaryEmpty": "Aucun échec d'automatisation n'a été enregistré."
+13 -1
View File
@@ -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.", "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", "providerDemo": "la base de connaissances de démo",
"providerRagcore": "RAGcore", "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", "statusAvailable": "Disponible",
"statusUnavailable": "Indisponible", "statusUnavailable": "Indisponible",
"proceduresIndexed": "{{count}} procédures indexées", "proceduresIndexed": "{{count}} procédures indexées",
@@ -16,6 +20,7 @@
"ask": "Demander", "ask": "Demander",
"asking": "Question en cours…", "asking": "Question en cours…",
"askFailed": "Impossible de joindre le service de connaissances.", "askFailed": "Impossible de joindre le service de connaissances.",
"feedbackFailed": "Impossible denregistrer le retour.",
"suggestedLabel": "Essayez :", "suggestedLabel": "Essayez :",
"suggestedQuestions": [ "suggestedQuestions": [
"Que dois-je faire quand un véhicule revient avec des dommages ?", "Que dois-je faire quand un véhicule revient avec des dommages ?",
@@ -38,5 +43,12 @@
"unavailable": "Service de connaissances indisponible" "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.", "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 laudit",
"feedbackLabel": "Avis sur la réponse",
"feedbackPrompt": "Cette réponse était-elle utile ?",
"feedbackThanks": "Avis enregistré",
"feedbackHelpful": "Oui",
"feedbackNotHelpful": "Non"
} }
@@ -3,6 +3,7 @@
"title": "Integratiebeheer", "title": "Integratiebeheer",
"description": "Bewaak afleverkwaliteit, beperkte beschikbaarheid en herprobeerbare workflowgebeurtenissen.", "description": "Bewaak afleverkwaliteit, beperkte beschikbaarheid en herprobeerbare workflowgebeurtenissen.",
"cards": { "cards": {
"checking": "Actuele integratiestatus ophalen…",
"orchestrationKicker": "Orkestratie", "orchestrationKicker": "Orkestratie",
"n8nTitle": "n8n-aflevering", "n8nTitle": "n8n-aflevering",
"n8nSummary": "{{succeeded}} geslaagd · {{failed}} mislukt · {{pending}} in wachtrij · {{delivering}} bezig", "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." "n8nDemoScenario": "Plus {{count}} voorbereid demoscenario — een gesimuleerde tijdelijke fout, geen integratieprobleem."
}, },
"statusLabels": { "statusLabels": {
"checking": "Controleren…",
"notConnected": "Niet gekoppeld", "notConnected": "Niet gekoppeld",
"deliveryFailed": "Verwerking mislukt", "deliveryFailed": "Verwerking mislukt",
"retryAvailable": "Opnieuw proberen mogelijk", "retryAvailable": "Opnieuw proberen mogelijk",
@@ -35,6 +37,8 @@
"workflows": { "workflows": {
"title": "Automatiseringsworkflows", "title": "Automatiseringsworkflows",
"description": "{{known}} van {{expected}} canonieke n8n-workflows hebben actuele evidentie van werking.", "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", "notBuilt": "Nog niet gebouwd",
"noEvidence": "Nog geen evidentie", "noEvidence": "Nog geen evidentie",
"healthy": "Gezond", "healthy": "Gezond",
@@ -53,6 +57,12 @@
"ragcoreSync": "Synchronisatie kennisprocedures", "ragcoreSync": "Synchronisatie kennisprocedures",
"errorHandler": "Workflowfoutafhandelaar" "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": { "errorHandler": {
"summary": "{{count}} automatiseringsfout(en) geregistreerd — laatste van {{workflow}} om {{when}}.", "summary": "{{count}} automatiseringsfout(en) geregistreerd — laatste van {{workflow}} om {{when}}.",
"summaryEmpty": "Er zijn geen automatiseringsfouten geregistreerd." "summaryEmpty": "Er zijn geen automatiseringsfouten geregistreerd."
+13 -1
View File
@@ -4,6 +4,10 @@
"description": "Stel operationele vragen. Antwoorden verschijnen enkel wanneer {{provider}} voldoende onderbouwde evidentie teruggeeft.", "description": "Stel operationele vragen. Antwoorden verschijnen enkel wanneer {{provider}} voldoende onderbouwde evidentie teruggeeft.",
"providerDemo": "de demokennisbank", "providerDemo": "de demokennisbank",
"providerRagcore": "RAGcore", "providerRagcore": "RAGcore",
"providerChecking": "de kennisbron",
"providerUnavailable": "de kennisdienst",
"statusChecking": "Kennisbron controleren…",
"statusCheckingDetail": "De actuele providerstatus wordt opgehaald.",
"statusAvailable": "Beschikbaar", "statusAvailable": "Beschikbaar",
"statusUnavailable": "Niet beschikbaar", "statusUnavailable": "Niet beschikbaar",
"proceduresIndexed": "{{count}} procedures geïndexeerd", "proceduresIndexed": "{{count}} procedures geïndexeerd",
@@ -16,6 +20,7 @@
"ask": "Vraag stellen", "ask": "Vraag stellen",
"asking": "Bezig met vragen…", "asking": "Bezig met vragen…",
"askFailed": "Kon de kennisdienst niet bereiken.", "askFailed": "Kon de kennisdienst niet bereiken.",
"feedbackFailed": "Kon feedback niet opslaan.",
"suggestedLabel": "Probeer:", "suggestedLabel": "Probeer:",
"suggestedQuestions": [ "suggestedQuestions": [
"Wat moet ik doen wanneer een voertuig terugkomt met schade?", "Wat moet ik doen wanneer een voertuig terugkomt met schade?",
@@ -38,5 +43,12 @@
"unavailable": "Kennisdienst niet beschikbaar" "unavailable": "Kennisdienst niet beschikbaar"
}, },
"unavailableBody": "De kennisdienst is momenteel niet bereikbaar. Operationele functies zijn hier niet door beïnvloed — probeer later opnieuw.", "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"
} }
+44 -14
View File
@@ -29,7 +29,9 @@ export function Automation() {
const [retryError, setRetryError] = useState<ApiErrorInfo | null>(null); const [retryError, setRetryError] = useState<ApiErrorInfo | null>(null);
const [retrying, setRetrying] = useState<string | null>(null); const [retrying, setRetrying] = useState<string | null>(null);
const [knowledge, setKnowledge] = useState<KnowledgeHealth | null>(null); const [knowledge, setKnowledge] = useState<KnowledgeHealth | null>(null);
const [knowledgeSettled, setKnowledgeSettled] = useState(false);
const [integrationStatus, setIntegrationStatus] = useState<IntegrationStatus | null>(null); const [integrationStatus, setIntegrationStatus] = useState<IntegrationStatus | null>(null);
const [integrationSettled, setIntegrationSettled] = useState(false);
const load = useCallback(() => { const load = useCallback(() => {
setRuns(null); setRuns(null);
@@ -49,14 +51,21 @@ export function Automation() {
}, [load]); }, [load]);
useEffect(() => { useEffect(() => {
api.get<KnowledgeHealth>("/api/v1/knowledge/status").then(setKnowledge).catch(() => setKnowledge(null)); setKnowledgeSettled(false);
api
.get<KnowledgeHealth>("/api/v1/knowledge/status")
.then(setKnowledge)
.catch(() => setKnowledge(null))
.finally(() => setKnowledgeSettled(true));
}, []); }, []);
const loadIntegrationStatus = useCallback(() => { const loadIntegrationStatus = useCallback(() => {
setIntegrationSettled(false);
api api
.get<IntegrationStatus>("/api/v1/integrations/status") .get<IntegrationStatus>("/api/v1/integrations/status")
.then(setIntegrationStatus) .then(setIntegrationStatus)
.catch(() => setIntegrationStatus(null)); .catch(() => setIntegrationStatus(null))
.finally(() => setIntegrationSettled(true));
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -167,7 +176,9 @@ export function Automation() {
<span className="integration-kicker">{t("cards.orchestrationKicker")}</span> <span className="integration-kicker">{t("cards.orchestrationKicker")}</span>
<h2>{t("cards.n8nTitle")}</h2> <h2>{t("cards.n8nTitle")}</h2>
<p> <p>
{integrationStatus {!integrationSettled
? t("cards.checking")
: integrationStatus
? t("cards.n8nSummary", { ? t("cards.n8nSummary", {
succeeded: integrationStatus.n8n.succeeded, succeeded: integrationStatus.n8n.succeeded,
failed: integrationStatus.n8n.unexpected_failed, failed: integrationStatus.n8n.unexpected_failed,
@@ -188,8 +199,8 @@ export function Automation() {
const meta = integrationStatus ? N8N_STATE_META[integrationStatus.n8n.state] : null; const meta = integrationStatus ? N8N_STATE_META[integrationStatus.n8n.state] : null;
return ( return (
<StatusBadge <StatusBadge
status={meta?.statusClass ?? (runs?.[0]?.status ?? "no_events")} status={!integrationSettled ? "loading" : (meta?.statusClass ?? (runs?.[0]?.status ?? "unavailable"))}
label={meta ? t(`statusLabels.${meta.labelKey}`) : undefined} label={!integrationSettled ? t("statusLabels.checking") : meta ? t(`statusLabels.${meta.labelKey}`) : t("statusLabels.unavailable")}
/> />
); );
})()} })()}
@@ -200,7 +211,9 @@ export function Automation() {
<span className="integration-kicker">{t("cards.knowledgeKicker")}</span> <span className="integration-kicker">{t("cards.knowledgeKicker")}</span>
<h2>{t("cards.knowledgeTitle")}</h2> <h2>{t("cards.knowledgeTitle")}</h2>
<p> <p>
{knowledge {!knowledgeSettled
? t("cards.checking")
: knowledge
? knowledge.document_count === null ? knowledge.document_count === null
? t("cards.knowledgeSummaryIndexUnknown", { collection: knowledge.collection }) ? t("cards.knowledgeSummaryIndexUnknown", { collection: knowledge.collection })
: t(knowledge.provider === "ragcore" ? "cards.knowledgeSummaryRagcore" : "cards.knowledgeSummaryDemo", { : t(knowledge.provider === "ragcore" ? "cards.knowledgeSummaryRagcore" : "cards.knowledgeSummaryDemo", {
@@ -211,9 +224,11 @@ export function Automation() {
</p> </p>
</div> </div>
<StatusBadge <StatusBadge
status={knowledge?.available ? "available" : "unavailable"} status={!knowledgeSettled ? "loading" : knowledge?.available ? "available" : "unavailable"}
label={ label={
knowledge?.available !knowledgeSettled
? t("statusLabels.checking")
: knowledge?.available
? t(`statusLabels.${knowledge.provider === "ragcore" ? "operational" : "demoMode"}`) ? t(`statusLabels.${knowledge.provider === "ragcore" ? "operational" : "demoMode"}`)
: t("statusLabels.unavailable") : t("statusLabels.unavailable")
} }
@@ -226,6 +241,7 @@ export function Automation() {
<h2>{t("cards.mcpTitle")}</h2> <h2>{t("cards.mcpTitle")}</h2>
<p> <p>
{(() => { {(() => {
if (!integrationSettled) return t("cards.checking");
const hub = integrationStatus?.mcp_hub; const hub = integrationStatus?.mcp_hub;
if (!hub?.registration_enabled) return t("cards.mcpNotConnected"); if (!hub?.registration_enabled) return t("cards.mcpNotConnected");
if (hub.total_calls > 0) { if (hub.total_calls > 0) {
@@ -241,7 +257,12 @@ export function Automation() {
<div className="integration-badge-stack"> <div className="integration-badge-stack">
{(() => { {(() => {
const meta = integrationStatus ? MCP_STATE_META[integrationStatus.mcp_hub.state] : null; const meta = integrationStatus ? MCP_STATE_META[integrationStatus.mcp_hub.state] : null;
return <StatusBadge status={meta?.statusClass ?? "not_configured"} label={meta ? t(`statusLabels.${meta.labelKey}`) : undefined} />; return (
<StatusBadge
status={!integrationSettled ? "loading" : (meta?.statusClass ?? "unavailable")}
label={!integrationSettled ? t("statusLabels.checking") : meta ? t(`statusLabels.${meta.labelKey}`) : t("statusLabels.unavailable")}
/>
);
})()} })()}
{integrationStatus?.mcp_hub.hub_reachable !== null && integrationStatus?.mcp_hub.hub_reachable !== undefined && ( {integrationStatus?.mcp_hub.hub_reachable !== null && integrationStatus?.mcp_hub.hub_reachable !== undefined && (
<StatusBadge <StatusBadge
@@ -255,10 +276,16 @@ export function Automation() {
<SectionHeading <SectionHeading
title={t("workflows.title")} title={t("workflows.title")}
description={t("workflows.description", { description={
known: integrationStatus?.n8n.known_workflow_count ?? 0, !integrationSettled
expected: integrationStatus?.n8n.expected_workflow_count ?? 4, ? t("workflows.loadingEvidence")
})} : integrationStatus
? t("workflows.description", {
known: integrationStatus.n8n.known_workflow_count,
expected: integrationStatus.n8n.expected_workflow_count,
})
: t("workflows.unavailable")
}
/> />
{integrationStatus && ( {integrationStatus && (
@@ -288,7 +315,10 @@ export function Automation() {
: { status: "no_events", label: t("workflows.noEvidence") }; : { status: "no_events", label: t("workflows.noEvidence") };
return ( return (
<tr key={w.name}> <tr key={w.name}>
<td data-label={t("workflows.columns.name")}>{displayName}</td> <td data-label={t("workflows.columns.name")}>
{displayName}
{slug && <span className="table-subtext">{t(`workflows.expectations.${slug}`)}</span>}
</td>
<td data-label={t("workflows.columns.status")}> <td data-label={t("workflows.columns.status")}>
<StatusBadge status={badge.status} label={badge.label} /> <StatusBadge status={badge.status} label={badge.label} />
</td> </td>
+123 -44
View File
@@ -10,11 +10,19 @@ import { PRODUCT_NAME } from "../product";
interface Exchange { interface Exchange {
question: string; question: string;
answer: GroundedAnswer; 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() { export function Knowledge() {
const { t, i18n } = useTranslation(["knowledge", "errors"]); const { t, i18n } = useTranslation(["knowledge", "errors"]);
const [status, setStatus] = useState<KnowledgeHealth | null>(null); const [status, setStatus] = useState<KnowledgeHealth | null>(null);
const [statusSettled, setStatusSettled] = useState(false);
const [question, setQuestion] = useState(""); const [question, setQuestion] = useState("");
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<ApiErrorInfo | null>(null); const [error, setError] = useState<ApiErrorInfo | null>(null);
@@ -23,21 +31,30 @@ export function Knowledge() {
const language = i18n.language as "nl-BE" | "en-GB" | "fr-BE"; const language = i18n.language as "nl-BE" | "en-GB" | "fr-BE";
useEffect(() => { useEffect(() => {
setStatusSettled(false);
api api
.get<KnowledgeHealth>(`/api/v1/knowledge/status?language=${language}`) .get<KnowledgeHealth>(`/api/v1/knowledge/status?language=${language}`)
.then(setStatus) .then(setStatus)
.catch(() => setStatus(null)); .catch(() => setStatus(null))
.finally(() => setStatusSettled(true));
}, [language]); }, [language]);
async function ask(questionText: string) { async function ask(questionText: string) {
setError(null); setError(null);
setSubmitting(true); setSubmitting(true);
const startedAt = performance.now();
try { try {
const answer = await api.post<GroundedAnswer>("/api/v1/knowledge/questions", { const answer = await api.post<GroundedAnswer>("/api/v1/knowledge/questions", {
question: questionText, question: questionText,
language, language,
}); });
setExchanges((prev) => [{ question: questionText, answer }, ...prev]); setExchanges((previous) => [{
question: questionText,
answer,
durationMs: Math.round(performance.now() - startedAt),
feedback: null,
feedbackSaving: false,
}, ...previous]);
setQuestion(""); setQuestion("");
} catch (err) { } catch (err) {
setError(describeApiError(t, err, "askFailed")); setError(describeApiError(t, err, "askFailed"));
@@ -46,13 +63,42 @@ export function Knowledge() {
} }
} }
async function handleSubmit(e: FormEvent) { async function handleSubmit(event: FormEvent) {
e.preventDefault(); event.preventDefault();
if (!question.trim()) return; if (!question.trim()) return;
await ask(question); 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[]; const suggestedQuestions = t("suggestedQuestions", { returnObjects: true, defaultValue: [] }) as string[];
return ( return (
@@ -62,59 +108,69 @@ export function Knowledge() {
title={t("title")} title={t("title")}
description={t("description", { provider: providerLabel.toLowerCase() })} description={t("description", { provider: providerLabel.toLowerCase() })}
/> />
{status && ( {!statusSettled && (
<div className="knowledge-status is-loading" role="status">
<span className="health-orb" />
<div><strong>{t("statusChecking")}</strong><span>{t("statusCheckingDetail")}</span></div>
</div>
)}
{statusSettled && status && (
<div className="knowledge-status"> <div className="knowledge-status">
<span className={`health-orb ${status.available ? "is-healthy" : "is-down"}`} /> <span className={`health-orb ${status.available ? "is-healthy" : "is-down"}`} />
<div> <div>
<strong>{providerLabel}</strong> <strong>{providerLabel}</strong>
<span> <span>
{status.available ? t("statusAvailable") : t("statusUnavailable")} ·{" "} {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 })}
</span> </span>
</div> </div>
<small>{status.collection}</small> <small>{status.collection}</small>
</div> </div>
)} )}
{status?.provider !== "ragcore" && ( {statusSettled && !status && (
<p className="knowledge-provider-note"> <div className="knowledge-status" role="status">
<Icon name="shield" /> {t("providerNote")} <span className="health-orb is-down" />
</p> <div><strong>{t("statusUnavailable")}</strong><span>{t("unavailableBody")}</span></div>
</div>
)}
{statusSettled && status?.provider === "demo" && (
<p className="knowledge-provider-note"><Icon name="shield" /> {t("providerNote")}</p>
)} )}
<form className="panel knowledge-form" onSubmit={handleSubmit} aria-labelledby="ask-heading"> <form className="panel knowledge-form" onSubmit={handleSubmit} aria-labelledby="ask-heading">
<div className="ask-heading"> <div className="ask-heading">
<span><Icon name="spark" /></span> <span><Icon name="spark" /></span>
<div> <div><h2 id="ask-heading">{t("askHeading")}</h2><p>{t("askSubheading")}</p></div>
<h2 id="ask-heading">{t("askHeading")}</h2>
<p>{t("askSubheading")}</p>
</div>
</div> </div>
<label htmlFor="knowledge-question" className="visually-hidden"> <label htmlFor="knowledge-question" className="visually-hidden">{t("questionLabel")}</label>
{t("questionLabel")}
</label>
<div className="knowledge-input-row"> <div className="knowledge-input-row">
<input <input
id="knowledge-question" id="knowledge-question"
type="text" type="text"
value={question} value={question}
onChange={(e) => setQuestion(e.target.value)} onChange={(event) => setQuestion(event.target.value)}
placeholder={t("questionPlaceholder")} placeholder={t("questionPlaceholder")}
minLength={3} minLength={3}
maxLength={1000} maxLength={1000}
required required
/> />
<button className="button button-primary" type="submit" disabled={submitting}> <button className="button button-primary" type="submit" disabled={submitting}>
{submitting ? t("asking") : t("ask")} {submitting ? t("asking") : t("ask")}{!submitting && <Icon name="chevron" />}
{!submitting && <Icon name="chevron" />}
</button> </button>
</div> </div>
<ApiErrorNotice error={error} /> <ApiErrorNotice error={error} />
<div className="knowledge-suggestions"> <div className="knowledge-suggestions">
<span>{t("suggestedLabel")}</span> <span>{t("suggestedLabel")}</span>
{suggestedQuestions.map((q) => ( {suggestedQuestions.map((suggestion) => (
<button key={q} type="button" className="suggestion-chip" onClick={() => ask(q)} disabled={submitting}> <button
{q} key={suggestion}
</button> type="button"
className="suggestion-chip"
onClick={() => ask(suggestion)}
disabled={submitting}
>{suggestion}</button>
))} ))}
</div> </div>
</form> </form>
@@ -134,34 +190,57 @@ export function Knowledge() {
)} )}
<ul className="exchange-list"> <ul className="exchange-list">
{exchanges.map((exchange, index) => ( {exchanges.map((exchange) => (
<li key={index} className="panel exchange"> <li key={exchange.answer.correlation_id} className="panel exchange">
<p className="exchange-question"> <p className="exchange-question"><strong>{t("questionLabelExchange")}</strong> {exchange.question}</p>
<strong>{t("questionLabelExchange")}</strong> {exchange.question}
</p>
<p className={`evidence-state evidence-${exchange.answer.evidence_state}`}> <p className={`evidence-state evidence-${exchange.answer.evidence_state}`}>
{t(`evidenceStates.${exchange.answer.evidence_state}`)} {t(`evidenceStates.${exchange.answer.evidence_state}`)}
</p> </p>
<p className="exchange-timing">
{exchange.answer.evidence_state === "unavailable" ? ( {t("answeredIn", { seconds: (exchange.durationMs / 1000).toFixed(1) })}
<p>{t("unavailableBody")}</p> </p>
) : ( {exchange.answer.evidence_state === "unavailable"
<p>{exchange.answer.answer}</p> ? <p>{t("unavailableBody")}</p>
)} : <p>{exchange.answer.answer}</p>}
{exchange.answer.sources.length > 0 && ( {exchange.answer.sources.length > 0 && (
<ul className="source-cards"> <ul className="source-cards source-cards-collapsed">
{exchange.answer.sources.map((source) => ( {exchange.answer.sources.map((source, sourceIndex) => (
<li key={`${source.document_id}-${source.section}`} className="source-card"> <li key={`${source.title}-${source.section}-${source.excerpt}`} className="source-card">
<p className="source-title"> <details open={sourceIndex === 0}>
{source.title} <span className="source-version">{t("sourceVersion", { version: source.version })}</span> <summary>
</p> <span className="source-number">{sourceIndex + 1}</span>
<p className="source-section">{source.section}</p> <span className="source-title">{source.title}</span>
<p className="source-excerpt">{source.excerpt}</p> {source.section && <span className="source-section">{source.section}</span>}
</summary>
<p className="source-excerpt">{source.excerpt}</p>
<p className="source-version">
{isOpaqueVersion(source.version)
? t("sourceTechnicalVersion")
: t("sourceVersion", { version: source.version })}
</p>
</details>
</li> </li>
))} ))}
</ul> </ul>
)} )}
<div className="knowledge-feedback" aria-label={t("feedbackLabel")}>
<span>{exchange.feedback === null ? t("feedbackPrompt") : t("feedbackThanks")}</span>
<button
type="button"
className={exchange.feedback === true ? "is-selected" : ""}
disabled={exchange.feedbackSaving}
aria-pressed={exchange.feedback === true}
onClick={() => sendFeedback(exchange.answer.correlation_id, true)}
>{t("feedbackHelpful")}</button>
<button
type="button"
className={exchange.feedback === false ? "is-selected" : ""}
disabled={exchange.feedbackSaving}
aria-pressed={exchange.feedback === false}
onClick={() => sendFeedback(exchange.answer.correlation_id, false)}
>{t("feedbackNotHelpful")}</button>
</div>
</li> </li>
))} ))}
</ul> </ul>
+18
View File
@@ -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; } .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 { 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-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; } .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); } .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); } .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 { 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); } .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; } .error { color: #9f2929; font-size: .74rem; font-weight: 600; }