| """PlannerService — single LLM call: context + catalog + tools + question -> TaskList. |
| |
| Mirrors `query/planner/service.py` (chain construction) and `query/service.py` |
| (validate-and-retry loop). The planner LLM emits a `TaskList` via structured |
| output; the `PlannerValidator` runs the 8 checks; on failure the planner is |
| re-prompted with the error context, up to `max_retries` (default 3). No |
| replanning happens at execution time — this loop only hardens the *initial* |
| static plan. |
| |
| The service takes the full `Catalog` (not just a `CatalogSummary`): it derives |
| the PII-safe `CatalogSummary` for the prompt, but validation needs the full |
| catalog so the existing `IRValidator` can check inline `retrieve_data` IRs. |
| |
| See AGENT_ARCHITECTURE_CONTEXT_new.md §7.3. |
| """ |
|
|
| from __future__ import annotations |
|
|
| from pathlib import Path |
|
|
| from langchain_core.messages import SystemMessage |
| from langchain_core.prompts import ChatPromptTemplate |
| from langchain_core.runnables import Runnable |
| from langchain_openai import AzureChatOpenAI |
|
|
| from src.middlewares.logging import get_logger |
|
|
| from ...catalog.models import Catalog |
| from .contracts import BusinessContext, ToolRegistry |
| from .errors import PlannerError, PlannerValidationError |
| from .inputs import CatalogSummary, Constraints |
| from .prompt import build_planner_prompt |
| from .schemas import TaskList |
| from .validator import PlannerValidator |
|
|
| logger = get_logger("planner_agent") |
|
|
| _PROMPT_PATH = ( |
| Path(__file__).resolve().parent.parent.parent / "config" / "prompts" / "planner.md" |
| ) |
|
|
|
|
| def _load_prompt_text() -> str: |
| return _PROMPT_PATH.read_text(encoding="utf-8") |
|
|
|
|
| def _build_default_chain() -> Runnable: |
| from src.config.settings import settings |
|
|
| llm = AzureChatOpenAI( |
| azure_deployment=settings.azureai_deployment_name_4o, |
| openai_api_version=settings.azureai_api_version_4o, |
| azure_endpoint=settings.azureai_endpoint_url_4o, |
| api_key=settings.azureai_api_key_4o, |
| temperature=0, |
| ) |
| prompt = ChatPromptTemplate.from_messages( |
| [ |
| SystemMessage(content=_load_prompt_text()), |
| ("human", "{human_content}"), |
| ] |
| ) |
| return prompt | llm.with_structured_output(TaskList) |
|
|
|
|
| _default_chain: Runnable | None = None |
|
|
|
|
| def _get_default_chain() -> Runnable: |
| global _default_chain |
| if _default_chain is None: |
| _default_chain = _build_default_chain() |
| return _default_chain |
|
|
|
|
| class PlannerService: |
| """Wraps the planner LLM call + the validate-and-retry loop. |
| |
| Inject `structured_chain` and/or `validator` for tests. |
| """ |
|
|
| def __init__( |
| self, |
| structured_chain: Runnable | None = None, |
| validator: PlannerValidator | None = None, |
| max_retries: int = 3, |
| ) -> None: |
| self._chain = structured_chain |
| self._validator = validator or PlannerValidator() |
| self._max_retries = max(1, max_retries) |
|
|
| def _ensure_chain(self) -> Runnable: |
| if self._chain is None: |
| self._chain = _get_default_chain() |
| return self._chain |
|
|
| async def plan( |
| self, |
| context: BusinessContext, |
| catalog: Catalog, |
| tools: ToolRegistry, |
| query: str, |
| constraints: Constraints, |
| callbacks: list | None = None, |
| ) -> TaskList: |
| summary = CatalogSummary.from_catalog(catalog) |
| chain = self._ensure_chain() |
| previous_errors: list[str] = [] |
|
|
| for attempt in range(1, self._max_retries + 1): |
| human_content = build_planner_prompt( |
| context, summary, tools, query, constraints, previous_errors |
| ) |
| |
| |
| if callbacks: |
| task_list: TaskList = await chain.ainvoke( |
| {"human_content": human_content}, config={"callbacks": callbacks} |
| ) |
| else: |
| task_list = await chain.ainvoke({"human_content": human_content}) |
| try: |
| self._validator.validate(task_list, tools, catalog, constraints) |
| except PlannerValidationError as e: |
| |
| |
| |
| |
| previous_errors.append(f"attempt {attempt}: {e}") |
| logger.warning( |
| "planner validation failed", |
| project_id=context.project_id, |
| plan_id=task_list.plan_id, |
| attempt=attempt, |
| error=str(e), |
| ) |
| continue |
|
|
| logger.info( |
| "analysis planned", |
| project_id=context.project_id, |
| plan_id=task_list.plan_id, |
| n_tasks=len(task_list.tasks), |
| retry=attempt > 1, |
| |
| |
| |
| plan=[ |
| { |
| "id": t.id, |
| "depends_on": t.depends_on, |
| "tools": [ |
| { |
| "tool": c.tool, |
| "args": sorted(c.args.keys()), |
| "cols": c.args.get("column_ids") or c.args.get("column"), |
| "data": c.args.get("data"), |
| } |
| for c in t.tool_calls |
| ], |
| } |
| for t in task_list.tasks |
| ], |
| ) |
| return task_list |
|
|
| raise PlannerError( |
| f"planner failed validation after {self._max_retries} attempts; " |
| f"last error: {previous_errors[-1] if previous_errors else 'unknown'}" |
| ) |
|
|
|
|
| async def plan_analysis( |
| context: BusinessContext, |
| catalog: Catalog, |
| tools: ToolRegistry, |
| query: str, |
| constraints: Constraints, |
| ) -> TaskList: |
| """Convenience entry point using the default chain + validator.""" |
| return await PlannerService().plan(context, catalog, tools, query, constraints) |
|
|