feat(assistant): the model offers the follow-up questions that fit the conversation

A turn may end on suggest_follow_ups: the server answers the call itself, keeps
its questions as data-trek-follow-ups and the chat shows them as chips under
the answer. Without them the chips fall back to what the answer holds, now by
the kind of change: a tour asks about the tour, the day plan's questions only
follow a change to the day plan, and an answer that asks the user something
offers no "tell me more".
This commit is contained in:
Maurice
2026-10-23 13:10:55 +02:00
parent 199a6f03a7
commit d6b15efa63
13 changed files with 330 additions and 21 deletions
@@ -35,7 +35,7 @@ export default function AssistantFollowUps({ message }: { message: AssistantMess
return (
<div role="group" aria-label={t('assistant.followUp.title')} className="flex flex-wrap gap-1.5 pt-0.5">
{chips.map((chip, i) => {
const text = t(chip.labelKey);
const text = chip.text ?? t(chip.labelKey);
return (
<button
key={chip.id}
@@ -1,12 +1,15 @@
/**
* FE-AI-FOLLOW-001 to FE-AI-FOLLOW-003
* FE-AI-FOLLOW-001 to FE-AI-FOLLOW-007
*
* The follow-ups a card offers under an answer that ends on it: the ones that
* ask for a change only for someone who may make it, the answer's own when
* none is left for a reader, and the ones with a condition only when a card of
* the answer meets it.
* the answer meets it. The questions the model offered win over all of them; a
* change asks what its own kind offers, the day plan's questions come only
* after a change to the day plan, and an answer that asks the user something
* offers no "tell me more".
*/
import type { AssistantMessage, TrekCard } from '@trek/shared';
import type { AssistantChangePreview, AssistantMessage, TrekCard } from '@trek/shared';
import { afterEach, describe, expect, it } from 'vitest';
import { followUpsFor } from './assistantFollowUpRules';
@@ -60,3 +63,54 @@ describe('card follow-ups', () => {
expect(asked).toEqual([{ kind: 'vacay' }]);
});
});
/** An answer of the given parts. */
const of = (parts: AssistantMessage['parts']): AssistantMessage => ({ ...answer, parts });
/** A proposed change, with the card of what it does. */
const change = (card?: TrekCard): AssistantChangePreview => ({
toolCallId: 'c1',
verbKey: 'addPlace',
subject: 'Something',
rows: [],
dayId: null,
placeIds: [],
undoable: true,
...(card ? { card } : {}),
});
describe('the questions an answer offers', () => {
it('FE-AI-FOLLOW-004: the questions the model offered win, sent as written, the last offer only', () => {
const offered = of([
{ type: 'text', text: 'Done.' },
{ type: 'data-trek-follow-ups', data: { questions: ['Old'] } },
{ type: 'data-trek-follow-ups', data: { questions: ['An easier variant', 'What to pack?'] } },
]);
expect(followUpsFor(offered, true).map((f) => f.text)).toEqual(['An easier variant', 'What to pack?']);
});
it('FE-AI-FOLLOW-005: a proposed tour asks about the tour, never to plan the next day the same way', () => {
const tour = { kind: 'tourRoute', source: 'proposal', activity: 'hike' } as TrekCard;
const ids = followUpsFor(of([{ type: 'data-trek-change', data: change(tour) }]), true).map((f) => f.id);
expect(ids).toContain('tourEasier');
expect(ids).not.toContain('nextDay');
});
it('FE-AI-FOLLOW-006: an applied expense offers the general questions, an applied stop the day plan', () => {
const applied = (entity: 'budget' | 'assignment') =>
of([
{ type: 'text', text: 'Added.' },
{
type: 'data-trek-applied',
data: { toolCallId: 'c1', entity, ids: [1], dayId: null, undoUntil: null },
},
]);
expect(followUpsFor(applied('budget'), true).map((f) => f.id)).toEqual(['more', 'next']);
expect(followUpsFor(applied('assignment'), true).map((f) => f.id)).toContain('nextDay');
});
it('FE-AI-FOLLOW-007: an answer that asks the user something waits for their answer, not for "tell me more"', () => {
expect(followUpsFor(of([{ type: 'text', text: 'Should I plan it anyway?' }]), true)).toEqual([]);
expect(followUpsFor(of([{ type: 'text', text: 'Here it is.' }]), true).map((f) => f.id)).toEqual(['more', 'next']);
});
});
@@ -1,20 +1,25 @@
import type { AssistantMessage, TrekCardKind } from '@trek/shared';
import type { AssistantMessage, TrekCard, TrekCardKind } from '@trek/shared';
import { messageBlocks } from './assistantParts';
import { CARD_LOOKS } from './cards/cardLooks';
import type { CardEntry, CardFollowUp, CardLook } from './cards/cardTypes';
/**
* The next questions an answer suggests, as one tap each: read from what the
* answer holds, not asked from the model, so they cost nothing and come at
* The next questions an answer suggests, as one tap each. The model offers
* them itself at the end of an answer, fitting the conversation; an answer
* without them falls back to what it holds, which costs nothing and comes at
* once. Cards ask what their kind offers, places found ask to narrow them, a
* plan asks to go on, a forecast asks for the other weather. Three at most;
* the first kind the answer holds wins.
* change asks what its own kind offers (a tour about the tour) and a change
* to the day plan asks to go on with the plan, a forecast asks for the other
* weather. Three at most; the first kind the answer holds wins.
*/
export interface FollowUp {
id: string;
/** What the chip says and sends, translated; a question the model wrote carries `text` instead. */
labelKey: string;
/** The model's own question, sent as it is. */
text?: string;
/** Asks for a change, so only someone who may edit the trip is offered it. */
edits?: boolean;
}
@@ -41,6 +46,9 @@ const GENERAL: readonly FollowUp[] = [
{ id: 'next', labelKey: 'assistant.followUp.next' },
];
/** What a change touches that the day plan's questions ("plan the next day the same way") are about. */
const PLAN_ENTITIES: ReadonlySet<string> = new Set(['place', 'assignment', 'day_note']);
/** What a kind offers to ask next, typed for any kind. */
function offeredBy(kind: TrekCardKind): readonly CardFollowUp<TrekCardKind>[] {
return (CARD_LOOKS[kind] as CardLook<TrekCardKind>).followUps ?? [];
@@ -64,13 +72,65 @@ function cardFollowUps(message: AssistantMessage, canEdit: boolean): readonly Fo
);
}
/**
* The follow-ups of the changes an answer proposed or made: what the first
* change card's kind offers (a tour's are about the tour), else the day
* plan's questions when a change touched the day plan, else none, so a
* budget entry or a packing list is not asked to plan the next day the same way.
*/
function changeFollowUps(message: AssistantMessage, canEdit: boolean): readonly FollowUp[] | null {
const cards: TrekCard[] = [];
const entities = new Set<string>();
let changed = false;
for (const part of message.parts) {
if (part.type === 'data-trek-change') {
changed = true;
if (part.data.card) cards.push(part.data.card);
} else if (part.type === 'data-trek-applied') {
changed = true;
entities.add(part.data.entity);
}
}
if (!changed) return null;
const kind = cards[0]?.kind;
if (kind) {
const offered = offeredBy(kind).filter(
(followUp) =>
(canEdit || !followUp.edits) &&
(!followUp.when || cards.some((card) => card.kind === kind && followUp.when?.(card)))
);
if (offered.length > 0) return offered;
}
if (!canEdit) return null;
const onPlan = entities.size === 0 ? !kind : [...entities].some((entity) => PLAN_ENTITIES.has(entity));
return onPlan ? PLAN : [];
}
/** Whether the answer's last words ask the user something. */
function endsWithQuestion(message: AssistantMessage): boolean {
const texts = message.parts.flatMap((part) => (part.type === 'text' ? [part.text] : []));
return /[??]\s*$/.test(texts[texts.length - 1] ?? '');
}
/** The questions the model offered at the end of the answer, the last offer's; none when it offered none. */
function modelFollowUps(message: AssistantMessage): readonly FollowUp[] {
const offers = message.parts.flatMap((part) => (part.type === 'data-trek-follow-ups' ? [part.data] : []));
const offer = offers[offers.length - 1];
if (!offer) return [];
return offer.questions.map((text, index) => ({ id: `model-${index}`, labelKey: '', text }));
}
/** What an answer offers to ask next; changes are offered only to someone who may make them. */
export function followUpsFor(message: AssistantMessage, canEdit: boolean): readonly FollowUp[] {
const fromModel = modelFollowUps(message);
if (fromModel.length > 0) return fromModel;
const fromCards = cardFollowUps(message, canEdit);
if (fromCards.length > 0) return fromCards;
const fromChanges = changeFollowUps(message, canEdit);
if (fromChanges !== null) return fromChanges.length > 0 ? fromChanges : GENERAL;
const types = new Set(message.parts.map((part) => part.type));
if (canEdit && (types.has('data-trek-change') || types.has('data-trek-applied'))) return PLAN;
if (types.has('data-trek-places')) return PLACES;
if (types.has('data-trek-weather')) return WEATHER;
return types.has('text') ? GENERAL : [];
// An answer that asks the user something waits for their answer, not for "tell me more".
return types.has('text') && !endsWithQuestion(message) ? GENERAL : [];
}
+2
View File
@@ -63,6 +63,8 @@ export function applyChunk(
]);
case 'data-trek-weather':
return withCurrent(messages, now, (parts) => [...parts, { type: 'data-trek-weather', data: chunk.data }]);
case 'data-trek-follow-ups':
return withCurrent(messages, now, (parts) => [...parts, { type: 'data-trek-follow-ups', data: chunk.data }]);
case 'data-trek-card':
return chunk.data.card ? placeCard(messages, now, chunk.data) : messages;
case 'data-trek-page-view':