| """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, |
| ) |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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: |
| |
| segments = _collect_segments(brief) |
| if not segments: |
| return (brief, None) |
|
|
| |
| payload: dict[str, str] = { |
| str(i): text for i, (_, text) in enumerate(segments) |
| } |
|
|
| |
| cfg = config or default_config() |
| llm = make_chat_model(cfg, max_retries=2, max_tokens=8192) |
|
|
| |
| 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." |
| ) |
|
|
| |
| resp = llm.invoke([ |
| SystemMessage(content=system_prompt), |
| HumanMessage(content=json.dumps(payload, ensure_ascii=False)), |
| ]) |
|
|
| |
| raw_data: dict = json.loads(_extract_json(resp.content)) |
|
|
| |
| 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) |
|
|
| |
| translated = copy.deepcopy(brief) |
|
|
| |
| for i, (path, _) in enumerate(segments): |
| translation = raw_data.get(str(i)) |
| if isinstance(translation, str) and translation: |
| _set_at_path(translated, path, translation) |
| |
|
|
| |
| translated["language"] = target_lang |
|
|
| |
| usage = compute_run_cost([resp], cfg.model) |
|
|
| |
| return (translated, usage) |
|
|
| except Exception as e: |
| print(f"[translate] {e}", file=sys.stderr) |
| return (brief, None) |
|
|