mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
feat(assistant): expenses, shares, wind, journey tracks and Dawarich checks as MCP tools
- list_budget_items lists a trip's expenses with their ids, payers and splits, page by page so no page is cut down on its way to a model; get_budget_per_person answers who paid and owes what. Both in a new budget-items.mcp.ts, checked like their REST twins. - get_wind_forecast reads the hourly wind, gusts and direction of a day. - get_journey_tracks lists a journey's GPX tracks; the line for drawing rides under a key the provider never receives. - The Dawarich wishlist scan and the countries the recordings suggest become tools, with their confirming writes, in DawarichAtlasMcp in the Atlas group; the shared time window check refuses alike on REST and MCP, and the model never sees when the user was last seen in a city. - The journal's map, tracks and recorded days load with the journey group instead of riding in the core of every step. - The prompt points at list_budget_items for expense ids (the trip summary never carried them) and names the shares and the wind tools; each new read has its own live step.
This commit is contained in:
@@ -108,8 +108,26 @@ function projectTripSummary(summary: Json): Json {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `suggest_atlas_countries` as the model gets it: each country with its
|
||||
* cities and the minutes spent in them, without when the user was last seen
|
||||
* in each city. The model offers countries to mark; a time per city would
|
||||
* hand the provider a dated trail of the user's whereabouts for nothing it
|
||||
* plans with.
|
||||
*/
|
||||
function projectAtlasSuggestions(value: Json): Json {
|
||||
if (!isRecord(value) || !Array.isArray(value.countries)) return value;
|
||||
return {
|
||||
...value,
|
||||
countries: value.countries.map((country) =>
|
||||
isRecord(country) ? { ...country, cities: pickAll(country.cities, ['name', 'minutes']) } : country,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const PROJECTIONS: Readonly<Record<string, (value: Json) => Json>> = {
|
||||
get_trip_summary: projectTripSummary,
|
||||
suggest_atlas_countries: projectAtlasSuggestions,
|
||||
};
|
||||
|
||||
function shrink(value: Json, cap: number, cut: { done: boolean }): Json {
|
||||
|
||||
@@ -143,14 +143,28 @@ export const ASSISTANT_TOOL_GROUPS: Readonly<Record<string, { about: string; con
|
||||
],
|
||||
},
|
||||
journey: {
|
||||
about: 'Journeys (the travel journal): entries, photos, the journal linked to this trip, location history.',
|
||||
controllers: ['JourneyMcp', 'TripJournalLinkMcp', 'MemoriesMcp', 'DawarichMcp'],
|
||||
about:
|
||||
'Journeys (the travel journal): entries, photos, the journal linked to this trip, its map and GPX tracks, ' +
|
||||
'location history and the recorded days.',
|
||||
controllers: [
|
||||
'JourneyMcp',
|
||||
'JourneyMapMcp',
|
||||
'JourneyTracksMcp',
|
||||
'JourneyRecordingsMcp',
|
||||
'JourneyDayImportMcp',
|
||||
'TripJournalLinkMcp',
|
||||
'MemoriesMcp',
|
||||
'DawarichMcp',
|
||||
],
|
||||
},
|
||||
vacay: {
|
||||
about: 'Vacation planner: vacation days per year, public and school holidays, shared calendars.',
|
||||
controllers: ['VacayMcp', 'SchoolHolidaysMcp'],
|
||||
},
|
||||
atlas: { about: 'Atlas: visited countries and regions, the bucket list.', controllers: ['AtlasMcp'] },
|
||||
atlas: {
|
||||
about: 'Atlas: visited countries and regions, the bucket list, and both checked against the Dawarich recordings.',
|
||||
controllers: ['AtlasMcp', 'DawarichAtlasMcp'],
|
||||
},
|
||||
collections: {
|
||||
about: 'Collections: saved places across trips, their labels and ratings.',
|
||||
controllers: ['CollectionsMcp'],
|
||||
|
||||
@@ -29,7 +29,8 @@ 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 or delete the trip. If asked, say so in one sentence and where in TREK the user can do it.
|
||||
- When the user asks you to remember something for this trip (who travels, what they like or avoid), keep it with remember_for_trip; it comes back as <trip_notes> in every later conversation. Never say you cannot remember things.
|
||||
- Existing expenses can be changed with update_budget_item (their ids are in get_trip_summary); never add a second entry to correct one.
|
||||
- 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.
|
||||
- If the request is unclear, ask one short question instead of guessing.`;
|
||||
|
||||
/** The standing instructions, telling the model which language to answer in. */
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { noAccess } from '../../mcp/tools/_shared';
|
||||
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '../../nest-mcp';
|
||||
import type { BudgetItem } from '../../types';
|
||||
import { addonGate } from '../addons/addon-gate';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { BudgetService } from './budget.service';
|
||||
import { BUDGET_ITEMS_PAGE_DEFAULT, budgetItemsPageSchema, idSchema, type BudgetItemsPage } from '@trek/shared';
|
||||
|
||||
/** The whole budget surface rides the budget addon, as in BudgetMcp. */
|
||||
const budgetAddonOn = addonGate(ADDON_IDS.BUDGET);
|
||||
|
||||
/**
|
||||
* How long the expenses of one page may run as JSON. The assistant cuts a tool
|
||||
* answer at 40,000 characters, and a page cut there would leave its last rows
|
||||
* unread while next_offset skips past them. So a page ends early, before that
|
||||
* cut, and next_offset starts at the first expense it left out; the totals and
|
||||
* the names next to the page fit in what is left.
|
||||
*/
|
||||
const PAGE_CHARS = 30_000;
|
||||
|
||||
/** How much of an expense's note a page carries: enough to tell two similar expenses apart. */
|
||||
const NOTE_CHARS = 200;
|
||||
|
||||
/** A split or payer list the service always fills; an empty one when it did not. */
|
||||
function rows<T>(list: T[] | undefined): T[] {
|
||||
return list ?? [];
|
||||
}
|
||||
|
||||
function shortNote(note: string | null | undefined): string | null {
|
||||
const line = (note ?? '').replace(/\s+/g, ' ').trim();
|
||||
if (!line) return null;
|
||||
return line.length > NOTE_CHARS ? `${line.slice(0, NOTE_CHARS - 1)}…` : line;
|
||||
}
|
||||
|
||||
/**
|
||||
* One expense as the tool answers it: the fields a model plans with and a
|
||||
* card shows, without the avatars, receipts and itemised ticket the REST row
|
||||
* carries, and with the start of its note. Amounts stay in the expense's own
|
||||
* currency, which is the trip's when the row names none.
|
||||
*/
|
||||
function slimItem(item: BudgetItem, tripCurrency: string) {
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
category: item.category,
|
||||
total_price: item.total_price,
|
||||
currency: item.currency || tripCurrency,
|
||||
expense_date: item.expense_date ?? null,
|
||||
note: shortNote(item.note),
|
||||
place_id: item.place_id ?? null,
|
||||
reservation_id: item.reservation_id ?? null,
|
||||
payers: rows(item.payers).map((p) => ({ user_id: p.user_id, amount: p.amount })),
|
||||
members: rows(item.members).map((m) => ({ user_id: m.user_id, amount: m.amount ?? null, paid: m.paid === 1 })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The expenses a page answers: the ones asked for, in order, until their JSON
|
||||
* would pass PAGE_CHARS. Always at least one, so a page is never empty while
|
||||
* expenses are left.
|
||||
*/
|
||||
function fillPage(items: BudgetItem[], tripCurrency: string) {
|
||||
const page: ReturnType<typeof slimItem>[] = [];
|
||||
let chars = 0;
|
||||
for (const item of items) {
|
||||
const slim = slimItem(item, tripCurrency);
|
||||
chars += JSON.stringify(slim).length + 1;
|
||||
if (page.length > 0 && chars > PAGE_CHARS) break;
|
||||
page.push(slim);
|
||||
}
|
||||
return page;
|
||||
}
|
||||
|
||||
/** The names behind the user ids on a page, once each, so a split reads as people rather than numbers. */
|
||||
function peopleOf(items: BudgetItem[]) {
|
||||
const names = new Map<number, string>();
|
||||
for (const item of items) {
|
||||
for (const person of [...rows(item.members), ...rows(item.payers)]) {
|
||||
if (person.username && !names.has(person.user_id)) names.set(person.user_id, person.username);
|
||||
}
|
||||
}
|
||||
return [...names].sort(([a], [b]) => a - b).map(([user_id, username]) => ({ user_id, username }));
|
||||
}
|
||||
|
||||
/**
|
||||
* The budget's read tools that answer with the rows themselves: the expense
|
||||
* list (the twin of GET /api/trips/:tripId/budget) and the per-person totals
|
||||
* (GET …/budget/summary/per-person). Same service calls and the same trip
|
||||
* access as the routes, which ask for no permission beyond it; BudgetMcp has
|
||||
* the writes and the settlement. A page of the list is a projection small
|
||||
* enough for a model, with the totals of the whole budget next to it.
|
||||
*/
|
||||
@McpController()
|
||||
export class BudgetItemsMcp {
|
||||
constructor(
|
||||
private readonly budget: BudgetService,
|
||||
readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
@Tool({
|
||||
name: 'list_budget_items',
|
||||
description: `List the trip's expenses with their ids, up to ${BUDGET_ITEMS_PAGE_DEFAULT} at a time from offset. A page of long expenses ends earlier, so always go on at next_offset (null on the last page). Each expense: name, category, total_price in its currency, expense_date, the start of its note, the place or booking it belongs to (place_id, reservation_id), who paid how much (payers) and who shares it (members: amount is the member's share on an uneven split, null on an equal one; paid says whether they paid their share). people names the user ids. Next to the page, over all expenses: the trip currency, the total and the totals per category in it, and unconverted_item_ids, foreign expenses no exchange rate converts, which count in no total. Use the ids with update_budget_item and delete_budget_item.`,
|
||||
inputSchema: { tripId: idSchema, ...budgetItemsPageSchema.shape },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: budgetAddonOn,
|
||||
access: { group: 'budget', mode: 'read' },
|
||||
})
|
||||
async listBudgetItems({ tripId, offset, limit }: { tripId: number } & BudgetItemsPage, ctx: McpContext) {
|
||||
const trip = await this.budget.verifyTripAccess(tripId, ctx.userId);
|
||||
if (!trip) return noAccess();
|
||||
const currency = (trip.currency || 'EUR').toUpperCase();
|
||||
const items = await this.budget.listBudgetItems(tripId);
|
||||
// The figures get_trip_summary shows: each row at the rate it was booked at (#2525).
|
||||
const totals = await this.budget.tripTotals(
|
||||
tripId,
|
||||
currency,
|
||||
await this.budget.ratesForTripTotals(tripId, currency),
|
||||
);
|
||||
const start = offset ?? 0;
|
||||
const page = fillPage(items.slice(start, start + (limit ?? BUDGET_ITEMS_PAGE_DEFAULT)), currency);
|
||||
const end = start + page.length;
|
||||
return ok({
|
||||
currency,
|
||||
total: totals.total,
|
||||
by_category: totals.byCategory,
|
||||
unconverted_item_ids: totals.unconverted,
|
||||
item_count: items.length,
|
||||
offset: start,
|
||||
next_offset: end < items.length ? end : null,
|
||||
items: page,
|
||||
people: peopleOf(items.slice(start, end)),
|
||||
});
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'get_budget_per_person',
|
||||
description:
|
||||
"What each person on the trip's expenses owes in all and how much of that they paid, in the trip currency: per user total_assigned (their shares of the expenses they are part of), total_paid (the shares marked paid) and items_count. Each share is converted at the rate its expense was booked at; a foreign expense no exchange rate converts counts for nobody. For who has to pay whom to settle up, use get_settlement_summary.",
|
||||
inputSchema: { tripId: idSchema },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: budgetAddonOn,
|
||||
access: { group: 'budget', mode: 'read' },
|
||||
})
|
||||
async getBudgetPerPerson({ tripId }: { tripId: number }, ctx: McpContext) {
|
||||
if (!(await this.budget.verifyTripAccess(tripId, ctx.userId))) return noAccess();
|
||||
return ok({ summary: await this.budget.perPersonSummary(tripId) });
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { RealtimeModule } from '../realtime/realtime.module';
|
||||
import { TripMembershipModule } from '../trip-membership/trip-membership.module';
|
||||
import { BudgetItemsMcp } from './budget-items.mcp';
|
||||
import { BudgetController } from './budget.controller';
|
||||
import { BudgetMcp } from './budget.mcp';
|
||||
import { BudgetService } from './budget.service';
|
||||
@@ -25,7 +26,8 @@ import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
/** Budget domain (S4 — Phase 2 trip sub-domain). Registered in AppModule.
|
||||
* BudgetMcp carries the decorator-registered MCP tools + resources.
|
||||
* BudgetMcp carries the decorator-registered MCP tools + resources,
|
||||
* BudgetItemsMcp the reads that answer with the expense rows.
|
||||
* AuthModule is deliberately absent (BudgetMcp's demo guard reads
|
||||
* RuntimeEnvService + the users table, not AuthService) — that absence is
|
||||
* what lets AuthModule import BudgetModule for UserCleanupService.
|
||||
@@ -57,7 +59,7 @@ import { Module } from '@nestjs/common';
|
||||
]),
|
||||
],
|
||||
controllers: [BudgetController],
|
||||
providers: [BudgetService, ExchangeRatesService, BudgetMcp, ExchangeRatesRpc, CostsRpc],
|
||||
providers: [BudgetService, ExchangeRatesService, BudgetMcp, BudgetItemsMcp, ExchangeRatesRpc, CostsRpc],
|
||||
// For in-container consumers (CostsRpc, TripsService,
|
||||
// ReservationsService, BookingImportService).
|
||||
exports: [BudgetService, ExchangeRatesService],
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import {
|
||||
McpController,
|
||||
Tool,
|
||||
type McpContext,
|
||||
TOOL_ANNOTATIONS_DELETE,
|
||||
TOOL_ANNOTATIONS_OPEN_WORLD_READONLY,
|
||||
TOOL_ANNOTATIONS_WRITE,
|
||||
errorResult,
|
||||
ok,
|
||||
} from '../../nest-mcp';
|
||||
import { addonGate } from '../addons/addon-gate';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { DawarichSuggestionsService } from './dawarich-suggestions.service';
|
||||
import { parseTimeWindow } from './dawarich.helpers';
|
||||
import { dawarichToolResult } from './dawarich.mcp';
|
||||
import {
|
||||
DAWARICH_BUCKET_MATCH_MIN_MINUTES,
|
||||
DAWARICH_BUCKET_MATCH_RADIUS_M,
|
||||
DAWARICH_BUCKET_SCAN_LIMIT,
|
||||
DAWARICH_WINDOW_MAX_DAYS,
|
||||
dawarichAtlasAcceptSchema,
|
||||
dawarichBucketConfirmSchema,
|
||||
dawarichTimeWindowSchema,
|
||||
idSchema,
|
||||
type DawarichAtlasAccept,
|
||||
type DawarichBucketConfirm,
|
||||
type DawarichTimeWindow,
|
||||
} from '@trek/shared';
|
||||
|
||||
/** Same gate as the controller's @RequireAddon(ADDON_IDS.DAWARICH): no addon, no tool. */
|
||||
const dawarichAddonOn = addonGate(ADDON_IDS.DAWARICH);
|
||||
|
||||
/**
|
||||
* The bucket list and the Atlas checked against the Dawarich recordings: the
|
||||
* twins of `/api/integrations/dawarich/bucket-list/*` and `/atlas/*`, on the
|
||||
* same service calls.
|
||||
*
|
||||
* Their own class rather than a part of DawarichMcp, because what they read
|
||||
* and write is the Atlas: the assistant loads them with the atlas tools, where
|
||||
* a question about the bucket list or the visited countries goes, while the
|
||||
* stays and the recorded route stay with the journal.
|
||||
*
|
||||
* Reading the recordings to offer wishes and countries is `atlas:read`, as the
|
||||
* answer is about the Atlas; ticking off and marking write it, `atlas:write`.
|
||||
* Like the routes the tools work on the caller's own data and take no trip,
|
||||
* and a refusal reads as the route's error sentence.
|
||||
*/
|
||||
@McpController()
|
||||
export class DawarichAtlasMcp {
|
||||
constructor(
|
||||
private readonly suggestions: DawarichSuggestionsService,
|
||||
readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
@Tool({
|
||||
name: 'scan_bucket_list_visits',
|
||||
description: `Ask the caller's Dawarich recordings, wish by wish, whether they ever reached the places on their bucket list. A wish counts as reached by a stay of at least ${DAWARICH_BUCKET_MATCH_MIN_MINUTES} minutes within ${DAWARICH_BUCKET_MATCH_RADIUS_M} m; each entry carries that stay (start, minutes, distance, recorded points) or null, and whether the wish was ticked off already. At most ${DAWARICH_BUCKET_SCAN_LIMIT} wishes per scan (truncated says more are left); wishes without coordinates are only counted. An instance that cannot be reached ends the scan with an error rather than a list of misses. Nothing is ticked off here: confirm_bucket_list_visits does that once the user agrees.`,
|
||||
inputSchema: {},
|
||||
// Asks the caller's Dawarich instance once per wish and writes nothing.
|
||||
annotations: TOOL_ANNOTATIONS_OPEN_WORLD_READONLY,
|
||||
access: { group: 'atlas', mode: 'read' },
|
||||
when: dawarichAddonOn,
|
||||
})
|
||||
async scanBucketList(_args: unknown, ctx: McpContext) {
|
||||
return dawarichToolResult(() => this.suggestions.scanBucketList(ctx.userId));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'confirm_bucket_list_visits',
|
||||
description:
|
||||
"Tick off the caller's bucket-list wishes the user confirmed after scan_bucket_list_visits (1 to 200 ids). Each is dated with visitedAt (ISO 8601) when given, otherwise with the start of the stay it was matched to, otherwise today. A wish ticked off already keeps its date. Answers how many were ticked off now (updated).",
|
||||
inputSchema: dawarichBucketConfirmSchema.shape,
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
access: { group: 'atlas', mode: 'write' },
|
||||
when: dawarichAddonOn,
|
||||
})
|
||||
async confirmBucketVisits({ itemIds, visitedAt }: DawarichBucketConfirm, ctx: McpContext) {
|
||||
return dawarichToolResult(async () => ({
|
||||
updated: await this.suggestions.confirmBucketVisits(ctx.userId, itemIds, visitedAt),
|
||||
}));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'clear_bucket_list_visit',
|
||||
description:
|
||||
"Take the visited mark off one of the caller's bucket-list wishes, with its date and where it came from, so the wish reads as not reached yet. The undo of confirm_bucket_list_visits and mark_bucket_list_item_visited_from_dawarich.",
|
||||
inputSchema: { itemId: idSchema.describe('The bucket-list entry') },
|
||||
annotations: TOOL_ANNOTATIONS_DELETE,
|
||||
access: { group: 'atlas', mode: 'write' },
|
||||
when: dawarichAddonOn,
|
||||
})
|
||||
async clearBucketVisit({ itemId }: { itemId: number }, ctx: McpContext) {
|
||||
// Someone else's wish answers like a missing one, as the route's 404 does.
|
||||
if (!(await this.suggestions.clearBucketVisit(ctx.userId, itemId))) {
|
||||
return errorResult('Bucket-list entry not found');
|
||||
}
|
||||
return ok({ success: true });
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'suggest_atlas_countries',
|
||||
description: `List the countries the caller's Dawarich recordings put them in between from and to (at most ${DAWARICH_WINDOW_MAX_DAYS} days), each with its cities, the minutes spent there and whether the Atlas counts the country as visited already. Country names TREK could not match to a code are listed under unresolved. Nothing is marked here: accept_atlas_countries does that for the codes the user confirms.`,
|
||||
inputSchema: dawarichTimeWindowSchema.shape,
|
||||
// Reads the caller's Dawarich instance live and writes nothing.
|
||||
annotations: TOOL_ANNOTATIONS_OPEN_WORLD_READONLY,
|
||||
access: { group: 'atlas', mode: 'read' },
|
||||
when: dawarichAddonOn,
|
||||
})
|
||||
async suggestAtlasCountries({ from, to }: DawarichTimeWindow, ctx: McpContext) {
|
||||
// The route's window check, refused before the instance is asked.
|
||||
const window = parseTimeWindow(from, to);
|
||||
return dawarichToolResult(() => this.suggestions.atlasSuggestions(ctx.userId, window.from, window.to));
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'accept_atlas_countries',
|
||||
description:
|
||||
"Mark countries the user confirmed from suggest_atlas_countries as visited in the caller's Atlas, by ISO 3166-1 alpha-2 code (1 to 300 at once). A country taken off the Atlas before comes back; one marked already keeps where it came from. Answers how many were added now (marked).",
|
||||
inputSchema: dawarichAtlasAcceptSchema.shape,
|
||||
annotations: TOOL_ANNOTATIONS_WRITE,
|
||||
access: { group: 'atlas', mode: 'write' },
|
||||
when: dawarichAddonOn,
|
||||
})
|
||||
async acceptAtlasCountries({ countryCodes }: DawarichAtlasAccept, ctx: McpContext) {
|
||||
return dawarichToolResult(async () => ({
|
||||
marked: await this.suggestions.acceptAtlasCountries(ctx.userId, countryCodes),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import { JourneyDomainService } from '../journey/journey-domain.service';
|
||||
import { JourneyEntryStaysService } from '../journey/journey-entry-stays.service';
|
||||
import { PermissionsService } from '../permissions/permissions.service';
|
||||
import { PlacesService } from '../places/places.service';
|
||||
import { DawarichClient, type DawarichCreds } from './dawarich.client';
|
||||
import { DawarichClient, DawarichError, type DawarichCreds } from './dawarich.client';
|
||||
import { minutesBetween, toNumber } from './dawarich.helpers';
|
||||
import { DawarichService } from './dawarich.service';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
@@ -37,8 +37,21 @@ import {
|
||||
type DawarichBucketScan,
|
||||
type DawarichSuggestion,
|
||||
type DawarichSuggestionList,
|
||||
type DawarichErrorCode,
|
||||
} from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The failures that are the instance's rather than one request's: it cannot
|
||||
* be reached (a timeout included), it turns the key away, or it asks TREK to
|
||||
* slow down. A bucket-list scan stops at the first of them.
|
||||
*/
|
||||
const INSTANCE_FAILURES: ReadonlySet<DawarichErrorCode> = new Set<DawarichErrorCode>([
|
||||
'unreachable',
|
||||
'unauthorized',
|
||||
'forbidden',
|
||||
'rate_limited',
|
||||
]);
|
||||
|
||||
/**
|
||||
* What a user does with the stays a sync brought in.
|
||||
*
|
||||
@@ -408,6 +421,11 @@ export class DawarichSuggestionsService {
|
||||
* One upstream request per wish, so it is capped and explicitly triggered
|
||||
* rather than run on a timer. The cap is reported, never silent: a user with
|
||||
* eighty wishes must not be told their other thirty had no match.
|
||||
*
|
||||
* For the same reason an instance that cannot answer at all ends the scan
|
||||
* with its error instead of reporting every wish as unmatched: one that does
|
||||
* not answer would otherwise hold the request for a timeout per wish, and
|
||||
* one that refuses the key would read as "you never got anywhere".
|
||||
*/
|
||||
async scanBucketList(userId: number): Promise<DawarichBucketScan> {
|
||||
const creds = await this.dawarich.getCredentials(userId);
|
||||
@@ -447,7 +465,10 @@ export class DawarichSuggestionsService {
|
||||
let stays;
|
||||
try {
|
||||
stays = await this.client.findVisitsNear(creds, lat, lng, DAWARICH_BUCKET_MATCH_RADIUS_M, 20);
|
||||
} catch {
|
||||
} catch (err) {
|
||||
// The instance itself is down or turns the key away: every later wish
|
||||
// would fail the same way, so the scan ends here with that error.
|
||||
if (err instanceof DawarichError && INSTANCE_FAILURES.has(err.code)) throw err;
|
||||
// One wish that could not be checked is not a failed scan; it reports as
|
||||
// "no match found" and the user can run it again.
|
||||
return null;
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
DawarichSettingsDto,
|
||||
DawarichSuggestionStateDto,
|
||||
} from './dawarich.dto';
|
||||
import { parseTimeWindow } from './dawarich.helpers';
|
||||
import { DawarichService } from './dawarich.service';
|
||||
import {
|
||||
Body,
|
||||
@@ -176,7 +177,7 @@ export class DawarichController {
|
||||
|
||||
@Get('atlas/suggestions')
|
||||
async atlasSuggestions(@CurrentUser() user: User, @Query('from') from?: string, @Query('to') to?: string) {
|
||||
const window = parseWindow(from, to);
|
||||
const window = parseTimeWindow(from, to);
|
||||
return this.guardAsync(() => this.suggestions.atlasSuggestions(user.id, window.from, window.to));
|
||||
}
|
||||
|
||||
@@ -215,7 +216,7 @@ export class DawarichController {
|
||||
@Query('to') to?: string,
|
||||
@Query('offset') offset?: string,
|
||||
) {
|
||||
const window = parseWindow(from, to);
|
||||
const window = parseTimeWindow(from, to);
|
||||
return this.guardAsync(() =>
|
||||
this.tracks.forWindow(user.id, window.from.toISOString(), window.to.toISOString(), parseOffset(offset)),
|
||||
);
|
||||
@@ -310,25 +311,6 @@ function parseDate(raw: string | undefined): string | undefined {
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* An explicit instant window. Both ends are required together: a half-open
|
||||
* range would silently become "everything since the epoch" on one side, which
|
||||
* is the request that makes an instance time out.
|
||||
*/
|
||||
function parseWindow(from: string | undefined, to: string | undefined): { from: Date; to: Date } {
|
||||
const start = from ? new Date(from) : null;
|
||||
const end = to ? new Date(to) : null;
|
||||
if (!start || !end || !Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime())) {
|
||||
throw new HttpException({ error: 'from and to are required ISO timestamps' }, 400);
|
||||
}
|
||||
if (end <= start) throw new HttpException({ error: 'to must be after from' }, 400);
|
||||
const MAX_DAYS = 400;
|
||||
if (end.getTime() - start.getTime() > MAX_DAYS * 86_400_000) {
|
||||
throw new HttpException({ error: `Window must be ${MAX_DAYS} days or less` }, 400);
|
||||
}
|
||||
return { from: start, to: end };
|
||||
}
|
||||
|
||||
/** The originating client's socket id, so a broadcast does not echo back to it. */
|
||||
function socketId(req: Request): string | undefined {
|
||||
const header = req.headers['x-socket-id'];
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { DomainError } from '../common/domain-error';
|
||||
import type {
|
||||
DawarichSlimPoint,
|
||||
DawarichTrackFeature,
|
||||
DawarichVisitRaw,
|
||||
DawarichVisitedCountry,
|
||||
} from './dawarich.client';
|
||||
import { RECORDING_GAP_METERS, type DawarichTrackDay, type DawarichTrackSegment } from '@trek/shared';
|
||||
import {
|
||||
DAWARICH_WINDOW_MAX_DAYS,
|
||||
RECORDING_GAP_METERS,
|
||||
type DawarichTrackDay,
|
||||
type DawarichTrackSegment,
|
||||
} from '@trek/shared';
|
||||
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
@@ -690,3 +696,25 @@ function sumOf(a: unknown, b: unknown): number | undefined {
|
||||
if (left === null && right === null) return undefined;
|
||||
return (left ?? 0) + (right ?? 0);
|
||||
}
|
||||
|
||||
// ── Windows ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* An explicit instant window, read the one way for the REST routes and the
|
||||
* MCP tools that take one. Both ends are required together: a half-open range
|
||||
* would silently become "everything since the epoch" on one side, which is the
|
||||
* request that makes an instance time out. A refusal is a 400 that REST and
|
||||
* MCP answer with the same sentence.
|
||||
*/
|
||||
export function parseTimeWindow(from: string | undefined, to: string | undefined): { from: Date; to: Date } {
|
||||
const start = from ? new Date(from) : null;
|
||||
const end = to ? new Date(to) : null;
|
||||
if (!start || !end || !Number.isFinite(start.getTime()) || !Number.isFinite(end.getTime())) {
|
||||
throw new DomainError(400, 'from and to are required ISO timestamps');
|
||||
}
|
||||
if (end <= start) throw new DomainError(400, 'to must be after from');
|
||||
if (end.getTime() - start.getTime() > DAWARICH_WINDOW_MAX_DAYS * 86_400_000) {
|
||||
throw new DomainError(400, `Window must be ${DAWARICH_WINDOW_MAX_DAYS} days or less`);
|
||||
}
|
||||
return { from: start, to: end };
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { addonGate } from '../addons/addon-gate';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { AcceptError, DawarichSuggestionsService } from './dawarich-suggestions.service';
|
||||
import { DawarichTracksService } from './dawarich-tracks.service';
|
||||
import { DawarichError } from './dawarich.client';
|
||||
import { idSchema } from '@trek/shared';
|
||||
|
||||
import { z } from 'zod';
|
||||
@@ -48,6 +49,9 @@ const MAX_LIMIT = 200;
|
||||
* The connection routes (settings, test, disconnect) are deliberately absent:
|
||||
* they take an API key for somebody's complete location history, and that is
|
||||
* exactly what no assistant scope should be able to read or replace.
|
||||
*
|
||||
* The bucket-list and Atlas twins of `/bucket-list/*` and `/atlas/*` are
|
||||
* DawarichAtlasMcp, which the assistant loads with the Atlas.
|
||||
*/
|
||||
@McpController()
|
||||
export class DawarichMcp {
|
||||
@@ -251,11 +255,22 @@ export class DawarichMcp {
|
||||
|
||||
/** The MCP echo of the controller's error shaping: a refusal, not an exception. */
|
||||
private async run<T>(action: () => Promise<T>) {
|
||||
try {
|
||||
return ok((await action()) as object);
|
||||
} catch (err) {
|
||||
if (err instanceof AcceptError) return errorResult(err.message);
|
||||
throw err;
|
||||
}
|
||||
return dawarichToolResult(action);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A Dawarich tool's answer: the action's result, or a refusal in its words
|
||||
* when it fails as the routes refuse. An instance that cannot be read is a
|
||||
* refusal too, with TREK's own sentence for it (the upstream text stays in
|
||||
* the error's detail, as on the route). Anything else propagates. Shared with
|
||||
* DawarichAtlasMcp.
|
||||
*/
|
||||
export async function dawarichToolResult<T>(action: () => Promise<T>) {
|
||||
try {
|
||||
return ok((await action()) as object);
|
||||
} catch (err) {
|
||||
if (err instanceof AcceptError || err instanceof DawarichError) return errorResult(err.message);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { McpSharedModule } from '../mcp-shared/mcp-shared.module';
|
||||
import { PermissionsModule } from '../permissions/permissions.module';
|
||||
import { PlacesModule } from '../places/places.module';
|
||||
import { SchedulingModule } from '../scheduling/scheduling.module';
|
||||
import { DawarichAtlasMcp } from './dawarich-atlas.mcp';
|
||||
import { DawarichDayStaysService } from './dawarich-day-stays.service';
|
||||
import { DawarichSuggestionsService } from './dawarich-suggestions.service';
|
||||
import { DawarichSyncJob } from './dawarich-sync.job';
|
||||
@@ -90,6 +91,7 @@ import { Module } from '@nestjs/common';
|
||||
DawarichDayStaysService,
|
||||
DawarichSyncJob,
|
||||
DawarichMcp,
|
||||
DawarichAtlasMcp,
|
||||
],
|
||||
exports: [DawarichService, DawarichSuggestionsService, DawarichTracksService, DawarichDayStaysService],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { ADDON_IDS } from '../../addons';
|
||||
import { McpController, TOOL_ANNOTATIONS_READONLY, Tool, errorResult, ok, type McpContext } from '../../nest-mcp';
|
||||
import { addonGate } from '../addons/addon-gate';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { JourneyDomainService } from './journey-domain.service';
|
||||
import { routeDistance, thinRoute } from './journey-stats';
|
||||
import { idSchema, type JourneyTrack } from '@trek/shared';
|
||||
|
||||
const journeyAddonOn = addonGate(ADDON_IDS.JOURNEY);
|
||||
|
||||
/** Points one track keeps at most: enough for its shape on a small map. */
|
||||
const TRACK_DISPLAY_POINTS = 200;
|
||||
|
||||
/**
|
||||
* Points all tracks of an answer keep together, so a journey of many trips
|
||||
* does not grow the answer by a full line per track. Each track keeps its two
|
||||
* ends whatever the share.
|
||||
*/
|
||||
const TRACKS_DISPLAY_POINTS = 1000;
|
||||
|
||||
/** How many points each of `count` tracks keeps: an even share of the total, between 2 and the per-track cap. */
|
||||
function pointsPerTrack(count: number): number {
|
||||
return Math.max(2, Math.min(TRACK_DISPLAY_POINTS, Math.floor(TRACKS_DISPLAY_POINTS / Math.max(1, count))));
|
||||
}
|
||||
|
||||
/** A coordinate to five decimals, about a metre: finer than any drawn line shows. */
|
||||
function coordinate(value: number): number {
|
||||
return Math.round(value * 1e5) / 1e5;
|
||||
}
|
||||
|
||||
/**
|
||||
* One track as the tool answers it: what the model reasons with (which trip
|
||||
* and place it belongs to, its name and kind, how long it is) and, under
|
||||
* `display_geometry`, the line thinned for an MCP client that draws it. No
|
||||
* card in TREK's own assistant draws it: the assistant drops every key that
|
||||
* names a geometry before the answer reaches the provider, so there the
|
||||
* points cost the model nothing.
|
||||
*/
|
||||
function trackSummary(track: JourneyTrack, points: number) {
|
||||
const distance = routeDistance(track.points.map(([lat, lng]) => ({ lat, lng })));
|
||||
return {
|
||||
place_id: track.place_id,
|
||||
trip_id: track.trip_id,
|
||||
name: track.name,
|
||||
// Absent means a trip's GPX, all GET :id/tracks has ever answered.
|
||||
kind: track.kind ?? 'trip_gpx',
|
||||
color: track.color,
|
||||
point_count: track.points.length,
|
||||
distance_km: Math.round(distance / 100) / 10,
|
||||
display_geometry: thinRoute(track.points, points).map(([lat, lng]) => [coordinate(lat), coordinate(lng)]),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The GPX tracks drawn on a journey's map over MCP: the same service and the
|
||||
* same journey access as GET /api/journeys/:id/tracks, read under the Journey
|
||||
* addon like the route. A track's full geometry can run to thousands of
|
||||
* points, so each answers with its size and a thinned line instead, the
|
||||
* lines of all tracks together held to TRACKS_DISPLAY_POINTS.
|
||||
*/
|
||||
@McpController()
|
||||
export class JourneyTracksMcp {
|
||||
constructor(
|
||||
private readonly journey: JourneyDomainService,
|
||||
readonly addons: AddonsService,
|
||||
) {}
|
||||
|
||||
@Tool({
|
||||
name: 'get_journey_tracks',
|
||||
description: `The GPX tracks a journey's map draws, from the trips added to it: per track the place and trip it belongs to, its name, its kind (trip_gpx for a trip's GPX), how many points it has and its length in km as the line runs. display_geometry holds the line thinned for drawing as [lat, lng] points: at most ${TRACK_DISPLAY_POINTS} per track and about ${TRACKS_DISPLAY_POINTS} for all tracks together, always with both ends. A journey whose trips have no tracks answers an empty list.`,
|
||||
inputSchema: { journeyId: idSchema },
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
when: journeyAddonOn,
|
||||
access: { group: 'journey', mode: 'read' },
|
||||
})
|
||||
async getJourneyTracks({ journeyId }: { journeyId: number }, ctx: McpContext) {
|
||||
const tracks = await this.journey.journeyTracks(journeyId, ctx.userId);
|
||||
if (!tracks) return errorResult('Journey not found or access denied.');
|
||||
const points = pointsPerTrack(tracks.length);
|
||||
return ok({ tracks: tracks.map((track) => trackSummary(track, points)) });
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { JourneyMapController } from './journey-map.controller';
|
||||
import { JourneyMapMcp } from './journey-map.mcp';
|
||||
import { JourneyPhotoCaptureModule } from './journey-photo-capture.module';
|
||||
import { JourneyPublicController } from './journey-public.controller';
|
||||
import { JourneyTracksMcp } from './journey-tracks.mcp';
|
||||
import { JourneyController } from './journey.controller';
|
||||
import { journeyImageFileFilter, journeyUploadFilename } from './journey.controller';
|
||||
import { JourneyMcp } from './journey.mcp';
|
||||
@@ -53,6 +54,6 @@ import { MulterModule } from '@nestjs/platform-express';
|
||||
MikroOrmModule.forFeature([Users, JourneyBooks]),
|
||||
],
|
||||
controllers: [JourneyController, JourneyMapController, JourneyPublicController],
|
||||
providers: [JourneyService, JourneyBookService, JourneyMcp, JourneyMapMcp],
|
||||
providers: [JourneyService, JourneyBookService, JourneyMcp, JourneyMapMcp, JourneyTracksMcp],
|
||||
})
|
||||
export class JourneyModule {}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '../../nest-mcp';
|
||||
import { WeatherService } from './weather.service';
|
||||
import { WindService } from './wind.service';
|
||||
import { WIND_FORECAST_DAYS, windQuerySchema, type WindQuery } from '@trek/shared';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -13,10 +15,15 @@ import { z } from 'zod';
|
||||
* The catch that answers with the upstream message and `isError: true` rather
|
||||
* than throwing is carried over verbatim: the client sees "Weather service not
|
||||
* available." instead of a transport-level failure when the provider is down.
|
||||
* get_wind_forecast, the twin of GET /api/weather/wind, answers its failures
|
||||
* the same way.
|
||||
*/
|
||||
@McpController()
|
||||
export class WeatherMcp {
|
||||
constructor(private readonly weather: WeatherService) {}
|
||||
constructor(
|
||||
private readonly weather: WeatherService,
|
||||
private readonly wind: WindService,
|
||||
) {}
|
||||
|
||||
@Tool({
|
||||
name: 'get_weather',
|
||||
@@ -38,10 +45,7 @@ export class WeatherMcp {
|
||||
const weather = await this.weather.get(String(lat), String(lng), date, lang ?? 'en');
|
||||
return ok({ weather });
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: (err as Error)?.message ?? 'Weather service not available.' }],
|
||||
isError: true,
|
||||
};
|
||||
return unavailable(err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,10 +69,30 @@ export class WeatherMcp {
|
||||
const weather = await this.weather.getDetailed(String(lat), String(lng), date, lang ?? 'en');
|
||||
return ok({ weather });
|
||||
} catch (err) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: (err as Error)?.message ?? 'Weather service not available.' }],
|
||||
isError: true,
|
||||
};
|
||||
return unavailable(err);
|
||||
}
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'get_wind_forecast',
|
||||
description: `Get the wind forecast for a location (lat, lng) on one day (date, YYYY-MM-DD): per local hour the mean speed and the gusts in km/h and the direction the wind comes from in degrees, and for the day the strongest mean, the strongest gust and the dominant direction. Only a forecast is answered: a day in the past or more than ${WIND_FORECAST_DAYS} days ahead comes back with its status (past, beyond_horizon) and no figures. A value the source did not give is null, never calm.`,
|
||||
inputSchema: windQuerySchema.shape,
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'weather', mode: 'read' },
|
||||
})
|
||||
async getWindForecast({ lat, lng, date }: WindQuery, _ctx: McpContext) {
|
||||
try {
|
||||
return ok({ wind: await this.wind.forecast({ lat, lng, date }) });
|
||||
} catch (err) {
|
||||
return unavailable(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The provider's failure as a refusal the caller can read, never a transport error. */
|
||||
function unavailable(err: unknown) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: (err as Error)?.message ?? 'Weather service not available.' }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user