M17: ground knowledge and integration evidence
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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,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"
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
@@ -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 l’instant",
|
"noEvidence": "Aucune preuve pour l’instant",
|
||||||
"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 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": {
|
"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é."
|
||||||
|
|||||||
@@ -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 d’enregistrer 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 l’audit",
|
||||||
|
"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."
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user