"""`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" # Neutral human turn when Help is triggered by a slash command with no real content # (button path passes message=None). Per language, so the synthetic turn never drags the # reply toward English — without this the only human-turn signal on the button path would # be an English sentence, and the model mirrors the last human turn's language. _DEFAULT_TRIGGERS = { "Indonesian": "Apa yang sebaiknya saya lakukan selanjutnya?", "English": "What should I do next?", } # Reply-language detection now lives in `src/agents/language.py` (shared with the # analysis answer composer). `_detect_reply_language` / `_FALLBACK_LANGUAGE` are # re-exported via the imports above so this module's call sites + tests are unchanged. @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" # Tolerant of the in-flight AnalysisState rename (#4): prefer objective + # business_questions, fall back to the legacy free-text problem_statement. 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