From 6093a749ebd0cbcce9f9715459d8743edb6c3380 Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Thu, 30 Jul 2026 13:56:39 +0200 Subject: [PATCH] feat(ai-history): render item-history blob shape-aware The AI agent returns `procurement_summary` + `top_historical_matches` arrays but the popover was dumping both as raw JSON. Now: - AiInsightsCard promotes the first *_summary/*_description key (or any long string) into a prose headline above the structured sections. - isNamedEntry recognises matched_description / match_description / suppliername, so the renderer turns n8n's historical-match rows into structured list rows instead of an object-dump. - AiCard inside the popover delegates body rendering to AiInsightsCard; the amber framing + freshness badge + raw-data fallback are kept. Tests cover headline promotion, named-list rendering, empty blob, description-keyed entries, and the popover's end-to-end shape. Co-authored-by: Orca --- src/components/ai-insights-renderer.test.tsx | 131 +++++++++++++++++++ src/components/ai-insights-renderer.tsx | 54 +++++++- src/components/item-history-popover.test.tsx | 52 ++++++++ src/components/item-history-popover.tsx | 10 +- 4 files changed, 242 insertions(+), 5 deletions(-) create mode 100644 src/components/ai-insights-renderer.test.tsx diff --git a/src/components/ai-insights-renderer.test.tsx b/src/components/ai-insights-renderer.test.tsx new file mode 100644 index 0000000..59a7471 --- /dev/null +++ b/src/components/ai-insights-renderer.test.tsx @@ -0,0 +1,131 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { AiInsightsCard } from "./ai-insights-renderer"; + +// NamedList renders each entry as two adjacent s (name + meta), +// so no single text node contains the name alone. Asserting via +// `getAllByText` + length is the standard idiom for that shape — every +// ancestor's textContent also contains the text, so `getByText` would +// error with "found multiple". Here we only care that it appears +// somewhere in the tree. +function present(needle: string | RegExp): boolean { + return screen.getAllByText((_, node) => { + if (!node) return false; + const text = node.textContent ?? ""; + return typeof needle === "string" + ? text.includes(needle) + : needle.test(text); + }).length > 0; +} + +describe("AiInsightsCard — item-history shape", () => { + it("surfaces procurement_summary as the headline when no `summary` is present", () => { + render( + , + ); + expect(present(/exclusively sourced from supplier/i)).toBe(true); + // No tiny "

Procurement Summary

" label — the text became the + // headline itself. + expect( + screen.queryByRole("heading", { name: /procurement summary/i }), + ).not.toBeInTheDocument(); + }); + + it("renders top_historical_matches as a named list, not raw JSON", () => { + const blob = { + procurement_summary: + "Long enough string to count as a headline by virtue of length alone — should be promoted above the structured rows.", + top_historical_matches: [ + { + cost_price: 10, + matched_description: "INGCO LASER DISTANCE METER 0.05-85M", + matched_item_id: "bcyl90cpx6f2oqo", + selling_price: 50, + supplier_id: "g4h6mmjeebohbyz", + }, + { + cost_price: 11, + matched_description: "INGCO LASER DISTANCE METER 0.05-100M", + matched_item_id: "abc123", + selling_price: 55, + supplier_id: "g4h6mmjeebohbyz", + }, + ], + }; + render(); + + expect(present("INGCO LASER DISTANCE METER 0.05-85M")).toBe(true); + expect(present("INGCO LASER DISTANCE METER 0.05-100M")).toBe(true); + expect( + screen.getByRole("heading", { name: /top historical matches/i }), + ).toBeInTheDocument(); + // No raw JSON dump for the array. + expect(screen.queryByText(/"matched_description"/)).not.toBeInTheDocument(); + }); + + it("falls back to `summary` when it's the canonical key", () => { + render( + , + ); + expect(present("Headline from the agent")).toBe(true); + expect(present("MATCH-A")).toBe(true); + }); + + it("renders nothing for an empty blob", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("accepts named-entry arrays with description (not just name/label)", () => { + // Defensive: the agent might swap matched_description for + // description in some flows. The renderer should still surface + // it as a list. + render( + , + ); + expect(present("Steel Co")).toBe(true); + expect(present("Bolt Suppliers")).toBe(true); + expect( + screen.getByRole("heading", { name: /suppliers/i }), + ).toBeInTheDocument(); + }); + + it("renders nested object values as key/value lists, not raw JSON", () => { + // Sanity check the existing fallback path so the shape-aware + // upgrade doesn't regress it. + const { container } = render( + , + ); + expect(container.querySelector("dl")).not.toBeNull(); + expect(screen.getByText(/on hand/i)).toBeInTheDocument(); + expect(screen.getByText(/reorder threshold/i)).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/components/ai-insights-renderer.tsx b/src/components/ai-insights-renderer.tsx index a27320f..9105bc2 100644 --- a/src/components/ai-insights-renderer.tsx +++ b/src/components/ai-insights-renderer.tsx @@ -119,6 +119,33 @@ function formatRelative(iso: string): string { return `${yr}y ago`; } +/** + * If the blob has no explicit `summary`, look for a top-level string + * that reads like a prose headline — long enough to be a sentence, + * and ideally ending the key name in `_summary` or just being the + * first string entry. The agent often produces `{procurement_summary, + * top_historical_matches: [...]}` and we want the first to surface + * above the structured list, not under a tiny `

` label. + */ +function pickHeadline( + entries: Array<[string, unknown]>, +): string | null { + // Prefer keys that explicitly name themselves as a summary. + const summaryKey = entries.find(([k]) => + /(^|_)summary$|_description$/i.test(k), + ); + if (summaryKey && typeof summaryKey[1] === "string") { + return summaryKey[1]; + } + // Otherwise fall back to the first string entry that's long + // enough to be a sentence. A short string (e.g. a status word) + // isn't worth promoting above the structured rows. + const firstLongString = entries.find( + ([, v]) => typeof v === "string" && v.length >= 40, + ); + return firstLongString ? (firstLongString[1] as string) : null; +} + /** Pretty-print a snake_case / camelCase key for the section header. */ function humanize(key: string): string { return key @@ -164,7 +191,17 @@ function isWarningEntry(e: unknown): e is { function isNamedEntry(e: unknown): e is { name?: string; label?: string; [k: string]: unknown } { if (!isPlainObject(e)) return false; - return typeof e.name === "string" || typeof e.label === "string"; + // The n8n agent's item-history blob uses `matched_description` + // for the headline; supplier lists typically use `suppliername`. + // Accept any of these so the shape-aware renderer can turn the + // array into a structured list instead of a raw JSON dump. + if (typeof e.name === "string") return true; + if (typeof e.label === "string") return true; + if (typeof e.matched_description === "string") return true; + if (typeof e.description === "string") return true; + if (typeof e.match_description === "string") return true; + if (typeof e.suppliername === "string") return true; + return false; } function WarningChips({ @@ -470,8 +507,19 @@ export function AiInsightsCard({ const entries = Object.entries(insights); if (entries.length === 0) return null; - const summary = typeof insights.summary === "string" ? insights.summary : null; - const rest = entries.filter(([k]) => k !== "summary"); + // Prefer the explicit `summary` key; fall back to any other + // long-string field at the top level (the n8n agent often uses + // `procurement_summary`, `description`, or just the first string + // field as its headline). Anything else falls through to a + // regular InsightRow below. + const summary = + (typeof insights.summary === "string" && insights.summary) || + pickHeadline(entries); + const summaryKey = + typeof insights.summary === "string" + ? "summary" + : entries.find(([, v]) => v === summary)?.[0] ?? "summary"; + const rest = entries.filter(([k]) => k !== summaryKey); return (
s (name + meta), and the +// raw-data JSON dump contains the same text again — `getByText` would +// either fail to find a single text node or error with "found +// multiple". Walk the tree with a function matcher instead. +function findInTree(needle: string | RegExp): boolean { + return screen.getAllByText((_, node) => { + if (!node) return false; + const text = node.textContent ?? ""; + return typeof needle === "string" + ? text.includes(needle) + : needle.test(text); + }).length > 0; +} + afterEach(cleanup); const baseGenerated = "2026-07-29T12:00:00Z"; @@ -144,4 +158,42 @@ describe("ItemHistoryPopover", () => { ); expect(screen.getByText(/best-guess match/)).toBeInTheDocument(); }); + + it("renders the procurement_summary blob as a headline + structured list (not raw JSON)", () => { + // Real n8n output shape. The popover should surface the summary + // as a paragraph and render the matches as named entries. + render( + + ); + expect(findInTree(/exclusively sourced from supplier/i)).toBe(true); + expect(findInTree("INGCO LASER DISTANCE METER 0.05-85M")).toBe(true); + // The matches array renders as a structured named list under its + // own heading — that's the "displayed better" part the user asked + // for. The popover keeps a raw-data
for debugging, so + // we don't assert on the JSON-key absence. + expect( + screen.getByRole("heading", { name: /top historical matches/i }), + ).toBeInTheDocument(); + }); }); diff --git a/src/components/item-history-popover.tsx b/src/components/item-history-popover.tsx index f5fbc05..742d2c7 100644 --- a/src/components/item-history-popover.tsx +++ b/src/components/item-history-popover.tsx @@ -3,6 +3,7 @@ import { Info, Loader2, Package, TrendingUp } from "lucide-react"; import { cn } from "@/lib/utils"; import type { ItemHistoryResponse } from "@/lib/types"; +import { AiInsightsCard } from "./ai-insights-renderer"; interface Props { data: ItemHistoryResponse | null; @@ -154,7 +155,12 @@ function DeterministicCard({ } function AiCard({ data, updatedAt }: { data: Record; updatedAt: string | null }) { - const summary = typeof data.summary === "string" ? data.summary : null; + // The body is rendered by AiInsightsCard, which knows how to surface + // a `summary` (or any *_summary / first long string) as the headline + // and turns named arrays (top_historical_matches with + // matched_description, etc.) into structured rows. We only wrap it + // here to keep the amber framing + freshness badge + Raw data + // fallback that operators expect. return (
@@ -164,7 +170,7 @@ function AiCard({ data, updatedAt }: { data: Record; updatedAt:
- {summary &&

{summary}

} +
Raw AI data