diff --git a/backend/src/api/routes/translate/__init__.py b/backend/src/api/routes/translate/__init__.py index 925b379e..16f440d4 100644 --- a/backend/src/api/routes/translate/__init__.py +++ b/backend/src/api/routes/translate/__init__.py @@ -27,8 +27,10 @@ from . import ( _job_routes, # noqa: F401 — registers job handlers _metrics_routes, # noqa: F401 — registers metrics handlers _preview_routes, # noqa: F401 — registers preview handlers + _run_edit_routes, # noqa: F401 — registers run edit/correction/bulk-replace handlers + _run_history_routes, # noqa: F401 — registers run history/status/records/batches handlers _run_list_routes, # noqa: F401 — registers run list/detail/csv handlers - _run_routes, # noqa: F401 — registers run handlers + _run_routes, # noqa: F401 — registers run execution/retry/cancel handlers _schedule_routes, # noqa: F401 — registers schedule handlers ) from ._router import router diff --git a/backend/src/api/routes/translate/_run_edit_routes.py b/backend/src/api/routes/translate/_run_edit_routes.py new file mode 100644 index 00000000..c0147e4d --- /dev/null +++ b/backend/src/api/routes/translate/_run_edit_routes.py @@ -0,0 +1,200 @@ +# #region TranslateRunEditRoutesModule [C:3] [TYPE Module] [SEMANTICS fastapi, translate, api, edit, correction, bulk, override] +# @BRIEF Translation Run inline edit, bulk find-replace, and language override routes. +# @LAYER API +# @RELATION DEPENDS_ON -> [TranslateRunRoutesModule] +# @RELATION CALLED_BY -> [TranslateRoutes] + +from fastapi import Depends, HTTPException, status +from sqlalchemy.orm import Session + +from ....core.config_manager import ConfigManager +from ....core.database import get_db +from ....core.logger import logger +from ....dependencies import get_config_manager, get_current_user, has_permission +from ....schemas.auth import User +from ....schemas.translate import ( + BulkFindReplaceRequest, + InlineEditRequest, + OverrideLanguageRequest, +) +from ._router import router + + +# #region override_detected_language [C:4] [TYPE Function] +# @BRIEF Manually override the detected source language for a specific translation language entry. +# @PRE User has translate.job.execute permission. Run, record, and language entry exist. +# @POST TranslationLanguage.source_language_detected is updated; language_overridden is set to True. +# @SIDE_EFFECT DB write. +@router.put("/runs/{run_id}/records/{record_id}/languages/{language_code}/override-language") +def override_detected_language( + run_id: str, + record_id: str, + language_code: str, + payload: OverrideLanguageRequest, + current_user: User = Depends(get_current_user), + _ = Depends(has_permission("translate.job", "EXECUTE")), + db: Session = Depends(get_db), +): + logger.reason( + f"override_detected_language — Run: {run_id}, Record: {record_id}, " + f"Lang: {language_code}, Override: {payload.source_language}", + extra={"src": "translate_routes"}, + ) + try: + from ....models.translate import TranslationLanguage, TranslationRecord + + record = ( + db.query(TranslationRecord) + .filter( + TranslationRecord.id == record_id, + TranslationRecord.run_id == run_id, + ) + .first() + ) + if not record: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Translation record '{record_id}' not found in run '{run_id}'", + ) + + lang_entry = ( + db.query(TranslationLanguage) + .filter( + TranslationLanguage.record_id == record_id, + TranslationLanguage.language_code == language_code, + ) + .first() + ) + if not lang_entry: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Language entry '{language_code}' not found for record '{record_id}'", + ) + + lang_entry.source_language_detected = payload.source_language + lang_entry.language_overridden = True + lang_entry.needs_review = False + db.commit() + db.refresh(lang_entry) + + logger.reason("Language override applied", { + "run_id": run_id, + "record_id": record_id, + "language_code": language_code, + "new_source_language": payload.source_language, + "overridden_by": current_user.username, + }) + + from ....schemas.translate import TranslationLanguageResponse + return TranslationLanguageResponse.model_validate(lang_entry) + except HTTPException: + raise + except Exception as e: + logger.explore("override_detected_language failed", extra={"src": "translate_routes", "error": str(e)}) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) +# #endregion override_detected_language + + +# #region inline_edit_translation [C:4] [TYPE Function] [SEMANTICS api,translate,correction] +# @BRIEF Apply an inline correction to a translated value on a completed run result. +# @PRE User has translate.job.execute permission. Run, record, and language entry exist. +# @POST TranslationLanguage.final_value and user_edit are updated. Optional dictionary submission. +@router.put("/runs/{run_id}/records/{record_id}/languages/{language_code}") +def inline_edit_translation( + run_id: str, + record_id: str, + language_code: str, + payload: InlineEditRequest, + current_user: User = Depends(get_current_user), + _ = Depends(has_permission("translate.job", "EXECUTE")), + db: Session = Depends(get_db), +): + logger.reason( + f"inline_edit_translation — Run: {run_id}, Record: {record_id}, " + f"Lang: {language_code}, User: {current_user.username}", + extra={"src": "translate_routes"}, + ) + try: + from ....plugins.translate.service import InlineCorrectionService + + result = InlineCorrectionService.apply_inline_edit( + db=db, + run_id=run_id, + record_id=record_id, + language_code=language_code, + final_value=payload.final_value, + submit_to_dictionary=payload.submit_to_dictionary, + dictionary_id=payload.dictionary_id, + current_user=current_user.username, + context_data_override=payload.context_data_override, + usage_notes=payload.usage_notes, + keep_context=payload.keep_context, + ) + return result + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + except Exception as e: + logger.explore("inline_edit_translation failed", extra={"src": "translate_routes", "error": str(e)}) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) +# #endregion inline_edit_translation + + +# #region bulk_find_replace [C:4] [TYPE Function] [SEMANTICS api,translate,bulk,replace] +# @BRIEF Perform bulk find-and-replace on translated values within a run. +# @PRE User has translate.job.execute permission. Run exists. +# @POST If preview=false, matching translations are updated. Optional dictionary submission. +@router.post("/runs/{run_id}/bulk-replace") +def bulk_find_replace( + run_id: str, + payload: BulkFindReplaceRequest, + current_user: User = Depends(get_current_user), + _ = Depends(has_permission("translate.job", "EXECUTE")), + db: Session = Depends(get_db), +): + logger.reason( + f"bulk_find_replace — Run: {run_id}, Pattern: '{payload.find_pattern}', " + f"Regex: {payload.is_regex}, Lang: {payload.target_language}, " + f"User: {current_user.username}", + extra={"src": "translate_routes"}, + ) + try: + from ....plugins.translate.service import BulkFindReplaceService + + if payload.preview: + preview = BulkFindReplaceService.preview( + db=db, + run_id=run_id, + pattern=payload.find_pattern, + is_regex=payload.is_regex, + replacement_text=payload.replacement_text, + target_language=payload.target_language, + ) + return { + "rows_affected": len(preview), + "corrections_submitted": 0, + "preview": preview, + } + + result = BulkFindReplaceService.apply( + db=db, + run_id=run_id, + pattern=payload.find_pattern, + is_regex=payload.is_regex, + replacement_text=payload.replacement_text, + target_language=payload.target_language, + submit_to_dictionary=payload.submit_to_dictionary, + dictionary_id=payload.dictionary_id, + usage_notes=payload.usage_notes, + current_user=current_user.username, + submit_to_dictionary_with_context=payload.submit_to_dictionary_with_context, + ) + return result + except ValueError as e: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) + except Exception as e: + logger.explore("bulk_find_replace failed", extra={"src": "translate_routes", "error": str(e)}) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) +# #endregion bulk_find_replace + + +# #endregion TranslateRunEditRoutesModule diff --git a/backend/src/api/routes/translate/_run_routes.py b/backend/src/api/routes/translate/_run_routes.py index 815bfdd1..df0d7dda 100644 --- a/backend/src/api/routes/translate/_run_routes.py +++ b/backend/src/api/routes/translate/_run_routes.py @@ -1,6 +1,9 @@ -# #region TranslateRunRoutesModule [C:3] [TYPE Module] [SEMANTICS fastapi, translate, api, search, execution, history] -# @BRIEF Translation Run execution, history, status, records and batches routes. +# #region TranslateRunRoutesModule [C:3] [TYPE Module] [SEMANTICS fastapi, translate, api, execution] +# @BRIEF Translation Run execution, retry, and cancel routes. # @LAYER API +# @RELATION DEPENDS_ON -> [TranslateRunHistoryRoutesModule] +# @RELATION DEPENDS_ON -> [TranslateRunEditRoutesModule] +# @RELATION CALLED_BY -> [TranslateRoutes] from datetime import UTC, datetime @@ -8,22 +11,15 @@ from fastapi import Depends, HTTPException, Query, status from sqlalchemy.orm import Session from ....core.config_manager import ConfigManager +from ....core.cot_logger import get_trace_id, seed_trace_id, set_trace_id from ....core.database import SessionLocal, get_db from ....core.logger import logger from ....dependencies import get_config_manager, get_current_user, has_permission from ....plugins.translate.orchestrator import TranslationOrchestrator from ....schemas.auth import User -from ....schemas.translate import ( - BulkFindReplaceRequest, - InlineEditRequest, - OverrideLanguageRequest, -) from ._helpers import _run_to_response from ._router import router -# ============================================================ -# Translation Run / Execute -# ============================================================ # #region run_translation [C:4] [TYPE Function] # @BRIEF Execute a translation job (trigger a run). @@ -55,8 +51,16 @@ def run_translation( # closed or expunged session. import threading + # Capture request trace_id for background thread correlation + _request_trace_id = get_trace_id() + def _background_execute(): from ....models.translate import TranslationRun as TRModel + # Preserve request's trace_id for log correlation across the thread boundary + if _request_trace_id: + set_trace_id(_request_trace_id) + else: + seed_trace_id() bg_db = None try: bg_db = SessionLocal() @@ -239,316 +243,4 @@ def cancel_run( # #endregion cancel_run -# #region get_run_history [C:4] [TYPE Function] -# @BRIEF Get run history for a translation job. -# @PRE User has translate.history.view permission. -# @POST Returns list of runs. -@router.get("/jobs/{job_id}/runs") -def get_run_history( - job_id: str, - page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=100), - current_user: User = Depends(get_current_user), - _ = Depends(has_permission("translate.history", "VIEW")), - db: Session = Depends(get_db), - config_manager: ConfigManager = Depends(get_config_manager), -): - """Get run history for a translation job.""" - logger.reason(f"get_run_history — Job: {job_id}, User: {current_user.username}", extra={"src": "translate_routes"}) - try: - orch = TranslationOrchestrator(db, config_manager, current_user.username) - total, runs = orch.get_run_history(job_id, page=page, page_size=page_size) - 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)) -# #endregion get_run_history - - -# #region get_run_status [C:4] [TYPE Function] -# @BRIEF Get status and statistics for a translation run. -# @PRE User has translate.history.view permission. -# @POST Returns run details with statistics. -@router.get("/runs/{run_id}") -def get_run_status( - run_id: str, - current_user: User = Depends(get_current_user), - _ = Depends(has_permission("translate.history", "VIEW")), - db: Session = Depends(get_db), - config_manager: ConfigManager = Depends(get_config_manager), -): - """Get status and statistics for a translation run.""" - logger.reason(f"get_run_status — Run: {run_id}, User: {current_user.username}", extra={"src": "translate_routes"}) - try: - orch = TranslationOrchestrator(db, config_manager, current_user.username) - return orch.get_run_status(run_id) - except ValueError as e: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) -# #endregion get_run_status - - -# #region get_run_records [C:4] [TYPE Function] -# @BRIEF Get paginated records for a translation run. -# @PRE User has translate.history.view permission. -# @POST Returns paginated records. -@router.get("/runs/{run_id}/records") -def get_run_records( - run_id: str, - page: int = Query(1, ge=1), - page_size: int = Query(50, ge=1, le=500), - status: str | None = Query(None), - current_user: User = Depends(get_current_user), - _ = Depends(has_permission("translate.history", "VIEW")), - db: Session = Depends(get_db), - config_manager: ConfigManager = Depends(get_config_manager), -): - """Get paginated records for a translation run.""" - logger.reason(f"get_run_records — Run: {run_id}, User: {current_user.username}", extra={"src": "translate_routes"}) - try: - orch = TranslationOrchestrator(db, config_manager, current_user.username) - 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)) -# #endregion get_run_records - - -# ============================================================ -# Batches -# ============================================================ - -# #region get_batches [C:4] [TYPE Function] -# @BRIEF Get batches for a translation run. -# @PRE User has translate.job.view permission. -# @POST Returns list of batches. -@router.get("/runs/{run_id}/batches") -def get_batches( - run_id: str, - current_user: User = Depends(get_current_user), - _ = Depends(has_permission("translate.job", "VIEW")), - db: Session = Depends(get_db), -): - """Get batches for a translation run.""" - logger.reason(f"get_batches — Run: {run_id}, User: {current_user.username}", extra={"src": "translate_routes"}) - try: - from ....models.translate import TranslationBatch - batches = ( - db.query(TranslationBatch) - .filter(TranslationBatch.run_id == run_id) - .order_by(TranslationBatch.batch_index.asc()) - .all() - ) - return [ - { - "id": b.id, - "run_id": b.run_id, - "batch_index": b.batch_index, - "status": b.status, - "total_records": b.total_records or 0, - "successful_records": b.successful_records or 0, - "failed_records": b.failed_records or 0, - "started_at": b.started_at.isoformat() if b.started_at else None, - "completed_at": b.completed_at.isoformat() if b.completed_at else None, - "created_at": b.created_at.isoformat() if b.created_at else None, - } - for b in batches - ] - except Exception as e: - logger.explore("get_batches failed", extra={"src": "translate_routes", "error": str(e)}) - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) -# #endregion get_batches - -# ============================================================ -# Manual Language Override -# ============================================================ - -# #region override_detected_language [C:4] [TYPE Function] -# @BRIEF Manually override the detected source language for a specific translation language entry. -# @PRE User has translate.job.execute permission. Run, record, and language entry exist. -# @POST TranslationLanguage.source_language_detected is updated; language_overridden is set to True. -# @SIDE_EFFECT DB write. -@router.put("/runs/{run_id}/records/{record_id}/languages/{language_code}/override-language") -def override_detected_language( - run_id: str, - record_id: str, - language_code: str, - payload: OverrideLanguageRequest, - current_user: User = Depends(get_current_user), - _ = Depends(has_permission("translate.job", "EXECUTE")), - db: Session = Depends(get_db), -): - """Manually override the detected source language for a translation entry.""" - logger.reason(f"override_detected_language — Run: {run_id}, Record: {record_id}, Lang: {language_code}, Override: {payload.source_language}", extra={"src": "translate_routes"}) - try: - from ....models.translate import TranslationLanguage, TranslationRecord - - # Verify the record exists and belongs to the run - record = ( - db.query(TranslationRecord) - .filter( - TranslationRecord.id == record_id, - TranslationRecord.run_id == run_id, - ) - .first() - ) - if not record: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Translation record '{record_id}' not found in run '{run_id}'", - ) - - # Find the language entry - lang_entry = ( - db.query(TranslationLanguage) - .filter( - TranslationLanguage.record_id == record_id, - TranslationLanguage.language_code == language_code, - ) - .first() - ) - if not lang_entry: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Language entry '{language_code}' not found for record '{record_id}'", - ) - - # Update the language entry - lang_entry.source_language_detected = payload.source_language - lang_entry.language_overridden = True - lang_entry.needs_review = False # Override clears the review flag - db.commit() - db.refresh(lang_entry) - - logger.reason("Language override applied", { - "run_id": run_id, - "record_id": record_id, - "language_code": language_code, - "new_source_language": payload.source_language, - "overridden_by": current_user.username, - }) - - from ....schemas.translate import TranslationLanguageResponse - return TranslationLanguageResponse.model_validate(lang_entry) - except HTTPException: - raise - except Exception as e: - logger.explore("override_detected_language failed", extra={"src": "translate_routes", "error": str(e)}) - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) -# #endregion override_detected_language - - -# ============================================================ -# Inline Correction -# ============================================================ - -# #region inline_edit_translation [C:4] [TYPE Function] [SEMANTICS api,translate,correction] -# @BRIEF Apply an inline correction to a translated value on a completed run result. -# @PRE User has translate.job.execute permission. Run, record, and language entry exist. -# @POST TranslationLanguage.final_value and user_edit are updated. Optional dictionary submission. -@router.put("/runs/{run_id}/records/{record_id}/languages/{language_code}") -def inline_edit_translation( - run_id: str, - record_id: str, - language_code: str, - payload: InlineEditRequest, - current_user: User = Depends(get_current_user), - _ = Depends(has_permission("translate.job", "EXECUTE")), - db: Session = Depends(get_db), -): - """Apply an inline correction to a translated value.""" - logger.reason( - f"inline_edit_translation — Run: {run_id}, Record: {record_id}, " - f"Lang: {language_code}, User: {current_user.username}", - extra={"src": "translate_routes"}, - ) - try: - from ....plugins.translate.service import InlineCorrectionService - - result = InlineCorrectionService.apply_inline_edit( - db=db, - run_id=run_id, - record_id=record_id, - language_code=language_code, - final_value=payload.final_value, - submit_to_dictionary=payload.submit_to_dictionary, - dictionary_id=payload.dictionary_id, - current_user=current_user.username, - context_data_override=payload.context_data_override, - usage_notes=payload.usage_notes, - keep_context=payload.keep_context, - ) - return result - except ValueError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) - except Exception as e: - logger.explore("inline_edit_translation failed", extra={"src": "translate_routes", "error": str(e)}) - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) -# #endregion inline_edit_translation - - -# ============================================================ -# Bulk Find-and-Replace -# ============================================================ - -# #region bulk_find_replace [C:4] [TYPE Function] [SEMANTICS api,translate,bulk,replace] -# @BRIEF Perform bulk find-and-replace on translated values within a run. -# @PRE User has translate.job.execute permission. Run exists. -# @POST If preview=false, matching translations are updated. Optional dictionary submission. -# @COMPLEXITY 4 - -@router.post("/runs/{run_id}/bulk-replace") -def bulk_find_replace( - run_id: str, - payload: BulkFindReplaceRequest, - current_user: User = Depends(get_current_user), - _ = Depends(has_permission("translate.job", "EXECUTE")), - db: Session = Depends(get_db), -): - """Perform bulk find-and-replace on translated values within a run.""" - logger.reason( - f"bulk_find_replace — Run: {run_id}, Pattern: '{payload.find_pattern}', " - f"Regex: {payload.is_regex}, Lang: {payload.target_language}, " - f"User: {current_user.username}", - extra={"src": "translate_routes"}, - ) - try: - from ....plugins.translate.service import BulkFindReplaceService - - if payload.preview: - # Preview mode: return matches without applying - preview = BulkFindReplaceService.preview( - db=db, - run_id=run_id, - pattern=payload.find_pattern, - is_regex=payload.is_regex, - replacement_text=payload.replacement_text, - target_language=payload.target_language, - ) - return { - "rows_affected": len(preview), - "corrections_submitted": 0, - "preview": preview, - } - - # Apply mode: perform replacements - result = BulkFindReplaceService.apply( - db=db, - run_id=run_id, - pattern=payload.find_pattern, - is_regex=payload.is_regex, - replacement_text=payload.replacement_text, - target_language=payload.target_language, - submit_to_dictionary=payload.submit_to_dictionary, - dictionary_id=payload.dictionary_id, - usage_notes=payload.usage_notes, - current_user=current_user.username, - submit_to_dictionary_with_context=payload.submit_to_dictionary_with_context, - ) - return result - except ValueError as e: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) - except Exception as e: - logger.explore("bulk_find_replace failed", extra={"src": "translate_routes", "error": str(e)}) - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) -# #endregion bulk_find_replace - - # #endregion TranslateRunRoutesModule diff --git a/backend/src/schemas/translate.py b/backend/src/schemas/translate.py index eeabb42a..8efa2825 100644 --- a/backend/src/schemas/translate.py +++ b/backend/src/schemas/translate.py @@ -213,6 +213,7 @@ class DictionaryEntryCreate(BaseModel): context_notes: str | None = Field(None, description="Optional context notes") source_language: str = Field("und", description="BCP-47 source language code (default: und)") target_language: str = Field("und", description="BCP-47 target language code (default: und)") + is_regex: bool = Field(False, description="Whether source_term is a regex pattern") model_config = ConfigDict(from_attributes=True) # #endregion DictionaryEntryCreate @@ -232,6 +233,7 @@ class DictionaryEntryResponse(BaseModel): context_data: dict[str, Any] | None = None usage_notes: str | None = None has_context: bool = False + is_regex: bool = False context_source: str | None = None origin_source_language: str | None = None created_at: datetime