ishaq101's picture
/fix validator and report (#10)
0e5fdb5
Raw
History Blame Contribute Delete
6.53 kB
"""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
)
# All retry attempts share `callbacks`, so each shows up under the same
# trace — that is how retry token cost becomes visible.
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:
# Accumulate the full error history (oldest first) so the next
# attempt sees every prior failure and can't fix one by
# reintroducing another (the observed value_type -> arg -> value_type
# whack-a-mole).
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,
# Compact plan dump: task id, its tools, deps, and each tool's arg keys
# (+ any analyze_* column refs) — enough to see how retrieve_data feeds
# the analyze_* tools without dumping full inline IRs.
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)