A request like “find schools within 500 m of a flood zone that lost power last night” is not one operation — it is a buffer, a spatial join, an attribute filter, and a temporal filter, in a specific order. Executing an LLM’s free-form answer directly is how pipelines end up running ST_Union on the wrong layer or buffering in degrees. This guide converts natural language into an ordered, typed, and validated geoprocessing plan before a single operation runs, as part of LLM-assisted geoprocessing pipelines.
The plan is the contract. Rather than let the model emit SQL or Python, it emits a structured list of steps, each naming one operation from a fixed allowlist and supplying typed arguments. A deterministic parser then validates every step — unknown op, missing argument, wrong type, or a reference to a layer that does not exist all fail closed. Only a fully valid plan advances to execution.
When to Use This Approach
Use plan decomposition whenever the model’s output will drive real geoprocessing rather than just answer a question in prose. The typed-plan indirection costs a little latency and prompt engineering, but it converts an open-ended code-generation risk into a bounded schema-validation problem.
| Style | Model emits | Safety | Best for |
|---|---|---|---|
| Direct execution | Raw SQL / Python | Low — arbitrary ops | Throwaway prototypes |
| Typed plan (this page) | Op + args JSON | High — allowlist + schema | Production agents |
| Fixed template | Slot values only | Highest — no op choice | Narrow, known queries |
Choose the typed plan when the space of legitimate requests is broad but the space of legitimate operations is small and known. If you only ever run two or three query shapes, a fixed template is stricter and simpler. If you genuinely need arbitrary computation, you have a much larger sandboxing problem than this page addresses. Downstream, each planned step should still be checked at runtime — see validating intermediate geoprocessing outputs.
Implementation
The parser below accepts the model’s JSON plan, validates each step against an operation allowlist with an argument schema, and rejects the whole plan on any violation. When parsing or validation fails, it returns a deterministic single-step fallback plan that the executor can run safely.
import json
import logging
from dataclasses import dataclass, field
from typing import Any, Callable
log = logging.getLogger("geo_planner")
# Allowlist: op name -> (required arg name -> validator predicate).
Validator = Callable[[Any], bool]
OP_SCHEMA: dict[str, dict[str, Validator]] = {
"buffer": {"layer": lambda v: isinstance(v, str),
"distance_m": lambda v: isinstance(v, (int, float)) and 0 < v <= 50000},
"spatial_join": {"left": lambda v: isinstance(v, str),
"right": lambda v: isinstance(v, str),
"predicate": lambda v: v in {"intersects", "within", "dwithin"}},
"attr_filter": {"layer": lambda v: isinstance(v, str),
"column": lambda v: isinstance(v, str) and v.isidentifier(),
"op": lambda v: v in {"=", "!=", ">", "<", ">=", "<="},
"value": lambda v: isinstance(v, (int, float, str, bool))},
}
KNOWN_LAYERS = {"schools", "flood_zones", "outages", "parcels"}
@dataclass
class Step:
op: str
args: dict[str, Any]
@dataclass
class Plan:
steps: list[Step] = field(default_factory=list)
fallback: bool = False
class PlanError(Exception):
pass
def _validate_step(raw: Any, idx: int) -> Step:
if not isinstance(raw, dict) or "op" not in raw:
raise PlanError(f"step {idx}: not an object with an 'op'")
op = raw["op"]
schema = OP_SCHEMA.get(op)
if schema is None:
raise PlanError(f"step {idx}: op '{op}' not in allowlist")
args = raw.get("args", {})
if not isinstance(args, dict):
raise PlanError(f"step {idx}: args must be an object")
missing = set(schema) - set(args)
if missing:
raise PlanError(f"step {idx}: missing args {sorted(missing)}")
for name, check in schema.items():
if not check(args[name]):
raise PlanError(f"step {idx}: arg '{name}' failed validation")
# Cross-check any layer reference against the known catalogue.
for key in ("layer", "left", "right"):
if key in args and args[key] not in KNOWN_LAYERS:
raise PlanError(f"step {idx}: unknown layer '{args[key]}'")
return Step(op=op, args=args)
def _safe_fallback() -> Plan:
# Deterministic minimal plan: return the base layer with no transformation.
return Plan(steps=[Step(op="attr_filter",
args={"layer": "schools", "column": "id",
"op": ">", "value": 0})],
fallback=True)
def parse_plan(model_output: str) -> Plan:
try:
payload = json.loads(model_output)
raw_steps = payload["steps"]
if not isinstance(raw_steps, list) or not raw_steps:
raise PlanError("plan has no steps")
if len(raw_steps) > 12:
raise PlanError("plan exceeds step budget")
steps = [_validate_step(s, i) for i, s in enumerate(raw_steps)]
log.info("accepted plan with %d steps", len(steps))
return Plan(steps=steps)
except (json.JSONDecodeError, KeyError, TypeError, PlanError) as exc:
log.warning("plan rejected (%s); using deterministic fallback", exc)
return _safe_fallback()
if __name__ == "__main__":
demo = json.dumps({"steps": [
{"op": "buffer", "args": {"layer": "flood_zones", "distance_m": 500}},
{"op": "spatial_join", "args": {"left": "schools", "right": "flood_zones",
"predicate": "dwithin"}},
]})
plan = parse_plan(demo)
for s in plan.steps:
print(s.op, s.args)
Every argument is checked for type and range (a buffer distance is capped, a column name must be a valid identifier), and every layer reference is confirmed against a known catalogue. A malformed or hallucinated plan never reaches the executor; it degrades to the fallback, which the orchestrator can flag for a re-prompt. This mirrors the safe-degradation posture in multi-step spatial agent orchestration.
Validation & Testing
- Unknown ops fail closed. Feed a plan containing
{"op": "rm_rf"}and assertparse_planreturns a plan withfallback=Trueand never raises. - Type and range enforcement. Assert a
bufferwithdistance_mof-5or"500"is rejected, while500is accepted — proving both the type check and the0 < v <= 50000bound fire. - Layer catalogue guard. Assert a
spatial_joinreferencingright="census_blocks"(not inKNOWN_LAYERS) yields the fallback, confirming hallucinated layer names cannot slip through.
Gotchas & Edge Cases
- Valid JSON, invalid semantics. The model may emit a well-formed
spatial_joinbefore thebufferthat produces its right layer. Add an ordering pass that checks each step’s inputs are produced by an earlier step or exist in the catalogue, not just that each step is individually well-typed. - Silent arg coercion. Accepting
"500"and casting it to500invites unit and precision drift. Reject the wrong type outright and force the model to emit numbers as numbers. - Unbounded plans. Without a step budget, a runaway model can emit hundreds of ops. Cap plan length (here, 12) and treat the overflow as a rejection, not a truncation.
- Allowlist drift. As new ops are added, the schema and the executor can fall out of sync, letting a planned op reach an executor that cannot run it. Generate the allowlist from the executor’s registered handlers so the two share one source of truth.