feat(assistant): the assistant plans hikes over several days in stages

- propose_hike, create_hike and update_tour take stage_end on the stops a day
  ends at, checked by the tours' own stage rules (never at the start or the
  end, at most 60 stages) on REST and MCP alike.
- A proposal over several days names its stage ends and sets no single day's
  schedule; its card counts the stages and marks the stops they end at.
- New points for a hike in stages keep the keys of the stops they stand on,
  so a stage keeps its day; they are refused only where they would drop every
  stage end.
- The tours group and the prompt name the stages, how to put each on its day,
  and what to offer once a tour is planned: places along it, its packing, the
  GPX file.
This commit is contained in:
Maurice
2026-10-23 16:13:11 +02:00
parent 7f9a4da52a
commit 746f53ff7e
12 changed files with 215 additions and 30 deletions
+25
View File
@@ -4,6 +4,7 @@ import {
HIKE_MAX_WAYPOINTS,
HIKE_PROPOSAL_SAC_SEGMENTS,
hikeCreateRequestSchema,
hikeStageIssues,
hikeProposalSchema,
hikeProposeRequestSchema,
hikeRouteOutcomeSchema,
@@ -50,6 +51,7 @@ const proposal = {
snapped_m: 12,
minutes: 0,
arrival: '08:30',
stage_end: false,
},
{
lat: 47.41,
@@ -60,6 +62,7 @@ const proposal = {
snapped_m: 4,
minutes: 200,
arrival: '11:50',
stage_end: false,
},
],
along: [{ name: 'Hut', category: 'shelter', lat: 47.405, lng: 11.101, at_m: 4100, off_m: 40 }],
@@ -237,3 +240,25 @@ describe('a hike created, changed or taken in from a file over MCP', () => {
expect(Object.keys(tourGpxImportRequestSchema.shape)).toContain('tour_type');
});
});
describe('a hike over several days', () => {
const three = [point(0), { ...point(1), stage_end: true }, point(2)];
it('TOUR-HIKE-SCHEMA-010: takes a stage end on a stop between start and end, in a proposal, a create and a change', () => {
expect(hikeProposeRequestSchema.safeParse({ name: 'Trek', waypoints: three }).success).toBe(true);
expect(hikeCreateRequestSchema.safeParse({ name: 'Trek', waypoints: three }).success).toBe(true);
expect(tourUpdateRequestSchema.safeParse({ placeId: 4, expected_revision: 2, waypoints: three }).success).toBe(
true,
);
});
it('TOUR-HIKE-SCHEMA-011: refuses a stage end at the start or the end; the last point given of a round trip is a stop on the way', () => {
const atStart = [{ ...point(0), stage_end: true }, point(1)];
const atEnd = [point(0), { ...point(1), stage_end: true }];
expect(hikeProposeRequestSchema.safeParse({ name: 'X', waypoints: atStart }).success).toBe(false);
expect(hikeCreateRequestSchema.safeParse({ name: 'X', waypoints: atEnd }).success).toBe(false);
expect(hikeProposeRequestSchema.safeParse({ name: 'X', waypoints: atEnd, round_trip: true }).success).toBe(true);
expect(hikeStageIssues(atEnd, false).map((issue) => issue.path)).toEqual([['waypoints', 1, 'stage_end']]);
expect(hikeStageIssues(atEnd, true)).toEqual([]);
});
});
+54 -5
View File
@@ -15,6 +15,7 @@ import { hikeScheduleSchema, hikeWarningSchema, HIKE_WARNING_KINDS } from './hik
import { includeGeometryFlagSchema } from './tour-display.schema';
import { tourGpxImportFieldsSchema } from './tour-gpx-import.schema';
import { tourPoiCategorySchema, type TourPoiCategory } from './tour-poi-category';
import { tourStageIssues } from './tour-stages';
import {
hikingPreferencesSchema,
plannedTourDurationMinutesSchema,
@@ -54,14 +55,57 @@ export const HIKE_ALONG_MAX = 12;
/** The largest GPX text a tool takes in, as the road trip's GPX import does. */
export const TOUR_GPX_TEXT_MAX = 1_000_000;
/**
* A point of a hike the assistant plans: the planning point, and on a hike
* over several days whether a day's stage ends there (MDT-01), where the
* night is spent and the next stage starts.
*/
const hikeStopSchema = hikePlanPointSchema
.extend({
stage_end: z
.boolean()
.optional()
.describe(
'On a hike over several days: true on the point where a day ends (the hut or village the night is spent at), where the next stage starts. Only on points between the start and the end; leave it out on a day hike',
),
})
.strict();
export type HikeStop = z.infer<typeof hikeStopSchema>;
const hikeWaypointsSchema = z
.array(hikePlanPointSchema)
.array(hikeStopSchema)
.min(2)
.max(HIKE_MAX_WAYPOINTS)
.describe(
'The points the hike goes through in order: its start, the points on the way (a hut, a summit, a lake) and its end. For a round trip leave the end out and set round_trip',
'The points the hike goes through in order: its start, the points on the way (a hut, a summit, a lake) and its end. For a round trip leave the end out and set round_trip. A hike over several days marks the stop of each night with stage_end',
);
/**
* Where a hike's stage ends do not hold, as the tour's own rules read them
* (`tourStageIssues`): never at the start or the end, at most MAX_TOUR_STAGES
* stages. A round trip comes back to its start, so its last point given is a
* stop on the way.
*/
export function hikeStageIssues(
points: readonly Pick<HikeStop, 'lat' | 'lng' | 'stage_end'>[],
roundTrip: boolean | undefined,
): ReturnType<typeof tourStageIssues> {
const last = roundTrip === true ? points.length : points.length - 1;
return tourStageIssues(
points.map((point, index) => ({
role: index === 0 ? ('start' as const) : index === last ? ('end' as const) : ('via' as const),
stage_end: point.stage_end,
})),
);
}
function refineStages(value: { waypoints?: readonly HikeStop[]; round_trip?: boolean }, ctx: z.RefinementCtx): void {
if (!value.waypoints) return;
for (const issue of hikeStageIssues(value.waypoints, value.round_trip)) {
ctx.addIssue({ code: 'custom', path: issue.path, message: issue.message });
}
}
const hikeLimitSchema = tourMaxHikingDifficultySchema.describe(
'The hardest SAC grade the way may take, 1 (T1, hiking) to 6 (T6, difficult alpine hiking); 2 (T2, mountain hiking) when left out',
);
@@ -114,7 +158,8 @@ export const hikeProposeRequestSchema = z
),
include_geometry: includeGeometryFlagSchema,
})
.strict();
.strict()
.superRefine(refineStages);
export type HikeProposeRequest = z.infer<typeof hikeProposeRequestSchema>;
/** Whether the way was planned, and why not: the plan's reasons, with the point no way reaches. */
@@ -175,6 +220,8 @@ export const hikeProposalWaypointSchema = z.object({
minutes: z.number().int().nonnegative().nullable(),
/** When it is reached, HH:MM; null without a start. */
arrival: clockTimeSchema.nullable(),
/** Whether a day's stage ends here, on a hike over several days. */
stage_end: z.boolean(),
});
export type HikeProposalWaypoint = z.infer<typeof hikeProposalWaypointSchema>;
@@ -240,7 +287,8 @@ export const hikeCreateRequestSchema = z
.optional()
.describe('When the hike starts on that day, HH:MM, kept as the start of its visit; only with day_id'),
})
.strict();
.strict()
.superRefine(refineStages);
export type HikeCreateRequest = z.infer<typeof hikeCreateRequestSchema>;
export const tourUpdateRequestSchema = z
@@ -260,7 +308,8 @@ export const tourUpdateRequestSchema = z
hiking_preferences: hikePreferencesSchema.optional(),
planned_duration_minutes: plannedMinutesSchema.optional(),
})
.strict();
.strict()
.superRefine(refineStages);
export type TourUpdateRequest = z.infer<typeof tourUpdateRequestSchema>;
/**