mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 10:53:59 +00:00
refactor(mapillary): remove the plain-English query feature
Street Level is imagery only from here on: coverage, sequences and the embedded viewer. The Claude planner, map-feature and traffic-sign tiles, the NDJSON feature stream, sprites, detection outlines and analyst records go, along with @anthropic-ai/sdk, zod and the Anthropic key entry. Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
1 parent
9ba6f96d87
commit
547b706f67
44 files changed
+143
-5318
No files matched your search
@@ -14,23 +14,8 @@ export const TILE_LAYERS = Object.freeze({
|
||||
maxZoom: 14,
|
||||
dropLayers: Object.freeze(['image']),
|
||||
}),
|
||||
// Point map features such as fire hydrants, benches and utility poles.
|
||||
points: Object.freeze({
|
||||
upstream: 'mly_map_feature_point',
|
||||
minZoom: 14,
|
||||
maxZoom: 14,
|
||||
}),
|
||||
// Classified traffic-sign detections.
|
||||
signs: Object.freeze({
|
||||
upstream: 'mly_map_feature_traffic_sign',
|
||||
minZoom: 14,
|
||||
maxZoom: 14,
|
||||
}),
|
||||
});
|
||||
|
||||
/** Zoom at which Mapillary publishes map-feature tiles. */
|
||||
export const FEATURE_TILE_ZOOM = 14;
|
||||
|
||||
/** Disk cache root, alongside the other providers' caches. */
|
||||
export const MAPILLARY_CACHE_DIR = path.join(
|
||||
process.cwd(),
|
||||
@@ -38,56 +23,22 @@ export const MAPILLARY_CACHE_DIR = path.join(
|
||||
'mapillary',
|
||||
);
|
||||
export const TILE_DISK_DIR = path.join(MAPILLARY_CACHE_DIR, 'tiles');
|
||||
export const SPRITE_DISK_DIR = path.join(MAPILLARY_CACHE_DIR, 'sprites');
|
||||
|
||||
/**
|
||||
* Tiles change when new imagery is processed, which is far slower than the
|
||||
* 15-minute upstream Cache-Control. A day keeps a city-wide query from
|
||||
* re-downloading hundreds of megabytes on every run.
|
||||
* 15-minute upstream Cache-Control. A day keeps a session over one city from
|
||||
* re-downloading its tiles on every camera move.
|
||||
*/
|
||||
export const TILE_DISK_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
export const SPRITE_DISK_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** A z14 image tile over a dense city is ~11 MB; anything past this is wrong. */
|
||||
export const TILE_MAX_BYTES = 48 * 1024 * 1024;
|
||||
export const SPRITE_MAX_BYTES = 512 * 1024;
|
||||
|
||||
/** In-memory tile cache budget (bytes) and upstream fetch timeout. */
|
||||
export const TILE_MEMORY_BUDGET_BYTES = 96 * 1024 * 1024;
|
||||
export const TILE_FETCH_TIMEOUT_MS = 60_000;
|
||||
|
||||
/** Executor limits for one natural-language query. */
|
||||
export const QUERY_MAX_TILES = 600;
|
||||
export const QUERY_MAX_FEATURES = 120_000;
|
||||
export const QUERY_TILE_CONCURRENCY = 6;
|
||||
export const QUERY_MAX_BODY_BYTES = 32 * 1024;
|
||||
|
||||
/** Sprite SVG source (MIT) — one file per taxonomy value. */
|
||||
export const SPRITE_SOURCE_ROOT =
|
||||
'https://raw.githubusercontent.com/mapillary/mapillary_sprite_source/master';
|
||||
|
||||
/** Planner defaults; the model id is external and may be overridden by env. */
|
||||
export const PLANNER_MODEL_DEFAULT = 'claude-opus-5';
|
||||
export const PLANNER_MAX_TOKENS = 1500;
|
||||
export const PLANNER_MAX_BODY_BYTES = 32 * 1024;
|
||||
export const PLANNER_MAX_QUERY_CHARS = 600;
|
||||
|
||||
/** The client token lives in the browser by design; the server adds it to tile URLs too. */
|
||||
export function mapillaryToken() {
|
||||
return String(process.env.MAPILLARY_CLIENT_TOKEN || '').trim();
|
||||
}
|
||||
|
||||
/** Anthropic credentials are server-side only. */
|
||||
export function anthropicConfigured() {
|
||||
return Boolean(
|
||||
String(process.env.ANTHROPIC_API_KEY || '').trim() ||
|
||||
String(process.env.ANTHROPIC_AUTH_TOKEN || '').trim(),
|
||||
);
|
||||
}
|
||||
|
||||
export function plannerModel() {
|
||||
return (
|
||||
String(process.env.ANTHROPIC_PLANNER_MODEL || '').trim() ||
|
||||
PLANNER_MODEL_DEFAULT
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { PbfReader } from 'pbf';
|
||||
import { VectorTile } from '@mapbox/vector-tile';
|
||||
import { tileLocalToLonLat } from '../../../src/layers/mapillary/tileMath.js';
|
||||
|
||||
/** Vector-tile layer name inside each Mapillary map-feature tile. */
|
||||
const FEATURE_LAYER_NAMES = Object.freeze({
|
||||
points: 'point',
|
||||
signs: 'traffic_sign',
|
||||
});
|
||||
|
||||
/**
|
||||
* Decode one map-feature tile into compact rows, keeping only features whose
|
||||
* value passes `matcher` and whose sighting window overlaps [since, until].
|
||||
* Rows are positional arrays to keep the streamed payload small:
|
||||
* [lon, lat, id, value, firstSeenMs, lastSeenMs].
|
||||
* @param {Buffer|Uint8Array} bytes
|
||||
* @param {{x:number,y:number,z:number,layer:'points'|'signs'}} address
|
||||
* @param {{matcher?: (value:string)=>boolean, since?: number|null, until?: number|null}} [filter]
|
||||
* @returns {Array<[number, number, string, string, number, number]>}
|
||||
*/
|
||||
export function decodeFeatureRows(bytes, address, filter = {}) {
|
||||
if (!bytes || !bytes.length) return [];
|
||||
const layerName = FEATURE_LAYER_NAMES[address.layer];
|
||||
const tile = new VectorTile(new PbfReader(bytes));
|
||||
const layer = layerName ? tile.layers[layerName] : null;
|
||||
if (!layer) return [];
|
||||
const matcher = filter.matcher || (() => true);
|
||||
const since = Number.isFinite(filter.since) ? filter.since : null;
|
||||
const until = Number.isFinite(filter.until) ? filter.until : null;
|
||||
const rows = [];
|
||||
for (let i = 0; i < layer.length; i++) {
|
||||
const feature = layer.feature(i);
|
||||
const value = feature.properties?.value;
|
||||
if (typeof value !== 'string' || !matcher(value)) continue;
|
||||
const first = Number(feature.properties.first_seen_at) || 0;
|
||||
const last = Number(feature.properties.last_seen_at) || first;
|
||||
if (since !== null && last < since) continue;
|
||||
if (until !== null && first > until) continue;
|
||||
const geometry = feature.loadGeometry();
|
||||
const point = geometry?.[0]?.[0];
|
||||
if (!point) continue;
|
||||
const [lon, lat] = tileLocalToLonLat(
|
||||
point.x,
|
||||
point.y,
|
||||
layer.extent,
|
||||
address.x,
|
||||
address.y,
|
||||
address.z,
|
||||
);
|
||||
rows.push([
|
||||
Number(lon.toFixed(6)),
|
||||
Number(lat.toFixed(6)),
|
||||
String(feature.properties.id ?? feature.id ?? ''),
|
||||
value,
|
||||
first,
|
||||
last,
|
||||
]);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
import { readRequestBody } from '../common/request.js';
|
||||
import {
|
||||
FEATURE_TILE_ZOOM,
|
||||
QUERY_MAX_BODY_BYTES,
|
||||
QUERY_MAX_FEATURES,
|
||||
QUERY_MAX_TILES,
|
||||
QUERY_TILE_CONCURRENCY,
|
||||
mapillaryToken,
|
||||
} from './constants.js';
|
||||
import { fetchTile, TileRequestError } from './tiles.js';
|
||||
import { decodeFeatureRows } from './decode.js';
|
||||
import {
|
||||
normalizeBbox,
|
||||
tilesForBbox,
|
||||
} from '../../../src/layers/mapillary/tileMath.js';
|
||||
import {
|
||||
createValueMatcher,
|
||||
normalizeValuePattern,
|
||||
} from '../../../src/layers/mapillary/values.js';
|
||||
|
||||
function sendJson(res, status, payload) {
|
||||
res.statusCode = status;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
/** Parse an ISO date (or epoch ms) into epoch ms, or null when absent/invalid. */
|
||||
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));
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a feature query body into an executable request.
|
||||
* @returns {{ok:true, request:object}|{ok:false, status:number, error:string, detail?:object}}
|
||||
*/
|
||||
export function normalizeFeatureQuery(body) {
|
||||
const bbox = normalizeBbox(body?.bbox);
|
||||
if (!bbox)
|
||||
return {
|
||||
ok: false,
|
||||
status: 400,
|
||||
error: 'A bbox [west,south,east,north] is required',
|
||||
};
|
||||
const layer = body?.layer === 'signs' ? 'signs' : 'points';
|
||||
const values = Array.isArray(body?.values)
|
||||
? body.values.map(normalizeValuePattern).filter(Boolean).slice(0, 64)
|
||||
: [];
|
||||
const since = parseEpochMs(body?.seenAfter);
|
||||
const until = parseEpochMs(body?.seenBefore);
|
||||
const { tiles, truncated, total } = tilesForBbox(bbox, FEATURE_TILE_ZOOM, {
|
||||
limit: QUERY_MAX_TILES,
|
||||
});
|
||||
if (truncated)
|
||||
return {
|
||||
ok: false,
|
||||
status: 413,
|
||||
error: 'area_too_large',
|
||||
detail: { tiles: total, limit: QUERY_MAX_TILES },
|
||||
};
|
||||
return { ok: true, request: { bbox, layer, values, since, until, tiles } };
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/mapillary/features — fan a bbox out over z14 map-feature tiles
|
||||
* and stream the matching features back as NDJSON, one line per tile, so
|
||||
* the globe fills in progressively while the rest of the city downloads.
|
||||
*/
|
||||
export async function handleFeatureQuery(req, res) {
|
||||
if (req.method !== 'POST')
|
||||
return sendJson(res, 405, { error: 'Method not allowed' });
|
||||
if (!mapillaryToken())
|
||||
return sendJson(res, 503, { error: 'no_key', keyRequired: true });
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(
|
||||
(await readRequestBody(req, QUERY_MAX_BODY_BYTES)) || '{}',
|
||||
);
|
||||
} catch (error) {
|
||||
return sendJson(res, 400, { error: error?.message || 'Invalid JSON body' });
|
||||
}
|
||||
const normalized = normalizeFeatureQuery(body);
|
||||
if (!normalized.ok)
|
||||
return sendJson(res, normalized.status, {
|
||||
error: normalized.error,
|
||||
...normalized.detail,
|
||||
});
|
||||
const { layer, values, since, until, tiles, bbox } = normalized.request;
|
||||
const matcher = createValueMatcher(values);
|
||||
|
||||
const abandoned = new AbortController();
|
||||
req.on?.('aborted', () => abandoned.abort());
|
||||
res.on?.('close', () => abandoned.abort());
|
||||
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'application/x-ndjson; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
const write = (line) => {
|
||||
if (res.writableEnded || abandoned.signal.aborted) return false;
|
||||
res.write(`${JSON.stringify(line)}\n`);
|
||||
return true;
|
||||
};
|
||||
write({
|
||||
type: 'start',
|
||||
tiles: tiles.length,
|
||||
zoom: FEATURE_TILE_ZOOM,
|
||||
layer,
|
||||
values,
|
||||
bbox,
|
||||
});
|
||||
|
||||
let total = 0;
|
||||
let failed = 0;
|
||||
let truncated = false;
|
||||
let cursor = 0;
|
||||
const worker = async () => {
|
||||
while (cursor < tiles.length && !abandoned.signal.aborted) {
|
||||
if (total >= QUERY_MAX_FEATURES) {
|
||||
truncated = true;
|
||||
return;
|
||||
}
|
||||
const index = cursor++;
|
||||
const tile = tiles[index];
|
||||
try {
|
||||
const { bytes, source } = await fetchTile(
|
||||
{ layer, z: tile.z, x: tile.x, y: tile.y },
|
||||
{ signal: abandoned.signal },
|
||||
);
|
||||
const rows = decodeFeatureRows(
|
||||
bytes,
|
||||
{ ...tile, layer },
|
||||
{ matcher, since, until },
|
||||
);
|
||||
total += rows.length;
|
||||
write({ type: 'tile', i: index, x: tile.x, y: tile.y, source, rows });
|
||||
} catch (error) {
|
||||
if (abandoned.signal.aborted) return;
|
||||
failed++;
|
||||
write({
|
||||
type: 'tile',
|
||||
i: index,
|
||||
x: tile.x,
|
||||
y: tile.y,
|
||||
rows: [],
|
||||
error:
|
||||
error instanceof TileRequestError
|
||||
? error.message
|
||||
: `tile unavailable (${error?.status || error?.message || 'error'})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
await Promise.all(
|
||||
Array.from(
|
||||
{ length: Math.min(QUERY_TILE_CONCURRENCY, tiles.length) },
|
||||
worker,
|
||||
),
|
||||
);
|
||||
write({ type: 'done', total, failed, truncated, tiles: tiles.length });
|
||||
if (!res.writableEnded) res.end();
|
||||
}
|
||||
@@ -1,322 +0,0 @@
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
import { z } from 'zod';
|
||||
import { readRequestBody } from '../common/request.js';
|
||||
import { makeOptInRateLimiter, clientKey } from '../common/rate-limit.js';
|
||||
import {
|
||||
PLANNER_MAX_BODY_BYTES,
|
||||
PLANNER_MAX_QUERY_CHARS,
|
||||
PLANNER_MAX_TOKENS,
|
||||
anthropicConfigured,
|
||||
mapillaryToken,
|
||||
plannerModel,
|
||||
} from './constants.js';
|
||||
import {
|
||||
OBJECT_VALUES,
|
||||
SIGN_FAMILIES,
|
||||
SIGN_VALUES,
|
||||
} from '../../../src/layers/mapillary/taxonomy.js';
|
||||
import {
|
||||
createValueMatcher,
|
||||
isSignValue,
|
||||
normalizeValuePattern,
|
||||
} from '../../../src/layers/mapillary/values.js';
|
||||
|
||||
/**
|
||||
* The plan the model returns. Every field is required (structured outputs
|
||||
* reject optional keys); "unknown" is spelled with null.
|
||||
*/
|
||||
const PlanSchema = z.object({
|
||||
intent: z.enum([
|
||||
'map_features',
|
||||
'traffic_signs',
|
||||
'coverage',
|
||||
'nearest_image',
|
||||
'unsupported',
|
||||
]),
|
||||
place: z.string().nullable(),
|
||||
use_current_view: z.boolean(),
|
||||
values: z.array(z.string()),
|
||||
seen_after: z.string().nullable(),
|
||||
seen_before: z.string().nullable(),
|
||||
prefer_pano: z.boolean(),
|
||||
visualise: z.enum(['icons', 'points', 'count']),
|
||||
title: z.string(),
|
||||
answer: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Frozen system prompt. Nothing volatile lives here so the block can be
|
||||
* prompt-cached across every query; the date and view context ride in the
|
||||
* user turn instead.
|
||||
*/
|
||||
export const PLANNER_SYSTEM_PROMPT = [
|
||||
"You translate natural-language requests about street-level map data into a query plan for God's Eye View, a 3D globe that can draw Mapillary map features (objects detected in street imagery) and traffic signs, show Mapillary coverage, or open the nearest street-level image.",
|
||||
'',
|
||||
'Return ONLY a plan object. Field rules:',
|
||||
'- intent: "map_features" for objects (hydrants, benches, poles, manholes, traffic lights, road markings…); "traffic_signs" for signs (stop, speed limit, no parking, pedestrian crossing…); "coverage" when the user asks where imagery exists or how recent it is; "nearest_image" when they want to see/open/look at the street view of a place; "unsupported" when the request is not about Mapillary data (then explain in `answer`).',
|
||||
'- place: the place to search, as a geocodable string ("Sacramento, California"; "Detroit, Michigan"; "Shinjuku, Tokyo"). null when the user means the current view ("here", "around me", "in this area") or gives no place at all.',
|
||||
'- use_current_view: true when place is null and the current view should be used.',
|
||||
'- values: Mapillary taxonomy values for the requested things. Use exact values from the lists below. A wildcard `*` matches a run of characters: use `regulatory--stop--*` to cover every regional variant of a sign family, `object--traffic-light--*` for all traffic lights, `marking--discrete--arrow--*` for all arrow markings. Include several values when the request is broad ("street furniture" → benches, trash cans, bike racks, mailboxes). Leave empty only for coverage or nearest_image intents.',
|
||||
'- seen_after / seen_before: ISO dates (YYYY-MM-DD) when the user constrains time ("since 2024", "before 2022", "in the last two years" relative to today); otherwise null.',
|
||||
'- prefer_pano: true when the user asks for 360°, panoramic or immersive imagery (nearest_image and coverage intents); otherwise false.',
|
||||
'- visualise: "icons" by default; "points" when the user asks for density or a very broad class; "count" when they only ask how many.',
|
||||
'- title: a short HUD title, e.g. "Fire hydrants · Sacramento".',
|
||||
'- answer: one plain sentence confirming what will be shown, mentioning any interpretation you made. Never claim counts; the data has not been fetched yet.',
|
||||
'',
|
||||
'Follow-ups: when a previous plan is supplied and the new request refines the same subject ("only the ones seen after 2024", "just the panoramas", "same thing in Detroit"), start from the previous plan and change only what the request changes. A request that names a new subject or a new place ("stop signs in downtown Detroit", "now benches here") starts fresh: keep nothing from the previous plan unless the user says "same", "also", "too" or "keep".',
|
||||
'',
|
||||
'Point object values (layer map_features):',
|
||||
OBJECT_VALUES.join(', '),
|
||||
'',
|
||||
'Traffic-sign families (layer traffic_signs). Each family exists as several regional variants suffixed --g1, --g2…; always request a family with the wildcard form `<family>--*`:',
|
||||
SIGN_FAMILIES.join(', '),
|
||||
].join('\n');
|
||||
|
||||
const KNOWN_VALUES = new Set([...OBJECT_VALUES, ...SIGN_VALUES]);
|
||||
|
||||
/**
|
||||
* Keep only values that exist in the taxonomy (exact) or match at least one
|
||||
* taxonomy value (wildcard). Returns the surviving patterns plus what was
|
||||
* dropped so the UI can say so.
|
||||
*/
|
||||
export function reconcilePlanValues(values) {
|
||||
const kept = [];
|
||||
const dropped = [];
|
||||
for (const raw of values || []) {
|
||||
const pattern = normalizeValuePattern(raw);
|
||||
if (!pattern) continue;
|
||||
if (pattern.includes('*')) {
|
||||
const matches = createValueMatcher([pattern]);
|
||||
if ([...KNOWN_VALUES].some(matches)) kept.push(pattern);
|
||||
else dropped.push(pattern);
|
||||
} else if (KNOWN_VALUES.has(pattern)) kept.push(pattern);
|
||||
else if (KNOWN_VALUES.has(`${pattern}--g1`)) kept.push(`${pattern}--*`);
|
||||
else dropped.push(pattern);
|
||||
}
|
||||
return { values: [...new Set(kept)], dropped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the model's plan into the executable plan the browser runs: values
|
||||
* reconciled against the taxonomy, the tile layer derived from the values,
|
||||
* and an honest downgrade to "unsupported" when nothing usable survived.
|
||||
*/
|
||||
export function finalizePlan(plan) {
|
||||
const { values, dropped } = reconcilePlanValues(plan.values);
|
||||
let intent = plan.intent;
|
||||
let layer = null;
|
||||
if (intent === 'map_features' || intent === 'traffic_signs') {
|
||||
if (!values.length) {
|
||||
return {
|
||||
...plan,
|
||||
intent: 'unsupported',
|
||||
values: [],
|
||||
dropped,
|
||||
layer: null,
|
||||
answer: dropped.length
|
||||
? `I could not map "${dropped.join('", "')}" onto a Mapillary object or sign class.`
|
||||
: plan.answer,
|
||||
};
|
||||
}
|
||||
const signs = values.filter(isSignValue).length;
|
||||
layer = signs && signs === values.length ? 'signs' : 'points';
|
||||
intent = layer === 'signs' ? 'traffic_signs' : 'map_features';
|
||||
if (signs && signs !== values.length) {
|
||||
// Mixed request: signs live in a different tile set. Keep the object
|
||||
// half and say so rather than silently dropping either.
|
||||
return {
|
||||
...plan,
|
||||
intent,
|
||||
layer,
|
||||
values: values.filter((value) => !isSignValue(value)),
|
||||
dropped: [...dropped, ...values.filter(isSignValue)],
|
||||
answer: `${plan.answer} Traffic signs and objects come from different layers, so this run shows the objects only.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { ...plan, intent, layer, values, dropped };
|
||||
}
|
||||
|
||||
let _client;
|
||||
function client() {
|
||||
return (_client ||= new Anthropic());
|
||||
}
|
||||
|
||||
let _limiter;
|
||||
function limiter() {
|
||||
if (_limiter === undefined)
|
||||
_limiter = makeOptInRateLimiter(
|
||||
process.env.GEV_RATELIMIT_ANTHROPIC_PER_MIN,
|
||||
);
|
||||
return _limiter;
|
||||
}
|
||||
|
||||
function sendJson(res, status, payload) {
|
||||
res.statusCode = status;
|
||||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
/** Build the volatile user turn: the request plus whatever the globe knows. */
|
||||
export function buildPlannerUserMessage({ query, context = {}, today }) {
|
||||
const lines = [`Today is ${today}.`];
|
||||
const view = context.view;
|
||||
if (view && Number.isFinite(view.lat) && Number.isFinite(view.lon)) {
|
||||
lines.push(
|
||||
`Current view: ${view.lat.toFixed(4)}, ${view.lon.toFixed(4)}` +
|
||||
(Number.isFinite(view.heightM)
|
||||
? `, camera ${Math.round(view.heightM)} m above ground`
|
||||
: '') +
|
||||
(view.label ? `, near ${String(view.label).slice(0, 120)}` : '') +
|
||||
'.',
|
||||
);
|
||||
}
|
||||
if (context.previousPlan) {
|
||||
lines.push(
|
||||
`Previous plan: ${JSON.stringify(context.previousPlan).slice(0, 1500)}`,
|
||||
);
|
||||
}
|
||||
lines.push('', `Request: ${query}`);
|
||||
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}>}
|
||||
*/
|
||||
export async function planQuery(
|
||||
{ query, context },
|
||||
{ anthropic = client(), model = plannerModel(), now = new Date() } = {},
|
||||
) {
|
||||
const response = await anthropic.messages.create({
|
||||
model,
|
||||
max_tokens: PLANNER_MAX_TOKENS,
|
||||
system: [
|
||||
{
|
||||
type: 'text',
|
||||
text: PLANNER_SYSTEM_PROMPT,
|
||||
cache_control: { type: 'ephemeral' },
|
||||
},
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: buildPlannerUserMessage({
|
||||
query,
|
||||
context,
|
||||
today: now.toISOString().slice(0, 10),
|
||||
}),
|
||||
},
|
||||
],
|
||||
output_config: { format: planOutputFormat(), effort: 'low' },
|
||||
});
|
||||
if (response.stop_reason === 'refusal') {
|
||||
return {
|
||||
plan: finalizePlan({
|
||||
intent: 'unsupported',
|
||||
place: null,
|
||||
use_current_view: false,
|
||||
values: [],
|
||||
seen_after: null,
|
||||
seen_before: null,
|
||||
prefer_pano: false,
|
||||
visualise: 'icons',
|
||||
title: 'Query declined',
|
||||
answer: 'The planner declined this request.',
|
||||
}),
|
||||
usage: response.usage ?? null,
|
||||
model,
|
||||
};
|
||||
}
|
||||
const parsed = parsePlanResponse(response);
|
||||
if (!parsed)
|
||||
throw Object.assign(new Error('Planner returned no plan'), { status: 502 });
|
||||
return {
|
||||
plan: finalizePlan(parsed),
|
||||
usage: response.usage ?? null,
|
||||
model,
|
||||
};
|
||||
}
|
||||
|
||||
function statusForError(error) {
|
||||
if (error instanceof Anthropic.AuthenticationError) return 503;
|
||||
if (error instanceof Anthropic.RateLimitError) return 429;
|
||||
if (error instanceof Anthropic.APIConnectionError) return 502;
|
||||
if (error instanceof Anthropic.APIStatusError) return error.status || 502;
|
||||
return error?.status || 500;
|
||||
}
|
||||
|
||||
/** POST /api/mapillary/plan — natural language in, executable plan out. */
|
||||
export async function handlePlan(req, res) {
|
||||
if (req.method !== 'POST')
|
||||
return sendJson(res, 405, { error: 'Method not allowed' });
|
||||
if (!mapillaryToken())
|
||||
return sendJson(res, 503, { error: 'no_key', keyRequired: 'mapillary' });
|
||||
if (!anthropicConfigured())
|
||||
return sendJson(res, 503, { error: 'no_key', keyRequired: 'anthropic' });
|
||||
const allow = limiter();
|
||||
if (allow && !allow(clientKey(req))) {
|
||||
res.setHeader('Retry-After', '5');
|
||||
return sendJson(res, 429, { error: 'Rate limit exceeded' });
|
||||
}
|
||||
let body;
|
||||
try {
|
||||
body = JSON.parse(
|
||||
(await readRequestBody(req, PLANNER_MAX_BODY_BYTES)) || '{}',
|
||||
);
|
||||
} catch (error) {
|
||||
return sendJson(res, 400, { error: error?.message || 'Invalid JSON body' });
|
||||
}
|
||||
const query = String(body?.query || '').trim();
|
||||
if (!query || query.length > PLANNER_MAX_QUERY_CHARS)
|
||||
return sendJson(res, 400, {
|
||||
error: `A query of 1–${PLANNER_MAX_QUERY_CHARS} characters is required`,
|
||||
});
|
||||
try {
|
||||
const result = await planQuery({
|
||||
query,
|
||||
context:
|
||||
body?.context && typeof body.context === 'object' ? body.context : {},
|
||||
});
|
||||
sendJson(res, 200, result);
|
||||
} catch (error) {
|
||||
const status = statusForError(error);
|
||||
console.warn('[Mapillary Planner]', error?.message || error);
|
||||
sendJson(res, status, {
|
||||
error:
|
||||
status === 503
|
||||
? 'Anthropic authentication failed'
|
||||
: error?.message || 'Planner request failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,5 @@
|
||||
import { fetchTile, TileRequestError, TileUpstreamError } from './tiles.js';
|
||||
import { handleFeatureQuery } from './features.js';
|
||||
import { handlePlan } from './planner.js';
|
||||
import { handleSprite } from './sprites.js';
|
||||
import {
|
||||
anthropicConfigured,
|
||||
mapillaryToken,
|
||||
plannerModel,
|
||||
QUERY_MAX_TILES,
|
||||
FEATURE_TILE_ZOOM,
|
||||
} from './constants.js';
|
||||
import { mapillaryToken } from './constants.js';
|
||||
|
||||
function sendJson(res, status, payload) {
|
||||
res.statusCode = status;
|
||||
@@ -17,29 +8,23 @@ function sendJson(res, status, payload) {
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
/** GET /api/mapillary/status — what this install can do, never a value. */
|
||||
/** GET /api/mapillary/status — whether a token is configured, never its value. */
|
||||
function handleStatus(req, res) {
|
||||
if (req.method !== 'GET')
|
||||
return sendJson(res, 405, { error: 'Method not allowed' });
|
||||
sendJson(res, 200, {
|
||||
configured: Boolean(mapillaryToken()),
|
||||
planner: anthropicConfigured(),
|
||||
plannerModel: anthropicConfigured() ? plannerModel() : null,
|
||||
limits: { maxTiles: QUERY_MAX_TILES, featureZoom: FEATURE_TILE_ZOOM },
|
||||
});
|
||||
sendJson(res, 200, { configured: Boolean(mapillaryToken()) });
|
||||
}
|
||||
|
||||
/** GET /api/mapillary/tiles/{layer}/{z}/{x}/{y} — cached protobuf tile. */
|
||||
/** GET /api/mapillary/tiles/coverage/{z}/{x}/{y} — cached protobuf tile. */
|
||||
async function handleTile(req, res) {
|
||||
if (req.method !== 'GET')
|
||||
return sendJson(res, 405, { error: 'Method not allowed' });
|
||||
const match =
|
||||
/^\/(coverage|points|signs)\/(\d{1,2})\/(\d{1,6})\/(\d{1,6})$/.exec(
|
||||
(req.url || '').split('?')[0],
|
||||
);
|
||||
const match = /^\/(coverage)\/(\d{1,2})\/(\d{1,6})\/(\d{1,6})$/.exec(
|
||||
(req.url || '').split('?')[0],
|
||||
);
|
||||
if (!match)
|
||||
return sendJson(res, 400, {
|
||||
error: 'Tile path must be /{layer}/{z}/{x}/{y}',
|
||||
error: 'Tile path must be /coverage/{z}/{x}/{y}',
|
||||
});
|
||||
if (!mapillaryToken())
|
||||
return sendJson(res, 503, { error: 'no_key', keyRequired: true });
|
||||
@@ -73,7 +58,4 @@ async function handleTile(req, res) {
|
||||
export function installMapillaryRoutes(middlewares) {
|
||||
middlewares.use('/api/mapillary/status', handleStatus);
|
||||
middlewares.use('/api/mapillary/tiles', handleTile);
|
||||
middlewares.use('/api/mapillary/features', handleFeatureQuery);
|
||||
middlewares.use('/api/mapillary/plan', handlePlan);
|
||||
middlewares.use('/api/mapillary/sprite', handleSprite);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
/**
|
||||
* 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 {
|
||||
SPRITE_DISK_DIR,
|
||||
SPRITE_DISK_TTL_MS,
|
||||
SPRITE_MAX_BYTES,
|
||||
SPRITE_SOURCE_ROOT,
|
||||
} from './constants.js';
|
||||
import { isSignValue } from '../../../src/layers/mapillary/values.js';
|
||||
|
||||
/** A taxonomy value: lower-case segments joined by `--`, words by `-`. */
|
||||
const VALUE_GRAMMAR =
|
||||
/^[a-z0-9]+(?:-[a-z0-9]+)*(?:--[a-z0-9]+(?:-[a-z0-9]+)*)*$/;
|
||||
|
||||
/** @type {Map<string, Promise<Buffer|null>>} */
|
||||
const _inFlight = new Map();
|
||||
|
||||
function spritePath(value) {
|
||||
return path.join(SPRITE_DISK_DIR, `${value}.svg`);
|
||||
}
|
||||
|
||||
async function readDisk(value) {
|
||||
try {
|
||||
const file = spritePath(value);
|
||||
const stat = await fsp.stat(file);
|
||||
if (Date.now() - stat.mtimeMs > SPRITE_DISK_TTL_MS) return null;
|
||||
return await fsp.readFile(file);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSprite(value) {
|
||||
const pkg = isSignValue(value) ? 'package_signs' : 'package_objects';
|
||||
const response = await fetch(`${SPRITE_SOURCE_ROOT}/${pkg}/${value}.svg`, {
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
if (response.status === 404) {
|
||||
await response.body?.cancel().catch(() => {});
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel().catch(() => {});
|
||||
throw new Error(`sprite source HTTP ${response.status}`);
|
||||
}
|
||||
const bytes = Buffer.from(await response.arrayBuffer());
|
||||
if (
|
||||
bytes.length > SPRITE_MAX_BYTES ||
|
||||
!bytes.subarray(0, 512).toString().includes('<svg')
|
||||
)
|
||||
return null;
|
||||
const file = spritePath(value);
|
||||
fsp
|
||||
.mkdir(path.dirname(file), { recursive: true })
|
||||
.then(() => fsp.writeFile(file, bytes))
|
||||
.catch(() => {});
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/mapillary/sprite/<value>.svg — the Mapillary icon for a taxonomy
|
||||
* value, fetched once from the MIT sprite source and cached on disk.
|
||||
*/
|
||||
export async function handleSprite(req, res) {
|
||||
const match = /^\/([a-z0-9-]+)\.svg$/.exec((req.url || '').split('?')[0]);
|
||||
const value = match?.[1];
|
||||
if (!value || !VALUE_GRAMMAR.test(value)) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: 'Invalid sprite value' }));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let bytes = await readDisk(value);
|
||||
if (!bytes) {
|
||||
let pending = _inFlight.get(value);
|
||||
if (!pending) {
|
||||
pending = fetchSprite(value).finally(() => _inFlight.delete(value));
|
||||
_inFlight.set(value, pending);
|
||||
}
|
||||
bytes = await pending;
|
||||
}
|
||||
if (!bytes) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Cache-Control', 'public, max-age=3600');
|
||||
res.end(JSON.stringify({ error: 'No sprite for value' }));
|
||||
return;
|
||||
}
|
||||
res.statusCode = 200;
|
||||
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.end(bytes);
|
||||
} catch (error) {
|
||||
res.statusCode = 502;
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify({ error: error?.message || 'Sprite unavailable' }));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user