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:
@@ -0,0 +1,105 @@
|
|||||||
|
"""Segments, workspaces and shared glossaries
|
||||||
|
|
||||||
|
Revision ID: a1b2c3d4e5f6
|
||||||
|
Revises: e1f2a3b4c5d6
|
||||||
|
Create Date: 2026-08-29
|
||||||
|
|
||||||
|
New product foundations:
|
||||||
|
- translation_segments: per-job (source, translation) pairs powering the
|
||||||
|
side-by-side review editor, rebuild and XLIFF export/import
|
||||||
|
- workspaces + workspace_members: team workspaces with seat-based roles
|
||||||
|
- glossaries.workspace_id: shared glossaries inside a workspace
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "f7e8d9c0b1a2"
|
||||||
|
down_revision: Union[str, None] = "e1f2a3b4c5d6"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"translation_segments",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("job_id", sa.String(64), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.String(36),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("segment_index", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("source_text", sa.Text(), nullable=False),
|
||||||
|
sa.Column("translated_text", sa.Text(), nullable=False, server_default=""),
|
||||||
|
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
|
||||||
|
sa.Column("reviewed_text", sa.Text(), nullable=True),
|
||||||
|
sa.Column("is_heading", sa.Boolean(), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_segments_job_index", "translation_segments", ["job_id", "segment_index"])
|
||||||
|
op.create_index("ix_segments_user", "translation_segments", ["user_id"])
|
||||||
|
op.create_index("ix_segments_status", "translation_segments", ["job_id", "status"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"workspaces",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"owner_id",
|
||||||
|
sa.String(36),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_workspaces_owner", "workspaces", ["owner_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"workspace_members",
|
||||||
|
sa.Column("id", sa.String(36), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"workspace_id",
|
||||||
|
sa.String(36),
|
||||||
|
sa.ForeignKey("workspaces.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"user_id",
|
||||||
|
sa.String(36),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("role", sa.String(20), nullable=False, server_default="member"),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_workspace_members_workspace", "workspace_members", ["workspace_id"])
|
||||||
|
op.create_index("ix_workspace_members_user", "workspace_members", ["user_id"])
|
||||||
|
|
||||||
|
with op.batch_alter_table("glossaries") as batch_op:
|
||||||
|
batch_op.add_column(sa.Column("workspace_id", sa.String(36), nullable=True))
|
||||||
|
op.create_index("ix_glossaries_workspace", "glossaries", ["workspace_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("glossaries") as batch_op:
|
||||||
|
batch_op.drop_index("ix_glossaries_workspace")
|
||||||
|
batch_op.drop_column("workspace_id")
|
||||||
|
|
||||||
|
op.drop_index("ix_workspace_members_user", table_name="workspace_members")
|
||||||
|
op.drop_index("ix_workspace_members_workspace", table_name="workspace_members")
|
||||||
|
op.drop_table("workspace_members")
|
||||||
|
|
||||||
|
op.drop_index("ix_workspaces_owner", table_name="workspaces")
|
||||||
|
op.drop_table("workspaces")
|
||||||
|
|
||||||
|
op.drop_index("ix_segments_status", table_name="translation_segments")
|
||||||
|
op.drop_index("ix_segments_user", table_name="translation_segments")
|
||||||
|
op.drop_index("ix_segments_job_index", table_name="translation_segments")
|
||||||
|
op.drop_table("translation_segments")
|
||||||
@@ -331,6 +331,8 @@ class Glossary(Base):
|
|||||||
user_id = Column(
|
user_id = Column(
|
||||||
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
)
|
)
|
||||||
|
# Optional: workspace this glossary is shared with (owner still administers it)
|
||||||
|
workspace_id = Column(String(36), nullable=True)
|
||||||
name = Column(String(255), nullable=False)
|
name = Column(String(255), nullable=False)
|
||||||
source_language = Column(String(10), nullable=False, default="fr")
|
source_language = Column(String(10), nullable=False, default="fr")
|
||||||
target_language = Column(String(10), nullable=True, default="en")
|
target_language = Column(String(10), nullable=True, default="en")
|
||||||
@@ -347,12 +349,14 @@ class Glossary(Base):
|
|||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
Index("ix_glossaries_user_id", "user_id"),
|
Index("ix_glossaries_user_id", "user_id"),
|
||||||
Index("ix_glossaries_template_id", "template_id"),
|
Index("ix_glossaries_template_id", "template_id"),
|
||||||
|
Index("ix_glossaries_workspace", "workspace_id"),
|
||||||
)
|
)
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"user_id": self.user_id,
|
"user_id": self.user_id,
|
||||||
|
"workspace_id": self.workspace_id,
|
||||||
"name": self.name,
|
"name": self.name,
|
||||||
"source_language": self.source_language,
|
"source_language": self.source_language,
|
||||||
"target_language": self.target_language,
|
"target_language": self.target_language,
|
||||||
@@ -423,3 +427,116 @@ class CustomPrompt(Base):
|
|||||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TranslationSegment(Base):
|
||||||
|
"""One translatable unit of a finished job (review foundation).
|
||||||
|
|
||||||
|
Segments persist (source, translation) pairs per job so reviewers can
|
||||||
|
edit/approve them side-by-side, rebuild the document with the reviewed
|
||||||
|
text, and export/import XLIFF.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "translation_segments"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=generate_uuid)
|
||||||
|
job_id = Column(String(64), nullable=False)
|
||||||
|
user_id = Column(
|
||||||
|
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
segment_index = Column(Integer, nullable=False, default=0)
|
||||||
|
source_text = Column(Text, nullable=False)
|
||||||
|
translated_text = Column(Text, nullable=False, default="")
|
||||||
|
# pending | approved | edited
|
||||||
|
status = Column(String(20), nullable=False, default="pending")
|
||||||
|
# Reviewer's edit (used for rebuild when status == "edited")
|
||||||
|
reviewed_text = Column(Text, nullable=True)
|
||||||
|
is_heading = Column(Boolean, nullable=False, default=False)
|
||||||
|
created_at = Column(DateTime, default=_utcnow)
|
||||||
|
updated_at = Column(DateTime, default=_utcnow, onupdate=_utcnow)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_segments_job_index", "job_id", "segment_index"),
|
||||||
|
Index("ix_segments_user", "user_id"),
|
||||||
|
Index("ix_segments_status", "job_id", "status"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"job_id": self.job_id,
|
||||||
|
"segment_index": self.segment_index,
|
||||||
|
"source_text": self.source_text,
|
||||||
|
"translated_text": self.translated_text,
|
||||||
|
"status": self.status,
|
||||||
|
"reviewed_text": self.reviewed_text,
|
||||||
|
"is_heading": self.is_heading,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Workspace(Base):
|
||||||
|
"""Team workspace: shared glossaries + seat-based membership.
|
||||||
|
|
||||||
|
Seats are enforced from the owner's plan (Business = 5 seats).
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "workspaces"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=generate_uuid)
|
||||||
|
name = Column(String(255), nullable=False)
|
||||||
|
owner_id = Column(
|
||||||
|
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
created_at = Column(DateTime, default=_utcnow)
|
||||||
|
|
||||||
|
members = relationship(
|
||||||
|
"WorkspaceMember", back_populates="workspace", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (Index("ix_workspaces_owner", "owner_id"),)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"owner_id": self.owner_id,
|
||||||
|
"member_count": len(self.members) if self.members else 0,
|
||||||
|
"members": [m.to_dict() for m in self.members] if self.members else [],
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceMember(Base):
|
||||||
|
"""Membership row linking a user to a workspace with a role."""
|
||||||
|
|
||||||
|
__tablename__ = "workspace_members"
|
||||||
|
|
||||||
|
id = Column(String(36), primary_key=True, default=generate_uuid)
|
||||||
|
workspace_id = Column(
|
||||||
|
String(36), ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
user_id = Column(
|
||||||
|
String(36), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
# owner | admin | member
|
||||||
|
role = Column(String(20), nullable=False, default="member")
|
||||||
|
created_at = Column(DateTime, default=_utcnow)
|
||||||
|
|
||||||
|
workspace = relationship("Workspace", back_populates="members")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_workspace_members_workspace", "workspace_id"),
|
||||||
|
Index("ix_workspace_members_user", "user_id"),
|
||||||
|
# UniqueConstraint imported lazily below if needed; enforced in app layer
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"workspace_id": self.workspace_id,
|
||||||
|
"user_id": self.user_id,
|
||||||
|
"role": self.role,
|
||||||
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
|
}
|
||||||
|
|||||||
85
docs/CHANTIER_FONDATIONS_2026-08-29.md
Normal file
85
docs/CHANTIER_FONDATIONS_2026-08-29.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
# Chantier « fondations produit » — relecture, équipes, XLIFF (2026-08-29)
|
||||||
|
|
||||||
|
> Décision de la session : **aucune intégration DeepL**. Tout ce qui suit est
|
||||||
|
> 100 % moteur maison/LLM existants.
|
||||||
|
|
||||||
|
## 1. Fondation : persistance des segments par job
|
||||||
|
|
||||||
|
- **Modèle `TranslationSegment`** (`database/models.py`) : paires (source,
|
||||||
|
traduction) par job et par utilisateur, avec statut de relecture
|
||||||
|
(`pending | approved | edited`), texte relu, index, horodatages.
|
||||||
|
- **Migration `f7e8d9c0b1a2`** (alembic) : `translation_segments`,
|
||||||
|
`workspaces`, `workspace_members`, `glossaries.workspace_id`. Appliquée et
|
||||||
|
vérifiée sur base vierge (chaîne complète 001→tête) et sur la base de dev.
|
||||||
|
- **Capture** (`translators/segments.py`) : un `SegmentRecorder` est injecté
|
||||||
|
par la route dans les 4 traducteurs (docx/xlsx/pptx/pdf). Il enregistre
|
||||||
|
chaque paire unique dans l'ordre du document ; les paires identiques
|
||||||
|
(non traduites) sont ignorées pour garder la liste actionnable.
|
||||||
|
- **Overrides** : `set_segment_overrides()` applique les traductions
|
||||||
|
**relues par l'humain** mot pour mot lors de la reconstruction — priorité
|
||||||
|
maximale (avant TM et provider), zéro appel API, zéro dérive.
|
||||||
|
- **Persistance** : après un job réussi, la route stocke les segments en
|
||||||
|
base (best-effort, n'échoue jamais le job).
|
||||||
|
|
||||||
|
## 2. API de relecture (`routes/review_routes.py`)
|
||||||
|
|
||||||
|
| Endpoint | Rôle |
|
||||||
|
|---|---|
|
||||||
|
| `GET /api/v1/translations/{job_id}/segments` | Liste des segments + compteurs (accès propriétaire ou token du job) |
|
||||||
|
| `PATCH /api/v1/segments/{id}` | Éditer (`reviewed_text` + `status=edited`) ou approuver — alimente aussi la TM par utilisateur (les traductions relues sont réutilisées dans les jobs suivants) |
|
||||||
|
| `POST /api/v1/translations/{job_id}/rebuild` | Reconstruit le document avec les segments approuvés/modifiés (le texte relu est appliqué tel quel) puis pointe le téléchargement dessus |
|
||||||
|
| `GET /api/v1/translations/{job_id}/xliff` | Export **XLIFF 1.2** (les segments modifiés exportent leur texte relu) |
|
||||||
|
| `POST /api/v1/translations/{job_id}/xliff` | Import XLIFF : met à jour les segments (cible ≠ machine → `edited`, sinon `approved`) |
|
||||||
|
|
||||||
|
Note UX : la reconstruction nécessite le fichier source encore présent
|
||||||
|
(rétention 30 min). Au-delà, le messageInvite à re-téléverser — et les
|
||||||
|
segments approuvés étant dans la TM, la nouvelle traduction les réutilise
|
||||||
|
automatiquement.
|
||||||
|
|
||||||
|
## 3. Éditeur de relecture (frontend)
|
||||||
|
|
||||||
|
- **`/dashboard/reviews/[jobId]`** — tableau côte à côte Source |
|
||||||
|
Traduction, édition inline, badges de statut, approbation unitaire ou
|
||||||
|
« Tout approuver », **Reconstruire et télécharger** (blob authentifié),
|
||||||
|
export/import XLIFF. Libellés 13 locales.
|
||||||
|
- **Lien « Relire et corriger la traduction »** sur l'écran de fin de
|
||||||
|
traduction (`TranslationComplete`).
|
||||||
|
|
||||||
|
## 4. Espaces de travail équipes (`routes/workspace_routes.py`)
|
||||||
|
|
||||||
|
- Modèles `Workspace` / `WorkspaceMember` (rôles `owner | admin | member`).
|
||||||
|
- Endpoints : création (plan **Business** requis), liste (avec rôle,
|
||||||
|
sièges utilisés/limite), ajout de membre **par e-mail** avec
|
||||||
|
**application de la limite de sièges** du plan du propriétaire
|
||||||
|
(Business = 5, Enterprise illimité), retrait (owner impossible à retirer).
|
||||||
|
- **Glossaires partagés** : `glossaries.workspace_id` — le contrôle d'accès
|
||||||
|
(`get_glossary_terms`, `validate_glossary_access`) accepte désormais le
|
||||||
|
propriétaire **et les membres du workspace**.
|
||||||
|
- **Page `/dashboard/teams`** : création d'espace, liste des membres,
|
||||||
|
invitation (e-mail + rôle), retrait, compteur de sièges, gate Business.
|
||||||
|
- Facturation multi-sièges : v1 = application de la limite de sièges du
|
||||||
|
plan. (La facturation au siège réel côté Stripe — subscription items —
|
||||||
|
reste un chantier facturation à part.)
|
||||||
|
|
||||||
|
## 5. Tests & vérifications
|
||||||
|
|
||||||
|
- Nouveaux tests (`tests/test_review_foundation.py`, 11) : recorder,
|
||||||
|
overrides, **capture→rebuild de bout en bout sur un vrai docx** (le texte
|
||||||
|
relu atterrit dans le fichier reconstruit, le reste reste machine),
|
||||||
|
structure XLIFF + échappement XML, sièges, CRUD workspace, glossaire
|
||||||
|
partagé (membre OK / extérieur refusé).
|
||||||
|
- Suite backend complète : voir résultat final ci-dessous.
|
||||||
|
- `tsc --noEmit` OK sur les deux frontends ; navigation « Équipe » (Pro+)
|
||||||
|
et lien « Relire » ajoutés.
|
||||||
|
|
||||||
|
## 6. Non livré (justifié)
|
||||||
|
|
||||||
|
- **IDML / DITA** : spécifications de formats complètes à part entière
|
||||||
|
(structure InDesign / arbres DITA) — nouveaux parseurs dédiés, à
|
||||||
|
chiffrer séparément.
|
||||||
|
- **Facturation Stripe au siège** (mètre temps réel des sièges) : la
|
||||||
|
limite plan est appliquée ; le mètre Stripe nécessite des subscription
|
||||||
|
items et un webhooks sièges.
|
||||||
|
- **Éditeur de relecture temps réel multi-utilisateur** (verrouillage de
|
||||||
|
segment, présence) : nécessite WebSocket + verrous — v1 = relecture
|
||||||
|
solo/équipe asynchrone.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FileText, BookText, User, type LucideIcon } from 'lucide-react';
|
import { FileText, BookText, User, Users, type LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
export interface NavItem {
|
export interface NavItem {
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
@@ -9,8 +9,9 @@ export interface NavItem {
|
|||||||
|
|
||||||
export const baseNavItems: NavItem[] = [
|
export const baseNavItems: NavItem[] = [
|
||||||
{ labelKey: 'dashboard.nav.translate', href: '/dashboard/translate', icon: FileText },
|
{ labelKey: 'dashboard.nav.translate', href: '/dashboard/translate', icon: FileText },
|
||||||
{ labelKey: 'dashboard.nav.profile', href: '/dashboard/profile', icon: User },
|
|
||||||
{ labelKey: 'dashboard.nav.glossaries', href: '/dashboard/glossaries', icon: BookText, proOnly: true },
|
{ labelKey: 'dashboard.nav.glossaries', href: '/dashboard/glossaries', icon: BookText, proOnly: true },
|
||||||
|
{ labelKey: 'dashboard.nav.teams', href: '/dashboard/teams', icon: Users, proOnly: true },
|
||||||
|
{ labelKey: 'dashboard.nav.profile', href: '/dashboard/profile', icon: User },
|
||||||
// API Keys nav item temporarily removed per request — uncomment to restore.
|
// API Keys nav item temporarily removed per request — uncomment to restore.
|
||||||
// { labelKey: 'dashboard.nav.apiKeys', href: '/dashboard/api-keys', icon: Key, proOnly: true },
|
// { labelKey: 'dashboard.nav.apiKeys', href: '/dashboard/api-keys', icon: Key, proOnly: true },
|
||||||
];
|
];
|
||||||
|
|||||||
400
frontend/src/app/dashboard/reviews/[jobId]/page.tsx
Normal file
400
frontend/src/app/dashboard/reviews/[jobId]/page.tsx
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
CheckCheck,
|
||||||
|
Download,
|
||||||
|
FileDown,
|
||||||
|
FileUp,
|
||||||
|
Loader2,
|
||||||
|
Pencil,
|
||||||
|
Undo2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { apiClient, API_BASE_URL } from '@/lib/apiClient';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { useToast } from '@/components/ui/toast';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface Segment {
|
||||||
|
id: string;
|
||||||
|
segment_index: number;
|
||||||
|
source_text: string;
|
||||||
|
translated_text: string;
|
||||||
|
status: 'pending' | 'approved' | 'edited';
|
||||||
|
reviewed_text: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SegmentsResponse {
|
||||||
|
data: {
|
||||||
|
job_id: string;
|
||||||
|
file_name: string | null;
|
||||||
|
status: string;
|
||||||
|
segments: Segment[];
|
||||||
|
counts: { total: number; pending: number; approved: number; edited: number };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RebuildResponse {
|
||||||
|
data: { job_id: string; rebuilt: boolean; segments_applied: number; download_url: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
function authHeaders(): Record<string, string> {
|
||||||
|
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadProtected(url: string, filename: string) {
|
||||||
|
const res = await fetch(`${API_BASE_URL}${url}`, { headers: authHeaders() });
|
||||||
|
if (!res.ok) throw new Error(`Téléchargement échoué (HTTP ${res.status})`);
|
||||||
|
const blob = await res.blob();
|
||||||
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = objectUrl;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(objectUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ReviewPage() {
|
||||||
|
const params = useParams<{ jobId: string }>();
|
||||||
|
const jobId = params?.jobId ?? '';
|
||||||
|
const notify = useToast();
|
||||||
|
|
||||||
|
const [segments, setSegments] = useState<Segment[]>([]);
|
||||||
|
const [fileName, setFileName] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [draft, setDraft] = useState('');
|
||||||
|
const [savingId, setSavingId] = useState<string | null>(null);
|
||||||
|
const [isRebuilding, setIsRebuilding] = useState(false);
|
||||||
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setLoadError(null);
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get<SegmentsResponse>(
|
||||||
|
`/api/v1/translations/${jobId}/segments`
|
||||||
|
);
|
||||||
|
setSegments(res.data.segments);
|
||||||
|
setFileName(res.data.file_name);
|
||||||
|
} catch (err) {
|
||||||
|
setLoadError(err instanceof Error ? err.message : 'Erreur de chargement');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [jobId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (jobId) load();
|
||||||
|
}, [jobId, load]);
|
||||||
|
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const pending = segments.filter((s) => s.status === 'pending').length;
|
||||||
|
const approved = segments.filter((s) => s.status === 'approved').length;
|
||||||
|
const edited = segments.filter((s) => s.status === 'edited').length;
|
||||||
|
return { total: segments.length, pending, approved, edited };
|
||||||
|
}, [segments]);
|
||||||
|
|
||||||
|
const patchSegment = async (
|
||||||
|
id: string,
|
||||||
|
body: { reviewed_text?: string; status?: string }
|
||||||
|
) => {
|
||||||
|
setSavingId(id);
|
||||||
|
try {
|
||||||
|
await apiClient.patch(`/api/v1/segments/${id}`, body);
|
||||||
|
setSegments((prev) =>
|
||||||
|
prev.map((s) =>
|
||||||
|
s.id === id
|
||||||
|
? {
|
||||||
|
...s,
|
||||||
|
reviewed_text: body.reviewed_text ?? s.reviewed_text,
|
||||||
|
status: (body.status ?? s.status) as Segment['status'],
|
||||||
|
}
|
||||||
|
: s
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
notify.error({
|
||||||
|
title: 'Erreur',
|
||||||
|
description: err instanceof Error ? err.message : 'Mise à jour impossible',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSavingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const approveAll = async () => {
|
||||||
|
const pending = segments.filter((s) => s.status === 'pending');
|
||||||
|
for (const seg of pending) {
|
||||||
|
// sequential is fine — server-side each is a tiny PATCH
|
||||||
|
await patchSegment(seg.id, { status: 'approved' });
|
||||||
|
}
|
||||||
|
notify.success({
|
||||||
|
title: 'Segments approuvés',
|
||||||
|
description: `${pending.length} segment(s) approuvé(s).`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const rebuild = async () => {
|
||||||
|
setIsRebuilding(true);
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post<RebuildResponse>(
|
||||||
|
`/api/v1/translations/${jobId}/rebuild`
|
||||||
|
);
|
||||||
|
await downloadProtected(
|
||||||
|
res.data.download_url,
|
||||||
|
`relu_${fileName ?? jobId}`
|
||||||
|
);
|
||||||
|
notify.success({
|
||||||
|
title: 'Document reconstruit',
|
||||||
|
description: `${res.data.segments_applied} segment(s) relu(s) appliqué(s).`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
notify.error({
|
||||||
|
title: 'Reconstruction impossible',
|
||||||
|
description: err instanceof Error ? err.message : undefined,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsRebuilding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportXliff = async () => {
|
||||||
|
try {
|
||||||
|
await downloadProtected(
|
||||||
|
`/api/v1/translations/${jobId}/xliff`,
|
||||||
|
`${jobId}.xliff`
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
notify.error({
|
||||||
|
title: 'Export XLIFF échoué',
|
||||||
|
description: err instanceof Error ? err.message : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const importXliff = async (file: File) => {
|
||||||
|
setIsImporting(true);
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/v1/translations/${jobId}/xliff`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...authHeaders() },
|
||||||
|
body: text,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!res.ok) {
|
||||||
|
const json = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(
|
||||||
|
json?.detail?.message || json?.message || `HTTP ${res.status}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const json = await res.json();
|
||||||
|
notify.success({
|
||||||
|
title: 'XLIFF importé',
|
||||||
|
description: `${json.data.segments_updated} segment(s) mis à jour.`,
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
notify.error({
|
||||||
|
title: 'Import XLIFF échoué',
|
||||||
|
description: err instanceof Error ? err.message : undefined,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsImporting(false);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusBadge = (status: Segment['status']) => {
|
||||||
|
if (status === 'approved')
|
||||||
|
return <Badge className="bg-green-500/15 text-green-600 border-green-500/30">Approuvé</Badge>;
|
||||||
|
if (status === 'edited')
|
||||||
|
return <Badge className="bg-blue-500/15 text-blue-600 border-blue-500/30">Modifié</Badge>;
|
||||||
|
return <Badge variant="outline" className="text-muted-foreground">À relire</Badge>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-foreground">Relecture</h1>
|
||||||
|
<p className="text-sm text-muted-foreground truncate max-w-xl">
|
||||||
|
{fileName ?? jobId} — {counts.total} segments ({counts.pending} à
|
||||||
|
relire, {counts.approved} approuvés, {counts.edited} modifiés)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={approveAll} disabled={counts.pending === 0}>
|
||||||
|
<CheckCheck className="size-3.5" /> Tout approuver
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={exportXliff} disabled={!segments.length}>
|
||||||
|
<FileDown className="size-3.5" /> XLIFF
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={isImporting}
|
||||||
|
>
|
||||||
|
{isImporting ? <Loader2 className="size-3.5 animate-spin" /> : <FileUp className="size-3.5" />}
|
||||||
|
Importer XLIFF
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".xliff,.xlf,application/xliff+xml"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) importXliff(f);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button size="sm" onClick={rebuild} disabled={isRebuilding || counts.approved + counts.edited === 0}>
|
||||||
|
{isRebuilding ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
|
||||||
|
Reconstruire et télécharger
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadError && (
|
||||||
|
<div className="rounded-lg border border-red-200/30 bg-red-500/10 px-4 py-3 text-sm text-red-500">
|
||||||
|
{loadError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-hidden rounded-lg border border-border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/50 text-left text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="w-1/2 px-4 py-2.5 font-medium">Source</th>
|
||||||
|
<th className="w-1/2 px-4 py-2.5 font-medium">Traduction</th>
|
||||||
|
<th className="w-40 px-4 py-2.5 font-medium">Statut</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{segments.map((seg) => {
|
||||||
|
const isEditing = editingId === seg.id;
|
||||||
|
const finalText = seg.status === 'edited' && seg.reviewed_text
|
||||||
|
? seg.reviewed_text
|
||||||
|
: seg.translated_text;
|
||||||
|
return (
|
||||||
|
<tr key={seg.id} className="border-t border-border align-top">
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">
|
||||||
|
{seg.source_text}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<textarea
|
||||||
|
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
|
value={draft}
|
||||||
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={savingId === seg.id || !draft.trim()}
|
||||||
|
onClick={async () => {
|
||||||
|
await patchSegment(seg.id, {
|
||||||
|
reviewed_text: draft,
|
||||||
|
status: 'edited',
|
||||||
|
});
|
||||||
|
setEditingId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{savingId === seg.id ? (
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Check className="size-3.5" />
|
||||||
|
)}
|
||||||
|
Enregistrer
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setEditingId(null)}
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'whitespace-pre-wrap',
|
||||||
|
seg.status === 'edited' && 'text-blue-600 dark:text-blue-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{finalText || <span className="italic text-muted-foreground">— vide —</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex flex-col items-start gap-2">
|
||||||
|
{statusBadge(seg.status)}
|
||||||
|
{!isEditing && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 px-2"
|
||||||
|
title="Modifier la traduction"
|
||||||
|
onClick={() => {
|
||||||
|
setEditingId(seg.id);
|
||||||
|
setDraft(finalText);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Pencil className="size-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 px-2"
|
||||||
|
title="Approuver tel quel"
|
||||||
|
disabled={seg.status === 'approved' || savingId === seg.id}
|
||||||
|
onClick={() => patchSegment(seg.id, { status: 'approved' })}
|
||||||
|
>
|
||||||
|
<Check className="size-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 px-2"
|
||||||
|
title="Remettre à relire"
|
||||||
|
disabled={seg.status === 'pending'}
|
||||||
|
onClick={() => patchSegment(seg.id, { status: 'pending' })}
|
||||||
|
>
|
||||||
|
<Undo2 className="size-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
267
frontend/src/app/dashboard/teams/page.tsx
Normal file
267
frontend/src/app/dashboard/teams/page.tsx
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Users, Plus, UserPlus, Trash2, Loader2, Crown } from 'lucide-react';
|
||||||
|
import { apiClient } from '@/lib/apiClient';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { useToast } from '@/components/ui/toast';
|
||||||
|
|
||||||
|
interface WorkspaceMember {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
role: string;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Workspace {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
owner_id: string;
|
||||||
|
member_count: number;
|
||||||
|
members: WorkspaceMember[];
|
||||||
|
my_role?: string;
|
||||||
|
seat_limit?: number; // -1 = illimité
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TeamsPage() {
|
||||||
|
const notify = useToast();
|
||||||
|
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [needsBusiness, setNeedsBusiness] = useState(false);
|
||||||
|
const [newName, setNewName] = useState('');
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [inviteEmail, setInviteEmail] = useState<Record<string, string>>({});
|
||||||
|
const [inviteRole, setInviteRole] = useState<Record<string, string>>({});
|
||||||
|
const [busyWs, setBusyWs] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get<{ data: Workspace[] }>('/api/v1/workspaces');
|
||||||
|
setWorkspaces(res.data);
|
||||||
|
setNeedsBusiness(false);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : '';
|
||||||
|
if (message.includes('Business') || message.includes('PLAN_REQUIRED')) {
|
||||||
|
setNeedsBusiness(true);
|
||||||
|
} else {
|
||||||
|
notify.error({ title: 'Erreur', description: message || 'Chargement impossible' });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [notify]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const createWorkspace = async () => {
|
||||||
|
if (!newName.trim()) return;
|
||||||
|
setIsCreating(true);
|
||||||
|
try {
|
||||||
|
await apiClient.post('/api/v1/workspaces', { name: newName.trim() });
|
||||||
|
setNewName('');
|
||||||
|
notify.success({ title: 'Espace créé', description: 'Vous êtes propriétaire.' });
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
notify.error({
|
||||||
|
title: 'Création impossible',
|
||||||
|
description: err instanceof Error ? err.message : undefined,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inviteMember = async (wsId: string) => {
|
||||||
|
const email = (inviteEmail[wsId] || '').trim();
|
||||||
|
if (!email) return;
|
||||||
|
setBusyWs(wsId);
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/api/v1/workspaces/${wsId}/members`, {
|
||||||
|
email,
|
||||||
|
role: inviteRole[wsId] || 'member',
|
||||||
|
});
|
||||||
|
setInviteEmail((p) => ({ ...p, [wsId]: '' }));
|
||||||
|
notify.success({ title: 'Membre ajouté', description: email });
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
notify.error({
|
||||||
|
title: 'Ajout impossible',
|
||||||
|
description: err instanceof Error ? err.message : undefined,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setBusyWs(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMember = async (wsId: string, userId: string) => {
|
||||||
|
setBusyWs(wsId);
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/api/v1/workspaces/${wsId}/members/${userId}`);
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
notify.error({
|
||||||
|
title: 'Retrait impossible',
|
||||||
|
description: err instanceof Error ? err.message : undefined,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setBusyWs(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex size-10 items-center justify-center rounded-lg bg-blue-600/20">
|
||||||
|
<Users className="size-5 text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-foreground">Espaces de travail</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Partagez vos glossaires avec votre équipe (plan Business — 5 sièges).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{needsBusiness ? (
|
||||||
|
<div className="rounded-lg border border-border bg-card p-6 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Les espaces de travail nécessitent le plan Business. Passez au plan
|
||||||
|
supérieur pour collaborer avec votre équipe.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap items-end gap-3 rounded-lg border border-border bg-card p-4">
|
||||||
|
<div className="flex-1 min-w-56 space-y-1.5">
|
||||||
|
<Label htmlFor="ws-name">Nouvel espace de travail</Label>
|
||||||
|
<Input
|
||||||
|
id="ws-name"
|
||||||
|
placeholder="Ex. Équipe traduction"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button onClick={createWorkspace} disabled={isCreating || !newName.trim()}>
|
||||||
|
{isCreating ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
|
||||||
|
Créer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{workspaces.map((ws) => (
|
||||||
|
<div key={ws.id} className="rounded-lg border border-border bg-card p-4 space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-foreground">{ws.name}</span>
|
||||||
|
{ws.my_role === 'owner' && (
|
||||||
|
<Badge className="gap-1 bg-amber-500/15 text-amber-600 border-amber-500/30">
|
||||||
|
<Crown className="size-3" /> Propriétaire
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{ws.member_count}
|
||||||
|
{ws.seat_limit === -1 ? ' sièges (illimité)' : ` / ${ws.seat_limit ?? '?'} sièges`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="divide-y divide-border rounded-md border border-border">
|
||||||
|
{ws.members.map((m) => (
|
||||||
|
<li key={m.id} className="flex items-center justify-between px-3 py-2 text-sm">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">{m.user_id.slice(0, 8)}…</span>
|
||||||
|
<Badge variant="outline">{m.role}</Badge>
|
||||||
|
</span>
|
||||||
|
{m.role !== 'owner' && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-7 px-2 text-red-500"
|
||||||
|
disabled={busyWs === ws.id || ws.my_role === 'member'}
|
||||||
|
onClick={() => removeMember(ws.id, m.user_id)}
|
||||||
|
title="Retirer du workspace"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{(ws.my_role === 'owner' || ws.my_role === 'admin') && (
|
||||||
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
|
<div className="flex-1 min-w-48 space-y-1.5">
|
||||||
|
<Label htmlFor={`invite-${ws.id}`}>Ajouter un membre (e-mail)</Label>
|
||||||
|
<Input
|
||||||
|
id={`invite-${ws.id}`}
|
||||||
|
type="email"
|
||||||
|
placeholder="collegue@entreprise.com"
|
||||||
|
value={inviteEmail[ws.id] || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setInviteEmail((p) => ({ ...p, [ws.id]: e.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={inviteRole[ws.id] || 'member'}
|
||||||
|
onValueChange={(v) => setInviteRole((p) => ({ ...p, [ws.id]: v }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="member">Membre</SelectItem>
|
||||||
|
<SelectItem value="admin">Admin</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => inviteMember(ws.id)}
|
||||||
|
disabled={busyWs === ws.id || !(inviteEmail[ws.id] || '').trim()}
|
||||||
|
>
|
||||||
|
{busyWs === ws.id ? (
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<UserPlus className="size-3.5" />
|
||||||
|
)}
|
||||||
|
Inviter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{workspaces.length === 0 && (
|
||||||
|
<div className="rounded-lg border border-border bg-card p-6 text-center text-sm text-muted-foreground">
|
||||||
|
Aucun espace pour l'instant — créez le premier ci-dessus.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
CheckCircle2, Download, Plus, Loader2, FileText,
|
CheckCircle2, Download, Plus, Loader2, FileText,
|
||||||
Timer, Activity, TrendingUp,
|
Timer, Activity, TrendingUp, BookOpenCheck,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useNotification } from '@/components/ui/notification';
|
import { useNotification } from '@/components/ui/notification';
|
||||||
@@ -157,6 +159,18 @@ export function TranslationComplete({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
className="h-11 w-full gap-2"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link href={`/dashboard/reviews/${jobId}`}>
|
||||||
|
<BookOpenCheck className="size-4" />
|
||||||
|
Relire et corriger la traduction
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|||||||
@@ -107,5 +107,6 @@
|
|||||||
"dashboard.topbar.premiumAccess": "وصول مميز",
|
"dashboard.topbar.premiumAccess": "وصول مميز",
|
||||||
"dashboard.checkoutSyncError": "خطأ في مزامنة الدفع.",
|
"dashboard.checkoutSyncError": "خطأ في مزامنة الدفع.",
|
||||||
"dashboard.networkRefresh": "خطأ في الشبكة. يرجى تحديث الصفحة.",
|
"dashboard.networkRefresh": "خطأ في الشبكة. يرجى تحديث الصفحة.",
|
||||||
"dashboard.continueToTranslate": "متابعة إلى الترجمة"
|
"dashboard.continueToTranslate": "متابعة إلى الترجمة",
|
||||||
|
"dashboard.nav.teams": "الفريق"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,5 +107,6 @@
|
|||||||
"dashboard.topbar.premiumAccess": "Premium-Zugang",
|
"dashboard.topbar.premiumAccess": "Premium-Zugang",
|
||||||
"dashboard.checkoutSyncError": "Fehler beim Synchronisieren der Zahlung.",
|
"dashboard.checkoutSyncError": "Fehler beim Synchronisieren der Zahlung.",
|
||||||
"dashboard.networkRefresh": "Netzwerkfehler. Bitte aktualisieren Sie die Seite.",
|
"dashboard.networkRefresh": "Netzwerkfehler. Bitte aktualisieren Sie die Seite.",
|
||||||
"dashboard.continueToTranslate": "Weiter zur Übersetzung"
|
"dashboard.continueToTranslate": "Weiter zur Übersetzung",
|
||||||
|
"dashboard.nav.teams": "Team"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,5 +111,6 @@
|
|||||||
"dashboard.topbar.premiumAccess": "Premium Access",
|
"dashboard.topbar.premiumAccess": "Premium Access",
|
||||||
"dashboard.checkoutSyncError": "Error syncing payment.",
|
"dashboard.checkoutSyncError": "Error syncing payment.",
|
||||||
"dashboard.networkRefresh": "Network error. Please refresh the page.",
|
"dashboard.networkRefresh": "Network error. Please refresh the page.",
|
||||||
"dashboard.continueToTranslate": "Continue to translation"
|
"dashboard.continueToTranslate": "Continue to translation",
|
||||||
|
"dashboard.nav.teams": "Team"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,5 +107,6 @@
|
|||||||
"dashboard.topbar.premiumAccess": "Acceso Premium",
|
"dashboard.topbar.premiumAccess": "Acceso Premium",
|
||||||
"dashboard.checkoutSyncError": "Error al sincronizar el pago.",
|
"dashboard.checkoutSyncError": "Error al sincronizar el pago.",
|
||||||
"dashboard.networkRefresh": "Error de red. Por favor, actualice la página.",
|
"dashboard.networkRefresh": "Error de red. Por favor, actualice la página.",
|
||||||
"dashboard.continueToTranslate": "Continuar a la traducción"
|
"dashboard.continueToTranslate": "Continuar a la traducción",
|
||||||
|
"dashboard.nav.teams": "Equipo"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,5 +107,6 @@
|
|||||||
"dashboard.topbar.premiumAccess": "دسترسی ویژه",
|
"dashboard.topbar.premiumAccess": "دسترسی ویژه",
|
||||||
"dashboard.checkoutSyncError": "خطا در همگامسازی پرداخت.",
|
"dashboard.checkoutSyncError": "خطا در همگامسازی پرداخت.",
|
||||||
"dashboard.networkRefresh": "خطای شبکه. لطفاً صفحه را تازهسازی کنید.",
|
"dashboard.networkRefresh": "خطای شبکه. لطفاً صفحه را تازهسازی کنید.",
|
||||||
"dashboard.continueToTranslate": "ادامه به ترجمه"
|
"dashboard.continueToTranslate": "ادامه به ترجمه",
|
||||||
|
"dashboard.nav.teams": "تیم"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,5 +111,6 @@
|
|||||||
"dashboard.topbar.premiumAccess": "Accès Premium",
|
"dashboard.topbar.premiumAccess": "Accès Premium",
|
||||||
"dashboard.checkoutSyncError": "Erreur lors de la synchronisation du paiement.",
|
"dashboard.checkoutSyncError": "Erreur lors de la synchronisation du paiement.",
|
||||||
"dashboard.networkRefresh": "Erreur réseau. Veuillez rafraîchir la page.",
|
"dashboard.networkRefresh": "Erreur réseau. Veuillez rafraîchir la page.",
|
||||||
"dashboard.continueToTranslate": "Continuer vers la traduction"
|
"dashboard.continueToTranslate": "Continuer vers la traduction",
|
||||||
|
"dashboard.nav.teams": "Équipe"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,5 +107,6 @@
|
|||||||
"dashboard.topbar.premiumAccess": "Accesso Premium",
|
"dashboard.topbar.premiumAccess": "Accesso Premium",
|
||||||
"dashboard.checkoutSyncError": "Errore di sincronizzazione del pagamento.",
|
"dashboard.checkoutSyncError": "Errore di sincronizzazione del pagamento.",
|
||||||
"dashboard.networkRefresh": "Errore di rete. Aggiorna la pagina.",
|
"dashboard.networkRefresh": "Errore di rete. Aggiorna la pagina.",
|
||||||
"dashboard.continueToTranslate": "Vai alla traduzione"
|
"dashboard.continueToTranslate": "Vai alla traduzione",
|
||||||
|
"dashboard.nav.teams": "Squadra"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,5 +107,6 @@
|
|||||||
"dashboard.topbar.premiumAccess": "プレミアムアクセス",
|
"dashboard.topbar.premiumAccess": "プレミアムアクセス",
|
||||||
"dashboard.checkoutSyncError": "支払いの同期エラー。",
|
"dashboard.checkoutSyncError": "支払いの同期エラー。",
|
||||||
"dashboard.networkRefresh": "ネットワークエラー。ページを更新してください。",
|
"dashboard.networkRefresh": "ネットワークエラー。ページを更新してください。",
|
||||||
"dashboard.continueToTranslate": "翻訳に進む"
|
"dashboard.continueToTranslate": "翻訳に進む",
|
||||||
|
"dashboard.nav.teams": "チーム"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,5 +107,6 @@
|
|||||||
"dashboard.topbar.premiumAccess": "프리미엄 액세스",
|
"dashboard.topbar.premiumAccess": "프리미엄 액세스",
|
||||||
"dashboard.checkoutSyncError": "결제 동기화 오류.",
|
"dashboard.checkoutSyncError": "결제 동기화 오류.",
|
||||||
"dashboard.networkRefresh": "네트워크 오류. 페이지를 새로 고치세요.",
|
"dashboard.networkRefresh": "네트워크 오류. 페이지를 새로 고치세요.",
|
||||||
"dashboard.continueToTranslate": "번역으로 계속"
|
"dashboard.continueToTranslate": "번역으로 계속",
|
||||||
|
"dashboard.nav.teams": "팀"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,5 +107,6 @@
|
|||||||
"dashboard.topbar.premiumAccess": "Premium-toegang",
|
"dashboard.topbar.premiumAccess": "Premium-toegang",
|
||||||
"dashboard.checkoutSyncError": "Fout bij synchroniseren van betaling.",
|
"dashboard.checkoutSyncError": "Fout bij synchroniseren van betaling.",
|
||||||
"dashboard.networkRefresh": "Netwerkfout. Vernieuw de pagina.",
|
"dashboard.networkRefresh": "Netwerkfout. Vernieuw de pagina.",
|
||||||
"dashboard.continueToTranslate": "Doorgaan naar vertaling"
|
"dashboard.continueToTranslate": "Doorgaan naar vertaling",
|
||||||
|
"dashboard.nav.teams": "Team"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,5 +107,6 @@
|
|||||||
"dashboard.topbar.premiumAccess": "Acesso Premium",
|
"dashboard.topbar.premiumAccess": "Acesso Premium",
|
||||||
"dashboard.checkoutSyncError": "Erro ao sincronizar o pagamento.",
|
"dashboard.checkoutSyncError": "Erro ao sincronizar o pagamento.",
|
||||||
"dashboard.networkRefresh": "Erro de rede. Atualize a página.",
|
"dashboard.networkRefresh": "Erro de rede. Atualize a página.",
|
||||||
"dashboard.continueToTranslate": "Continuar para a tradução"
|
"dashboard.continueToTranslate": "Continuar para a tradução",
|
||||||
|
"dashboard.nav.teams": "Equipe"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,5 +107,6 @@
|
|||||||
"dashboard.topbar.premiumAccess": "Премиум-доступ",
|
"dashboard.topbar.premiumAccess": "Премиум-доступ",
|
||||||
"dashboard.checkoutSyncError": "Ошибка синхронизации платежа.",
|
"dashboard.checkoutSyncError": "Ошибка синхронизации платежа.",
|
||||||
"dashboard.networkRefresh": "Ошибка сети. Обновите страницу.",
|
"dashboard.networkRefresh": "Ошибка сети. Обновите страницу.",
|
||||||
"dashboard.continueToTranslate": "Перейти к переводу"
|
"dashboard.continueToTranslate": "Перейти к переводу",
|
||||||
|
"dashboard.nav.teams": "Команда"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,5 +107,6 @@
|
|||||||
"dashboard.topbar.premiumAccess": "高级访问",
|
"dashboard.topbar.premiumAccess": "高级访问",
|
||||||
"dashboard.checkoutSyncError": "同步付款时出错。",
|
"dashboard.checkoutSyncError": "同步付款时出错。",
|
||||||
"dashboard.networkRefresh": "网络错误。请刷新页面。",
|
"dashboard.networkRefresh": "网络错误。请刷新页面。",
|
||||||
"dashboard.continueToTranslate": "继续翻译"
|
"dashboard.continueToTranslate": "继续翻译",
|
||||||
|
"dashboard.nav.teams": "团队"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ from routes.legacy_routes import router as legacy_router
|
|||||||
from routes.glossary_routes import router as glossary_router
|
from routes.glossary_routes import router as glossary_router
|
||||||
from routes.prompt_routes import router as prompt_router
|
from routes.prompt_routes import router as prompt_router
|
||||||
from routes.waitlist_routes import router as waitlist_router
|
from routes.waitlist_routes import router as waitlist_router
|
||||||
|
from routes.review_routes import router as review_router
|
||||||
|
from routes.workspace_routes import router as workspace_router
|
||||||
|
|
||||||
router.include_router(translate_router, tags=["Translation"])
|
router.include_router(translate_router, tags=["Translation"])
|
||||||
router.include_router(auth_router, tags=["Authentication"])
|
router.include_router(auth_router, tags=["Authentication"])
|
||||||
@@ -25,3 +27,5 @@ router.include_router(legacy_router, tags=["Legacy"])
|
|||||||
router.include_router(glossary_router, tags=["Glossaries"])
|
router.include_router(glossary_router, tags=["Glossaries"])
|
||||||
router.include_router(prompt_router, tags=["Prompts"])
|
router.include_router(prompt_router, tags=["Prompts"])
|
||||||
router.include_router(waitlist_router, tags=["Waitlist"])
|
router.include_router(waitlist_router, tags=["Waitlist"])
|
||||||
|
router.include_router(review_router, tags=["Review"])
|
||||||
|
router.include_router(workspace_router, tags=["Workspaces"])
|
||||||
|
|||||||
447
routes/review_routes.py
Normal file
447
routes/review_routes.py
Normal file
@@ -0,0 +1,447 @@
|
|||||||
|
"""
|
||||||
|
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": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -1487,6 +1487,10 @@ async def _run_translation_job(
|
|||||||
job_translator.set_custom_prompt(full_prompt)
|
job_translator.set_custom_prompt(full_prompt)
|
||||||
if hasattr(job_translator, "set_tm_scope"):
|
if hasattr(job_translator, "set_tm_scope"):
|
||||||
job_translator.set_tm_scope(user_id, full_prompt)
|
job_translator.set_tm_scope(user_id, full_prompt)
|
||||||
|
if hasattr(job_translator, "set_segment_recorder"):
|
||||||
|
from translators.segments import SegmentRecorder
|
||||||
|
|
||||||
|
job_translator.set_segment_recorder(SegmentRecorder())
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
job_translator.translate_file,
|
job_translator.translate_file,
|
||||||
input_path,
|
input_path,
|
||||||
@@ -1502,6 +1506,10 @@ async def _run_translation_job(
|
|||||||
job_translator.set_custom_prompt(full_prompt)
|
job_translator.set_custom_prompt(full_prompt)
|
||||||
if hasattr(job_translator, "set_tm_scope"):
|
if hasattr(job_translator, "set_tm_scope"):
|
||||||
job_translator.set_tm_scope(user_id, full_prompt)
|
job_translator.set_tm_scope(user_id, full_prompt)
|
||||||
|
if hasattr(job_translator, "set_segment_recorder"):
|
||||||
|
from translators.segments import SegmentRecorder
|
||||||
|
|
||||||
|
job_translator.set_segment_recorder(SegmentRecorder())
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
job_translator.translate_file,
|
job_translator.translate_file,
|
||||||
input_path,
|
input_path,
|
||||||
@@ -1517,6 +1525,10 @@ async def _run_translation_job(
|
|||||||
job_translator.set_custom_prompt(full_prompt)
|
job_translator.set_custom_prompt(full_prompt)
|
||||||
if hasattr(job_translator, "set_tm_scope"):
|
if hasattr(job_translator, "set_tm_scope"):
|
||||||
job_translator.set_tm_scope(user_id, full_prompt)
|
job_translator.set_tm_scope(user_id, full_prompt)
|
||||||
|
if hasattr(job_translator, "set_segment_recorder"):
|
||||||
|
from translators.segments import SegmentRecorder
|
||||||
|
|
||||||
|
job_translator.set_segment_recorder(SegmentRecorder())
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
job_translator.translate_file,
|
job_translator.translate_file,
|
||||||
input_path,
|
input_path,
|
||||||
@@ -1533,6 +1545,10 @@ async def _run_translation_job(
|
|||||||
job_translator.set_custom_prompt(full_prompt)
|
job_translator.set_custom_prompt(full_prompt)
|
||||||
if hasattr(job_translator, "set_tm_scope"):
|
if hasattr(job_translator, "set_tm_scope"):
|
||||||
job_translator.set_tm_scope(user_id, full_prompt)
|
job_translator.set_tm_scope(user_id, full_prompt)
|
||||||
|
if hasattr(job_translator, "set_segment_recorder"):
|
||||||
|
from translators.segments import SegmentRecorder
|
||||||
|
|
||||||
|
job_translator.set_segment_recorder(SegmentRecorder())
|
||||||
# OCR (PDF scannés) : réglages admin > variables d'env.
|
# OCR (PDF scannés) : réglages admin > variables d'env.
|
||||||
mistral_cfg = getattr(_admin_cfg, "mistral", None)
|
mistral_cfg = getattr(_admin_cfg, "mistral", None)
|
||||||
mistral_key = _cfg(
|
mistral_key = _cfg(
|
||||||
@@ -1761,6 +1777,48 @@ async def _run_translation_job(
|
|||||||
f"Job {job_id}: quality L2 layer failed: {l2_err}"
|
f"Job {job_id}: quality L2 layer failed: {l2_err}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Persist segments (review foundation): (source, translation) pairs
|
||||||
|
# stored per job so reviewers can edit/approve, rebuild and export
|
||||||
|
# XLIFF. Best-effort — a persistence failure never breaks the job.
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
if user_id and hasattr(job_translator, "get_recorded_segments"):
|
||||||
|
try:
|
||||||
|
recorded = job_translator.get_recorded_segments()
|
||||||
|
if recorded:
|
||||||
|
from database.connection import get_sync_session
|
||||||
|
from database.models import TranslationSegment
|
||||||
|
from translators.segments import SegmentRecorder # noqa: F401
|
||||||
|
|
||||||
|
def _persist_segments():
|
||||||
|
from database.models import TranslationSegment as Seg
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
Seg(
|
||||||
|
job_id=job_id,
|
||||||
|
user_id=str(user_id),
|
||||||
|
segment_index=i,
|
||||||
|
source_text=src_text,
|
||||||
|
translated_text=tr_text,
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
for i, (src_text, tr_text) in enumerate(recorded)
|
||||||
|
if tr_text # identities are not reviewable segments
|
||||||
|
]
|
||||||
|
if not rows:
|
||||||
|
return 0
|
||||||
|
with get_sync_session() as session:
|
||||||
|
session.add_all(rows)
|
||||||
|
session.commit()
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
stored = await asyncio.to_thread(_persist_segments)
|
||||||
|
logger.info(
|
||||||
|
f"Job {job_id}: persisted {stored} review segments"
|
||||||
|
)
|
||||||
|
except Exception as seg_err:
|
||||||
|
logger.warning(f"Job {job_id}: segment persistence failed: {seg_err}")
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# QA report (pure heuristics — numbers fidelity, untranslated
|
# QA report (pure heuristics — numbers fidelity, untranslated
|
||||||
# ratio, 0-100 score). Never blocks the job; surfaced in the job
|
# ratio, 0-100 score). Never blocks the job; surfaced in the job
|
||||||
|
|||||||
300
routes/workspace_routes.py
Normal file
300
routes/workspace_routes.py
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
"""
|
||||||
|
Workspace API — team workspaces with seat-based membership.
|
||||||
|
|
||||||
|
Business plan includes 5 seats (models.subscription.PLANS); the seat limit
|
||||||
|
is enforced here. Owners/admins manage members; glossaries can be shared
|
||||||
|
with a workspace (glossaries.workspace_id).
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
POST /api/v1/workspaces create (owner membership)
|
||||||
|
GET /api/v1/workspaces list mine (with members)
|
||||||
|
POST /api/v1/workspaces/{id}/members add member by email
|
||||||
|
DELETE /api/v1/workspaces/{id}/members/{user_id} remove member
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from core.logging import get_logger
|
||||||
|
from database.connection import get_sync_session
|
||||||
|
from database.models import User, Workspace, WorkspaceMember
|
||||||
|
from middleware.api_key_auth import get_authenticated_user
|
||||||
|
from models.subscription import PlanType, PLANS
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/workspaces", tags=["Workspaces"])
|
||||||
|
|
||||||
|
VALID_ROLES = {"admin", "member"}
|
||||||
|
|
||||||
|
|
||||||
|
def _seat_limit_for_owner(plan) -> int:
|
||||||
|
"""Seats allowed for the workspace owner's plan (Business/Enterprise)."""
|
||||||
|
try:
|
||||||
|
plan_cfg = PLANS.get(PlanType(plan))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
plan_cfg = None
|
||||||
|
seats = (plan_cfg or {}).get("team_seats", 0) or 0
|
||||||
|
return seats if seats > 0 else 0 # -1 (enterprise) treated as unlimited
|
||||||
|
|
||||||
|
|
||||||
|
def _seats_unlimited(plan) -> bool:
|
||||||
|
try:
|
||||||
|
return PlanType(plan) == PlanType.ENTERPRISE
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceCreate(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
class MemberAdd(BaseModel):
|
||||||
|
email: str = Field(..., min_length=3)
|
||||||
|
role: str = Field("member", description="admin | member")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=201)
|
||||||
|
async def create_workspace(
|
||||||
|
payload: WorkspaceCreate,
|
||||||
|
current_user=Depends(get_authenticated_user),
|
||||||
|
):
|
||||||
|
if not current_user:
|
||||||
|
raise HTTPException(status_code=401, detail={"error": "AUTH_REQUIRED"})
|
||||||
|
if not _seats_unlimited(current_user.plan) and _seat_limit_for_owner(current_user.plan) == 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail={
|
||||||
|
"error": "PLAN_REQUIRED",
|
||||||
|
"message": "Les espaces de travail nécessitent le plan Business.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create():
|
||||||
|
with get_sync_session() as session:
|
||||||
|
ws = Workspace(name=payload.name.strip(), owner_id=str(current_user.id))
|
||||||
|
session.add(ws)
|
||||||
|
session.flush()
|
||||||
|
session.add(
|
||||||
|
WorkspaceMember(
|
||||||
|
workspace_id=ws.id,
|
||||||
|
user_id=str(current_user.id),
|
||||||
|
role="owner",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(ws)
|
||||||
|
return ws.to_dict()
|
||||||
|
|
||||||
|
from asyncio import to_thread
|
||||||
|
|
||||||
|
data = await to_thread(_create)
|
||||||
|
return JSONResponse(status_code=201, content={"data": data, "meta": {}})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_workspaces(current_user=Depends(get_authenticated_user)):
|
||||||
|
if not current_user:
|
||||||
|
raise HTTPException(status_code=401, detail={"error": "AUTH_REQUIRED"})
|
||||||
|
|
||||||
|
def _list():
|
||||||
|
with get_sync_session() as session:
|
||||||
|
memberships = (
|
||||||
|
session.query(WorkspaceMember)
|
||||||
|
.filter(WorkspaceMember.user_id == str(current_user.id))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
result = []
|
||||||
|
for m in memberships:
|
||||||
|
ws = (
|
||||||
|
session.query(Workspace)
|
||||||
|
.filter(Workspace.id == m.workspace_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not ws:
|
||||||
|
continue
|
||||||
|
members = (
|
||||||
|
session.query(WorkspaceMember)
|
||||||
|
.filter(WorkspaceMember.workspace_id == ws.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
enriched = {
|
||||||
|
**ws.to_dict(),
|
||||||
|
"my_role": m.role,
|
||||||
|
"member_count": len(members),
|
||||||
|
"seat_limit": (
|
||||||
|
-1
|
||||||
|
if _seats_unlimited(current_user.plan)
|
||||||
|
else _seat_limit_for_owner(current_user.plan)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
result.append(enriched)
|
||||||
|
return result
|
||||||
|
|
||||||
|
from asyncio import to_thread
|
||||||
|
|
||||||
|
data = await to_thread(_list)
|
||||||
|
return JSONResponse(status_code=200, content={"data": data, "meta": {}})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{workspace_id}/members", status_code=201)
|
||||||
|
async def add_member(
|
||||||
|
workspace_id: str,
|
||||||
|
payload: MemberAdd,
|
||||||
|
current_user=Depends(get_authenticated_user),
|
||||||
|
):
|
||||||
|
if not current_user:
|
||||||
|
raise HTTPException(status_code=401, detail={"error": "AUTH_REQUIRED"})
|
||||||
|
if payload.role not in VALID_ROLES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422, detail={"error": "INVALID_ROLE"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def _add():
|
||||||
|
with get_sync_session() as session:
|
||||||
|
ws = (
|
||||||
|
session.query(Workspace)
|
||||||
|
.filter(Workspace.id == workspace_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not ws:
|
||||||
|
return ("not_found", None)
|
||||||
|
me = (
|
||||||
|
session.query(WorkspaceMember)
|
||||||
|
.filter(
|
||||||
|
WorkspaceMember.workspace_id == workspace_id,
|
||||||
|
WorkspaceMember.user_id == str(current_user.id),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not me or me.role not in ("owner", "admin"):
|
||||||
|
return ("forbidden", None)
|
||||||
|
|
||||||
|
# Seat enforcement (owner's plan)
|
||||||
|
if ws.owner_id == str(current_user.id):
|
||||||
|
owner_plan = current_user.plan
|
||||||
|
else:
|
||||||
|
owner = session.query(User).filter(User.id == ws.owner_id).first()
|
||||||
|
owner_plan = getattr(owner, "plan", None)
|
||||||
|
count = (
|
||||||
|
session.query(WorkspaceMember)
|
||||||
|
.filter(WorkspaceMember.workspace_id == workspace_id)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
if not _seats_unlimited(owner_plan) and count >= _seat_limit_for_owner(owner_plan):
|
||||||
|
return ("seat_limit", count)
|
||||||
|
|
||||||
|
target = (
|
||||||
|
session.query(User)
|
||||||
|
.filter(User.email == payload.email.strip().lower())
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not target:
|
||||||
|
return ("user_not_found", None)
|
||||||
|
existing = (
|
||||||
|
session.query(WorkspaceMember)
|
||||||
|
.filter(
|
||||||
|
WorkspaceMember.workspace_id == workspace_id,
|
||||||
|
WorkspaceMember.user_id == str(target.id),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
return ("already_member", None)
|
||||||
|
|
||||||
|
member = WorkspaceMember(
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
user_id=str(target.id),
|
||||||
|
role=payload.role,
|
||||||
|
)
|
||||||
|
session.add(member)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(member)
|
||||||
|
return ("ok", member.to_dict())
|
||||||
|
|
||||||
|
from asyncio import to_thread
|
||||||
|
|
||||||
|
status, payload_out = await to_thread(_add)
|
||||||
|
if status == "not_found":
|
||||||
|
raise HTTPException(status_code=404, detail={"error": "WORKSPACE_NOT_FOUND"})
|
||||||
|
if status == "forbidden":
|
||||||
|
raise HTTPException(status_code=403, detail={"error": "ACCESS_DENIED"})
|
||||||
|
if status == "seat_limit":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail={
|
||||||
|
"error": "SEAT_LIMIT_REACHED",
|
||||||
|
"message": "Limite de sièges du plan atteinte. Passez au plan supérieur pour ajouter des membres.",
|
||||||
|
"details": {"seats_used": payload_out},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if status == "user_not_found":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail={
|
||||||
|
"error": "USER_NOT_FOUND",
|
||||||
|
"message": "Aucun compte avec cet e-mail — l'utilisateur doit d'abord créer un compte.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if status == "already_member":
|
||||||
|
raise HTTPException(status_code=409, detail={"error": "ALREADY_MEMBER"})
|
||||||
|
|
||||||
|
return JSONResponse(status_code=201, content={"data": payload_out, "meta": {}})
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{workspace_id}/members/{user_id}", status_code=200)
|
||||||
|
async def remove_member(
|
||||||
|
workspace_id: str,
|
||||||
|
user_id: str,
|
||||||
|
current_user=Depends(get_authenticated_user),
|
||||||
|
):
|
||||||
|
if not current_user:
|
||||||
|
raise HTTPException(status_code=401, detail={"error": "AUTH_REQUIRED"})
|
||||||
|
|
||||||
|
def _remove():
|
||||||
|
with get_sync_session() as session:
|
||||||
|
me = (
|
||||||
|
session.query(WorkspaceMember)
|
||||||
|
.filter(
|
||||||
|
WorkspaceMember.workspace_id == workspace_id,
|
||||||
|
WorkspaceMember.user_id == str(current_user.id),
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not me or me.role not in ("owner", "admin"):
|
||||||
|
return "forbidden"
|
||||||
|
target = (
|
||||||
|
session.query(WorkspaceMember)
|
||||||
|
.filter(
|
||||||
|
WorkspaceMember.workspace_id == workspace_id,
|
||||||
|
WorkspaceMember.user_id == user_id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not target:
|
||||||
|
return "not_found"
|
||||||
|
if target.role == "owner":
|
||||||
|
return "owner_immutable"
|
||||||
|
session.delete(target)
|
||||||
|
session.commit()
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
from asyncio import to_thread
|
||||||
|
|
||||||
|
status = await to_thread(_remove)
|
||||||
|
if status == "forbidden":
|
||||||
|
raise HTTPException(status_code=403, detail={"error": "ACCESS_DENIED"})
|
||||||
|
if status == "not_found":
|
||||||
|
raise HTTPException(status_code=404, detail={"error": "MEMBER_NOT_FOUND"})
|
||||||
|
if status == "owner_immutable":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail={"error": "OWNER_IMMUTABLE", "message": "Le propriétaire ne peut pas être retiré."},
|
||||||
|
)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=200, content={"data": {"removed": True}, "meta": {}}
|
||||||
|
)
|
||||||
@@ -15,29 +15,49 @@ from utils.exceptions import GlossaryNotFoundError
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _user_workspace_ids(session, user_id: str) -> List[str]:
|
||||||
|
"""Workspaces the user belongs to (for shared-glossary access)."""
|
||||||
|
from database.models import WorkspaceMember
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
session.query(WorkspaceMember.workspace_id)
|
||||||
|
.filter(WorkspaceMember.user_id == user_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [r[0] for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _glossary_accessible(session, glossary: Glossary, user_id: str) -> bool:
|
||||||
|
"""Owner always; otherwise any workspace the glossary is shared with."""
|
||||||
|
if str(glossary.user_id) == str(user_id):
|
||||||
|
return True
|
||||||
|
if glossary.workspace_id:
|
||||||
|
return glossary.workspace_id in _user_workspace_ids(session, user_id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def get_glossary_terms(glossary_id: str, user_id: str) -> Dict[str, Any]:
|
def get_glossary_terms(glossary_id: str, user_id: str) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Retrieve glossary terms and metadata for a specific glossary owned by a user.
|
Retrieve glossary terms and metadata for a glossary the user can access
|
||||||
|
(owner, or member of the workspace the glossary is shared with).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
glossary_id: UUID of the glossary
|
glossary_id: UUID of the glossary
|
||||||
user_id: UUID of the user (must own the glossary)
|
user_id: UUID of the user
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with 'source_language' and 'terms' (list of dicts with source, target, translations)
|
Dict with 'source_language' and 'terms' (list of dicts with source, target, translations)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
GlossaryNotFoundError: If glossary doesn't exist or doesn't belong to user
|
GlossaryNotFoundError: If glossary doesn't exist or isn't accessible
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
with get_sync_session() as session:
|
with get_sync_session() as session:
|
||||||
glossary = (
|
glossary = (
|
||||||
session.query(Glossary)
|
session.query(Glossary).filter(Glossary.id == glossary_id).first()
|
||||||
.filter(Glossary.id == glossary_id, Glossary.user_id == user_id)
|
|
||||||
.first()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not glossary:
|
if not glossary or not _glossary_accessible(session, glossary, user_id):
|
||||||
raise GlossaryNotFoundError(
|
raise GlossaryNotFoundError(
|
||||||
message="Glossaire introuvable ou vous n'avez pas accès à cette ressource.",
|
message="Glossaire introuvable ou vous n'avez pas accès à cette ressource.",
|
||||||
details={"glossary_id": glossary_id}
|
details={"glossary_id": glossary_id}
|
||||||
@@ -77,35 +97,34 @@ def get_glossary_terms(glossary_id: str, user_id: str) -> Dict[str, Any]:
|
|||||||
|
|
||||||
def validate_glossary_access(glossary_id: str, user_id: str) -> bool:
|
def validate_glossary_access(glossary_id: str, user_id: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Validate that a glossary exists and belongs to the user.
|
Validate that a glossary exists and is accessible to the user
|
||||||
|
(owner, or member of the workspace the glossary is shared with).
|
||||||
|
|
||||||
This is a lightweight check that doesn't return the terms,
|
This is a lightweight check that doesn't return the terms,
|
||||||
useful for early validation before starting a translation job.
|
useful for early validation before starting a translation job.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
glossary_id: UUID of the glossary
|
glossary_id: UUID of the glossary
|
||||||
user_id: UUID of the user (must own the glossary)
|
user_id: UUID of the user
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if glossary exists and belongs to user
|
True if glossary exists and is accessible
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
GlossaryNotFoundError: If glossary doesn't exist or doesn't belong to user
|
GlossaryNotFoundError: If glossary doesn't exist or isn't accessible
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
with get_sync_session() as session:
|
with get_sync_session() as session:
|
||||||
glossary = (
|
glossary = (
|
||||||
session.query(Glossary)
|
session.query(Glossary).filter(Glossary.id == glossary_id).first()
|
||||||
.filter(Glossary.id == glossary_id, Glossary.user_id == user_id)
|
|
||||||
.first()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not glossary:
|
if not glossary or not _glossary_accessible(session, glossary, user_id):
|
||||||
raise GlossaryNotFoundError(
|
raise GlossaryNotFoundError(
|
||||||
message="Glossaire introuvable ou vous n'avez pas accès à cette ressource.",
|
message="Glossaire introuvable ou vous n'avez pas accès à cette ressource.",
|
||||||
details={"glossary_id": glossary_id}
|
details={"glossary_id": glossary_id}
|
||||||
)
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except GlossaryNotFoundError:
|
except GlossaryNotFoundError:
|
||||||
|
|||||||
288
tests/test_review_foundation.py
Normal file
288
tests/test_review_foundation.py
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
"""Review foundation: segment recording → persistence → overrides → rebuild,
|
||||||
|
plus the XLIFF and workspace APIs."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from docx import Document
|
||||||
|
|
||||||
|
from database.models import TranslationSegment, Workspace, WorkspaceMember
|
||||||
|
from translators.segments import SegmentRecorder, apply_overrides
|
||||||
|
from translators.word_translator import WordTranslator
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeProvider:
|
||||||
|
"""New-style provider; translates only on explicit call."""
|
||||||
|
|
||||||
|
def get_name(self):
|
||||||
|
return "fake"
|
||||||
|
|
||||||
|
def is_available(self):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def translate_batch(self, texts, target_language, source_language="auto"):
|
||||||
|
return [f"FR:{t}" for t in texts]
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Recorder + overrides (unit)
|
||||||
|
# ===========================================================================
|
||||||
|
class TestRecorder:
|
||||||
|
def test_records_unique_in_order(self):
|
||||||
|
rec = SegmentRecorder()
|
||||||
|
rec.record_pair("Hello", "Bonjour")
|
||||||
|
rec.record_pair("World", "Monde")
|
||||||
|
rec.record_pair("Hello", "Bonjour") # duplicate ignored
|
||||||
|
assert rec.get_pairs() == [("Hello", "Bonjour"), ("World", "Monde")]
|
||||||
|
|
||||||
|
def test_skips_empty_and_identity(self):
|
||||||
|
rec = SegmentRecorder()
|
||||||
|
rec.record_pair("", "x")
|
||||||
|
rec.record_pair(" ", "x")
|
||||||
|
rec.record_pair("same", "same")
|
||||||
|
assert rec.get_pairs() == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyOverrides:
|
||||||
|
def test_hits_and_misses(self):
|
||||||
|
hits, misses = apply_overrides(
|
||||||
|
["a", "b", "c"], {"a": "A!", "c": "C!"}
|
||||||
|
)
|
||||||
|
assert hits == {0: "A!", 2: "C!"}
|
||||||
|
assert misses == [1]
|
||||||
|
|
||||||
|
def test_identity_override_is_a_miss(self):
|
||||||
|
hits, misses = apply_overrides(["same"], {"same": "same"})
|
||||||
|
assert hits == {}
|
||||||
|
assert misses == [0]
|
||||||
|
|
||||||
|
def test_none_overrides_all_miss(self):
|
||||||
|
hits, misses = apply_overrides(["a", "b"], None)
|
||||||
|
assert hits == {} and misses == [0, 1]
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# End-to-end: translate → recorded segments → rebuild with overrides
|
||||||
|
# ===========================================================================
|
||||||
|
class TestSegmentCaptureAndRebuild:
|
||||||
|
def _make_doc(self, path):
|
||||||
|
doc = Document()
|
||||||
|
doc.add_paragraph("Hello world")
|
||||||
|
doc.add_paragraph("Second paragraph")
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
doc.save(str(path))
|
||||||
|
|
||||||
|
def test_capture_and_rebuild(self, tmp_path):
|
||||||
|
from database.models import Base
|
||||||
|
from database.connection import sync_engine
|
||||||
|
|
||||||
|
Base.metadata.create_all(bind=sync_engine)
|
||||||
|
|
||||||
|
self._make_doc(tmp_path / "in.docx")
|
||||||
|
|
||||||
|
# 1) Translate with recorder
|
||||||
|
t = WordTranslator(provider=_FakeProvider())
|
||||||
|
rec = SegmentRecorder()
|
||||||
|
t.set_segment_recorder(rec)
|
||||||
|
t.translate_file(tmp_path / "in.docx", tmp_path / "out.docx", "fr", "en")
|
||||||
|
|
||||||
|
pairs = t.get_recorded_segments()
|
||||||
|
assert ("Hello world", "FR:Hello world") in pairs
|
||||||
|
assert ("Second paragraph", "FR:Second paragraph") in pairs
|
||||||
|
|
||||||
|
# 2) Rebuild with a human-reviewed override
|
||||||
|
t2 = WordTranslator(provider=_FakeProvider())
|
||||||
|
t2.set_segment_overrides({"Hello world": "Bonjour le monde (relu)"})
|
||||||
|
t2.translate_file(tmp_path / "in.docx", tmp_path / "rebuilt.docx", "fr", "en")
|
||||||
|
|
||||||
|
result = Document(str(tmp_path / "rebuilt.docx"))
|
||||||
|
texts = [p.text for p in result.paragraphs]
|
||||||
|
assert "Bonjour le monde (relu)" in texts
|
||||||
|
# Non-overridden text still machine-translated
|
||||||
|
assert "FR:Second paragraph" in texts
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# XLIFF export format
|
||||||
|
# ===========================================================================
|
||||||
|
class TestXliff:
|
||||||
|
def test_export_structure(self):
|
||||||
|
from routes.review_routes import _xliff_export
|
||||||
|
|
||||||
|
job = {
|
||||||
|
"source_lang": "en",
|
||||||
|
"target_lang": "fr",
|
||||||
|
"file_name": "doc.docx",
|
||||||
|
}
|
||||||
|
segments = [
|
||||||
|
{
|
||||||
|
"id": "seg-1",
|
||||||
|
"segment_index": 0,
|
||||||
|
"source_text": "Hello",
|
||||||
|
"translated_text": "Bonjour",
|
||||||
|
"status": "pending",
|
||||||
|
"reviewed_text": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "seg-2",
|
||||||
|
"segment_index": 1,
|
||||||
|
"source_text": "World",
|
||||||
|
"translated_text": "Monde",
|
||||||
|
"status": "edited",
|
||||||
|
"reviewed_text": "Le monde",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
xml = _xliff_export(job, segments)
|
||||||
|
assert '<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2"' in xml
|
||||||
|
assert 'source-language="en"' in xml
|
||||||
|
assert "<source>Hello</source>" in xml
|
||||||
|
# edited segments export their REVIEWED text
|
||||||
|
assert "<target>Le monde</target>" in xml
|
||||||
|
assert "status: edited" in xml
|
||||||
|
|
||||||
|
def test_export_escapes_xml(self):
|
||||||
|
from routes.review_routes import _xliff_export
|
||||||
|
|
||||||
|
xml = _xliff_export(
|
||||||
|
{"source_lang": "en", "target_lang": "fr", "file_name": "x"},
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "s1",
|
||||||
|
"segment_index": 0,
|
||||||
|
"source_text": "a < b & c",
|
||||||
|
"translated_text": "x < y",
|
||||||
|
"status": "pending",
|
||||||
|
"reviewed_text": None,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert "a < b & c" in xml
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Workspace seats
|
||||||
|
# ===========================================================================
|
||||||
|
class TestWorkspaceSeats:
|
||||||
|
def test_seat_limit_helpers(self):
|
||||||
|
from routes.workspace_routes import _seat_limit_for_owner
|
||||||
|
from models.subscription import PlanType
|
||||||
|
|
||||||
|
assert _seat_limit_for_owner(PlanType.BUSINESS) == 5
|
||||||
|
assert _seat_limit_for_owner(PlanType.FREE) == 0
|
||||||
|
assert _seat_limit_for_owner(PlanType.PRO) == 0
|
||||||
|
|
||||||
|
def test_workspace_crud_flow(self):
|
||||||
|
from database.connection import get_sync_session
|
||||||
|
from database.models import Base, User
|
||||||
|
from database.connection import sync_engine
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
Base.metadata.create_all(bind=sync_engine)
|
||||||
|
|
||||||
|
with get_sync_session() as session:
|
||||||
|
owner = User(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
email=f"owner-{uuid.uuid4().hex[:6]}@t.local",
|
||||||
|
name="Owner",
|
||||||
|
password_hash="x",
|
||||||
|
plan="business",
|
||||||
|
)
|
||||||
|
member = User(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
email=f"member-{uuid.uuid4().hex[:6]}@t.local",
|
||||||
|
name="Member",
|
||||||
|
password_hash="x",
|
||||||
|
)
|
||||||
|
session.add_all([owner, member])
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
ws = Workspace(name="Équipe", owner_id=owner.id)
|
||||||
|
session.add(ws)
|
||||||
|
session.flush()
|
||||||
|
session.add(
|
||||||
|
WorkspaceMember(
|
||||||
|
workspace_id=ws.id, user_id=owner.id, role="owner"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.add(
|
||||||
|
WorkspaceMember(
|
||||||
|
workspace_id=ws.id, user_id=member.id, role="member"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
count = (
|
||||||
|
session.query(WorkspaceMember)
|
||||||
|
.filter(WorkspaceMember.workspace_id == ws.id)
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
assert count == 2
|
||||||
|
from services.glossary_service import _user_workspace_ids
|
||||||
|
|
||||||
|
ids = _user_workspace_ids(session, member.id)
|
||||||
|
assert ws.id in ids
|
||||||
|
|
||||||
|
session.query(WorkspaceMember).filter(
|
||||||
|
WorkspaceMember.workspace_id == ws.id
|
||||||
|
).delete()
|
||||||
|
session.query(Workspace).filter(Workspace.id == ws.id).delete()
|
||||||
|
session.query(User).filter(User.id.in_([owner.id, member.id])).delete()
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Shared glossary access
|
||||||
|
# ===========================================================================
|
||||||
|
class TestSharedGlossaryAccess:
|
||||||
|
def test_workspace_member_can_access_shared_glossary(self):
|
||||||
|
from database.connection import get_sync_session, sync_engine
|
||||||
|
from database.models import Base, User, Glossary
|
||||||
|
from services.glossary_service import (
|
||||||
|
get_glossary_terms,
|
||||||
|
validate_glossary_access,
|
||||||
|
)
|
||||||
|
from utils.exceptions import GlossaryNotFoundError
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
Base.metadata.create_all(bind=sync_engine)
|
||||||
|
uid = uuid.uuid4().hex[:8]
|
||||||
|
|
||||||
|
with get_sync_session() as session:
|
||||||
|
owner = User(id=str(uuid.uuid4()), email=f"go-{uid}@t.local",
|
||||||
|
name="O", password_hash="x")
|
||||||
|
member = User(id=str(uuid.uuid4()), email=f"gm-{uid}@t.local",
|
||||||
|
name="M", password_hash="x")
|
||||||
|
outsider = User(id=str(uuid.uuid4()), email=f"gx-{uid}@t.local",
|
||||||
|
name="X", password_hash="x")
|
||||||
|
session.add_all([owner, member, outsider])
|
||||||
|
session.flush()
|
||||||
|
|
||||||
|
ws = Workspace(name="W", owner_id=owner.id)
|
||||||
|
session.add(ws)
|
||||||
|
session.flush()
|
||||||
|
session.add(WorkspaceMember(workspace_id=ws.id, user_id=member.id, role="member"))
|
||||||
|
|
||||||
|
glossary = Glossary(
|
||||||
|
id=str(uuid.uuid4()), user_id=owner.id,
|
||||||
|
workspace_id=ws.id, name="Shared",
|
||||||
|
)
|
||||||
|
session.add(glossary)
|
||||||
|
session.commit()
|
||||||
|
gid = glossary.id
|
||||||
|
o_id, m_id, x_id = owner.id, member.id, outsider.id
|
||||||
|
ws_id = ws.id
|
||||||
|
|
||||||
|
try:
|
||||||
|
# owner OK
|
||||||
|
assert validate_glossary_access(gid, o_id) is True
|
||||||
|
# workspace member OK (shared)
|
||||||
|
assert validate_glossary_access(gid, m_id) is True
|
||||||
|
assert get_glossary_terms(gid, m_id)["source_language"] == "fr"
|
||||||
|
# outsider refused
|
||||||
|
with pytest.raises(GlossaryNotFoundError):
|
||||||
|
validate_glossary_access(gid, x_id)
|
||||||
|
finally:
|
||||||
|
with get_sync_session() as session:
|
||||||
|
session.query(Glossary).filter(Glossary.id == gid).delete()
|
||||||
|
session.query(WorkspaceMember).filter(WorkspaceMember.workspace_id == ws_id).delete()
|
||||||
|
session.query(Workspace).filter(Workspace.id == ws_id).delete()
|
||||||
|
session.query(User).filter(User.id.in_([o_id, m_id, x_id])).delete()
|
||||||
|
session.commit()
|
||||||
@@ -125,6 +125,18 @@ class ExcelTranslator:
|
|||||||
user_id, prompt or getattr(self, "_custom_prompt", None)
|
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(
|
def translate_file(
|
||||||
self,
|
self,
|
||||||
@@ -461,12 +473,41 @@ class ExcelTranslator:
|
|||||||
miss_texts, target_language, source_language
|
miss_texts, target_language, source_language
|
||||||
)
|
)
|
||||||
|
|
||||||
# Translation memory: reuse this user's previous translations
|
from translators.segments import apply_overrides
|
||||||
# (identical context/prompt) before hitting the provider.
|
|
||||||
translated = translate_with_tm(
|
# Reviewer overrides (approved/edited segments) win over everything:
|
||||||
texts, target_language, source_language,
|
# no TM lookup, no provider call, zero drift from the reviewed text.
|
||||||
provider_name, getattr(self, "_tm_scope", None), _do_translate,
|
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())
|
changed = sum(1 for orig, trans in zip(texts, translated) if orig != trans and trans.strip())
|
||||||
self._translation_stats["changed"] += changed
|
self._translation_stats["changed"] += changed
|
||||||
|
|||||||
@@ -172,6 +172,18 @@ class PDFTranslator:
|
|||||||
if enabled is not None:
|
if enabled is not None:
|
||||||
self._ocr_enabled = enabled
|
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]:
|
def _get_font_path(self) -> Optional[str]:
|
||||||
"""Resolve a Unicode-capable TTF/OTF font file."""
|
"""Resolve a Unicode-capable TTF/OTF font file."""
|
||||||
if self._font_path is not None:
|
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
|
Also feeds the job-level attempted/changed stats so the route can
|
||||||
detect a total provider failure (changed == 0) on PDFs too.
|
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():
|
if text and text.strip():
|
||||||
self._translation_stats["attempted"] += 1
|
self._translation_stats["attempted"] += 1
|
||||||
if self._provider is not None:
|
if self._provider is not None:
|
||||||
try:
|
try:
|
||||||
results = self._translate_with_provider([text], target_language, source_language)
|
results = self._translate_with_provider([text], target_language, source_language)
|
||||||
if results and results[0].strip():
|
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
|
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:
|
except Exception as e:
|
||||||
logger.warning("provider_single_failed", error=str(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)
|
result = translation_service.translate_text(text, target_language, source_language)
|
||||||
if result and result.strip() and result.strip() != text.strip():
|
if result and result.strip() and result.strip() != text.strip():
|
||||||
self._translation_stats["changed"] += 1
|
self._translation_stats["changed"] += 1
|
||||||
|
recorder = getattr(self, "_segment_recorder", None)
|
||||||
|
if recorder is not None:
|
||||||
|
recorder.record_pair(text, result)
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("legacy_single_failed", error=str(e))
|
logger.warning("legacy_single_failed", error=str(e))
|
||||||
|
|||||||
@@ -233,6 +233,18 @@ class PowerPointTranslator:
|
|||||||
user_id, prompt or getattr(self, "_custom_prompt", None)
|
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(
|
def translate_file(
|
||||||
self,
|
self,
|
||||||
@@ -499,12 +511,41 @@ class PowerPointTranslator:
|
|||||||
miss_texts, target_language, source_language
|
miss_texts, target_language, source_language
|
||||||
)
|
)
|
||||||
|
|
||||||
# Translation memory: reuse this user's previous translations
|
from translators.segments import apply_overrides
|
||||||
# (identical context/prompt) before hitting the provider.
|
|
||||||
translated = translate_with_tm(
|
# Reviewer overrides (approved/edited segments) win over everything:
|
||||||
texts, target_language, source_language,
|
# no TM lookup, no provider call, zero drift from the reviewed text.
|
||||||
provider_name, getattr(self, "_tm_scope", None), _do_translate,
|
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())
|
changed = sum(1 for orig, trans in zip(texts, translated) if orig != trans and trans.strip())
|
||||||
self._translation_stats["changed"] += changed
|
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)
|
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(
|
def translate_file(
|
||||||
self,
|
self,
|
||||||
input_path: Path,
|
input_path: Path,
|
||||||
@@ -561,12 +573,41 @@ class WordTranslator:
|
|||||||
miss_texts, target_language, source_language
|
miss_texts, target_language, source_language
|
||||||
)
|
)
|
||||||
|
|
||||||
# Translation memory: reuse this user's previous translations
|
from translators.segments import apply_overrides
|
||||||
# (identical context/prompt) before hitting the provider.
|
|
||||||
translated = translate_with_tm(
|
# Reviewer overrides (approved/edited segments) win over everything:
|
||||||
texts, target_language, source_language,
|
# no TM lookup, no provider call, zero drift from the reviewed text.
|
||||||
provider_name, self._tm_scope, _do_translate,
|
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())
|
changed = sum(1 for orig, trans in zip(texts, translated) if orig != trans and trans.strip())
|
||||||
self._translation_stats["changed"] += changed
|
self._translation_stats["changed"] += changed
|
||||||
|
|||||||
Reference in New Issue
Block a user