diff --git a/server/src/nest/maps/maps.helpers.ts b/server/src/nest/maps/maps.helpers.ts index 62e3850d3..89ef00249 100644 --- a/server/src/nest/maps/maps.helpers.ts +++ b/server/src/nest/maps/maps.helpers.ts @@ -367,6 +367,16 @@ export const CATEGORY_OSM_FILTERS: Record = { charging: ['amenity=charging_station'], rest_area: ['highway=rest_area', 'highway=services'], campsite: ['tourism=camp_site', 'tourism=caravan_site'], + // What a hike or a paddle tour stops for along its line (PLN-02). Asked for by the Tours + // search along a route, never offered by the explore pill. + viewpoint: ['tourism=viewpoint'], + peak: ['natural=peak'], + shelter: ['amenity=shelter', 'tourism=wilderness_hut', 'tourism=alpine_hut'], + drinking_water: ['amenity=drinking_water', 'amenity=water_point'], + toilets: ['amenity=toilets'], + picnic: ['tourism=picnic_site', 'leisure=picnic_table'], + slipway: ['leisure=slipway', 'canoe=put_in'], + boat_rental: ['amenity=boat_rental'], }; export const POI_CATEGORY_KEYS = Object.keys(CATEGORY_OSM_FILTERS); diff --git a/server/src/nest/tours/dto/tour-poi-search.dto.ts b/server/src/nest/tours/dto/tour-poi-search.dto.ts new file mode 100644 index 000000000..9ce3c91db --- /dev/null +++ b/server/src/nest/tours/dto/tour-poi-search.dto.ts @@ -0,0 +1,5 @@ +import { tourPoiSearchRequestSchema } from '@trek/shared'; + +import { createZodDto } from 'nestjs-zod'; + +export class TourPoiSearchDto extends createZodDto(tourPoiSearchRequestSchema) {} diff --git a/server/src/nest/tours/tour-library.service.ts b/server/src/nest/tours/tour-library.service.ts index 923fb0982..d66897ba4 100644 --- a/server/src/nest/tours/tour-library.service.ts +++ b/server/src/nest/tours/tour-library.service.ts @@ -1,7 +1,7 @@ import { Tours } from '../../db/entities/Tours.entity'; import type { ToursRepository, TourLibraryRow } from '../../db/repositories/Tours.repository'; import { TripMembershipService } from '../trip-membership/trip-membership.service'; -import { parseRouteGeometry } from './tours.helpers'; +import { parseRouteGeometry, thinnedLine } from './tours.helpers'; import { ToursService } from './tours.service'; import { InjectRepository } from '@mikro-orm/nestjs'; import { Injectable, NotFoundException } from '@nestjs/common'; @@ -40,18 +40,6 @@ function toItem(row: TourLibraryRow): TourLibraryItem { }; } -/** Every `step`-th point of a line, its last point always kept, so a preview draws the whole tour lightly. */ -function thinned(line: Array<[number, number]>, most: number): Array<[number, number]> { - if (line.length <= most) return line; - const step = (line.length - 1) / (most - 1); - return Array.from({ length: most }, (_, index) => Math.min(line.length - 1, Math.round(index * step))).flatMap( - (index) => { - const point = line[index]; - return point ? [point] : []; - }, - ); -} - /** * LIB-02: the tours of every other trip the user can open, to take one over * into the trip they are planning. Listing and previewing read only. Every @@ -97,7 +85,11 @@ export class TourLibraryService { const row = await this.toursRepo.findForLibrary(await this.reachableTrips(userId), placeId); if (!row) throw new NotFoundException('Tour not found'); const line = parseRouteGeometry(row.route_geometry).map((point): [number, number] => [point[0], point[1]]); - return { tour: toItem(row), line: thinned(line, TOUR_LIBRARY_PREVIEW_POINTS), waypoint_count: row.waypoint_count }; + return { + tour: toItem(row), + line: thinnedLine(line, TOUR_LIBRARY_PREVIEW_POINTS), + waypoint_count: row.waypoint_count, + }; } /** diff --git a/server/src/nest/tours/tour-poi-search.controller.ts b/server/src/nest/tours/tour-poi-search.controller.ts new file mode 100644 index 000000000..5076602db --- /dev/null +++ b/server/src/nest/tours/tour-poi-search.controller.ts @@ -0,0 +1,29 @@ +import { ADDON_IDS } from '../../addons'; +import { AddonGuard } from '../addons/addon.guard'; +import { RequireAddon } from '../addons/require-addon.decorator'; +import { JwtAuthGuard } from '../auth-core/jwt-auth.guard'; +import { ResponseContract } from '../common/response-contract'; +import { TripAccessGuard } from '../permissions/trip-access.guard'; +import { TourPoiSearchDto } from './dto/tour-poi-search.dto'; +import { TourPoiSearchService } from './tour-poi-search.service'; +import { Body, Controller, HttpCode, Post, UseGuards } from '@nestjs/common'; +import { tourPoiSearchResponseSchema } from '@trek/shared'; + +/** + * PLN-02 POST /api/trips/:tripId/tours/poi-search: places along the line of + * the tour being planned. A read, so anyone who sees the trip may ask; the + * line comes in the body because the draft may not be saved yet. + */ +@Controller('api/trips/:tripId/tours') +@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard) +@RequireAddon(ADDON_IDS.TOURS, 'Tours') +export class TourPoiSearchController { + constructor(private readonly search: TourPoiSearchService) {} + + @Post('poi-search') + @HttpCode(200) + @ResponseContract(tourPoiSearchResponseSchema) + async searchAlong(@Body() body: TourPoiSearchDto) { + return await this.search.search(body); + } +} diff --git a/server/src/nest/tours/tour-poi-search.service.ts b/server/src/nest/tours/tour-poi-search.service.ts new file mode 100644 index 000000000..9b5781ae2 --- /dev/null +++ b/server/src/nest/tours/tour-poi-search.service.ts @@ -0,0 +1,147 @@ +import { Places } from '../../db/entities/Places.entity'; +import { Tours } from '../../db/entities/Tours.entity'; +import type { PlacesRepository } from '../../db/repositories/Places.repository'; +import type { ToursRepository } from '../../db/repositories/Tours.repository'; +import { MapsService } from '../maps/maps.service'; +import { parseRouteGeometry, thinnedLine } from './tours.helpers'; +import { InjectRepository } from '@mikro-orm/nestjs'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { + nearnessToLine, + plannableTourTypeSchema, + TOUR_POI_LIMIT, + TOUR_POI_MAX_LINE_POINTS, + type TourPoi, + type TourPoiCategory, + type TourPoiSearchRequest, + type TourPoiSearchResponse, +} from '@trek/shared'; +import { corridorTiles, simplifyLine } from '@trek/shared/roadtrip'; + +/** The map search's own categories each Tours category asks for. */ +const MAPS_CATEGORIES: Record = { + viewpoint: ['viewpoint'], + peak: ['peak'], + shelter: ['shelter'], + drinking_water: ['drinking_water'], + food: ['restaurant', 'cafe'], + toilets: ['toilets'], + picnic: ['picnic'], + campsite: ['campsite'], + sights: ['sights'], + slipway: ['slipway'], + boat_rental: ['boat_rental'], +}; + +/** + * How many stretches of the line one search covers, each at most about half a + * degree, so a long tour is searched along its first stretches and the answer + * says so. Three are asked at a time, so the public mirrors are not flooded. + */ +const MAX_TILES = 12; +const PARALLEL_TILES = 3; +/** The most places one stretch is asked for. */ +const PER_TILE_LIMIT = 60; + +function tourCategoryOf(mapsCategory: string, asked: readonly TourPoiCategory[]): TourPoiCategory | undefined { + return asked.find((category) => MAPS_CATEGORIES[category].includes(mapsCategory)); +} + +/** + * PLN-02: places along a tour, searched on purpose. The line is cut into + * stretches as the road trip's corridor search does, each searched through + * the map search (the TREK places index first, OpenStreetMap after), the + * places kept that lie within the corridor of the line itself, nearest the + * start first. Nothing is written. A stretch that fails leaves the others' + * places and marks the answer partial; the distance is measured straight + * across to the line, never a way someone walked or paddled. + */ +@Injectable() +export class TourPoiSearchService { + constructor( + private readonly maps: MapsService, + @InjectRepository(Tours) private readonly toursRepo: ToursRepository, + @InjectRepository(Places) private readonly placesRepo: PlacesRepository, + ) {} + + async search(body: TourPoiSearchRequest): Promise { + const corridorKm = body.corridor_m / 1000; + // The contract's tuples, spelled out as pairs once for the measures below. + const line = body.line.map((point): [number, number] => [Number(point[0]), Number(point[1])]); + const simplified = simplifyLine( + line.map(([lat, lng]) => ({ lat, lng })), + Math.max(0.05, corridorKm / 4), + ); + const tiles = corridorTiles(simplified, corridorKm); + const searched = tiles.slice(0, MAX_TILES); + const asked = [...new Set(body.categories.flatMap((category) => MAPS_CATEGORIES[category]))].join(','); + + // Three stretches at a time, each batch after the last, so no more than three are ever in flight. + const batches = Array.from({ length: Math.ceil(searched.length / PARALLEL_TILES) }, (_, index) => + searched.slice(index * PARALLEL_TILES, (index + 1) * PARALLEL_TILES), + ); + const answers = await batches.reduce< + Promise>>>> + >( + async (done, batch) => [ + ...(await done), + ...(await Promise.allSettled(batch.map((tile) => this.maps.pois(asked, tile, body.lang, PER_TILE_LIMIT)))), + ], + Promise.resolve([]), + ); + + const found = new Map(); + let truncated = false; + for (const answer of answers) { + if (answer.status !== 'fulfilled') continue; + truncated ||= answer.value.truncated || answer.value.clamped; + for (const poi of answer.value.pois) { + const category = tourCategoryOf(poi.category, body.categories); + const near = nearnessToLine({ lat: poi.lat, lng: poi.lng }, line); + if (!category || !near || near.distanceM > body.corridor_m || found.has(poi.osm_id)) continue; + found.set(poi.osm_id, { + id: poi.osm_id, + name: poi.name, + lat: poi.lat, + lng: poi.lng, + category, + kind: poi.poi_type, + source: poi.source ?? 'openstreetmap', + address: poi.address, + website: poi.website, + phone: poi.phone, + opening_hours: poi.opening_hours, + distance_m: Math.round(near.distanceM), + along_m: Math.round(near.alongM), + }); + } + } + const pois = [...found.values()].sort((a, b) => a.along_m - b.along_m || a.distance_m - b.distance_m); + return { + pois: pois.slice(0, TOUR_POI_LIMIT), + truncated: truncated || pois.length > TOUR_POI_LIMIT, + partial: answers.some((answer) => answer.status === 'rejected'), + capped: tiles.length > MAX_TILES, + }; + } + + /** The same search along a saved tour's line, for a client that has no draft (MCP). */ + async searchSavedTour( + tripId: number, + placeId: number, + request: Pick, + ): Promise { + const [tour, place] = await Promise.all([ + this.toursRepo.findInTrip(tripId, placeId), + this.placesRepo.findInTrip(placeId, tripId), + ]); + const line = parseRouteGeometry(place?.route_geometry).map((point): [number, number] => [point[0], point[1]]); + const tourType = plannableTourTypeSchema.safeParse(tour?.tour_type); + if (!tour || line.length < 2) throw new NotFoundException('Tour not found'); + return await this.search({ + ...request, + tour_type: tourType.success ? tourType.data : 'hike', + line: thinnedLine(line, TOUR_POI_MAX_LINE_POINTS), + }); + } +} diff --git a/server/src/nest/tours/tours.helpers.ts b/server/src/nest/tours/tours.helpers.ts index c2fed3555..38f2f1c62 100644 --- a/server/src/nest/tours/tours.helpers.ts +++ b/server/src/nest/tours/tours.helpers.ts @@ -69,3 +69,18 @@ export function changedParts(before: string, now: string): Array<(typeof CONTENT const b = now.split('.'); return CONTENT_PARTS.filter((_, i) => a[i] !== b[i]); } + +/** + * At most `most` points of a line, evenly spread along it with its ends kept: + * a preview or a search gets the whole shape without the thousands of points. + */ +export function thinnedLine

(line: readonly P[], most: number): P[] { + if (line.length <= most) return [...line]; + const step = (line.length - 1) / (most - 1); + return Array.from({ length: most }, (_, index) => Math.min(line.length - 1, Math.round(index * step))).flatMap( + (index) => { + const point = line[index]; + return point === undefined ? [] : [point]; + }, + ); +} diff --git a/server/src/nest/tours/tours.mcp.ts b/server/src/nest/tours/tours.mcp.ts index c518deb03..10fef0fd6 100644 --- a/server/src/nest/tours/tours.mcp.ts +++ b/server/src/nest/tours/tours.mcp.ts @@ -6,12 +6,15 @@ import { addonGate } from '../addons/addon-gate'; import { AddonsService } from '../addons/addons.service'; import { TripAccessService } from '../trip-membership/trip-access.service'; import { TourGpxExportService } from './tour-gpx-export.service'; +import { TourPoiSearchService } from './tour-poi-search.service'; import { ToursService } from './tours.service'; import { NotFoundException } from '@nestjs/common'; -import { idSchema, type TourListItem } from '@trek/shared'; +import { idSchema, tourPoiSearchRequestSchema, type TourListItem, type TourPoiSearchRequest } from '@trek/shared'; type TourDay = Pick; +const poiFields = tourPoiSearchRequestSchema.shape; + /** A tour as a client reads it: its list facts and the days of the trip it is on. */ function withDays(tour: TourListItem, days: ReadonlyMap) { return { ...tour, days: days.get(tour.place_id) ?? [] }; @@ -32,6 +35,7 @@ export class ToursMcp { private readonly tours: ToursService, private readonly access: TripAccessService, private readonly gpxExport: TourGpxExportService, + private readonly poiSearch: TourPoiSearchService, // Read by the addon gate of every tool here. readonly addons: AddonsService, ) {} @@ -122,4 +126,36 @@ export class ToursMcp { throw err; } } + + @Tool({ + name: 'search_tour_pois', + description: + 'Search places along the saved line of a tour: what a hike or a paddle tour stops for (viewpoint, peak, shelter, drinking_water, food, toilets, picnic, campsite, sights, slipway, boat_rental), within corridor_m metres of the line. Answers each place with its source, kind, address, website and phone where known, opening hours as the source has them (a hint to check, never a promise), distance_m (straight across to the line, not a detour) and along_m (from the start along the line), nearest the start first, up to 60. partial says some stretches could not be searched, capped that only the first part of a long line was. Nothing is added to the tour or the trip.', + inputSchema: { + tripId: idSchema, + placeId: idSchema.describe('The place id of the tour, as list_tours gives it'), + categories: poiFields.categories, + corridor_m: poiFields.corridor_m, + lang: poiFields.lang, + }, + annotations: TOOL_ANNOTATIONS_READONLY, + when: addonGate(ADDON_IDS.TOURS), + access: { group: 'trips', mode: 'read' }, + }) + async searchTourPois( + { + tripId, + placeId, + ...request + }: { tripId: number; placeId: number } & Pick, + ctx: McpContext, + ) { + if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess(); + try { + return ok(await this.poiSearch.searchSavedTour(tripId, placeId, request)); + } catch (err: unknown) { + if (err instanceof NotFoundException) return errorResult('Tour not found.'); + throw err; + } + } } diff --git a/server/src/nest/tours/tours.module.ts b/server/src/nest/tours/tours.module.ts index e6bfcc74a..53176c2db 100644 --- a/server/src/nest/tours/tours.module.ts +++ b/server/src/nest/tours/tours.module.ts @@ -5,6 +5,7 @@ import { TourTypes } from '../../db/entities/TourTypes.entity'; import { TourWaypoints } from '../../db/entities/TourWaypoints.entity'; import { Tours } from '../../db/entities/Tours.entity'; import { AddonsModule } from '../addons/addons.module'; +import { MapsModule } from '../maps/maps.module'; import { McpSharedModule } from '../mcp-shared/mcp-shared.module'; import { PaddleObstaclesModule } from '../paddle-obstacles/paddle-obstacles.module'; import { PermissionsModule } from '../permissions/permissions.module'; @@ -20,6 +21,8 @@ import { TourObstacleReviewsService } from './tour-obstacle-reviews.service'; import { PaddleCheckCache } from './tour-paddle-check.cache'; import { TourPaddleCheckService } from './tour-paddle-check.service'; import { TourPaddlePlanService } from './tour-paddle-plan.service'; +import { TourPoiSearchController } from './tour-poi-search.controller'; +import { TourPoiSearchService } from './tour-poi-search.service'; import { ToursImportController } from './tours-import.controller'; import { ToursWandererController } from './tours-wanderer.controller'; import { ToursWandererService } from './tours-wanderer.service'; @@ -44,6 +47,8 @@ import { Module } from '@nestjs/common'; TripMembershipModule, // The take-over tool checks 'place_edit' on the trip it writes into (LIB-02). McpSharedModule, + // PLN-02 searches places along a tour through the map search. + MapsModule, AddonsModule, // The wanderer import reads trails through the caller's own connection. @@ -54,6 +59,7 @@ import { Module } from '@nestjs/common'; ], controllers: [ TourTakeOverController, + TourPoiSearchController, ToursController, ToursImportController, ToursWandererController, @@ -68,6 +74,7 @@ import { Module } from '@nestjs/common'; TourGpxExportService, ToursWandererService, TourLibraryService, + TourPoiSearchService, ToursMcp, TourLibraryMcp, ], diff --git a/server/tests/e2e/tours-planned.e2e.test.ts b/server/tests/e2e/tours-planned.e2e.test.ts index 661029634..1bd7a8509 100644 --- a/server/tests/e2e/tours-planned.e2e.test.ts +++ b/server/tests/e2e/tours-planned.e2e.test.ts @@ -15,6 +15,7 @@ import { Trips } from '../../src/db/entities/Trips.entity'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe'; import { JourneyDomainService } from '../../src/nest/journey/journey-domain.service'; +import { MapsService } from '../../src/nest/maps/maps.service'; import { PermissionsService } from '../../src/nest/permissions/permissions.service'; import { RealtimeModule } from '../../src/nest/realtime/realtime.module'; import { RealtimeService } from '../../src/nest/realtime/realtime.service'; @@ -55,6 +56,8 @@ const { journeyHooks } = vi.hoisted(() => ({ })); const db = createSnapshotTestDb(); +// The map search behind the POI search (PLN-02): no network in a test. +const mapsPois = vi.fn(); const realtime = new FakeRealtimeService(); let orm: TestOrm; @@ -98,6 +101,8 @@ describe('Tours planned e2e (real guards + temp SQLite)', () => { .useValue(realtime) .overrideProvider(JourneyDomainService) .useValue(journeyHooks) + .overrideProvider(MapsService) + .useValue({ pois: mapsPois }) .compile(); const nest = moduleRef.createNestApplication(); nest.use(cookieParser()); @@ -673,4 +678,68 @@ describe('Tours planned e2e (real guards + temp SQLite)', () => { expect(off.body).toEqual({ error: 'Tours addon is not enabled' }); }); }); + describe('places along a tour (PLN-02)', () => { + const body = { + tour_type: 'hike', + categories: ['drinking_water'], + corridor_m: 500, + line: [ + [47, 11], + [47.02, 11], + ], + }; + const spring = { + osm_id: 'node:7', + name: 'Spring', + lat: 47.01, + lng: 11.001, + category: 'drinking_water', + poi_type: 'amenity=drinking_water', + address: null, + website: null, + phone: null, + opening_hours: null, + cuisine: null, + brand: null, + brand_wikidata: null, + charging: null, + source: 'openstreetmap', + }; + + it('answers the places along a draft line to anyone who sees the trip, and writes nothing', async () => { + mapsPois.mockResolvedValue({ pois: [spring], source: 'openstreetmap', truncated: false, clamped: false }); + const places = await countRows(orm, Places); + const res = await request(server) + .post('/api/trips/5/tours/poi-search') + .set('Cookie', sessionCookie(VIEWER)) + .send(body); + expect(res.status).toBe(200); + expect(res.body.pois).toEqual([ + expect.objectContaining({ id: 'node:7', name: 'Spring', category: 'drinking_water' }), + ]); + expect(res.body).toMatchObject({ truncated: false, partial: false, capped: false }); + expect(await countRows(orm, Places)).toBe(places); + expect(realtime.broadcastMock).not.toHaveBeenCalled(); + }); + + it('404 for someone outside the trip, 400 for a body it does not know, 401 without a session', async () => { + const outsider = await request(server) + .post('/api/trips/5/tours/poi-search') + .set('Cookie', sessionCookie(OUTSIDER)) + .send(body); + expect(outsider.status).toBe(404); + for (const bad of [ + { ...body, categories: ['fuel'] }, + { ...body, corridor_m: 9000 }, + { ...body, line: [[47, 11]] }, + { ...body, add: 1 }, + ]) { + expect( + (await request(server).post('/api/trips/5/tours/poi-search').set('Cookie', sessionCookie(OWNER)).send(bad)) + .status, + ).toBe(400); + } + expect((await request(server).post('/api/trips/5/tours/poi-search').send(body)).status).toBe(401); + }); + }); }); diff --git a/server/tests/unit/nest/tour-poi-search.service.test.ts b/server/tests/unit/nest/tour-poi-search.service.test.ts new file mode 100644 index 000000000..292b0fe96 --- /dev/null +++ b/server/tests/unit/nest/tour-poi-search.service.test.ts @@ -0,0 +1,193 @@ +/** + * TourPoiSearchService (PLN-02): places along a tour's line through the map + * search. The map search is a stub here: these tests are about which + * stretches are asked, what is kept and how the answer says what it missed. + */ +import { Places } from '../../../src/db/entities/Places.entity'; +import type { McpContext } from '../../../src/nest-mcp'; +import { MapsService } from '../../../src/nest/maps/maps.service'; +import { TourPoiSearchService } from '../../../src/nest/tours/tour-poi-search.service'; +import { ToursMcp } from '../../../src/nest/tours/tours.mcp'; +import { ToursModule } from '../../../src/nest/tours/tours.module'; +import { createSnapshotTestDb } from '../../helpers/db-mock'; +import { createPlace, createTrip, createUser } from '../../helpers/factories'; +import { updateRows } from '../../helpers/factories/rows'; +import { resetTestDb } from '../../helpers/test-db'; +import { createTestModule, type TestModule } from '../../helpers/test-module'; +import { createTestOrm } from '../../helpers/test-orm'; +import { createTour } from '../../helpers/tours-repos'; + +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +const db = createSnapshotTestDb(); +const pois = vi.fn(); +let t: TestModule; +let service: TourPoiSearchService; + +beforeAll(async () => { + t = await createTestModule({ db, imports: [ToursModule], overrides: [{ provide: MapsService, useValue: { pois } }] }); + service = t.get(TourPoiSearchService); +}); +beforeEach(() => { + resetTestDb(db); + pois.mockReset(); +}); +afterAll(async () => { + await t.close(); + db.close(); +}); + +// About 2.2 km due north, along one meridian. +const LINE: Array<[number, number]> = [ + [47.4, 11.1], + [47.42, 11.1], +]; + +function poi(id: string, lat: number, lng: number, category = 'viewpoint', over: Record = {}) { + return { + osm_id: id, + name: id, + lat, + lng, + category, + poi_type: `tourism=${category}`, + address: null, + website: null, + phone: null, + opening_hours: 'Mo-Su 08:00-18:00', + cuisine: null, + brand: null, + brand_wikidata: null, + charging: null, + source: 'openstreetmap', + ...over, + }; +} + +const answer = (list: unknown[], over: Record = {}) => ({ + pois: list, + source: 'openstreetmap', + truncated: false, + clamped: false, + ...over, +}); + +describe('TourPoiSearchService', () => { + it('TOUR-POI-001: keeps the places within the corridor, once each, nearest the start first, with where they lie', async () => { + pois.mockResolvedValue( + answer([ + poi('node:far', 47.41, 11.2), + poi('node:late', 47.418, 11.1005), + poi('node:early', 47.402, 11.1003), + poi('node:early', 47.402, 11.1003), + ]), + ); + const result = await service.search({ tour_type: 'hike', categories: ['viewpoint'], corridor_m: 500, line: LINE }); + expect(result.pois.map((found) => found.id)).toEqual(['node:early', 'node:late']); + expect(result.pois[0]).toMatchObject({ + category: 'viewpoint', + kind: 'tourism=viewpoint', + opening_hours: 'Mo-Su 08:00-18:00', + }); + expect(result.pois[0]?.distance_m).toBeGreaterThan(10); + expect(result.pois[0]?.distance_m).toBeLessThan(40); + expect(result.pois[0]?.along_m).toBeLessThan(result.pois[1]?.along_m); + expect(result).toMatchObject({ truncated: false, partial: false, capped: false }); + }); + + it('TOUR-POI-002: asks the map search for what each category means, and names a hit by the category asked', async () => { + pois.mockResolvedValue(answer([poi('node:cafe', 47.41, 11.1, 'cafe')])); + const result = await service.search({ + tour_type: 'hike', + categories: ['food', 'drinking_water'], + corridor_m: 250, + line: LINE, + lang: 'de', + }); + expect(pois).toHaveBeenCalledWith('restaurant,cafe,drinking_water', expect.any(Object), 'de', 60); + expect(result.pois[0]?.category).toBe('food'); + }); + + it('TOUR-POI-003: a stretch that fails leaves the others and marks the answer partial; a cut answer is truncated', async () => { + // A line long enough to need several stretches. + const long: Array<[number, number]> = [ + [47.0, 11.0], + [48.6, 11.0], + ]; + pois + .mockRejectedValueOnce(new Error('overpass down')) + .mockResolvedValue(answer([poi('node:a', 47.6, 11.0005)], { truncated: true })); + const result = await service.search({ tour_type: 'hike', categories: ['viewpoint'], corridor_m: 500, line: long }); + expect(pois.mock.calls.length).toBeGreaterThan(1); + expect(result.partial).toBe(true); + expect(result.truncated).toBe(true); + expect(result.pois.map((found) => found.id)).toEqual(['node:a']); + }); + + it('TOUR-POI-004: a line longer than one search covers is searched along its first stretches and says so', async () => { + pois.mockResolvedValue(answer([])); + const result = await service.search({ + tour_type: 'kayak', + categories: ['slipway'], + corridor_m: 2000, + line: [ + [44.0, 8.0], + [52.0, 14.0], + ], + }); + expect(pois).toHaveBeenCalledTimes(12); + expect(result.capped).toBe(true); + }); + + it('TOUR-POI-005: searches along a saved tour for MCP, and a tour without a line is not found', async () => { + const { user } = createUser(db); + const trip = createTrip(db, user.id); + const ridge = createPlace(db, trip.id, { name: 'Ridge' }); + createTour(db, ridge.id); + pois.mockResolvedValue(answer([poi('node:x', 47.41, 11.1)])); + await expect( + service.searchSavedTour(trip.id, ridge.id, { categories: ['viewpoint'], corridor_m: 500 }), + ).rejects.toThrow('Tour not found'); + const orm = await createTestOrm(db); + await updateRows( + orm, + Places, + { id: ridge.id }, + { route_geometry: JSON.stringify(LINE.map(([lat, lng]) => [lat, lng, 600])) }, + ); + await orm.close(); + const result = await service.searchSavedTour(trip.id, ridge.id, { categories: ['viewpoint'], corridor_m: 500 }); + expect(result.pois.map((found) => found.id)).toEqual(['node:x']); + + // The MCP tool reads the same, and only for someone who sees the trip. + const mcp = t.get(ToursMcp); + const ctx = (userId: number) => ({ userId }) as McpContext; + const read = (r: { content: Array<{ text: string }>; isError?: boolean }) => + r.isError ? { error: r.content[0]?.text } : JSON.parse(r.content[0]?.text ?? '{}'); + expect( + read( + await mcp.searchTourPois( + { tripId: trip.id, placeId: ridge.id, categories: ['viewpoint'], corridor_m: 500 }, + ctx(user.id), + ), + ).pois, + ).toHaveLength(1); + const { user: outsider } = createUser(db); + expect( + read( + await mcp.searchTourPois( + { tripId: trip.id, placeId: ridge.id, categories: ['viewpoint'], corridor_m: 500 }, + ctx(outsider.id), + ), + ), + ).toEqual({ error: 'Trip not found or access denied.' }); + expect( + read( + await mcp.searchTourPois( + { tripId: trip.id, placeId: 999999, categories: ['viewpoint'], corridor_m: 500 }, + ctx(user.id), + ), + ), + ).toEqual({ error: 'Tour not found.' }); + }); +}); diff --git a/shared/src/index.ts b/shared/src/index.ts index c8c49aab2..df8567e8f 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -57,6 +57,7 @@ export * from './place/track-colors'; export * from './tours/tours.schema'; export * from './tours/tour-provenance.schema'; export * from './tours/tour-library.schema'; +export * from './tours/tour-poi-search.schema'; export * from './tours/tours.metrics'; export * from './tours/tour-gpx-import.schema'; export * from './tours/paddle-plan.schema'; diff --git a/shared/src/tours/tour-poi-search.schema.spec.ts b/shared/src/tours/tour-poi-search.schema.spec.ts new file mode 100644 index 000000000..c72da2beb --- /dev/null +++ b/shared/src/tours/tour-poi-search.schema.spec.ts @@ -0,0 +1,70 @@ +import { + TOUR_POI_CATEGORIES, + TOUR_POI_CATEGORIES_BY_TYPE, + tourPoiSearchRequestSchema, + tourPoiSearchResponseSchema, +} from './tour-poi-search.schema'; + +import { describe, expect, it } from 'vitest'; + +const line = [ + [47.4, 11.1], + [47.41, 11.12], +]; + +describe('tour POI search contracts (PLN-02)', () => { + it('offers each tour type categories of its own, every one a known category', () => { + expect(TOUR_POI_CATEGORIES_BY_TYPE.hike).toContain('viewpoint'); + expect(TOUR_POI_CATEGORIES_BY_TYPE.kayak).toContain('slipway'); + expect(TOUR_POI_CATEGORIES_BY_TYPE.hike).not.toContain('slipway'); + for (const type of Object.values(TOUR_POI_CATEGORIES_BY_TYPE)) + for (const category of type) expect(TOUR_POI_CATEGORIES).toContain(category); + }); + + it('takes a line, a corridor and up to six categories, and nothing else', () => { + const ok = { tour_type: 'hike', categories: ['viewpoint', 'food'], corridor_m: 500, line }; + expect(tourPoiSearchRequestSchema.safeParse(ok).success).toBe(true); + expect(tourPoiSearchRequestSchema.safeParse({ ...ok, categories: [] }).success).toBe(false); + expect(tourPoiSearchRequestSchema.safeParse({ ...ok, categories: ['fuel'] }).success).toBe(false); + expect(tourPoiSearchRequestSchema.safeParse({ ...ok, corridor_m: 5000 }).success).toBe(false); + expect(tourPoiSearchRequestSchema.safeParse({ ...ok, line: [[47, 11]] }).success).toBe(false); + expect( + tourPoiSearchRequestSchema.safeParse({ + ...ok, + line: [ + [91, 11], + [47, 11], + ], + }).success, + ).toBe(false); + expect(tourPoiSearchRequestSchema.safeParse({ ...ok, tour_type: 'bike' }).success).toBe(false); + expect(tourPoiSearchRequestSchema.safeParse({ ...ok, add: true }).success).toBe(false); + }); + + it('answers places with where they lie against the line, opening hours nullable', () => { + const answer = tourPoiSearchResponseSchema.parse({ + pois: [ + { + id: 'node:1', + name: 'Spring', + lat: 47.4, + lng: 11.1, + category: 'drinking_water', + kind: 'amenity=drinking_water', + source: 'openstreetmap', + address: null, + website: null, + phone: null, + opening_hours: null, + distance_m: 40, + along_m: 1200, + }, + ], + truncated: false, + partial: true, + capped: false, + }); + expect(answer.partial).toBe(true); + expect(answer.pois[0]?.distance_m).toBe(40); + }); +}); diff --git a/shared/src/tours/tour-poi-search.schema.ts b/shared/src/tours/tour-poi-search.schema.ts new file mode 100644 index 000000000..fda814c7b --- /dev/null +++ b/shared/src/tours/tour-poi-search.schema.ts @@ -0,0 +1,90 @@ +import { plannableTourTypeSchema, type PlannableTourType } from './tours.schema'; + +import { z } from 'zod'; + +/** + * PLN-02: places along a tour, searched on purpose. The search reads only: + * it changes no tour, place or day. Distances are how far a place lies from + * the line as drawn, never a detour someone walked or paddled. + */ + +/** What can be looked for along a tour on foot or on the water. */ +export const TOUR_POI_CATEGORIES = [ + 'viewpoint', + 'peak', + 'shelter', + 'drinking_water', + 'food', + 'toilets', + 'picnic', + 'campsite', + 'sights', + 'slipway', + 'boat_rental', +] as const; +export const tourPoiCategorySchema = z.enum(TOUR_POI_CATEGORIES); +export type TourPoiCategory = z.infer; + +/** The categories each planned tour type offers, the most asked first. */ +export const TOUR_POI_CATEGORIES_BY_TYPE: Record = { + hike: ['viewpoint', 'shelter', 'drinking_water', 'food', 'peak', 'toilets', 'picnic', 'campsite', 'sights'], + kayak: ['slipway', 'boat_rental', 'drinking_water', 'food', 'toilets', 'picnic', 'campsite', 'shelter'], +}; + +/** How far from the line a place may lie, the choices offered, in metres. */ +export const TOUR_POI_CORRIDORS_M = [250, 500, 1000, 2000] as const; +/** The most places one answer carries, nearest the start first. */ +export const TOUR_POI_LIMIT = 60; +/** The most points the line sent may have; a longer line is thinned first. */ +export const TOUR_POI_MAX_LINE_POINTS = 1000; +/** The most categories one search asks for. */ +export const TOUR_POI_MAX_CATEGORIES = 6; + +export const tourPoiSearchRequestSchema = z + .object({ + tour_type: plannableTourTypeSchema, + categories: z.array(tourPoiCategorySchema).min(1).max(TOUR_POI_MAX_CATEGORIES), + corridor_m: z.number().int().min(50).max(2000), + line: z + .array(z.tuple([z.number().finite().min(-90).max(90), z.number().finite().min(-180).max(180)])) + .min(2) + .max(TOUR_POI_MAX_LINE_POINTS), + lang: z.string().trim().min(2).max(10).optional(), + }) + .strict(); +export type TourPoiSearchRequest = z.infer; + +/** One place found: what the source says about it, and where it lies against the line. */ +export const tourPoiSchema = z.object({ + /** The source's own id, `node:123` style for OpenStreetMap. */ + id: z.string(), + name: z.string(), + lat: z.number(), + lng: z.number(), + category: tourPoiCategorySchema, + /** The source's own kind for it, `amenity=drinking_water` style. */ + kind: z.string(), + /** Which index answered: OpenStreetMap, or TREK's own places index. */ + source: z.string(), + address: z.string().nullable(), + website: z.string().nullable(), + phone: z.string().nullable(), + /** As the source has them; a hint to check, never a promise. */ + opening_hours: z.string().nullable(), + /** Metres from the line, straight across. */ + distance_m: z.number().nonnegative(), + /** Metres along the line from its start to the point nearest the place. */ + along_m: z.number().nonnegative(), +}); +export type TourPoi = z.infer; + +export const tourPoiSearchResponseSchema = z.object({ + pois: z.array(tourPoiSchema), + /** More places matched than the answer carries. */ + truncated: z.boolean(), + /** Some stretches of the line could not be searched; the places of the others are there. */ + partial: z.boolean(), + /** The line was longer than one search covers; only its first part was searched. */ + capped: z.boolean(), +}); +export type TourPoiSearchResponse = z.infer; diff --git a/wiki/MCP-Addon-Tools.md b/wiki/MCP-Addon-Tools.md index fc01c12e7..0c2946bb8 100644 --- a/wiki/MCP-Addon-Tools.md +++ b/wiki/MCP-Addon-Tools.md @@ -241,6 +241,7 @@ Read only: the saved tours of a trip, as the Tours list and the tour detail show | `get_tour_plan` | One tour: its facts, its ordered control points, its provenance (where it came from, when, and whether it was changed in TREK since) and its days. A control point can carry a fixed leg to the next one: a via ferrata (`ferrata`) or a stretch drawn by hand (`free`), which no router planned or checked. A shaping point (`shaping: true`) only steers the route and is no stop. A paddle tour also carries its obstacle checklist (`obstacle_check`): the obstacles of the OpenStreetMap data along its route, what the travellers decided about each, and their own portages and locks. The full track geometry is not included | `trips:read` | | `find_tours_for_day` | The tours put on one day of a trip, with the same facts as `list_tours` | `trips:read` | | `export_tour_gpx` | One saved tour as a GPX file: its whole saved line as one track, with the heights it carries and none it lacks, and a file name. A tour without a line cannot be exported | `trips:read` | +| `search_tour_pois` | Places along the saved line of a tour within a corridor (viewpoints, peaks, shelters, drinking water, food, toilets, picnic, campsites, sights, slipways, boat rental): source, kind, address, website, phone, opening hours as the source has them (a hint, never a promise), the distance straight across to the line (not a detour) and how far along the line, nearest the start first, up to 60. Says when stretches could not be searched or only the first part of a long line was. Nothing is added | `trips:read` | | `list_tour_library` | The saved tours of every trip the user can open, to choose one to take over into another trip: name, type, distance, ascent, descent, planned times and the trip each is in. Narrowed by a part of the name, a type or a trip; the trip being planned can be left out. Up to 100 tours, `truncated` says there were more | `trips:read` | | `take_over_tour` | Copies a saved tour of another trip into a trip as an independent tour: its line, measures, settings and control points, without its days, its links to places of the other trip or the obstacle decisions taken there. The copy remembers which trip and tour it came from; the source trip is not changed. Needs `place_edit` on the trip it writes into | `trips:write` | | `plan_paddle_route` | Plans the legs of a paddle tour on the water network TREK builds from the paddle regions an admin added: each leg (`from`, `to`) along rivers and canals and across lakes, carried round weirs, dams and waterfalls, through or round locks (`lock_policy`), with `boat` and `lake_style` (`direct` or `shore`). Answers per leg its length, how far its ends were moved onto the water and its stretches (water, lake, whitewater, boat pass, portage with its source, lock, gap); a leg that cannot be planned says why (`not_on_water`, `no_region`, `cross_region`, `no_water_path`, `busy`, `timeout`). Nothing is saved; a planning aid, never a safety or legal clearance | `trips:read` |