feat: multi-image support for V2 endpoint
Browse files- main.py +119 -0
- orchestrator.py +27 -0
- prompts.py +4 -0
- quota_system_v2.py +129 -0
- strategy_manager.py +6 -2
main.py
CHANGED
|
@@ -33,7 +33,9 @@ except ModuleNotFoundError as e:
|
|
| 33 |
|
| 34 |
from orchestrator import orchestrator, build_standard_response
|
| 35 |
from quota_system import quota_manager
|
|
|
|
| 36 |
from config import IS_PRODUCTION, ENV
|
|
|
|
| 37 |
|
| 38 |
if hasattr(sys.stdout, 'reconfigure'):
|
| 39 |
sys.stdout.reconfigure(encoding='utf-8')
|
|
@@ -79,6 +81,12 @@ def verify_system_health():
|
|
| 79 |
|
| 80 |
logger.info(f"✅ [HEALTH-CHECK] cv2 version: {cv2.__version__}, numpy: {np.__version__}")
|
| 81 |
logger.info(f"✅ [HEALTH-CHECK] Environment: {ENV.upper()}, Production Mode: {IS_PRODUCTION}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
# --- INFRA HARDENING: Lifespan Context Manager ---
|
| 84 |
@asynccontextmanager
|
|
@@ -203,6 +211,117 @@ async def solve_stream(
|
|
| 203 |
)
|
| 204 |
return JSONResponse(status_code=500, content=response_content)
|
| 205 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
@app.post("/explain_step")
|
| 207 |
async def explain_step(request: Request):
|
| 208 |
data = await request.json()
|
|
|
|
| 33 |
|
| 34 |
from orchestrator import orchestrator, build_standard_response
|
| 35 |
from quota_system import quota_manager
|
| 36 |
+
from quota_system_v2 import quota_manager_v2
|
| 37 |
from config import IS_PRODUCTION, ENV
|
| 38 |
+
from firebase_manager import firebase_manager
|
| 39 |
|
| 40 |
if hasattr(sys.stdout, 'reconfigure'):
|
| 41 |
sys.stdout.reconfigure(encoding='utf-8')
|
|
|
|
| 81 |
|
| 82 |
logger.info(f"✅ [HEALTH-CHECK] cv2 version: {cv2.__version__}, numpy: {np.__version__}")
|
| 83 |
logger.info(f"✅ [HEALTH-CHECK] Environment: {ENV.upper()}, Production Mode: {IS_PRODUCTION}")
|
| 84 |
+
|
| 85 |
+
# V5.8.1: API Key Validation
|
| 86 |
+
if not os.environ.get("GOOGLE_API_KEY"):
|
| 87 |
+
logger.error("❌ [HEALTH-CHECK] GOOGLE_API_KEY is missing! Gemini calls will fail.")
|
| 88 |
+
else:
|
| 89 |
+
logger.info("✅ [HEALTH-CHECK] GOOGLE_API_KEY is detected.")
|
| 90 |
|
| 91 |
# --- INFRA HARDENING: Lifespan Context Manager ---
|
| 92 |
@asynccontextmanager
|
|
|
|
| 211 |
)
|
| 212 |
return JSONResponse(status_code=500, content=response_content)
|
| 213 |
|
| 214 |
+
@app.post("/v2/solve_stream")
|
| 215 |
+
async def solve_stream_v2(
|
| 216 |
+
request: Request,
|
| 217 |
+
user: Optional[str] = Form(None),
|
| 218 |
+
student_name: Optional[str] = Form(None),
|
| 219 |
+
grade: str = Form("י'"),
|
| 220 |
+
student_gender: str = Form("M"),
|
| 221 |
+
mode: str = Form("solve"),
|
| 222 |
+
user_note: Optional[str] = Form(None),
|
| 223 |
+
files: List[UploadFile] = File(...)
|
| 224 |
+
):
|
| 225 |
+
"""
|
| 226 |
+
V2: Token-based Auth and Firestore Quota Management.
|
| 227 |
+
"""
|
| 228 |
+
auth_header = request.headers.get('Authorization')
|
| 229 |
+
if not auth_header or not auth_header.startswith('Bearer '):
|
| 230 |
+
logger.warning("🚨 [V2_ENDPOINT] Missing or invalid Authorization header.")
|
| 231 |
+
return JSONResponse(status_code=401, content={"error": "Unauthorized: Missing Token"})
|
| 232 |
+
|
| 233 |
+
id_token = auth_header.split('Bearer ')[1]
|
| 234 |
+
decoded_token = firebase_manager.verify_token(id_token)
|
| 235 |
+
|
| 236 |
+
if not decoded_token:
|
| 237 |
+
logger.warning("🚨 [V2_ENDPOINT] Invalid or expired Firebase ID token.")
|
| 238 |
+
return JSONResponse(status_code=401, content={"error": "Unauthorized: Invalid Token"})
|
| 239 |
+
|
| 240 |
+
uid = decoded_token.get('uid')
|
| 241 |
+
final_student_name = student_name or user or "תלמיד"
|
| 242 |
+
print(f"🚀 🟢 [V2] Received request from UID: {uid} ({final_student_name}). Grade: {grade}")
|
| 243 |
+
|
| 244 |
+
# V2 Quota Check (Firestore)
|
| 245 |
+
is_allowed, msg, current_usage, limit = quota_manager_v2.check_limit(uid)
|
| 246 |
+
if not is_allowed:
|
| 247 |
+
response_content = build_standard_response(
|
| 248 |
+
final_answer=f"הגעת למכסה היומית ({limit} שאלות)",
|
| 249 |
+
teacher_summary="נא להמתין למחר לקבלת מכסה חדשה או לשדרג לפרימיום.",
|
| 250 |
+
logic_error=True,
|
| 251 |
+
response_type="error"
|
| 252 |
+
)
|
| 253 |
+
response_content["error"] = "QUOTA_EXCEEDED"
|
| 254 |
+
return JSONResponse(status_code=403, content=response_content) # Changed to 403 Forbidden for quota specifically
|
| 255 |
+
|
| 256 |
+
# Only increment usage if OCR/Solving process starts successfully
|
| 257 |
+
|
| 258 |
+
try:
|
| 259 |
+
# 1. קריאת הבינארי
|
| 260 |
+
image_bytes_list = []
|
| 261 |
+
for single_file in files:
|
| 262 |
+
image_bytes_list.append(await single_file.read())
|
| 263 |
+
|
| 264 |
+
print(f"📸 [V2-LOG] Received {len(image_bytes_list)} images.")
|
| 265 |
+
|
| 266 |
+
if not image_bytes_list:
|
| 267 |
+
raise HTTPException(status_code=400, detail="No images provided")
|
| 268 |
+
|
| 269 |
+
# 2. OpenCV Decoder (validate the first image)
|
| 270 |
+
nparr = np.frombuffer(image_bytes_list[0], np.uint8)
|
| 271 |
+
img_cv2 = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
| 272 |
+
|
| 273 |
+
if img_cv2 is None:
|
| 274 |
+
print("❌ [V2-LOG] OpenCV failed to decode first image!")
|
| 275 |
+
raise HTTPException(status_code=400, detail="Invalid image data")
|
| 276 |
+
|
| 277 |
+
print(f"✅ [V2-LOG] OpenCV Matrix Ready: {img_cv2.shape}")
|
| 278 |
+
|
| 279 |
+
# Increment quota AFTER we are sure the image is valid
|
| 280 |
+
quota_manager_v2.increment_usage(uid)
|
| 281 |
+
|
| 282 |
+
# 3. OCR & Solving Pipeline (Streaming)
|
| 283 |
+
print("🚀 [TRACE-V2] Initiating streaming orchestrator.solve_problem with multiple images...")
|
| 284 |
+
|
| 285 |
+
async def event_generator():
|
| 286 |
+
try:
|
| 287 |
+
# orchestrator handles the rest using student_name for pedagogical stuff, but quota is already handled.
|
| 288 |
+
async for event in orchestrator.solve_problem(
|
| 289 |
+
problem_text="", # Will be extracted by OCR
|
| 290 |
+
grade=grade,
|
| 291 |
+
student_name=final_student_name,
|
| 292 |
+
student_gender=student_gender,
|
| 293 |
+
user_note=user_note,
|
| 294 |
+
image_data_list=image_bytes_list,
|
| 295 |
+
mode=mode
|
| 296 |
+
):
|
| 297 |
+
# SSE Protocol: yield a dict with "data" key
|
| 298 |
+
yield {
|
| 299 |
+
"event": "message",
|
| 300 |
+
"id": event.question_id,
|
| 301 |
+
"data": event.model_dump_json() # Pydantic v2
|
| 302 |
+
}
|
| 303 |
+
except Exception as e:
|
| 304 |
+
logger.error(f"STREAMING ERROR (V2): {e}")
|
| 305 |
+
yield {
|
| 306 |
+
"event": "error",
|
| 307 |
+
"data": json.dumps({"error": str(e)})
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
return EventSourceResponse(event_generator())
|
| 311 |
+
|
| 312 |
+
except Exception as e:
|
| 313 |
+
logger.exception("CRITICAL FLOW ERROR (V2)")
|
| 314 |
+
print(f"🔥 [V2-LOG] CRITICAL ERROR: {str(e)}")
|
| 315 |
+
import traceback
|
| 316 |
+
traceback.print_exc()
|
| 317 |
+
response_content = build_standard_response(
|
| 318 |
+
final_answer="שגיאה בפענוח התמונה או התרגיל",
|
| 319 |
+
teacher_summary="המורה למתמטיקה מתנצל, אך חלה שגיאה לא צפויה.",
|
| 320 |
+
logic_error=True,
|
| 321 |
+
response_type="error"
|
| 322 |
+
)
|
| 323 |
+
return JSONResponse(status_code=500, content=response_content)
|
| 324 |
+
|
| 325 |
@app.post("/explain_step")
|
| 326 |
async def explain_step(request: Request):
|
| 327 |
data = await request.json()
|
orchestrator.py
CHANGED
|
@@ -1377,6 +1377,7 @@ ctx.finish("$$ 4 $$", "מעולה! הגענו לתוצאה.")
|
|
| 1377 |
student_name: str,
|
| 1378 |
student_gender: str = "M",
|
| 1379 |
image_data: bytes = None,
|
|
|
|
| 1380 |
ambiguity_warning: bool = False,
|
| 1381 |
processing_strategy: ProcessingStrategy = None,
|
| 1382 |
question_id: str = "q_default",
|
|
@@ -1392,6 +1393,14 @@ ctx.finish("$$ 4 $$", "מעולה! הגענו לתוצאה.")
|
|
| 1392 |
print("🎯 [BIT-LOG] Using SMART SOLVE (V231.15)")
|
| 1393 |
|
| 1394 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1395 |
# Step 1: Understand problem structure
|
| 1396 |
understanding = await self._understand_problem(problem_text, data_anchor)
|
| 1397 |
|
|
@@ -1589,6 +1598,7 @@ ctx.finish("$$ 4 $$", "מעולה! הגענו לתוצאה.")
|
|
| 1589 |
data_anchor=effective_context,
|
| 1590 |
grade=grade,
|
| 1591 |
image_data=image_data,
|
|
|
|
| 1592 |
parent_category=effective_category,
|
| 1593 |
student_name=student_name,
|
| 1594 |
student_gender=student_gender
|
|
@@ -1907,10 +1917,26 @@ ctx.finish("$$ 4 $$", "מעולה! הגענו לתוצאה.")
|
|
| 1907 |
"""
|
| 1908 |
V277.0: Main solve method with BINARY DATA SUPPORT.
|
| 1909 |
"""
|
|
|
|
| 1910 |
image_data = kwargs.get('image_data') or kwargs.get('image_bytes')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1911 |
question_id = kwargs.get('question_id', f"q_{int(time.time())}")
|
| 1912 |
start_time = asyncio.get_event_loop().time()
|
| 1913 |
# GLOBAL_TIMEOUT_SEC = 240 # 4 minutes usually
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1914 |
|
| 1915 |
# ===================== V285.0: CHECK ME ROUTING =====================
|
| 1916 |
mode = kwargs.get('mode', 'solve')
|
|
@@ -2020,6 +2046,7 @@ ctx.finish("$$ 4 $$", "מעולה! הגענו לתוצאה.")
|
|
| 2020 |
student_gender=student_gender,
|
| 2021 |
processing_strategy=strategy,
|
| 2022 |
image_data=image_data,
|
|
|
|
| 2023 |
ambiguity_warning=ambiguity_warning,
|
| 2024 |
question_id=question_id
|
| 2025 |
):
|
|
|
|
| 1377 |
student_name: str,
|
| 1378 |
student_gender: str = "M",
|
| 1379 |
image_data: bytes = None,
|
| 1380 |
+
image_data_list: list = None,
|
| 1381 |
ambiguity_warning: bool = False,
|
| 1382 |
processing_strategy: ProcessingStrategy = None,
|
| 1383 |
question_id: str = "q_default",
|
|
|
|
| 1393 |
print("🎯 [BIT-LOG] Using SMART SOLVE (V231.15)")
|
| 1394 |
|
| 1395 |
try:
|
| 1396 |
+
# Heartbeat: Strategy/Planning
|
| 1397 |
+
yield BuddyEvent(
|
| 1398 |
+
question_id=question_id,
|
| 1399 |
+
state=BuddyState.SECTION_WORKING,
|
| 1400 |
+
current_section_id="בניית אסטרטגיה",
|
| 1401 |
+
payload={"status": "המורה בונה אסטרטגיה לפתרון..."}
|
| 1402 |
+
)
|
| 1403 |
+
|
| 1404 |
# Step 1: Understand problem structure
|
| 1405 |
understanding = await self._understand_problem(problem_text, data_anchor)
|
| 1406 |
|
|
|
|
| 1598 |
data_anchor=effective_context,
|
| 1599 |
grade=grade,
|
| 1600 |
image_data=image_data,
|
| 1601 |
+
image_data_list=image_data_list,
|
| 1602 |
parent_category=effective_category,
|
| 1603 |
student_name=student_name,
|
| 1604 |
student_gender=student_gender
|
|
|
|
| 1917 |
"""
|
| 1918 |
V277.0: Main solve method with BINARY DATA SUPPORT.
|
| 1919 |
"""
|
| 1920 |
+
image_data_list = kwargs.get('image_data_list')
|
| 1921 |
image_data = kwargs.get('image_data') or kwargs.get('image_bytes')
|
| 1922 |
+
|
| 1923 |
+
# V308.0: Dual support logic
|
| 1924 |
+
if image_data and not image_data_list:
|
| 1925 |
+
image_data_list = [image_data]
|
| 1926 |
+
elif image_data_list and not image_data:
|
| 1927 |
+
image_data = image_data_list[0]
|
| 1928 |
+
|
| 1929 |
question_id = kwargs.get('question_id', f"q_{int(time.time())}")
|
| 1930 |
start_time = asyncio.get_event_loop().time()
|
| 1931 |
# GLOBAL_TIMEOUT_SEC = 240 # 4 minutes usually
|
| 1932 |
+
|
| 1933 |
+
# Immediate Heartbeat for UX
|
| 1934 |
+
yield BuddyEvent(
|
| 1935 |
+
question_id=question_id,
|
| 1936 |
+
state=BuddyState.SECTION_WORKING,
|
| 1937 |
+
current_section_id="ניתוח תמונה",
|
| 1938 |
+
payload={"status": "המורה קוראת את השאלה..."}
|
| 1939 |
+
)
|
| 1940 |
|
| 1941 |
# ===================== V285.0: CHECK ME ROUTING =====================
|
| 1942 |
mode = kwargs.get('mode', 'solve')
|
|
|
|
| 2046 |
student_gender=student_gender,
|
| 2047 |
processing_strategy=strategy,
|
| 2048 |
image_data=image_data,
|
| 2049 |
+
image_data_list=image_data_list,
|
| 2050 |
ambiguity_warning=ambiguity_warning,
|
| 2051 |
question_id=question_id
|
| 2052 |
):
|
prompts.py
CHANGED
|
@@ -607,6 +607,10 @@ def get_master_prompt_v860():
|
|
| 607 |
12. **PEDAGOGICAL HIGHLIGHTING (CRITICAL):**
|
| 608 |
- When performing a substitution, showing a change in sign, taking a derivative, or highlighting a key transition in a calculation inside `block_math` or `content_mixed`, use `\\color{red}{...}` or `\\color{blue}{...}` to visually highlight the element that changed. (e.g., `\\color{red}{x^2}`, `\\color{blue}{+4}`).
|
| 609 |
- Only wrap valid Math inside the color tag. Do not color Hebrew text.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 610 |
|
| 611 |
|
| 612 |
═══════════════════════════════════════════
|
|
|
|
| 607 |
12. **PEDAGOGICAL HIGHLIGHTING (CRITICAL):**
|
| 608 |
- When performing a substitution, showing a change in sign, taking a derivative, or highlighting a key transition in a calculation inside `block_math` or `content_mixed`, use `\\color{red}{...}` or `\\color{blue}{...}` to visually highlight the element that changed. (e.g., `\\color{red}{x^2}`, `\\color{blue}{+4}`).
|
| 609 |
- Only wrap valid Math inside the color tag. Do not color Hebrew text.
|
| 610 |
+
13. **MULTI-IMAGE & GRADING LOGIC (V308.0 - CRITICAL)**:
|
| 611 |
+
- אם קיבלת תמונה אחת בלבד: עליך להניח שהתלמיד מבקש פתרון מלא מההתחלה, או הסבר רגיל שלבים שלבים כפי שביקש.
|
| 612 |
+
- אם קיבלת יותר מתמונה אחת: התמונה הראשונה היא השאלה המקורית. שאר התמונות הן נסיונות הפתרון של התלמיד בכתב יד.
|
| 613 |
+
- במצב של יותר מתמונה אחת עליך לעבור ל**מצב 'בדיקת שיעורי בית' (Grading Mode)** בו אתה ראשית בודק היכן התלמיד טעה בפתרון שלו מהמחברת, מתקן אותו נקודתית וחולק לו שבחים על מה שכן הצליח, ואז מחזיר אותו למסלול הנכון להמשך התרגיל. אל תוציא מיד פתרון מלא במצב זה, תן לו להבין קודם איפה הטעות!
|
| 614 |
|
| 615 |
|
| 616 |
═══════════════════════════════════════════
|
quota_system_v2.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import datetime
|
| 2 |
+
import logging
|
| 3 |
+
from firebase_admin import firestore
|
| 4 |
+
|
| 5 |
+
# We import the manager to get the initialized DB
|
| 6 |
+
from firebase_manager import firebase_manager
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger("HamoraServer")
|
| 9 |
+
|
| 10 |
+
class QuotaManagerV2:
|
| 11 |
+
"""
|
| 12 |
+
V2 QuotaManager: Uses Firestore exclusively.
|
| 13 |
+
Ignores local JSON files.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
def __init__(self):
|
| 17 |
+
self.default_limit = 30 # Can be overridden in config or by user doc
|
| 18 |
+
self.collection_name = "users"
|
| 19 |
+
|
| 20 |
+
def _get_db(self):
|
| 21 |
+
return firebase_manager.get_db()
|
| 22 |
+
|
| 23 |
+
def get_today_key(self):
|
| 24 |
+
return datetime.date.today().isoformat()
|
| 25 |
+
|
| 26 |
+
def check_limit(self, uid: str):
|
| 27 |
+
"""
|
| 28 |
+
Returns:
|
| 29 |
+
(allowed: bool, message: str, current_usage: int, limit: int)
|
| 30 |
+
"""
|
| 31 |
+
if not uid:
|
| 32 |
+
return False, "Missing UID", 0, 0
|
| 33 |
+
|
| 34 |
+
db = self._get_db()
|
| 35 |
+
if not db:
|
| 36 |
+
logger.error("❌ [QUOTA_V2] Firestore DB not available.")
|
| 37 |
+
return False, "Database error", 0, 0
|
| 38 |
+
|
| 39 |
+
try:
|
| 40 |
+
doc_ref = db.collection(self.collection_name).document(uid)
|
| 41 |
+
doc = doc_ref.get()
|
| 42 |
+
|
| 43 |
+
if not doc.exists:
|
| 44 |
+
logger.warning(f"⚠️ [QUOTA_V2] User document not found for {uid}. Allowing with default.")
|
| 45 |
+
return True, "Allowed (Default)", 0, self.default_limit
|
| 46 |
+
|
| 47 |
+
data = doc.to_dict()
|
| 48 |
+
|
| 49 |
+
# Check if blocked
|
| 50 |
+
if data.get("status") == "rejected":
|
| 51 |
+
return False, "User is blocked", 0, 0
|
| 52 |
+
|
| 53 |
+
# Admin or special roles
|
| 54 |
+
if data.get("role") == "admin" or data.get("status") == "admin":
|
| 55 |
+
return True, "Unlimited (Admin)", 0, -1
|
| 56 |
+
|
| 57 |
+
limit = data.get("quota_limit", self.default_limit)
|
| 58 |
+
|
| 59 |
+
# Special limits
|
| 60 |
+
if limit < 0:
|
| 61 |
+
return True, "Unlimited", 0, -1
|
| 62 |
+
|
| 63 |
+
today = self.get_today_key()
|
| 64 |
+
last_usage_date = data.get("last_usage_date", "")
|
| 65 |
+
|
| 66 |
+
# If it's a new day, usage is effectively 0
|
| 67 |
+
if last_usage_date != today:
|
| 68 |
+
current_count = 0
|
| 69 |
+
else:
|
| 70 |
+
current_count = data.get("used_today", 0)
|
| 71 |
+
|
| 72 |
+
if current_count >= limit:
|
| 73 |
+
return False, f"Daily limit reached ({limit})", current_count, limit
|
| 74 |
+
|
| 75 |
+
return True, "Allowed", current_count, limit
|
| 76 |
+
|
| 77 |
+
except Exception as e:
|
| 78 |
+
logger.error(f"❌ [QUOTA_V2] Error checking limit for {uid}: {e}")
|
| 79 |
+
# Fail closed or open? Let's fail open temporarily so we don't block users if DB hiccups
|
| 80 |
+
return True, "Error - Fallback Allow", 0, self.default_limit
|
| 81 |
+
|
| 82 |
+
def increment_usage(self, uid: str):
|
| 83 |
+
if not uid:
|
| 84 |
+
return
|
| 85 |
+
|
| 86 |
+
db = self._get_db()
|
| 87 |
+
if not db:
|
| 88 |
+
return
|
| 89 |
+
|
| 90 |
+
today = self.get_today_key()
|
| 91 |
+
doc_ref = db.collection(self.collection_name).document(uid)
|
| 92 |
+
|
| 93 |
+
try:
|
| 94 |
+
@firestore.transactional
|
| 95 |
+
def update_in_transaction(transaction, doc_ref):
|
| 96 |
+
snapshot = doc_ref.get(transaction=transaction)
|
| 97 |
+
if not snapshot.exists:
|
| 98 |
+
# Create document if it doesn't exist (though migration should handle this)
|
| 99 |
+
transaction.set(doc_ref, {
|
| 100 |
+
"quota_limit": self.default_limit,
|
| 101 |
+
"used_today": 1,
|
| 102 |
+
"last_usage_date": today,
|
| 103 |
+
"last_seen": firestore.SERVER_TIMESTAMP
|
| 104 |
+
})
|
| 105 |
+
return
|
| 106 |
+
|
| 107 |
+
data = snapshot.to_dict()
|
| 108 |
+
last_date = data.get("last_usage_date", "")
|
| 109 |
+
|
| 110 |
+
if last_date != today:
|
| 111 |
+
new_usage = 1
|
| 112 |
+
else:
|
| 113 |
+
new_usage = data.get("used_today", 0) + 1
|
| 114 |
+
|
| 115 |
+
transaction.update(doc_ref, {
|
| 116 |
+
"used_today": new_usage,
|
| 117 |
+
"last_usage_date": today,
|
| 118 |
+
"last_seen": firestore.SERVER_TIMESTAMP
|
| 119 |
+
})
|
| 120 |
+
|
| 121 |
+
transaction = db.transaction()
|
| 122 |
+
update_in_transaction(transaction, doc_ref)
|
| 123 |
+
logger.info(f"📊 [QUOTA_V2] Incremented usage for {uid}. Date: {today}")
|
| 124 |
+
|
| 125 |
+
except Exception as e:
|
| 126 |
+
logger.error(f"❌ [QUOTA_V2] Failed to increment usage for {uid}: {e}")
|
| 127 |
+
|
| 128 |
+
# Global instance
|
| 129 |
+
quota_manager_v2 = QuotaManagerV2()
|
strategy_manager.py
CHANGED
|
@@ -17,7 +17,7 @@ class StrategyManager:
|
|
| 17 |
self.llm = llm_model
|
| 18 |
self.max_retries = 2
|
| 19 |
|
| 20 |
-
async def solve_with_strategy(self, problem_text, data_anchor, grade="10", image_data=None, parent_category=None, ambiguity_warning=False, intent=None, intent_contract=None, proof_graph_steps_count=1, student_name="נסיך", student_gender="M"):
|
| 21 |
"""
|
| 22 |
V3.1.2: Added support for adaptive failure (Soft Recovery).
|
| 23 |
V5.8.2: Dynamic Token Budget added via proof_graph_steps_count.
|
|
@@ -28,7 +28,11 @@ class StrategyManager:
|
|
| 28 |
category = detected_category if detected_category != "GENERAL" else (parent_category or "GENERAL")
|
| 29 |
|
| 30 |
image_pages = []
|
| 31 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
try:
|
| 33 |
from ocr_strip_engine import paginate_image
|
| 34 |
image_pages = paginate_image(image_data)
|
|
|
|
| 17 |
self.llm = llm_model
|
| 18 |
self.max_retries = 2
|
| 19 |
|
| 20 |
+
async def solve_with_strategy(self, problem_text, data_anchor, grade="10", image_data=None, image_data_list=None, parent_category=None, ambiguity_warning=False, intent=None, intent_contract=None, proof_graph_steps_count=1, student_name="נסיך", student_gender="M"):
|
| 21 |
"""
|
| 22 |
V3.1.2: Added support for adaptive failure (Soft Recovery).
|
| 23 |
V5.8.2: Dynamic Token Budget added via proof_graph_steps_count.
|
|
|
|
| 28 |
category = detected_category if detected_category != "GENERAL" else (parent_category or "GENERAL")
|
| 29 |
|
| 30 |
image_pages = []
|
| 31 |
+
if image_data_list:
|
| 32 |
+
# Multi-image support
|
| 33 |
+
for i_data in image_data_list:
|
| 34 |
+
image_pages.append({"mime_type": "image/jpeg", "data": i_data})
|
| 35 |
+
elif image_data:
|
| 36 |
try:
|
| 37 |
from ocr_strip_engine import paginate_image
|
| 38 |
image_pages = paginate_image(image_data)
|