mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
feat(assistant): entry cards show tags, verdict and order, and changes can be taken back
The update card silently left out the tags and the pros and cons it was about to write, so the user approved a change the card did not show. An entry now shows its first tags and how its verdict leans, and an update names the tags, each side of the verdict and the coordinates it changes. A new order of the entries is drawn as the entries that move, in their new places. A changed entry is written back from what the tool answered it replaced, and a reorder is taken back to the order before, each while nothing changed since. A Dawarich stay taken in as an entry gets the entry card; it has no way back, since deleting the entry would leave the stay marked as taken in.
This commit is contained in:
@@ -21,7 +21,17 @@ import {
|
||||
UNLINK_LINKED_TRIP,
|
||||
} from './journey-header-undo';
|
||||
import { readCard } from './journey-look';
|
||||
import { DELETE_CREATED_ENTRY } from './journey-undo';
|
||||
import {
|
||||
describeReorder,
|
||||
describeStay,
|
||||
loadJournalOrder,
|
||||
loadStay,
|
||||
reorderCard,
|
||||
stayCard,
|
||||
type JournalOrderLoaded,
|
||||
type StayLoaded,
|
||||
} from './journey-order';
|
||||
import { DELETE_CREATED_ENTRY, RESTORE_CHANGED_ENTRY, RESTORE_ENTRY_ORDER } from './journey-undo';
|
||||
import type { AssistantChangeVerbKey, JourneyEntriesCard } from '@trek/shared';
|
||||
|
||||
/**
|
||||
@@ -85,6 +95,35 @@ function journalHeader(
|
||||
};
|
||||
}
|
||||
|
||||
/** A new order of the journal's entries: the entries that move, in their new places, and the way back. */
|
||||
const reorderCards: ToolCards<JournalOrderLoaded> = {
|
||||
write: { verbKey: 'reorderJournalEntries', entity: 'journey_entry' },
|
||||
load: (args, read) => loadJournalOrder(args, read),
|
||||
describe: describeReorder,
|
||||
change: (_ctx, args, loaded) => reorderCard(args, loaded),
|
||||
appliedIds: (args) => {
|
||||
const first = Array.isArray(args.orderedIds) ? args.orderedIds[0] : null;
|
||||
return typeof first === 'number' ? [first] : [];
|
||||
},
|
||||
undo: RESTORE_ENTRY_ORDER,
|
||||
};
|
||||
|
||||
/**
|
||||
* A stay Dawarich recorded taken in as an entry: the entry it writes. No way
|
||||
* back: deleting the entry would leave the stay marked as taken in, with no
|
||||
* way to take it in again.
|
||||
*/
|
||||
const stayCards: ToolCards<StayLoaded> = {
|
||||
write: { verbKey: 'addJournalEntry', entity: 'journey_entry' },
|
||||
load: (args, read) => loadStay(args, read),
|
||||
describe: describeStay,
|
||||
change: (_ctx, args, loaded) => stayCard(args, loaded),
|
||||
appliedIds: (_args, result) => {
|
||||
const id = recordOf(result).createdJournalEntryId;
|
||||
return typeof id === 'number' ? [id] : [];
|
||||
},
|
||||
};
|
||||
|
||||
export const JOURNEY_CARDS: DomainCards = {
|
||||
id: 'journey',
|
||||
tools: {
|
||||
@@ -108,6 +147,7 @@ export const JOURNEY_CARDS: DomainCards = {
|
||||
update_journey_entry: {
|
||||
write: { verbKey: 'updateJournalEntry', entity: 'journey_entry' },
|
||||
describe: JOURNEY_DESCRIBERS.update_journey_entry,
|
||||
undo: RESTORE_CHANGED_ENTRY,
|
||||
...journalWrite('update_journey_entry'),
|
||||
},
|
||||
delete_journey_entry: {
|
||||
@@ -115,6 +155,8 @@ export const JOURNEY_CARDS: DomainCards = {
|
||||
describe: JOURNEY_DESCRIBERS.delete_journey_entry,
|
||||
...journalWrite('delete_journey_entry'),
|
||||
},
|
||||
reorder_journey_entries: reorderCards,
|
||||
accept_dawarich_suggestion_as_journal_entry: stayCards,
|
||||
create_journey: journalHeader('create_journey', 'createJournal', DELETE_CREATED_JOURNEY),
|
||||
update_journey: journalHeader('update_journey', 'updateJournalSettings', RESTORE_JOURNEY_SETTINGS),
|
||||
delete_journey: journalHeader('delete_journey', 'deleteJournal'),
|
||||
|
||||
@@ -47,6 +47,8 @@ const ENTRY_FIELDS = [
|
||||
'visibility',
|
||||
'type',
|
||||
'is_draft',
|
||||
'tags',
|
||||
'pros_cons',
|
||||
] as const;
|
||||
|
||||
/** The states of an entry an update can set, each a row of its own when it changes. */
|
||||
@@ -142,6 +144,41 @@ function addStateRows(rows: Rows, before: Row, args: ToolArgs): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** A list the user wrote (tags, a side of the verdict) as one row value: its items in order, joined. */
|
||||
function listText(value: unknown): string {
|
||||
return text(Array.isArray(value) ? value.filter((item) => typeof item === 'string' && item.trim()).join(', ') : '');
|
||||
}
|
||||
|
||||
/** Coordinates as a row shows them, both or none. */
|
||||
function pointText(lat: unknown, lng: unknown): string {
|
||||
return typeof lat === 'number' && typeof lng === 'number' ? `${lat.toFixed(4)}, ${lng.toFixed(4)}` : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows of what an update sets beyond the words and the states: the tags,
|
||||
* each side of the verdict, and the coordinates (the entry placed on the map,
|
||||
* moved or taken off it), each when it changes.
|
||||
*/
|
||||
function addListRows(rows: Rows, before: Row, args: ToolArgs): void {
|
||||
const push = (field: string, was: string, now: string) => {
|
||||
if (was !== now) rows.rows.push({ op: 'change', labelKey: 'field', field, before: was || null, after: now });
|
||||
};
|
||||
if ('tags' in args) push('tags', listText(before.tags), listText(args.tags));
|
||||
if ('pros_cons' in args) {
|
||||
const was = recordOf(before.pros_cons);
|
||||
const now = recordOf(args.pros_cons);
|
||||
push('pros', listText(was.pros), listText(now.pros));
|
||||
push('cons', listText(was.cons), listText(now.cons));
|
||||
}
|
||||
if ('location_lat' in args || 'location_lng' in args) {
|
||||
const lat = 'location_lat' in args ? args.location_lat : before.location_lat;
|
||||
const lng = 'location_lng' in args ? args.location_lng : before.location_lng;
|
||||
const was = pointText(before.location_lat, before.location_lng);
|
||||
const now = pointText(lat, lng);
|
||||
if (was !== now) rows.change('coordinates', was, now);
|
||||
}
|
||||
}
|
||||
|
||||
/** The fields of the entry an update sets: those its arguments name, a null among them clearing one. */
|
||||
function changedFields(args: ToolArgs): Array<(typeof ENTRY_FIELDS)[number]> {
|
||||
return ENTRY_FIELDS.filter((field) => field in args);
|
||||
@@ -165,6 +202,7 @@ export const JOURNEY_DESCRIBERS = {
|
||||
if ('location_name' in args) rows.change('location', text(before.location_name), text(args.location_name));
|
||||
if ('story' in args) rows.change('text', storyText(before.story), storyText(args.story));
|
||||
addStateRows(rows, before, args);
|
||||
addListRows(rows, before, args);
|
||||
return described(subjectOf(ctx, { ...before, ...args }), rows);
|
||||
},
|
||||
delete_journey_entry: (ctx: PreviewContext, _args: ToolArgs, loaded: JournalLoaded | null): Described => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
stripMarkdown,
|
||||
type JourneyEntriesCard,
|
||||
type JourneyEntryLook,
|
||||
type JourneyEntryVerdict,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
@@ -74,6 +75,30 @@ function coverOf(photos: Row[]): { photoId: number | null; video: boolean } {
|
||||
return { photoId: positiveId(cover.photo_id), video: cover.media_type === 'video' };
|
||||
}
|
||||
|
||||
/** Lines of text a list field holds (tags, a side of the verdict), each read like any line of its source. */
|
||||
function linesOf(value: unknown, key: string, max: number, source: LookSource): string[] {
|
||||
const items = Array.isArray(value) ? value : [];
|
||||
return items.flatMap((item) => {
|
||||
const line = lineOf({ [key]: item }, key, max, source);
|
||||
return line === null ? [] : [line];
|
||||
});
|
||||
}
|
||||
|
||||
/** The first tags, as many as a card carries. */
|
||||
function tagsOf(row: Row, source: LookSource): string[] {
|
||||
return linesOf(row.tags, 'tags', JOURNEY_CARD_LIMITS.tag, source).slice(0, JOURNEY_CARD_LIMITS.tags);
|
||||
}
|
||||
|
||||
/** The verdict's first points on each side and how many there are; null for an entry without one. */
|
||||
function verdictOf(row: Row, source: LookSource): JourneyEntryVerdict | null {
|
||||
const verdict = recordOf(row.pros_cons);
|
||||
const pros = linesOf(verdict.pros, 'pros_cons', JOURNEY_CARD_LIMITS.point, source);
|
||||
const cons = linesOf(verdict.cons, 'pros_cons', JOURNEY_CARD_LIMITS.point, source);
|
||||
if (pros.length === 0 && cons.length === 0) return null;
|
||||
const cut = JOURNEY_CARD_LIMITS.points;
|
||||
return { pros: pros.slice(0, cut), cons: cons.slice(0, cut), prosCount: pros.length, consCount: cons.length };
|
||||
}
|
||||
|
||||
/** Both coordinates, or neither. */
|
||||
function pointOf(row: Row): JourneyEntryLook['point'] {
|
||||
const lat = finite(row.location_lat);
|
||||
@@ -110,6 +135,8 @@ export function lookOf(value: unknown, source: LookSource = {}): JourneyEntryLoo
|
||||
draft: row.is_draft === true || row.is_draft === 1,
|
||||
fromTripPlace: positiveId(row.source_place_id) !== null && row.type !== 'skeleton',
|
||||
dayIndex: day >= 0 && day <= 9_999 ? day : null,
|
||||
tags: tagsOf(row, source),
|
||||
verdict: verdictOf(row, source),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import type { CardReader, Described, PreviewContext, ToolArgs } from '../card-types';
|
||||
import { argId, recordOf, type Row } from '../card-values';
|
||||
import { formatDate, Rows, text } from '../preview-format';
|
||||
import { rowsOf } from '../undo-snapshot';
|
||||
import { journeyArgs } from './journey-header';
|
||||
import { entriesCard, lookOf } from './journey-look';
|
||||
import { JOURNEY_CARD_LIMITS, stripMarkdown, type JourneyEntriesCard, type JourneyEntryLook } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* Two more entry changes of the journal in the chat: a new order of its
|
||||
* entries, drawn as the entries that move, in their new order; and a stay
|
||||
* Dawarich recorded taken in as a new entry, drawn as the entry it writes
|
||||
* from the stay and what the call corrects of it.
|
||||
*/
|
||||
|
||||
/** What a held reorder looked up: the journal's entries in their reading order, and the journal. */
|
||||
export interface JournalOrderLoaded {
|
||||
entries: Row[];
|
||||
journeyId: number | null;
|
||||
title: unknown;
|
||||
}
|
||||
|
||||
/** One read: the journal's entries as list_journey_entries answers them, on the journal the call names or pins. */
|
||||
export async function loadJournalOrder(args: ToolArgs, read: CardReader): Promise<JournalOrderLoaded | null> {
|
||||
const answer = recordOf(await read('list_journey_entries', journeyArgs(args)));
|
||||
const journey = recordOf(answer.journey);
|
||||
const journeyId = argId(journey, 'id');
|
||||
return journeyId === null ? null : { entries: rowsOf(answer.entries), journeyId, title: journey.title };
|
||||
}
|
||||
|
||||
function orderedIdsOf(args: ToolArgs): number[] {
|
||||
return Array.isArray(args.orderedIds) ? args.orderedIds.filter((id): id is number => Number.isInteger(id)) : [];
|
||||
}
|
||||
|
||||
/** The ids the call names, in the order they stand in now and in the new one, and those that change place. */
|
||||
function orderOf(args: ToolArgs, loaded: JournalOrderLoaded) {
|
||||
const next = orderedIdsOf(args);
|
||||
const named = new Set(next);
|
||||
const now = loaded.entries.map((entry) => entry.id).filter((id): id is number => named.has(id as number));
|
||||
const moved = new Set(next.filter((id, index) => now.indexOf(id) !== index));
|
||||
return { now, next, moved };
|
||||
}
|
||||
|
||||
function nameOf(ctx: PreviewContext, entry: Row | undefined): string {
|
||||
if (!entry) return '';
|
||||
const day = typeof entry.entry_date === 'string' ? formatDate(ctx, entry.entry_date) : '';
|
||||
return text(entry.title) || text(entry.location_name) || day;
|
||||
}
|
||||
|
||||
/** The row of a new order: the entries that move, as they stood and as they will stand. */
|
||||
export function describeReorder(ctx: PreviewContext, args: ToolArgs, loaded: JournalOrderLoaded | null): Described {
|
||||
const rows = new Rows();
|
||||
if (loaded) {
|
||||
const { now, next, moved } = orderOf(args, loaded);
|
||||
const byId = new Map(loaded.entries.map((entry) => [entry.id, entry]));
|
||||
const names = (ids: number[]) =>
|
||||
ids
|
||||
.filter((id) => moved.has(id))
|
||||
.map((id) => nameOf(ctx, byId.get(id)))
|
||||
.join(', ');
|
||||
if (moved.size > 0) rows.change('order', names(now), names(next));
|
||||
}
|
||||
return { subject: text(loaded?.title), rows, dayId: null, placeIds: [], domain: 'journey' };
|
||||
}
|
||||
|
||||
/** The card of a new order: the entries that move in their new places, the rest of the call counted. */
|
||||
export function reorderCard(args: ToolArgs, loaded: JournalOrderLoaded | null): JourneyEntriesCard | null {
|
||||
if (!loaded) return null;
|
||||
const { next, moved } = orderOf(args, loaded);
|
||||
const byId = new Map(loaded.entries.map((entry) => [entry.id, entry]));
|
||||
const looks = next.flatMap((id): JourneyEntryLook[] => {
|
||||
if (!moved.has(id)) return [];
|
||||
const look = lookOf(byId.get(id));
|
||||
return look ? [{ ...look, moved: true }] : [];
|
||||
});
|
||||
const kept = looks.slice(0, JOURNEY_CARD_LIMITS.entries);
|
||||
const journal = {
|
||||
journeyId: loaded.journeyId,
|
||||
title: loaded.title,
|
||||
tripJournal: false,
|
||||
change: 'reordered' as const,
|
||||
};
|
||||
return entriesCard(journal, kept, looks.length - kept.length);
|
||||
}
|
||||
|
||||
/** What a held Dawarich stay taken in as an entry looked up: the stay, and the journal it goes into. */
|
||||
export interface StayLoaded {
|
||||
stay: Row | null;
|
||||
journeyId: number | null;
|
||||
title: unknown;
|
||||
}
|
||||
|
||||
/** The journal a call names as `journalId`, or none in a journey's own conversation, where the session pins it. */
|
||||
function journalArgs(args: ToolArgs): ToolArgs {
|
||||
const journeyId = argId(args, 'journalId');
|
||||
return journeyId === null ? {} : { journeyId };
|
||||
}
|
||||
|
||||
/** Two reads: the stays held for review, to find this one, and the journal. */
|
||||
export async function loadStay(args: ToolArgs, read: CardReader): Promise<StayLoaded | null> {
|
||||
const suggestionId = argId(args, 'suggestionId');
|
||||
if (suggestionId === null) return null;
|
||||
const listed = rowsOf(recordOf(await read('list_dawarich_suggestions', { limit: 200 })).suggestions);
|
||||
const journey = recordOf(recordOf(await read('get_journey', journalArgs(args))).journey);
|
||||
return {
|
||||
stay: listed.find((row) => row.id === suggestionId) ?? null,
|
||||
journeyId: argId(journey, 'id'),
|
||||
title: journey.title,
|
||||
};
|
||||
}
|
||||
|
||||
/** The entry a stay becomes: the call's corrections over what the stay recorded, as the service writes it. */
|
||||
function stayEntry(args: ToolArgs, stay: Row): Row {
|
||||
const name = text(args.name) || text(stay.name);
|
||||
const started = typeof stay.startedAt === 'string' ? stay.startedAt : '';
|
||||
const clock = /^\d{4}-\d{2}-\d{2}T(\d{2}:\d{2})/.exec(started)?.[1] ?? null;
|
||||
return {
|
||||
id: null,
|
||||
type: 'entry',
|
||||
title: name || null,
|
||||
story: typeof args.notes === 'string' ? args.notes : null,
|
||||
entry_date: typeof args.date === 'string' ? args.date : stay.localDate,
|
||||
entry_time: typeof args.time === 'string' ? args.time : clock,
|
||||
location_name: name || null,
|
||||
location_lat: stay.lat,
|
||||
location_lng: stay.lng,
|
||||
photos: [],
|
||||
is_draft: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** The row of a stay taken in: what the entry is about, its day, time, place and text. */
|
||||
export function describeStay(ctx: PreviewContext, args: ToolArgs, loaded: StayLoaded | null): Described {
|
||||
const entry = loaded?.stay
|
||||
? stayEntry(args, loaded.stay)
|
||||
: { title: args.name, story: args.notes, entry_date: args.date };
|
||||
const day = typeof entry.entry_date === 'string' ? formatDate(ctx, entry.entry_date) : '';
|
||||
const story = typeof entry.story === 'string' ? text(stripMarkdown(entry.story)) : '';
|
||||
const rows = new Rows()
|
||||
.add('day', day)
|
||||
.add('time', text(entry.entry_time))
|
||||
.add('location', text(entry.location_name))
|
||||
.add('text', story);
|
||||
return { subject: text(entry.title) || day, rows, dayId: null, placeIds: [], domain: 'journey' };
|
||||
}
|
||||
|
||||
/** The card of a stay taken in: the entry it will write, in the journal it goes into. */
|
||||
export function stayCard(args: ToolArgs, loaded: StayLoaded | null): JourneyEntriesCard | null {
|
||||
if (!loaded?.stay) return null;
|
||||
// What the call writes is cut only; what the stay recorded is masked like any stored text.
|
||||
const fromArgs = new Set([...(text(args.name) ? ['title', 'location_name'] : []), ...(args.notes ? ['story'] : [])]);
|
||||
const look = lookOf(stayEntry(args, loaded.stay), { fromArgs });
|
||||
const journal = { journeyId: loaded.journeyId, title: loaded.title, tripJournal: false, change: 'new' as const };
|
||||
return entriesCard(journal, look ? [look] : [], 0);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ToolCards, UndoSnapshot } from '../card-types';
|
||||
import type { ToolArgs, ToolCards, UndoSnapshot } from '../card-types';
|
||||
import { idIn, readKey, recordOf } from '../card-values';
|
||||
import { journeyArgs } from './journey-header';
|
||||
|
||||
/** How the undo reads the created entry again: by its id, whatever the turn read before. */
|
||||
function entryRead(result: unknown) {
|
||||
@@ -39,3 +40,49 @@ export const DELETE_CREATED_ENTRY: NonNullable<ToolCards['undo']> = {
|
||||
reads: (_args, result) => [entryRead(result)],
|
||||
unchanged: entryUnchanged,
|
||||
};
|
||||
|
||||
/**
|
||||
* A changed entry written back as it was, from what the tool answered it
|
||||
* replaced, while nothing was written to it since and it holds the photos it
|
||||
* held then.
|
||||
*/
|
||||
export const RESTORE_CHANGED_ENTRY: NonNullable<ToolCards['undo']> = {
|
||||
tool: 'update_journey_entry',
|
||||
args: (_args, result) => {
|
||||
const entryId = idIn(result, 'entry');
|
||||
const previous = recordOf(recordOf(result).previous);
|
||||
return entryId === null || Object.keys(previous).length === 0 ? null : { entryId, ...previous };
|
||||
},
|
||||
reads: (_args, result) => [entryRead(result)],
|
||||
unchanged: entryUnchanged,
|
||||
};
|
||||
|
||||
/** The ids a reorder named, and the order the tool answered they stood in before. */
|
||||
function ordersOf(args: ToolArgs, result: unknown): { next: number[]; before: number[] } {
|
||||
const ids = (value: unknown) =>
|
||||
Array.isArray(value) ? value.filter((id): id is number => Number.isInteger(id)) : [];
|
||||
return { next: ids(args.orderedIds), before: ids(recordOf(result).previous_order) };
|
||||
}
|
||||
|
||||
/**
|
||||
* A new order taken back to the one before, while the entries still stand
|
||||
* as it left them: each at the place the reorder gave it.
|
||||
*/
|
||||
export const RESTORE_ENTRY_ORDER: NonNullable<ToolCards['undo']> = {
|
||||
tool: 'reorder_journey_entries',
|
||||
args: (args, result) => {
|
||||
const { next, before } = ordersOf(args, result);
|
||||
const same = before.length === next.length && before.every((id, index) => id === next[index]);
|
||||
return before.length === 0 || same ? null : { ...journeyArgs(args), orderedIds: before };
|
||||
},
|
||||
reads: (args) => [{ tool: 'list_journey_entries', args: journeyArgs(args) }],
|
||||
unchanged: (result, snapshot, call) => {
|
||||
const args = call?.args ?? {};
|
||||
const { next } = ordersOf(args, result);
|
||||
const read = recordOf(snapshot.reads.get(readKey('list_journey_entries', journeyArgs(args))));
|
||||
const sortOf = new Map(
|
||||
(Array.isArray(read.entries) ? read.entries.map(recordOf) : []).map((entry) => [entry.id, entry.sort_order]),
|
||||
);
|
||||
return next.length > 0 && next.every((id, index) => sortOf.get(id) === index);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -377,7 +377,7 @@ describe('the applied change', () => {
|
||||
expect(ASSISTANT_WRITE_TOOLS.create_journey_entry).toEqual({ verbKey: 'addJournalEntry', entity: 'journey_entry' });
|
||||
expect(ASSISTANT_WRITE_TOOLS.update_journey_entry?.verbKey).toBe('updateJournalEntry');
|
||||
expect(ASSISTANT_WRITE_TOOLS.delete_journey_entry?.verbKey).toBe('removeJournalEntry');
|
||||
expect(ASSISTANT_UNDO_MAP.update_journey_entry).toBeUndefined();
|
||||
expect(ASSISTANT_UNDO_MAP.update_journey_entry?.tool).toBe('update_journey_entry');
|
||||
expect(CARD_PROMPT_LINES).toEqual(expect.arrayContaining([...(JOURNEY_CARDS.prompt ?? [])]));
|
||||
expect(JOURNEY_CARDS.prompt).toHaveLength(2);
|
||||
// A trip chat sends a book request to the Studio instead of trying it.
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* ASTCARD-JORD-001 to ASTCARD-JORD-007
|
||||
*
|
||||
* The rest of a journal entry's changes in the assistant chat: an update's
|
||||
* tags, verdict and coordinates on the row and the card, a new order of the
|
||||
* entries with its way back, a changed entry written back as it was, and a
|
||||
* Dawarich stay taken in as an entry. Every card is checked against the
|
||||
* shared union through the registry's guard.
|
||||
*/
|
||||
import { buildTripCard } from '../../../../../../src/nest/assistant/assistant-context';
|
||||
import type { CardReader, PreviewContext, UndoSnapshot } from '../../../../../../src/nest/assistant/cards/card-types';
|
||||
import { readKey } from '../../../../../../src/nest/assistant/cards/card-values';
|
||||
import { JOURNEY_CARDS } from '../../../../../../src/nest/assistant/cards/journey';
|
||||
import type { JournalLoaded } from '../../../../../../src/nest/assistant/cards/journey/journey-change';
|
||||
import { lookOf } from '../../../../../../src/nest/assistant/cards/journey/journey-look';
|
||||
import { loadJournalOrder, loadStay } from '../../../../../../src/nest/assistant/cards/journey/journey-order';
|
||||
import { ASSISTANT_WRITE_TOOLS, changeCardOf } from '../../../../../../src/nest/assistant/cards/registry';
|
||||
import { describeToolCall } from '../../../../../../src/nest/assistant/tool-call-preview.helpers';
|
||||
import { journeyEntriesCardSchema, type JourneyEntriesCard } from '@trek/shared';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../../../../src/nest/audit/audit-log.logger', () => ({
|
||||
logInfo: vi.fn(),
|
||||
logError: vi.fn(),
|
||||
logWarn: vi.fn(),
|
||||
logDebug: vi.fn(),
|
||||
}));
|
||||
|
||||
const ctx: PreviewContext = {
|
||||
card: buildTripCard(1, 7, { trip: { title: 'Berlin' } }, null, null),
|
||||
categories: new Map(),
|
||||
locale: 'en',
|
||||
today: '2026-10-24',
|
||||
airport: () => null,
|
||||
};
|
||||
|
||||
function cardOf(value: unknown): JourneyEntriesCard {
|
||||
return journeyEntriesCardSchema.parse(value);
|
||||
}
|
||||
|
||||
function readerOf(answers: Record<string, unknown>): { read: CardReader; asked: string[] } {
|
||||
const asked: string[] = [];
|
||||
const read: CardReader = (tool, args = {}) => {
|
||||
asked.push(readKey(tool, args));
|
||||
return Promise.resolve(answers[readKey(tool, args)] ?? answers[tool] ?? null);
|
||||
};
|
||||
return { read, asked };
|
||||
}
|
||||
|
||||
function undoOf(tool: string) {
|
||||
const undo = JOURNEY_CARDS.tools[tool]?.undo;
|
||||
if (!undo) throw new Error(`no undo for ${tool}`);
|
||||
return undo;
|
||||
}
|
||||
|
||||
const SPREE = {
|
||||
id: 12,
|
||||
journey_id: 3,
|
||||
type: 'entry',
|
||||
title: 'Sunset at the Spree',
|
||||
entry_date: '2026-10-21',
|
||||
tags: ['river', 'evening'],
|
||||
pros_cons: { pros: ['the light'], cons: [] },
|
||||
location_lat: null,
|
||||
location_lng: null,
|
||||
photos: [],
|
||||
updated_at: 5,
|
||||
};
|
||||
|
||||
describe('the tags, the verdict and the coordinates of an entry', () => {
|
||||
it('ASTCARD-JORD-001: an entry shows its first tags and how its verdict leans', () => {
|
||||
expect(lookOf(SPREE)).toMatchObject({
|
||||
tags: ['river', 'evening'],
|
||||
verdict: { pros: ['the light'], cons: [], prosCount: 1, consCount: 0 },
|
||||
});
|
||||
const many = { ...SPREE, tags: Array.from({ length: 9 }, (_, i) => `t${i}`), pros_cons: null };
|
||||
expect(lookOf(many)?.tags).toHaveLength(6);
|
||||
expect(lookOf(many)?.verdict).toBeNull();
|
||||
expect(lookOf({ ...SPREE, tags: 'not a list', pros_cons: { pros: [' '], cons: [] } })).toMatchObject({
|
||||
tags: [],
|
||||
verdict: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('ASTCARD-JORD-002: an update names the tags, each side of the verdict and the coordinates it changes', () => {
|
||||
const loaded: JournalLoaded = { entry: SPREE, journeyId: 3, title: 'Berlin', tripJournal: false };
|
||||
const args = {
|
||||
entryId: 12,
|
||||
tags: ['river'],
|
||||
pros_cons: { pros: ['the light', 'calm'], cons: ['cold'] },
|
||||
location_lat: 52.52,
|
||||
location_lng: 13.405,
|
||||
};
|
||||
const preview = describeToolCall('update_journey_entry', 'c1', args, ctx, loaded);
|
||||
expect(preview.rows).toEqual([
|
||||
{ op: 'change', labelKey: 'field', field: 'tags', before: 'river, evening', after: 'river' },
|
||||
{ op: 'change', labelKey: 'field', field: 'pros', before: 'the light', after: 'the light, calm' },
|
||||
{ op: 'change', labelKey: 'field', field: 'cons', before: null, after: 'cold' },
|
||||
{ op: 'change', labelKey: 'coordinates', before: null, after: '52.5200, 13.4050' },
|
||||
]);
|
||||
// The card shows the entry with what the call sets, the verdict and the tags included.
|
||||
const card = cardOf(changeCardOf('update_journey_entry', ctx, args, loaded));
|
||||
expect(card.entries[0]).toMatchObject({ tags: ['river'], verdict: { prosCount: 2, consCount: 1 } });
|
||||
expect(card.entries[0]?.point).toEqual({ lat: 52.52, lng: 13.405 });
|
||||
// A list set to what it holds gets no row.
|
||||
expect(
|
||||
describeToolCall('update_journey_entry', 'c2', { entryId: 12, tags: ['river', 'evening'] }, ctx, loaded).rows,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('ASTCARD-JORD-003: a changed entry is written back from what the tool answered it replaced', () => {
|
||||
const undo = undoOf('update_journey_entry');
|
||||
const result = { entry: { ...SPREE, title: 'Evening' }, previous: { title: 'Sunset at the Spree', tags: [] } };
|
||||
expect(undo.args({}, result, null)).toEqual({ entryId: 12, title: 'Sunset at the Spree', tags: [] });
|
||||
expect(undo.args({}, { entry: SPREE, previous: {} }, null)).toBeNull();
|
||||
const snapshot = (current: unknown): UndoSnapshot => ({
|
||||
summary: null,
|
||||
places: null,
|
||||
reads: new Map([[readKey('get_journey_entry', { entryId: 12 }), current]]),
|
||||
});
|
||||
expect(undo.unchanged(result, snapshot({ entry: { ...SPREE, title: 'Evening' } }))).toBe(true);
|
||||
expect(undo.unchanged(result, snapshot({ entry: { ...SPREE, updated_at: 6 } }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const ENTRIES = [
|
||||
{ id: 1, title: 'Arrival', entry_date: '2026-10-21', sort_order: 0, photos: [] },
|
||||
{ id: 2, title: 'Museum Island', entry_date: '2026-10-21', sort_order: 1, photos: [] },
|
||||
{ id: 3, title: 'Dinner', entry_date: '2026-10-21', sort_order: 2, photos: [] },
|
||||
];
|
||||
|
||||
describe('a new order of the entries', () => {
|
||||
it('ASTCARD-JORD-004: reads the journal, names the entries that move, and draws them in their new places', async () => {
|
||||
const { read, asked } = readerOf({
|
||||
list_journey_entries: { journey: { id: 3, title: 'Berlin' }, entries: ENTRIES },
|
||||
});
|
||||
const loaded = await loadJournalOrder({ journeyId: 3, orderedIds: [2, 1, 3] }, read);
|
||||
expect(asked).toEqual([readKey('list_journey_entries', { journeyId: 3 })]);
|
||||
expect(loaded).toMatchObject({ journeyId: 3, title: 'Berlin' });
|
||||
const preview = describeToolCall('reorder_journey_entries', 'c1', { orderedIds: [2, 1, 3] }, ctx, loaded);
|
||||
expect(preview).toMatchObject({ verbKey: 'reorderJournalEntries', subject: 'Berlin', undoable: true });
|
||||
expect(preview.rows).toEqual([
|
||||
{ op: 'change', labelKey: 'order', before: 'Arrival, Museum Island', after: 'Museum Island, Arrival' },
|
||||
]);
|
||||
const card = cardOf(preview.card);
|
||||
expect(card.change).toBe('reordered');
|
||||
expect(card.entries.map((look) => [look.entryId, look.moved])).toEqual([
|
||||
[2, true],
|
||||
[1, true],
|
||||
]);
|
||||
expect(await loadJournalOrder({}, readerOf({}).read)).toBeNull();
|
||||
expect(ASSISTANT_WRITE_TOOLS.reorder_journey_entries).toEqual({
|
||||
verbKey: 'reorderJournalEntries',
|
||||
entity: 'journey_entry',
|
||||
});
|
||||
});
|
||||
|
||||
it('ASTCARD-JORD-005: is taken back to the order before while every entry still stands where it put it', () => {
|
||||
const undo = undoOf('reorder_journey_entries');
|
||||
const args = { journeyId: 3, orderedIds: [2, 1, 3] };
|
||||
const result = { success: true, previous_order: [1, 2, 3] };
|
||||
expect(undo.args(args, result, null)).toEqual({ journeyId: 3, orderedIds: [1, 2, 3] });
|
||||
expect(undo.args({ orderedIds: [1, 2, 3] }, result, null)).toBeNull();
|
||||
expect(undo.args(args, { success: true }, null)).toBeNull();
|
||||
const check = (entries: unknown[]) =>
|
||||
undo.unchanged(
|
||||
result,
|
||||
{
|
||||
summary: null,
|
||||
places: null,
|
||||
reads: new Map([[readKey('list_journey_entries', { journeyId: 3 }), { entries }]]),
|
||||
},
|
||||
{ args, preview: null },
|
||||
);
|
||||
expect(
|
||||
check([
|
||||
{ id: 2, sort_order: 0 },
|
||||
{ id: 1, sort_order: 1 },
|
||||
{ id: 3, sort_order: 2 },
|
||||
]),
|
||||
).toBe(true);
|
||||
expect(
|
||||
check([
|
||||
{ id: 1, sort_order: 0 },
|
||||
{ id: 2, sort_order: 1 },
|
||||
{ id: 3, sort_order: 2 },
|
||||
]),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
const STAY = {
|
||||
id: 4,
|
||||
name: 'Berlin Hauptbahnhof',
|
||||
lat: 52.525,
|
||||
lng: 13.369,
|
||||
startedAt: '2026-10-20T09:15:00+02:00',
|
||||
localDate: '2026-10-20',
|
||||
};
|
||||
|
||||
describe('a Dawarich stay taken in as an entry', () => {
|
||||
it('ASTCARD-JORD-006: reads the stay and the journal, and shows the entry the stay becomes', async () => {
|
||||
const { read, asked } = readerOf({
|
||||
list_dawarich_suggestions: { suggestions: [STAY] },
|
||||
get_journey: { journey: { id: 3, title: 'Berlin' } },
|
||||
});
|
||||
const args = { suggestionId: 4, journalId: 3, notes: 'Arrived early' };
|
||||
const loaded = await loadStay(args, read);
|
||||
expect(asked).toEqual([
|
||||
readKey('list_dawarich_suggestions', { limit: 200 }),
|
||||
readKey('get_journey', { journeyId: 3 }),
|
||||
]);
|
||||
const preview = describeToolCall('accept_dawarich_suggestion_as_journal_entry', 'c1', args, ctx, loaded);
|
||||
expect(preview).toMatchObject({ verbKey: 'addJournalEntry', subject: 'Berlin Hauptbahnhof', undoable: false });
|
||||
expect(preview.rows.map((row) => [row.labelKey, row.after])).toEqual([
|
||||
['day', 'Tue, Oct 20'],
|
||||
['time', '09:15'],
|
||||
['location', 'Berlin Hauptbahnhof'],
|
||||
['text', 'Arrived early'],
|
||||
]);
|
||||
const card = cardOf(preview.card);
|
||||
expect(card).toMatchObject({ journeyId: 3, journalTitle: 'Berlin', change: 'new' });
|
||||
expect(card.entries[0]).toMatchObject({ entryId: null, date: '2026-10-20', point: { lat: 52.525, lng: 13.369 } });
|
||||
});
|
||||
|
||||
it('ASTCARD-JORD-007: a correction wins over the stay, and a stay no longer listed draws no card', async () => {
|
||||
const loaded = { stay: STAY, journeyId: 3, title: 'Berlin' };
|
||||
const fixed = describeToolCall(
|
||||
'accept_dawarich_suggestion_as_journal_entry',
|
||||
'c1',
|
||||
{ suggestionId: 4, name: 'Hbf', date: '2026-10-19', time: '20:00' },
|
||||
ctx,
|
||||
loaded,
|
||||
);
|
||||
expect(fixed.subject).toBe('Hbf');
|
||||
expect(fixed.rows.map((row) => row.after)).toEqual(['Mon, Oct 19', '20:00', 'Hbf']);
|
||||
expect(
|
||||
changeCardOf('accept_dawarich_suggestion_as_journal_entry', ctx, { suggestionId: 4 }, { ...loaded, stay: null }),
|
||||
).toBeNull();
|
||||
expect(await loadStay({}, readerOf({}).read)).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user