Files
SaaS-PDF/backend/app/services/translation_guardrails.py
Your Name 314f847ece fix: Add scrollable container to ToolSelectorModal for small screens
- Add max-h-[90vh] and flex-col to modal content container
- Wrap tools grid in max-h-[50vh] overflow-y-auto container
- Add overscroll-contain for smooth scroll behavior on mobile
- Fixes issue where 21 PDF tools overflow viewport on small screens
2026-04-01 22:22:48 +02:00

152 lines
4.9 KiB
Python

"""Translation guardrails — admission control, caching, and cost protection.
This module implements the guardrail model described in
docs/tool-portfolio/05-ai-cost-and-performance-plan.md.
"""
import hashlib
import logging
import os
from typing import Optional
from flask import current_app
logger = logging.getLogger(__name__)
# ── Page-count admission tiers ──────────────────────────────────────
# These limits define the maximum number of pages allowed per plan.
# Free/anonymous users get a lower cap; Pro users get a higher cap.
FREE_TRANSLATE_MAX_PAGES = int(os.getenv("FREE_TRANSLATE_MAX_PAGES", "10"))
PRO_TRANSLATE_MAX_PAGES = int(os.getenv("PRO_TRANSLATE_MAX_PAGES", "50"))
class TranslationAdmissionError(Exception):
"""Raised when a translation job is rejected at admission."""
def __init__(self, message: str, status_code: int = 400):
super().__init__(message)
self.message = message
self.status_code = status_code
def get_page_limit(plan: str) -> int:
"""Return the page cap for a given plan."""
from app.services.account_service import normalize_plan
if normalize_plan(plan) == "pro":
return PRO_TRANSLATE_MAX_PAGES
return FREE_TRANSLATE_MAX_PAGES
def count_pdf_pages(file_path: str) -> int:
"""Return the number of pages in a PDF file."""
try:
from PyPDF2 import PdfReader
reader = PdfReader(file_path)
return len(reader.pages)
except Exception as e:
logger.warning("Failed to count PDF pages for admission: %s", e)
# If we can't count pages, allow the job through but log it
return 0
def check_page_admission(file_path: str, plan: str) -> int:
"""Verify a PDF is within the page limit for the given plan.
Returns the page count on success.
Raises TranslationAdmissionError if the file exceeds the limit.
"""
page_count = count_pdf_pages(file_path)
if page_count == 0:
# Can't determine — allow through (OCR fallback scenario)
return page_count
limit = get_page_limit(plan)
if page_count > limit:
raise TranslationAdmissionError(
f"This PDF has {page_count} pages. "
f"Your plan allows up to {limit} pages for translation. "
f"Please upgrade your plan or use a smaller file.",
status_code=413,
)
return page_count
# ── Content-hash caching ────────────────────────────────────────────
# Redis-based cache keyed by file-content hash + target language.
# Avoids re-translating identical documents.
TRANSLATION_CACHE_TTL = int(os.getenv("TRANSLATION_CACHE_TTL", str(7 * 24 * 3600))) # 7 days
def _get_redis():
"""Get Redis connection from Flask app config."""
try:
import redis
redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0")
return redis.Redis.from_url(redis_url, decode_responses=True)
except Exception as e:
logger.debug("Redis not available for translation cache: %s", e)
return None
def _compute_content_hash(file_path: str) -> str:
"""Compute SHA-256 hash of file contents."""
sha = hashlib.sha256()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(8192), b""):
sha.update(chunk)
return sha.hexdigest()
def _cache_key(content_hash: str, target_language: str, source_language: str) -> str:
"""Build a Redis key for the translation cache."""
return f"translate_cache:{content_hash}:{source_language}:{target_language}"
def get_cached_translation(
file_path: str, target_language: str, source_language: str = "auto"
) -> Optional[dict]:
"""Look up a cached translation result. Returns None on miss."""
r = _get_redis()
if r is None:
return None
try:
content_hash = _compute_content_hash(file_path)
key = _cache_key(content_hash, target_language, source_language)
import json
cached = r.get(key)
if cached:
logger.info("Translation cache hit for %s", key)
return json.loads(cached)
except Exception as e:
logger.debug("Translation cache lookup failed: %s", e)
return None
def store_cached_translation(
file_path: str,
target_language: str,
source_language: str,
result: dict,
) -> None:
"""Store a successful translation result in Redis."""
r = _get_redis()
if r is None:
return
try:
import json
content_hash = _compute_content_hash(file_path)
key = _cache_key(content_hash, target_language, source_language)
r.setex(key, TRANSLATION_CACHE_TTL, json.dumps(result, ensure_ascii=False))
logger.info("Translation cached: %s (TTL=%ds)", key, TRANSLATION_CACHE_TTL)
except Exception as e:
logger.debug("Translation cache store failed: %s", e)