""" Review API — the side-by-side review foundation. Endpoints (per translation job): GET /api/v1/translations/{job_id}/segments list segments for review PATCH /api/v1/segments/{segment_id} edit / approve / reject POST /api/v1/translations/{job_id}/rebuild rebuild the document with approved/edited segments GET /api/v1/translations/{job_id}/xliff export XLIFF 1.2 POST /api/v1/translations/{job_id}/xliff import XLIFF back Access mirrors the job endpoints: owner (JWT/API key) or the job's secret token. Approvals also feed the per-user translation memory so future jobs reuse human-reviewed translations. """ import asyncio from pathlib import Path from typing import Optional, List from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi.responses import JSONResponse, PlainTextResponse from pydantic import BaseModel, Field from core.logging import get_logger from database.connection import get_sync_session from database.models import TranslationSegment from middleware.api_key_auth import get_authenticated_user from routes.translate_routes import ( _check_job_access, _translation_jobs, ) logger = get_logger(__name__) router = APIRouter(prefix="/api/v1", tags=["Review"]) XLIFF_NS = "urn:oasis:names:tc:xliff:document:1.2" # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- async def _load_job(job_id: str) -> Optional[dict]: job = _translation_jobs.get(job_id) if job: return job from core.redis import get_job_status_async return await get_job_status_async(job_id) async def _job_or_403(job_id: str, current_user, token: Optional[str]) -> dict: job = await _load_job(job_id) if not job: raise HTTPException(status_code=404, detail={"error": "JOB_NOT_FOUND"}) denied = _check_job_access(job, current_user, token) if denied: # Convert JSONResponse to HTTPException semantics via raise raise HTTPException( status_code=denied.status_code, detail=denied.body ) return job def _fetch_segments(job_id: str) -> List[TranslationSegment]: with get_sync_session() as session: rows = ( session.query(TranslationSegment) .filter(TranslationSegment.job_id == job_id) .order_by(TranslationSegment.segment_index.asc()) .all() ) # Detach into plain dicts before the session closes return [r.to_dict() for r in rows] # --------------------------------------------------------------------------- # Segment list # --------------------------------------------------------------------------- @router.get("/translations/{job_id}/segments") async def list_segments( job_id: str, token: Optional[str] = Query(None), current_user=Depends(get_authenticated_user), ): job = await _job_or_403(job_id, current_user, token) segments = await asyncio.to_thread(_fetch_segments, job_id) return JSONResponse( status_code=200, content={ "data": { "job_id": job_id, "file_name": job.get("file_name"), "status": job.get("status"), "segments": segments, "counts": { "total": len(segments), "pending": sum(1 for s in segments if s["status"] == "pending"), "approved": sum(1 for s in segments if s["status"] == "approved"), "edited": sum(1 for s in segments if s["status"] == "edited"), }, }, "meta": {}, }, ) # --------------------------------------------------------------------------- # Segment edit / approve # --------------------------------------------------------------------------- class SegmentUpdate(BaseModel): reviewed_text: Optional[str] = None status: Optional[str] = Field(None, description="pending | approved | edited") VALID_STATUSES = {"pending", "approved", "edited"} @router.patch("/segments/{segment_id}") async def update_segment( segment_id: str, update: SegmentUpdate, token: Optional[str] = Query(None), current_user=Depends(get_authenticated_user), ): def _apply(): with get_sync_session() as session: seg = ( session.query(TranslationSegment) .filter(TranslationSegment.id == segment_id) .first() ) if not seg: return None # Segments are only persisted for authenticated users — # ownership is checked directly on the row. if not current_user or str(seg.user_id) != str(current_user.id): raise PermissionError("access denied") if update.reviewed_text is not None: seg.reviewed_text = update.reviewed_text if update.status is not None: if update.status not in VALID_STATUSES: raise ValueError("invalid status") seg.status = update.status # Editing implies the reviewed text is the new translation if update.status == "edited" and seg.reviewed_text is None: raise ValueError("reviewed_text required for status=edited") if update.status == "pending": seg.reviewed_text = None session.commit() refreshed = ( session.query(TranslationSegment) .filter(TranslationSegment.id == segment_id) .first() ) return refreshed.to_dict() try: result = await asyncio.to_thread(_apply) except PermissionError: raise HTTPException(status_code=403, detail={"error": "ACCESS_DENIED"}) except FileNotFoundError: raise HTTPException(status_code=404, detail={"error": "SEGMENT_NOT_FOUND"}) except ValueError as e: raise HTTPException(status_code=422, detail={"error": str(e)}) if result is None: raise HTTPException(status_code=404, detail={"error": "SEGMENT_NOT_FOUND"}) # Approved/edited segments feed the per-user translation memory so the # next jobs reuse human-reviewed translations (broad context: these are # explicit human approvals). if result["status"] in ("approved", "edited"): try: from services.translation_tm import store_tm, TMScope job = _translation_jobs.get(result["job_id"]) or {} provider_name = job.get("provider") or "google" final_text = result["reviewed_text"] or result["translated_text"] store_tm( [result["source_text"]], [final_text], target_language=(job or {}).get("target_lang", "en"), source_language=(job or {}).get("source_lang", "auto"), provider_name=provider_name, scope=TMScope.from_prompt(result["user_id"], None), ) except Exception as tm_err: logger.warning("review_tm_sync_failed", error=str(tm_err)) return JSONResponse(status_code=200, content={"data": result, "meta": {}}) # --------------------------------------------------------------------------- # Rebuild # --------------------------------------------------------------------------- def _build_overrides(job_id: str) -> dict: with get_sync_session() as session: rows = ( session.query(TranslationSegment) .filter( TranslationSegment.job_id == job_id, TranslationSegment.status.in_(["approved", "edited"]), ) .all() ) return { r.source_text: (r.reviewed_text or r.translated_text) for r in rows if (r.reviewed_text or r.translated_text) } @router.post("/translations/{job_id}/rebuild") async def rebuild_translation( job_id: str, token: Optional[str] = Query(None), current_user=Depends(get_authenticated_user), ): job = await _job_or_403(job_id, current_user, token) if job.get("status") != "completed": raise HTTPException( status_code=409, detail={ "error": "JOB_NOT_COMPLETED", "message": "Rebuild is only available for completed jobs.", }, ) overrides = await asyncio.to_thread(_build_overrides, job_id) if not overrides: raise HTTPException( status_code=422, detail={ "error": "NO_REVIEWED_SEGMENTS", "message": "Approve or edit at least one segment before rebuilding.", }, ) input_path = Path(job.get("input_path", "")) if not input_path.exists(): raise HTTPException( status_code=410, detail={ "error": "SOURCE_FILE_EXPIRED", "message": ( "Le fichier source a été supprimé (rétention). " "Re-téléversez-le : les segments approuvés sont dans la " "mémoire de traduction et seront réutilisés automatiquement." ), }, ) file_extension = job.get("file_extension") or input_path.suffix.lower() target_lang = job.get("target_lang", "en") source_lang = job.get("source_lang", "auto") output_filename = ( f"rebuilt_{job_id[3:]}_{Path(job.get('file_name') or input_path.name).name}" ) from utils.file_handler import FileHandler output_path = Path( FileHandler().generate_unique_filename(output_filename, "translated") ) output_path = Path(job.get("input_path", "")).parent.parent / "outputs" / output_path.name output_path.parent.mkdir(parents=True, exist_ok=True) def _rebuild(): from translators import ExcelTranslator, WordTranslator, PowerPointTranslator ext = file_extension if ext == ".docx": translator = WordTranslator(provider=None) elif ext == ".xlsx": translator = ExcelTranslator(provider=None) elif ext == ".pptx": translator = PowerPointTranslator(provider=None) elif ext == ".pdf": from translators.pdf_translator import PDFTranslator translator = PDFTranslator(provider=None) else: raise ValueError(f"unsupported extension {ext}") translator.set_segment_overrides(overrides) kwargs = {} if ext == ".pdf": kwargs["pdf_mode"] = job.get("pdf_mode") or "layout" result = translator.translate_file( input_path, output_path, target_lang, source_lang, **kwargs ) return Path(result), translator.get_translation_stats() try: result_path, stats = await asyncio.to_thread(_rebuild) except Exception as e: logger.error("rebuild_failed", job_id=job_id, error=str(e)) raise HTTPException( status_code=500, detail={"error": "REBUILD_FAILED", "message": str(e)[:300]}, ) if not result_path.exists() or result_path.stat().st_size == 0: raise HTTPException( status_code=500, detail={"error": "REBUILD_EMPTY_OUTPUT"} ) # Point the job's download at the rebuilt file job["output_path"] = str(result_path) job["rebuilt_at"] = asyncio.get_event_loop().time() _translation_jobs[job_id] = job return JSONResponse( status_code=200, content={ "data": { "job_id": job_id, "rebuilt": True, "segments_applied": len(overrides), "stats": stats, "download_url": f"/api/v1/download/{job_id}", }, "meta": {}, }, ) # --------------------------------------------------------------------------- # XLIFF 1.2 export / import # --------------------------------------------------------------------------- def _xliff_export(job: dict, segments: List[dict]) -> str: from xml.sax.saxutils import escape, quoteattr src_lang = job.get("source_lang") or "auto" tgt_lang = job.get("target_lang") or "en" file_name = job.get("file_name") or "document" lines = [ '', f'', f' ', " ", ] for seg in segments: target = ( seg["reviewed_text"] if seg["status"] == "edited" and seg["reviewed_text"] else seg["translated_text"] ) lines.append(f' ') lines.append(f" {escape(seg['source_text'])}") lines.append(f" {escape(target)}") if seg["status"] != "pending": lines.append(f' status: {seg["status"]}') lines.append(" ") lines.extend([" ", " ", ""]) return "\n".join(lines) @router.get("/translations/{job_id}/xliff") async def export_xliff( job_id: str, token: Optional[str] = Query(None), current_user=Depends(get_authenticated_user), ): job = await _job_or_403(job_id, current_user, token) segments = await asyncio.to_thread(_fetch_segments, job_id) if not segments: raise HTTPException( status_code=404, detail={"error": "NO_SEGMENTS", "message": "Aucun segment pour ce job."}, ) xml = _xliff_export(job, segments) return PlainTextResponse( content=xml, media_type="application/xliff+xml", headers={ "Content-Disposition": f'attachment; filename="{job_id}.xliff"' }, ) @router.post("/translations/{job_id}/xliff") async def import_xliff( job_id: str, request: Request, token: Optional[str] = Query(None), current_user=Depends(get_authenticated_user), ): job = await _job_or_403(job_id, current_user, token) body = await request.body() from lxml import etree try: root = etree.fromstring(body) except Exception as e: raise HTTPException( status_code=422, detail={"error": "INVALID_XLIFF", "message": str(e)[:200]}, ) updates = {} for unit in root.iter(f"{{{XLIFF_NS}}}trans-unit"): unit_id = unit.get("id") target_el = unit.find(f"{{{XLIFF_NS}}}target") if not unit_id or target_el is None or target_el.text is None: continue updates[unit_id] = target_el.text if not updates: raise HTTPException( status_code=422, detail={"error": "NO_TRANS_UNITS", "message": "Aucun trans-unit avec cible trouvé."}, ) def _apply(): applied = 0 with get_sync_session() as session: segs = ( session.query(TranslationSegment) .filter(TranslationSegment.job_id == job_id) .all() ) by_id = {s.id: s for s in segs} for seg_id, new_target in updates.items(): seg = by_id.get(seg_id) if not seg: continue seg.reviewed_text = new_target seg.status = "edited" if new_target.strip() != seg.translated_text.strip() else "approved" applied += 1 session.commit() return applied applied = await asyncio.to_thread(_apply) return JSONResponse( status_code=200, content={ "data": {"job_id": job_id, "segments_updated": applied}, "meta": {}, }, )