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)