"""Bilingual labels for the slow path's SSE `status` events (A22). The slow path used to emit three stage-level pings for a ~12s turn — "Planning", "Running N steps", "Composing". Past ~10 seconds what a waiting user needs is not a faster answer but evidence that something is happening (Miller's attention threshold), so the TaskRunner now reports each wave as it starts and these labels name it. Two rules this module exists to enforce: - **Status text is user-facing, so it follows the reply language.** The rest of a plan is an internal English artifact (`objective`, `goal_restated`, `success_criteria` — see the exemplar note in `planner/examples.py`), which is exactly why a task's `objective` is NOT used as status text: it would show English prose to an Indonesian user. The activity vocabulary below is a bounded, translated set instead. - **Unknown tools degrade, never break.** A tool with no entry falls back to the generic "analysing" label, so the six tools still to land (DEV_PLAN §0.9) produce sensible status before anyone touches this file. The wire contract is unchanged: `status` is still `text`, still optional, still safe for the frontend to ignore (API_CONTRACT §chat). This is more of the same event, not a new shape. """ from __future__ import annotations from collections.abc import Sequence from typing import Any def lang_key(reply_language: str | None) -> str: """"id" for Indonesian, "en" otherwise — the same test `refusals.py` uses.""" return "id" if reply_language == "Indonesian" else "en" # --------------------------------------------------------------------------- # # Stage pings (coordinator) # --------------------------------------------------------------------------- # PLANNING = { "en": "Planning the analysis…", "id": "Menyusun rencana analisis…", } RUNNING = { "en": "Running {n} analysis steps…", "id": "Menjalankan {n} langkah analisis…", } COMPOSING = { "en": "Composing the answer…", "id": "Menyusun jawaban…", } def stage(table: dict[str, str], reply_language: str | None, **fmt: Any) -> str: """Render one stage ping in the reply language.""" return table[lang_key(reply_language)].format(**fmt) # --------------------------------------------------------------------------- # # Per-wave step labels (TaskRunner) # --------------------------------------------------------------------------- # _STEP = { "en": "Step {pos} of {total} — {activity}", "id": "Langkah {pos} dari {total} — {activity}", } # Bounded, translated vocabulary. Keyed by tool name; anything absent uses _DEFAULT. _ACTIVITY: dict[str, dict[str, str]] = { "check_data": {"en": "checking available data", "id": "memeriksa data yang tersedia"}, "check_knowledge": { "en": "checking available documents", "id": "memeriksa dokumen yang tersedia", }, "retrieve_data": {"en": "retrieving data", "id": "mengambil data"}, "retrieve_knowledge": {"en": "searching documents", "id": "mencari di dokumen"}, "render_chart": {"en": "building the chart", "id": "membuat grafik"}, "analyze_aggregate": {"en": "aggregating", "id": "menghitung agregat"}, "analyze_descriptive": {"en": "summarising the data", "id": "meringkas data"}, "analyze_correlation": {"en": "checking correlation", "id": "memeriksa korelasi"}, "analyze_trend": {"en": "analysing the trend", "id": "menganalisis tren"}, "analyze_merge": {"en": "combining the tables", "id": "menggabungkan tabel"}, "analyze_forecast": {"en": "forecasting", "id": "membuat proyeksi"}, "analyze_anomaly": {"en": "detecting anomalies", "id": "mendeteksi anomali"}, } _DEFAULT = {"en": "analysing", "id": "menganalisis"} def _task_tool(task: Any) -> str | None: """The tool that gives a task its purpose — the LAST call in its chain. A task is an ordered chain (`retrieve_data` → `analyze_trend`); the last call is what the task is *for*, so it names the step better than the first. """ calls = getattr(task, "tool_calls", None) or [] return getattr(calls[-1], "tool", None) if calls else None def _activity(tasks: Sequence[Any], lang: str) -> str: """Label a wave: one tool names itself, two are joined, more stay generic.""" seen: dict[str, None] = {} for task in tasks: tool = _task_tool(task) if tool: seen.setdefault(_ACTIVITY.get(tool, _DEFAULT)[lang], None) labels = list(seen) if not labels: return _DEFAULT[lang] if len(labels) == 1: return labels[0] if len(labels) == 2: return " + ".join(labels) return _DEFAULT[lang] def step_label( tasks: Sequence[Any], completed: int, total: int, reply_language: str | None ) -> str: """"Step 2 of 4 — retrieving data", or "Step 2–3 of 4 — …" for a parallel wave. `completed` is how many tasks finished before this wave, so the positions are 1-based and contiguous across waves. """ lang = lang_key(reply_language) first, last = completed + 1, completed + len(tasks) pos = str(first) if first >= last else f"{first}–{last}" return _STEP[lang].format(pos=pos, total=total, activity=_activity(tasks, lang))