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
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:
@@ -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 ==============
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|
||||||
|
|||||||
42
frontend/src/app/dashboard/DashboardNavLinks.tsx
Normal file
42
frontend/src/app/dashboard/DashboardNavLinks.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'}`}>−{annualDiscountPercent} %</span>
|
<span className={`ml-2 transition-colors ${isYearly ? 'text-accent' : 'text-accent/60'}`}>−{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} €</h3>
|
<h3 className="text-4xl font-serif font-medium tracking-tight text-brand-dark dark:text-white">{price} €</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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"""
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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"],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user