-
Notifications
You must be signed in to change notification settings - Fork 0
feat(lexai-functions): OpenAI translation around RunPod for non-English #82
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| # Copy to `.env` for local runs and for `firebase deploy` (Firebase loads this folder’s `.env` | ||
| # when resolving params during deploy). | ||
|
|
||
| # --- Google Cloud Secret Manager (production runtime) --- | ||
| # Create or rotate secrets (you will be prompted for the value; paste once, it is not echoed): | ||
| # cd .. # repo root (where firebase.json lives) | ||
| # firebase functions:secrets:set OPENAI_API_KEY | ||
| # firebase functions:secrets:set PINECONE_API_KEY | ||
| # firebase functions:secrets:set RUNPOD_API_KEY | ||
| # | ||
| # First deploy after adding secrets: if the CLI asks, allow it to grant the function’s | ||
| # service account access to read these secrets. | ||
| # | ||
| # For local development only, you may also put the same keys in this file so imports and | ||
| # the emulator match production variable names: | ||
| OPENAI_API_KEY= | ||
| PINECONE_API_KEY= | ||
| RUNPOD_API_KEY= | ||
|
|
||
| # --- Plain environment / deploy params (not Secret Manager) --- | ||
| # Supplied via this `.env` on deploy, or via interactive prompts if unset. | ||
| RUNPOD_ENDPOINT_ID= | ||
| OPENAI_TRANSLATION_MODEL=gpt-5.1 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,3 +4,4 @@ __pycache__/ | |
| # Python virtual environment | ||
| venv/ | ||
| *.local | ||
| .env | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,167 @@ | ||
| """OpenAI helpers wrapping a non-English UI around an English-only RunPod model. | ||
|
|
||
| ``normalize_to_english`` batches prior turns plus the current user message into one | ||
| structured JSON translation call (stable indices, translation-only system prompt). | ||
| ``translate_english_to_ui_language`` maps the assistant's English reply back to the | ||
| client's display language. English UI skips both calls. | ||
|
|
||
| Model id: ``OPENAI_TRANSLATION_MODEL`` (default ``gpt-5.1``). ``_chat_complete`` tries | ||
| ``max_completion_tokens`` first, then falls back to ``max_tokens`` for older SDK shapes. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import os | ||
| from typing import Any | ||
|
|
||
| from openai import OpenAI | ||
|
|
||
| # System prompts: batch in (_BATCH_SYSTEM) vs single assistant string out (_OUT_SYSTEM_TEMPLATE). | ||
| _BATCH_SYSTEM = """You translate legal app chat fragments to English for a downstream English-only legal model. | ||
|
|
||
| Rules: | ||
| - Output ONLY one JSON object. No markdown code fences. No commentary before or after JSON. | ||
| - Exact shape: {"items":[{"i":<int>,"t":"<english text>"}]} | ||
| - The same integer keys `i`, same count, and same order as in the input. | ||
| - Preserve statute identifiers, MCL / Michigan references, section numbers, docket-style numbers, and party names. | ||
| - Do not add legal analysis or advice — translation only. | ||
| """ | ||
|
|
||
| _OUT_SYSTEM_TEMPLATE = """Translate the assistant message from English into {target_language}. | ||
|
|
||
| Rules: | ||
| - Preserve citations, statute and section numbers, lists, and paragraph breaks where possible. | ||
| - Output only the translated text — no preamble, no quotes, no markdown wrapper.""" | ||
|
|
||
|
|
||
| def is_ui_english(language: str | None) -> bool: | ||
| """True when the client is using English; skips translation in/out.""" | ||
| s = (language or "").strip().lower() | ||
| return s in ("english", "en", "") | ||
|
|
||
|
|
||
| def _client() -> OpenAI: | ||
| """Configured OpenAI client; requires ``OPENAI_API_KEY`` in the environment.""" | ||
| api_key = os.environ.get("OPENAI_API_KEY") | ||
| if not api_key: | ||
| raise RuntimeError("OPENAI_API_KEY is not set") | ||
| return OpenAI(api_key=api_key) | ||
|
|
||
|
|
||
| def translation_model() -> str: | ||
| """Model id for translation calls (not the RunPod legal model).""" | ||
| return os.environ.get("OPENAI_TRANSLATION_MODEL", "gpt-5.1").strip() | ||
|
|
||
|
|
||
| def require_openai_if_translating(ui_language: str) -> None: | ||
| """Fail fast before RAG/RunPod when non-English UI is selected but no API key is bound.""" | ||
| if is_ui_english(ui_language): | ||
| return | ||
| if not os.environ.get("OPENAI_API_KEY"): | ||
| raise RuntimeError("OPENAI_API_KEY is required when the UI language is not English") | ||
|
|
||
|
|
||
| def _chat_complete( | ||
| client: OpenAI, | ||
| *, | ||
| messages: list[dict[str, str]], | ||
| json_object: bool, | ||
| ) -> str: | ||
| """One chat.completions call; ``json_object=True`` for batch translate payloads.""" | ||
| model = translation_model() | ||
| kwargs: dict[str, Any] = { | ||
| "model": model, | ||
| "messages": messages, | ||
| "temperature": 0.2, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For translation tasks, 0 might be better for temperature because we want deterministic literal output. 0.2 is low but could still introduce randomness. |
||
| } | ||
| if json_object: | ||
| kwargs["response_format"] = {"type": "json_object"} | ||
| try: | ||
| # Newer OpenAI Python SDK uses max_completion_tokens. | ||
| resp = client.chat.completions.create(**kwargs, max_completion_tokens=8192) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using a bare except TypeError to detect the API version differences is fragile. Might be better to check the SDK verrsion explicitly or pin the SDK version in requirements.txt |
||
| except TypeError: | ||
| resp = client.chat.completions.create(**kwargs, max_tokens=8192) | ||
| content = resp.choices[0].message.content | ||
| return (content or "").strip() | ||
|
|
||
|
|
||
| def normalize_to_english( | ||
| chat_history: list[Any], | ||
| current_prompt: str, | ||
| ui_language: str, | ||
| ) -> tuple[list[dict[str, str]], str]: | ||
| """Return ``(history_en, prompt_en)`` for RunPod. | ||
|
|
||
| Non-English: one batched JSON object over all segments so roles stay aligned with | ||
| translated text. Validates that every input index ``i`` is returned exactly once. | ||
| """ | ||
| history: list[dict[str, str]] = [] | ||
| for m in chat_history: | ||
| if not isinstance(m, dict): | ||
| continue | ||
| role = str(m.get("role", "user")) | ||
| content = str(m.get("content", "")) | ||
| history.append({"role": role, "content": content}) | ||
|
|
||
| prompt = str(current_prompt) | ||
|
|
||
| if is_ui_english(ui_language): | ||
| return history, prompt | ||
|
|
||
| client = _client() | ||
| payloads: list[dict[str, Any]] = [] | ||
| idx = 0 | ||
| for m in history: | ||
| payloads.append({"i": idx, "role": m["role"], "t": m["content"]}) | ||
| idx += 1 | ||
| payloads.append({"i": idx, "role": "user", "t": prompt}) | ||
| last_i = idx | ||
|
|
||
| user_payload = json.dumps({"items": payloads}, ensure_ascii=False) | ||
| raw = _chat_complete( | ||
| client, | ||
| messages=[ | ||
| {"role": "system", "content": _BATCH_SYSTEM}, | ||
| {"role": "user", "content": user_payload}, | ||
| ], | ||
| json_object=True, | ||
| ) | ||
| data = json.loads(raw or "{}") | ||
| items = data.get("items") | ||
| if not isinstance(items, list) or len(items) != len(payloads): | ||
| raise RuntimeError("OpenAI translation returned invalid items length") | ||
|
|
||
| by_i: dict[int, str] = {} | ||
| for x in items: | ||
| if not isinstance(x, dict) or "i" not in x: | ||
| continue | ||
| by_i[int(x["i"])] = str(x.get("t", "")) | ||
|
|
||
| if len(by_i) != len(payloads): | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The validation checks that the count matches, but doesn't verify that every expected index i (0 through last_i) is actually present — by_i could have the right length but with wrong/duplicate keys. Consider checking set(by_i.keys()) == set(range(len(payloads))) instead |
||
| raise RuntimeError("OpenAI translation missing segment keys") | ||
|
|
||
| history_en: list[dict[str, str]] = [] | ||
| for p in payloads: | ||
| if p["i"] == last_i: | ||
| break | ||
| history_en.append({"role": str(p["role"]), "content": by_i[p["i"]]}) | ||
| prompt_en = by_i[last_i] | ||
| return history_en, prompt_en | ||
|
|
||
|
|
||
| def translate_english_to_ui_language(text: str, ui_language: str) -> str: | ||
| """Map RunPod's English assistant string to the UI language; no-op for English.""" | ||
| if is_ui_english(ui_language): | ||
| return text | ||
| client = _client() | ||
| target = (ui_language or "English").strip() | ||
| system = _OUT_SYSTEM_TEMPLATE.format(target_language=target) | ||
| return _chat_complete( | ||
| client, | ||
| messages=[ | ||
| {"role": "system", "content": system}, | ||
| {"role": "user", "content": text}, | ||
| ], | ||
| json_object=False, | ||
| ) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This creates a brand new client object each time normalize_to_english and translate_english_to_ui_language are called within the same request. Consider caching it as a module-level singleton (like the Pinecone client pattern used in main.py) to avoid redundant initialization overhead.