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:
co-authored by
Claude Sonnet 5
parent
3ebca9e9b7
commit
34df66d28c
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user