Spaces:
Running
Running
feat: implement profile caching and optimize database client initialization.
Browse files- auth/routes.py +44 -26
- database.py +16 -6
- dependencies.py +37 -12
- quiz_generator/quiz.py +6 -0
- store.py +5 -4
- summary_generator/summary.py +2 -1
auth/routes.py
CHANGED
|
@@ -28,50 +28,68 @@ async def get_profile(
|
|
| 28 |
current_user=Depends(get_current_user)
|
| 29 |
):
|
| 30 |
user = get_user_by_id(user_id)
|
| 31 |
-
|
| 32 |
-
#
|
| 33 |
email = user.get("email", "") if user else ""
|
| 34 |
-
is_placeholder =
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
if is_placeholder:
|
| 37 |
-
#
|
| 38 |
from src.database import get_supabase
|
| 39 |
supabase = get_supabase()
|
| 40 |
if supabase:
|
| 41 |
try:
|
| 42 |
-
# Use admin API to get real user data
|
| 43 |
res = supabase.auth.admin.get_user_by_id(user_id)
|
| 44 |
if res.user and res.user.email and "@" in res.user.email:
|
| 45 |
real_email = res.user.email
|
| 46 |
real_name = res.user.user_metadata.get("name")
|
| 47 |
-
|
| 48 |
-
# Use upsert to create or update the profile with real data
|
| 49 |
from src.store import _table_supabase, _map_profile
|
| 50 |
data = {"id": user_id, "email": real_email}
|
| 51 |
if real_name:
|
| 52 |
data["display_name"] = real_name
|
| 53 |
-
|
| 54 |
try:
|
| 55 |
-
res_upd = _table_supabase("profiles").
|
| 56 |
if res_upd.data:
|
| 57 |
user = _map_profile(res_upd.data[0])
|
| 58 |
except Exception:
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
except Exception:
|
| 61 |
pass
|
| 62 |
-
|
| 63 |
-
if not user
|
| 64 |
-
# Last resort:
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
if not user:
|
| 77 |
raise HTTPException(404, "Profile not found")
|
|
@@ -82,8 +100,8 @@ async def get_profile(
|
|
| 82 |
async def update_profile(body: ProfileUpdateRequest, user_id: str = Depends(get_current_user_id)):
|
| 83 |
try:
|
| 84 |
updated_user = update_user_profile(
|
| 85 |
-
user_id,
|
| 86 |
-
name=body.name,
|
| 87 |
avatar_url=body.avatar_url,
|
| 88 |
theme=body.theme
|
| 89 |
)
|
|
|
|
| 28 |
current_user=Depends(get_current_user)
|
| 29 |
):
|
| 30 |
user = get_user_by_id(user_id)
|
| 31 |
+
|
| 32 |
+
# Fast-path: profile row exists and has a real email — return immediately
|
| 33 |
email = user.get("email", "") if user else ""
|
| 34 |
+
is_placeholder = (
|
| 35 |
+
not user
|
| 36 |
+
or not email
|
| 37 |
+
or "@placeholder.ai" in email
|
| 38 |
+
or "@studymate.ai" in email
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
if is_placeholder:
|
| 42 |
+
# First-ever login: pull real data from Supabase Auth admin API and persist it
|
| 43 |
from src.database import get_supabase
|
| 44 |
supabase = get_supabase()
|
| 45 |
if supabase:
|
| 46 |
try:
|
|
|
|
| 47 |
res = supabase.auth.admin.get_user_by_id(user_id)
|
| 48 |
if res.user and res.user.email and "@" in res.user.email:
|
| 49 |
real_email = res.user.email
|
| 50 |
real_name = res.user.user_metadata.get("name")
|
| 51 |
+
|
|
|
|
| 52 |
from src.store import _table_supabase, _map_profile
|
| 53 |
data = {"id": user_id, "email": real_email}
|
| 54 |
if real_name:
|
| 55 |
data["display_name"] = real_name
|
| 56 |
+
|
| 57 |
try:
|
| 58 |
+
res_upd = _table_supabase("profiles").insert(data).execute()
|
| 59 |
if res_upd.data:
|
| 60 |
user = _map_profile(res_upd.data[0])
|
| 61 |
except Exception:
|
| 62 |
+
# Profile row probably already exists — do a targeted update instead
|
| 63 |
+
try:
|
| 64 |
+
res_upd = (
|
| 65 |
+
_table_supabase("profiles")
|
| 66 |
+
.update(data)
|
| 67 |
+
.eq("id", user_id)
|
| 68 |
+
.execute()
|
| 69 |
+
)
|
| 70 |
+
if res_upd.data:
|
| 71 |
+
user = _map_profile(res_upd.data[0])
|
| 72 |
+
except Exception:
|
| 73 |
+
pass
|
| 74 |
except Exception:
|
| 75 |
pass
|
| 76 |
+
|
| 77 |
+
if not user:
|
| 78 |
+
# Last resort: synthetic profile from the JWT claims so the UI doesn't break
|
| 79 |
+
user_obj = current_user
|
| 80 |
+
uid = getattr(user_obj, "id", None) or (user_obj.get("id") if isinstance(user_obj, dict) else None)
|
| 81 |
+
if uid:
|
| 82 |
+
from src.store import _map_profile
|
| 83 |
+
meta = getattr(user_obj, "user_metadata", {}) or {}
|
| 84 |
+
user = _map_profile({
|
| 85 |
+
"id": uid,
|
| 86 |
+
"display_name": meta.get("full_name") or meta.get("name") or "User",
|
| 87 |
+
"email": getattr(user_obj, "email", "") or "",
|
| 88 |
+
"avatar_url": "",
|
| 89 |
+
"daily_requests": 0,
|
| 90 |
+
"last_request_date": "",
|
| 91 |
+
"_is_fallback": True,
|
| 92 |
+
})
|
| 93 |
|
| 94 |
if not user:
|
| 95 |
raise HTTPException(404, "Profile not found")
|
|
|
|
| 100 |
async def update_profile(body: ProfileUpdateRequest, user_id: str = Depends(get_current_user_id)):
|
| 101 |
try:
|
| 102 |
updated_user = update_user_profile(
|
| 103 |
+
user_id,
|
| 104 |
+
name=body.name,
|
| 105 |
avatar_url=body.avatar_url,
|
| 106 |
theme=body.theme
|
| 107 |
)
|
database.py
CHANGED
|
@@ -2,14 +2,24 @@ from typing import Optional
|
|
| 2 |
from supabase import Client, create_client
|
| 3 |
from src.config import settings
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
def get_supabase() -> Optional[Client]:
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
|
| 12 |
def get_auth_supabase() -> Optional[Client]:
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from supabase import Client, create_client
|
| 3 |
from src.config import settings
|
| 4 |
|
| 5 |
+
# Singletons — created once, reused on every request
|
| 6 |
+
_supabase_client: Optional[Client] = None
|
| 7 |
+
_auth_supabase_client: Optional[Client] = None
|
| 8 |
+
|
| 9 |
|
| 10 |
def get_supabase() -> Optional[Client]:
|
| 11 |
+
global _supabase_client
|
| 12 |
+
if _supabase_client is None:
|
| 13 |
+
if not settings.supabase_url or not settings.supabase_key:
|
| 14 |
+
return None
|
| 15 |
+
_supabase_client = create_client(settings.supabase_url, settings.supabase_key)
|
| 16 |
+
return _supabase_client
|
| 17 |
|
| 18 |
|
| 19 |
def get_auth_supabase() -> Optional[Client]:
|
| 20 |
+
global _auth_supabase_client
|
| 21 |
+
if _auth_supabase_client is None:
|
| 22 |
+
if not settings.supabase_url or not settings.supabase_anon_key:
|
| 23 |
+
return None
|
| 24 |
+
_auth_supabase_client = create_client(settings.supabase_url, settings.supabase_anon_key)
|
| 25 |
+
return _auth_supabase_client
|
dependencies.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
from fastapi import Depends, HTTPException, Header, status, Request
|
| 2 |
from fastapi.security import OAuth2PasswordBearer
|
| 3 |
from typing import Any, Optional
|
|
@@ -9,6 +10,34 @@ DEV_USER = {"id": DEV_USER_ID, "email": "dev@studymate.ai", "name": "Dev User"}
|
|
| 9 |
|
| 10 |
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False)
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
def _extract_token(request: Request) -> Optional[str]:
|
| 14 |
"""
|
|
@@ -35,7 +64,8 @@ def _extract_token(request: Request) -> Optional[str]:
|
|
| 35 |
|
| 36 |
|
| 37 |
async def get_current_user_id(request: Request) -> str:
|
| 38 |
-
client
|
|
|
|
| 39 |
|
| 40 |
# Dev mode
|
| 41 |
if client is None:
|
|
@@ -47,18 +77,15 @@ async def get_current_user_id(request: Request) -> str:
|
|
| 47 |
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Not authenticated")
|
| 48 |
|
| 49 |
try:
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
user_obj = getattr(user, "user", None) or user
|
| 54 |
-
return str(user_obj.id)
|
| 55 |
-
except Exception as e:
|
| 56 |
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid or expired token")
|
| 57 |
|
| 58 |
|
| 59 |
async def get_current_user(request: Request) -> Any:
|
| 60 |
-
|
| 61 |
-
client = get_supabase()
|
| 62 |
|
| 63 |
# Dev mode
|
| 64 |
if client is None:
|
|
@@ -68,9 +95,7 @@ async def get_current_user(request: Request) -> Any:
|
|
| 68 |
|
| 69 |
if token:
|
| 70 |
try:
|
| 71 |
-
|
| 72 |
-
response = verify_client.auth.get_user(token)
|
| 73 |
-
user = getattr(response, "user", None) or response
|
| 74 |
if user:
|
| 75 |
return user
|
| 76 |
except Exception:
|
|
|
|
| 1 |
+
import time
|
| 2 |
from fastapi import Depends, HTTPException, Header, status, Request
|
| 3 |
from fastapi.security import OAuth2PasswordBearer
|
| 4 |
from typing import Any, Optional
|
|
|
|
| 10 |
|
| 11 |
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False)
|
| 12 |
|
| 13 |
+
_TOKEN_CACHE: dict = {}
|
| 14 |
+
_TOKEN_CACHE_TTL = 300 # 5 minutes
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _verify_token_cached(client, token: str) -> Any:
|
| 18 |
+
now = time.time()
|
| 19 |
+
|
| 20 |
+
# Simple cleanup to prevent unbounded growth
|
| 21 |
+
if len(_TOKEN_CACHE) > 1000:
|
| 22 |
+
expired = [k for k, v in _TOKEN_CACHE.items() if now - v[1] > _TOKEN_CACHE_TTL]
|
| 23 |
+
for k in expired:
|
| 24 |
+
del _TOKEN_CACHE[k]
|
| 25 |
+
|
| 26 |
+
# Return cached user if valid
|
| 27 |
+
if token in _TOKEN_CACHE:
|
| 28 |
+
user, timestamp = _TOKEN_CACHE[token]
|
| 29 |
+
if now - timestamp < _TOKEN_CACHE_TTL:
|
| 30 |
+
return user
|
| 31 |
+
|
| 32 |
+
# Not cached or expired — fetch from Supabase
|
| 33 |
+
response = client.auth.get_user(token)
|
| 34 |
+
user = getattr(response, "user", None) or response
|
| 35 |
+
if user:
|
| 36 |
+
_TOKEN_CACHE[token] = (user, now)
|
| 37 |
+
return user
|
| 38 |
+
|
| 39 |
+
raise ValueError("Invalid token response")
|
| 40 |
+
|
| 41 |
|
| 42 |
def _extract_token(request: Request) -> Optional[str]:
|
| 43 |
"""
|
|
|
|
| 64 |
|
| 65 |
|
| 66 |
async def get_current_user_id(request: Request) -> str:
|
| 67 |
+
# Use a single cached client — prefer the auth client, fall back to service client
|
| 68 |
+
client = get_auth_supabase() or get_supabase()
|
| 69 |
|
| 70 |
# Dev mode
|
| 71 |
if client is None:
|
|
|
|
| 77 |
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Not authenticated")
|
| 78 |
|
| 79 |
try:
|
| 80 |
+
user = _verify_token_cached(client, token)
|
| 81 |
+
return str(user.id)
|
| 82 |
+
except Exception:
|
|
|
|
|
|
|
|
|
|
| 83 |
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid or expired token")
|
| 84 |
|
| 85 |
|
| 86 |
async def get_current_user(request: Request) -> Any:
|
| 87 |
+
# Use a single cached client — prefer the auth client, fall back to service client
|
| 88 |
+
client = get_auth_supabase() or get_supabase()
|
| 89 |
|
| 90 |
# Dev mode
|
| 91 |
if client is None:
|
|
|
|
| 95 |
|
| 96 |
if token:
|
| 97 |
try:
|
| 98 |
+
user = _verify_token_cached(client, token)
|
|
|
|
|
|
|
| 99 |
if user:
|
| 100 |
return user
|
| 101 |
except Exception:
|
quiz_generator/quiz.py
CHANGED
|
@@ -56,6 +56,12 @@ Include exactly:
|
|
| 56 |
]
|
| 57 |
}}
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
**AVAILABLE CONTEXT:**
|
| 60 |
{context}
|
| 61 |
|
|
|
|
| 56 |
]
|
| 57 |
}}
|
| 58 |
|
| 59 |
+
**CRITICAL RULES:**
|
| 60 |
+
1. Your final output MUST be exactly the JSON structure above.
|
| 61 |
+
2. DO NOT include any conversational text, prefixes, or markdown blocks (like ```json).
|
| 62 |
+
3. Even if you cannot find enough context or tools fail, YOU MUST STILL output a valid JSON containing questions based on your general knowledge.
|
| 63 |
+
4. ANY deviation from the valid JSON format will break the system.
|
| 64 |
+
|
| 65 |
**AVAILABLE CONTEXT:**
|
| 66 |
{context}
|
| 67 |
|
store.py
CHANGED
|
@@ -1,4 +1,6 @@
|
|
| 1 |
import os
|
|
|
|
|
|
|
| 2 |
from typing import Optional
|
| 3 |
import logging
|
| 4 |
from datetime import datetime, timezone, date
|
|
@@ -125,13 +127,12 @@ def _table_supabase(table: str):
|
|
| 125 |
return _FakeTable(table)
|
| 126 |
|
| 127 |
def _robust_execute(query):
|
| 128 |
-
import time
|
| 129 |
-
from httpx import RemoteProtocolError
|
| 130 |
for attempt in range(3):
|
| 131 |
try:
|
| 132 |
return query.execute()
|
| 133 |
-
except
|
| 134 |
-
if attempt == 2:
|
|
|
|
| 135 |
time.sleep(0.5 * (attempt + 1))
|
| 136 |
return query.execute()
|
| 137 |
|
|
|
|
| 1 |
import os
|
| 2 |
+
import time
|
| 3 |
+
from httpx import RemoteProtocolError
|
| 4 |
from typing import Optional
|
| 5 |
import logging
|
| 6 |
from datetime import datetime, timezone, date
|
|
|
|
| 127 |
return _FakeTable(table)
|
| 128 |
|
| 129 |
def _robust_execute(query):
|
|
|
|
|
|
|
| 130 |
for attempt in range(3):
|
| 131 |
try:
|
| 132 |
return query.execute()
|
| 133 |
+
except RemoteProtocolError as e:
|
| 134 |
+
if attempt == 2:
|
| 135 |
+
raise e
|
| 136 |
time.sleep(0.5 * (attempt + 1))
|
| 137 |
return query.execute()
|
| 138 |
|
summary_generator/summary.py
CHANGED
|
@@ -124,7 +124,8 @@ def web_summarizer(topic: str) -> str:
|
|
| 124 |
logger.warning(f"Arxiv search for '{topic}' failed: {e}")
|
| 125 |
|
| 126 |
if not all_content:
|
| 127 |
-
|
|
|
|
| 128 |
|
| 129 |
combined = "\n\n".join(all_content)
|
| 130 |
logger.info(f"Web search combined text length for topic '{topic}': {len(combined)}")
|
|
|
|
| 124 |
logger.warning(f"Arxiv search for '{topic}' failed: {e}")
|
| 125 |
|
| 126 |
if not all_content:
|
| 127 |
+
logger.warning(f"No content found for topic: {topic}. Falling back to general knowledge.")
|
| 128 |
+
all_content.append(f"Topic: {topic}\n\nPlease provide a comprehensive educational summary of this topic based on your general knowledge.")
|
| 129 |
|
| 130 |
combined = "\n\n".join(all_content)
|
| 131 |
logger.info(f"Web search combined text length for topic '{topic}': {len(combined)}")
|