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:
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
122
backend/app/services/credit_config.py
Normal file
122
backend/app/services/credit_config.py
Normal 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)
|
||||
268
backend/app/services/credit_service.py
Normal file
268
backend/app/services/credit_service.py
Normal 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,
|
||||
}
|
||||
86
backend/app/services/guest_budget_service.py
Normal file
86
backend/app/services/guest_budget_service.py
Normal 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."
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
151
backend/app/services/translation_guardrails.py
Normal file
151
backend/app/services/translation_guardrails.py
Normal 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)
|
||||
Reference in New Issue
Block a user