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
This commit is contained in:
Your Name
2026-04-01 22:22:48 +02:00
parent 3e1c0e5f99
commit 314f847ece
49 changed files with 2142 additions and 361 deletions

View File

@@ -228,6 +228,30 @@ def _init_postgres_tables(conn):
ON file_events(created_at DESC)
""")
cursor.execute("""
CREATE TABLE IF NOT EXISTS user_credit_windows (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL UNIQUE,
window_start_at TEXT NOT NULL,
window_end_at TEXT NOT NULL,
credits_allocated INTEGER NOT NULL,
credits_used INTEGER NOT NULL DEFAULT 0,
plan TEXT NOT NULL DEFAULT 'free',
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_ucw_user
ON user_credit_windows(user_id)
""")
# Add cost_points column to usage_events if missing
if not _column_exists(conn, "usage_events", "cost_points"):
cursor.execute(
"ALTER TABLE usage_events ADD COLUMN cost_points INTEGER NOT NULL DEFAULT 1"
)
def _init_sqlite_tables(conn):
conn.executescript(
@@ -316,6 +340,21 @@ def _init_sqlite_tables(conn):
CREATE INDEX IF NOT EXISTS idx_file_events_created
ON file_events(created_at DESC);
CREATE TABLE IF NOT EXISTS user_credit_windows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL UNIQUE,
window_start_at TEXT NOT NULL,
window_end_at TEXT NOT NULL,
credits_allocated INTEGER NOT NULL,
credits_used INTEGER NOT NULL DEFAULT 0,
plan TEXT NOT NULL DEFAULT 'free',
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_ucw_user
ON user_credit_windows(user_id);
"""
)
@@ -325,6 +364,8 @@ def _init_sqlite_tables(conn):
conn.execute("ALTER TABLE users ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''")
if not _column_exists(conn, "users", "role"):
conn.execute("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'")
if not _column_exists(conn, "usage_events", "cost_points"):
conn.execute("ALTER TABLE usage_events ADD COLUMN cost_points INTEGER NOT NULL DEFAULT 1")
def create_user(email: str, password: str) -> dict:
@@ -842,6 +883,7 @@ def record_usage_event(
task_id: str,
event_type: str,
api_key_id: int | None = None,
cost_points: int = 1,
):
if user_id is None:
return
@@ -851,17 +893,17 @@ def record_usage_event(
"""
INSERT INTO usage_events (
user_id, api_key_id, source, tool, task_id,
event_type, created_at, period_month
event_type, created_at, period_month, cost_points
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
"""
if is_postgres()
else """
INSERT INTO usage_events (
user_id, api_key_id, source, tool, task_id,
event_type, created_at, period_month
event_type, created_at, period_month, cost_points
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
"""
)
execute_query(
@@ -876,6 +918,7 @@ def record_usage_event(
event_type,
_utc_now(),
get_current_period_month(),
cost_points,
),
)

View File

@@ -0,0 +1,122 @@
"""Unified Credit System — tool cost registry and credit constants.
Every tool has a credit cost. Lighter tools cost 1 credit, heavier
server-side conversions cost 2, CPU/ML-intensive tools cost 3,
and AI-powered tools cost 5+.
This module is the single source of truth for all credit-related
constants consumed by policy_service, credit_service, and the
frontend config endpoint.
"""
import os
# ── Credit allocations per rolling 30-day window ────────────────
FREE_CREDITS_PER_WINDOW = int(os.getenv("FREE_CREDITS_PER_WINDOW", "50"))
PRO_CREDITS_PER_WINDOW = int(os.getenv("PRO_CREDITS_PER_WINDOW", "500"))
CREDIT_WINDOW_DAYS = int(os.getenv("CREDIT_WINDOW_DAYS", "30"))
# ── Guest demo budget (anonymous, pre-registration) ────────────
GUEST_DEMO_BUDGET = int(os.getenv("GUEST_DEMO_BUDGET", "3"))
GUEST_DEMO_TTL_HOURS = int(os.getenv("GUEST_DEMO_TTL_HOURS", "24"))
# ── API quota (Pro only, per rolling window) ────────────────────
PRO_API_CREDITS_PER_WINDOW = int(os.getenv("PRO_API_CREDITS_PER_WINDOW", "1000"))
# ── Cost tiers ──────────────────────────────────────────────────
TIER_LIGHT = 1 # Fast, in-memory or trivial server ops
TIER_MEDIUM = 2 # Server-side conversion (LibreOffice, Ghostscript, etc.)
TIER_HEAVY = 3 # CPU/ML-intensive (OCR, background removal, compression)
TIER_AI = 5 # AI-powered tools (LLM API calls)
# ── Per-tool credit costs ───────────────────────────────────────
# Keys match the `tool` parameter passed to record_usage_event / routes.
TOOL_CREDIT_COSTS: dict[str, int] = {
# ─── PDF Core (light operations) ────────────────────────────
"merge-pdf": TIER_LIGHT,
"split-pdf": TIER_LIGHT,
"rotate-pdf": TIER_LIGHT,
"reorder-pdf": TIER_LIGHT,
"extract-pages": TIER_LIGHT,
"page-numbers": TIER_LIGHT,
"watermark-pdf": TIER_LIGHT,
"protect-pdf": TIER_LIGHT,
"unlock-pdf": TIER_LIGHT,
"flatten-pdf": TIER_LIGHT,
"repair-pdf": TIER_LIGHT,
"pdf-metadata": TIER_LIGHT,
"crop-pdf": TIER_LIGHT,
"sign-pdf": TIER_LIGHT,
"pdf-to-images": TIER_LIGHT,
"images-to-pdf": TIER_LIGHT,
# ─── Conversion (medium — server-side rendering) ────────────
"pdf-to-word": TIER_MEDIUM,
"word-to-pdf": TIER_MEDIUM,
"pdf-to-excel": TIER_MEDIUM,
"excel-to-pdf": TIER_MEDIUM,
"pdf-to-pptx": TIER_MEDIUM,
"pptx-to-pdf": TIER_MEDIUM,
"html-to-pdf": TIER_MEDIUM,
"pdf-editor": TIER_MEDIUM,
# ─── Image (light to medium) ────────────────────────────────
"image-converter": TIER_LIGHT,
"image-resize": TIER_LIGHT,
"image-crop": TIER_LIGHT,
"image-rotate-flip": TIER_LIGHT,
"image-to-svg": TIER_MEDIUM,
# ─── Image / PDF heavy (CPU/ML) ────────────────────────────
"compress-pdf": TIER_HEAVY,
"compress-image": TIER_HEAVY,
"ocr": TIER_HEAVY,
"remove-background": TIER_HEAVY,
"remove-watermark-pdf": TIER_HEAVY,
# ─── Utility ────────────────────────────────────────────────
"qr-code": TIER_LIGHT,
"barcode-generator": TIER_LIGHT,
"video-to-gif": TIER_MEDIUM,
"word-counter": TIER_LIGHT,
"text-cleaner": TIER_LIGHT,
# ─── AI-powered ─────────────────────────────────────────────
"chat-pdf": TIER_AI,
"summarize-pdf": TIER_AI,
"translate-pdf": TIER_AI,
"extract-tables": TIER_AI,
"pdf-flowchart": TIER_AI,
# ─── Route-specific aliases ─────────────────────────────────────
# Some routes record a tool name that differs from the manifest slug.
# Both names must map to the same cost.
"barcode": TIER_LIGHT, # manifest: barcode-generator
"image-convert": TIER_LIGHT, # manifest: image-converter
"ocr-image": TIER_HEAVY, # manifest: ocr
"ocr-pdf": TIER_HEAVY, # manifest: ocr
"pdf-flowchart-sample": TIER_AI, # manifest: pdf-flowchart
"pdf-edit": TIER_MEDIUM, # manifest: pdf-editor
"edit-metadata": TIER_LIGHT, # manifest: pdf-metadata
"remove-watermark": TIER_HEAVY, # manifest: remove-watermark-pdf
"remove-bg": TIER_HEAVY, # manifest: remove-background
"video-frames": TIER_MEDIUM, # route alias for video-to-gif
"edit-pdf-text": TIER_MEDIUM, # route alias for pdf-editor
}
# Default cost for any tool not explicitly listed
DEFAULT_CREDIT_COST = TIER_LIGHT
def get_tool_credit_cost(tool: str) -> int:
"""Return the credit cost for a given tool slug."""
return TOOL_CREDIT_COSTS.get(tool, DEFAULT_CREDIT_COST)
def get_credits_for_plan(plan: str) -> int:
"""Return the total credits per window for a plan."""
return PRO_CREDITS_PER_WINDOW if plan == "pro" else FREE_CREDITS_PER_WINDOW
def get_all_tool_costs() -> dict[str, int]:
"""Return the full cost registry — used by the config API endpoint."""
return dict(TOOL_CREDIT_COSTS)

View File

@@ -0,0 +1,268 @@
"""Credit window management — rolling 30-day balance for registered users.
Handles lazy window creation on first use, automatic reset after expiry,
balance queries, and atomic credit deduction.
"""
import logging
import os
from datetime import datetime, timedelta, timezone
from app.services.credit_config import (
CREDIT_WINDOW_DAYS,
get_credits_for_plan,
get_tool_credit_cost,
)
from app.utils.database import (
db_connection,
execute_query,
is_postgres,
row_to_dict,
)
logger = logging.getLogger(__name__)
# ── Redis caching (optional) ───────────────────────────────────
_BALANCE_CACHE_TTL = int(os.getenv("CREDIT_BALANCE_CACHE_TTL", "300")) # 5 min
def _get_redis():
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:
return None
def _balance_cache_key(user_id: int) -> str:
return f"credit_balance:{user_id}"
def _invalidate_balance_cache(user_id: int) -> None:
r = _get_redis()
if r:
try:
r.delete(_balance_cache_key(user_id))
except Exception:
pass
def _cache_balance(user_id: int, balance: int) -> None:
r = _get_redis()
if r:
try:
r.setex(_balance_cache_key(user_id), _BALANCE_CACHE_TTL, str(balance))
except Exception:
pass
def _get_cached_balance(user_id: int) -> int | None:
r = _get_redis()
if r is None:
return None
try:
val = r.get(_balance_cache_key(user_id))
return int(str(val)) if val is not None else None
except Exception:
return None
# ── Window helpers ──────────────────────────────────────────────
def _utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
def _utc_now_dt() -> datetime:
return datetime.now(timezone.utc)
def _make_window_end(start_iso: str) -> str:
start = datetime.fromisoformat(start_iso)
end = start + timedelta(days=CREDIT_WINDOW_DAYS)
return end.isoformat()
def _is_window_expired(window_end_at: str) -> bool:
end = datetime.fromisoformat(window_end_at)
if end.tzinfo is None:
end = end.replace(tzinfo=timezone.utc)
return _utc_now_dt() >= end
def _get_window(conn, user_id: int) -> dict | None:
sql = (
"SELECT * FROM user_credit_windows WHERE user_id = %s"
if is_postgres()
else "SELECT * FROM user_credit_windows WHERE user_id = ?"
)
cursor = execute_query(conn, sql, (user_id,))
row = cursor.fetchone()
return row_to_dict(row)
def _create_window(conn, user_id: int, plan: str) -> dict:
now = _utc_now()
credits = get_credits_for_plan(plan)
end = _make_window_end(now)
sql = (
"""
INSERT INTO user_credit_windows
(user_id, window_start_at, window_end_at, credits_allocated, credits_used, plan, updated_at)
VALUES (%s, %s, %s, %s, 0, %s, %s)
ON CONFLICT (user_id) DO UPDATE SET
window_start_at = EXCLUDED.window_start_at,
window_end_at = EXCLUDED.window_end_at,
credits_allocated = EXCLUDED.credits_allocated,
credits_used = 0,
plan = EXCLUDED.plan,
updated_at = EXCLUDED.updated_at
"""
if is_postgres()
else """
INSERT OR REPLACE INTO user_credit_windows
(user_id, window_start_at, window_end_at, credits_allocated, credits_used, plan, updated_at)
VALUES (?, ?, ?, ?, 0, ?, ?)
"""
)
execute_query(conn, sql, (user_id, now, end, credits, plan, now))
return {
"user_id": user_id,
"window_start_at": now,
"window_end_at": end,
"credits_allocated": credits,
"credits_used": 0,
"plan": plan,
"updated_at": now,
}
def _reset_window(conn, user_id: int, plan: str) -> dict:
"""Reset an expired window — starts a fresh 30-day period."""
return _create_window(conn, user_id, plan)
# ── Public API ──────────────────────────────────────────────────
def get_or_create_credit_window(user_id: int, plan: str) -> dict:
"""Return the active credit window, creating or resetting as needed.
This is the lazy initialization entrypoint:
- First call after registration creates the window.
- First call after window expiry resets it with a fresh allocation.
- Plan upgrades (free→pro) are reflected on the next reset.
"""
with db_connection() as conn:
window = _get_window(conn, user_id)
if window is None:
window = _create_window(conn, user_id, plan)
logger.info("Created credit window for user %d (plan=%s)", user_id, plan)
return window
if _is_window_expired(window["window_end_at"]):
window = _reset_window(conn, user_id, plan)
_invalidate_balance_cache(user_id)
logger.info("Reset expired credit window for user %d (plan=%s)", user_id, plan)
return window
# If plan changed mid-window, update allocation (pro upgrade benefit)
expected_credits = get_credits_for_plan(plan)
if window["plan"] != plan and expected_credits > window["credits_allocated"]:
additional = expected_credits - window["credits_allocated"]
sql = (
"""
UPDATE user_credit_windows
SET credits_allocated = credits_allocated + %s, plan = %s, updated_at = %s
WHERE user_id = %s
"""
if is_postgres()
else """
UPDATE user_credit_windows
SET credits_allocated = credits_allocated + ?, plan = ?, updated_at = ?
WHERE user_id = ?
"""
)
execute_query(conn, sql, (additional, plan, _utc_now(), user_id))
window["credits_allocated"] += additional
window["plan"] = plan
_invalidate_balance_cache(user_id)
logger.info(
"Upgraded credit window for user %d: +%d credits (plan=%s)",
user_id,
additional,
plan,
)
return window
def get_rolling_balance(user_id: int, plan: str) -> int:
"""Return remaining credits for the current window."""
cached = _get_cached_balance(user_id)
if cached is not None:
return cached
window = get_or_create_credit_window(user_id, plan)
balance = max(0, window["credits_allocated"] - window["credits_used"])
_cache_balance(user_id, balance)
return balance
def deduct_credits(user_id: int, plan: str, tool: str) -> int:
"""Deduct tool credits from the user's window. Returns the cost deducted.
Raises ValueError if insufficient credits.
"""
cost = get_tool_credit_cost(tool)
with db_connection() as conn:
# Ensure window is current
window = _get_window(conn, user_id)
if window is None or _is_window_expired(window.get("window_end_at", "")):
# get_or_create handles reset
pass
window = get_or_create_credit_window(user_id, plan)
balance = window["credits_allocated"] - window["credits_used"]
if balance < cost:
raise ValueError(
f"Insufficient credits: {balance} remaining, {cost} required for {tool}."
)
sql = (
"""
UPDATE user_credit_windows
SET credits_used = credits_used + %s, updated_at = %s
WHERE user_id = %s
"""
if is_postgres()
else """
UPDATE user_credit_windows
SET credits_used = credits_used + ?, updated_at = ?
WHERE user_id = ?
"""
)
execute_query(conn, sql, (cost, _utc_now(), user_id))
_invalidate_balance_cache(user_id)
return cost
def get_credit_summary(user_id: int, plan: str) -> dict:
"""Return a full credit summary for the account page."""
window = get_or_create_credit_window(user_id, plan)
balance = max(0, window["credits_allocated"] - window["credits_used"])
return {
"credits_allocated": window["credits_allocated"],
"credits_used": window["credits_used"],
"credits_remaining": balance,
"window_start_at": window["window_start_at"],
"window_end_at": window["window_end_at"],
"plan": window["plan"],
"window_days": CREDIT_WINDOW_DAYS,
}

View File

@@ -0,0 +1,86 @@
"""Guest demo budget enforcement.
Anonymous visitors get a small usage budget tracked by IP address
via Redis (with Flask session fallback). The budget prevents abuse
of expensive tools before the download-gate forces registration.
"""
import os
from flask import request, session
from app.services.credit_config import GUEST_DEMO_BUDGET, GUEST_DEMO_TTL_HOURS
_TTL_SECONDS = GUEST_DEMO_TTL_HOURS * 3600
# ── Redis helpers ──────────────────────────────────────────────
def _get_redis():
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:
return None
def _guest_redis_key(ip: str) -> str:
return f"guest_demo:{ip}"
def _get_client_ip() -> str:
"""Return the best-effort client IP for rate tracking."""
forwarded = request.headers.get("X-Forwarded-For", "")
if forwarded:
return forwarded.split(",")[0].strip()
return request.remote_addr or "unknown"
# ── Public API ─────────────────────────────────────────────────
def get_guest_remaining() -> int:
"""Return how many demo operations the current guest has left."""
ip = _get_client_ip()
r = _get_redis()
if r is not None:
try:
used = r.get(_guest_redis_key(ip))
if used is None:
return GUEST_DEMO_BUDGET
return max(0, GUEST_DEMO_BUDGET - int(str(used)))
except Exception:
pass
# Fallback: Flask session
used = session.get("guest_demo_used", 0)
return max(0, GUEST_DEMO_BUDGET - used)
def record_guest_usage() -> None:
"""Increment the guest demo counter for the current visitor."""
ip = _get_client_ip()
r = _get_redis()
if r is not None:
try:
key = _guest_redis_key(ip)
pipe = r.pipeline()
pipe.incr(key)
pipe.expire(key, _TTL_SECONDS)
pipe.execute()
return
except Exception:
pass
# Fallback: Flask session
session["guest_demo_used"] = session.get("guest_demo_used", 0) + 1
def assert_guest_budget_available() -> None:
"""Raise ValueError if the guest has exhausted their demo budget."""
remaining = get_guest_remaining()
if remaining <= 0:
raise ValueError(
"You have used all your free demo tries. "
"Create a free account to continue."
)

View File

@@ -12,6 +12,23 @@ from app.services.account_service import (
normalize_plan,
record_usage_event,
)
from app.services.credit_config import (
get_tool_credit_cost,
get_credits_for_plan,
get_all_tool_costs,
GUEST_DEMO_BUDGET,
GUEST_DEMO_TTL_HOURS,
PRO_API_CREDITS_PER_WINDOW,
)
from app.services.credit_service import (
deduct_credits,
get_credit_summary,
get_rolling_balance,
)
from app.services.guest_budget_service import (
assert_guest_budget_available,
record_guest_usage,
)
from app.utils.auth import get_current_user_id, logout_user_session
from app.utils.auth import has_session_task_access, remember_task_access
from app.utils.file_validator import validate_file
@@ -19,10 +36,6 @@ from app.utils.file_validator import validate_file
FREE_PLAN = "free"
PRO_PLAN = "pro"
FREE_WEB_MONTHLY_LIMIT = 50
PRO_WEB_MONTHLY_LIMIT = 500
PRO_API_MONTHLY_LIMIT = 1000
FREE_HISTORY_LIMIT = 25
PRO_HISTORY_LIMIT = 250
@@ -56,15 +69,15 @@ def get_history_limit(plan: str) -> int:
def get_web_quota_limit(plan: str, actor_type: str) -> int | None:
"""Return the monthly accepted-task cap for one web actor."""
"""Return the credit allocation for one web actor's window."""
if actor_type == "anonymous":
return None
return PRO_WEB_MONTHLY_LIMIT if normalize_plan(plan) == PRO_PLAN else FREE_WEB_MONTHLY_LIMIT
return get_credits_for_plan(normalize_plan(plan))
def get_api_quota_limit(plan: str) -> int | None:
"""Return the monthly accepted-task cap for one API actor."""
return PRO_API_MONTHLY_LIMIT if normalize_plan(plan) == PRO_PLAN else None
"""Return the credit allocation for one API actor's window."""
return PRO_API_CREDITS_PER_WINDOW if normalize_plan(plan) == PRO_PLAN else None
def ads_enabled(plan: str, actor_type: str) -> bool:
@@ -97,27 +110,19 @@ def get_effective_file_size_limits_mb(plan: str) -> dict[str, int]:
def get_usage_summary_for_user(user_id: int, plan: str) -> dict:
"""Return usage/quota summary for one authenticated user."""
normalized_plan = normalize_plan(plan)
current_period = get_current_period_month()
web_used = count_usage_events(
user_id, "web", event_type="accepted", period_month=current_period
)
api_used = count_usage_events(
user_id, "api", event_type="accepted", period_month=current_period
)
credit_info = get_credit_summary(user_id, normalized_plan)
return {
"plan": normalized_plan,
"period_month": current_period,
"ads_enabled": ads_enabled(normalized_plan, "session"),
"history_limit": get_history_limit(normalized_plan),
"file_limits_mb": get_effective_file_size_limits_mb(normalized_plan),
"credits": credit_info,
"tool_costs": get_all_tool_costs(),
# Legacy fields for backward compatibility
"web_quota": {
"used": web_used,
"limit": get_web_quota_limit(normalized_plan, "session"),
},
"api_quota": {
"used": api_used,
"limit": get_api_quota_limit(normalized_plan),
"used": credit_info["credits_used"],
"limit": credit_info["credits_allocated"],
},
}
@@ -173,21 +178,38 @@ def validate_actor_file(file_storage, allowed_types: list[str], actor: ActorCont
)
def assert_quota_available(actor: ActorContext):
"""Ensure an actor still has accepted-task quota for the current month."""
def assert_quota_available(actor: ActorContext, tool: str | None = None):
"""Ensure an actor still has credits for the requested tool.
For registered users: checks rolling credit window balance.
For anonymous users: checks guest demo budget.
"""
if actor.user_id is None:
# Guest demo budget enforcement
try:
assert_guest_budget_available()
except ValueError:
raise PolicyError(
"You have used all your free demo tries. "
"Create a free account to continue.",
429,
)
return
if actor.source == "web":
limit = get_web_quota_limit(actor.plan, actor.actor_type)
if limit is None:
return
used = count_usage_events(actor.user_id, "web", event_type="accepted")
if used >= limit:
# Credit-based check
cost = get_tool_credit_cost(tool) if tool else 1
balance = get_rolling_balance(actor.user_id, actor.plan)
if balance < cost:
if normalize_plan(actor.plan) == PRO_PLAN:
raise PolicyError("Your monthly Pro web quota has been reached.", 429)
raise PolicyError(
f"Your Pro credit balance is exhausted ({balance} remaining, "
f"{cost} required). Credits reset at the end of your 30-day window.",
429,
)
raise PolicyError(
"Your monthly free plan limit has been reached. Upgrade to Pro for higher limits.",
f"Your free credit balance is exhausted ({balance} remaining, "
f"{cost} required). Upgrade to Pro for more credits.",
429,
)
return
@@ -202,10 +224,29 @@ def assert_quota_available(actor: ActorContext):
def record_accepted_usage(actor: ActorContext, tool: str, celery_task_id: str):
"""Record one accepted usage event after task dispatch succeeds."""
"""Record one accepted usage event and deduct credits after task dispatch."""
if actor.source == "web":
remember_task_access(celery_task_id)
cost = get_tool_credit_cost(tool)
# Deduct credits from the rolling window (registered users only)
if actor.user_id is not None and actor.source == "web":
try:
deduct_credits(actor.user_id, actor.plan, tool)
except ValueError:
# Balance check should have caught this in assert_quota_available.
# Log but don't block — the usage event is the source of truth.
import logging
logging.getLogger(__name__).warning(
"Credit deduction failed for user %d tool %s (insufficient balance at record time)",
actor.user_id,
tool,
)
elif actor.user_id is None and actor.source == "web":
# Record guest demo usage
record_guest_usage()
record_usage_event(
user_id=actor.user_id,
source=actor.source,
@@ -213,6 +254,7 @@ def record_accepted_usage(actor: ActorContext, tool: str, celery_task_id: str):
task_id=celery_task_id,
event_type="accepted",
api_key_id=actor.api_key_id,
cost_points=cost,
)

View File

@@ -0,0 +1,151 @@
"""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)