| """`help` skill handler — state-aware next-step guidance (LLM call). |
| |
| Reads the per-analysis state + chat history (and a deterministic report-readiness |
| signal) and tells the user where they are and what to do next. Help only guides; |
| it never runs analysis or produces data answers. |
| |
| The prompt lives in `config/prompts/help.md` (the playbook); this module composes |
| the context and streams the LLM answer, mirroring `ChatbotAgent`. The **consistency |
| guard** has teeth here, not just in the prompt: `_derive_available_actions` computes |
| the actions actually allowed from the readiness signal, and that list is fed into the |
| prompt — the LLM is told to suggest *only* those, so it can't tell the user to |
| generate a report before the analysis is ready. |
| |
| NOTE (KM-652, 2026-06-24): the `problem_statement` skill + the `problem_validated` |
| gate were removed — the goal is now two user-entered fields (`objective` + |
| `business_questions`) captured at onboarding, with no agent validation. So Help no |
| longer steers users to define/validate a goal in chat; it just orients them to |
| analysis and (when ready) the report. |
| |
| SEAMS: |
| - `AnalysisState` is the contract from `gate.py`. The gate, this skill, and tests |
| share `gate.stub_analysis_state(...)` so they exercise the same shape. (The |
| `objective`/`business_questions` rename is in-flight — task #4 — so this module |
| reads those getattr-tolerantly, falling back to legacy `problem_statement`.) |
| - `ReportReadiness` is the return shape of `is_report_ready` (seam #5, Rifqi — built |
| in `report/readiness.py`). Help *consumes* it; it does not compute it. A missing |
| signal degrades to a not-ready stub. |
| """ |
|
|
| from __future__ import annotations |
|
|
| from collections.abc import AsyncIterator |
| from dataclasses import dataclass, field |
| from pathlib import Path |
| from typing import Any |
|
|
| from langchain_core.messages import BaseMessage |
| from langchain_core.output_parsers import StrOutputParser |
| from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder |
| from langchain_core.runnables import Runnable |
| from langchain_openai import AzureChatOpenAI |
|
|
| from src.agents.gate import AnalysisState |
| from src.agents.language import ( |
| FALLBACK_LANGUAGE as _FALLBACK_LANGUAGE, |
| ) |
| from src.agents.language import ( |
| detect_reply_language as _detect_reply_language, |
| ) |
| from src.middlewares.logging import get_logger |
|
|
| logger = get_logger("help") |
|
|
| _PROMPT_DIR = Path(__file__).resolve().parent.parent.parent / "config" / "prompts" |
| _SYSTEM_PROMPT_PATH = _PROMPT_DIR / "help.md" |
| _GUARDRAILS_PATH = _PROMPT_DIR / "guardrails.md" |
|
|
| |
| |
| |
| |
| _DEFAULT_TRIGGERS = { |
| "Indonesian": "Apa yang sebaiknya saya lakukan selanjutnya?", |
| "English": "What should I do next?", |
| } |
| |
| |
| |
|
|
|
|
| @dataclass |
| class ReportReadiness: |
| """Deterministic report-readiness signal — the return of Rifqi's `is_report_ready`. |
| |
| `missing` lists the gaps to fill when `ready` is False. |
| """ |
|
|
| ready: bool = False |
| missing: list[str] = field(default_factory=list) |
|
|
|
|
| def _derive_available_actions(state: AnalysisState, report_ready: ReportReadiness) -> list[str]: |
| """Actions Help is allowed to suggest, derived from the readiness signal. |
| |
| Since KM-652 there is no goal-validation gate: the goal (objective + |
| business_questions) is set in the onboarding form, so asking analysis questions is |
| always available. A report is only offered when the readiness signal says so. |
| """ |
| actions = ["ask_analysis_question"] |
| if report_ready.ready: |
| actions.append("generate_report") |
| return actions |
|
|
|
|
| def _format_state(state: AnalysisState) -> str: |
| """Render the analysis state as a compact context block for the LLM.""" |
| has_report = "yes" if state.report_id else "no" |
| |
| |
| objective = getattr(state, "objective", "") or getattr(state, "problem_statement", "") or "" |
| questions = getattr(state, "business_questions", None) or [] |
| business_questions = "; ".join(questions) if questions else "(none)" |
| return ( |
| "[Analysis state]\n" |
| f"analysis_title: {state.analysis_title or '(none)'}\n" |
| f"objective: {objective or '(empty)'}\n" |
| f"business_questions: {business_questions}\n" |
| f"has_report: {has_report}" |
| ) |
|
|
|
|
| def _format_report_ready(report_ready: ReportReadiness) -> str: |
| missing = ", ".join(report_ready.missing) if report_ready.missing else "(none)" |
| return ( |
| "[Report readiness]\n" |
| f"ready: {str(report_ready.ready).lower()}\n" |
| f"missing: {missing}" |
| ) |
|
|
|
|
| def _build_context_block( |
| state: AnalysisState, |
| report_ready: ReportReadiness, |
| available_actions: list[str], |
| reply_language: str = _FALLBACK_LANGUAGE, |
| ) -> str: |
| """Compose the deterministic context the prompt's 'never misguide' rule trusts. |
| |
| `reply_language` is a hard directive: the prompt is told to reply ONLY in this |
| language, so the answer matches the user's language even on the button path (where |
| the synthetic human turn would otherwise pull the reply toward English). |
| """ |
| return "\n\n".join( |
| [ |
| _format_state(state), |
| _format_report_ready(report_ready), |
| "[Available actions]\n" + ", ".join(available_actions), |
| f"[Reply language]\nRespond ONLY in: {reply_language}", |
| ] |
| ) |
|
|
|
|
| def _load_system_prompt() -> str: |
| """Compose system prompt = help.md + guardrails.md (guardrails last, as elsewhere).""" |
| help_md = _SYSTEM_PROMPT_PATH.read_text(encoding="utf-8") |
| guardrails = _GUARDRAILS_PATH.read_text(encoding="utf-8") |
| return f"{help_md}\n\n{guardrails}" |
|
|
|
|
| def _build_default_chain() -> Runnable: |
| from src.config.settings import settings |
|
|
| llm = AzureChatOpenAI( |
| azure_deployment=settings.azureai_deployment_name_54m, |
| openai_api_version=settings.azureai_api_version_54m, |
| azure_endpoint=settings.azureai_endpoint_url_54m, |
| api_key=settings.azureai_api_key_54m, |
| temperature=0.3, |
| model_kwargs={"stream_options": {"include_usage": True}}, |
| ) |
| prompt = ChatPromptTemplate.from_messages( |
| [ |
| ("system", _load_system_prompt()), |
| MessagesPlaceholder(variable_name="history", optional=True), |
| ("human", "{message}"), |
| ("system", "Analysis state and signals for this turn:\n\n{context}"), |
| ] |
| ) |
| return prompt | llm | StrOutputParser() |
|
|
|
|
| class HelpAgent: |
| """Streams state-aware guidance to the user. |
| |
| `chain` is injectable: tests pass a fake that yields canned tokens. Default |
| constructs the production Azure OpenAI streaming 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 astream( |
| self, |
| state: AnalysisState, |
| history: list[BaseMessage] | None = None, |
| report_ready: ReportReadiness | None = None, |
| message: str | None = None, |
| available_actions: list[str] | None = None, |
| callbacks: list | None = None, |
| ) -> AsyncIterator[str]: |
| """Stream tokens of the guidance reply. |
| |
| `report_ready` defaults to "not ready" so a missing signal degrades safely. |
| `available_actions`, when omitted, is derived deterministically from state. |
| """ |
| readiness = report_ready or ReportReadiness() |
| actions = available_actions or _derive_available_actions(state, readiness) |
| goal_texts = [ |
| getattr(state, "objective", "") or "", |
| *(getattr(state, "business_questions", None) or []), |
| ] |
| reply_language = _detect_reply_language(history, message, goal_texts=goal_texts) |
| logger.info( |
| "help guidance", |
| report_ready=readiness.ready, |
| available_actions=actions, |
| reply_language=reply_language, |
| ) |
|
|
| chain = self._ensure_chain() |
| default_trigger = _DEFAULT_TRIGGERS.get( |
| reply_language, _DEFAULT_TRIGGERS[_FALLBACK_LANGUAGE] |
| ) |
| payload: dict[str, Any] = { |
| "message": message or default_trigger, |
| "history": history or [], |
| "context": _build_context_block(state, readiness, actions, reply_language), |
| } |
| if callbacks: |
| async for token in chain.astream(payload, config={"callbacks": callbacks}): |
| yield token |
| else: |
| async for token in chain.astream(payload): |
| yield token |
|
|