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:
Maurice
2026-10-24 21:46:43 +02:00
parent 5160bd23d5
commit 53c05ab33b
40 changed files with 908 additions and 13 deletions
@@ -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);
},
};