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: 9.
|
| 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")
|
| 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.
|
| 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
|
| 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
|
| 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 |
-
|
|
|
|
| 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.
|
| 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
|
| 285 |
-
|
| 286 |
@app.get("/admin/users")
|
| 287 |
async def admin_get_users(secret: str = ""):
|
| 288 |
-
if secret != ADMIN_SECRET_KEY:
|
| 289 |
-
|
| 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 |
-
|
| 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 |
-
|
| 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
|
| 340 |
|
| 341 |
-
#
|
| 342 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 364 |
manager.rotate_key()
|
| 365 |
-
time.sleep(1)
|
| 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 |
-
#
|
| 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)
|
| 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.
|
| 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")
|