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:
@@ -258,6 +258,13 @@ OPENAI_MODEL=gpt-4o-mini
|
|||||||
# AZURE_OPENAI_API_VERSION=2024-02-01
|
# AZURE_OPENAI_API_VERSION=2024-02-01
|
||||||
# AI_MODEL=gpt-4o # deployment name in Azure
|
# AI_MODEL=gpt-4o # deployment name in Azure
|
||||||
|
|
||||||
|
# **Document Translation**
|
||||||
|
# After processing, documents whose detected language differs from the default
|
||||||
|
# target language are automatically translated. Only the original and this
|
||||||
|
# default-language version are persisted; other translations are on-the-fly.
|
||||||
|
# Users can override this in their profile settings.
|
||||||
|
# DEFAULT_DOCUMENT_LANGUAGE=en
|
||||||
|
|
||||||
# Azure Document Intelligence (OCR – separate from AI provider above)
|
# Azure Document Intelligence (OCR – separate from AI provider above)
|
||||||
# **Email Settings (shared SMTP – password reset, verification, and system notifications)**
|
# **Email Settings (shared SMTP – password reset, verification, and system notifications)**
|
||||||
EMAIL_HOST=smtp.example.com
|
EMAIL_HOST=smtp.example.com
|
||||||
|
|||||||
@@ -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.shared_links import router as shared_links_router
|
||||||
from app.api.similarity import router as similarity_router
|
from app.api.similarity import router as similarity_router
|
||||||
from app.api.subscriptions import router as subscriptions_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
|
from app.api.url_upload import router as url_upload_router
|
||||||
|
|
||||||
# Import all the individual routers
|
# Import all the individual routers
|
||||||
@@ -96,3 +97,4 @@ router.include_router(audit_logs_router)
|
|||||||
router.include_router(i18n_router)
|
router.include_router(i18n_router)
|
||||||
router.include_router(mobile_router)
|
router.include_router(mobile_router)
|
||||||
router.include_router(compliance_router)
|
router.include_router(compliance_router)
|
||||||
|
router.include_router(translation_router)
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ class ProfileResponse(BaseModel):
|
|||||||
contact_email: str | None
|
contact_email: str | None
|
||||||
preferred_language: str | None
|
preferred_language: str | None
|
||||||
preferred_theme: 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
|
avatar_url: str
|
||||||
"""Gravatar URL or ``data:`` URI for a custom uploaded avatar."""
|
"""Gravatar URL or ``data:`` URI for a custom uploaded avatar."""
|
||||||
is_local_user: bool
|
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")
|
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_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'")
|
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):
|
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]
|
contact_email=profile.contact_email, # type: ignore[arg-type]
|
||||||
preferred_language=profile.preferred_language, # type: ignore[arg-type]
|
preferred_language=profile.preferred_language, # type: ignore[arg-type]
|
||||||
preferred_theme=profile.preferred_theme, # 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,
|
avatar_url=avatar_url,
|
||||||
is_local_user=is_local,
|
is_local_user=is_local,
|
||||||
)
|
)
|
||||||
@@ -201,6 +208,16 @@ async def update_profile(
|
|||||||
)
|
)
|
||||||
profile.preferred_theme = theme or None # type: ignore[assignment]
|
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:
|
if body.display_name is not None:
|
||||||
profile.display_name = body.display_name.strip() or None # type: ignore[assignment]
|
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]
|
contact_email=profile.contact_email, # type: ignore[arg-type]
|
||||||
preferred_language=profile.preferred_language, # type: ignore[arg-type]
|
preferred_language=profile.preferred_language, # type: ignore[arg-type]
|
||||||
preferred_theme=profile.preferred_theme, # 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,
|
avatar_url=avatar_url,
|
||||||
is_local_user=is_local,
|
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,
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -39,6 +39,7 @@ from app.tasks.refine_text_with_gpt import refine_text_with_gpt # noqa: F401
|
|||||||
from app.tasks.rotate_pdf_pages import rotate_pdf_pages # noqa: F401
|
from app.tasks.rotate_pdf_pages import rotate_pdf_pages # noqa: F401
|
||||||
from app.tasks.send_to_all import send_to_all_destinations # noqa: F401
|
from app.tasks.send_to_all import send_to_all_destinations # noqa: F401
|
||||||
from app.tasks.subscription_tasks import apply_pending_subscription_changes_all # noqa: F401
|
from app.tasks.subscription_tasks import apply_pending_subscription_changes_all # noqa: F401
|
||||||
|
from app.tasks.translate_to_default_language import translate_to_default_language # noqa: F401
|
||||||
|
|
||||||
# Import new send tasks
|
# Import new send tasks
|
||||||
from app.tasks.upload_to_dropbox import upload_to_dropbox # noqa: F401
|
from app.tasks.upload_to_dropbox import upload_to_dropbox # noqa: F401
|
||||||
|
|||||||
@@ -166,6 +166,25 @@ class Settings(BaseSettings):
|
|||||||
google_docai_location: str = "us" # Processor location, e.g. "us" or "eu"
|
google_docai_location: str = "us" # Processor location, e.g. "us" or "eu"
|
||||||
external_hostname: str = "localhost" # Default to localhost
|
external_hostname: str = "localhost" # Default to localhost
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Document Translation Settings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Default target language for automatic document translation (ISO 639-1 code).
|
||||||
|
# After OCR / metadata extraction, if the detected document language differs
|
||||||
|
# from this value the system translates the extracted text into this language
|
||||||
|
# and stores it alongside the original. Other language translations are
|
||||||
|
# generated on the fly via the AI provider and are NOT persisted.
|
||||||
|
# Per-user overrides are stored in UserProfile.default_document_language.
|
||||||
|
default_document_language: str = Field(
|
||||||
|
default="en",
|
||||||
|
description=(
|
||||||
|
"ISO 639-1 language code for the default translation target "
|
||||||
|
"(e.g. 'en', 'de', 'fr'). Documents whose detected language "
|
||||||
|
"differs are automatically translated into this language after "
|
||||||
|
"processing. Default: 'en' (English)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# Authentication settings
|
# Authentication settings
|
||||||
auth_enabled: bool = True # Default to enabled
|
auth_enabled: bool = True # Default to enabled
|
||||||
admin_username: Optional[str] = None
|
admin_username: Optional[str] = None
|
||||||
|
|||||||
@@ -84,6 +84,19 @@ class FileRecord(Base):
|
|||||||
# Processing pipeline assigned to this file (NULL = use system default)
|
# Processing pipeline assigned to this file (NULL = use system default)
|
||||||
pipeline_id = Column(Integer, ForeignKey(_PIPELINES_ID_FK), nullable=True, index=True)
|
pipeline_id = Column(Integer, ForeignKey(_PIPELINES_ID_FK), nullable=True, index=True)
|
||||||
|
|
||||||
|
# Detected document language (ISO 639-1 code, e.g. "de", "en", "fr")
|
||||||
|
# Extracted from AI metadata during processing; cached here for fast access.
|
||||||
|
detected_language = Column(String(10), nullable=True)
|
||||||
|
|
||||||
|
# Default-language translation of the extracted text.
|
||||||
|
# Stored when the detected language differs from the user's/system default
|
||||||
|
# document language. Only the original text and this translation are persisted;
|
||||||
|
# other languages are translated on the fly via the AI provider.
|
||||||
|
default_language_text = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# ISO 639-1 code of the default-language translation stored above (e.g. "en").
|
||||||
|
default_language_code = Column(String(10), nullable=True)
|
||||||
|
|
||||||
# Timestamp when we inserted this record
|
# Timestamp when we inserted this record
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||||
|
|
||||||
@@ -282,6 +295,12 @@ class UserProfile(Base):
|
|||||||
# NULL means "auto-detect from browser Accept-Language header"
|
# NULL means "auto-detect from browser Accept-Language header"
|
||||||
preferred_language = Column(String(10), nullable=True)
|
preferred_language = Column(String(10), nullable=True)
|
||||||
|
|
||||||
|
# Default document language for translated versions (ISO 639-1 code).
|
||||||
|
# When a document's detected language differs from this value, the system
|
||||||
|
# automatically generates and stores a translation into this language.
|
||||||
|
# NULL means "use the global DEFAULT_DOCUMENT_LANGUAGE setting".
|
||||||
|
default_document_language = Column(String(10), nullable=True)
|
||||||
|
|
||||||
# UI colour scheme preference: "light" | "dark" | "system" (NULL = "system")
|
# UI colour scheme preference: "light" | "dark" | "system" (NULL = "system")
|
||||||
preferred_theme = Column(String(10), nullable=True)
|
preferred_theme = Column(String(10), nullable=True)
|
||||||
|
|
||||||
|
|||||||
@@ -216,6 +216,30 @@ def embed_metadata_into_pdf(self, local_file_path: str, extracted_text: str, met
|
|||||||
except Exception as search_exc:
|
except Exception as search_exc:
|
||||||
logger.warning(f"[{task_id}] Meilisearch indexing failed (non-fatal): {search_exc}")
|
logger.warning(f"[{task_id}] Meilisearch indexing failed (non-fatal): {search_exc}")
|
||||||
|
|
||||||
|
# Cache the detected language on the FileRecord and trigger
|
||||||
|
# default-language translation when the document is in a
|
||||||
|
# different language.
|
||||||
|
detected_lang = metadata.get("language") if metadata else None
|
||||||
|
if detected_lang and extracted_text:
|
||||||
|
try:
|
||||||
|
file_record.detected_language = detected_lang
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
from app.tasks.translate_to_default_language import translate_to_default_language
|
||||||
|
|
||||||
|
translate_to_default_language.delay(
|
||||||
|
file_id,
|
||||||
|
extracted_text,
|
||||||
|
detected_lang,
|
||||||
|
owner_id=file_record.owner_id,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"[{task_id}] Queued default-language translation for file {file_id} "
|
||||||
|
f"(detected: {detected_lang})"
|
||||||
|
)
|
||||||
|
except Exception as trans_exc:
|
||||||
|
logger.warning(f"[{task_id}] Could not queue translation task (non-fatal): {trans_exc}")
|
||||||
|
|
||||||
# Persist the metadata into a JSON file with the same base name.
|
# Persist the metadata into a JSON file with the same base name.
|
||||||
# Include file path references for traceability
|
# Include file path references for traceability
|
||||||
logger.info(f"[{task_id}] Persisting metadata to JSON")
|
logger.info(f"[{task_id}] Persisting metadata to JSON")
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Celery task to translate extracted document text into the default target language.
|
||||||
|
|
||||||
|
This task is triggered after metadata extraction when the detected document
|
||||||
|
language differs from the user's (or system) default document language. The
|
||||||
|
translated text is persisted in ``FileRecord.default_language_text`` so that
|
||||||
|
users can always read a reference copy in their preferred language.
|
||||||
|
|
||||||
|
Other ad-hoc translations are generated on the fly via the ``/api/files/{id}/translate``
|
||||||
|
endpoint and are NOT persisted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.celery_app import celery
|
||||||
|
from app.config import settings
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.models import FileRecord, UserProfile
|
||||||
|
from app.tasks.retry_config import BaseTaskWithRetry
|
||||||
|
from app.utils import log_task_progress
|
||||||
|
from app.utils.ai_provider import get_ai_provider
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_default_language(owner_id: str | None) -> str:
|
||||||
|
"""Return the default document language for the given owner.
|
||||||
|
|
||||||
|
Resolution order:
|
||||||
|
1. ``UserProfile.default_document_language`` (per-user override)
|
||||||
|
2. ``settings.default_document_language`` (global setting)
|
||||||
|
"""
|
||||||
|
if owner_id:
|
||||||
|
with SessionLocal() as db:
|
||||||
|
profile = db.query(UserProfile).filter_by(user_id=owner_id).first()
|
||||||
|
if profile and profile.default_document_language:
|
||||||
|
return profile.default_document_language
|
||||||
|
return settings.default_document_language
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(base=BaseTaskWithRetry, bind=True)
|
||||||
|
def translate_to_default_language(
|
||||||
|
self,
|
||||||
|
file_id: int,
|
||||||
|
extracted_text: str,
|
||||||
|
detected_language: str,
|
||||||
|
owner_id: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Translate *extracted_text* into the default document language and persist the result.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_id: Primary key of the :class:`FileRecord`.
|
||||||
|
extracted_text: The OCR / refined text in the document's original language.
|
||||||
|
detected_language: ISO 639-1 code of the document's detected language.
|
||||||
|
owner_id: Owner identifier used to resolve per-user language preference.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict with ``status``, ``target_language``, and the translated text length.
|
||||||
|
"""
|
||||||
|
task_id = self.request.id
|
||||||
|
target_language = _resolve_default_language(owner_id)
|
||||||
|
|
||||||
|
# Nothing to do when the document is already in the target language.
|
||||||
|
if detected_language == target_language:
|
||||||
|
logger.info(
|
||||||
|
f"[{task_id}] Document {file_id} already in target language '{target_language}', skipping translation"
|
||||||
|
)
|
||||||
|
log_task_progress(
|
||||||
|
task_id,
|
||||||
|
"translate_to_default_language",
|
||||||
|
"skipped",
|
||||||
|
f"Document already in {target_language}",
|
||||||
|
file_id=file_id,
|
||||||
|
)
|
||||||
|
return {"status": "skipped", "reason": "already_in_target_language"}
|
||||||
|
|
||||||
|
logger.info(f"[{task_id}] Translating document {file_id} from '{detected_language}' to '{target_language}'")
|
||||||
|
log_task_progress(
|
||||||
|
task_id,
|
||||||
|
"translate_to_default_language",
|
||||||
|
"in_progress",
|
||||||
|
f"Translating from {detected_language} to {target_language}",
|
||||||
|
file_id=file_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
provider = get_ai_provider()
|
||||||
|
model = settings.ai_model or settings.openai_model
|
||||||
|
translated_text = provider.chat_completion(
|
||||||
|
messages=[
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": (
|
||||||
|
f"You are a professional translator. Translate the following text "
|
||||||
|
f"from {detected_language} to {target_language}. "
|
||||||
|
f"Preserve the original formatting, paragraph structure, and meaning. "
|
||||||
|
f"Do not add any commentary or explanation — output ONLY the translated text."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{"role": "user", "content": extracted_text},
|
||||||
|
],
|
||||||
|
model=model,
|
||||||
|
temperature=0.3,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Persist the translation.
|
||||||
|
with SessionLocal() as db:
|
||||||
|
record = db.query(FileRecord).filter_by(id=file_id).first()
|
||||||
|
if record:
|
||||||
|
record.default_language_text = translated_text
|
||||||
|
record.default_language_code = target_language
|
||||||
|
record.detected_language = detected_language
|
||||||
|
db.commit()
|
||||||
|
logger.info(
|
||||||
|
f"[{task_id}] Stored default-language translation ({len(translated_text)} chars) for file {file_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
log_task_progress(
|
||||||
|
task_id,
|
||||||
|
"translate_to_default_language",
|
||||||
|
"success",
|
||||||
|
f"Translated {len(extracted_text)} → {len(translated_text)} chars ({detected_language} → {target_language})",
|
||||||
|
file_id=file_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"target_language": target_language,
|
||||||
|
"translated_length": len(translated_text),
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception(f"[{task_id}] Translation failed for file {file_id}: {exc}")
|
||||||
|
log_task_progress(
|
||||||
|
task_id,
|
||||||
|
"translate_to_default_language",
|
||||||
|
"failure",
|
||||||
|
f"Exception: {exc}",
|
||||||
|
file_id=file_id,
|
||||||
|
)
|
||||||
|
raise
|
||||||
@@ -522,6 +522,19 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": False,
|
"restart_required": False,
|
||||||
},
|
},
|
||||||
|
# Document Translation
|
||||||
|
"default_document_language": {
|
||||||
|
"category": "AI Services",
|
||||||
|
"description": (
|
||||||
|
"ISO 639-1 language code for the default document translation target "
|
||||||
|
"(e.g. 'en', 'de', 'fr'). Documents whose detected language differs "
|
||||||
|
"are automatically translated into this language after processing."
|
||||||
|
),
|
||||||
|
"type": "string",
|
||||||
|
"sensitive": False,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": False,
|
||||||
|
},
|
||||||
# OCR Engine Configuration
|
# OCR Engine Configuration
|
||||||
"ocr_providers": {
|
"ocr_providers": {
|
||||||
"category": "OCR Engines",
|
"category": "OCR Engines",
|
||||||
|
|||||||
@@ -832,6 +832,34 @@ def get_processed_text(request: Request, file_id: int, db: Session = Depends(get
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/files/{file_id}/text/default-language")
|
||||||
|
@require_login
|
||||||
|
def get_default_language_text(request: Request, file_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""Return the persisted default-language translation for the file view."""
|
||||||
|
from fastapi import status
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from app.models import FileRecord
|
||||||
|
|
||||||
|
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||||
|
if not file_record:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=_FILE_NOT_FOUND)
|
||||||
|
|
||||||
|
if not file_record.default_language_text:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="No default-language translation available",
|
||||||
|
)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"text": file_record.default_language_text,
|
||||||
|
"language_code": file_record.default_language_code,
|
||||||
|
"detected_language": file_record.detected_language,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/duplicates")
|
@router.get("/duplicates")
|
||||||
@require_login
|
@require_login
|
||||||
def duplicates_page(
|
def duplicates_page(
|
||||||
|
|||||||
@@ -934,6 +934,48 @@ OPENAI_API_KEY=sk-ant-... # passed as the api_key to LiteLLM
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Document Translation
|
||||||
|
|
||||||
|
After processing, DocuElevate can automatically translate a document's extracted text into a configurable *default language* (e.g. English). This reference translation is stored alongside the original text so users always have a version in a language they understand.
|
||||||
|
|
||||||
|
Other languages are translated **on the fly** via the AI provider and are not persisted.
|
||||||
|
|
||||||
|
#### Settings
|
||||||
|
|
||||||
|
| **Variable** | **Description** | **Default** |
|
||||||
|
|------------------------------|-----------------------------------------------------------------------------------------------------------|-------------|
|
||||||
|
| `DEFAULT_DOCUMENT_LANGUAGE` | ISO 639-1 code for the default translation target (e.g. `en`, `de`, `fr`). Documents whose detected language differs are automatically translated into this language after processing. | `en` |
|
||||||
|
|
||||||
|
Each user can override this global default in their profile (`UserProfile.default_document_language`).
|
||||||
|
|
||||||
|
#### How It Works
|
||||||
|
|
||||||
|
1. During metadata extraction the AI detects the document language (stored as `detected_language` on the file record).
|
||||||
|
2. If the detected language differs from the default target language, a background Celery task (`translate_to_default_language`) translates the extracted text.
|
||||||
|
3. The translated text is persisted in `default_language_text` and the target code in `default_language_code`.
|
||||||
|
4. The file detail view shows both the original text and the default-language version.
|
||||||
|
5. Users can also request on-the-fly translations to any language via the **Translate** dropdown.
|
||||||
|
|
||||||
|
#### API Endpoints
|
||||||
|
|
||||||
|
| **Endpoint** | **Method** | **Description** |
|
||||||
|
|-----------------------------------------------|------------|------------------------------------------------------------------------|
|
||||||
|
| `/api/files/{id}/translation/default` | GET | Returns the persisted default-language translation (404 if unavailable)|
|
||||||
|
| `/api/files/{id}/translate?lang=xx` | GET | On-the-fly translation to any ISO 639-1 language code |
|
||||||
|
| `/files/{id}/text/default-language` | GET | View endpoint returning the default-language text as JSON |
|
||||||
|
|
||||||
|
#### Example
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get the stored English translation of a German document
|
||||||
|
curl http://localhost:8000/api/files/42/translation/default
|
||||||
|
|
||||||
|
# Translate on the fly to French
|
||||||
|
curl "http://localhost:8000/api/files/42/translate?lang=fr"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### OCR Providers
|
### OCR Providers
|
||||||
|
|
||||||
DocuElevate supports multiple OCR engines that can be used individually or in combination. Configure the list of active providers with `OCR_PROVIDERS` and tune each provider with the settings below.
|
DocuElevate supports multiple OCR engines that can be used individually or in combination. Configure the list of active providers with `OCR_PROVIDERS` and tune each provider with the settings below.
|
||||||
|
|||||||
@@ -439,10 +439,105 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if file.detected_language %}
|
||||||
|
<div style="font-size:0.75rem;color:#6b7280;margin-bottom:0.5rem;">
|
||||||
|
<i class="fas fa-globe" aria-hidden="true" style="margin-right:0.25rem;"></i>
|
||||||
|
Detected language: <strong>{{ file.detected_language }}</strong>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div id="ocr-text-block" style="display:none;">
|
<div id="ocr-text-block" style="display:none;">
|
||||||
<pre class="ocr-text" id="ocr-text-content">{{ file.ocr_text }}</pre>
|
<pre class="ocr-text" id="ocr-text-content">{{ file.ocr_text }}</pre>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Default-language translation ── -->
|
||||||
|
{% if file.default_language_text %}
|
||||||
|
<div class="doc-card">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem;">
|
||||||
|
<div class="doc-card-title" style="margin:0;">
|
||||||
|
<i class="fas fa-language" aria-hidden="true" style="color:#3b82f6;margin-right:0.4rem;"></i>
|
||||||
|
Default Language Version
|
||||||
|
{% if file.default_language_code %}
|
||||||
|
<span style="font-size:0.75rem;color:#6b7280;font-weight:normal;margin-left:0.5rem;">({{ file.default_language_code }})</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:0.5rem;">
|
||||||
|
<button class="text-toggle" onclick="toggleDefaultLangText()" aria-expanded="false" aria-controls="default-lang-text-block">
|
||||||
|
<i id="default-lang-toggle-icon" class="fas fa-chevron-down" aria-hidden="true"></i>
|
||||||
|
<span id="default-lang-toggle-label">Show text</span>
|
||||||
|
</button>
|
||||||
|
<button class="text-toggle" onclick="copyDefaultLangText()" id="default-lang-copy-btn" style="color:#10b981;" aria-label="Copy default language text">
|
||||||
|
<i class="fas fa-copy" aria-hidden="true"></i> Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="default-lang-text-block" style="display:none;">
|
||||||
|
<pre class="ocr-text" id="default-lang-text-content">{{ file.default_language_text }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% elif file.detected_language %}
|
||||||
|
<!-- Translation pending or document already in default language -->
|
||||||
|
<div class="doc-card">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||||
|
<div class="doc-card-title" style="margin:0;">
|
||||||
|
<i class="fas fa-language" aria-hidden="true" style="color:#3b82f6;margin-right:0.4rem;"></i>
|
||||||
|
Default Language Version
|
||||||
|
</div>
|
||||||
|
<button class="action-btn btn-secondary" onclick="loadDefaultLangText({{ file.id }})">
|
||||||
|
<i class="fas fa-language" aria-hidden="true"></i> Load translation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="default-lang-load-area" style="margin-top:0.75rem;"></div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- ── On-the-fly translation ── -->
|
||||||
|
{% if file.ocr_text %}
|
||||||
|
<div class="doc-card">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem;">
|
||||||
|
<div class="doc-card-title" style="margin:0;">
|
||||||
|
<i class="fas fa-exchange-alt" aria-hidden="true" style="color:#f59e0b;margin-right:0.4rem;"></i>
|
||||||
|
Translate to Another Language
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
|
||||||
|
<label for="translate-lang-select" class="sr-only">Target language</label>
|
||||||
|
<select id="translate-lang-select" style="padding:0.4rem 0.6rem;border:1px solid #d1d5db;border-radius:0.375rem;font-size:0.85rem;min-width:160px;" aria-label="Select target language for translation">
|
||||||
|
<option value="">Select language…</option>
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="de">Deutsch</option>
|
||||||
|
<option value="fr">Français</option>
|
||||||
|
<option value="es">Español</option>
|
||||||
|
<option value="it">Italiano</option>
|
||||||
|
<option value="pt">Português</option>
|
||||||
|
<option value="nl">Nederlands</option>
|
||||||
|
<option value="pl">Polski</option>
|
||||||
|
<option value="ru">Русский</option>
|
||||||
|
<option value="zh">中文</option>
|
||||||
|
<option value="ja">日本語</option>
|
||||||
|
<option value="ko">한국어</option>
|
||||||
|
<option value="ar">العربية</option>
|
||||||
|
<option value="hi">हिन्दी</option>
|
||||||
|
<option value="tr">Türkçe</option>
|
||||||
|
<option value="sv">Svenska</option>
|
||||||
|
<option value="da">Dansk</option>
|
||||||
|
<option value="no">Norsk</option>
|
||||||
|
<option value="fi">Suomi</option>
|
||||||
|
<option value="cs">Čeština</option>
|
||||||
|
<option value="ro">Română</option>
|
||||||
|
<option value="uk">Українська</option>
|
||||||
|
</select>
|
||||||
|
<button class="action-btn btn-secondary" onclick="translateOnTheFly({{ file.id }})" id="translate-btn" style="min-height:44px;min-width:44px;">
|
||||||
|
<i class="fas fa-language" aria-hidden="true"></i> Translate
|
||||||
|
</button>
|
||||||
|
<button class="text-toggle" onclick="copyTranslatedText()" id="translate-copy-btn" style="color:#10b981;display:none;" aria-label="Copy translated text">
|
||||||
|
<i class="fas fa-copy" aria-hidden="true"></i> Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="translate-result-area" style="margin-top:0.75rem;"></div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% elif processed_file_exists or original_file_exists %}
|
{% elif processed_file_exists or original_file_exists %}
|
||||||
<div class="doc-card">
|
<div class="doc-card">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||||
@@ -688,6 +783,149 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Default-language text toggle ──
|
||||||
|
function toggleDefaultLangText() {
|
||||||
|
var block = document.getElementById('default-lang-text-block');
|
||||||
|
var icon = document.getElementById('default-lang-toggle-icon');
|
||||||
|
var label = document.getElementById('default-lang-toggle-label');
|
||||||
|
var btn = icon ? icon.closest('button') : null;
|
||||||
|
if (!block) return;
|
||||||
|
if (block.style.display === 'none') {
|
||||||
|
block.style.display = 'block';
|
||||||
|
if (icon) icon.className = 'fas fa-chevron-up';
|
||||||
|
if (label) label.textContent = 'Hide text';
|
||||||
|
if (btn) btn.setAttribute('aria-expanded', 'true');
|
||||||
|
} else {
|
||||||
|
block.style.display = 'none';
|
||||||
|
if (icon) icon.className = 'fas fa-chevron-down';
|
||||||
|
if (label) label.textContent = 'Show text';
|
||||||
|
if (btn) btn.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Copy default-language text ──
|
||||||
|
function copyDefaultLangText() {
|
||||||
|
var content = document.getElementById('default-lang-text-content');
|
||||||
|
if (!content) return;
|
||||||
|
var text = content.textContent;
|
||||||
|
var btn = document.getElementById('default-lang-copy-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
var orig = btn.innerHTML;
|
||||||
|
navigator.clipboard.writeText(text).then(function() {
|
||||||
|
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
|
||||||
|
setTimeout(function() { btn.innerHTML = orig; }, 2000);
|
||||||
|
}).catch(function() {
|
||||||
|
try {
|
||||||
|
var ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.cssText = 'position:fixed;opacity:0;';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
|
||||||
|
setTimeout(function() { btn.innerHTML = orig; }, 2000);
|
||||||
|
} catch(e) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Load default-language text on demand ──
|
||||||
|
function loadDefaultLangText(fileId) {
|
||||||
|
var area = document.getElementById('default-lang-load-area');
|
||||||
|
if (!area) return;
|
||||||
|
area.innerHTML = '<div style="text-align:center;padding:1.5rem;color:#6b7280;"><i class="fas fa-spinner fa-spin fa-2x" aria-hidden="true"></i><p style="margin-top:0.5rem;">Loading translation…</p></div>';
|
||||||
|
fetch('/files/' + fileId + '/text/default-language')
|
||||||
|
.then(function(r) {
|
||||||
|
if (r.status === 404) throw new Error('No translation available yet — it may still be processing');
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function(data) {
|
||||||
|
area.innerHTML = '';
|
||||||
|
var info = document.createElement('div');
|
||||||
|
info.style.cssText = 'font-size:0.75rem;color:#6b7280;margin-bottom:0.5rem;';
|
||||||
|
info.textContent = 'Translated to: ' + data.language_code + (data.detected_language ? ' (from ' + data.detected_language + ')' : '');
|
||||||
|
area.appendChild(info);
|
||||||
|
var pre = document.createElement('pre');
|
||||||
|
pre.className = 'ocr-text';
|
||||||
|
pre.textContent = data.text;
|
||||||
|
area.appendChild(pre);
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
area.innerHTML = '<div style="color:#92400e;padding:0.75rem;background:#fef3c7;border-radius:0.375rem;font-size:0.875rem;"><i class="fas fa-info-circle" aria-hidden="true"></i> ' + err.message + '</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── On-the-fly translation ──
|
||||||
|
var _translateCache = {};
|
||||||
|
function translateOnTheFly(fileId) {
|
||||||
|
var select = document.getElementById('translate-lang-select');
|
||||||
|
var area = document.getElementById('translate-result-area');
|
||||||
|
var copyBtn = document.getElementById('translate-copy-btn');
|
||||||
|
if (!select || !area) return;
|
||||||
|
var lang = select.value;
|
||||||
|
if (!lang) { area.innerHTML = '<div style="color:#92400e;padding:0.75rem;background:#fef3c7;border-radius:0.375rem;font-size:0.875rem;">Please select a target language.</div>'; return; }
|
||||||
|
|
||||||
|
// Check cache
|
||||||
|
if (_translateCache[lang]) {
|
||||||
|
renderTranslation(area, _translateCache[lang], lang);
|
||||||
|
if (copyBtn) copyBtn.style.display = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
area.innerHTML = '<div style="text-align:center;padding:1.5rem;color:#6b7280;" aria-live="polite"><i class="fas fa-spinner fa-spin fa-2x" aria-hidden="true"></i><p style="margin-top:0.5rem;">Translating…</p></div>';
|
||||||
|
if (copyBtn) copyBtn.style.display = 'none';
|
||||||
|
|
||||||
|
fetch('/api/files/' + fileId + '/translate?lang=' + encodeURIComponent(lang))
|
||||||
|
.then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
_translateCache[lang] = data.text;
|
||||||
|
renderTranslation(area, data.text, lang);
|
||||||
|
if (copyBtn) copyBtn.style.display = '';
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
area.innerHTML = '<div style="color:#dc2626;padding:0.75rem;background:#fee2e2;border-radius:0.375rem;font-size:0.875rem;"><i class="fas fa-exclamation-triangle" aria-hidden="true"></i> Translation failed: ' + err.message + '</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTranslation(container, text, lang) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
var info = document.createElement('div');
|
||||||
|
info.style.cssText = 'font-size:0.75rem;color:#6b7280;margin-bottom:0.5rem;';
|
||||||
|
info.textContent = 'Translated to: ' + lang;
|
||||||
|
container.appendChild(info);
|
||||||
|
var pre = document.createElement('pre');
|
||||||
|
pre.className = 'ocr-text';
|
||||||
|
pre.id = 'translated-text-content';
|
||||||
|
pre.textContent = text;
|
||||||
|
container.appendChild(pre);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyTranslatedText() {
|
||||||
|
var content = document.getElementById('translated-text-content');
|
||||||
|
if (!content) return;
|
||||||
|
var text = content.textContent;
|
||||||
|
var btn = document.getElementById('translate-copy-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
var orig = btn.innerHTML;
|
||||||
|
navigator.clipboard.writeText(text).then(function() {
|
||||||
|
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
|
||||||
|
setTimeout(function() { btn.innerHTML = orig; }, 2000);
|
||||||
|
}).catch(function() {
|
||||||
|
try {
|
||||||
|
var ta = document.createElement('textarea');
|
||||||
|
ta.value = text;
|
||||||
|
ta.style.cssText = 'position:fixed;opacity:0;';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
|
||||||
|
setTimeout(function() { btn.innerHTML = orig; }, 2000);
|
||||||
|
} catch(e) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Initialise on load ──
|
// ── Initialise on load ──
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
{% if (processed_file_exists or original_file_exists) and file %}
|
{% if (processed_file_exists or original_file_exists) and file %}
|
||||||
|
|||||||
@@ -231,6 +231,28 @@
|
|||||||
{{ _("profile.theme_hint") }}
|
{{ _("profile.theme_hint") }}
|
||||||
</p>
|
</p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
|
<!-- Default Document Language -->
|
||||||
|
<div>
|
||||||
|
<label for="doc-lang-select" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
<i class="fas fa-language text-gray-400 mr-1" aria-hidden="true"></i>{{ _("profile.default_document_language_label") }}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="doc-lang-select"
|
||||||
|
x-model="form.default_document_language"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||||
|
style="min-height:44px;"
|
||||||
|
>
|
||||||
|
<option value="">{{ _("profile.default_document_language_auto") }}</option>
|
||||||
|
{% for lang in supported_languages %}
|
||||||
|
<option value="{{ lang.code }}">{{ lang.flag }} {{ lang.native }} ({{ lang.name }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ _("profile.default_document_language_hint") }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -340,6 +362,7 @@ function profileSettings() {
|
|||||||
contact_email: '',
|
contact_email: '',
|
||||||
preferred_language: '',
|
preferred_language: '',
|
||||||
preferred_theme: 'system',
|
preferred_theme: 'system',
|
||||||
|
default_document_language: '',
|
||||||
},
|
},
|
||||||
|
|
||||||
pwForm: {
|
pwForm: {
|
||||||
@@ -363,6 +386,7 @@ function profileSettings() {
|
|||||||
this.form.contact_email = data.contact_email || '';
|
this.form.contact_email = data.contact_email || '';
|
||||||
this.form.preferred_language = data.preferred_language || '';
|
this.form.preferred_language = data.preferred_language || '';
|
||||||
this.form.preferred_theme = data.preferred_theme || 'system';
|
this.form.preferred_theme = data.preferred_theme || 'system';
|
||||||
|
this.form.default_document_language = data.default_document_language || '';
|
||||||
this._initialLanguage = this.form.preferred_language;
|
this._initialLanguage = this.form.preferred_language;
|
||||||
} catch (_e) {
|
} catch (_e) {
|
||||||
// Silently ignore — user might not be logged in (rare for this page)
|
// Silently ignore — user might not be logged in (rare for this page)
|
||||||
|
|||||||
@@ -1377,6 +1377,9 @@
|
|||||||
"profile.contact_email_label": "Contact / Notification E-mail",
|
"profile.contact_email_label": "Contact / Notification E-mail",
|
||||||
"profile.contact_email_placeholder": "you@example.com",
|
"profile.contact_email_placeholder": "you@example.com",
|
||||||
"profile.current_password": "Current Password",
|
"profile.current_password": "Current Password",
|
||||||
|
"profile.default_document_language_auto": "Use system default",
|
||||||
|
"profile.default_document_language_hint": "Documents in other languages are automatically translated into this language. Leave blank to use the system default (English).",
|
||||||
|
"profile.default_document_language_label": "Default Document Language",
|
||||||
"profile.dismiss": "Dismiss",
|
"profile.dismiss": "Dismiss",
|
||||||
"profile.display_name_hint": "Leave blank to use your account username or email.",
|
"profile.display_name_hint": "Leave blank to use your account username or email.",
|
||||||
"profile.display_name_label": "Display Name",
|
"profile.display_name_label": "Display Name",
|
||||||
@@ -1731,5 +1734,21 @@
|
|||||||
"upload.uploading": "Uploading...",
|
"upload.uploading": "Uploading...",
|
||||||
"upload.url_description": "Enter a direct link to a file (PDF, Office documents, or images)",
|
"upload.url_description": "Enter a direct link to a file (PDF, Office documents, or images)",
|
||||||
"upload.url_label": "File URL",
|
"upload.url_label": "File URL",
|
||||||
"upload.url_placeholder": "https://example.com/document.pdf"
|
"upload.url_placeholder": "https://example.com/document.pdf",
|
||||||
|
|
||||||
|
"translation.default_language_version": "Default Language Version",
|
||||||
|
"translation.detected_language": "Detected language",
|
||||||
|
"translation.show_text": "Show text",
|
||||||
|
"translation.hide_text": "Hide text",
|
||||||
|
"translation.copy": "Copy",
|
||||||
|
"translation.load_translation": "Load translation",
|
||||||
|
"translation.translate_to": "Translate to Another Language",
|
||||||
|
"translation.select_language": "Select language…",
|
||||||
|
"translation.translate_btn": "Translate",
|
||||||
|
"translation.translating": "Translating…",
|
||||||
|
"translation.no_translation": "No translation available yet — it may still be processing",
|
||||||
|
"translation.translated_to": "Translated to",
|
||||||
|
"translation.translation_failed": "Translation failed",
|
||||||
|
"translation.select_target": "Please select a target language.",
|
||||||
|
"translation.copied": "Copied!"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Add document translation fields to files and user_profiles tables.
|
||||||
|
|
||||||
|
Adds detected_language, default_language_text, and default_language_code to
|
||||||
|
the files table so that a translated version of the document text can be
|
||||||
|
stored alongside the original.
|
||||||
|
|
||||||
|
Adds default_document_language to user_profiles so each user can override
|
||||||
|
the system-wide default translation target language.
|
||||||
|
|
||||||
|
Revision ID: 036_add_document_translation_fields
|
||||||
|
Revises: 035_add_routing_rules
|
||||||
|
Create Date: 2026-03-16
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "036_add_document_translation_fields"
|
||||||
|
down_revision: Union[str, None] = "035_add_routing_rules"
|
||||||
|
depends_on: Union[str, None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Add translation columns to files and user_profiles."""
|
||||||
|
with op.batch_alter_table("files") as batch_op:
|
||||||
|
batch_op.add_column(sa.Column("detected_language", sa.String(10), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column("default_language_text", sa.Text(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column("default_language_code", sa.String(10), nullable=True))
|
||||||
|
|
||||||
|
with op.batch_alter_table("user_profiles") as batch_op:
|
||||||
|
batch_op.add_column(sa.Column("default_document_language", sa.String(10), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Remove translation columns."""
|
||||||
|
with op.batch_alter_table("user_profiles") as batch_op:
|
||||||
|
batch_op.drop_column("default_document_language")
|
||||||
|
|
||||||
|
with op.batch_alter_table("files") as batch_op:
|
||||||
|
batch_op.drop_column("default_language_code")
|
||||||
|
batch_op.drop_column("default_language_text")
|
||||||
|
batch_op.drop_column("detected_language")
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
"""Tests for document translation feature.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- translate_to_default_language Celery task
|
||||||
|
- /api/files/{id}/translate on-the-fly translation endpoint
|
||||||
|
- /api/files/{id}/translation/default stored translation endpoint
|
||||||
|
- /files/{id}/text/default-language view endpoint
|
||||||
|
- _resolve_default_language helper
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.models import FileRecord, UserProfile
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def file_with_ocr(db_session):
|
||||||
|
"""Create a FileRecord with OCR text and detected language."""
|
||||||
|
record = FileRecord(
|
||||||
|
filehash="abc123translationtest",
|
||||||
|
local_filename="/tmp/test_translate.pdf",
|
||||||
|
file_size=1024,
|
||||||
|
mime_type="application/pdf",
|
||||||
|
original_filename="test_translate.pdf",
|
||||||
|
ocr_text="Dies ist ein Testdokument in deutscher Sprache.",
|
||||||
|
detected_language="de",
|
||||||
|
)
|
||||||
|
db_session.add(record)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(record)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def file_with_translation(db_session):
|
||||||
|
"""Create a FileRecord with a persisted default-language translation."""
|
||||||
|
record = FileRecord(
|
||||||
|
filehash="def456translationtest",
|
||||||
|
local_filename="/tmp/test_translated.pdf",
|
||||||
|
file_size=2048,
|
||||||
|
mime_type="application/pdf",
|
||||||
|
original_filename="test_translated.pdf",
|
||||||
|
ocr_text="Ceci est un document de test en français.",
|
||||||
|
detected_language="fr",
|
||||||
|
default_language_text="This is a test document in French.",
|
||||||
|
default_language_code="en",
|
||||||
|
)
|
||||||
|
db_session.add(record)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(record)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def file_without_ocr(db_session):
|
||||||
|
"""Create a FileRecord without OCR text."""
|
||||||
|
record = FileRecord(
|
||||||
|
filehash="ghi789translationtest",
|
||||||
|
local_filename="/tmp/test_no_ocr.pdf",
|
||||||
|
file_size=512,
|
||||||
|
mime_type="application/pdf",
|
||||||
|
original_filename="test_no_ocr.pdf",
|
||||||
|
)
|
||||||
|
db_session.add(record)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(record)
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def user_profile_with_language(db_session):
|
||||||
|
"""Create a UserProfile with a custom default_document_language."""
|
||||||
|
profile = UserProfile(
|
||||||
|
user_id="test-user-lang",
|
||||||
|
default_document_language="de",
|
||||||
|
)
|
||||||
|
db_session.add(profile)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(profile)
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Model tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestFileRecordTranslationFields:
|
||||||
|
"""Verify that the new translation columns exist on FileRecord."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_detected_language_column(self, file_with_ocr):
|
||||||
|
assert file_with_ocr.detected_language == "de"
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_default_language_text_column(self, file_with_translation):
|
||||||
|
assert file_with_translation.default_language_text == "This is a test document in French."
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_default_language_code_column(self, file_with_translation):
|
||||||
|
assert file_with_translation.default_language_code == "en"
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_translation_columns_nullable(self, file_with_ocr):
|
||||||
|
"""Translation columns should be NULL when no translation exists."""
|
||||||
|
assert file_with_ocr.default_language_text is None
|
||||||
|
assert file_with_ocr.default_language_code is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserProfileDefaultLanguage:
|
||||||
|
"""Verify UserProfile.default_document_language column."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_default_document_language_set(self, user_profile_with_language):
|
||||||
|
assert user_profile_with_language.default_document_language == "de"
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_default_document_language_nullable(self, db_session):
|
||||||
|
profile = UserProfile(user_id="test-user-no-lang")
|
||||||
|
db_session.add(profile)
|
||||||
|
db_session.commit()
|
||||||
|
db_session.refresh(profile)
|
||||||
|
assert profile.default_document_language is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Celery task tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestTranslateToDefaultLanguageTask:
|
||||||
|
"""Tests for the translate_to_default_language Celery task."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@patch("app.tasks.translate_to_default_language.get_ai_provider")
|
||||||
|
def test_translate_stores_result(self, mock_provider_fn, db_session, file_with_ocr):
|
||||||
|
"""Successful translation is persisted to the FileRecord."""
|
||||||
|
mock_provider = MagicMock()
|
||||||
|
mock_provider.chat_completion.return_value = "This is a test document in German."
|
||||||
|
mock_provider_fn.return_value = mock_provider
|
||||||
|
|
||||||
|
from app.tasks.translate_to_default_language import translate_to_default_language
|
||||||
|
|
||||||
|
# Patch SessionLocal to use our test session
|
||||||
|
with patch("app.tasks.translate_to_default_language.SessionLocal") as mock_session_cls:
|
||||||
|
mock_ctx = MagicMock()
|
||||||
|
mock_ctx.__enter__ = MagicMock(return_value=db_session)
|
||||||
|
mock_ctx.__exit__ = MagicMock(return_value=False)
|
||||||
|
mock_session_cls.return_value = mock_ctx
|
||||||
|
|
||||||
|
task = translate_to_default_language
|
||||||
|
# Call the underlying function (not .delay) for synchronous testing
|
||||||
|
result = task.apply(
|
||||||
|
args=[file_with_ocr.id, file_with_ocr.ocr_text, "de"],
|
||||||
|
kwargs={"owner_id": None},
|
||||||
|
).get()
|
||||||
|
|
||||||
|
assert result["status"] == "success"
|
||||||
|
assert result["target_language"] == "en"
|
||||||
|
|
||||||
|
# Verify it was stored
|
||||||
|
db_session.refresh(file_with_ocr)
|
||||||
|
assert file_with_ocr.default_language_text == "This is a test document in German."
|
||||||
|
assert file_with_ocr.default_language_code == "en"
|
||||||
|
assert file_with_ocr.detected_language == "de"
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
@patch("app.tasks.translate_to_default_language.get_ai_provider")
|
||||||
|
def test_skip_when_already_in_target_language(self, mock_provider_fn, db_session, file_with_ocr):
|
||||||
|
"""No translation when document language matches default target."""
|
||||||
|
file_with_ocr.detected_language = "en"
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
from app.tasks.translate_to_default_language import translate_to_default_language
|
||||||
|
|
||||||
|
with patch("app.tasks.translate_to_default_language.SessionLocal") as mock_session_cls:
|
||||||
|
mock_ctx = MagicMock()
|
||||||
|
mock_ctx.__enter__ = MagicMock(return_value=db_session)
|
||||||
|
mock_ctx.__exit__ = MagicMock(return_value=False)
|
||||||
|
mock_session_cls.return_value = mock_ctx
|
||||||
|
|
||||||
|
result = translate_to_default_language.apply(
|
||||||
|
args=[file_with_ocr.id, file_with_ocr.ocr_text, "en"],
|
||||||
|
).get()
|
||||||
|
|
||||||
|
assert result["status"] == "skipped"
|
||||||
|
mock_provider_fn.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_resolve_default_language_global(self):
|
||||||
|
"""Falls back to the global setting when no user profile override."""
|
||||||
|
from app.tasks.translate_to_default_language import _resolve_default_language
|
||||||
|
|
||||||
|
with patch("app.tasks.translate_to_default_language.settings") as mock_settings:
|
||||||
|
mock_settings.default_document_language = "en"
|
||||||
|
assert _resolve_default_language(None) == "en"
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_resolve_default_language_user_override(self, db_session, user_profile_with_language):
|
||||||
|
"""Per-user override is used when available."""
|
||||||
|
from app.tasks.translate_to_default_language import _resolve_default_language
|
||||||
|
|
||||||
|
with patch("app.tasks.translate_to_default_language.SessionLocal") as mock_session_cls:
|
||||||
|
mock_ctx = MagicMock()
|
||||||
|
mock_ctx.__enter__ = MagicMock(return_value=db_session)
|
||||||
|
mock_ctx.__exit__ = MagicMock(return_value=False)
|
||||||
|
mock_session_cls.return_value = mock_ctx
|
||||||
|
|
||||||
|
result = _resolve_default_language("test-user-lang")
|
||||||
|
|
||||||
|
assert result == "de"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# API endpoint tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestDefaultTranslationEndpoint:
|
||||||
|
"""Tests for GET /api/files/{id}/translation/default."""
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_returns_default_translation(self, client: TestClient, file_with_translation):
|
||||||
|
response = client.get(f"/api/files/{file_with_translation.id}/translation/default")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["text"] == "This is a test document in French."
|
||||||
|
assert data["default_language_code"] == "en"
|
||||||
|
assert data["detected_language"] == "fr"
|
||||||
|
assert data["file_id"] == file_with_translation.id
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_404_when_no_translation(self, client: TestClient, file_with_ocr):
|
||||||
|
response = client.get(f"/api/files/{file_with_ocr.id}/translation/default")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_404_for_nonexistent_file(self, client: TestClient):
|
||||||
|
response = client.get("/api/files/999999/translation/default")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestOnTheFlyTranslateEndpoint:
|
||||||
|
"""Tests for GET /api/files/{id}/translate?lang=xx."""
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@patch("app.api.translation.get_ai_provider")
|
||||||
|
def test_translate_on_the_fly(self, mock_provider_fn, client: TestClient, file_with_ocr):
|
||||||
|
mock_provider = MagicMock()
|
||||||
|
mock_provider.chat_completion.return_value = "This is a test document in German language."
|
||||||
|
mock_provider_fn.return_value = mock_provider
|
||||||
|
|
||||||
|
response = client.get(f"/api/files/{file_with_ocr.id}/translate?lang=en")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["text"] == "This is a test document in German language."
|
||||||
|
assert data["target_language"] == "en"
|
||||||
|
assert data["cached"] is False
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_returns_cached_default_language(self, client: TestClient, file_with_translation):
|
||||||
|
"""If the requested language matches the stored default, return cached text."""
|
||||||
|
response = client.get(f"/api/files/{file_with_translation.id}/translate?lang=en")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["text"] == "This is a test document in French."
|
||||||
|
assert data["cached"] is True
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_returns_original_when_same_language(self, client: TestClient, file_with_ocr):
|
||||||
|
"""Return the original text when target matches detected language."""
|
||||||
|
response = client.get(f"/api/files/{file_with_ocr.id}/translate?lang=de")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["text"] == file_with_ocr.ocr_text
|
||||||
|
assert data["cached"] is True
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_400_when_no_ocr_text(self, client: TestClient, file_without_ocr):
|
||||||
|
response = client.get(f"/api/files/{file_without_ocr.id}/translate?lang=en")
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_missing_lang_param(self, client: TestClient, file_with_ocr):
|
||||||
|
response = client.get(f"/api/files/{file_with_ocr.id}/translate")
|
||||||
|
assert response.status_code == 422 # validation error
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_404_for_nonexistent_file(self, client: TestClient):
|
||||||
|
response = client.get("/api/files/999999/translate?lang=en")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
@patch("app.api.translation.get_ai_provider")
|
||||||
|
def test_502_on_provider_error(self, mock_provider_fn, client: TestClient, file_with_ocr):
|
||||||
|
mock_provider = MagicMock()
|
||||||
|
mock_provider.chat_completion.side_effect = RuntimeError("AI error")
|
||||||
|
mock_provider_fn.return_value = mock_provider
|
||||||
|
|
||||||
|
response = client.get(f"/api/files/{file_with_ocr.id}/translate?lang=fr")
|
||||||
|
assert response.status_code == 502
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# View endpoint tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestDefaultLanguageTextView:
|
||||||
|
"""Tests for GET /files/{id}/text/default-language."""
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_returns_default_language_text(self, client: TestClient, file_with_translation):
|
||||||
|
response = client.get(f"/files/{file_with_translation.id}/text/default-language")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["text"] == "This is a test document in French."
|
||||||
|
assert data["language_code"] == "en"
|
||||||
|
assert data["detected_language"] == "fr"
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_404_when_no_default_text(self, client: TestClient, file_with_ocr):
|
||||||
|
response = client.get(f"/files/{file_with_ocr.id}/text/default-language")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_404_for_nonexistent_file(self, client: TestClient):
|
||||||
|
response = client.get("/files/999999/text/default-language")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Config tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestDefaultDocumentLanguageConfig:
|
||||||
|
"""Verify the DEFAULT_DOCUMENT_LANGUAGE setting."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_default_value_is_english(self):
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
assert settings.default_document_language == "en"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Profile API integration tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestProfileDefaultDocumentLanguage:
|
||||||
|
"""Tests for default_document_language in the profile API."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def prof_engine(self):
|
||||||
|
"""In-memory SQLite engine for profile tests."""
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app.database import Base
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
"sqlite:///:memory:",
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
yield engine
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def prof_session(self, prof_engine):
|
||||||
|
"""DB session for profile tests."""
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
Session = sessionmaker(bind=self.prof_engine if hasattr(self, "prof_engine") else prof_engine)
|
||||||
|
session = Session()
|
||||||
|
yield session
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_get_profile_includes_default_document_language(self, prof_engine):
|
||||||
|
"""GET handler returns default_document_language in response."""
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from app.api.profile import get_profile
|
||||||
|
|
||||||
|
Session = sessionmaker(bind=prof_engine)
|
||||||
|
session = Session()
|
||||||
|
|
||||||
|
req = MagicMock()
|
||||||
|
req.session = {"user": {"preferred_username": "languser", "email": "lang@test.com"}}
|
||||||
|
|
||||||
|
result = await get_profile(req, session)
|
||||||
|
assert hasattr(result, "default_document_language")
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_update_default_document_language(self, prof_engine):
|
||||||
|
"""PATCH handler updates default_document_language."""
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from app.api.profile import ProfileUpdateRequest, update_profile
|
||||||
|
|
||||||
|
Session = sessionmaker(bind=prof_engine)
|
||||||
|
session = Session()
|
||||||
|
|
||||||
|
req = MagicMock()
|
||||||
|
req.session = {"user": {"preferred_username": "languser2", "email": "lang2@test.com"}}
|
||||||
|
resp = MagicMock()
|
||||||
|
|
||||||
|
body = ProfileUpdateRequest(default_document_language="de")
|
||||||
|
result = await update_profile(body, req, resp, session)
|
||||||
|
assert result.default_document_language == "de"
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_clear_default_document_language(self, prof_engine):
|
||||||
|
"""Setting default_document_language to empty string clears it."""
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from app.api.profile import ProfileUpdateRequest, update_profile
|
||||||
|
|
||||||
|
Session = sessionmaker(bind=prof_engine)
|
||||||
|
session = Session()
|
||||||
|
|
||||||
|
req = MagicMock()
|
||||||
|
req.session = {"user": {"preferred_username": "languser3", "email": "lang3@test.com"}}
|
||||||
|
resp = MagicMock()
|
||||||
|
|
||||||
|
# Set
|
||||||
|
body = ProfileUpdateRequest(default_document_language="fr")
|
||||||
|
await update_profile(body, req, resp, session)
|
||||||
|
# Clear
|
||||||
|
body = ProfileUpdateRequest(default_document_language="")
|
||||||
|
result = await update_profile(body, req, resp, session)
|
||||||
|
assert result.default_document_language is None
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_reject_invalid_default_document_language(self, prof_engine):
|
||||||
|
"""Invalid language codes are rejected with 422."""
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from app.api.profile import ProfileUpdateRequest, update_profile
|
||||||
|
|
||||||
|
Session = sessionmaker(bind=prof_engine)
|
||||||
|
session = Session()
|
||||||
|
|
||||||
|
req = MagicMock()
|
||||||
|
req.session = {"user": {"preferred_username": "languser4", "email": "lang4@test.com"}}
|
||||||
|
resp = MagicMock()
|
||||||
|
|
||||||
|
body = ProfileUpdateRequest(default_document_language="xx_invalid")
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await update_profile(body, req, resp, session)
|
||||||
|
assert exc_info.value.status_code == 422
|
||||||
|
session.close()
|
||||||
Reference in New Issue
Block a user