Commit ·
5a010a6
1
Parent(s): 0d108e1
[NOTICKET] fix(agents): analysis answer reply-language + number formatting
Browse filesThe 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(),
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|