- 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
276 lines
14 KiB
Python
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
|