File size: 6,457 Bytes
0e02a0f 0721bb4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 | # 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
|