hoangthiencm commited on
Commit
b1ca4dd
·
verified ·
1 Parent(s): e4765f4

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +271 -217
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.2 (Added /version endpoint for Load Balancer Check)
4
  Tác giả: Hoàng Tấn Thiên
5
  """
6
 
@@ -13,59 +13,53 @@ import tempfile
13
  import hashlib
14
  import secrets
15
  import uuid
16
- import sys
17
  from typing import List, Optional
18
 
19
- from fastapi import FastAPI, File, UploadFile, HTTPException, Form, Request, Header, Body
20
  from fastapi.middleware.cors import CORSMiddleware
21
  from fastapi.responses import JSONResponse, FileResponse
22
  from fastapi.staticfiles import StaticFiles
23
- from pydantic import BaseModel
24
  from PIL import Image
25
  import fitz # PyMuPDF
26
  import google.generativeai as genai
27
 
28
- # --- INIT LOGGING ---
29
- print(">> [SYSTEM] Starting app.py v9.2 (With Version Check)...")
30
-
31
  # --- PANDOC IMPORT ---
32
  try:
33
  import pypandoc
34
- print(f">> [INFO] Pandoc version: {pypandoc.get_pandoc_version()}")
35
  except ImportError:
36
- print(">> [WARN] pypandoc module not found.")
37
  except OSError:
38
- print(">> [WARN] pandoc binary not found.")
39
 
40
- # --- SUPABASE SETUP ---
41
  try:
42
  from supabase import create_client, Client
43
  SUPABASE_AVAILABLE = True
44
  except ImportError:
45
  SUPABASE_AVAILABLE = False
46
- print(">> [WARN] supabase module not found.")
 
47
 
48
  # ===== CẤU HÌNH =====
49
  GEMINI_API_KEYS = os.getenv("GEMINI_API_KEYS", "").split(",")
50
  GEMINI_MODELS = os.getenv("GEMINI_MODELS", "gemini-2.5-flash,gemini-1.5-pro").split(",")
51
  SUPABASE_URL = os.getenv("SUPABASE_URL", "")
52
  SUPABASE_KEY = os.getenv("SUPABASE_KEY", "")
53
- MAX_THREADS = int(os.getenv("MAX_THREADS", "5"))
54
- ADMIN_SECRET_KEY = os.getenv("ADMIN_SECRET_KEY", "admin123") # Key mặc định nếu quên set env
55
 
56
  # Setup Supabase
57
  supabase = None
58
  if SUPABASE_AVAILABLE and SUPABASE_URL and SUPABASE_KEY:
59
  try:
60
  supabase = create_client(SUPABASE_URL, SUPABASE_KEY)
61
- print(">> [INFO] Supabase connected.")
62
  except Exception as e:
63
- print(f">> [ERR] Supabase connection failed: {e}")
64
 
65
- # ===== KHỞI TẠO APP =====
66
- app = FastAPI(title="HT_MATH_WEB API", version="9.2")
67
 
68
- # CORS Middleware
69
  app.add_middleware(
70
  CORSMiddleware,
71
  allow_origins=["*"],
@@ -74,245 +68,281 @@ app.add_middleware(
74
  allow_headers=["*"],
75
  )
76
 
77
- # --- GLOBAL REQUEST LOGGING MIDDLEWARE ---
78
- @app.middleware("http")
79
- async def log_requests(request: Request, call_next):
80
- # Log để debug: xem Frontend gọi URL nào
81
- # print(f">> [REQ] {request.method} {request.url.path}") # Uncomment nếu cần debug chi tiết
82
- response = await call_next(request)
83
- if response.status_code == 404:
84
- print(f">> [ERR] 404 Not Found at: {request.url.path} | Container PID: {os.getpid()}")
85
- return response
86
-
87
- # Static Files
88
  os.makedirs("uploads", exist_ok=True)
89
  app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
90
 
91
- # ===== MODELS & HELPERS =====
92
-
93
- class AdminActionModel(BaseModel):
94
- email: str
95
- admin_key: str
96
-
97
- def hash_password(password: str) -> str:
98
- return hashlib.sha256(password.encode()).hexdigest()
99
-
100
- def verify_password(password: str, hashed: str) -> bool:
101
- return hash_password(password) == hashed
102
-
103
  class ApiKeyManager:
104
  def __init__(self, keys: List[str]):
105
  self.api_keys = [k.strip() for k in keys if k.strip()]
106
  self.current_index = 0
 
107
  def get_next_key(self) -> Optional[str]:
108
  if not self.api_keys: return None
109
  key = self.api_keys[self.current_index]
110
  self.current_index = (self.current_index + 1) % len(self.api_keys)
111
  return key
 
112
  def get_key_count(self) -> int:
113
  return len(self.api_keys)
114
 
115
  key_manager = ApiKeyManager(GEMINI_API_KEYS)
116
 
117
- def clean_latex_formulas(text: str) -> str:
118
- return re.sub(r'\$\s+(.*?)\s+\$', lambda m: f'${m.group(1).strip()}$', text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
 
 
 
 
 
120
  def stitch_text(text_a: str, text_b: str, min_overlap_chars: int = 20) -> str:
 
 
 
 
121
  if not text_a: return text_b
122
  if not text_b: return text_a
 
123
  a_lines = text_a.splitlines()
124
  b_lines = text_b.splitlines()
 
 
 
125
  scan_window = min(len(a_lines), len(b_lines), 30)
 
126
  best_overlap_idx = 0
 
 
127
  for i in range(scan_window, 0, -1):
 
128
  tail_a = "\n".join(a_lines[-i:]).strip()
 
129
  head_b = "\n".join(b_lines[:i]).strip()
 
 
 
130
  if len(tail_a) >= min_overlap_chars and tail_a == head_b:
131
  best_overlap_idx = i
132
- break
 
133
  if best_overlap_idx > 0:
 
 
134
  return text_a + "\n" + "\n".join(b_lines[best_overlap_idx:])
135
  else:
 
 
136
  return text_a + "\n\n" + text_b
137
 
138
- # ===== AI LOGIC =====
139
- STRONG_PROMPT = r"""Role: Chuyên viên nhập liệu Toán học. Task: Số hóa ảnh thành Markdown/LaTeX. YÊU CẦU: Trích xuất KHÔNG BỎ SÓT. Giữ nguyên định dạng."""
140
- SAFE_PROMPT = r"""Role: Trợ lý khiếm thị. Task: Mô tả chi tiết nội dung văn bản và toán học."""
141
-
142
- async def process_image_with_gemini(image: Image.Image, model_id: str, prompt_mode: str, max_retries: int = 3) -> str:
143
- current_prompt = STRONG_PROMPT if prompt_mode == "latex" else "Trích xuất văn bản."
144
- safety_settings = [
145
- {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_NONE"},
146
- {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_NONE"},
147
- {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_NONE"},
148
- {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_NONE"},
149
- ]
150
-
151
- for attempt in range(max_retries):
152
- try:
153
- api_key = key_manager.get_next_key()
154
- if not api_key: raise ValueError("No API Key")
155
- genai.configure(api_key=api_key)
156
- model = genai.GenerativeModel(model_id, generation_config={"temperature": 0.0, "top_p": 1.0, "max_output_tokens": 8192})
157
-
158
- response = model.generate_content([current_prompt, image], safety_settings=safety_settings)
159
-
160
- if response.candidates:
161
- cand = response.candidates[0]
162
- if cand.content and cand.content.parts:
163
- return response.text.strip()
164
-
165
- reason = cand.finish_reason
166
- print(f">> [AI] Blocked: {reason}")
167
- if reason == 4 and current_prompt == STRONG_PROMPT:
168
- current_prompt = SAFE_PROMPT
169
- continue
170
- if reason == 4: return "\n> *[Hidden: Copyright]*\n"
171
- if reason == 3: return "\n> *[Hidden: Safety]*\n"
172
- except Exception as e:
173
- print(f">> [AI] Error: {e}")
174
- if "429" in str(e): time.sleep(2); continue
175
- return ""
176
 
177
- async def process_large_image(image: Image.Image, model: str, prompt_mode: str, semaphore: asyncio.Semaphore) -> str:
178
- CHUNK_HEIGHT = 1536
179
- if image.height <= CHUNK_HEIGHT:
180
- async with semaphore: return await process_image_with_gemini(image, model, prompt_mode)
181
-
182
- chunks = []
183
- y = 0
184
- while y < image.height:
185
- bottom = min(y + CHUNK_HEIGHT, image.height)
186
- chunks.append(image.crop((0, y, image.width, bottom)))
187
- if bottom == image.height: break
188
- y += (CHUNK_HEIGHT - 300)
189
-
190
- tasks = [process_chunk_wrapper(c, i, model, prompt_mode, semaphore) for i, c in enumerate(chunks)]
191
- results = await asyncio.gather(*tasks)
192
- results.sort(key=lambda x: x[0])
193
-
194
- final = results[0][1]
195
- for i in range(1, len(results)): final = stitch_text(final, results[i][1])
196
- return final
197
 
198
- async def process_chunk_wrapper(chunk, idx, model, mode, sem):
199
- async with sem:
200
- return idx, await process_image_with_gemini(chunk, model, mode)
201
 
202
- # ===== ROUTES =====
203
 
204
  @app.get("/")
205
  @app.get("/health")
206
  async def root():
207
- return {"status": "ok", "version": "9.2", "routes": [r.path for r in app.routes]}
208
-
209
- @app.get("/version")
210
- async def version():
211
- """Endpoint để Client kiểm tra phiên bản Container"""
212
- return {
213
- "version": "9.2",
214
- "build_date": time.strftime("%Y-%m-%d %H:%M:%S"),
215
- "pid": os.getpid()
216
- }
217
-
218
- @app.get("/debug/sys-info")
219
- async def sys_info():
220
  return {
221
- "version": "9.2",
222
- "cwd": os.getcwd(),
223
- "files_in_root": os.listdir("."),
224
- "python": sys.version
225
  }
226
 
227
  @app.get("/api/models")
228
  async def get_models():
229
  return {"models": GEMINI_MODELS}
230
 
231
- # --- AUTH ROUTES ---
232
-
233
  @app.post("/api/register")
234
  async def register(email: str = Form(...), password: str = Form(...)):
235
- if not supabase: raise HTTPException(500, "DB Error")
236
- if supabase.table("users").select("email").eq("email", email).execute().data:
237
- raise HTTPException(400, "Email exist")
238
- supabase.table("users").insert({
239
- "email": email,
240
- "password": hash_password(password),
241
- "status": "pending",
242
- "created_at": time.strftime("%Y-%m-%d %H:%M:%S")
243
- }).execute()
244
- return {"success": True}
245
 
246
  @app.post("/api/login")
247
- async def login(email: str = Form(...), password: str = Form(...)):
248
- if not supabase: raise HTTPException(500, "DB Error")
249
  res = supabase.table("users").select("*").eq("email", email).execute()
250
- if not res.data: raise HTTPException(401, "Auth failed")
251
  user = res.data[0]
252
- if not verify_password(password, user["password"]): raise HTTPException(401, "Auth failed")
253
- if user.get("status") != "active": raise HTTPException(403, "Not active")
254
-
255
  token = secrets.token_urlsafe(32)
256
  try: supabase.table("sessions").delete().eq("email", email).execute()
257
  except: pass
258
- supabase.table("sessions").insert({"email": email, "token": token}).execute()
259
  return {"success": True, "token": token, "email": email}
260
 
261
  @app.post("/api/check-session")
262
  async def check_session(email: str = Form(...), token: str = Form(...)):
263
- if not supabase: raise HTTPException(500, "DB Error")
264
  res = supabase.table("sessions").select("token").eq("email", email).execute()
265
- if not res.data or res.data[0]['token'] != token:
266
- raise HTTPException(401, "Invalid")
267
  return {"status": "valid"}
268
 
269
  @app.post("/api/logout")
270
  async def logout(request: Request):
271
  try:
272
  data = await request.json()
273
- if supabase: supabase.table("sessions").delete().eq("email", data.get("email")).execute()
 
274
  except: pass
275
  return {"status": "success"}
276
 
277
- # --- ADMIN ROUTES (NEW - FIX 404) ---
 
 
 
 
 
 
 
 
 
 
278
 
279
- @app.get("/api/admin/users")
280
- async def admin_get_users(key: str = Header(None)):
281
- """Lấy danh sách Users (Yêu cầu Admin Key ở Header)"""
282
- if key != ADMIN_SECRET_KEY:
283
- raise HTTPException(status_code=401, detail="Invalid Admin Key")
284
- if not supabase:
285
- raise HTTPException(status_code=500, detail="DB Error")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
286
 
287
- # Lấy toàn bộ users, sắp xếp theo ngày tạo mới nhất
288
- res = supabase.table("users").select("*").order("created_at", desc=True).execute()
289
- return {"users": res.data}
290
-
291
- @app.post("/api/admin/approve")
292
- async def admin_approve_user(data: AdminActionModel):
293
- """Duyệt user (Active)"""
294
- if data.admin_key != ADMIN_SECRET_KEY:
295
- raise HTTPException(status_code=401, detail="Invalid Admin Key")
296
- if not supabase:
297
- raise HTTPException(status_code=500, detail="DB Error")
 
 
 
298
 
299
- supabase.table("users").update({"status": "active"}).eq("email", data.email).execute()
300
- return {"success": True, "message": f"User {data.email} approved"}
301
-
302
- @app.post("/api/admin/delete")
303
- async def admin_delete_user(data: AdminActionModel):
304
- """Xóa user"""
305
- if data.admin_key != ADMIN_SECRET_KEY:
306
- raise HTTPException(status_code=401, detail="Invalid Admin Key")
307
- if not supabase:
308
- raise HTTPException(status_code=500, detail="DB Error")
309
 
310
- # Xóa cả session trước để tránh lỗi khóa ngoại (nếu có)
311
- supabase.table("sessions").delete().eq("email", data.email).execute()
312
- supabase.table("users").delete().eq("email", data.email).execute()
313
- return {"success": True, "message": f"User {data.email} deleted"}
314
-
315
- # --- CONVERT & EXPORT ---
 
 
 
 
 
 
 
 
 
 
 
 
 
316
 
317
  @app.post("/api/convert")
318
  async def convert_file(
@@ -321,56 +351,80 @@ async def convert_file(
321
  model: str = Form("gemini-2.5-flash"),
322
  mode: str = Form("latex")
323
  ):
324
- print(f">> [CONVERT] Start: {file.filename} | Model: {model}")
325
- if key_manager.get_key_count() == 0: raise HTTPException(500, "No API Key")
 
 
 
326
 
327
  try:
328
- content = await file.read()
329
- ext = os.path.splitext(file.filename)[1].lower()
330
- sem = asyncio.Semaphore(MAX_THREADS)
 
 
 
 
331
  results = []
332
 
333
- if ext == ".pdf":
334
- doc = fitz.open(stream=content, filetype="pdf")
335
- tasks = []
336
- for i, page in enumerate(doc):
 
 
337
  pix = page.get_pixmap(dpi=300)
338
  img = Image.open(io.BytesIO(pix.tobytes("png")))
339
- tasks.append(process_large_image(img, model, mode, sem))
 
 
340
 
341
- raw_results = await asyncio.gather(*tasks)
342
- results = list(raw_results)
 
343
  doc.close()
344
- elif ext in [".png", ".jpg", ".jpeg", ".bmp"]:
345
- img = Image.open(io.BytesIO(content))
346
- results.append(await process_large_image(img, model, mode, sem))
347
- else:
348
- raise HTTPException(400, "Format not supported")
349
 
350
- return {"success": True, "result": clean_latex_formulas("\n\n".join(results))}
 
 
 
 
 
 
 
 
 
 
351
  except Exception as e:
352
- import traceback; traceback.print_exc()
353
- raise HTTPException(500, str(e))
 
354
 
 
355
  @app.post("/api/export-docx")
356
  async def export_docx(markdown_text: str = Form(...)):
357
  try:
358
- with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as tmp:
359
- path = tmp.name
360
- pypandoc.convert_text(markdown_text, 'docx', 'markdown', outputfile=path, extra_args=['--standalone'])
361
- return FileResponse(path, media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", filename="Result.docx")
 
 
 
 
 
 
 
 
 
 
 
 
362
  except Exception as e:
363
- raise HTTPException(500, str(e))
364
-
365
- # ===== STARTUP PRINT =====
366
- @app.on_event("startup")
367
- async def list_routes():
368
- print("\n" + "="*40)
369
- print(">> REGISTERED ROUTES (v9.2):")
370
- for route in app.routes:
371
- print(f" {route.methods} {route.path}")
372
- print("="*40 + "\n")
373
 
374
  if __name__ == "__main__":
375
  import uvicorn
376
- uvicorn.run(app, host="0.0.0.0", port=7860)
 
1
  """
2
  Backend API cho HT_MATH_WEB - Chạy trên Hugging Face Spaces (Docker Version)
3
+ Phiên bản: 8.0 (Content-Based Stitching OCR - Overlap Algorithm)
4
  Tác giả: Hoàng Tấn Thiên
5
  """
6
 
 
13
  import hashlib
14
  import secrets
15
  import uuid
16
+ import math
17
  from typing import List, Optional
18
 
19
+ from fastapi import FastAPI, File, UploadFile, HTTPException, Form, Request
20
  from fastapi.middleware.cors import CORSMiddleware
21
  from fastapi.responses import JSONResponse, FileResponse
22
  from fastapi.staticfiles import StaticFiles
 
23
  from PIL import Image
24
  import fitz # PyMuPDF
25
  import google.generativeai as genai
26
 
 
 
 
27
  # --- PANDOC IMPORT ---
28
  try:
29
  import pypandoc
30
+ print(f"INFO: Pandoc version detected: {pypandoc.get_pandoc_version()}")
31
  except ImportError:
32
+ print("CRITICAL WARNING: pypandoc module not found.")
33
  except OSError:
34
+ print("CRITICAL WARNING: pandoc binary not found in system path.")
35
 
36
+ # --- SUPABASE ---
37
  try:
38
  from supabase import create_client, Client
39
  SUPABASE_AVAILABLE = True
40
  except ImportError:
41
  SUPABASE_AVAILABLE = False
42
+ Client = None
43
+ create_client = None
44
 
45
  # ===== CẤU HÌNH =====
46
  GEMINI_API_KEYS = os.getenv("GEMINI_API_KEYS", "").split(",")
47
  GEMINI_MODELS = os.getenv("GEMINI_MODELS", "gemini-2.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", "5")) # Tăng thread để xử lý các mảnh cắt song song
51
+ ADMIN_SECRET_KEY = os.getenv("ADMIN_SECRET_KEY", "admin123")
52
 
53
  # Setup Supabase
54
  supabase = None
55
  if SUPABASE_AVAILABLE and SUPABASE_URL and SUPABASE_KEY:
56
  try:
57
  supabase = create_client(SUPABASE_URL, SUPABASE_KEY)
 
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="8.0")
 
62
 
 
63
  app.add_middleware(
64
  CORSMiddleware,
65
  allow_origins=["*"],
 
68
  allow_headers=["*"],
69
  )
70
 
71
+ # --- SETUP STATIC FILES ---
 
 
 
 
 
 
 
 
 
 
72
  os.makedirs("uploads", exist_ok=True)
73
  app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads")
74
 
75
+ @app.exception_handler(404)
76
+ async def not_found_handler(request, exc):
77
+ return JSONResponse(
78
+ status_code=404,
79
+ content={
80
+ "detail": f"Route not found: {request.url.path}",
81
+ "available_routes": ["/", "/api/models", "/api/convert", "/api/export-docx", "/api/login", "/api/check-session", "/api/upload-image"]
82
+ }
83
+ )
84
+
85
+ # ===== KEY MANAGER & RATE LIMIT =====
 
86
  class ApiKeyManager:
87
  def __init__(self, keys: List[str]):
88
  self.api_keys = [k.strip() for k in keys if k.strip()]
89
  self.current_index = 0
90
+
91
  def get_next_key(self) -> Optional[str]:
92
  if not self.api_keys: return None
93
  key = self.api_keys[self.current_index]
94
  self.current_index = (self.current_index + 1) % len(self.api_keys)
95
  return key
96
+
97
  def get_key_count(self) -> int:
98
  return len(self.api_keys)
99
 
100
  key_manager = ApiKeyManager(GEMINI_API_KEYS)
101
 
102
+ ip_rate_limits = {}
103
+ RATE_LIMIT_DURATION = 7
104
+
105
+ def check_rate_limit(request: Request):
106
+ forwarded = request.headers.get("X-Forwarded-For")
107
+ client_ip = forwarded.split(",")[0].strip() if forwarded else request.client.host
108
+ now = time.time()
109
+ if client_ip in ip_rate_limits:
110
+ elapsed = now - ip_rate_limits[client_ip]
111
+ if elapsed < RATE_LIMIT_DURATION:
112
+ print(f"[RateLimit] IP {client_ip} requesting too fast.")
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
119
+ - KHÔNG được bỏ sót bất kỳ câu hỏi, ví dụ, bài tập nào
120
+ - KỂ CẢ các câu nhỏ, câu phụ, chữ mờ, chữ sát lề
121
+ - Nếu không chắc, VẪN PHẢI ghi lại nội dung nhìn thấy
122
+ ⚠️ NHIỆM VỤ:
123
+ 1. Trích xuất toàn bộ văn bản, giữ nguyên định dạng gốc.
124
+ 2. KHÔNG sử dụng LaTeX ($...$), giữ nguyên biểu thức toán dạng text (ví dụ: x^2 + 1 = 0).
125
+ 3. Đánh dấu tiêu đề bằng Markdown **đậm**.
126
+ 4. KHÔNG thêm lời giải thích.
127
+ """
128
+
129
+ DIRECT_GEMINI_PROMPT_LATEX = r"""Bạn là công cụ trích xuất văn bản từ ảnh/PDF. NHIỆM VỤ: Chuyển đổi nội dung trong ảnh sang Markdown với công thức LaTeX.
130
+ ⚠️ YÊU CẦU CỐT LÕI - KHÔNG ĐƯỢC BỎ SÓT:
131
+ - Đọc kỹ từng pixel, trích xuất TOÀN BỘ nội dung từ trên xuống dưới.
132
+ - KHÔNG bỏ qua bất kỳ bài tập, hình vẽ, hoặc ghi chú nhỏ nào.
133
+ - 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.
134
+
135
+ ⚠️ QUY TẮC LATEX (BẮT BUỘC):
136
+ 1. Mọi công thức toán PHẢI bọc trong dấu $. Ví dụ: $x^2$, $\frac{1}{2}$.
137
+ 2. KHÔNG dùng \[...\] hoặc \(...\).
138
+ 3. Luôn có khoảng trắng trước dấu $: "Cho hàm số $f(x)$..." (Đúng).
139
+
140
+ ⚠️ ĐỊNH DẠNG:
141
+ - Giữ nguyên cấu trúc dòng, đoạn.
142
+ - Tiêu đề in đậm: **Câu 1:**, **Bài tập:**.
143
+ - Bảng biểu giữ nguyên Markdown Table.
144
 
145
+ CHỈ TRẢ VỀ MARKDOWN. KHÔNG GIẢI THÍCH THÊM.
146
+ """
147
+
148
+ # ===== STITCHING ALGORITHM (QUAN TRỌNG) =====
149
  def stitch_text(text_a: str, text_b: str, min_overlap_chars: int = 20) -> str:
150
+ """
151
+ Thuật toán ghép nối nội dung dựa trên sự trùng lặp (Content-Based Stitching).
152
+ So sánh phần đuôi của text_a và phần đầu của text_b để tìm đoạn trùng khớp nhất.
153
+ """
154
  if not text_a: return text_b
155
  if not text_b: return text_a
156
+
157
  a_lines = text_a.splitlines()
158
  b_lines = text_b.splitlines()
159
+
160
+ # Chỉ so sánh N dòng cuối của A và N dòng đầu của B để tối ưu hiệu năng
161
+ # (Tránh việc so sánh toàn bộ văn bản nếu văn bản quá dài)
162
  scan_window = min(len(a_lines), len(b_lines), 30)
163
+
164
  best_overlap_idx = 0
165
+
166
+ # Quét từ overlap lớn nhất (scan_window) về 1
167
  for i in range(scan_window, 0, -1):
168
+ # Lấy i dòng cuối của A
169
  tail_a = "\n".join(a_lines[-i:]).strip()
170
+ # Lấy i dòng đầu của B
171
  head_b = "\n".join(b_lines[:i]).strip()
172
+
173
+ # Kiểm tra độ dài tối thiểu và nội dung trùng khớp
174
+ # .strip() giúp bỏ qua sự khác biệt về khoảng trắng thừa
175
  if len(tail_a) >= min_overlap_chars and tail_a == head_b:
176
  best_overlap_idx = i
177
+ break # Tìm thấy overlap lớn nhất thì dừng ngay (Greedy)
178
+
179
  if best_overlap_idx > 0:
180
+ # Tìm thấy overlap: Ghép A + B (bỏ đi phần đầu trùng lặp của B)
181
+ # print(f"[Stitch] Found overlap: {best_overlap_idx} lines")
182
  return text_a + "\n" + "\n".join(b_lines[best_overlap_idx:])
183
  else:
184
+ # Không tìm thấy overlap: Nối bình thường với dòng trống
185
+ # print("[Stitch] No overlap found, simple join")
186
  return text_a + "\n\n" + text_b
187
 
188
+ # ===== HELPER FUNCTIONS =====
189
+ def clean_latex_formulas(text: str) -> str:
190
+ return re.sub(r'\$\s+(.*?)\s+\$', lambda m: f'${m.group(1).strip()}$', text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
 
192
+ def hash_password(password: str) -> str:
193
+ return hashlib.sha256(password.encode()).hexdigest()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
 
195
+ def verify_password(password: str, hashed: str) -> bool:
196
+ return hash_password(password) == hashed
 
197
 
198
+ # ===== API ENDPOINTS =====
199
 
200
  @app.get("/")
201
  @app.get("/health")
202
  async def root():
203
+ pandoc_status = "Not Found"
204
+ try:
205
+ pandoc_status = pypandoc.get_pandoc_version()
206
+ except:
207
+ pass
 
 
 
 
 
 
 
 
208
  return {
209
+ "status": "ok",
210
+ "service": "HT_MATH_WEB API v8.0 (Stitching OCR)",
211
+ "keys_loaded": key_manager.get_key_count(),
212
+ "pandoc_version": pandoc_status
213
  }
214
 
215
  @app.get("/api/models")
216
  async def get_models():
217
  return {"models": GEMINI_MODELS}
218
 
219
+ # --- AUTH API ---
 
220
  @app.post("/api/register")
221
  async def register(email: str = Form(...), password: str = Form(...)):
222
+ if not supabase: raise HTTPException(status_code=500, detail="DB Error")
223
+ res = supabase.table("users").select("email").eq("email", email).execute()
224
+ if res.data: raise HTTPException(status_code=400, detail="Email tồn tại")
225
+ user_data = {"email": email, "password": hash_password(password), "status": "pending", "created_at": time.strftime("%Y-%m-%d %H:%M:%S")}
226
+ supabase.table("users").insert(user_data).execute()
227
+ return {"success": True, "message": "Đăng ký thành công, chờ duyệt."}
 
 
 
 
228
 
229
  @app.post("/api/login")
230
+ async def login(request: Request, email: str = Form(...), password: str = Form(...)):
231
+ if not supabase: raise HTTPException(status_code=500, detail="DB Error")
232
  res = supabase.table("users").select("*").eq("email", email).execute()
233
+ if not res.data: raise HTTPException(status_code=401, detail="Sai email/pass")
234
  user = res.data[0]
235
+ if not verify_password(password, user["password"]): raise HTTPException(status_code=401, detail="Sai email/pass")
236
+ if user.get("status") != "active": raise HTTPException(status_code=403, detail="Tài khoản chưa kích hoạt")
 
237
  token = secrets.token_urlsafe(32)
238
  try: supabase.table("sessions").delete().eq("email", email).execute()
239
  except: pass
240
+ supabase.table("sessions").insert({"email": email, "token": token, "last_seen": time.strftime("%Y-%m-%d %H:%M:%S")}).execute()
241
  return {"success": True, "token": token, "email": email}
242
 
243
  @app.post("/api/check-session")
244
  async def check_session(email: str = Form(...), token: str = Form(...)):
245
+ if not supabase: raise HTTPException(status_code=500, detail="DB Error")
246
  res = supabase.table("sessions").select("token").eq("email", email).execute()
247
+ if not res.data or res.data[0]['token'] != token: raise HTTPException(status_code=401, detail="Session expired")
248
+ supabase.table("sessions").update({"last_seen": time.strftime("%Y-%m-%d %H:%M:%S")}).eq("email", email).execute()
249
  return {"status": "valid"}
250
 
251
  @app.post("/api/logout")
252
  async def logout(request: Request):
253
  try:
254
  data = await request.json()
255
+ email = data.get("email")
256
+ if email and supabase: supabase.table("sessions").delete().eq("email", email).execute()
257
  except: pass
258
  return {"status": "success"}
259
 
260
+ @app.post("/api/upload-image")
261
+ async def upload_image(file: UploadFile = File(...)):
262
+ try:
263
+ file_ext = os.path.splitext(file.filename)[1] or ".png"
264
+ file_name = f"{uuid.uuid4().hex}{file_ext}"
265
+ file_path = f"uploads/{file_name}"
266
+ with open(file_path, "wb") as f: f.write(await file.read())
267
+ return {"url": file_path}
268
+ except Exception as e: raise HTTPException(status_code=500, detail=str(e))
269
+
270
+ # --- CORE CONVERT LOGIC ---
271
 
272
+ async def process_image_with_gemini(image: Image.Image, model_id: str, prompt: str, max_retries: int = 3) -> str:
273
+ """Gửi 1 ảnh (hoặc mảnh ảnh) lên Gemini và nhận text"""
274
+ for attempt in range(max_retries):
275
+ try:
276
+ api_key = key_manager.get_next_key()
277
+ if not api_key: raise ValueError("No API Key")
278
+ genai.configure(api_key=api_key)
279
+
280
+ generation_config = {"temperature": 0.0, "top_p": 1.0, "max_output_tokens": 8192}
281
+ model = genai.GenerativeModel(model_id, generation_config=generation_config)
282
+ response = model.generate_content([prompt, image])
283
+
284
+ if response.text:
285
+ return response.text.strip()
286
+ except Exception as e:
287
+ if "429" in str(e) and attempt < max_retries - 1:
288
+ time.sleep(2)
289
+ continue
290
+ if attempt == max_retries - 1: raise e
291
+ return ""
292
+
293
+ async def process_large_image(image: Image.Image, model: str, prompt: str, semaphore: asyncio.Semaphore) -> str:
294
+ """
295
+ Xử lý ảnh lớn bằng kỹ thuật: Overlap Splitting + Content-Based Stitching
296
+ """
297
+ # Cấu hình cắt ảnh
298
+ CHUNK_HEIGHT = 2048 # Chiều cao mỗi mảnh (pixel)
299
+ OVERLAP_HEIGHT = 512 # Chiều cao phần chồng lặp (pixel)
300
+
301
+ width, height = image.size
302
 
303
+ # Nếu ảnh nhỏ hơn ngưỡng cắt, xử bình thường
304
+ if height <= CHUNK_HEIGHT:
305
+ async with semaphore:
306
+ return await process_image_with_gemini(image, model, prompt)
307
+
308
+ # --- Cắt ảnh thành các mảnh có Overlap ---
309
+ chunks = []
310
+ y = 0
311
+ while y < height:
312
+ # Xác định vùng cắt
313
+ bottom = min(y + CHUNK_HEIGHT, height)
314
+ box = (0, y, width, bottom)
315
+ chunk = image.crop(box)
316
+ chunks.append(chunk)
317
 
318
+ # Nếu đã đến đáy ảnh thì dừng
319
+ if bottom == height:
320
+ break
321
+
322
+ # Di chuyển y xuống, nhưng lùi lại một đoạn overlap
323
+ y += (CHUNK_HEIGHT - OVERLAP_HEIGHT)
324
+
325
+ print(f"[Split] Image height {height}px -> {len(chunks)} chunks with overlap.")
 
 
326
 
327
+ # --- Gửi song song các mảnh lên Gemini ---
328
+ async def process_chunk(chunk_img, index):
329
+ async with semaphore:
330
+ text = await process_image_with_gemini(chunk_img, model, prompt)
331
+ return index, text
332
+
333
+ tasks = [process_chunk(chunk, i) for i, chunk in enumerate(chunks)]
334
+ chunk_results = await asyncio.gather(*tasks)
335
+
336
+ # Sắp xếp lại theo đúng thứ tự
337
+ chunk_results.sort(key=lambda x: x[0])
338
+ ordered_texts = [text for _, text in chunk_results]
339
+
340
+ # --- Ghép nối thông minh (Stitching) ---
341
+ final_text = ordered_texts[0]
342
+ for i in range(1, len(ordered_texts)):
343
+ final_text = stitch_text(final_text, ordered_texts[i], min_overlap_chars=20)
344
+
345
+ return final_text
346
 
347
  @app.post("/api/convert")
348
  async def convert_file(
 
351
  model: str = Form("gemini-2.5-flash"),
352
  mode: str = Form("latex")
353
  ):
354
+ check_rate_limit(request)
355
+ if key_manager.get_key_count() == 0:
356
+ raise HTTPException(status_code=500, detail="Chưa cấu hình API Key")
357
+
358
+ prompt = DIRECT_GEMINI_PROMPT_LATEX if mode == "latex" else DIRECT_GEMINI_PROMPT_TEXT_ONLY
359
 
360
  try:
361
+ file_content = await file.read()
362
+ file_ext = os.path.splitext(file.filename)[1].lower()
363
+
364
+ # Global semaphore để kiểm soát tổng số request đồng thời lên Gemini
365
+ # Tránh lỗi 429 Quota Exceeded khi cắt quá nhiều mảnh
366
+ global_semaphore = asyncio.Semaphore(MAX_THREADS)
367
+
368
  results = []
369
 
370
+ if file_ext == ".pdf":
371
+ doc = fitz.open(stream=file_content, filetype="pdf")
372
+
373
+ # Hàm xử từng trang (có thể cắt nhỏ bên trong nếu trang dài)
374
+ async def process_page_wrapper(page, idx):
375
+ # Render ảnh chất lượng cao
376
  pix = page.get_pixmap(dpi=300)
377
  img = Image.open(io.BytesIO(pix.tobytes("png")))
378
+ # Gọi hàm xử lý ảnh lớn (tự động cắt/ghép nếu cần)
379
+ text = await process_large_image(img, model, prompt, global_semaphore)
380
+ return idx, text
381
 
382
+ tasks = [process_page_wrapper(doc[i], i) for i in range(len(doc))]
383
+ page_results = await asyncio.gather(*tasks)
384
+ results = [text for _, text in sorted(page_results, key=lambda x: x[0])]
385
  doc.close()
 
 
 
 
 
386
 
387
+ elif file_ext in [".png", ".jpg", ".jpeg", ".bmp"]:
388
+ img = Image.open(io.BytesIO(file_content))
389
+ # Xử lý ảnh upload (tự động cắt/ghép nếu dài)
390
+ text = await process_large_image(img, model, prompt, global_semaphore)
391
+ results.append(text)
392
+ else:
393
+ raise HTTPException(status_code=400, detail="Định dạng file không hỗ trợ")
394
+
395
+ final_text = "\n\n".join(results)
396
+ return {"success": True, "result": clean_latex_formulas(final_text)}
397
+
398
  except Exception as e:
399
+ import traceback
400
+ traceback.print_exc()
401
+ raise HTTPException(status_code=500, detail=str(e))
402
 
403
+ # --- WORD EXPORT API (PANDOC NATIVE) ---
404
  @app.post("/api/export-docx")
405
  async def export_docx(markdown_text: str = Form(...)):
406
  try:
407
+ with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as tmp_file:
408
+ output_filename = tmp_file.name
409
+
410
+ pypandoc.convert_text(
411
+ markdown_text,
412
+ to='docx',
413
+ format='markdown',
414
+ outputfile=output_filename,
415
+ extra_args=['--standalone']
416
+ )
417
+
418
+ return FileResponse(
419
+ output_filename,
420
+ media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
421
+ filename="Ket_qua_HT_MATH_Pandoc.docx"
422
+ )
423
  except Exception as e:
424
+ import traceback
425
+ traceback.print_exc()
426
+ raise HTTPException(status_code=500, detail=f"Lỗi xuất Word: {str(e)}")
 
 
 
 
 
 
 
427
 
428
  if __name__ == "__main__":
429
  import uvicorn
430
+ uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", "7860")))