Merge pull request #729 from christianlouis/copilot/add-default-language-version-support
feat(translation): automatic document translation to configurable default language
This commit is contained in:
@@ -43,6 +43,7 @@ from app.api.shared_links import public_router as shared_links_public_router
|
||||
from app.api.shared_links import router as shared_links_router
|
||||
from app.api.similarity import router as similarity_router
|
||||
from app.api.subscriptions import router as subscriptions_router
|
||||
from app.api.translation import router as translation_router
|
||||
from app.api.url_upload import router as url_upload_router
|
||||
|
||||
# Import all the individual routers
|
||||
@@ -96,3 +97,4 @@ router.include_router(audit_logs_router)
|
||||
router.include_router(i18n_router)
|
||||
router.include_router(mobile_router)
|
||||
router.include_router(compliance_router)
|
||||
router.include_router(translation_router)
|
||||
|
||||
@@ -99,6 +99,8 @@ class ProfileResponse(BaseModel):
|
||||
contact_email: str | None
|
||||
preferred_language: str | None
|
||||
preferred_theme: str | None
|
||||
default_document_language: str | None
|
||||
"""ISO 639-1 code for the user's preferred document translation target language."""
|
||||
avatar_url: str
|
||||
"""Gravatar URL or ``data:`` URI for a custom uploaded avatar."""
|
||||
is_local_user: bool
|
||||
@@ -112,6 +114,10 @@ class ProfileUpdateRequest(BaseModel):
|
||||
contact_email: str | None = Field(default=None, max_length=255, description="Contact / notification e-mail")
|
||||
preferred_language: str | None = Field(default=None, description="ISO 639-1 language code, e.g. 'en', 'de'")
|
||||
preferred_theme: str | None = Field(default=None, description="Colour scheme: 'light', 'dark', or 'system'")
|
||||
default_document_language: str | None = Field(
|
||||
default=None,
|
||||
description="ISO 639-1 code for the default document translation target language, e.g. 'en', 'de'",
|
||||
)
|
||||
|
||||
|
||||
class ChangePasswordRequest(BaseModel):
|
||||
@@ -149,6 +155,7 @@ async def get_profile(request: Request, db: DbSession) -> ProfileResponse:
|
||||
contact_email=profile.contact_email, # type: ignore[arg-type]
|
||||
preferred_language=profile.preferred_language, # type: ignore[arg-type]
|
||||
preferred_theme=profile.preferred_theme, # type: ignore[arg-type]
|
||||
default_document_language=profile.default_document_language, # type: ignore[arg-type]
|
||||
avatar_url=avatar_url,
|
||||
is_local_user=is_local,
|
||||
)
|
||||
@@ -201,6 +208,16 @@ async def update_profile(
|
||||
)
|
||||
profile.preferred_theme = theme or None # type: ignore[assignment]
|
||||
|
||||
# Validate default document language
|
||||
if body.default_document_language is not None:
|
||||
doc_lang = body.default_document_language.lower().strip()
|
||||
if doc_lang and doc_lang not in SUPPORTED_LANGUAGE_CODES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Unsupported language code: {doc_lang}",
|
||||
)
|
||||
profile.default_document_language = doc_lang or None # type: ignore[assignment]
|
||||
|
||||
if body.display_name is not None:
|
||||
profile.display_name = body.display_name.strip() or None # type: ignore[assignment]
|
||||
|
||||
@@ -225,6 +242,7 @@ async def update_profile(
|
||||
contact_email=profile.contact_email, # type: ignore[arg-type]
|
||||
preferred_language=profile.preferred_language, # type: ignore[arg-type]
|
||||
preferred_theme=profile.preferred_theme, # type: ignore[arg-type]
|
||||
default_document_language=profile.default_document_language, # type: ignore[arg-type]
|
||||
avatar_url=avatar_url,
|
||||
is_local_user=is_local,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
API endpoints for document translation.
|
||||
|
||||
Provides on-the-fly translation via the AI provider and access to the
|
||||
persisted default-language translation.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import require_login
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.models import FileRecord
|
||||
from app.utils.ai_provider import get_ai_provider
|
||||
from app.utils.user_scope import apply_owner_filter, get_current_owner_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
|
||||
# Maximum characters sent to the AI provider for a single translation request.
|
||||
_MAX_TRANSLATION_INPUT = 50_000
|
||||
|
||||
|
||||
def _get_file_or_404(db: Session, file_id: int, request: Request) -> FileRecord:
|
||||
"""Fetch a FileRecord visible to the current user or raise 404."""
|
||||
query = db.query(FileRecord).filter(FileRecord.id == file_id)
|
||||
owner_id = get_current_owner_id(request)
|
||||
if owner_id:
|
||||
query = apply_owner_filter(query, owner_id, FileRecord)
|
||||
record = query.first()
|
||||
if not record:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||
return record
|
||||
|
||||
|
||||
@router.get("/files/{file_id}/translation/default")
|
||||
@require_login
|
||||
def get_default_translation(
|
||||
request: Request,
|
||||
file_id: int,
|
||||
db: DbSession,
|
||||
) -> JSONResponse:
|
||||
"""Return the persisted default-language translation for a document.
|
||||
|
||||
Returns 404 if no default-language translation has been generated yet
|
||||
(e.g. because the document is already in the default language).
|
||||
"""
|
||||
record = _get_file_or_404(db, file_id, request)
|
||||
|
||||
if not record.default_language_text:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No default-language translation available for this file",
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"file_id": record.id,
|
||||
"detected_language": record.detected_language,
|
||||
"default_language_code": record.default_language_code,
|
||||
"text": record.default_language_text,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/files/{file_id}/translate")
|
||||
@require_login
|
||||
def translate_on_the_fly(
|
||||
request: Request,
|
||||
file_id: int,
|
||||
db: DbSession,
|
||||
lang: str = Query(..., min_length=2, max_length=10, description="Target language ISO 639-1 code"),
|
||||
) -> JSONResponse:
|
||||
"""Translate a document's extracted text into an arbitrary language on the fly.
|
||||
|
||||
The translation is generated via the configured AI provider and is **not**
|
||||
persisted. For the default-language translation, use the
|
||||
``/files/{file_id}/translation/default`` endpoint instead.
|
||||
"""
|
||||
record = _get_file_or_404(db, file_id, request)
|
||||
|
||||
source_text = record.ocr_text
|
||||
if not source_text:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No extracted text available for this file — translation requires OCR text",
|
||||
)
|
||||
|
||||
# If the requested language matches what is already stored, return it directly.
|
||||
if record.default_language_code and lang == record.default_language_code and record.default_language_text:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"file_id": record.id,
|
||||
"source_language": record.detected_language,
|
||||
"target_language": lang,
|
||||
"text": record.default_language_text,
|
||||
"cached": True,
|
||||
}
|
||||
)
|
||||
|
||||
# If the detected language already matches, return the original text.
|
||||
detected = record.detected_language
|
||||
if detected and detected == lang:
|
||||
return JSONResponse(
|
||||
content={
|
||||
"file_id": record.id,
|
||||
"source_language": detected,
|
||||
"target_language": lang,
|
||||
"text": source_text,
|
||||
"cached": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Truncate to keep AI costs bounded.
|
||||
text_to_translate = source_text[:_MAX_TRANSLATION_INPUT]
|
||||
|
||||
try:
|
||||
provider = get_ai_provider()
|
||||
model = settings.ai_model or settings.openai_model
|
||||
translated = provider.chat_completion(
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
f"You are a professional translator. Translate the following text "
|
||||
f"into {lang}. Preserve the original formatting, paragraph structure, "
|
||||
f"and meaning. Do not add any commentary — output ONLY the translated text."
|
||||
),
|
||||
},
|
||||
{"role": "user", "content": text_to_translate},
|
||||
],
|
||||
model=model,
|
||||
temperature=0.3,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception(f"On-the-fly translation failed for file {file_id}: {exc}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="Translation failed — the AI provider returned an error",
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"file_id": record.id,
|
||||
"source_language": detected or "unknown",
|
||||
"target_language": lang,
|
||||
"text": translated,
|
||||
"cached": False,
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user