M8: GUI polish, n8n workflow-3 fixes, RAGcore retrieval root-cause and fix

GUI: dashboard Attention Queue presents a curated severity mix instead of pure
severity-sort (grouped Now/Today/Later headers); Today's Movements seed data
curated so a fresh reset shows a credible day (2+ departures, 2+ returns), with
a new seed-integrity test; About Demo restructured into a compact grid with
progressive disclosure for technical sections; Duplicate Merge shows match/conflict
counts, hides matching fields by default, and previews the final merged record
before confirmation.

Repo hygiene: removed a stray empty `backend;C` directory and an untracked 31MB
zip export; `.gitignore` now excludes future archive exports.

n8n: fixed invalid JSON (a missing `},` between two node objects) in the committed
`fleet-ops-vehicle-return.json` -- the file could not be parsed. Live-validated
workflow 3 (RAGcore Procedure Sync): found and fixed a real defect (three body
parameters had a stray trailing `}}`) and a missing Error Workflow wiring, both
via the safe `n8n import:workflow` CLI path; exported the corrected, still-
inactive workflow as the new source of truth and updated MANIFEST.md/check_drift.py.
Publishing it (starts real daily unattended runs) remains a separate decision.

RAGcore: root-caused and fixed (live, approved) the "zero retrieval candidates"
bug -- a filesystem permission bug (`embedding_profiles.json` unreadable by the
app's own runtime user) that broke every retrieval call before it reached Qdrant.
Every other suspect (grants, scope resolution, Qdrant filters, embeddings) was
verified healthy first. Found a second, deeper gap: the reranker adapter calls
an Ollama HTTP route that does not exist on the deployed Ollama version, so
`/v1/answers` still returns `not_answerable`. `KNOWLEDGE_PROVIDER` stays `demo`
until that is resolved on the RAGcore side. Evidence-based MCP Hub integration
status (real tool-call audit history, not just a boolean flag) replaces the old
`configured`/`not_configured` guess. Full findings in
`docs/final-integrations/current-state-audit.md`.

Backend: 172 tests passing, ruff clean, mypy clean (50 files). Frontend: tsc
clean, production build clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
NuklearRabbit
2026-08-05 13:05:02 +02:00
co-authored by Claude Sonnet 5
parent 3ebca9e9b7
commit 34df66d28c
25 changed files with 669 additions and 100 deletions
+5 -1
View File
@@ -284,7 +284,11 @@ export interface N8nIntegrationStatus {
export interface McpHubIntegrationStatus {
registration_enabled: boolean;
state: "not_configured" | "configured";
state: "not_configured" | "no_evidence" | "operational";
total_calls: number;
last_tool: string | null;
last_client: string | null;
last_called_at: string | null;
}
export interface IntegrationStatus {
+2 -1
View File
@@ -15,7 +15,8 @@ export const N8N_STATE_META: Record<IntegrationStatus["n8n"]["state"], { statusC
export const MCP_STATE_META: Record<IntegrationStatus["mcp_hub"]["state"], { statusClass: string; labelKey: string }> = {
not_configured: { statusClass: "not_configured", labelKey: "notConnected" },
configured: { statusClass: "no_events", labelKey: "prepared" },
no_evidence: { statusClass: "no_events", labelKey: "prepared" },
operational: { statusClass: "available", labelKey: "operational" },
};
// Maps the backend's canonical n8n workflow name (see n8n/workflows/MANIFEST.md) to a
@@ -43,6 +43,9 @@
"severityMedium": "Warning",
"severityLow": "Info",
"severityAriaLabel": "Severity",
"tierNow": "Handle now",
"tierToday": "Follow up today",
"tierLater": "Review later",
"empty": "No issues match this filter.",
"openRecord": "Open {{title}}"
},
@@ -118,6 +118,10 @@
"keepAsSurvivor": "Keep as survivor",
"differs": "Differs",
"match": "Match",
"summaryCounts": "{{matchCount}} matching fields · {{conflictCount}} conflicting fields",
"showMatchingFields": "Also show matching fields",
"hideMatchingFields": "Hide matching fields",
"previewTitle": "Preview of the merged record ({{ref}})",
"mergePreview": "{{loser}} will become a tombstone linked to {{survivor}}; its bookings will be rewired.",
"mergeInto": "Merge into {{ref}}",
"confirmMergeTitle": "Confirm merge",
@@ -43,6 +43,9 @@
"severityMedium": "Avertissement",
"severityLow": "Info",
"severityAriaLabel": "Gravité",
"tierNow": "À traiter maintenant",
"tierToday": "À suivre aujourd'hui",
"tierLater": "À contrôler plus tard",
"empty": "Aucun problème ne correspond à ce filtre.",
"openRecord": "Ouvrir {{title}}"
},
@@ -118,6 +118,10 @@
"keepAsSurvivor": "Conserver comme fiche principale",
"differs": "Diffère",
"match": "Identique",
"summaryCounts": "{{matchCount}} champs identiques · {{conflictCount}} champs en conflit",
"showMatchingFields": "Afficher aussi les champs identiques",
"hideMatchingFields": "Masquer les champs identiques",
"previewTitle": "Aperçu de la fiche fusionnée ({{ref}})",
"mergePreview": "{{loser}} deviendra une fiche archivée liée à {{survivor}} ; ses réservations seront transférées.",
"mergeInto": "Fusionner avec {{ref}}",
"confirmMergeTitle": "Confirmer la fusion",
@@ -43,6 +43,9 @@
"severityMedium": "Waarschuwing",
"severityLow": "Info",
"severityAriaLabel": "Ernst",
"tierNow": "Nu behandelen",
"tierToday": "Vandaag opvolgen",
"tierLater": "Later controleren",
"empty": "Geen aandachtspunten voor dit filter.",
"openRecord": "Open {{title}}"
},
@@ -118,6 +118,10 @@
"keepAsSurvivor": "Behouden als hoofdprofiel",
"differs": "Verschilt",
"match": "Gelijk",
"summaryCounts": "{{matchCount}} overeenkomende velden · {{conflictCount}} conflicterende velden",
"showMatchingFields": "Ook overeenkomende velden tonen",
"hideMatchingFields": "Overeenkomende velden verbergen",
"previewTitle": "Voorbeeld van het samengevoegde record ({{ref}})",
"mergePreview": "{{loser}} wordt een tombstone gekoppeld aan {{survivor}}; de boekingen worden herverbonden.",
"mergeInto": "Samenvoegen met {{ref}}",
"confirmMergeTitle": "Samenvoegen bevestigen",
+22 -20
View File
@@ -67,30 +67,32 @@ export function AboutDemo() {
<p>{t("about.scopeBody", { productName: PRODUCT_NAME })}</p>
</section>
<section className="record-surface about-card">
<h2>{t("about.realTitle")}</h2>
<p>{t("about.realBody")}</p>
</section>
<div className="about-grid">
<div className="record-surface about-card">
<h2>{t("about.realTitle")}</h2>
<p>{t("about.realBody")}</p>
</div>
<section className="record-surface about-card">
<h2>{t("about.syntheticTitle")}</h2>
<p>{t("about.syntheticBody", { testDomain: ".test" })}</p>
</section>
<div className="record-surface about-card">
<h2>{t("about.syntheticTitle")}</h2>
<p>{t("about.syntheticBody", { testDomain: ".test" })}</p>
</div>
<section className="record-surface about-card">
<h2>{t("about.architectureTitle")}</h2>
<p>{t("about.architectureBody")}</p>
</section>
<details className="record-surface about-card about-details">
<summary>{t("about.architectureTitle")}</summary>
<p>{t("about.architectureBody")}</p>
</details>
<section className="record-surface about-card">
<h2>{t("about.securityTitle")}</h2>
<p>{t("about.securityBody")}</p>
</section>
<details className="record-surface about-card about-details">
<summary>{t("about.securityTitle")}</summary>
<p>{t("about.securityBody")}</p>
</details>
<section className="record-surface about-card">
<h2>{t("about.testingTitle")}</h2>
<p>{t("about.testingBody")}</p>
</section>
<details className="record-surface about-card about-details">
<summary>{t("about.testingTitle")}</summary>
<p>{t("about.testingBody")}</p>
</details>
</div>
<section aria-label={t("about.integrationsTitle")} className="record-surface">
<SectionHeading title={t("about.integrationsTitle")} description={t("about.integrationsDescription")} />
+44 -26
View File
@@ -75,6 +75,17 @@ export function Dashboard() {
return matchesSeverity && haystack.includes(query.toLowerCase());
}) ?? [], [data, query, severity, t]);
const isUnfiltered = severity === "all" && query === "";
const attentionTiers = useMemo(() => {
if (!isUnfiltered) return [{ key: "all", labelKey: "", items: attention.slice(0, 6) }];
const bySeverity = (level: string) => attention.filter((item) => item.severity === level);
return [
{ key: "high", labelKey: "attention.tierNow", items: bySeverity("high") },
{ key: "medium", labelKey: "attention.tierToday", items: bySeverity("medium") },
{ key: "low", labelKey: "attention.tierLater", items: bySeverity("low") },
].filter((tier) => tier.items.length > 0);
}, [attention, isUnfiltered]);
if (error) return <ErrorState message={error} />;
if (!data) return <LoadingState label={t("common:status.loading")} />;
@@ -129,32 +140,39 @@ export function Dashboard() {
<label><span className="visually-hidden">{t("attention.severityAriaLabel")}</span><select value={severity} onChange={(event) => setSeverity(event.target.value)}><option value="all">{t("attention.severityAll")}</option><option value="high">{t("attention.severityHigh")}</option><option value="medium">{t("attention.severityMedium")}</option><option value="low">{t("attention.severityLow")}</option></select></label>
</div>
{attention.length === 0 ? <p className="quiet-empty"><Icon name="check" /> {t("attention.empty")}</p> : (
<ul className="attention-list">
{attention.slice(0, 6).map((item, index) => {
const href = item.issue_ref && canSeeQuality
? `/data-quality/${item.issue_ref}`
: item.link_type === "vehicle"
? `/vehicles/${item.link_ref}`
: null;
const title = attentionItemTitle(t, item);
return (
<li key={`${item.link_ref}-${index}`} className={href ? "row-clickable" : ""}>
<SeverityBadge severity={item.severity} />
<div className="queue-copy">
<p className="attention-title">{title}</p>
<p className="attention-detail">{item.detail}</p>
</div>
<span className="queue-ref">{item.link_ref}</span>
<Icon name="chevron" className="row-chevron" />
{href && (
<Link className="row-link" to={href} aria-label={t("attention.openRecord", { title })}>
<span className="visually-hidden">{title}</span>
</Link>
)}
</li>
);
})}
</ul>
<>
{attentionTiers.map((tier) => (
<div key={tier.key} className="attention-tier">
{attentionTiers.length > 1 && <p className="attention-tier-label">{t(tier.labelKey)}</p>}
<ul className="attention-list">
{tier.items.map((item, index) => {
const href = item.issue_ref && canSeeQuality
? `/data-quality/${item.issue_ref}`
: item.link_type === "vehicle"
? `/vehicles/${item.link_ref}`
: null;
const title = attentionItemTitle(t, item);
return (
<li key={`${item.link_ref}-${index}`} className={href ? "row-clickable" : ""}>
<SeverityBadge severity={item.severity} />
<div className="queue-copy">
<p className="attention-title">{title}</p>
<p className="attention-detail">{item.detail}</p>
</div>
<span className="queue-ref">{item.link_ref}</span>
<Icon name="chevron" className="row-chevron" />
{href && (
<Link className="row-link" to={href} aria-label={t("attention.openRecord", { title })}>
<span className="visually-hidden">{title}</span>
</Link>
)}
</li>
);
})}
</ul>
</div>
))}
</>
)}
</section>
+32 -1
View File
@@ -128,6 +128,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
const [error, setError] = useState<ApiErrorInfo | null>(null);
const [submitting, setSubmitting] = useState(false);
const [confirming, setConfirming] = useState(false);
const [showMatching, setShowMatching] = useState(false);
if (!issue.entity_snapshot || !issue.related_snapshots[0]) {
return <p className="error">{t("detail.duplicateCustomer.bothMissing")}</p>;
@@ -137,6 +138,9 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
const survivor = survivorRef === a.public_ref ? a : b;
const loser = survivorRef === a.public_ref ? b : a;
const conflictingFields = MERGE_FIELDS.filter((field) => String(a[field] ?? "") !== String(b[field] ?? ""));
const matchingFields = MERGE_FIELDS.filter((field) => !conflictingFields.includes(field));
const visibleFields = showMatching ? MERGE_FIELDS : conflictingFields;
async function handleMerge() {
setError(null);
@@ -174,6 +178,10 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
<SectionHeading headingId="compare-heading" title={t("detail.duplicateCustomer.heading")} description={t("detail.duplicateCustomer.description")} />
<ApiErrorNotice error={error} />
<p className="merge-summary-counts">
{t("detail.duplicateCustomer.summaryCounts", { matchCount: matchingFields.length, conflictCount: conflictingFields.length })}
</p>
<fieldset className="choice-fieldset">
<legend>{t("detail.duplicateCustomer.keepAsSurvivor")}</legend>
<label className={`choice-card ${survivorRef === a.public_ref ? "is-selected" : ""}`}>
@@ -205,7 +213,7 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
</tr>
</thead>
<tbody>
{MERGE_FIELDS.map((field) => {
{visibleFields.map((field) => {
const valueA = a[field] ? String(a[field]) : "—";
const valueB = b[field] ? String(b[field]) : "—";
const differ = valueA !== valueB;
@@ -248,10 +256,33 @@ function DuplicateCustomerPanel({ issue, onResolved }: { issue: IssueDetail; onR
</tbody>
</table>
{matchingFields.length > 0 && (
<button type="button" className="button button-tertiary toggle-matching-fields" onClick={() => setShowMatching((v) => !v)}>
{showMatching ? t("detail.duplicateCustomer.hideMatchingFields") : t("detail.duplicateCustomer.showMatchingFields")}
</button>
)}
<p className="merge-preview">
{t("detail.duplicateCustomer.mergePreview", { loser: loser.public_ref, survivor: survivor.public_ref })}
</p>
<div className="merge-record-preview">
<p className="merge-record-preview-title">{t("detail.duplicateCustomer.previewTitle", { ref: survivor.public_ref })}</p>
<dl>
{MERGE_FIELDS.map((field) => {
const choice = fieldChoices[field];
const chosenSide = choice === "a" ? a : choice === "b" ? b : survivor;
const value = (chosenSide[field] ? String(chosenSide[field]) : survivor[field] ? String(survivor[field]) : "—");
return (
<div key={field}>
<dt>{t(`detail.duplicateCustomer.fields.${field}`)}</dt>
<dd>{value || "—"}</dd>
</div>
);
})}
</dl>
</div>
{!confirming && (
<button type="button" className="button button-primary" onClick={() => setConfirming(true)}>
{t("detail.duplicateCustomer.mergeInto", { ref: survivor.public_ref })}
+16
View File
@@ -193,6 +193,8 @@ a:hover { color: var(--teal); }
.row-link { position: absolute; inset: 0; z-index: 1; border-radius: inherit; }
.row-link:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
.attention-list li.row-clickable:hover .attention-title, .movement-timeline li.row-clickable:hover .movement-ref { color: var(--teal-dark); }
.attention-tier + .attention-tier { border-top: 1px solid var(--line); }
.attention-tier-label { margin: 0; padding: 9px 18px 5px; color: var(--muted); font-size: .62rem; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; }
.data-table tr.row-clickable:hover { background: var(--surface-subtle); }
.data-table tr.row-clickable .cell-link { position: relative; z-index: 2; }
@@ -249,6 +251,12 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.about-cta { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; background: var(--teal-pale); border-color: #bfe6df; }
.about-cta strong { display: block; color: var(--ink); font-size: .85rem; }
.about-cta p { margin: 2px 0 0; }
.about-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 12px; margin-bottom: 18px; }
.about-grid .about-card { margin-bottom: 0; }
.about-details summary { cursor: pointer; color: var(--ink); font-size: 1rem; letter-spacing: -.015em; font-weight: 700; }
.about-details[open] summary { margin-bottom: 8px; }
.about-details summary::-webkit-details-marker { display: none; }
.about-details summary:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
.detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1px; margin: 0; background: var(--line); border: 1px solid var(--line); }
.detail-grid div { min-height: 76px; padding: 13px 14px; background: white; }
.detail-grid dt { margin: 0; color: var(--muted); font-size: .63rem; font-weight: 700; text-transform: uppercase; letter-spacing: .065em; }.detail-grid dd { margin: 7px 0 0; color: var(--ink); font-size: .82rem; font-weight: 700; }
@@ -288,6 +296,14 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
.duplicate-compare { margin-top: 18px; }.duplicate-compare fieldset { margin: 15px 0; padding: 13px; border: 1px solid var(--line); }.duplicate-compare legend { padding: 0 5px; color: var(--ink-soft); font-size: .69rem; font-weight: 700; }.duplicate-compare fieldset label { display: inline-flex !important; margin-right: 18px; }
.compare-table th, .compare-table td { vertical-align: top; }.compare-table label { display: inline-flex; flex-direction: row; align-items: center; gap: 6px; }.difference-mark, .match-mark { display: block; width: max-content; margin-top: 4px; padding: 2px 5px; font-size: .52rem; border-radius: 2px; }.difference-mark { color: var(--warning); background: var(--warning-pale); }.match-mark { color: var(--success); background: var(--success-pale); }
.merge-preview { padding: 13px; color: var(--ink-soft); background: var(--surface-subtle); border-left: 3px solid var(--teal); font-size: .75rem; }.duplicate-compare > button { color: white; background: var(--teal-dark); border-color: var(--teal-dark); }
.merge-summary-counts { margin: 0 0 12px; color: var(--muted); font-size: .72rem; font-weight: 700; }
.toggle-matching-fields { display: inline-block; margin: 10px 0; padding: 6px 10px; color: var(--ink-soft); background: transparent; border: 1px solid var(--line-strong); border-radius: var(--radius); font-size: .68rem; font-weight: 700; cursor: pointer; }
.toggle-matching-fields:hover { background: var(--surface-subtle); }
.merge-record-preview { margin: 14px 0; padding: 13px; background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); }
.merge-record-preview-title { margin: 0 0 8px; color: var(--ink); font-size: .72rem; font-weight: 700; }
.merge-record-preview dl { margin: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 6px 16px; }
.merge-record-preview dt { margin: 0; color: var(--muted); font-size: .62rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }
.merge-record-preview dd { margin: 1px 0 0; color: var(--ink); font-size: .78rem; }
.confirm-bar { margin-top: 13px; padding: 15px; background: var(--warning-pale); border: 1px solid #eed4aa; }.confirm-bar p { margin: 0 0 11px; color: #75420c; font-size: .75rem; font-weight: 700; }.confirm-bar button:first-of-type { color: white; background: var(--critical); border-color: var(--critical); }
.resolution-actions { display: flex; gap: 9px; }.evidence-block { max-width: 100%; padding: 13px; overflow: auto; color: #324054; background: var(--surface-subtle); border: 1px solid var(--line); font-size: .68rem; }
.evidence-disclosure { margin-top: 14px; }.evidence-disclosure summary { font-size: .68rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }.evidence-disclosure .evidence-block { margin-top: 8px; }