From 791e77fa3368fdd66844a57262f620cd611269aa Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 23 Sep 2026 15:44:32 +0100 Subject: [PATCH] =?UTF-8?q?feat(mapillary):=20dev-server=20provider=20?= =?UTF-8?q?=E2=80=94=20cached=20tiles,=20feature=20stream,=20sprites,=20op?= =?UTF-8?q?tional=20Claude=20planner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vite middleware for /api/mapillary: status (capabilities, never values), vector tiles cached in memory and on disk with the 12 MB z14 image layer stripped in transit, an NDJSON feature stream capped at 600 tiles, MIT sprite icons behind a grammar-guarded path, and a structured-output planner that only activates when ANTHROPIC_API_KEY is set server-side. Co-Authored-By: Claude Fable 5.1 --- server/providers/mapillary/features.js | 2 +- server/providers/mapillary/planner.js | 41 ++++++++++++++++++++++---- server/providers/mapillary/sprites.js | 4 +++ 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/server/providers/mapillary/features.js b/server/providers/mapillary/features.js index ffa038b35..40f907d52 100644 --- a/server/providers/mapillary/features.js +++ b/server/providers/mapillary/features.js @@ -26,7 +26,7 @@ function sendJson(res, status, payload) { } /** Parse an ISO date (or epoch ms) into epoch ms, or null when absent/invalid. */ -export function parseEpochMs(value) { +function parseEpochMs(value) { if (value === null || value === undefined || value === '') return null; if (typeof value === 'number') return Number.isFinite(value) ? value : null; const parsed = Date.parse(String(value)); diff --git a/server/providers/mapillary/planner.js b/server/providers/mapillary/planner.js index 1001b9371..0160a72ce 100644 --- a/server/providers/mapillary/planner.js +++ b/server/providers/mapillary/planner.js @@ -1,6 +1,5 @@ import Anthropic from '@anthropic-ai/sdk'; import { z } from 'zod'; -import { zodOutputFormat } from '@anthropic-ai/sdk/helpers/zod'; import { readRequestBody } from '../common/request.js'; import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js'; import { @@ -26,7 +25,7 @@ import { * The plan the model returns. Every field is required (structured outputs * reject optional keys); "unknown" is spelled with null. */ -export const PlanSchema = z.object({ +const PlanSchema = z.object({ intent: z.enum([ 'map_features', 'traffic_signs', @@ -182,6 +181,35 @@ export function buildPlannerUserMessage({ query, context = {}, today }) { return lines.join('\n'); } +/** + * Structured-output format for the plan: the Zod schema as JSON Schema. Kept + * free of the SDK's zod helper so the provider only depends on package roots. + */ +function planOutputFormat() { + const { $schema: _schema, ...schema } = z.toJSONSchema(PlanSchema, { + reused: 'ref', + }); + return { type: 'json_schema', schema }; +} + +/** The plan object from a structured-output response, or null. */ +function parsePlanResponse(response) { + if (response?.parsed_output) return response.parsed_output; + const text = (response?.content || []) + .filter((block) => block?.type === 'text') + .map((block) => block.text) + .join(''); + if (!text.trim()) return null; + let raw; + try { + raw = JSON.parse(text); + } catch { + return null; + } + const result = PlanSchema.safeParse(raw); + return result.success ? result.data : null; +} + /** * Ask Claude for a plan. Exported for tests with an injectable client. * @returns {Promise<{plan: object, usage: object|null, model: string}>} @@ -190,7 +218,7 @@ export async function planQuery( { query, context }, { anthropic = client(), model = plannerModel(), now = new Date() } = {}, ) { - const response = await anthropic.messages.parse({ + const response = await anthropic.messages.create({ model, max_tokens: PLANNER_MAX_TOKENS, system: [ @@ -210,7 +238,7 @@ export async function planQuery( }), }, ], - output_config: { format: zodOutputFormat(PlanSchema), effort: 'low' }, + output_config: { format: planOutputFormat(), effort: 'low' }, }); if (response.stop_reason === 'refusal') { return { @@ -230,10 +258,11 @@ export async function planQuery( model, }; } - if (!response.parsed_output) + const parsed = parsePlanResponse(response); + if (!parsed) throw Object.assign(new Error('Planner returned no plan'), { status: 502 }); return { - plan: finalizePlan(response.parsed_output), + plan: finalizePlan(parsed), usage: response.usage ?? null, model, }; diff --git a/server/providers/mapillary/sprites.js b/server/providers/mapillary/sprites.js index c2f888555..15cf2ef15 100644 --- a/server/providers/mapillary/sprites.js +++ b/server/providers/mapillary/sprites.js @@ -1,3 +1,7 @@ +/** + * Sprite proxy. Icons come from github.com/mapillary/mapillary_sprite_source + * (MIT, © Mapillary), fetched on demand and cached on disk. + */ import path from 'node:path'; import { promises as fsp } from 'node:fs'; import {