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 <help@stably.ai>
This commit is contained in:
SJCoetz 2026-07-30 13:56:39 +02:00
parent d3f8242e4f
commit 6093a749eb
4 changed files with 242 additions and 5 deletions

View file

@ -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 <span>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(
<AiInsightsCard
insights={{
procurement_summary:
"This item is exclusively sourced from supplier g4h6mmjeebohbyz, with a consistent wholesale cost price of 10 and selling price of 50.",
}}
/>,
);
expect(present(/exclusively sourced from supplier/i)).toBe(true);
// No tiny "<h4>Procurement Summary</h4>" 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(<AiInsightsCard insights={blob} />);
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(
<AiInsightsCard
insights={{
summary: "Headline from the agent",
top_historical_matches: [
{
cost_price: 5,
matched_description: "MATCH-A",
},
],
}}
/>,
);
expect(present("Headline from the agent")).toBe(true);
expect(present("MATCH-A")).toBe(true);
});
it("renders nothing for an empty blob", () => {
const { container } = render(<AiInsightsCard insights={{}} />);
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(
<AiInsightsCard
insights={{
summary: "x".repeat(60),
suppliers: [
{ description: "Steel Co", last_cost: 25 },
{ description: "Bolt Suppliers", last_cost: 11 },
],
}}
/>,
);
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(
<AiInsightsCard
insights={{
summary: "x".repeat(60),
stock: { on_hand: 12, reorder_threshold: 5 },
}}
/>,
);
expect(container.querySelector("dl")).not.toBeNull();
expect(screen.getByText(/on hand/i)).toBeInTheDocument();
expect(screen.getByText(/reorder threshold/i)).toBeInTheDocument();
});
});

View file

@ -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 `<h4>` 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 (
<div

View file

@ -4,6 +4,20 @@ import { afterEach } from "vitest";
import { ItemHistoryPopover } from "@/components/item-history-popover";
import type { ItemHistoryResponse } from "@/lib/types";
// NamedList splits an entry across two <span>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(
<ItemHistoryPopover
data={{
deterministic: null,
ai: {
procurement_summary:
"This item is exclusively sourced from supplier g4h6mmjeebohbyz, with a consistent wholesale cost price of 10 and selling price of 50.",
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",
},
],
},
ai_updated_at: baseGenerated,
generated_at: baseGenerated,
}}
loading={false}
error={null}
/>
);
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 <details> for debugging, so
// we don't assert on the JSON-key absence.
expect(
screen.getByRole("heading", { name: /top historical matches/i }),
).toBeInTheDocument();
});
});

View file

@ -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<string, unknown>; 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 (
<div className="rounded-lg border border-amber-200 bg-amber-50/40 p-2.5 space-y-2">
<div className="flex items-center justify-between">
@ -164,7 +170,7 @@ function AiCard({ data, updatedAt }: { data: Record<string, unknown>; updatedAt:
</div>
<FreshnessBadge label="snapshot" ts={updatedAt} variant="amber" />
</div>
{summary && <p className="text-xs">{summary}</p>}
<AiInsightsCard insights={data} variant="popover" title="" className="border-0 bg-transparent p-0" />
<details className="text-[10px] text-muted-foreground">
<summary className="cursor-pointer">Raw AI data</summary>
<pre className="mt-1 max-h-32 overflow-auto rounded bg-muted/40 p-1 text-[10px]">