Spaces:
Running
Running
refactor: replace daily usage increment with atomic reservation
Browse files- main.py +17 -17
- quiz_generator/routes.py +15 -7
- rag/rag.py +47 -27
- store.py +145 -5
- summary_generator/routes.py +15 -7
main.py
CHANGED
|
@@ -50,23 +50,23 @@ async def lifespan(app: FastAPI):
|
|
| 50 |
logger.warning(f"Embedder failed to load: {e}")
|
| 51 |
|
| 52 |
# Eagerly load ASR models so warmup runs at startup, not on first request
|
| 53 |
-
try:
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
except Exception as e:
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
try:
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
except Exception as e:
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
from src.rag.batch_workers import start_workers
|
| 66 |
-
start_workers()
|
| 67 |
-
|
| 68 |
-
from src.asr.batch_workers import start_asr_workers
|
| 69 |
-
start_asr_workers()
|
| 70 |
|
| 71 |
yield
|
| 72 |
|
|
|
|
| 50 |
logger.warning(f"Embedder failed to load: {e}")
|
| 51 |
|
| 52 |
# Eagerly load ASR models so warmup runs at startup, not on first request
|
| 53 |
+
# try:
|
| 54 |
+
# from src.asr.models import get_audio_model_en
|
| 55 |
+
# get_audio_model_en()
|
| 56 |
+
# except Exception as e:
|
| 57 |
+
# logger.warning(f"English ASR model failed to load: {e}")
|
| 58 |
+
|
| 59 |
+
# try:
|
| 60 |
+
# from src.asr.models import get_audio_model_ar
|
| 61 |
+
# get_audio_model_ar()
|
| 62 |
+
# except Exception as e:
|
| 63 |
+
# logger.warning(f"Arabic ASR model failed to load: {e}")
|
| 64 |
+
|
| 65 |
+
# from src.rag.batch_workers import start_workers
|
| 66 |
+
# start_workers()
|
| 67 |
+
|
| 68 |
+
# from src.asr.batch_workers import start_asr_workers
|
| 69 |
+
# start_asr_workers()
|
| 70 |
|
| 71 |
yield
|
| 72 |
|
quiz_generator/routes.py
CHANGED
|
@@ -3,7 +3,7 @@ from loguru import logger
|
|
| 3 |
from fastapi import APIRouter, HTTPException, Depends
|
| 4 |
from typing import Optional
|
| 5 |
from src.quiz_generator.quiz import smart_quiz_generator
|
| 6 |
-
from src.store import get_material, get_chunks, save_chunks, get_summary, save_quiz, get_quizzes, save_quiz_result, get_quiz_results,
|
| 7 |
from src.dependencies import get_current_user_id, get_current_user
|
| 8 |
from src.config import settings
|
| 9 |
from .schemas import QuizRequest, QuizResponse, SaveQuizResultRequest
|
|
@@ -31,9 +31,10 @@ async def generate_quiz(
|
|
| 31 |
user_id: str = Depends(get_current_user_id),
|
| 32 |
current_user=Depends(get_current_user),
|
| 33 |
):
|
| 34 |
-
#
|
| 35 |
user_email = current_user.get("email") if isinstance(current_user, dict) else getattr(current_user, "email", None)
|
| 36 |
-
|
|
|
|
| 37 |
raise HTTPException(429, "Daily limit of 20 requests reached. Come back tomorrow!")
|
| 38 |
|
| 39 |
body.difficulty = body.difficulty.capitalize()
|
|
@@ -158,16 +159,23 @@ async def generate_quiz(
|
|
| 158 |
model_name=settings.model_name,
|
| 159 |
)
|
| 160 |
|
| 161 |
-
# Only increment limit if the quiz was generated successfully and saved without error
|
| 162 |
-
if not (user_email and user_email in ADMIN_EMAILS):
|
| 163 |
-
increment_daily_usage(user_id)
|
| 164 |
-
|
| 165 |
return QuizResponse(quiz=quiz, quiz_id=saved["id"])
|
| 166 |
except ValueError as e:
|
| 167 |
logger.warning(f"Validation error in generate_quiz: {str(e)}")
|
|
|
|
|
|
|
|
|
|
| 168 |
raise HTTPException(400, str(e))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
except Exception as e:
|
| 170 |
logger.error(f"Quiz generation failed: {str(e)}", exc_info=True)
|
|
|
|
|
|
|
|
|
|
| 171 |
raise HTTPException(500, f"Quiz generation failed: {e}")
|
| 172 |
|
| 173 |
|
|
|
|
| 3 |
from fastapi import APIRouter, HTTPException, Depends
|
| 4 |
from typing import Optional
|
| 5 |
from src.quiz_generator.quiz import smart_quiz_generator
|
| 6 |
+
from src.store import get_material, get_chunks, save_chunks, get_summary, save_quiz, get_quizzes, save_quiz_result, get_quiz_results, atomic_check_and_increment_daily_limit, decrement_daily_usage, ADMIN_EMAILS
|
| 7 |
from src.dependencies import get_current_user_id, get_current_user
|
| 8 |
from src.config import settings
|
| 9 |
from .schemas import QuizRequest, QuizResponse, SaveQuizResultRequest
|
|
|
|
| 31 |
user_id: str = Depends(get_current_user_id),
|
| 32 |
current_user=Depends(get_current_user),
|
| 33 |
):
|
| 34 |
+
# Atomically reserve a daily request slot before starting expensive generation.
|
| 35 |
user_email = current_user.get("email") if isinstance(current_user, dict) else getattr(current_user, "email", None)
|
| 36 |
+
is_admin = bool(user_email and user_email in ADMIN_EMAILS)
|
| 37 |
+
if not atomic_check_and_increment_daily_limit(user_id, email=user_email, limit=20):
|
| 38 |
raise HTTPException(429, "Daily limit of 20 requests reached. Come back tomorrow!")
|
| 39 |
|
| 40 |
body.difficulty = body.difficulty.capitalize()
|
|
|
|
| 159 |
model_name=settings.model_name,
|
| 160 |
)
|
| 161 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
return QuizResponse(quiz=quiz, quiz_id=saved["id"])
|
| 163 |
except ValueError as e:
|
| 164 |
logger.warning(f"Validation error in generate_quiz: {str(e)}")
|
| 165 |
+
# Refund the reserved slot β validation errors are our fault, not the user's.
|
| 166 |
+
if not is_admin:
|
| 167 |
+
decrement_daily_usage(user_id)
|
| 168 |
raise HTTPException(400, str(e))
|
| 169 |
+
except HTTPException:
|
| 170 |
+
# Re-raise HTTP exceptions as-is (403, 400, etc.).
|
| 171 |
+
if not is_admin:
|
| 172 |
+
decrement_daily_usage(user_id)
|
| 173 |
+
raise
|
| 174 |
except Exception as e:
|
| 175 |
logger.error(f"Quiz generation failed: {str(e)}", exc_info=True)
|
| 176 |
+
# Refund: generation/save failed through no fault of the user.
|
| 177 |
+
if not is_admin:
|
| 178 |
+
decrement_daily_usage(user_id)
|
| 179 |
raise HTTPException(500, f"Quiz generation failed: {e}")
|
| 180 |
|
| 181 |
|
rag/rag.py
CHANGED
|
@@ -75,40 +75,60 @@ async def store_embeddings_async(material_id: str, chunk_ids: list[str], chunks:
|
|
| 75 |
"""
|
| 76 |
Async variant of store_embeddings that routes embedding inference through
|
| 77 |
the batch worker queue for batching across concurrent requests.
|
|
|
|
| 78 |
"""
|
| 79 |
-
|
| 80 |
-
|
|
|
|
| 81 |
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
await job.done.wait()
|
| 86 |
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
| 92 |
|
| 93 |
-
|
| 94 |
-
{"chunk_id": cid, "material_id": material_id, "embedding": emb}
|
| 95 |
-
for cid, emb in zip(chunk_ids, embeddings)
|
| 96 |
-
]
|
| 97 |
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
return
|
| 102 |
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
await loop.run_in_executor(None, _insert_records)
|
| 111 |
-
logger.info(f"Embeddings stored successfully for material {material_id}.")
|
| 112 |
|
| 113 |
|
| 114 |
def similarity_search(query: str, material_id: str, k: int = 5) -> list[dict]:
|
|
|
|
| 75 |
"""
|
| 76 |
Async variant of store_embeddings that routes embedding inference through
|
| 77 |
the batch worker queue for batching across concurrent requests.
|
| 78 |
+
Falls back to the synchronous path if the batch workers are not running.
|
| 79 |
"""
|
| 80 |
+
try:
|
| 81 |
+
from src.rag.batch_workers import embedding_queue, job_store
|
| 82 |
+
from .schemas import EmbeddingJob
|
| 83 |
|
| 84 |
+
job = EmbeddingJob(job_id=str(uuid.uuid4()), texts=chunks)
|
| 85 |
+
job_store[job.job_id] = {"status": "pending", "result": None, "error": None}
|
| 86 |
+
await embedding_queue.put(job)
|
|
|
|
| 87 |
|
| 88 |
+
# Wait for the worker to process the job, but with a timeout.
|
| 89 |
+
# If batch workers are not running (e.g. commented out in main.py),
|
| 90 |
+
# this would hang forever β the timeout triggers a fallback to the sync path.
|
| 91 |
+
try:
|
| 92 |
+
await asyncio.wait_for(job.done.wait(), timeout=30.0)
|
| 93 |
+
except asyncio.TimeoutError:
|
| 94 |
+
# Workers not running β clean up and fall back to sync embedding
|
| 95 |
+
job_store.pop(job.job_id, None)
|
| 96 |
+
logger.warning(
|
| 97 |
+
f"store_embeddings_async timed out waiting for batch worker "
|
| 98 |
+
f"(material={material_id}). Falling back to synchronous embedding."
|
| 99 |
+
)
|
| 100 |
+
await asyncio.to_thread(store_embeddings, material_id, chunk_ids, chunks)
|
| 101 |
+
return
|
| 102 |
+
|
| 103 |
+
entry = job_store.pop(job.job_id)
|
| 104 |
+
if entry["status"] == "error":
|
| 105 |
+
raise RuntimeError(f"Embedding failed: {entry['error']}")
|
| 106 |
+
|
| 107 |
+
embeddings = entry["result"]
|
| 108 |
+
|
| 109 |
+
records = [
|
| 110 |
+
{"chunk_id": cid, "material_id": material_id, "embedding": emb}
|
| 111 |
+
for cid, emb in zip(chunk_ids, embeddings)
|
| 112 |
+
]
|
| 113 |
|
| 114 |
+
db = get_supabase()
|
| 115 |
+
if db is None:
|
| 116 |
+
logger.warning("Supabase not connected β embeddings computed but NOT stored (no DB).")
|
| 117 |
+
return
|
| 118 |
|
| 119 |
+
logger.info(f"Storing {len(records)} embeddings in Supabase for material {material_id}...")
|
|
|
|
|
|
|
|
|
|
| 120 |
|
| 121 |
+
def _insert_records():
|
| 122 |
+
for i in range(0, len(records), 50):
|
| 123 |
+
db.table("material_embeddings").insert(records[i:i + 50]).execute()
|
|
|
|
| 124 |
|
| 125 |
+
await asyncio.to_thread(_insert_records)
|
| 126 |
+
logger.info(f"Embeddings stored successfully for material {material_id}.")
|
| 127 |
+
|
| 128 |
+
except Exception as e:
|
| 129 |
+
# If anything unexpected fails, fall back to sync to avoid blocking the caller.
|
| 130 |
+
logger.warning(f"store_embeddings_async failed ({e}); falling back to sync.")
|
| 131 |
+
await asyncio.to_thread(store_embeddings, material_id, chunk_ids, chunks)
|
|
|
|
|
|
|
| 132 |
|
| 133 |
|
| 134 |
def similarity_search(query: str, material_id: str, k: int = 5) -> list[dict]:
|
store.py
CHANGED
|
@@ -816,14 +816,154 @@ def increment_daily_usage(user_id: str) -> None:
|
|
| 816 |
logger.error(f"Failed to increment daily usage: {e}")
|
| 817 |
|
| 818 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 819 |
def check_and_increment_daily_limit(user_id: str, email: Optional[str] = None, limit: int = 20) -> bool:
|
| 820 |
"""
|
| 821 |
-
|
|
|
|
| 822 |
"""
|
| 823 |
-
|
| 824 |
-
if allowed and not (email and email in ADMIN_EMAILS):
|
| 825 |
-
increment_daily_usage(user_id)
|
| 826 |
-
return allowed
|
| 827 |
|
| 828 |
|
| 829 |
def get_usage(user_id: str) -> dict:
|
|
|
|
| 816 |
logger.error(f"Failed to increment daily usage: {e}")
|
| 817 |
|
| 818 |
|
| 819 |
+
def decrement_daily_usage(user_id: str) -> None:
|
| 820 |
+
"""
|
| 821 |
+
Rollback helper β decrements the daily counter by 1 (clamped to 0).
|
| 822 |
+
|
| 823 |
+
Called when a generation request was reserved (incremented) but failed
|
| 824 |
+
before producing a usable result, so the user is not penalised for a
|
| 825 |
+
server-side error.
|
| 826 |
+
"""
|
| 827 |
+
today = _get_today_date_str()
|
| 828 |
+
|
| 829 |
+
# ββ Redis path β atomic DECR clamped to 0 ββββββββββββββββββββββββββββββββ
|
| 830 |
+
r = get_redis()
|
| 831 |
+
if r is not None:
|
| 832 |
+
try:
|
| 833 |
+
rkey = f"{DAILY_RATE_LIMIT_KEY_PREFIX}{user_id}:{today}"
|
| 834 |
+
# DECR is atomic; clamp to 0 so we never go negative
|
| 835 |
+
current = r.decr(rkey)
|
| 836 |
+
if current < 0:
|
| 837 |
+
r.set(rkey, 0)
|
| 838 |
+
r.expire(rkey, DAILY_RATE_LIMIT_REDIS_TTL)
|
| 839 |
+
except Exception as e:
|
| 840 |
+
logger.warning("Redis decrement_daily_usage failed: %s", e)
|
| 841 |
+
|
| 842 |
+
# ββ Supabase β keep DB in sync ββββββββββββββββββββββββββββββββββββββββββββ
|
| 843 |
+
try:
|
| 844 |
+
result = _robust_execute(
|
| 845 |
+
_table_supabase("profiles")
|
| 846 |
+
.select("daily_requests, last_request_date")
|
| 847 |
+
.eq("id", user_id)
|
| 848 |
+
)
|
| 849 |
+
if not result.data:
|
| 850 |
+
return
|
| 851 |
+
profile = result.data[0] if result.data else None
|
| 852 |
+
if not profile:
|
| 853 |
+
return
|
| 854 |
+
|
| 855 |
+
last_date = profile.get("last_request_date")
|
| 856 |
+
count = profile.get("daily_requests", 0) or 0
|
| 857 |
+
if last_date != today:
|
| 858 |
+
count = 0
|
| 859 |
+
new_count = max(0, count - 1)
|
| 860 |
+
|
| 861 |
+
_robust_execute(
|
| 862 |
+
_table_supabase("profiles")
|
| 863 |
+
.update({"daily_requests": new_count, "last_request_date": today})
|
| 864 |
+
.eq("id", user_id)
|
| 865 |
+
)
|
| 866 |
+
except Exception as e:
|
| 867 |
+
logger.error(f"Failed to decrement daily usage: {e}")
|
| 868 |
+
|
| 869 |
+
|
| 870 |
+
# Lua script: atomically check count < limit, then INCR + EXPIRE if allowed.
|
| 871 |
+
# Returns 1 (allowed and incremented) or 0 (limit exceeded, no change).
|
| 872 |
+
_ATOMIC_RATE_LIMIT_LUA = """
|
| 873 |
+
local key = KEYS[1]
|
| 874 |
+
local limit = tonumber(ARGV[1])
|
| 875 |
+
local ttl = tonumber(ARGV[2])
|
| 876 |
+
local count = tonumber(redis.call('GET', key) or 0)
|
| 877 |
+
if count >= limit then
|
| 878 |
+
return 0
|
| 879 |
+
end
|
| 880 |
+
redis.call('INCR', key)
|
| 881 |
+
redis.call('EXPIRE', key, ttl)
|
| 882 |
+
return 1
|
| 883 |
+
"""
|
| 884 |
+
|
| 885 |
+
|
| 886 |
+
def atomic_check_and_increment_daily_limit(
|
| 887 |
+
user_id: str, email: Optional[str] = None, limit: int = 20
|
| 888 |
+
) -> bool:
|
| 889 |
+
"""
|
| 890 |
+
Atomically check the daily limit AND increment in one operation.
|
| 891 |
+
|
| 892 |
+
This eliminates the TOCTOU race condition that existed when `check_daily_limit`
|
| 893 |
+
and `increment_daily_usage` were called as two separate steps: concurrent
|
| 894 |
+
requests could both pass the check before either had incremented the counter.
|
| 895 |
+
|
| 896 |
+
Redis path: executes a Lua script so the GET + conditional INCR is one
|
| 897 |
+
indivisible command β Redis serialises all commands within a script.
|
| 898 |
+
|
| 899 |
+
Supabase fallback: increment first, then verify; rollback if over limit.
|
| 900 |
+
This is safe because Supabase operations are individually atomic (but not
|
| 901 |
+
as tight as the Lua approach under extreme concurrency).
|
| 902 |
+
|
| 903 |
+
Returns True if the request is allowed (counter was incremented),
|
| 904 |
+
False if the daily limit is already reached (counter unchanged).
|
| 905 |
+
"""
|
| 906 |
+
# Admins are always allowed and never counted
|
| 907 |
+
if email and email in ADMIN_EMAILS:
|
| 908 |
+
return True
|
| 909 |
+
|
| 910 |
+
today = _get_today_date_str()
|
| 911 |
+
|
| 912 |
+
# ββ Redis path: true atomic check-and-increment via Lua βββββββββββββββββββ
|
| 913 |
+
r = get_redis()
|
| 914 |
+
if r is not None:
|
| 915 |
+
try:
|
| 916 |
+
rkey = f"{DAILY_RATE_LIMIT_KEY_PREFIX}{user_id}:{today}"
|
| 917 |
+
result = r.eval(_ATOMIC_RATE_LIMIT_LUA, 1, rkey, limit, DAILY_RATE_LIMIT_REDIS_TTL)
|
| 918 |
+
return bool(result) # 1 β allowed, 0 β exceeded
|
| 919 |
+
except Exception as e:
|
| 920 |
+
logger.warning(
|
| 921 |
+
"Redis atomic_check_and_increment failed: %s β falling back to Supabase", e
|
| 922 |
+
)
|
| 923 |
+
|
| 924 |
+
# ββ Supabase fallback: increment-first strategy βββββββββββββββββββββββββββ
|
| 925 |
+
try:
|
| 926 |
+
result = _robust_execute(
|
| 927 |
+
_table_supabase("profiles")
|
| 928 |
+
.select("daily_requests, last_request_date")
|
| 929 |
+
.eq("id", user_id)
|
| 930 |
+
)
|
| 931 |
+
if not result.data:
|
| 932 |
+
# No profile row yet β treat as first request (allowed)
|
| 933 |
+
_robust_execute(
|
| 934 |
+
_table_supabase("profiles")
|
| 935 |
+
.update({"daily_requests": 1, "last_request_date": today})
|
| 936 |
+
.eq("id", user_id)
|
| 937 |
+
)
|
| 938 |
+
return True
|
| 939 |
+
|
| 940 |
+
profile = result.data[0]
|
| 941 |
+
last_date = profile.get("last_request_date")
|
| 942 |
+
count = profile.get("daily_requests", 0) or 0
|
| 943 |
+
if last_date != today:
|
| 944 |
+
count = 0 # day rolled over β reset
|
| 945 |
+
|
| 946 |
+
if count >= limit:
|
| 947 |
+
return False # already at limit β do not increment
|
| 948 |
+
|
| 949 |
+
# Increment in Supabase
|
| 950 |
+
_robust_execute(
|
| 951 |
+
_table_supabase("profiles")
|
| 952 |
+
.update({"daily_requests": count + 1, "last_request_date": today})
|
| 953 |
+
.eq("id", user_id)
|
| 954 |
+
)
|
| 955 |
+
return True
|
| 956 |
+
except Exception as e:
|
| 957 |
+
logger.error(f"Supabase atomic_check_and_increment failed: {e}")
|
| 958 |
+
return True # fail open rather than block the user on a DB error
|
| 959 |
+
|
| 960 |
+
|
| 961 |
def check_and_increment_daily_limit(user_id: str, email: Optional[str] = None, limit: int = 20) -> bool:
|
| 962 |
"""
|
| 963 |
+
Legacy wrapper kept for backward compatibility (used in rag/routes.py).
|
| 964 |
+
Delegates to the new atomic implementation.
|
| 965 |
"""
|
| 966 |
+
return atomic_check_and_increment_daily_limit(user_id, email, limit)
|
|
|
|
|
|
|
|
|
|
| 967 |
|
| 968 |
|
| 969 |
def get_usage(user_id: str) -> dict:
|
summary_generator/routes.py
CHANGED
|
@@ -9,7 +9,7 @@ from src.rag.rag import store_embeddings_async
|
|
| 9 |
from src.store import (
|
| 10 |
get_material, get_chunks, save_chunks, save_summary,
|
| 11 |
get_summary as get_stored_summary, update_material_status,
|
| 12 |
-
|
| 13 |
)
|
| 14 |
from src.dependencies import get_current_user_id, get_current_user
|
| 15 |
from src.config import settings
|
|
@@ -25,15 +25,20 @@ async def generate_summary(
|
|
| 25 |
user_id: str = Depends(get_current_user_id),
|
| 26 |
current_user=Depends(get_current_user),
|
| 27 |
):
|
| 28 |
-
#
|
| 29 |
user_email = current_user.get("email") if isinstance(current_user, dict) else getattr(current_user, "email", None)
|
| 30 |
-
|
|
|
|
| 31 |
raise HTTPException(429, "Daily limit of 20 requests reached. Come back tomorrow!")
|
| 32 |
|
| 33 |
mat = get_material(body.material_id)
|
| 34 |
if not mat:
|
|
|
|
|
|
|
| 35 |
raise HTTPException(404, "Material not found")
|
| 36 |
if mat.get("user_id") != user_id:
|
|
|
|
|
|
|
| 37 |
raise HTTPException(403, "Access denied")
|
| 38 |
|
| 39 |
try:
|
|
@@ -106,12 +111,15 @@ async def generate_summary(
|
|
| 106 |
model_name=settings.model_name,
|
| 107 |
)
|
| 108 |
|
| 109 |
-
# Only increment limit if the summary was generated successfully and saved without error
|
| 110 |
-
if not (user_email and user_email in settings.admin_emails if hasattr(settings, "admin_emails") else False):
|
| 111 |
-
increment_daily_usage(user_id)
|
| 112 |
-
|
| 113 |
return SummarizeResponse(summary=summary, time_taken=elapsed)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
except Exception as e:
|
|
|
|
|
|
|
| 115 |
raise HTTPException(500, f"Summarization failed: {e}")
|
| 116 |
|
| 117 |
|
|
|
|
| 9 |
from src.store import (
|
| 10 |
get_material, get_chunks, save_chunks, save_summary,
|
| 11 |
get_summary as get_stored_summary, update_material_status,
|
| 12 |
+
atomic_check_and_increment_daily_limit, decrement_daily_usage, ADMIN_EMAILS,
|
| 13 |
)
|
| 14 |
from src.dependencies import get_current_user_id, get_current_user
|
| 15 |
from src.config import settings
|
|
|
|
| 25 |
user_id: str = Depends(get_current_user_id),
|
| 26 |
current_user=Depends(get_current_user),
|
| 27 |
):
|
| 28 |
+
# Atomically reserve a daily request slot before starting expensive generation.
|
| 29 |
user_email = current_user.get("email") if isinstance(current_user, dict) else getattr(current_user, "email", None)
|
| 30 |
+
is_admin = bool(user_email and user_email in ADMIN_EMAILS)
|
| 31 |
+
if not atomic_check_and_increment_daily_limit(user_id, email=user_email, limit=20):
|
| 32 |
raise HTTPException(429, "Daily limit of 20 requests reached. Come back tomorrow!")
|
| 33 |
|
| 34 |
mat = get_material(body.material_id)
|
| 35 |
if not mat:
|
| 36 |
+
if not is_admin:
|
| 37 |
+
decrement_daily_usage(user_id)
|
| 38 |
raise HTTPException(404, "Material not found")
|
| 39 |
if mat.get("user_id") != user_id:
|
| 40 |
+
if not is_admin:
|
| 41 |
+
decrement_daily_usage(user_id)
|
| 42 |
raise HTTPException(403, "Access denied")
|
| 43 |
|
| 44 |
try:
|
|
|
|
| 111 |
model_name=settings.model_name,
|
| 112 |
)
|
| 113 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
return SummarizeResponse(summary=summary, time_taken=elapsed)
|
| 115 |
+
except HTTPException:
|
| 116 |
+
# Refund on any HTTP exception raised inside the try block (e.g. 400).
|
| 117 |
+
if not is_admin:
|
| 118 |
+
decrement_daily_usage(user_id)
|
| 119 |
+
raise
|
| 120 |
except Exception as e:
|
| 121 |
+
if not is_admin:
|
| 122 |
+
decrement_daily_usage(user_id)
|
| 123 |
raise HTTPException(500, f"Summarization failed: {e}")
|
| 124 |
|
| 125 |
|