sofhiaazzhr commited on
Commit
5a010a6
·
1 Parent(s): 0d108e1

[NOTICKET] fix(agents): analysis answer reply-language + number formatting

Browse files

The Assembler (analysis answer composer) had no reply-language rule and drifted to English; it also detected language from the router's rewritten_query (normalized to English) rather than the user's turn. Detect reply_language from the ORIGINAL message in chat_handler and thread it through coordinator -> assembler; add the [Reply language] default+explicit-override rule to assembler.md. Also add number formatting to assembler.md: round to a sensible default precision (honoring an explicit user request like '2 angka di belakang koma'), match prose and table cells, and use the decimal separator of the reply language (comma for Indonesian).

src/agents/chat_handler.py CHANGED
@@ -47,6 +47,7 @@ from .handlers.help import HelpAgent
47
  # `run_problem_statement` unwired 2026-06-24 (problem_statement removed from the router).
48
  # `ProblemStatementAgent` kept — still referenced by the constructor + _get_ps_agent.
49
  from .handlers.problem_statement import ProblemStatementAgent
 
50
  from .orchestration import OrchestratorAgent
51
  from .refusals import blocked_message, out_of_scope_message
52
 
@@ -410,8 +411,12 @@ class ChatHandler:
410
  catalog = await reader.read(user_id, "structured")
411
  # structured_flow always runs the slow analytical path (the
412
  # ENABLE_SLOW_PATH flag was removed 2026-07-02).
 
 
 
 
413
  async for event in self._run_slow_path(
414
- user_id, rewritten, catalog, tracer, reader, analysis_id
415
  ):
416
  yield event
417
  return
@@ -594,6 +599,7 @@ class ChatHandler:
594
  tracer: Any = None,
595
  catalog_reader: CatalogReader | None = None,
596
  analysis_id: str | None = None,
 
597
  ) -> AsyncIterator[dict[str, Any]]:
598
  """Run the slow path and stream its assembled answer as SSE events.
599
 
@@ -640,7 +646,8 @@ class ChatHandler:
640
 
641
  run_task = asyncio.create_task(
642
  coordinator.run(
643
- context, catalog, query, Constraints(), progress=_progress, **run_kw
 
644
  )
645
  )
646
  getter: asyncio.Task = asyncio.create_task(progress_q.get())
 
47
  # `run_problem_statement` unwired 2026-06-24 (problem_statement removed from the router).
48
  # `ProblemStatementAgent` kept — still referenced by the constructor + _get_ps_agent.
49
  from .handlers.problem_statement import ProblemStatementAgent
50
+ from .language import detect_reply_language
51
  from .orchestration import OrchestratorAgent
52
  from .refusals import blocked_message, out_of_scope_message
53
 
 
411
  catalog = await reader.read(user_id, "structured")
412
  # structured_flow always runs the slow analytical path (the
413
  # ENABLE_SLOW_PATH flag was removed 2026-07-02).
414
+ # Detect reply language from the ORIGINAL message (not `rewritten` — the
415
+ # router's rewritten_query is often normalized to English, which would
416
+ # make the assembled answer English for an Indonesian question).
417
+ reply_language = detect_reply_language(history, message=message)
418
  async for event in self._run_slow_path(
419
+ user_id, rewritten, catalog, tracer, reader, analysis_id, reply_language
420
  ):
421
  yield event
422
  return
 
599
  tracer: Any = None,
600
  catalog_reader: CatalogReader | None = None,
601
  analysis_id: str | None = None,
602
+ reply_language: str | None = None,
603
  ) -> AsyncIterator[dict[str, Any]]:
604
  """Run the slow path and stream its assembled answer as SSE events.
605
 
 
646
 
647
  run_task = asyncio.create_task(
648
  coordinator.run(
649
+ context, catalog, query, Constraints(),
650
+ progress=_progress, reply_language=reply_language, **run_kw
651
  )
652
  )
653
  getter: asyncio.Task = asyncio.create_task(progress_q.get())
src/agents/slow_path/assembler.py CHANGED
@@ -25,6 +25,7 @@ from langchain_openai import AzureChatOpenAI
25
 
26
  from src.middlewares.logging import get_logger
27
 
 
28
  from ..planner.contracts import BusinessContext
29
  from .errors import AssemblerError
30
  from .prompt import build_assembler_prompt
@@ -93,10 +94,16 @@ class Assembler:
93
  run_state: RunState,
94
  context: BusinessContext,
95
  question: str | None = None,
 
96
  callbacks: list | None = None,
97
  ) -> AssembledOutput:
98
  chain = self._ensure_chain()
99
- human_content = build_assembler_prompt(run_state, context, question)
 
 
 
 
 
100
  try:
101
  if callbacks:
102
  narrative: AssemblerNarrative = await chain.ainvoke(
@@ -113,6 +120,7 @@ class Assembler:
113
  plan_id=run_state.plan_id,
114
  business_context_id=run_state.business_context_id,
115
  n_tasks=len(run_state.results),
 
116
  )
117
  return AssembledOutput(chat_answer=narrative.chat_answer, analysis_record=record)
118
 
 
25
 
26
  from src.middlewares.logging import get_logger
27
 
28
+ from ..language import detect_reply_language
29
  from ..planner.contracts import BusinessContext
30
  from .errors import AssemblerError
31
  from .prompt import build_assembler_prompt
 
94
  run_state: RunState,
95
  context: BusinessContext,
96
  question: str | None = None,
97
+ reply_language: str | None = None,
98
  callbacks: list | None = None,
99
  ) -> AssembledOutput:
100
  chain = self._ensure_chain()
101
+ # `reply_language` is detected upstream from the ORIGINAL user message. Fall back
102
+ # to `question` only if not provided — but note `question` is the router's
103
+ # rewritten_query, which may be normalized to English, so the caller should pass it.
104
+ if reply_language is None:
105
+ reply_language = detect_reply_language([], message=question)
106
+ human_content = build_assembler_prompt(run_state, context, question, reply_language)
107
  try:
108
  if callbacks:
109
  narrative: AssemblerNarrative = await chain.ainvoke(
 
120
  plan_id=run_state.plan_id,
121
  business_context_id=run_state.business_context_id,
122
  n_tasks=len(run_state.results),
123
+ reply_language=reply_language,
124
  )
125
  return AssembledOutput(chat_answer=narrative.chat_answer, analysis_record=record)
126
 
src/agents/slow_path/coordinator.py CHANGED
@@ -43,6 +43,7 @@ class SlowPathCoordinator:
43
  planner_callbacks: list | None = None,
44
  assembler_callbacks: list | None = None,
45
  progress: Callable[[str], Awaitable[None]] | None = None,
 
46
  ) -> AssembledOutput:
47
  # `progress` (optional) surfaces per-stage status to the caller so a long
48
  # slow-path run isn't a silent ~12s on the wire. Each stage is a single
@@ -62,5 +63,5 @@ class SlowPathCoordinator:
62
  await progress("Composing the answer…")
63
  asm_kw = {"callbacks": assembler_callbacks} if assembler_callbacks else {}
64
  return await self._assembler.assemble(
65
- run_state, context, question=query, **asm_kw
66
  )
 
43
  planner_callbacks: list | None = None,
44
  assembler_callbacks: list | None = None,
45
  progress: Callable[[str], Awaitable[None]] | None = None,
46
+ reply_language: str | None = None,
47
  ) -> AssembledOutput:
48
  # `progress` (optional) surfaces per-stage status to the caller so a long
49
  # slow-path run isn't a silent ~12s on the wire. Each stage is a single
 
63
  await progress("Composing the answer…")
64
  asm_kw = {"callbacks": assembler_callbacks} if assembler_callbacks else {}
65
  return await self._assembler.assemble(
66
+ run_state, context, question=query, reply_language=reply_language, **asm_kw
67
  )
src/agents/slow_path/prompt.py CHANGED
@@ -56,6 +56,7 @@ def build_assembler_prompt(
56
  run_state: RunState,
57
  context: BusinessContext,
58
  question: str | None = None,
 
59
  ) -> str:
60
  sections = [
61
  f"# Business context\n\n{render_business_context(context)}",
@@ -63,4 +64,15 @@ def build_assembler_prompt(
63
  ]
64
  if question:
65
  sections.append(f"# Original question\n\n{question}")
 
 
 
 
 
 
 
 
 
 
 
66
  return "\n\n".join(sections)
 
56
  run_state: RunState,
57
  context: BusinessContext,
58
  question: str | None = None,
59
+ reply_language: str | None = None,
60
  ) -> str:
61
  sections = [
62
  f"# Business context\n\n{render_business_context(context)}",
 
64
  ]
65
  if question:
66
  sections.append(f"# Original question\n\n{question}")
67
+ if reply_language:
68
+ # Imperative + resolved language name (a bare "[Reply language]: X" label is too
69
+ # weak for the structured-output call — the English data render drowns it out).
70
+ # The full rule (incl. explicit-request exception) lives in assembler.md.
71
+ sections.append(
72
+ f"# Reply language (MANDATORY)\n\n"
73
+ f"Write `chat_answer` AND every narrative field entirely in **{reply_language}**. "
74
+ f"The results above use English column names and labels — do NOT let that switch "
75
+ f"your reply to English. (If the user explicitly asked for a different language, "
76
+ f"follow that instead.)"
77
+ )
78
  return "\n\n".join(sections)
src/config/prompts/assembler.md CHANGED
@@ -9,6 +9,16 @@ You produce two things in one structured object:
9
  2. The narrative fields of an analysis record: `goal_restated`, `findings`,
10
  `caveats`, `data_used`, `open_questions`.
11
 
 
 
 
 
 
 
 
 
 
 
12
  # Hard rules (non-negotiable)
13
 
14
  1. **Ground every claim in the provided results.** Use only the numbers, tables,
@@ -28,6 +38,19 @@ You produce two things in one structured object:
28
 
29
  - **`chat_answer`**: lead with the answer. Add a short markdown table when it makes
30
  the numbers clearer. Keep it tight — this streams into a chat, not a report.
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  - **`findings`**: the key takeaways, each a single self-contained sentence with the
32
  supporting figure.
33
  - **`caveats`**: data-quality limits, partial/failed steps, assumptions that affect
 
9
  2. The narrative fields of an analysis record: `goal_restated`, `findings`,
10
  `caveats`, `data_used`, `open_questions`.
11
 
12
+ # Reply language
13
+
14
+ > **Default** to the language named in `[Reply language]` (in the input's *Reply language*
15
+ > section, detected from the user's question). Write `chat_answer` **and** the narrative fields
16
+ > in that language. The task results — column/table names and rows — are often English; do
17
+ > **not** let them pull your reply toward English. The user's language wins.
18
+ > **Exception — explicit request overrides.** If the user explicitly asks to reply in a
19
+ > particular language (e.g. "jawab dalam bahasa Inggris", "please answer in Indonesian"), honor
20
+ > that instead. Proper nouns and column/table names may stay as-is.
21
+
22
  # Hard rules (non-negotiable)
23
 
24
  1. **Ground every claim in the provided results.** Use only the numbers, tables,
 
38
 
39
  - **`chat_answer`**: lead with the answer. Add a short markdown table when it makes
40
  the numbers clearer. Keep it tight — this streams into a chat, not a report.
41
+ - **Number formatting.** **Default:** round every figure to a sensible reading precision —
42
+ usually **3 decimals** (up to 5 for small magnitudes like correlations/rates). Never paste
43
+ a raw full-precision float (e.g. `18.053165810898534` → `18.053`), and keep whole numbers
44
+ whole. Apply this in **both** the prose and the table cells so they match.
45
+ **Exception — honor an explicit user request.** If the user asked for a specific precision
46
+ (e.g. "3 angka di belakang koma", "bulatkan ke bilangan bulat", "give the exact value"),
47
+ use exactly that instead of the default — consistently across prose and tables.
48
+ This is display rounding only — it does **not** violate "Render, don't recompute"; the
49
+ underlying value is unchanged.
50
+ **Decimal separator.** Match the reply language: when replying in **Indonesian**, use a
51
+ **comma** for the decimal point and a period for thousands (`71,26`, `1.250,5`); in
52
+ **English**, use a period for the decimal and a comma for thousands (`71.26`, `1,250.5`).
53
+ Be consistent within one reply.
54
  - **`findings`**: the key takeaways, each a single self-contained sentence with the
55
  supporting figure.
56
  - **`caveats`**: data-quality limits, partial/failed steps, assumptions that affect