Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 2m14s
Foundations:
- TranslationSegment model + migration f7e8d9c0b1a2 (segments, workspaces,
workspace_members, glossaries.workspace_id)
- SegmentRecorder injected into all 4 translators: unique (source,
translation) pairs captured per job and persisted (best-effort)
- set_segment_overrides: human-reviewed translations applied verbatim on
rebuild — top priority over TM and provider, zero API calls
Review API (routes/review_routes.py):
- GET /translations/{id}/segments (owner or job token)
- PATCH /segments/{id} edit/approve — feeds the per-user TM so approved
translations are reused in later jobs
- POST /translations/{id}/rebuild — rebuild document with reviewed text
- GET/POST /translations/{id}/xliff — XLIFF 1.2 export/import (edited
segments export their reviewed text)
Review editor (frontend /dashboard/reviews/[jobId]):
- side-by-side source/translation table, inline edit, approve (single or
all), rebuild & download (auth blob), XLIFF export/import, 13 locales
- 'Relire et corriger' link on the translation-complete screen
Team workspaces (routes/workspace_routes.py + /dashboard/teams):
- Workspace/WorkspaceMember models, roles owner/admin/member
- create (Business plan), list with seat usage, invite by email with
seat-limit enforcement (Business=5, Enterprise unlimited), removal
- shared glossaries: workspace members can use a glossary shared to their
workspace (access check extended)
Tests: 1184 passed / 0 failed (11 new: recorder, overrides, docx
capture->rebuild e2e, XLIFF structure/escaping, seats, workspace CRUD,
shared glossary access)
448 lines
16 KiB
Python
448 lines
16 KiB
Python
"""
|
|
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 = [
|
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
f'<xliff xmlns="{XLIFF_NS}" version="1.2">',
|
|
f' <file original={quoteattr(file_name)} source-language={quoteattr(src_lang)}'
|
|
f' target-language={quoteattr(tgt_lang)} datatype="plaintext">',
|
|
" <body>",
|
|
]
|
|
for seg in segments:
|
|
target = (
|
|
seg["reviewed_text"] if seg["status"] == "edited" and seg["reviewed_text"]
|
|
else seg["translated_text"]
|
|
)
|
|
lines.append(f' <trans-unit id={quoteattr(seg["id"])} resname="seg-{seg["segment_index"]}">')
|
|
lines.append(f" <source>{escape(seg['source_text'])}</source>")
|
|
lines.append(f" <target>{escape(target)}</target>")
|
|
if seg["status"] != "pending":
|
|
lines.append(f' <note>status: {seg["status"]}</note>')
|
|
lines.append(" </trans-unit>")
|
|
lines.extend([" </body>", " </file>", "</xliff>"])
|
|
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": {},
|
|
},
|
|
)
|