Skip to content

Commit a41d107

Browse files
feat(chat): lay the comparison table out as products × criteria (#7268)
* feat(chat): lay the comparison table out as products × criteria The compare tool now takes model-authored criteria rows (label + one value per product) instead of attribute keys, so the agent can compare on details it read in the records (description, derived verdicts) and not only on stored attributes. Products run across the top, hydrated from the chat records store and rendered with the widget's item template/component; criteria run down the side. The attribute-based input is still accepted. Co-authored-by: Cursor <cursoragent@cursor.com> * test(chat): stringify the record name in the item component fixture Co-authored-by: Cursor <cursoragent@cursor.com> * fix(chat): fall back to the record name when there is no custom item template The chat widget's default `item` template dumps the record as JSON, which is not a usable product header. The comparison tool only renders the header with the item template when the merchant provided one. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 4bb9824 commit a41d107

11 files changed

Lines changed: 619 additions & 310 deletions

File tree

‎packages/instantsearch-ui-components/src/components/chat/tools/CompareProductsTool.tsx‎

Lines changed: 94 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,47 +2,58 @@
22

33
import {
44
createGroundedComparisonTableComponent,
5+
criteriaFromAttributes,
56
defaultComparisonTableTranslations,
67
} from './createGroundedComparisonTable';
78

8-
import type { Renderer } from '../../../types';
9+
import type { RecordWithObjectID, Renderer } from '../../../types';
910
import type { ClientSideToolComponentProps } from '../types';
10-
import type { ComparisonTableTranslations } from './createGroundedComparisonTable';
11+
import type {
12+
ComparisonCriterion,
13+
ComparisonTableTranslations,
14+
GroundedComparisonTableProps,
15+
} from './createGroundedComparisonTable';
1116

1217
/**
1318
* Builtin comparison tool (`algolia_compare_products`).
1419
*
15-
* The agent triggers this client-side tool when it detects a comparison
16-
* request. Its arguments name ONLY the products (by objectID) and the attribute
17-
* *keys* to compare — never the values. Every cell is hydrated from the chat
18-
* records store (`context.records`, filled by the shopper's comparison
19-
* selection and by the search tools), so the model physically cannot type
20-
* (and therefore cannot hallucinate) a price or spec.
21-
* This is the "grounded table" fix from the agentic-evals comparison study,
22-
* promoted from the display-block prototype to a first-class tool.
20+
* The agent calls this client-side tool to lay out a comparison as a table:
21+
* the compared products across the top (hydrated from the chat records store,
22+
* `context.records`, so the header never shows a product that isn't in the
23+
* catalog) and model-authored criteria down the side. Each criterion carries
24+
* one value per product; the agent fills them from the records it has (an
25+
* attribute, a detail from the description, a derived verdict), so the table
26+
* can say everything a Markdown table could — with a proper layout.
2327
*
2428
* Tool-call input it consumes:
2529
*
2630
* {
27-
* objectIDs: ['A', 'B'], // 2+ products, one row each
28-
* attributes: ['price', 'rating'], // attribute KEYS (also headers)
29-
* columns?: ['Product', 'Price', 'Rating'], // optional display labels
30-
* intro?: 'Both are great for...' // optional one-line lead-in
31+
* objectIDs: ['A', 'B'], // 2+ products, one column each
32+
* criteria: [ // one row each, values aligned
33+
* { label: 'Price', values: ['$199', '$299'] },
34+
* { label: 'Battery', values: ['4000 mAh', null] }, // null → "—"
35+
* ],
36+
* intro?: 'Both are great for...' // optional one-line lead-in
3137
* }
3238
*
33-
* Any other input field (e.g. smuggled attribute values) is ignored by
34-
* construction: the renderer never reads values off the input.
39+
* The attribute-based shape (`attributes: ['price']`, values read off the
40+
* records) is still accepted for agents configured with the previous tool
41+
* definition.
3542
*/
3643

3744
export type CompareProductsToolInput = {
3845
objectIDs?: unknown;
46+
criteria?: unknown;
3947
attributes?: unknown;
4048
columns?: unknown;
4149
intro?: unknown;
4250
};
4351

44-
export type CompareProductsToolProps = {
52+
export type CompareProductsToolProps<
53+
THit extends RecordWithObjectID = RecordWithObjectID,
54+
> = {
4555
toolProps: ClientSideToolComponentProps;
56+
itemComponent?: GroundedComparisonTableProps<THit>['itemComponent'];
4657
translations?: Partial<ComparisonTableTranslations>;
4758
};
4859

@@ -56,18 +67,68 @@ function stringList(value: unknown): string[] {
5667
);
5768
}
5869

59-
export function createCompareProductsToolComponent({
60-
createElement,
61-
Fragment,
62-
}: Renderer) {
63-
const GroundedComparisonTable = createGroundedComparisonTableComponent({
70+
/** Keeps only well-formed `{ label, values[] }` rows. */
71+
function criteriaList(value: unknown): ComparisonCriterion[] {
72+
if (!Array.isArray(value)) {
73+
return [];
74+
}
75+
return value.flatMap((entry): ComparisonCriterion[] => {
76+
if (
77+
!entry ||
78+
typeof entry !== 'object' ||
79+
typeof (entry as ComparisonCriterion).label !== 'string' ||
80+
(entry as ComparisonCriterion).label === ''
81+
) {
82+
return [];
83+
}
84+
const values = (entry as ComparisonCriterion).values;
85+
return [
86+
{
87+
label: (entry as ComparisonCriterion).label,
88+
values: Array.isArray(values) ? values : [],
89+
},
90+
];
91+
});
92+
}
93+
94+
/**
95+
* Previous tool contract: `attributes` (keys read off the records) plus an
96+
* optional `columns` list labelling `[product, ...attributes]`.
97+
*/
98+
function legacyCriteria(
99+
objectIDs: string[],
100+
input: CompareProductsToolInput,
101+
records: ClientSideToolComponentProps['context']['records']
102+
): ComparisonCriterion[] {
103+
const attributes = stringList(input.attributes);
104+
const columns = Array.isArray(input.columns)
105+
? stringList(input.columns)
106+
: undefined;
107+
const labels =
108+
columns && columns.length === attributes.length + 1
109+
? columns.slice(1)
110+
: columns;
111+
112+
return criteriaFromAttributes(objectIDs, attributes, records, labels);
113+
}
114+
115+
export function createCompareProductsToolComponent<
116+
THit extends RecordWithObjectID = RecordWithObjectID,
117+
>({ createElement, Fragment }: Renderer) {
118+
const GroundedComparisonTable = createGroundedComparisonTableComponent<THit>({
64119
createElement,
65120
Fragment,
66121
});
67122

68-
return function CompareProductsTool(userProps: CompareProductsToolProps) {
69-
const { toolProps, translations: userTranslations } = userProps;
70-
const { message, records } = toolProps.context;
123+
return function CompareProductsTool(
124+
userProps: CompareProductsToolProps<THit>
125+
) {
126+
const {
127+
toolProps,
128+
itemComponent,
129+
translations: userTranslations,
130+
} = userProps;
131+
const { message, records, sendEvent } = toolProps.context;
71132

72133
const translations: ComparisonTableTranslations = {
73134
...defaultComparisonTableTranslations,
@@ -85,23 +146,25 @@ export function createCompareProductsToolComponent({
85146

86147
const input = (message.input ?? {}) as CompareProductsToolInput;
87148
const objectIDs = stringList(input.objectIDs);
88-
const attributes = stringList(input.attributes);
89-
const columns = Array.isArray(input.columns)
90-
? stringList(input.columns)
91-
: undefined;
92149
const intro = typeof input.intro === 'string' ? input.intro : undefined;
93150

94151
if (objectIDs.length === 0) {
95152
return <Fragment />;
96153
}
97154

155+
const criteria =
156+
input.criteria !== undefined
157+
? criteriaList(input.criteria)
158+
: legacyCriteria(objectIDs, input, records);
159+
98160
return (
99161
<GroundedComparisonTable
100162
intro={intro}
101163
objectIDs={objectIDs}
102-
attributes={attributes}
103-
columns={columns}
164+
criteria={criteria}
104165
records={records}
166+
itemComponent={itemComponent}
167+
sendEvent={sendEvent}
105168
translations={translations}
106169
/>
107170
);

‎packages/instantsearch-ui-components/src/components/chat/tools/ComparisonTableTool.tsx‎

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import {
44
createGroundedComparisonTableComponent,
5+
criteriaFromAttributes,
56
defaultComparisonTableTranslations,
67
} from './createGroundedComparisonTable';
78

@@ -29,9 +30,9 @@ import type { ComparisonTableTranslations } from './createGroundedComparisonTabl
2930
* }
3031
* ]
3132
*
32-
* The first column is always the product (its `name`/`title`, sourced from the
33-
* hit). Remaining columns are the requested attributes, read off the hit. A
34-
* missing attribute renders as an explicit em-dash, never a fabricated value.
33+
* Products run across the top (their `name`/`title`, sourced from the hit) and
34+
* the requested attributes run down the side, read off the hit. A missing
35+
* attribute renders as an explicit em-dash, never a fabricated value.
3536
*/
3637

3738
type MarkdownTableRow = {
@@ -104,12 +105,25 @@ export function createComparisonTableToolComponent({
104105
return <Fragment />;
105106
}
106107

108+
const objectIDs = table.rows.map((row) => row.objectID);
109+
const attributes = table.attributes ?? [];
110+
// `columns` labels `[product, ...attributes]`; the product label is now
111+
// the header row, so only the attribute labels are kept.
112+
const labels =
113+
table.columns && table.columns.length === attributes.length + 1
114+
? table.columns.slice(1)
115+
: table.columns;
116+
107117
return (
108118
<GroundedComparisonTable
109119
intro={intro}
110-
objectIDs={table.rows.map((row) => row.objectID)}
111-
attributes={table.attributes ?? []}
112-
columns={table.columns}
120+
objectIDs={objectIDs}
121+
criteria={criteriaFromAttributes(
122+
objectIDs,
123+
attributes,
124+
records,
125+
labels
126+
)}
113127
records={records}
114128
translations={translations}
115129
/>

0 commit comments

Comments
 (0)