File size: 6,524 Bytes
10d762a 7880373 10d762a 6c0f904 10d762a 7880373 10d762a 7880373 10d762a 7880373 10d762a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 | """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)
|