ishaq101's picture
feat/Analysis State & Report Rework (#4)
0e02a0f
Raw
History Blame Contribute Delete
6.46 kB
# UNWIRED 2026-06-24: the problem_statement skill is no longer routed to — it was removed
# from the 6-intent router and the gate (the goal is now user-entered objective +
# business_questions, no agent validation). File kept intact (comment, don't delete) so
# the skill can be restored if needed. See DEV_PLAN.md #1.
"""Problem Statement skill — guide the user to a usable problem statement.
Routed by the orchestrator (intent `problem_statement`) and callable as a skill.
An LLM drafts/refines the statement from the analysis title + the user's message and
declares what's still `missing`; a check validates only when nothing is missing. The
model is instructed to fill `objective`/`metric` ONLY from what the user explicitly
stated — a bare data question ("which X has the most Y?") leaves them in `missing`, so
it does not auto-validate (the gate stays meaningful). On a valid draft it persists
`problem_statement` + `problem_validated=True`; otherwise it streams guidance and
leaves the analysis un-validated.
NOTE: completeness is still a (hardened) LLM judgment — the truly deterministic gate
is an explicit user confirmation, planned with the frontend (see T3b / #11).
See `ORCHESTRATOR_REWORK_PLAN.md` §4 and the 2026-06-18 checkpoint.
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
from langchain_core.messages import BaseMessage
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.runnables import Runnable
from langchain_openai import AzureChatOpenAI
from pydantic import BaseModel, Field
from src.middlewares.logging import get_logger
if TYPE_CHECKING:
from src.agents.state_store import AnalysisStateStore
logger = get_logger("problem_statement")
_PROMPT_PATH = (
Path(__file__).resolve().parent.parent.parent
/ "config"
/ "prompts"
/ "problem_statement.md"
)
class ProblemStatementDraft(BaseModel):
"""LLM output for the Problem Statement skill."""
problem_statement: str = Field(
..., description="The refined, standalone problem statement (never empty)."
)
objective: str = Field(
"", description="What success looks like — fill ONLY when the user explicitly "
"stated it; never inferred from a data question. Empty otherwise."
)
metric: str = Field(
"", description="The KPI to move/investigate — fill ONLY when the user "
"explicitly stated it; never inferred from a data question. Empty otherwise."
)
missing: list[str] = Field(
default_factory=list,
description="Which of 'objective' / 'metric' the user has NOT explicitly stated "
"yet. A bare data question leaves both here. Empty list = complete.",
)
feedback: str = Field(
...,
description="Message to the user — guidance if incomplete, confirmation if complete.",
)
def is_valid(draft: ProblemStatementDraft) -> bool:
"""Complete iff there's a statement and the model flagged nothing missing.
Keying on the model's explicit `missing` list (rather than 'are objective/metric
non-empty') is what stops a bare data question from auto-validating: the hardened
prompt puts the un-stated parts in `missing`, so this returns False for it.
"""
return bool(draft.problem_statement.strip()) and not draft.missing
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(
[
("system", _load_prompt_text()),
MessagesPlaceholder(variable_name="history", optional=True),
(
"human",
"Analysis title: {analysis_title}\n"
"Current problem statement: {current}\n\n"
"User message: {message}",
),
]
)
return prompt | llm.with_structured_output(ProblemStatementDraft)
class ProblemStatementAgent:
"""Single LLM call that drafts/refines a problem statement.
Inject `chain` for tests; the default builds the Azure OpenAI chain on first use.
"""
def __init__(self, chain: Runnable | None = None) -> None:
self._chain = chain
def _ensure_chain(self) -> Runnable:
if self._chain is None:
self._chain = _build_default_chain()
return self._chain
async def draft(
self,
message: str,
analysis_title: str,
current: str,
history: list[BaseMessage] | None = None,
) -> ProblemStatementDraft:
chain = self._ensure_chain()
return await chain.ainvoke(
{
"message": message,
"analysis_title": analysis_title,
"current": current,
"history": history or [],
}
)
async def run_problem_statement(
message: str,
analysis_id: str | None,
*,
agent: ProblemStatementAgent,
store: AnalysisStateStore,
history: list[BaseMessage] | None = None,
) -> str:
"""Draft + validate the problem statement; persist on a valid draft.
Loads the current title/statement (if the analysis exists), drafts a refinement,
runs the deterministic completeness check, and writes `problem_statement` +
`problem_validated` back. Returns the user-facing feedback. When `analysis_id` is
missing (e.g. a legacy room), it still drafts + returns guidance but cannot persist.
"""
analysis_title, current = "New analysis", ""
if analysis_id:
state = await store.get(analysis_id)
if state is not None:
analysis_title, current = state.analysis_title, state.problem_statement
draft = await agent.draft(message, analysis_title, current, history)
validated = is_valid(draft)
if analysis_id:
await store.update(
analysis_id,
problem_statement=draft.problem_statement,
problem_validated=validated,
)
logger.info("problem_statement drafted", analysis_id=analysis_id, validated=validated)
return draft.feedback