feat(review,teams): review foundation — segments, side-by-side editor, rebuild, XLIFF, team workspaces
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 2m14s
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)
This commit is contained in:
@@ -125,6 +125,18 @@ class ExcelTranslator:
|
||||
user_id, prompt or getattr(self, "_custom_prompt", None)
|
||||
)
|
||||
|
||||
def set_segment_recorder(self, recorder) -> None:
|
||||
"""Attach a segment recorder (review foundation)."""
|
||||
self._segment_recorder = recorder
|
||||
|
||||
def set_segment_overrides(self, overrides) -> None:
|
||||
"""Human-reviewed translations applied verbatim on rebuild."""
|
||||
self._segment_overrides = overrides or {}
|
||||
|
||||
def get_recorded_segments(self):
|
||||
recorder = getattr(self, "_segment_recorder", None)
|
||||
return recorder.get_pairs() if recorder is not None else []
|
||||
|
||||
|
||||
def translate_file(
|
||||
self,
|
||||
@@ -461,12 +473,41 @@ class ExcelTranslator:
|
||||
miss_texts, target_language, source_language
|
||||
)
|
||||
|
||||
# Translation memory: reuse this user's previous translations
|
||||
# (identical context/prompt) before hitting the provider.
|
||||
translated = translate_with_tm(
|
||||
texts, target_language, source_language,
|
||||
provider_name, getattr(self, "_tm_scope", None), _do_translate,
|
||||
from translators.segments import apply_overrides
|
||||
|
||||
# Reviewer overrides (approved/edited segments) win over everything:
|
||||
# no TM lookup, no provider call, zero drift from the reviewed text.
|
||||
ov_hits, ov_misses = apply_overrides(
|
||||
texts, getattr(self, "_segment_overrides", None)
|
||||
)
|
||||
miss_texts = [texts[i] for i in ov_misses]
|
||||
|
||||
if miss_texts:
|
||||
# Translation memory: reuse this user's previous translations
|
||||
# (identical context/prompt) before hitting the provider.
|
||||
miss_translated = translate_with_tm(
|
||||
miss_texts, target_language, source_language,
|
||||
provider_name, getattr(self, "_tm_scope", None), _do_translate,
|
||||
)
|
||||
else:
|
||||
miss_translated = []
|
||||
|
||||
translated = []
|
||||
miss_pos = 0
|
||||
for i in range(len(texts)):
|
||||
if i in ov_hits:
|
||||
translated.append(ov_hits[i])
|
||||
else:
|
||||
translated.append(
|
||||
miss_translated[miss_pos]
|
||||
if miss_pos < len(miss_translated)
|
||||
else texts[i]
|
||||
)
|
||||
miss_pos += 1
|
||||
|
||||
recorder = getattr(self, "_segment_recorder", None)
|
||||
if recorder is not None:
|
||||
recorder.record_pairs(zip(texts, translated))
|
||||
|
||||
changed = sum(1 for orig, trans in zip(texts, translated) if orig != trans and trans.strip())
|
||||
self._translation_stats["changed"] += changed
|
||||
|
||||
@@ -172,6 +172,18 @@ class PDFTranslator:
|
||||
if enabled is not None:
|
||||
self._ocr_enabled = enabled
|
||||
|
||||
def set_segment_recorder(self, recorder) -> None:
|
||||
"""Attach a segment recorder (review foundation)."""
|
||||
self._segment_recorder = recorder
|
||||
|
||||
def set_segment_overrides(self, overrides) -> None:
|
||||
"""Human-reviewed translations applied verbatim on rebuild."""
|
||||
self._segment_overrides = overrides or {}
|
||||
|
||||
def get_recorded_segments(self):
|
||||
recorder = getattr(self, "_segment_recorder", None)
|
||||
return recorder.get_pairs() if recorder is not None else []
|
||||
|
||||
def _get_font_path(self) -> Optional[str]:
|
||||
"""Resolve a Unicode-capable TTF/OTF font file."""
|
||||
if self._font_path is not None:
|
||||
@@ -1555,16 +1567,31 @@ class PDFTranslator:
|
||||
|
||||
Also feeds the job-level attempted/changed stats so the route can
|
||||
detect a total provider failure (changed == 0) on PDFs too.
|
||||
Reviewer overrides (approved/edited segments) short-circuit the
|
||||
provider entirely — verbatim reviewed text, zero drift.
|
||||
"""
|
||||
overrides = getattr(self, "_segment_overrides", None)
|
||||
if overrides:
|
||||
override = overrides.get((text or "").strip())
|
||||
if override and override.strip() and override.strip() != (text or "").strip():
|
||||
recorder = getattr(self, "_segment_recorder", None)
|
||||
if recorder is not None:
|
||||
recorder.record_pair(text, override)
|
||||
return override
|
||||
|
||||
if text and text.strip():
|
||||
self._translation_stats["attempted"] += 1
|
||||
if self._provider is not None:
|
||||
try:
|
||||
results = self._translate_with_provider([text], target_language, source_language)
|
||||
if results and results[0].strip():
|
||||
if results[0].strip() != text.strip():
|
||||
result = results[0]
|
||||
if result.strip() != text.strip():
|
||||
self._translation_stats["changed"] += 1
|
||||
return results[0]
|
||||
recorder = getattr(self, "_segment_recorder", None)
|
||||
if recorder is not None:
|
||||
recorder.record_pair(text, result)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning("provider_single_failed", error=str(e))
|
||||
|
||||
@@ -1573,6 +1600,9 @@ class PDFTranslator:
|
||||
result = translation_service.translate_text(text, target_language, source_language)
|
||||
if result and result.strip() and result.strip() != text.strip():
|
||||
self._translation_stats["changed"] += 1
|
||||
recorder = getattr(self, "_segment_recorder", None)
|
||||
if recorder is not None:
|
||||
recorder.record_pair(text, result)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning("legacy_single_failed", error=str(e))
|
||||
|
||||
@@ -233,6 +233,18 @@ class PowerPointTranslator:
|
||||
user_id, prompt or getattr(self, "_custom_prompt", None)
|
||||
)
|
||||
|
||||
def set_segment_recorder(self, recorder) -> None:
|
||||
"""Attach a segment recorder (review foundation)."""
|
||||
self._segment_recorder = recorder
|
||||
|
||||
def set_segment_overrides(self, overrides) -> None:
|
||||
"""Human-reviewed translations applied verbatim on rebuild."""
|
||||
self._segment_overrides = overrides or {}
|
||||
|
||||
def get_recorded_segments(self):
|
||||
recorder = getattr(self, "_segment_recorder", None)
|
||||
return recorder.get_pairs() if recorder is not None else []
|
||||
|
||||
|
||||
def translate_file(
|
||||
self,
|
||||
@@ -499,12 +511,41 @@ class PowerPointTranslator:
|
||||
miss_texts, target_language, source_language
|
||||
)
|
||||
|
||||
# Translation memory: reuse this user's previous translations
|
||||
# (identical context/prompt) before hitting the provider.
|
||||
translated = translate_with_tm(
|
||||
texts, target_language, source_language,
|
||||
provider_name, getattr(self, "_tm_scope", None), _do_translate,
|
||||
from translators.segments import apply_overrides
|
||||
|
||||
# Reviewer overrides (approved/edited segments) win over everything:
|
||||
# no TM lookup, no provider call, zero drift from the reviewed text.
|
||||
ov_hits, ov_misses = apply_overrides(
|
||||
texts, getattr(self, "_segment_overrides", None)
|
||||
)
|
||||
miss_texts = [texts[i] for i in ov_misses]
|
||||
|
||||
if miss_texts:
|
||||
# Translation memory: reuse this user's previous translations
|
||||
# (identical context/prompt) before hitting the provider.
|
||||
miss_translated = translate_with_tm(
|
||||
miss_texts, target_language, source_language,
|
||||
provider_name, getattr(self, "_tm_scope", None), _do_translate,
|
||||
)
|
||||
else:
|
||||
miss_translated = []
|
||||
|
||||
translated = []
|
||||
miss_pos = 0
|
||||
for i in range(len(texts)):
|
||||
if i in ov_hits:
|
||||
translated.append(ov_hits[i])
|
||||
else:
|
||||
translated.append(
|
||||
miss_translated[miss_pos]
|
||||
if miss_pos < len(miss_translated)
|
||||
else texts[i]
|
||||
)
|
||||
miss_pos += 1
|
||||
|
||||
recorder = getattr(self, "_segment_recorder", None)
|
||||
if recorder is not None:
|
||||
recorder.record_pairs(zip(texts, translated))
|
||||
|
||||
changed = sum(1 for orig, trans in zip(texts, translated) if orig != trans and trans.strip())
|
||||
self._translation_stats["changed"] += changed
|
||||
|
||||
74
translators/segments.py
Normal file
74
translators/segments.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
Segment recording & overrides — the review foundation.
|
||||
|
||||
During a translation job, every (source → translation) pair is recorded so
|
||||
it can be persisted per job (translation_segments table), reviewed and
|
||||
edited side-by-side, and re-applied when rebuilding the document.
|
||||
|
||||
Overrides are the inverse path: human-reviewed translations (approved or
|
||||
edited segments) applied verbatim on rebuild — no provider call, no drift.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class SegmentRecorder:
|
||||
"""Collects unique (source, translation) pairs in document order."""
|
||||
|
||||
def __init__(self):
|
||||
self._pairs: List[Tuple[str, str]] = []
|
||||
self._seen: set = set()
|
||||
|
||||
def record_pair(self, source: str, translation: str) -> None:
|
||||
if not source or not source.strip():
|
||||
return
|
||||
stripped = source.strip()
|
||||
# Identity pairs carry no review value ( untranslated or unchanged
|
||||
# text) — skip them so the review list stays actionable.
|
||||
if not translation or not translation.strip() or translation.strip() == stripped:
|
||||
return
|
||||
if stripped in self._seen:
|
||||
return
|
||||
self._seen.add(stripped)
|
||||
self._pairs.append((stripped, translation.strip()))
|
||||
|
||||
def record_pairs(self, pairs) -> None:
|
||||
for source, translation in pairs:
|
||||
self.record_pair(source, translation)
|
||||
|
||||
def get_pairs(self) -> List[Tuple[str, str]]:
|
||||
return list(self._pairs)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._pairs)
|
||||
|
||||
|
||||
def apply_overrides(
|
||||
texts: List[str],
|
||||
overrides: Optional[Dict[str, str]],
|
||||
) -> Tuple[Dict[int, str], List[int]]:
|
||||
"""Split a batch: ({index: override_translation}, [indices to translate]).
|
||||
|
||||
Overrides are keyed by the stripped source text (that is how segments
|
||||
are persisted). Identity overrides are treated as "no override" so the
|
||||
normal pipeline (TM → provider) stays in charge.
|
||||
"""
|
||||
if not overrides:
|
||||
return {}, list(range(len(texts)))
|
||||
|
||||
hits: Dict[int, str] = {}
|
||||
misses: List[int] = []
|
||||
for i, text in enumerate(texts):
|
||||
if not text or not text.strip():
|
||||
misses.append(i)
|
||||
continue
|
||||
override = overrides.get(text.strip())
|
||||
if override is not None and override.strip() and override.strip() != text.strip():
|
||||
hits[i] = override.strip()
|
||||
else:
|
||||
misses.append(i)
|
||||
return hits, misses
|
||||
@@ -260,6 +260,18 @@ class WordTranslator:
|
||||
|
||||
self._tm_scope = TMScope.from_prompt(user_id, prompt or self._custom_prompt)
|
||||
|
||||
def set_segment_recorder(self, recorder) -> None:
|
||||
"""Attach a segment recorder (review foundation)."""
|
||||
self._segment_recorder = recorder
|
||||
|
||||
def set_segment_overrides(self, overrides) -> None:
|
||||
"""Human-reviewed translations applied verbatim on rebuild."""
|
||||
self._segment_overrides = overrides or {}
|
||||
|
||||
def get_recorded_segments(self):
|
||||
recorder = getattr(self, "_segment_recorder", None)
|
||||
return recorder.get_pairs() if recorder is not None else []
|
||||
|
||||
def translate_file(
|
||||
self,
|
||||
input_path: Path,
|
||||
@@ -561,12 +573,41 @@ class WordTranslator:
|
||||
miss_texts, target_language, source_language
|
||||
)
|
||||
|
||||
# Translation memory: reuse this user's previous translations
|
||||
# (identical context/prompt) before hitting the provider.
|
||||
translated = translate_with_tm(
|
||||
texts, target_language, source_language,
|
||||
provider_name, self._tm_scope, _do_translate,
|
||||
from translators.segments import apply_overrides
|
||||
|
||||
# Reviewer overrides (approved/edited segments) win over everything:
|
||||
# no TM lookup, no provider call, zero drift from the reviewed text.
|
||||
ov_hits, ov_misses = apply_overrides(
|
||||
texts, getattr(self, "_segment_overrides", None)
|
||||
)
|
||||
miss_texts = [texts[i] for i in ov_misses]
|
||||
|
||||
if miss_texts:
|
||||
# Translation memory: reuse this user's previous translations
|
||||
# (identical context/prompt) before hitting the provider.
|
||||
miss_translated = translate_with_tm(
|
||||
miss_texts, target_language, source_language,
|
||||
provider_name, getattr(self, "_tm_scope", None), _do_translate,
|
||||
)
|
||||
else:
|
||||
miss_translated = []
|
||||
|
||||
translated = []
|
||||
miss_pos = 0
|
||||
for i in range(len(texts)):
|
||||
if i in ov_hits:
|
||||
translated.append(ov_hits[i])
|
||||
else:
|
||||
translated.append(
|
||||
miss_translated[miss_pos]
|
||||
if miss_pos < len(miss_translated)
|
||||
else texts[i]
|
||||
)
|
||||
miss_pos += 1
|
||||
|
||||
recorder = getattr(self, "_segment_recorder", None)
|
||||
if recorder is not None:
|
||||
recorder.record_pairs(zip(texts, translated))
|
||||
|
||||
changed = sum(1 for orig, trans in zip(texts, translated) if orig != trans and trans.strip())
|
||||
self._translation_stats["changed"] += changed
|
||||
|
||||
Reference in New Issue
Block a user