M1: ReDoS guard — 500 char max + try/except on re.compile M2: Performance note — O(n×m) regex documented for future optimization M3: Verified test key names correct (metrics.py transforms cumulative_* → short) L1: Removed @staticmethod no-op from module-level function L2: Added aria-label to emoji indicators in CorrectionCell
1045 lines
43 KiB
Python
1045 lines
43 KiB
Python
# #region TranslateJobService [C:4] [TYPE Module] [SEMANTICS sqlalchemy, translate, job, datasource, dialect]
|
|
# @BRIEF Service layer for translation job CRUD with datasource column validation and database dialect detection.
|
|
# @LAYER: Domain
|
|
# @RELATION DEPENDS_ON -> [TranslationJob]
|
|
# @RELATION DEPENDS_ON -> [SupersetClient]
|
|
# @RELATION DEPENDS_ON -> [ConfigManager]
|
|
# @PRE: Database session and config manager are available.
|
|
# @POST: Translation jobs are created/updated/deleted with column validation and dialect caching.
|
|
# @SIDE_EFFECT: Queries Superset for column metadata and database dialect at save time.
|
|
# @RATIONALE: Snapshot isolation — in-progress runs use config snapshot; config edits affect future runs only.
|
|
# @REJECTED: Invalidating in-progress runs on config edit would break scheduled run continuity.
|
|
|
|
import re
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ...core.config_manager import ConfigManager
|
|
from ...core.logger import logger
|
|
from ...core.superset_client import SupersetClient
|
|
from ...models.translate import TranslationJob, TranslationJobDictionary
|
|
from ...schemas.translate import (
|
|
DatasourceColumnResponse,
|
|
DatasourceColumnsResponse,
|
|
TranslateJobCreate,
|
|
TranslateJobResponse,
|
|
TranslateJobUpdate,
|
|
)
|
|
from .dictionary import _validate_bcp47
|
|
|
|
# Supported database dialects for translation
|
|
SUPPORTED_DIALECTS = {
|
|
"postgresql", "mysql", "clickhouse", "sqlite", "mssql",
|
|
"oracle", "snowflake", "bigquery", "redshift", "presto",
|
|
"trino", "druid", "hive", "spark", "databricks",
|
|
}
|
|
|
|
|
|
# #region get_dialect_from_database [TYPE Function]
|
|
# @BRIEF Extract normalized dialect string from a Superset database record.
|
|
# @PRE: database_record is a dict from Superset API.
|
|
# @POST: Returns normalized dialect string or raises ValueError if unsupported.
|
|
def get_dialect_from_database(database_record: dict[str, Any]) -> str:
|
|
"""Extract and validate dialect from Superset database record."""
|
|
backend = (
|
|
database_record.get("backend")
|
|
or database_record.get("engine")
|
|
or ""
|
|
).lower().strip()
|
|
|
|
if not backend:
|
|
raise ValueError("Could not determine database dialect from connection")
|
|
|
|
# Map Superset backend names to normalized dialect
|
|
dialect_map = {
|
|
"postgresql": "postgresql",
|
|
"greenplum": "postgresql",
|
|
"mysql": "mysql",
|
|
"clickhouse": "clickhouse",
|
|
"clickhousedb": "clickhouse",
|
|
"sqlite": "sqlite",
|
|
"mssql": "mssql",
|
|
"oracle": "oracle",
|
|
"snowflake": "snowflake",
|
|
"bigquery": "bigquery",
|
|
"redshift": "redshift",
|
|
"presto": "presto",
|
|
"trino": "trino",
|
|
"druid": "druid",
|
|
"hive": "hive",
|
|
"spark": "spark",
|
|
"databricks": "databricks",
|
|
}
|
|
|
|
normalized = dialect_map.get(backend, backend)
|
|
if normalized not in SUPPORTED_DIALECTS:
|
|
raise ValueError(
|
|
f"Unsupported database dialect: '{backend}'. "
|
|
f"Supported dialects: {', '.join(sorted(SUPPORTED_DIALECTS))}"
|
|
)
|
|
return normalized
|
|
# #endregion get_dialect_from_database
|
|
|
|
|
|
# #region fetch_datasource_metadata [TYPE Function]
|
|
# @BRIEF Fetch datasource columns and database dialect from Superset.
|
|
# @PRE: dataset_id is a valid Superset dataset ID and environment has valid credentials.
|
|
# @POST: Returns (columns_list, dialect_string) or raises on failure.
|
|
def fetch_datasource_metadata(
|
|
dataset_id: int,
|
|
env_id: str,
|
|
config_manager: ConfigManager,
|
|
) -> tuple[list[dict[str, Any]], str]:
|
|
"""Fetch column metadata and database dialect for a datasource from Superset."""
|
|
# Find environment config
|
|
environments = config_manager.get_environments()
|
|
env_config = next((e for e in environments if e.id == env_id), None)
|
|
if not env_config:
|
|
raise ValueError(f"Superset environment '{env_id}' not found in configuration")
|
|
|
|
# Create Superset client and fetch dataset detail
|
|
client = SupersetClient(env_config)
|
|
dataset_detail = client.get_dataset_detail(dataset_id)
|
|
|
|
# Extract columns
|
|
raw_columns = dataset_detail.get("columns", [])
|
|
columns = []
|
|
for col in raw_columns:
|
|
col_name = col.get("name") or col.get("column_name")
|
|
if not col_name:
|
|
continue
|
|
columns.append({
|
|
"name": str(col_name),
|
|
"type": col.get("type"),
|
|
"is_physical": col.get("is_physical", True),
|
|
"is_dttm": col.get("is_dttm", False),
|
|
"description": col.get("description", ""),
|
|
})
|
|
|
|
# Extract database dialect from datasource
|
|
database_info = dataset_detail.get("database", {})
|
|
if isinstance(database_info, dict):
|
|
dialect = get_dialect_from_database(database_info)
|
|
else:
|
|
# Fallback: try to fetch database directly
|
|
try:
|
|
db_id = dataset_detail.get("database_id")
|
|
if db_id:
|
|
db_record = client.get_database(int(db_id))
|
|
db_result = db_record.get("result", db_record) if isinstance(db_record, dict) else db_record
|
|
dialect = get_dialect_from_database(db_result)
|
|
else:
|
|
raise ValueError("No database information available for this datasource")
|
|
except Exception as e:
|
|
logger.warning(f"[translate_service] Could not fetch database dialect: {e}")
|
|
raise ValueError(f"Could not determine database dialect: {e}")
|
|
|
|
return columns, dialect
|
|
# #endregion fetch_datasource_metadata
|
|
|
|
|
|
# #region detect_virtual_columns [TYPE Function]
|
|
# @BRIEF Identify virtual (calculated) columns from column metadata.
|
|
# @PRE: columns is a list of dicts with 'is_physical' key.
|
|
# @POST: Returns list of virtual column names.
|
|
def detect_virtual_columns(columns: list[dict[str, Any]]) -> list[str]:
|
|
"""Return names of columns that are virtual (not physical)."""
|
|
return [col["name"] for col in columns if not col.get("is_physical", True)]
|
|
# #endregion detect_virtual_columns
|
|
|
|
|
|
# #region TranslateJobService [TYPE Class]
|
|
# @BRIEF Service for translation job CRUD with validation and Superset integration.
|
|
class TranslateJobService:
|
|
|
|
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
|
|
|
|
# region list_jobs [TYPE Function]
|
|
# @PURPOSE: List translation jobs with optional status filter and pagination.
|
|
# @POST: Returns tuple of (total_count, list_of_jobs).
|
|
def list_jobs(
|
|
self,
|
|
page: int = 1,
|
|
page_size: int = 20,
|
|
status_filter: str | None = None,
|
|
) -> tuple[int, list[TranslationJob]]:
|
|
query = self.db.query(TranslationJob)
|
|
|
|
if status_filter:
|
|
query = query.filter(TranslationJob.status == status_filter)
|
|
|
|
total = query.count()
|
|
offset = (page - 1) * page_size
|
|
jobs = query.order_by(TranslationJob.created_at.desc()).offset(offset).limit(page_size).all()
|
|
|
|
return total, jobs
|
|
# endregion list_jobs
|
|
|
|
# region get_job [TYPE Function]
|
|
# @PURPOSE: Get a single translation job by ID.
|
|
# @POST: Returns TranslationJob or raises ValueError.
|
|
def get_job(self, job_id: str) -> TranslationJob:
|
|
job = self.db.query(TranslationJob).filter(TranslationJob.id == job_id).first()
|
|
if not job:
|
|
raise ValueError(f"Translation job '{job_id}' not found")
|
|
return job
|
|
# endregion get_job
|
|
|
|
# region create_job [TYPE Function]
|
|
# @PURPOSE: Create a new translation job with column validation.
|
|
# @PRE: payload contains valid job configuration.
|
|
# @POST: Returns the created TranslationJob with database_dialect cached.
|
|
# @SIDE_EFFECT: Validates columns via SupersetClient if source_datasource_id is provided.
|
|
# @SIDE_EFFECT: Caches database_dialect from Superset connection.
|
|
def create_job(self, payload: TranslateJobCreate) -> TranslationJob:
|
|
logger.info(f"[TranslateJobService] Creating job '{payload.name}'")
|
|
|
|
# Validate: must have a translation column if datasource is configured
|
|
if payload.source_datasource_id and not payload.translation_column:
|
|
raise ValueError("A translation column is required when a datasource is selected")
|
|
|
|
# Validate upsert strategy
|
|
valid_strategies = {"MERGE", "INSERT", "UPDATE"}
|
|
if payload.upsert_strategy not in valid_strategies:
|
|
raise ValueError(
|
|
f"Invalid upsert_strategy '{payload.upsert_strategy}'. "
|
|
f"Must be one of: {', '.join(sorted(valid_strategies))}"
|
|
)
|
|
|
|
# Detect database dialect and validate columns if datasource is specified
|
|
dialect = payload.database_dialect
|
|
if payload.source_datasource_id and (payload.environment_id or payload.source_dialect):
|
|
# If no explicit dialect, try to detect it
|
|
if not dialect:
|
|
try:
|
|
env_id = payload.environment_id or payload.source_dialect
|
|
_, detected_dialect = fetch_datasource_metadata(
|
|
int(payload.source_datasource_id),
|
|
env_id,
|
|
self.config_manager,
|
|
)
|
|
dialect = detected_dialect
|
|
except Exception as e:
|
|
logger.warning(f"[TranslateJobService] Dialect detection failed: {e}")
|
|
dialect = payload.source_dialect
|
|
|
|
# Resolve target_languages: accept new format (list) or legacy single language
|
|
target_languages = payload.target_languages
|
|
if not target_languages and payload.target_language:
|
|
target_languages = [payload.target_language]
|
|
if target_languages:
|
|
if not isinstance(target_languages, list):
|
|
target_languages = [str(target_languages)]
|
|
for lang in target_languages:
|
|
_validate_bcp47(lang, "target_languages")
|
|
|
|
# Build job instance
|
|
job = TranslationJob(
|
|
id=str(uuid.uuid4()),
|
|
name=payload.name,
|
|
description=payload.description,
|
|
source_dialect=payload.source_dialect,
|
|
target_dialect=payload.target_dialect,
|
|
database_dialect=dialect,
|
|
source_datasource_id=payload.source_datasource_id,
|
|
source_table=payload.source_table,
|
|
target_schema=payload.target_schema,
|
|
target_table=payload.target_table,
|
|
source_key_cols=payload.source_key_cols or [],
|
|
target_key_cols=payload.target_key_cols or [],
|
|
translation_column=payload.translation_column,
|
|
target_column=payload.target_column,
|
|
context_columns=payload.context_columns or [],
|
|
target_language=payload.target_language,
|
|
target_languages=target_languages,
|
|
provider_id=payload.provider_id,
|
|
batch_size=payload.batch_size,
|
|
upsert_strategy=payload.upsert_strategy,
|
|
environment_id=payload.environment_id,
|
|
target_database_id=payload.target_database_id,
|
|
status="DRAFT",
|
|
created_by=self.current_user,
|
|
)
|
|
self.db.add(job)
|
|
self.db.flush()
|
|
|
|
# Attach dictionaries
|
|
if payload.dictionary_ids:
|
|
for dict_id in payload.dictionary_ids:
|
|
assoc = TranslationJobDictionary(
|
|
id=str(uuid.uuid4()),
|
|
job_id=job.id,
|
|
dictionary_id=dict_id,
|
|
)
|
|
self.db.add(assoc)
|
|
|
|
self.db.commit()
|
|
self.db.refresh(job)
|
|
logger.info(f"[TranslateJobService] Created job '{job.id}'")
|
|
return job
|
|
# endregion create_job
|
|
|
|
# region update_job [TYPE Function]
|
|
# @PURPOSE: Update an existing translation job.
|
|
# @PRE: payload contains fields to update.
|
|
# @POST: Returns the updated TranslationJob.
|
|
# @SIDE_EFFECT: Re-detects database_dialect if source_datasource_id changed.
|
|
def update_job(self, job_id: str, payload: TranslateJobUpdate) -> TranslationJob:
|
|
logger.info(f"[TranslateJobService] Updating job '{job_id}'")
|
|
job = self.get_job(job_id)
|
|
|
|
update_data = payload.model_dump(exclude_unset=True)
|
|
dict_ids = update_data.pop("dictionary_ids", None)
|
|
|
|
# Backward compat: if only target_language is set (old API), wrap into target_languages
|
|
if "target_language" in update_data and "target_languages" not in update_data:
|
|
target_languages = [update_data["target_language"]] if update_data["target_language"] else []
|
|
update_data["target_languages"] = target_languages
|
|
elif "target_languages" in update_data and "target_language" not in update_data:
|
|
# Keep deprecated field in sync
|
|
if update_data["target_languages"]:
|
|
update_data["target_language"] = update_data["target_languages"][0]
|
|
|
|
# Validate BCP-47 for target_languages
|
|
if update_data.get("target_languages"):
|
|
if not isinstance(update_data["target_languages"], list):
|
|
update_data["target_languages"] = [str(update_data["target_languages"])]
|
|
for lang in update_data["target_languages"]:
|
|
_validate_bcp47(lang, "target_languages")
|
|
|
|
for field, value in update_data.items():
|
|
if hasattr(job, field):
|
|
setattr(job, field, value)
|
|
|
|
# Re-detect dialect if datasource changed
|
|
if payload.source_datasource_id and not payload.database_dialect:
|
|
try:
|
|
env_id = (payload.environment_id or payload.source_dialect or job.environment_id or job.source_dialect)
|
|
_, detected_dialect = fetch_datasource_metadata(
|
|
int(payload.source_datasource_id),
|
|
env_id,
|
|
self.config_manager,
|
|
)
|
|
job.database_dialect = detected_dialect
|
|
except Exception as e:
|
|
logger.warning(f"[TranslateJobService] Dialect re-detection failed: {e}")
|
|
|
|
job.updated_at = datetime.now(UTC)
|
|
|
|
# Update dictionary associations if provided
|
|
if dict_ids is not None:
|
|
self.db.query(TranslationJobDictionary).filter(
|
|
TranslationJobDictionary.job_id == job_id
|
|
).delete()
|
|
for dict_id in dict_ids:
|
|
assoc = TranslationJobDictionary(
|
|
id=str(uuid.uuid4()),
|
|
job_id=job_id,
|
|
dictionary_id=dict_id,
|
|
)
|
|
self.db.add(assoc)
|
|
|
|
self.db.commit()
|
|
self.db.refresh(job)
|
|
logger.info(f"[TranslateJobService] Updated job '{job_id}'")
|
|
return job
|
|
# endregion update_job
|
|
|
|
# region delete_job [TYPE Function]
|
|
# @PURPOSE: Delete a translation job and its associations.
|
|
# @PRE: job_id must exist.
|
|
# @POST: Job and all related records are deleted.
|
|
def delete_job(self, job_id: str) -> None:
|
|
logger.info(f"[TranslateJobService] Deleting job '{job_id}'")
|
|
job = self.get_job(job_id)
|
|
|
|
# Delete dictionary associations
|
|
self.db.query(TranslationJobDictionary).filter(
|
|
TranslationJobDictionary.job_id == job_id
|
|
).delete()
|
|
|
|
self.db.delete(job)
|
|
self.db.commit()
|
|
logger.info(f"[TranslateJobService] Deleted job '{job_id}'")
|
|
# endregion delete_job
|
|
|
|
# region duplicate_job [TYPE Function]
|
|
# @PURPOSE: Duplicate a translation job with a new name.
|
|
# @PRE: job_id must exist.
|
|
# @POST: Returns the duplicated TranslationJob with status DRAFT.
|
|
def duplicate_job(self, job_id: str, new_name: str | None = None) -> TranslationJob:
|
|
logger.info(f"[TranslateJobService] Duplicating job '{job_id}'")
|
|
source = self.get_job(job_id)
|
|
|
|
# Copy all fields except id, created_at, updated_at, status
|
|
new_job = TranslationJob(
|
|
id=str(uuid.uuid4()),
|
|
name=new_name or f"{source.name} (Copy)",
|
|
description=source.description,
|
|
source_dialect=source.source_dialect,
|
|
target_dialect=source.target_dialect,
|
|
database_dialect=source.database_dialect,
|
|
source_datasource_id=source.source_datasource_id,
|
|
source_table=source.source_table,
|
|
target_schema=source.target_schema,
|
|
target_table=source.target_table,
|
|
source_key_cols=source.source_key_cols,
|
|
target_key_cols=source.target_key_cols,
|
|
translation_column=source.translation_column,
|
|
target_column=source.target_column,
|
|
context_columns=source.context_columns,
|
|
target_language=source.target_language,
|
|
target_languages=source.target_languages,
|
|
provider_id=source.provider_id,
|
|
batch_size=source.batch_size,
|
|
upsert_strategy=source.upsert_strategy,
|
|
status="DRAFT",
|
|
created_by=self.current_user,
|
|
)
|
|
self.db.add(new_job)
|
|
self.db.flush()
|
|
|
|
# Copy dictionary associations
|
|
old_dicts = self.db.query(TranslationJobDictionary).filter(
|
|
TranslationJobDictionary.job_id == job_id
|
|
).all()
|
|
for assoc in old_dicts:
|
|
new_assoc = TranslationJobDictionary(
|
|
id=str(uuid.uuid4()),
|
|
job_id=new_job.id,
|
|
dictionary_id=assoc.dictionary_id,
|
|
)
|
|
self.db.add(new_assoc)
|
|
|
|
self.db.commit()
|
|
self.db.refresh(new_job)
|
|
logger.info(f"[TranslateJobService] Duplicated job '{job_id}' -> '{new_job.id}'")
|
|
return new_job
|
|
# endregion duplicate_job
|
|
|
|
# region get_job_dictionary_ids [TYPE Function]
|
|
# @PURPOSE: Get dictionary IDs attached to a job.
|
|
# @POST: Returns list of dictionary IDs.
|
|
def get_job_dictionary_ids(self, job_id: str) -> list[str]:
|
|
assocs = self.db.query(TranslationJobDictionary).filter(
|
|
TranslationJobDictionary.job_id == job_id
|
|
).all()
|
|
return [a.dictionary_id for a in assocs]
|
|
# endregion get_job_dictionary_ids
|
|
|
|
# region fetch_available_datasources [TYPE Function]
|
|
# @PURPOSE: List available Superset datasets for translation job creation.
|
|
def fetch_available_datasources(self, env_id: str, search: str | None = None) -> list:
|
|
"""List Superset datasets available for translation."""
|
|
from ...core.superset_client import SupersetClient
|
|
env = self.config_manager.get_environment(env_id)
|
|
if not env:
|
|
raise ValueError(f"Environment '{env_id}' not found")
|
|
client = SupersetClient(env)
|
|
_, datasets = client.get_datasets()
|
|
result = []
|
|
for ds in datasets:
|
|
name = ds.get("table_name", "")
|
|
if search and search.lower() not in name.lower():
|
|
continue
|
|
db_info = ds.get("database", {})
|
|
backend = db_info.get("backend", "")
|
|
dialect = _extract_dialect(backend)
|
|
result.append({
|
|
"id": ds.get("id"),
|
|
"table_name": name,
|
|
"schema": ds.get("schema"),
|
|
"database_name": db_info.get("database_name", "Unknown"),
|
|
"database_dialect": dialect,
|
|
"description": ds.get("description", ""),
|
|
})
|
|
return result
|
|
# endregion fetch_available_datasources
|
|
# #endregion TranslateJobService
|
|
|
|
|
|
# #region _extract_dialect [TYPE Function]
|
|
# @BRIEF Extract database dialect from Superset backend URI.
|
|
def _extract_dialect(backend: str) -> str:
|
|
"""Extract dialect name from a Superset database backend URI (e.g. 'postgresql+psycopg2://...')."""
|
|
if not backend:
|
|
return "unknown"
|
|
try:
|
|
scheme = backend.split("://")[0]
|
|
dialect = scheme.split("+")[0]
|
|
return dialect.lower()
|
|
except Exception:
|
|
return "unknown"
|
|
|
|
|
|
# #region job_to_response [TYPE Function]
|
|
# @BRIEF Convert a TranslationJob ORM model to a TranslateJobResponse schema with dictionary_ids.
|
|
def job_to_response(job: TranslationJob, dict_ids: list[str] | None = None) -> TranslateJobResponse:
|
|
return TranslateJobResponse(
|
|
id=job.id,
|
|
name=job.name,
|
|
description=job.description,
|
|
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 or [],
|
|
target_key_cols=job.target_key_cols or [],
|
|
translation_column=job.translation_column,
|
|
target_column=job.target_column,
|
|
context_columns=job.context_columns or [],
|
|
target_language=job.target_language,
|
|
target_languages=job.target_languages,
|
|
provider_id=job.provider_id,
|
|
batch_size=job.batch_size or 50,
|
|
upsert_strategy=job.upsert_strategy or "MERGE",
|
|
status=job.status,
|
|
created_by=job.created_by,
|
|
created_at=job.created_at,
|
|
updated_at=job.updated_at,
|
|
dictionary_ids=dict_ids or [],
|
|
environment_id=job.environment_id,
|
|
target_database_id=job.target_database_id,
|
|
)
|
|
# #endregion job_to_response
|
|
|
|
|
|
# #region DatasourceColumnsService [TYPE Function]
|
|
# @BRIEF Fetch datasource column metadata from Superset and return structured response.
|
|
# @PRE: datasource_id is a valid Superset dataset ID.
|
|
# @POST: Returns DatasourceColumnsResponse with column metadata and database dialect.
|
|
# @SIDE_EFFECT: Queries Superset API for dataset detail and database info.
|
|
def get_datasource_columns(
|
|
datasource_id: int,
|
|
env_id: str,
|
|
config_manager: ConfigManager,
|
|
) -> DatasourceColumnsResponse:
|
|
"""Fetch and return column metadata for a given Superset datasource."""
|
|
logger.info(f"[get_datasource_columns] Fetching columns for datasource {datasource_id}")
|
|
|
|
# Find environment config
|
|
environments = config_manager.get_environments()
|
|
env_config = next((e for e in environments if e.id == env_id), None)
|
|
if not env_config:
|
|
raise ValueError(f"Superset environment '{env_id}' not found")
|
|
|
|
# Create Superset client
|
|
client = SupersetClient(env_config)
|
|
dataset_detail = client.get_dataset_detail(datasource_id)
|
|
|
|
# Extract database dialect
|
|
database_info = dataset_detail.get("database", {})
|
|
dialect = None
|
|
if isinstance(database_info, dict):
|
|
try:
|
|
dialect = get_dialect_from_database(database_info)
|
|
except (ValueError, KeyError):
|
|
dialect = None
|
|
if dialect is None:
|
|
database_id = dataset_detail.get("database_id")
|
|
if database_id:
|
|
db_record = client.get_database(int(database_id))
|
|
db_result = db_record.get("result", db_record) if isinstance(db_record, dict) else db_record
|
|
dialect = get_dialect_from_database(db_result)
|
|
else:
|
|
raise ValueError("Could not determine database dialect for this datasource")
|
|
|
|
# Extract columns
|
|
raw_columns = dataset_detail.get("columns", [])
|
|
columns = []
|
|
for col in raw_columns:
|
|
col_name = col.get("name") or col.get("column_name")
|
|
if not col_name:
|
|
continue
|
|
columns.append(DatasourceColumnResponse(
|
|
name=str(col_name),
|
|
type=col.get("type"),
|
|
is_physical=col.get("is_physical", True),
|
|
is_dttm=col.get("is_dttm", False),
|
|
description=col.get("description", ""),
|
|
))
|
|
|
|
return DatasourceColumnsResponse(
|
|
datasource_id=datasource_id,
|
|
datasource_name=dataset_detail.get("table_name"),
|
|
schema_name=dataset_detail.get("schema"),
|
|
database_dialect=dialect,
|
|
columns=columns,
|
|
)
|
|
# #endregion DatasourceColumnsService
|
|
|
|
|
|
# #region InlineCorrectionService [C:3] [TYPE Class] [SEMANTICS translate, correction, inline, dictionary]
|
|
# @BRIEF Service for inline editing translated values and submitting corrections to dictionaries.
|
|
class InlineCorrectionService:
|
|
"""Handle inline correction of translated values with optional dictionary submission."""
|
|
|
|
@staticmethod
|
|
def apply_inline_edit(
|
|
db: Session,
|
|
run_id: str,
|
|
record_id: str,
|
|
language_code: str,
|
|
final_value: str,
|
|
submit_to_dictionary: bool = False,
|
|
dictionary_id: str | None = None,
|
|
current_user: str | None = None,
|
|
context_data_override: dict | None = None,
|
|
usage_notes: str | None = None,
|
|
keep_context: bool = True,
|
|
) -> dict:
|
|
"""Apply an inline edit to a TranslationLanguage entry.
|
|
|
|
Updates final_value and user_edit fields. Optionally submits to dictionary.
|
|
Returns the updated TranslationLanguage data as a dict.
|
|
|
|
Args:
|
|
context_data_override: If provided, overrides auto-captured context data.
|
|
usage_notes: Usage notes for the dictionary entry.
|
|
keep_context: If False, clear context on the dictionary entry.
|
|
"""
|
|
from ...models.translate import TranslationLanguage, TranslationRecord
|
|
|
|
# Find the language entry
|
|
lang_entry = (
|
|
db.query(TranslationLanguage)
|
|
.filter(
|
|
TranslationLanguage.record_id == record_id,
|
|
TranslationLanguage.language_code == language_code,
|
|
)
|
|
.first()
|
|
)
|
|
if not lang_entry:
|
|
raise ValueError(f"Language entry '{language_code}' not found for record '{record_id}'")
|
|
|
|
# Verify the record belongs to the run
|
|
record = (
|
|
db.query(TranslationRecord)
|
|
.filter(
|
|
TranslationRecord.id == record_id,
|
|
TranslationRecord.run_id == run_id,
|
|
)
|
|
.first()
|
|
)
|
|
if not record:
|
|
raise ValueError(
|
|
f"Translation record '{record_id}' not found in run '{run_id}'"
|
|
)
|
|
|
|
# Apply the edit
|
|
lang_entry.final_value = final_value
|
|
lang_entry.user_edit = final_value
|
|
if lang_entry.status in ("pending", "translated"):
|
|
lang_entry.status = "edited"
|
|
db.flush()
|
|
|
|
# Optionally submit to dictionary
|
|
dict_result = None
|
|
if submit_to_dictionary and dictionary_id:
|
|
try:
|
|
dict_result = InlineCorrectionService.submit_correction_to_dict(
|
|
db=db,
|
|
record_id=record_id,
|
|
language_code=language_code,
|
|
dictionary_id=dictionary_id,
|
|
corrected_value=final_value,
|
|
current_user=current_user,
|
|
context_data_override=context_data_override,
|
|
usage_notes=usage_notes,
|
|
keep_context=keep_context,
|
|
)
|
|
except Exception as e:
|
|
# Dictionary submission failure should not block the edit
|
|
dict_result = {"error": str(e), "action": "failed"}
|
|
|
|
db.commit()
|
|
db.refresh(lang_entry)
|
|
|
|
from ...schemas.translate import TranslationLanguageResponse
|
|
response = TranslationLanguageResponse.model_validate(lang_entry)
|
|
result = response.model_dump()
|
|
if dict_result:
|
|
result["dictionary_result"] = dict_result
|
|
return result
|
|
|
|
@staticmethod
|
|
def submit_correction_to_dict(
|
|
db: Session,
|
|
record_id: str,
|
|
language_code: str,
|
|
dictionary_id: str,
|
|
corrected_value: str,
|
|
current_user: str | None = None,
|
|
context_data_override: dict | None = None,
|
|
usage_notes: str | None = None,
|
|
keep_context: bool = True,
|
|
) -> dict:
|
|
"""Submit a correction from an inline edit to the dictionary.
|
|
|
|
Fetches the TranslationLanguage entry, auto-captures source text and context,
|
|
and creates/updates a DictionaryEntry with correct language pair.
|
|
Returns conflict info if an entry already exists.
|
|
|
|
Args:
|
|
context_data_override: If provided, overrides auto-captured context_data.
|
|
usage_notes: Optional usage notes to store on the dictionary entry.
|
|
keep_context: If False, sets has_context=False and clears context_data.
|
|
"""
|
|
from ...core.logger import belief_scope, logger
|
|
from ...models.translate import DictionaryEntry, TranslationLanguage, TranslationRecord
|
|
from ._utils import _normalize_term
|
|
|
|
with belief_scope("InlineCorrectionService.submit_correction_to_dict"):
|
|
# Find the language entry
|
|
lang_entry = (
|
|
db.query(TranslationLanguage)
|
|
.filter(
|
|
TranslationLanguage.record_id == record_id,
|
|
TranslationLanguage.language_code == language_code,
|
|
)
|
|
.first()
|
|
)
|
|
if not lang_entry:
|
|
raise ValueError(f"Language entry '{language_code}' not found for record '{record_id}'")
|
|
|
|
# Find the record for source text and context
|
|
record = (
|
|
db.query(TranslationRecord)
|
|
.filter(TranslationRecord.id == record_id)
|
|
.first()
|
|
)
|
|
if not record:
|
|
raise ValueError(f"Translation record '{record_id}' not found")
|
|
|
|
# Get source text from the record
|
|
source_term = record.source_sql or record.source_object_name or ""
|
|
if not source_term:
|
|
raise ValueError("No source text available for this record")
|
|
|
|
# Determine language pair
|
|
source_language = lang_entry.source_language_detected or "und"
|
|
target_language = language_code
|
|
|
|
# Prepare context data — auto-capture from source row
|
|
context_data = {}
|
|
if record.source_data:
|
|
context_data["source_data"] = record.source_data
|
|
if record.source_object_type:
|
|
context_data["source_object_type"] = record.source_object_type
|
|
if record.source_object_name:
|
|
context_data["source_object_name"] = record.source_object_name
|
|
if record.source_object_id:
|
|
context_data["source_object_id"] = record.source_object_id
|
|
|
|
# Apply context_data_override if provided (user edited)
|
|
if context_data_override is not None:
|
|
context_data = context_data_override
|
|
context_source = "manual"
|
|
else:
|
|
context_source = "auto"
|
|
|
|
# Handle keep_context=False (user explicitly removed context)
|
|
if not keep_context:
|
|
context_data = None
|
|
context_source = "manual"
|
|
|
|
# Normalize source term
|
|
normalized = _normalize_term(source_term)
|
|
|
|
# Check for existing entry with same (dictionary_id, source_term_norm, source_language, target_language)
|
|
existing = (
|
|
db.query(DictionaryEntry)
|
|
.filter(
|
|
DictionaryEntry.dictionary_id == dictionary_id,
|
|
DictionaryEntry.source_term_normalized == normalized,
|
|
DictionaryEntry.source_language == source_language,
|
|
DictionaryEntry.target_language == target_language,
|
|
)
|
|
.first()
|
|
)
|
|
|
|
result = {
|
|
"action": "created",
|
|
"entry_id": None,
|
|
"conflict": None,
|
|
"message": None,
|
|
}
|
|
|
|
if existing:
|
|
# Conflict: return conflict info
|
|
result["action"] = "conflict_detected"
|
|
result["conflict"] = {
|
|
"source_term": source_term,
|
|
"existing_target_term": existing.target_term,
|
|
"submitted_target_term": corrected_value,
|
|
"action": "keep_existing",
|
|
}
|
|
result["message"] = (
|
|
f"Existing entry found: '{existing.target_term}' "
|
|
f"for source '{source_term}' ({source_language} → {target_language})"
|
|
)
|
|
logger.reason("Correction conflict detected", result)
|
|
return result
|
|
|
|
# Create new entry
|
|
entry = DictionaryEntry(
|
|
dictionary_id=dictionary_id,
|
|
source_term=source_term.strip(),
|
|
source_term_normalized=normalized,
|
|
target_term=corrected_value.strip(),
|
|
source_language=source_language,
|
|
target_language=target_language,
|
|
context_data=context_data if context_data else None,
|
|
context_notes=None,
|
|
has_context=bool(context_data) and keep_context,
|
|
context_source=context_source,
|
|
usage_notes=usage_notes,
|
|
origin_source_language=source_language,
|
|
origin_run_id=record.run_id,
|
|
origin_row_key=record.id,
|
|
origin_user_id=current_user,
|
|
)
|
|
db.add(entry)
|
|
db.flush()
|
|
result["entry_id"] = entry.id
|
|
result["message"] = (
|
|
f"Entry created for '{source_term}' ({source_language}) → "
|
|
f"'{corrected_value}' ({target_language})"
|
|
)
|
|
logger.reflect("Dictionary entry created from correction", result)
|
|
return result
|
|
# #endregion InlineCorrectionService
|
|
|
|
|
|
# #region BulkFindReplaceService [C:3] [TYPE Class] [SEMANTICS translate, bulk, find, replace, regex]
|
|
# @BRIEF Service for bulk find-and-replace operations on translated values.
|
|
class BulkFindReplaceService:
|
|
"""Handle bulk find-and-replace on TranslationLanguage entries."""
|
|
|
|
MAX_PATTERN_LENGTH = 500
|
|
|
|
@staticmethod
|
|
def _compile_pattern(pattern: str, is_regex: bool) -> re.Pattern:
|
|
"""Compile a search pattern (regex or plain text)."""
|
|
if len(pattern) > BulkFindReplaceService.MAX_PATTERN_LENGTH:
|
|
raise ValueError(
|
|
f"Pattern too long (max {BulkFindReplaceService.MAX_PATTERN_LENGTH} characters)"
|
|
)
|
|
try:
|
|
return re.compile(pattern) if is_regex else re.compile(re.escape(pattern))
|
|
except re.error as e:
|
|
raise ValueError(f"Invalid regex pattern: {e}")
|
|
|
|
@staticmethod
|
|
def _find_matching_entries(
|
|
db: Session,
|
|
run_id: str,
|
|
pattern: str,
|
|
is_regex: bool,
|
|
target_language: str,
|
|
) -> list[Any]:
|
|
"""Find all TranslationLanguage entries matching the pattern."""
|
|
from ...models.translate import TranslationLanguage, TranslationRecord
|
|
|
|
# We scan entries for the target language in this run
|
|
entries = (
|
|
db.query(TranslationLanguage)
|
|
.join(
|
|
TranslationRecord,
|
|
TranslationRecord.id == TranslationLanguage.record_id,
|
|
)
|
|
.filter(
|
|
TranslationRecord.run_id == run_id,
|
|
TranslationLanguage.language_code == target_language,
|
|
)
|
|
.all()
|
|
)
|
|
return entries
|
|
|
|
@staticmethod
|
|
def preview(
|
|
db: Session,
|
|
run_id: str,
|
|
pattern: str,
|
|
is_regex: bool,
|
|
replacement_text: str,
|
|
target_language: str,
|
|
) -> list[dict]:
|
|
"""Scan TranslationLanguage entries and return matching items without applying changes."""
|
|
from ...models.translate import TranslationRecord
|
|
|
|
compiled = BulkFindReplaceService._compile_pattern(pattern, is_regex)
|
|
entries = BulkFindReplaceService._find_matching_entries(
|
|
db, run_id, pattern, is_regex, target_language
|
|
)
|
|
|
|
preview_items = []
|
|
for entry in entries:
|
|
current_value = entry.final_value or entry.translated_value or ""
|
|
if compiled.search(current_value):
|
|
# Get source term for context
|
|
record = (
|
|
db.query(TranslationRecord)
|
|
.filter(TranslationRecord.id == entry.record_id)
|
|
.first()
|
|
)
|
|
source_term = record.source_sql if record else ""
|
|
|
|
new_value = compiled.sub(
|
|
replacement_text,
|
|
current_value,
|
|
)
|
|
if new_value != current_value:
|
|
preview_items.append({
|
|
"record_id": entry.record_id,
|
|
"language_code": entry.language_code,
|
|
"source_term": source_term or "",
|
|
"current_value": current_value,
|
|
"new_value": new_value,
|
|
"source_language_detected": entry.source_language_detected,
|
|
})
|
|
|
|
return preview_items
|
|
|
|
@staticmethod
|
|
def _get_replacement(replacement_text: str, is_regex: bool) -> str:
|
|
"""Return replacement pattern (for regex, use backrefs; for plain, use literal)."""
|
|
return replacement_text
|
|
|
|
@staticmethod
|
|
def apply(
|
|
db: Session,
|
|
run_id: str,
|
|
pattern: str,
|
|
is_regex: bool,
|
|
replacement_text: str,
|
|
target_language: str,
|
|
submit_to_dictionary: bool = False,
|
|
dictionary_id: str | None = None,
|
|
usage_notes: str | None = None,
|
|
current_user: str | None = None,
|
|
submit_to_dictionary_with_context: bool = False,
|
|
) -> dict:
|
|
"""Apply find-and-replace to matching TranslationLanguage entries.
|
|
|
|
Returns dict with rows_affected, corrections_submitted, and preview list.
|
|
|
|
Args:
|
|
submit_to_dictionary_with_context: If True, auto-capture source row context
|
|
when submitting to dictionary.
|
|
"""
|
|
from ...core.logger import belief_scope, logger
|
|
from ...models.translate import DictionaryEntry, TranslationRecord
|
|
|
|
with belief_scope("BulkFindReplaceService.apply"):
|
|
compiled = BulkFindReplaceService._compile_pattern(pattern, is_regex)
|
|
entries = BulkFindReplaceService._find_matching_entries(
|
|
db, run_id, pattern, is_regex, target_language
|
|
)
|
|
|
|
preview_items = []
|
|
rows_affected = 0
|
|
corrections_submitted = 0
|
|
|
|
# Track unique (source_term, replacement) -> first record for context capture
|
|
seen_term_replacements: dict[str, str] = {}
|
|
|
|
for entry in entries:
|
|
current_value = entry.final_value or entry.translated_value or ""
|
|
if compiled.search(current_value):
|
|
new_value = compiled.sub(replacement_text, current_value)
|
|
if new_value != current_value:
|
|
# Apply the replacement
|
|
entry.final_value = new_value
|
|
entry.user_edit = new_value
|
|
if entry.status in ("pending", "translated"):
|
|
entry.status = "edited"
|
|
rows_affected += 1
|
|
|
|
# Get source term for preview
|
|
record = (
|
|
db.query(TranslationRecord)
|
|
.filter(TranslationRecord.id == entry.record_id)
|
|
.first()
|
|
)
|
|
source_term = record.source_sql if record else ""
|
|
|
|
# Optionally submit to dictionary
|
|
if submit_to_dictionary and dictionary_id:
|
|
try:
|
|
dict_result = InlineCorrectionService.submit_correction_to_dict(
|
|
db=db,
|
|
record_id=entry.record_id,
|
|
language_code=entry.language_code,
|
|
dictionary_id=dictionary_id,
|
|
corrected_value=new_value,
|
|
current_user=current_user,
|
|
)
|
|
if dict_result.get("action") in ("created", "updated"):
|
|
corrections_submitted += 1
|
|
|
|
# Track context for unique (source_term, replacement) pairs
|
|
if submit_to_dictionary_with_context and record:
|
|
term_key = f"{source_term}::{new_value}"
|
|
if term_key not in seen_term_replacements:
|
|
seen_term_replacements[term_key] = entry.record_id
|
|
# Auto-capture context from this row
|
|
context_data = {}
|
|
if record.source_data:
|
|
context_data["source_data"] = record.source_data
|
|
if record.source_object_type:
|
|
context_data["source_object_type"] = record.source_object_type
|
|
if record.source_object_name:
|
|
context_data["source_object_name"] = record.source_object_name
|
|
if record.source_object_id:
|
|
context_data["source_object_id"] = record.source_object_id
|
|
# Update the just-created entry's context
|
|
if dict_result.get("action") == "created" and dict_result.get("entry_id"):
|
|
d_entry = (
|
|
db.query(DictionaryEntry)
|
|
.filter(DictionaryEntry.id == dict_result["entry_id"])
|
|
.first()
|
|
)
|
|
if d_entry:
|
|
d_entry.context_data = context_data if context_data else None
|
|
d_entry.has_context = bool(context_data)
|
|
d_entry.context_source = "bulk"
|
|
except Exception as e:
|
|
logger.explore(
|
|
"Bulk replace: dictionary submission failed",
|
|
extra={"record_id": entry.record_id, "error": str(e)},
|
|
)
|
|
|
|
preview_items.append({
|
|
"record_id": entry.record_id,
|
|
"language_code": entry.language_code,
|
|
"source_term": source_term or "",
|
|
"current_value": current_value,
|
|
"new_value": new_value,
|
|
"source_language_detected": entry.source_language_detected,
|
|
})
|
|
|
|
db.commit()
|
|
|
|
result = {
|
|
"rows_affected": rows_affected,
|
|
"corrections_submitted": corrections_submitted,
|
|
"preview": preview_items,
|
|
}
|
|
logger.reason("Bulk replace completed", result)
|
|
return result
|
|
# #endregion BulkFindReplaceService
|
|
|
|
|
|
# #endregion TranslateJobService
|
|
# #endregion TranslateJobService
|