Files
ss-tools/backend/src/plugins/translate/preview.py
busya 04a20f7d3c feat(translate): replace LLM-based language detection with local lingua-detector
- Add _lang_detect.py module wrapping lingua-language-detector (pure Python, 0.076ms/call)
- Add batch_detect() with detector caching for performance
- Pre-filter rows where detected source language matches target (same-language skip)
- Remove detected_source_language from LLM prompt templates (token savings)
- Use local detection for cached rows (was always 'und')
- Preserve backward compat: LLM detected_source_language as fallback when lingua returns 'und'
- Add 24 unit tests for detection, edge cases, caching, and mapping
2026-05-20 14:31:37 +03:00

276 lines
14 KiB
Python

# #region TranslationPreview [C:4] [TYPE Module] [SEMANTICS sqlalchemy, translate, preview, llm, review]
# @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.
import time
import uuid
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy.orm import Session
from ...core.config_manager import ConfigManager
from ._lang_detect import detect_language, get_detector
from .preview_constants import DEFAULT_EXECUTION_PROMPT_TEMPLATE, DEFAULT_PREVIEW_PROMPT_TEMPLATE # noqa: F401
from ...core.logger import belief_scope, logger
from ...models.translate import (
TranslationJob,
TranslationPreviewLanguage,
TranslationPreviewRecord,
TranslationPreviewSession,
)
from .preview_executor import PreviewExecutor
from .preview_prompt_builder import PreviewPromptBuilder
from .preview_review import PreviewSessionManager
from .preview_token_estimator import TokenEstimator
from .preview_response_parser import (
compute_config_hash as _compute_config_hash_module,
parse_llm_response as _parse_llm_response_module,
)
# #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: str | None = None,
):
self.db = db
self.config_manager = config_manager
self.current_user = current_user
self._executor = PreviewExecutor(db, config_manager)
self._prompt_builder = PreviewPromptBuilder(db)
self._session_mgr = PreviewSessionManager(db)
# region preview_rows [TYPE Function]
# @PURPOSE: Fetch sample rows, send to LLM, create preview session with per-language records.
# @SIDE_EFFECT: Fetches data from Superset; calls LLM; creates DB rows.
def preview_rows(
self,
job_id: str,
sample_size: int = 10,
prompt_template: str | None = None,
env_id: str | None = None,
) -> dict[str, Any]:
with belief_scope("TranslationPreview.preview_rows"):
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")
if not job.target_languages:
raise ValueError("Job must have at least one target language configured for preview")
if not job.provider_id:
raise ValueError("Job must have an LLM provider configured for preview")
target_languages = job.target_languages or [job.target_dialect or "en"]
if not isinstance(target_languages, list):
target_languages = [str(target_languages)]
config_hash = self._executor.compute_config_hash(job)
dict_snapshot_hash = self._executor.compute_dict_snapshot_hash(job_id)
source_rows = self._executor.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")
provider_model = self._executor.resolve_provider_model(job)
budget_result = self._prompt_builder.estimate_token_budget_for_rows(
source_rows=source_rows, target_languages=target_languages, job=job, provider_model=provider_model,
)
source_rows, actual_row_count, token_budget = (
budget_result["source_rows"], budget_result["actual_row_count"], budget_result["token_budget"],
)
if token_budget.get("warning"):
logger.explore("Token budget warning", {"warning": token_budget["warning"], "sample_size": actual_row_count})
prompt_data = self._prompt_builder.build_prompt_from_rows(
job=job, source_rows=source_rows, sample_size=sample_size, prompt_template=prompt_template,
)
# ★ NEW: Run local language detection on row_meta (no LLM)
detector = get_detector(target_languages)
for meta in prompt_data["row_meta"]:
meta["_detected_lang"] = detect_language(meta["source_text"], detector)
t_llm = time.monotonic()
llm_response = self._executor.call_llm(
job=job, prompt=prompt_data["prompt"], max_tokens=token_budget["max_output_needed"],
)
logger.reason(f"TIMING: LLM call: {time.monotonic() - t_llm:.2f}s", {})
translations = self._executor.parse_llm_response(
llm_response, prompt_data["actual_row_count"], target_languages=target_languages,
)
session = TranslationPreviewSession(
id=str(uuid.uuid4()), job_id=job_id, status="ACTIVE",
created_by=self.current_user, created_at=datetime.now(UTC),
expires_at=datetime.now(UTC) + timedelta(hours=24),
)
self.db.add(session)
self.db.flush()
records = self._create_preview_records(
job=job, row_meta=prompt_data["row_meta"], translations=translations,
target_languages=target_languages, session=session,
)
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, "target_languages": target_languages,
"cost_estimate": {
"sample_size": prompt_data["actual_row_count"],
"num_languages": prompt_data["num_languages"],
"sample_prompt_tokens": prompt_data["sample_prompt_tokens"],
"sample_output_tokens": prompt_data["sample_output_tokens"],
"sample_total_tokens": prompt_data["sample_total_tokens"],
"sample_cost": prompt_data["sample_cost"],
"estimated_total_rows": prompt_data["total_est_rows"],
"estimated_tokens": prompt_data["total_est_tokens"],
"estimated_cost": prompt_data["total_est_cost"],
"warning": prompt_data["cost_warning"],
},
"token_budget": {
"batch_size_adjusted": token_budget["batch_size_adjusted"],
"estimated_input_tokens": token_budget["estimated_input_tokens"],
"estimated_output_tokens": token_budget["estimated_output_tokens"],
"max_output_needed": token_budget["max_output_needed"],
"warning": token_budget["warning"],
},
"config_hash": config_hash, "dict_snapshot_hash": dict_snapshot_hash,
}
logger.reflect("Preview completed", {"session_id": session.id, "row_count": actual_row_count})
return result
# endregion preview_rows
# region _create_preview_records [TYPE Function]
def _create_preview_records(
self,
job: TranslationJob, row_meta: list[dict[str, Any]],
translations: dict[str, dict[str, str]], target_languages: list[str],
session: TranslationPreviewSession,
) -> list[dict[str, Any]]:
records = []
for meta in row_meta:
idx = meta["row_index"]
source_text = meta["source_text"]
translation_data = translations.get(str(idx), {})
# ★ Local detection (from lingua) takes priority over LLM response
detected_lang = meta.get("_detected_lang", "und") or "und"
if detected_lang == "und" and isinstance(translation_data, dict):
detected_lang = translation_data.get("detected_source_language", "und") or "und"
lang_entries: list[TranslationPreviewLanguage] = []
for lang_code in target_languages:
lang_translation = None
if isinstance(translation_data, dict):
lang_translation = translation_data.get(lang_code) or translation_data.get("translation", "")
elif isinstance(translation_data, str):
lang_translation = translation_data
if str(lang_code).lower() == str(detected_lang).lower() and detected_lang != "und":
lang_translation = source_text
if not lang_translation:
lang_translation = ""
lang_entries.append(TranslationPreviewLanguage(
id=str(uuid.uuid4()), preview_record_id="",
language_code=lang_code, source_language_detected=detected_lang,
translated_value=str(lang_translation), final_value=str(lang_translation),
status="pending", needs_review=(detected_lang == "und"),
))
source_row = meta.get("source_row", {})
source_data = None
if job.target_key_cols:
source_data = {k: source_row.get(k) for k in job.target_key_cols if k in source_row}
elif source_row:
source_data = dict(source_row)
record = TranslationPreviewRecord(
id=str(uuid.uuid4()), session_id=session.id, source_sql=source_text,
target_sql=lang_entries[0].translated_value if lang_entries else "",
source_object_type="table_row", source_object_id=str(idx),
source_object_name=f"Row {idx + 1}", source_data=source_data,
status="PENDING", feedback=None, created_at=datetime.now(UTC),
)
self.db.add(record)
self.db.flush()
serialized_langs = []
for le in lang_entries:
le.preview_record_id = record.id
self.db.add(le)
serialized_langs.append({
"language_code": le.language_code, "source_language_detected": le.source_language_detected,
"translated_value": le.translated_value, "final_value": le.final_value,
"status": le.status, "needs_review": le.needs_review,
})
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, "source_language_detected": detected_lang,
"needs_review": any(le.needs_review for le in lang_entries),
"languages": serialized_langs,
})
self.db.commit()
return records
# endregion _create_preview_records
# Backward-compatible static methods (delegates to preview_response_parser)
@staticmethod
def _parse_llm_response(
response_text: str, expected_count: int,
target_languages: list[str] | None = None,
finish_reason: str | None = None,
) -> dict[str, dict[str, str]]:
return _parse_llm_response_module(response_text, expected_count, target_languages, finish_reason)
@staticmethod
def _compute_config_hash(job: TranslationJob) -> str:
return _compute_config_hash_module(job)
# Delegated methods
def update_preview_row(self, job_id: str, row_id: str, action: str, translation: str | None = None,
feedback: str | None = None, language_code: str | None = None) -> dict[str, Any]:
return self._session_mgr.update_preview_row(job_id, row_id, action, translation, feedback, language_code)
def accept_preview_session(self, job_id: str) -> dict[str, Any]:
return self._session_mgr.accept_preview_session(job_id)
def get_preview_session(self, job_id: str) -> dict[str, Any]:
return self._session_mgr.get_preview_session(job_id)
# #endregion TranslationPreview
# Re-export for backward compatibility
from .preview_token_estimator import TokenEstimator # noqa: E402, F401
# #endregion TranslationPreview