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:
parent
d3f8242e4f
commit
6093a749eb
4 changed files with 242 additions and 5 deletions
131
src/components/ai-insights-renderer.test.tsx
Normal file
131
src/components/ai-insights-renderer.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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]">
|
||||
|
|
|
|||
Loading…
Reference in a new issue