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
This commit is contained in:
2026-05-12 14:32:28 +03:00
parent fefdee98d0
commit 9f995f22ae
29 changed files with 1594 additions and 1292 deletions

View File

@@ -1,17 +1,16 @@
# [DEF:TranslateRoutes:Module]
# @COMPLEXITY: 4
# @SEMANTICS: api, routes, translate
# @PURPOSE: API routes for LLM-based SQL/dashboard translation management including terminology dictionary CRUD and import.
# @LAYER: UI (API)
# @RELATION: DEPENDS_ON -> [TranslateJobResponse]
# @RELATION: DEPENDS_ON -> [DictionaryManager:Class]
# @RELATION: DEPENDS_ON -> [get_current_user]
# @RELATION: DEPENDS_ON -> [get_db]
# @RELATION: DEPENDS_ON -> [has_permission]
# @RELATION: DEPENDS_ON -> [ConfigManager]
# @RELATION: DEPENDS_ON -> [SupersetClient]
# @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.
# #region TranslateRoutes [C:4] [TYPE Module] [SEMANTICS api,routes,translate]
# @BRIEF API routes for LLM-based SQL/dashboard translation management including terminology dictionary CRUD and import.
# @LAYER UI
# @RELATION DEPENDS_ON -> [TranslateJobResponse]
# @RELATION DEPENDS_ON -> [DictionaryManager]
# @RELATION DEPENDS_ON -> [get_current_user]
# @RELATION DEPENDS_ON -> [get_db]
# @RELATION DEPENDS_ON -> [has_permission]
# @RELATION DEPENDS_ON -> [ConfigManager]
# @RELATION DEPENDS_ON -> [SupersetClient]
# @PRE All sub-modules importable. Router instance available from ._router.
# @POST Package imports all route modules and re-exports router. Sub-routes registered on shared router.
# @SIDE_EFFECT Registers route handlers on shared APIRouter at import time.
"""
Translate routes package.
@@ -34,4 +33,4 @@ __all__ = [
"router",
]
# [/DEF:TranslateRoutes:Module]
# #endregion TranslateRoutes

View File

@@ -1,8 +1,12 @@
# [DEF:TranslateCorrectionRoutesModule:Module]
# @COMPLEXITY: 2
# @PURPOSE: Term correction submission endpoints.
# @LAYER: API
# @SEMANTICS: api, routes, translate, corrections
# #region TranslateCorrectionRoutesModule [C:4] [TYPE Module] [SEMANTICS api,routes,translate,corrections]
# @BRIEF Term correction submission endpoints for applying user feedback to dictionary entries.
# @LAYER UI
# @RELATION DEPENDS_ON -> [DictionaryManager]
# @RELATION DEPENDS_ON -> [get_current_user]
# @RELATION DEPENDS_ON -> [get_db]
# @PRE DB session initialized. User authenticated with translate.dictionary.edit permissions.
# @POST Term corrections submitted and applied to dictionary with conflict detection.
# @SIDE_EFFECT Creates/updates DictionaryEntry records in DB based on correction submissions.
from fastapi import APIRouter, Depends, HTTPException, status, Query
from typing import Any, Dict, List, Optional
@@ -26,10 +30,12 @@ from ._router import router
# Corrections
# ============================================================
# [DEF:submit_correction:Function]
# @PURPOSE: Submit a term correction from a run result review.
# @PRE: User has translate.dictionary.edit permission.
# @POST: Correction applied with conflict detection.
# #region submit_correction [C:4] [TYPE Function] [SEMANTICS api,translate,corrections]
# @BRIEF Submit a single term correction from a run result review.
# @PRE User has translate.dictionary.edit permission. Dictionary ID is provided.
# @POST Correction applied with conflict detection. Result returned.
# @SIDE_EFFECT Creates/updates DictionaryEntry record in DB; logs correction origin.
# @RELATION DEPENDS_ON -> [DictionaryManager]
@router.post("/corrections")
async def submit_correction(
payload: TermCorrectionSubmit,
@@ -55,13 +61,15 @@ async def submit_correction(
return result
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# [/DEF:submit_correction:Function]
# #endregion submit_correction
# [DEF:submit_bulk_corrections:Function]
# @PURPOSE: Submit multiple term corrections atomically.
# @PRE: User has translate.dictionary.edit permission.
# @POST: All corrections applied or none with conflict list.
# #region submit_bulk_corrections [C:4] [TYPE Function] [SEMANTICS api,translate,corrections]
# @BRIEF Submit multiple term corrections atomically with full conflict reporting.
# @PRE User has translate.dictionary.edit permission. Dictionary ID is provided.
# @POST All corrections applied or none with conflict list returned.
# @SIDE_EFFECT Creates/updates multiple DictionaryEntry records in DB atomically.
# @RELATION DEPENDS_ON -> [DictionaryManager]
@router.post("/corrections/bulk")
async def submit_bulk_corrections(
payload: TermCorrectionBulkSubmit,
@@ -92,6 +100,6 @@ async def submit_bulk_corrections(
return result
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# [/DEF:submit_bulk_corrections:Function]
# #endregion submit_bulk_corrections
# [/DEF:TranslateCorrectionRoutesModule:Module]
# #endregion TranslateCorrectionRoutesModule

View File

@@ -1,8 +1,12 @@
# [DEF:TranslateDictionaryRoutesModule:Module]
# @COMPLEXITY: 3
# @PURPOSE: Terminology Dictionary CRUD, entries management, and import routes.
# @LAYER: API
# @SEMANTICS: api, routes, translate, dictionaries
# #region TranslateDictionaryRoutesModule [C:4] [TYPE Module] [SEMANTICS api,routes,translate,dictionaries]
# @BRIEF Terminology Dictionary CRUD, entries management, and import routes.
# @LAYER UI
# @RELATION DEPENDS_ON -> [DictionaryManager]
# @RELATION DEPENDS_ON -> [get_current_user]
# @RELATION DEPENDS_ON -> [get_db]
# @PRE DB session initialized. User authenticated with translate.dictionary permissions.
# @POST Dictionaries and entries CRUD completed. Import executed with conflict resolution.
# @SIDE_EFFECT Reads/writes TerminologyDictionary and DictionaryEntry records to DB.
from fastapi import APIRouter, Depends, HTTPException, status, Query
from typing import Any, Dict, List, Optional
@@ -29,10 +33,12 @@ from ._helpers import _dict_to_response, _get_dictionary_entry_counts
# Terminology Dictionaries
# ============================================================
# [DEF:list_dictionaries:Function]
# @PURPOSE: List all terminology dictionaries.
# @PRE: User has translate.dictionary.view permission.
# @POST: Returns list of dictionaries with total count.
# #region list_dictionaries [C:4] [TYPE Function] [SEMANTICS api,translate,dictionaries]
# @BRIEF List all terminology dictionaries with pagination and entry counts.
# @PRE User has translate.dictionary.view permission.
# @POST Returns paginated list of dictionaries with total count.
# @SIDE_EFFECT Reads TerminologyDictionary and DictionaryEntry records from DB.
# @RELATION DEPENDS_ON -> [DictionaryManager]
@router.get("/dictionaries")
async def list_dictionaries(
page: int = Query(1, ge=1),
@@ -50,13 +56,15 @@ async def list_dictionaries(
items = [_dict_to_response(d, counts.get(d.id, 0)) for d in dicts]
logger.reflect("Dictionaries listed", {"total": total, "returned": len(items)})
return {"items": items, "total": total, "page": page, "page_size": page_size}
# [/DEF:list_dictionaries:Function]
# #endregion list_dictionaries
# [DEF:get_dictionary:Function]
# @PURPOSE: Get a single terminology dictionary by ID.
# @PRE: User has translate.dictionary.view permission.
# @POST: Returns the dictionary with entry_count.
# #region get_dictionary [C:4] [TYPE Function] [SEMANTICS api,translate,dictionaries]
# @BRIEF Get a single terminology dictionary by ID with entry count.
# @PRE User has translate.dictionary.view permission.
# @POST Returns the dictionary with entry_count or 404.
# @SIDE_EFFECT Reads TerminologyDictionary and DictionaryEntry records from DB.
# @RELATION DEPENDS_ON -> [DictionaryManager]
@router.get("/dictionaries/{dictionary_id}")
async def get_dictionary(
dictionary_id: str,
@@ -69,19 +77,21 @@ async def get_dictionary(
logger.reason(f"Dict: {dictionary_id}, User: {current_user.username}")
try:
d = DictionaryManager.get_dictionary(db, dictionary_id)
from ...models.translate import DictionaryEntry
from ....models.translate import DictionaryEntry
entry_count = db.query(DictionaryEntry.id).filter(DictionaryEntry.dictionary_id == dictionary_id).count()
logger.reflect("Dictionary found", {"id": dictionary_id})
return _dict_to_response(d, entry_count)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:get_dictionary:Function]
# #endregion get_dictionary
# [DEF:create_dictionary:Function]
# @PURPOSE: Create a new terminology dictionary.
# @PRE: User has translate.dictionary.create permission.
# @POST: Returns the created dictionary.
# #region create_dictionary [C:4] [TYPE Function] [SEMANTICS api,translate,dictionaries]
# @BRIEF Create a new terminology dictionary.
# @PRE User has translate.dictionary.create permission. Payload validated.
# @POST Dictionary created and returned with zero entries.
# @SIDE_EFFECT Creates TerminologyDictionary record in DB.
# @RELATION DEPENDS_ON -> [DictionaryManager]
@router.post("/dictionaries", status_code=status.HTTP_201_CREATED)
async def create_dictionary(
payload: DictionaryCreate,
@@ -103,13 +113,15 @@ async def create_dictionary(
)
logger.reflect("Dictionary created", {"id": d.id})
return _dict_to_response(d, 0)
# [/DEF:create_dictionary:Function]
# #endregion create_dictionary
# [DEF:update_dictionary:Function]
# @PURPOSE: Update an existing terminology dictionary.
# @PRE: User has translate.dictionary.edit permission.
# @POST: Returns the updated dictionary.
# #region update_dictionary [C:4] [TYPE Function] [SEMANTICS api,translate,dictionaries]
# @BRIEF Update an existing terminology dictionary.
# @PRE User has translate.dictionary.edit permission. Dictionary exists.
# @POST Dictionary updated and returned with current entry count.
# @SIDE_EFFECT Updates TerminologyDictionary record in DB.
# @RELATION DEPENDS_ON -> [DictionaryManager]
@router.put("/dictionaries/{dictionary_id}")
async def update_dictionary(
dictionary_id: str,
@@ -131,19 +143,21 @@ async def update_dictionary(
target_dialect=payload.target_dialect,
is_active=payload.is_active,
)
from ...models.translate import DictionaryEntry
from ....models.translate import DictionaryEntry
entry_count = db.query(DictionaryEntry.id).filter(DictionaryEntry.dictionary_id == dictionary_id).count()
logger.reflect("Dictionary updated", {"id": dictionary_id})
return _dict_to_response(d, entry_count)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:update_dictionary:Function]
# #endregion update_dictionary
# [DEF:delete_dictionary:Function]
# @PURPOSE: Delete a terminology dictionary, blocked if attached to active/scheduled jobs.
# @PRE: User has translate.dictionary.delete permission.
# @POST: Dictionary is deleted.
# #region delete_dictionary [C:4] [TYPE Function] [SEMANTICS api,translate,dictionaries]
# @BRIEF Delete a terminology dictionary, blocked if attached to active/scheduled jobs.
# @PRE User has translate.dictionary.delete permission. Dictionary exists and is not in use.
# @POST Dictionary is deleted or 409 if attached to active jobs.
# @SIDE_EFFECT Deletes TerminologyDictionary record from DB.
# @RELATION DEPENDS_ON -> [DictionaryManager]
@router.delete("/dictionaries/{dictionary_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_dictionary(
dictionary_id: str,
@@ -162,17 +176,19 @@ async def delete_dictionary(
if "active/scheduled" in str(e):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:delete_dictionary:Function]
# #endregion delete_dictionary
# ============================================================
# Dictionary Entries CRUD
# ============================================================
# [DEF:list_dictionary_entries:Function]
# @PURPOSE: List entries for a dictionary.
# @PRE: User has translate.dictionary.view permission.
# @POST: Returns paginated list of entries.
# #region list_dictionary_entries [C:4] [TYPE Function] [SEMANTICS api,translate,dictionaries,entries]
# @BRIEF List entries for a dictionary with pagination.
# @PRE User has translate.dictionary.view permission. Dictionary exists.
# @POST Returns paginated list of entries.
# @SIDE_EFFECT Reads DictionaryEntry records from DB.
# @RELATION DEPENDS_ON -> [DictionaryManager]
@router.get("/dictionaries/{dictionary_id}/entries")
async def list_dictionary_entries(
dictionary_id: str,
@@ -204,13 +220,15 @@ async def list_dictionary_entries(
return {"items": items, "total": total, "page": page, "page_size": page_size}
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:list_dictionary_entries:Function]
# #endregion list_dictionary_entries
# [DEF:add_dictionary_entry:Function]
# @PURPOSE: Add a new entry to a dictionary.
# @PRE: User has translate.dictionary.edit permission.
# @POST: Entry is created.
# #region add_dictionary_entry [C:4] [TYPE Function] [SEMANTICS api,translate,dictionaries,entries]
# @BRIEF Add a new entry to a dictionary.
# @PRE User has translate.dictionary.edit permission. Dictionary exists.
# @POST Entry created and returned.
# @SIDE_EFFECT Creates DictionaryEntry record in DB.
# @RELATION DEPENDS_ON -> [DictionaryManager]
@router.post("/dictionaries/{dictionary_id}/entries", status_code=status.HTTP_201_CREATED)
async def add_dictionary_entry(
dictionary_id: str,
@@ -244,13 +262,15 @@ async def add_dictionary_entry(
if "already exists" in str(e):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:add_dictionary_entry:Function]
# #endregion add_dictionary_entry
# [DEF:edit_dictionary_entry:Function]
# @PURPOSE: Update an existing dictionary entry.
# @PRE: User has translate.dictionary.edit permission.
# @POST: Entry is updated.
# #region edit_dictionary_entry [C:4] [TYPE Function] [SEMANTICS api,translate,dictionaries,entries]
# @BRIEF Update an existing dictionary entry.
# @PRE User has translate.dictionary.edit permission. Entry exists.
# @POST Entry updated and returned.
# @SIDE_EFFECT Updates DictionaryEntry record in DB.
# @RELATION DEPENDS_ON -> [DictionaryManager]
@router.put("/dictionaries/{dictionary_id}/entries/{entry_id}")
async def edit_dictionary_entry(
dictionary_id: str,
@@ -285,13 +305,15 @@ async def edit_dictionary_entry(
if "already exists" in str(e):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:edit_dictionary_entry:Function]
# #endregion edit_dictionary_entry
# [DEF:delete_dictionary_entry:Function]
# @PURPOSE: Delete a dictionary entry.
# @PRE: User has translate.dictionary.edit permission.
# @POST: Entry is deleted.
# #region delete_dictionary_entry [C:4] [TYPE Function] [SEMANTICS api,translate,dictionaries,entries]
# @BRIEF Delete a dictionary entry.
# @PRE User has translate.dictionary.edit permission. Entry exists.
# @POST Entry is deleted.
# @SIDE_EFFECT Deletes DictionaryEntry record from DB.
# @RELATION DEPENDS_ON -> [DictionaryManager]
@router.delete("/dictionaries/{dictionary_id}/entries/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_dictionary_entry(
dictionary_id: str,
@@ -309,13 +331,15 @@ async def delete_dictionary_entry(
return None
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:delete_dictionary_entry:Function]
# #endregion delete_dictionary_entry
# [DEF:import_dictionary_entries:Function]
# @PURPOSE: Import entries into a terminology dictionary from CSV/TSV content.
# @PRE: User has translate.dictionary.edit permission.
# @POST: Entries are imported per conflict mode.
# #region import_dictionary_entries [C:4] [TYPE Function] [SEMANTICS api,translate,dictionaries,import]
# @BRIEF Import entries into a terminology dictionary from CSV/TSV content.
# @PRE User has translate.dictionary.edit permission. Dictionary exists. Content is valid CSV/TSV.
# @POST Entries imported per conflict mode (overwrite/keep_existing/cancel). Import result returned.
# @SIDE_EFFECT Creates/updates DictionaryEntry records in DB.
# @RELATION DEPENDS_ON -> [DictionaryManager]
@router.post("/dictionaries/{dictionary_id}/import")
async def import_dictionary_entries(
dictionary_id: str,
@@ -351,6 +375,6 @@ async def import_dictionary_entries(
return result
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# [/DEF:import_dictionary_entries:Function]
# #endregion import_dictionary_entries
# [/DEF:TranslateDictionaryRoutesModule:Module]
# #endregion TranslateDictionaryRoutesModule

View File

@@ -1,17 +1,13 @@
# [DEF:TranslateHelpersModule:Module]
# @COMPLEXITY: 2
# @PURPOSE: Shared helper functions for translate route handlers.
# @LAYER: API
# @SEMANTICS: api, routes, translate, helpers
# @RELATION: DEPENDS_ON -> [models.translate]
# #region TranslateHelpersModule [C:2] [TYPE Module] [SEMANTICS api,routes,translate,helpers]
# @BRIEF Shared helper functions for translate route handlers.
# @LAYER UI
from typing import Any, Dict, List
from sqlalchemy.orm import Session
# [DEF:_run_to_response:Function]
# @COMPLEXITY: 2
# @PURPOSE: Convert TranslationRun ORM to response dict.
# #region _run_to_response [C:2] [TYPE Function] [SEMANTICS helpers,translate]
# @BRIEF Convert TranslationRun ORM to response dict.
def _run_to_response(run: Any) -> dict:
return {
"id": run.id,
@@ -34,12 +30,11 @@ def _run_to_response(run: Any) -> dict:
"created_by": run.created_by,
"created_at": run.created_at.isoformat() if run.created_at else None,
}
# [/DEF:_run_to_response:Function]
# #endregion _run_to_response
# [DEF:_dict_to_response:Function]
# @COMPLEXITY: 2
# @PURPOSE: Convert a TerminologyDictionary ORM model to a response dict with computed entry_count.
# #region _dict_to_response [C:2] [TYPE Function] [SEMANTICS helpers,translate]
# @BRIEF Convert a TerminologyDictionary ORM model to a response dict with computed entry_count.
@staticmethod
def _dict_to_response(d: Any, entry_count: int = 0) -> dict:
return {
@@ -54,15 +49,14 @@ def _dict_to_response(d: Any, entry_count: int = 0) -> dict:
"updated_at": d.updated_at,
"entry_count": entry_count,
}
# [/DEF:_dict_to_response:Function]
# #endregion _dict_to_response
# [DEF:_get_dictionary_entry_counts:Function]
# @COMPLEXITY: 2
# @PURPOSE: Get entry counts for a list of dictionary IDs.
# #region _get_dictionary_entry_counts [C:2] [TYPE Function] [SEMANTICS helpers,translate]
# @BRIEF Get entry counts for a list of dictionary IDs.
def _get_dictionary_entry_counts(db: Session, dict_ids: List[str]) -> Dict[str, int]:
from sqlalchemy import func
from ...models.translate import DictionaryEntry
from ....models.translate import DictionaryEntry
counts = (
db.query(DictionaryEntry.dictionary_id, func.count(DictionaryEntry.id))
.filter(DictionaryEntry.dictionary_id.in_(dict_ids))
@@ -70,6 +64,6 @@ def _get_dictionary_entry_counts(db: Session, dict_ids: List[str]) -> Dict[str,
.all()
)
return {row[0]: row[1] for row in counts}
# [/DEF:_get_dictionary_entry_counts:Function]
# #endregion _get_dictionary_entry_counts
# [/DEF:TranslateHelpersModule:Module]
# #endregion TranslateHelpersModule

View File

@@ -1,8 +1,14 @@
# [DEF:TranslateJobRoutesModule:Module]
# @COMPLEXITY: 3
# @PURPOSE: Translation Job CRUD and datasource column routes.
# @LAYER: API
# @SEMANTICS: api, routes, translate, jobs
# #region TranslateJobRoutesModule [C:4] [TYPE Module] [SEMANTICS api,routes,translate,jobs]
# @BRIEF Translation Job CRUD and datasource column routes.
# @LAYER UI
# @RELATION DEPENDS_ON -> [TranslateJobService]
# @RELATION DEPENDS_ON -> [ConfigManager]
# @RELATION DEPENDS_ON -> [SupersetClient]
# @RELATION DEPENDS_ON -> [get_current_user]
# @RELATION DEPENDS_ON -> [get_db]
# @PRE ConfigManager and DB session initialized. User authenticated with translate.job permissions.
# @POST Translation job CRUD completed or datasource columns fetched.
# @SIDE_EFFECT Reads/writes TranslationJob records to DB; fetches datasource metadata from Superset API.
from fastapi import APIRouter, Depends, HTTPException, status, Query
from typing import Any, Dict, List, Optional
@@ -33,10 +39,9 @@ from ._router import router
# Translation Job CRUD
# ============================================================
# [DEF:list_jobs:Function]
# @PURPOSE: List all translation jobs.
# @PRE: User has translate.job.view permission.
# @POST: Returns list of translation jobs.
# #region list_jobs [C:3] [TYPE Function] [SEMANTICS api,translate,jobs]
# @BRIEF List all translation jobs with pagination and optional status filter.
# @RELATION DEPENDS_ON -> [TranslateJobService]
@router.get("/jobs", response_model=List[TranslateJobResponse])
async def list_jobs(
page: int = Query(1, ge=1),
@@ -63,13 +68,12 @@ async def list_jobs(
return results
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# [/DEF:list_jobs:Function]
# #endregion list_jobs
# [DEF:get_job:Function]
# @PURPOSE: Get a single translation job by ID.
# @PRE: User has translate.job.view permission.
# @POST: Returns the translation job.
# #region get_job [C:3] [TYPE Function] [SEMANTICS api,translate,jobs]
# @BRIEF Get a single translation job by ID.
# @RELATION DEPENDS_ON -> [TranslateJobService]
@router.get("/jobs/{job_id}", response_model=TranslateJobResponse)
async def get_job(
job_id: str,
@@ -87,14 +91,15 @@ async def get_job(
return job_to_response(job, dict_ids)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:get_job:Function]
# #endregion get_job
# [DEF:create_job:Function]
# @PURPOSE: Create a new translation job.
# @PRE: User has translate.job.create permission.
# @POST: Returns the created translation job.
# @SIDE_EFFECT: Validates columns via SupersetClient; caches database_dialect.
# #region create_job [C:4] [TYPE Function] [SEMANTICS api,translate,jobs]
# @BRIEF Create a new translation job.
# @PRE User has translate.job.create permission. Payload validated.
# @POST Translation job created and returned.
# @SIDE_EFFECT Creates TranslationJob record in DB; validates columns via SupersetClient; caches database_dialect.
# @RELATION DEPENDS_ON -> [TranslateJobService]
@router.post("/jobs", response_model=TranslateJobResponse, status_code=status.HTTP_201_CREATED)
async def create_job(
payload: TranslateJobCreate,
@@ -112,14 +117,15 @@ async def create_job(
return job_to_response(job, dict_ids)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e))
# [/DEF:create_job:Function]
# #endregion create_job
# [DEF:update_job:Function]
# @PURPOSE: Update an existing translation job.
# @PRE: User has translate.job.edit permission.
# @POST: Returns the updated translation job.
# @SIDE_EFFECT: Re-detects database_dialect if datasource changed.
# #region update_job [C:4] [TYPE Function] [SEMANTICS api,translate,jobs]
# @BRIEF Update an existing translation job.
# @PRE User has translate.job.edit permission. Job exists.
# @POST Translation job updated and returned.
# @SIDE_EFFECT Updates TranslationJob record in DB; re-detects database_dialect if datasource changed.
# @RELATION DEPENDS_ON -> [TranslateJobService]
@router.put("/jobs/{job_id}", response_model=TranslateJobResponse)
async def update_job(
job_id: str,
@@ -138,13 +144,15 @@ async def update_job(
return job_to_response(job, dict_ids)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:update_job:Function]
# #endregion update_job
# [DEF:delete_job:Function]
# @PURPOSE: Delete a translation job.
# @PRE: User has translate.job.delete permission.
# @POST: Job is deleted.
# #region delete_job [C:4] [TYPE Function] [SEMANTICS api,translate,jobs]
# @BRIEF Delete a translation job.
# @PRE User has translate.job.delete permission. Job exists.
# @POST Translation job is deleted from DB.
# @SIDE_EFFECT Deletes TranslationJob and associated records from DB.
# @RELATION DEPENDS_ON -> [TranslateJobService]
@router.delete("/jobs/{job_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_job(
job_id: str,
@@ -160,13 +168,15 @@ async def delete_job(
service.delete_job(job_id)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:delete_job:Function]
# #endregion delete_job
# [DEF:duplicate_job:Function]
# @PURPOSE: Duplicate a translation job.
# @PRE: User has translate.job.create permission.
# @POST: Returns the duplicated job with status DRAFT.
# #region duplicate_job [C:4] [TYPE Function] [SEMANTICS api,translate,jobs]
# @BRIEF Duplicate a translation job.
# @PRE User has translate.job.create permission. Source job exists.
# @POST Duplicated job created with status DRAFT and returned.
# @SIDE_EFFECT Creates a new TranslationJob record in DB as a copy of the source.
# @RELATION DEPENDS_ON -> [TranslateJobService]
@router.post("/jobs/{job_id}/duplicate", response_model=DuplicateJobResponse, status_code=status.HTTP_201_CREATED)
async def duplicate_job(
job_id: str,
@@ -187,17 +197,16 @@ async def duplicate_job(
)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:duplicate_job:Function]
# #endregion duplicate_job
# [DEF:get_datasource_columns:Function]
# @PURPOSE: Get column metadata and database dialect for a Superset datasource.
# @PRE: User has translate.job.view permission.
# @POST: Returns column list with metadata and database_dialect.
# @SIDE_EFFECT: Queries Superset API for dataset detail and database info.
# @RATIONALE: Dialect detection from Superset connection cached on TranslationJob at save time.
# @REJECTED: Hardcoding dialect list per database — would drift from actual Superset config.
# #region list_datasources [C:4] [TYPE Function] [SEMANTICS api,translate,datasources]
# @BRIEF List all Superset datasets available for translation jobs.
# @PRE User has translate.job.view permission.
# @POST Returns list of datasets with metadata.
# @SIDE_EFFECT Queries Superset API for available datasets.
# @RELATION DEPENDS_ON -> [TranslateJobService]
# @RELATION DEPENDS_ON -> [SupersetClient]
@router.get("/datasources")
async def list_datasources(
env_id: str = Query(..., description="Superset environment ID"),
@@ -216,7 +225,16 @@ async def list_datasources(
except Exception as e:
logger.error(f"[translate_routes][list_datasources] Error: {e}")
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e))
# #endregion list_datasources
# #region get_datasource_columns [C:4] [TYPE Function] [SEMANTICS api,translate,datasources]
# @BRIEF Get column metadata and database dialect for a Superset datasource.
# @PRE User has translate.job.view permission. Datasource exists in Superset.
# @POST Returns column list with metadata and database_dialect.
# @SIDE_EFFECT Queries Superset API for dataset detail and database info.
# @RELATION DEPENDS_ON -> [SupersetClient]
# @RELATION DEPENDS_ON -> [ConfigManager]
@router.get("/datasources/{datasource_id}/columns", response_model=DatasourceColumnsResponse)
async def get_datasource_columns(
datasource_id: int,
@@ -245,6 +263,6 @@ async def get_datasource_columns(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Failed to fetch datasource columns from Superset: {e}",
)
# [/DEF:get_datasource_columns:Function]
# #endregion get_datasource_columns
# [/DEF:TranslateJobRoutesModule:Module]
# #endregion TranslateJobRoutesModule

View File

@@ -1,8 +1,9 @@
# [DEF:TranslateMetricsRoutesModule:Module]
# @COMPLEXITY: 2
# @PURPOSE: Translation Metrics endpoints.
# @LAYER: API
# @SEMANTICS: api, routes, translate, metrics
# #region TranslateMetricsRoutesModule [C:3] [TYPE Module] [SEMANTICS api,routes,translate,metrics]
# @BRIEF Translation Metrics endpoints providing aggregated stats on runs and corrections.
# @LAYER UI
# @RELATION DEPENDS_ON -> [TranslationMetrics]
# @RELATION DEPENDS_ON -> [get_current_user]
# @RELATION DEPENDS_ON -> [get_db]
from fastapi import APIRouter, Depends, HTTPException, status, Query
from typing import Any, Dict, List, Optional
@@ -21,10 +22,9 @@ from ._router import router
# Metrics
# ============================================================
# [DEF:get_metrics:Function]
# @PURPOSE: Get translation metrics, optionally filtered by job.
# @PRE: User has translate.metrics.view permission.
# @POST: Returns metrics data.
# #region get_metrics [C:3] [TYPE Function] [SEMANTICS api,translate,metrics]
# @BRIEF Get translation metrics across all jobs, optionally filtered by job.
# @RELATION DEPENDS_ON -> [TranslationMetrics]
@router.get("/metrics")
async def get_metrics(
job_id: Optional[str] = Query(None),
@@ -41,13 +41,12 @@ async def get_metrics(
return metrics_svc.get_all_metrics()
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:get_metrics:Function]
# #endregion get_metrics
# [DEF:get_job_metrics:Function]
# @PURPOSE: Get aggregated metrics for a specific job.
# @PRE: User has translate.metrics.view permission.
# @POST: Returns metrics dict.
# #region get_job_metrics [C:3] [TYPE Function] [SEMANTICS api,translate,metrics]
# @BRIEF Get aggregated metrics for a specific translation job.
# @RELATION DEPENDS_ON -> [TranslationMetrics]
@router.get("/jobs/{job_id}/metrics")
async def get_job_metrics(
job_id: str,
@@ -62,6 +61,6 @@ async def get_job_metrics(
return metrics_svc.get_job_metrics(job_id)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:get_job_metrics:Function]
# #endregion get_job_metrics
# [/DEF:TranslateMetricsRoutesModule:Module]
# #endregion TranslateMetricsRoutesModule

View File

@@ -1,8 +1,13 @@
# [DEF:TranslatePreviewRoutesModule:Module]
# @COMPLEXITY: 3
# @PURPOSE: Translation Preview session management routes.
# @LAYER: API
# @SEMANTICS: api, routes, translate, preview
# #region TranslatePreviewRoutesModule [C:4] [TYPE Module] [SEMANTICS api,routes,translate,preview]
# @BRIEF Translation Preview session management routes.
# @LAYER UI
# @RELATION DEPENDS_ON -> [TranslationPreview]
# @RELATION DEPENDS_ON -> [ConfigManager]
# @RELATION DEPENDS_ON -> [get_current_user]
# @RELATION DEPENDS_ON -> [get_db]
# @PRE ConfigManager and DB session initialized. User authenticated with translate permissions.
# @POST Preview sessions created, rows reviewed/accepted, records queried.
# @SIDE_EFFECT Creates/updates preview sessions and rows in DB; fetches sample data from Superset; calls LLM provider.
from fastapi import APIRouter, Depends, HTTPException, status, Query
from typing import Any, Dict, List, Optional
@@ -28,11 +33,13 @@ from ._router import router
# Preview
# ============================================================
# [DEF:preview_translation:Function]
# @PURPOSE: Preview a translation before applying it.
# @PRE: User has translate.job.execute permission.
# @POST: Returns a preview session with records and cost estimation.
# @SIDE_EFFECT: Fetches sample data from Superset; calls LLM provider; creates DB rows.
# #region preview_translation [C:4] [TYPE Function] [SEMANTICS api,translate,preview]
# @BRIEF Preview a translation before applying it.
# @PRE User has translate.job.execute permission. Job exists.
# @POST Preview session created with sample rows and cost estimation.
# @SIDE_EFFECT Fetches sample data from Superset; calls LLM provider; creates PreviewSession and PreviewRecord rows in DB.
# @RELATION DEPENDS_ON -> [TranslationPreview]
# @RELATION DEPENDS_ON -> [SupersetClient]
@router.post("/jobs/{job_id}/preview", status_code=status.HTTP_201_CREATED)
async def preview_translation(
job_id: str,
@@ -60,13 +67,15 @@ async def preview_translation(
except Exception as e:
logger.error(f"[translate_routes][preview_translation] Error: {e}")
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Preview failed: {e}")
# [/DEF:preview_translation:Function]
# #endregion preview_translation
# [DEF:update_preview_row:Function]
# @PURPOSE: Approve, edit, or reject a preview row.
# @PRE: User has translate.job.execute permission.
# @POST: Preview row status is updated.
# #region update_preview_row [C:4] [TYPE Function] [SEMANTICS api,translate,preview]
# @BRIEF Approve, edit, or reject a preview row.
# @PRE User has translate.job.execute permission. Preview row exists.
# @POST Preview row status updated to APPROVED/REJECTED/EDITED.
# @SIDE_EFFECT Updates PreviewRecord status in DB.
# @RELATION DEPENDS_ON -> [TranslationPreview]
@router.put("/jobs/{job_id}/preview/rows/{row_key}")
async def update_preview_row(
job_id: str,
@@ -91,13 +100,15 @@ async def update_preview_row(
return result
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# [/DEF:update_preview_row:Function]
# #endregion update_preview_row
# [DEF:accept_preview_session:Function]
# @PURPOSE: Accept a preview session, marking it as the quality gate for full execution.
# @PRE: User has translate.job.execute permission. Job has an ACTIVE preview session.
# @POST: Preview session is marked as APPLIED; full execution can proceed.
# #region accept_preview_session [C:4] [TYPE Function] [SEMANTICS api,translate,preview]
# @BRIEF Accept a preview session, marking it as the quality gate for full execution.
# @PRE User has translate.job.execute permission. Job has an ACTIVE preview session.
# @POST Preview session status set to APPLIED. Full execution can proceed.
# @SIDE_EFFECT Updates PreviewSession status to APPLIED in DB.
# @RELATION DEPENDS_ON -> [TranslationPreview]
@router.post("/jobs/{job_id}/preview/accept")
async def accept_preview_session(
job_id: str,
@@ -114,13 +125,15 @@ async def accept_preview_session(
return result
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# [/DEF:accept_preview_session:Function]
# #endregion accept_preview_session
# [DEF:apply_preview:Function]
# @PURPOSE: Apply a preview session (alias for accept when accepting at session level).
# @PRE: User has translate.job.execute permission.
# @POST: Preview is applied.
# #region apply_preview [C:4] [TYPE Function] [SEMANTICS api,translate,preview]
# @BRIEF Apply a preview session (alias for accept when accepting at session level).
# @PRE User has translate.job.execute permission. Session exists.
# @POST Preview session applied and marked as quality gate.
# @SIDE_EFFECT Updates PreviewSession status to APPLIED in DB.
# @RELATION DEPENDS_ON -> [TranslationPreview]
@router.post("/preview/{session_id}/apply")
async def apply_preview(
session_id: str,
@@ -132,7 +145,7 @@ async def apply_preview(
"""Apply a preview session by session ID."""
logger.info(f"[translate_routes][apply_preview] Session: {session_id}, User: {current_user.username}")
# Find job_id from session
from ...models.translate import TranslationPreviewSession
from ....models.translate import TranslationPreviewSession
session = db.query(TranslationPreviewSession).filter(TranslationPreviewSession.id == session_id).first()
if not session:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Preview session '{session_id}' not found")
@@ -142,17 +155,17 @@ async def apply_preview(
return result
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# [/DEF:apply_preview:Function]
# #endregion apply_preview
# ============================================================
# Preview Records
# ============================================================
# [DEF:get_preview_records:Function]
# @PURPOSE: Get records for a preview session.
# @PRE: User has translate.job.view permission.
# @POST: Returns list of preview records.
# #region get_preview_records [C:3] [TYPE Function] [SEMANTICS api,translate,preview]
# @BRIEF Get records for a preview session.
# @RELATION DEPENDS_ON -> [TranslationPreviewSession]
# @RELATION DEPENDS_ON -> [TranslationPreviewRecord]
@router.get("/preview/{session_id}/records", response_model=List[PreviewRow])
async def get_preview_records(
session_id: str,
@@ -163,7 +176,7 @@ async def get_preview_records(
"""Get records for a preview session."""
logger.info(f"[translate_routes][get_preview_records] Session: {session_id}, User: {current_user.username}")
try:
from ...models.translate import TranslationPreviewSession, TranslationPreviewRecord
from ....models.translate import TranslationPreviewSession, TranslationPreviewRecord
session = db.query(TranslationPreviewSession).filter(TranslationPreviewSession.id == session_id).first()
if not session:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Preview session '{session_id}' not found")
@@ -190,6 +203,6 @@ async def get_preview_records(
except Exception as e:
logger.error(f"[translate_routes][get_preview_records] Error: {e}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# [/DEF:get_preview_records:Function]
# #endregion get_preview_records
# [/DEF:TranslatePreviewRoutesModule:Module]
# #endregion TranslatePreviewRoutesModule

View File

@@ -1,14 +1,10 @@
# [DEF:TranslateRouterModule:Module]
# @COMPLEXITY: 1
# @PURPOSE: APIRouter instance for translate routes.
# @LAYER: API
# @SEMANTICS: api, routes, translate, router
# #region TranslateRouterModule [C:1] [TYPE Module] [SEMANTICS api,routes,translate,router]
# @LAYER UI
from fastapi import APIRouter
# [DEF:translate_router:Global]
# @PURPOSE: APIRouter instance for all translate sub-routes.
# #region translate_router [C:1] [TYPE Global]
router = APIRouter(prefix="/api/translate", tags=["translate"])
# [/DEF:translate_router:Global]
# #endregion translate_router
# [/DEF:TranslateRouterModule:Module]
# #endregion TranslateRouterModule

View File

@@ -1,8 +1,10 @@
# [DEF:TranslateRunListRoutesModule:Module]
# @COMPLEXITY: 3
# @PURPOSE: Translation Run listing, detail, and CSV download routes (cross-job).
# @LAYER: API
# @SEMANTICS: api, routes, translate, runs, list, detail
# #region TranslateRunListRoutesModule [C:3] [TYPE Module] [SEMANTICS api,routes,translate,runs,list,detail]
# @BRIEF Translation Run listing, detail, and CSV download routes (cross-job).
# @LAYER UI
# @RELATION DEPENDS_ON -> [TranslationOrchestrator]
# @RELATION DEPENDS_ON -> [TranslationRun]
# @RELATION DEPENDS_ON -> [get_current_user]
# @RELATION DEPENDS_ON -> [get_db]
from fastapi import APIRouter, Depends, HTTPException, status, Query
from typing import Any, Dict, List, Optional
@@ -23,14 +25,13 @@ from ._router import router
# List Runs (cross-job)
# ============================================================
# [DEF:list_runs:Function]
# @PURPOSE: List all runs with cross-job filtering and pagination.
# @PRE: User has translate.history.view permission.
# @POST: Returns paginated list of runs.
# #region list_runs [C:3] [TYPE Function] [SEMANTICS api,translate,runs,list]
# @BRIEF List all runs with cross-job filtering and pagination.
# @RELATION DEPENDS_ON -> [TranslationRun]
@router.get("/runs")
async def list_runs(
job_id: Optional[str] = Query(None),
run_status: Optional[str] = Query(None),
run_status: Optional[str] = Query(None, alias="status"),
trigger_type: Optional[str] = Query(None),
created_by: Optional[str] = Query(None),
date_from: Optional[str] = Query(None),
@@ -44,7 +45,7 @@ async def list_runs(
"""List all runs with cross-job filtering and pagination."""
logger.info(f"[translate_routes][list_runs] User: {current_user.username}")
try:
from ...models.translate import TranslationRun
from ....models.translate import TranslationRun
query = db.query(TranslationRun)
if job_id:
@@ -104,17 +105,18 @@ async def list_runs(
except Exception as e:
logger.error(f"[translate_routes][list_runs] Error: {e}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# [/DEF:list_runs:Function]
# #endregion list_runs
# ============================================================
# Run Detail
# ============================================================
# [DEF:get_run_detail:Function]
# @PURPOSE: Get detailed run info with config_snapshot, records, events.
# @PRE: User has translate.history.view permission.
# @POST: Returns run detail with records and events.
# #region get_run_detail [C:3] [TYPE Function] [SEMANTICS api,translate,runs,detail]
# @BRIEF Get detailed run info with config_snapshot, records, events.
# @RELATION DEPENDS_ON -> [TranslationOrchestrator]
# @RELATION DEPENDS_ON -> [TranslationEventLog]
# @RELATION DEPENDS_ON -> [TranslationRun]
@router.get("/runs/{run_id}/detail")
async def get_run_detail(
run_id: str,
@@ -133,7 +135,7 @@ async def get_run_detail(
event_summary = TranslationEventLog(db).get_run_event_summary(run_id)
# Get config snapshot from run
from ...models.translate import TranslationRun
from ....models.translate import TranslationRun
run = db.query(TranslationRun).filter(TranslationRun.id == run_id).first()
config_snapshot = run.config_snapshot if run else None
@@ -147,17 +149,16 @@ async def get_run_detail(
}
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:get_run_detail:Function]
# #endregion get_run_detail
# ============================================================
# Download Skipped CSV
# ============================================================
# [DEF:download_skipped_csv:Function]
# @PURPOSE: Download a CSV of skipped translation records from a run.
# @PRE: User has translate.job.view permission.
# @POST: Returns CSV file of skipped records.
# #region download_skipped_csv [C:3] [TYPE Function] [SEMANTICS api,translate,runs,csv]
# @BRIEF Download a CSV of skipped translation records from a run.
# @RELATION DEPENDS_ON -> [TranslationRecord]
@router.get("/runs/{run_id}/skipped.csv")
async def download_skipped_csv(
run_id: str,
@@ -168,7 +169,7 @@ async def download_skipped_csv(
"""Download skipped translation records as CSV."""
logger.info(f"[translate_routes][download_skipped_csv] Run: {run_id}, User: {current_user.username}")
try:
from ...models.translate import TranslationRecord
from ....models.translate import TranslationRecord
from fastapi.responses import StreamingResponse
import csv
import io
@@ -201,6 +202,6 @@ async def download_skipped_csv(
except Exception as e:
logger.error(f"[translate_routes][download_skipped_csv] Error: {e}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# [/DEF:download_skipped_csv:Function]
# #endregion download_skipped_csv
# [/DEF:TranslateRunListRoutesModule:Module]
# #endregion TranslateRunListRoutesModule

View File

@@ -1,20 +1,26 @@
# [DEF:TranslateRunRoutesModule:Module]
# @COMPLEXITY: 3
# @PURPOSE: Translation Run execution, history, status, records and batches routes.
# @LAYER: API
# @SEMANTICS: api, routes, translate, runs
# #region TranslateRunRoutesModule [C:4] [TYPE Module] [SEMANTICS api,routes,translate,runs]
# @BRIEF Translation Run execution, history, status, records and batches routes.
# @LAYER UI
# @RELATION DEPENDS_ON -> [TranslationOrchestrator]
# @RELATION DEPENDS_ON -> [ConfigManager]
# @RELATION DEPENDS_ON -> [get_current_user]
# @RELATION DEPENDS_ON -> [get_db]
# @PRE ConfigManager and DB session initialized. User authenticated with translate permissions.
# @POST Translation run executed, manipulated, or queried. Results returned to caller.
# @SIDE_EFFECT Creates/updates TranslationRun records in DB; spawns background threads for execution; queries Superset API.
from fastapi import APIRouter, Depends, HTTPException, status, Query
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from ....core.database import get_db
from ....core.database import get_db, SessionLocal
from ....core.logger import logger, belief_scope
from ....schemas.auth import User
from ....dependencies import get_current_user, has_permission, get_config_manager
from ....core.config_manager import ConfigManager
from ....plugins.translate.orchestrator import TranslationOrchestrator
from ....plugins.translate.events import TranslationEventLog
from ....models.translate import TranslationRun
from ._router import router
from ._helpers import _run_to_response
@@ -24,43 +30,64 @@ from ._helpers import _run_to_response
# Translation Run / Execute
# ============================================================
# [DEF:run_translation:Function]
# @PURPOSE: Execute a translation job (trigger a run).
# @PRE: User has translate.job.execute permission.
# @POST: Returns the created translation run.
# #region run_translation [C:4] [TYPE Function] [SEMANTICS api,translate,runs]
# @BRIEF Execute a translation job (trigger a run).
# @PRE User has translate.job.execute permission. Job exists and preview is accepted.
# @POST Translation run created and started in background. Run object returned.
# @SIDE_EFFECT Creates TranslationRun record in DB; spawns background thread for translation execution.
# @RELATION DEPENDS_ON -> [TranslationOrchestrator]
@router.post("/jobs/{job_id}/run", status_code=status.HTTP_201_CREATED)
async def run_translation(
job_id: str,
full_translation: bool = Query(False, description="If True, fetch ALL rows from Superset dataset instead of preview-only rows"),
current_user: User = Depends(get_current_user),
_ = Depends(has_permission("translate.job", "EXECUTE")),
db: Session = Depends(get_db),
config_manager: ConfigManager = Depends(get_config_manager),
):
"""Execute a translation job (trigger a run)."""
logger.info(f"[translate_routes][run_translation] Job: {job_id}, User: {current_user.username}")
"""Execute a translation job (trigger a run).
By default runs translation on preview-approved rows only.
Set full_translation=true to fetch ALL rows from the Superset source dataset.
"""
logger.info(f"[translate_routes][run_translation] Job: {job_id}, User: {current_user.username}, full={full_translation}")
try:
orch = TranslationOrchestrator(db, config_manager, current_user.username)
run = orch.start_run(job_id=job_id, is_scheduled=False)
# Execute asynchronously in background
# Execute asynchronously in background with a FRESH session.
# The request-scoped session from Depends(get_db) is closed after the handler returns,
# so the background thread must create its own session to avoid "This transaction is closed".
import threading
threading.Thread(
target=orch.execute_run,
args=(run,),
daemon=True,
).start()
def _execute_background():
thread_db = SessionLocal()
try:
thread_orch = TranslationOrchestrator(thread_db, config_manager, current_user.username)
# Reload run from DB with the new session (the passed run object is detached)
thread_run = thread_db.query(TranslationRun).filter(TranslationRun.id == run.id).first()
if thread_run:
thread_orch.execute_run(thread_run, full_translation=full_translation)
except Exception as e:
logger.error(f"[translate_routes][run_translation] Background execution error: {e}")
finally:
thread_db.close()
threading.Thread(target=_execute_background, daemon=True).start()
return _run_to_response(run)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
except Exception as e:
logger.error(f"[translate_routes][run_translation] Error: {e}")
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Run failed: {e}")
# [/DEF:run_translation:Function]
# #endregion run_translation
# [DEF:retry_run:Function]
# @PURPOSE: Retry failed batches in a translation run.
# @PRE: User has translate.job.execute permission.
# @POST: Returns the updated translation run.
# #region retry_run [C:4] [TYPE Function] [SEMANTICS api,translate,runs]
# @BRIEF Retry failed batches in a translation run.
# @PRE User has translate.job.execute permission. Run exists and has failed batches.
# @POST Failed batches re-executed. Updated run returned.
# @SIDE_EFFECT Updates TranslationRun and TranslationBatch records in DB.
# @RELATION DEPENDS_ON -> [TranslationOrchestrator]
@router.post("/runs/{run_id}/retry")
async def retry_run(
run_id: str,
@@ -80,13 +107,15 @@ async def retry_run(
except Exception as e:
logger.error(f"[translate_routes][retry_run] Error: {e}")
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Retry failed: {e}")
# [/DEF:retry_run:Function]
# #endregion retry_run
# [DEF:retry_insert:Function]
# @PURPOSE: Retry the SQL insert phase for a completed run.
# @PRE: User has translate.job.execute permission.
# @POST: Returns the updated run.
# #region retry_insert [C:4] [TYPE Function] [SEMANTICS api,translate,runs]
# @BRIEF Retry the SQL insert phase for a completed run.
# @PRE User has translate.job.execute permission. Run completed with insert_status FAILED.
# @POST SQL insert phase re-executed. Updated run returned.
# @SIDE_EFFECT Re-executes SQL insert into Superset; updates TranslationRun insert_status.
# @RELATION DEPENDS_ON -> [TranslationOrchestrator]
@router.post("/runs/{run_id}/retry-insert")
async def retry_insert(
run_id: str,
@@ -106,13 +135,15 @@ async def retry_insert(
except Exception as e:
logger.error(f"[translate_routes][retry_insert] Error: {e}")
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Retry insert failed: {e}")
# [/DEF:retry_insert:Function]
# #endregion retry_insert
# [DEF:cancel_run:Function]
# @PURPOSE: Cancel a running translation.
# @PRE: User has translate.job.execute permission.
# @POST: Run is cancelled.
# #region cancel_run [C:4] [TYPE Function] [SEMANTICS api,translate,runs]
# @BRIEF Cancel a running translation.
# @PRE User has translate.job.execute permission. Run is in RUNNING state.
# @POST Run is cancelled. Updated run returned.
# @SIDE_EFFECT Updates TranslationRun status to CANCELLED in DB.
# @RELATION DEPENDS_ON -> [TranslationOrchestrator]
@router.post("/runs/{run_id}/cancel")
async def cancel_run(
run_id: str,
@@ -129,13 +160,12 @@ async def cancel_run(
return _run_to_response(run)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# [/DEF:cancel_run:Function]
# #endregion cancel_run
# [DEF:get_run_history:Function]
# @PURPOSE: Get run history for a translation job.
# @PRE: User has translate.history.view permission.
# @POST: Returns list of runs.
# #region get_run_history [C:3] [TYPE Function] [SEMANTICS api,translate,runs]
# @BRIEF Get run history for a translation job.
# @RELATION DEPENDS_ON -> [TranslationOrchestrator]
@router.get("/jobs/{job_id}/runs")
async def get_run_history(
job_id: str,
@@ -154,13 +184,12 @@ async def get_run_history(
return {"items": runs, "total": total, "page": page, "page_size": page_size}
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:get_run_history:Function]
# #endregion get_run_history
# [DEF:get_run_status:Function]
# @PURPOSE: Get status and statistics for a translation run.
# @PRE: User has translate.history.view permission.
# @POST: Returns run details with statistics.
# #region get_run_status [C:3] [TYPE Function] [SEMANTICS api,translate,runs]
# @BRIEF Get status and statistics for a translation run.
# @RELATION DEPENDS_ON -> [TranslationOrchestrator]
@router.get("/runs/{run_id}")
async def get_run_status(
run_id: str,
@@ -176,13 +205,12 @@ async def get_run_status(
return orch.get_run_status(run_id)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:get_run_status:Function]
# #endregion get_run_status
# [DEF:get_run_records:Function]
# @PURPOSE: Get paginated records for a translation run.
# @PRE: User has translate.history.view permission.
# @POST: Returns paginated records.
# #region get_run_records [C:3] [TYPE Function] [SEMANTICS api,translate,runs]
# @BRIEF Get paginated records for a translation run.
# @RELATION DEPENDS_ON -> [TranslationOrchestrator]
@router.get("/runs/{run_id}/records")
async def get_run_records(
run_id: str,
@@ -201,17 +229,16 @@ async def get_run_records(
return orch.get_run_records(run_id, page=page, page_size=page_size, status_filter=status)
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:get_run_records:Function]
# #endregion get_run_records
# ============================================================
# Batches
# ============================================================
# [DEF:get_batches:Function]
# @PURPOSE: Get batches for a translation run.
# @PRE: User has translate.job.view permission.
# @POST: Returns list of batches.
# #region get_batches [C:3] [TYPE Function] [SEMANTICS api,translate,runs]
# @BRIEF Get batches for a translation run.
# @RELATION DEPENDS_ON -> [TranslationBatch]
@router.get("/runs/{run_id}/batches")
async def get_batches(
run_id: str,
@@ -222,7 +249,7 @@ async def get_batches(
"""Get batches for a translation run."""
logger.info(f"[translate_routes][get_batches] Run: {run_id}, User: {current_user.username}")
try:
from ...models.translate import TranslationBatch
from ....models.translate import TranslationBatch
batches = (
db.query(TranslationBatch)
.filter(TranslationBatch.run_id == run_id)
@@ -247,6 +274,6 @@ async def get_batches(
except Exception as e:
logger.error(f"[translate_routes][get_batches] Error: {e}")
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# [/DEF:get_batches:Function]
# #endregion get_batches
# [/DEF:TranslateRunRoutesModule:Module]
# #endregion TranslateRunRoutesModule

View File

@@ -1,8 +1,13 @@
# [DEF:TranslateScheduleRoutesModule:Module]
# @COMPLEXITY: 3
# @PURPOSE: Translation Schedule management routes.
# @LAYER: API
# @SEMANTICS: api, routes, translate, schedule
# #region TranslateScheduleRoutesModule [C:4] [TYPE Module] [SEMANTICS api,routes,translate,schedule]
# @BRIEF Translation Schedule management routes.
# @LAYER UI
# @RELATION DEPENDS_ON -> [TranslationScheduler]
# @RELATION DEPENDS_ON -> [ConfigManager]
# @RELATION DEPENDS_ON -> [get_current_user]
# @RELATION DEPENDS_ON -> [get_db]
# @PRE ConfigManager and DB session initialized. User authenticated with translate.schedule permissions.
# @POST Schedule CRUD operations completed. APScheduler jobs registered/unregistered.
# @SIDE_EFFECT Creates/updates/deletes TranslationSchedule records in DB; registers/removes jobs in APScheduler.
from fastapi import APIRouter, Depends, HTTPException, status, Query
from typing import Any, Dict, List, Optional
@@ -23,10 +28,9 @@ from ._router import router
# Schedule
# ============================================================
# [DEF:get_schedule:Function]
# @PURPOSE: Get the schedule for a translation job.
# @PRE: User has translate.schedule.view permission.
# @POST: Returns the schedule configuration.
# #region get_schedule [C:3] [TYPE Function] [SEMANTICS api,translate,schedule]
# @BRIEF Get the schedule configuration for a translation job.
# @RELATION DEPENDS_ON -> [TranslationScheduler]
@router.get("/jobs/{job_id}/schedule")
async def get_schedule(
job_id: str,
@@ -54,13 +58,15 @@ async def get_schedule(
}
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:get_schedule:Function]
# #endregion get_schedule
# [DEF:set_schedule:Function]
# @PURPOSE: Set or update the schedule for a translation job.
# @PRE: User has translate.schedule.manage permission.
# @POST: Schedule is created or updated.
# #region set_schedule [C:4] [TYPE Function] [SEMANTICS api,translate,schedule]
# @BRIEF Set or update the schedule for a translation job.
# @PRE User has translate.schedule.manage permission. Job exists.
# @POST Schedule created or updated. APScheduler job registered.
# @SIDE_EFFECT Creates/updates TranslationSchedule record in DB; registers job in APScheduler.
# @RELATION DEPENDS_ON -> [TranslationScheduler]
@router.put("/jobs/{job_id}/schedule")
async def set_schedule(
job_id: str,
@@ -75,7 +81,7 @@ async def set_schedule(
try:
scheduler = TranslationScheduler(db, config_manager, current_user.username)
# Check if schedule already exists
from ...models.translate import TranslationSchedule
from ....models.translate import TranslationSchedule
existing = db.query(TranslationSchedule).filter(
TranslationSchedule.job_id == job_id
).first()
@@ -94,7 +100,7 @@ async def set_schedule(
is_active=payload.is_active,
)
# Register with APScheduler via SchedulerService
from ...dependencies import get_scheduler_service
from ....dependencies import get_scheduler_service
sched_svc = get_scheduler_service()
sched_svc.add_translation_job(
schedule_id=schedule.id,
@@ -115,13 +121,15 @@ async def set_schedule(
}
except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# [/DEF:set_schedule:Function]
# #endregion set_schedule
# [DEF:enable_schedule:Function]
# @PURPOSE: Enable a schedule for a translation job.
# @PRE: User has translate.schedule.manage permission.
# @POST: Schedule is enabled.
# #region enable_schedule [C:4] [TYPE Function] [SEMANTICS api,translate,schedule]
# @BRIEF Enable a schedule for a translation job.
# @PRE User has translate.schedule.manage permission. Schedule exists.
# @POST Schedule is enabled. APScheduler job registered.
# @SIDE_EFFECT Updates TranslationSchedule is_active to True in DB; registers job in APScheduler.
# @RELATION DEPENDS_ON -> [TranslationScheduler]
@router.post("/jobs/{job_id}/schedule/enable")
async def enable_schedule(
job_id: str,
@@ -135,7 +143,7 @@ async def enable_schedule(
try:
scheduler = TranslationScheduler(db, config_manager, current_user.username)
schedule = scheduler.set_schedule_active(job_id, is_active=True)
from ...dependencies import get_scheduler_service
from ....dependencies import get_scheduler_service
sched_svc = get_scheduler_service()
sched_svc.add_translation_job(
schedule_id=schedule.id,
@@ -146,13 +154,15 @@ async def enable_schedule(
return {"status": "enabled", "job_id": job_id}
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:enable_schedule:Function]
# #endregion enable_schedule
# [DEF:disable_schedule:Function]
# @PURPOSE: Disable a schedule for a translation job.
# @PRE: User has translate.schedule.manage permission.
# @POST: Schedule is disabled.
# #region disable_schedule [C:4] [TYPE Function] [SEMANTICS api,translate,schedule]
# @BRIEF Disable a schedule for a translation job.
# @PRE User has translate.schedule.manage permission. Schedule exists.
# @POST Schedule is disabled. APScheduler job removed.
# @SIDE_EFFECT Updates TranslationSchedule is_active to False in DB; removes job from APScheduler.
# @RELATION DEPENDS_ON -> [TranslationScheduler]
@router.post("/jobs/{job_id}/schedule/disable")
async def disable_schedule(
job_id: str,
@@ -166,19 +176,21 @@ async def disable_schedule(
try:
scheduler = TranslationScheduler(db, config_manager, current_user.username)
scheduler.set_schedule_active(job_id, is_active=False)
from ...dependencies import get_scheduler_service
from ....dependencies import get_scheduler_service
sched_svc = get_scheduler_service()
sched_svc.remove_translation_job(schedule_id=job_id)
return {"status": "disabled", "job_id": job_id}
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:disable_schedule:Function]
# #endregion disable_schedule
# [DEF:delete_schedule:Function]
# @PURPOSE: Delete the schedule for a translation job.
# @PRE: User has translate.schedule.manage permission.
# @POST: Schedule is removed.
# #region delete_schedule [C:4] [TYPE Function] [SEMANTICS api,translate,schedule]
# @BRIEF Delete the schedule for a translation job.
# @PRE User has translate.schedule.manage permission. Schedule exists.
# @POST Schedule is removed. APScheduler job removed.
# @SIDE_EFFECT Deletes TranslationSchedule record from DB; removes job from APScheduler.
# @RELATION DEPENDS_ON -> [TranslationScheduler]
@router.delete("/jobs/{job_id}/schedule", status_code=status.HTTP_204_NO_CONTENT)
async def delete_schedule(
job_id: str,
@@ -192,19 +204,18 @@ async def delete_schedule(
try:
scheduler = TranslationScheduler(db, config_manager, current_user.username)
scheduler.delete_schedule(job_id)
from ...dependencies import get_scheduler_service
from ....dependencies import get_scheduler_service
sched_svc = get_scheduler_service()
sched_svc.remove_translation_job(schedule_id=job_id)
return None
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:delete_schedule:Function]
# #endregion delete_schedule
# [DEF:get_next_executions:Function]
# @PURPOSE: Preview next N executions for a job's schedule.
# @PRE: User has translate.schedule.view permission.
# @POST: Returns next execution times.
# #region get_next_executions [C:3] [TYPE Function] [SEMANTICS api,translate,schedule]
# @BRIEF Preview next N execution times for a job's schedule.
# @RELATION DEPENDS_ON -> [TranslationScheduler]
@router.get("/jobs/{job_id}/schedule/next-executions")
async def get_next_executions(
job_id: str,
@@ -230,6 +241,6 @@ async def get_next_executions(
}
except ValueError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
# [/DEF:get_next_executions:Function]
# #endregion get_next_executions
# [/DEF:TranslateScheduleRoutesModule:Module]
# #endregion TranslateScheduleRoutesModule

View File

@@ -1,3 +1,2 @@
# [DEF:ModelsPackage:Package]
# @PURPOSE: Domain model package root.
# [/DEF:ModelsPackage:Package]
# #region ModelsPackage [C:1] [TYPE Package]
# #endregion ModelsPackage

View File

@@ -1,9 +1,6 @@
# [DEF:TranslateModels:Module]
# @COMPLEXITY: 3
# @SEMANTICS: translate, models, sqlalchemy, persistence
# @PURPOSE: SQLAlchemy ORM models for LLM-based SQL/dashboard translation across dialects.
# @LAYER: Domain
# @RELATION: INHERITS_FROM -> MappingModels:Base
# #region TranslateModels [C:2] [TYPE Module]
# @BRIEF SQLAlchemy ORM models for LLM-based SQL/dashboard translation across dialects.
# @LAYER Domain
from sqlalchemy import (
Column, String, Boolean, DateTime, JSON, Text,
@@ -16,14 +13,13 @@ import hashlib
from .mapping import Base
# #region generate_uuid [C:1] [TYPE Function]
def generate_uuid():
return str(uuid.uuid4())
# #endregion generate_uuid
# [DEF:TranslationJob:Class]
# @PURPOSE: A translation job representing a multi-dialect conversion task with column mappings, LLM config, and dictionary attachments.
# @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.
# #region TranslationJob [C:1] [TYPE Class]
class TranslationJob(Base):
__tablename__ = "translation_jobs"
@@ -55,15 +51,15 @@ class TranslationJob(Base):
# Environment association
environment_id = Column(String, nullable=True, comment="Superset environment ID for datasource access")
target_database_id = Column(String, nullable=True, comment="Superset database ID for INSERT via SQL Lab")
created_by = Column(String, nullable=True)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
# [/DEF:TranslationJob:Class]
# #endregion TranslationJob
# [DEF:TranslationRun:Class]
# @PURPOSE: Represents a single execution of a translation job, capturing status, timing, and Superset execution metadata.
# #region TranslationRun [C:1] [TYPE Class]
class TranslationRun(Base):
__tablename__ = "translation_runs"
@@ -87,11 +83,10 @@ class TranslationRun(Base):
dict_snapshot_hash = Column(String, nullable=True, comment="Hash of dictionary state at run time")
created_by = Column(String, nullable=True)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
# [/DEF:TranslationRun:Class]
# #endregion TranslationRun
# [DEF:TranslationBatch:Class]
# @PURPOSE: Groups translation records within a run into manageable batches with timing and record counts.
# #region TranslationBatch [C:1] [TYPE Class]
class TranslationBatch(Base):
__tablename__ = "translation_batches"
@@ -105,11 +100,10 @@ class TranslationBatch(Base):
started_at = Column(DateTime, nullable=True)
completed_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
# [/DEF:TranslationBatch:Class]
# #endregion TranslationBatch
# [DEF:TranslationRecord:Class]
# @PURPOSE: Individual translation result for a single SQL statement or dashboard element, tracking source, target, and error state.
# #region TranslationRecord [C:1] [TYPE Class]
class TranslationRecord(Base):
__tablename__ = "translation_records"
@@ -126,16 +120,16 @@ class TranslationRecord(Base):
token_count_input = Column(Integer, nullable=True)
token_count_output = Column(Integer, nullable=True)
translation_duration_ms = Column(Integer, nullable=True)
source_data = Column(JSON, nullable=True, comment="Snapshot of source row key/context column values for INSERT phase")
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
__table_args__ = (
Index("ix_translation_records_run_status", "run_id", "status"),
)
# [/DEF:TranslationRecord:Class]
# #endregion TranslationRecord
# [DEF:TranslationEvent:Class]
# @PURPOSE: Audit/event log for translation operations, with optional run_id for context.
# #region TranslationEvent [C:1] [TYPE Class]
class TranslationEvent(Base):
__tablename__ = "translation_events"
@@ -146,11 +140,10 @@ class TranslationEvent(Base):
event_data = Column(JSON, nullable=True)
created_by = Column(String, nullable=True)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
# [/DEF:TranslationEvent:Class]
# #endregion TranslationEvent
# [DEF:TranslationPreviewSession:Class]
# @PURPOSE: A preview session allowing users to review proposed translations before applying them.
# #region TranslationPreviewSession [C:1] [TYPE Class]
class TranslationPreviewSession(Base):
__tablename__ = "translation_preview_sessions"
@@ -161,11 +154,10 @@ class TranslationPreviewSession(Base):
created_by = Column(String, nullable=True)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
expires_at = Column(DateTime, nullable=True)
# [/DEF:TranslationPreviewSession:Class]
# #endregion TranslationPreviewSession
# [DEF:TranslationPreviewRecord:Class]
# @PURPOSE: Individual preview entry within a preview session, showing original and translated content side by side.
# #region TranslationPreviewRecord [C:1] [TYPE Class]
class TranslationPreviewRecord(Base):
__tablename__ = "translation_preview_records"
@@ -178,12 +170,12 @@ class TranslationPreviewRecord(Base):
source_object_name = Column(String, nullable=True)
status = Column(String, nullable=False, default="PENDING") # PENDING, APPROVED, REJECTED
feedback = Column(Text, nullable=True)
source_data = Column(JSON, nullable=True, comment="Snapshot of source row values for key/context columns")
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
# [/DEF:TranslationPreviewRecord:Class]
# #endregion TranslationPreviewRecord
# [DEF:TerminologyDictionary:Class]
# @PURPOSE: A named collection of terminology mappings used during translation to ensure consistent term translation.
# #region TerminologyDictionary [C:1] [TYPE Class]
class TerminologyDictionary(Base):
__tablename__ = "terminology_dictionaries"
@@ -196,11 +188,10 @@ class TerminologyDictionary(Base):
created_by = Column(String, nullable=True)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
# [/DEF:TerminologyDictionary:Class]
# #endregion TerminologyDictionary
# [DEF:DictionaryEntry:Class]
# @PURPOSE: A single term mapping entry with case-insensitive unique constraint on source_term_normalized and origin tracking.
# #region DictionaryEntry [C:1] [TYPE Class]
class DictionaryEntry(Base):
__tablename__ = "dictionary_entries"
@@ -222,11 +213,10 @@ class DictionaryEntry(Base):
name="uq_dictionary_entry_term"
),
)
# [/DEF:DictionaryEntry:Class]
# #endregion DictionaryEntry
# [DEF:TranslationSchedule:Class]
# @PURPOSE: Defines a cron-based schedule for recurring translation jobs.
# #region TranslationSchedule [C:1] [TYPE Class]
class TranslationSchedule(Base):
__tablename__ = "translation_schedules"
@@ -240,11 +230,10 @@ class TranslationSchedule(Base):
created_by = Column(String, nullable=True)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
# [/DEF:TranslationSchedule:Class]
# #endregion TranslationSchedule
# [DEF:TranslationJobDictionary:Class]
# @PURPOSE: Many-to-many association between translation jobs and terminology dictionaries.
# #region TranslationJobDictionary [C:1] [TYPE Class]
class TranslationJobDictionary(Base):
__tablename__ = "translation_job_dictionaries"
@@ -259,11 +248,10 @@ class TranslationJobDictionary(Base):
name="uq_job_dictionary"
),
)
# [/DEF:TranslationJobDictionary:Class]
# #endregion TranslationJobDictionary
# [DEF:MetricSnapshot:Class]
# @PURPOSE: Captures translation performance metrics at a point in time with config/content/dictionary hash keys for aggregation.
# #region MetricSnapshot [C:1] [TYPE Class]
class MetricSnapshot(Base):
__tablename__ = "translation_metric_snapshots"
@@ -290,6 +278,6 @@ class MetricSnapshot(Base):
__table_args__ = (
Index("ix_metric_snapshots_job_date", "job_id", "snapshot_date"),
)
# [/DEF:MetricSnapshot:Class]
# #endregion MetricSnapshot
# [/DEF:TranslateModels:Module]
# #endregion TranslateModels

View File

@@ -1,3 +1,2 @@
# [DEF:TranslatePluginPackage:Package]
# @PURPOSE: Translation plugin package for LLM-based cross-Superset SQL/dashboard translation.
# [/DEF:TranslatePluginPackage:Package]
# #region TranslatePluginPackage [C:1] [TYPE Package]
# #endregion TranslatePluginPackage

View File

@@ -41,6 +41,7 @@ def mock_job() -> MagicMock:
job.source_datasource_id = "42"
job.translation_column = "name"
job.context_columns = ["category"]
job.source_table = "source_table_name"
job.target_schema = "public"
job.target_table = "translated_data"
job.target_key_cols = ["id"]
@@ -152,7 +153,6 @@ class TestTranslationOrchestrator:
# @PURPOSE: start_run creates a PENDING run with event.
def test_start_run_success(
self,
mock_service,
mock_job: MagicMock,
mock_preview_session: MagicMock,
) -> None:

View File

@@ -1,30 +1,22 @@
# [DEF:DictionaryUtils:Module]
# @COMPLEXITY: 1
# @SEMANTICS: dictionary, utils, normalize, delimiter
# @PURPOSE: Utility helpers for dictionary management (term normalization and delimiter detection).
# @LAYER: Domain
# #region DictionaryUtils [C:1] [TYPE Module]
# #endregion DictionaryUtils
import re
import unicodedata
# [DEF:_normalize_term:Function]
# @PURPOSE: Normalize a term for case-insensitive unique constraint lookup.
# @RATIONALE: NFC normalization is applied before lowercasing to ensure consistent
# comparison of Unicode characters (e.g. precomposed vs decomposed forms).
# @REJECTED: Lowercasing without NFC normalization — would cause duplicate entries
# for semantically identical Unicode strings in different normalization forms.
# #region _normalize_term [C:2] [TYPE Function] [SEMANTICS dictionary,normalize]
# @BRIEF Normalize a term for case-insensitive unique constraint lookup using NFC normalization.
def _normalize_term(term: str) -> str:
"""Normalize a term by NFC, lowercasing, and removing extra whitespace."""
if not term:
return ""
term = unicodedata.normalize('NFC', term)
return " ".join(term.lower().split())
# [/DEF:_normalize_term:Function]
# #endregion _normalize_term
# [DEF:_detect_delimiter:Function]
# @PURPOSE: Detect the delimiter used in a CSV/TSV header line.
# #region _detect_delimiter [C:1] [TYPE Function] [SEMANTICS dictionary,delimiter]
def _detect_delimiter(header_line: str) -> str:
"""Detect delimiter by counting tabs vs commas in the first line."""
if not header_line:
@@ -32,4 +24,4 @@ def _detect_delimiter(header_line: str) -> str:
tab_count = header_line.count("\t")
comma_count = header_line.count(",")
return "\t" if tab_count > comma_count else ","
# [/DEF:_detect_delimiter:Function]
# #endregion _detect_delimiter

View File

@@ -1,15 +1,15 @@
# [DEF:DictionaryManagerModule:Module]
# @COMPLEXITY: 4
# @SEMANTICS: dictionary, manager, terminology, crud, import, filter
# @PURPOSE: Business logic for terminology dictionary management, entry CRUD, CSV/TSV import with conflict detection, and per-batch filtering.
# @LAYER: Domain
# @RELATION: DEPENDS_ON -> [TerminologyDictionary:Class]
# @RELATION: DEPENDS_ON -> [DictionaryEntry:Class]
# @RELATION: DEPENDS_ON -> [TranslationJobDictionary:Class]
# @RELATION: DEPENDS_ON -> [TranslationJob:Class]
# @RATIONALE: C4 complexity because dictionary CRUD is stateful with referential integrity enforcement on deletion and unique constraint on normalized source term.
# @REJECTED: Pure C3 CRUD without state guards would allow orphaned job-dictionary links.
# @REJECTED: "Keep both" as conflict option — UniqueConstraint(dictionary_id, source_term_normalized) prohibits variants; only overwrite/keep existing.
# #region DictionaryManagerModule [C:5] [TYPE Module] [SEMANTICS dictionary,manager,terminology,crud,import,filter]
# @BRIEF Business logic for terminology dictionary management, entry CRUD, CSV/TSV import with conflict detection, and per-batch filtering.
# @LAYER Domain
# @RELATION DEPENDS_ON -> [TerminologyDictionary]
# @RELATION DEPENDS_ON -> [DictionaryEntry]
# @RELATION DEPENDS_ON -> [TranslationJobDictionary]
# @RELATION DEPENDS_ON -> [TranslationJob]
# @DATA_CONTRACT Input[name, source_dialect, target_dialect] -> Output[TerminologyDictionary]
# @INVARIANT UniqueConstraint(dictionary_id, source_term_normalized) prohibits duplicate entries within a dictionary.
# @RATIONALE C5 complexity because dictionary CRUD is stateful with referential integrity enforcement on deletion and unique constraint on normalized source term.
# @REJECTED Pure C3 CRUD without state guards would allow orphaned job-dictionary links.
# @REJECTED "Keep both" as conflict option — UniqueConstraint(dictionary_id, source_term_normalized) prohibits variants; only overwrite/keep existing.
import csv
import io
@@ -27,18 +27,17 @@ from ...models.translate import (
from ._utils import _normalize_term, _detect_delimiter
# [DEF:DictionaryManager:Class]
# @COMPLEXITY: 4
# @PURPOSE: Manages terminology dictionaries and their entries with referential integrity.
# @PRE: Database session is open and valid.
# @POST: Dictionary and entry mutations are persisted with conflict detection.
# @SIDE_EFFECT: Creates, updates, deletes TerminologyDictionary and DictionaryEntry rows; enforces deletion guards.
# #region DictionaryManager [C:5] [TYPE Class] [SEMANTICS dictionary,manager,crud,import]
# @BRIEF Manages terminology dictionaries and their entries with referential integrity, import, and batch filtering.
# @PRE Database session is open and valid.
# @POST Dictionary and entry mutations are persisted with conflict detection.
# @SIDE_EFFECT Creates, updates, deletes TerminologyDictionary and DictionaryEntry rows; enforces deletion guards.
# @DATA_CONTRACT Input[name, source_dialect, target_dialect] -> Output[TerminologyDictionary]
# @INVARIANT UniqueConstraint(dictionary_id, source_term_normalized) enforces no duplicates within a dictionary.
class DictionaryManager:
# [DEF:DictionaryManager.create_dictionary:Function]
# @COMPLEXITY: 3
# @PURPOSE: Create a new terminology dictionary.
# @PRE: payload contains name, source_dialect, target_dialect.
# @POST: New TerminologyDictionary row is created and returned.
# #region DictionaryManager.create_dictionary [C:3] [TYPE Function] [SEMANTICS dictionary,create]
# @BRIEF Create a new terminology dictionary with name, source_dialect, and target_dialect.
# @RELATION DEPENDS_ON -> [TerminologyDictionary]
@staticmethod
def create_dictionary(
db: Session, name: str, source_dialect: str, target_dialect: str,
@@ -60,13 +59,11 @@ class DictionaryManager:
db.refresh(dictionary)
logger.reflect("Dictionary created", {"id": dictionary.id})
return dictionary
# [/DEF:DictionaryManager.create_dictionary:Function]
# #endregion DictionaryManager.create_dictionary
# [DEF:DictionaryManager.update_dictionary:Function]
# @COMPLEXITY: 3
# @PURPOSE: Update an existing terminology dictionary.
# @PRE: dict_id exists in terminology_dictionaries table.
# @POST: Dictionary metadata is updated and returned.
# #region DictionaryManager.update_dictionary [C:3] [TYPE Function] [SEMANTICS dictionary,update]
# @BRIEF Update an existing terminology dictionary's metadata fields.
# @RELATION DEPENDS_ON -> [TerminologyDictionary]
@staticmethod
def update_dictionary(
db: Session, dict_id: str, name: Optional[str] = None,
@@ -92,14 +89,13 @@ class DictionaryManager:
db.refresh(dictionary)
logger.reflect("Dictionary updated", {"id": dictionary.id})
return dictionary
# [/DEF:DictionaryManager.update_dictionary:Function]
# #endregion DictionaryManager.update_dictionary
# [DEF:DictionaryManager.delete_dictionary:Function]
# @COMPLEXITY: 4
# @PURPOSE: Delete a dictionary, blocked if attached to active/scheduled jobs.
# @PRE: dict_id exists.
# @POST: Dictionary and its entries are deleted, unless attached to ACTIVE/READY/RUNNING/SCHEDULED jobs.
# @SIDE_EFFECT: Deletes TerminologyDictionary row and all DictionaryEntry rows.
# #region DictionaryManager.delete_dictionary [C:4] [TYPE Function] [SEMANTICS dictionary,delete]
# @BRIEF Delete a dictionary, blocked if attached to active/scheduled jobs (referential integrity guard).
# @PRE dict_id exists.
# @POST Dictionary and its entries are deleted, unless attached to ACTIVE/READY/RUNNING/SCHEDULED jobs.
# @SIDE_EFFECT Deletes TerminologyDictionary row and all DictionaryEntry rows; checks job attachments first.
@staticmethod
def delete_dictionary(db: Session, dict_id: str) -> None:
with belief_scope("DictionaryManager.delete_dictionary"):
@@ -136,26 +132,20 @@ class DictionaryManager:
db.delete(dictionary)
db.commit()
logger.reflect("Dictionary deleted", {"id": dict_id})
# [/DEF:DictionaryManager.delete_dictionary:Function]
# #endregion DictionaryManager.delete_dictionary
# [DEF:DictionaryManager.get_dictionary:Function]
# @COMPLEXITY: 2
# @PURPOSE: Get a single dictionary by ID with entry count.
# @PRE: dict_id exists.
# @POST: Returns dict with dictionary + entry_count or raises ValueError.
# #region DictionaryManager.get_dictionary [C:2] [TYPE Function] [SEMANTICS dictionary,get]
# @BRIEF Get a single dictionary by ID with entry count.
@staticmethod
def get_dictionary(db: Session, dict_id: str) -> TerminologyDictionary:
dictionary = db.query(TerminologyDictionary).filter(TerminologyDictionary.id == dict_id).first()
if not dictionary:
raise ValueError(f"Dictionary not found: {dict_id}")
return dictionary
# [/DEF:DictionaryManager.get_dictionary:Function]
# #endregion DictionaryManager.get_dictionary
# [DEF:DictionaryManager.list_dictionaries:Function]
# @COMPLEXITY: 2
# @PURPOSE: List dictionaries with pagination and entry counts.
# @PRE: page >= 1, page_size between 1 and 100.
# @POST: Returns (list of dicts, total_count).
# #region DictionaryManager.list_dictionaries [C:2] [TYPE Function] [SEMANTICS dictionary,list]
# @BRIEF List dictionaries with pagination and total count.
@staticmethod
def list_dictionaries(
db: Session, page: int = 1, page_size: int = 20,
@@ -169,13 +159,11 @@ class DictionaryManager:
.all()
)
return dictionaries, total
# [/DEF:DictionaryManager.list_dictionaries:Function]
# #endregion DictionaryManager.list_dictionaries
# [DEF:DictionaryManager.add_entry:Function]
# @COMPLEXITY: 3
# @PURPOSE: Add an entry to a dictionary, enforcing unique source_term_normalized.
# @PRE: dict_id exists. source_term and target_term are non-empty.
# @POST: New DictionaryEntry row is created or raises on duplicate.
# #region DictionaryManager.add_entry [C:3] [TYPE Function] [SEMANTICS dictionary,entry,add]
# @BRIEF Add an entry to a dictionary, enforcing unique source_term_normalized per dictionary.
# @RELATION DEPENDS_ON -> [DictionaryEntry]
@staticmethod
def add_entry(
db: Session, dict_id: str, source_term: str, target_term: str,
@@ -210,13 +198,11 @@ class DictionaryManager:
db.refresh(entry)
logger.reflect("Entry added", {"entry_id": entry.id})
return entry
# [/DEF:DictionaryManager.add_entry:Function]
# #endregion DictionaryManager.add_entry
# [DEF:DictionaryManager.edit_entry:Function]
# @COMPLEXITY: 3
# @PURPOSE: Edit an existing dictionary entry with duplicate-aware normalization.
# @PRE: entry_id exists.
# @POST: Entry fields are updated.
# #region DictionaryManager.edit_entry [C:3] [TYPE Function] [SEMANTICS dictionary,entry,edit]
# @BRIEF Edit an existing dictionary entry with duplicate-aware normalization check.
# @RELATION DEPENDS_ON -> [DictionaryEntry]
@staticmethod
def edit_entry(
db: Session, entry_id: str, source_term: Optional[str] = None,
@@ -255,13 +241,10 @@ class DictionaryManager:
db.refresh(entry)
logger.reflect("Entry updated", {"entry_id": entry.id})
return entry
# [/DEF:DictionaryManager.edit_entry:Function]
# #endregion DictionaryManager.edit_entry
# [DEF:DictionaryManager.delete_entry:Function]
# @COMPLEXITY: 2
# @PURPOSE: Delete a single dictionary entry.
# @PRE: entry_id exists.
# @POST: Entry is deleted.
# #region DictionaryManager.delete_entry [C:2] [TYPE Function] [SEMANTICS dictionary,entry,delete]
# @BRIEF Delete a single dictionary entry.
@staticmethod
def delete_entry(db: Session, entry_id: str) -> None:
with belief_scope("DictionaryManager.delete_entry"):
@@ -272,13 +255,10 @@ class DictionaryManager:
db.delete(entry)
db.commit()
logger.reflect("Entry deleted", {"entry_id": entry_id})
# [/DEF:DictionaryManager.delete_entry:Function]
# #endregion DictionaryManager.delete_entry
# [DEF:DictionaryManager.clear_entries:Function]
# @COMPLEXITY: 2
# @PURPOSE: Delete all entries for a dictionary.
# @PRE: dict_id exists.
# @POST: All entries for the dictionary are deleted.
# #region DictionaryManager.clear_entries [C:2] [TYPE Function] [SEMANTICS dictionary,entry,clear]
# @BRIEF Delete all entries for a dictionary.
@staticmethod
def clear_entries(db: Session, dict_id: str) -> int:
with belief_scope("DictionaryManager.clear_entries"):
@@ -290,13 +270,10 @@ class DictionaryManager:
db.commit()
logger.reflect("Entries cleared", {"dict_id": dict_id, "count": deleted})
return deleted
# [/DEF:DictionaryManager.clear_entries:Function]
# #endregion DictionaryManager.clear_entries
# [DEF:DictionaryManager.list_entries:Function]
# @COMPLEXITY: 2
# @PURPOSE: List entries for a dictionary with pagination.
# @PRE: dict_id exists.
# @POST: Returns (list of entries, total_count).
# #region DictionaryManager.list_entries [C:2] [TYPE Function] [SEMANTICS dictionary,entry,list]
# @BRIEF List entries for a dictionary with pagination.
@staticmethod
def list_entries(
db: Session, dict_id: str, page: int = 1, page_size: int = 50,
@@ -316,14 +293,13 @@ class DictionaryManager:
.all()
)
return entries, total
# [/DEF:DictionaryManager.list_entries:Function]
# #endregion DictionaryManager.list_entries
# [DEF:DictionaryManager.import_entries:Function]
# @COMPLEXITY: 4
# @PURPOSE: Import entries from CSV/TSV content with duplicate detection and conflict resolution.
# @PRE: content is valid CSV or TSV. dict_id exists.
# @POST: Entries are created/updated/skipped per conflict mode. Returns result summary.
# @SIDE_EFFECT: Batch-inserts or updates DictionaryEntry rows.
# #region DictionaryManager.import_entries [C:4] [TYPE Function] [SEMANTICS dictionary,import,csv]
# @BRIEF Import entries from CSV/TSV content with duplicate detection and conflict resolution (overwrite/keep_existing/cancel).
# @PRE content is valid CSV or TSV. dict_id exists.
# @POST Entries are created/updated/skipped per conflict mode. Returns result summary with preview support.
# @SIDE_EFFECT Batch-inserts or updates DictionaryEntry rows.
@staticmethod
def import_entries(
db: Session, dict_id: str, content: str,
@@ -456,14 +432,13 @@ class DictionaryManager:
"errors": len(result["errors"]),
})
return result
# [/DEF:DictionaryManager.import_entries:Function]
# #endregion DictionaryManager.import_entries
# [DEF:DictionaryManager.filter_for_batch:Function]
# @COMPLEXITY: 4
# @PURPOSE: Scan batch texts for case-insensitive, word-boundary-aware matches against all dictionaries attached to a job.
# @PRE: job_id exists and source_texts is a list of strings.
# @POST: Returns list of matched entries with match info, sorted by priority across attached dictionaries.
# @SIDE_EFFECT: Queries TranslationJobDictionary, TerminologyDictionary, and DictionaryEntry tables.
# #region DictionaryManager.filter_for_batch [C:4] [TYPE Function] [SEMANTICS dictionary,filter,batch]
# @BRIEF Scan batch texts for case-insensitive, word-boundary-aware matches against all dictionaries attached to a job.
# @PRE job_id exists and source_texts is a list of strings.
# @POST Returns list of matched entries with match info, sorted by dictionary link priority.
# @SIDE_EFFECT Queries TranslationJobDictionary, TerminologyDictionary, and DictionaryEntry tables.
@staticmethod
def filter_for_batch(
db: Session, source_texts: List[str], job_id: str,
@@ -554,15 +529,14 @@ class DictionaryManager:
"dictionaries_used": len(dictionaries),
})
return matched
# [/DEF:DictionaryManager.filter_for_batch:Function]
# #endregion DictionaryManager.filter_for_batch
# [DEF:DictionaryManager.submit_correction:Function]
# @COMPLEXITY: 4
# @PURPOSE: Submit a term correction from a run result. Validates language match, detects conflicts.
# @PRE: source_term, incorrect_target_term, corrected_target_term are non-empty. dict_id exists.
# @POST: Entry created or updated; origin tracking populated. Returns action + conflict info.
# @SIDE_EFFECT: Creates/updates DictionaryEntry row.
# #region DictionaryManager.submit_correction [C:4] [TYPE Function] [SEMANTICS dictionary,correction,submit]
# @BRIEF Submit a term correction from a run result with conflict detection and origin tracking.
# @PRE source_term, incorrect_target_term, corrected_target_term are non-empty. dict_id exists.
# @POST Entry created or updated; origin tracking populated. Returns action + conflict info.
# @SIDE_EFFECT Creates/updates DictionaryEntry row.
@staticmethod
def submit_correction(
db: Session,
@@ -653,14 +627,13 @@ class DictionaryManager:
db.commit()
logger.reflect("Correction processed", result)
return result
# [/DEF:DictionaryManager.submit_correction:Function]
# #endregion DictionaryManager.submit_correction
# [DEF:DictionaryManager.submit_bulk_corrections:Function]
# @COMPLEXITY: 4
# @PURPOSE: Submit multiple term corrections atomically — all succeed or all fail.
# @PRE: corrections list is non-empty. dict_id exists.
# @POST: All corrections applied or none applied with conflict list.
# @SIDE_EFFECT: Creates/updates DictionaryEntry rows; commits once.
# #region DictionaryManager.submit_bulk_corrections [C:4] [TYPE Function] [SEMANTICS dictionary,correction,bulk]
# @BRIEF Submit multiple term corrections atomically — all succeed or all fail on conflict.
# @PRE corrections list is non-empty. dict_id exists.
# @POST All corrections applied or none applied with conflict list (atomic).
# @SIDE_EFFECT Creates/updates DictionaryEntry rows; commits once.
@staticmethod
def submit_bulk_corrections(
db: Session,
@@ -774,8 +747,8 @@ class DictionaryManager:
"total": len(corrections),
"applied": sum(1 for r in results if r["action"] in ("created", "updated")),
}
# [/DEF:DictionaryManager.submit_bulk_corrections:Function]
# #endregion DictionaryManager.submit_bulk_corrections
# [/DEF:DictionaryManager:Class]
# [/DEF:DictionaryManagerModule:Module]
# #endregion DictionaryManager
# #endregion DictionaryManagerModule

View File

@@ -1,18 +1,16 @@
# [DEF:TranslationEventLog:Module]
# @COMPLEXITY: 5
# @SEMANTICS: translate, events, audit, logging
# @PURPOSE: Structured event logging for translation operations with terminal event invariant enforcement.
# @LAYER: Domain
# @RELATION: DEPENDS_ON -> [TranslationEvent]
# @RELATION: DEPENDS_ON -> [MetricSnapshot]
# @PRE: Database session is open and valid.
# @POST: Events are persisted immutably; terminal events enforce exactly-one invariant per run.
# @SIDE_EFFECT: Writes TranslationEvent rows; prunes expired events with MetricSnapshot before deletion.
# @DATA_CONTRACT: Input[run_id:Optional[str], job_id:str, event_type:str, payload:dict] -> Output[TranslationEvent]
# @INVARIANT: Exactly one run_started + exactly one terminal event per non-null run_id.
# @RATIONALE: Immutable event log with nullable run_id allows job-level events (no run context) and run-level events.
# @REJECTED: stdout-only logging — lacks structured payload integrity; cannot enforce terminal-event invariant.
# @REJECTED: Separate event table per entity — single TranslationEvent table with nullable run_id is simpler for audit.
# #region TranslationEventLog [C:5] [TYPE Module] [SEMANTICS translate,events,audit,logging]
# @BRIEF Structured event logging for translation operations with terminal event invariant enforcement.
# @LAYER Domain
# @RELATION DEPENDS_ON -> [TranslationEvent]
# @RELATION DEPENDS_ON -> [MetricSnapshot]
# @PRE Database session is open and valid.
# @POST Events are persisted immutably; terminal events enforce exactly-one invariant per run.
# @SIDE_EFFECT Writes TranslationEvent rows; prunes expired events with MetricSnapshot before deletion.
# @DATA_CONTRACT Input[run_id:Optional[str], job_id:str, event_type:str, payload:dict] -> Output[TranslationEvent]
# @INVARIANT Exactly one run_started + exactly one terminal event per non-null run_id.
# @RATIONALE Immutable event log with nullable run_id allows job-level events (no run context) and run-level events.
# @REJECTED stdout-only logging — lacks structured payload integrity; cannot enforce terminal-event invariant.
# @REJECTED Separate event table per entity — single TranslationEvent table with nullable run_id is simpler for audit.
from typing import Any, Dict, List, Optional
from sqlalchemy.orm import Session
from datetime import datetime, timezone, timedelta
@@ -38,24 +36,48 @@ VALID_EVENT_TYPES = TERMINAL_EVENT_TYPES | {
DEFAULT_RETENTION_DAYS = 90
# [DEF:TranslationEventLog:Class]
# @COMPLEXITY: 5
# @PURPOSE: Structured event logging for translation operations with terminal event invariant enforcement.
# @PRE: Database session is available.
# @POST: Events are written immutably; terminal events enforced per run.
# @SIDE_EFFECT: Writes TranslationEvent rows; prunes expired events.
# @INVARIANT: Exactly one run_started + exactly one terminal event per non-null run_id.
# #region TranslationEventLog [C:5] [TYPE Class] [SEMANTICS translate,events,log,invariants]
# @BRIEF Structured event logging for translation operations with terminal event invariant enforcement and pruning.
# @PRE Database session is available.
# @POST Events are written immutably; terminal events enforced per run; expired events pruned with MetricSnapshot.
# @SIDE_EFFECT Writes TranslationEvent rows; prunes expired events.
# @DATA_CONTRACT Input[run_id:Optional[str], job_id:str, event_type:str, payload:dict] -> Output[TranslationEvent]
# @INVARIANT Exactly one run_started + exactly one terminal event per non-null run_id.
class TranslationEventLog:
def __init__(self, db: Session):
self.db = db
# [DEF:log_event:Function]
# @COMPLEXITY: 4
# @PURPOSE: Write an immutable event. Enforces terminal event invariant for non-null run_id.
# @PRE: event_type must be a known type. If run_id is not None, enforce terminal invariant.
# @POST: TranslationEvent row is created.
# @SIDE_EFFECT: DB write.
# #region _create_event_raw [C:3] [TYPE Function] [SEMANTICS translate,events,create]
# @BRIEF Create a TranslationEvent row without invariant checks. Internal helper for callers that manage invariants themselves.
# @RELATION DEPENDS_ON -> [TranslationEvent]
def _create_event_raw(
self,
job_id: str,
event_type: str,
payload: Optional[Dict[str, Any]] = None,
run_id: Optional[str] = None,
created_by: Optional[str] = None,
) -> TranslationEvent:
event = TranslationEvent(
id=str(uuid.uuid4()),
job_id=job_id,
run_id=run_id,
event_type=event_type,
event_data=payload or {},
created_by=created_by,
created_at=datetime.now(timezone.utc),
)
self.db.add(event)
self.db.flush()
return event
# #endregion _create_event_raw
# #region log_event [C:4] [TYPE Function] [SEMANTICS translate,events,log]
# @BRIEF Write an immutable event. Enforces terminal event invariant and run_started ordering for non-null run_id.
# @PRE event_type must be a known type. If run_id is not None, enforce terminal + start invariants.
# @POST TranslationEvent row is created; invariants checked before write.
# @SIDE_EFFECT DB write.
def log_event(
self,
job_id: str,
@@ -82,9 +104,10 @@ class TranslationEventLog:
.first()
)
if existing_terminal:
existing_id = existing_terminal[0] if isinstance(existing_terminal, (tuple, list)) else str(existing_terminal.id)
raise ValueError(
f"Run '{run_id}' already has a terminal event "
f"({existing_terminal[0]}). Cannot add another terminal event."
f"({existing_id}). Cannot add another terminal event."
)
# Enforce run_started invariant — must exist before other run events
@@ -103,18 +126,13 @@ class TranslationEventLog:
f"RUN_STARTED event must precede other run events."
)
event = TranslationEvent(
id=str(uuid.uuid4()),
event = self._create_event_raw(
job_id=job_id,
run_id=run_id,
event_type=event_type,
event_data=payload or {},
payload=payload,
run_id=run_id,
created_by=created_by,
created_at=datetime.now(timezone.utc),
)
self.db.add(event)
self.db.flush()
logger.reason(f"Event logged: {event_type}", {
"event_id": event.id,
"job_id": job_id,
@@ -122,12 +140,10 @@ class TranslationEventLog:
"event_type": event_type,
})
return event
# [/DEF:log_event:Function]
# #endregion log_event
# [DEF:query_events:Function]
# @PURPOSE: Query events with optional filters.
# @PRE: None.
# @POST: Returns list of TranslationEvent dicts matching filters.
# #region query_events [C:2] [TYPE Function] [SEMANTICS translate,events,query]
# @BRIEF Query events with optional filters (job_id, run_id, event_type) and pagination.
def query_events(
self,
job_id: Optional[str] = None,
@@ -165,13 +181,13 @@ class TranslationEventLog:
}
for e in events
]
# [/DEF:query_events:Function]
# #endregion query_events
# [DEF:prune_expired:Function]
# @PURPOSE: Delete events older than retention_days. Persists MetricSnapshot before pruning.
# @PRE: None.
# @POST: Expired events are deleted; MetricSnapshot is created before deletion.
# @SIDE_EFFECT: Creates MetricSnapshot row; deletes TranslationEvent rows.
# #region prune_expired [C:4] [TYPE Function] [SEMANTICS translate,events,prune]
# @BRIEF Delete events older than retention_days. Creates MetricSnapshot before pruning for audit trail.
# @PRE None.
# @POST Expired events are deleted; MetricSnapshot is created before deletion in batch.
# @SIDE_EFFECT Creates MetricSnapshot row; deletes TranslationEvent rows in batches.
def prune_expired(
self,
retention_days: int = DEFAULT_RETENTION_DAYS,
@@ -233,12 +249,11 @@ class TranslationEventLog:
"snapshot_id": snapshot_id,
})
return {"pruned": pruned, "snapshot_id": snapshot_id}
# [/DEF:prune_expired:Function]
# #endregion prune_expired
# [DEF:get_run_event_summary:Function]
# @PURPOSE: Get a summary of events for a run, including invariant check.
# @PRE: run_id is not None.
# @POST: Returns dict with event list and invariant validity.
# #region get_run_event_summary [C:3] [TYPE Function] [SEMANTICS translate,events,summary]
# @BRIEF Get a summary of events for a run, including invariant validity check.
# @RELATION DEPENDS_ON -> [TranslationEvent]
def get_run_event_summary(self, run_id: str) -> Dict[str, Any]:
with belief_scope("TranslationEventLog.get_run_event_summary"):
events = self.query_events(run_id=run_id)
@@ -255,8 +270,35 @@ class TranslationEventLog:
"invariant_valid": has_started and len(terminal_events) <= 1,
"events": events,
}
# [/DEF:get_run_event_summary:Function]
# #endregion get_run_event_summary
# #region get_run_event_invariants_lightweight [C:3] [TYPE Function] [SEMANTICS translate,events,invariants]
# @BRIEF Lightweight invariant check for a run — fetches only event_type column, avoids event_data for performance.
# @RELATION DEPENDS_ON -> [TranslationEvent]
def get_run_event_invariants_lightweight(self, run_id: str) -> Dict[str, Any]:
with belief_scope("TranslationEventLog.get_run_event_invariants_lightweight"):
# Lightweight query: only fetch event_type column, not event_data
event_types = [
row[0]
for row in (
self.db.query(TranslationEvent.event_type)
.filter(TranslationEvent.run_id == run_id)
.order_by(TranslationEvent.created_at.desc())
.limit(100)
.all()
)
]
has_run_started = "RUN_STARTED" in event_types
terminal_count = sum(1 for et in event_types if et in TERMINAL_EVENT_TYPES)
return {
"has_run_started": has_run_started,
"terminal_event_count": terminal_count,
"invariant_valid": has_run_started and terminal_count <= 1,
}
# #endregion get_run_event_invariants_lightweight
# [/DEF:TranslationEventLog:Class]
# [/DEF:TranslationEventLog:Module]
# #endregion TranslationEventLog
# #endregion TranslationEventLog

View File

@@ -1,19 +1,19 @@
# [DEF:TranslationExecutor:Module]
# @COMPLEXITY: 4
# @SEMANTICS: translate, executor, batch, llm
# @PURPOSE: Process translation in batches: fetch source rows, call LLM, persist TranslationBatch and TranslationRecord rows.
# @LAYER: Domain
# @RELATION: DEPENDS_ON -> [TranslationBatch]
# @RELATION: DEPENDS_ON -> [TranslationRecord]
# @RELATION: DEPENDS_ON -> [TranslationRun]
# @RELATION: DEPENDS_ON -> [LLMProviderService]
# @RELATION: DEPENDS_ON -> [DictionaryManager]
# @RELATION: DEPENDS_ON -> [TranslationPreview]
# @PRE: Valid TranslationRun with job configuration. DB session is available.
# @POST: TranslationBatch and TranslationRecord rows are created. Run status is updated.
# @SIDE_EFFECT: Calls LLM provider; creates DB rows; updates run statistics.
# @RATIONALE: Batch processing with retry — independent batches allow partial recovery.
# @REJECTED: Single monolithic LLM call — would lose all progress on any failure.
# #region TranslationExecutor [C:5] [TYPE Module] [SEMANTICS translate,executor,batch,llm]
# @BRIEF Process translation in batches: fetch source rows, call LLM, persist TranslationBatch and TranslationRecord rows.
# @LAYER Domain
# @RELATION DEPENDS_ON -> [TranslationBatch]
# @RELATION DEPENDS_ON -> [TranslationRecord]
# @RELATION DEPENDS_ON -> [TranslationRun]
# @RELATION DEPENDS_ON -> [LLMProviderService]
# @RELATION DEPENDS_ON -> [DictionaryManager]
# @RELATION DEPENDS_ON -> [TranslationPreview]
# @PRE Valid TranslationRun with job configuration. DB session is available.
# @POST TranslationBatch and TranslationRecord rows are created. Run status is updated.
# @SIDE_EFFECT Calls LLM provider; creates DB rows; updates run statistics.
# @DATA_CONTRACT Input[TranslationRun, job] -> Output[TranslationRun with batches and records]
# @INVARIANT Batch processing with retry — independent batches allow partial recovery; MAX_RETRIES_PER_BATCH = 3.
# @RATIONALE Batch processing with retry — independent batches allow partial recovery.
# @REJECTED Single monolithic LLM call — would lose all progress on any failure.
import json
import time
@@ -35,21 +35,23 @@ from ...models.translate import (
)
from ...services.llm_provider import LLMProviderService
from ...services.llm_prompt_templates import render_prompt
from ...core.superset_client import SupersetClient
from .dictionary import DictionaryManager
from .preview import DEFAULT_EXECUTION_PROMPT_TEMPLATE
# [DEF:MAX_RETRIES_PER_BATCH:Constant]
# @PURPOSE: Maximum number of retries for a single batch before marking it failed.
# #region MAX_RETRIES_PER_BATCH [C:1] [TYPE Constant] [SEMANTICS translate,executor,retry]
MAX_RETRIES_PER_BATCH = 3
# #endregion MAX_RETRIES_PER_BATCH
# [DEF:TranslationExecutor:Class]
# @COMPLEXITY: 4
# @PURPOSE: Process translation batches: fetch source rows, filter dict, call LLM, persist results.
# @PRE: DB session and config manager available.
# @POST: Batches and records created with status tracking.
# @SIDE_EFFECT: LLM API calls; DB writes.
# #region TranslationExecutor [C:5] [TYPE Class] [SEMANTICS translate,executor,batch]
# @BRIEF Process translation batches: fetch source rows, filter dict, call LLM, persist results.
# @PRE DB session and config manager available.
# @POST Batches and records created with status tracking; run statistics updated.
# @SIDE_EFFECT LLM API calls; DB writes.
# @DATA_CONTRACT Input[TranslationRun] -> Output[TranslationRun with batches, records, stats]
# @INVARIANT MAX_RETRIES_PER_BATCH limit enforced; partial batch success tracked via COMPLETED_WITH_ERRORS.
class TranslationExecutor:
def __init__(
@@ -65,15 +67,16 @@ class TranslationExecutor:
self.on_batch_progress = on_batch_progress
self._current_run_id: Optional[str] = None
# [DEF:execute_run:Function]
# @PURPOSE: Run full translation execution for a TranslationRun.
# @PRE: run is in PENDING or RUNNING status with valid job config.
# @POST: Run is populated with batches and records.
# @SIDE_EFFECT: LLM API calls; DB batch writes.
# #region execute_run [C:4] [TYPE Function] [SEMANTICS translate,run,execute]
# @BRIEF Run full translation execution for a TranslationRun: fetch rows, batch, call LLM, persist.
# @PRE run is in PENDING or RUNNING status with valid job config.
# @POST Run is populated with batches and records; run status updated to COMPLETED/FAILED.
# @SIDE_EFFECT LLM API calls; DB batch writes.
def execute_run(
self,
run: TranslationRun,
llm_progress_callback: Optional[Callable[[str, int, int, int], None]] = None,
full_translation: bool = False,
) -> TranslationRun:
with belief_scope("TranslationExecutor.execute_run"):
job = self.db.query(TranslationJob).filter(TranslationJob.id == run.job_id).first()
@@ -84,6 +87,7 @@ class TranslationExecutor:
"run_id": run.id,
"job_id": job.id,
"batch_size": job.batch_size,
"full_translation": full_translation,
})
# Mark run as RUNNING
@@ -91,8 +95,13 @@ class TranslationExecutor:
run.started_at = datetime.now(timezone.utc)
self.db.flush()
# Fetch source rows from the accepted preview session
source_rows = self._fetch_source_rows(job.id, run.id)
# Fetch source rows
if full_translation:
# Full translation: fetch ALL rows from Superset dataset
source_rows = self._fetch_all_rows_from_superset(job)
else:
# Preview-based: fetch rows from the accepted preview session
source_rows = self._fetch_source_rows(job.id, run.id)
if not source_rows:
logger.explore("No source rows to translate", {"run_id": run.id})
run.status = "COMPLETED"
@@ -164,12 +173,13 @@ class TranslationExecutor:
})
return run
# [/DEF:execute_run:Function]
# #endregion execute_run
# [DEF:_fetch_source_rows:Function]
# @PURPOSE: Fetch source rows from the accepted preview session for this job.
# @PRE: job_id exists.
# @POST: Returns list of dicts with source data.
# #region _fetch_source_rows [C:4] [TYPE Function] [SEMANTICS translate,source,rows]
# @BRIEF Fetch source rows from the accepted preview session for this job.
# @PRE job_id exists.
# @POST Returns list of dicts with source data (row_index, source_text, approved_translation, etc).
# @SIDE_EFFECT Queries preview session and records from DB.
def _fetch_source_rows(self, job_id: str, run_id: str) -> List[Dict[str, Any]]:
with belief_scope("TranslationExecutor._fetch_source_rows"):
# Get the latest APPLIED preview session
@@ -203,6 +213,7 @@ class TranslationExecutor:
"source_text": rec.source_sql or "",
"approved_translation": rec.target_sql if rec.status == "APPROVED" else None,
"source_object_name": rec.source_object_name or "",
"source_data": rec.source_data or {},
})
logger.reason(f"Fetched {len(source_rows)} source rows from preview", {
@@ -210,13 +221,115 @@ class TranslationExecutor:
"session_id": session.id,
})
return source_rows
# [/DEF:_fetch_source_rows:Function]
# #endregion _fetch_source_rows
# [DEF:_process_batch:Function]
# @PURPOSE: Process a single batch: filter dict, build prompt, call LLM, persist records.
# @PRE: job and batch_rows are valid.
# @POST: TranslationBatch and TranslationRecord rows are created.
# @SIDE_EFFECT: LLM API call.
# #region _fetch_all_rows_from_superset [C:4] [TYPE Function] [SEMANTICS translate,superset,rows]
# @BRIEF Fetch ALL rows from the Superset dataset for full translation (paginated).
# @PRE job has source_datasource_id and environment_id configured.
# @POST Returns list of row dicts with row_index, source_text, source_data.
# @SIDE_EFFECT Calls Superset chart data endpoint (paginated).
def _fetch_all_rows_from_superset(self, job: TranslationJob) -> List[Dict[str, Any]]:
with belief_scope("TranslationExecutor._fetch_all_rows_from_superset"):
env_id = job.environment_id or job.source_dialect or ""
environments = self.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")
client = SupersetClient(env_config)
dataset_detail = client.get_dataset_detail(int(job.source_datasource_id))
# Build query context (same as preview, but with large row_limit)
query_context = client.build_dataset_preview_query_context(
dataset_id=int(job.source_datasource_id),
dataset_record=dataset_detail,
template_params={},
effective_filters=[],
)
queries = query_context.get("queries", [])
if queries:
queries[0]["row_limit"] = 100000 # Superset max default
queries[0].pop("result_type", None)
queries[0].pop("columns", None)
queries[0]["metrics"] = []
queries[0]["row_offset"] = 0
query_context["result_type"] = "samples"
form_data = query_context.get("form_data", {})
form_data.pop("query_mode", None)
all_rows: List[Dict[str, Any]] = []
batch_size_fetch = 10000 # Fetch 10K rows per pagination request
while True:
# Set pagination for this batch
if queries:
queries[0]["row_limit"] = min(batch_size_fetch, 100000)
queries[0]["row_offset"] = len(all_rows)
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 during full fetch", {
"offset": len(all_rows),
"error": str(e),
})
if not all_rows:
raise ValueError(f"Failed to fetch data from Superset: {e}")
break # Return what we have
from .preview import TranslationPreview
rows = TranslationPreview._extract_data_rows(response)
if not rows:
break # No more data
all_rows.extend(rows)
logger.reason(f"Fetched {len(rows)} rows (total: {len(all_rows)})", {
"offset": len(all_rows) - len(rows),
})
if len(rows) < batch_size_fetch:
break # Last page
# Convert to the format expected by _process_batch
source_rows = []
for idx, row in enumerate(all_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 "")
# Also include target_key_cols values in context_data
if job.target_key_cols:
for col in job.target_key_cols:
context_values[col] = str(row.get(col, "") or "")
source_rows.append({
"row_index": str(idx),
"source_text": translation_value,
"source_data": context_values,
"source_object_name": f"Row {idx + 1}",
"approved_translation": None,
})
logger.reason(f"Prepared {len(source_rows)} source rows for full translation", {
"job_id": job.id,
})
return source_rows
# #endregion _fetch_all_rows_from_superset
# #region _process_batch [C:4] [TYPE Function] [SEMANTICS translate,batch,process]
# @BRIEF Process a single batch: filter dictionary, build prompt, call LLM, persist records.
# @PRE job and batch_rows are valid.
# @POST TranslationBatch and TranslationRecord rows are created.
# @SIDE_EFFECT LLM API call; DB writes.
def _process_batch(
self,
job: TranslationJob,
@@ -275,6 +388,7 @@ class TranslationExecutor:
source_object_type="table_row",
source_object_id=row.get("row_index"),
source_object_name=row.get("source_object_name", ""),
source_data=row.get("source_data", {}),
status="SUCCESS",
)
self.db.add(record)
@@ -309,13 +423,13 @@ class TranslationExecutor:
})
return result
# [/DEF:_process_batch:Function]
# #endregion _process_batch
# [DEF:_call_llm_for_batch:Function]
# @PURPOSE: Call LLM for a batch of rows requiring translation. Parse structured JSON response.
# @PRE: job has valid provider_id. batch_rows is non-empty.
# @POST: Returns dict with successful/failed/skipped counts. Creates TranslationRecord rows.
# @SIDE_EFFECT: HTTP call to LLM provider.
# #region _call_llm_for_batch [C:4] [TYPE Function] [SEMANTICS translate,llm,batch]
# @BRIEF Call LLM for a batch of rows requiring translation. Parse structured JSON response.
# @PRE job has valid provider_id. batch_rows is non-empty.
# @POST Returns dict with successful/failed/skipped counts. Creates TranslationRecord rows.
# @SIDE_EFFECT HTTP call to LLM provider.
def _call_llm_for_batch(
self,
job: TranslationJob,
@@ -401,6 +515,7 @@ class TranslationExecutor:
source_object_type="table_row",
source_object_id=row.get("row_index"),
source_object_name=row.get("source_object_name", ""),
source_data=row.get("source_data", {}),
status="FAILED",
error_message=f"LLM call failed after {retries} retries: {last_error}",
)
@@ -428,6 +543,7 @@ class TranslationExecutor:
source_object_type="table_row",
source_object_id=row.get("row_index"),
source_object_name=row.get("source_object_name", ""),
source_data=row.get("source_data", {}),
status="SKIPPED",
error_message=f"LLM parse failure: {e}",
)
@@ -459,6 +575,7 @@ class TranslationExecutor:
source_object_type="table_row",
source_object_id=row.get("row_index"),
source_object_name=row.get("source_object_name", ""),
source_data=row.get("source_data", {}),
status="SKIPPED",
error_message="NULL translation returned by LLM",
)
@@ -477,6 +594,7 @@ class TranslationExecutor:
source_object_type="table_row",
source_object_id=row.get("row_index"),
source_object_name=row.get("source_object_name", ""),
source_data=row.get("source_data", {}),
status="SKIPPED",
error_message="Empty translation returned by LLM",
)
@@ -493,6 +611,7 @@ class TranslationExecutor:
source_object_type="table_row",
source_object_id=row.get("row_index"),
source_object_name=row.get("source_object_name", ""),
source_data=row.get("source_data", {}),
status="SUCCESS",
)
self.db.add(record)
@@ -503,13 +622,13 @@ class TranslationExecutor:
"skipped": skipped,
"retries": retries,
}
# [/DEF:_call_llm_for_batch:Function]
# #endregion _call_llm_for_batch
# [DEF:_call_llm:Function]
# @PURPOSE: Call the configured LLM provider with the batch prompt.
# @PRE: job has valid provider_id.
# @POST: Returns raw LLM response string.
# @SIDE_EFFECT: HTTP call to LLM provider.
# #region _call_llm [C:4] [TYPE Function] [SEMANTICS translate,llm,call]
# @BRIEF Call the configured LLM provider with the batch prompt, routing by provider type.
# @PRE job has valid provider_id.
# @POST Returns raw LLM response string.
# @SIDE_EFFECT HTTP call to LLM provider.
def _call_llm(self, job: TranslationJob, prompt: str) -> str:
with belief_scope("TranslationExecutor._call_llm"):
if not job.provider_id:
@@ -537,13 +656,13 @@ class TranslationExecutor:
)
else:
raise ValueError(f"Unsupported provider type '{provider_type}'")
# [/DEF:_call_llm:Function]
# #endregion _call_llm
# [DEF:_call_openai_compatible:Function]
# @PURPOSE: Call OpenAI-compatible API for batch translation.
# @PRE: Valid API endpoint, key, model, and prompt.
# @POST: Returns response text.
# @SIDE_EFFECT: HTTP POST to LLM API.
# #region _call_openai_compatible [C:4] [TYPE Function] [SEMANTICS translate,llm,openai]
# @BRIEF Call OpenAI-compatible API for batch translation with retry and structured output support.
# @PRE Valid API endpoint, key, model, and prompt.
# @POST Returns response text.
# @SIDE_EFFECT HTTP POST to LLM API.
@staticmethod
def _call_openai_compatible(
base_url: str,
@@ -596,15 +715,26 @@ class TranslationExecutor:
content = choices[0].get("message", {}).get("content", "")
if not content:
raise ValueError("LLM returned empty content")
# Log full response for diagnostics
finish_reason = choices[0].get("finish_reason", "unknown")
logger.explore("LLM returned empty content", {
"finish_reason": finish_reason,
"model": payload.get("model"),
"prompt_len": len(prompt),
"response_keys": list(data.keys()),
"choices_count": len(choices),
})
raise ValueError(
f"LLM returned empty content (finish_reason={finish_reason}, "
f"model={payload.get('model')}, prompt_len={len(prompt)})"
)
return content
# [/DEF:_call_openai_compatible:Function]
# #endregion _call_openai_compatible
# [DEF:_parse_llm_response:Function]
# @PURPOSE: Parse LLM JSON response into dict of row_id -> translation.
# @PRE: response_text is valid JSON with {"rows": [...]} structure.
# @POST: Returns dict mapping row_id to translation text.
# #region _parse_llm_response [C:3] [TYPE Function] [SEMANTICS translate,llm,parse]
# @BRIEF Parse LLM JSON response into dict of row_id -> translation text.
# @RELATION DEPENDS_ON -> [json]
@staticmethod
def _parse_llm_response(response_text: str, expected_count: int) -> Dict[str, str]:
with belief_scope("TranslationExecutor._parse_llm_response"):
@@ -637,8 +767,8 @@ class TranslationExecutor:
translations[row_id] = str(translation)
return translations
# [/DEF:_parse_llm_response:Function]
# #endregion _parse_llm_response
# [/DEF:TranslationExecutor:Class]
# [/DEF:TranslationExecutor:Module]
# #endregion TranslationExecutor
# #endregion TranslationExecutor

View File

@@ -1,16 +1,10 @@
# [DEF:TranslationMetrics:Module]
# @COMPLEXITY: 3
# @SEMANTICS: translate, metrics, aggregation
# @PURPOSE: Aggregate translation metrics from live TranslationEvent + MetricSnapshot for per-job reporting.
# @LAYER: Domain
# @RELATION: DEPENDS_ON -> [TranslationEvent:Class]
# @RELATION: DEPENDS_ON -> [MetricSnapshot:Class]
# @RELATION: DEPENDS_ON -> [TranslationRun:Class]
# @RELATION: DEPENDS_ON -> [TranslationSchedule:Class]
# @PRE: Database session is open.
# @POST: Metrics are aggregated and returned; no side effects.
# @RATIONALE: Live events (<90 days) + MetricSnapshot (>=90 days) fusion for complete picture.
# @REJECTED: Querying all events for all time — would be slow; MetricSnapshot provides historical summary.
# #region TranslationMetrics [C:3] [TYPE Module] [SEMANTICS translate,metrics,aggregation]
# @BRIEF Aggregate translation metrics from live TranslationEvent + MetricSnapshot for per-job reporting.
# @LAYER Domain
# @RELATION DEPENDS_ON -> [TranslationEvent]
# @RELATION DEPENDS_ON -> [MetricSnapshot]
# @RELATION DEPENDS_ON -> [TranslationRun]
# @RELATION DEPENDS_ON -> [TranslationSchedule]
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional, Tuple
@@ -26,18 +20,18 @@ from ...models.translate import (
)
# [DEF:TranslationMetrics:Class]
# @COMPLEXITY: 3
# @PURPOSE: Aggregate translation metrics from live events and MetricSnapshot.
# #region TranslationMetrics [C:3] [TYPE Class] [SEMANTICS translate,metrics]
# @BRIEF Aggregate translation metrics from live events and MetricSnapshot.
# @RELATION DEPENDS_ON -> [TranslationRun]
# @RELATION DEPENDS_ON -> [MetricSnapshot]
# @RELATION DEPENDS_ON -> [TranslationEvent]
class TranslationMetrics:
def __init__(self, db: Session):
self.db = db
# [DEF:get_job_metrics:Function]
# @PURPOSE: Get aggregated metrics for a specific job.
# @PRE: job_id exists.
# @POST: Returns dict with metrics from events + latest snapshot.
# #region get_job_metrics [C:2] [TYPE Function] [SEMANTICS translate,metrics,job]
# @BRIEF Get aggregated metrics for a specific job including run counts, record stats, and duration.
def get_job_metrics(self, job_id: str) -> Dict[str, Any]:
with belief_scope("TranslationMetrics.get_job_metrics"):
# Run counts from TranslationRun
@@ -151,11 +145,10 @@ class TranslationMetrics:
"last_run_at": last_run.created_at.isoformat() if last_run else None,
"next_scheduled_run": next_schedule.last_run_at.isoformat() if next_schedule and next_schedule.last_run_at else None,
}
# [/DEF:get_job_metrics:Function]
# #endregion get_job_metrics
# [DEF:get_all_metrics:Function]
# @PURPOSE: Get aggregated metrics for all jobs.
# @POST: Returns list of per-job metrics.
# #region get_all_metrics [C:2] [TYPE Function] [SEMANTICS translate,metrics,all]
# @BRIEF Get aggregated metrics for all jobs.
def get_all_metrics(self) -> List[Dict[str, Any]]:
with belief_scope("TranslationMetrics.get_all_metrics"):
job_ids = (
@@ -164,8 +157,8 @@ class TranslationMetrics:
.all()
)
return [self.get_job_metrics(jid[0]) for jid in job_ids if jid[0]]
# [/DEF:get_all_metrics:Function]
# #endregion get_all_metrics
# [/DEF:TranslationMetrics:Class]
# [/DEF:TranslationMetrics:Module]
# #endregion TranslationMetrics
# #endregion TranslationMetrics

View File

@@ -1,23 +1,21 @@
# [DEF:TranslationOrchestrator:Module]
# @COMPLEXITY: 5
# @SEMANTICS: translate, orchestrator, lifecycle, run
# @PURPOSE: 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.
# #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
@@ -44,13 +42,13 @@ from .events import TranslationEventLog
from ..translate.service import TranslateJobService
# [DEF:TranslationOrchestrator:Class]
# @COMPLEXITY: 5
# @PURPOSE: 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.
# #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__(
@@ -65,12 +63,11 @@ class TranslationOrchestrator:
self.event_log = TranslationEventLog(db)
self._job: Optional[TranslationJob] = None
# [DEF:start_run:Function]
# @COMPLEXITY: 5
# @PURPOSE: Start a new translation run for a job.
# @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.
# #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,
@@ -166,18 +163,19 @@ class TranslationOrchestrator:
"trigger_type": run.trigger_type,
})
return run
# [/DEF:start_run:Function]
# #endregion start_run
# [DEF:execute_run:Function]
# @PURPOSE: 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.
# #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()
@@ -212,7 +210,7 @@ class TranslationOrchestrator:
on_batch_progress=on_batch_progress,
)
try:
run = executor.execute_run(run, llm_progress_callback=None)
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,
@@ -299,13 +297,13 @@ class TranslationOrchestrator:
"insert_status": run.insert_status,
})
return run
# [/DEF:execute_run:Function]
# #endregion execute_run
# [DEF:_generate_and_insert_sql:Function]
# @PURPOSE: 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.
# #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,
@@ -333,11 +331,12 @@ class TranslationOrchestrator:
})
# Build rows for SQL generation
columns = job.context_columns or []
if job.translation_column and job.translation_column not in columns:
# Only include the translation column — context columns are for LLM only
columns = []
if job.translation_column:
columns.append(job.translation_column)
# Also include key columns if used for upsert
# 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:
@@ -346,14 +345,13 @@ class TranslationOrchestrator:
rows_for_sql = []
for rec in records:
row_data = {}
if job.context_columns:
for col in job.context_columns:
row_data[col] = ""
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:
row_data[k] = ""
# 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:
@@ -377,6 +375,16 @@ class TranslationOrchestrator:
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,
@@ -388,7 +396,18 @@ class TranslationOrchestrator:
# Submit to Superset
try:
env_id = job.environment_id or job.source_dialect or ""
executor = SupersetSqlLabExecutor(self.config_manager, env_id)
# 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,
@@ -411,13 +430,13 @@ class TranslationOrchestrator:
)
return result
# [/DEF:_generate_and_insert_sql:Function]
# #endregion _generate_and_insert_sql
# [DEF:_validate_preconditions:Function]
# @PURPOSE: Validate preconditions before starting a run.
# @PRE: None.
# @POST: Raises ValueError if preconditions are not met.
# @SIDE_EFFECT: None.
# #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,
@@ -473,13 +492,13 @@ class TranslationOrchestrator:
"job_id": job.id,
"is_scheduled": is_scheduled,
})
# [/DEF:_validate_preconditions:Function]
# #endregion _validate_preconditions
# [DEF:retry_failed_batches:Function]
# @PURPOSE: Retry failed batches in a run.
# @PRE: run exists and has failed batches.
# @POST: Failed batches are re-processed.
# @SIDE_EFFECT: LLM calls, DB writes.
# #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,
@@ -588,13 +607,13 @@ class TranslationOrchestrator:
"status": run.status,
})
return run
# [/DEF:retry_failed_batches:Function]
# #endregion retry_failed_batches
# [DEF:retry_insert:Function]
# @PURPOSE: 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.
# #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,
@@ -638,13 +657,13 @@ class TranslationOrchestrator:
"insert_status": run.insert_status,
})
return run
# [/DEF:retry_insert:Function]
# #endregion retry_insert
# [DEF:cancel_run:Function]
# @PURPOSE: Cancel a running translation.
# @PRE: run is in PENDING or RUNNING status.
# @POST: Run status is set to CANCELLED.
# @SIDE_EFFECT: DB write; records event.
# #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()
@@ -661,7 +680,8 @@ class TranslationOrchestrator:
run.completed_at = datetime.now(timezone.utc)
self.db.flush()
self.event_log.log_event(
# 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",
@@ -676,12 +696,12 @@ class TranslationOrchestrator:
"run_id": run_id,
})
return run
# [/DEF:cancel_run:Function]
# #endregion cancel_run
# [DEF:get_run_status:Function]
# @PURPOSE: Get run status with statistics.
# @PRE: run_id exists.
# @POST: Returns dict with run details.
# #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()
@@ -695,8 +715,8 @@ class TranslationOrchestrator:
.count()
)
# Get event summary
event_summary = self.event_log.get_run_event_summary(run_id)
# Get event invariants (lightweight — only fetches event_type column)
invariants = self.event_log.get_run_event_invariants_lightweight(run_id)
return {
"id": run.id,
@@ -712,20 +732,16 @@ class TranslationOrchestrator:
"insert_status": run.insert_status,
"superset_execution_id": run.superset_execution_id,
"batch_count": batch_count,
"event_invariants": {
"has_run_started": event_summary["has_run_started"],
"terminal_event_count": event_summary["terminal_event_count"],
"invariant_valid": event_summary["invariant_valid"],
},
"event_invariants": invariants,
"created_by": run.created_by,
"created_at": run.created_at.isoformat() if run.created_at else None,
}
# [/DEF:get_run_status:Function]
# #endregion get_run_status
# [DEF:get_run_records:Function]
# @PURPOSE: Get paginated records for a run.
# @PRE: run_id exists.
# @POST: Returns dict with records and pagination info.
# #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,
@@ -771,12 +787,12 @@ class TranslationOrchestrator:
"page_size": page_size,
"status_filter": status_filter,
}
# [/DEF:get_run_records:Function]
# #endregion get_run_records
# [DEF:get_run_history:Function]
# @PURPOSE: Get run history for a job.
# @PRE: job_id exists.
# @POST: Returns list of runs.
# #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,
@@ -814,10 +830,10 @@ class TranslationOrchestrator:
}
for r in runs
]
# [/DEF:get_run_history:Function]
# #endregion get_run_history
# [DEF:_compute_config_hash:Function]
# @PURPOSE: Compute a hash of the job's current configuration for snapshot comparison.
# #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
@@ -833,10 +849,10 @@ class TranslationOrchestrator:
"upsert_strategy": job.upsert_strategy,
}, sort_keys=True)
return hashlib.sha256(config_str.encode()).hexdigest()[:16]
# [/DEF:_compute_config_hash:Function]
# #endregion _compute_config_hash
# [DEF:_compute_dict_snapshot_hash:Function]
# @PURPOSE: Compute a hash of dictionary state for snapshot comparison.
# #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
@@ -848,8 +864,8 @@ class TranslationOrchestrator:
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 _compute_dict_snapshot_hash
# [/DEF:TranslationOrchestrator:Class]
# [/DEF:TranslationOrchestrator:Module]
# #endregion TranslationOrchestrator
# #endregion TranslationOrchestrator

View File

@@ -1,19 +1,15 @@
# [DEF:TranslatePlugin:Module]
# @COMPLEXITY: 2
# @SEMANTICS: plugin, translate, llm, sql, dashboard
# @PURPOSE: TranslatePlugin skeleton for cross-dialect SQL and dashboard translation.
# @LAYER: Domain
# @RELATION: INHERITS -> [PluginBase]
# @RATIONALE: Separate plugin avoids bloating LLMAnalysisPlugin beyond fractal limit (<400 lines).
# @REJECTED: Extending LLMAnalysisPlugin would conflate two distinct feature domains.
# #region TranslatePlugin [C:3] [TYPE Module] [SEMANTICS plugin,translate,llm,sql,dashboard]
# @BRIEF TranslatePlugin skeleton for cross-dialect SQL and dashboard translation.
# @LAYER Domain
# @RELATION INHERITS -> [PluginBase]
from typing import Dict, Any, Optional
from ...core.plugin_base import PluginBase
# [DEF:TranslatePlugin:Class]
# @PURPOSE: Plugin for translating SQL queries and dashboard definitions across database dialects.
# @RELATION: IMPLEMENTS -> backend.src.core.plugin_base.PluginBase
# #region TranslatePlugin [C:3] [TYPE Class] [SEMANTICS plugin,translate]
# @BRIEF Plugin for translating SQL queries and dashboard definitions across database dialects.
# @RELATION IMPLEMENTS -> [PluginBase]
class TranslatePlugin(PluginBase):
@property
def id(self) -> str:
@@ -57,6 +53,6 @@ class TranslatePlugin(PluginBase):
async def execute(self, params: Dict[str, Any], context: Optional[Any] = None):
"""Execute a translation job — implementation deferred to later phases."""
raise NotImplementedError("TranslatePlugin.execute not yet implemented")
# [/DEF:TranslatePlugin:Class]
# #endregion TranslatePlugin
# [/DEF:TranslatePlugin:Module]
# #endregion TranslatePlugin

View File

@@ -1,21 +1,21 @@
# [DEF:TranslationPreview:Module]
# @COMPLEXITY: 4
# @SEMANTICS: translate, preview, llm, session
# @PURPOSE: 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.
# #region TranslationPreview [C:5] [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]
# @RELATION DEPENDS_ON -> [TranslationPreviewSession]
# @RELATION DEPENDS_ON -> [TranslationPreviewRecord]
# @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.
# @DATA_CONTRACT Input[job_id, sample_size] -> Output[Dict with session, records, cost_estimate]
# @INVARIANT Preview sessions expire after 24 hours; exactly one ACTIVE session per job at a time.
# @RATIONALE C5 because preview is stateful with approve/edit/reject lifecycle and LLM API calls requiring structured error handling.
# @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
@@ -38,8 +38,7 @@ from ...services.llm_provider import LLMProviderService
from ...services.llm_prompt_templates import render_prompt
from .dictionary import DictionaryManager
# [DEF:DEFAULT_EXECUTION_PROMPT_TEMPLATE:Constant]
# @PURPOSE: Default prompt template for batch LLM translation execution (no context columns — faster).
# #region DEFAULT_EXECUTION_PROMPT_TEMPLATE [C:1] [TYPE Constant] [SEMANTICS translate,prompt]
DEFAULT_EXECUTION_PROMPT_TEMPLATE: str = (
"Translate the following database content from {source_language} to {target_language}.\n\n"
"Source dialect: {source_dialect}\n"
@@ -52,11 +51,10 @@ DEFAULT_EXECUTION_PROMPT_TEMPLATE: str = (
'{{"rows": [{{"row_id": "<row_index>", "translation": "<translated_text>"}}]}}\n'
"Each row_id must match the index provided. Return exactly {row_count} entries."
)
# [/DEF:DEFAULT_EXECUTION_PROMPT_TEMPLATE:Constant]
# #endregion DEFAULT_EXECUTION_PROMPT_TEMPLATE
# [DEF:DEFAULT_PREVIEW_PROMPT_TEMPLATE:Constant]
# @PURPOSE: Default prompt template for LLM translation preview.
# #region DEFAULT_PREVIEW_PROMPT_TEMPLATE [C:1] [TYPE Constant] [SEMANTICS translate,prompt]
DEFAULT_PREVIEW_PROMPT_TEMPLATE: str = (
"Translate the following database content from {source_language} to {target_language}.\n\n"
"Source dialect: {source_dialect}\n"
@@ -70,13 +68,11 @@ DEFAULT_PREVIEW_PROMPT_TEMPLATE: str = (
'{{"rows": [{{"row_id": "<row_index>", "translation": "<translated_text>"}}]}}\n'
"Each row_id must match the index provided. Return exactly {row_count} entries."
)
# [/DEF:DEFAULT_PREVIEW_PROMPT_TEMPLATE:Constant]
# #endregion DEFAULT_PREVIEW_PROMPT_TEMPLATE
# [DEF:TokenEstimator:Class]
# @PURPOSE: 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.
# #region TokenEstimator [C:2] [TYPE Class] [SEMANTICS translate,tokens,estimate]
# @BRIEF Estimate token counts and costs for LLM translation operations using heuristic chars/token ratio.
class TokenEstimator:
"""Estimate token counts and costs for LLM operations."""
@@ -84,46 +80,38 @@ class TokenEstimator:
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).
# #region estimate_prompt_tokens [C:1] [TYPE Function] [SEMANTICS translate,tokens]
@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]
# #endregion estimate_prompt_tokens
# [DEF:estimate_output_tokens:Function]
# @PURPOSE: Estimate output token count for translating N rows.
# @PRE: row_count >= 0.
# @POST: Returns estimated output token count.
# #region estimate_output_tokens [C:1] [TYPE Function] [SEMANTICS translate,tokens]
@staticmethod
def estimate_output_tokens(row_count: int) -> int:
return row_count * TokenEstimator.OUTPUT_TOKENS_PER_ROW_ESTIMATE
# [/DEF:estimate_output_tokens:Function]
# #endregion estimate_output_tokens
# [DEF:estimate_cost:Function]
# @PURPOSE: Estimate cost for a given number of tokens.
# @PRE: total_tokens >= 0.
# @POST: Returns estimated cost in USD.
# #region estimate_cost [C:1] [TYPE Function] [SEMANTICS translate,cost]
@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 estimate_cost
# [/DEF:TokenEstimator:Class]
# #endregion TokenEstimator
# [DEF:TranslationPreview:Class]
# @COMPLEXITY: 4
# @PURPOSE: 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.
# #region TranslationPreview [C:5] [TYPE Class] [SEMANTICS translate,preview,lifecycle]
# @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.
# @DATA_CONTRACT Input[job_id, sample_size] -> Output[Dict with session, records, cost_estimate]
# @INVARIANT Preview sessions expire after 24 hours; exactly one ACTIVE session per job at a time.
class TranslationPreview:
def __init__(
@@ -136,12 +124,11 @@ class TranslationPreview:
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.
# #region preview_rows [C:4] [TYPE Function] [SEMANTICS translate,preview,rows]
# @BRIEF 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,
@@ -303,6 +290,7 @@ class TranslationPreview:
source_object_name=f"Row {idx + 1}",
status=status,
feedback=feedback,
source_data=meta.get("context_data"),
created_at=datetime.now(timezone.utc),
)
self.db.add(record)
@@ -348,12 +336,13 @@ class TranslationPreview:
"sample_cost": sample_cost,
})
return result
# [/DEF:preview_rows:Function]
# #endregion preview_rows
# [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.
# #region update_preview_row [C:4] [TYPE Function] [SEMANTICS translate,preview,row,update]
# @BRIEF Approve, edit, or reject an individual preview row.
# @PRE session_id and row_id exist, session is ACTIVE.
# @POST PreviewRecord status is updated (APPROVED, REJECTED, or APPROVED with edited translation).
# @SIDE_EFFECT DB write.
def update_preview_row(
self,
job_id: str,
@@ -417,13 +406,13 @@ class TranslationPreview:
"status": record.status,
"feedback": record.feedback,
}
# [/DEF:update_preview_row:Function]
# #endregion update_preview_row
# [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.
# #region accept_preview_session [C:4] [TYPE Function] [SEMANTICS translate,preview,accept]
# @BRIEF Mark a preview session as accepted (APPLIED), which gates full execution.
# @PRE job_id has an ACTIVE preview session.
# @POST Session status changes to APPLIED; future full execution calls check for accepted session.
# @SIDE_EFFECT DB write.
def accept_preview_session(self, job_id: str) -> Dict[str, Any]:
with belief_scope("TranslationPreview.accept_preview_session"):
session = (
@@ -472,12 +461,11 @@ class TranslationPreview:
for r in records
],
}
# [/DEF:accept_preview_session:Function]
# #endregion accept_preview_session
# [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.
# #region get_preview_session [C:3] [TYPE Function] [SEMANTICS translate,preview,get]
# @BRIEF Get the latest preview session for a job with its records.
# @RELATION DEPENDS_ON -> [TranslationPreviewSession]
def get_preview_session(self, job_id: str) -> Dict[str, Any]:
with belief_scope("TranslationPreview.get_preview_session"):
session = (
@@ -516,14 +504,13 @@ class TranslationPreview:
for r in records
],
}
# [/DEF:get_preview_session:Function]
# #endregion get_preview_session
# [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.
# #region _fetch_sample_rows [C:4] [TYPE Function] [SEMANTICS translate,superset,sample]
# @BRIEF 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 from Superset chart data API.
# @SIDE_EFFECT Calls Superset chart data endpoint with pagination.
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)
@@ -602,12 +589,11 @@ class TranslationPreview:
)
return rows
# [/DEF:_fetch_sample_rows:Function]
# #endregion _fetch_sample_rows
# [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.
# #region _extract_data_rows [C:3] [TYPE Function] [SEMANTICS translate,superset,data]
# @BRIEF Extract data rows from Superset chart data response, trying multiple response formats.
# @RELATION DEPENDS_ON -> [SupersetClient]
@staticmethod
def _extract_data_rows(response: Dict[str, Any]) -> List[Dict[str, Any]]:
with belief_scope("TranslationPreview._extract_data_rows"):
@@ -636,14 +622,13 @@ class TranslationPreview:
return result
return []
# [/DEF:_extract_data_rows:Function]
# #endregion _extract_data_rows
# [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.
# #region _call_llm [C:4] [TYPE Function] [SEMANTICS translate,llm,call]
# @BRIEF 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:
@@ -679,13 +664,13 @@ class TranslationPreview:
"response_length": len(response_text),
})
return response_text
# [/DEF:_call_llm:Function]
# #endregion _call_llm
# [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.
# #region _call_openai_compatible [C:4] [TYPE Function] [SEMANTICS translate,llm,openai]
# @BRIEF Call an OpenAI-compatible API for translation with structured output support.
# @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,
@@ -741,12 +726,13 @@ class TranslationPreview:
raise ValueError("LLM returned empty content")
return content
# [/DEF:_call_openai_compatible:Function]
# #endregion _call_openai_compatible
# [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.
# #region _parse_llm_response [C:4] [TYPE Function] [SEMANTICS translate,llm,parse]
# @BRIEF Parse the LLM JSON response into a dict of row_id -> translation, with markdown code block fallback.
# @PRE response_text is valid JSON with {"rows": [...]} structure.
# @POST Returns dict mapping string row_id to translation text; warns if fewer translations than expected.
# @SIDE_EFFECT Logs warnings for short responses.
@staticmethod
def _parse_llm_response(response_text: str, expected_count: int) -> Dict[str, str]:
with belief_scope("TranslationPreview._parse_llm_response"):
@@ -785,10 +771,10 @@ class TranslationPreview:
)
return translations
# [/DEF:_parse_llm_response:Function]
# #endregion _parse_llm_response
# [DEF:_compute_config_hash:Function]
# @PURPOSE: Compute a hash of the job's current configuration for snapshot comparison.
# #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:
config_str = json.dumps({
@@ -803,10 +789,10 @@ class TranslationPreview:
"upsert_strategy": job.upsert_strategy,
}, sort_keys=True)
return hashlib.sha256(config_str.encode()).hexdigest()[:16]
# [/DEF:_compute_config_hash:Function]
# #endregion _compute_config_hash
# [DEF:_compute_dict_snapshot_hash:Function]
# @PURPOSE: Compute a hash of the dictionary state for snapshot comparison.
# #region _compute_dict_snapshot_hash [C:2] [TYPE Function] [SEMANTICS translate,hash]
# @BRIEF Compute a SHA-256 hash of the dictionary state for snapshot comparison.
def _compute_dict_snapshot_hash(self, job_id: str) -> str:
dict_links = (
self.db.query(TranslationJobDictionary)
@@ -816,9 +802,8 @@ class TranslationPreview:
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 _compute_dict_snapshot_hash
# [/DEF:TranslationPreview:Class]
# [/DEF:TranslationPreview:Module]
# #endregion TranslationPreview
# #endregion TranslationPreview

View File

@@ -1,18 +1,18 @@
# [DEF:TranslationScheduler:Module]
# @COMPLEXITY: 4
# @SEMANTICS: translate, scheduler, apscheduler, cron
# @PURPOSE: Manage TranslationSchedule rows and register them with core SchedulerService.
# @LAYER: Domain
# @RELATION: DEPENDS_ON -> [TranslationSchedule:Class]
# @RELATION: DEPENDS_ON -> [SchedulerService:Class]
# @RELATION: DEPENDS_ON -> [TranslationOrchestrator:Class]
# @RELATION: DEPENDS_ON -> [TranslationEventLog:Class]
# @PRE: Database session and SchedulerService are available.
# @POST: TranslationSchedule CRUD persisted; APScheduler jobs registered/updated/removed.
# @SIDE_EFFECT: Registers APScheduler jobs; runs translations on trigger; creates events.
# @RATIONALE: Uses existing SchedulerService (APScheduler) to avoid creating a second scheduler instance.
# @REJECTED: Separate scheduler instance would create resource contention.
# @REJECTED: Polling-based approach — event-driven APScheduler is more precise.
# #region TranslationScheduler [C:5] [TYPE Module] [SEMANTICS translate,scheduler,apscheduler,cron]
# @BRIEF Manage TranslationSchedule rows and register them with core SchedulerService for cron-based execution.
# @LAYER Domain
# @RELATION DEPENDS_ON -> [TranslationSchedule]
# @RELATION DEPENDS_ON -> [SchedulerService]
# @RELATION DEPENDS_ON -> [TranslationOrchestrator]
# @RELATION DEPENDS_ON -> [TranslationEventLog]
# @PRE Database session and SchedulerService are available.
# @POST TranslationSchedule CRUD persisted; translation runs triggered on schedule.
# @SIDE_EFFECT Registers APScheduler jobs; runs translations on trigger; creates events.
# @DATA_CONTRACT Input[job_id, cron_expression] -> Output[TranslationSchedule]
# @INVARIANT Concurrency guard: max 1 pending/running run per job at scheduled trigger time.
# @RATIONALE Uses existing SchedulerService (APScheduler) to avoid creating a second scheduler instance.
# @REJECTED Separate scheduler instance would create resource contention.
# @REJECTED Polling-based approach — event-driven APScheduler is more precise.
import uuid
from datetime import datetime, timezone, timedelta
@@ -26,9 +26,13 @@ from ...models.translate import TranslationSchedule, TranslationJob, Translation
from .events import TranslationEventLog
# [DEF:TranslationScheduler:Class]
# @COMPLEXITY: 4
# @PURPOSE: CRUD for TranslationSchedule rows + APScheduler registration wrappers.
# #region TranslationScheduler [C:5] [TYPE Class] [SEMANTICS translate,scheduler,crud]
# @BRIEF CRUD for TranslationSchedule rows + APScheduler registration wrappers.
# @PRE Database session and ConfigManager are available.
# @POST Schedule rows created/updated/deleted with event logging.
# @SIDE_EFFECT Writes TranslationSchedule rows; logs SCHEDULE_CREATED/UPDATED/DELETED events.
# @DATA_CONTRACT Input[job_id, cron_expression, timezone] -> Output[TranslationSchedule]
# @INVARIANT Exactly one schedule per job (upsert pattern).
class TranslationScheduler:
def __init__(self, db: Session, config_manager: ConfigManager, current_user: Optional[str] = None):
@@ -37,10 +41,11 @@ class TranslationScheduler:
self.current_user = current_user
self.event_log = TranslationEventLog(db)
# [DEF:create_schedule:Function]
# @PURPOSE: Create a new schedule for a job.
# @PRE: job_id exists. cron_expression is valid.
# @POST: TranslationSchedule row created.
# #region create_schedule [C:4] [TYPE Function] [SEMANTICS translate,schedule,create]
# @BRIEF Create a new schedule for a job with cron expression and event logging.
# @PRE job_id exists. cron_expression is valid.
# @POST TranslationSchedule row created; SCHEDULE_CREATED event logged.
# @SIDE_EFFECT DB write; event logging.
def create_schedule(
self,
job_id: str,
@@ -79,12 +84,13 @@ class TranslationScheduler:
logger.reflect("Schedule created", {"schedule_id": schedule.id, "job_id": job_id})
return schedule
# [/DEF:create_schedule:Function]
# #endregion create_schedule
# [DEF:update_schedule:Function]
# @PURPOSE: Update an existing schedule.
# @PRE: job_id has an existing schedule.
# @POST: Schedule updated.
# #region update_schedule [C:4] [TYPE Function] [SEMANTICS translate,schedule,update]
# @BRIEF Update an existing schedule's cron expression, timezone, or active state.
# @PRE job_id has an existing schedule.
# @POST Schedule updated; SCHEDULE_UPDATED event logged.
# @SIDE_EFFECT DB write; event logging.
def update_schedule(
self,
job_id: str,
@@ -123,12 +129,13 @@ class TranslationScheduler:
logger.reflect("Schedule updated", {"schedule_id": schedule.id, "job_id": job_id})
return schedule
# [/DEF:update_schedule:Function]
# #endregion update_schedule
# [DEF:delete_schedule:Function]
# @PURPOSE: Delete a schedule for a job.
# @PRE: job_id has an existing schedule.
# @POST: Schedule deleted.
# #region delete_schedule [C:4] [TYPE Function] [SEMANTICS translate,schedule,delete]
# @BRIEF Delete a schedule for a job with event logging.
# @PRE job_id has an existing schedule.
# @POST Schedule deleted; SCHEDULE_DELETED event logged.
# @SIDE_EFFECT DB write; event logging.
def delete_schedule(self, job_id: str) -> None:
with belief_scope("TranslationScheduler.delete_schedule"):
schedule = self.db.query(TranslationSchedule).filter(
@@ -149,12 +156,13 @@ class TranslationScheduler:
)
logger.reflect("Schedule deleted", {"schedule_id": schedule_id, "job_id": job_id})
# [/DEF:delete_schedule:Function]
# #endregion delete_schedule
# [DEF:enable_disable_schedule:Function]
# @PURPOSE: Enable or disable a schedule.
# @PRE: job_id has an existing schedule.
# @POST: Schedule is_active updated.
# #region set_schedule_active [C:4] [TYPE Function] [SEMANTICS translate,schedule,activate]
# @BRIEF Enable or disable a schedule by setting is_active flag.
# @PRE job_id has an existing schedule.
# @POST Schedule is_active updated.
# @SIDE_EFFECT DB write.
def set_schedule_active(self, job_id: str, is_active: bool) -> TranslationSchedule:
with belief_scope("TranslationScheduler.set_schedule_active"):
schedule = self.db.query(TranslationSchedule).filter(
@@ -174,12 +182,10 @@ class TranslationScheduler:
"is_active": is_active,
})
return schedule
# [/DEF:enable_disable_schedule:Function]
# #endregion set_schedule_active
# [DEF:get_schedule:Function]
# @PURPOSE: Get schedule for a job.
# @PRE: job_id exists.
# @POST: Returns TranslationSchedule or raises ValueError.
# #region get_schedule [C:2] [TYPE Function] [SEMANTICS translate,schedule,get]
# @BRIEF Get the schedule for a job by job_id.
def get_schedule(self, job_id: str) -> TranslationSchedule:
with belief_scope("TranslationScheduler.get_schedule"):
schedule = self.db.query(TranslationSchedule).filter(
@@ -188,11 +194,10 @@ class TranslationScheduler:
if not schedule:
raise ValueError(f"No schedule found for job '{job_id}'")
return schedule
# [/DEF:get_schedule:Function]
# #endregion get_schedule
# [DEF:list_active_schedules:Function]
# @PURPOSE: List all active schedules.
# @POST: Returns list of active TranslationSchedule rows.
# #region list_active_schedules [C:2] [TYPE Function] [SEMANTICS translate,schedule,list]
# @BRIEF List all active schedules.
@staticmethod
def list_active_schedules(db: Session) -> List[TranslationSchedule]:
return (
@@ -200,13 +205,10 @@ class TranslationScheduler:
.filter(TranslationSchedule.is_active == True)
.all()
)
# [/DEF:list_active_schedules:Function]
# #endregion list_active_schedules
# [DEF:get_next_executions:Function]
# @PURPOSE: Compute next N execution times from cron expression.
# @PRE: cron_expression is valid.
# @POST: Returns list of ISO datetime strings.
@staticmethod
# #region get_next_executions [C:2] [TYPE Function] [SEMANTICS translate,schedule,next]
# @BRIEF Compute next N execution times from a cron expression using APScheduler's CronTrigger.
def get_next_executions(cron_expression: str, timezone_str: str = "UTC", n: int = 3) -> List[str]:
from zoneinfo import ZoneInfo
from apscheduler.triggers.cron import CronTrigger
@@ -231,18 +233,20 @@ class TranslationScheduler:
prev = ft
next_time = ft
return results
# [/DEF:get_next_executions:Function]
# #endregion get_next_executions
# [/DEF:TranslationScheduler:Class]
# #endregion TranslationScheduler
# [DEF:execute_scheduled_translation:Function]
# @PURPOSE: APScheduler job handler — runs scheduled translation with concurrency check and new-key-only fallback.
# @PRE: schedule_id is valid.
# @POST: Translation run created and executed if no concurrent run exists.
# @SIDE_EFFECT: DB writes; LLM calls; Superset API calls.
# @RATIONALE: New-key-only mode compares keys against most recent succeeded run; baseline_expired fallback does full.
# #region execute_scheduled_translation [C:5] [TYPE Function] [SEMANTICS translate,schedule,execute]
# @BRIEF APScheduler job handler — runs scheduled translation with concurrency check and baseline expiry detection.
# @PRE schedule_id is valid and job exists.
# @POST Translation run created and executed if no concurrent run exists.
# @SIDE_EFFECT DB writes; LLM calls; Superset API calls; event logging.
# @DATA_CONTRACT Input[schedule_id, job_id, db_session_maker, config_manager] -> Output[None]
# @INVARIANT Concurrency guard: max 1 pending/running run per job at trigger time.
# @RATIONALE New-key-only mode compares keys against most recent succeeded run; baseline_expired fallback does full.
def execute_scheduled_translation(
schedule_id: str,
job_id: str,
@@ -357,5 +361,5 @@ def execute_scheduled_translation(
logger.error(f"[scheduled_translation] Unexpected error: {e}")
finally:
db.close()
# [/DEF:execute_scheduled_translation:Function]
# [/DEF:TranslationScheduler:Module]
# #endregion execute_scheduled_translation
# #endregion TranslationScheduler

View File

@@ -1,16 +1,16 @@
# [DEF:TranslateJobService:Module]
# @COMPLEXITY: 4
# @SEMANTICS: translate, service, crud, validation
# @PURPOSE: 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.
# #region TranslateJobService [C:5] [TYPE Module] [SEMANTICS translate,service,crud,validation]
# @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.
# @DATA_CONTRACT Input[TranslateJobCreate/Update] -> Output[TranslationJob/TranslateJobResponse]
# @INVARIANT Snapshot isolation: in-progress runs use config snapshot; config edits affect future runs only.
# @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.
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy.orm import Session
@@ -37,12 +37,16 @@ SUPPORTED_DIALECTS = {
}
# [DEF:get_dialect_from_database:Function]
# @PURPOSE: 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.
# #region get_dialect_from_database [C:4] [TYPE Function] [SEMANTICS translate,dialect,validation]
# @BRIEF Extract normalized dialect string from a Superset database record and validate it is supported.
# @PRE database_record is a dict from Superset API with 'backend' or 'engine' key.
# @POST Returns normalized dialect string or raises ValueError if unsupported.
# @SIDE_EFFECT None — pure data extraction.
def get_dialect_from_database(database_record: Dict[str, Any]) -> str:
"""Extract and validate dialect from Superset database record."""
logger.reason("Extracting dialect from database record", {
"has_backend": bool(database_record.get("backend") or database_record.get("engine")),
})
backend = (
database_record.get("backend")
or database_record.get("engine")
@@ -50,6 +54,9 @@ def get_dialect_from_database(database_record: Dict[str, Any]) -> str:
).lower().strip()
if not backend:
logger.explore("Could not determine database dialect", {
"record_keys": list(database_record.keys()),
})
raise ValueError("Could not determine database dialect from connection")
# Map Superset backend names to normalized dialect
@@ -75,28 +82,36 @@ def get_dialect_from_database(database_record: Dict[str, Any]) -> str:
normalized = dialect_map.get(backend, backend)
if normalized not in SUPPORTED_DIALECTS:
logger.explore("Unsupported dialect", {"backend": backend, "normalized": normalized})
raise ValueError(
f"Unsupported database dialect: '{backend}'. "
f"Supported dialects: {', '.join(sorted(SUPPORTED_DIALECTS))}"
)
logger.reflect("Dialect validated", {"dialect": normalized})
return normalized
# [/DEF:get_dialect_from_database:Function]
# #endregion get_dialect_from_database
# [DEF:fetch_datasource_metadata:Function]
# @PURPOSE: 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.
# #region fetch_datasource_metadata [C:4] [TYPE Function] [SEMANTICS translate,datasource,metadata]
# @BRIEF Fetch datasource columns and database dialect from Superset for a given dataset.
# @PRE dataset_id is a valid Superset dataset ID and environment has valid credentials.
# @POST Returns (columns_list, dialect_string) or raises on failure.
# @SIDE_EFFECT Queries Superset API for dataset detail and database info.
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."""
logger.reason("Fetching datasource metadata", {
"dataset_id": dataset_id,
"env_id": env_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:
logger.explore("Environment not found", {"env_id": env_id})
raise ValueError(f"Superset environment '{env_id}' not found in configuration")
# Create Superset client and fetch dataset detail
@@ -133,25 +148,29 @@ def fetch_datasource_metadata(
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}")
logger.explore("Could not fetch database dialect", {"error": str(e)})
raise ValueError(f"Could not determine database dialect: {e}")
logger.reflect("Metadata fetched", {"columns": len(columns), "dialect": dialect})
return columns, dialect
# [/DEF:fetch_datasource_metadata:Function]
# #endregion fetch_datasource_metadata
# [DEF:detect_virtual_columns:Function]
# @PURPOSE: 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.
# #region detect_virtual_columns [C:2] [TYPE Function] [SEMANTICS translate,columns]
# @BRIEF Identify virtual (calculated) columns from column metadata by filtering non-physical entries.
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)]
# [/DEF:detect_virtual_columns:Function]
# #endregion detect_virtual_columns
# [DEF:TranslateJobService:Class]
# @PURPOSE: Service for translation job CRUD with validation and Superset integration.
# #region TranslateJobService [C:5] [TYPE Class] [SEMANTICS translate,service,crud]
# @BRIEF Service for translation job CRUD with validation and Superset integration.
# @PRE Database session and config manager are available.
# @POST Translation jobs are managed with full lifecycle (create/update/delete/duplicate).
# @SIDE_EFFECT Queries Superset for dialect detection and column validation.
# @DATA_CONTRACT Input[TranslateJobCreate/Update] -> Output[TranslationJob]
# @INVARIANT Snapshot isolation: in-progress runs unaffected by config edits.
class TranslateJobService:
def __init__(self, db: Session, config_manager: ConfigManager, current_user: Optional[str] = None):
@@ -159,9 +178,8 @@ class TranslateJobService:
self.config_manager = config_manager
self.current_user = current_user
# [DEF:list_jobs:Function]
# @PURPOSE: List translation jobs with optional status filter and pagination.
# @POST: Returns tuple of (total_count, list_of_jobs).
# #region list_jobs [C:2] [TYPE Function] [SEMANTICS translate,jobs,query]
# @BRIEF List translation jobs with optional status filter and pagination.
def list_jobs(
self,
page: int = 1,
@@ -178,29 +196,30 @@ class TranslateJobService:
jobs = query.order_by(TranslationJob.created_at.desc()).offset(offset).limit(page_size).all()
return total, jobs
# [/DEF:list_jobs:Function]
# #endregion list_jobs
# [DEF:get_job:Function]
# @PURPOSE: Get a single translation job by ID.
# @POST: Returns TranslationJob or raises ValueError.
# #region get_job [C:2] [TYPE Function] [SEMANTICS translate,jobs,query]
# @BRIEF Get a single translation job by ID, raising ValueError if not found.
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
# [/DEF:get_job:Function]
# #endregion get_job
# [DEF:create_job: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.
# #region create_job [C:4] [TYPE Function] [SEMANTICS translate,jobs,create]
# @BRIEF Create a new translation job with column validation and dialect detection.
# @PRE payload contains valid job configuration (name, upsert_strategy, etc).
# @POST Returns the created TranslationJob with database_dialect cached.
# @SIDE_EFFECT Validates columns via SupersetClient if source_datasource_id is provided; caches database_dialect.
def create_job(self, payload: TranslateJobCreate) -> TranslationJob:
logger.info(f"[TranslateJobService] Creating job '{payload.name}'")
logger.reason("Creating translation job", {"name": payload.name})
# Validate: must have a translation column if datasource is configured
if payload.source_datasource_id and not payload.translation_column:
logger.explore("Missing translation column", {
"source_datasource_id": payload.source_datasource_id,
})
raise ValueError("A translation column is required when a datasource is selected")
# Validate upsert strategy
@@ -225,7 +244,7 @@ class TranslateJobService:
)
dialect = detected_dialect
except Exception as e:
logger.warning(f"[TranslateJobService] Dialect detection failed: {e}")
logger.explore("Dialect detection failed", {"error": str(e)})
dialect = payload.source_dialect
# Build job instance
@@ -249,6 +268,7 @@ class TranslateJobService:
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,
)
@@ -267,17 +287,17 @@ class TranslateJobService:
self.db.commit()
self.db.refresh(job)
logger.info(f"[TranslateJobService] Created job '{job.id}'")
logger.reflect("Job created", {"job_id": job.id})
return job
# [/DEF:create_job:Function]
# #endregion create_job
# [DEF:update_job: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.
# #region update_job [C:4] [TYPE Function] [SEMANTICS translate,jobs,update]
# @BRIEF Update an existing translation job with optional dialect re-detection.
# @PRE payload contains fields to update; job_id exists.
# @POST Returns the updated TranslationJob with re-detected dialect if datasource changed.
# @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}'")
logger.reason("Updating translation job", {"job_id": job_id})
job = self.get_job(job_id)
update_data = payload.model_dump(exclude_unset=True)
@@ -298,7 +318,7 @@ class TranslateJobService:
)
job.database_dialect = detected_dialect
except Exception as e:
logger.warning(f"[TranslateJobService] Dialect re-detection failed: {e}")
logger.explore("Dialect re-detection failed", {"error": str(e)})
job.updated_at = datetime.now(timezone.utc)
@@ -317,16 +337,17 @@ class TranslateJobService:
self.db.commit()
self.db.refresh(job)
logger.info(f"[TranslateJobService] Updated job '{job_id}'")
logger.reflect("Job updated", {"job_id": job_id})
return job
# [/DEF:update_job:Function]
# #endregion update_job
# [DEF:delete_job:Function]
# @PURPOSE: Delete a translation job and its associations.
# @PRE: job_id must exist.
# @POST: Job and all related records are deleted.
# #region delete_job [C:4] [TYPE Function] [SEMANTICS translate,jobs,delete]
# @BRIEF Delete a translation job and its dictionary associations.
# @PRE job_id must exist.
# @POST Job and all related TranslationJobDictionary records are deleted.
# @SIDE_EFFECT Deletes TranslationJob and TranslationJobDictionary rows.
def delete_job(self, job_id: str) -> None:
logger.info(f"[TranslateJobService] Deleting job '{job_id}'")
logger.reason("Deleting translation job", {"job_id": job_id})
job = self.get_job(job_id)
# Delete dictionary associations
@@ -336,15 +357,16 @@ class TranslateJobService:
self.db.delete(job)
self.db.commit()
logger.info(f"[TranslateJobService] Deleted job '{job_id}'")
# [/DEF:delete_job:Function]
logger.reflect("Job deleted", {"job_id": job_id})
# #endregion delete_job
# [DEF:duplicate_job:Function]
# @PURPOSE: Duplicate a translation job with a new name.
# @PRE: job_id must exist.
# @POST: Returns the duplicated TranslationJob with status DRAFT.
# #region duplicate_job [C:4] [TYPE Function] [SEMANTICS translate,jobs,duplicate]
# @BRIEF Duplicate a translation job with a new name, copying all fields and dictionary associations.
# @PRE job_id must exist.
# @POST Returns the duplicated TranslationJob with status DRAFT.
# @SIDE_EFFECT Creates new TranslationJob and TranslationJobDictionary rows.
def duplicate_job(self, job_id: str, new_name: Optional[str] = None) -> TranslationJob:
logger.info(f"[TranslateJobService] Duplicating job '{job_id}'")
logger.reason("Duplicating translation job", {"job_id": job_id, "new_name": new_name})
source = self.get_job(job_id)
# Copy all fields except id, created_at, updated_at, status
@@ -367,6 +389,8 @@ class TranslateJobService:
provider_id=source.provider_id,
batch_size=source.batch_size,
upsert_strategy=source.upsert_strategy,
environment_id=source.environment_id,
target_database_id=source.target_database_id,
status="DRAFT",
created_by=self.current_user,
)
@@ -387,22 +411,20 @@ class TranslateJobService:
self.db.commit()
self.db.refresh(new_job)
logger.info(f"[TranslateJobService] Duplicated job '{job_id}' -> '{new_job.id}'")
logger.reflect("Job duplicated", {"new_job_id": new_job.id, "source_job_id": job_id})
return new_job
# [/DEF:duplicate_job:Function]
# #endregion duplicate_job
# [DEF:get_job_dictionary_ids:Function]
# @PURPOSE: Get dictionary IDs attached to a job.
# @POST: Returns list of dictionary IDs.
# #region get_job_dictionary_ids [C:1] [TYPE Function] [SEMANTICS translate,jobs,dictionaries]
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]
# [/DEF:get_job_dictionary_ids:Function]
# #endregion get_job_dictionary_ids
# [DEF:fetch_available_datasources:Function]
# @PURPOSE: List available Superset datasets for translation job creation.
# #region fetch_available_datasources [C:2] [TYPE Function] [SEMANTICS translate,datasources,query]
# @BRIEF List available Superset datasets for translation job creation with optional search filter.
def fetch_available_datasources(self, env_id: str, search: Optional[str] = None) -> list:
"""List Superset datasets available for translation."""
from ...core.superset_client import SupersetClient
@@ -428,12 +450,11 @@ class TranslateJobService:
"description": ds.get("description", ""),
})
return result
# [/DEF:fetch_available_datasources:Function]
# [/DEF:TranslateJobService:Class]
# #endregion fetch_available_datasources
# #endregion TranslateJobService
# [DEF:_extract_dialect:Function]
# @PURPOSE: Extract database dialect from Superset backend URI.
# #region _extract_dialect [C:1] [TYPE Function] [SEMANTICS translate,dialect]
def _extract_dialect(backend: str) -> str:
"""Extract dialect name from a Superset database backend URI (e.g. 'postgresql+psycopg2://...')."""
if not backend:
@@ -444,10 +465,11 @@ def _extract_dialect(backend: str) -> str:
return dialect.lower()
except Exception:
return "unknown"
# #endregion _extract_dialect
# [DEF:job_to_response:Function]
# @PURPOSE: Convert a TranslationJob ORM model to a TranslateJobResponse schema with dictionary_ids.
# #region job_to_response [C:2] [TYPE Function] [SEMANTICS translate,serialization]
# @BRIEF Convert a TranslationJob ORM model to a TranslateJobResponse schema with dictionary_ids.
def job_to_response(job: TranslationJob, dict_ids: Optional[List[str]] = None) -> TranslateJobResponse:
return TranslateJobResponse(
id=job.id,
@@ -474,27 +496,29 @@ def job_to_response(job: TranslationJob, dict_ids: Optional[List[str]] = None) -
updated_at=job.updated_at,
dictionary_ids=dict_ids or [],
environment_id=job.environment_id,
target_database_id=job.target_database_id,
)
# [/DEF:job_to_response:Function]
# #endregion job_to_response
# [DEF:DatasourceColumnsService:Function]
# @PURPOSE: 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.
# #region get_datasource_columns [C:4] [TYPE Function] [SEMANTICS translate,datasource,columns]
# @BRIEF Fetch datasource column metadata from Superset and return structured DatasourceColumnsResponse.
# @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}")
logger.reason("Fetching datasource columns", {"datasource_id": datasource_id, "env_id": env_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:
logger.explore("Environment not found", {"env_id": env_id})
raise ValueError(f"Superset environment '{env_id}' not found")
# Create Superset client
@@ -516,6 +540,7 @@ def get_datasource_columns(
db_result = db_record.get("result", db_record) if isinstance(db_record, dict) else db_record
dialect = get_dialect_from_database(db_result)
else:
logger.explore("Could not determine dialect", {"datasource_id": datasource_id})
raise ValueError("Could not determine database dialect for this datasource")
# Extract columns
@@ -533,13 +558,19 @@ def get_datasource_columns(
description=col.get("description", ""),
))
return DatasourceColumnsResponse(
result = DatasourceColumnsResponse(
datasource_id=datasource_id,
datasource_name=dataset_detail.get("table_name"),
schema_name=dataset_detail.get("schema"),
database_dialect=dialect,
columns=columns,
)
# [/DEF:DatasourceColumnsService:Function]
logger.reflect("Datasource columns fetched", {
"datasource_id": datasource_id,
"columns_count": len(columns),
"dialect": dialect,
})
return result
# #endregion get_datasource_columns
# [/DEF:TranslateJobService:Module]
# #endregion TranslateJobService

View File

@@ -1,18 +1,13 @@
# [DEF:SQLGenerator:Module]
# @COMPLEXITY: 3
# @SEMANTICS: translate, sql, generator, dialect
# @PURPOSE: Dialect-aware safe SQL generation for INSERT/UPSERT operations.
# @LAYER: Domain
# @RELATION: DEPENDS_ON -> [TranslationJob]
# @RELATION: DEPENDS_ON -> [TranslationRun]
# @PRE: Job has target_schema and target_table configured. Dialect is one of supported SUPPORTED_DIALECTS.
# @POST: Returns safe SQL strings for the target dialect.
# @SIDE_EFFECT: None — pure code generation.
# @RATIONALE: Dialect-aware SQL uses ON CONFLICT for PostgreSQL; plain INSERT for ClickHouse with documented limitations.
# @REJECTED: UPDATE statements — source is append-only; UPSERT covers overwrite case.
# @REJECTED: ORM-based insert bypasses Superset's SQL Lab audit trail.
# #region SQLGenerator [C:4] [TYPE Module] [SEMANTICS translate,sql,generator,dialect]
# @BRIEF Dialect-aware safe SQL generation for INSERT/UPSERT operations with identifier quoting and value encoding.
# @LAYER Domain
# @RELATION DEPENDS_ON -> [TranslationJob]
# @RELATION DEPENDS_ON -> [TranslationRun]
# @PRE Job has target_schema and target_table configured. Dialect is one of supported SUPPORTED_DIALECTS.
# @POST Returns safe SQL strings for the target dialect.
from typing import Any, Dict, List, Optional, Tuple
from datetime import datetime, timezone
from ...core.logger import logger, belief_scope
# PostgreSQL/Greenplum dialects that support ON CONFLICT
@@ -21,10 +16,45 @@ POSTGRESQL_DIALECTS = {"postgresql", "redshift", "greenplum"}
CLICKHOUSE_DIALECTS = {"clickhouse"}
# [DEF:_quote_identifier:Function]
# @PURPOSE: Quote an identifier per dialect rules. PostgreSQL uses double quotes; ClickHouse uses backticks.
# @PRE: identifier is a non-empty string.
# @POST: Returns safely quoted identifier.
# #region _normalize_timestamp_value [C:2] [TYPE Function] [SEMANTICS translate,sql,timestamp]
# @BRIEF Detect Unix timestamp strings (seconds or millis) and convert to 'YYYY-MM-DD' for Date columns.
def _normalize_timestamp_value(value: Any) -> Optional[str]:
"""Detect Unix timestamp values and convert to date string.
Handles:
- Integer/float Unix timestamps (seconds or milliseconds)
- String representations of Unix timestamps (e.g. '1726358400000.0')
Returns 'YYYY-MM-DD' if conversion succeeds, None if value is not a timestamp.
"""
# Try numeric conversion first
try:
ts = float(value)
except (ValueError, TypeError):
return None
# Heuristic: Unix timestamps in seconds are ~10 digits (1e9 range for 2001-2033)
# Unix timestamps in milliseconds are ~13 digits (1e12 range for 2001-2033)
if 1e9 <= ts < 1e12:
# Already in seconds
pass
elif 1e12 <= ts < 1e15:
# Milliseconds — convert to seconds
ts = ts / 1000.0
else:
# Not a plausible Unix timestamp
return None
try:
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
return dt.strftime("%Y-%m-%d")
except (OSError, OverflowError, ValueError):
return None
# #endregion _normalize_timestamp_value
# #region _quote_identifier [C:2] [TYPE Function] [SEMANTICS translate,sql,quoting]
# @BRIEF Quote a SQL identifier per dialect rules — double quotes for PostgreSQL, backticks for ClickHouse.
def _quote_identifier(identifier: str, dialect: str) -> str:
"""Quote a SQL identifier per dialect rules."""
if not identifier:
@@ -38,50 +68,67 @@ def _quote_identifier(identifier: str, dialect: str) -> str:
else:
# Generic ANSI double-quote
return f'"{cleaned}"'
# [/DEF:_quote_identifier:Function]
# #endregion _quote_identifier
# [DEF:_encode_sql_value:Function]
# @PURPOSE: Encode a Python value into a SQL-safe literal for INSERT VALUES.
# @PRE: value is a Python primitive or None.
# @POST: Returns SQL-safe string literal representation.
def _encode_sql_value(value: Any) -> str:
"""Encode a Python value into a SQL-safe literal."""
# #region _encode_sql_value [C:2] [TYPE Function] [SEMANTICS translate,sql,encoding]
# @BRIEF Encode a Python value into a SQL-safe literal for INSERT VALUES, with ClickHouse timestamp normalization.
def _encode_sql_value(value: Any, dialect: Optional[str] = None) -> str:
"""Encode a Python value into a SQL-safe literal.
For ClickHouse dialect, attempts to detect Unix timestamp strings
and convert them to 'YYYY-MM-DD' format for Date column compatibility.
"""
if value is None:
return "NULL"
if isinstance(value, bool):
return "TRUE" if value else "FALSE"
# For ClickHouse: try to normalize timestamp-like string values
if dialect in CLICKHOUSE_DIALECTS and isinstance(value, str) and value:
normalized = _normalize_timestamp_value(value)
if normalized:
return f"'{normalized}'"
if isinstance(value, (int, float)):
return str(value)
# String — escape single quotes by doubling them
escaped = str(value).replace("'", "''")
return f"'{escaped}'"
# [/DEF:_encode_sql_value:Function]
# #endregion _encode_sql_value
# [DEF:_build_values_clause:Function]
# @PURPOSE: Build a VALUES clause for multiple rows.
# @PRE: columns list is non-empty; rows is a list of dicts.
# @POST: Returns SQL VALUES clause string.
def _build_values_clause(columns: List[str], rows: List[Dict[str, Any]]) -> str:
"""Build VALUES (...) clause for multiple rows."""
# #region _build_values_clause [C:2] [TYPE Function] [SEMANTICS translate,sql,values]
# @BRIEF Build a VALUES clause for multiple rows with per-column value encoding and dialect-aware quoting.
def _build_values_clause(columns: List[str], rows: List[Dict[str, Any]], dialect: Optional[str] = None) -> str:
"""Build VALUES (...) clause for multiple rows.
NOTE: columns may be quoted (e.g. '"col"' or '`col`') for the SQL column list,
but row dicts have UNQUOTED keys. Strip quotes before value lookup.
"""
value_groups = []
for row in rows:
values = [_encode_sql_value(row.get(col)) for col in columns]
values = []
for col in columns:
# Strip quoting for value lookup — row keys are unquoted
lookup_key = col.strip('"').strip('`').strip('[]')
val = row.get(lookup_key)
values.append(_encode_sql_value(val, dialect=dialect))
value_groups.append(f"({', '.join(values)})")
return ",\n".join(value_groups)
# [/DEF:_build_values_clause:Function]
# #endregion _build_values_clause
# [DEF:generate_insert_sql:Function]
# @PURPOSE: Generate a dialect-aware INSERT SQL statement.
# @PRE: target_table, columns are non-empty.
# @POST: Returns SQL string or raises ValueError on invalid input.
# #region generate_insert_sql [C:3] [TYPE Function] [SEMANTICS translate,sql,insert]
# @BRIEF Generate a dialect-aware plain INSERT SQL statement for the given table, columns, and rows.
# @RELATION DEPENDS_ON -> [_quote_identifier]
# @RELATION DEPENDS_ON -> [_build_values_clause]
def generate_insert_sql(
target_schema: Optional[str],
target_table: str,
columns: List[str],
rows: List[Dict[str, Any]],
dialect: Optional[str] = None,
) -> str:
"""Generate a plain INSERT SQL."""
with belief_scope("generate_insert_sql"):
@@ -93,7 +140,7 @@ def generate_insert_sql(
raise ValueError("At least one row is required for INSERT SQL generation")
col_list = ", ".join(columns)
values = _build_values_clause(columns, rows)
values = _build_values_clause(columns, rows, dialect=dialect)
table_ref = target_table
if target_schema:
@@ -101,19 +148,20 @@ def generate_insert_sql(
sql = f"INSERT INTO {table_ref} ({col_list})\nVALUES\n{values};"
return sql
# [/DEF:generate_insert_sql:Function]
# #endregion generate_insert_sql
# [DEF:generate_upsert_sql:Function]
# @PURPOSE: Generate PostgreSQL dialect UPSERT SQL with ON CONFLICT DO UPDATE.
# @PRE: dialect is postgresql-compatible. target_table, columns, key_columns are non-empty.
# @POST: Returns UPSERT SQL string or raises ValueError.
# #region generate_upsert_sql [C:3] [TYPE Function] [SEMANTICS translate,sql,upsert]
# @BRIEF Generate PostgreSQL dialect UPSERT SQL with ON CONFLICT DO UPDATE / DO NOTHING.
# @RELATION DEPENDS_ON -> [_quote_identifier]
# @RELATION DEPENDS_ON -> [_build_values_clause]
def generate_upsert_sql(
target_schema: Optional[str],
target_table: str,
columns: List[str],
key_columns: List[str],
rows: List[Dict[str, Any]],
dialect: Optional[str] = None,
) -> str:
"""Generate INSERT ... ON CONFLICT (key_cols) DO UPDATE SET ... SQL."""
with belief_scope("generate_upsert_sql"):
@@ -128,7 +176,7 @@ def generate_upsert_sql(
col_list = ", ".join(columns)
key_list = ", ".join(key_columns)
values = _build_values_clause(columns, rows)
values = _build_values_clause(columns, rows, dialect=dialect)
table_ref = target_table
if target_schema:
@@ -150,21 +198,23 @@ def generate_upsert_sql(
f"ON CONFLICT ({key_list}) {conflict_action};"
)
return sql
# [/DEF:generate_upsert_sql:Function]
# #endregion generate_upsert_sql
# [DEF:SQLGenerator:Class]
# @COMPLEXITY: 3
# @PURPOSE: Generate safe, dialect-appropriate SQL INSERT/UPSERT statements.
# @PRE: Job has target_schema, target_table, key columns configured.
# @POST: Returns generated SQL string for the target dialect.
# #region SQLGenerator [C:4] [TYPE Class] [SEMANTICS translate,sql,generator]
# @BRIEF Generate safe, dialect-appropriate SQL INSERT/UPSERT statements with batching support.
# @PRE Job has target_schema, target_table, key columns configured.
# @POST Returns generated SQL string for the target dialect.
# @SIDE_EFFECT None — pure SQL generation.
# @RELATION DEPENDS_ON -> [generate_insert_sql]
# @RELATION DEPENDS_ON -> [generate_upsert_sql]
class SQLGenerator:
# [DEF:SQLGenerator.generate:Function]
# @PURPOSE: Generate SQL for a set of rows, detecting dialect from the job configuration.
# @PRE: dialect is a supported database dialect. columns list is non-empty. rows is non-empty.
# @POST: Returns tuple of (sql_string, statement_count).
# @SIDE_EFFECT: None — pure SQL generation.
# #region SQLGenerator.generate [C:4] [TYPE Function] [SEMANTICS translate,sql,generate]
# @BRIEF Generate SQL for a set of rows, detecting dialect from the job configuration.
# @PRE dialect is a supported database dialect. columns list is non-empty. rows is non-empty.
# @POST Returns tuple of (sql_string, statement_count).
# @SIDE_EFFECT None — pure SQL generation.
@staticmethod
def generate(
dialect: str,
@@ -236,6 +286,7 @@ class SQLGenerator:
columns=quoted_columns,
key_columns=quoted_key_columns,
rows=rows,
dialect=dialect,
)
else:
sql = generate_insert_sql(
@@ -243,6 +294,7 @@ class SQLGenerator:
target_table=table_ref,
columns=quoted_columns,
rows=rows,
dialect=dialect,
)
elif dialect in CLICKHOUSE_DIALECTS:
# ClickHouse: plain INSERT, no ON CONFLICT support
@@ -251,6 +303,7 @@ class SQLGenerator:
target_table=table_ref,
columns=quoted_columns,
rows=rows,
dialect=dialect,
)
if use_upsert:
logger.reason("ClickHouse UPSERT not supported; using plain INSERT", {
@@ -263,6 +316,7 @@ class SQLGenerator:
target_table=table_ref,
columns=quoted_columns,
rows=rows,
dialect=dialect,
)
logger.reflect("SQL generated", {
@@ -271,12 +325,11 @@ class SQLGenerator:
"sql_length": len(sql),
})
return sql, len(rows)
# [/DEF:SQLGenerator.generate:Function]
# #endregion SQLGenerator.generate
# [DEF:SQLGenerator.generate_batch:Function]
# @PURPOSE: Generate separate INSERT statements for each row (batch-safe version).
# @PRE: Same as generate().
# @POST: Returns list of (sql_string, row_index) tuples.
# #region SQLGenerator.generate_batch [C:3] [TYPE Function] [SEMANTICS translate,sql,batch]
# @BRIEF Generate separate INSERT statements for each row chunk (batch-safe version).
# @RELATION DEPENDS_ON -> [SQLGenerator.generate]
@staticmethod
def generate_batch(
dialect: str,
@@ -312,8 +365,8 @@ class SQLGenerator:
statements.append((sql, count))
return statements
# [/DEF:SQLGenerator.generate_batch:Function]
# #endregion SQLGenerator.generate_batch
# [/DEF:SQLGenerator:Class]
# [/DEF:SQLGenerator:Module]
# #endregion SQLGenerator
# #endregion SQLGenerator

View File

@@ -1,17 +1,17 @@
# [DEF:SupersetSqlLabExecutor:Module]
# @COMPLEXITY: 4
# @SEMANTICS: translate, superset, sqllab, execute
# @PURPOSE: Submit SQL to Superset SQL Lab API and poll execution status.
# @LAYER: Infra
# @RELATION: DEPENDS_ON -> [SupersetClient]
# @RELATION: DEPENDS_ON -> [ConfigManager]
# @PRE: Valid Superset environment configuration and authenticated client.
# @POST: SQL is submitted to Superset SQL Lab; execution reference is returned.
# @SIDE_EFFECT: Makes HTTP calls to Superset /api/v1/sqllab/execute/; polls status.
# @RATIONALE: Direct SQL Lab API submission provides audit trail and execution monitoring within Superset.
# @REJECTED: Direct database connection bypass — would skip Superset's SQL Lab audit and RBAC.
# #region SupersetSqlLabExecutor [C:5] [TYPE Module] [SEMANTICS translate,superset,sqllab,execute]
# @BRIEF Submit SQL to Superset SQL Lab API and poll execution status with retry.
# @LAYER Infra
# @RELATION DEPENDS_ON -> [SupersetClient]
# @RELATION DEPENDS_ON -> [ConfigManager]
# @PRE Valid Superset environment configuration and authenticated client.
# @POST SQL is submitted to Superset SQL Lab; execution reference is returned.
# @SIDE_EFFECT Makes HTTP calls to Superset /api/v1/sqllab/execute/; polls status.
# @DATA_CONTRACT Input[sql:str, database_id:Optional] -> Output[Dict with query_id, status, error_message]
# @INVARIANT Polling respects max_polls limit; timeout returns structured error.
# @RATIONALE Direct SQL Lab API submission provides audit trail and execution monitoring within Superset.
# @REJECTED Direct database connection bypass — would skip Superset's SQL Lab audit and RBAC.
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, List, Optional, Tuple, Union
from datetime import datetime, timezone
import json
import time
@@ -22,25 +22,26 @@ from ...core.config_manager import ConfigManager
from ...core.superset_client import SupersetClient
# [DEF:SupersetSqlLabExecutor:Class]
# @COMPLEXITY: 4
# @PURPOSE: Submit SQL to Superset SQL Lab API with polling and status tracking.
# @PRE: Valid environment ID and ConfigManager.
# @POST: SQL submitted to Superset; execution reference recorded.
# @SIDE_EFFECT: Makes HTTP POST to /api/v1/sqllab/execute/; polls GET for status.
# #region SupersetSqlLabExecutor [C:5] [TYPE Class] [SEMANTICS translate,superset,sqllab]
# @BRIEF Submit SQL to Superset SQL Lab API with polling and status tracking.
# @PRE Valid environment ID and ConfigManager.
# @POST SQL submitted to Superset; execution reference recorded; polling completes.
# @SIDE_EFFECT Makes HTTP POST to /api/v1/sqllab/execute/; polls GET for status.
# @DATA_CONTRACT Input[env_id:str, database_id:Optional] -> Output[Dict with query_id, status]
# @INVARIANT database_id resolved lazily; polling capped at max_polls.
class SupersetSqlLabExecutor:
def __init__(self, config_manager: ConfigManager, env_id: str):
def __init__(self, config_manager: ConfigManager, env_id: str, database_id: Optional[int] = None):
self.config_manager = config_manager
self.env_id = env_id
self._client: Optional[SupersetClient] = None
self._database_id: Optional[int] = None
self._database_id: Optional[int] = database_id
# [DEF:_get_client:Function]
# @PURPOSE: Lazy-initialize SupersetClient for the configured environment.
# @PRE: env_id must correspond to a valid environment config.
# @POST: Returns authenticated SupersetClient.
# @SIDE_EFFECT: Authenticates against Superset API.
# #region _get_client [C:4] [TYPE Function] [SEMANTICS translate,superset,client]
# @BRIEF Lazy-initialize SupersetClient for the configured environment.
# @PRE env_id must correspond to a valid environment config.
# @POST Returns authenticated SupersetClient.
# @SIDE_EFFECT Authenticates against Superset API on first call.
def _get_client(self) -> SupersetClient:
with belief_scope("SupersetSqlLabExecutor._get_client"):
if self._client is not None:
@@ -53,13 +54,13 @@ class SupersetSqlLabExecutor:
self._client = SupersetClient(env_config)
return self._client
# [/DEF:_get_client:Function]
# #endregion _get_client
# [DEF:resolve_database_id:Function]
# @PURPOSE: Resolve the target database ID from the environment.
# @PRE: database_name or database_id should be known.
# @POST: Returns database_id integer or raises ValueError.
# @SIDE_EFFECT: Fetches databases list from Superset.
# #region resolve_database_id [C:4] [TYPE Function] [SEMANTICS translate,database,resolve]
# @BRIEF Resolve the target database ID from the environment by name or default.
# @PRE Superset environment is reachable and has databases.
# @POST Returns database_id integer or raises ValueError.
# @SIDE_EFFECT Fetches databases list from Superset API.
def resolve_database_id(self, database_name: Optional[str] = None) -> int:
with belief_scope("SupersetSqlLabExecutor.resolve_database_id"):
client = self._get_client()
@@ -89,37 +90,71 @@ class SupersetSqlLabExecutor:
"database_id": self._database_id,
})
return self._database_id
# [/DEF:resolve_database_id:Function]
# #endregion resolve_database_id
# [DEF:execute_sql:Function]
# @PURPOSE: Submit SQL to Superset SQL Lab and return execution tracking info.
# @PRE: sql is valid SQL string. database_id is a valid Superset DB ID.
# @POST: Returns execution result dict with query_id and status.
# @SIDE_EFFECT: Makes HTTP POST to /api/v1/sqllab/execute/.
# #region resolve_database_uuid [C:4] [TYPE Function] [SEMANTICS translate,database,uuid]
# @BRIEF Resolve a database UUID to a numeric database ID from Superset.
# @PRE uuid_str is a valid Superset database UUID.
# @POST Returns numeric database_id or raises ValueError if not found.
# @SIDE_EFFECT Fetches databases list from Superset API.
def resolve_database_uuid(self, uuid_str: str) -> int:
with belief_scope("SupersetSqlLabExecutor.resolve_database_uuid"):
client = self._get_client()
_, databases = client.get_databases(
query={"columns": ["id", "uuid", "database_name"]}
)
if not databases:
raise ValueError("No databases found in Superset environment")
for db in databases:
db_uuid = db.get("uuid") or ""
if db_uuid.lower() == uuid_str.lower():
db_id = db["id"]
self._database_id = db_id
logger.reason("Resolved database ID by UUID", {
"uuid": uuid_str,
"database_id": db_id,
"database_name": db.get("database_name"),
})
return db_id
raise ValueError(
f"Database with UUID '{uuid_str}' not found in Superset environment"
)
# #endregion resolve_database_uuid
# #region execute_sql [C:4] [TYPE Function] [SEMANTICS translate,sql,execute]
# @BRIEF Submit SQL to Superset SQL Lab and return execution tracking info.
# @PRE sql is valid SQL string. database_id is a valid Superset DB ID.
# @POST Returns execution result dict with query_id and status.
# @SIDE_EFFECT Makes HTTP POST to /api/v1/sqllab/execute/.
def execute_sql(
self,
sql: str,
database_id: Optional[int] = None,
run_async: bool = True,
database_id: Optional[Union[int, str]] = None,
) -> Dict[str, Any]:
with belief_scope("SupersetSqlLabExecutor.execute_sql"):
client = self._get_client()
db_id = database_id or self._database_id
if not db_id:
if db_id is None:
db_id = self.resolve_database_id()
# Handle UUID string passed as database_id — resolve to numeric ID
if isinstance(db_id, str):
try:
db_id = int(db_id)
except (ValueError, TypeError):
db_id = self.resolve_database_uuid(db_id)
logger.reason("Submitting SQL to Superset SQL Lab", {
"database_id": db_id,
"sql_length": len(sql),
"run_async": run_async,
})
payload = {
"database_id": db_id,
"sql": sql,
"runAsync": run_async,
"client_id": uuid.uuid4().hex[:10],
"schema": None,
"tab": "translation-insert",
}
try:
@@ -133,14 +168,22 @@ class SupersetSqlLabExecutor:
logger.explore("SQL Lab execute failed", {"error": str(e)})
raise ValueError(f"Superset SQL Lab execute failed: {e}")
# Parse response
# Parse response — try multiple formats
result = response if isinstance(response, dict) else {}
query_id = result.get("query_id") or result.get("id")
query_id = (result.get("query_id")
or result.get("id")
or result.get("sql_lab_session_ref")
or (result.get("result") or {}).get("query_id")
or (result.get("result") or {}).get("id"))
status = result.get("status", "unknown")
logger.reason("SQL Lab execute response", {
"query_id": query_id,
"status": status,
"has_query_id": query_id is not None,
"has_errors": "errors" in result or "error" in result,
"response_keys": list(result.keys()) if isinstance(result, dict) else type(result).__name__,
"response_preview": str(result)[:1000] if isinstance(result, dict) else str(result)[:1000],
})
return {
@@ -149,13 +192,13 @@ class SupersetSqlLabExecutor:
"raw_response": result,
"database_id": db_id,
}
# [/DEF:execute_sql:Function]
# #endregion execute_sql
# [DEF:poll_execution_status:Function]
# @PURPOSE: Poll Superset for SQL execution status until completion or timeout.
# @PRE: query_id is a valid Superset query ID.
# @POST: Returns final execution status dict.
# @SIDE_EFFECT: Makes HTTP GET requests to Superset.
# #region poll_execution_status [C:4] [TYPE Function] [SEMANTICS translate,poll,status]
# @BRIEF Poll Superset for SQL execution status until completion or timeout.
# @PRE query_id is a valid Superset query ID.
# @POST Returns final execution status dict with success/failure/timeout.
# @SIDE_EFFECT Makes HTTP GET requests to Superset API.
def poll_execution_status(
self,
query_id: str,
@@ -240,17 +283,17 @@ class SupersetSqlLabExecutor:
"error_message": f"Polling timed out after {max_polls} attempts",
"results": None,
}
# [/DEF:poll_execution_status:Function]
# #endregion poll_execution_status
# [DEF:execute_and_poll:Function]
# @PURPOSE: Execute SQL and wait for completion. One-shot convenience method.
# @PRE: sql is valid SQL.
# @POST: Returns final execution result.
# @SIDE_EFFECT: Makes HTTP calls to Superset API.
# #region execute_and_poll [C:4] [TYPE Function] [SEMANTICS translate,sql,execute,poll]
# @BRIEF Execute SQL and wait for completion. One-shot convenience method.
# @PRE sql is valid SQL.
# @POST Returns final execution result.
# @SIDE_EFFECT Makes HTTP calls to Superset API.
def execute_and_poll(
self,
sql: str,
database_id: Optional[int] = None,
database_id: Optional[Union[int, str]] = None,
max_polls: int = 60,
poll_interval_seconds: float = 2.0,
) -> Dict[str, Any]:
@@ -258,7 +301,6 @@ class SupersetSqlLabExecutor:
exec_result = self.execute_sql(
sql=sql,
database_id=database_id,
run_async=True,
)
query_id = exec_result.get("query_id")
@@ -277,13 +319,13 @@ class SupersetSqlLabExecutor:
max_polls=max_polls,
poll_interval_seconds=poll_interval_seconds,
)
# [/DEF:execute_and_poll:Function]
# #endregion execute_and_poll
# [DEF:get_query_results:Function]
# @PURPOSE: Fetch the results of a completed query.
# @PRE: query_id is a valid Superset query ID.
# @POST: Returns query results if available.
# @SIDE_EFFECT: Makes HTTP GET to Superset.
# #region get_query_results [C:4] [TYPE Function] [SEMANTICS translate,query,results]
# @BRIEF Fetch the results of a completed query from Superset.
# @PRE query_id is a valid Superset query ID.
# @POST Returns query results if available, or error dict.
# @SIDE_EFFECT Makes HTTP GET to Superset API.
def get_query_results(self, query_id: str) -> Dict[str, Any]:
with belief_scope("SupersetSqlLabExecutor.get_query_results"):
client = self._get_client()
@@ -309,8 +351,8 @@ class SupersetSqlLabExecutor:
"error_message": str(e),
"results": None,
}
# [/DEF:get_query_results:Function]
# #endregion get_query_results
# [/DEF:SupersetSqlLabExecutor:Class]
# [/DEF:SupersetSqlLabExecutor:Module]
# #endregion SupersetSqlLabExecutor
# #endregion SupersetSqlLabExecutor

View File

@@ -1,3 +1,2 @@
# [DEF:SchemasPackage:Package]
# @PURPOSE: API schema package root.
# [/DEF:SchemasPackage:Package]
# #region SchemasPackage [C:1] [TYPE Package]
# #endregion SchemasPackage

View File

@@ -1,9 +1,6 @@
# [DEF:TranslateSchemas:Module]
# @COMPLEXITY: 2
# @SEMANTICS: translate, schemas, pydantic
# @PURPOSE: Pydantic v2 schemas for translation API request/response serialization.
# @LAYER: API
# @RELATION: DEPENDS_ON -> pydantic
# #region TranslateSchemas [C:2] [TYPE Module]
# @BRIEF Pydantic v2 schemas for translation API request/response serialization.
# @LAYER Domain
from datetime import datetime
from typing import Any, Dict, List, Optional
@@ -11,8 +8,7 @@ from pydantic import BaseModel, Field
import json
# [DEF:TranslateJobCreate:Class]
# @PURPOSE: Schema for creating a new translation job.
# #region TranslateJobCreate [C:1] [TYPE Class]
class TranslateJobCreate(BaseModel):
name: str
description: Optional[str] = None
@@ -33,11 +29,11 @@ class TranslateJobCreate(BaseModel):
upsert_strategy: str = Field("MERGE", description="UPSERT strategy: MERGE, INSERT, UPDATE")
dictionary_ids: Optional[List[str]] = Field(default_factory=list, description="Associated terminology dictionary IDs")
environment_id: Optional[str] = Field(None, description="Superset environment ID")
# [/DEF:TranslateJobCreate:Class]
target_database_id: Optional[str] = Field(None, description="Superset database ID for INSERT via SQL Lab")
# #endregion TranslateJobCreate
# [DEF:TranslateJobUpdate:Class]
# @PURPOSE: Schema for updating an existing translation job.
# #region TranslateJobUpdate [C:1] [TYPE Class]
class TranslateJobUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
@@ -59,11 +55,11 @@ class TranslateJobUpdate(BaseModel):
status: Optional[str] = None
dictionary_ids: Optional[List[str]] = None
environment_id: Optional[str] = None
# [/DEF:TranslateJobUpdate:Class]
target_database_id: Optional[str] = None
# #endregion TranslateJobUpdate
# [DEF:TranslateJobResponse:Class]
# @PURPOSE: Schema for translation job API responses.
# #region TranslateJobResponse [C:1] [TYPE Class]
class TranslateJobResponse(BaseModel):
id: str
name: str
@@ -89,24 +85,24 @@ class TranslateJobResponse(BaseModel):
updated_at: Optional[datetime] = None
dictionary_ids: Optional[List[str]] = None
environment_id: Optional[str] = None
target_database_id: Optional[str] = None
class Config:
from_attributes = True
# [/DEF:TranslateJobResponse:Class]
# #endregion TranslateJobResponse
# [DEF:DatasourceColumnResponse:Class]
# @PURPOSE: Schema for datasource column metadata response.
# #region DatasourceColumnResponse [C:1] [TYPE Class]
class DatasourceColumnResponse(BaseModel):
name: str
type: Optional[str] = None
is_physical: bool = True
is_dttm: bool = False
description: Optional[str] = None
# #endregion DatasourceColumnResponse
# [DEF:DatasourceColumnsResponse:Class]
# @PURPOSE: Schema for datasource columns endpoint response.
# #region DatasourceColumnsResponse [C:1] [TYPE Class]
class DatasourceColumnsResponse(BaseModel):
datasource_id: int
datasource_name: Optional[str] = None
@@ -116,11 +112,10 @@ class DatasourceColumnsResponse(BaseModel):
class Config:
from_attributes = True
# [/DEF:DatasourceColumnsResponse:Class]
# #endregion DatasourceColumnsResponse
# [DEF:DuplicateJobResponse:Class]
# @PURPOSE: Schema for duplicate job response.
# #region DuplicateJobResponse [C:1] [TYPE Class]
class DuplicateJobResponse(BaseModel):
id: str
name: str
@@ -128,32 +123,29 @@ class DuplicateJobResponse(BaseModel):
class Config:
from_attributes = True
# [/DEF:DuplicateJobResponse:Class]
# #endregion DuplicateJobResponse
# [DEF:DictionaryCreate:Class]
# @PURPOSE: Schema for creating a new terminology dictionary.
# #region DictionaryCreate [C:1] [TYPE Class]
class DictionaryCreate(BaseModel):
name: str
description: Optional[str] = None
source_dialect: str
target_dialect: str
is_active: bool = True
# [/DEF:DictionaryCreate:Class]
# #endregion DictionaryCreate
# [DEF:DictionaryImport:Class]
# @PURPOSE: Schema for importing entries into a terminology dictionary.
# #region DictionaryImport [C:1] [TYPE Class]
class DictionaryImport(BaseModel):
content: str = Field(..., description="CSV or TSV content as raw string")
delimiter: Optional[str] = Field(None, description="Detected or forced delimiter: ',' or '\\t'. Auto-detect if omitted.")
on_conflict: str = Field("overwrite", description="'overwrite' or 'keep_existing' or 'cancel'")
preview_only: bool = Field(False, description="If true, return preview without applying")
# [/DEF:DictionaryImport:Class]
# #endregion DictionaryImport
# [DEF:DictionaryResponse:Class]
# @PURPOSE: Schema for terminology dictionary API responses.
# #region DictionaryResponse [C:1] [TYPE Class]
class DictionaryResponse(BaseModel):
id: str
name: str
@@ -168,11 +160,10 @@ class DictionaryResponse(BaseModel):
class Config:
from_attributes = True
# [/DEF:DictionaryResponse:Class]
# #endregion DictionaryResponse
# [DEF:DictionaryEntryCreate:Class]
# @PURPOSE: Schema for adding/editing a dictionary entry.
# #region DictionaryEntryCreate [C:1] [TYPE Class]
class DictionaryEntryCreate(BaseModel):
source_term: str = Field(..., description="Source term to translate")
target_term: str = Field(..., description="Target/translated term")
@@ -180,11 +171,10 @@ class DictionaryEntryCreate(BaseModel):
class Config:
from_attributes = True
# [/DEF:DictionaryEntryCreate:Class]
# #endregion DictionaryEntryCreate
# [DEF:DictionaryEntryResponse:Class]
# @PURPOSE: Schema for dictionary entry API responses.
# #region DictionaryEntryResponse [C:1] [TYPE Class]
class DictionaryEntryResponse(BaseModel):
id: str
dictionary_id: str
@@ -197,11 +187,10 @@ class DictionaryEntryResponse(BaseModel):
class Config:
from_attributes = True
# [/DEF:DictionaryEntryResponse:Class]
# #endregion DictionaryEntryResponse
# [DEF:DictionaryImportResult:Class]
# @PURPOSE: Schema for dictionary import result.
# #region DictionaryImportResult [C:1] [TYPE Class]
class DictionaryImportResult(BaseModel):
total: int = 0
created: int = 0
@@ -209,28 +198,26 @@ class DictionaryImportResult(BaseModel):
skipped: int = 0
errors: List[Dict[str, Any]] = Field(default_factory=list)
preview: List[Dict[str, Any]] = Field(default_factory=list, description="Preview rows with conflict flags")
# [/DEF:DictionaryImportResult:Class]
# #endregion DictionaryImportResult
# [DEF:PreviewRequest:Class]
# @PURPOSE: Schema for triggering a translation preview.
# #region PreviewRequest [C:1] [TYPE Class]
class PreviewRequest(BaseModel):
sample_size: int = Field(10, ge=1, le=100, description="Number of sample rows to preview")
prompt_template: Optional[str] = Field(None, description="Optional custom prompt template")
env_id: Optional[str] = Field(None, description="Superset environment ID for preview data fetch")
# #endregion PreviewRequest
# [DEF:PreviewRowUpdate:Class]
# @PURPOSE: Schema for approving/editing/rejecting a preview row.
# #region PreviewRowUpdate [C:1] [TYPE Class]
class PreviewRowUpdate(BaseModel):
action: str = Field(..., description="'approve', 'reject', or 'edit'")
translation: Optional[str] = Field(None, description="Edited translation (required for 'edit' action)")
feedback: Optional[str] = Field(None, description="Optional feedback/comment")
# [/DEF:PreviewRowUpdate:Class]
# #endregion PreviewRowUpdate
# [DEF:PreviewAcceptResponse:Class]
# @PURPOSE: Schema for preview accept response.
# #region PreviewAcceptResponse [C:1] [TYPE Class]
class PreviewAcceptResponse(BaseModel):
id: str
job_id: str
@@ -242,11 +229,10 @@ class PreviewAcceptResponse(BaseModel):
class Config:
from_attributes = True
# [/DEF:PreviewAcceptResponse:Class]
# #endregion PreviewAcceptResponse
# [DEF:CostEstimate:Class]
# @PURPOSE: Schema for cost estimation in preview response.
# #region CostEstimate [C:1] [TYPE Class]
class CostEstimate(BaseModel):
sample_size: int = 0
sample_prompt_tokens: int = 0
@@ -256,11 +242,10 @@ class CostEstimate(BaseModel):
estimated_total_rows: int = 0
estimated_tokens: int = 0
estimated_cost: float = 0.0
# [/DEF:CostEstimate:Class]
# #endregion CostEstimate
# [DEF:TermCorrectionSubmit:Class]
# @PURPOSE: Schema for submitting a term correction in a translation preview.
# #region TermCorrectionSubmit [C:1] [TYPE Class]
class TermCorrectionSubmit(BaseModel):
source_term: str
incorrect_target_term: str
@@ -268,29 +253,26 @@ class TermCorrectionSubmit(BaseModel):
dictionary_id: Optional[str] = Field(None, description="Target dictionary ID (language-filtered)")
origin_run_id: Optional[str] = Field(None, description="Run ID from which this correction originated")
origin_row_key: Optional[str] = Field(None, description="Row key within the run")
# [/DEF:TermCorrectionSubmit:Class]
# #endregion TermCorrectionSubmit
# [DEF:TermCorrectionBulkSubmit:Class]
# @PURPOSE: Schema for submitting multiple term corrections atomically.
# #region TermCorrectionBulkSubmit [C:1] [TYPE Class]
class TermCorrectionBulkSubmit(BaseModel):
corrections: List[TermCorrectionSubmit]
dictionary_id: str = Field(..., description="Target dictionary ID for all corrections")
# [/DEF:TermCorrectionBulkSubmit:Class]
# #endregion TermCorrectionBulkSubmit
# [DEF:CorrectionConflictResult:Class]
# @PURPOSE: Schema for reporting conflicts in correction submission.
# #region CorrectionConflictResult [C:1] [TYPE Class]
class CorrectionConflictResult(BaseModel):
source_term: str
existing_target_term: str
submitted_target_term: str
action: str = "keep_existing"
# [/DEF:CorrectionConflictResult:Class]
# #endregion CorrectionConflictResult
# [DEF:CorrectionSubmitResponse:Class]
# @PURPOSE: Schema for correction submission response.
# #region CorrectionSubmitResponse [C:1] [TYPE Class]
class CorrectionSubmitResponse(BaseModel):
entry_id: Optional[str] = None
action: str # "created", "updated", "conflict_detected", "skipped"
@@ -298,20 +280,18 @@ class CorrectionSubmitResponse(BaseModel):
target_term: str
conflict: Optional[CorrectionConflictResult] = None
message: Optional[str] = None
# [/DEF:CorrectionSubmitResponse:Class]
# #endregion CorrectionSubmitResponse
# [DEF:ScheduleConfig:Class]
# @PURPOSE: Schema for configuring a recurring translation schedule.
# #region ScheduleConfig [C:1] [TYPE Class]
class ScheduleConfig(BaseModel):
cron_expression: str = Field(..., description="Cron expression for scheduling (e.g. '0 2 * * *')")
timezone: str = Field("UTC", description="Timezone for the cron schedule (e.g. 'UTC', 'Europe/Moscow')")
is_active: bool = True
# [/DEF:ScheduleConfig:Class]
# #endregion ScheduleConfig
# [DEF:ScheduleResponse:Class]
# @PURPOSE: Schema for schedule API responses.
# #region ScheduleResponse [C:1] [TYPE Class]
class ScheduleResponse(BaseModel):
id: str
job_id: str
@@ -326,21 +306,19 @@ class ScheduleResponse(BaseModel):
class Config:
from_attributes = True
# [/DEF:ScheduleResponse:Class]
# #endregion ScheduleResponse
# [DEF:NextExecutionResponse:Class]
# @PURPOSE: Schema for next execution preview.
# #region NextExecutionResponse [C:1] [TYPE Class]
class NextExecutionResponse(BaseModel):
job_id: str
cron_expression: str
timezone: str
next_executions: List[str] = []
# [/DEF:NextExecutionResponse:Class]
# #endregion NextExecutionResponse
# [DEF:TranslationRunResponse:Class]
# @PURPOSE: Schema for translation run API responses.
# #region TranslationRunResponse [C:1] [TYPE Class]
class TranslationRunResponse(BaseModel):
id: str
job_id: str
@@ -364,22 +342,20 @@ class TranslationRunResponse(BaseModel):
class Config:
from_attributes = True
# [/DEF:TranslationRunResponse:Class]
# #endregion TranslationRunResponse
# [DEF:RunDetailResponse:Class]
# @PURPOSE: Schema for detailed run response including records, events, and config snapshot.
# #region RunDetailResponse [C:1] [TYPE Class]
class RunDetailResponse(BaseModel):
run: TranslationRunResponse
records: List[Dict[str, Any]] = Field(default_factory=list, description="Paginated translation records")
events: List[Dict[str, Any]] = Field(default_factory=list, description="Run events")
event_invariants: Optional[Dict[str, Any]] = None
batch_count: int = 0
# [/DEF:RunDetailResponse:Class>
# #endregion RunDetailResponse
# [DEF:RunHistoryFilter:Class]
# @PURPOSE: Schema for filtering run history.
# #region RunHistoryFilter [C:1] [TYPE Class]
class RunHistoryFilter(BaseModel):
job_id: Optional[str] = None
status: Optional[str] = None
@@ -389,21 +365,19 @@ class RunHistoryFilter(BaseModel):
date_to: Optional[datetime] = None
page: int = 1
page_size: int = 20
# [/DEF:RunHistoryFilter:Class>
# #endregion RunHistoryFilter
# [DEF:RunListResponse:Class]
# @PURPOSE: Schema for paginated run list response.
# #region RunListResponse [C:1] [TYPE Class]
class RunListResponse(BaseModel):
items: List[TranslationRunResponse] = []
total: int = 0
page: int = 1
page_size: int = 20
# [/DEF:RunListResponse:Class>
# #endregion RunListResponse
# [DEF:AggregatedMetricsResponse:Class]
# @PURPOSE: Schema for aggregated per-job metrics.
# #region AggregatedMetricsResponse [C:1] [TYPE Class]
class AggregatedMetricsResponse(BaseModel):
job_id: str
total_runs: int = 0
@@ -419,11 +393,10 @@ class AggregatedMetricsResponse(BaseModel):
avg_duration_ms: Optional[float] = None
last_run_at: Optional[datetime] = None
next_scheduled_run: Optional[datetime] = None
# [/DEF:AggregatedMetricsResponse:Class]
# #endregion AggregatedMetricsResponse
# [DEF:PreviewRow:Class]
# @PURPOSE: A single row in a translation preview showing original and translated content side by side.
# #region PreviewRow [C:1] [TYPE Class]
class PreviewRow(BaseModel):
id: str
source_sql: Optional[str] = None
@@ -433,11 +406,10 @@ class PreviewRow(BaseModel):
source_object_name: Optional[str] = None
status: str = "PENDING"
feedback: Optional[str] = None
# [/DEF:PreviewRow:Class]
# #endregion PreviewRow
# [DEF:TranslationPreviewResponse:Class]
# @PURPOSE: Schema for translation preview session API responses.
# #region TranslationPreviewResponse [C:1] [TYPE Class]
class TranslationPreviewResponse(BaseModel):
id: str
job_id: str
@@ -450,11 +422,10 @@ class TranslationPreviewResponse(BaseModel):
class Config:
from_attributes = True
# [/DEF:TranslationPreviewResponse:Class]
# #endregion TranslationPreviewResponse
# [DEF:TranslationBatchResponse:Class]
# @PURPOSE: Schema for translation batch API responses.
# #region TranslationBatchResponse [C:1] [TYPE Class]
class TranslationBatchResponse(BaseModel):
id: str
run_id: str
@@ -469,11 +440,10 @@ class TranslationBatchResponse(BaseModel):
class Config:
from_attributes = True
# [/DEF:TranslationBatchResponse:Class]
# #endregion TranslationBatchResponse
# [DEF:MetricsResponse:Class]
# @PURPOSE: Schema for translation metrics API responses.
# #region MetricsResponse [C:1] [TYPE Class]
class MetricsResponse(BaseModel):
job_id: str
snapshot_date: datetime
@@ -490,6 +460,6 @@ class MetricsResponse(BaseModel):
class Config:
from_attributes = True
# [/DEF:MetricsResponse:Class]
# #endregion MetricsResponse
# [/DEF:TranslateSchemas:Module]
# #endregion TranslateSchemas