fix(tours): get_tour_plan calls the leg of a recorded track a track, not routed

An import without control points of its own answers the two ends of its
recorded track as its points, and the one leg between them was labelled
routed, as if the router had planned it. The assistant read a false fact.
Such a leg now says track, and the tool description and the wiki explain
the kind.
This commit is contained in:
Maurice
2026-10-20 12:13:06 +02:00
parent 19946039ad
commit b826ecd425
5 changed files with 49 additions and 15 deletions
+20 -6
View File
@@ -61,11 +61,15 @@ interface PlanLeg {
/** The keys of its two points, the same across saves; null for a point that has none. */
from_key: string | null;
to_key: string | null;
/** routed: planned by the router; ferrata, free and portage: a fixed line taken as it stands. */
kind: 'routed' | TourFixedLegKind;
/**
* routed: planned by the router; track: part of a recorded line (an import
* without control points of its own), which no router planned; ferrata, free
* and portage: a fixed line taken as it stands.
*/
kind: 'routed' | 'track' | TourFixedLegKind;
/** The via ferrata's name where OpenStreetMap gives one. */
name: string | null;
/** Derived: the length of a fixed leg's own line; null for a routed leg, whose length is not stored apart. */
/** Derived: the length of a fixed leg's own line; null for a routed or track leg, whose length is not stored apart. */
length_km: number | null;
}
@@ -74,8 +78,18 @@ function roundKm(km: number): number {
return Math.round(km * 100) / 100;
}
/** The legs between consecutive control points, each saying whether the router planned it or it is a fixed line. */
export function planLegs(points: readonly TourWaypoint[]): PlanLeg[] {
/** How the line between the control points came to be, as far as the tour itself says. */
interface PlanLegSource {
/** The line is a recorded track (the tour stores no control points of its own), so no router planned it. */
recorded: boolean;
}
/**
* The legs between consecutive control points, each saying whether the router
* planned it, it follows a recorded track, or it is a fixed line.
*/
export function planLegs(points: readonly TourWaypoint[], { recorded }: PlanLegSource): PlanLeg[] {
const unfixed = recorded ? 'track' : 'routed';
return points.slice(1).map((to, index) => {
const from = points[index] as TourWaypoint;
const leg = from.fixed_leg ?? null;
@@ -84,7 +98,7 @@ export function planLegs(points: readonly TourWaypoint[]): PlanLeg[] {
to_sequence: to.sequence,
from_key: from.key ?? null,
to_key: to.key ?? null,
kind: leg?.kind ?? 'routed',
kind: leg?.kind ?? unfixed,
name: leg?.kind === 'ferrata' ? leg.name : null,
length_km: leg ? roundKm(computeTourMetrics(leg.geometry).distanceKm) : null,
};
+4 -3
View File
@@ -50,13 +50,14 @@ function planOf({ detail, days, line, names }: PlanRead, run: WalkProgress | nul
);
const saved = planLine(line);
// A tour that stores no control points answers the two ends of its line instead.
const controlsFromTrack = detail.tour.has_waypoints !== true && detail.waypoints.length > 0;
const recorded = detail.tour.has_waypoints !== true;
const controlsFromTrack = recorded && detail.waypoints.length > 0;
const isWalk = detail.tour.tour_type === 'walk';
return {
...detail,
tour: withDays(detail.tour, days),
waypoints: planPoints(detail.tour.tour_type, named, run),
legs: planLegs(detail.waypoints),
legs: planLegs(detail.waypoints, { recorded }),
line: saved,
...(isWalk ? { run: run ? planRun(run) : null } : {}),
derived: derivedFields(detail, { controlsFromTrack, line: saved, run }),
@@ -111,7 +112,7 @@ export class ToursMcp {
@Tool({
name: 'get_tour_plan',
description:
'Read one saved tour of a trip as the travellers saved it: its facts (tour: place_id, name, tour_type, distance, heights and walking time as stored, null when not known; the routing options as list_tours describes them; revision, raised by every saved change; the days it is on), its ordered control points (start, via and end, each with its sequence and its key, the same across saves; an import without its own shows the two ends of its track), where it came from (provenance) and its legs. Drafts in a browser are never visible: only saved tours exist here, and reading changes nothing, not the tour, its revision nor any visit. A control point with linked_place_id stands for that place of the trip (linked_place_name names it); it keeps its own coordinates. legs lists every step from one control point to the next (from_sequence, to_sequence, from_key, to_key) with its kind: routed (planned by the router), ferrata (follows a via ferrata, name gives it), free (drawn by hand and checked by nothing), portage (a paddle tour carried over land) or horse_route (a ride only: a section of a signposted horse route from OpenStreetMap in the direction the rider chose; the fixed_leg of its control point says signposted, with or against the direction the route is signposted in, null where it is not signposted one way, and access, the metres of each horse=* value its ways carried when it was taken, unknown where untagged; being on a horse route is no permission to ride it); length_km is the length of the line a fixed leg keeps and null for a routed leg, whose length is not stored apart. line gives the saved line in brief: point_count, has_heights and a preview thinned to 200 points; null while the tour waits for its way to be planned. A paddle tour also carries its obstacle checklist (obstacle_check): the obstacles of the OpenStreetMap data along its line, the decisions the travellers made about them and their own portages and locks; a planning aid, never a safety or legal clearance. A city walk (tour_type walk) has stations for control points, shaping points and the way back of a loop aside, each with every field spelled out: key (the visits of a day are kept by it), name, stay_minutes (the stay in minutes; 0 for a point only passed through, null when not known), importance (optional or must; null for a normal station), protected (true for a station any change you propose has to keep where it is: neither removed, replaced nor moved; the travellers can still edit it themselves in the app), opening_hours (as OpenStreetMap writes them; a hint to check, never a promise; null when none is known) and visit_status. Visits belong to one day of the walk: name that day with dayId (one of its days) to read the status of each station that day (planned, visited or skipped) and run (the day, its assignment and what is still open); without dayId visit_status and run are null. A station visited or skipped that day is done: do not ask about it again or move it to another day. dayId is for a city walk only. derived names the fields worked out on reading rather than stored with the tour: never present them as saved facts.',
'Read one saved tour of a trip as the travellers saved it: its facts (tour: place_id, name, tour_type, distance, heights and walking time as stored, null when not known; the routing options as list_tours describes them; revision, raised by every saved change; the days it is on), its ordered control points (start, via and end, each with its sequence and its key, the same across saves; an import without its own shows the two ends of its track), where it came from (provenance) and its legs. Drafts in a browser are never visible: only saved tours exist here, and reading changes nothing, not the tour, its revision nor any visit. A control point with linked_place_id stands for that place of the trip (linked_place_name names it); it keeps its own coordinates. legs lists every step from one control point to the next (from_sequence, to_sequence, from_key, to_key) with its kind: routed (planned by the router), track (part of a recorded line, an import without control points of its own, which no router planned), ferrata (follows a via ferrata, name gives it), free (drawn by hand and checked by nothing), portage (a paddle tour carried over land) or horse_route (a ride only: a section of a signposted horse route from OpenStreetMap in the direction the rider chose; the fixed_leg of its control point says signposted, with or against the direction the route is signposted in, null where it is not signposted one way, and access, the metres of each horse=* value its ways carried when it was taken, unknown where untagged; being on a horse route is no permission to ride it); length_km is the length of the line a fixed leg keeps and null for a routed or track leg, whose length is not stored apart. line gives the saved line in brief: point_count, has_heights and a preview thinned to 200 points; null while the tour waits for its way to be planned. A paddle tour also carries its obstacle checklist (obstacle_check): the obstacles of the OpenStreetMap data along its line, the decisions the travellers made about them and their own portages and locks; a planning aid, never a safety or legal clearance. A city walk (tour_type walk) has stations for control points, shaping points and the way back of a loop aside, each with every field spelled out: key (the visits of a day are kept by it), name, stay_minutes (the stay in minutes; 0 for a point only passed through, null when not known), importance (optional or must; null for a normal station), protected (true for a station any change you propose has to keep where it is: neither removed, replaced nor moved; the travellers can still edit it themselves in the app), opening_hours (as OpenStreetMap writes them; a hint to check, never a promise; null when none is known) and visit_status. Visits belong to one day of the walk: name that day with dayId (one of its days) to read the status of each station that day (planned, visited or skipped) and run (the day, its assignment and what is still open); without dayId visit_status and run are null. A station visited or skipped that day is done: do not ask about it again or move it to another day. dayId is for a city walk only. derived names the fields worked out on reading rather than stored with the tour: never present them as saved facts.',
inputSchema: {
tripId: idSchema,
placeId: idSchema.describe('The place id of the tour, as list_tours gives it'),
+22 -3
View File
@@ -4,7 +4,7 @@ import type { TourDetailResponse, TourWaypoint } from '@trek/shared';
import { describe, expect, it } from 'vitest';
// TOUR-PLAN-READ-001 to TOUR-PLAN-READ-005
// TOUR-PLAN-READ-001 to TOUR-PLAN-READ-006
/** The detail of a tour of this type, only as far as the plan reads it. */
function detailOf(tourType: TourDetailResponse['tour']['tour_type']): TourDetailResponse {
@@ -66,10 +66,10 @@ describe('tour plan read (MCP-T01)', () => {
],
},
});
expect(planLegs([portage, point(1)])).toEqual([
expect(planLegs([portage, point(1)], { recorded: false })).toEqual([
expect.objectContaining({ kind: 'portage', name: null, length_km: 1.11 }),
]);
expect(planLegs([point(0)])).toEqual([]);
expect(planLegs([point(0)], { recorded: false })).toEqual([]);
});
it('TOUR-PLAN-READ-004: a line of fewer than two points is no line', () => {
@@ -88,4 +88,23 @@ describe('tour plan read (MCP-T01)', () => {
expect(walk).not.toContain('tour.paddle_check');
expect(walk).not.toContain('run.open');
});
it('TOUR-PLAN-READ-006: a leg of a recorded track is a track, never routed, and a fixed leg keeps its own kind', () => {
expect(planLegs([point(0), point(1)], { recorded: false })).toEqual([
expect.objectContaining({ kind: 'routed', length_km: null }),
]);
expect(planLegs([point(0), point(1)], { recorded: true })).toEqual([
expect.objectContaining({ kind: 'track', name: null, length_km: null }),
]);
const free = point(0, {
fixed_leg: {
kind: 'free',
geometry: [
[48, 11],
[48.01, 11],
],
},
});
expect(planLegs([free, point(1)], { recorded: true })).toEqual([expect.objectContaining({ kind: 'free' })]);
});
});
+2 -2
View File
@@ -432,7 +432,7 @@ describe('ToursMcp', () => {
await orm.close();
});
it('TOURS-MCP-017: an import without control points answers the ends of its track as worked out, a tour without a line none', async () => {
it('TOURS-MCP-017: an import without control points answers the ends of its track as worked out and its leg as track, a tour without a line none', async () => {
const { user, trip, ridge, gorge } = fixture();
const orm = await createTestOrm(db);
const track = [
@@ -448,7 +448,7 @@ describe('ToursMcp', () => {
[47.01, undefined],
]);
expect(plan.legs).toEqual([
{ from_sequence: 0, to_sequence: 1, from_key: null, to_key: null, kind: 'routed', name: null, length_km: null },
{ from_sequence: 0, to_sequence: 1, from_key: null, to_key: null, kind: 'track', name: null, length_km: null },
]);
expect(plan.line).toEqual({ point_count: 3, has_heights: false, preview: track });
expect(plan.derived).toContain('waypoints');
+1 -1
View File
@@ -238,7 +238,7 @@ The saved tours of a trip, as the Tours list and the tour detail show them, how
| Tool | Description | Scope |
|---|---|---|
| `list_tours` | The saved tours of a trip: name, type (`hike`, `walk` for a city walk, `run`, `bike`, `kayak` for a paddle tour, `horse` for a ride, which has no riding time worked out, or `ski` for a ski track), origin (`trek` for planned in TREK, `gpx` for a GPX import, `wanderer` for a wanderer snapshot), distance, ascent and descent (unknown for a paddle tour drawn in the planner), the stored routing limit of a hike on the SAC scale and its route choices (`hiking_preferences`), the winter label of a hike (`winter_activity`: `winter_hike` or `snowshoe`; a label only, the route and its time are planned as for any hike, never a statement about winter conditions), the classification of a ski track (`ski_discipline`: `alpine`, `snowboard`, `cross_country` or `touring`; `ski_technique` for cross-country only: `classic`, `skating` or `unknown`; a ski track is imported from a GPX file and kept as recorded, never routed, has no ski time worked out and says nothing about snow, avalanches or open runs), the boat, water and chosen speed of a paddle tour, the check bench of a paddle tour (`paddle_check`: how far the OpenStreetMap obstacle data covers its route and how many obstacles are open, to check again or decided; a planning status, never a safety or legal clearance), for a city walk how many stations it has (`station_count`), their stays as far as entered and how many have none, whether a tour still waits for its way to be planned (`route_pending`: saved with its points only, its distance and walking time unknown until someone opens it in the planner and saves it), the `revision` (raised by every saved change of the tour), and the days of the trip each tour is on | `trips:read` |
| `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`), a stretch drawn by hand (`free`), which no router planned or checked, or on a ride a section of a signposted horse route (`horse_route`) in the direction chosen, with its signposting and the `horse=*` tags its ways carried; being on a route is no permission to ride it. 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 stations of a city walk carry every field spelled out: `key` (the same across saves), `name`, `stay_minutes` (`0` for a point only passed, `null` when not known), `importance` (`optional`, `must`, or `null` for normal), `protected` (a station a proposed change has to keep where it is; the travellers can still edit it) and `opening_hours` (as OpenStreetMap writes them, `null` when none is known). Every leg from one control point to the next is listed in `legs` with its kind (`routed`, `ferrata`, `free`, `portage` or `horse_route`) and, for a fixed leg, the length of its own line; a routed leg has no length of its own stored. `line` gives the saved line in brief: how many points it has, whether they carry heights, and a preview thinned to 200 points. Pass `dayId` with a city walk to read the visits of that day: each station then says `visit_status` (planned, visited or skipped) and `run` what is still open; without a day no visit is told. `derived` names every field worked out on reading rather than stored with the tour. Reading changes nothing, not even the revision. The full track geometry is not included | `trips:read` |
| `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`), a stretch drawn by hand (`free`), which no router planned or checked, or on a ride a section of a signposted horse route (`horse_route`) in the direction chosen, with its signposting and the `horse=*` tags its ways carried; being on a route is no permission to ride it. 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 stations of a city walk carry every field spelled out: `key` (the same across saves), `name`, `stay_minutes` (`0` for a point only passed, `null` when not known), `importance` (`optional`, `must`, or `null` for normal), `protected` (a station a proposed change has to keep where it is; the travellers can still edit it) and `opening_hours` (as OpenStreetMap writes them, `null` when none is known). Every leg from one control point to the next is listed in `legs` with its kind (`routed` for a leg the router planned, `track` for part of a recorded line no router planned, as for an import without control points of its own, `ferrata`, `free`, `portage` or `horse_route`) and, for a fixed leg, the length of its own line; a routed or track leg has no length of its own stored. `line` gives the saved line in brief: how many points it has, whether they carry heights, and a preview thinned to 200 points. Pass `dayId` with a city walk to read the visits of that day: each station then says `visit_status` (planned, visited or skipped) and `run` what is still open; without a day no visit is told. `derived` names every field worked out on reading rather than stored with the tour. Reading changes nothing, not even the revision. 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` |
| `get_city_walk_progress` | How far a city walk got on one day it is on: every station in order with its key, its status that day (planned, visited or skipped, as checked off in the day plan), importance, protection, stay and opening hours, what is still open, and the walking time of the whole walk. Visits belong to that day only. Reading changes nothing | `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` |