mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
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:
@@ -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 : [];
|
||||
}
|
||||
|
||||
@@ -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':
|
||||
|
||||
Reference in New Issue
Block a user