Source code for langgoap.interpreter

"""Natural language to GoalSpec interpreter using LLM structured output.

Converts free-text requests like "Generate a report under $5" into formal
GoalSpec objects via LangChain's ``with_structured_output()`` mechanism.
Provider-agnostic: works with any ``BaseChatModel`` that supports structured
output (OpenAI, Anthropic, Google, local models, etc.).

Architecture::

    "Generate a report under $5"

    [GoalInterpreter.interpret()]
      ├─ Build prompt (system message with action catalog + user request)
      ├─ LLM call via with_structured_output(InterpretedGoal)
      └─ Convert InterpretedGoal → GoalSpec

    GoalSpec(conditions={"report_complete": True}, ...)
"""

from __future__ import annotations

from types import MappingProxyType
from typing import Any, Literal, cast

from langchain_core.language_models import BaseChatModel
from langchain_core.messages import HumanMessage, SystemMessage
from pydantic import BaseModel, Field

from langgoap.actions import ActionSpec
from langgoap.goals import ConstraintSpec, GoalSpec
from langgoap.types import ObjectiveDirection

# ---------------------------------------------------------------------------
# Pydantic schemas (LLM-facing)
# ---------------------------------------------------------------------------


[docs] class InterpretedConstraint(BaseModel): """A resource constraint extracted from natural language. ``level`` distinguishes hard ("must not exceed $5") from soft ("would be nice to stay under $5") constraints. Defaults to ``"hard"`` so pre-existing mocks that omit the field continue to produce hard constraints. """ key: str = Field(description="Resource key (e.g. 'cost_usd', 'total_tokens')") max: float | None = Field(default=None, description="Upper bound, or null") min: float | None = Field(default=None, description="Lower bound, or null") weight: float = Field(default=1.0, description="Relative importance (default 1.0)") level: Literal["hard", "soft"] = Field( default="hard", description=( "'hard' (must not violate) or 'soft' (prefer not to " "violate; incurs a weighted penalty). Use 'soft' for " "language like 'ideally', 'preferably', 'nice to have'." ), )
[docs] class InterpretedObjective(BaseModel): """An optimization objective extracted from natural language.""" metric: str = Field(description="Metric name to optimize") direction: Literal["minimize", "maximize"] = Field( description="Optimization direction — must be 'minimize' or 'maximize'" )
[docs] class InterpretedGoal(BaseModel): """Structured goal extracted from a natural language request. This is a transient LLM output — deliberately a mutable Pydantic model, not a frozen dataclass. Converted to ``GoalSpec`` before use in planning. Note: ``conditions`` defaults to an empty dict so that LLM structured output always parses successfully even when a model omits the field (common with smaller models in function-calling mode when constraints dominate the request). Emptiness is validated by :func:`to_goal_spec`, which raises ``ValueError`` if no conditions were extracted. """ conditions: dict[str, Any] = Field( default_factory=dict, description="Required world-state conditions (key → hashable scalar value)", ) constraints: list[InterpretedConstraint] = Field( default_factory=list, description="Hard resource/budget constraints", ) objectives: list[InterpretedObjective] = Field( default_factory=list, description="Optimization targets (minimize/maximize)", ) reasoning: str = Field( default="", description="Brief explanation of how the goal was derived", )
# --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def build_action_catalog(actions: list[ActionSpec]) -> str: """Format a list of actions into a human-readable catalog for the prompt. Includes name, preconditions, effects, description (from metadata), and resources for each action. """ if not actions: return "(no actions available)" lines: list[str] = [] for action in actions: parts = [f"- **{action.name}**"] if action.preconditions: parts.append(f" Preconditions: {dict(action.preconditions)}") if action.has_dynamic_effects: parts.append( f" Effects: <dynamic, keys={sorted(action.effect_key_set())}>" ) elif action.effects: parts.append(f" Effects: {dict(action.effects)}") # type: ignore[arg-type] if action.metadata and action.metadata.get("description"): parts.append(f" Description: {action.metadata['description']}") if action.resources: parts.append(f" Resources: {dict(action.resources)}") lines.append("\n".join(parts)) return "\n".join(lines) def to_goal_spec(interpreted: InterpretedGoal) -> GoalSpec: """Convert an InterpretedGoal (LLM output) to a GoalSpec (planning type). Maps InterpretedConstraint → ConstraintSpec, InterpretedObjective → ObjectiveDirection enum. Raises: ValueError: If ``conditions`` is empty; if a condition value is not a hashable scalar (``bool``, ``str``, ``int``, or ``float``); if a constraint has ``min > max`` (validated by :class:`~langgoap.goals.ConstraintSpec`). All three errors can occur when an LLM hallucinates invalid values — callers should catch ``ValueError`` and prompt the user to rephrase. Note: Objective ``direction`` is constrained to ``Literal["minimize", "maximize"]`` on :class:`InterpretedObjective` itself — Pydantic rejects any other value before this function is called. """ if not interpreted.conditions: raise ValueError( "LLM returned empty conditions — cannot create a meaningful GoalSpec. " "Ask the user to rephrase the request with a concrete end-state." ) _SCALAR_TYPES = (bool, int, float, str) bad = { k: type(v).__name__ for k, v in interpreted.conditions.items() if not isinstance(v, _SCALAR_TYPES) } if bad: raise ValueError( f"Condition values must be bool, int, float, or str. " f"Non-scalar keys returned by LLM: {bad}. " f"Ask the user to rephrase so the goal uses only scalar values." ) constraints = tuple( ConstraintSpec( key=c.key, max=c.max, min=c.min, weight=c.weight, level=c.level, ) for c in interpreted.constraints ) # InterpretedObjective.direction is Literal["minimize", "maximize"]; Pydantic # guarantees no other value reaches here. _direction_map: dict[str, ObjectiveDirection] = { "minimize": ObjectiveDirection.MINIMIZE, "maximize": ObjectiveDirection.MAXIMIZE, } objectives: MappingProxyType[str, ObjectiveDirection] | None = None if interpreted.objectives: objectives = MappingProxyType( { obj.metric: _direction_map[obj.direction] for obj in interpreted.objectives } ) # MappingProxyType wrapping satisfies GoalSpec's field type annotation. # GoalSpec.__post_init__ detects it is already a MappingProxyType and skips re-wrapping. return GoalSpec( conditions=MappingProxyType(interpreted.conditions), constraints=constraints, objectives=objectives, ) # --------------------------------------------------------------------------- # Default system prompt # --------------------------------------------------------------------------- DEFAULT_SYSTEM_PROMPT = """\ You are a goal interpreter for a Goal-Oriented Action Planning (GOAP) system. Given a natural language request, extract a structured goal specification. ## Available Actions {action_catalog} ## Current World State {world_state} ## Instructions 1. **conditions**: Identify the desired end-state as key-value pairs. Use \ condition keys that appear in the action effects above. Values must be \ hashable scalars: bool, str, int, or float. 2. **constraints**: Extract resource constraints from budget/limit language \ (e.g. "under $5" → key="cost_usd", max=5.0). Only include constraints \ explicitly stated in the request. 3. **objectives**: Extract optimization targets from language like \ "minimize", "cheapest", "fastest", "maximize quality". Map to the \ appropriate metric and direction ("minimize" or "maximize"). 4. **reasoning**: Briefly explain how you derived the goal from the request. Return ONLY the structured goal. Do not include actions or plans.\ """ # --------------------------------------------------------------------------- # GoalInterpreter # --------------------------------------------------------------------------- def _default_structured_output_kwargs(llm: BaseChatModel) -> dict[str, Any]: """Auto-detect provider-specific structured output kwargs. OpenAI models default to strict JSON-schema mode which requires ``additionalProperties: false`` on all objects — incompatible with the open-ended ``conditions`` dict in :class:`InterpretedGoal`. This helper detects OpenAI models and falls back to ``function_calling`` mode automatically. """ try: from langchain_openai import ChatOpenAI if isinstance(llm, ChatOpenAI): return {"method": "function_calling"} except ImportError: pass return {}
[docs] class GoalInterpreter: """Converts natural language requests into GoalSpec objects via LLM. Uses ``BaseChatModel.with_structured_output()`` to extract structured goals from free-text requests. Provider-agnostic: any chat model that supports structured output works. Args: llm: A LangChain chat model (e.g. ``ChatOpenAI``, ``ChatAnthropic``). actions: Available GOAP actions (used to build the prompt catalog). system_prompt: Custom system prompt template. Must contain ``{action_catalog}`` and ``{world_state}`` placeholders. Defaults to :data:`DEFAULT_SYSTEM_PROMPT`. structured_output_kwargs: Optional keyword arguments forwarded to ``llm.with_structured_output()``. Use this for provider-specific options, e.g. ``{"method": "function_calling"}`` for OpenAI when ``conditions`` must remain an open dict (OpenAI strict JSON-schema mode requires ``additionalProperties: false`` on all objects, which is incompatible with a free-form conditions mapping). When ``None`` (the default), auto-detects OpenAI models and applies ``{"method": "function_calling"}`` automatically. """
[docs] def __init__( self, llm: BaseChatModel, actions: list[ActionSpec], system_prompt: str | None = None, structured_output_kwargs: dict[str, Any] | None = None, ) -> None: self._llm = llm self._actions = actions self._system_prompt = system_prompt or DEFAULT_SYSTEM_PROMPT if structured_output_kwargs is None: structured_output_kwargs = _default_structured_output_kwargs(llm) self._structured_llm = llm.with_structured_output( InterpretedGoal, **(structured_output_kwargs or {}) )
def build_messages( self, request: str, world_state: dict[str, Any] | None ) -> list[SystemMessage | HumanMessage]: catalog = build_action_catalog(self._actions) ws_str = str(world_state) if world_state else "{}" system_text = self._system_prompt.format( action_catalog=catalog, world_state=ws_str, ) return [SystemMessage(content=system_text), HumanMessage(content=request)] def interpret( self, request: str, world_state: dict[str, Any] | None = None ) -> GoalSpec: """Interpret a natural language request and return a GoalSpec.""" raw = self.interpret_raw(request, world_state=world_state) return to_goal_spec(raw) async def ainterpret( self, request: str, world_state: dict[str, Any] | None = None ) -> GoalSpec: """Async variant of :meth:`interpret`.""" raw = await self.ainterpret_raw(request, world_state=world_state) return to_goal_spec(raw) def interpret_raw( self, request: str, world_state: dict[str, Any] | None = None ) -> InterpretedGoal: """Interpret a request and return the raw InterpretedGoal. Preserves the ``reasoning`` field for debugging/transparency. """ messages = self.build_messages(request, world_state) return cast(InterpretedGoal, self._structured_llm.invoke(messages)) async def ainterpret_raw( self, request: str, world_state: dict[str, Any] | None = None ) -> InterpretedGoal: """Async variant of :meth:`interpret_raw`.""" messages = self.build_messages(request, world_state) return cast(InterpretedGoal, await self._structured_llm.ainvoke(messages))