Files
ss-tools/backend/src/plugins/translate/executor.py
busya 8d0bc6fb93 feat(translate,scheduler,trace): new-key-only mode, stale PENDING protection, trace_id propagation
Translations:
- FR-045: new-key-only execution mode — translate only rows with unseen keys
- Compare source row keys against TranslationRecord.source_data from last succeeded run
- Baseline expired (>90 days) fallback to full mode with baseline_expired event
- run_noop early return when zero new keys (skip LLM + SQL)

Scheduler reliability:
- Stale PENDING protection: runs older than 1h auto-marked FAILED, no longer block schedule
- load_schedules() reloads active translation schedules from DB on restart
- add_translation_job/remove_translation_job register/unregister with APScheduler
- execution_mode column on translation_schedules with additive DB migration

Automation view:
- GET /settings/automation/translation-schedules endpoint
- Translation schedules displayed on Automation page below validation policies

Trace propagation:
- seed_trace_id() in all background entry points:
  TaskManager._run_task/_flusher_loop, SchedulerService._trigger_backup,
  websocket_endpoint, IdMappingService, all standalone scripts

Migrations:
- dictionary_entries: origin_run_id, origin_row_key, origin_user_id
- translation_schedules: execution_mode

Tests:
- 3 new test modules (22 tests): core_scheduler, executor_filter, scheduler_execution+guard
- Fix pre-existing translate test isolation (conftest.py)
- Fix test_list_runs_filter_status parameter
2026-05-14 10:13:56 +03:00

849 lines
35 KiB
Python

# #region TranslationExecutor [C:4] [TYPE Module] [SEMANTICS sqlalchemy, tenacity, translate, insert, llm-retry]
# @BRIEF Process translation in batches: fetch source rows, call LLM, persist TranslationBatch and TranslationRecord rows.
# @LAYER: Domain
# @RELATION DEPENDS_ON -> [TranslationBatch]
# @RELATION DEPENDS_ON -> [TranslationRecord]
# @RELATION DEPENDS_ON -> [TranslationRun]
# @RELATION DEPENDS_ON -> [LLMProviderService]
# @RELATION DEPENDS_ON -> [DictionaryManager]
# @RELATION DEPENDS_ON -> [TranslationPreview]
# @PRE: Valid TranslationRun with job configuration. DB session is available.
# @POST: TranslationBatch and TranslationRecord rows are created. Run status is updated.
# @SIDE_EFFECT: Calls LLM provider; creates DB rows; updates run statistics.
# @RATIONALE: Batch processing with retry — independent batches allow partial recovery.
# @REJECTED: Single monolithic LLM call — would lose all progress on any failure.
import json
import time
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Set, Tuple, Callable
from sqlalchemy.orm import Session
from ...core.logger import logger, belief_scope
from ...core.config_manager import ConfigManager
from ...models.translate import (
TranslationJob,
TranslationRun,
TranslationBatch,
TranslationRecord,
TranslationPreviewSession,
TranslationPreviewRecord,
)
from ...services.llm_provider import LLMProviderService
from ...services.llm_prompt_templates import render_prompt
from .dictionary import DictionaryManager
from .preview import DEFAULT_EXECUTION_PROMPT_TEMPLATE
# #region MAX_RETRIES_PER_BATCH [TYPE Constant]
# @BRIEF Maximum number of retries for a single batch before marking it failed.
MAX_RETRIES_PER_BATCH = 3
# #endregion MAX_RETRIES_PER_BATCH
# #region TranslationExecutor [C:4] [TYPE Class]
# @BRIEF Process translation batches: fetch source rows, filter dict, call LLM, persist results.
# @PRE: DB session and config manager available.
# @POST: Batches and records created with status tracking.
# @SIDE_EFFECT: LLM API calls; DB writes.
class TranslationExecutor:
def __init__(
self,
db: Session,
config_manager: ConfigManager,
current_user: Optional[str] = None,
on_batch_progress: Optional[Callable[[str, int, int, int, int], None]] = None,
):
self.db = db
self.config_manager = config_manager
self.current_user = current_user
self.on_batch_progress = on_batch_progress
self._current_run_id: Optional[str] = None
# region execute_run [TYPE Function]
# @PURPOSE: Run full translation execution for a TranslationRun.
# @PRE: run is in PENDING or RUNNING status with valid job config.
# @POST: Run is populated with batches and records.
# @SIDE_EFFECT: LLM API calls; DB batch writes.
def execute_run(
self,
run: TranslationRun,
llm_progress_callback: Optional[Callable[[str, int, int, int], None]] = None,
) -> TranslationRun:
with belief_scope("TranslationExecutor.execute_run"):
job = self.db.query(TranslationJob).filter(TranslationJob.id == run.job_id).first()
if not job:
raise ValueError(f"Job '{run.job_id}' not found for run '{run.id}'")
logger.reason("Starting translation execution", {
"run_id": run.id,
"job_id": job.id,
"batch_size": job.batch_size,
})
# Mark run as RUNNING
run.status = "RUNNING"
run.started_at = datetime.now(timezone.utc)
self.db.flush()
# Fetch source rows from the accepted preview session
source_rows = self._fetch_source_rows(job.id, run.id)
if not source_rows:
logger.explore("No source rows to translate", {"run_id": run.id})
run.status = "COMPLETED"
run.completed_at = datetime.now(timezone.utc)
self.db.flush()
return run
# Apply new-key-only filtering for scheduled runs
if run.trigger_type == "new_key_only":
source_rows = self._filter_new_keys(job, run.id, source_rows)
if not source_rows:
logger.reason("run_noop — no new rows to translate", {
"job_id": job.id, "run_id": run.id,
})
run.status = "COMPLETED"
run.insert_status = "skipped"
run.total_records = 0
run.completed_at = datetime.now(timezone.utc)
self.db.commit()
return
total_rows = len(source_rows)
run.total_records = total_rows
# Split into batches
batch_size = job.batch_size or 50
batches = [
source_rows[i:i + batch_size]
for i in range(0, total_rows, batch_size)
]
logger.reason(f"Processing {len(batches)} batches", {
"run_id": run.id,
"total_rows": total_rows,
"batch_size": batch_size,
})
successful_records = 0
failed_records = 0
skipped_records = 0
for batch_idx, batch_rows in enumerate(batches):
batch_result = self._process_batch(
job=job,
run_id=run.id,
batch_index=batch_idx,
batch_rows=batch_rows,
)
successful_records += batch_result["successful"]
failed_records += batch_result["failed"]
skipped_records += batch_result["skipped"]
# Update run stats incrementally
run.successful_records = successful_records
run.failed_records = failed_records
run.skipped_records = skipped_records
self.db.flush()
if self.on_batch_progress:
self.on_batch_progress(
run.id, batch_idx + 1, len(batches),
successful_records, total_rows,
)
# Update final run status
if failed_records == 0 and skipped_records == 0:
run.status = "COMPLETED"
elif successful_records == 0:
run.status = "FAILED"
else:
run.status = "COMPLETED" # Partial success
run.completed_at = datetime.now(timezone.utc)
self.db.flush()
logger.reflect("Translation execution complete", {
"run_id": run.id,
"status": run.status,
"total": total_rows,
"successful": successful_records,
"failed": failed_records,
"skipped": skipped_records,
})
return run
# endregion execute_run
# region _fetch_source_rows [TYPE Function]
# @PURPOSE: Fetch full source dataset from Superset (via datasource) for full translation.
# @PRE: job_id exists. Job may have source_datasource_id for full fetch.
# @POST: Returns list of dicts with source data (all rows from the source datasource).
# @SIDE_EFFECT: Makes HTTP call to Superset chart data API when datasource is configured.
def _fetch_source_rows(self, job_id: str, run_id: str) -> List[Dict[str, Any]]:
with belief_scope("TranslationExecutor._fetch_source_rows"):
job = self.db.query(TranslationJob).filter(TranslationJob.id == job_id).first()
# If source_datasource_id is configured, fetch ALL rows from the Superset chart data API
if job and job.source_datasource_id:
try:
logger.reason("Fetching full dataset from Superset datasource", {
"run_id": run_id,
"datasource_id": job.source_datasource_id,
"environment_id": job.environment_id,
})
# Determine environment
environments = self.config_manager.get_environments()
target_env_id = job.environment_id or job.source_dialect or ""
env_config = next(
(e for e in environments if e.id == target_env_id),
None,
)
if not env_config and environments:
env_config = environments[0]
if env_config:
from ...core.superset_client import SupersetClient
client = SupersetClient(env_config)
# Fetch dataset detail to build proper query context
dataset_detail = client.get_dataset_detail(int(job.source_datasource_id))
# Build query context (same approach as preview but without row_limit)
query_context = client.build_dataset_preview_query_context(
dataset_id=int(job.source_datasource_id),
dataset_record=dataset_detail,
template_params={},
effective_filters=[],
)
# Remove row_limit to get ALL rows; use result_type="samples"
queries = query_context.get("queries", [])
if queries:
queries[0].pop("row_limit", None)
queries[0].pop("result_type", None)
queries[0]["metrics"] = []
query_context["result_type"] = "samples"
form_data = query_context.get("form_data", {})
form_data.pop("query_mode", None)
response = client.network.request(
method="POST",
endpoint="/api/v1/chart/data",
data=json.dumps(query_context),
headers={"Content-Type": "application/json"},
)
# Extract rows
rows = self._extract_chart_data_rows(response)
if rows:
logger.reason(f"Fetched {len(rows)} rows from Superset datasource", {
"run_id": run_id,
})
# Map rows to source_rows format
source_rows = []
for idx, row in enumerate(rows):
source_data_dict = dict(row) if row else None
source_text = str(row.get(job.translation_column, "")) if job.translation_column else json.dumps(row)
source_rows.append({
"row_index": str(idx),
"source_text": source_text,
"approved_translation": None,
"source_object_name": f"Row {idx}",
"source_data": source_data_dict,
})
return source_rows
else:
logger.explore("Superset datasource returned no rows", {
"run_id": run_id,
"datasource_id": job.source_datasource_id,
})
else:
logger.explore("No environment config found for datasource fetch", {
"env_id": target_env_id,
})
except Exception as e:
logger.explore("Failed to fetch full dataset from Superset, falling back to preview", {
"run_id": run_id,
"error": str(e),
})
# Fall through to preview-based fetch
# Fallback: get the latest APPLIED preview session
session = (
self.db.query(TranslationPreviewSession)
.filter(
TranslationPreviewSession.job_id == job_id,
TranslationPreviewSession.status == "APPLIED",
)
.order_by(TranslationPreviewSession.created_at.desc())
.first()
)
if not session:
logger.explore("No accepted preview session found", {"job_id": job_id})
return []
# Fetch APPROVED or all records from the session
records = (
self.db.query(TranslationPreviewRecord)
.filter(
TranslationPreviewRecord.session_id == session.id,
TranslationPreviewRecord.status.in_(["APPROVED", "PENDING"]),
)
.all()
)
source_rows = []
for rec in records:
source_data_dict = None
if hasattr(rec, "source_data") and rec.source_data:
source_data_dict = dict(rec.source_data)
source_rows.append({
"row_index": rec.source_object_id or "0",
"source_text": rec.source_sql or "",
"approved_translation": rec.target_sql if rec.status == "APPROVED" else None,
"source_object_name": rec.source_object_name or "",
"source_data": source_data_dict,
})
logger.reason(f"Fetched {len(source_rows)} source rows from preview fallback", {
"run_id": run_id,
"session_id": session.id,
})
return source_rows
# endregion _fetch_source_rows
# region _filter_new_keys [TYPE Function]
# @PURPOSE: Filter source rows to only include those with keys absent from the last successful run.
# @PRE: job and run are persisted; source_rows is a list of dicts with source_data containing key columns.
# @POST: Returns filtered list of source rows with only new keys. Logs skip count.
# @SIDE_EFFECT: Queries TranslationRecord from previous runs; no writes.
def _filter_new_keys(self, job, run_id: str, source_rows: list) -> list:
with belief_scope("TranslationExecutor._filter_new_keys"):
# Find most recent COMPLETED run with successful insert for this job
prev_run = (
self.db.query(TranslationRun)
.filter(
TranslationRun.job_id == job.id,
TranslationRun.status == "COMPLETED",
TranslationRun.insert_status == "succeeded",
TranslationRun.id != run_id,
)
.order_by(TranslationRun.created_at.desc())
.first()
)
if not prev_run:
logger.reason("No prior successful run — all keys treated as new", {
"job_id": job.id,
})
return source_rows
# Get successful records from that run
prev_records = (
self.db.query(TranslationRecord)
.filter(
TranslationRecord.run_id == prev_run.id,
TranslationRecord.status == "SUCCESS",
)
.all()
)
if not prev_records:
return source_rows
# Build set of already-translated composite keys
key_cols = job.target_key_cols or job.source_key_cols or []
if not key_cols:
logger.explore("No key columns configured — skipping new-key-only filter",
{"job_id": job.id})
return source_rows
existing_keys = set()
for rec in prev_records:
sd = rec.source_data or {}
key_tuple = tuple(str(sd.get(k, "")) for k in key_cols)
existing_keys.add(key_tuple)
# Filter: keep only rows whose keys are NOT in the existing set
filtered = []
skipped = 0
for row in source_rows:
sd = row.get("source_data", {}) or {}
key_tuple = tuple(str(sd.get(k, "")) for k in key_cols)
if key_tuple not in existing_keys:
filtered.append(row)
else:
skipped += 1
logger.reason(f"New-key-only filter: {len(source_rows)} total → {len(filtered)} new, {skipped} skipped", {
"job_id": job.id,
"prev_run_id": prev_run.id,
"key_cols": key_cols,
})
return filtered
# endregion _filter_new_keys
# region _extract_chart_data_rows [TYPE Function]
# @PURPOSE: Extract data rows from Superset chart data API response.
# @POST: Returns list of dicts with column-value pairs.
@staticmethod
def _extract_chart_data_rows(response: Dict[str, Any]) -> List[Dict[str, Any]]:
result = response.get("result")
if isinstance(result, list):
for item in result:
if isinstance(item, dict):
data = item.get("data")
if isinstance(data, list) and data:
return data
if isinstance(result, dict):
data = result.get("data")
if isinstance(data, list) and data:
return data
data = response.get("data")
if isinstance(data, list) and data:
return data
if isinstance(result, list):
return result
return []
# endregion _extract_chart_data_rows
# region _process_batch [TYPE Function]
# @PURPOSE: Process a single batch: filter dict, build prompt, call LLM, persist records.
# @PRE: job and batch_rows are valid.
# @POST: TranslationBatch and TranslationRecord rows are created.
# @SIDE_EFFECT: LLM API call.
def _process_batch(
self,
job: TranslationJob,
run_id: str,
batch_index: int,
batch_rows: List[Dict[str, Any]],
) -> Dict[str, int]:
with belief_scope("TranslationExecutor._process_batch"):
batch_start = time.monotonic()
# Create batch record
batch = TranslationBatch(
id=str(uuid.uuid4()),
run_id=run_id,
batch_index=batch_index,
status="RUNNING",
total_records=len(batch_rows),
started_at=datetime.now(timezone.utc),
)
self.db.add(batch)
self.db.flush()
batch_id = batch.id
result = {"successful": 0, "failed": 0, "skipped": 0, "retries": 0}
# Extract source texts for dict filtering
source_texts = [
row.get("source_text", "")
for row in batch_rows
if row.get("source_text")
]
# Filter dictionary entries
dict_matches = DictionaryManager.filter_for_batch(
self.db, source_texts, job.id
)
# For each row, determine if we need LLM translation or can use approved translation
rows_for_llm = []
pre_translated = []
for row in batch_rows:
if row.get("approved_translation"):
pre_translated.append(row)
else:
rows_for_llm.append(row)
# Handle pre-translated (approved) rows
for row in pre_translated:
record = TranslationRecord(
id=str(uuid.uuid4()),
batch_id=batch_id,
run_id=run_id,
source_sql=row.get("source_text", ""),
target_sql=row.get("approved_translation"),
source_object_type="table_row",
source_object_id=row.get("row_index"),
source_object_name=row.get("source_object_name", ""),
source_data=row.get("source_data"),
status="SUCCESS",
)
self.db.add(record)
result["successful"] += 1
# Process rows needing LLM translation
if rows_for_llm:
llm_result = self._call_llm_for_batch(
job=job,
run_id=run_id,
batch_rows=rows_for_llm,
dict_matches=dict_matches,
batch_id=batch_id,
)
result["successful"] += llm_result["successful"]
result["failed"] += llm_result["failed"]
result["skipped"] += llm_result["skipped"]
result["retries"] += llm_result.get("retries", 0)
# Update batch status
batch.successful_records = result["successful"]
batch.failed_records = result["failed"]
batch.completed_at = datetime.now(timezone.utc)
batch.status = "COMPLETED" if result["failed"] == 0 else "COMPLETED_WITH_ERRORS"
self.db.flush()
batch_latency = int((time.monotonic() - batch_start) * 1000)
logger.reason(f"Batch {batch_index} complete", {
"batch_id": batch_id,
"latency_ms": batch_latency,
**result,
})
return result
# endregion _process_batch
# region _call_llm_for_batch [TYPE Function]
# @PURPOSE: Call LLM for a batch of rows requiring translation. Parse structured JSON response.
# @PRE: job has valid provider_id. batch_rows is non-empty.
# @POST: Returns dict with successful/failed/skipped counts. Creates TranslationRecord rows.
# @SIDE_EFFECT: HTTP call to LLM provider.
def _call_llm_for_batch(
self,
job: TranslationJob,
run_id: str,
batch_rows: List[Dict[str, Any]],
dict_matches: List[Dict[str, Any]],
batch_id: str,
) -> Dict[str, int]:
with belief_scope("TranslationExecutor._call_llm_for_batch"):
# Build dictionary section
dictionary_section = ""
if dict_matches:
glossary_lines = []
for m in dict_matches:
glossary_lines.append(
f"- '{m['source_term']}' -> '{m['target_term']}'"
f"{' (' + m['context_notes'] + ')' if m.get('context_notes') else ''}"
)
dictionary_section = (
"Terminology dictionary (use these translations when applicable):\n"
+ "\n".join(glossary_lines)
+ "\n\n"
)
# Build rows JSON for LLM
rows_json = json.dumps([
{
"row_id": str(row.get("row_index", idx)),
"text": row.get("source_text", ""),
}
for idx, row in enumerate(batch_rows)
], indent=2)
# Build prompt
prompt = render_prompt(
DEFAULT_EXECUTION_PROMPT_TEMPLATE,
{
"source_language": job.source_dialect or "SQL",
"target_language": job.target_language or job.target_dialect or "en",
"source_dialect": job.source_dialect or "",
"target_dialect": job.target_dialect or "",
"translation_column": job.translation_column or "",
"dictionary_section": dictionary_section,
"rows_json": rows_json,
"row_count": str(len(batch_rows)),
},
)
# Call LLM with retry
llm_response = None
last_error = None
retries = 0
for attempt in range(1, MAX_RETRIES_PER_BATCH + 1):
try:
llm_response = self._call_llm(job, prompt)
break
except Exception as e:
last_error = str(e)
retries += 1
logger.explore(f"LLM call failed (attempt {attempt})", {
"batch_id": batch_id,
"error": last_error,
"attempt": attempt,
})
if attempt < MAX_RETRIES_PER_BATCH:
time.sleep(2 ** attempt) # Exponential backoff
else:
logger.explore("LLM call exhausted retries", {
"batch_id": batch_id,
"last_error": last_error,
})
if llm_response is None:
# All retries failed — mark all rows as failed
for row in batch_rows:
record = TranslationRecord(
id=str(uuid.uuid4()),
batch_id=batch_id,
run_id=run_id,
source_sql=row.get("source_text", ""),
target_sql=None,
source_object_type="table_row",
source_object_id=row.get("row_index"),
source_object_name=row.get("source_object_name", ""),
source_data=row.get("source_data"),
status="FAILED",
error_message=f"LLM call failed after {retries} retries: {last_error}",
)
self.db.add(record)
return {"successful": 0, "failed": len(batch_rows), "skipped": 0, "retries": retries}
# Parse LLM response
try:
translations = self._parse_llm_response(llm_response, len(batch_rows))
except ValueError as e:
# Parse failure — mark all rows as SKIPPED
logger.explore("LLM response parse failed", {
"batch_id": batch_id,
"error": str(e),
"response_preview": llm_response[:500] if llm_response else "",
})
skipped = len(batch_rows)
for row in batch_rows:
record = TranslationRecord(
id=str(uuid.uuid4()),
batch_id=batch_id,
run_id=run_id,
source_sql=row.get("source_text", ""),
target_sql=None,
source_object_type="table_row",
source_object_id=row.get("row_index"),
source_object_name=row.get("source_object_name", ""),
source_data=row.get("source_data"),
status="SKIPPED",
error_message=f"LLM parse failure: {e}",
)
self.db.add(record)
return {
"successful": 0,
"failed": 0,
"skipped": skipped,
"retries": retries,
}
successful = 0
failed = 0
skipped = 0
for row in batch_rows:
row_id = str(row.get("row_index", ""))
translation = translations.get(row_id)
if translation is None:
# NULL translation — skip
skipped += 1
record = TranslationRecord(
id=str(uuid.uuid4()),
batch_id=batch_id,
run_id=run_id,
source_sql=row.get("source_text", ""),
target_sql="",
source_object_type="table_row",
source_object_id=row.get("row_index"),
source_object_name=row.get("source_object_name", ""),
source_data=row.get("source_data"),
status="SKIPPED",
error_message="NULL translation returned by LLM",
)
self.db.add(record)
continue
if translation.strip() == "":
# Empty translation — skip
skipped += 1
record = TranslationRecord(
id=str(uuid.uuid4()),
batch_id=batch_id,
run_id=run_id,
source_sql=row.get("source_text", ""),
target_sql="",
source_object_type="table_row",
source_object_id=row.get("row_index"),
source_object_name=row.get("source_object_name", ""),
source_data=row.get("source_data"),
status="SKIPPED",
error_message="Empty translation returned by LLM",
)
self.db.add(record)
continue
successful += 1
record = TranslationRecord(
id=str(uuid.uuid4()),
batch_id=batch_id,
run_id=run_id,
source_sql=row.get("source_text", ""),
target_sql=translation,
source_object_type="table_row",
source_object_id=row.get("row_index"),
source_object_name=row.get("source_object_name", ""),
source_data=row.get("source_data"),
status="SUCCESS",
)
self.db.add(record)
return {
"successful": successful,
"failed": failed,
"skipped": skipped,
"retries": retries,
}
# endregion _call_llm_for_batch
# region _call_llm [TYPE Function]
# @PURPOSE: Call the configured LLM provider with the batch prompt.
# @PRE: job has valid provider_id.
# @POST: Returns raw LLM response string.
# @SIDE_EFFECT: HTTP call to LLM provider.
def _call_llm(self, job: TranslationJob, prompt: str) -> str:
with belief_scope("TranslationExecutor._call_llm"):
if not job.provider_id:
raise ValueError("Job has no LLM provider configured")
provider_svc = LLMProviderService(self.db)
provider = provider_svc.get_provider(job.provider_id)
if not provider:
raise ValueError(f"LLM provider '{job.provider_id}' not found")
api_key = provider_svc.get_decrypted_api_key(job.provider_id)
if not api_key:
raise ValueError(f"Could not decrypt API key for provider '{job.provider_id}'")
model = provider.default_model or "gpt-4o-mini"
provider_type = provider.provider_type.lower() if provider.provider_type else "openai"
if provider_type in ("openai", "openai_compatible", "openrouter", "kilo"):
return self._call_openai_compatible(
base_url=provider.base_url,
api_key=api_key,
model=model,
prompt=prompt,
provider_type=provider_type,
)
else:
raise ValueError(f"Unsupported provider type '{provider_type}'")
# endregion _call_llm
# region _call_openai_compatible [TYPE Function]
# @PURPOSE: Call OpenAI-compatible API for batch translation.
# @PRE: Valid API endpoint, key, model, and prompt.
# @POST: Returns response text.
# @SIDE_EFFECT: HTTP POST to LLM API.
@staticmethod
def _call_openai_compatible(
base_url: str,
api_key: str,
model: str,
prompt: str,
provider_type: str = "openai",
) -> str:
with belief_scope("TranslationExecutor._call_openai_compatible"):
import requests as http_requests
url = f"{base_url.rstrip('/')}/chat/completions"
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
payload = {
"model": model,
"messages": [
{"role": "system", "content": "You are a database content translation assistant. Translate the provided text accurately, preserving data semantics."},
{"role": "user", "content": prompt},
],
"temperature": 0.1,
"max_tokens": 4096,
}
# Structured output (response_format) only for native OpenAI — upstream providers routed via
# Kilo/OpenRouter may not support it (e.g. StepFun returns "structured_outputs is not supported")
if provider_type in ("openai", "openai_compatible"):
payload["response_format"] = {"type": "json_object"}
logger.reason(
f"LLM request model={payload.get('model')} "
f"provider_type={provider_type} "
f"response_format={'yes' if 'response_format' in payload else 'no'} "
f"prompt_len={len(prompt)}"
)
response = http_requests.post(url, headers=headers, json=payload, timeout=180)
if not response.ok:
logger.explore(
f"LLM API error status={response.status_code} "
f"model={payload.get('model')} "
f"body={response.text[:2000]}"
)
response.raise_for_status()
data = response.json()
choices = data.get("choices", [])
if not choices:
raise ValueError("LLM returned no choices")
content = choices[0].get("message", {}).get("content", "")
if not content:
raise ValueError("LLM returned empty content")
return content
# endregion _call_openai_compatible
# region _parse_llm_response [TYPE Function]
# @PURPOSE: Parse LLM JSON response into dict of row_id -> translation.
# @PRE: response_text is valid JSON with {"rows": [...]} structure.
# @POST: Returns dict mapping row_id to translation text.
@staticmethod
def _parse_llm_response(response_text: str, expected_count: int) -> Dict[str, str]:
with belief_scope("TranslationExecutor._parse_llm_response"):
try:
data = json.loads(response_text)
except json.JSONDecodeError:
# Try to extract from markdown code block
import re
match = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', response_text, re.DOTALL)
if match:
try:
data = json.loads(match.group(1))
except json.JSONDecodeError:
raise ValueError("LLM response was not valid JSON")
else:
raise ValueError("LLM response was not valid JSON")
rows = data.get("rows", [])
if not isinstance(rows, list):
raise ValueError("LLM response missing 'rows' array")
translations: Dict[str, str] = {}
for item in rows:
row_id = str(item.get("row_id", ""))
translation = item.get("translation")
if translation is None:
# Skip NULL translations — they'll be handled by caller
continue
if row_id:
translations[row_id] = str(translation)
return translations
# endregion _parse_llm_response
# #endregion TranslationExecutor
# #endregion TranslationExecutor