Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@
| `PROXY_ENDPOINT` | Chemin exposé par le proxy geodata. | `/api/v1/proxy` |
| `PROXY_PUBLIC_BASE_URL` | URL de base publiquement joignable du proxy, utilisée pour construire la `data_url` absolue transmise à Carto. Derrière un reverse-proxy, elle diffère de l'adresse d'écoute ; en développement local, c'est typiquement `http://localhost:3002`. Requise avec `PROXY_URL_SECRET` pour activer les tools `*_layer`. | Aucune |
| `GPF_WFS_PROXY_RATE_LIMIT` | Limite de requêtes/s du proxy vers le WFS, distincte de `GPF_WFS_RATE_LIMIT`. Les deux comptent sur le même service IGN : répartir une seule allocation entre les deux. | `10` |
| `GPF_NAVIGATION_PROXY_RATE_LIMIT` | Limite de requêtes/s du proxy vers le service d'isochrone (filtre `travel_time` et `gpf_isochrone_layer`), distincte de `GPF_NAVIGATION_RATE_LIMIT`. Les deux comptent sur le même service IGN : répartir une seule allocation entre les deux. | `5` |
| `PROXY_UPSTREAM_TIMEOUT` | Délai (secondes) des appels amont du proxy (WFS **et** isochrone), plus court que `HTTP_TIMEOUT` pour qu'une requête à 2 appels (`intersects_feature` ou `travel_time`) reste sous le délai du navigateur/Carto. Borne aussi `gpf_isoline_layer` : une isochrone `car` de longue durée peut le dépasser (erreur 504). | `10` |
| `GPF_NAVIGATION_PROXY_RATE_LIMIT` | Limite de requêtes/s du proxy vers le service d'isochrone (filtre `isoline` et `gpf_isoline_layer`), distincte de `GPF_NAVIGATION_RATE_LIMIT`. Les deux comptent sur le même service IGN : répartir une seule allocation entre les deux. | `5` |
| `PROXY_UPSTREAM_TIMEOUT` | Délai (secondes) des appels amont du proxy (WFS **et** isochrone), plus court que `HTTP_TIMEOUT` pour qu'une requête à 2 appels (`intersects_feature` ou `isoline`) reste sous le délai du navigateur/Carto. Borne aussi `gpf_isoline_layer` : une isochrone `car` de longue durée peut le dépasser (erreur 504). | `10` |

## Génération de `PROXY_URL_SECRET`

Expand Down
118 changes: 72 additions & 46 deletions docs/mcp-tools.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,12 +225,12 @@ const envSchema = z.object({
// Dedicated upstream WFS rate limit for the proxy, separate from GPF_WFS_RATE_LIMIT.
// Both counters hit the same IGN service, so split one allowance across them.
GPF_WFS_PROXY_RATE_LIMIT: z.preprocess(emptyToUndefined, positiveIntegerSchema.default(10)),
// Dedicated isochrone rate limit for the proxy's travel_time leg, separate from
// Dedicated isochrone rate limit for the proxy's isoline leg, separate from
// GPF_NAVIGATION_RATE_LIMIT. Both counters hit the same IGN service, so split one
// allowance across them.
GPF_NAVIGATION_PROXY_RATE_LIMIT: z.preprocess(emptyToUndefined, positiveIntegerSchema.default(5)),
// Upstream timeout (seconds) for the proxy's WFS AND isochrone calls, shorter than
// HTTP_TIMEOUT so a 2-call intersects_feature/travel_time stays under the
// HTTP_TIMEOUT so a 2-call intersects_feature/isoline stays under the
// browser/Carto fetch timeout.
PROXY_UPSTREAM_TIMEOUT: z.preprocess(emptyToUndefined, positiveNumberSchema.default(10)),
});
Expand Down
3 changes: 1 addition & 2 deletions src/gpf/itinerary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ import { NAVIGATION_METRICS, NAVIGATION_PROFILES, NAVIGATION_ISOLINE_RESOURCE }

export const NAVIGATION_ITINERARY_SOURCE = "Géoplateforme (calcul d'itinéraire)";
export const NAVIGATION_ITINERARY_URL = "https://data.geopf.fr/navigation/itineraire";
// Same engine as the `travel_time_filter` isochrones, so that both report the
// same travel times.
// Same engine as the `isoline_filter`, so that both report the same travel times.
export const ITINERARY_RESOURCE = NAVIGATION_ISOLINE_RESOURCE;
export const ITINERARY_PROFILES = NAVIGATION_PROFILES;
export const ITINERARY_METRICS = NAVIGATION_METRICS;
Expand Down
2 changes: 0 additions & 2 deletions src/gpf/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@ export const NAVIGATION_ISODISTANCE_MAX_METERS = 50_000;
export const NAVIGATION_PROFILES = ["car", "pedestrian"] as const;
export const NAVIGATION_METRICS = ["time", "distance"] as const;

export const TRAVEL_TIME_MAX_MINUTES = 120;

export type NavigationProfile = typeof NAVIGATION_PROFILES[number];
export type NavigationMetric = typeof NAVIGATION_METRICS[number];

Expand Down
2 changes: 1 addition & 1 deletion src/helpers/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ export async function fetchJSONPostWithLimit<T>(

/**
* GET variant of the size-bounded transport, parsing the bounded body as JSON.
* Used by the proxy's isochrone leg so a `travel_time` layer request goes
* Used by the proxy's isochrone leg so an `isoline` layer request goes
* through the SAME `PROXY_UPSTREAM_TIMEOUT` + `PROXY_MAX_RESPONSE_BYTES` bounds
* as its WFS leg, instead of the unbounded `HTTP_TIMEOUT`-only `fetchJSONGet`.
*
Expand Down
20 changes: 10 additions & 10 deletions src/proxy/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*
* `runGeometryFeatureQuery` (entry point) compiles and runs the layer query;
* `resolveReferenceGeometry` (internal helper) resolves the reference geometry
* for `intersects_feature` / `travel_time` filters.
* for `intersects_feature` / `isoline` filters.
*
* Unlike the LLM-facing `executeQueryFeatures` (which strips geometry to `null`
* via `postProcessFeatureCollection` to save tokens), the proxy needs the OPPOSITE: a
Expand Down Expand Up @@ -57,21 +57,21 @@ export type WfsClientLike = {
};

/**
* Resolves the isochrone geometry for a `travel_time` filter. Injected by
* Resolves the isochrone/isodistance geometry for an `isoline` filter. Injected by
* the HTTP layer (backed by the navigation/isochrone service). Required, because
* `travel_time` is part of the `gpf_get_features` query contract the proxy must
* `isoline` is part of the `gpf_get_features` query contract the proxy must
* honour — it is not an optional capability. The engine stays isochrone-agnostic
* (pure, network-free, testable), exactly as it is for `wfsClient`.
*/
export type TravelTimeResolver = (input: GpfGetFeaturesInput) => Promise<Geometry>;
export type IsolineResolver = (input: GpfGetFeaturesInput) => Promise<Geometry>;

/**
* Dependencies injected into {@link runGeometryFeatureQuery}.
*/
export type GeometryFeatureQueryDeps = {
wfsClient: WfsClientLike;
/** Isochrone resolver, invoked only for `travel_time` filters. */
resolveTravelTime: TravelTimeResolver;
/** Isochrone/isodistance resolver, invoked only for `isoline` filters. */
resolveIsoline: IsolineResolver;
};

// --- Internal Helpers ---
Expand Down Expand Up @@ -161,10 +161,10 @@ async function resolveReferenceGeometry(
): Promise<Geometry | undefined> {
const spatialFilter = getSpatialFilter(input);

// travel_time is resolved by the injected isochrone resolver, up front, so
// isoline is resolved by the injected isochrone/isodistance resolver, up front, so
// compileQueryParts never sees an unresolved ref (symmetric to intersects_feature).
if (spatialFilter?.operator === "travel_time") {
return deps.resolveTravelTime(input);
if (spatialFilter?.operator === "isoline") {
return deps.resolveIsoline(input);
}

if (!spatialFilter || spatialFilter.operator !== "intersects_feature") {
Expand All @@ -190,7 +190,7 @@ async function resolveReferenceGeometry(
* @param input Validated layer query input (same shape as `gpf_get_features`
* minus the LLM-only `spatial_extras` knob).
* @param deps Injected WFS client (catalog + execution) and isochrone resolver
* (always required; invoked only for `travel_time` filters).
* (always required; invoked only for `isoline` filters).
* @returns The raw WFS FeatureCollection, geometry preserved.
*/
export async function runGeometryFeatureQuery(
Expand Down
28 changes: 12 additions & 16 deletions src/proxy/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { getSpatialFilter } from "../wfs/queryPreparation.js";
import type { GpfGetFeaturesInput } from "../wfs/schema.js";
import { NavigationIsolineClient } from "../gpf/navigation.js";
import type {
TravelTimeResolver,
IsolineResolver,
GeometryFeatureQueryDeps,
GeometryFeatureByIdQueryDeps,
GeometryIsolineQueryDeps,
Expand Down Expand Up @@ -92,7 +92,7 @@ let cachedProxyIsolineClient: NavigationIsolineClient | undefined;
* (`PROXY_UPSTREAM_TIMEOUT` + `PROXY_MAX_RESPONSE_BYTES`) and its own
* `GPF_NAVIGATION_PROXY` rate limiter — NOT the default `navigationIsolineClient`
* singleton, which uses the unbounded `HTTP_TIMEOUT`-only `fetchJSONGet`. This
* keeps both upstream legs of a `travel_time` layer request under the same bounds,
* keeps both upstream legs of an `isoline` layer request under the same bounds,
* so its worst case matches `intersects_feature` (2 × PROXY_UPSTREAM_TIMEOUT).
* Lazily built so the bounds are read from a fully-parsed environment.
*/
Expand All @@ -104,34 +104,30 @@ function getProxyIsolineClient(): NavigationIsolineClient {
return cachedProxyIsolineClient;
}

// --- Reference-geometry resolver (travel_time / isoline) ---
// --- Reference-geometry resolver ---

/**
* Reference-geometry resolver for the `travel_time` spatial filter: turns the
* Reference-geometry resolver for the `isoline` spatial filter: turns the
* isoline into a reference geometry that is fed INTO the WFS query — the
* sibling of `intersects_feature`'s reference-geometry resolution
* (`resolveFeatureGeometry`). It does NOT fetch features itself (that is the
* WFS transport's job). Backed by the proxy isoline client (bounded fetch +
* `GPF_NAVIGATION_PROXY` rate limiter), and injected into `runGeometryFeatureQuery`
* so it only fires for travel_time inputs.
* so it only fires for isoline inputs.
*/
export const resolveProxyTravelTimeGeometry: TravelTimeResolver = async (
export const resolveProxyIsolineGeometry: IsolineResolver = async (
input: GpfGetFeaturesInput,
): Promise<Geometry> => {
const spatialFilter = getSpatialFilter(input);
if (spatialFilter?.operator !== "travel_time") {
// Guarded by the caller (runGeometryFeatureQuery only calls this for travel_time);
if (spatialFilter?.operator !== "isoline") {
// Guarded by the caller (runGeometryFeatureQuery only calls this for isoline);
// defensive check keeps the type narrow.
throw new Error("resolveProxyTravelTimeGeometry appelé sans filtre `travel_time`.");
throw new Error("resolveProxyIsolineGeometry appelé sans filtre `isoline`.");
}

const { operator, minutes, ...parameters } = spatialFilter;
const { operator, ...parameters } = spatialFilter;

return await getProxyIsolineClient().getIsoline({
...parameters,
cost_type: "time",
cost_value: minutes,
});
return await getProxyIsolineClient().getIsoline(parameters);
};

// --- Default Engine Dependencies ---
Expand All @@ -146,7 +142,7 @@ export const resolveProxyTravelTimeGeometry: TravelTimeResolver = async (
export function getDefaultGeometryFeatureQueryDeps(): GeometryFeatureQueryDeps {
return {
wfsClient: getProxyWfsClient(),
resolveTravelTime: resolveProxyTravelTimeGeometry,
resolveIsoline: resolveProxyIsolineGeometry,
};
}

Expand Down
3 changes: 2 additions & 1 deletion src/tools/GpfCountFeaturesTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ const GPF_COUNT_FEATURES_TOOL_DESCRIPTION = [
"Exemple point dans géométrie : `intersects_point_filter={ lon: 2.35, lat: 48.85 }`.",
"Exemple distance : `dwithin_point_filter={ lon: 2.35, lat: 48.85, distance_m: 500 }`.",
"Exemple réutilisation : `intersects_feature_filter={ typename, feature_id }` avec `typename` et `feature_id` issus d'une `feature_ref`.",
"Exemple temps de trajet : `travel_time_filter={ lon: 2.35, lat: 48.85, minutes: 15, profile: \"pedestrian\" }` pour les objets atteignables en 15 minutes à pied depuis ce point.",
"Exemple isochrone : `isoline_filter={ lon: 2.35, lat: 48.85, profile: \"pedestrian\", cost_type: \"time\", cost_value: 15 }` pour les objets atteignables en 15 minutes à pied depuis ce point.",
"Exemple isodistance : `isoline_filter={ lon: 2.35, lat: 48.85, profile: \"car\", cost_type: \"distance\", cost_value: 2000 }` pour les objets atteignables à 2 km en voiture depuis ce point.",
"⚠️ Quand `typename` et `intersects_feature_filter.typename` sont identiques, utiliser `gpf_get_feature_by_id` pour récupérer exactement l'objet ciblé.",
"**OBLIGATOIRE dès que `where` est utilisé : toujours appeler `gpf_describe_type` avant ce tool, sauf si `gpf_describe_type` a déjà été appelé pour ce même typename dans la conversation en cours.** Un comptage par `typename` et filtre spatial seul (sans `where`) ne référence aucun nom de propriété et ne nécessite pas cet appel préalable.",
"Les noms de propriétés utilisés dans `where` **ne peuvent pas être devinés** : ils sont spécifiques à chaque typename et diffèrent systématiquement des conventions habituelles (ex : pas de nom_officiel, navigabilite sans accent, etc.). Toute clause `where` sans appel préalable à `gpf_describe_type` **provoquera une erreur.**",
Expand Down
4 changes: 2 additions & 2 deletions src/tools/GpfGetFeaturesLayerTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,12 +140,12 @@ class GpfGetFeaturesLayerTool extends BaseTool<GpfGetFeaturesLayerInput> {
getGeometryName(referenceType);
}

// We must NOT resolve the reference geometry for intersects_feature/travel_time
// We must NOT resolve the reference geometry for intersects_feature/isoline
// (that IS a network call) — those geometries resolve at fetch time on the
// proxy — so we pass a placeholder ref just to let compileQueryParts validate
// the attribute side (select/where/order_by).
const needsResolvedRef =
spatialFilter?.operator === "intersects_feature" || spatialFilter?.operator === "travel_time";
spatialFilter?.operator === "intersects_feature" || spatialFilter?.operator === "isoline";
compileQueryParts(
compiledInput,
featureType,
Expand Down
3 changes: 2 additions & 1 deletion src/tools/GpfGetFeaturesTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ const GPF_GET_FEATURES_TOOL_DESCRIPTION = [
"Exemple point dans géométrie : `intersects_point_filter={ lon: 2.35, lat: 48.85 }`.",
"Exemple distance : `dwithin_point_filter={ lon: 2.35, lat: 48.85, distance_m: 500 }`.",
"Exemple réutilisation : `intersects_feature_filter={ typename, feature_id }` avec `typename` et `feature_id` issus d'une `feature_ref`.",
"Exemple temps de trajet : `travel_time_filter={ lon: 2.35, lat: 48.85, minutes: 15, profile: \"pedestrian\" }` pour les objets atteignables en 15 minutes à pied depuis ce point.",
"Exemple isochrone : `isoline_filter={ lon: 2.35, lat: 48.85, profile: \"pedestrian\", cost_type: \"time\", cost_value: 15 }` pour les objets atteignables en 15 minutes à pied depuis ce point.",
"Exemple isodistance : `isoline_filter={ lon: 2.35, lat: 48.85, profile: \"car\", cost_type: \"distance\", cost_value: 2000 }` pour les objets atteignables à 2 km en voiture depuis ce point.",
"⚠️ Quand `typename` et `intersects_feature_filter.typename` sont identiques, utiliser `gpf_get_feature_by_id` pour récupérer exactement l'objet ciblé.",
"**OBLIGATOIRE : toujours appeler `gpf_describe_type` avant ce tool, sauf si `gpf_describe_type` a déjà été appelé pour ce même typename dans la conversation en cours.**",
"Les noms de propriétés **ne peuvent pas être devinés** : ils sont spécifiques à chaque typename et diffèrent systématiquement des conventions habituelles (ex : pas de nom_officiel, navigabilite sans accent, etc.). Toute tentative sans appel préalable à `gpf_describe_type` **provoquera une erreur.**",
Expand Down
20 changes: 8 additions & 12 deletions src/wfs/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,26 +96,22 @@ export async function resolveIntersectsFeatureGeometry(
}

/**
* Resolves the travel-time isochrone geometry when `travel_time_filter` is used.
* Resolves the isoline geometry (isochrone or isodistance) when `isoline_filter` is used.
*
* @param input Normalized tool input.
* @returns The resolved isochrone geometry, or `undefined` when no travel-time filter is requested.
* @returns The resolved isoline geometry, or `undefined` when no isoline filter is requested.
*/
export async function resolveTravelTimeGeometry(
export async function resolveIsolineGeometry(
input: GpfQueryFeaturesInput,
): Promise<Geometry | undefined> {
const spatialFilter = getSpatialFilter(input);
if (!spatialFilter || spatialFilter.operator !== "travel_time") {
if (!spatialFilter || spatialFilter.operator !== "isoline") {
return undefined;
}

const { operator, minutes, ...parameters } = spatialFilter;
const { operator, ...parameters } = spatialFilter;

return await navigationIsolineClient.getIsoline({
...parameters,
cost_type: "time",
cost_value: minutes,
});
return await navigationIsolineClient.getIsoline(parameters);
}

/**
Expand All @@ -132,8 +128,8 @@ export async function resolveSpatialFilterGeometry(
switch (spatialFilter?.operator) {
case "intersects_feature":
return resolveIntersectsFeatureGeometry(input);
case "travel_time":
return resolveTravelTimeGeometry(input);
case "isoline":
return resolveIsolineGeometry(input);
default:
return undefined;
}
Expand Down
4 changes: 2 additions & 2 deletions src/wfs/queryPreparation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,9 +209,9 @@ export function compileQueryParts(
}
fragments.push(compileIntersectsFeatureSpatialFilter(geometryName, resolvedGeometryRef));
break;
case "travel_time":
case "isoline":
if (!resolvedGeometryRef) {
throw new Error("Le filtre spatial `travel_time` exige la résolution préalable de la géométrie d'isochrone.");
throw new Error("Le filtre spatial `isoline` exige la résolution préalable de la géométrie d'isochrone/isodistance.");
}
fragments.push(compileIntersectsFeatureSpatialFilter(geometryName, resolvedGeometryRef));
break;
Expand Down
Loading
Loading