Update app.py
Browse files
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:
|
| 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
|
| 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 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"
|
| 35 |
except ImportError:
|
| 36 |
-
print("
|
| 37 |
except OSError:
|
| 38 |
-
print("
|
| 39 |
|
| 40 |
-
# --- SUPABASE
|
| 41 |
try:
|
| 42 |
from supabase import create_client, Client
|
| 43 |
SUPABASE_AVAILABLE = True
|
| 44 |
except ImportError:
|
| 45 |
SUPABASE_AVAILABLE = False
|
| 46 |
-
|
|
|
|
| 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")
|
| 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"
|
| 64 |
|
| 65 |
-
|
| 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 |
-
# ---
|
| 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 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 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 |
-
|
| 118 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
# =====
|
| 139 |
-
|
| 140 |
-
|
| 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 |
-
|
| 178 |
-
|
| 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 |
-
|
| 199 |
-
|
| 200 |
-
return idx, await process_image_with_gemini(chunk, model, mode)
|
| 201 |
|
| 202 |
-
# =====
|
| 203 |
|
| 204 |
@app.get("/")
|
| 205 |
@app.get("/health")
|
| 206 |
async def root():
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 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 |
-
"
|
| 222 |
-
"
|
| 223 |
-
"
|
| 224 |
-
"
|
| 225 |
}
|
| 226 |
|
| 227 |
@app.get("/api/models")
|
| 228 |
async def get_models():
|
| 229 |
return {"models": GEMINI_MODELS}
|
| 230 |
|
| 231 |
-
# --- AUTH
|
| 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 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 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, "
|
| 251 |
user = res.data[0]
|
| 252 |
-
if not verify_password(password, user["password"]): raise HTTPException(401, "
|
| 253 |
-
if user.get("status") != "active": raise HTTPException(403, "
|
| 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 |
-
|
| 267 |
return {"status": "valid"}
|
| 268 |
|
| 269 |
@app.post("/api/logout")
|
| 270 |
async def logout(request: Request):
|
| 271 |
try:
|
| 272 |
data = await request.json()
|
| 273 |
-
|
|
|
|
| 274 |
except: pass
|
| 275 |
return {"status": "success"}
|
| 276 |
|
| 277 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
|
| 287 |
-
#
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
|
|
|
|
|
|
|
|
|
| 298 |
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
if not supabase:
|
| 308 |
-
raise HTTPException(status_code=500, detail="DB Error")
|
| 309 |
|
| 310 |
-
#
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 325 |
-
if key_manager.get_key_count() == 0:
|
|
|
|
|
|
|
|
|
|
| 326 |
|
| 327 |
try:
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
results = []
|
| 332 |
|
| 333 |
-
if
|
| 334 |
-
doc = fitz.open(stream=
|
| 335 |
-
|
| 336 |
-
|
|
|
|
|
|
|
| 337 |
pix = page.get_pixmap(dpi=300)
|
| 338 |
img = Image.open(io.BytesIO(pix.tobytes("png")))
|
| 339 |
-
|
|
|
|
|
|
|
| 340 |
|
| 341 |
-
|
| 342 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
except Exception as e:
|
| 352 |
-
import traceback
|
| 353 |
-
|
|
|
|
| 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
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 362 |
except Exception as e:
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 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ử lý 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ử lý 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")))
|