Files
ss-tools/backend/src/plugins/translate/orchestrator.py
busya 9f995f22ae refactor: migrate translate engine to GRACE-Poly v2.6 semantic protocol
- Convert all 84 contracts from legacy [DEF:] to #region/#endregion syntax
- Fix complexity tiers: 14 modules re-tiered (6 C4 route modules, 7 C4→C5 plugin services)
- Remove forbidden tags: @RATIONALE/@REJECTED stripped from C1–C4 contracts
- Add required tags: @PRE/@POST/@SIDE_EFFECT on C4, @RELATION on C3, @DATA_CONTRACT/@INVARIANT on C5
- Add belief runtime markers (reason/reflect/explore) to 7 service.py functions
- Fix @LAYER: route files → UI, plugins → Domain, superset_executor → Infra
- Fix pre-existing test mock_service fixture in test_orchestrator.py
- 196/196 translation tests pass, zero regressions
2026-05-12 14:32:28 +03:00

872 lines
35 KiB
Python

# #region TranslationOrchestrator [C:5] [TYPE Module] [SEMANTICS translate,orchestrator,lifecycle,run]
# @BRIEF Run lifecycle coordination: validate preconditions, dispatch executor, generate SQL, submit to Superset, record events.
# @LAYER Domain
# @RELATION DEPENDS_ON -> [TranslationRun]
# @RELATION DEPENDS_ON -> [TranslationJob]
# @RELATION DEPENDS_ON -> [TranslationExecutor]
# @RELATION DEPENDS_ON -> [SQLGenerator]
# @RELATION DEPENDS_ON -> [SupersetSqlLabExecutor]
# @RELATION DEPENDS_ON -> [TranslationEventLog]
# @RELATION DEPENDS_ON -> [TranslationPreviewSession]
# @PRE Valid job and accepted preview (for manual runs). Superset and LLM are reachable.
# @POST Translation run is executed, SQL generated and submitted, events recorded.
# @SIDE_EFFECT Creates TranslationRun; creates batches and records; generates SQL; submits to Superset; records events.
# @DATA_CONTRACT Input[db, config_manager, current_user] -> Output[TranslationRun]
# @INVARIANT State transitions: PENDING -> RUNNING -> COMPLETED|FAILED|CANCELLED. Exactly one terminal event per run.
# @RATIONALE C5 orchestrator because preview, execution, event logging, and retry share state within single transaction boundary.
# @REJECTED Distributed actor model (Celery) — eventual-consistency challenges at current scale.
# @REJECTED stdout-only logging — lacks structured payload integrity; cannot enforce terminal-event invariant.
import json
import time
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Callable, Tuple
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,
)
from ...schemas.translate import TranslationRunResponse
from .executor import TranslationExecutor
from .sql_generator import SQLGenerator
from .superset_executor import SupersetSqlLabExecutor
from .events import TranslationEventLog
from ..translate.service import TranslateJobService
# #region TranslationOrchestrator [C:5] [TYPE Class] [SEMANTICS translate,orchestrator]
# @BRIEF Coordinates full translation run lifecycle: validation, execution, SQL generation, Superset submission, event logging.
# @PRE DB session and config manager are available.
# @POST Runs are created, executed, and finalized with event records.
# @SIDE_EFFECT Creates/modifies TranslationRun, TranslationBatch, TranslationRecord, TranslationEvent rows.
# @INVARIANT State transitions: PENDING -> RUNNING -> COMPLETED|FAILED|CANCELLED.
# @DATA_CONTRACT Input[db, config_manager, current_user] -> Output[TranslationRun]
class TranslationOrchestrator:
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
self.event_log = TranslationEventLog(db)
self._job: Optional[TranslationJob] = None
# #region start_run [C:5] [TYPE Function] [SEMANTICS translate,run,create]
# @BRIEF Start a new translation run for a job with config snapshot and hash computation.
# @PRE job_id exists. For manual runs, there must be an accepted preview session.
# @POST TranslationRun is created in PENDING status with hash fields and config snapshot. Events are recorded.
# @SIDE_EFFECT DB writes; event logging.
def start_run(
self,
job_id: str,
is_scheduled: bool = False,
trigger_type: Optional[str] = None,
) -> TranslationRun:
with belief_scope("TranslationOrchestrator.start_run"):
# Load and validate job
job = self.db.query(TranslationJob).filter(TranslationJob.id == job_id).first()
if not job:
raise ValueError(f"Translation job '{job_id}' not found")
self._job = job
logger.reason("Starting translation run", {
"job_id": job_id,
"is_scheduled": is_scheduled,
})
# Validate preconditions
self._validate_preconditions(job, is_scheduled=is_scheduled)
# Compute hashes
config_hash = self._compute_config_hash(job)
dict_snapshot_hash = self._compute_dict_snapshot_hash(job_id)
# Build config snapshot
config_snapshot = {
"source_dialect": job.source_dialect,
"target_dialect": job.target_dialect,
"database_dialect": job.database_dialect,
"source_datasource_id": job.source_datasource_id,
"source_table": job.source_table,
"target_schema": job.target_schema,
"target_table": job.target_table,
"source_key_cols": job.source_key_cols,
"target_key_cols": job.target_key_cols,
"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,
"dictionary_ids": self._compute_dict_snapshot_hash(job_id),
}
# Compute key_hash from source_key_cols
import hashlib
key_hash_input = json.dumps({
"source_key_cols": job.source_key_cols,
"source_datasource_id": job.source_datasource_id,
"source_table": job.source_table,
}, sort_keys=True)
key_hash = hashlib.sha256(key_hash_input.encode()).hexdigest()[:16]
# Create run record with all hash/snapshot fields
run = TranslationRun(
id=str(uuid.uuid4()),
job_id=job_id,
status="PENDING",
trigger_type=trigger_type or ("scheduled" if is_scheduled else "manual"),
config_snapshot=config_snapshot,
key_hash=key_hash,
config_hash=config_hash,
dict_snapshot_hash=dict_snapshot_hash,
created_by=self.current_user,
created_at=datetime.now(timezone.utc),
)
self.db.add(run)
self.db.flush()
# Record event
self.event_log.log_event(
job_id=job_id,
run_id=run.id,
event_type="RUN_STARTED",
payload={
"is_scheduled": is_scheduled,
"trigger_type": run.trigger_type,
"config_hash": config_hash,
"dict_snapshot_hash": dict_snapshot_hash,
"key_hash": key_hash,
},
created_by=self.current_user,
)
self.db.commit()
self.db.refresh(run)
logger.reflect("Run created", {
"run_id": run.id,
"job_id": job_id,
"status": run.status,
"trigger_type": run.trigger_type,
})
return run
# #endregion start_run
# #region execute_run [C:5] [TYPE Function] [SEMANTICS translate,run,execute]
# @BRIEF Execute a translation run: dispatch executor, generate SQL, submit to Superset.
# @PRE run is in PENDING status.
# @POST Run is executed, SQL generated, Superset submission attempted.
# @SIDE_EFFECT LLM calls, DB writes, Superset API calls.
def execute_run(
self,
run: TranslationRun,
on_batch_progress: Optional[Callable[[str, int, int, int, int], None]] = None,
skip_insert: bool = False,
full_translation: bool = False,
) -> TranslationRun:
with belief_scope("TranslationOrchestrator.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")
self._job = job
if run.status != "PENDING":
raise ValueError(
f"Cannot execute run in status '{run.status}'. "
f"Run must be in PENDING status."
)
logger.reason("Executing run", {
"run_id": run.id,
"job_id": job.id,
"skip_insert": skip_insert,
})
# Record translation phase start
self.event_log.log_event(
job_id=job.id,
run_id=run.id,
event_type="TRANSLATION_PHASE_STARTED",
payload={},
created_by=self.current_user,
)
# Dispatch executor
executor = TranslationExecutor(
self.db, self.config_manager, self.current_user,
on_batch_progress=on_batch_progress,
)
try:
run = executor.execute_run(run, llm_progress_callback=None, full_translation=full_translation)
except Exception as e:
logger.explore("Translation execution failed", {
"run_id": run.id,
"error": str(e),
})
run.status = "FAILED"
run.error_message = f"Translation execution failed: {e}"
run.completed_at = datetime.now(timezone.utc)
self.db.flush()
self.event_log.log_event(
job_id=job.id,
run_id=run.id,
event_type="RUN_FAILED",
payload={"error": str(e), "phase": "translation"},
created_by=self.current_user,
)
self.db.commit()
return run
# Record translation phase complete
self.event_log.log_event(
job_id=job.id,
run_id=run.id,
event_type="TRANSLATION_PHASE_COMPLETED",
payload={
"total": run.total_records,
"successful": run.successful_records,
"failed": run.failed_records,
"skipped": run.skipped_records,
},
created_by=self.current_user,
)
# Skip insert phase if requested (e.g., for preview-only execution)
if skip_insert:
run.status = "COMPLETED"
run.completed_at = datetime.now(timezone.utc)
self.db.flush()
self.event_log.log_event(
job_id=job.id,
run_id=run.id,
event_type="RUN_COMPLETED",
payload={"skip_insert": True},
created_by=self.current_user,
)
self.db.commit()
return run
# Generate SQL and submit to Superset
insert_result = self._generate_and_insert_sql(job, run)
run.insert_status = insert_result.get("status")
run.superset_execution_id = str(insert_result.get("query_id") or "")
run.superset_execution_log = insert_result
run.status = "COMPLETED" if insert_result.get("status") == "success" else "COMPLETED"
if insert_result.get("error_message"):
run.error_message = insert_result["error_message"]
run.completed_at = datetime.now(timezone.utc)
self.db.flush()
# Record terminal event
terminal_event = "RUN_COMPLETED"
self.event_log.log_event(
job_id=job.id,
run_id=run.id,
event_type=terminal_event,
payload={
"insert_status": insert_result.get("status"),
"query_id": insert_result.get("query_id"),
"rows_affected": insert_result.get("rows_affected"),
"total_records": run.total_records,
"successful": run.successful_records,
"failed": run.failed_records,
"skipped": run.skipped_records,
},
created_by=self.current_user,
)
self.db.commit()
self.db.refresh(run)
logger.reflect("Run execution complete", {
"run_id": run.id,
"status": run.status,
"insert_status": run.insert_status,
})
return run
# #endregion execute_run
# #region _generate_and_insert_sql [C:5] [TYPE Function] [SEMANTICS translate,sql,insert]
# @BRIEF Generate INSERT SQL from successful records and submit to Superset SQL Lab.
# @PRE job has target table configured. run has successful records.
# @POST SQL is generated and submitted. Returns execution result.
# @SIDE_EFFECT Superset API call; event logging.
def _generate_and_insert_sql(
self,
job: TranslationJob,
run: TranslationRun,
) -> Dict[str, Any]:
with belief_scope("TranslationOrchestrator._generate_and_insert_sql"):
# Fetch successful records
records = (
self.db.query(TranslationRecord)
.filter(
TranslationRecord.run_id == run.id,
TranslationRecord.status == "SUCCESS",
TranslationRecord.target_sql.isnot(None),
)
.all()
)
if not records:
logger.reason("No successful records to insert", {"run_id": run.id})
return {"status": "skipped", "reason": "no_records", "query_id": None}
logger.reason(f"Generating SQL for {len(records)} records", {
"run_id": run.id,
"dialect": job.database_dialect or job.target_dialect,
})
# Build rows for SQL generation
# Only include the translation column — context columns are for LLM only
columns = []
if job.translation_column:
columns.append(job.translation_column)
# Include key columns if present (for UPSERT matching)
if job.target_key_cols:
for k in job.target_key_cols:
if k not in columns:
columns.append(k)
rows_for_sql = []
for rec in records:
row_data = {}
if job.translation_column:
row_data[job.translation_column] = rec.target_sql or ""
if job.target_key_cols:
source_data = rec.source_data or {}
for k in job.target_key_cols:
# Use source_data value if available, fall back to empty string
row_data[k] = source_data.get(k, "")
rows_for_sql.append(row_data)
if not columns:
# Use target_sql as the sole column
columns = [job.translation_column or "translated_text"]
rows_for_sql = [{columns[0]: rec.target_sql or ""} for rec in records]
# Generate SQL
try:
sql, row_count = SQLGenerator.generate(
dialect=job.database_dialect or job.target_dialect or "postgresql",
target_schema=job.target_schema,
target_table=job.target_table or "translated_data",
columns=columns,
rows=rows_for_sql,
key_columns=job.target_key_cols,
upsert_strategy=job.upsert_strategy or "MERGE",
)
except ValueError as e:
logger.explore("SQL generation failed", {"error": str(e)})
return {"status": "failed", "error_message": str(e), "query_id": None}
# Log insert phase start
logger.reason("Insert SQL generated", {
"run_id": run.id,
"sql_preview": sql[:500],
"sql_length": len(sql),
"row_count": row_count,
"dialect": job.database_dialect or job.target_dialect or "postgresql",
"columns": columns,
"has_key_cols": bool(job.target_key_cols),
})
self.event_log.log_event(
job_id=job.id,
run_id=run.id,
event_type="INSERT_PHASE_STARTED",
payload={"sql_length": len(sql), "row_count": row_count},
created_by=self.current_user,
)
# Submit to Superset
try:
env_id = job.environment_id or job.source_dialect or ""
# Resolve database_id: explicit target_database_id from job (int or UUID), or resolve from environment
target_db_id = None
if job.target_database_id:
try:
target_db_id = int(job.target_database_id)
except (ValueError, TypeError):
# Could be a UUID — pass as-is, executor will resolve it
target_db_id = job.target_database_id
logger.reason("target_database_id is not an integer, will resolve as UUID", {
"target_database_id": job.target_database_id,
})
executor = SupersetSqlLabExecutor(self.config_manager, env_id, database_id=target_db_id)
result = executor.execute_and_poll(
sql=sql,
max_polls=30,
poll_interval_seconds=2.0,
)
except Exception as e:
logger.explore("Superset SQL submission failed", {
"run_id": run.id,
"error": str(e),
})
result = {"status": "failed", "error_message": str(e), "query_id": None}
# Log insert phase complete
self.event_log.log_event(
job_id=job.id,
run_id=run.id,
event_type="INSERT_PHASE_COMPLETED",
payload=result,
created_by=self.current_user,
)
return result
# #endregion _generate_and_insert_sql
# #region _validate_preconditions [C:5] [TYPE Function] [SEMANTICS translate,validation]
# @BRIEF Validate preconditions before starting a translation run.
# @PRE None.
# @POST Raises ValueError if preconditions are not met.
# @SIDE_EFFECT None.
def _validate_preconditions(
self,
job: TranslationJob,
is_scheduled: bool = False,
) -> None:
with belief_scope("TranslationOrchestrator._validate_preconditions"):
# Job must be in valid status
if job.status in ("DRAFT",):
raise ValueError(
f"Cannot run job '{job.id}' in status '{job.status}'. "
f"Job must be READY, ACTIVE, or COMPLETED."
)
# Must have target table configured
if not job.target_table:
raise ValueError(
f"Job '{job.id}' has no target table configured. "
"Configure a target table before running."
)
# Must have LLM provider configured
if not job.provider_id:
raise ValueError(
f"Job '{job.id}' has no LLM provider configured. "
"Select an LLM provider before running."
)
# Must have a translation column
if not job.translation_column:
raise ValueError(
f"Job '{job.id}' has no translation column configured. "
"Select a translation column before running."
)
# For manual runs, must have accepted preview
if not is_scheduled:
accepted_session = (
self.db.query(TranslationPreviewSession)
.filter(
TranslationPreviewSession.job_id == job.id,
TranslationPreviewSession.status == "APPLIED",
)
.order_by(TranslationPreviewSession.created_at.desc())
.first()
)
if not accepted_session:
raise ValueError(
f"Job '{job.id}' has no accepted preview session. "
"Run and accept a preview before executing a manual translation run."
)
logger.reason("Preconditions validated", {
"job_id": job.id,
"is_scheduled": is_scheduled,
})
# #endregion _validate_preconditions
# #region retry_failed_batches [C:5] [TYPE Function] [SEMANTICS translate,retry,batch]
# @BRIEF Retry failed batches in a run by re-processing failed records.
# @PRE run exists and has failed batches.
# @POST Failed batches are re-processed; run status and stats updated.
# @SIDE_EFFECT LLM calls; DB writes; event logging.
def retry_failed_batches(
self,
run_id: str,
) -> TranslationRun:
with belief_scope("TranslationOrchestrator.retry_failed_batches"):
run = self.db.query(TranslationRun).filter(TranslationRun.id == run_id).first()
if not run:
raise ValueError(f"Run '{run_id}' not found")
job = self.db.query(TranslationJob).filter(TranslationJob.id == run.job_id).first()
if not job:
raise ValueError(f"Job '{run.job_id}' not found")
self._job = job
# Find failed batches
failed_batches = (
self.db.query(TranslationBatch)
.filter(
TranslationBatch.run_id == run_id,
TranslationBatch.status.in_(["FAILED", "COMPLETED_WITH_ERRORS"]),
)
.all()
)
if not failed_batches:
raise ValueError(f"No failed batches found for run '{run_id}'")
logger.reason("Retrying failed batches", {
"run_id": run_id,
"batch_count": len(failed_batches),
})
self.event_log.log_event(
job_id=job.id,
run_id=run.id,
event_type="RUN_RETRYING",
payload={"batch_count": len(failed_batches)},
created_by=self.current_user,
)
# Re-process each failed batch
executor = TranslationExecutor(self.db, self.config_manager, self.current_user)
run.status = "RUNNING"
self.db.flush()
for batch in failed_batches:
# Fetch failed records for this batch
failed_records = (
self.db.query(TranslationRecord)
.filter(
TranslationRecord.batch_id == batch.id,
TranslationRecord.status == "FAILED",
)
.all()
)
if not failed_records:
continue
# Build rows from failed records
retry_rows = []
for rec in failed_records:
retry_rows.append({
"row_index": rec.source_object_id or "0",
"source_text": rec.source_sql or "",
"approved_translation": None,
"source_object_name": rec.source_object_name or "",
})
# Process retry batch
result = executor._process_batch(
job=job,
run_id=run_id,
batch_index=batch.batch_index,
batch_rows=retry_rows,
)
# Update run stats
run.successful_records = (run.successful_records or 0) + result["successful"]
run.failed_records = (run.failed_records or 0) + result["failed"]
run.skipped_records = (run.skipped_records or 0) + result["skipped"]
self.db.flush()
# Update run status
if run.failed_records == 0:
run.status = "COMPLETED"
elif run.successful_records == 0:
run.status = "FAILED"
else:
run.status = "COMPLETED"
run.completed_at = datetime.now(timezone.utc)
self.db.flush()
self.event_log.log_event(
job_id=job.id,
run_id=run.id,
event_type="RUN_COMPLETED",
payload={"retry": True},
created_by=self.current_user,
)
self.db.commit()
logger.reflect("Retry complete", {
"run_id": run_id,
"status": run.status,
})
return run
# #endregion retry_failed_batches
# #region retry_insert [C:5] [TYPE Function] [SEMANTICS translate,retry,insert]
# @BRIEF Retry the SQL insert phase for a completed run.
# @PRE run exists and has successful records.
# @POST SQL is regenerated and re-submitted to Superset.
# @SIDE_EFFECT Superset API call; event logging.
def retry_insert(
self,
run_id: str,
) -> TranslationRun:
with belief_scope("TranslationOrchestrator.retry_insert"):
run = self.db.query(TranslationRun).filter(TranslationRun.id == run_id).first()
if not run:
raise ValueError(f"Run '{run_id}' not found")
job = self.db.query(TranslationJob).filter(TranslationJob.id == run.job_id).first()
if not job:
raise ValueError(f"Job '{run.job_id}' not found")
self._job = job
logger.reason("Retrying insert phase", {
"run_id": run_id,
})
self.event_log.log_event(
job_id=job.id,
run_id=run.id,
event_type="RUN_RETRY_INSERT",
payload={},
created_by=self.current_user,
)
# Regenerate SQL and submit
insert_result = self._generate_and_insert_sql(job, run)
# Update run
run.insert_status = insert_result.get("status")
run.superset_execution_id = str(insert_result.get("query_id") or "")
if insert_result.get("error_message"):
run.error_message = insert_result["error_message"]
self.db.flush()
self.db.commit()
self.db.refresh(run)
logger.reflect("Insert retry complete", {
"run_id": run_id,
"insert_status": run.insert_status,
})
return run
# #endregion retry_insert
# #region cancel_run [C:5] [TYPE Function] [SEMANTICS translate,run,cancel]
# @BRIEF Cancel a running translation run.
# @PRE run is in PENDING or RUNNING status.
# @POST Run status is set to CANCELLED; event recorded.
# @SIDE_EFFECT DB write; records event.
def cancel_run(self, run_id: str) -> TranslationRun:
with belief_scope("TranslationOrchestrator.cancel_run"):
run = self.db.query(TranslationRun).filter(TranslationRun.id == run_id).first()
if not run:
raise ValueError(f"Run '{run_id}' not found")
if run.status not in ("PENDING", "RUNNING"):
raise ValueError(
f"Cannot cancel run in status '{run.status}'. "
f"Only PENDING or RUNNING runs can be cancelled."
)
run.status = "CANCELLED"
run.completed_at = datetime.now(timezone.utc)
self.db.flush()
# Record terminal event directly — we are the source of the terminal event
self.event_log._create_event_raw(
job_id=run.job_id,
run_id=run.id,
event_type="RUN_CANCELLED",
payload={},
created_by=self.current_user,
)
self.db.commit()
self.db.refresh(run)
logger.reflect("Run cancelled", {
"run_id": run_id,
})
return run
# #endregion cancel_run
# #region get_run_status [C:5] [TYPE Function] [SEMANTICS translate,run,status]
# @BRIEF Get run status with statistics and event invariant checks.
# @PRE run_id exists.
# @POST Returns dict with run details.
def get_run_status(self, run_id: str) -> Dict[str, Any]:
with belief_scope("TranslationOrchestrator.get_run_status"):
run = self.db.query(TranslationRun).filter(TranslationRun.id == run_id).first()
if not run:
raise ValueError(f"Run '{run_id}' not found")
# Count batches
batch_count = (
self.db.query(TranslationBatch)
.filter(TranslationBatch.run_id == run_id)
.count()
)
# Get event invariants (lightweight — only fetches event_type column)
invariants = self.event_log.get_run_event_invariants_lightweight(run_id)
return {
"id": run.id,
"job_id": run.job_id,
"status": run.status,
"started_at": run.started_at.isoformat() if run.started_at else None,
"completed_at": run.completed_at.isoformat() if run.completed_at else None,
"error_message": run.error_message,
"total_records": run.total_records or 0,
"successful_records": run.successful_records or 0,
"failed_records": run.failed_records or 0,
"skipped_records": run.skipped_records or 0,
"insert_status": run.insert_status,
"superset_execution_id": run.superset_execution_id,
"batch_count": batch_count,
"event_invariants": invariants,
"created_by": run.created_by,
"created_at": run.created_at.isoformat() if run.created_at else None,
}
# #endregion get_run_status
# #region get_run_records [C:5] [TYPE Function] [SEMANTICS translate,run,records]
# @BRIEF Get paginated records for a run with optional status filter.
# @PRE run_id exists.
# @POST Returns dict with records and pagination info.
def get_run_records(
self,
run_id: str,
page: int = 1,
page_size: int = 50,
status_filter: Optional[str] = None,
) -> Dict[str, Any]:
with belief_scope("TranslationOrchestrator.get_run_records"):
query = self.db.query(TranslationRecord).filter(
TranslationRecord.run_id == run_id
)
if status_filter:
query = query.filter(TranslationRecord.status == status_filter)
total = query.count()
offset = (page - 1) * page_size
records = (
query.order_by(TranslationRecord.created_at.desc())
.offset(offset)
.limit(page_size)
.all()
)
return {
"items": [
{
"id": r.id,
"batch_id": r.batch_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,
"error_message": r.error_message,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
for r in records
],
"total": total,
"page": page,
"page_size": page_size,
"status_filter": status_filter,
}
# #endregion get_run_records
# #region get_run_history [C:5] [TYPE Function] [SEMANTICS translate,run,history]
# @BRIEF Get paginated run history for a job.
# @PRE job_id exists.
# @POST Returns tuple of (total_count, list_of_runs).
def get_run_history(
self,
job_id: str,
page: int = 1,
page_size: int = 20,
) -> Tuple[int, List[Dict[str, Any]]]:
with belief_scope("TranslationOrchestrator.get_run_history"):
query = self.db.query(TranslationRun).filter(
TranslationRun.job_id == job_id
)
total = query.count()
runs = (
query.order_by(TranslationRun.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
return total, [
{
"id": r.id,
"job_id": r.job_id,
"status": r.status,
"started_at": r.started_at.isoformat() if r.started_at else None,
"completed_at": r.completed_at.isoformat() if r.completed_at else None,
"error_message": r.error_message,
"total_records": r.total_records or 0,
"successful_records": r.successful_records or 0,
"failed_records": r.failed_records or 0,
"skipped_records": r.skipped_records or 0,
"insert_status": r.insert_status,
"superset_execution_id": r.superset_execution_id,
"created_by": r.created_by,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
for r in runs
]
# #endregion get_run_history
# #region _compute_config_hash [C:2] [TYPE Function] [SEMANTICS translate,hash]
# @BRIEF Compute a SHA-256 hash of the job's current configuration for snapshot comparison.
@staticmethod
def _compute_config_hash(job: TranslationJob) -> str:
import hashlib
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]
# #endregion _compute_config_hash
# #region _compute_dict_snapshot_hash [C:2] [TYPE Function] [SEMANTICS translate,hash]
# @BRIEF Compute a SHA-256 hash of dictionary state for snapshot comparison.
def _compute_dict_snapshot_hash(self, job_id: str) -> str:
import hashlib
from ...models.translate import TranslationJobDictionary
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]
# #endregion _compute_dict_snapshot_hash
# #endregion TranslationOrchestrator
# #endregion TranslationOrchestrator