diff --git a/agent/src/ss_tools/agent/_confirmation.py b/agent/src/ss_tools/agent/_confirmation.py index 5d1197b7..71e49d33 100644 --- a/agent/src/ss_tools/agent/_confirmation.py +++ b/agent/src/ss_tools/agent/_confirmation.py @@ -15,6 +15,7 @@ from typing import Any from langchain_openai import ChatOpenAI from ss_tools.agent._llm_params import chat_openai_kwargs +from ss_tools.shared._llm_http import get_shared_http_client from ss_tools.agent._tool_resolver import ( extract_tool_call_from_state, find_tool, @@ -287,12 +288,15 @@ async def _format_tool_output_via_llm( config = await _fetch_llm_config() if config and config.get("configured"): try: - llm = ChatOpenAI(**chat_openai_kwargs( - model=config.get("default_model", "gpt-4o-mini"), - base_url=config.get("base_url", "https://api.openai.com/v1"), - api_key=config["api_key"], - max_tokens=1024, - )) + llm = ChatOpenAI( + http_client=get_shared_http_client(), + **chat_openai_kwargs( + model=config.get("default_model", "gpt-4o-mini"), + base_url=config.get("base_url", "https://api.openai.com/v1"), + api_key=config["api_key"], + max_tokens=1024, + ), + ) prompt = ( f"Tool '{tool_name}' returned this data:\n\n{text}\n\n" "Summarize this data in a concise, human-readable format. " diff --git a/agent/src/ss_tools/agent/_persistence.py b/agent/src/ss_tools/agent/_persistence.py index 5c5cce58..917bb173 100644 --- a/agent/src/ss_tools/agent/_persistence.py +++ b/agent/src/ss_tools/agent/_persistence.py @@ -14,10 +14,9 @@ import re from typing import Any import uuid -import httpx - from ss_tools.agent._config import AGENT_PREFETCH_DASHBOARD_LIMIT as _PREFETCH_LIMIT, FASTAPI_URL, SERVICE_JWT as _SERVICE_JWT from ss_tools.agent._llm_params import add_temperature_if_supported +from ss_tools.shared._llm_http import get_shared_http_client from ss_tools.shared.logger import logger SAVE_API_URL = FASTAPI_URL + "/api/agent/conversations/save" @@ -119,10 +118,10 @@ async def _get_llm_config() -> dict[str, Any] | None: headers = {"Content-Type": "application/json"} if service_token: headers["Authorization"] = f"Bearer {service_token}" - async with httpx.AsyncClient(timeout=5) as client: - resp = await client.get(f"{fastapi_url}/api/agent/llm-config", headers=headers) - if resp.status_code == 200: - return resp.json() + client = get_shared_http_client(timeout=10) + resp = await client.get(f"{fastapi_url}/api/agent/llm-config", headers=headers) + if resp.status_code == 200: + return resp.json() except Exception: pass return None @@ -152,10 +151,10 @@ async def _call_llm_for_title(user_text: str) -> str | None: if base.endswith("/v1"): base = base[:-3] api_url = base + "/v1/chat/completions" - async with httpx.AsyncClient(timeout=10) as client: - resp = await client.post(api_url, json=payload, headers=headers) - if resp.status_code != 200: - return None + client = get_shared_http_client(timeout=180) + resp = await client.post(api_url, json=payload, headers=headers) + if resp.status_code != 200: + return None data = resp.json() title = data.get("choices", [{}])[0].get("message", {}).get("content", "") if title: @@ -188,8 +187,8 @@ async def generate_llm_title(conv_id: str, user_text: str) -> None: if _SERVICE_JWT: headers["Authorization"] = f"Bearer {_SERVICE_JWT}" payload = {"conversation_id": conv_id, "title": title, "user_id": "admin", "messages": []} - async with httpx.AsyncClient(timeout=5) as client: - await client.post(SAVE_API_URL, json=payload, headers=headers) + client = get_shared_http_client(timeout=10) + await client.post(SAVE_API_URL, json=payload, headers=headers) logger.reflect("LLM title updated", payload={"conv_id": conv_id, "title": title[:40]}, extra={"src": "AgentChat.Persistence"}) except Exception as e: logger.explore("LLM title save failed", payload={"conv_id": conv_id}, error=str(e), extra={"src": "AgentChat.Persistence"}) @@ -204,14 +203,14 @@ async def generate_llm_title(conv_id: str, user_text: str) -> None: async def prefetch_dashboards(env_id: str) -> str: try: from ss_tools.agent.tools import FASTAPI_URL, _dual_auth_headers - async with httpx.AsyncClient(timeout=10) as client: - resp = await client.get( - f"{FASTAPI_URL}/api/dashboards", - params={"q": "", "env_id": env_id or ""}, - headers=_dual_auth_headers(), - ) - if resp.status_code != 200: - return "" + client = get_shared_http_client(timeout=10) + resp = await client.get( + f"{FASTAPI_URL}/api/dashboards", + params={"q": "", "env_id": env_id or ""}, + headers=_dual_auth_headers(), + ) + if resp.status_code != 200: + return "" data = resp.json() dashboards = data.get("dashboards", []) if not dashboards: @@ -243,14 +242,14 @@ async def prefetch_dashboards(env_id: str) -> str: async def prefetch_databases(env_id: str) -> str: try: from ss_tools.agent.tools import FASTAPI_URL, _dual_auth_headers - async with httpx.AsyncClient(timeout=10) as client: - resp = await client.get( - f"{FASTAPI_URL}/api/agent/superset/databases", - params={"environment_id": env_id or ""}, - headers=_dual_auth_headers(), - ) - if resp.status_code != 200: - return "" + client = get_shared_http_client(timeout=10) + resp = await client.get( + f"{FASTAPI_URL}/api/agent/superset/databases", + params={"environment_id": env_id or ""}, + headers=_dual_auth_headers(), + ) + if resp.status_code != 200: + return "" databases = resp.json() if not databases: return "No databases found." @@ -287,8 +286,8 @@ async def save_conversation(conv_id: str, user_text: str, user_id: str = "admin" if assistant_text: messages.append({"id": str(uuid.uuid4()), "conversation_id": conv_id, "role": "assistant", "text": assistant_text.strip(), "state": None, "created_at": datetime.utcnow().isoformat()}) payload = {"conversation_id": conv_id, "title": clean_title(user_text)[:TITLE_MAX_LENGTH], "user_id": user_id, "messages": messages} - async with httpx.AsyncClient(timeout=10) as client: - await client.post(SAVE_API_URL, json=payload, headers=headers) + client = get_shared_http_client(timeout=10) + await client.post(SAVE_API_URL, json=payload, headers=headers) logger.reflect("Conversation saved", payload={"conv_id": conv_id, "user_id": user_id, "messages": len(messages)}, extra={"src": "AgentChat.Persistence"}) except Exception as e: logger.explore("Save conversation failed", payload={"conv_id": conv_id}, error=str(e), extra={"src": "AgentChat.Persistence"}) diff --git a/agent/src/ss_tools/agent/langgraph_setup.py b/agent/src/ss_tools/agent/langgraph_setup.py index 01f0ec87..41e17679 100644 --- a/agent/src/ss_tools/agent/langgraph_setup.py +++ b/agent/src/ss_tools/agent/langgraph_setup.py @@ -10,7 +10,6 @@ import inspect as _inspect import os -import httpx from langchain_openai import ChatOpenAI from langgraph.checkpoint.memory import InMemorySaver from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver @@ -23,6 +22,7 @@ import pydantic_core as _pydantic_core from ss_tools.agent._config import AGENT_CONFIRM_TOOLS, AGENT_INTERRUPT_BEFORE as _INTERRUPT_BEFORE, FASTAPI_URL from ss_tools.agent._llm_params import chat_openai_kwargs +from ss_tools.shared._llm_http import get_shared_http_client from ss_tools.shared.logger import logger _original_transform = _openai_transform._async_transform_recursive @@ -90,13 +90,13 @@ async def _fetch_llm_config() -> dict | None: global _llm_config try: fastapi_url = FASTAPI_URL - async with httpx.AsyncClient(timeout=5) as client: - resp = await client.get(f"{fastapi_url}/api/agent/llm-config") - if resp.status_code == 200: - config = resp.json() - if config.get("configured"): - _llm_config = config - return config + client = get_shared_http_client(timeout=10) + resp = await client.get(f"{fastapi_url}/api/agent/llm-config") + if resp.status_code == 200: + config = resp.json() + if config.get("configured"): + _llm_config = config + return config except Exception as e: logger.explore("Failed to fetch LLM config from FastAPI", error=str(e), extra={"src": "AgentChat.LangGraph.Setup"}) return _llm_config @@ -133,7 +133,10 @@ async def create_agent(tools: list, env_id: str | None = None, interrupt_before: else: raise RuntimeError("No LLM provider configured in backend. Configure one via Settings → AI Providers in the web UI.") logger.reason("Creating LangGraph agent", payload={"model": model, "tools_count": len(tools), "env_id": env_id}, extra={"src": "AgentChat.LangGraph.Setup"}) - llm = ChatOpenAI(**chat_openai_kwargs(model=model, base_url=base_url, api_key=api_key, max_tokens=2048)) + llm = ChatOpenAI( + http_client=get_shared_http_client(), + **chat_openai_kwargs(model=model, base_url=base_url, api_key=api_key, max_tokens=2048), + ) prompt = ( "You are a Superset Tools assistant. You have access to tools for searching " "dashboards, managing maintenance, running migrations and backups, " diff --git a/agent/src/ss_tools/agent/run.py b/agent/src/ss_tools/agent/run.py index 2bfeb457..def32058 100644 --- a/agent/src/ss_tools/agent/run.py +++ b/agent/src/ss_tools/agent/run.py @@ -22,6 +22,7 @@ from ss_tools.agent._config import ( ) from ss_tools.shared.cot_logger import seed_trace_id from ss_tools.shared.logger import logger +from ss_tools.shared.ssl import httpx_verify def _find_free_port(start_port: int, max_attempts: int = 100) -> int: @@ -46,9 +47,15 @@ def _fetch_llm_config() -> dict | None: service_token = SERVICE_JWT headers = {"Authorization": f"Bearer {service_token}"} if service_token else {} + ssl_ctx = httpx_verify() for attempt in range(6): try: - resp = httpx.get(f"{FASTAPI_URL}/api/agent/llm-config", headers=headers, timeout=5) + resp = httpx.get( + f"{FASTAPI_URL}/api/agent/llm-config", + headers=headers, + timeout=5, + verify=ssl_ctx, + ) resp.raise_for_status() config = resp.json() if config.get("configured"): diff --git a/agent/src/ss_tools/agent/tools.py b/agent/src/ss_tools/agent/tools.py index d4f9c277..8be48410 100644 --- a/agent/src/ss_tools/agent/tools.py +++ b/agent/src/ss_tools/agent/tools.py @@ -19,6 +19,7 @@ from pydantic import BaseModel, Field from ss_tools.agent._config import FASTAPI_URL, SERVICE_JWT as _SERVICE_JWT from ss_tools.agent.context import get_service_jwt, get_user_jwt, get_user_role +from ss_tools.shared._llm_http import get_shared_http_client from ss_tools.shared.logger import logger TOOL_RESPONSE_LIMIT = 4000 @@ -249,19 +250,19 @@ async def _execute_with_timeout(tool_name: str, tool_fn, is_write: bool = False, # @BRIEF Async HTTP GET to FastAPI with dual-auth headers. async def _get(path: str, params: dict[str, Any] | None = None) -> httpx.Response: async def _request() -> httpx.Response: - async with httpx.AsyncClient(timeout=TOOL_TIMEOUT_SECONDS) as client: - resp = await client.get( - f"{FASTAPI_URL}{path}", - params=params, - headers=_dual_auth_headers(), + client = get_shared_http_client(timeout=TOOL_TIMEOUT_SECONDS) + resp = await client.get( + f"{FASTAPI_URL}{path}", + params=params, + headers=_dual_auth_headers(), + ) + if resp.status_code in {502, 503, 504}: + raise httpx.HTTPStatusError( + f"Transient FastAPI error {resp.status_code}", + request=resp.request, + response=resp, ) - if resp.status_code in {502, 503, 504}: - raise httpx.HTTPStatusError( - f"Transient FastAPI error {resp.status_code}", - request=resp.request, - response=resp, - ) - return resp + return resp try: return await _execute_with_timeout(path, lambda: _retry_read_tool(path, _request), timeout_s=TOOL_TIMEOUT_SECONDS) @@ -279,13 +280,13 @@ async def _post( params: dict[str, Any] | None = None, ) -> httpx.Response: async def _request() -> httpx.Response: - async with httpx.AsyncClient(timeout=TOOL_TIMEOUT_SECONDS) as client: - return await client.post( - f"{FASTAPI_URL}{path}", - json=payload or {}, - params=params, - headers=_dual_auth_headers(), - ) + client = get_shared_http_client(timeout=TOOL_TIMEOUT_SECONDS) + return await client.post( + f"{FASTAPI_URL}{path}", + json=payload or {}, + params=params, + headers=_dual_auth_headers(), + ) try: return await _execute_with_timeout(path, _request, is_write=True, timeout_s=TOOL_TIMEOUT_SECONDS) diff --git a/backend/src/api/routes/llm.py b/backend/src/api/routes/llm.py index 68408e7b..8ce887d7 100644 --- a/backend/src/api/routes/llm.py +++ b/backend/src/api/routes/llm.py @@ -527,10 +527,13 @@ async def probe_max_images( if not model: raise HTTPException(status_code=400, detail="Provider has no default model set") - # Build the client + # Build the client with system SSL context + from ss_tools.shared._llm_http import get_shared_http_client + client = AsyncOpenAI( api_key=api_key, base_url=db_provider.base_url, + http_client=get_shared_http_client(timeout=180), ) def build_content(n_images: int) -> list[dict]: diff --git a/backend/src/core/database.py b/backend/src/core/database.py index 10af3364..6cd54694 100644 --- a/backend/src/core/database.py +++ b/backend/src/core/database.py @@ -593,6 +593,22 @@ def _ensure_translation_jobs_columns(bind_engine): extra={"error": str(migration_error)}, ) + if "include_source_reference" not in existing_columns: + try: + with bind_engine.begin() as connection: + connection.execute( + text( + "ALTER TABLE translation_jobs " + "ADD COLUMN include_source_reference BOOLEAN NOT NULL DEFAULT TRUE" + ) + ) + logger.reflect("Added include_source_reference column to translation_jobs") + except Exception as migration_error: + logger.explore( + "Failed to add include_source_reference to translation_jobs", + extra={"error": str(migration_error)}, + ) + def _ensure_dataset_review_session_columns(bind_engine): with belief_scope("_ensure_dataset_review_session_columns"): diff --git a/backend/src/plugins/translate/__tests__/test_batch_insert.py b/backend/src/plugins/translate/__tests__/test_batch_insert.py index 425deba2..e3b74476 100644 --- a/backend/src/plugins/translate/__tests__/test_batch_insert.py +++ b/backend/src/plugins/translate/__tests__/test_batch_insert.py @@ -28,6 +28,7 @@ def make_job(**overrides) -> TranslationJob: job.target_schema = "public" job.target_table = "translations" job.target_languages = ["ru"] + job.include_source_reference = True job.context_columns = [] job.target_dialect = "clickhouse" job.database_dialect = None @@ -151,6 +152,24 @@ class TestBuildInsertRows: # Only the original row — language is skipped because en == detected assert len(rows) == 1 + def test_no_source_reference_skips_matching_language_entirely(self): + """When source references are disabled, src_lang == target_lang inserts nothing.""" + job = make_job(target_key_cols=["id"], include_source_reference=False) + rec = make_record(languages=[make_lang("ru", "Оплачено 22.06", detected="ru")]) + rows = _build_insert_rows([rec], job, "translated_text", "ru", []) + + assert rows == [] + + def test_no_source_reference_inserts_only_real_translation(self): + """Reference row is omitted, non-source translation row remains.""" + job = make_job(target_key_cols=["id"], include_source_reference=False) + rec = make_record(languages=[make_lang("ru", "Привет", detected="en")]) + rows = _build_insert_rows([rec], job, "translated_text", "ru", []) + + assert len(rows) == 1 + assert rows[0]["is_original"] == 0 + assert rows[0]["lang_code"] == "ru" + def test_source_column_values(self): job = make_job(target_key_cols=["id"]) rec = make_record() diff --git a/backend/src/plugins/translate/_batch_insert.py b/backend/src/plugins/translate/_batch_insert.py index 810d38fe..aaff9013 100644 --- a/backend/src/plugins/translate/_batch_insert.py +++ b/backend/src/plugins/translate/_batch_insert.py @@ -47,6 +47,13 @@ async def insert_batch_to_target( context_keys = _build_context_keys(job, effective_target) rows_for_sql = _build_insert_rows(records, job, effective_target, primary_language, context_keys) + if not rows_for_sql: + logger.reason( + f"Batch {batch_id[:12]} has no rows to insert after language filtering", + {"batch_id": batch_id, "records": len(records)}, + ) + return + if not columns: columns = [effective_target or "translated_text"] rows_for_sql = [{columns[0]: rec.target_sql or ""} for rec in records] @@ -140,6 +147,7 @@ def _build_insert_rows( ) -> list[dict[str, object]]: """Build row dicts for the INSERT SQL statement.""" rows_for_sql: list[dict[str, object]] = [] + include_source_reference = getattr(job, "include_source_reference", True) for rec in records: source_data = rec.source_data or {} detected_src_lang = "und" @@ -166,13 +174,14 @@ def _build_insert_rows( base_row[job.target_source_language_column] = detected_src_lang base_row["context"] = json.dumps(context_data, ensure_ascii=False) - original_row = dict(base_row) - if effective_target: - original_row[effective_target] = rec.source_sql or "" - if job.target_language_column: - original_row[job.target_language_column] = detected_src_lang - original_row["is_original"] = 1 - rows_for_sql.append(original_row) + if include_source_reference: + original_row = dict(base_row) + if effective_target: + original_row[effective_target] = rec.source_sql or "" + if job.target_language_column: + original_row[job.target_language_column] = detected_src_lang + original_row["is_original"] = 1 + rows_for_sql.append(original_row) if rec.languages and len(rec.languages) > 0: for lang in rec.languages: @@ -187,6 +196,8 @@ def _build_insert_rows( trans_row["is_original"] = 0 rows_for_sql.append(trans_row) else: + if str(primary_language).lower() == str(detected_src_lang).lower(): + continue fallback = dict(base_row) if effective_target: fallback[effective_target] = rec.target_sql or "" diff --git a/backend/src/plugins/translate/_lang_detect.py b/backend/src/plugins/translate/_lang_detect.py index 079883eb..6f73aeda 100644 --- a/backend/src/plugins/translate/_lang_detect.py +++ b/backend/src/plugins/translate/_lang_detect.py @@ -20,6 +20,7 @@ from lingua import Language, LanguageDetector, LanguageDetectorBuilder _CYRILLIC_RE = re.compile(r"[\u0400-\u04FF\u0500-\u052F]") # ASCII letter range (Latin-based) _ASCII_LETTER_RE = re.compile(r"[a-zA-Z]") +_DIGIT_RE = re.compile(r"\d") # --------------------------------------------------------------------------- # Build BCP-47 → lingua Language mapping from the lingua Language enum @@ -181,6 +182,43 @@ def _character_block_fallback( # #endregion _character_block_fallback +# #region _mark_suspicious_detections_und [C:2] [TYPE Function] [SEMANTICS language, detection, llm-fallback] +# @BRIEF Downgrade low-signal detections to "und" so LLM can arbitrate source language. +# @RATIONALE lingua is reliable on full sentences, but short accounting strings with dates, +# amounts, or mixed scripts are frequently misclassified (ru text as uk/it/en). +# Returning "und" routes the row through LLM with explicit language-detection +# instructions instead of trusting a brittle local guess. +def _mark_suspicious_detections_und( + texts: list[str], + results: list[str], + target_languages: list[str] | None, +) -> list[str]: + """Mark suspicious non-target detections as und for LLM-assisted verification.""" + if not target_languages or "ru" not in {t.lower() for t in target_languages}: + return results + + for i, (text, result) in enumerate(zip(texts, results)): + result_l = (result or "und").lower() + if result_l in ("und", "ru") or not text: + continue + + ascii_letters = len(_ASCII_LETTER_RE.findall(text)) + cyrillic_letters = len(_CYRILLIC_RE.findall(text)) + if cyrillic_letters == 0: + continue + + total_letters = ascii_letters + cyrillic_letters + digit_count = len(_DIGIT_RE.findall(text)) + short_text = total_letters <= 20 + number_heavy = total_letters > 0 and digit_count / total_letters >= 0.35 + + if short_text or number_heavy: + results[i] = "und" + + return results +# #endregion _mark_suspicious_detections_und + + # #region batch_detect [C:3] [TYPE Function] [SEMANTICS language, detection, batch] # @ingroup Translate # @BRIEF Detect language for multiple texts in batch. Builds detector if not provided. @@ -206,7 +244,7 @@ def batch_detect( detector = get_detector(target_languages) results = [detect_language(t, detector) for t in texts] - results = _character_block_fallback(texts, results, target_languages) + results = _mark_suspicious_detections_und(texts, results, target_languages) return results # #endregion batch_detect # #endregion LanguageDetectService diff --git a/backend/src/plugins/translate/_llm_async_http.py b/backend/src/plugins/translate/_llm_async_http.py index 3652aa5f..6b853466 100644 --- a/backend/src/plugins/translate/_llm_async_http.py +++ b/backend/src/plugins/translate/_llm_async_http.py @@ -15,254 +15,26 @@ # .ok attribute (only is_success), causes 'Response' object has no attribute 'ok'. import asyncio -import os -import ssl from typing import Any import httpx from ...core.cot_logger import log from ...core.logger import logger -from ._utils import _sanitize_url - -# Module-level httpx client, lazily initialized for connection reuse -_http_client: httpx.AsyncClient | None = None +from ss_tools.shared._llm_http import ( + get_shared_http_client, + sanitize_url, + call_openai_compatible, +) # Default provider and max_tokens constants DEFAULT_PROVIDER_TYPE: str = "openai" DEFAULT_MAX_TOKENS: int = 8192 - -# #region _get_verify [C:1] [TYPE Function] [SEMANTICS translate, ssl, verify] -# @BRIEF Resolve SSL verification context via centralized core.ssl helper. -# @RATIONALE Используем capath=/etc/ssl/certs/ через ssl.create_default_context, -# потому что OpenSSL 3.x не использует intermediate CA сертификаты из cafile -# для построения цепочки (verify code 20). capath с хеш-симлинками работает -# корректно (verify code 0). Возвращаем SSLContext вместо строки — httpx 0.28.x -# депрекейтит строковый путь в verify=. -# @REJECTED cafile отвергнут — OpenSSL 3.x не использует intermediate CA -# из единого bundle-файла. Только capath с хеш-симлинками даёт code 0. -# @REJECTED Строковый путь "/etc/ssl/certs/" отвергнут — httpx 0.28.x депрекейтит -# строки в verify=, требует SSLContext. -# @REJECTED LLM_SSL_VERIFY=false escape hatch отвергнут — централизованный SSL -# всегда использует системное хранилище без возможности отключения. -# @POST Returns ssl.SSLContext with capath (never False). -def _get_verify() -> ssl.SSLContext: - from ...core.ssl import httpx_verify - - return httpx_verify() - - -# #endregion _get_verify - - -# #region _get_http_client [C:1] [TYPE Function] -# @BRIEF Get or create the module-level httpx.AsyncClient singleton. -# @POST Returns httpx.AsyncClient with SSL verify and 180s timeout. -async def _get_http_client() -> httpx.AsyncClient: - global _http_client - if _http_client is None: - ssl_verify = _get_verify() - _http_client = httpx.AsyncClient( - verify=ssl_verify, - timeout=httpx.Timeout(180.0), - ) - return _http_client - - -# #endregion _get_http_client - - -# #region call_openai_compatible [C:3] [TYPE Function] [SEMANTICS translate, llm, http, openai, async] -# @ingroup Translate -# @BRIEF Call OpenAI-compatible API asynchronously with rate-limit handling and structured output fallback. -# @PRE Valid API endpoint, key, model, and prompt. -# @POST Returns (response text, finish_reason). -# @SIDE_EFFECT Async HTTP POST to LLM API. -async def call_openai_compatible( - base_url: str, - api_key: str, - model: str, - prompt: str, - provider_type: str = "openai", - max_tokens: int = 8192, - disable_reasoning: bool = False, -) -> tuple[str, str | None]: - """Call OpenAI-compatible API for batch translation (async).""" - if not base_url: - raise ValueError("LLM provider has no base_url configured") - - # Normalise base_url: strip trailing /v1 to avoid double /v1 - base = base_url.rstrip("/") - if base.endswith("/v1"): - base = base[:-3] - url = f"{base}/v1/chat/completions" - headers = { - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", - } - system_content = "You are a database content translation assistant. Translate the provided text accurately, preserving data semantics. Respond directly with ONLY the JSON result. Do NOT include any reasoning, thinking, chain-of-thought, analysis, or explanation. Output ONLY valid JSON." - - payload: dict[str, Any] = { - "model": model, - "messages": [ - {"role": "system", "content": system_content}, - {"role": "user", "content": prompt}, - ], - "temperature": 0.1, - "max_tokens": max_tokens, - } - - if provider_type in ("openai", "openai_compatible", "kilo", "openrouter", "litellm"): - if not disable_reasoning: - payload["response_format"] = {"type": "json_object"} - - if disable_reasoning: - if provider_type not in ("kilo", "openrouter", "litellm"): - payload["reasoning_effort"] = "none" - payload["max_tokens"] = max_tokens - - logger.reason(f"LLM request url={_sanitize_url(base_url)} model={payload.get('model')} provider_type={provider_type} response_format={'yes' if 'response_format' in payload else 'no'} prompt_len={len(prompt)}") - - response, response_text = await _do_http_request(url, headers, payload) - await _handle_response_format_fallback(response, response_text, payload, url, headers) - - if not response.is_success: - logger.explore(f"LLM API error status={response.status_code} model={payload.get('model')} body={response_text[:2000]}") - response.raise_for_status() - data = response.json() - - choices = data.get("choices", []) - if not choices: - logger.explore( - "LLM returned no choices", - extra={ - "src": "executor", - "response_keys": list(data.keys()), - "response_preview": str(data)[:2000], - }, - ) - raise ValueError("LLM returned no choices") - - try: - finish_reason = choices[0].get("finish_reason") or "none" - msg = choices[0].get("message") or {} - except (TypeError, AttributeError) as e: - logger.explore( - "TypeError processing LLM response choices", - extra={ - "src": "executor_diag", - "error": str(e), - "choices_0_type": type(choices[0]).__name__ if choices else "N/A", - "choices_0_repr": repr(choices[0])[:2000] if choices else "N/A", - "data_type": type(data).__name__, - "data_preview": str(data)[:2000], - }, - ) - raise ValueError(f"LLM response processing failed: {e}") - - # Log provider token usage for batch sizing calibration - usage = data.get("usage") or {} - if usage: - logger.reason( - "LLM provider usage", - { - "prompt_tokens": usage.get("prompt_tokens"), - "completion_tokens": usage.get("completion_tokens"), - "total_tokens": usage.get("total_tokens"), - "finish_reason": finish_reason, - "max_tokens_sent": max_tokens, - "chars_sent": len(prompt), - }, - ) - - refusal = msg.get("refusal") if isinstance(msg, dict) else None - if refusal: - logger.explore( - "LLM refused to respond", - extra={ - "src": "executor", - "refusal": str(refusal)[:500], - "finish_reason": finish_reason, - }, - ) - raise ValueError(f"LLM refused to respond: {refusal}") - - content = msg.get("content") if isinstance(msg, dict) else "" - if not content and isinstance(msg, dict): - content = msg.get("content") or "" - - logger.reason(f"LLM response finish_reason={finish_reason} content_len={len(content)} msg_keys={list(msg.keys()) if isinstance(msg, dict) else []}") - if not content: - logger.explore( - "LLM returned empty content", - extra={ - "src": "executor", - "finish_reason": finish_reason, - "msg_keys": list(msg.keys()) if isinstance(msg, dict) else [], - "response_preview": str(data)[:2000], - }, - ) - raise ValueError("LLM returned empty content") - - return content, finish_reason - - -# #endregion call_openai_compatible - - -# #region _do_http_request [C:1] [TYPE Function] -async def _do_http_request(url: str, headers: dict, payload: dict) -> tuple[httpx.Response, str]: - """Make async HTTP POST with rate-limit (429) retry handling.""" - client = await _get_http_client() - _max_retry_429 = 3 - _retry_count_429 = 0 - while _retry_count_429 < _max_retry_429: - response = await client.post(url, headers=headers, json=payload) - response_text = response.text - if response.status_code == 429: - _retry_count_429 += 1 - retry_after = response.headers.get("Retry-After") - if retry_after: - try: - wait = int(retry_after) - except (ValueError, TypeError): - wait = 2**_retry_count_429 - else: - wait = 2**_retry_count_429 - logger.explore(f"Rate limited (429), retry {_retry_count_429}/{_max_retry_429} after {wait}s", extra={"src": "executor", "retry_after": retry_after, "wait": wait}) - await asyncio.sleep(wait) - if _retry_count_429 >= _max_retry_429: - break - else: - break - return response, response_text - - -# #endregion _do_http_request - - -# #region _handle_response_format_fallback [C:1] [TYPE Function] -async def _handle_response_format_fallback( - response: httpx.Response, - response_text: str, - payload: dict, - url: str, - headers: dict, -) -> None: - """Handle 400 errors from structured_outputs not being supported.""" - _patterns = ("response_format", "structured_outputs", "structured", "json_object") - if not response.is_success and response.status_code == 400 and any(p in (response_text or "").lower() for p in _patterns): - client = await _get_http_client() - logger.explore("Structured outputs not supported, retrying without response_format", extra={"src": "executor"}) - payload.pop("response_format", None) - new_response = await client.post(url, headers=headers, json=payload) - # Mutate the original response object with new data - response.status_code = new_response.status_code - response._content = new_response.content - response.encoding = new_response.encoding - response.headers = new_response.headers - - -# #endregion _handle_response_format_fallback +# Re-export for backward compatibility with _llm_call.py and preview_executor.py +__all__ = [ + "call_openai_compatible", + "DEFAULT_PROVIDER_TYPE", + "DEFAULT_MAX_TOKENS", +] # #endregion LLMAsyncHttpClient diff --git a/backend/src/plugins/translate/_llm_call.py b/backend/src/plugins/translate/_llm_call.py index 7d2ffb5b..0edbdf0d 100644 --- a/backend/src/plugins/translate/_llm_call.py +++ b/backend/src/plugins/translate/_llm_call.py @@ -3,8 +3,8 @@ # @BRIEF LLM interaction for batch translation: call provider with retry, handle truncation # by recursive splitting, enforce dictionary post-processing. Orchestrates HTTP calls # (_llm_http) and response parsing (_llm_parse). -# Language detection is now handled locally (lingua) — LLM prompt no longer -# requests detected_source_language; local _detected_lang takes priority. +# Language detection is handled locally first; ambiguous rows keep "und" and +# the LLM response supplies detected_source_language for arbitration. # @LAYER Domain # @RELATION DEPENDS_ON -> [LLMProviderService] # @RELATION DEPENDS_ON -> [TranslationRecord], [TranslationLanguage] @@ -167,20 +167,37 @@ class LLMTranslationService: # #region _build_prompt [C:2] [TYPE Function] [SEMANTICS translate, llm, prompt] # @BRIEF Build the full LLM prompt from batch rows, dictionary, and target languages. + # Context columns from job.context_columns are included in rows_json when configured. @staticmethod def _build_prompt(job, batch_rows, dictionary_section, target_languages): target_languages_str = ", ".join(target_languages) + + context_cols = job.context_columns or [] + context_hint = "" + if context_cols: + context_hint = ( + f"Context columns: {', '.join(context_cols)}\n" + "Consider these context fields when determining the meaning of the text.\n\n" + ) + rows_json = json.dumps([ - {"row_id": str(row.get("row_index", idx)), "text": row.get("source_text", "")} + { + "row_id": str(row.get("row_index", idx)), + "text": row.get("source_text", ""), + "detected_source_language": row.get("_detected_lang", "und") or "und", + **({"context": { + col: str((row.get("source_data") or {}).get(col) or "") + for col in context_cols + }} if context_cols else {}) + } for idx, row in enumerate(batch_rows) ], indent=2) + return render_prompt(DEFAULT_EXECUTION_PROMPT_TEMPLATE, { - "source_language": job.source_dialect or "SQL", - "target_language": target_languages_str, "target_languages": target_languages_str, - "source_dialect": job.source_dialect or "", - "target_dialect": job.target_dialect or "", + "target_language": target_languages_str, "translation_column": job.translation_column or "", + "context_hint": context_hint, "dictionary_section": dictionary_section, "rows_json": rows_json, "row_count": str(len(batch_rows)), @@ -339,12 +356,14 @@ class LLMTranslationService: self.db.add(record) for lang_code in target_languages: if detected_lang != "und" and str(lang_code).lower() == str(detected_lang).lower(): - continue - val = plv.get(lang_code, "") + val = source_text + else: + val = plv.get(lang_code, "") + needs_review = (detected_lang == "und") self.db.add(TranslationLanguage( id=str(uuid.uuid4()), record_id=record.id, language_code=lang_code, source_language_detected=detected_lang, translated_value=val or "", - final_value=val or "", status="translated", needs_review=(detected_lang == "und"), + final_value=val or "", status="translated", needs_review=needs_review, )) logger.reason( @@ -440,8 +459,9 @@ class LLMTranslationService: self.db.add(record) for lang_code in target_languages: if detected_lang != "und" and str(lang_code).lower() == str(detected_lang).lower(): - continue - val = plv.get(lang_code, "") + val = source_text + else: + val = plv.get(lang_code, "") needs_review = (detected_lang == "und") if needs_review: logger.explore("undetected language", {"record_id": row_id, "language_code": lang_code, "text": source_text[:100]}) diff --git a/backend/src/plugins/translate/orchestrator_sql_rows.py b/backend/src/plugins/translate/orchestrator_sql_rows.py index f685f311..aa69e769 100644 --- a/backend/src/plugins/translate/orchestrator_sql_rows.py +++ b/backend/src/plugins/translate/orchestrator_sql_rows.py @@ -62,6 +62,7 @@ def build_rows( ) -> list[dict[str, object]]: """Build row data for SQL INSERT with per-language expansion.""" rows_for_sql: list[dict[str, object]] = [] + include_source_reference = getattr(job, "include_source_reference", True) for rec in records: source_data = rec.source_data or {} detected_src_lang = "und" @@ -84,13 +85,14 @@ def build_rows( base_row[job.target_source_language_column] = detected_src_lang base_row["context"] = json.dumps(context_data, ensure_ascii=False) - original_row = dict(base_row) - if effective_target: - original_row[effective_target] = rec.source_sql or "" - if job.target_language_column: - original_row[job.target_language_column] = detected_src_lang - original_row["is_original"] = 1 - rows_for_sql.append(original_row) + if include_source_reference: + original_row = dict(base_row) + if effective_target: + original_row[effective_target] = rec.source_sql or "" + if job.target_language_column: + original_row[job.target_language_column] = detected_src_lang + original_row["is_original"] = 1 + rows_for_sql.append(original_row) if rec.languages and len(rec.languages) > 0: for lang in rec.languages: @@ -105,6 +107,8 @@ def build_rows( trans_row["is_original"] = 0 rows_for_sql.append(trans_row) else: + if str(primary_language).lower() == str(detected_src_lang).lower(): + continue fallback_row = dict(base_row) if effective_target: fallback_row[effective_target] = rec.target_sql or "" diff --git a/backend/src/plugins/translate/preview_constants.py b/backend/src/plugins/translate/preview_constants.py index 7b1af1e6..3c3ea8d1 100644 --- a/backend/src/plugins/translate/preview_constants.py +++ b/backend/src/plugins/translate/preview_constants.py @@ -1,23 +1,26 @@ # #region DEFAULT_EXECUTION_PROMPT_TEMPLATE [C:1] [TYPE Block] [SEMANTICS prompt, template] # @BRIEF Default LLM prompt template for full execution (batch translation). -# Language detection is handled locally (lingua) — prompt does not request -# detected_source_language, saving LLM tokens. +# Language detection is handled locally first; ambiguous/und rows ask the LLM +# to return detected_source_language for source-language arbitration. # Supports both single-language and multi-language modes via {target_languages} placeholder. DEFAULT_EXECUTION_PROMPT_TEMPLATE: str = ( - "Translate the following database content from {source_language} to the following language(s): {target_languages}.\n\n" - "Source dialect: {source_dialect}\n" - "Target dialect(s): {target_dialect}\n" + "Translate the following content to the following language(s): {target_languages}.\n\n" "Column to translate: {translation_column}\n\n" + "{context_hint}" "{dictionary_section}" "IMPORTANT — You MUST use the terminology dictionary above. " "For any source term listed in the dictionary, you MUST use its exact target translation. " "Do not translate dictionary terms differently. " "This is mandatory, not optional.\n\n" - "For each row, provide an accurate translation of the text into each target language.\n\n" + "For each row, provide an accurate translation of the text into each target language.\n" + "Each row contains detected_source_language from local detection. " + "If it is 'und', determine the source language yourself and return it as detected_source_language. " + "If the source language is the same as a target language, return the original text for that target language.\n\n" "Rows to translate:\n{rows_json}\n\n" "Respond with a JSON object in this exact format:\n" - '{{"rows": [{{"row_id": "", "": "", "": ""}}]}}\n' + '{{"rows": [{{"row_id": "", "detected_source_language": "", "": "", "": ""}}]}}\n' + "Include detected_source_language for EVERY row. " "Include a separate key for EACH target language code with the translated text in that language.\n" "Each row_id must match the index provided. Return exactly {row_count} entries." ) @@ -28,8 +31,7 @@ DEFAULT_EXECUTION_PROMPT_TEMPLATE: str = ( # @BRIEF Default LLM prompt template for preview (sample translation). DEFAULT_PREVIEW_PROMPT_TEMPLATE: str = ( - "Translate the following database content.\n\n" - "Source dialect: {source_dialect}\n" + "Translate the following content.\n\n" "Column to translate: {translation_column}\n\n" "{dictionary_section}" "IMPORTANT — You MUST use the terminology dictionary above. " diff --git a/backend/src/plugins/translate/preview_prompt_builder.py b/backend/src/plugins/translate/preview_prompt_builder.py index 6eb349ce..2c34eeb5 100644 --- a/backend/src/plugins/translate/preview_prompt_builder.py +++ b/backend/src/plugins/translate/preview_prompt_builder.py @@ -92,10 +92,8 @@ class PreviewPromptBuilder: target_languages_str = ", ".join(target_languages) template = prompt_template or DEFAULT_PREVIEW_PROMPT_TEMPLATE prompt = render_prompt(template, { - "source_language": job.source_dialect or "SQL", - "target_language": target_languages_str, - "source_dialect": job.source_dialect or "", "target_languages": target_languages_str, + "target_language": target_languages_str, "translation_column": job.translation_column or "", "dictionary_section": dictionary_section, "rows_json": rows_json, diff --git a/backend/src/plugins/translate/service_utils.py b/backend/src/plugins/translate/service_utils.py index e28968d5..b0725eab 100644 --- a/backend/src/plugins/translate/service_utils.py +++ b/backend/src/plugins/translate/service_utils.py @@ -54,6 +54,7 @@ def job_to_response(job: TranslationJob, dict_ids: list[str] | None = None) -> T target_language_column=job.target_language_column, target_source_column=job.target_source_column, target_source_language_column=job.target_source_language_column, + include_source_reference=job.include_source_reference, context_columns=job.context_columns or [], target_languages=job.target_languages, provider_id=job.provider_id, diff --git a/backend/src/schemas/translate.py b/backend/src/schemas/translate.py index f04378a7..98ef1816 100644 --- a/backend/src/schemas/translate.py +++ b/backend/src/schemas/translate.py @@ -32,8 +32,8 @@ def _validate_bcp47_list(v: list[str] | None) -> list[str] | None: class TranslateJobCreate(BaseModel): name: str description: str | None = None - source_dialect: str = Field(..., description="Source database dialect (e.g. postgresql, clickhouse)") - target_dialect: str = Field(..., description="Target database dialect (e.g. postgresql, clickhouse)") + source_dialect: str = Field("", description="Source database dialect (e.g. postgresql, clickhouse) — auto-filled from database_dialect if empty") + target_dialect: str = Field("", description="Target database dialect (e.g. postgresql, clickhouse) — auto-filled from database_dialect if empty") database_dialect: str | None = Field(None, description="Detected dialect from Superset connection at save time") source_datasource_id: str | None = Field(None, description="Superset datasource ID") source_table: str | None = Field(None, description="Source table name") @@ -46,6 +46,7 @@ class TranslateJobCreate(BaseModel): target_language_column: str | None = Field(None, description="Target column for language code (e.g. 'ru', 'en')") target_source_column: str | None = Field(None, description="Target column for source/original text") target_source_language_column: str | None = Field(None, description="Target column for detected source language (BCP-47)") + include_source_reference: bool = Field(True, description="If true, insert original/source rows alongside translations") context_columns: list[str] | None = Field(default_factory=list, description="Context column names") target_language: str | None = Field(None, description="Target language code [DEPRECATED: use target_languages]") target_languages: list[str] | None = Field(default_factory=list, description="List of BCP-47 target language codes") @@ -90,6 +91,7 @@ class TranslateJobUpdate(BaseModel): target_language_column: str | None = None target_source_column: str | None = None target_source_language_column: str | None = None + include_source_reference: bool | None = None context_columns: list[str] | None = None target_language: str | None = None target_languages: list[str] | None = None @@ -129,6 +131,7 @@ class TranslateJobResponse(BaseModel): target_language_column: str | None = None target_source_column: str | None = None target_source_language_column: str | None = None + include_source_reference: bool = True context_columns: list[str] | None = None # source_language removed — deprecated, per-row auto-detected target_languages: list[str] | None = None diff --git a/backend/tests/plugins/translate/test_lang_detect.py b/backend/tests/plugins/translate/test_lang_detect.py index 2922d0e6..f624bd45 100644 --- a/backend/tests/plugins/translate/test_lang_detect.py +++ b/backend/tests/plugins/translate/test_lang_detect.py @@ -17,6 +17,7 @@ from src.plugins.translate._lang_detect import ( get_detector, detect_language, _character_block_fallback, + _mark_suspicious_detections_und, batch_detect, ) @@ -184,6 +185,39 @@ class TestCharacterBlockFallback: assert results[0] == "und" +class TestSuspiciousDetections: + """Suspicious short Cyrillic detections are routed to LLM as und.""" + + def test_short_cyrillic_uk_for_ru_target_becomes_und(self): + results = _mark_suspicious_detections_und( + ["Оплачено 22.06"], + ["uk"], + ["ru"], + ) + assert results == ["und"] + + def test_short_cyrillic_it_for_ru_target_becomes_und(self): + results = _mark_suspicious_detections_und( + ["Нет"], + ["it"], + ["ru"], + ) + assert results == ["und"] + + def test_long_mixed_text_keeps_detection(self): + text = "Contract with KZ Trading for Financing in China Необходимо подтверждение депозита" + results = _mark_suspicious_detections_und([text], ["en"], ["ru"]) + assert results == ["en"] + + def test_non_ru_target_keeps_detection(self): + results = _mark_suspicious_detections_und( + ["Оплачено 22.06"], + ["uk"], + ["en"], + ) + assert results == ["uk"] + + class TestBatchDetect: """batch_detect.""" diff --git a/backend/tests/plugins/translate/test_llm_async.py b/backend/tests/plugins/translate/test_llm_async.py index b4c381c5..f9accbf5 100644 --- a/backend/tests/plugins/translate/test_llm_async.py +++ b/backend/tests/plugins/translate/test_llm_async.py @@ -19,7 +19,7 @@ import pytest @pytest.mark.asyncio async def test_llm_rate_limit_uses_asyncio_sleep(): """Verify call_openai_compatible retries on 429 using asyncio.sleep, not time.sleep.""" - from src.plugins.translate._llm_async_http import call_openai_compatible + from ss_tools.shared._llm_http import call_openai_compatible call_count = 0 @@ -30,6 +30,7 @@ async def test_llm_rate_limit_uses_asyncio_sleep(): resp = MagicMock(spec=httpx.Response) resp.status_code = 429 resp.ok = False + resp.is_success = False resp.text = "Rate limited" resp.headers = {"Retry-After": "1"} resp.json.return_value = {} @@ -37,6 +38,7 @@ async def test_llm_rate_limit_uses_asyncio_sleep(): resp = MagicMock(spec=httpx.Response) resp.status_code = 200 resp.ok = True + resp.is_success = True resp.text = '{"choices":[{"message":{"content":"{\\"translated\\": \\"hola\\"}"},"finish_reason":"stop"}]}' resp.json.return_value = { "choices": [{"message": {"content": '{"translated": "hola"}'}, "finish_reason": "stop"}] @@ -46,7 +48,7 @@ async def test_llm_rate_limit_uses_asyncio_sleep(): mock_client = AsyncMock() mock_client.post = AsyncMock(side_effect=mock_post_side_effect) - with patch("src.plugins.translate._llm_async_http._get_http_client", return_value=mock_client): + with patch("ss_tools.shared._llm_http.get_shared_http_client", return_value=mock_client): # Patch asyncio.sleep to verify it's called (not time.sleep) sleep_calls = [] original_sleep = asyncio.sleep @@ -55,7 +57,7 @@ async def test_llm_rate_limit_uses_asyncio_sleep(): sleep_calls.append(seconds) await original_sleep(0) # Don't actually wait - with patch("src.plugins.translate._llm_async_http.asyncio.sleep", side_effect=tracking_sleep): + with patch("ss_tools.shared._llm_http.asyncio.sleep", side_effect=tracking_sleep): content, finish_reason = await call_openai_compatible( base_url="http://fake-llm.local", api_key="test-key", @@ -75,7 +77,7 @@ async def test_llm_rate_limit_uses_asyncio_sleep(): @pytest.mark.asyncio async def test_call_openai_compatible_no_base_url(): """Verify empty base_url raises ValueError immediately.""" - from src.plugins.translate._llm_async_http import call_openai_compatible + from ss_tools.shared._llm_http import call_openai_compatible with pytest.raises(ValueError, match="no base_url"): await call_openai_compatible( @@ -84,6 +86,8 @@ async def test_call_openai_compatible_no_base_url(): model="gpt-4", prompt="hello", ) + + # #endregion test_call_openai_compatible_no_base_url @@ -93,24 +97,29 @@ async def test_call_openai_compatible_no_base_url(): @pytest.mark.asyncio async def test_call_openai_compatible_empty_choices(): """Verify LLM response with empty choices raises ValueError.""" - from src.plugins.translate._llm_async_http import call_openai_compatible + from ss_tools.shared._llm_http import call_openai_compatible mock_response = MagicMock(spec=httpx.Response) mock_response.status_code = 200 - mock_response.ok = True + mock_response.is_success = True mock_response.json.return_value = {"choices": []} + mock_response.text = '{"choices": []}' mock_client = AsyncMock() mock_client.post = AsyncMock(return_value=mock_response) - with patch("src.plugins.translate._llm_async_http._get_http_client", return_value=mock_client): - with pytest.raises(ValueError, match="no choices"): - await call_openai_compatible( - base_url="http://fake-llm.local", - api_key="test-key", - model="gpt-4", - prompt="hello", - ) + with patch("ss_tools.shared._llm_http.get_shared_http_client", return_value=mock_client): + with patch("ss_tools.shared._llm_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))): + with patch("ss_tools.shared._llm_http._handle_response_format_fallback", AsyncMock()): + with pytest.raises(ValueError, match="no choices"): + await call_openai_compatible( + base_url="http://fake-llm.local", + api_key="test-key", + model="gpt-4", + prompt="hello", + ) + + # #endregion test_call_openai_compatible_empty_choices @@ -120,26 +129,31 @@ async def test_call_openai_compatible_empty_choices(): @pytest.mark.asyncio async def test_call_openai_compatible_refusal(): """Verify LLM refusal response raises ValueError.""" - from src.plugins.translate._llm_async_http import call_openai_compatible + from ss_tools.shared._llm_http import call_openai_compatible mock_response = MagicMock(spec=httpx.Response) mock_response.status_code = 200 - mock_response.ok = True + mock_response.is_success = True mock_response.json.return_value = { "choices": [{"message": {"content": "", "refusal": "I cannot translate this"}, "finish_reason": "stop"}] } + mock_response.text = '{"choices": [{"message": {"content": "", "refusal": "I cannot translate this"}, "finish_reason": "stop"}]}' mock_client = AsyncMock() mock_client.post = AsyncMock(return_value=mock_response) - with patch("src.plugins.translate._llm_async_http._get_http_client", return_value=mock_client): - with pytest.raises(ValueError, match="refused"): - await call_openai_compatible( - base_url="http://fake-llm.local", - api_key="test-key", - model="gpt-4", - prompt="hello", - ) + with patch("ss_tools.shared._llm_http.get_shared_http_client", return_value=mock_client): + with patch("ss_tools.shared._llm_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))): + with patch("ss_tools.shared._llm_http._handle_response_format_fallback", AsyncMock()): + with pytest.raises(ValueError, match="refused"): + await call_openai_compatible( + base_url="http://fake-llm.local", + api_key="test-key", + model="gpt-4", + prompt="hello", + ) + + # #endregion test_call_openai_compatible_refusal @@ -149,7 +163,7 @@ async def test_call_openai_compatible_refusal(): @pytest.mark.asyncio async def test_retry_does_not_block_event_loop(): """Verify that 429 retry backoff allows other coroutines to run.""" - from src.plugins.translate._llm_async_http import _do_http_request + from ss_tools.shared._llm_http import _do_http_request concurrent_ran = False @@ -166,25 +180,24 @@ async def test_retry_does_not_block_event_loop(): if call_count == 1: resp = MagicMock(spec=httpx.Response) resp.status_code = 429 - resp.ok = False + resp.is_success = False resp.text = "Rate limited" resp.headers = {"Retry-After": "1"} return resp resp = MagicMock(spec=httpx.Response) resp.status_code = 200 - resp.ok = True + resp.is_success = True resp.text = "ok" return resp mock_client = AsyncMock() mock_client.post = AsyncMock(side_effect=mock_post) - with patch("src.plugins.translate._llm_async_http._get_http_client", return_value=mock_client): - with patch("src.plugins.translate._llm_async_http.asyncio.sleep", new_callable=AsyncMock) as mock_sleep: - # Run the HTTP request alongside a concurrent task - http_task = asyncio.create_task(_do_http_request("http://fake", {}, {})) - conc_task = asyncio.create_task(concurrent_task()) - await asyncio.gather(http_task, conc_task) + with patch("ss_tools.shared._llm_http.asyncio.sleep", new_callable=AsyncMock) as mock_sleep: + # Run the HTTP request alongside a concurrent task + http_task = asyncio.create_task(_do_http_request(mock_client, "http://fake", {}, {})) + conc_task = asyncio.create_task(concurrent_task()) + await asyncio.gather(http_task, conc_task) assert concurrent_ran, "Concurrent task was blocked by 429 retry" assert mock_sleep.called, "asyncio.sleep was not called during 429 retry" diff --git a/backend/tests/plugins/translate/test_llm_async_http.py b/backend/tests/plugins/translate/test_llm_async_http.py index 252aac4a..af32e6f1 100644 --- a/backend/tests/plugins/translate/test_llm_async_http.py +++ b/backend/tests/plugins/translate/test_llm_async_http.py @@ -17,9 +17,9 @@ from unittest.mock import AsyncMock, MagicMock, patch import httpx -from src.plugins.translate._llm_async_http import ( - _get_verify, - _get_http_client, +from ss_tools.shared.ssl import httpx_verify, system_ssl_context +from ss_tools.shared._llm_http import get_shared_http_client +from ss_tools.shared._llm_http import ( call_openai_compatible, _do_http_request, _handle_response_format_fallback, @@ -27,34 +27,37 @@ from src.plugins.translate._llm_async_http import ( class TestGetVerify: - """_get_verify — SSL verification context.""" + """SSL verification context (now from shared module).""" def test_returns_ssl_context(self): - """Always returns SSLContext (no env-based disable).""" - result = _get_verify() + """httpx_verify returns SSLContext (no env-based disable).""" + result = httpx_verify() assert isinstance(result, ssl.SSLContext) + def test_system_context_not_false(self): + """system_ssl_context never returns False.""" + result = system_ssl_context() + assert result is not False + def test_ignores_llm_ssl_verify_env(self): """LLM_SSL_VERIFY env is no longer read — central ssl helper is used.""" with patch.dict(os.environ, {"LLM_SSL_VERIFY": "false"}): - result = _get_verify() + result = system_ssl_context() assert isinstance(result, ssl.SSLContext), "must not return False" class TestGetHttpClient: - """_get_http_client — module-level singleton.""" + """get_shared_http_client — module-level singleton.""" - @pytest.mark.asyncio - async def test_creates_client(self): + def test_creates_client(self): """Creates client on first call.""" - client = await _get_http_client() + client = get_shared_http_client(timeout=180) assert isinstance(client, httpx.AsyncClient) - @pytest.mark.asyncio - async def test_reuses_client(self): + def test_reuses_client(self): """Returns same client on second call.""" - client1 = await _get_http_client() - client2 = await _get_http_client() + client1 = get_shared_http_client(timeout=180) + client2 = get_shared_http_client(timeout=180) assert client1 is client2 @@ -84,8 +87,8 @@ class TestCallOpenaiCompatible: } mock_response.text = '{"choices": [{"finish_reason": "stop", "message": {"content": "Hello, world!"}}]}' - with patch("src.plugins.translate._llm_async_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))): - with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback", AsyncMock()): + with patch("ss_tools.shared._llm_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))): + with patch("ss_tools.shared._llm_http._handle_response_format_fallback", AsyncMock()): content, finish_reason = await call_openai_compatible( "https://api.openai.com", "sk-test", @@ -104,8 +107,8 @@ class TestCallOpenaiCompatible: mock_response.json.return_value = {"choices": []} mock_response.text = '{"choices": []}' - with patch("src.plugins.translate._llm_async_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))): - with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback", AsyncMock()): + with patch("ss_tools.shared._llm_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))): + with patch("ss_tools.shared._llm_http._handle_response_format_fallback", AsyncMock()): with pytest.raises(ValueError, match="no choices"): await call_openai_compatible( "https://api.openai.com", @@ -130,8 +133,8 @@ class TestCallOpenaiCompatible: } mock_response.text = '{"choices": [{"finish_reason": "stop", "message": {"content": ""}}]}' - with patch("src.plugins.translate._llm_async_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))): - with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback", AsyncMock()): + with patch("ss_tools.shared._llm_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))): + with patch("ss_tools.shared._llm_http._handle_response_format_fallback", AsyncMock()): with pytest.raises(ValueError, match="empty content"): await call_openai_compatible( "https://api.openai.com", @@ -151,8 +154,8 @@ class TestCallOpenaiCompatible: } mock_response.text = '{"choices": [null]}' - with patch("src.plugins.translate._llm_async_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))): - with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback", AsyncMock()): + with patch("ss_tools.shared._llm_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))): + with patch("ss_tools.shared._llm_http._handle_response_format_fallback", AsyncMock()): with pytest.raises(ValueError, match="LLM response processing failed"): await call_openai_compatible( "https://api.openai.com", @@ -177,8 +180,8 @@ class TestCallOpenaiCompatible: } mock_response.text = '{"choices": [{"finish_reason": "stop", "message": {"refusal": "I cannot answer that", "content": null}}]}' - with patch("src.plugins.translate._llm_async_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))): - with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback", AsyncMock()): + with patch("ss_tools.shared._llm_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))): + with patch("ss_tools.shared._llm_http._handle_response_format_fallback", AsyncMock()): with pytest.raises(ValueError, match="refused"): await call_openai_compatible( "https://api.openai.com", @@ -200,8 +203,8 @@ class TestCallOpenaiCompatible: ) mock_response.text = "Internal Server Error" - with patch("src.plugins.translate._llm_async_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))): - with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback", AsyncMock()): + with patch("ss_tools.shared._llm_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))): + with patch("ss_tools.shared._llm_http._handle_response_format_fallback", AsyncMock()): with pytest.raises(httpx.HTTPStatusError): await call_openai_compatible( "https://api.openai.com", @@ -222,8 +225,8 @@ class TestCallOpenaiCompatible: } mock_response.text = '{"choices": [{"finish_reason": "stop", "message": {"content": "hi"}}]}' - with patch("src.plugins.translate._llm_async_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))): - with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback", AsyncMock()): + with patch("ss_tools.shared._llm_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))): + with patch("ss_tools.shared._llm_http._handle_response_format_fallback", AsyncMock()): # disable_reasoning=False, so response_format should be set content, _ = await call_openai_compatible( "https://api.openai.com", @@ -247,8 +250,8 @@ class TestCallOpenaiCompatible: } mock_response.text = '{"choices": [{"finish_reason": "stop", "message": {"content": "hi"}}]}' - with patch("src.plugins.translate._llm_async_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))): - with patch("src.plugins.translate._llm_async_http._handle_response_format_fallback", AsyncMock()): + with patch("ss_tools.shared._llm_http._do_http_request", AsyncMock(return_value=(mock_response, mock_response.text))): + with patch("ss_tools.shared._llm_http._handle_response_format_fallback", AsyncMock()): content, _ = await call_openai_compatible( "https://api.openai.com", "sk-test", @@ -262,28 +265,29 @@ class TestCallOpenaiCompatible: class TestDoHttpRequest: """_do_http_request — async HTTP POST with rate-limit retry.""" + @pytest.fixture + def mock_client(self): + return AsyncMock(spec=httpx.AsyncClient) + @pytest.mark.asyncio - async def test_success(self): + async def test_success(self, mock_client): """Single successful request.""" mock_response = MagicMock(spec=httpx.Response) mock_response.status_code = 200 mock_response.text = "OK" + mock_client.post.return_value = mock_response - with patch("src.plugins.translate._llm_async_http._get_http_client", AsyncMock()) as mock_get: - mock_client = AsyncMock() - mock_get.return_value = mock_client - mock_client.post.return_value = mock_response - - response, text = await _do_http_request( - "https://api.openai.com/chat/completions", - {"Authorization": "Bearer test"}, - {"model": "gpt-4o"}, - ) - assert response.status_code == 200 - assert text == "OK" + response, text = await _do_http_request( + mock_client, + "https://api.openai.com/chat/completions", + {"Authorization": "Bearer test"}, + {"model": "gpt-4o"}, + ) + assert response.status_code == 200 + assert text == "OK" @pytest.mark.asyncio - async def test_rate_limit_retry(self): + async def test_rate_limit_retry(self, mock_client): """429 triggers retry.""" mock_429 = MagicMock(spec=httpx.Response) mock_429.status_code = 429 @@ -294,23 +298,21 @@ class TestDoHttpRequest: mock_200.status_code = 200 mock_200.text = "OK" - with patch("src.plugins.translate._llm_async_http._get_http_client", AsyncMock()) as mock_get: - mock_client = AsyncMock() - mock_get.return_value = mock_client - mock_client.post.side_effect = [mock_429, mock_200] + mock_client.post.side_effect = [mock_429, mock_200] - with patch("src.plugins.translate._llm_async_http.asyncio.sleep", AsyncMock()): - response, text = await _do_http_request( - "https://api.openai.com/chat/completions", - {"Authorization": "Bearer test"}, - {"model": "gpt-4o"}, - ) - assert response.status_code == 200 - assert text == "OK" - assert mock_client.post.call_count == 2 + with patch("ss_tools.shared._llm_http.asyncio.sleep", AsyncMock()): + response, text = await _do_http_request( + mock_client, + "https://api.openai.com/chat/completions", + {"Authorization": "Bearer test"}, + {"model": "gpt-4o"}, + ) + assert response.status_code == 200 + assert text == "OK" + assert mock_client.post.call_count == 2 @pytest.mark.asyncio - async def test_rate_limit_with_retry_after(self): + async def test_rate_limit_with_retry_after(self, mock_client): """Retry-After header is respected.""" mock_429 = MagicMock(spec=httpx.Response) mock_429.status_code = 429 @@ -321,46 +323,41 @@ class TestDoHttpRequest: mock_200.status_code = 200 mock_200.text = "OK" - with patch("src.plugins.translate._llm_async_http._get_http_client", AsyncMock()) as mock_get: - mock_client = AsyncMock() - mock_get.return_value = mock_client - mock_client.post.side_effect = [mock_429, mock_200] + mock_client.post.side_effect = [mock_429, mock_200] - with patch("src.plugins.translate._llm_async_http.asyncio.sleep", AsyncMock()) as mock_sleep: - response, _ = await _do_http_request( - "https://api.openai.com/chat/completions", - {"Authorization": "Bearer test"}, - {"model": "gpt-4o"}, - ) - assert response.status_code == 200 - # Verify sleep was called with retry-after value - mock_sleep.assert_called_once_with(2) + with patch("ss_tools.shared._llm_http.asyncio.sleep", AsyncMock()) as mock_sleep: + response, _ = await _do_http_request( + mock_client, + "https://api.openai.com/chat/completions", + {"Authorization": "Bearer test"}, + {"model": "gpt-4o"}, + ) + assert response.status_code == 200 + mock_sleep.assert_called_once_with(2) @pytest.mark.asyncio - async def test_rate_limit_exhausted(self): + async def test_rate_limit_exhausted(self, mock_client): """All 429 retries exhausted, returns last 429.""" mock_429 = MagicMock(spec=httpx.Response) mock_429.status_code = 429 mock_429.headers = {} mock_429.text = "Rate limited" - with patch("src.plugins.translate._llm_async_http._get_http_client", AsyncMock()) as mock_get: - mock_client = AsyncMock() - mock_get.return_value = mock_client - mock_client.post.return_value = mock_429 # Always 429 + mock_client.post.return_value = mock_429 # Always 429 - with patch("src.plugins.translate._llm_async_http.asyncio.sleep", AsyncMock()): - response, _ = await _do_http_request( - "https://api.openai.com/chat/completions", - {"Authorization": "Bearer test"}, - {"model": "gpt-4o"}, - ) - assert response.status_code == 429 - assert mock_client.post.call_count == 3 # max 3 retries + with patch("ss_tools.shared._llm_http.asyncio.sleep", AsyncMock()): + response, _ = await _do_http_request( + mock_client, + "https://api.openai.com/chat/completions", + {"Authorization": "Bearer test"}, + {"model": "gpt-4o"}, + ) + assert response.status_code == 429 + assert mock_client.post.call_count == 3 # max 3 retries @pytest.mark.asyncio - async def test_rate_limit_invalid_retry_after(self): - """Retry-After with non-int value falls back to exponential backoff (lines 215-216).""" + async def test_rate_limit_invalid_retry_after(self, mock_client): + """Retry-After with non-int value falls back to exponential backoff.""" mock_429 = MagicMock(spec=httpx.Response) mock_429.status_code = 429 mock_429.headers = {"Retry-After": "abc"} @@ -370,27 +367,29 @@ class TestDoHttpRequest: mock_200.status_code = 200 mock_200.text = "OK" - with patch("src.plugins.translate._llm_async_http._get_http_client", AsyncMock()) as mock_get: - mock_client = AsyncMock() - mock_get.return_value = mock_client - mock_client.post.side_effect = [mock_429, mock_200] + mock_client.post.side_effect = [mock_429, mock_200] - with patch("src.plugins.translate._llm_async_http.asyncio.sleep", AsyncMock()) as mock_sleep: - response, _ = await _do_http_request( - "https://api.openai.com/chat/completions", - {"Authorization": "Bearer test"}, - {"model": "gpt-4o"}, - ) - assert response.status_code == 200 - # Should fall back to exponential backoff: 2^1 = 2 - mock_sleep.assert_called_once_with(2) + with patch("ss_tools.shared._llm_http.asyncio.sleep", AsyncMock()) as mock_sleep: + response, _ = await _do_http_request( + mock_client, + "https://api.openai.com/chat/completions", + {"Authorization": "Bearer test"}, + {"model": "gpt-4o"}, + ) + assert response.status_code == 200 + # Should fall back to exponential backoff: 2^1 = 2 + mock_sleep.assert_called_once_with(2) class TestHandleResponseFormatFallback: """_handle_response_format_fallback — structured output fallback.""" + @pytest.fixture + def mock_client(self): + return AsyncMock(spec=httpx.AsyncClient) + @pytest.mark.asyncio - async def test_400_response_format_error(self): + async def test_400_response_format_error(self, mock_client): """400 error with response_format pattern triggers retry.""" mock_400 = MagicMock(spec=httpx.Response) mock_400.status_code = 400 @@ -404,27 +403,25 @@ class TestHandleResponseFormatFallback: mock_200.encoding = "utf-8" mock_200.headers = {} + mock_client.post.return_value = mock_200 + payload = {"model": "gpt-4o", "response_format": {"type": "json_object"}} - with patch("src.plugins.translate._llm_async_http._get_http_client", AsyncMock()) as mock_get: - mock_client = AsyncMock() - mock_get.return_value = mock_client - mock_client.post.return_value = mock_200 - - await _handle_response_format_fallback( - mock_400, - mock_400.text, - payload, - "https://api.openai.com", - {}, - ) - # Verify response_format was popped - assert "response_format" not in payload - # Verify status_code was updated - assert mock_400.status_code == 200 + await _handle_response_format_fallback( + mock_client, + mock_400, + mock_400.text, + payload, + "https://api.openai.com", + {}, + ) + # Verify response_format was popped + assert "response_format" not in payload + # Verify status_code was updated + assert mock_400.status_code == 200 @pytest.mark.asyncio - async def test_non_400_noop(self): + async def test_non_400_noop(self, mock_client): """Non-400 error does nothing.""" mock_500 = MagicMock(spec=httpx.Response) mock_500.status_code = 500 @@ -433,24 +430,21 @@ class TestHandleResponseFormatFallback: payload = {"model": "gpt-4o", "response_format": {"type": "json_object"}} - with patch("src.plugins.translate._llm_async_http._get_http_client", AsyncMock()) as mock_get: - mock_client = AsyncMock() - mock_get.return_value = mock_client - - await _handle_response_format_fallback( - mock_500, - mock_500.text, - payload, - "https://api.openai.com", - {}, - ) - # Payload unchanged - assert "response_format" in payload - # No retry call - mock_client.post.assert_not_called() + await _handle_response_format_fallback( + mock_client, + mock_500, + mock_500.text, + payload, + "https://api.openai.com", + {}, + ) + # Payload unchanged + assert "response_format" in payload + # No retry call + mock_client.post.assert_not_called() @pytest.mark.asyncio - async def test_400_no_pattern_noop(self): + async def test_400_no_pattern_noop(self, mock_client): """400 error without response_format pattern does nothing.""" mock_400 = MagicMock(spec=httpx.Response) mock_400.status_code = 400 @@ -459,19 +453,16 @@ class TestHandleResponseFormatFallback: payload = {"model": "gpt-4o", "response_format": {"type": "json_object"}} - with patch("src.plugins.translate._llm_async_http._get_http_client", AsyncMock()) as mock_get: - mock_client = AsyncMock() - mock_get.return_value = mock_client - - await _handle_response_format_fallback( - mock_400, - mock_400.text, - payload, - "https://api.openai.com", - {}, - ) - assert "response_format" in payload - mock_client.post.assert_not_called() + await _handle_response_format_fallback( + mock_client, + mock_400, + mock_400.text, + payload, + "https://api.openai.com", + {}, + ) + assert "response_format" in payload + mock_client.post.assert_not_called() # #endregion Test.LLMAsyncHttpClient diff --git a/backend/tests/plugins/translate/test_llm_call_orthogonal.py b/backend/tests/plugins/translate/test_llm_call_orthogonal.py new file mode 100644 index 00000000..70aaf4f8 --- /dev/null +++ b/backend/tests/plugins/translate/test_llm_call_orthogonal.py @@ -0,0 +1,259 @@ +# #region Test.LLMTranslationService.BuildPrompt [C:3] [TYPE Module] [SEMANTICS test, llm, translate, prompt, context] +# @BRIEF Orthogonal verification of _build_prompt context column changes: +# rows_json enrichment, context_hint computation, template dialect/source-language removal. +# @RELATION BINDS_TO -> [LLMTranslationService._build_prompt] +# @TEST_EDGE: no_context_columns -> No 'context' key in rows_json, empty context_hint, no deprecated dialect markers +# @TEST_EDGE: context_columns_present -> 'context' key with source_data values, non-empty context_hint +# @TEST_EDGE: context_columns_missing_source_data -> Graceful fallback with empty string values per column +import json +from unittest.mock import MagicMock + +from src.models.translate import TranslationLanguage +from src.models.translate import TranslationJob +from src.plugins.translate._llm_call import LLMTranslationService + +from .conftest import JOB_ID + + +def _make_job(session, context_columns=None, translation_column="title"): + """Create or update a TranslationJob for _build_prompt testing. + + Each test invokes this against the function-scoped db_session fixture, + so job state is isolated per test. + """ + job = session.query(TranslationJob).filter(TranslationJob.id == JOB_ID).first() + if job is None: + job = TranslationJob( + id=JOB_ID, name="BuildPrompt Test", + source_dialect="en", target_dialect="fr", + status="ACTIVE", translation_column=translation_column, + ) + job.context_columns = context_columns or [] + session.add(job) + else: + job.context_columns = context_columns or [] + job.translation_column = translation_column + session.commit() + return job + + +def _batch_rows(count=2, *, source_data_present=True): + """Build batch rows; when source_data_present=False, omit the source_data key entirely.""" + rows = [] + for i in range(count): + row: dict = { + "row_index": str(i), + "source_text": f"Hello world {i}", + } + if source_data_present: + row["source_data"] = { + "author": f"Author_{i}", + "date": f"2024-01-0{i+1}", + "table": f"tbl_{i}", + } + rows.append(row) + return rows + + +class TestBuildPromptContextColumns: + """Orthogonal verification of _build_prompt context column behaviour. + + Covers the three orthogonal projections of context column handling: + (1) absent → no enrichment, (2) present → full enrichment, + (3) partially absent source_data → graceful fallback. + """ + + # #region test_no_context_columns [C:2] [TYPE Function] + # @BRIEF Without context_columns: rows_json has no 'context' key, context_hint absent, no legacy dialect markers. + def test_no_context_columns(self, db_session): + """Job has no context_columns → prompt stays lean, template changes verified.""" + job = _make_job(db_session, context_columns=[]) + rows = _batch_rows(2) + prompt = LLMTranslationService._build_prompt(job, rows, "", ["fr"]) + + # Rows JSON: no "context" key injected + assert '"context"' not in prompt, ( + "rows_json must NOT contain 'context' key when context_columns is empty; " + "found the key in rendered prompt" + ) + + # Context hint: absent from the rendered prompt + assert "Context columns:" not in prompt, ( + "context_hint section must be absent when no context_columns configured" + ) + + # Regression: removed source_dialect / target_dialect / source_language from template + assert "Source dialect" not in prompt, ( + "Prompt must NOT contain removed 'Source dialect' marker" + ) + assert "Translate from" not in prompt, ( + "Prompt must NOT contain removed 'Translate from' marker" + ) + + # Sanity: row data still present + assert "Hello world 0" in prompt, "Row text must be present" + assert "Hello world 1" in prompt, "Row text must be present" + assert '"detected_source_language": "und"' in prompt, ( + "rows_json must carry local detected_source_language for LLM arbitration" + ) + assert "Include detected_source_language for EVERY row" in prompt, ( + "prompt must require LLM to return detected_source_language" + ) + # #endregion test_no_context_columns + + # #region test_with_context_columns [C:2] [TYPE Function] + # @BRIEF With context_columns=["author","date"]: rows_json includes 'context' dict with source_data values, context_hint lists columns. + def test_with_context_columns(self, db_session): + """Job has context_columns=['author','date'] → prompt enriched with context fields.""" + job = _make_job(db_session, context_columns=["author", "date"]) + rows = _batch_rows(2) + prompt = LLMTranslationService._build_prompt(job, rows, "", ["fr"]) + + # Rows JSON: must contain "context" key with correct source_data values + assert '"context"' in prompt, ( + "rows_json must contain 'context' key when context_columns configured" + ) + assert '"author": "Author_0"' in prompt, ( + "context.author must match source_data value for row 0" + ) + assert '"date": "2024-01-01"' in prompt, ( + "context.date must match source_data value for row 0" + ) + assert '"author": "Author_1"' in prompt, ( + "context.author must match source_data value for row 1" + ) + + # Context hint: must mention the configured column names + assert "Context columns: author, date" in prompt, ( + "context_hint must list configured context columns" + ) + assert "Consider these context fields" in prompt, ( + "context_hint must include guidance phrase about context fields" + ) + # #endregion test_with_context_columns + + # #region test_context_columns_missing_source_data [C:2] [TYPE Function] + # @BRIEF Context columns set but row has no source_data key → graceful fallback with empty-string values. + def test_context_columns_missing_source_data(self, db_session): + """When source_data key is entirely absent, context values fall back to empty strings.""" + job = _make_job(db_session, context_columns=["author", "date"]) + rows = _batch_rows(1, source_data_present=False) + prompt = LLMTranslationService._build_prompt(job, rows, "", ["fr"]) + + # "context" key must still be present (structure is maintained) + assert '"context"' in prompt, ( + "rows_json must still include 'context' key when source_data is missing" + ) + + # Each column falls back to an empty string (not null, not missing) + assert '"author": ""' in prompt, ( + "context.author must fallback to empty string when source_data absent" + ) + assert '"date": ""' in prompt, ( + "context.date must fallback to empty string when source_data absent" + ) + + # Row text must still be present (basic structure intact) + assert "Hello world 0" in prompt, ( + "Row text must be present even when source_data is missing" + ) + + # table field from _batch_rows(source_data_present=False) should NOT leak + assert '"table"' not in prompt, ( + "Only context_columns fields should appear in context, not all source_data fields" + ) + # #endregion test_context_columns_missing_source_data + + # #region test_context_columns_empty_list [C:2] [TYPE Function] + # @BRIEF Edge case: empty context_columns list (same as None / not set). + def test_context_columns_empty_list(self, db_session): + """Empty list is treated identically to None — no context enrichment.""" + job = _make_job(db_session, context_columns=[]) + rows = _batch_rows(1) + prompt = LLMTranslationService._build_prompt(job, rows, "", ["fr"]) + + assert '"context"' not in prompt, ( + "Empty context_columns must behave like None — no context key injected" + ) + assert "Hello world 0" in prompt, "Row text must be present" + # #endregion test_context_columns_empty_list + + # #region test_context_columns_single_column [C:2] [TYPE Function] + # @BRIEF Single context column: correct context dict with exactly one key. + def test_context_columns_single_column(self, db_session): + """A single context column produces a context dict with one key.""" + job = _make_job(db_session, context_columns=["author"]) + rows = _batch_rows(1) + prompt = LLMTranslationService._build_prompt(job, rows, "", ["fr"]) + + assert '"context"' in prompt, "Single context column must produce context key" + assert '"author": "Author_0"' in prompt, "Value must match source_data" + assert '"date"' not in prompt, "Unlisted column must not appear in context" + assert "Context columns: author" in prompt, "context_hint must list the single column" + # #endregion test_context_columns_single_column + + # #region test_context_columns_still_passes_other_placeholders [C:2] [TYPE Function] + # @BRIEF Context column changes must not break other template placeholders. + def test_context_columns_still_passes_other_placeholders(self, db_session): + """Verify target_languages, translation_column, and dictionary still render correctly.""" + job = _make_job(db_session, context_columns=["author"], translation_column="description") + rows = _batch_rows(1) + prompt = LLMTranslationService._build_prompt(job, rows, "DICT_SECTION\n", ["fr", "de"]) + + # Target languages + assert "fr, de" in prompt, "target_languages must be present" + + # Translation column + assert "description" in prompt, "translation_column must be present" + + # Dictionary section + assert "DICT_SECTION" in prompt, "dictionary_section must be present" + + # Row count + assert "Return exactly 1 entries" in prompt, "row_count must be correct" + + # Context hint and context data still render + assert "Context columns: author" in prompt, "context_hint must appear with context columns" + assert '"context"' in prompt, "context key must appear with context columns" + # #endregion test_context_columns_still_passes_other_placeholders + + +class TestCreateRecordsLanguageArbitration: + """Regression tests for LLM-assisted source-language detection.""" + + # #region test_llm_detected_same_language_creates_skip_marker [C:2] [TYPE Function] + # @BRIEF und local detection + LLM-detected same target creates marker language entry. + def test_llm_detected_same_language_creates_skip_marker(self): + """When LLM detects ru for target ru, insert stage must skip translation row.""" + db = MagicMock() + service = LLMTranslationService(db) + rows = [{ + "row_index": "0", + "source_text": "Оплачено 22.06", + "_detected_lang": "und", + "source_data": {"id": "1"}, + "_source_hash": "hash", + }] + translations = { + "0": { + "detected_source_language": "ru", + "ru": "Оплачено 22.06", + } + } + + result = service._create_records_from_translations( + rows, "run-1", "batch-1", ["ru"], translations, [], 0, + ) + + assert result["successful"] == 1 + language_entries = [ + call.args[0] for call in db.add.call_args_list + if isinstance(call.args[0], TranslationLanguage) + ] + assert len(language_entries) == 1 + assert language_entries[0].language_code == "ru" + assert language_entries[0].source_language_detected == "ru" + assert language_entries[0].final_value == "Оплачено 22.06" + # #endregion test_llm_detected_same_language_creates_skip_marker + +# #endregion Test.LLMTranslationService.BuildPrompt diff --git a/backend/tests/plugins/translate/test_orchestrator_sql_rows.py b/backend/tests/plugins/translate/test_orchestrator_sql_rows.py index 61df75dc..65e32585 100644 --- a/backend/tests/plugins/translate/test_orchestrator_sql_rows.py +++ b/backend/tests/plugins/translate/test_orchestrator_sql_rows.py @@ -31,6 +31,7 @@ def mock_job(): job.target_language_column = "lang" job.target_source_column = "source_text" job.target_source_language_column = "src_lang" + job.include_source_reference = True job.context_columns = ["category", "brand"] job.translation_column = "name" return job @@ -322,6 +323,44 @@ class TestBuildRows: translated_langs = [r["lang"] for r in rows if r["is_original"] == 0] assert translated_langs == ["ru"] + def test_no_source_reference_skips_src_language_entirely(self, mock_job): + """No reference row + source language matching target -> no insert rows.""" + mock_job.include_source_reference = False + lang_ru = self._make_lang("ru", final="Оплачено 22.06", src_lang="ru") + rec = self._make_record( + source_data={"product_id": 1, "store_id": "S1"}, + source_sql="Оплачено 22.06", + languages=[lang_ru], + ) + rows = build_rows( + records=[rec], + job=mock_job, + effective_target="translated_name", + primary_language="ru", + context_keys=[], + ) + assert rows == [] + + def test_no_source_reference_keeps_non_source_translation(self, mock_job): + """No reference row still inserts real target-language translations.""" + mock_job.include_source_reference = False + lang_ru = self._make_lang("ru", final="Привет", src_lang="en") + rec = self._make_record( + source_data={"product_id": 1, "store_id": "S1"}, + source_sql="Hello", + languages=[lang_ru], + ) + rows = build_rows( + records=[rec], + job=mock_job, + effective_target="translated_name", + primary_language="ru", + context_keys=[], + ) + assert len(rows) == 1 + assert rows[0]["is_original"] == 0 + assert rows[0]["lang"] == "ru" + # ── Line 100: final_value vs translated_value fallback ── def test_translation_uses_final_value_then_translated_value(self, mock_job): diff --git a/frontend/src/lib/api/translate/__tests__/runs.test.ts b/frontend/src/lib/api/translate/__tests__/runs.test.ts index 7da308f7..45f6f8a2 100644 --- a/frontend/src/lib/api/translate/__tests__/runs.test.ts +++ b/frontend/src/lib/api/translate/__tests__/runs.test.ts @@ -272,7 +272,7 @@ describe('fetchAllRuns', () => { expect(mockApi.fetchApi).toHaveBeenCalledWith('/translate/runs'); }); - it('fetches all runs with job_id, status, trigger_type, and created_by filters', async () => { + it('fetches all runs with job_id, status, trigger_type, created_by, and date filters', async () => { mockApi.fetchApi.mockResolvedValue({ runs: [{ id: 'r1' }] }); const { fetchAllRuns } = await import('$lib/api/translate/runs.js'); const result = await fetchAllRuns({ @@ -280,12 +280,14 @@ describe('fetchAllRuns', () => { status: 'FAILED', trigger_type: 'manual', created_by: 'user1', + date_from: '2026-07-01', + date_to: '2026-07-08', page: 1, page_size: 25, }); expect(result).toEqual({ runs: [{ id: 'r1' }] }); expect(mockApi.fetchApi).toHaveBeenCalledWith( - '/translate/runs?page=1&page_size=25&job_id=job-1&status=FAILED&trigger_type=manual&created_by=user1', + '/translate/runs?page=1&page_size=25&job_id=job-1&status=FAILED&trigger_type=manual&created_by=user1&date_from=2026-07-01&date_to=2026-07-08', ); }); diff --git a/frontend/src/lib/api/translate/runs.ts b/frontend/src/lib/api/translate/runs.ts index dd4dc055..0f1571ca 100644 --- a/frontend/src/lib/api/translate/runs.ts +++ b/frontend/src/lib/api/translate/runs.ts @@ -29,6 +29,8 @@ export interface AllRunsQueryOptions extends RunQueryOptions { status?: string; trigger_type?: string; created_by?: string; + date_from?: string; + date_to?: string; } // #region triggerRun [C:2] [TYPE Function] [SEMANTICS translate, runs, trigger] @@ -171,6 +173,8 @@ export async function fetchAllRuns(options: AllRunsQueryOptions = { if (options.status) params.append('status', options.status); if (options.trigger_type) params.append('trigger_type', options.trigger_type); if (options.created_by) params.append('created_by', options.created_by); + if (options.date_from) params.append('date_from', options.date_from); + if (options.date_to) params.append('date_to', options.date_to); const query = params.toString(); return await api.fetchApi(`/translate/runs${query ? `?${query}` : ''}`); } catch (error) { diff --git a/frontend/src/lib/models/TranslateHistoryModel.svelte.ts b/frontend/src/lib/models/TranslateHistoryModel.svelte.ts index 854c1e9b..c96de52b 100644 --- a/frontend/src/lib/models/TranslateHistoryModel.svelte.ts +++ b/frontend/src/lib/models/TranslateHistoryModel.svelte.ts @@ -54,7 +54,15 @@ export class TranslateHistoryModel { this.isLoading = true; this.uxState = 'loading'; try { - const result = await fetchAllRuns({ page: this.currentPage, page_size: this.pageSize, job_id: this.filterJobId || undefined, status: this.filterStatus || undefined, trigger_type: this.filterTrigger || undefined }); + const result = await fetchAllRuns({ + page: this.currentPage, + page_size: this.pageSize, + job_id: this.filterJobId || undefined, + status: this.filterStatus || undefined, + trigger_type: this.filterTrigger || undefined, + date_from: this.filterDateFrom || undefined, + date_to: this.filterDateTo || undefined, + }); this.runs = (result as { items?: Record[] })?.items || []; this.total = (result as { total?: number })?.total || 0; this.uxState = this.runs.length === 0 ? 'empty' : 'populated'; @@ -72,8 +80,20 @@ export class TranslateHistoryModel { async loadJobs(): Promise { try { - const result = await fetchJobs({ page_size: 100 }); - this.jobs = Array.isArray(result) ? result : ((result as { items?: unknown[]; results?: unknown[] })?.items || (result as { items?: unknown[]; results?: unknown[] })?.results || []) as Record[]; + const pageSize = 100; + const first = await fetchJobs({ page: 1, page_size: pageSize }); + const firstJobs = this._extractJobs(first); + const total = Array.isArray(first) ? firstJobs.length : ((first as { total?: number })?.total || firstJobs.length); + const jobs = [...firstJobs]; + + for (let page = 2; jobs.length < total; page += 1) { + const result = await fetchJobs({ page, page_size: pageSize }); + const pageJobs = this._extractJobs(result); + if (pageJobs.length === 0) break; + jobs.push(...pageJobs); + } + + this.jobs = jobs; } catch { /* non-critical */ } } @@ -158,5 +178,11 @@ export class TranslateHistoryModel { total_records: this.metrics.reduce((s: number, m: Record) => s + ((m.total_records as number) || 0), 0), }; } + + private _extractJobs(result: unknown): Record[] { + if (Array.isArray(result)) return result as Record[]; + const payload = result as { items?: unknown[]; results?: unknown[] }; + return (payload?.items || payload?.results || []) as Record[]; + } } // #endregion Translate.HistoryModel diff --git a/frontend/src/lib/models/__tests__/TranslateHistoryModel.test.ts b/frontend/src/lib/models/__tests__/TranslateHistoryModel.test.ts index be41f1c6..3195c30e 100644 --- a/frontend/src/lib/models/__tests__/TranslateHistoryModel.test.ts +++ b/frontend/src/lib/models/__tests__/TranslateHistoryModel.test.ts @@ -86,10 +86,16 @@ describe("TranslateHistoryModel — Data Loading", () => { // #region TranslateHistoryModel.LoadRuns [C:2] [TYPE Test] it("loadRuns sets loading state and populates runs", async () => { vi.mocked(fetchAllRuns).mockResolvedValue({ items: [{ id: "r1" }, { id: "r2" }], total: 2 }); + model.filterDateFrom = "2026-07-01"; + model.filterDateTo = "2026-07-08"; const promise = model.loadRuns(); expect(model.isLoading).toBe(true); expect(model.uxState).toBe("loading"); await promise; + expect(fetchAllRuns).toHaveBeenCalledWith(expect.objectContaining({ + date_from: "2026-07-01", + date_to: "2026-07-08", + })); expect(model.runs).toHaveLength(2); expect(model.total).toBe(2); expect(model.uxState).toBe("populated"); @@ -138,6 +144,16 @@ describe("TranslateHistoryModel — Data Loading", () => { expect(model.jobs).toHaveLength(1); }); + it("loadJobs fetches all pages when total exceeds first page", async () => { + vi.mocked(fetchJobs) + .mockResolvedValueOnce({ items: Array.from({ length: 100 }, (_, i) => ({ id: `j${i}` })), total: 101 }) + .mockResolvedValueOnce({ items: [{ id: "j100", name: "Job 100" }], total: 101 }); + await model.loadJobs(); + expect(fetchJobs).toHaveBeenCalledWith({ page: 1, page_size: 100 }); + expect(fetchJobs).toHaveBeenCalledWith({ page: 2, page_size: 100 }); + expect(model.jobs).toHaveLength(101); + }); + it("loadJobs accepts results format result", async () => { vi.mocked(fetchJobs).mockResolvedValue({ results: [{ id: "j1", name: "Job 1" }] }); await model.loadJobs(); diff --git a/frontend/src/routes/translate/history/+page.svelte b/frontend/src/routes/translate/history/+page.svelte index ff43449b..a8c1ddd2 100644 --- a/frontend/src/routes/translate/history/+page.svelte +++ b/frontend/src/routes/translate/history/+page.svelte @@ -53,7 +53,7 @@
-
+
+
@@ -90,6 +91,11 @@ {:else if m.uxState === 'populated' || m.uxState === 'detail_open'} +
+ {_('translate.history.showing') + .replace('{count}', String(Math.min(m.currentPage * m.pageSize, m.total))) + .replace('{total}', String(m.total))} +
{#each m.runs as run}
m.openDetail(run)} class="bg-surface-card border border-border rounded-lg p-3 hover:shadow-sm hover:border-border-strong transition-all cursor-pointer"> diff --git a/shared/src/ss_tools/shared/__init__.py b/shared/src/ss_tools/shared/__init__.py index 662c7d84..ebf81f7e 100644 --- a/shared/src/ss_tools/shared/__init__.py +++ b/shared/src/ss_tools/shared/__init__.py @@ -3,6 +3,8 @@ # Contains: cot_logger (stdlib-only trace propagation), # ssl (stdlib-only SSL context), # logger (lightweight JSON logger, no pydantic), +# _llm_http (shared httpx.AsyncClient with system SSL), # _llm_health (LLM health probe, openai+httpx). # @INVARIANT No dependency on FastAPI, SQLAlchemy, Gradio, LangChain. +# @INVARIANT All HTTP clients use system_ssl_context() from ssl module. # #endregion ss_tools.shared diff --git a/shared/src/ss_tools/shared/_llm_health.py b/shared/src/ss_tools/shared/_llm_health.py index f7914355..cb2afa10 100644 --- a/shared/src/ss_tools/shared/_llm_health.py +++ b/shared/src/ss_tools/shared/_llm_health.py @@ -27,6 +27,7 @@ from openai import ( RateLimitError, ) +from ._llm_http import get_shared_http_client from .logger import logger # ── LLM provider health cache ───────────────────────────────────────── @@ -50,19 +51,19 @@ async def _check_llm_provider_health() -> str: # Fetch LLM config from backend's own API (same as agent container does) try: fastapi_url = os.getenv("FASTAPI_URL", "http://localhost:8000") - async with httpx.AsyncClient(timeout=5) as client: - resp = await client.get(f"{fastapi_url}/api/agent/llm-config") - if resp.status_code != 200: - _llm_status["status"] = "unavailable" - _llm_status["last_error"] = f"LLM config endpoint returned {resp.status_code}" - _llm_status["last_check_ts"] = time.time() - return "unavailable" - config = resp.json() - if not config or not config.get("configured"): - _llm_status["status"] = "unavailable" - _llm_status["last_error"] = "No LLM provider configured" - _llm_status["last_check_ts"] = time.time() - return "unavailable" + client = get_shared_http_client(timeout=10) + resp = await client.get(f"{fastapi_url}/api/agent/llm-config") + if resp.status_code != 200: + _llm_status["status"] = "unavailable" + _llm_status["last_error"] = f"LLM config endpoint returned {resp.status_code}" + _llm_status["last_check_ts"] = time.time() + return "unavailable" + config = resp.json() + if not config or not config.get("configured"): + _llm_status["status"] = "unavailable" + _llm_status["last_error"] = "No LLM provider configured" + _llm_status["last_check_ts"] = time.time() + return "unavailable" except Exception as exc: _llm_status["status"] = "unavailable" _llm_status["last_error"] = f"Failed to fetch LLM config: {exc}" @@ -72,15 +73,11 @@ async def _check_llm_provider_health() -> str: # Probe LLM API using AsyncOpenAI (available in both backend and agent containers) try: from openai import AsyncOpenAI - from .ssl import system_ssl_context api = AsyncOpenAI( api_key=config.get("api_key", ""), base_url=config.get("base_url", "https://api.openai.com/v1"), - http_client=httpx.AsyncClient( - verify=system_ssl_context(), - timeout=10, - ), + http_client=get_shared_http_client(timeout=10), ) await api.chat.completions.create( model=config.get("default_model", "gpt-4o-mini"), diff --git a/shared/src/ss_tools/shared/_llm_http.py b/shared/src/ss_tools/shared/_llm_http.py new file mode 100644 index 00000000..4561e6a4 --- /dev/null +++ b/shared/src/ss_tools/shared/_llm_http.py @@ -0,0 +1,308 @@ +# #region SharedLlmHttpClient [C:3] [TYPE Module] [SEMANTICS shared,http,client,ssl,llm] +# @defgroup Shared Shared lightweight utilities for backend and agent. +# @BRIEF Singleton httpx.AsyncClient with system SSL context for all LLM/API HTTP calls. +# Provides connection pooling, proper SSL verification (capath), configurable timeout. +# @LAYER Infrastructure +# @INVARIANT No dependency on FastAPI, SQLAlchemy, Gradio, LangChain, pydantic. +# @INVARIANT All HTTP clients use system_ssl_context() for SSL verification. +# @RELATION DEPENDS_ON -> [CoreSslTrust] +# @RATIONALE All HTTP calls to LLM providers and external APIs must use the system CA +# store (capath) instead of certifi to respect corporate certificates installed at +# container startup. This module provides a single source of truth for HTTP client +# creation, eliminating the pattern of per-module httpx.AsyncClient(timeout=N) without +# SSL context that silently uses certifi and fails with corporate CA certs. +# @REJECTED Per-module httpx.AsyncClient singletons were rejected — each one was a copy +# that could forget SSL context; module-level _http_client variables scattered across +# 5+ files created connection pooling fragmentation. +# @REJECTED Passing verify=True (certifi default) was rejected — it passes with public CAs +# but silently fails with corporate intermediate CAs installed in /etc/ssl/certs. +# Only capath-based ssl.create_default_context() works with OpenSSL 3.x intermediates. + +import asyncio +import ssl +from typing import Any + +import httpx + +from .logger import logger +from .ssl import httpx_verify + +# Module-level singleton clients, lazily initialized +_http_client_180: httpx.AsyncClient | None = None +_http_client_10: httpx.AsyncClient | None = None + + +# #region SharedLlmHttpClient.GetSharedClient [C:2] [TYPE Function] [SEMANTICS shared,http,client,get] +# @ingroup Shared +# @BRIEF Get or create a module-level httpx.AsyncClient singleton with system SSL context. +# Uses shared ssl module (capath) for certificate verification. +# @POST Returns httpx.AsyncClient with verify=system_ssl_context() and specified timeout. +# @SIDE_EFFECT Lazily creates the client on first call and caches it for reuse. +def get_shared_http_client(timeout: float = 180.0) -> httpx.AsyncClient: + """Get or create a shared httpx.AsyncClient singleton with system SSL context. + + Uses the system CA store (capath) for SSL verification — NOT certifi. + This ensures corporate CA certificates installed at container startup + are trusted for all HTTP calls. + + Args: + timeout: Total timeout in seconds (default 180). + Use 10 for lightweight config fetches, 180 for LLM API calls. + + Returns: + httpx.AsyncClient with SSL context and specified timeout. + """ + global _http_client_180, _http_client_10 + + # Cache two common timeout configurations to avoid creating new clients + if abs(timeout - 180.0) < 0.1: + if _http_client_180 is None: + ssl_ctx = httpx_verify() + _http_client_180 = httpx.AsyncClient( + verify=ssl_ctx, + timeout=httpx.Timeout(180.0), + ) + logger.reason( + "Created shared HTTP client (180s timeout)", + extra={"src": "SharedLlmHttpClient", "ssl": "system_ssl_context"}, + ) + return _http_client_180 + + if abs(timeout - 10.0) < 0.1: + if _http_client_10 is None: + ssl_ctx = httpx_verify() + _http_client_10 = httpx.AsyncClient( + verify=ssl_ctx, + timeout=httpx.Timeout(10.0), + ) + logger.reason( + "Created shared HTTP client (10s timeout)", + extra={"src": "SharedLlmHttpClient", "ssl": "system_ssl_context"}, + ) + return _http_client_10 + + # Custom timeout — create a new client (not cached) + ssl_ctx = httpx_verify() + client = httpx.AsyncClient( + verify=ssl_ctx, + timeout=httpx.Timeout(timeout), + ) + logger.reason( + "Created shared HTTP client (custom timeout)", + extra={"src": "SharedLlmHttpClient", "timeout": timeout, "ssl": "system_ssl_context"}, + ) + return client +# #endregion SharedLlmHttpClient.GetSharedClient + + +# #region SharedLlmHttpClient.SanitizeUrl [C:1] [TYPE Function] [SEMANTICS shared,url,sanitize] +# @ingroup Shared +# @BRIEF Strip embedded credentials from URL for safe logging. +# @POST Returns URL with user:pass@ portion removed, preserving host:port. +def sanitize_url(url: str) -> str: + """Strip embedded credentials from URL for safe logging.""" + if not url: + return url + from urllib.parse import urlsplit, urlunsplit + + parsed = urlsplit(url) + if parsed.username or parsed.password: + safe_netloc = parsed.hostname + if parsed.port: + safe_netloc += f":{parsed.port}" + parsed = parsed._replace(netloc=safe_netloc) + return urlunsplit(parsed) +# #endregion SharedLlmHttpClient.SanitizeUrl + + +# #region SharedLlmHttpClient.CallOpenaiCompatible [C:3] [TYPE Function] [SEMANTICS shared,llm,http,openai,async] +# @ingroup Shared +# @BRIEF Call OpenAI-compatible API asynchronously with rate-limit handling and structured output fallback. +# @PRE Valid API endpoint, key, model, and prompt. +# @POST Returns (response text, finish_reason). +# @SIDE_EFFECT Async HTTP POST to LLM API with optional retry on 429. +# @REJECTED Keeping sync requests.post — would block async event loop during LLM calls. +# Per-request httpx.AsyncClient — loses connection pooling. +async def call_openai_compatible( + base_url: str, + api_key: str, + model: str, + prompt: str, + provider_type: str = "openai", + max_tokens: int = 8192, + disable_reasoning: bool = False, +) -> tuple[str, str | None]: + """Call OpenAI-compatible API for LLM requests (async).""" + if not base_url: + raise ValueError("LLM provider has no base_url configured") + + # Normalise base_url: strip trailing /v1 to avoid double /v1 + base = base_url.rstrip("/") + if base.endswith("/v1"): + base = base[:-3] + url = f"{base}/v1/chat/completions" + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + system_content = ( + "You are a database content translation assistant. " + "Translate the provided text accurately, preserving data semantics. " + "Respond directly with ONLY the JSON result. " + "Do NOT include any reasoning, thinking, chain-of-thought, analysis, or explanation. " + "Output ONLY valid JSON." + ) + + payload: dict[str, Any] = { + "model": model, + "messages": [ + {"role": "system", "content": system_content}, + {"role": "user", "content": prompt}, + ], + "temperature": 0.1, + "max_tokens": max_tokens, + } + + if provider_type in ("openai", "openai_compatible", "kilo", "openrouter", "litellm"): + if not disable_reasoning: + payload["response_format"] = {"type": "json_object"} + + if disable_reasoning: + if provider_type not in ("kilo", "openrouter", "litellm"): + payload["reasoning_effort"] = "none" + payload["max_tokens"] = max_tokens + + client = get_shared_http_client() + response, response_text = await _do_http_request(client, url, headers, payload) + await _handle_response_format_fallback(client, response, response_text, payload, url, headers) + + if not response.is_success: + logger.explore( + f"LLM API error status={response.status_code} model={payload.get('model')} body={response_text[:2000]}", + extra={"src": "SharedLlmHttpClient"}, + ) + response.raise_for_status() + data = response.json() + + choices = data.get("choices", []) + if not choices: + logger.explore( + "LLM returned no choices", + extra={ + "src": "SharedLlmHttpClient", + "response_keys": list(data.keys()), + "response_preview": str(data)[:2000], + }, + ) + raise ValueError("LLM returned no choices") + + try: + finish_reason = choices[0].get("finish_reason") or "none" + msg = choices[0].get("message") or {} + except (TypeError, AttributeError) as e: + logger.explore( + "TypeError processing LLM response choices", + extra={ + "src": "SharedLlmHttpClient", + "error": str(e), + "choices_0_type": type(choices[0]).__name__ if choices else "N/A", + "choices_0_repr": repr(choices[0])[:2000] if choices else "N/A", + "data_type": type(data).__name__, + "data_preview": str(data)[:2000], + }, + ) + raise ValueError(f"LLM response processing failed: {e}") + + refusal = msg.get("refusal") if isinstance(msg, dict) else None + if refusal: + logger.explore( + "LLM refused to respond", + extra={ + "src": "SharedLlmHttpClient", + "refusal": str(refusal)[:500], + "finish_reason": finish_reason, + }, + ) + raise ValueError(f"LLM refused to respond: {refusal}") + + content = msg.get("content") if isinstance(msg, dict) else "" + if not content and isinstance(msg, dict): + content = msg.get("content") or "" + + if not content: + logger.explore( + "LLM returned empty content", + extra={ + "src": "SharedLlmHttpClient", + "finish_reason": finish_reason, + "msg_keys": list(msg.keys()) if isinstance(msg, dict) else [], + "response_preview": str(data)[:2000], + }, + ) + raise ValueError("LLM returned empty content") + + return content, finish_reason +# #endregion SharedLlmHttpClient.CallOpenaiCompatible + + +# #region SharedLlmHttpClient.DoHttpRequest [C:1] [TYPE Function] [SEMANTICS shared,http,request,retry] +async def _do_http_request( + client: httpx.AsyncClient, + url: str, + headers: dict, + payload: dict, +) -> tuple[httpx.Response, str]: + """Make async HTTP POST with rate-limit (429) retry handling.""" + _max_retry_429 = 3 + _retry_count_429 = 0 + while _retry_count_429 < _max_retry_429: + response = await client.post(url, headers=headers, json=payload) + response_text = response.text + if response.status_code == 429: + _retry_count_429 += 1 + retry_after = response.headers.get("Retry-After") + if retry_after: + try: + wait = int(retry_after) + except (ValueError, TypeError): + wait = 2**_retry_count_429 + else: + wait = 2**_retry_count_429 + logger.explore( + f"Rate limited (429), retry {_retry_count_429}/{_max_retry_429} after {wait}s", + extra={"src": "SharedLlmHttpClient", "retry_after": retry_after, "wait": wait}, + ) + await asyncio.sleep(wait) + if _retry_count_429 >= _max_retry_429: + break + else: + break + return response, response_text +# #endregion SharedLlmHttpClient.DoHttpRequest + + +# #region SharedLlmHttpClient.HandleResponseFormatFallback [C:1] [TYPE Function] [SEMANTICS shared,http,response,fallback] +async def _handle_response_format_fallback( + client: httpx.AsyncClient, + response: httpx.Response, + response_text: str, + payload: dict, + url: str, + headers: dict, +) -> None: + """Handle 400 errors from structured_outputs not being supported.""" + _patterns = ("response_format", "structured_outputs", "structured", "json_object") + if not response.is_success and response.status_code == 400 and any(p in (response_text or "").lower() for p in _patterns): + logger.explore( + "Structured outputs not supported, retrying without response_format", + extra={"src": "SharedLlmHttpClient"}, + ) + payload.pop("response_format", None) + new_response = await client.post(url, headers=headers, json=payload) + # Mutate the original response object with new data + response.status_code = new_response.status_code + response._content = new_response.content + response.encoding = new_response.encoding + response.headers = new_response.headers +# #endregion SharedLlmHttpClient.HandleResponseFormatFallback +# #endregion SharedLlmHttpClient