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

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:
2026-08-29 19:04:32 +02:00
parent 526c87348f
commit b4e873ad2c
31 changed files with 2399 additions and 54 deletions

View File

@@ -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")

View File

@@ -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,
}

View 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.

View File

@@ -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 },
]; ];

View 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>
);
}

View 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&apos;instant créez le premier ci-dessus.
</div>
)}
</div>
</>
)}
</div>
);
}

View File

@@ -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"

View File

@@ -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": "الفريق"
} }

View File

@@ -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"
} }

View File

@@ -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"
} }

View File

@@ -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"
} }

View File

@@ -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": "تیم"
} }

View File

@@ -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"
} }

View File

@@ -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"
} }

View File

@@ -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": "チーム"
} }

View File

@@ -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": "팀"
} }

View File

@@ -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"
} }

View File

@@ -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"
} }

View File

@@ -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": "Команда"
} }

View File

@@ -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": "团队"
} }

View File

@@ -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
View 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": {},
},
)

View File

@@ -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
View 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": {}}
)

View File

@@ -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,30 +97,29 @@ 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}

View 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 &lt; b &amp; 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()

View File

@@ -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
) )
from translators.segments import apply_overrides
# Reviewer overrides (approved/edited segments) win over everything:
# no TM lookup, no provider call, zero drift from the reviewed text.
ov_hits, ov_misses = apply_overrides(
texts, getattr(self, "_segment_overrides", None)
)
miss_texts = [texts[i] for i in ov_misses]
if miss_texts:
# Translation memory: reuse this user's previous translations # Translation memory: reuse this user's previous translations
# (identical context/prompt) before hitting the provider. # (identical context/prompt) before hitting the provider.
translated = translate_with_tm( miss_translated = translate_with_tm(
texts, target_language, source_language, miss_texts, target_language, source_language,
provider_name, getattr(self, "_tm_scope", None), _do_translate, 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

View File

@@ -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))

View File

@@ -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
) )
from translators.segments import apply_overrides
# Reviewer overrides (approved/edited segments) win over everything:
# no TM lookup, no provider call, zero drift from the reviewed text.
ov_hits, ov_misses = apply_overrides(
texts, getattr(self, "_segment_overrides", None)
)
miss_texts = [texts[i] for i in ov_misses]
if miss_texts:
# Translation memory: reuse this user's previous translations # Translation memory: reuse this user's previous translations
# (identical context/prompt) before hitting the provider. # (identical context/prompt) before hitting the provider.
translated = translate_with_tm( miss_translated = translate_with_tm(
texts, target_language, source_language, miss_texts, target_language, source_language,
provider_name, getattr(self, "_tm_scope", None), _do_translate, 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
View 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

View File

@@ -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
) )
from translators.segments import apply_overrides
# Reviewer overrides (approved/edited segments) win over everything:
# no TM lookup, no provider call, zero drift from the reviewed text.
ov_hits, ov_misses = apply_overrides(
texts, getattr(self, "_segment_overrides", None)
)
miss_texts = [texts[i] for i in ov_misses]
if miss_texts:
# Translation memory: reuse this user's previous translations # Translation memory: reuse this user's previous translations
# (identical context/prompt) before hitting the provider. # (identical context/prompt) before hitting the provider.
translated = translate_with_tm( miss_translated = translate_with_tm(
texts, target_language, source_language, miss_texts, target_language, source_language,
provider_name, self._tm_scope, _do_translate, 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