mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 15:51: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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user