feat(translate): language detection, async HTTP LLM, history model, agent improvements
- Add async HTTP-based LLM transport (_llm_async_http.py) - Add orthogonal LLM call tests - Improve language detection (_lang_detect.py) and batch insert - Update translate schemas, service utils, preview constants/prompts - Add TranslateHistoryModel with pagination and filtering - Update agent confirmation, persistence, langgraph setup, run, tools - Improve LLM health checking in shared module - Update translate runs API, history route
This commit is contained in:
@@ -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. "
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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, "
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]})
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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": "<row_index>", "<language_code_1>": "<translation_in_lang_1>", "<language_code_2>": "<translation_in_lang_2>"}}]}}\n'
|
||||
'{{"rows": [{{"row_id": "<row_index>", "detected_source_language": "<bcp47_or_und>", "<language_code_1>": "<translation_in_lang_1>", "<language_code_2>": "<translation_in_lang_2>"}}]}}\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. "
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
259
backend/tests/plugins/translate/test_llm_call_orthogonal.py
Normal file
259
backend/tests/plugins/translate/test_llm_call_orthogonal.py
Normal file
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<T = unknown>(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<T>(`/translate/runs${query ? `?${query}` : ''}`);
|
||||
} catch (error) {
|
||||
|
||||
@@ -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<string, unknown>[] })?.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<void> {
|
||||
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<string, unknown>[];
|
||||
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<string, unknown>) => s + ((m.total_records as number) || 0), 0),
|
||||
};
|
||||
}
|
||||
|
||||
private _extractJobs(result: unknown): Record<string, unknown>[] {
|
||||
if (Array.isArray(result)) return result as Record<string, unknown>[];
|
||||
const payload = result as { items?: unknown[]; results?: unknown[] };
|
||||
return (payload?.items || payload?.results || []) as Record<string, unknown>[];
|
||||
}
|
||||
}
|
||||
// #endregion Translate.HistoryModel
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="bg-surface-card border border-border rounded-lg p-4 mb-4">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-5 gap-3">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-6 gap-3">
|
||||
<select bind:value={m.filterJobId} class="px-3 py-2 border border-border-strong rounded-lg text-sm">
|
||||
<option value="">{$t.translate?.history?.all_jobs}</option>
|
||||
{#each m.jobs as job}<option value={job.id}>{job.name}</option>{/each}
|
||||
@@ -73,6 +73,7 @@
|
||||
<option value="baseline_expired">{$t.translate?.history?.trigger_baseline_expired}</option>
|
||||
</select>
|
||||
<input type="date" bind:value={m.filterDateFrom} class="px-3 py-2 border border-border-strong rounded-lg text-sm" placeholder={$t.translate?.common?.from} />
|
||||
<input type="date" bind:value={m.filterDateTo} class="px-3 py-2 border border-border-strong rounded-lg text-sm" placeholder={$t.translate?.common?.to} />
|
||||
<button onclick={() => { page0 = 0; m.applyFilters(); }} class="px-4 py-2 text-sm bg-primary text-white rounded-lg hover:bg-primary-hover">{$t.translate?.history?.filter}</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,6 +91,11 @@
|
||||
|
||||
<!-- Populated / Detail -->
|
||||
{:else if m.uxState === 'populated' || m.uxState === 'detail_open'}
|
||||
<div class="mb-3 text-sm text-text-muted">
|
||||
{_('translate.history.showing')
|
||||
.replace('{count}', String(Math.min(m.currentPage * m.pageSize, m.total)))
|
||||
.replace('{total}', String(m.total))}
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
{#each m.runs as run}
|
||||
<div onclick={() => 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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
308
shared/src/ss_tools/shared/_llm_http.py
Normal file
308
shared/src/ss_tools/shared/_llm_http.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user