feat(ui,api): wave 3 — editorial pricing, real cancel, server history, DeepL purge
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m36s

Pricing: full editorial redesign — serif card headers with accent pills
replace the colored font-black blocks, tone sweep across toggle/metrics/
features/CTAs, PLAN_COLORS removed; one design system app-wide.

Translate: decorative titles one step down (CTA hierarchy restored);
glossary and image-translation blocks hidden entirely for free users
(progressive disclosure — three controls for free).

Reviews: XLIFF hint line explains the exchange format; backend errors
routed through a friendly mapper (session/not-found/rate-limit/server).

Landing: fabricated hero UI cards (fake 'Context Engine' overlay)
removed — the photo no longer promises screens that don't exist.

Nav: single DashboardNavLinks component shared by sidebar and mobile
drawer (was duplicated markup).

API: GET /api/v1/translations (user job history, paginated; completed
jobs retained 24h) and POST /api/v1/translations/{id}/cancel —
cooperative cancellation with worker checkpoints before dispatch and
before finalisation, reserved quota released immediately. Translate
monitor now offers a real 'Cancel translation' next to 'Back to start';
recent-jobs list reads server history first, localStorage fallback.

DeepL purge (backend): provider module, registry registration, config
attrs/defaults, dispatch branch, admin settings schema + test branch,
legacy availability block, validation rules, plan provider lists,
error-code mappings, MCP enums, translator prompt mention, related
tests updated/removed. Fallback resolver skips unknown providers, so
stale chains containing 'deepl' degrade gracefully.

Verified: backend 110 tests passed; frontend build exit 0, vitest 9/9,
0 missing i18n keys, eslint 63 errors (vs 64 at HEAD).
This commit is contained in:
2026-08-30 22:42:29 +02:00
parent 52748ee653
commit 67365918ae
35 changed files with 398 additions and 1572 deletions

View File

@@ -13,7 +13,6 @@ load_dotenv()
class Config: class Config:
# ============== Translation Service ============== # ============== Translation Service ==============
TRANSLATION_SERVICE = os.getenv("TRANSLATION_SERVICE", "google") TRANSLATION_SERVICE = os.getenv("TRANSLATION_SERVICE", "google")
DEEPL_API_KEY = os.getenv("DEEPL_API_KEY", "")
# ============== File Upload Configuration ============== # ============== File Upload Configuration ==============

View File

@@ -2,30 +2,24 @@
import { useState } from 'react'; import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { import {
Menu, Menu,
X, X,
LogOut LogOut
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useUser } from './useUser'; import { useUser } from './useUser';
import { useLogout } from './useLogout'; import { useLogout } from './useLogout';
import { baseNavItems } from './constants'; import { DashboardNavLinks } from './DashboardNavLinks';
import { getInitials, translateTier } from './utils'; import { getInitials, translateTier } from './utils';
import { ThemeToggle } from '@/components/ui/theme-toggle'; import { ThemeToggle } from '@/components/ui/theme-toggle';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
export function DashboardHeader() { export function DashboardHeader() {
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
const pathname = usePathname();
const { data: user, isLoading } = useUser(); const { data: user, isLoading } = useUser();
const { logout } = useLogout(); const { logout } = useLogout();
const { t } = useI18n(); const { t } = useI18n();
const isPro = ['pro', 'business', 'enterprise'].includes(user?.tier ?? '');
const navItems = isPro ? baseNavItems : baseNavItems.filter(item => !item.proOnly);
return ( return (
<> <>
<header className="flex h-20 shrink-0 items-center justify-between border-b border-black/5 dark:border-white/5 bg-white/50 dark:bg-[#141414]/50 backdrop-blur-md px-6 lg:px-8"> <header className="flex h-20 shrink-0 items-center justify-between border-b border-black/5 dark:border-white/5 bg-white/50 dark:bg-[#141414]/50 backdrop-blur-md px-6 lg:px-8">
@@ -97,25 +91,7 @@ export function DashboardHeader() {
{mobileOpen && ( {mobileOpen && (
<div className="border-b border-black/5 dark:border-white/5 bg-white dark:bg-[#141414] px-6 py-4 lg:hidden"> <div className="border-b border-black/5 dark:border-white/5 bg-white dark:bg-[#141414] px-6 py-4 lg:hidden">
<nav className="flex flex-col gap-1"> <nav className="flex flex-col gap-1">
{navItems.map((item) => { <DashboardNavLinks onNavigate={() => setMobileOpen(false)} />
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
onClick={() => setMobileOpen(false)}
className={cn(
'flex items-center gap-4 rounded-2xl px-6 py-4 text-[11px] font-black uppercase tracking-[0.2em] transition-all duration-200',
isActive
? 'bg-brand-dark text-white shadow-xl'
: 'text-brand-dark/65 dark:text-white/65 hover:bg-brand-muted dark:hover:bg-[#1f1f1f]'
)}
>
<item.icon size={18} className="shrink-0" />
{t(item.labelKey)}
</Link>
);
})}
<div className="my-3 h-px bg-black/5 dark:bg-white/5" /> <div className="my-3 h-px bg-black/5 dark:bg-white/5" />

View File

@@ -0,0 +1,42 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
import { useUser } from './useUser';
import { baseNavItems } from './constants';
import { useI18n } from '@/lib/i18n';
/** Single source of dashboard navigation — used by the sidebar and the mobile drawer. */
export function DashboardNavLinks({ onNavigate }: { onNavigate?: () => void }) {
const pathname = usePathname();
const { data: user } = useUser();
const { t } = useI18n();
const isPro = ['pro', 'business', 'enterprise'].includes(user?.tier ?? '');
const navItems = isPro ? baseNavItems : baseNavItems.filter(item => !item.proOnly);
return (
<>
{navItems.map((item) => {
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
onClick={onNavigate}
className={cn(
'flex items-center gap-4 rounded-2xl px-6 py-4 text-[11px] font-black uppercase tracking-[0.2em] transition-all duration-200',
isActive
? 'bg-brand-dark text-white shadow-xl dark:bg-brand-accent dark:text-brand-dark'
: 'text-brand-dark/65 dark:text-white/65 hover:bg-brand-muted dark:hover:bg-[#1f1f1f]'
)}
>
<item.icon size={18} className="shrink-0" />
{t(item.labelKey)}
</Link>
);
})}
</>
);
}

View File

@@ -1,24 +1,19 @@
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { LogOut } from 'lucide-react'; import { LogOut } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useUser } from './useUser'; import { useUser } from './useUser';
import { useLogout } from './useLogout'; import { useLogout } from './useLogout';
import { baseNavItems } from './constants'; import { DashboardNavLinks } from './DashboardNavLinks';
import { getInitials, translateTier } from './utils'; import { getInitials, translateTier } from './utils';
import { ThemeToggle } from '@/components/ui/theme-toggle'; import { ThemeToggle } from '@/components/ui/theme-toggle';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
export function DashboardSidebar() { export function DashboardSidebar() {
const pathname = usePathname();
const { data: user, isLoading } = useUser(); const { data: user, isLoading } = useUser();
const { logout } = useLogout(); const { logout } = useLogout();
const { t } = useI18n(); const { t } = useI18n();
const isPro = ['pro', 'business', 'enterprise'].includes(user?.tier ?? '');
const navItems = isPro ? baseNavItems : baseNavItems.filter(item => !item.proOnly);
return ( return (
<aside className="hidden w-72 shrink-0 border-r border-black/5 dark:border-white/5 bg-white dark:bg-[#141414] lg:flex lg:flex-col"> <aside className="hidden w-72 shrink-0 border-r border-black/5 dark:border-white/5 bg-white dark:bg-[#141414] lg:flex lg:flex-col">
@@ -35,24 +30,7 @@ export function DashboardSidebar() {
{/* Navigation */} {/* Navigation */}
<nav className="flex-1 overflow-y-auto px-3 py-2"> <nav className="flex-1 overflow-y-auto px-3 py-2">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{navItems.map((item) => { <DashboardNavLinks />
const isActive = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-4 rounded-2xl px-6 py-4 text-[11px] font-black uppercase tracking-[0.2em] transition-all duration-200',
isActive
? 'bg-brand-dark text-white shadow-xl'
: 'text-brand-dark/65 dark:text-white/65 hover:bg-brand-muted dark:hover:bg-[#1f1f1f]'
)}
>
<item.icon size={18} className="shrink-0" />
{t(item.labelKey)}
</Link>
);
})}
</div> </div>
</nav> </nav>

View File

@@ -43,6 +43,17 @@ interface RebuildResponse {
data: { job_id: string; rebuilt: boolean; segments_applied: number; download_url: string }; data: { job_id: string; rebuilt: boolean; segments_applied: number; download_url: string };
} }
/** Map raw backend errors to something the reviewer can act on. */
function friendlyReviewError(raw: string | undefined, t: (k: string) => string, fallbackKey: string): string | undefined {
if (!raw) return undefined;
const r = raw.toLowerCase();
if (r.includes('401') || r.includes('unauthorized') || r.includes('403')) return t('reviews.error.sessionExpired');
if (r.includes('404') || r.includes('not found')) return t('reviews.error.notFound');
if (r.includes('429') || r.includes('rate')) return t('reviews.error.rateLimited');
if (r.includes('500') || r.includes('server')) return t('reviews.error.server');
return raw || t(fallbackKey);
}
function authHeaders(): Record<string, string> { function authHeaders(): Record<string, string> {
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null; const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
@@ -130,7 +141,7 @@ export default function ReviewPage() {
} catch (err) { } catch (err) {
notify.error({ notify.error({
title: t('reviews.error.title'), title: t('reviews.error.title'),
description: err instanceof Error ? err.message : t('reviews.error.update'), description: friendlyReviewError(err instanceof Error ? err.message : undefined, t, 'reviews.error.update'),
}); });
} finally { } finally {
setSavingId(null); setSavingId(null);
@@ -174,7 +185,7 @@ export default function ReviewPage() {
} catch (err) { } catch (err) {
notify.error({ notify.error({
title: t('reviews.rebuildFailed'), title: t('reviews.rebuildFailed'),
description: err instanceof Error ? err.message : undefined, description: friendlyReviewError(err instanceof Error ? err.message : undefined, t, 'reviews.error.update'),
}); });
} finally { } finally {
setIsRebuilding(false); setIsRebuilding(false);
@@ -191,7 +202,7 @@ export default function ReviewPage() {
} catch (err) { } catch (err) {
notify.error({ notify.error({
title: t('reviews.xliffExportFailed'), title: t('reviews.xliffExportFailed'),
description: err instanceof Error ? err.message : undefined, description: friendlyReviewError(err instanceof Error ? err.message : undefined, t, 'reviews.error.update'),
}); });
} }
}; };
@@ -223,7 +234,7 @@ export default function ReviewPage() {
} catch (err) { } catch (err) {
notify.error({ notify.error({
title: t('reviews.xliffImportFailed'), title: t('reviews.xliffImportFailed'),
description: err instanceof Error ? err.message : undefined, description: friendlyReviewError(err instanceof Error ? err.message : undefined, t, 'reviews.error.update'),
}); });
} finally { } finally {
setIsImporting(false); setIsImporting(false);
@@ -256,6 +267,9 @@ export default function ReviewPage() {
edited: counts.edited, edited: counts.edited,
})} })}
</p> </p>
<p className="max-w-xl text-xs font-light text-brand-dark/50 dark:text-white/50">
{t('reviews.xliffHint')}
</p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button variant={approveArmed ? 'default' : 'outline'} size="sm" onClick={approveAll} disabled={counts.pending === 0}> <Button variant={approveArmed ? 'default' : 'outline'} size="sm" onClick={approveAll} disabled={counts.pending === 0}>

View File

@@ -12,7 +12,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { useFileUpload } from './useFileUpload'; import { useFileUpload } from './useFileUpload';
import { useTranslationConfig } from './useTranslationConfig'; import { useTranslationConfig } from './useTranslationConfig';
import { useTranslationSubmit, getRecentJobs, type RecentJob } from './useTranslationSubmit'; import { useTranslationSubmit, getRecentJobs, fetchServerHistory, type RecentJob } from './useTranslationSubmit';
import LanguageSelector from './LanguageSelector'; import LanguageSelector from './LanguageSelector';
import { ProviderSelector } from './ProviderSelector'; import { ProviderSelector } from './ProviderSelector';
import { GlossarySelector } from './GlossarySelector'; import { GlossarySelector } from './GlossarySelector';
@@ -122,9 +122,14 @@ export default function TranslatePage() {
return () => { if (timerRef.current) clearInterval(timerRef.current); }; return () => { if (timerRef.current) clearInterval(timerRef.current); };
}, [submit.status, submit.isSubmitting]); }, [submit.status, submit.isSubmitting]);
// Recent jobs (client-side history) — loaded on mount, refreshed when a job completes // History: server list first (survives any device), localStorage as fallback
useEffect(() => { useEffect(() => {
setRecentJobs(getRecentJobs()); let cancelled = false;
fetchServerHistory().then((server) => {
if (cancelled) return;
setRecentJobs(server.length > 0 ? server : getRecentJobs());
});
return () => { cancelled = true; };
}, [submit.status]); }, [submit.status]);
const handleTranslate = async () => { const handleTranslate = async () => {
@@ -141,6 +146,17 @@ export default function TranslatePage() {
await handleTranslate(); await handleTranslate();
}; };
const handleCancel = async () => {
const ok = await submit.cancelJob();
if (ok) {
submit.reset();
setElapsed(0);
showError({ title: t('translate.cancelledTitle'), description: t('translate.cancelledDesc') });
} else {
showError({ title: t('translate.cancelFailedTitle'), description: t('translate.cancelFailedDesc') });
}
};
const handleNewTranslation = () => { submit.reset(); upload.removeFile(); setElapsed(0); setRecentJobs(getRecentJobs()); }; const handleNewTranslation = () => { submit.reset(); upload.removeFile(); setElapsed(0); setRecentJobs(getRecentJobs()); };
const handleDownload = async (jobId: string = submit.jobId ?? '') => { const handleDownload = async (jobId: string = submit.jobId ?? '') => {
if (!jobId) return; if (!jobId) return;
@@ -220,7 +236,7 @@ export default function TranslatePage() {
{showProcessing ? ( {showProcessing ? (
<> <>
<span className="accent-pill mb-4 block w-fit italic">{t('translate.header.processing')}</span> <span className="accent-pill mb-4 block w-fit italic">{t('translate.header.processing')}</span>
<h1 className="text-4xl md:text-5xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight"> <h1 className="text-3xl md:text-4xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">
<SplitTitle base={t('translate.header.aiActiveTitle')} accent={t('translate.header.aiActiveAccent')} /> <SplitTitle base={t('translate.header.aiActiveTitle')} accent={t('translate.header.aiActiveAccent')} />
</h1> </h1>
<p className="text-brand-dark/50 dark:text-white/50 text-sm font-light leading-relaxed"> <p className="text-brand-dark/50 dark:text-white/50 text-sm font-light leading-relaxed">
@@ -230,7 +246,7 @@ export default function TranslatePage() {
) : showComplete ? ( ) : showComplete ? (
<> <>
<span className="accent-pill mb-4 block w-fit italic">{t('translate.header.completed')}</span> <span className="accent-pill mb-4 block w-fit italic">{t('translate.header.completed')}</span>
<h1 className="text-4xl md:text-5xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight"> <h1 className="text-3xl md:text-4xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">
<SplitTitle base={t('translate.header.completedTitleBase')} accent={t('translate.header.completedTitleAccent')} /> <SplitTitle base={t('translate.header.completedTitleBase')} accent={t('translate.header.completedTitleAccent')} />
</h1> </h1>
<p className="text-brand-dark/50 dark:text-white/50 text-sm font-light leading-relaxed truncate max-w-xl"> <p className="text-brand-dark/50 dark:text-white/50 text-sm font-light leading-relaxed truncate max-w-xl">
@@ -240,7 +256,7 @@ export default function TranslatePage() {
) : ( ) : (
<> <>
<span className="accent-pill mb-4 block w-fit">{t('translate.header.workspace')}</span> <span className="accent-pill mb-4 block w-fit">{t('translate.header.workspace')}</span>
<h1 className="text-4xl md:text-5xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight"> <h1 className="text-3xl md:text-4xl mb-3 leading-tight text-brand-dark dark:text-white font-serif font-medium tracking-tight">
<SplitTitle base={t('translate.header.translateDocBase')} accent={t('translate.header.translateDocAccent')} /> <SplitTitle base={t('translate.header.translateDocBase')} accent={t('translate.header.translateDocAccent')} />
</h1> </h1>
<p className="text-brand-dark/50 dark:text-white/50 text-sm font-light leading-relaxed"> <p className="text-brand-dark/50 dark:text-white/50 text-sm font-light leading-relaxed">
@@ -596,7 +612,8 @@ export default function TranslatePage() {
</div> </div>
)} )}
{/* Glossary selector */} {/* Glossary selector — Pro only; hidden entirely for free users */}
{config.isPro && (
<GlossarySelector <GlossarySelector
sourceLang={config.sourceLang} sourceLang={config.sourceLang}
targetLang={config.targetLang} targetLang={config.targetLang}
@@ -606,8 +623,10 @@ export default function TranslatePage() {
onChange={config.setGlossaryId} onChange={config.setGlossaryId}
disabled={submit.isSubmitting} disabled={submit.isSubmitting}
/> />
)}
{/* Translate Images */} {/* Translate Images — LLM mode only; hidden for free users */}
{config.isPro && (
<div className="bg-brand-muted/30 dark:bg-white/[0.02] border border-black/[0.03] dark:border-white/[0.03] p-4 rounded-xl space-y-3"> <div className="bg-brand-muted/30 dark:bg-white/[0.02] border border-black/[0.03] dark:border-white/[0.03] p-4 rounded-xl space-y-3">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -639,6 +658,8 @@ export default function TranslatePage() {
)} )}
</div> </div>
)}
{/* PDF mode selector */} {/* PDF mode selector */}
{isPdf && ( {isPdf && (
<div className="space-y-2 text-left"> <div className="space-y-2 text-left">
@@ -741,11 +762,17 @@ export default function TranslatePage() {
</div> </div>
<button <button
onClick={handleNewTranslation} onClick={handleCancel}
title={t('translate.leaveScreenHint')} className="w-full mt-8 py-3.5 border border-red-200 dark:border-red-900/40 text-red-500 rounded-2xl text-[10px] font-bold uppercase tracking-[0.2em] flex items-center justify-center gap-2 hover:bg-red-50 dark:hover:bg-red-950/30 transition-all cursor-pointer"
className="w-full mt-8 py-3.5 border border-black/10 dark:border-white/10 text-brand-dark/50 dark:text-white/50 rounded-2xl text-[10px] font-bold uppercase tracking-[0.2em] flex items-center justify-center gap-2 hover:bg-brand-muted/40 dark:hover:bg-white/5 hover:text-brand-dark dark:hover:text-white transition-all cursor-pointer"
> >
<X size={13} /> <X size={13} />
{t('translate.cancelAction')}
</button>
<button
onClick={handleNewTranslation}
title={t('translate.leaveScreenHint')}
className="w-full mt-2 py-2.5 text-[10px] font-bold uppercase tracking-[0.2em] text-brand-dark/50 dark:text-white/50 hover:text-brand-dark dark:hover:text-white transition-colors cursor-pointer"
>
{t('translate.leaveScreen')} {t('translate.leaveScreen')}
</button> </button>
</div> </div>

View File

@@ -101,6 +101,8 @@ export interface TranslationStatusResponse {
export interface UseTranslationSubmitReturn { export interface UseTranslationSubmitReturn {
submitTranslation: (file: File, config: TranslationConfig) => Promise<void>; submitTranslation: (file: File, config: TranslationConfig) => Promise<void>;
/** Ask the backend to cancel the running job. */
cancelJob: () => Promise<boolean>;
jobId: string | null; jobId: string | null;
status: TranslationStatus; status: TranslationStatus;
progress: number; progress: number;

View File

@@ -22,6 +22,30 @@ interface StoredJob { jobId: string; fileName: string | null; savedAt: number }
export interface RecentJob { jobId: string; fileName: string; completedAt: number } export interface RecentJob { jobId: string; fileName: string; completedAt: number }
/** Server history (last jobs, newest first) — empty when offline/unauthenticated. */
export async function fetchServerHistory(perPage = 6): Promise<RecentJob[]> {
if (typeof window === 'undefined') return [];
const token = localStorage.getItem('token');
if (!token) return [];
try {
const res = await fetch(`${API_BASE}/api/v1/translations?per_page=${perPage}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) return [];
const json = await res.json();
return (json.data ?? []).map((j: {
id: string; file_name?: string | null;
completed_at?: string | null; created_at?: string | null;
}) => ({
jobId: j.id,
fileName: j.file_name ?? '',
completedAt: Date.parse(j.completed_at ?? j.created_at ?? '') || Date.now(),
}));
} catch {
return [];
}
}
export function getRecentJobs(): RecentJob[] { export function getRecentJobs(): RecentJob[] {
if (typeof window === 'undefined') return []; if (typeof window === 'undefined') return [];
try { try {
@@ -235,6 +259,28 @@ export function useTranslationSubmit(): UseTranslationSubmitReturn {
// NOTE: Don't set isSubmitting(false) here - let polling handle the transition // NOTE: Don't set isSubmitting(false) here - let polling handle the transition
}, [startPolling]); }, [startPolling]);
/** Ask the backend to cancel the current job. Returns true on success. */
const cancelJob = useCallback(async (): Promise<boolean> => {
const id = jobId;
if (!id) return false;
try {
const token = localStorage.getItem('token');
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}/api/v1/translations/${id}/cancel`, {
method: 'POST',
headers,
});
if (!res.ok) return false;
stopPolling();
persistActiveJob(null);
setIsSubmitting(false);
return true;
} catch {
return false;
}
}, [jobId, stopPolling]);
const reset = useCallback(() => { const reset = useCallback(() => {
stopPolling(); stopPolling();
persistActiveJob(null); persistActiveJob(null);
@@ -285,6 +331,7 @@ export function useTranslationSubmit(): UseTranslationSubmitReturn {
return { return {
submitTranslation, submitTranslation,
cancelJob,
jobId, jobId,
status, status,
progress, progress,

View File

@@ -215,14 +215,6 @@ const PLAN_ICONS: Record<string, any> = {
enterprise: Shield, enterprise: Shield,
}; };
const PLAN_COLORS: Record<string, { header: string; iconColor: string; nameColor: string }> = {
free: { header: "bg-muted", iconColor: "text-foreground/40", nameColor: "text-foreground/40" },
starter: { header: "bg-foreground", iconColor: "text-white/20", nameColor: "text-white/50" },
pro: { header: "bg-accent", iconColor: "text-white/30", nameColor: "text-white/50" },
business: { header: "bg-foreground", iconColor: "text-accent/40", nameColor: "text-white/50" },
enterprise: { header: "bg-[#252525]", iconColor: "text-white/10", nameColor: "text-white/50" },
};
/** Avoids flash of static prices before the API responds on refresh. */ /** Avoids flash of static prices before the API responds on refresh. */
function PricingDataSkeleton() { function PricingDataSkeleton() {
return ( return (
@@ -460,7 +452,7 @@ export default function PricingPage() {
<div className="flex justify-between items-center mb-20"> <div className="flex justify-between items-center mb-20">
<button <button
onClick={() => router.back()} onClick={() => router.back()}
className="flex items-center gap-3 text-[10px] font-black uppercase tracking-[0.4em] text-foreground/30 hover:text-foreground transition-all group" className="flex items-center gap-3 text-[11px] font-bold uppercase tracking-[0.4em] text-foreground/30 hover:text-foreground transition-all group"
> >
<ChevronLeft size={16} className="group-hover:-translate-x-1 transition-transform" /> <ChevronLeft size={16} className="group-hover:-translate-x-1 transition-transform" />
{t('pricing.nav.back')} {t('pricing.nav.back')}
@@ -476,7 +468,7 @@ export default function PricingPage() {
{isLoggedIn && ( {isLoggedIn && (
<Link <Link
href="/dashboard/profile" href="/dashboard/profile"
className="px-6 py-2 bg-white rounded-full text-[9px] font-black uppercase tracking-widest text-foreground shadow-sm border border-black/5" className="px-6 py-2 bg-white rounded-full text-[9px] font-bold uppercase tracking-wider text-foreground shadow-sm border border-black/5"
> >
{t('pricing.nav.mySubscription')} {t('pricing.nav.mySubscription')}
</Link> </Link>
@@ -562,13 +554,13 @@ export default function PricingPage() {
<div className="flex p-1 bg-muted rounded-full border border-black/5 shadow-inner px-2"> <div className="flex p-1 bg-muted rounded-full border border-black/5 shadow-inner px-2">
<button <button
onClick={() => setIsYearly(false)} onClick={() => setIsYearly(false)}
className={`px-8 py-3 rounded-full text-[10px] font-black uppercase tracking-widest transition-all ${!isYearly ? 'bg-foreground text-white shadow-xl' : 'text-foreground/60 hover:text-foreground'}`} className={`px-8 py-3 rounded-full text-[11px] font-bold uppercase tracking-wider transition-all ${!isYearly ? 'bg-brand-dark text-white shadow-xl dark:bg-brand-accent dark:text-brand-dark' : 'text-foreground/60 hover:text-foreground'}`}
> >
{t('pricing.billing.monthly')} {t('pricing.billing.monthly')}
</button> </button>
<button <button
onClick={() => setIsYearly(true)} onClick={() => setIsYearly(true)}
className={`px-8 py-3 rounded-full text-[10px] font-black uppercase tracking-widest transition-all ${isYearly ? 'bg-foreground text-white shadow-xl' : 'text-foreground/60 hover:text-foreground'}`} className={`px-8 py-3 rounded-full text-[11px] font-bold uppercase tracking-wider transition-all ${isYearly ? 'bg-brand-dark text-white shadow-xl dark:bg-brand-accent dark:text-brand-dark' : 'text-foreground/60 hover:text-foreground'}`}
> >
{t('pricing.billing.yearly')} {t('pricing.billing.yearly')}
<span className={`ml-2 transition-colors ${isYearly ? 'text-accent' : 'text-accent/60'}`}>&minus;{annualDiscountPercent} %</span> <span className={`ml-2 transition-colors ${isYearly ? 'text-accent' : 'text-accent/60'}`}>&minus;{annualDiscountPercent} %</span>
@@ -585,7 +577,6 @@ export default function PricingPage() {
<div className="grid md:grid-cols-3 lg:grid-cols-5 gap-6 items-stretch"> <div className="grid md:grid-cols-3 lg:grid-cols-5 gap-6 items-stretch">
{plans.map((plan) => { {plans.map((plan) => {
const Icon = PLAN_ICONS[plan.id] ?? Sparkles; const Icon = PLAN_ICONS[plan.id] ?? Sparkles;
const colors = PLAN_COLORS[plan.id] ?? PLAN_COLORS.starter;
const price = displayPrice(plan); const price = displayPrice(plan);
const isCurrent = currentPlan === plan.id; const isCurrent = currentPlan === plan.id;
const isEnterprise = plan.id === "enterprise"; const isEnterprise = plan.id === "enterprise";
@@ -599,43 +590,34 @@ export default function PricingPage() {
plan.popular && "border-accent/30 ring-4 ring-accent/5" plan.popular && "border-accent/30 ring-4 ring-accent/5"
)} )}
> >
{/* ── Header section ── */} {/* ── Header (editorial) ── */}
<div className={cn("p-8 text-white relative h-48 flex flex-col justify-end", colors.header)}> <div className="relative border-b border-black/[0.04] p-8 pb-6 dark:border-white/[0.06]">
{/* Badges for popular/current plan */} {(plan.popular || isCurrent) && (
{plan.popular && ( <div className="absolute top-5 right-5 flex gap-2">
<div className="absolute top-0 right-0 p-3 flex gap-2">
{plan.badge && ( {plan.badge && (
<span className="bg-foreground/20 backdrop-blur-md text-white text-[10px] font-black uppercase tracking-wider px-3 py-1 rounded-full border border-white/20 shadow-lg"> <span className="rounded-full border border-brand-accent/30 bg-brand-accent/10 px-3 py-1 text-[10px] font-bold uppercase tracking-wider text-brand-accent">
{t(plan.badge)} {t(plan.badge)}
</span> </span>
)} )}
{isCurrent && ( {isCurrent && (
<span className="bg-foreground/40 backdrop-blur-md text-white text-[10px] font-black uppercase tracking-wider px-3 py-1 rounded-full border border-white/20 shadow-lg flex items-center gap-1"> <span className="flex items-center gap-1.5 rounded-full border border-black/10 bg-brand-muted px-3 py-1 text-[10px] font-bold uppercase tracking-wider text-brand-dark/70 dark:border-white/10 dark:bg-white/10 dark:text-white/70">
<div className="w-1.5 h-1.5 bg-accent rounded-full animate-pulse" /> {t('pricing.card.myPlan')} <span className="h-1.5 w-1.5 rounded-full bg-brand-accent animate-pulse" /> {t('pricing.card.myPlan')}
</span> </span>
)} )}
</div> </div>
)} )}
{plan.badge && !plan.popular && ( {!plan.popular && !isCurrent && plan.badge && (
<div className="absolute top-0 right-0 p-3"> <span className="absolute top-5 right-5 rounded-full border border-black/[0.06] bg-brand-muted px-3 py-1 text-[10px] font-bold uppercase tracking-wider text-brand-dark/60 dark:border-white/[0.08] dark:bg-white/5 dark:text-white/60">
<span className="bg-white/10 backdrop-blur-md text-white text-[10px] font-black uppercase tracking-wider px-3 py-1 rounded-full border border-white/10">
{t(plan.badge)} {t(plan.badge)}
</span> </span>
</div>
)}
{/* "My Plan" badge for current non-popular plan */}
{isCurrent && !plan.popular && (
<div className="absolute top-0 right-0 p-3">
<span className="bg-foreground/40 backdrop-blur-md text-white text-[10px] font-black uppercase tracking-wider px-3 py-1 rounded-full border border-white/20 shadow-lg flex items-center gap-1">
<div className="w-1.5 h-1.5 bg-accent rounded-full animate-pulse" /> {t('pricing.card.myPlan')}
</span>
</div>
)} )}
{/* Icon + plan name */} {/* Icon + plan name */}
<div className="flex items-center gap-3 mb-4"> <div className="mb-4 flex items-center gap-3">
<Icon size={20} className={colors.iconColor} /> <div className="flex size-10 items-center justify-center rounded-xl bg-brand-muted text-brand-accent dark:bg-white/10">
<span className={cn("text-sm font-black uppercase tracking-widest", isFree ? "text-foreground/40" : colors.nameColor)}> <Icon size={18} />
</div>
<span className="text-xs font-bold uppercase tracking-[0.15em] text-brand-dark/70 dark:text-white/70">
{t(plan.name)} {t(plan.name)}
</span> </span>
</div> </div>
@@ -643,30 +625,30 @@ export default function PricingPage() {
{/* Price */} {/* Price */}
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
{isEnterprise ? ( {isEnterprise ? (
<h3 className={cn("text-3xl font-black uppercase tracking-tighter", isFree ? "text-foreground" : "text-white")}> <h3 className="text-3xl font-serif font-medium tracking-tight text-brand-dark dark:text-white">
{t('pricing.card.onRequest')} {t('pricing.card.onRequest')}
</h3> </h3>
) : price === 0 ? ( ) : price === 0 ? (
<h3 className="text-3xl font-black uppercase tracking-tighter text-foreground"> <h3 className="text-3xl font-serif font-medium tracking-tight text-brand-dark dark:text-white">
{t('pricing.card.free')} {t('pricing.card.free')}
</h3> </h3>
) : ( ) : (
<> <>
<h3 className="text-3xl font-black uppercase tracking-tighter text-white">{price} &euro;</h3> <h3 className="text-4xl font-serif font-medium tracking-tight text-brand-dark dark:text-white">{price} &euro;</h3>
<span className="text-[10px] font-bold uppercase tracking-widest text-white/70">{t('pricing.card.perMonth')}</span> <span className="text-[11px] font-semibold uppercase tracking-wider text-brand-dark/55 dark:text-white/55">{t('pricing.card.perMonth')}</span>
</> </>
)} )}
</div> </div>
{/* Yearly billing note */} {/* Yearly billing note */}
{isYearly && plan.price_yearly > 0 && ( {isYearly && plan.price_yearly > 0 && (
<div className="text-white/70 text-[10px] mt-1"> <div className="mt-1 text-[11px] text-brand-dark/55 dark:text-white/55">
{t('pricing.card.billedYearly', { price: plan.price_yearly.toFixed(2) })} {t('pricing.card.billedYearly', { price: plan.price_yearly.toFixed(2) })}
</div> </div>
)} )}
{/* Description */} {/* Description */}
<p className={cn("text-[10px] font-medium uppercase mt-3 tracking-widest leading-relaxed", isFree ? "text-foreground/40" : "text-white/60")}> <p className="mt-3 text-xs font-light leading-relaxed text-brand-dark/60 dark:text-white/60">
{t(plan.description || '')} {t(plan.description || '')}
</p> </p>
</div> </div>
@@ -678,18 +660,18 @@ export default function PricingPage() {
<div className="flex justify-between items-center py-2 border-b border-black/[0.03] dark:border-border/20"> <div className="flex justify-between items-center py-2 border-b border-black/[0.03] dark:border-border/20">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FileText size={12} className="text-foreground/40" /> <FileText size={12} className="text-foreground/40" />
<span className="text-[11px] font-bold uppercase tracking-wider text-foreground/60">{t('pricing.card.documents')}</span> <span className="text-[11px] font-semibold text-foreground/70">{t('pricing.card.documents')}</span>
</div> </div>
<span className="text-[10px] font-black uppercase text-foreground"> <span className="text-[11px] font-bold text-foreground">
{plan.docs_per_month === -1 ? t('pricing.card.unlimited') : `${plan.docs_per_month} ${t('pricing.card.perMonthStat')}`} {plan.docs_per_month === -1 ? t('pricing.card.unlimited') : `${plan.docs_per_month} ${t('pricing.card.perMonthStat')}`}
</span> </span>
</div> </div>
<div className="flex justify-between items-center py-2 border-b border-black/[0.03] dark:border-border/20"> <div className="flex justify-between items-center py-2 border-b border-black/[0.03] dark:border-border/20">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Layers size={12} className="text-foreground/40" /> <Layers size={12} className="text-foreground/40" />
<span className="text-[11px] font-bold uppercase tracking-wider text-foreground/60">{t('pricing.card.pagesMax')}</span> <span className="text-[11px] font-semibold text-foreground/70">{t('pricing.card.pagesMax')}</span>
</div> </div>
<span className="text-[10px] font-black uppercase text-foreground"> <span className="text-[11px] font-bold text-foreground">
{plan.max_pages_per_doc === -1 ? t('pricing.card.unlimited') : `${plan.max_pages_per_doc} ${t('pricing.card.perDoc')}`} {plan.max_pages_per_doc === -1 ? t('pricing.card.unlimited') : `${plan.max_pages_per_doc} ${t('pricing.card.perDoc')}`}
</span> </span>
</div> </div>
@@ -706,13 +688,13 @@ export default function PricingPage() {
"text-foreground/40" "text-foreground/40"
} /> } />
<span className={cn( <span className={cn(
"text-[9px] font-black uppercase tracking-widest", "text-[10px] font-bold uppercase tracking-wider",
plan.ai_tier === "essential" ? "text-accent/60" : plan.ai_tier === "essential" ? "text-accent/60" :
"text-foreground/40" "text-foreground/40"
)}>{t('pricing.card.aiTranslation')}</span> )}>{t('pricing.card.aiTranslation')}</span>
</div> </div>
<span className={cn( <span className={cn(
"text-[9px] font-black uppercase", "text-[10px] font-bold uppercase",
plan.ai_tier === "essential" ? "text-accent" : plan.ai_tier === "essential" ? "text-accent" :
plan.ai_tier === "premium" ? "text-foreground" : plan.ai_tier === "premium" ? "text-foreground" :
"text-foreground" "text-foreground"
@@ -732,7 +714,7 @@ export default function PricingPage() {
<div className="w-4 h-4 rounded-full bg-accent/10 flex items-center justify-center shrink-0 mt-0.5"> <div className="w-4 h-4 rounded-full bg-accent/10 flex items-center justify-center shrink-0 mt-0.5">
<CheckCircle2 size={10} className="text-accent" /> <CheckCircle2 size={10} className="text-accent" />
</div> </div>
<span className="text-[10px] font-bold text-foreground/60 leading-normal">{t(feat)}</span> <span className="text-[11px] font-medium text-foreground/75 leading-normal">{t(feat)}</span>
</li> </li>
))} ))}
</ul> </ul>
@@ -741,7 +723,7 @@ export default function PricingPage() {
{isCurrent ? ( {isCurrent ? (
<Link <Link
href="/dashboard/profile" href="/dashboard/profile"
className="w-full py-4 rounded-2xl text-[11px] font-black uppercase tracking-widest transition-all flex items-center justify-center gap-3 border shadow-sm hover:shadow-xl active:scale-95 bg-muted text-foreground border-black/5 hover:bg-foreground hover:text-white" className="w-full py-4 rounded-2xl text-xs font-bold uppercase tracking-wider transition-all flex items-center justify-center gap-3 border shadow-sm hover:shadow-xl active:scale-95 bg-muted text-foreground border-black/5 hover:bg-foreground hover:text-white"
> >
{t('pricing.card.managePlan')} {t('pricing.card.managePlan')}
<ArrowRight size={14} className="opacity-40" /> <ArrowRight size={14} className="opacity-40" />
@@ -749,7 +731,7 @@ export default function PricingPage() {
) : isFree && !currentPlan ? ( ) : isFree && !currentPlan ? (
<Link <Link
href="/auth/register" href="/auth/register"
className="w-full py-4 rounded-2xl text-[11px] font-black uppercase tracking-widest transition-all flex items-center justify-center gap-3 border shadow-sm hover:shadow-xl active:scale-95 bg-foreground text-white border-transparent hover:bg-accent" className="w-full py-4 rounded-2xl text-xs font-bold uppercase tracking-wider transition-all flex items-center justify-center gap-3 border shadow-sm hover:shadow-xl active:scale-95 bg-foreground text-white border-transparent hover:bg-accent"
> >
{t('pricing.card.startFree')} {t('pricing.card.startFree')}
<ArrowRight size={14} className="opacity-40" /> <ArrowRight size={14} className="opacity-40" />
@@ -759,7 +741,7 @@ export default function PricingPage() {
onClick={() => setConfirmPlan(plan)} onClick={() => setConfirmPlan(plan)}
disabled={loadingPlanId !== null} disabled={loadingPlanId !== null}
className={cn( className={cn(
"w-full py-4 rounded-2xl text-[11px] font-black uppercase tracking-widest transition-all flex items-center justify-center gap-3 border shadow-sm hover:shadow-xl active:scale-95", "w-full py-4 rounded-2xl text-xs font-bold uppercase tracking-wider transition-all flex items-center justify-center gap-3 border shadow-sm hover:shadow-xl active:scale-95",
plan.popular plan.popular
? "bg-muted text-foreground border-black/5 hover:bg-foreground hover:text-white" ? "bg-muted text-foreground border-black/5 hover:bg-foreground hover:text-white"
: "bg-foreground text-white border-transparent hover:bg-accent", : "bg-foreground text-white border-transparent hover:bg-accent",

View File

@@ -143,29 +143,6 @@ const Hero = () => {
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
/> />
<div className="absolute inset-0 bg-brand-dark/5 dark:bg-black/20" /> <div className="absolute inset-0 bg-brand-dark/5 dark:bg-black/20" />
<div className="absolute top-8 right-8 w-64 bg-white/90 dark:bg-[#1a1a1a]/90 backdrop-blur-xl border border-black/5 dark:border-white/10 p-6 rounded-2xl shadow-2xl text-left hidden md:block">
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-full bg-brand-accent/20 flex items-center justify-center text-brand-accent">
<Zap size={16} />
</div>
<span className="text-[10px] font-bold uppercase tracking-widest text-brand-dark dark:text-white">Context Engine</span>
</div>
<p className="text-xs text-brand-dark/70 dark:text-white/70 leading-relaxed font-medium">{t('landing.hero.contextEngine')}</p>
</div>
<div className="absolute bottom-8 left-8 w-64 bg-brand-dark dark:bg-[#0a0a0a] text-white p-6 rounded-2xl shadow-2xl text-left hidden md:block">
<div className="flex items-center gap-3 mb-4">
<Activity size={16} className="text-brand-accent" />
<span className="text-[10px] font-bold uppercase tracking-widest text-brand-accent">{t('landing.hero.liveAnalysis')}</span>
</div>
<div className="flex items-center gap-3">
{[1, 2, 3].map(i => (
<div key={i} className="w-6 h-6 rounded-full border-2 border-brand-dark dark:border-[#0a0a0a] bg-brand-muted dark:bg-[#1f1f1f] text-[8px] flex items-center justify-center font-bold text-brand-dark dark:text-white">JD</div>
))}
<span className="text-[10px] ml-2 text-white/60">+12 {t('landing.hero.termsDetected')}</span>
</div>
</div>
</div> </div>
</div> </div>
</motion.div> </motion.div>

View File

@@ -34,5 +34,10 @@
"reviews.titleAccent": "review", "reviews.titleAccent": "review",
"reviews.approveAllConfirm": "Confirm ({count})", "reviews.approveAllConfirm": "Confirm ({count})",
"reviews.action.approveShort": "OK", "reviews.action.approveShort": "OK",
"reviews.action.resetShort": "Re-review" "reviews.action.resetShort": "Re-review",
"reviews.xliffHint": "XLIFF is the standard exchange format for translation reviews — export the segments, correct them in your own tool or send them to a reviewer, then import them back.",
"reviews.error.sessionExpired": "Your session has expired. Log in again and reload this page.",
"reviews.error.notFound": "This job no longer exists. It may have been cleaned up.",
"reviews.error.rateLimited": "Too many requests — wait a few seconds and retry.",
"reviews.error.server": "The server could not process this. Try again in a moment."
} }

View File

@@ -62,8 +62,8 @@
"translate.chooseTargetLang": "Please choose a target language", "translate.chooseTargetLang": "Please choose a target language",
"translate.pleaseLoadFile": "Please upload a file first", "translate.pleaseLoadFile": "Please upload a file first",
"translate.contextEngineActive": "Contextual engine active", "translate.contextEngineActive": "Contextual engine active",
"translate.phase1": "Phase 1: Initialisation", "translate.phase1": "Step 1: Analysing the document",
"translate.phase2": "Phase 2: Contextual reconstruction", "translate.phase2": "Step 2: Translating & rebuilding layout",
"translate.stat.time": "time", "translate.stat.time": "time",
"translate.download": "Download", "translate.download": "Download",
"translate.newTranslation": "+ New translation", "translate.newTranslation": "+ New translation",
@@ -96,5 +96,10 @@
"translate.downloadFailed": "The download failed. Please try again.", "translate.downloadFailed": "The download failed. Please try again.",
"translate.recent.title": "Recent translations", "translate.recent.title": "Recent translations",
"translate.recent.review": "Review", "translate.recent.review": "Review",
"translate.upload.ariaDropzone": "Upload a document: drag and drop, or press Enter to browse files" "translate.upload.ariaDropzone": "Upload a document: drag and drop, or press Enter to browse files",
"translate.cancelAction": "Cancel translation",
"translate.cancelledTitle": "Translation cancelled",
"translate.cancelledDesc": "The job was stopped and the reserved document slot released.",
"translate.cancelFailedTitle": "Could not cancel",
"translate.cancelFailedDesc": "The job may have already finished. The display will update shortly."
} }

View File

@@ -34,5 +34,10 @@
"reviews.titleAccent": "traduction", "reviews.titleAccent": "traduction",
"reviews.approveAllConfirm": "Confirmer ({count})", "reviews.approveAllConfirm": "Confirmer ({count})",
"reviews.action.approveShort": "OK", "reviews.action.approveShort": "OK",
"reviews.action.resetShort": "À relire" "reviews.action.resetShort": "À relire",
"reviews.xliffHint": "XLIFF est le format d'échange standard des relectures — exportez les segments, corrigez-les dans votre outil ou envoyez-les à un réviseur, puis réimportez-les.",
"reviews.error.sessionExpired": "Votre session a expiré. Reconnectez-vous puis rechargez cette page.",
"reviews.error.notFound": "Ce job n'existe plus. Il a peut-être été nettoyé.",
"reviews.error.rateLimited": "Trop de requêtes — attendez quelques secondes et réessayez.",
"reviews.error.server": "Le serveur n'a pas pu traiter la demande. Réessayez dans un instant."
} }

View File

@@ -62,8 +62,8 @@
"translate.chooseTargetLang": "Veuillez choisir une langue cible", "translate.chooseTargetLang": "Veuillez choisir une langue cible",
"translate.pleaseLoadFile": "Veuillez charger un fichier d'abord", "translate.pleaseLoadFile": "Veuillez charger un fichier d'abord",
"translate.contextEngineActive": "Moteur contextuel actif", "translate.contextEngineActive": "Moteur contextuel actif",
"translate.phase1": "Phase 1 : Initialisation", "translate.phase1": "Étape 1 : Analyse du document",
"translate.phase2": "Phase 2 : Reconstruction contextuelle", "translate.phase2": "Étape 2 : Traduction et reconstruction de la mise en page",
"translate.stat.time": "temps", "translate.stat.time": "temps",
"translate.download": "Télécharger", "translate.download": "Télécharger",
"translate.newTranslation": "+ Nouvelle traduction", "translate.newTranslation": "+ Nouvelle traduction",
@@ -96,5 +96,10 @@
"translate.downloadFailed": "Le téléchargement a échoué. Réessayez.", "translate.downloadFailed": "Le téléchargement a échoué. Réessayez.",
"translate.recent.title": "Traductions récentes", "translate.recent.title": "Traductions récentes",
"translate.recent.review": "Relire", "translate.recent.review": "Relire",
"translate.upload.ariaDropzone": "Déposer un document : glisser-déposer ou appuyer sur Entrée pour parcourir" "translate.upload.ariaDropzone": "Déposer un document : glisser-déposer ou appuyer sur Entrée pour parcourir",
"translate.cancelAction": "Annuler la traduction",
"translate.cancelledTitle": "Traduction annulée",
"translate.cancelledDesc": "Le job a été arrêté et le document réservé a été libéré.",
"translate.cancelFailedTitle": "Annulation impossible",
"translate.cancelFailedDesc": "Le job est peut-être déjà terminé. L'affichage se mettra à jour sous peu."
} }

View File

@@ -43,7 +43,7 @@ class MCPServer:
}, },
"provider": { "provider": {
"type": "string", "type": "string",
"enum": ["google", "google_cloud", "deepl", "openai", "openrouter", "deepseek", "minimax", "zai"], "enum": ["google", "google_cloud", "openai", "openrouter", "deepseek", "minimax", "zai"],
"description": "Translation provider (default: google)" "description": "Translation provider (default: google)"
}, },
"translate_images": { "translate_images": {
@@ -79,7 +79,7 @@ class MCPServer:
"properties": { "properties": {
"provider": { "provider": {
"type": "string", "type": "string",
"enum": ["google", "google_cloud", "deepl", "openai", "openrouter", "deepseek", "minimax", "zai"], "enum": ["google", "google_cloud", "openai", "openrouter", "deepseek", "minimax", "zai"],
"description": "Default translation provider" "description": "Default translation provider"
} }
} }

View File

@@ -625,7 +625,6 @@ class ProviderValidator:
SUPPORTED_PROVIDERS = { SUPPORTED_PROVIDERS = {
"google", "google",
"google_cloud", "google_cloud",
"deepl",
"openai", "openai",
"openrouter", "openrouter",
"openrouter_premium", "openrouter_premium",
@@ -657,14 +656,7 @@ class ProviderValidator:
) )
# Provider-specific validation # Provider-specific validation
if normalized == "deepl": if normalized == "openai":
if not kwargs.get("deepl_api_key"):
raise ValidationError(
"La cle API DeepL est requise pour utiliser le fournisseur DeepL",
code="missing_deepl_key",
)
elif normalized == "openai":
if not kwargs.get("openai_api_key"): if not kwargs.get("openai_api_key"):
raise ValidationError( raise ValidationError(
"La cle API OpenAI est requise pour utiliser le fournisseur OpenAI", "La cle API OpenAI est requise pour utiliser le fournisseur OpenAI",

View File

@@ -70,7 +70,7 @@ PLANS = {
"max_pages_per_doc": 50, "max_pages_per_doc": 50,
"max_file_size_mb": 10, "max_file_size_mb": 10,
"max_chars_per_month": 500_000, "max_chars_per_month": 500_000,
"providers": ["google", "deepl"], "providers": ["google"],
"features": [ "features": [
"pricing.plans.starter.feat1", "pricing.plans.starter.feat1",
"pricing.plans.starter.feat2", "pricing.plans.starter.feat2",
@@ -96,7 +96,7 @@ PLANS = {
"max_pages_per_doc": 200, "max_pages_per_doc": 200,
"max_file_size_mb": 25, "max_file_size_mb": 25,
"max_chars_per_month": 2_000_000, "max_chars_per_month": 2_000_000,
"providers": ["google", "google_cloud", "deepl", "openrouter"], "providers": ["google", "google_cloud", "openrouter"],
"ai_model_essential": "deepseek/deepseek-chat", "ai_model_essential": "deepseek/deepseek-chat",
"features": [ "features": [
"pricing.plans.pro.feat1", "pricing.plans.pro.feat1",
@@ -126,7 +126,7 @@ PLANS = {
"max_pages_per_doc": 500, "max_pages_per_doc": 500,
"max_file_size_mb": 50, "max_file_size_mb": 50,
"max_chars_per_month": 10_000_000, "max_chars_per_month": 10_000_000,
"providers": ["google", "google_cloud", "deepl", "openrouter", "openrouter_premium", "openai", "zai"], "providers": ["google", "google_cloud", "openrouter", "openrouter_premium", "openai", "zai"],
"ai_model_essential": "deepseek/deepseek-chat", "ai_model_essential": "deepseek/deepseek-chat",
"ai_model_premium": "anthropic/claude-sonnet-4.6", "ai_model_premium": "anthropic/claude-sonnet-4.6",
"features": [ "features": [
@@ -161,7 +161,7 @@ PLANS = {
"max_pages_per_doc": -1, "max_pages_per_doc": -1,
"max_file_size_mb": -1, "max_file_size_mb": -1,
"max_chars_per_month": -1, "max_chars_per_month": -1,
"providers": ["google", "google_cloud", "deepl", "openrouter", "openrouter_premium", "openai", "zai", "custom"], "providers": ["google", "google_cloud", "openrouter", "openrouter_premium", "openai", "zai", "custom"],
"features": [ "features": [
"pricing.plans.enterprise.feat1", "pricing.plans.enterprise.feat1",
"pricing.plans.enterprise.feat2", "pricing.plans.enterprise.feat2",

View File

@@ -337,7 +337,6 @@ async def get_admin_dashboard(admin_id: str = Depends(require_admin)):
"label": label, "label": label,
} }
_engine_status("deepl", "DEEPL_API_KEY", "DeepL")
_engine_status("openrouter", "OPENROUTER_API_KEY", "Traduction IA Éco") _engine_status("openrouter", "OPENROUTER_API_KEY", "Traduction IA Éco")
_engine_status("openrouter_premium", "OPENROUTER_API_KEY", "Traduction IA Premium") _engine_status("openrouter_premium", "OPENROUTER_API_KEY", "Traduction IA Premium")
_engine_status("openai", "OPENAI_API_KEY", "OpenAI") _engine_status("openai", "OPENAI_API_KEY", "OpenAI")
@@ -712,7 +711,6 @@ async def update_default_provider(
valid_providers = [ valid_providers = [
"google", "google",
"google_cloud", "google_cloud",
"deepl",
"openai", "openai",
"openrouter", "openrouter",
"openrouter_premium", "openrouter_premium",
@@ -916,7 +914,6 @@ class SmtpSettings(BaseModel):
class SettingsConfig(BaseModel): class SettingsConfig(BaseModel):
google: ProviderSettings = ProviderSettings(enabled=True) google: ProviderSettings = ProviderSettings(enabled=True)
google_cloud: ProviderSettings = ProviderSettings() # Cloud Translation API v2 (clé API) google_cloud: ProviderSettings = ProviderSettings() # Cloud Translation API v2 (clé API)
deepl: ProviderSettings = ProviderSettings()
openai: ProviderSettings = ProviderSettings() openai: ProviderSettings = ProviderSettings()
openrouter: ProviderSettings = ProviderSettings() # "Traduction IA Essentielle" openrouter: ProviderSettings = ProviderSettings() # "Traduction IA Essentielle"
@@ -926,8 +923,8 @@ class SettingsConfig(BaseModel):
zai: ProviderSettings = ProviderSettings() zai: ProviderSettings = ProviderSettings()
mistral: ProviderSettings = ProviderSettings() # OCR Mistral (PDF scannés) mistral: ProviderSettings = ProviderSettings() # OCR Mistral (PDF scannés)
smtp: SmtpSettings = SmtpSettings() smtp: SmtpSettings = SmtpSettings()
fallback_chain: str = "google,google_cloud,deepl,openrouter,openrouter_premium,openai,deepseek,zai" fallback_chain: str = "google,google_cloud,openrouter,openrouter_premium,openai,deepseek,zai"
fallback_chain_classic: str = "google,google_cloud,deepl" fallback_chain_classic: str = "google,google_cloud"
fallback_chain_llm: str = "openrouter,openrouter_premium,openai,deepseek,zai" fallback_chain_llm: str = "openrouter,openrouter_premium,openai,deepseek,zai"
@@ -991,7 +988,6 @@ async def get_settings(admin_id: str = Depends(require_admin)):
# Premium : Claude Sonnet 4.6 — précision maximale sur documents complexes # Premium : Claude Sonnet 4.6 — précision maximale sur documents complexes
payload["openrouter_premium"] = _merge_env(settings.openrouter_premium, key_env="OPENROUTER_API_KEY", model_env="OPENROUTER_PREMIUM_MODEL", default_model="anthropic/claude-sonnet-4.6") payload["openrouter_premium"] = _merge_env(settings.openrouter_premium, key_env="OPENROUTER_API_KEY", model_env="OPENROUTER_PREMIUM_MODEL", default_model="anthropic/claude-sonnet-4.6")
payload["openai"] = _merge_env(settings.openai, key_env="OPENAI_API_KEY", model_env="OPENAI_MODEL", default_model="gpt-4o-mini") payload["openai"] = _merge_env(settings.openai, key_env="OPENAI_API_KEY", model_env="OPENAI_MODEL", default_model="gpt-4o-mini")
payload["deepl"] = _merge_env(settings.deepl, key_env="DEEPL_API_KEY")
payload["deepseek"] = _merge_env(settings.deepseek, key_env="DEEPSEEK_API_KEY", model_env="DEEPSEEK_MODEL", default_model="deepseek-chat") payload["deepseek"] = _merge_env(settings.deepseek, key_env="DEEPSEEK_API_KEY", model_env="DEEPSEEK_MODEL", default_model="deepseek-chat")
payload["minimax"] = _merge_env(settings.minimax, key_env="MINIMAX_API_KEY", model_env="MINIMAX_MODEL", default_model="abab6.5s-chat") payload["minimax"] = _merge_env(settings.minimax, key_env="MINIMAX_API_KEY", model_env="MINIMAX_MODEL", default_model="abab6.5s-chat")
payload["zai"] = _merge_env(settings.zai, key_env="ZAI_API_KEY", model_env="ZAI_MODEL", url_env="ZAI_BASE_URL", default_model="grok-2-1212", default_url="https://api.x.ai/v1") payload["zai"] = _merge_env(settings.zai, key_env="ZAI_API_KEY", model_env="ZAI_MODEL", url_env="ZAI_BASE_URL", default_model="grok-2-1212", default_url="https://api.x.ai/v1")
@@ -1023,7 +1019,6 @@ async def get_settings(admin_id: str = Depends(require_admin)):
# (boolean only — never expose actual values) # (boolean only — never expose actual values)
has_openrouter = bool(os.getenv("OPENROUTER_API_KEY", "").strip()) has_openrouter = bool(os.getenv("OPENROUTER_API_KEY", "").strip())
env_info = { env_info = {
"deepl": bool(os.getenv("DEEPL_API_KEY", "").strip()),
"openai": bool(os.getenv("OPENAI_API_KEY", "").strip()), "openai": bool(os.getenv("OPENAI_API_KEY", "").strip()),
"openrouter": has_openrouter, "openrouter": has_openrouter,
"openrouter_premium": has_openrouter, # same key, different model "openrouter_premium": has_openrouter, # same key, different model
@@ -1193,20 +1188,6 @@ async def test_provider(
}, },
) )
elif provider == "deepl":
api_key = _key(provider_config.api_key, "DEEPL_API_KEY")
if not api_key:
return JSONResponse(
status_code=400,
content={"available": False, "error": "Aucune clé API DeepL trouvée (JSON ou .env)"},
)
import deepl
translator = deepl.Translator(api_key)
usage = translator.get_usage()
return JSONResponse(
status_code=200, content={"available": True, "usage": str(usage)}
)
elif provider == "openrouter_premium": elif provider == "openrouter_premium":
current.openrouter_premium = _update_provider(current.openrouter_premium, update_data) current.openrouter_premium = _update_provider(current.openrouter_premium, update_data)

View File

@@ -82,16 +82,6 @@ async def get_available_providers(
"tier": "free", "tier": "free",
}) })
# DeepL — if configured
if _is_enabled("deepl", key_var="DEEPL_API_KEY"):
available.append({
"id": "deepl",
"label": "DeepL",
"description": "Traduction professionnelle haute qualité (langues européennes)",
"mode": "classic",
"tier": "pro",
})
# AI Essentielle (OpenRouter — cheap model / Eco) # AI Essentielle (OpenRouter — cheap model / Eco)
if _is_enabled("openrouter", key_var="OPENROUTER_API_KEY"): if _is_enabled("openrouter", key_var="OPENROUTER_API_KEY"):
or_cfg = getattr(settings, "openrouter", None) or_cfg = getattr(settings, "openrouter", None)

View File

@@ -464,6 +464,8 @@ async def download_from_url(url: str, timeout: int = 30) -> tuple[Path, str]:
_translation_jobs: dict[str, dict] = {} _translation_jobs: dict[str, dict] = {}
_JOB_TTL_SECONDS = 3600 _JOB_TTL_SECONDS = 3600
# Completed jobs are kept longer so the history endpoint has depth.
_JOB_HISTORY_TTL_SECONDS = 24 * 3600
_last_cleanup_ts: float = 0.0 _last_cleanup_ts: float = 0.0
# Google Cloud API key validity cache — avoids probing the API on every request. # Google Cloud API key validity cache — avoids probing the API on every request.
@@ -516,10 +518,14 @@ def _cleanup_old_jobs() -> None:
expired_job_ids = [ expired_job_ids = [
job_id job_id
for job_id, job in list(_translation_jobs.items()) for job_id, job in list(_translation_jobs.items())
if job.get("status") in ("completed", "failed") if job.get("status") in ("completed", "failed", "cancelled")
and ( and (
(ts := job.get("completed_at") or job.get("failed_at")) (ts := job.get("completed_at") or job.get("failed_at") or job.get("cancelled_at"))
and _job_age_seconds(ts) > _JOB_TTL_SECONDS and _job_age_seconds(ts) > (
_JOB_HISTORY_TTL_SECONDS
if job.get("status") == "completed"
else _JOB_TTL_SECONDS
)
) )
] ]
@@ -659,7 +665,7 @@ async def translate_document_v1(
- `source_lang`: Source language code (default: auto-detect) - `source_lang`: Source language code (default: auto-detect)
- `target_lang`: Target language code (required) - `target_lang`: Target language code (required)
- `mode`: Translation mode - "classic" or "llm" (default: classic) - `mode`: Translation mode - "classic" or "llm" (default: classic)
- `provider`: Provider override (google, deepl, ollama, openai, openrouter) - `provider`: Provider override (google, ollama, openai, openrouter)
- `webhook_url`: URL to receive POST notification when complete - `webhook_url`: URL to receive POST notification when complete
- `glossary_id`: Glossary ID for LLM translation (Pro only) - `glossary_id`: Glossary ID for LLM translation (Pro only)
- `custom_prompt`: Custom system prompt (Pro only) - `custom_prompt`: Custom system prompt (Pro only)
@@ -1233,6 +1239,10 @@ async def _run_translation_job(
await set_job_status_async(job_id, dict(job)) await set_job_status_async(job_id, dict(job))
tracker.update(10, "Validating file") tracker.update(10, "Validating file")
if job.get("status") == "cancelled":
logger.info(f"Job {job_id}: cancelled by user before dispatch — aborting")
return
async def _sync_job_to_redis(): async def _sync_job_to_redis():
"""Sync job status to Redis every 0.5s until completed/failed or job removed.""" """Sync job status to Redis every 0.5s until completed/failed or job removed."""
while True: while True:
@@ -1311,7 +1321,6 @@ async def _run_translation_job(
from services.providers.google_provider import GoogleTranslationProvider from services.providers.google_provider import GoogleTranslationProvider
from services.providers.google_cloud_provider import GoogleCloudTranslationProvider from services.providers.google_cloud_provider import GoogleCloudTranslationProvider
from services.providers.deepl_provider import DeepLTranslationProvider
from services.providers.openai_provider import OpenAITranslationProvider from services.providers.openai_provider import OpenAITranslationProvider
from services.providers.deepseek_provider import DeepSeekTranslationProvider from services.providers.deepseek_provider import DeepSeekTranslationProvider
from services.providers.minimax_provider import MinimaxTranslationProvider from services.providers.minimax_provider import MinimaxTranslationProvider
@@ -1398,13 +1407,6 @@ async def _run_translation_job(
model=mm_model, model=mm_model,
timeout=int(os.getenv("MINIMAX_TIMEOUT", "60")), timeout=int(os.getenv("MINIMAX_TIMEOUT", "60")),
) )
elif _p == "deepl":
deepl_key = _cfg(_admin_cfg.deepl.api_key, "DEEPL_API_KEY")
if deepl_key:
translation_provider = DeepLTranslationProvider(
api_key=deepl_key,
timeout=int(os.getenv("DEEPL_TIMEOUT", "30")),
)
elif _p == "zai": elif _p == "zai":
zai_key = _cfg(_admin_cfg.zai.api_key, "ZAI_API_KEY") zai_key = _cfg(_admin_cfg.zai.api_key, "ZAI_API_KEY")
zai_model = _cfg(_admin_cfg.zai.model, "ZAI_MODEL", "grok-2-1212") zai_model = _cfg(_admin_cfg.zai.model, "ZAI_MODEL", "grok-2-1212")
@@ -1887,6 +1889,10 @@ async def _run_translation_job(
except Exception as wm_err: except Exception as wm_err:
logger.warning(f"Job {job_id}: watermark failed: {wm_err}") logger.warning(f"Job {job_id}: watermark failed: {wm_err}")
if job.get("status") == "cancelled":
logger.info(f"Job {job_id}: cancelled by user mid-flight — discarding result")
return
tracker.set_completed(str(output_path)) tracker.set_completed(str(output_path))
# Record translation metric # Record translation metric
duration = _compute_duration_seconds(job.get("created_at", "")) duration = _compute_duration_seconds(job.get("created_at", ""))
@@ -2123,6 +2129,134 @@ async def get_translation_status(
} }
@router_v1.get(
"/translations",
responses={
200: {"description": "Translation job history for the current user"},
401: {"description": "Authentication required"},
},
)
async def list_translation_history(
page: int = 1,
per_page: int = 20,
current_user: Optional[Any] = Depends(get_authenticated_user),
):
"""
List the current user's translation jobs, newest first.
Jobs are kept in memory: completed jobs for 24 hours, other states for 1 hour.
Pagination via ``page`` / ``per_page`` (max 50).
"""
if current_user is None:
return JSONResponse(
status_code=401,
content={"error": "AUTH_REQUIRED", "message": "Authentication required."},
)
user_id = str(getattr(current_user, "id", ""))
per_page = max(1, min(per_page, 50))
page = max(1, page)
jobs = [
{
"id": job.get("id"),
"status": job.get("status"),
"progress_percent": job.get("progress_percent", 0),
"file_name": job.get("file_name"),
"source_lang": job.get("source_lang"),
"target_lang": job.get("target_lang"),
"provider": job.get("provider"),
"created_at": job.get("created_at"),
"completed_at": job.get("completed_at"),
"failed_at": job.get("failed_at"),
"cancelled_at": job.get("cancelled_at"),
}
for job in _translation_jobs.values()
if str(job.get("user_id", "")) == user_id
]
jobs.sort(key=lambda j: j.get("created_at") or "", reverse=True)
total = len(jobs)
start = (page - 1) * per_page
return {
"data": jobs[start : start + per_page],
"meta": {"total": total, "page": page, "per_page": per_page},
}
@router_v1.post(
"/translations/{job_id}/cancel",
responses={
200: {"description": "Job cancelled"},
401: {"description": "Authentication required"},
404: {"description": "Job not found"},
409: {"description": "Job already finished"},
},
)
async def cancel_translation(
job_id: str,
token: Optional[str] = None,
current_user: Optional[Any] = Depends(get_authenticated_user),
):
"""
Cancel a queued or processing translation job.
Cancellation is cooperative: the worker aborts at its next checkpoint
(before dispatch, or before finalisation for in-flight jobs) and the
reserved quota is released immediately.
"""
job = await get_job_status_async(job_id)
if not job:
job = _translation_jobs.get(job_id)
if not job:
return JSONResponse(
status_code=404,
content={
"error": "NOT_FOUND",
"message": "Translation job not found.",
"details": {"job_id": job_id},
},
)
denied = _check_job_access(job, current_user, token)
if denied:
return denied
if job.get("status") in ("completed", "failed", "cancelled"):
return JSONResponse(
status_code=409,
content={
"error": "ALREADY_FINISHED",
"message": f"Job already {job.get('status')}.",
"details": {"job_id": job_id, "status": job.get("status")},
},
)
mem_job = _translation_jobs.get(job_id)
if mem_job is None:
# Redis-only copy (e.g. after a worker restart): cancel that view.
mem_job = job
_translation_jobs[job_id] = dict(job)
mem_job["status"] = "cancelled"
mem_job["cancelled_at"] = datetime.now(timezone.utc).isoformat()
mem_job["current_step"] = "Cancelled by user"
await set_job_status_async(job_id, dict(mem_job))
# Release the reserved document slot right away unless usage was recorded.
job_user_id = mem_job.get("user_id")
if job_user_id and not mem_job.get("usage_recorded"):
try:
await asyncio.to_thread(release_translation_quota, str(job_user_id))
logger.info(f"Job {job_id}: released reserved quota after user cancellation")
except Exception as release_err:
logger.exception(f"Job {job_id}: failed to release quota on cancel: {release_err}")
return {"data": {"id": job_id, "status": "cancelled"}, "meta": {}}
@router_v1.get("/translate/health") @router_v1.get("/translate/health")
async def translate_health(): async def translate_health():
"""Health check for translation endpoint.""" """Health check for translation endpoint."""

View File

@@ -1048,7 +1048,7 @@ def generate_pdf():
p4.insert_text( p4.insert_text(
(72, 430), (72, 430),
"You can choose between several translation providers depending on your " "You can choose between several translation providers depending on your "
"needs: Google Translate (free tier), DeepL (premium), OpenAI GPT-4 " "needs: Google Translate (free tier), OpenAI GPT-4 "
"(premium, best for technical content), or Anthropic Claude (premium, " "(premium, best for technical content), or Anthropic Claude (premium, "
"best for literary content).", "best for literary content).",
fontsize=11, fontsize=11,

View File

@@ -16,7 +16,7 @@ Usage:
response = google_provider.translate_text(request) response = google_provider.translate_text(request)
# Use fallback chain # Use fallback chain
provider = registry.get_first_available(["google", "deepl", "openai"]) provider = registry.get_first_available(["google", "openai"])
""" """
from .base import TranslationProvider from .base import TranslationProvider
@@ -54,13 +54,6 @@ def _auto_register_providers() -> None:
if ProvidersConfig.GOOGLE_ENABLED: if ProvidersConfig.GOOGLE_ENABLED:
register_google_provider() register_google_provider()
if ProvidersConfig.DEEPL_ENABLED and ProvidersConfig.DEEPL_API_KEY:
from .deepl_provider import register_deepl_provider
register_deepl_provider()
if ProvidersConfig.OPENAI_ENABLED and ProvidersConfig.OPENAI_API_KEY: if ProvidersConfig.OPENAI_ENABLED and ProvidersConfig.OPENAI_API_KEY:
from .openai_provider import register_openai_provider from .openai_provider import register_openai_provider

View File

@@ -43,7 +43,7 @@ class TranslationProvider(ABC):
Return the provider name for logging and registry. Return the provider name for logging and registry.
Returns: Returns:
Provider name as a string (e.g., "google", "deepl", "openai") Provider name as a string (e.g., "google", "openai")
""" """
pass pass

View File

@@ -60,12 +60,6 @@ class ProvidersConfig:
os.getenv("GOOGLE_CLOUD_RETRY_DELAY", "1.0") os.getenv("GOOGLE_CLOUD_RETRY_DELAY", "1.0")
) )
# DeepL
DEEPL_ENABLED: bool = os.getenv("DEEPL_ENABLED", "false").lower() == "true"
DEEPL_API_KEY: str = os.getenv("DEEPL_API_KEY", "")
DEEPL_TIMEOUT: int = int(os.getenv("DEEPL_TIMEOUT", "30"))
DEEPL_MAX_RETRIES: int = int(os.getenv("DEEPL_MAX_RETRIES", "3"))
DEEPL_RETRY_DELAY: float = float(os.getenv("DEEPL_RETRY_DELAY", "1.0"))
# OpenAI # OpenAI
OPENAI_ENABLED: bool = os.getenv("OPENAI_ENABLED", "false").lower() == "true" OPENAI_ENABLED: bool = os.getenv("OPENAI_ENABLED", "false").lower() == "true"
@@ -110,7 +104,7 @@ class ProvidersConfig:
# #
# IMPORTANT: the registry-based fallback (translate_with_fallback) only # IMPORTANT: the registry-based fallback (translate_with_fallback) only
# ever sees providers that _auto_register_providers() registers, i.e. # ever sees providers that _auto_register_providers() registers, i.e.
# google, deepl, openai, deepseek and minimax. The OpenAI-compatible # google, openai, deepseek and minimax. The OpenAI-compatible
# shims (openrouter, openrouter_premium, zai) and google_cloud are wired # shims (openrouter, openrouter_premium, zai) and google_cloud are wired
# directly in routes/translate_routes.py and are intentionally NOT part of # directly in routes/translate_routes.py and are intentionally NOT part of
# the registry fallback chain — listing them here would make # the registry fallback chain — listing them here would make
@@ -120,16 +114,16 @@ class ProvidersConfig:
FALLBACK_CHAIN: List[str] = [ FALLBACK_CHAIN: List[str] = [
name.strip() name.strip()
for name in os.getenv( for name in os.getenv(
"PROVIDER_FALLBACK_CHAIN", "google,deepl,openai,deepseek,minimax" "PROVIDER_FALLBACK_CHAIN", "google,openai,deepseek,minimax"
).split(",") ).split(",")
if name.strip() if name.strip()
] ]
# Mode-specific fallback chains # Mode-specific fallback chains
# Classic mode: Google Translate -> DeepL # Classic mode: Google Translate -> Google Cloud
FALLBACK_CHAIN_CLASSIC: List[str] = [ FALLBACK_CHAIN_CLASSIC: List[str] = [
name.strip() name.strip()
for name in os.getenv("FALLBACK_CHAIN_CLASSIC", "google,deepl").split(",") for name in os.getenv("FALLBACK_CHAIN_CLASSIC", "google").split(",")
if name.strip() if name.strip()
] ]
@@ -166,7 +160,7 @@ class ProvidersConfig:
Get settings for a specific provider. Get settings for a specific provider.
Args: Args:
provider_name: Name of the provider (e.g., "google", "deepl") provider_name: Name of the provider (e.g., "google", "openai")
Returns: Returns:
ProviderSettings for the requested provider ProviderSettings for the requested provider
@@ -181,12 +175,6 @@ class ProvidersConfig:
base_url=None, base_url=None,
model=None, model=None,
), ),
"deepl": ProviderSettings(
enabled=cls.DEEPL_ENABLED,
api_key=cls.DEEPL_API_KEY if cls.DEEPL_API_KEY else None,
base_url=None,
model=None,
),
"openai": ProviderSettings( "openai": ProviderSettings(
enabled=cls.OPENAI_ENABLED, enabled=cls.OPENAI_ENABLED,
api_key=cls.OPENAI_API_KEY if cls.OPENAI_API_KEY else None, api_key=cls.OPENAI_API_KEY if cls.OPENAI_API_KEY else None,
@@ -231,7 +219,7 @@ class ProvidersConfig:
return False return False
# Providers requiring API keys # Providers requiring API keys
providers_requiring_key = {"deepl", "openai", "openrouter", "google_cloud", "deepseek", "minimax"} providers_requiring_key = {"openai", "openrouter", "google_cloud", "deepseek", "minimax"}
if provider_name.lower() in providers_requiring_key: if provider_name.lower() in providers_requiring_key:
return bool(settings.api_key) return bool(settings.api_key)

View File

@@ -1,757 +0,0 @@
"""
DeepL Provider - Production-ready implementation.
Extends TranslationProvider base class with robust error handling,
retry logic, and health monitoring.
Features:
- Automatic Free/Pro endpoint detection based on API key format
- Specific error codes for all DeepL API errors
- Retry logic with exponential backoff for transient errors
- Timeout configuration
- Health check with caching
- Structlog-compatible logging (no document content in logs)
"""
import os
import socket
import threading
import time
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from core.logging import get_logger
logger = get_logger(__name__)
_HAS_STRUCTLOG = True
def _log_info(event: str, **kwargs):
"""Log info message compatible with both structlog and standard logging."""
if _HAS_STRUCTLOG:
logger.info(event, **kwargs)
else:
logger.info(f"{event} {' '.join(f'{k}={v}' for k, v in kwargs.items())}")
def _log_warning(event: str, **kwargs):
"""Log warning message compatible with both structlog and standard logging."""
if _HAS_STRUCTLOG:
logger.warning(event, **kwargs)
else:
logger.warning(f"{event} {' '.join(f'{k}={v}' for k, v in kwargs.items())}")
def _log_error(event: str, **kwargs):
"""Log error message compatible with both structlog and standard logging."""
if _HAS_STRUCTLOG:
logger.error(event, **kwargs)
else:
logger.error(f"{event} {' '.join(f'{k}={v}' for k, v in kwargs.items())}")
from .base import TranslationProvider
from .schemas import (
BatchTranslationRequest,
BatchTranslationResponse,
ProviderHealthStatus,
TranslationRequest,
TranslationResponse,
)
DEEPL_QUOTA_EXCEEDED = "DEEPL_QUOTA_EXCEEDED"
DEEPL_INVALID_KEY = "DEEPL_INVALID_KEY"
DEEPL_NETWORK_ERROR = "DEEPL_NETWORK_ERROR"
DEEPL_UNSUPPORTED_LANGUAGE = "DEEPL_UNSUPPORTED_LANGUAGE"
DEEPL_TEXT_TOO_LONG = "DEEPL_TEXT_TOO_LONG"
_RETRYABLE_ERRORS = {DEEPL_NETWORK_ERROR, DEEPL_QUOTA_EXCEEDED}
DEEPL_FREE_SUFFIX = ":fx"
MAX_TEXT_LENGTH = 128 * 1024
DEEPL_SUPPORTED_LANGUAGES = {
"BG",
"CS",
"DA",
"DE",
"EL",
"EN-GB",
"EN-US",
"ES",
"ET",
"FI",
"FR",
"HU",
"ID",
"IT",
"JA",
"KO",
"LT",
"LV",
"NB",
"NL",
"PL",
"PT-BR",
"PT-PT",
"RO",
"RU",
"SK",
"SL",
"SV",
"TR",
"UK",
"ZH",
}
class DeepLProviderError(Exception):
"""Exception raised for DeepL API errors."""
def __init__(
self, code: str, message: str, details: Optional[Dict[str, Any]] = None
):
self.code = code
self.message = message
self.details = details or {}
super().__init__(message)
def to_dict(self) -> Dict[str, Any]:
"""Convert error to dictionary format."""
result = {
"error": self.code,
"message": self.message,
}
if self.details:
result["details"] = self.details
return result
class DeepLTranslationProvider(TranslationProvider):
"""
DeepL implementation using deep_translator library.
Features:
- Automatic Free/Pro endpoint detection based on API key format
- Thread-safe translator instances per thread
- Caching support (uses global cache from translation_service)
- Batch translation with optimized processing
- Robust error handling with specific error codes
- Retry logic with exponential backoff
- Configurable timeout
- Health check with result caching
"""
def __init__(
self,
api_key: str,
use_cache: bool = True,
timeout: int = 30,
max_retries: int = 3,
retry_delay: float = 1.0,
):
"""
Initialize DeepL provider.
Args:
api_key: DeepL API key (Free keys end with :fx)
use_cache: Whether to use translation caching (default: True)
timeout: Request timeout in seconds (default: 30)
max_retries: Maximum retry attempts for transient errors (default: 3)
retry_delay: Initial retry delay in seconds (default: 1.0)
"""
if not api_key:
raise ValueError("DeepL API key is required")
self._api_key = api_key
self._api_type = self._detect_api_type(api_key)
self._local = threading.local()
self._use_cache = use_cache
self._provider_name = "deepl"
self._cache = None
self.timeout = timeout
self.max_retries = max_retries
self.retry_delay = retry_delay
self._health_cache: Dict[str, Any] = {}
self._health_cache_ttl = 60
self._health_cache_lock = threading.Lock()
if use_cache:
self._init_cache()
def _detect_api_type(self, api_key: str) -> str:
"""
Detect if API key is Free or Pro based on suffix.
Free tier keys end with ':fx', Pro keys do not.
Args:
api_key: DeepL API key
Returns:
"free" or "pro"
"""
if api_key.endswith(DEEPL_FREE_SUFFIX):
return "free"
return "pro"
def _get_api_url(self) -> str:
"""
Get correct API URL based on key type.
Note: deep_translator handles this internally, but we log it.
Returns:
API URL for Free or Pro tier
"""
if self._api_type == "free":
return "https://api-free.deepl.com/v2/translate"
return "https://api.deepl.com/v2/translate"
def _init_cache(self):
"""Initialize or get the translation cache."""
from services.translation_service import _translation_cache
self._cache = _translation_cache
def _normalize_language_code(self, lang_code: str) -> str:
"""
Normalize language code for DeepL.
DeepL uses uppercase language codes (e.g., "EN-US", "FR").
Args:
lang_code: Input language code (e.g., "en", "en-US", "EN-us")
Returns:
Normalized language code for DeepL
"""
if not lang_code or lang_code.lower() == "auto":
return ""
lang_upper = lang_code.upper()
if lang_upper in DEEPL_SUPPORTED_LANGUAGES:
return lang_upper
base_lang = lang_upper.split("-")[0]
if base_lang == "EN":
return "EN-US"
elif base_lang == "PT":
return "PT-BR"
elif base_lang in {
"BG",
"CS",
"DA",
"DE",
"EL",
"ES",
"ET",
"FI",
"FR",
"HU",
"ID",
"IT",
"JA",
"KO",
"LT",
"LV",
"NB",
"NL",
"PL",
"RO",
"RU",
"SK",
"SL",
"SV",
"TR",
"UK",
"ZH",
}:
return base_lang
return lang_upper
def _is_language_supported(self, lang_code: str) -> bool:
"""
Check if a language code is supported by DeepL.
Args:
lang_code: Language code to check
Returns:
True if supported, False otherwise
"""
if not lang_code:
return True
normalized = self._normalize_language_code(lang_code)
return normalized in DEEPL_SUPPORTED_LANGUAGES
def _get_translator(self, source_language: str, target_language: str):
"""Get or create a translator instance for the current thread."""
from deep_translator import DeepLTranslator
source_lang = self._normalize_language_code(source_language)
target_lang = self._normalize_language_code(target_language)
key = f"{source_lang}_{target_lang}"
if not hasattr(self._local, "translators"):
self._local.translators = {}
if key not in self._local.translators:
self._local.translators[key] = DeepLTranslator(
api_key=self._api_key,
source=source_lang if source_lang else "auto",
target=target_lang,
)
return self._local.translators[key]
def _make_api_request(
self, text: str, source_language: str, target_language: str
) -> str:
"""
Make API request with error mapping.
Raises:
DeepLProviderError: For any API errors with specific codes
"""
if len(text.encode("utf-8")) > MAX_TEXT_LENGTH:
raise DeepLProviderError(
code=DEEPL_TEXT_TOO_LONG,
message="Texte trop long (max 128KB par requête).",
details={"text_length": len(text), "max_length": MAX_TEXT_LENGTH},
)
if not self._is_language_supported(target_language):
raise DeepLProviderError(
code=DEEPL_UNSUPPORTED_LANGUAGE,
message=f"Langue '{target_language}' non supportée par DeepL.",
details={"unsupported_language": target_language},
)
try:
translator = self._get_translator(source_language, target_language)
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(translator.translate, text)
return future.result(timeout=self.timeout)
except Exception as e:
error_str = str(e).lower()
if (
"quota" in error_str
or "limit" in error_str
or "429" in error_str
or "456" in error_str
):
raise DeepLProviderError(
code=DEEPL_QUOTA_EXCEEDED,
message="Quota DeepL dépassé. Réessayez demain.",
details={"provider": "deepl", "api_type": self._api_type},
)
elif (
"auth" in error_str
or "key" in error_str
or "invalid" in error_str
or "401" in error_str
or "403" in error_str
):
raise DeepLProviderError(
code=DEEPL_INVALID_KEY,
message="Clé API DeepL invalide. Contactez l'administrateur.",
details={"provider": "deepl"},
)
elif "language" in error_str or "not supported" in error_str:
raise DeepLProviderError(
code=DEEPL_UNSUPPORTED_LANGUAGE,
message=f"Langue '{target_language}' non supportée par DeepL.",
details={"unsupported_language": target_language},
)
elif (
isinstance(e, (socket.timeout, TimeoutError, FuturesTimeoutError))
or "timeout" in error_str
):
raise DeepLProviderError(
code=DEEPL_NETWORK_ERROR,
message="Service DeepL indisponible. Réessayez.",
details={"provider": "deepl", "error_type": "timeout"},
)
else:
raise DeepLProviderError(
code=DEEPL_NETWORK_ERROR,
message="Service DeepL indisponible. Réessayez.",
details={"provider": "deepl", "original_error": str(e)[:100]},
)
def get_name(self) -> str:
"""Return provider name."""
return self._provider_name
def is_available(self) -> bool:
"""
Check if DeepL is available (API key configured and API reachable).
Performs a minimal translate call to verify the API is actually reachable.
Uses cached result if available and not expired (TTL 60s).
"""
current_time = time.time()
with self._health_cache_lock:
if "is_available" in self._health_cache:
cached = self._health_cache["is_available"]
if current_time - cached["timestamp"] < self._health_cache_ttl:
return cached["value"]
available = False
try:
translator = self._get_translator("en", "fr")
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(translator.translate, "a")
future.result(timeout=5)
available = True
except Exception as e:
_log_warning(
"deepl_availability_check_failed",
error=str(e)[:100],
)
with self._health_cache_lock:
self._health_cache["is_available"] = {
"value": available,
"timestamp": current_time,
}
return available
def translate_text(self, request: TranslationRequest) -> TranslationResponse:
"""
Translate a single text string using DeepL.
API Usage Notes:
- DeepL Free tier: 500,000 characters/month
- DeepL Pro: ~€25 per million characters
- 128KB max per request
Optimization: Skips API call if source == target language.
Args:
request: TranslationRequest with text and language info
Returns:
TranslationResponse with translated text
"""
text = request.text
target_language = request.target_language
source_language = request.source_language or "auto"
if not text or not text.strip():
return TranslationResponse(
translated_text=text,
provider_name=self._provider_name,
from_cache=False,
)
norm_source = self._normalize_language_code(source_language)
norm_target = self._normalize_language_code(target_language)
if norm_source and norm_source == norm_target:
_log_info(
"deepl_translation_skip",
source_target_lang=target_language,
text_length=len(text),
)
return TranslationResponse(
translated_text=text,
provider_name=self._provider_name,
from_cache=False,
source_language=source_language,
)
if self._use_cache and self._cache:
cached = self._cache.get(
text, target_language, source_language, self._provider_name
)
if cached is not None:
return TranslationResponse(
translated_text=cached,
provider_name=self._provider_name,
from_cache=True,
)
last_error: Optional[DeepLProviderError] = None
retries = 0
while retries <= self.max_retries:
try:
result = self._make_api_request(text, source_language, target_language)
if self._use_cache and self._cache:
self._cache.set(
text,
target_language,
source_language,
self._provider_name,
result,
)
_log_info(
"deepl_translation_success",
chars=len(text),
source_lang=source_language,
target_lang=target_language,
api_type=self._api_type,
retries=retries,
)
return TranslationResponse(
translated_text=result,
provider_name=self._provider_name,
from_cache=False,
)
except DeepLProviderError as e:
last_error = e
if e.code not in _RETRYABLE_ERRORS:
break
retries += 1
if retries <= self.max_retries:
delay = self.retry_delay * (2 ** (retries - 1))
_log_info(
"deepl_translation_retry",
attempt=retries,
delay_s=round(delay, 2),
error_code=e.code,
text_length=len(text),
source_lang=source_language,
target_lang=target_language,
)
time.sleep(delay)
except Exception as e:
last_error = DeepLProviderError(
code=DEEPL_NETWORK_ERROR,
message="Service DeepL indisponible. Réessayez.",
details={"original_error": str(e)[:100]},
)
retries += 1
if retries <= self.max_retries:
delay = self.retry_delay * (2 ** (retries - 1))
time.sleep(delay)
if last_error:
_log_error(
"deepl_translation_failed",
error_code=last_error.code,
text_length=len(text),
source_lang=source_language,
target_lang=target_language,
retries=retries,
)
return TranslationResponse(
translated_text=text,
provider_name=self._provider_name,
from_cache=False,
error=last_error.message,
error_code=last_error.code,
error_details=last_error.details,
)
return TranslationResponse(
translated_text=text,
provider_name=self._provider_name,
from_cache=False,
error="Unknown error",
error_code=DEEPL_NETWORK_ERROR,
)
def translate_batch(
self, requests: List[TranslationRequest]
) -> List[TranslationResponse]:
"""
Translate multiple texts with optimized batch processing.
Args:
requests: List of TranslationRequest objects
Returns:
List of TranslationResponse objects
"""
if not requests:
return []
return [self.translate_text(req) for req in requests]
def health_check(self) -> ProviderHealthStatus:
"""
Return health status details for the provider.
Performs a lightweight check to verify the provider is operational.
Includes cached result for efficiency.
Returns:
ProviderHealthStatus with availability and latency information
"""
current_time = time.time()
with self._health_cache_lock:
if "health_check" in self._health_cache:
cached = self._health_cache["health_check"]
if current_time - cached["timestamp"] < self._health_cache_ttl:
return cached["value"]
start_time = time.time()
last_check_iso = datetime.now(timezone.utc).isoformat()
try:
available = self.is_available()
latency_ms = (time.time() - start_time) * 1000
status = ProviderHealthStatus(
name=self._provider_name,
available=available,
latency_ms=round(latency_ms, 2),
error=None if available else "Provider not available",
last_check=last_check_iso,
)
except Exception as e:
latency_ms = (time.time() - start_time) * 1000
status = ProviderHealthStatus(
name=self._provider_name,
available=False,
latency_ms=round(latency_ms, 2),
error=str(e)[:100],
last_check=last_check_iso,
)
with self._health_cache_lock:
self._health_cache["health_check"] = {
"value": status,
"timestamp": current_time,
}
return status
def register_deepl_provider():
"""
Register the DeepL provider in the global registry.
This function should be called during module initialization
to make the provider available through the registry.
"""
from .registry import registry
provider = get_deepl_provider()
if provider:
registry.register("deepl", provider)
return provider
_provider_instance = None
_provider_instance_lock = threading.Lock()
def get_deepl_provider() -> Optional[DeepLTranslationProvider]:
"""Get or create the DeepL provider instance (reads config from env). Thread-safe."""
global _provider_instance
if _provider_instance is None:
with _provider_instance_lock:
if _provider_instance is None:
from .config import ProvidersConfig
if not ProvidersConfig.DEEPL_API_KEY:
return None
_provider_instance = DeepLTranslationProvider(
api_key=ProvidersConfig.DEEPL_API_KEY,
use_cache=True,
timeout=getattr(ProvidersConfig, "DEEPL_TIMEOUT", 30),
max_retries=getattr(ProvidersConfig, "DEEPL_MAX_RETRIES", 3),
retry_delay=getattr(ProvidersConfig, "DEEPL_RETRY_DELAY", 1.0),
)
return _provider_instance
class LegacyDeepLAdapter:
"""
Exposes the new DeepLTranslationProvider via the legacy interface used by
translation_service: .translate(text, target_lang, source_lang) -> str and
.translate_batch(texts, target_lang, source_lang) -> List[str].
Raises TranslationProviderError on failure so the API can return 4xx/502.
"""
def __init__(self):
self._provider = get_deepl_provider()
self.provider_name = "deepl"
def translate(
self, text: str, target_language: str, source_language: str = "auto"
) -> str:
if not self._provider:
from utils.exceptions import TranslationProviderError
raise TranslationProviderError(
"DEEPL_NOT_CONFIGURED",
"DeepL provider not configured. Set DEEPL_API_KEY.",
None,
)
req = TranslationRequest(
text=text,
target_language=target_language,
source_language=source_language,
)
resp = self._provider.translate_text(req)
if resp.error:
from utils.exceptions import TranslationProviderError
raise TranslationProviderError(
resp.error_code or "UNKNOWN",
resp.error or "Translation failed",
resp.error_details,
)
return resp.translated_text
def translate_batch(
self,
texts: List[str],
target_language: str,
source_language: str = "auto",
batch_size: int = 50,
) -> List[str]:
if not self._provider:
from utils.exceptions import TranslationProviderError
raise TranslationProviderError(
"DEEPL_NOT_CONFIGURED",
"DeepL provider not configured. Set DEEPL_API_KEY.",
None,
)
requests = [
TranslationRequest(
text=t,
target_language=target_language,
source_language=source_language,
)
for t in texts
]
responses = self._provider.translate_batch(requests)
result = []
for r in responses:
if r.error:
from utils.exceptions import TranslationProviderError
raise TranslationProviderError(
r.error_code or "UNKNOWN",
r.error or "Translation failed",
r.error_details,
)
result.append(r.translated_text)
return result
def get_legacy_deepl_adapter() -> LegacyDeepLAdapter:
"""Return an adapter so the legacy translation_service can use the new provider."""
return LegacyDeepLAdapter()

View File

@@ -132,10 +132,10 @@ def translate_with_fallback(
Example: Example:
>>> request = TranslationRequest(text="Hello", target_language="fr") >>> request = TranslationRequest(text="Hello", target_language="fr")
>>> response = translate_with_fallback( >>> response = translate_with_fallback(
... request, ["google", "deepl", "openai"] ... request, ["google", "openai"]
... ) ... )
>>> print(response.translated_text) # "Bonjour" >>> print(response.translated_text) # "Bonjour"
>>> print(response.provider_name) # "deepl" (first that succeeded) >>> print(response.provider_name) # "google" (first that succeeded)
""" """
if not provider_names: if not provider_names:
raise AllProvidersFailedError( raise AllProvidersFailedError(
@@ -309,7 +309,7 @@ class LegacyFallbackAdapter:
def __init__(self, mode: str = "classic"): def __init__(self, mode: str = "classic"):
""" """
Args: Args:
mode: "classic" (Google → DeepL) or "llm" (Ollama → OpenAI) mode: "classic" (Google → Google Cloud) or "llm" (Ollama → OpenAI)
""" """
self._mode = mode.lower() self._mode = mode.lower()
self.provider_name = f"fallback_{self._mode}" self.provider_name = f"fallback_{self._mode}"

View File

@@ -39,7 +39,7 @@ class ProviderRegistry:
Register a translation provider. Register a translation provider.
Args: Args:
name: Unique name for the provider (e.g., "google", "deepl") name: Unique name for the provider (e.g., "google", "openai")
provider: TranslationProvider instance provider: TranslationProvider instance
""" """
with self._providers_lock: with self._providers_lock:

View File

@@ -7,7 +7,7 @@ Optimized for high performance with parallel processing and caching
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Optional, List, Dict, Tuple from typing import Optional, List, Dict, Tuple
import requests import requests
from deep_translator import GoogleTranslator, DeeplTranslator, LibreTranslator from deep_translator import GoogleTranslator, LibreTranslator
from config import config from config import config
import concurrent.futures import concurrent.futures
import threading import threading
@@ -386,72 +386,6 @@ class GoogleTranslationProvider(TranslationProvider):
return results return results
class DeepLTranslationProvider(TranslationProvider):
"""DeepL Translate implementation with batch support"""
def __init__(self, api_key: str):
self.api_key = api_key
self._translator_cache = {}
def _get_translator(
self, source_language: str, target_language: str
) -> DeeplTranslator:
key = f"{source_language}_{target_language}"
if key not in self._translator_cache:
self._translator_cache[key] = DeeplTranslator(
api_key=self.api_key, source=source_language, target=target_language
)
return self._translator_cache[key]
def translate(
self, text: str, target_language: str, source_language: str = "auto"
) -> str:
if not text or not text.strip():
return text
try:
translator = self._get_translator(source_language, target_language)
return translator.translate(text)
except Exception as e:
logger.warning("translation_error", error_type=type(e).__name__)
return text
def translate_batch(
self, texts: List[str], target_language: str, source_language: str = "auto"
) -> List[str]:
"""Batch translate using DeepL"""
if not texts:
return []
results = [""] * len(texts)
non_empty = [(i, t) for i, t in enumerate(texts) if t and t.strip()]
if not non_empty:
return [t if t else "" for t in texts]
try:
translator = self._get_translator(source_language, target_language)
non_empty_texts = [t for _, t in non_empty]
if hasattr(translator, "translate_batch"):
translated = translator.translate_batch(non_empty_texts)
else:
translated = [translator.translate(t) for t in non_empty_texts]
for (idx, _), trans in zip(non_empty, translated):
results[idx] = trans if trans else texts[idx]
# Fill empty positions
for i, text in enumerate(texts):
if not text or not text.strip():
results[i] = text if text else ""
return results
except Exception as e:
logger.warning("deepl_batch_error", error_type=type(e).__name__)
return [self.translate(t, target_language, source_language) for t in texts]
class LibreTranslationProvider(TranslationProvider): class LibreTranslationProvider(TranslationProvider):
"""LibreTranslate implementation with batch support""" """LibreTranslate implementation with batch support"""

View File

@@ -122,7 +122,7 @@ def client(users_file: Path, monkeypatch):
"extra_credits": 0, "extra_credits": 0,
"max_pages_per_doc": 50, "max_pages_per_doc": 50,
"max_file_size_mb": 10, "max_file_size_mb": 10,
"allowed_providers": ["google", "deepl"], "allowed_providers": ["google"],
} }
monkeypatch.setattr( monkeypatch.setattr(

View File

@@ -19,8 +19,8 @@ class TestAllowedProviders:
def test_free_gets_google_only(self): def test_free_gets_google_only(self):
assert _allowed_providers_for_plan(PlanType.FREE) == {"google"} assert _allowed_providers_for_plan(PlanType.FREE) == {"google"}
def test_starter_adds_deepl(self): def test_starter_engines(self):
assert _allowed_providers_for_plan(PlanType.STARTER) == {"google", "deepl"} assert _allowed_providers_for_plan(PlanType.STARTER) == {"google"}
def test_pro_adds_cloud_and_openrouter(self): def test_pro_adds_cloud_and_openrouter(self):
allowed = _allowed_providers_for_plan(PlanType.PRO) allowed = _allowed_providers_for_plan(PlanType.PRO)

View File

@@ -1,488 +0,0 @@
"""
Tests for the DeepLTranslationProvider.
"""
import socket
import pytest
from concurrent.futures import TimeoutError as FuturesTimeoutError
from unittest.mock import patch, MagicMock
from services.providers.deepl_provider import (
DeepLTranslationProvider,
DeepLProviderError,
get_deepl_provider,
register_deepl_provider,
DEEPL_QUOTA_EXCEEDED,
DEEPL_INVALID_KEY,
DEEPL_NETWORK_ERROR,
DEEPL_UNSUPPORTED_LANGUAGE,
DEEPL_TEXT_TOO_LONG,
)
from services.providers.schemas import TranslationRequest, TranslationResponse
class TestDeepLProviderError:
"""Tests for DeepLProviderError exception."""
def test_error_creation(self):
"""Test error creation with all fields."""
error = DeepLProviderError(
code=DEEPL_INVALID_KEY,
message="Invalid API key",
details={"provider": "deepl"},
)
assert error.code == DEEPL_INVALID_KEY
assert error.message == "Invalid API key"
assert error.details == {"provider": "deepl"}
def test_error_to_dict(self):
"""Test error serialization."""
error = DeepLProviderError(
code=DEEPL_QUOTA_EXCEEDED,
message="Quota exceeded",
details={"reset_at": "2024-01-16T00:00:00Z"},
)
result = error.to_dict()
assert result["error"] == DEEPL_QUOTA_EXCEEDED
assert result["message"] == "Quota exceeded"
assert result["details"]["reset_at"] == "2024-01-16T00:00:00Z"
def test_error_to_dict_no_details(self):
"""Test error serialization without details."""
error = DeepLProviderError(
code=DEEPL_NETWORK_ERROR,
message="Network error",
)
result = error.to_dict()
assert result["error"] == DEEPL_NETWORK_ERROR
assert result["message"] == "Network error"
assert "details" not in result
class TestDeepLTranslationProvider:
"""Tests for DeepLTranslationProvider."""
@pytest.fixture
def provider(self):
"""Create a DeepL provider instance with Pro key."""
return DeepLTranslationProvider(
api_key="test-pro-key-12345",
use_cache=False,
)
@pytest.fixture
def provider_free(self):
"""Create a DeepL provider instance with Free tier key."""
return DeepLTranslationProvider(
api_key="test-free-key:fx",
use_cache=False,
)
def test_init_requires_api_key(self):
"""Test that initialization requires API key."""
with pytest.raises(ValueError, match="API key is required"):
DeepLTranslationProvider(api_key="")
def test_get_name(self, provider):
"""Test provider name."""
assert provider.get_name() == "deepl"
def test_detect_api_type_pro(self, provider):
"""Test Pro API key detection."""
assert provider._api_type == "pro"
def test_detect_api_type_free(self, provider_free):
"""Test Free API key detection."""
assert provider_free._api_type == "free"
def test_get_api_url_pro(self, provider):
"""Test Pro API URL."""
url = provider._get_api_url()
assert url == "https://api.deepl.com/v2/translate"
def test_get_api_url_free(self, provider_free):
"""Test Free API URL."""
url = provider_free._get_api_url()
assert url == "https://api-free.deepl.com/v2/translate"
def test_normalize_language_code_uppercase(self, provider):
"""Test language code normalization to uppercase."""
assert provider._normalize_language_code("en") == "EN-US"
assert provider._normalize_language_code("fr") == "FR"
assert provider._normalize_language_code("pt") == "PT-BR"
def test_normalize_language_code_preserves_variant(self, provider):
"""Test that language variants are preserved."""
assert provider._normalize_language_code("en-gb") == "EN-GB"
assert provider._normalize_language_code("en-us") == "EN-US"
assert provider._normalize_language_code("pt-pt") == "PT-PT"
def test_normalize_language_code_auto(self, provider):
"""Test auto language code handling."""
assert provider._normalize_language_code("auto") == ""
assert provider._normalize_language_code("") == ""
def test_is_language_supported(self, provider):
"""Test language support checking."""
assert provider._is_language_supported("en") is True
assert provider._is_language_supported("fr") is True
assert provider._is_language_supported("EN-US") is True
assert provider._is_language_supported("XX") is False
def test_translate_text_empty(self, provider):
"""Test translating empty text."""
request = TranslationRequest(text="", target_language="fr")
response = provider.translate_text(request)
assert response.translated_text == ""
assert response.provider_name == "deepl"
assert response.from_cache is False
def test_translate_text_whitespace(self, provider):
"""Test translating whitespace-only text."""
request = TranslationRequest(text=" ", target_language="fr")
response = provider.translate_text(request)
assert response.translated_text == " "
@patch("services.providers.deepl_provider.DeepLTranslationProvider._get_translator")
def test_translate_text_success(self, mock_get_translator, provider):
"""Test successful translation."""
mock_translator = MagicMock()
mock_translator.translate.return_value = "Bonjour"
mock_get_translator.return_value = mock_translator
request = TranslationRequest(text="Hello", target_language="fr")
response = provider.translate_text(request)
assert response.translated_text == "Bonjour"
assert response.provider_name == "deepl"
assert response.from_cache is False
@patch("services.providers.deepl_provider.DeepLTranslationProvider._get_translator")
def test_translate_text_with_source_language(self, mock_get_translator, provider):
"""Test translation with explicit source language."""
mock_translator = MagicMock()
mock_translator.translate.return_value = "Bonjour"
mock_get_translator.return_value = mock_translator
request = TranslationRequest(
text="Hello", target_language="fr", source_language="en"
)
response = provider.translate_text(request)
assert response.translated_text == "Bonjour"
def test_translate_text_same_language_skip(self, provider):
"""Test that translation is skipped when source == target."""
request = TranslationRequest(
text="Hello",
target_language="en",
source_language="en",
)
response = provider.translate_text(request)
assert response.translated_text == "Hello"
assert response.from_cache is False
@patch("services.providers.deepl_provider.DeepLTranslationProvider._get_translator")
def test_translate_text_error_fallback(self, mock_get_translator, provider):
"""Test that translation errors return original text and structured error."""
mock_get_translator.side_effect = Exception("API Error")
request = TranslationRequest(text="Hello", target_language="fr")
response = provider.translate_text(request)
assert response.translated_text == "Hello"
assert response.provider_name == "deepl"
assert response.error is not None
assert response.error_code is not None
def test_translate_batch_empty(self, provider):
"""Test batch translation with empty list."""
responses = provider.translate_batch([])
assert responses == []
@patch.object(DeepLTranslationProvider, "translate_text")
def test_translate_batch(self, mock_translate, provider):
"""Test batch translation."""
mock_translate.side_effect = [
TranslationResponse(translated_text="Bonjour", provider_name="deepl"),
TranslationResponse(translated_text="Monde", provider_name="deepl"),
]
requests = [
TranslationRequest(text="Hello", target_language="fr"),
TranslationRequest(text="World", target_language="fr"),
]
responses = provider.translate_batch(requests)
assert len(responses) == 2
assert responses[0].translated_text == "Bonjour"
assert responses[1].translated_text == "Monde"
def test_health_check(self, provider):
"""Test health check."""
status = provider.health_check()
assert status.name == "deepl"
assert isinstance(status.available, bool)
assert status.latency_ms is not None
class TestDeepLErrorCodes:
"""Tests for DeepL error code handling."""
@pytest.fixture
def provider(self):
"""Create a DeepL provider instance."""
return DeepLTranslationProvider(
api_key="test-key-12345",
use_cache=False,
max_retries=0,
)
@patch("services.providers.deepl_provider.DeepLTranslationProvider._get_translator")
def test_quota_exceeded_error(self, mock_get_translator, provider):
"""Test quota exceeded error handling."""
mock_get_translator.side_effect = Exception("quota exceeded")
request = TranslationRequest(text="Hello", target_language="fr")
response = provider.translate_text(request)
assert response.error_code == DEEPL_QUOTA_EXCEEDED
assert "quota" in response.error.lower() or "dépassé" in response.error.lower()
@patch("services.providers.deepl_provider.DeepLTranslationProvider._get_translator")
def test_invalid_key_error(self, mock_get_translator, provider):
"""Test invalid API key error handling."""
mock_get_translator.side_effect = Exception("403 Forbidden - invalid auth")
request = TranslationRequest(text="Hello", target_language="fr")
response = provider.translate_text(request)
assert response.error_code == DEEPL_INVALID_KEY
@patch("services.providers.deepl_provider.DeepLTranslationProvider._get_translator")
def test_unsupported_language_error(self, mock_get_translator, provider):
"""Test unsupported language error handling."""
mock_get_translator.side_effect = Exception("language not supported")
request = TranslationRequest(text="Hello", target_language="fr")
response = provider.translate_text(request)
assert response.error_code == DEEPL_UNSUPPORTED_LANGUAGE
def test_text_too_long_error(self, provider):
"""Test text too long error handling."""
long_text = "x" * (200 * 1024)
request = TranslationRequest(text=long_text, target_language="fr")
response = provider.translate_text(request)
assert response.error_code == DEEPL_TEXT_TOO_LONG
assert response.error_details is not None
assert "text_length" in response.error_details or "max_length" in response.error_details
@patch("services.providers.deepl_provider.DeepLTranslationProvider._get_translator")
def test_timeout_exception_maps_to_network_error(self, mock_get_translator, provider):
"""Test that socket.timeout and FuturesTimeoutError map to DEEPL_NETWORK_ERROR."""
mock_get_translator.side_effect = FuturesTimeoutError()
request = TranslationRequest(text="Hello", target_language="fr")
response = provider.translate_text(request)
assert response.error_code == DEEPL_NETWORK_ERROR
assert response.error is not None
@patch("services.providers.deepl_provider.DeepLTranslationProvider._get_translator")
def test_socket_timeout_maps_to_network_error(self, mock_get_translator, provider):
"""Test that socket.timeout maps to DEEPL_NETWORK_ERROR."""
mock_get_translator.side_effect = socket.timeout("timed out")
request = TranslationRequest(text="Hello", target_language="fr")
response = provider.translate_text(request)
assert response.error_code == DEEPL_NETWORK_ERROR
class TestDeepLProviderCaching:
"""Tests for DeepL provider caching functionality."""
@pytest.fixture
def mock_cache(self):
"""Create a mock cache."""
cache = MagicMock()
cache.get.return_value = None
return cache
def test_cache_hit(self, mock_cache):
"""Test that cache hits return cached result."""
mock_cache.get.return_value = "Cached Translation"
provider = DeepLTranslationProvider(
api_key="test-key-12345",
use_cache=True,
)
provider._cache = mock_cache
request = TranslationRequest(text="Hello", target_language="fr")
response = provider.translate_text(request)
assert response.translated_text == "Cached Translation"
assert response.from_cache is True
@patch("services.providers.deepl_provider.DeepLTranslationProvider._get_translator")
def test_cache_set_on_miss(self, mock_get_translator, mock_cache):
"""Test that translations are cached on miss."""
mock_translator = MagicMock()
mock_translator.translate.return_value = "Bonjour"
mock_get_translator.return_value = mock_translator
provider = DeepLTranslationProvider(
api_key="test-key-12345",
use_cache=True,
)
provider._cache = mock_cache
request = TranslationRequest(text="Hello", target_language="fr")
provider.translate_text(request)
mock_cache.set.assert_called_once()
class TestDeepLProviderRetry:
"""Tests for DeepL provider retry logic."""
@pytest.fixture
def provider(self):
"""Create a DeepL provider with retry enabled."""
return DeepLTranslationProvider(
api_key="test-key-12345",
use_cache=False,
max_retries=2,
retry_delay=0.01,
)
@patch("services.providers.deepl_provider.DeepLTranslationProvider._get_translator")
def test_retry_on_network_error(self, mock_get_translator, provider):
"""Test that network errors trigger retry."""
mock_translator = MagicMock()
mock_translator.translate.side_effect = [
Exception("timeout"),
"Bonjour",
]
mock_get_translator.return_value = mock_translator
request = TranslationRequest(text="Hello", target_language="fr")
response = provider.translate_text(request)
assert response.translated_text == "Bonjour"
assert mock_translator.translate.call_count == 2
@patch("services.providers.deepl_provider.DeepLTranslationProvider._get_translator")
def test_no_retry_on_invalid_key(self, mock_get_translator, provider):
"""Test that invalid key errors do not trigger retry."""
mock_translator = MagicMock()
mock_translator.translate.side_effect = Exception("401 invalid auth")
mock_get_translator.return_value = mock_translator
request = TranslationRequest(text="Hello", target_language="fr")
provider.translate_text(request)
assert mock_translator.translate.call_count == 1
class TestDeepLProviderSingleton:
"""Tests for DeepL provider singleton functions."""
def test_get_deepl_provider_no_config(self):
"""Test get_deepl_provider returns None without config."""
import services.providers.deepl_provider as deepl_module
deepl_module._provider_instance = None
with patch("services.providers.config.ProvidersConfig") as mock_config:
mock_config.DEEPL_API_KEY = ""
result = deepl_module.get_deepl_provider()
assert result is None
def test_get_deepl_provider_with_config(self):
"""Test get_deepl_provider creates instance with config."""
import services.providers.deepl_provider as deepl_module
deepl_module._provider_instance = None
with patch("services.providers.config.ProvidersConfig") as mock_config:
mock_config.DEEPL_API_KEY = "test-key:fx"
mock_config.DEEPL_TIMEOUT = 30
mock_config.DEEPL_MAX_RETRIES = 3
mock_config.DEEPL_RETRY_DELAY = 1.0
provider = deepl_module.get_deepl_provider()
assert provider is not None
assert provider._api_type == "free"
deepl_module._provider_instance = None
class TestDeepLRegistryIntegration:
"""Tests for DeepL provider registry integration."""
def test_register_deepl_provider(self):
"""Test provider registration."""
from services.providers.registry import registry
registry.unregister("deepl")
with patch("services.providers.deepl_provider.get_deepl_provider") as mock_get:
mock_provider = MagicMock()
mock_get.return_value = mock_provider
from services.providers.deepl_provider import register_deepl_provider
result = register_deepl_provider()
assert result == mock_provider
assert "deepl" in registry
registry.unregister("deepl")
def test_register_deepl_provider_no_config(self):
"""Test provider registration when not configured."""
from services.providers.registry import registry
registry.unregister("deepl")
with patch("services.providers.deepl_provider.get_deepl_provider") as mock_get:
mock_get.return_value = None
from services.providers.deepl_provider import register_deepl_provider
result = register_deepl_provider()
assert result is None
assert "deepl" not in registry
class TestLegacyDeepLAdapter:
"""Tests for LegacyDeepLAdapter."""
def test_adapter_not_configured(self):
"""Test adapter when DeepL is not configured."""
with patch("services.providers.deepl_provider.get_deepl_provider") as mock_get:
mock_get.return_value = None
from services.providers.deepl_provider import LegacyDeepLAdapter
adapter = LegacyDeepLAdapter()
with pytest.raises(Exception) as exc_info:
adapter.translate("Hello", "fr")
assert "not configured" in str(exc_info.value).lower()

View File

@@ -589,7 +589,7 @@ class TestURLIngestionIntegration:
"extra_credits": 0, "extra_credits": 0,
"max_pages_per_doc": 50, "max_pages_per_doc": 50,
"max_file_size_mb": 10, "max_file_size_mb": 10,
"allowed_providers": ["google", "deepl"], "allowed_providers": ["google"],
}, },
) )
@@ -616,7 +616,7 @@ class TestURLIngestionIntegration:
"extra_credits": 0, "extra_credits": 0,
"max_pages_per_doc": 50, "max_pages_per_doc": 50,
"max_file_size_mb": 10, "max_file_size_mb": 10,
"allowed_providers": ["google", "deepl"], "allowed_providers": ["google"],
}, },
) )
@@ -652,7 +652,7 @@ class TestURLIngestionIntegration:
"extra_credits": 0, "extra_credits": 0,
"max_pages_per_doc": 50, "max_pages_per_doc": 50,
"max_file_size_mb": 10, "max_file_size_mb": 10,
"allowed_providers": ["google", "deepl"], "allowed_providers": ["google"],
}, },
) )
@@ -690,7 +690,7 @@ class TestURLIngestionIntegration:
"extra_credits": 0, "extra_credits": 0,
"max_pages_per_doc": 50, "max_pages_per_doc": 50,
"max_file_size_mb": 10, "max_file_size_mb": 10,
"allowed_providers": ["google", "deepl"], "allowed_providers": ["google"],
}, },
) )
@@ -728,7 +728,7 @@ class TestURLIngestionIntegration:
"extra_credits": 0, "extra_credits": 0,
"max_pages_per_doc": 50, "max_pages_per_doc": 50,
"max_file_size_mb": 10, "max_file_size_mb": 10,
"allowed_providers": ["google", "deepl"], "allowed_providers": ["google"],
}, },
) )
@@ -764,7 +764,7 @@ class TestURLIngestionIntegration:
"extra_credits": 0, "extra_credits": 0,
"max_pages_per_doc": 50, "max_pages_per_doc": 50,
"max_file_size_mb": 10, "max_file_size_mb": 10,
"allowed_providers": ["google", "deepl"], "allowed_providers": ["google"],
}, },
) )
@@ -800,7 +800,7 @@ class TestURLIngestionIntegration:
"extra_credits": 0, "extra_credits": 0,
"max_pages_per_doc": 50, "max_pages_per_doc": 50,
"max_file_size_mb": 10, "max_file_size_mb": 10,
"allowed_providers": ["google", "deepl"], "allowed_providers": ["google"],
}, },
) )

View File

@@ -125,7 +125,7 @@ def client(users_file: Path, monkeypatch):
"extra_credits": 0, "extra_credits": 0,
"max_pages_per_doc": 50, "max_pages_per_doc": 50,
"max_file_size_mb": 10, "max_file_size_mb": 10,
"allowed_providers": ["google", "deepl"], "allowed_providers": ["google"],
} }
monkeypatch.setattr( monkeypatch.setattr(
@@ -483,7 +483,7 @@ class TestQuotaExceeded:
"extra_credits": 0, "extra_credits": 0,
"max_pages_per_doc": 50, "max_pages_per_doc": 50,
"max_file_size_mb": 10, "max_file_size_mb": 10,
"allowed_providers": ["google", "deepl"], "allowed_providers": ["google"],
} }
monkeypatch.setattr( monkeypatch.setattr(
@@ -535,7 +535,7 @@ class TestQuotaExceeded:
"extra_credits": 0, "extra_credits": 0,
"max_pages_per_doc": 50, "max_pages_per_doc": 50,
"max_file_size_mb": 10, "max_file_size_mb": 10,
"allowed_providers": ["google", "deepl"], "allowed_providers": ["google"],
} }
monkeypatch.setattr( monkeypatch.setattr(

View File

@@ -48,7 +48,7 @@ class TestMakeCacheKey:
def test_different_provider_different_key(self): def test_different_provider_different_key(self):
k1 = make_cache_key("Hello", "fr", "en", "google") k1 = make_cache_key("Hello", "fr", "en", "google")
k2 = make_cache_key("Hello", "fr", "en", "deepl") k2 = make_cache_key("Hello", "fr", "en", "openai")
assert k1 != k2 assert k1 != k2
def test_different_text_different_key(self): def test_different_text_different_key(self):

View File

@@ -111,11 +111,6 @@ _PROVIDER_ERROR_HTTP_STATUS = {
"GOOGLE_NETWORK_ERROR": 502, "GOOGLE_NETWORK_ERROR": 502,
"GOOGLE_UNSUPPORTED_LANGUAGE": 400, "GOOGLE_UNSUPPORTED_LANGUAGE": 400,
"GOOGLE_TEXT_TOO_LONG": 413, "GOOGLE_TEXT_TOO_LONG": 413,
"DEEPL_QUOTA_EXCEEDED": 429,
"DEEPL_INVALID_KEY": 401,
"DEEPL_NETWORK_ERROR": 502,
"DEEPL_UNSUPPORTED_LANGUAGE": 400,
"DEEPL_TEXT_TOO_LONG": 413,
"ALL_PROVIDERS_FAILED": 502, "ALL_PROVIDERS_FAILED": 502,
} }