mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-25 00:13:00 +00:00
feat(assistant): an attached file can be kept in Files, on its own or with a booking or an expense
The chips offered to keep an attached file in Files, but nothing could do it: the assistant's upload_trip_file stays denied, and the file never reached Files. A new server write, keep_attached_file, is offered to the model where the thread holds an attachment and held as a change card naming the file and the booking, expense or place it goes with. Applied, it runs the checks of a Files upload with the user's rights (the demo block, the trip, file_upload and file_edit for a link, the instance's file types, every linked id on the same trip), copies the bytes to a trip file of their own, and writes the trip file, its expense link and the attachment's pointer to it in one transaction; the members' planners get file:created after it. A second keep of the same attachment answers the file kept the first time. FilesService gains storeBytes and discardBytes so the storage write stays out of that transaction. The receipt opens the Files tab; the card's verb, its "Attached to" row and the receipt's open label are new keys in all 27 locales. Undo is not offered: no tool takes a trip file back yet.
This commit is contained in:
@@ -464,6 +464,7 @@ export const CHANGE_LABELS: Record<AssistantChangeLabelKey, { labelKey: string }
|
||||
packing: { labelKey: 'assistant.change.label.packing' },
|
||||
breaks: { labelKey: 'assistant.change.label.breaks' },
|
||||
sentTo: { labelKey: 'assistant.change.label.sentTo' },
|
||||
attachedTo: { labelKey: 'assistant.change.label.attachedTo' },
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -65,6 +65,13 @@ describe('cardLooks', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-AI-LOOK-003b: a file kept from the chat opens the trip Files and shows nothing on the map', () => {
|
||||
const kept: AssistantApplied = { toolCallId: 'k', entity: 'file', ids: [], dayId: null, undoUntil: null };
|
||||
expect(ENTITY_LOOK.file.open?.(kept, null)).toEqual({ kind: 'tab', tab: 'dateien' });
|
||||
expect(ENTITY_LOOK.file.onMap).toBeUndefined();
|
||||
expect(ENTITY_LOOK.file.openLabelKey).toBe('assistant.card.file.open');
|
||||
});
|
||||
|
||||
it('FE-AI-LOOK-004: every kind has a look and a view; a pending view groups by kind and gives way to nothing', () => {
|
||||
expect(Object.keys(CARD_LOOKS).sort()).toEqual([...KINDS].sort());
|
||||
expect(Object.keys(CARD_VIEWS).sort()).toEqual([...KINDS].sort());
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
CalendarPlus,
|
||||
Circle,
|
||||
FileSearch,
|
||||
FileText,
|
||||
FolderInput,
|
||||
ListTodo,
|
||||
Luggage,
|
||||
MapPin,
|
||||
@@ -65,6 +67,7 @@ export const VERB_LOOK: Readonly<Record<AssistantChangeVerbKey, VerbLook>> = {
|
||||
importBooking: { Icon: Ticket, tone: 'success', labelKey: 'assistant.change.verb.importBooking' },
|
||||
rememberNote: { Icon: NotebookPen, tone: 'info', labelKey: 'assistant.change.verb.rememberNote' },
|
||||
readAttachment: { Icon: FileSearch, tone: 'info', labelKey: 'assistant.change.verb.readAttachment' },
|
||||
keepFile: { Icon: FolderInput, tone: 'success', labelKey: 'assistant.change.verb.keepFile' },
|
||||
importTour: { Icon: Plus, tone: 'success', labelKey: 'assistant.change.verb.importTour' },
|
||||
genericCreate: { Icon: Plus, tone: 'success', labelKey: 'assistant.change.verb.genericCreate' },
|
||||
genericUpdate: { Icon: PencilLine, tone: 'info', labelKey: 'assistant.change.verb.genericUpdate' },
|
||||
@@ -196,6 +199,8 @@ export const ENTITY_LOOK: Readonly<Record<AssistantApplied['entity'], EntityLook
|
||||
assignment: { Icon: CalendarDays, onMap: BY_DAY },
|
||||
day_note: { Icon: StickyNote, onMap: BY_DAY },
|
||||
memory: { Icon: NotebookPen },
|
||||
// A file kept from the chat: it opens the trip's Files.
|
||||
file: { Icon: FileText, openLabelKey: 'assistant.card.file.open', open: () => ({ kind: 'tab', tab: 'dateien' }) },
|
||||
other: { Icon: Circle },
|
||||
...TOUR_LOOK.entities,
|
||||
...ROADTRIP_LOOK.entities,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { AssistantAttachmentSummary } from '../../db/repositories/AssistantAttachments.repository';
|
||||
import type { AiToolSpec } from '../ai-connections/ai-chat-client.interface';
|
||||
import type { AgentToolPort, AgentToolRun } from './assistant-agent';
|
||||
import { READ_ATTACHED_FILE_TOOL } from './assistant-tools.constants';
|
||||
import { assistantReadAttachedFileArgsSchema } from '@trek/shared';
|
||||
import { KEEP_ATTACHED_FILE_TOOL, READ_ATTACHED_FILE_TOOL } from './assistant-tools.constants';
|
||||
import { assistantKeepFileArgsSchema, assistantReadAttachedFileArgsSchema } from '@trek/shared';
|
||||
|
||||
type ToolArgs = Record<string, unknown>;
|
||||
|
||||
@@ -39,6 +39,36 @@ export const READ_ATTACHED_FILE_SPEC: AiToolSpec = {
|
||||
},
|
||||
};
|
||||
|
||||
export const KEEP_ATTACHED_FILE_SPEC: AiToolSpec = {
|
||||
name: KEEP_ATTACHED_FILE_TOOL,
|
||||
description:
|
||||
"Keep a file the user attached in this conversation in the trip's Files, by the attachment id their message " +
|
||||
'names, on its own or attached to one booking, expense or place of the trip (ids from the trip card, ' +
|
||||
'get_trip_summary or list_budget_items). The app shows it as a change card; nothing is kept until the user ' +
|
||||
'applies it. Call it when the user asks to keep the file, or to attach it to something of the trip.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
attachmentId: { type: 'string', description: 'The attachment id from the message the file came with.' },
|
||||
reservation_id: { type: 'integer', description: 'A booking of the trip to attach the file to.' },
|
||||
budget_item_id: { type: 'integer', description: 'An expense of the trip to attach the file to (a receipt).' },
|
||||
place_id: { type: 'integer', description: 'A place of the trip to attach the file to.' },
|
||||
description: { type: 'string', maxLength: 1000, description: 'A short description of the file.' },
|
||||
},
|
||||
required: ['attachmentId'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
};
|
||||
|
||||
/** The arguments a keep runs with: the model's, checked, with the file's name for the card. */
|
||||
function keepArgs(turn: AttachedTurn, args: ToolArgs): { args: ToolArgs } | { error: string } {
|
||||
const row = turn.attached.find((entry) => entry.id === args.attachmentId);
|
||||
if (!row) return { error: NO_SUCH_ATTACHMENT };
|
||||
const parsed = assistantKeepFileArgsSchema.safeParse(args);
|
||||
if (!parsed.success) return { error: `Invalid arguments: ${parsed.error.issues[0]?.message ?? 'unknown'}` };
|
||||
return { args: { ...parsed.data, name: row.name } };
|
||||
}
|
||||
|
||||
/** What the port needs of the turn: its attachments, where a text goes, and how a shared file is read. */
|
||||
export interface AttachedTurn {
|
||||
attached: readonly AssistantAttachmentSummary[];
|
||||
@@ -56,28 +86,32 @@ function attachmentOf(turn: AttachedTurn, args: ToolArgs): AssistantAttachmentSu
|
||||
}
|
||||
|
||||
/**
|
||||
* The turn's tools with read_attached_file, where the thread holds a file
|
||||
* with text. A file not yet shared is a write (held as the consent card),
|
||||
* a shared one a read. The held call keeps the file's name and where its
|
||||
* text goes for the card, never the text.
|
||||
* The turn's tools with the attached files' own: keep_attached_file where
|
||||
* the thread holds an attachment, read_attached_file where one holds text.
|
||||
* A file not yet shared is a write (held as the consent card), a shared one
|
||||
* a read; a keep is always held. A held call keeps the file's name (and
|
||||
* where its text goes) for the card, never the text.
|
||||
*/
|
||||
export function attachedPort(port: AgentToolPort, turn: AttachedTurn | null): AgentToolPort {
|
||||
if (!turn?.attached.some((row) => row.text_status === 'ok')) return port;
|
||||
if (!turn || turn.attached.length === 0) return port;
|
||||
const reads = turn.attached.some((row) => row.text_status === 'ok');
|
||||
return {
|
||||
specs: () => [...port.specs(), READ_ATTACHED_FILE_SPEC],
|
||||
specs: () => [...port.specs(), ...(reads ? [READ_ATTACHED_FILE_SPEC] : []), KEEP_ATTACHED_FILE_SPEC],
|
||||
kindOf: (name, args) => {
|
||||
if (name !== READ_ATTACHED_FILE_TOOL) return port.kindOf(name, args);
|
||||
if (name === KEEP_ATTACHED_FILE_TOOL) return 'write';
|
||||
if (name !== READ_ATTACHED_FILE_TOOL || !reads) return port.kindOf(name, args);
|
||||
return attachmentOf(turn, args ?? {})?.shared_at ? 'read' : 'write';
|
||||
},
|
||||
pinArgs: (name, args) => {
|
||||
if (name !== READ_ATTACHED_FILE_TOOL) return port.pinArgs(name, args);
|
||||
if (name === KEEP_ATTACHED_FILE_TOOL) return keepArgs(turn, args);
|
||||
if (name !== READ_ATTACHED_FILE_TOOL || !reads) return port.pinArgs(name, args);
|
||||
const row = attachmentOf(turn, args);
|
||||
if (!row) return { error: NO_SUCH_ATTACHMENT };
|
||||
if (row.text_status !== 'ok') return { error: NO_TEXT };
|
||||
return { args: { attachmentId: row.id, name: row.name, sentTo: turn.sentTo } };
|
||||
},
|
||||
call: (name, args) => {
|
||||
if (name !== READ_ATTACHED_FILE_TOOL) return port.call(name, args);
|
||||
if (name !== READ_ATTACHED_FILE_TOOL || !reads) return port.call(name, args);
|
||||
const row = attachmentOf(turn, args);
|
||||
return row ? turn.read(row.id) : Promise.resolve({ ok: false, value: NO_SUCH_ATTACHMENT });
|
||||
},
|
||||
|
||||
@@ -12,7 +12,8 @@ import { LlmParseService } from '../llm-parse/llm-parse.service';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import type { AgentToolRun } from './assistant-agent';
|
||||
import { NO_SUCH_ATTACHMENT, NO_TEXT, type AttachedTurn } from './assistant-attachment-tools';
|
||||
import { READ_ATTACHED_FILE_TOOL } from './assistant-tools.constants';
|
||||
import { AssistantKeepFileService } from './assistant-keep-file.service';
|
||||
import { KEEP_ATTACHED_FILE_TOOL, READ_ATTACHED_FILE_TOOL } from './assistant-tools.constants';
|
||||
import type { ToolRun } from './assistant-tools.service';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
@@ -60,7 +61,7 @@ export interface PreparedAttachment {
|
||||
}
|
||||
|
||||
/** The held changes on an attachment this service applies. */
|
||||
const ATTACHMENT_CHANGES: ReadonlySet<string> = new Set([READ_ATTACHED_FILE_TOOL]);
|
||||
const ATTACHMENT_CHANGES: ReadonlySet<string> = new Set([READ_ATTACHED_FILE_TOOL, KEEP_ATTACHED_FILE_TOOL]);
|
||||
|
||||
/** What a read of a file the user has not agreed to send answers with. */
|
||||
const NOT_SHARED = 'The user has not let you read this file yet.';
|
||||
@@ -110,6 +111,7 @@ export class AssistantAttachmentsService {
|
||||
private readonly storage: StorageService,
|
||||
private readonly llmParse: LlmParseService,
|
||||
private readonly orm: MikroORM,
|
||||
private readonly keeper: AssistantKeepFileService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -193,6 +195,7 @@ export class AssistantAttachmentsService {
|
||||
/** Applies a held change on an attachment. */
|
||||
applyChange(name: string, at: AttachmentChangeAt, args: Record<string, unknown>): Promise<ToolRun> {
|
||||
if (name === READ_ATTACHED_FILE_TOOL) return this.share(at.userId, at.threadId, args);
|
||||
if (name === KEEP_ATTACHED_FILE_TOOL) return this.keeper.keep(at, args);
|
||||
return Promise.resolve({ ok: false, value: `Unknown tool: ${name}` });
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
ASSISTANT_SERVER_TOOLS,
|
||||
IMPORT_BOOKING_TOOL,
|
||||
IMPORT_TOUR_FILE_TOOL,
|
||||
KEEP_ATTACHED_FILE_TOOL,
|
||||
READ_ATTACHED_FILE_TOOL,
|
||||
READ_BOOKING_FILE_TOOL,
|
||||
READ_TOUR_FILE_TOOL,
|
||||
@@ -129,7 +130,7 @@ function takenProposalOutcome(part: ToolCallPart): string {
|
||||
return `The user took the proposed ${what} ${name} to add it; it waits for them to add or discard it.`;
|
||||
}
|
||||
|
||||
/** Why a booking file held no booking, as the model is told it (BookingReadReason); a reason not listed adds nothing. */
|
||||
/** Why a booking file held no booking, as the model is told it; a reason this build does not know adds nothing. */
|
||||
const EMPTY_REASONS: Readonly<Record<string, string>> = {
|
||||
ai_not_configured: "AI reading for imports is not set up on this server, so only TREK's own reader looked at it",
|
||||
ai_failed: 'the AI reader for imports could not read it',
|
||||
@@ -189,6 +190,27 @@ function earlierCut(text: string, earlier: boolean): string {
|
||||
: text;
|
||||
}
|
||||
|
||||
/**
|
||||
* The model's own calls on attached files: told after the step that made
|
||||
* them, so the history ends on the user's side when the user's decision on
|
||||
* them goes on.
|
||||
*/
|
||||
const ATTACHED_FILE_TOOLS: ReadonlySet<string> = new Set([READ_ATTACHED_FILE_TOOL, KEEP_ATTACHED_FILE_TOOL]);
|
||||
|
||||
/** What became of keeping an attached file in Files, as its stored part says. */
|
||||
function keptFileLine(part: ToolCallPart): string {
|
||||
const file = quoted(part.args.name);
|
||||
if (part.approval?.approved === true) {
|
||||
const kept = (part.result ?? {}) as { file?: { id?: unknown } };
|
||||
if (part.isError) return `The attached file ${file} could not be kept in Files: ${outputText(part.result)}`;
|
||||
return `The attached file ${file} is in the trip's Files now (file id ${String(kept.file?.id ?? '?')}).`;
|
||||
}
|
||||
if (part.approval?.approved === false) return `The user did not keep the attached file ${file} in Files.`;
|
||||
if (part.isError) return `Keeping the attached file ${file} was refused: ${outputText(part.result)}`;
|
||||
if (part.approval?.resolution) return `The attached file ${file} was not kept; the user moved on without deciding.`;
|
||||
return `Keeping the attached file ${file} in Files waits for the user to apply or discard it.`;
|
||||
}
|
||||
|
||||
/** What the model is told of one read of an attached file. */
|
||||
function attachedReadLine(
|
||||
part: ToolCallPart,
|
||||
@@ -224,7 +246,11 @@ function attachedReadNote(
|
||||
told: Set<string>,
|
||||
earlier: boolean,
|
||||
): string {
|
||||
return calls.map((part) => attachedReadLine(part, texts, told, earlier)).join('\n\n');
|
||||
return calls
|
||||
.map((part) =>
|
||||
part.toolName === KEEP_ATTACHED_FILE_TOOL ? keptFileLine(part) : attachedReadLine(part, texts, told, earlier),
|
||||
)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,9 +282,9 @@ export function toChatHistory(
|
||||
(part): part is Extract<AssistantMessagePart, { type: 'tool-call' }> =>
|
||||
part.type === 'tool-call' && part.toolName !== ASSISTANT_WEB_SEARCH_STEP,
|
||||
);
|
||||
const reads = allCalls.filter((part) => part.toolName === READ_ATTACHED_FILE_TOOL);
|
||||
const reads = allCalls.filter((part) => ATTACHED_FILE_TOOLS.has(part.toolName));
|
||||
const serverCalls = allCalls.filter(
|
||||
(part) => ASSISTANT_SERVER_TOOLS.has(part.toolName) && part.toolName !== READ_ATTACHED_FILE_TOOL,
|
||||
(part) => ASSISTANT_SERVER_TOOLS.has(part.toolName) && !ATTACHED_FILE_TOOLS.has(part.toolName),
|
||||
);
|
||||
// Told as the user's side of the conversation: the history then ends on it
|
||||
// when a decision on these bookings starts the next answer.
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { AssistantAttachments } from '../../db/entities/AssistantAttachments.entity';
|
||||
import type { AssistantAttachmentsRepository } from '../../db/repositories/AssistantAttachments.repository';
|
||||
import { logError } from '../audit/audit-log.logger';
|
||||
import { DemoService } from '../common/demo.service';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { AllowedFileTypesService } from '../files/allowed-file-types.service';
|
||||
import { isUploadTypeAllowed } from '../files/files.constants';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { TripAccessService } from '../trip-membership/trip-access.service';
|
||||
import { NO_SUCH_ATTACHMENT } from './assistant-attachment-tools';
|
||||
import { MAX_ATTACHMENT_BYTES } from './assistant-tools.constants';
|
||||
import type { ToolRun } from './assistant-tools.service';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { assistantKeepFileArgsSchema, type AssistantKeepFileArgs } from '@trek/shared';
|
||||
|
||||
const BROKEN = 'This change can no longer be read.';
|
||||
const NOT_ALLOWED = 'You are not allowed to keep files on this trip.';
|
||||
const NOT_ALLOWED_LINK = 'You are not allowed to attach files to bookings, expenses or places on this trip.';
|
||||
const TYPE_NOT_ALLOWED = 'This file type is not allowed on this TREK instance.';
|
||||
const GONE = 'The attached file is no longer kept, so it cannot be put into Files.';
|
||||
const NOT_KEPT = 'The file could not be kept.';
|
||||
|
||||
/** Where a keep runs: the user, and the thread and trip of the conversation. */
|
||||
interface KeepAt {
|
||||
userId: number;
|
||||
threadId: number;
|
||||
tripId: number;
|
||||
}
|
||||
|
||||
/** What the receipt of a kept file names: the trip file and what it is attached to. */
|
||||
interface KeptFile {
|
||||
id: number;
|
||||
name: string;
|
||||
reservation_id: number | null;
|
||||
place_id: number | null;
|
||||
budget_item_id: number | null;
|
||||
}
|
||||
|
||||
/** The links a keep asks for, as FilesService takes them. */
|
||||
function linksOf(args: AssistantKeepFileArgs) {
|
||||
return {
|
||||
reservation_id: args.reservation_id ?? null,
|
||||
place_id: args.place_id ?? null,
|
||||
budget_item_id: args.budget_item_id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* keep_attached_file, applied: a file attached in the chat becomes a trip
|
||||
* file, with the checks the Files upload runs (the demo block, the trip, the
|
||||
* `file_upload` right and `file_edit` for a link, the instance's file types,
|
||||
* every linked id on the same trip) taken when the user applies the change.
|
||||
* The bytes are copied from the attachment to a trip file's own key; the
|
||||
* trip file's row, its link to an expense and the attachment's pointer to it
|
||||
* are written in one transaction, and the members' planners hear of the new
|
||||
* file after it. Keeping the same attachment twice answers the file kept the
|
||||
* first time.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AssistantKeepFileService {
|
||||
constructor(
|
||||
@InjectRepository(AssistantAttachments) private readonly rows: AssistantAttachmentsRepository,
|
||||
private readonly storage: StorageService,
|
||||
private readonly files: FilesService,
|
||||
private readonly allowedTypes: AllowedFileTypesService,
|
||||
private readonly guards: McpToolGuardsService,
|
||||
private readonly demo: DemoService,
|
||||
private readonly trips: TripAccessService,
|
||||
private readonly uow: UnitOfWork,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Keeps the attachment the stored arguments name; the answer is shaped like
|
||||
* a tool's: the trip file, or why it was not kept.
|
||||
*
|
||||
* @txIndependent the bytes are copied in storage before the one transaction
|
||||
* that records them, and dropped again if it fails.
|
||||
*/
|
||||
async keep(at: KeepAt, args: Record<string, unknown>): Promise<ToolRun> {
|
||||
const parsed = assistantKeepFileArgsSchema.safeParse(args);
|
||||
if (!parsed.success) return { ok: false, value: BROKEN };
|
||||
const refused = await this.refusalOf(at, parsed.data);
|
||||
if (refused) return { ok: false, value: refused };
|
||||
const row = await this.rows.findInThread(parsed.data.attachmentId, at.threadId, at.userId);
|
||||
if (!row || row.trip_id !== at.tripId) return { ok: false, value: NO_SUCH_ATTACHMENT };
|
||||
const already = row.file_id === null ? undefined : await this.files.getFileById(row.file_id, at.tripId);
|
||||
if (already && !already.deleted_at) return { ok: true, value: { file: keptOf(already) } };
|
||||
if (!isUploadTypeAllowed(row.name, row.mime, await this.allowedTypes.get())) {
|
||||
return { ok: false, value: TYPE_NOT_ALLOWED };
|
||||
}
|
||||
const links = linksOf(parsed.data);
|
||||
const foreign = await this.files.findForeignLinkTarget(at.tripId, links);
|
||||
if (foreign) return { ok: false, value: `Linked item does not belong to this trip (${foreign}).` };
|
||||
const bytes = await this.bytesOf(row.storage_key);
|
||||
if (!bytes) return { ok: false, value: GONE };
|
||||
const upload = { originalname: row.name, mimetype: row.mime, bytes };
|
||||
const filename = await this.files.storeBytes(upload);
|
||||
let created: Awaited<ReturnType<FilesService['createFile']>>;
|
||||
try {
|
||||
created = await this.uow.transactional(async () => {
|
||||
const file = await this.files.createFile(
|
||||
at.tripId,
|
||||
{ filename, originalname: row.name, size: bytes.length, mimetype: row.mime },
|
||||
at.userId,
|
||||
{ ...links, description: parsed.data.description ?? null },
|
||||
);
|
||||
await this.rows.setFile(row.id, file.id);
|
||||
return file;
|
||||
});
|
||||
} catch (err) {
|
||||
logError(`[assistant] keeping attachment ${row.id} failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
await this.files.discardBytes(filename).catch((dropErr: unknown) => {
|
||||
logError(`[assistant] dropping ${filename} failed: ${String(dropErr)}`);
|
||||
});
|
||||
return { ok: false, value: NOT_KEPT };
|
||||
}
|
||||
// No socket to spare from the echo: the user's own planner learns of the file like every other member's.
|
||||
this.files.broadcast(String(at.tripId), 'file:created', { file: created }, undefined);
|
||||
return { ok: true, value: { file: keptOf({ ...created, ...links }) } };
|
||||
}
|
||||
|
||||
/** Why the user may not keep this file here, or null: the demo, the trip, the Files rights. */
|
||||
private async refusalOf(at: KeepAt, args: AssistantKeepFileArgs): Promise<string | null> {
|
||||
if (await this.demo.isDemoUserId(at.userId)) return NOT_ALLOWED;
|
||||
if (!(await this.trips.findAccessible(at.tripId, at.userId))) return NOT_ALLOWED;
|
||||
if (!(await this.guards.hasTripPermission('file_upload', at.tripId, at.userId))) return NOT_ALLOWED;
|
||||
const linking = args.reservation_id ?? args.budget_item_id ?? args.place_id;
|
||||
if (linking && !(await this.guards.hasTripPermission('file_edit', at.tripId, at.userId))) {
|
||||
return NOT_ALLOWED_LINK;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The attachment's bytes, at most what an attachment may hold; null when they are gone. */
|
||||
private async bytesOf(key: string): Promise<Buffer | null> {
|
||||
try {
|
||||
const { stream } = await this.storage.getStream('files', key);
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
for await (const chunk of stream) {
|
||||
const part = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);
|
||||
total += part.length;
|
||||
if (total > MAX_ATTACHMENT_BYTES) {
|
||||
stream.destroy();
|
||||
return null;
|
||||
}
|
||||
chunks.push(part);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
} catch (err) {
|
||||
logError(`[assistant] reading attached file ${key} failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A trip file as the receipt names it. */
|
||||
function keptOf(file: {
|
||||
id: number;
|
||||
original_name: string;
|
||||
reservation_id?: number | string | null;
|
||||
place_id?: number | string | null;
|
||||
budget_item_id?: number | string | null;
|
||||
}): KeptFile {
|
||||
const idOf = (value: number | string | null | undefined) => (value == null ? null : Number(value));
|
||||
return {
|
||||
id: file.id,
|
||||
name: file.original_name,
|
||||
reservation_id: idOf(file.reservation_id),
|
||||
place_id: idOf(file.place_id),
|
||||
budget_item_id: idOf(file.budget_item_id),
|
||||
};
|
||||
}
|
||||
@@ -140,6 +140,15 @@ const TOUR_FILE_VIA = 'import_tour_gpx';
|
||||
*/
|
||||
export const READ_ATTACHED_FILE_TOOL = 'read_attached_file';
|
||||
|
||||
/**
|
||||
* A file the user attached in this conversation, kept in the trip's Files,
|
||||
* on its own or attached to a booking, an expense or a place of the trip.
|
||||
* The server offers it to the model where the thread holds an attachment,
|
||||
* holds it as a change card like any write, and applies it with the Files
|
||||
* rights of the user (AssistantAttachmentsService.keep).
|
||||
*/
|
||||
export const KEEP_ATTACHED_FILE_TOOL = 'keep_attached_file';
|
||||
|
||||
/**
|
||||
* A place from the search results of an answer, suggested for a day by the
|
||||
* user ("+ Day 3"). Held as a change card like any write, without a model turn.
|
||||
@@ -206,6 +215,7 @@ export const ASSISTANT_SERVER_WRITE_TOOLS: Readonly<Record<string, AssistantWrit
|
||||
[REMEMBER_TOOL]: { verbKey: 'rememberNote', entity: 'memory' },
|
||||
// Held only until the user let the model read the file; it writes nothing but that agreement.
|
||||
[READ_ATTACHED_FILE_TOOL]: { verbKey: 'readAttachment', entity: 'other' },
|
||||
[KEEP_ATTACHED_FILE_TOOL]: { verbKey: 'keepFile', entity: 'file' },
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,6 +10,7 @@ import { BookingImportModule } from '../booking-import/booking-import.module';
|
||||
import { CollabModule } from '../collab/collab.module';
|
||||
import { RateLimitModule } from '../common/rate-limit.module';
|
||||
import { AllowedFileTypesModule } from '../files/allowed-file-types.module';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { JourneyBookModule } from '../journey/journey-book.module';
|
||||
import { JourneyDomainModule } from '../journey/journey-domain.module';
|
||||
import { LlmParseModule } from '../llm-parse/llm-parse.module';
|
||||
@@ -28,6 +29,7 @@ import { AssistantBookGate } from './assistant-book-gate';
|
||||
import { AssistantBookingImportService } from './assistant-booking-import.service';
|
||||
import { AssistantFileAnswers } from './assistant-file-answer.service';
|
||||
import { AssistantFoundPlacesService } from './assistant-found-places.service';
|
||||
import { AssistantKeepFileService } from './assistant-keep-file.service';
|
||||
import { AssistantProposalsService } from './assistant-proposals.service';
|
||||
import { AssistantRenderRegistry, InMemoryAssistantRenderRegistry } from './assistant-render.registry';
|
||||
import { AssistantRendersController } from './assistant-renders.controller';
|
||||
@@ -95,6 +97,8 @@ import { Module } from '@nestjs/common';
|
||||
LlmParseModule,
|
||||
// The chips under the answer offer to keep a file only where Files takes its type.
|
||||
AllowedFileTypesModule,
|
||||
// A file attached in the chat is kept in Files through the Files service, with its rights and checks.
|
||||
FilesModule,
|
||||
MikroOrmModule.forFeature([AssistantThreads, AssistantMessages, AssistantToolCalls, AssistantAttachments]),
|
||||
],
|
||||
controllers: [
|
||||
@@ -113,6 +117,7 @@ import { Module } from '@nestjs/common';
|
||||
AssistantBookingImportService,
|
||||
AssistantFileAnswers,
|
||||
AssistantAttachmentsService,
|
||||
AssistantKeepFileService,
|
||||
AssistantTourFileService,
|
||||
AssistantFoundPlacesService,
|
||||
AssistantProposalsService,
|
||||
|
||||
@@ -33,7 +33,7 @@ How you work:
|
||||
- Propose a deletion only when the user asks for it; it cannot be undone, and the card says so.
|
||||
- You cannot share, invite people, publish links or calendar feeds, change who can see a trip, leave, copy or delete the trip, or make a new one. If asked, say so in one sentence and where in TREK the user can do it.
|
||||
- Keep what will matter later with remember_for_trip on your own, without asking: who travels, needs, likes and dislikes, budget, pace, fixed times the user mentions, and anything they ask you to remember. It is kept at once and comes back as <trip_notes> in every later conversation; say briefly that you noted it. Where you are not sure it lasts, offer it as a follow-up instead ("Remember: …"). Never say you cannot remember things.
|
||||
- A file the user attached comes with its attachment id, and TREK already said what it found in it. When the user wants you to work with what is in the file (make a booking from it, say what it holds), read it with read_attached_file; the app asks the user before its text reaches you, so never say you read a file you have not. A booking you find in it is proposed with create_reservation, with every field you are not sure of left out and the uncertain ones named in a sentence; then offer to keep the file with that booking.
|
||||
- A file the user attached comes with its attachment id, and TREK already said what it found in it. When the user wants you to work with what is in the file (make a booking from it, say what it holds), read it with read_attached_file; the app asks the user before its text reaches you, so never say you read a file you have not. A booking you find in it is proposed with create_reservation, with every field you are not sure of left out and the uncertain ones named in a sentence; then offer to keep the file with that booking. To keep a file in the trip's Files, on its own or with a booking, an expense or a place, use keep_attached_file; never upload it another way.
|
||||
- Expenses and their ids come from list_budget_items. Change an existing one with update_budget_item; never add a second entry to correct one. For what each person's share comes to, use get_budget_per_person; for who pays whom, get_settlement_summary.
|
||||
- For the wind on a day (a paddle, a sail, a ridge walk), use get_wind_forecast: it has the gusts and the direction hour by hour.
|
||||
- End every answer with one suggest_follow_ups call, alone in its step after your text: up to three short messages the user would plausibly send next, about what you just discussed (a hike you planned: an easier variant, what to pack; never the day plan when the answer was not about it). When you asked the user a question, offer its likely answers instead ("Yes, plan it anyway", "Rather in the Harz"). Skip it only while changes wait for the user's decision.
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CardPlace } from './assistant-context';
|
||||
import { noteOf } from './assistant-memory';
|
||||
import {
|
||||
ASSISTANT_SERVER_WRITE_TOOLS,
|
||||
KEEP_ATTACHED_FILE_TOOL,
|
||||
READ_ATTACHED_FILE_TOOL,
|
||||
REMEMBER_TOOL,
|
||||
writeToolOf,
|
||||
@@ -125,6 +126,26 @@ function updatePlaceRows(ctx: PreviewContext, args: Args, rows: Rows): void {
|
||||
|
||||
type Describer = (ctx: PreviewContext, args: Args) => Described;
|
||||
|
||||
/** The name of an expense of the trip, from the trip summary the card was read from; its number otherwise. */
|
||||
function expenseName(ctx: PreviewContext, itemId: number | null): string {
|
||||
if (itemId === null) return '';
|
||||
const summary = ctx.card.sources.summary;
|
||||
const budget = summary && typeof summary === 'object' ? (summary as { budget?: { items?: unknown } }).budget : null;
|
||||
const items = Array.isArray(budget?.items) ? (budget.items as Array<{ id?: unknown; name?: unknown }>) : [];
|
||||
const found = items.find((item) => item.id === itemId);
|
||||
return typeof found?.name === 'string' && found.name ? found.name : `#${itemId}`;
|
||||
}
|
||||
|
||||
/** What a kept file is attached to, by name: the booking, the expense, the place. */
|
||||
function keptWith(ctx: PreviewContext, args: Args): string {
|
||||
const reservationId = id(args, 'reservation_id');
|
||||
const booking =
|
||||
reservationId === null ? '' : (ctx.card.reservations.get(reservationId)?.title ?? `#${reservationId}`);
|
||||
return [booking, expenseName(ctx, id(args, 'budget_item_id')), placeName(ctx, id(args, 'place_id'))]
|
||||
.filter((name) => name !== '')
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
/** Who shares an expense: the uneven split's members, else the equal one's. */
|
||||
function splitOf(args: Args): unknown {
|
||||
return Array.isArray(args.members)
|
||||
@@ -248,6 +269,16 @@ const DESCRIBERS: Readonly<Record<string, Describer>> = {
|
||||
return { subject: text(args.name) || `#${id(args, 'itemId') ?? ''}`, rows, dayId: null, placeIds: [] };
|
||||
},
|
||||
[REMEMBER_TOOL]: (_ctx, args) => ({ subject: noteOf(args) ?? '', rows: new Rows(), dayId: null, placeIds: [] }),
|
||||
// The file by its name, and what of the trip it is attached to.
|
||||
[KEEP_ATTACHED_FILE_TOOL]: (ctx, args) => {
|
||||
const placeId = id(args, 'place_id');
|
||||
return {
|
||||
subject: text(args.name),
|
||||
rows: new Rows().add('attachedTo', keptWith(ctx, args)).add('description', text(args.description)),
|
||||
dayId: null,
|
||||
placeIds: placeId ? [placeId] : [],
|
||||
};
|
||||
},
|
||||
// The file by its name and where its text would go; the server set both when it held the call.
|
||||
[READ_ATTACHED_FILE_TOOL]: (_ctx, args) => ({
|
||||
subject: text(args.name),
|
||||
|
||||
@@ -406,8 +406,7 @@ export class FilesService {
|
||||
uploadedBy: number,
|
||||
opts: { place_id?: number | null; reservation_id?: number | null; description?: string | null },
|
||||
) {
|
||||
const filename = `${randomUUID()}${path.extname(upload.originalname)}`;
|
||||
await this.storage.put('files', filename, Readable.from([upload.bytes]), { contentType: upload.mimetype });
|
||||
const filename = await this.storeBytes(upload);
|
||||
return this.createFile(
|
||||
tripId,
|
||||
{ filename, originalname: upload.originalname, size: upload.bytes.length, mimetype: upload.mimetype },
|
||||
@@ -416,6 +415,24 @@ export class FilesService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts bytes under a fresh storage key of the trip files, the one
|
||||
* `createFileFromBytes` would choose, and answers the name a `trip_files`
|
||||
* row records them under. For a caller that writes that row inside a
|
||||
* transaction of its own (the assistant keeping an attached file), so the
|
||||
* storage write stays out of it.
|
||||
*/
|
||||
async storeBytes(upload: { originalname: string; mimetype: string; bytes: Buffer }): Promise<string> {
|
||||
const filename = `${randomUUID()}${path.extname(upload.originalname)}`;
|
||||
await this.storage.put('files', filename, Readable.from([upload.bytes]), { contentType: upload.mimetype });
|
||||
return filename;
|
||||
}
|
||||
|
||||
/** Drops bytes `storeBytes` put, when the row that was to record them could not be written. */
|
||||
async discardBytes(filename: string): Promise<void> {
|
||||
await this.storage.delete('files', path.basename(filename));
|
||||
}
|
||||
|
||||
/** R2: `updateFile`'s field update and its conditional `file_links` insert/delete (FL12+FL13+FL14) run inside one transaction. */
|
||||
async updateFile(
|
||||
id: string | number,
|
||||
|
||||
@@ -12,18 +12,23 @@
|
||||
* user's language, never with an error; the model reads a file only after
|
||||
* the user agreed on its card, its text reaching the model and nothing else,
|
||||
* and proposes the booking from it; an attachment of another conversation is
|
||||
* answered like one that does not exist.
|
||||
* answered like one that does not exist; a file is kept in Files with the
|
||||
* booking it belongs to, with the rights of Files, and only once.
|
||||
*/
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { AssistantAttachments } from '../../src/db/entities/AssistantAttachments.entity';
|
||||
import { AssistantMessages } from '../../src/db/entities/AssistantMessages.entity';
|
||||
import { AssistantThreads } from '../../src/db/entities/AssistantThreads.entity';
|
||||
import { BookingImportService } from '../../src/nest/booking-import/booking-import.service';
|
||||
import { DemoService } from '../../src/nest/common/demo.service';
|
||||
import { PermissionsService } from '../../src/nest/permissions/permissions.service';
|
||||
import { StorageService } from '../../src/nest/storage/storage.service';
|
||||
import { makeReservation } from '../helpers/factories/reservations';
|
||||
import { countRows, findRow, findRows } from '../helpers/factories/rows';
|
||||
import { setAddonEnabled } from '../helpers/factories/settings';
|
||||
import { makeTrip } from '../helpers/factories/trips';
|
||||
import { makeUser } from '../helpers/factories/users';
|
||||
import { spyOnRealtime, type RealtimeSpies } from '../helpers/fake-realtime';
|
||||
import { resetRateLimits } from '../helpers/test-db';
|
||||
import { sessionCookie } from './harness';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
@@ -104,6 +109,7 @@ async function answer(req: IncomingMessage, res: ServerResponse): Promise<void>
|
||||
let nestApp: INestApplication;
|
||||
let orm: MikroORM;
|
||||
let fake: HttpServer;
|
||||
let broadcasts: RealtimeSpies;
|
||||
|
||||
beforeAll(async () => {
|
||||
fake = createServer((req, res) => {
|
||||
@@ -112,6 +118,7 @@ beforeAll(async () => {
|
||||
await new Promise<void>((resolve) => fake.listen(0, '127.0.0.1', resolve));
|
||||
nestApp = await buildApp();
|
||||
orm = nestApp.get(MikroORM);
|
||||
broadcasts = spyOnRealtime(nestApp);
|
||||
await setAddonEnabled(orm, 'ai_assistant', true);
|
||||
const adminId = (await makeUser(orm, { role: 'admin' })).user.id;
|
||||
const { port } = fake.address() as AddressInfo;
|
||||
@@ -126,6 +133,7 @@ beforeEach(async () => {
|
||||
await resetRateLimits(nestApp);
|
||||
provider.requests = [];
|
||||
provider.script = [];
|
||||
broadcasts.broadcast.mockClear();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -461,4 +469,90 @@ describe('Trip assistant: attached files', () => {
|
||||
expect(await refusalOf(owner, sameTripThread.threadId, '9f1e2d3c-4b5a-4968-8776-655443322110')).toBe(message);
|
||||
expect((await findRow(orm, AssistantAttachments, { id: attachmentId }))?.shared_at).toBeNull();
|
||||
});
|
||||
|
||||
/** A chat turn in which the model proposes keeping the file, and the id the change is held under. */
|
||||
async function proposeKeep(t: { userId: number; base: string }, threadId: number, args: Record<string, unknown>) {
|
||||
provider.script = [callTools({ id: `call_keep_${threadId}`, name: 'keep_attached_file', args })];
|
||||
const turn = await post(`${t.base}/chat`, t.userId, { ...CTX, threadId, text: 'Keep it with the booking' });
|
||||
return { turn, toolCallId: ofType(turn.chunks, 'tool-approval-request')[0]?.toolCallId };
|
||||
}
|
||||
|
||||
it('ATTE2E-008: the file is kept in Files with its booking once the user applies it, and only once', async () => {
|
||||
const t = await travelerWithTrip();
|
||||
const booking = await makeReservation(orm, t.tripId, { title: 'Casa Lisboa', type: 'hotel' });
|
||||
const { threadId, attachmentId } = await threadWithFile(t);
|
||||
const { turn, toolCallId } = await proposeKeep(t, threadId, { attachmentId, reservation_id: booking.id });
|
||||
expect(provider.requests[0].tools?.map((tool) => tool.function.name)).toContain('keep_attached_file');
|
||||
expect(ofType(turn.chunks, 'data-trek-change')[0].data).toMatchObject({
|
||||
verbKey: 'keepFile',
|
||||
subject: 'AirBnB Buchung.txt',
|
||||
rows: [{ labelKey: 'attachedTo', after: 'Casa Lisboa' }],
|
||||
});
|
||||
|
||||
provider.script = [say('It is in Files now.')];
|
||||
const decided = await post(`${t.base}/threads/${threadId}/decisions`, t.userId, {
|
||||
...CTX,
|
||||
decisions: [{ toolCallId, approved: true }],
|
||||
});
|
||||
expect(ofType(decided.chunks, 'data-trek-applied')[0].data).toMatchObject({ toolCallId, entity: 'file' });
|
||||
const listed = await get(`/api/trips/${t.tripId}/files`, t.userId);
|
||||
expect(listed.status).toBe(200);
|
||||
expect(listed.body.files).toHaveLength(1);
|
||||
const [file] = listed.body.files as Array<{ id: number; original_name: string; reservation_id: number | null }>;
|
||||
expect(file).toMatchObject({ original_name: 'AirBnB Buchung.txt', reservation_id: booking.id });
|
||||
expect((await findRow(orm, AssistantAttachments, { id: attachmentId }))?.file_id).toBe(file.id);
|
||||
expect(broadcasts.broadcast).toHaveBeenCalledWith(
|
||||
String(t.tripId),
|
||||
'file:created',
|
||||
{ file: expect.objectContaining({ id: file.id }) },
|
||||
undefined,
|
||||
);
|
||||
const sent = provider.requests[1].messages.map((message) => String(message.content ?? '')).join(' ');
|
||||
expect(sent).toContain(`The attached file "AirBnB Buchung.txt" is in the trip's Files now (file id ${file.id}).`);
|
||||
const download = await get(`/api/trips/${t.tripId}/files/${file.id}/download`, t.userId);
|
||||
expect(download.status).toBe(200);
|
||||
|
||||
// A second keep of the same file answers the first one.
|
||||
const second = await proposeKeep(t, threadId, { attachmentId });
|
||||
provider.script = [say('Already there.')];
|
||||
await post(`${t.base}/threads/${threadId}/decisions`, t.userId, {
|
||||
...CTX,
|
||||
decisions: [{ toolCallId: second.toolCallId, approved: true }],
|
||||
});
|
||||
expect((await get(`/api/trips/${t.tripId}/files`, t.userId)).body.files).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('ATTE2E-009: a demo account and a member without the right to upload keep nothing', async () => {
|
||||
const t = await travelerWithTrip();
|
||||
const { threadId, attachmentId } = await threadWithFile(t);
|
||||
const tryKeep = async () => {
|
||||
const { toolCallId } = await proposeKeep(t, threadId, { attachmentId });
|
||||
provider.script = [say('Could not keep it.')];
|
||||
const decided = await post(`${t.base}/threads/${threadId}/decisions`, t.userId, {
|
||||
...CTX,
|
||||
decisions: [{ toolCallId, approved: true }],
|
||||
});
|
||||
return ofType(decided.chunks, 'tool-output-error')[0]?.errorText;
|
||||
};
|
||||
const demo = vi.spyOn(nestApp.get(DemoService), 'isDemoUserId').mockResolvedValue(true);
|
||||
try {
|
||||
expect(await tryKeep()).toBe('You are not allowed to keep files on this trip.');
|
||||
} finally {
|
||||
demo.mockRestore();
|
||||
}
|
||||
const permissions = vi.spyOn(nestApp.get(PermissionsService), 'checkPermission');
|
||||
permissions.mockImplementation((action: string) => Promise.resolve(action !== 'file_upload'));
|
||||
try {
|
||||
expect(await tryKeep()).toBe('You are not allowed to keep files on this trip.');
|
||||
} finally {
|
||||
permissions.mockRestore();
|
||||
}
|
||||
expect((await get(`/api/trips/${t.tripId}/files`, t.userId)).body.files).toHaveLength(0);
|
||||
expect(broadcasts.broadcast).not.toHaveBeenCalledWith(
|
||||
String(t.tripId),
|
||||
'file:created',
|
||||
expect.anything(),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { AgentToolPort } from '../../../../src/nest/assistant/assistant-age
|
||||
import {
|
||||
attachedPort,
|
||||
NO_SUCH_ATTACHMENT,
|
||||
KEEP_ATTACHED_FILE_SPEC,
|
||||
NO_TEXT,
|
||||
READ_ATTACHED_FILE_SPEC,
|
||||
} from '../../../../src/nest/assistant/assistant-attachment-tools';
|
||||
@@ -51,14 +52,25 @@ function base(): AgentToolPort {
|
||||
const ATTACHED = [row(OWN), row(SHARED, { shared_at: '2026-10-24 08:01:00' }), row(SCAN, { text_status: 'scanned' })];
|
||||
|
||||
describe('attachedPort', () => {
|
||||
it('ASTATL-001: offers read_attached_file only where a file of the thread holds text', () => {
|
||||
it('ASTATL-001: offers keep_attached_file where the thread holds a file, read_attached_file where one has text', () => {
|
||||
const port = base();
|
||||
expect(attachedPort(port, null)).toBe(port);
|
||||
expect(attachedPort(port, { attached: [row(SCAN, { text_status: 'scanned' })], sentTo: 'm', read: vi.fn() })).toBe(
|
||||
port,
|
||||
);
|
||||
expect(attachedPort(port, { attached: [], sentTo: 'm', read: vi.fn() })).toBe(port);
|
||||
const scanOnly = attachedPort(port, {
|
||||
attached: [row(SCAN, { text_status: 'scanned' })],
|
||||
sentTo: 'm',
|
||||
read: vi.fn(),
|
||||
});
|
||||
expect(scanOnly.specs().map((spec) => spec.name)).toEqual(['get_trip_summary', KEEP_ATTACHED_FILE_SPEC.name]);
|
||||
expect(scanOnly.pinArgs('read_attached_file', { attachmentId: SCAN })).toEqual({
|
||||
args: { attachmentId: SCAN },
|
||||
});
|
||||
const offered = attachedPort(port, { attached: ATTACHED, sentTo: 'm', read: vi.fn() });
|
||||
expect(offered.specs().map((spec) => spec.name)).toEqual(['get_trip_summary', READ_ATTACHED_FILE_SPEC.name]);
|
||||
expect(offered.specs().map((spec) => spec.name)).toEqual([
|
||||
'get_trip_summary',
|
||||
READ_ATTACHED_FILE_SPEC.name,
|
||||
KEEP_ATTACHED_FILE_SPEC.name,
|
||||
]);
|
||||
});
|
||||
|
||||
it('ASTATL-002: the first read of a file is held for the user, a shared one runs at once', () => {
|
||||
@@ -100,4 +112,16 @@ describe('attachedPort', () => {
|
||||
expect(prompt).toContain('read it with read_attached_file');
|
||||
expect(prompt).toContain('proposed with create_reservation');
|
||||
});
|
||||
|
||||
it('ASTATL-006: a keep is always held, checked, and names the file; an attachment of no one here is refused alike', () => {
|
||||
const port = attachedPort(base(), { attached: ATTACHED, sentTo: 'm', read: vi.fn() });
|
||||
expect(port.kindOf('keep_attached_file', { attachmentId: SCAN })).toBe('write');
|
||||
expect(port.pinArgs('keep_attached_file', { attachmentId: SCAN, reservation_id: 5, name: 'forged.exe' })).toEqual({
|
||||
args: { attachmentId: SCAN, reservation_id: 5, name: '2d9e.pdf' },
|
||||
});
|
||||
expect(port.pinArgs('keep_attached_file', { attachmentId: ELSEWHERE })).toEqual({ error: NO_SUCH_ATTACHMENT });
|
||||
expect(port.pinArgs('keep_attached_file', {})).toEqual({ error: NO_SUCH_ATTACHMENT });
|
||||
const bad = port.pinArgs('keep_attached_file', { attachmentId: OWN, budget_item_id: 'seven' });
|
||||
expect(bad).toEqual({ error: expect.stringContaining('Invalid arguments') });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ASSISTANT_ATTACHMENT_TTL_DAYS,
|
||||
AssistantAttachmentsService,
|
||||
} from '../../../../src/nest/assistant/assistant-attachments.service';
|
||||
import { AssistantKeepFileService } from '../../../../src/nest/assistant/assistant-keep-file.service';
|
||||
import { LlmParseService } from '../../../../src/nest/llm-parse/llm-parse.service';
|
||||
import { StorageService } from '../../../../src/nest/storage/storage.service';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
@@ -96,6 +97,10 @@ async function make(opts: { text?: string | Error; listed?: Listed[]; failDelete
|
||||
{ provide: StorageService, useValue: storage },
|
||||
{ provide: LlmParseService, useValue: llmParse },
|
||||
{ provide: MikroORM, useValue: {} },
|
||||
{
|
||||
provide: AssistantKeepFileService,
|
||||
useValue: { keep: vi.fn(async () => ({ ok: true, value: { file: { id: 3 } } })) },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
return { service: moduleRef.get(AssistantAttachmentsService), rows, storage, llmParse };
|
||||
@@ -212,6 +217,15 @@ describe('AssistantAttachmentsService', () => {
|
||||
});
|
||||
expect(t.service.handles('read_attached_file')).toBe(true);
|
||||
expect(t.service.handles('create_place')).toBe(false);
|
||||
const at = { userId: 7, threadId: 4, tripId: 9 };
|
||||
expect(await t.service.applyChange('keep_attached_file', at, { attachmentId: FRESH })).toEqual({
|
||||
ok: true,
|
||||
value: { file: { id: 3 } },
|
||||
});
|
||||
expect(await t.service.applyChange('create_place', at, {})).toEqual({
|
||||
ok: false,
|
||||
value: 'Unknown tool: create_place',
|
||||
});
|
||||
});
|
||||
|
||||
it('ASTATT-010: a shared file is read for the model only; one not shared yet is not read', async () => {
|
||||
|
||||
@@ -317,6 +317,41 @@ describe('toChatHistory', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('ASTHIS-005f: keeping an attached file is told after its step, whatever became of it', () => {
|
||||
const keep = (extra: Partial<Extract<AssistantMessagePart, { type: 'tool-call' }>>) =>
|
||||
toChatHistory(
|
||||
[
|
||||
row('user', [{ type: 'text', text: 'Keep it in Files' }]),
|
||||
row('assistant', [
|
||||
{ type: 'text', text: 'Here is the card.' },
|
||||
{
|
||||
type: 'tool-call',
|
||||
toolCallId: 'k',
|
||||
toolName: 'keep_attached_file',
|
||||
args: { attachmentId: 'a', name: 'a.pdf' },
|
||||
...extra,
|
||||
},
|
||||
]),
|
||||
],
|
||||
new Map(),
|
||||
);
|
||||
const kept = keep({ approval: { id: 'k', approved: true }, result: { file: { id: 31 } } });
|
||||
expect(kept.map((message) => message.role)).toEqual(['user', 'assistant', 'user']);
|
||||
expect((kept[2] as { text: string }).text).toBe(
|
||||
'The attached file "a.pdf" is in the trip\'s Files now (file id 31).',
|
||||
);
|
||||
const lastOf = (extra: Parameters<typeof keep>[0]) => (keep(extra).at(-1) as { text: string }).text;
|
||||
expect(
|
||||
lastOf({ approval: { id: 'k', approved: true }, result: 'The file could not be kept.', isError: true }),
|
||||
).toBe('The attached file "a.pdf" could not be kept in Files: The file could not be kept.');
|
||||
expect(lastOf({ approval: { id: 'k', approved: false } })).toBe(
|
||||
'The user did not keep the attached file "a.pdf" in Files.',
|
||||
);
|
||||
expect(lastOf({ result: 'No such attachment in this conversation.', isError: true })).toContain('was refused');
|
||||
expect(lastOf({ approval: { id: 'k', resolution: 'cancelled' } })).toContain('the user moved on without deciding');
|
||||
expect(lastOf({ approval: { id: 'k' } })).toContain('waits for the user to apply or discard it');
|
||||
});
|
||||
|
||||
it('ASTHIS-006: a place the user picked from the search results is told as a note, whatever became of it', () => {
|
||||
const found = (approval: AssistantToolApproval, isError = false) =>
|
||||
serverStepsNote([
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* keep_attached_file applied: the checks a Files upload runs (demo, trip,
|
||||
* rights, types, same-trip links), the bytes copied to a trip file of their
|
||||
* own, the rows written in one transaction, the broadcast after it, and a
|
||||
* second keep that answers the first file.
|
||||
*/
|
||||
import { AssistantAttachments } from '../../../../src/db/entities/AssistantAttachments.entity';
|
||||
import { AssistantKeepFileService } from '../../../../src/nest/assistant/assistant-keep-file.service';
|
||||
import { DemoService } from '../../../../src/nest/common/demo.service';
|
||||
import { UnitOfWork } from '../../../../src/nest/database/unit-of-work';
|
||||
import { AllowedFileTypesService } from '../../../../src/nest/files/allowed-file-types.service';
|
||||
import { FilesService } from '../../../../src/nest/files/files.service';
|
||||
import { McpToolGuardsService } from '../../../../src/nest/mcp-shared/mcp-tool-guards.service';
|
||||
import { StorageService } from '../../../../src/nest/storage/storage.service';
|
||||
import { TripAccessService } from '../../../../src/nest/trip-membership/trip-access.service';
|
||||
import { getRepositoryToken } from '@mikro-orm/nestjs';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import { Readable } from 'node:stream';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const logMock = vi.hoisted(() => ({ logInfo: vi.fn(), logError: vi.fn(), logWarn: vi.fn(), logDebug: vi.fn() }));
|
||||
vi.mock('../../../../src/nest/audit/audit-log.logger', () => logMock);
|
||||
|
||||
const ID = '0b7c2f0e-6f6d-4a8e-9b1a-3d2c1e0f9a87';
|
||||
const AT = { userId: 7, threadId: 4, tripId: 9 };
|
||||
|
||||
interface Opts {
|
||||
demo?: boolean;
|
||||
trip?: boolean;
|
||||
upload?: boolean;
|
||||
edit?: boolean;
|
||||
allowed?: string;
|
||||
foreign?: 'reservation_id' | 'budget_item_id' | null;
|
||||
row?: Record<string, unknown> | null;
|
||||
kept?: Record<string, unknown> | undefined;
|
||||
bytes?: Buffer | Error;
|
||||
failTx?: boolean;
|
||||
}
|
||||
|
||||
async function make(opts: Opts = {}) {
|
||||
const row =
|
||||
opts.row === undefined
|
||||
? {
|
||||
id: ID,
|
||||
trip_id: 9,
|
||||
name: 'AirBnB Buchung.pdf',
|
||||
mime: 'application/pdf',
|
||||
storage_key: `assistant/${ID}.pdf`,
|
||||
file_id: null,
|
||||
}
|
||||
: opts.row;
|
||||
const rows = { findInThread: vi.fn(async () => row), setFile: vi.fn(async () => undefined) };
|
||||
const storage = {
|
||||
getStream: vi.fn(async () => {
|
||||
if (opts.bytes instanceof Error) throw opts.bytes;
|
||||
return { stream: Readable.from([opts.bytes ?? Buffer.from('%PDF')]), stat: { key: 'k', size: 4, mtimeMs: 0 } };
|
||||
}),
|
||||
};
|
||||
const created = { id: 31, original_name: 'AirBnB Buchung.pdf', reservation_id: 5, place_id: null, url: '/x' };
|
||||
const files = {
|
||||
getFileById: vi.fn(async () => opts.kept),
|
||||
findForeignLinkTarget: vi.fn(async () => opts.foreign ?? null),
|
||||
storeBytes: vi.fn(async () => 'stored.pdf'),
|
||||
discardBytes: vi.fn(async () => undefined),
|
||||
createFile: vi.fn(async () => {
|
||||
if (opts.failTx) throw new Error('disk I/O error');
|
||||
return created;
|
||||
}),
|
||||
broadcast: vi.fn(),
|
||||
};
|
||||
const guards = {
|
||||
hasTripPermission: vi.fn(async (action: string) =>
|
||||
action === 'file_upload' ? opts.upload !== false : opts.edit !== false,
|
||||
),
|
||||
};
|
||||
const uow = { transactional: vi.fn(async (fn: () => Promise<unknown>) => fn()) };
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
providers: [
|
||||
AssistantKeepFileService,
|
||||
{ provide: getRepositoryToken(AssistantAttachments), useValue: rows },
|
||||
{ provide: StorageService, useValue: storage },
|
||||
{ provide: FilesService, useValue: files },
|
||||
{ provide: AllowedFileTypesService, useValue: { get: vi.fn(async () => opts.allowed ?? 'pdf,txt') } },
|
||||
{ provide: McpToolGuardsService, useValue: guards },
|
||||
{ provide: DemoService, useValue: { isDemoUserId: vi.fn(async () => opts.demo === true) } },
|
||||
{
|
||||
provide: TripAccessService,
|
||||
useValue: { findAccessible: vi.fn(async () => (opts.trip === false ? undefined : { id: 9 })) },
|
||||
},
|
||||
{ provide: UnitOfWork, useValue: uow },
|
||||
],
|
||||
}).compile();
|
||||
return { service: moduleRef.get(AssistantKeepFileService), rows, files, guards, uow, storage };
|
||||
}
|
||||
|
||||
describe('AssistantKeepFileService', () => {
|
||||
it('ASTKEP-001: keeps the file as a trip file with its booking, in one transaction, and tells the trip after it', async () => {
|
||||
const t = await make();
|
||||
const run = await t.service.keep(AT, { attachmentId: ID, reservation_id: 5, name: 'ignored.exe' });
|
||||
expect(run).toEqual({
|
||||
ok: true,
|
||||
value: { file: { id: 31, name: 'AirBnB Buchung.pdf', reservation_id: 5, place_id: null, budget_item_id: null } },
|
||||
});
|
||||
expect(t.rows.findInThread).toHaveBeenCalledWith(ID, 4, 7);
|
||||
expect(t.files.storeBytes).toHaveBeenCalledWith({
|
||||
originalname: 'AirBnB Buchung.pdf',
|
||||
mimetype: 'application/pdf',
|
||||
bytes: Buffer.from('%PDF'),
|
||||
});
|
||||
expect(t.files.createFile).toHaveBeenCalledWith(
|
||||
9,
|
||||
{ filename: 'stored.pdf', originalname: 'AirBnB Buchung.pdf', size: 4, mimetype: 'application/pdf' },
|
||||
7,
|
||||
{ reservation_id: 5, place_id: null, budget_item_id: null, description: null },
|
||||
);
|
||||
expect(t.rows.setFile).toHaveBeenCalledWith(ID, 31);
|
||||
expect(t.uow.transactional).toHaveBeenCalledTimes(1);
|
||||
expect(t.files.broadcast).toHaveBeenCalledWith(
|
||||
'9',
|
||||
'file:created',
|
||||
{ file: expect.objectContaining({ id: 31 }) },
|
||||
undefined,
|
||||
);
|
||||
expect(t.guards.hasTripPermission).toHaveBeenCalledWith('file_edit', 9, 7);
|
||||
});
|
||||
|
||||
it('ASTKEP-002: an expense link and a description go along; a keep without a link asks no edit right', async () => {
|
||||
const t = await make();
|
||||
await t.service.keep(AT, { attachmentId: ID, budget_item_id: 12, description: 'Receipt' });
|
||||
expect(t.files.createFile.mock.calls[0][3]).toEqual({
|
||||
reservation_id: null,
|
||||
place_id: null,
|
||||
budget_item_id: 12,
|
||||
description: 'Receipt',
|
||||
});
|
||||
const plain = await make({ edit: false });
|
||||
expect((await plain.service.keep(AT, { attachmentId: ID })).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('ASTKEP-003: refused for a demo account, off the trip, without the right to upload or to link', async () => {
|
||||
const refused = async (opts: Opts, args: Record<string, unknown> = { attachmentId: ID }) => {
|
||||
const t = await make(opts);
|
||||
const run = await t.service.keep(AT, args);
|
||||
expect(t.files.storeBytes).not.toHaveBeenCalled();
|
||||
return run;
|
||||
};
|
||||
const notAllowed = { ok: false, value: 'You are not allowed to keep files on this trip.' };
|
||||
expect(await refused({ demo: true })).toEqual(notAllowed);
|
||||
expect(await refused({ trip: false })).toEqual(notAllowed);
|
||||
expect(await refused({ upload: false })).toEqual(notAllowed);
|
||||
expect(await refused({ edit: false }, { attachmentId: ID, place_id: 3 })).toEqual({
|
||||
ok: false,
|
||||
value: 'You are not allowed to attach files to bookings, expenses or places on this trip.',
|
||||
});
|
||||
});
|
||||
|
||||
it('ASTKEP-004: an attachment of no one here, a type Files refuses and a link off the trip are refused', async () => {
|
||||
const run = async (opts: Opts, args: Record<string, unknown> = { attachmentId: ID }) =>
|
||||
(await make(opts)).service.keep(AT, args);
|
||||
const none = { ok: false, value: 'No such attachment in this conversation.' };
|
||||
expect(await run({ row: null })).toEqual(none);
|
||||
expect(await run({ row: { id: ID, trip_id: 99, name: 'a.pdf', mime: 'application/pdf', file_id: null } })).toEqual(
|
||||
none,
|
||||
);
|
||||
expect(await run({}, { attachmentId: 'nope' })).toEqual({ ok: false, value: 'This change can no longer be read.' });
|
||||
expect(await run({ allowed: 'jpg' })).toEqual({
|
||||
ok: false,
|
||||
value: 'This file type is not allowed on this TREK instance.',
|
||||
});
|
||||
expect(await run({ foreign: 'reservation_id' }, { attachmentId: ID, reservation_id: 404 })).toEqual({
|
||||
ok: false,
|
||||
value: 'Linked item does not belong to this trip (reservation_id).',
|
||||
});
|
||||
});
|
||||
|
||||
it('ASTKEP-005: keeping the same file twice answers the first one; a trashed one is kept again', async () => {
|
||||
const first = { id: 31, original_name: 'AirBnB Buchung.pdf', reservation_id: 5, place_id: null, deleted_at: null };
|
||||
const row = {
|
||||
id: ID,
|
||||
trip_id: 9,
|
||||
name: 'AirBnB Buchung.pdf',
|
||||
mime: 'application/pdf',
|
||||
file_id: 31,
|
||||
storage_key: 'k',
|
||||
};
|
||||
const again = await make({ row, kept: first });
|
||||
expect(await again.service.keep(AT, { attachmentId: ID })).toEqual({
|
||||
ok: true,
|
||||
value: { file: { id: 31, name: 'AirBnB Buchung.pdf', reservation_id: 5, place_id: null, budget_item_id: null } },
|
||||
});
|
||||
expect(again.files.storeBytes).not.toHaveBeenCalled();
|
||||
const trashed = await make({ row, kept: { ...first, deleted_at: '2026-10-24 09:00:00' } });
|
||||
await trashed.service.keep(AT, { attachmentId: ID });
|
||||
expect(trashed.files.createFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ASTKEP-006: bytes that are gone or too large keep nothing; a failed transaction drops the copied bytes', async () => {
|
||||
const gone = await make({ bytes: new Error('NoSuchKey') });
|
||||
expect(await gone.service.keep(AT, { attachmentId: ID })).toEqual({
|
||||
ok: false,
|
||||
value: 'The attached file is no longer kept, so it cannot be put into Files.',
|
||||
});
|
||||
const huge = await make({ bytes: Buffer.alloc(10 * 1024 * 1024 + 1) });
|
||||
expect((await huge.service.keep(AT, { attachmentId: ID })).ok).toBe(false);
|
||||
const failing = await make({ failTx: true });
|
||||
expect(await failing.service.keep(AT, { attachmentId: ID })).toEqual({
|
||||
ok: false,
|
||||
value: 'The file could not be kept.',
|
||||
});
|
||||
expect(failing.files.discardBytes).toHaveBeenCalledWith('stored.pdf');
|
||||
expect(failing.rows.setFile).not.toHaveBeenCalled();
|
||||
expect(failing.files.broadcast).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -360,6 +360,57 @@ describe('describeToolCall', () => {
|
||||
const note = describe_('remember_for_trip', { note: ' We travel as two. ' });
|
||||
expect(note).toMatchObject({ verbKey: 'rememberNote', subject: 'We travel as two.', rows: [] });
|
||||
});
|
||||
|
||||
it('ASTPRV-023: an attached file is read by its name and where its text goes, kept by what it goes with', () => {
|
||||
const read = describe_('read_attached_file', {
|
||||
attachmentId: 'a',
|
||||
name: 'AirBnB Buchung.pdf',
|
||||
sentTo: 'gpt-5 (api.openai.com)',
|
||||
});
|
||||
expect(read).toMatchObject({
|
||||
verbKey: 'readAttachment',
|
||||
subject: 'AirBnB Buchung.pdf',
|
||||
rows: [{ op: 'add', labelKey: 'sentTo', before: null, after: 'gpt-5 (api.openai.com)' }],
|
||||
undoable: false,
|
||||
});
|
||||
const tripCard = buildTripCard(
|
||||
1,
|
||||
7,
|
||||
{ reservations: [{ id: 5, title: 'Casa Lisboa' }], budget: { items: [{ id: 12, name: 'Deposit' }] } },
|
||||
{ places: [{ id: 12, name: 'Nishiki Market', lat: 35, lng: 135 }] },
|
||||
null,
|
||||
);
|
||||
const kept = describeToolCall(
|
||||
'keep_attached_file',
|
||||
'tc-2',
|
||||
{
|
||||
attachmentId: 'a',
|
||||
name: 'AirBnB Buchung.pdf',
|
||||
reservation_id: 5,
|
||||
budget_item_id: 12,
|
||||
place_id: 12,
|
||||
description: 'Stay',
|
||||
},
|
||||
{ ...ctx, card: tripCard },
|
||||
);
|
||||
expect(kept).toMatchObject({ verbKey: 'keepFile', subject: 'AirBnB Buchung.pdf', placeIds: [12], undoable: false });
|
||||
expect(kept.rows).toEqual([
|
||||
{ op: 'add', labelKey: 'attachedTo', before: null, after: 'Casa Lisboa, Deposit, Nishiki Market' },
|
||||
{ op: 'add', labelKey: 'description', before: null, after: 'Stay' },
|
||||
]);
|
||||
const unknown = describe_('keep_attached_file', {
|
||||
attachmentId: 'a',
|
||||
name: 'x.pdf',
|
||||
reservation_id: 77,
|
||||
budget_item_id: 78,
|
||||
});
|
||||
expect(unknown.rows[0]).toMatchObject({ labelKey: 'attachedTo', after: '#77, #78' });
|
||||
expect(describe_('keep_attached_file', { attachmentId: 'a', name: 'x.pdf' }).rows).toEqual([]);
|
||||
expect(appliedChange('keep_attached_file', 'tc', {}, { file: { id: 31 } }, null, null)).toMatchObject({
|
||||
entity: 'file',
|
||||
ids: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('appliedChange', () => {
|
||||
|
||||
@@ -93,8 +93,10 @@ const { consumeEphemeralToken } = vi.hoisted(() => ({ consumeEphemeralToken: vi.
|
||||
vi.mock('../../../src/nest/auth-core/ephemeral-tokens', () => ({ consumeEphemeralToken }));
|
||||
|
||||
const storageDelete = vi.fn();
|
||||
const storagePut = vi.fn(async () => undefined);
|
||||
const storageStub = {
|
||||
delete: storageDelete,
|
||||
put: storagePut,
|
||||
} as unknown as import('../../../src/nest/storage/storage.service').StorageService;
|
||||
// EntityManager stub (Plan 3b Task 1 RULING): verifyJwtAndLoadUser is
|
||||
// fully mocked above, so `em.getRepository` never needs to return
|
||||
@@ -448,6 +450,18 @@ describe('createFile', () => {
|
||||
expect(file.uploaded_by).toBe(user.id);
|
||||
});
|
||||
|
||||
it('FILE-SVC-061: bytes for a row written elsewhere go under a fresh key with their extension, and can be dropped', async () => {
|
||||
const filename = await svc.storeBytes({
|
||||
originalname: 'AirBnB Buchung.pdf',
|
||||
mimetype: 'application/pdf',
|
||||
bytes: Buffer.from('x'),
|
||||
});
|
||||
expect(filename).toMatch(/^[0-9a-f-]{36}\.pdf$/);
|
||||
expect(storagePut).toHaveBeenCalledWith('files', filename, expect.anything(), { contentType: 'application/pdf' });
|
||||
await svc.discardBytes(filename);
|
||||
expect(storageDelete).toHaveBeenCalledWith('files', filename);
|
||||
});
|
||||
|
||||
it('FILE-SVC-060 (R2): a failing file_links insert rolls back the trip_files row too', async () => {
|
||||
const { user, trip } = seedTrip();
|
||||
const item = await insertBudgetItem(trip.id, 'Dinner');
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { assistantAttachmentIdSchema, assistantReadAttachedFileArgsSchema } from './assistant-attachments.schema';
|
||||
import {
|
||||
assistantAttachmentIdSchema,
|
||||
assistantKeepFileArgsSchema,
|
||||
assistantReadAttachedFileArgsSchema,
|
||||
} from './assistant-attachments.schema';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -18,4 +22,13 @@ describe('assistant attachments contract', () => {
|
||||
});
|
||||
expect(assistantReadAttachedFileArgsSchema.safeParse({}).success).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a file with plain ids of the trip, never a string or a negative id, and a short description', () => {
|
||||
const kept = { attachmentId: ID, reservation_id: 5, budget_item_id: 7, place_id: 9, description: 'Airbnb' };
|
||||
expect(assistantKeepFileArgsSchema.parse({ ...kept, name: 'x.pdf' })).toEqual(kept);
|
||||
expect(assistantKeepFileArgsSchema.parse({ attachmentId: ID })).toEqual({ attachmentId: ID });
|
||||
for (const bad of [{ reservation_id: '5' }, { budget_item_id: -1 }, { description: 'x'.repeat(1001) }]) {
|
||||
expect(assistantKeepFileArgsSchema.safeParse({ attachmentId: ID, ...bad }).success).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { idSchema } from '../common/primitives.schema';
|
||||
import { fileUpdateRequestSchema } from '../file/file.schema';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
@@ -20,3 +23,18 @@ export const assistantReadAttachedFileArgsSchema = z.object({
|
||||
attachmentId: assistantAttachmentIdSchema,
|
||||
});
|
||||
export type AssistantReadAttachedFileArgs = z.infer<typeof assistantReadAttachedFileArgsSchema>;
|
||||
|
||||
/**
|
||||
* What the model calls `keep_attached_file` with: the attachment, and what
|
||||
* of the trip the kept file is attached to, the fields a trip file's update
|
||||
* takes (`fileUpdateRequestSchema`) as plain ids. Every id must name a row of
|
||||
* the same trip; the server checks that when the user applies the change.
|
||||
*/
|
||||
export const assistantKeepFileArgsSchema = z.object({
|
||||
attachmentId: assistantAttachmentIdSchema,
|
||||
reservation_id: idSchema.optional(),
|
||||
budget_item_id: idSchema.optional(),
|
||||
place_id: idSchema.optional(),
|
||||
description: fileUpdateRequestSchema.shape.description.unwrap().max(1000).optional(),
|
||||
});
|
||||
export type AssistantKeepFileArgs = z.infer<typeof assistantKeepFileArgsSchema>;
|
||||
|
||||
@@ -105,6 +105,8 @@ export const CORE_VERB_KEYS = [
|
||||
'rememberNote',
|
||||
// The text of a file attached in the chat, sent to the AI provider once the user agrees.
|
||||
'readAttachment',
|
||||
// A file attached in the chat, kept in the trip's Files, on its own or with a booking, an expense or a place.
|
||||
'keepFile',
|
||||
// A tour: a hike or a city walk created, a hike changed, tours taken in from a GPX file.
|
||||
'addTour',
|
||||
'updateTour',
|
||||
@@ -183,6 +185,8 @@ export const ASSISTANT_CHANGE_LABEL_KEYS = [
|
||||
'field',
|
||||
// Where the text of an attached file goes once the user agrees: the model and its host.
|
||||
'sentTo',
|
||||
// The booking, expense or place a kept file is attached to.
|
||||
'attachedTo',
|
||||
] as const;
|
||||
export const assistantChangeLabelKeySchema = z.enum(ASSISTANT_CHANGE_LABEL_KEYS);
|
||||
export type AssistantChangeLabelKey = z.infer<typeof assistantChangeLabelKeySchema>;
|
||||
@@ -419,6 +423,8 @@ export const CORE_APPLIED_ENTITIES = [
|
||||
'reservation',
|
||||
'memory',
|
||||
'tour',
|
||||
// A file kept in the trip's Files.
|
||||
'file',
|
||||
'other',
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -574,5 +574,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.readAttachment': 'اسمح للمساعد بقراءة الملف',
|
||||
'assistant.change.label.sentTo': 'يُرسل إلى',
|
||||
'assistant.step.readingAttachment': 'جارٍ قراءة الملف',
|
||||
'assistant.change.verb.keepFile': 'احفظ الملف في الملفات',
|
||||
'assistant.change.label.attachedTo': 'مرفق بـ',
|
||||
'assistant.card.file.open': 'افتح في الملفات',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -492,5 +492,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.readAttachment': 'Köməkçiyə faylı oxumağa icazə ver',
|
||||
'assistant.change.label.sentTo': 'Göndərilir',
|
||||
'assistant.step.readingAttachment': 'Fayl oxunur',
|
||||
'assistant.change.verb.keepFile': 'Faylı Fayllarda saxla',
|
||||
'assistant.change.label.attachedTo': 'Əlavə olunduğu yer',
|
||||
'assistant.card.file.open': 'Fayllarda aç',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -493,5 +493,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.readAttachment': 'Deixar o assistente ler o arquivo',
|
||||
'assistant.change.label.sentTo': 'Enviado para',
|
||||
'assistant.step.readingAttachment': 'Lendo o arquivo',
|
||||
'assistant.change.verb.keepFile': 'Guardar o arquivo em Arquivos',
|
||||
'assistant.change.label.attachedTo': 'Anexado a',
|
||||
'assistant.card.file.open': 'Abrir em Arquivos',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -495,5 +495,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.readAttachment': 'Deixa que l’assistent llegeixi el fitxer',
|
||||
'assistant.change.label.sentTo': 'S’envia a',
|
||||
'assistant.step.readingAttachment': 'S’està llegint el fitxer',
|
||||
'assistant.change.verb.keepFile': 'Desa el fitxer a Fitxers',
|
||||
'assistant.change.label.attachedTo': 'Adjunt a',
|
||||
'assistant.card.file.open': 'Obre a Fitxers',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -512,5 +512,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.readAttachment': 'Nechat asistenta přečíst soubor',
|
||||
'assistant.change.label.sentTo': 'Odesílá se do',
|
||||
'assistant.step.readingAttachment': 'Čtu soubor',
|
||||
'assistant.change.verb.keepFile': 'Uložit soubor do Souborů',
|
||||
'assistant.change.label.attachedTo': 'Připojeno k',
|
||||
'assistant.card.file.open': 'Otevřít v Souborech',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -493,5 +493,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.readAttachment': 'Den Assistenten die Datei lesen lassen',
|
||||
'assistant.change.label.sentTo': 'Geht an',
|
||||
'assistant.step.readingAttachment': 'Lese die Datei',
|
||||
'assistant.change.verb.keepFile': 'Datei in Dateien ablegen',
|
||||
'assistant.change.label.attachedTo': 'Angehängt an',
|
||||
'assistant.card.file.open': 'In Dateien öffnen',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -487,5 +487,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.readAttachment': 'Let the assistant read the file',
|
||||
'assistant.change.label.sentTo': 'Sent to',
|
||||
'assistant.step.readingAttachment': 'Reading the file',
|
||||
'assistant.change.verb.keepFile': 'Keep the file in Files',
|
||||
'assistant.change.label.attachedTo': 'Attached to',
|
||||
'assistant.card.file.open': 'Open in Files',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -491,5 +491,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.readAttachment': 'Dejar que el asistente lea el archivo',
|
||||
'assistant.change.label.sentTo': 'Se envía a',
|
||||
'assistant.step.readingAttachment': 'Leyendo el archivo',
|
||||
'assistant.change.verb.keepFile': 'Guardar el archivo en Archivos',
|
||||
'assistant.change.label.attachedTo': 'Adjunto a',
|
||||
'assistant.card.file.open': 'Abrir en Archivos',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -492,5 +492,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.readAttachment': 'Luba assistendil fail läbi lugeda',
|
||||
'assistant.change.label.sentTo': 'Saadetakse',
|
||||
'assistant.step.readingAttachment': 'Loen faili',
|
||||
'assistant.change.verb.keepFile': 'Salvesta fail failidesse',
|
||||
'assistant.change.label.attachedTo': 'Lisatud juurde',
|
||||
'assistant.card.file.open': 'Ava failides',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -498,5 +498,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.readAttachment': 'Laisser l’assistant lire le fichier',
|
||||
'assistant.change.label.sentTo': 'Envoyé à',
|
||||
'assistant.step.readingAttachment': 'Lecture du fichier',
|
||||
'assistant.change.verb.keepFile': 'Garder le fichier dans Fichiers',
|
||||
'assistant.change.label.attachedTo': 'Joint à',
|
||||
'assistant.card.file.open': 'Ouvrir dans Fichiers',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -496,5 +496,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.readAttachment': 'Άφησε τον βοηθό να διαβάσει το αρχείο',
|
||||
'assistant.change.label.sentTo': 'Αποστέλλεται σε',
|
||||
'assistant.step.readingAttachment': 'Ανάγνωση του αρχείου',
|
||||
'assistant.change.verb.keepFile': 'Αποθήκευση του αρχείου στα Αρχεία',
|
||||
'assistant.change.label.attachedTo': 'Επισυνάπτεται σε',
|
||||
'assistant.card.file.open': 'Άνοιγμα στα Αρχεία',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -496,5 +496,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.readAttachment': 'Engedd, hogy az asszisztens elolvassa a fájlt',
|
||||
'assistant.change.label.sentTo': 'Címzett',
|
||||
'assistant.step.readingAttachment': 'A fájl olvasása',
|
||||
'assistant.change.verb.keepFile': 'A fájl mentése a Fájlok közé',
|
||||
'assistant.change.label.attachedTo': 'Csatolva ehhez',
|
||||
'assistant.card.file.open': 'Megnyitás a Fájlokban',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -471,5 +471,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.readAttachment': 'Izinkan asisten membaca berkas',
|
||||
'assistant.change.label.sentTo': 'Dikirim ke',
|
||||
'assistant.step.readingAttachment': 'Membaca berkas',
|
||||
'assistant.change.verb.keepFile': 'Simpan berkas di Berkas',
|
||||
'assistant.change.label.attachedTo': 'Dilampirkan ke',
|
||||
'assistant.card.file.open': 'Buka di Berkas',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -499,5 +499,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.readAttachment': 'Lascia che l’assistente legga il file',
|
||||
'assistant.change.label.sentTo': 'Inviato a',
|
||||
'assistant.step.readingAttachment': 'Lettura del file',
|
||||
'assistant.change.verb.keepFile': 'Salva il file in File',
|
||||
'assistant.change.label.attachedTo': 'Allegato a',
|
||||
'assistant.card.file.open': 'Apri in File',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -465,5 +465,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.readAttachment': 'アシスタントにファイルを読ませる',
|
||||
'assistant.change.label.sentTo': '送信先',
|
||||
'assistant.step.readingAttachment': 'ファイルを読んでいます',
|
||||
'assistant.change.verb.keepFile': 'ファイルに保存',
|
||||
'assistant.change.label.attachedTo': '添付先',
|
||||
'assistant.card.file.open': 'ファイルで開く',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -462,5 +462,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.readAttachment': '어시스턴트가 파일을 읽도록 허용',
|
||||
'assistant.change.label.sentTo': '전송 대상',
|
||||
'assistant.step.readingAttachment': '파일을 읽는 중',
|
||||
'assistant.change.verb.keepFile': '파일에 보관',
|
||||
'assistant.change.label.attachedTo': '첨부 대상',
|
||||
'assistant.card.file.open': '파일에서 열기',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -494,5 +494,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.readAttachment': 'De assistent het bestand laten lezen',
|
||||
'assistant.change.label.sentTo': 'Gaat naar',
|
||||
'assistant.step.readingAttachment': 'Bestand lezen',
|
||||
'assistant.change.verb.keepFile': 'Bestand bewaren in Bestanden',
|
||||
'assistant.change.label.attachedTo': 'Gekoppeld aan',
|
||||
'assistant.card.file.open': 'Openen in Bestanden',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -537,5 +537,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.readAttachment': 'Pozwól asystentowi przeczytać plik',
|
||||
'assistant.change.label.sentTo': 'Trafia do',
|
||||
'assistant.step.readingAttachment': 'Czytam plik',
|
||||
'assistant.change.verb.keepFile': 'Zapisz plik w Plikach',
|
||||
'assistant.change.label.attachedTo': 'Dołączono do',
|
||||
'assistant.card.file.open': 'Otwórz w Plikach',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -538,5 +538,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.readAttachment': 'Разрешить ассистенту прочитать файл',
|
||||
'assistant.change.label.sentTo': 'Отправляется в',
|
||||
'assistant.step.readingAttachment': 'Читаю файл',
|
||||
'assistant.change.verb.keepFile': 'Сохранить файл в Файлы',
|
||||
'assistant.change.label.attachedTo': 'Прикреплён к',
|
||||
'assistant.card.file.open': 'Открыть в Файлах',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -512,5 +512,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.readAttachment': 'Nechať asistenta prečítať súbor',
|
||||
'assistant.change.label.sentTo': 'Odosiela sa do',
|
||||
'assistant.step.readingAttachment': 'Čítam súbor',
|
||||
'assistant.change.verb.keepFile': 'Uložiť súbor do Súborov',
|
||||
'assistant.change.label.attachedTo': 'Pripojené k',
|
||||
'assistant.card.file.open': 'Otvoriť v Súboroch',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -492,5 +492,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.readAttachment': 'Låt assistenten läsa filen',
|
||||
'assistant.change.label.sentTo': 'Skickas till',
|
||||
'assistant.step.readingAttachment': 'Läser filen',
|
||||
'assistant.change.verb.keepFile': 'Spara filen i Filer',
|
||||
'assistant.change.label.attachedTo': 'Bifogad till',
|
||||
'assistant.card.file.open': 'Öppna i Filer',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -463,5 +463,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.readAttachment': 'อนุญาตให้ผู้ช่วยอ่านไฟล์',
|
||||
'assistant.change.label.sentTo': 'ส่งไปยัง',
|
||||
'assistant.step.readingAttachment': 'กำลังอ่านไฟล์',
|
||||
'assistant.change.verb.keepFile': 'เก็บไฟล์ไว้ในไฟล์',
|
||||
'assistant.change.label.attachedTo': 'แนบกับ',
|
||||
'assistant.card.file.open': 'เปิดในไฟล์',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -492,5 +492,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.readAttachment': 'Asistanın dosyayı okumasına izin ver',
|
||||
'assistant.change.label.sentTo': 'Gönderileceği yer',
|
||||
'assistant.step.readingAttachment': 'Dosya okunuyor',
|
||||
'assistant.change.verb.keepFile': 'Dosyayı Dosyalar’a kaydet',
|
||||
'assistant.change.label.attachedTo': 'Eklendiği yer',
|
||||
'assistant.card.file.open': 'Dosyalar’da aç',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -536,5 +536,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.readAttachment': 'Дозволити асистентові прочитати файл',
|
||||
'assistant.change.label.sentTo': 'Надсилається до',
|
||||
'assistant.step.readingAttachment': 'Читаю файл',
|
||||
'assistant.change.verb.keepFile': 'Зберегти файл у Файлах',
|
||||
'assistant.change.label.attachedTo': 'Прикріплено до',
|
||||
'assistant.card.file.open': 'Відкрити у Файлах',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -466,5 +466,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.readAttachment': 'Cho phép trợ lý đọc tệp',
|
||||
'assistant.change.label.sentTo': 'Gửi đến',
|
||||
'assistant.step.readingAttachment': 'Đang đọc tệp',
|
||||
'assistant.change.verb.keepFile': 'Lưu tệp vào Tệp',
|
||||
'assistant.change.label.attachedTo': 'Đính kèm vào',
|
||||
'assistant.card.file.open': 'Mở trong Tệp',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -457,5 +457,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.readAttachment': '允許助理讀取檔案',
|
||||
'assistant.change.label.sentTo': '傳送至',
|
||||
'assistant.step.readingAttachment': '正在讀取檔案',
|
||||
'assistant.change.verb.keepFile': '將檔案儲存到檔案',
|
||||
'assistant.change.label.attachedTo': '附加到',
|
||||
'assistant.card.file.open': '在檔案中開啟',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
@@ -457,5 +457,8 @@ const assistant: TranslationStrings = {
|
||||
'assistant.change.verb.readAttachment': '允许助手读取文件',
|
||||
'assistant.change.label.sentTo': '发送至',
|
||||
'assistant.step.readingAttachment': '正在读取文件',
|
||||
'assistant.change.verb.keepFile': '把文件保存到文件',
|
||||
'assistant.change.label.attachedTo': '附加到',
|
||||
'assistant.card.file.open': '在文件中打开',
|
||||
};
|
||||
export default assistant;
|
||||
|
||||
Reference in New Issue
Block a user