hoangthiencm commited on
Commit
f79fb54
·
verified ·
1 Parent(s): 0419ef0

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +42 -61
app.py CHANGED
@@ -1,6 +1,6 @@
1
  """
2
  Backend API cho HT_MATH_WEB - Chạy trên Hugging Face Spaces (Docker Version)
3
- Phiên bản: 9.6 (Add Admin Routes)
4
  Tác giả: Hoàng Tấn Thiên
5
  """
6
 
@@ -43,13 +43,12 @@ except ImportError:
43
  create_client = None
44
 
45
  # ===== CẤU HÌNH =====
46
- # Keys mặc định của server (dự phòng)
47
  SERVER_GEMINI_KEYS = os.getenv("GEMINI_API_KEYS", "").split(",")
48
  GEMINI_MODELS = os.getenv("GEMINI_MODELS", "gemini-2.5-flash,gemini-1.5-flash,gemini-1.5-pro").split(",")
49
  SUPABASE_URL = os.getenv("SUPABASE_URL", "")
50
  SUPABASE_KEY = os.getenv("SUPABASE_KEY", "")
51
  MAX_THREADS = int(os.getenv("MAX_THREADS", "8"))
52
- ADMIN_SECRET_KEY = os.getenv("ADMIN_SECRET_KEY", "admin123") # Key bảo mật cho Admin
53
 
54
  # Setup Supabase
55
  supabase = None
@@ -59,7 +58,7 @@ if SUPABASE_AVAILABLE and SUPABASE_URL and SUPABASE_KEY:
59
  except Exception as e:
60
  print(f"Warning: Không thể kết nối Supabase: {e}")
61
 
62
- app = FastAPI(title="HT_MATH_WEB API", version="9.6")
63
 
64
  app.add_middleware(
65
  CORSMiddleware,
@@ -69,14 +68,12 @@ app.add_middleware(
69
  allow_headers=["*"],
70
  )
71
 
72
- # --- SETUP STATIC FILES ---
73
  os.makedirs("uploads", exist_ok=True)
74
  app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
75
 
76
- # ===== KEY MANAGER CLASS =====
77
  class ApiKeyManager:
78
  def __init__(self, keys: List[str]):
79
- # Lọc bỏ key rỗng và khoảng trắng
80
  self.api_keys = [k.strip() for k in keys if k.strip()]
81
  self.current_index = 0
82
  self.total_keys = len(self.api_keys)
@@ -86,7 +83,6 @@ class ApiKeyManager:
86
  return self.api_keys[self.current_index]
87
 
88
  def rotate_key(self) -> Optional[str]:
89
- """Chuyển sang key tiếp theo"""
90
  if not self.api_keys: return None
91
  self.current_index = (self.current_index + 1) % self.total_keys
92
  print(f"[KeyManager] Rotated to key index: {self.current_index}/{self.total_keys}")
@@ -95,10 +91,7 @@ class ApiKeyManager:
95
  def get_key_count(self) -> int:
96
  return len(self.api_keys)
97
 
98
- # Global manager cho keys của server
99
  server_key_manager = ApiKeyManager(SERVER_GEMINI_KEYS)
100
-
101
- # Rate limiter đơn giản
102
  ip_rate_limits = {}
103
  RATE_LIMIT_DURATION = 2
104
 
@@ -109,10 +102,10 @@ def check_rate_limit(request: Request):
109
  if client_ip in ip_rate_limits:
110
  elapsed = now - ip_rate_limits[client_ip]
111
  if elapsed < RATE_LIMIT_DURATION:
112
- pass # Có thể log warning
113
  ip_rate_limits[client_ip] = now
114
 
115
- # ===== PROMPTS =====
116
  DIRECT_GEMINI_PROMPT_TEXT_ONLY = r"""**TRÍCH XUẤT VĂN BẢN THUẦN TÚY**
117
  ⚠️ YÊU CẦU BẮT BUỘC:
118
  - PHẢI trích xuất TOÀN BỘ nội dung xuất hiện trong ảnh/PDF
@@ -124,10 +117,6 @@ DIRECT_GEMINI_PROMPT_TEXT_ONLY = r"""**TRÍCH XUẤT VĂN BẢN THUẦN TÚY**
124
  DIRECT_GEMINI_PROMPT_LATEX = r"""**TRÍCH XUẤT SANG MARKDOWN VỚI CÔNG THỨC LATEX ($...$)**
125
  Bạn là một trợ lý kỹ thuật có nhiệm vụ duy nhất là chuyển đổi hình ảnh chứa nội dung toán học sang định dạng Markdown.
126
  Sự chính xác tuyệt đối trong việc tuân thủ các quy tắc dưới đây là yêu cầu bắt buộc để đầu ra tương thích với công cụ xử lý tự động.
127
- ⚠️ YÊU CẦU CỐT LÕI - KHÔNG ĐƯỢC BỎ SÓT:
128
- - Đọc kỹ từng pixel, trích xuất TOÀN BỘ nội dung từ trên xuống dưới.
129
- - KHÔNG bỏ qua bất kỳ bài tập, hình vẽ, hoặc ghi chú nhỏ nào.
130
- - Nếu ảnh bị cắt ngang chữ, hãy cố gắng đoán và hoàn thiện từ đó dựa trên ngữ cảnh.
131
  **QUY TẮC BẤT DI BẤT DỊCH:**
132
  1.  **CHỈ MARKDOWN:** Toàn bộ đầu ra phải là văn bản Markdown thuần túy. CẤM TUYỆT ĐỐI chứa các lệnh như `\documentclass`, `\usepackage`, `\begin{document}`.
133
  2.  **QUY TẮC DẤU ĐÔ LA ($) - QUAN TRỌNG NHẤT:**
@@ -210,17 +199,18 @@ def stitch_text(text_a: str, text_b: str, min_overlap_chars: int = 20) -> str:
210
  for i in range(scan_window, 0, -1):
211
  tail_a = "\n".join(a_lines[-i:]).strip()
212
  head_b = "\n".join(b_lines[:i]).strip()
213
- if len(tail_a) >= min_overlap_chars and tail_a == head_b:
 
214
  best_overlap_idx = i
215
  break
216
 
217
  if best_overlap_idx > 0:
218
  return text_a + "\n" + "\n".join(b_lines[best_overlap_idx:])
219
  else:
 
220
  return text_a + "\n\n" + text_b
221
 
222
  def clean_latex_formulas(text: str) -> str:
223
- # Chuẩn hóa khoảng trắng LaTeX
224
  return re.sub(r'\$\s+(.*?)\s+\$', lambda m: f'${m.group(1).strip()}$', text)
225
 
226
  def hash_password(password: str) -> str:
@@ -234,7 +224,7 @@ def verify_password(password: str, hashed: str) -> bool:
234
  @app.get("/")
235
  @app.get("/health")
236
  async def root():
237
- return {"status": "ok", "version": "9.6"}
238
 
239
  @app.get("/api/models")
240
  async def get_models():
@@ -281,46 +271,33 @@ async def logout(request: Request):
281
  except: pass
282
  return {"status": "success"}
283
 
284
- # --- ADMIN API (BỔ SUNG) ---
285
-
286
  @app.get("/admin/users")
287
  async def admin_get_users(secret: str = ""):
288
- if secret != ADMIN_SECRET_KEY:
289
- raise HTTPException(status_code=403, detail="Forbidden")
290
- if not supabase:
291
- raise HTTPException(status_code=500, detail="DB Error")
292
-
293
  try:
294
  res = supabase.table("users").select("*").order("created_at", desc=True).execute()
295
  return {"users": res.data}
296
- except Exception as e:
297
- raise HTTPException(status_code=500, detail=str(e))
298
 
299
  @app.post("/admin/approve-user")
300
  async def admin_approve_user(email: str = Form(...), secret: str = Form(...)):
301
- if secret != ADMIN_SECRET_KEY:
302
- raise HTTPException(status_code=403, detail="Forbidden")
303
- if not supabase:
304
- raise HTTPException(status_code=500, detail="DB Error")
305
-
306
  try:
307
  supabase.table("users").update({"status": "active"}).eq("email", email).execute()
308
  return {"success": True}
309
- except Exception as e:
310
- raise HTTPException(status_code=500, detail=str(e))
311
 
312
  @app.post("/admin/delete-user")
313
  async def admin_delete_user(email: str = Form(...), secret: str = Form(...)):
314
- if secret != ADMIN_SECRET_KEY:
315
- raise HTTPException(status_code=403, detail="Forbidden")
316
- if not supabase:
317
- raise HTTPException(status_code=500, detail="DB Error")
318
-
319
  try:
320
  supabase.table("users").delete().eq("email", email).execute()
321
  return {"success": True}
322
- except Exception as e:
323
- raise HTTPException(status_code=500, detail=str(e))
324
 
325
  # --- IMAGE UPLOAD ---
326
  @app.post("/api/upload-image")
@@ -336,10 +313,16 @@ async def upload_image(file: UploadFile = File(...)):
336
  # --- CORE PROCESSING ---
337
 
338
  async def process_image_with_gemini(image: Image.Image, model_id: str, prompt: str, manager: ApiKeyManager, max_retries: int = 3) -> str:
339
- """Gửi ảnh lên Gemini với chế xoay vòng Key khi gặp lỗi 429"""
340
 
341
- # Thử tối đa số lần = số lượng key * 2 (để chắc chắn đã quay vòng hết các key)
342
- # Hoặc giới hạn cứng là 10 lần để tránh loop vô hạn
 
 
 
 
 
 
343
  max_attempts = min(manager.get_key_count() * 2, 10) if manager.get_key_count() > 0 else 3
344
 
345
  for i in range(max_attempts):
@@ -349,30 +332,33 @@ async def process_image_with_gemini(image: Image.Image, model_id: str, prompt: s
349
  try:
350
  genai.configure(api_key=api_key)
351
  generation_config = {"temperature": 0.0, "top_p": 1.0, "max_output_tokens": 8192}
352
- model = genai.GenerativeModel(model_id, generation_config=generation_config)
353
 
354
- # Gọi API
 
 
 
 
 
 
355
  response = model.generate_content([prompt, image])
356
  if response.text:
357
  return response.text.strip()
358
 
359
  except Exception as e:
360
  error_str = str(e)
361
- # Nếu gặp lỗi Quota (429) hoặc lỗi Model quá tải (503) -> Xoay Key
362
  if "429" in error_str or "quota" in error_str.lower() or "503" in error_str:
363
- print(f"[KeyManager] Key {api_key[:8]}... exhausted/error. Rotating...")
364
  manager.rotate_key()
365
- time.sleep(1) # Nghỉ 1s trước khi thử key mới
366
  continue
367
 
368
- # Các lỗi khác (400, v.v) thì không retry bằng key khác làm gì
369
  print(f"[Gemini Error] {error_str}")
370
  if i == max_attempts - 1: raise e
371
 
372
  return ""
373
 
374
  async def process_large_image(image: Image.Image, model: str, prompt: str, semaphore: asyncio.Semaphore, manager: ApiKeyManager) -> str:
375
- # Tăng kích thước chunk để giảm số request
376
  CHUNK_HEIGHT = 4096
377
  OVERLAP_HEIGHT = 256
378
 
@@ -392,6 +378,7 @@ async def process_large_image(image: Image.Image, model: str, prompt: str, semap
392
  if bottom == height: break
393
  y += (CHUNK_HEIGHT - OVERLAP_HEIGHT)
394
 
 
395
  async def process_chunk(chunk_img, index):
396
  async with semaphore:
397
  text = await process_image_with_gemini(chunk_img, model, prompt, manager)
@@ -413,21 +400,18 @@ async def convert_file(
413
  file: UploadFile = File(...),
414
  model: str = Form("gemini-2.5-flash"),
415
  mode: str = Form("latex"),
416
- api_keys: str = Form(None) # Nhận danh sách keys từ client
417
  ):
418
  check_rate_limit(request)
419
 
420
- # Ưu tiên dùng keys từ User gửi lên
421
  if api_keys and len(api_keys.strip()) > 0:
422
  user_keys = api_keys.split(',')
423
  manager = ApiKeyManager(user_keys)
424
- # print(f"Using {manager.get_key_count()} user provided keys.")
425
  else:
426
- # Fallback về keys của server
427
  manager = server_key_manager
428
 
429
  if manager.get_key_count() == 0:
430
- raise HTTPException(status_code=400, detail="Chưa có API Key. Vui lòng nạp file Key hoặc cấu hình Server.")
431
 
432
  prompt = DIRECT_GEMINI_PROMPT_LATEX if mode == "latex" else DIRECT_GEMINI_PROMPT_TEXT_ONLY
433
 
@@ -441,7 +425,6 @@ async def convert_file(
441
  if file_ext == ".pdf":
442
  doc = fitz.open(stream=file_content, filetype="pdf")
443
  async def process_page_wrapper(page, idx):
444
- # Render 200 DPI cho nhanh
445
  pix = page.get_pixmap(dpi=200)
446
  img = Image.open(io.BytesIO(pix.tobytes("png")))
447
  text = await process_large_image(img, model, prompt, global_semaphore, manager)
@@ -463,8 +446,6 @@ async def convert_file(
463
  return {"success": True, "result": clean_latex_formulas(final_text)}
464
 
465
  except Exception as e:
466
- # import traceback
467
- # traceback.print_exc()
468
  raise HTTPException(status_code=500, detail=str(e))
469
 
470
  @app.post("/api/export-docx")
 
1
  """
2
  Backend API cho HT_MATH_WEB - Chạy trên Hugging Face Spaces (Docker Version)
3
+ Phiên bản: 9.8 (Revert to Strict Prompt + Full Features)
4
  Tác giả: Hoàng Tấn Thiên
5
  """
6
 
 
43
  create_client = None
44
 
45
  # ===== CẤU HÌNH =====
 
46
  SERVER_GEMINI_KEYS = os.getenv("GEMINI_API_KEYS", "").split(",")
47
  GEMINI_MODELS = os.getenv("GEMINI_MODELS", "gemini-2.5-flash,gemini-1.5-flash,gemini-1.5-pro").split(",")
48
  SUPABASE_URL = os.getenv("SUPABASE_URL", "")
49
  SUPABASE_KEY = os.getenv("SUPABASE_KEY", "")
50
  MAX_THREADS = int(os.getenv("MAX_THREADS", "8"))
51
+ ADMIN_SECRET_KEY = os.getenv("ADMIN_SECRET_KEY", "admin123")
52
 
53
  # Setup Supabase
54
  supabase = None
 
58
  except Exception as e:
59
  print(f"Warning: Không thể kết nối Supabase: {e}")
60
 
61
+ app = FastAPI(title="HT_MATH_WEB API", version="9.8")
62
 
63
  app.add_middleware(
64
  CORSMiddleware,
 
68
  allow_headers=["*"],
69
  )
70
 
 
71
  os.makedirs("uploads", exist_ok=True)
72
  app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
73
 
74
+ # ===== KEY MANAGER =====
75
  class ApiKeyManager:
76
  def __init__(self, keys: List[str]):
 
77
  self.api_keys = [k.strip() for k in keys if k.strip()]
78
  self.current_index = 0
79
  self.total_keys = len(self.api_keys)
 
83
  return self.api_keys[self.current_index]
84
 
85
  def rotate_key(self) -> Optional[str]:
 
86
  if not self.api_keys: return None
87
  self.current_index = (self.current_index + 1) % self.total_keys
88
  print(f"[KeyManager] Rotated to key index: {self.current_index}/{self.total_keys}")
 
91
  def get_key_count(self) -> int:
92
  return len(self.api_keys)
93
 
 
94
  server_key_manager = ApiKeyManager(SERVER_GEMINI_KEYS)
 
 
95
  ip_rate_limits = {}
96
  RATE_LIMIT_DURATION = 2
97
 
 
102
  if client_ip in ip_rate_limits:
103
  elapsed = now - ip_rate_limits[client_ip]
104
  if elapsed < RATE_LIMIT_DURATION:
105
+ pass
106
  ip_rate_limits[client_ip] = now
107
 
108
+ # ===== PROMPTS (KHÔI PHỤC BẢN CHUẨN MỰC) =====
109
  DIRECT_GEMINI_PROMPT_TEXT_ONLY = r"""**TRÍCH XUẤT VĂN BẢN THUẦN TÚY**
110
  ⚠️ YÊU CẦU BẮT BUỘC:
111
  - PHẢI trích xuất TOÀN BỘ nội dung xuất hiện trong ảnh/PDF
 
117
  DIRECT_GEMINI_PROMPT_LATEX = r"""**TRÍCH XUẤT SANG MARKDOWN VỚI CÔNG THỨC LATEX ($...$)**
118
  Bạn là một trợ lý kỹ thuật có nhiệm vụ duy nhất là chuyển đổi hình ảnh chứa nội dung toán học sang định dạng Markdown.
119
  Sự chính xác tuyệt đối trong việc tuân thủ các quy tắc dưới đây là yêu cầu bắt buộc để đầu ra tương thích với công cụ xử lý tự động.
 
 
 
 
120
  **QUY TẮC BẤT DI BẤT DỊCH:**
121
  1.  **CHỈ MARKDOWN:** Toàn bộ đầu ra phải là văn bản Markdown thuần túy. CẤM TUYỆT ĐỐI chứa các lệnh như `\documentclass`, `\usepackage`, `\begin{document}`.
122
  2.  **QUY TẮC DẤU ĐÔ LA ($) - QUAN TRỌNG NHẤT:**
 
199
  for i in range(scan_window, 0, -1):
200
  tail_a = "\n".join(a_lines[-i:]).strip()
201
  head_b = "\n".join(b_lines[:i]).strip()
202
+ # So sánh lỏng hơn một chút để tránh lỗi do khoảng trắng thừa
203
+ if len(tail_a) >= min_overlap_chars and tail_a.replace(" ","") == head_b.replace(" ",""):
204
  best_overlap_idx = i
205
  break
206
 
207
  if best_overlap_idx > 0:
208
  return text_a + "\n" + "\n".join(b_lines[best_overlap_idx:])
209
  else:
210
+ # Nếu không tìm thấy điểm nối, nối tiếp luôn bằng 2 dòng trống để an toàn
211
  return text_a + "\n\n" + text_b
212
 
213
  def clean_latex_formulas(text: str) -> str:
 
214
  return re.sub(r'\$\s+(.*?)\s+\$', lambda m: f'${m.group(1).strip()}$', text)
215
 
216
  def hash_password(password: str) -> str:
 
224
  @app.get("/")
225
  @app.get("/health")
226
  async def root():
227
+ return {"status": "ok", "version": "9.8"}
228
 
229
  @app.get("/api/models")
230
  async def get_models():
 
271
  except: pass
272
  return {"status": "success"}
273
 
274
+ # --- ADMIN API ---
 
275
  @app.get("/admin/users")
276
  async def admin_get_users(secret: str = ""):
277
+ if secret != ADMIN_SECRET_KEY: raise HTTPException(status_code=403, detail="Forbidden")
278
+ if not supabase: raise HTTPException(status_code=500, detail="DB Error")
 
 
 
279
  try:
280
  res = supabase.table("users").select("*").order("created_at", desc=True).execute()
281
  return {"users": res.data}
282
+ except Exception as e: raise HTTPException(status_code=500, detail=str(e))
 
283
 
284
  @app.post("/admin/approve-user")
285
  async def admin_approve_user(email: str = Form(...), secret: str = Form(...)):
286
+ if secret != ADMIN_SECRET_KEY: raise HTTPException(status_code=403, detail="Forbidden")
287
+ if not supabase: raise HTTPException(status_code=500, detail="DB Error")
 
 
 
288
  try:
289
  supabase.table("users").update({"status": "active"}).eq("email", email).execute()
290
  return {"success": True}
291
+ except Exception as e: raise HTTPException(status_code=500, detail=str(e))
 
292
 
293
  @app.post("/admin/delete-user")
294
  async def admin_delete_user(email: str = Form(...), secret: str = Form(...)):
295
+ if secret != ADMIN_SECRET_KEY: raise HTTPException(status_code=403, detail="Forbidden")
296
+ if not supabase: raise HTTPException(status_code=500, detail="DB Error")
 
 
 
297
  try:
298
  supabase.table("users").delete().eq("email", email).execute()
299
  return {"success": True}
300
+ except Exception as e: raise HTTPException(status_code=500, detail=str(e))
 
301
 
302
  # --- IMAGE UPLOAD ---
303
  @app.post("/api/upload-image")
 
313
  # --- CORE PROCESSING ---
314
 
315
  async def process_image_with_gemini(image: Image.Image, model_id: str, prompt: str, manager: ApiKeyManager, max_retries: int = 3) -> str:
316
+ """Gửi ảnh lên Gemini với settings tắt bộ lọc an toàn để tránh mất nội dung"""
317
 
318
+ # Cấu hình tắt bộ lọc an toàn (QUAN TRỌNG ĐỂ KHÔNG BỊ MẤT CHỮ)
319
+ safety_settings = [
320
+ {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
321
+ {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
322
+ {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
323
+ {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
324
+ ]
325
+
326
  max_attempts = min(manager.get_key_count() * 2, 10) if manager.get_key_count() > 0 else 3
327
 
328
  for i in range(max_attempts):
 
332
  try:
333
  genai.configure(api_key=api_key)
334
  generation_config = {"temperature": 0.0, "top_p": 1.0, "max_output_tokens": 8192}
 
335
 
336
+ # Khởi tạo model kèm safety_settings
337
+ model = genai.GenerativeModel(
338
+ model_id,
339
+ generation_config=generation_config,
340
+ safety_settings=safety_settings
341
+ )
342
+
343
  response = model.generate_content([prompt, image])
344
  if response.text:
345
  return response.text.strip()
346
 
347
  except Exception as e:
348
  error_str = str(e)
 
349
  if "429" in error_str or "quota" in error_str.lower() or "503" in error_str:
350
+ print(f"[KeyManager] Key {api_key[:8]}... exhausted. Rotating...")
351
  manager.rotate_key()
352
+ time.sleep(1)
353
  continue
354
 
 
355
  print(f"[Gemini Error] {error_str}")
356
  if i == max_attempts - 1: raise e
357
 
358
  return ""
359
 
360
  async def process_large_image(image: Image.Image, model: str, prompt: str, semaphore: asyncio.Semaphore, manager: ApiKeyManager) -> str:
361
+ # Chunk height lớn (4096) để đọc 1 lần hết trang A4 tiêu chuẩn (200dpi)
362
  CHUNK_HEIGHT = 4096
363
  OVERLAP_HEIGHT = 256
364
 
 
378
  if bottom == height: break
379
  y += (CHUNK_HEIGHT - OVERLAP_HEIGHT)
380
 
381
+ # Nếu phải cắt, xử lý từng phần rồi nối lại
382
  async def process_chunk(chunk_img, index):
383
  async with semaphore:
384
  text = await process_image_with_gemini(chunk_img, model, prompt, manager)
 
400
  file: UploadFile = File(...),
401
  model: str = Form("gemini-2.5-flash"),
402
  mode: str = Form("latex"),
403
+ api_keys: str = Form(None)
404
  ):
405
  check_rate_limit(request)
406
 
 
407
  if api_keys and len(api_keys.strip()) > 0:
408
  user_keys = api_keys.split(',')
409
  manager = ApiKeyManager(user_keys)
 
410
  else:
 
411
  manager = server_key_manager
412
 
413
  if manager.get_key_count() == 0:
414
+ raise HTTPException(status_code=400, detail="Chưa có API Key.")
415
 
416
  prompt = DIRECT_GEMINI_PROMPT_LATEX if mode == "latex" else DIRECT_GEMINI_PROMPT_TEXT_ONLY
417
 
 
425
  if file_ext == ".pdf":
426
  doc = fitz.open(stream=file_content, filetype="pdf")
427
  async def process_page_wrapper(page, idx):
 
428
  pix = page.get_pixmap(dpi=200)
429
  img = Image.open(io.BytesIO(pix.tobytes("png")))
430
  text = await process_large_image(img, model, prompt, global_semaphore, manager)
 
446
  return {"success": True, "result": clean_latex_formulas(final_text)}
447
 
448
  except Exception as e:
 
 
449
  raise HTTPException(status_code=500, detail=str(e))
450
 
451
  @app.post("/api/export-docx")