dotandru commited on
Commit
9f563be
·
1 Parent(s): 8c31f4f

feat: multi-image support for V2 endpoint

Browse files
Files changed (5) hide show
  1. main.py +119 -0
  2. orchestrator.py +27 -0
  3. prompts.py +4 -0
  4. quota_system_v2.py +129 -0
  5. 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 image_data:
 
 
 
 
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)