Files
ss-tools/backend/src/plugins/translate/preview.py
busya 306c5ae742 semantics: complete DEF-to-region migration, fix regressions
- Convert legacy [DEF🆔Type] anchors to #region/#endregion across 329 files
- Reinstate _normalize_timestamp_value in sql_generator.py
- Fix MarkerLogger→logger migration in events.py (molecular CoT markers)
- Fix dataset_review orchestrator dependencies (_build_execution_snapshot)
- Fix config_manager stale-record deletion (moved to save path only)
- Add 77 missing [/DEF:] closers in 5 unbalanced test files
- Update assistant_chat.integration.test.js for #region format
- Apply molecular-cot-logging markers (REASON/REFLECT/EXPLORE) via logger.* methods
2026-05-12 23:54:55 +03:00

822 lines
35 KiB
Python

# #region TranslationPreview [C:4] [TYPE Module] [SEMANTICS translate, preview, llm, session]
# @BRIEF Preview session management: fetch sample rows from Superset, send to LLM with context + filtered dictionary, return side-by-side results.
# @LAYER: Domain
# @RELATION DEPENDS_ON -> [TranslationJob:Class]
# @RELATION DEPENDS_ON -> [TranslationPreviewSession:Class]
# @RELATION DEPENDS_ON -> [TranslationPreviewRecord:Class]
# @RELATION DEPENDS_ON -> [LLMProviderService]
# @RELATION DEPENDS_ON -> [DictionaryManager]
# @RELATION DEPENDS_ON -> [SupersetClient]
# @RELATION DEPENDS_ON -> [render_prompt]
# @RELATION DEPENDS_ON -> [ConfigManager]
# @PRE: Database session and config manager are available.
# @POST: Preview sessions are created with LLM-translated rows; records can be approved/edited/rejected.
# @SIDE_EFFECT: Fetches sample data from Superset; calls LLM provider; creates DB rows.
# @RATIONALE: C4 because preview is stateful with approve/edit/reject lifecycle and LLM API calls.
# @REJECTED: Transient preview state (in-memory/session-scoped) — would lose decisions on restart and cannot gate execution reliably.
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy.orm import Session
from datetime import datetime, timezone, timedelta
import uuid
import json
import hashlib
import re
from ...core.logger import logger, belief_scope
from ...core.config_manager import ConfigManager
from ...core.superset_client import SupersetClient
from ...models.translate import (
TranslationJob,
TranslationPreviewSession,
TranslationPreviewRecord,
TranslationJobDictionary,
)
from ...services.llm_provider import LLMProviderService
from ...services.llm_prompt_templates import render_prompt
from .dictionary import DictionaryManager
# #region DEFAULT_EXECUTION_PROMPT_TEMPLATE [TYPE Constant]
# @BRIEF Default prompt template for batch LLM translation execution (no context columns — faster).
DEFAULT_EXECUTION_PROMPT_TEMPLATE: str = (
"Translate the following database content from {source_language} to {target_language}.\n\n"
"Source dialect: {source_dialect}\n"
"Target dialect: {target_dialect}\n"
"Column to translate: {translation_column}\n\n"
"{dictionary_section}"
"For each row, provide an accurate translation of the text.\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>", "translation": "<translated_text>"}}]}}\n'
"Each row_id must match the index provided. Return exactly {row_count} entries."
)
# #endregion DEFAULT_EXECUTION_PROMPT_TEMPLATE
# #region DEFAULT_PREVIEW_PROMPT_TEMPLATE [TYPE Constant]
# @BRIEF Default prompt template for LLM translation preview.
DEFAULT_PREVIEW_PROMPT_TEMPLATE: str = (
"Translate the following database content from {source_language} to {target_language}.\n\n"
"Source dialect: {source_dialect}\n"
"Target dialect: {target_dialect}\n"
"Column to translate: {translation_column}\n\n"
"{dictionary_section}"
"For each row, provide an accurate translation of the '{translation_column}' value.\n"
"Consider the context columns when determining the meaning of the text.\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>", "translation": "<translated_text>"}}]}}\n'
"Each row_id must match the index provided. Return exactly {row_count} entries."
)
# #endregion DEFAULT_PREVIEW_PROMPT_TEMPLATE
# #region TokenEstimator [TYPE Class]
# @BRIEF Estimate token counts and costs for LLM translation operations.
# @RATIONALE: Token estimation uses a heuristic (chars/token ratio) since exact tokenization depends on the LLM model.
# @REJECTED: Using an external tokenizer library would introduce a heavy dependency for estimation only.
class TokenEstimator:
"""Estimate token counts and costs for LLM operations."""
CHARS_PER_TOKEN_ESTIMATE: float = 4.0
OUTPUT_TOKENS_PER_ROW_ESTIMATE: int = 50
TOKEN_COST_PER_1K: float = 0.002 # Default cost per 1K tokens
# [DEF:estimate_prompt_tokens:Function]
# @PURPOSE: Estimate token count for a prompt string.
# @PRE: prompt is a non-empty string.
# @POST: Returns estimated token count (integer).
@staticmethod
def estimate_prompt_tokens(prompt: str) -> int:
if not prompt:
return 0
return max(1, int(len(prompt) / TokenEstimator.CHARS_PER_TOKEN_ESTIMATE))
# [/DEF:estimate_prompt_tokens:Function]
# [DEF:estimate_output_tokens:Function]
# @PURPOSE: Estimate output token count for translating N rows.
# @PRE: row_count >= 0.
# @POST: Returns estimated output token count.
@staticmethod
def estimate_output_tokens(row_count: int) -> int:
return row_count * TokenEstimator.OUTPUT_TOKENS_PER_ROW_ESTIMATE
# [/DEF:estimate_output_tokens:Function]
# [DEF:estimate_cost:Function]
# @PURPOSE: Estimate cost for a given number of tokens.
# @PRE: total_tokens >= 0.
# @POST: Returns estimated cost in USD.
@staticmethod
def estimate_cost(total_tokens: int, cost_per_1k: Optional[float] = None) -> float:
rate = cost_per_1k if cost_per_1k is not None else TokenEstimator.TOKEN_COST_PER_1K
return round((total_tokens / 1000) * rate, 6)
# [/DEF:estimate_cost:Function]
# #endregion TokenEstimator
# #region TranslationPreview [C:4] [TYPE Class]
# @BRIEF Manages preview lifecycle: fetch sample rows, call LLM, manage row-level approve/edit/reject, accept gate.
# @PRE: Database session and config manager are available.
# @POST: Preview sessions created with persisted records; full execution gates on accepted session.
# @SIDE_EFFECT: Fetches sample data from Superset; calls LLM provider; creates DB rows.
class TranslationPreview:
def __init__(
self,
db: Session,
config_manager: ConfigManager,
current_user: Optional[str] = None,
):
self.db = db
self.config_manager = config_manager
self.current_user = current_user
# [DEF:preview_rows:Function]
# @COMPLEXITY: 4
# @PURPOSE: Fetch sample rows from Superset dataset, send to LLM for translation, create preview session with records.
# @PRE: job_id exists and job has source_datasource_id, translation_column configured.
# @POST: Returns TranslationPreviewResponse with records, cost estimation, and persistent session.
# @SIDE_EFFECT: Fetches data from Superset; calls LLM; creates TranslationPreviewSession and TranslationPreviewRecord rows.
def preview_rows(
self,
job_id: str,
sample_size: int = 10,
prompt_template: Optional[str] = None,
env_id: Optional[str] = None,
) -> Dict[str, Any]:
with belief_scope("TranslationPreview.preview_rows"):
logger.reason("Starting preview for job", {"job_id": job_id, "sample_size": sample_size})
# 1. Load job
job = self.db.query(TranslationJob).filter(TranslationJob.id == job_id).first()
if not job:
raise ValueError(f"Translation job '{job_id}' not found")
if not job.source_datasource_id:
raise ValueError("Job must have a source datasource configured for preview")
if not job.translation_column:
raise ValueError("Job must have a translation column configured for preview")
# 2. Compute config hash and dict snapshot hash
config_hash = self._compute_config_hash(job)
dict_snapshot_hash = self._compute_dict_snapshot_hash(job_id)
# 3. Fetch sample rows from Superset
logger.reason("Fetching sample rows from Superset", {
"datasource_id": job.source_datasource_id,
"sample_size": sample_size,
"translation_column": job.translation_column,
})
source_rows = self._fetch_sample_rows(
job=job,
sample_size=sample_size,
env_id=env_id,
)
if not source_rows:
raise ValueError("No rows returned from datasource for preview")
actual_row_count = len(source_rows)
logger.reason(f"Fetched {actual_row_count} sample row(s)")
# Debug: log first row keys and translation column value
if source_rows:
first_row = source_rows[0]
logger.reason(
f"First source row keys={list(first_row.keys())} "
f"translation_col={job.translation_column} "
f"val='{first_row.get(job.translation_column, '')}'"
)
# 4. Build prompt context from rows
all_source_texts = []
row_meta: List[Dict[str, Any]] = []
for idx, row in enumerate(source_rows):
translation_value = str(row.get(job.translation_column, "") or "")
context_values = {}
if job.context_columns:
for col in job.context_columns:
context_values[col] = str(row.get(col, "") or "")
all_source_texts.append(translation_value)
row_meta.append({
"row_index": idx,
"source_text": translation_value,
"context_data": context_values,
"source_row": row,
})
# 5. Filter dictionary entries for this batch
dict_matches = DictionaryManager.filter_for_batch(
self.db, all_source_texts, job_id
)
# Build dictionary glossary 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"
)
# 6. Build LLM prompt
rows_json = json.dumps([
{"row_id": str(m["row_index"]), "text": m["source_text"], "context": m["context_data"]}
for m in row_meta
], indent=2)
template = prompt_template or DEFAULT_PREVIEW_PROMPT_TEMPLATE
prompt = render_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(actual_row_count),
})
# 7. Estimate tokens/cost for sample and full dataset
sample_prompt_tokens = TokenEstimator.estimate_prompt_tokens(prompt)
sample_output_tokens = TokenEstimator.estimate_output_tokens(actual_row_count)
sample_total_tokens = sample_prompt_tokens + sample_output_tokens
sample_cost = TokenEstimator.estimate_cost(sample_total_tokens)
# Estimate full dataset cost (if we knew total rows)
total_est_tokens = TokenEstimator.estimate_prompt_tokens(
prompt.replace(str(actual_row_count), "{total}")
) + TokenEstimator.estimate_output_tokens(sample_size * 10) # rough extrapolation
total_est_cost = TokenEstimator.estimate_cost(total_est_tokens)
# 8. Call LLM
logger.reason("Calling LLM for preview translation", {
"provider_id": job.provider_id,
"row_count": actual_row_count,
"estimated_tokens": sample_total_tokens,
})
llm_response = self._call_llm(
job=job,
prompt=prompt,
)
# 9. Parse LLM response
translations = self._parse_llm_response(llm_response, actual_row_count)
# 10. Create preview session
session = TranslationPreviewSession(
id=str(uuid.uuid4()),
job_id=job_id,
status="ACTIVE",
created_by=self.current_user,
created_at=datetime.now(timezone.utc),
expires_at=datetime.now(timezone.utc) + timedelta(hours=24),
)
self.db.add(session)
self.db.flush()
# 11. Create preview records
records = []
for meta in row_meta:
idx = meta["row_index"]
translation = translations.get(str(idx), "")
is_rejected = False
status = "PENDING"
feedback = None
record = TranslationPreviewRecord(
id=str(uuid.uuid4()),
session_id=session.id,
source_sql=meta["source_text"],
target_sql=translation,
source_object_type="table_row",
source_object_id=str(idx),
source_object_name=f"Row {idx + 1}",
status=status,
feedback=feedback,
created_at=datetime.now(timezone.utc),
)
self.db.add(record)
self.db.flush()
records.append({
"id": record.id,
"source_sql": record.source_sql,
"target_sql": record.target_sql,
"source_object_type": record.source_object_type,
"source_object_id": record.source_object_id,
"source_object_name": record.source_object_name,
"status": record.status,
"feedback": record.feedback,
})
self.db.commit()
result = {
"id": session.id,
"job_id": job_id,
"status": "ACTIVE",
"created_by": self.current_user,
"created_at": session.created_at.isoformat(),
"expires_at": session.expires_at.isoformat() if session.expires_at else None,
"records": records,
"cost_estimate": {
"sample_size": actual_row_count,
"sample_prompt_tokens": sample_prompt_tokens,
"sample_output_tokens": sample_output_tokens,
"sample_total_tokens": sample_total_tokens,
"sample_cost": sample_cost,
"estimated_total_rows": actual_row_count * 10,
"estimated_tokens": total_est_tokens,
"estimated_cost": total_est_cost,
},
"config_hash": config_hash,
"dict_snapshot_hash": dict_snapshot_hash,
}
logger.reflect("Preview completed", {
"session_id": session.id,
"row_count": actual_row_count,
"sample_cost": sample_cost,
})
return result
# [/DEF:preview_rows:Function]
# [DEF:update_preview_row:Function]
# @PURPOSE: Approve, edit, or reject an individual preview row.
# @PRE: session_id and row_id exist, session is ACTIVE.
# @POST: PreviewRecord status is updated.
def update_preview_row(
self,
job_id: str,
row_id: str,
action: str,
translation: Optional[str] = None,
feedback: Optional[str] = None,
) -> Dict[str, Any]:
with belief_scope("TranslationPreview.update_preview_row"):
# Find the active session for this job
session = (
self.db.query(TranslationPreviewSession)
.filter(
TranslationPreviewSession.job_id == job_id,
TranslationPreviewSession.status == "ACTIVE",
)
.order_by(TranslationPreviewSession.created_at.desc())
.first()
)
if not session:
raise ValueError(f"No active preview session for job '{job_id}'")
record = (
self.db.query(TranslationPreviewRecord)
.filter(
TranslationPreviewRecord.id == row_id,
TranslationPreviewRecord.session_id == session.id,
)
.first()
)
if not record:
raise ValueError(f"Preview record '{row_id}' not found in active session")
if action == "approve":
record.status = "APPROVED"
elif action == "reject":
record.status = "REJECTED"
elif action == "edit":
record.status = "APPROVED"
if translation is not None:
record.target_sql = translation
else:
raise ValueError(f"Invalid action '{action}'. Use 'approve', 'reject', or 'edit'.")
if feedback is not None:
record.feedback = feedback
self.db.commit()
self.db.refresh(record)
logger.reason(f"Preview row {action}d", {
"row_id": row_id,
"session_id": session.id,
"status": record.status,
})
return {
"id": record.id,
"source_sql": record.source_sql,
"target_sql": record.target_sql,
"status": record.status,
"feedback": record.feedback,
}
# [/DEF:update_preview_row:Function]
# [DEF:accept_preview_session:Function]
# @PURPOSE: Mark a preview session as accepted, which gates full execution.
# @PRE: job_id has an ACTIVE preview session.
# @POST: Session status changes to APPLIED.
# @SIDE_EFFECT: Future full execution calls will check for accepted session.
def accept_preview_session(self, job_id: str) -> Dict[str, Any]:
with belief_scope("TranslationPreview.accept_preview_session"):
session = (
self.db.query(TranslationPreviewSession)
.filter(
TranslationPreviewSession.job_id == job_id,
TranslationPreviewSession.status == "ACTIVE",
)
.order_by(TranslationPreviewSession.created_at.desc())
.first()
)
if not session:
raise ValueError(f"No active preview session for job '{job_id}'")
session.status = "APPLIED"
self.db.commit()
self.db.refresh(session)
logger.reason("Preview session accepted", {
"session_id": session.id,
"job_id": job_id,
})
# Get records for response
records = (
self.db.query(TranslationPreviewRecord)
.filter(TranslationPreviewRecord.session_id == session.id)
.all()
)
return {
"id": session.id,
"job_id": job_id,
"status": "APPLIED",
"created_by": session.created_by,
"created_at": session.created_at.isoformat(),
"expires_at": session.expires_at.isoformat() if session.expires_at else None,
"records": [
{
"id": r.id,
"source_sql": r.source_sql,
"target_sql": r.target_sql,
"status": r.status,
"feedback": r.feedback,
}
for r in records
],
}
# [/DEF:accept_preview_session:Function]
# [DEF:get_preview_session:Function]
# @PURPOSE: Get the latest preview session for a job with its records.
# @PRE: job_id exists.
# @POST: Returns session data with records or raises ValueError.
def get_preview_session(self, job_id: str) -> Dict[str, Any]:
with belief_scope("TranslationPreview.get_preview_session"):
session = (
self.db.query(TranslationPreviewSession)
.filter(TranslationPreviewSession.job_id == job_id)
.order_by(TranslationPreviewSession.created_at.desc())
.first()
)
if not session:
raise ValueError(f"No preview session found for job '{job_id}'")
records = (
self.db.query(TranslationPreviewRecord)
.filter(TranslationPreviewRecord.session_id == session.id)
.all()
)
return {
"id": session.id,
"job_id": job_id,
"status": session.status,
"created_by": session.created_by,
"created_at": session.created_at.isoformat(),
"expires_at": session.expires_at.isoformat() if session.expires_at else None,
"records": [
{
"id": r.id,
"source_sql": r.source_sql,
"target_sql": r.target_sql,
"source_object_type": r.source_object_type,
"source_object_id": r.source_object_id,
"source_object_name": r.source_object_name,
"status": r.status,
"feedback": r.feedback,
}
for r in records
],
}
# [/DEF:get_preview_session:Function]
# [DEF:_fetch_sample_rows:Function]
# @COMPLEXITY: 4
# @PURPOSE: Fetch sample rows from the Superset dataset for preview.
# @PRE: job has source_datasource_id and translation_column.
# @POST: Returns list of dicts with row data.
# @SIDE_EFFECT: Calls Superset chart data endpoint.
def _fetch_sample_rows(self, job: TranslationJob, sample_size: int = 10, env_id: Optional[str] = None) -> List[Dict[str, Any]]:
with belief_scope("TranslationPreview._fetch_sample_rows"):
# Determine environment: prefer explicit env_id, then job.environment_id, then job.source_dialect (legacy)
environments = self.config_manager.get_environments()
target_env_id = env_id or 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:
logger.explore("Could not find environment for datasource", {
"env_id": target_env_id,
})
# Fallback: try first environment
if environments:
env_config = environments[0]
logger.explore("Falling back to first available environment", {
"env_id": env_config.id,
})
else:
raise ValueError("No Superset environments configured")
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 for chart data endpoint.
# Virtual columns (e.g. comment_text_ru) are NOT resolved when:
# - result_type="query" (physical columns only)
# - query_mode="raw" (virtual columns unavailable in raw mode)
# Solution: remove both result_type="query" AND query_mode="raw",
# use aggregate mode with no metrics — this resolves virtual columns.
query_context = client.build_dataset_preview_query_context(
dataset_id=int(job.source_datasource_id),
dataset_record=dataset_detail,
template_params={},
effective_filters=[],
)
# Modify: use result_type="samples" which returns sample data
# including all columns (physical + virtual), without needing
# explicit column objects that trigger validation errors.
queries = query_context.get("queries", [])
if queries:
queries[0]["row_limit"] = sample_size
queries[0].pop("result_type", None)
queries[0].pop("columns", None)
queries[0]["metrics"] = []
query_context["result_type"] = "samples"
form_data = query_context.get("form_data", {})
form_data.pop("query_mode", None)
try:
response = client.network.request(
method="POST",
endpoint="/api/v1/chart/data",
data=json.dumps(query_context),
headers={"Content-Type": "application/json"},
)
except Exception as e:
logger.explore("Chart data API failed", {"error": str(e)})
raise ValueError(f"Failed to fetch sample data from Superset: {e}")
# Parse response
rows = self._extract_data_rows(response)
logger.reason(f"Extracted {len(rows)} data row(s)")
# Debug: log first row keys and translation column value
if rows:
first_row = rows[0]
logger.reason(
f"Row keys={list(first_row.keys())} "
f"target_col={job.translation_column} "
f"val='{first_row.get(job.translation_column, '')}'"
)
return rows
# [/DEF:_fetch_sample_rows:Function]
# [DEF:_extract_data_rows:Function]
# @PURPOSE: Extract data rows from Superset chart data response.
# @PRE: response is a dict from Superset API.
# @POST: Returns list of row dicts.
@staticmethod
def _extract_data_rows(response: Dict[str, Any]) -> List[Dict[str, Any]]:
with belief_scope("TranslationPreview._extract_data_rows"):
# Try various response formats
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
# Try flat result
if isinstance(result, dict):
data = result.get("data")
if isinstance(data, list) and data:
return data
# Legacy: response may have data at top level
data = response.get("data")
if isinstance(data, list) and data:
return data
# Last resort: return response itself wrapped if it looks like a list of rows
if isinstance(result, list):
return result
return []
# [/DEF:_extract_data_rows:Function]
# [DEF:_call_llm:Function]
# @COMPLEXITY: 4
# @PURPOSE: Call the configured LLM provider with the preview prompt.
# @PRE: job has a valid provider_id.
# @POST: Returns raw LLM response string.
# @SIDE_EFFECT: Makes HTTP call to LLM provider.
def _call_llm(self, job: TranslationJob, prompt: str) -> str:
with belief_scope("TranslationPreview._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}'")
# Build the API call based on provider type
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"):
response_text = 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}' for preview")
logger.reason("LLM call completed", {
"provider_id": job.provider_id,
"model": model,
"response_length": len(response_text),
})
return response_text
# [/DEF:_call_llm:Function]
# [DEF:_call_openai_compatible:Function]
# @PURPOSE: Call an OpenAI-compatible API for translation.
# @PRE: base_url, api_key, model, prompt are valid.
# @POST: Returns response text.
# @SIDE_EFFECT: Makes 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("TranslationPreview._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=120)
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
# [/DEF:_call_openai_compatible:Function]
# [DEF:_parse_llm_response:Function]
# @PURPOSE: Parse the LLM JSON response into a dict of row_id -> translation.
# @PRE: response_text is valid JSON with {"rows": [...]} structure.
# @POST: Returns dict mapping string row_id to translation text.
@staticmethod
def _parse_llm_response(response_text: str, expected_count: int) -> Dict[str, str]:
with belief_scope("TranslationPreview._parse_llm_response"):
logger.reason(f"Raw LLM response length={len(response_text)} preview={response_text[:500]}")
try:
data = json.loads(response_text)
except json.JSONDecodeError:
# Try to extract JSON 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):
logger.explore(f"LLM response has no 'rows' array, keys={list(data.keys())} text_preview={response_text[:300]}")
raise ValueError("LLM response missing 'rows' array")
translations: Dict[str, str] = {}
for item in rows:
row_id = str(item.get("row_id", ""))
translation = str(item.get("translation", ""))
if row_id:
translations[row_id] = translation
if len(translations) < expected_count:
logger.explore(
f"LLM returned fewer translations expected={expected_count} "
f"got={len(translations)} response_preview={response_text[:600]}"
)
return translations
# [/DEF:_parse_llm_response:Function]
# [DEF:_compute_config_hash:Function]
# @PURPOSE: Compute a hash of the job's current configuration for snapshot comparison.
@staticmethod
def _compute_config_hash(job: TranslationJob) -> str:
config_str = json.dumps({
"source_dialect": job.source_dialect,
"target_dialect": job.target_dialect,
"source_datasource_id": job.source_datasource_id,
"translation_column": job.translation_column,
"context_columns": job.context_columns,
"target_language": job.target_language,
"provider_id": job.provider_id,
"batch_size": job.batch_size,
"upsert_strategy": job.upsert_strategy,
}, sort_keys=True)
return hashlib.sha256(config_str.encode()).hexdigest()[:16]
# [/DEF:_compute_config_hash:Function]
# [DEF:_compute_dict_snapshot_hash:Function]
# @PURPOSE: Compute a hash of the dictionary state for snapshot comparison.
def _compute_dict_snapshot_hash(self, job_id: str) -> str:
dict_links = (
self.db.query(TranslationJobDictionary)
.filter(TranslationJobDictionary.job_id == job_id)
.all()
)
dict_ids = sorted([dl.dictionary_id for dl in dict_links])
hash_input = ",".join(dict_ids)
return hashlib.sha256(hash_input.encode()).hexdigest()[:16]
# [/DEF:_compute_dict_snapshot_hash:Function]
# #endregion TranslationPreview
# #endregion TranslationPreview