Commit Β·
cb7f5ac
1
Parent(s): 09857fc
[NOTICKET] fix(help): lock reply language to user's language on button path
Browse files- src/agents/handlers/help.py +71 -5
- src/config/prompts/help.md +8 -2
src/agents/handlers/help.py
CHANGED
|
@@ -29,6 +29,7 @@ SEAMS:
|
|
| 29 |
|
| 30 |
from __future__ import annotations
|
| 31 |
|
|
|
|
| 32 |
from collections.abc import AsyncIterator
|
| 33 |
from dataclasses import dataclass, field
|
| 34 |
from pathlib import Path
|
|
@@ -49,8 +50,63 @@ _PROMPT_DIR = Path(__file__).resolve().parent.parent.parent / "config" / "prompt
|
|
| 49 |
_SYSTEM_PROMPT_PATH = _PROMPT_DIR / "help.md"
|
| 50 |
_GUARDRAILS_PATH = _PROMPT_DIR / "guardrails.md"
|
| 51 |
|
| 52 |
-
# Neutral human turn when Help is triggered by a slash command with no real content
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
|
| 56 |
@dataclass
|
|
@@ -107,13 +163,20 @@ def _build_context_block(
|
|
| 107 |
state: AnalysisState,
|
| 108 |
report_ready: ReportReadiness,
|
| 109 |
available_actions: list[str],
|
|
|
|
| 110 |
) -> str:
|
| 111 |
-
"""Compose the deterministic context the prompt's 'never misguide' rule trusts.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
return "\n\n".join(
|
| 113 |
[
|
| 114 |
_format_state(state),
|
| 115 |
_format_report_ready(report_ready),
|
| 116 |
"[Available actions]\n" + ", ".join(available_actions),
|
|
|
|
| 117 |
]
|
| 118 |
)
|
| 119 |
|
|
@@ -178,17 +241,20 @@ class HelpAgent:
|
|
| 178 |
"""
|
| 179 |
readiness = report_ready or ReportReadiness()
|
| 180 |
actions = available_actions or _derive_available_actions(state, readiness)
|
|
|
|
| 181 |
logger.info(
|
| 182 |
"help guidance",
|
| 183 |
report_ready=readiness.ready,
|
| 184 |
available_actions=actions,
|
|
|
|
| 185 |
)
|
| 186 |
|
| 187 |
chain = self._ensure_chain()
|
|
|
|
| 188 |
payload: dict[str, Any] = {
|
| 189 |
-
"message": message or
|
| 190 |
"history": history or [],
|
| 191 |
-
"context": _build_context_block(state, readiness, actions),
|
| 192 |
}
|
| 193 |
if callbacks:
|
| 194 |
async for token in chain.astream(payload, config={"callbacks": callbacks}):
|
|
|
|
| 29 |
|
| 30 |
from __future__ import annotations
|
| 31 |
|
| 32 |
+
import re
|
| 33 |
from collections.abc import AsyncIterator
|
| 34 |
from dataclasses import dataclass, field
|
| 35 |
from pathlib import Path
|
|
|
|
| 50 |
_SYSTEM_PROMPT_PATH = _PROMPT_DIR / "help.md"
|
| 51 |
_GUARDRAILS_PATH = _PROMPT_DIR / "guardrails.md"
|
| 52 |
|
| 53 |
+
# Neutral human turn when Help is triggered by a slash command with no real content
|
| 54 |
+
# (button path passes message=None). Per language, so the synthetic turn never drags the
|
| 55 |
+
# reply toward English β without this the only human-turn signal on the button path would
|
| 56 |
+
# be an English sentence, and the model mirrors the last human turn's language.
|
| 57 |
+
_DEFAULT_TRIGGERS = {
|
| 58 |
+
"Indonesian": "Apa yang sebaiknya saya lakukan selanjutnya?",
|
| 59 |
+
"English": "What should I do next?",
|
| 60 |
+
}
|
| 61 |
+
_FALLBACK_LANGUAGE = "Indonesian" # team default when no human turn exists yet
|
| 62 |
+
|
| 63 |
+
# Lightweight, LLM-free language detection over the last human turn. The result is LOCKED
|
| 64 |
+
# into the prompt via a `[Reply language]` directive (see `_build_context_block`), so
|
| 65 |
+
# replying in the user's language is deterministic/mandatory β not a soft prompt hint that
|
| 66 |
+
# an English system prompt + English default trigger can override.
|
| 67 |
+
_ID_MARKERS = frozenset({
|
| 68 |
+
"yang", "dan", "apa", "gimana", "bagaimana", "kenapa", "mengapa", "aku", "saya",
|
| 69 |
+
"tolong", "ini", "itu", "nih", "dong", "kah", "untuk", "dengan", "pada", "adalah",
|
| 70 |
+
"tidak", "enggak", "nggak", "bisa", "mau", "buat", "dari", "kamu", "ya",
|
| 71 |
+
"berapa", "kapan", "siapa", "dimana", "juga", "sudah", "belum", "akan",
|
| 72 |
+
})
|
| 73 |
+
_EN_MARKERS = frozenset({
|
| 74 |
+
"the", "what", "how", "why", "please", "this", "that", "is", "are", "can", "could",
|
| 75 |
+
"should", "for", "with", "of", "and", "you", "do", "does", "when", "where",
|
| 76 |
+
"who", "which", "my", "me", "your", "have", "has", "want", "next",
|
| 77 |
+
})
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _last_human_text(history: list[BaseMessage] | None) -> str:
|
| 81 |
+
"""Return the text of the most recent human turn in history, or '' if none."""
|
| 82 |
+
for msg in reversed(history or []):
|
| 83 |
+
if getattr(msg, "type", None) == "human":
|
| 84 |
+
content = msg.content
|
| 85 |
+
return content if isinstance(content, str) else str(content)
|
| 86 |
+
return ""
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _detect_reply_language(
|
| 90 |
+
history: list[BaseMessage] | None, message: str | None = None
|
| 91 |
+
) -> str:
|
| 92 |
+
"""Detect the reply language from the last human turn (deterministic, no LLM).
|
| 93 |
+
|
| 94 |
+
Prefers an explicit `message` (intent path β the user's real turn) over the last
|
| 95 |
+
human turn in `history` (button path, where `message` is None). Counts Indonesian vs
|
| 96 |
+
English marker words; ties or no signal fall back to Indonesian (the team default).
|
| 97 |
+
Returns "Indonesian" or "English".
|
| 98 |
+
"""
|
| 99 |
+
text = (message or _last_human_text(history)).lower()
|
| 100 |
+
if not text.strip():
|
| 101 |
+
return _FALLBACK_LANGUAGE
|
| 102 |
+
tokens = re.findall(r"[a-z']+", text)
|
| 103 |
+
id_hits = sum(1 for t in tokens if t in _ID_MARKERS)
|
| 104 |
+
en_hits = sum(1 for t in tokens if t in _EN_MARKERS)
|
| 105 |
+
if en_hits > id_hits:
|
| 106 |
+
return "English"
|
| 107 |
+
if id_hits > en_hits:
|
| 108 |
+
return "Indonesian"
|
| 109 |
+
return _FALLBACK_LANGUAGE
|
| 110 |
|
| 111 |
|
| 112 |
@dataclass
|
|
|
|
| 163 |
state: AnalysisState,
|
| 164 |
report_ready: ReportReadiness,
|
| 165 |
available_actions: list[str],
|
| 166 |
+
reply_language: str = _FALLBACK_LANGUAGE,
|
| 167 |
) -> str:
|
| 168 |
+
"""Compose the deterministic context the prompt's 'never misguide' rule trusts.
|
| 169 |
+
|
| 170 |
+
`reply_language` is a hard directive: the prompt is told to reply ONLY in this
|
| 171 |
+
language, so the answer matches the user's language even on the button path (where
|
| 172 |
+
the synthetic human turn would otherwise pull the reply toward English).
|
| 173 |
+
"""
|
| 174 |
return "\n\n".join(
|
| 175 |
[
|
| 176 |
_format_state(state),
|
| 177 |
_format_report_ready(report_ready),
|
| 178 |
"[Available actions]\n" + ", ".join(available_actions),
|
| 179 |
+
f"[Reply language]\nRespond ONLY in: {reply_language}",
|
| 180 |
]
|
| 181 |
)
|
| 182 |
|
|
|
|
| 241 |
"""
|
| 242 |
readiness = report_ready or ReportReadiness()
|
| 243 |
actions = available_actions or _derive_available_actions(state, readiness)
|
| 244 |
+
reply_language = _detect_reply_language(history, message)
|
| 245 |
logger.info(
|
| 246 |
"help guidance",
|
| 247 |
report_ready=readiness.ready,
|
| 248 |
available_actions=actions,
|
| 249 |
+
reply_language=reply_language,
|
| 250 |
)
|
| 251 |
|
| 252 |
chain = self._ensure_chain()
|
| 253 |
+
default_trigger = _DEFAULT_TRIGGERS.get(reply_language, _DEFAULT_TRIGGERS[_FALLBACK_LANGUAGE])
|
| 254 |
payload: dict[str, Any] = {
|
| 255 |
+
"message": message or default_trigger,
|
| 256 |
"history": history or [],
|
| 257 |
+
"context": _build_context_block(state, readiness, actions, reply_language),
|
| 258 |
}
|
| 259 |
if callbacks:
|
| 260 |
async for token in chain.astream(payload, config={"callbacks": callbacks}):
|
src/config/prompts/help.md
CHANGED
|
@@ -23,6 +23,7 @@ You are given context, never raw user prose to analyze:
|
|
| 23 |
- `ready` (bool) β whether there is enough analysis to generate a report.
|
| 24 |
- `missing` (list) β if not ready, the gaps to fill.
|
| 25 |
- **`available_actions`** *(optional)* β which actions are actually wired right now. If present, **only suggest actions listed here.**
|
|
|
|
| 26 |
|
| 27 |
> **Hard rule β never misguide.** Trust the signals above for *what is possible*, not your
|
| 28 |
> own guess. If `report_ready.ready` is `false`, do **not** tell the user to generate a
|
|
@@ -72,8 +73,13 @@ Do not over-promise the report's depth.
|
|
| 72 |
## Tone
|
| 73 |
|
| 74 |
Plain, warm, and encouraging β like a helpful guide, **not** a hype trailer. No exclamation
|
| 75 |
-
spam, no overselling.
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
## Constraints
|
| 79 |
|
|
|
|
| 23 |
- `ready` (bool) β whether there is enough analysis to generate a report.
|
| 24 |
- `missing` (list) β if not ready, the gaps to fill.
|
| 25 |
- **`available_actions`** *(optional)* β which actions are actually wired right now. If present, **only suggest actions listed here.**
|
| 26 |
+
- **`[Reply language]`** β the language you MUST reply in (detected deterministically from the user's last turn). This is an instruction, not a suggestion β see the hard rule below.
|
| 27 |
|
| 28 |
> **Hard rule β never misguide.** Trust the signals above for *what is possible*, not your
|
| 29 |
> own guess. If `report_ready.ready` is `false`, do **not** tell the user to generate a
|
|
|
|
| 73 |
## Tone
|
| 74 |
|
| 75 |
Plain, warm, and encouraging β like a helpful guide, **not** a hype trailer. No exclamation
|
| 76 |
+
spam, no overselling. A few sentences is usually enough.
|
| 77 |
+
|
| 78 |
+
> **Hard rule β reply language.** Reply **only** in the language named in `[Reply language]`.
|
| 79 |
+
> This is mandatory and overrides the language of this prompt, its examples, and the trigger
|
| 80 |
+
> question. If `[Reply language]` says `Indonesian`, answer entirely in Indonesian even though
|
| 81 |
+
> these instructions are in English; if it says `English`, answer in English. Never mix
|
| 82 |
+
> languages or switch mid-reply.
|
| 83 |
|
| 84 |
## Constraints
|
| 85 |
|