sofhiaazzhr Claude Opus 4.8 commited on
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 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
- _DEFAULT_TRIGGER = "What should I do next?"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 _DEFAULT_TRIGGER,
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. Respond in the **user's language** (match `chat_history` β€” Indonesian or
76
- English). A few sentences is usually enough.
 
 
 
 
 
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