diff --git a/alembic/versions/f7e8d9c0b1a2_segments_workspaces_shared_glossaries.py b/alembic/versions/f7e8d9c0b1a2_segments_workspaces_shared_glossaries.py new file mode 100644 index 0000000..fee30d0 --- /dev/null +++ b/alembic/versions/f7e8d9c0b1a2_segments_workspaces_shared_glossaries.py @@ -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") diff --git a/database/models.py b/database/models.py index 5e4b137..015b769 100644 --- a/database/models.py +++ b/database/models.py @@ -331,6 +331,8 @@ class Glossary(Base): user_id = Column( 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) source_language = Column(String(10), nullable=False, default="fr") target_language = Column(String(10), nullable=True, default="en") @@ -347,12 +349,14 @@ class Glossary(Base): __table_args__ = ( Index("ix_glossaries_user_id", "user_id"), Index("ix_glossaries_template_id", "template_id"), + Index("ix_glossaries_workspace", "workspace_id"), ) def to_dict(self) -> dict: return { "id": self.id, "user_id": self.user_id, + "workspace_id": self.workspace_id, "name": self.name, "source_language": self.source_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, "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, + } diff --git a/docs/CHANTIER_FONDATIONS_2026-08-29.md b/docs/CHANTIER_FONDATIONS_2026-08-29.md new file mode 100644 index 0000000..fe4ea9d --- /dev/null +++ b/docs/CHANTIER_FONDATIONS_2026-08-29.md @@ -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. diff --git a/frontend/src/app/dashboard/constants.ts b/frontend/src/app/dashboard/constants.ts index a46cf7e..2b43d95 100644 --- a/frontend/src/app/dashboard/constants.ts +++ b/frontend/src/app/dashboard/constants.ts @@ -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 { labelKey: string; @@ -9,8 +9,9 @@ export interface NavItem { export const baseNavItems: NavItem[] = [ { 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.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. // { labelKey: 'dashboard.nav.apiKeys', href: '/dashboard/api-keys', icon: Key, proOnly: true }, ]; diff --git a/frontend/src/app/dashboard/reviews/[jobId]/page.tsx b/frontend/src/app/dashboard/reviews/[jobId]/page.tsx new file mode 100644 index 0000000..a8fa98c --- /dev/null +++ b/frontend/src/app/dashboard/reviews/[jobId]/page.tsx @@ -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 { + const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null; + const headers: Record = {}; + 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([]); + const [fileName, setFileName] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [editingId, setEditingId] = useState(null); + const [draft, setDraft] = useState(''); + const [savingId, setSavingId] = useState(null); + const [isRebuilding, setIsRebuilding] = useState(false); + const [isImporting, setIsImporting] = useState(false); + const fileInputRef = useRef(null); + + const load = useCallback(async () => { + setIsLoading(true); + setLoadError(null); + try { + const res = await apiClient.get( + `/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( + `/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 Approuvé; + if (status === 'edited') + return Modifié; + return À relire; + }; + + return ( +
+
+
+

Relecture

+

+ {fileName ?? jobId} — {counts.total} segments ({counts.pending} à + relire, {counts.approved} approuvés, {counts.edited} modifiés) +

+
+
+ + + + { + const f = e.target.files?.[0]; + if (f) importXliff(f); + }} + /> + +
+
+ + {loadError && ( +
+ {loadError} +
+ )} + + {isLoading ? ( +
+ +
+ ) : ( +
+ + + + + + + + + + {segments.map((seg) => { + const isEditing = editingId === seg.id; + const finalText = seg.status === 'edited' && seg.reviewed_text + ? seg.reviewed_text + : seg.translated_text; + return ( + + +
SourceTraductionStatut
+ {seg.source_text} + + {isEditing ? ( +
+