# #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": "", "translation": ""}}]}}\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": "", "translation": ""}}]}}\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