amplegest / agent /translate.py
Viney's picture
feat: multi-provider LLM support, prominent chat, design pass, and new analytics
7880373
Raw
History Blame Contribute Delete
6.52 kB
"""agent/translate.py β€” segment-map translation of a research brief.
Key design: extract only prose strings into a flat {id: text} map, translate
that map in ONE LLM call, then re-inject by path. Enums, numbers, tickers,
evidence_snippet, and quarter_deltas are structurally never touched.
"""
from __future__ import annotations
import copy
import json
import sys
from langchain_core.messages import HumanMessage, SystemMessage
from agent.cost_log import compute_run_cost
from agent.graph import _extract_json
from agent.llm import RunConfig, default_config, make_chat_model
from agent.prompts import (
NEVER_TRANSLATE_FIELDS,
PROSE_FIELDS,
PROSE_LIST_FIELDS,
SKIP_SUBTREES,
)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _collect_segments(node, path: tuple = ()) -> list[tuple[tuple, str]]:
"""Walk *node* recursively and return a list of (path, text) pairs for
every prose string that should be translated.
Rules (applied in order for dict keys):
- key in SKIP_SUBTREES β†’ skip subtree entirely
- key in NEVER_TRANSLATE_FIELDS β†’ skip (don't collect, don't recurse)
- key in PROSE_FIELDS β†’ collect if value is a non-empty string
- key in PROSE_LIST_FIELDS β†’ collect each non-empty string item
- otherwise β†’ recurse into value
"""
if isinstance(node, dict):
result: list[tuple[tuple, str]] = []
for key, value in node.items():
if key in SKIP_SUBTREES:
continue
if key in NEVER_TRANSLATE_FIELDS:
continue
if key in PROSE_FIELDS:
if isinstance(value, str) and value:
result.append((path + (key,), value))
elif key in PROSE_LIST_FIELDS:
if isinstance(value, list):
for i, item in enumerate(value):
if isinstance(item, str) and item:
result.append((path + (key, i), item))
else:
result.extend(_collect_segments(value, path + (key,)))
return result
if isinstance(node, list):
result = []
for i, item in enumerate(node):
result.extend(_collect_segments(item, path + (i,)))
return result
return []
def _set_at_path(obj: dict, path: tuple, value: str) -> None:
"""Navigate *obj* using *path* (mixed str/int keys) and set the leaf.
Silently returns without error if any intermediate key is missing.
Example: path=("bull_points", 0, "text") β†’ obj["bull_points"][0]["text"] = value
"""
if not path:
return
current = obj
for step in path[:-1]:
try:
current = current[step]
except (KeyError, IndexError, TypeError):
return
try:
current[path[-1]] = value
except (KeyError, IndexError, TypeError):
return
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def translate_brief(
brief: dict, target_lang: str, config: RunConfig | None = None
) -> tuple[dict, dict | None]:
"""Translate all prose fields in *brief* into *target_lang*.
Returns ``(translated_brief, usage_dict)`` on success, or
``(original_brief, None)`` on any failure. Never raises.
"""
try:
# Step 1 β€” collect prose segments
segments = _collect_segments(brief)
if not segments:
return (brief, None)
# Step 2 — build flat id→text payload
payload: dict[str, str] = {
str(i): text for i, (_, text) in enumerate(segments)
}
# Step 3 β€” create LLM (max_tokens=8192 is mandatory to avoid truncation)
cfg = config or default_config()
llm = make_chat_model(cfg, max_retries=2, max_tokens=8192)
# Step 4 β€” system prompt
system_prompt = (
f"You are a financial translator. The user message is a JSON object "
f"mapping ids to English equity-research text.\n"
f"Translate every value into {target_lang}.\n"
f"Rules:\n"
f"- Return ONLY a JSON object with exactly the same keys; values are the translations.\n"
f"- Professional equity-research register "
f"(for French: vouvoiement, standard finance vocabulary).\n"
f"- Keep verbatim: all numbers, percentages, currency amounts, ticker symbols, "
f"company names, period labels (e.g. \"Q3 2025\"), and untranslatable finance terms "
f"(EPS, FCF, capex, guidance, EBIT, EBITDA, YoY, QoQ).\n"
f"- Plain text only β€” no HTML, no markdown, no commentary, no code fences."
)
# Step 5 β€” invoke
resp = llm.invoke([
SystemMessage(content=system_prompt),
HumanMessage(content=json.dumps(payload, ensure_ascii=False)),
])
# Step 6 β€” parse response
raw_data: dict = json.loads(_extract_json(resp.content))
# Step 7 β€” quality gate: at least 80% of ids must have a non-empty translation
expected = len(payload)
present = sum(
1 for k in payload
if isinstance(raw_data.get(k), str) and raw_data[k]
)
if present < 0.8 * expected:
print(
f"[translate] quality gate failed: {present}/{expected} ids translated "
f"(threshold 80%)",
file=sys.stderr,
)
return (brief, None)
# Step 8 β€” deep-copy the brief
translated = copy.deepcopy(brief)
# Step 9 β€” re-inject translations by path
for i, (path, _) in enumerate(segments):
translation = raw_data.get(str(i))
if isinstance(translation, str) and translation:
_set_at_path(translated, path, translation)
# missing ids β†’ keep original English text (silent fallthrough)
# Step 10 β€” tag the language
translated["language"] = target_lang
# Step 11 β€” compute cost
usage = compute_run_cost([resp], cfg.model)
# Step 12 β€” return
return (translated, usage)
except Exception as e:
print(f"[translate] {e}", file=sys.stderr)
return (brief, None)