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 |
|
|
@@ -33,6 +33,16 @@ except ImportError:
|
|
| 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
|
|
@@ -47,7 +57,7 @@ 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"))
|
| 51 |
ADMIN_SECRET_KEY = os.getenv("ADMIN_SECRET_KEY", "admin123")
|
| 52 |
|
| 53 |
# Setup Supabase
|
|
@@ -58,7 +68,7 @@ if SUPABASE_AVAILABLE and SUPABASE_URL and 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="
|
| 62 |
|
| 63 |
app.add_middleware(
|
| 64 |
CORSMiddleware,
|
|
@@ -112,82 +122,61 @@ def check_rate_limit(request: Request):
|
|
| 112 |
print(f"[RateLimit] IP {client_ip} requesting too fast.")
|
| 113 |
ip_rate_limits[client_ip] = now
|
| 114 |
|
| 115 |
-
# ===== PROMPTS =====
|
| 116 |
-
|
| 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 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
-
|
| 133 |
-
-
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
3. Luôn có khoảng trắng trước dấu $: "Cho hàm số $f(x)$..." (Đúng).
|
| 139 |
|
| 140 |
-
⚠️
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
|
|
|
| 144 |
|
| 145 |
-
CHỈ TRẢ VỀ
|
| 146 |
"""
|
| 147 |
|
| 148 |
-
# ===== STITCHING ALGORITHM
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 191 |
|
| 192 |
def hash_password(password: str) -> str:
|
| 193 |
return hashlib.sha256(password.encode()).hexdigest()
|
|
@@ -195,21 +184,69 @@ def hash_password(password: str) -> str:
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
return {
|
| 209 |
"status": "ok",
|
| 210 |
-
"service": "HT_MATH_WEB API
|
| 211 |
"keys_loaded": key_manager.get_key_count(),
|
| 212 |
-
"
|
|
|
|
| 213 |
}
|
| 214 |
|
| 215 |
@app.get("/api/models")
|
|
@@ -270,61 +307,67 @@ async def upload_image(file: UploadFile = File(...)):
|
|
| 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.
|
| 281 |
model = genai.GenerativeModel(model_id, generation_config=generation_config)
|
|
|
|
|
|
|
| 282 |
response = model.generate_content([prompt, image])
|
| 283 |
|
| 284 |
-
|
| 285 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 296 |
"""
|
| 297 |
-
|
| 298 |
-
|
| 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 |
-
#
|
| 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
|
| 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)
|
|
@@ -333,11 +376,9 @@ async def process_large_image(image: Image.Image, model: str, prompt: str, semap
|
|
| 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)
|
|
@@ -361,21 +402,15 @@ async def convert_file(
|
|
| 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 |
|
|
@@ -386,7 +421,6 @@ async def convert_file(
|
|
| 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:
|
|
@@ -400,7 +434,6 @@ async def convert_file(
|
|
| 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:
|
|
@@ -418,7 +451,7 @@ async def export_docx(markdown_text: str = Form(...)):
|
|
| 418 |
return FileResponse(
|
| 419 |
output_filename,
|
| 420 |
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
| 421 |
-
filename="
|
| 422 |
)
|
| 423 |
except Exception as e:
|
| 424 |
import traceback
|
|
|
|
| 1 |
"""
|
| 2 |
Backend API cho HT_MATH_WEB - Chạy trên Hugging Face Spaces (Docker Version)
|
| 3 |
+
Phiên bản: 9.0 (Copyright Safety & Fallback OCR)
|
| 4 |
Tác giả: Hoàng Tấn Thiên
|
| 5 |
"""
|
| 6 |
|
|
|
|
| 33 |
except OSError:
|
| 34 |
print("CRITICAL WARNING: pandoc binary not found in system path.")
|
| 35 |
|
| 36 |
+
# --- TESSERACT IMPORT (FALLBACK OCR) ---
|
| 37 |
+
try:
|
| 38 |
+
import pytesseract
|
| 39 |
+
# Kiểm tra xem binary có tồn tại không (trong Docker thường ở /usr/bin/tesseract)
|
| 40 |
+
# pytesseract.pytesseract.tesseract_cmd = r'/usr/bin/tesseract'
|
| 41 |
+
print("INFO: Tesseract OCR module loaded.")
|
| 42 |
+
except ImportError:
|
| 43 |
+
print("WARNING: pytesseract not found. Fallback OCR will not work.")
|
| 44 |
+
pytesseract = None
|
| 45 |
+
|
| 46 |
# --- SUPABASE ---
|
| 47 |
try:
|
| 48 |
from supabase import create_client, Client
|
|
|
|
| 57 |
GEMINI_MODELS = os.getenv("GEMINI_MODELS", "gemini-2.5-flash,gemini-1.5-pro").split(",")
|
| 58 |
SUPABASE_URL = os.getenv("SUPABASE_URL", "")
|
| 59 |
SUPABASE_KEY = os.getenv("SUPABASE_KEY", "")
|
| 60 |
+
MAX_THREADS = int(os.getenv("MAX_THREADS", "5"))
|
| 61 |
ADMIN_SECRET_KEY = os.getenv("ADMIN_SECRET_KEY", "admin123")
|
| 62 |
|
| 63 |
# Setup Supabase
|
|
|
|
| 68 |
except Exception as e:
|
| 69 |
print(f"Warning: Không thể kết nối Supabase: {e}")
|
| 70 |
|
| 71 |
+
app = FastAPI(title="HT_MATH_WEB API", version="9.0")
|
| 72 |
|
| 73 |
app.add_middleware(
|
| 74 |
CORSMiddleware,
|
|
|
|
| 122 |
print(f"[RateLimit] IP {client_ip} requesting too fast.")
|
| 123 |
ip_rate_limits[client_ip] = now
|
| 124 |
|
| 125 |
+
# ===== SAFE PROMPTS (ANTI-COPYRIGHT) =====
|
| 126 |
+
# Đã đổi prompt để tránh trigger bản quyền (finish_reason=4)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
|
| 128 |
+
DIRECT_GEMINI_PROMPT_TEXT_ONLY = r"""Bạn là trợ lý AI hỗ trợ người khiếm thị số hóa tài liệu.
|
| 129 |
+
Nhiệm vụ: Nhận diện và mô tả lại nội dung văn bản trong hình ảnh một cách trung thực, chính xác.
|
| 130 |
+
Yêu cầu:
|
| 131 |
+
- Trích xuất nội dung văn bản để lưu trữ dữ liệu.
|
| 132 |
+
- Giữ nguyên các thông số, dữ liệu toán học.
|
| 133 |
+
- Định dạng Markdown rõ ràng.
|
| 134 |
+
- KHÔNG thêm lời bình luận.
|
| 135 |
+
"""
|
| 136 |
|
| 137 |
+
DIRECT_GEMINI_PROMPT_LATEX = r"""Bạn là trợ lý AI hỗ trợ số hóa tài liệu giáo dục.
|
| 138 |
+
Nhiệm vụ: Phân tích hình ảnh và viết lại nội dung dưới dạng Markdown chuẩn Toán học (LaTeX).
|
| 139 |
+
Mục đích: Số hóa để lưu trữ và giảng dạy, không nhằm mục đích sao chép thương mại.
|
|
|
|
| 140 |
|
| 141 |
+
⚠️ YÊU CẦU XỬ LÝ AN TOÀN:
|
| 142 |
+
1. Nếu gặp nội dung giống sách giáo khoa hoặc tài liệu có bản quyền, hãy VIẾT LẠI (paraphrase) lời dẫn nhưng GIỮ NGUYÊN công thức toán học và số liệu.
|
| 143 |
+
2. Trình bày công thức toán trong dấu `$`. Ví dụ: $x^2 + 2x = 0$.
|
| 144 |
+
3. Đảm bảo cấu trúc bài tập rõ ràng (Câu 1, Câu 2...).
|
| 145 |
+
4. Nếu hình ảnh mờ hoặc bị cắt, hãy cố gắng phục hồi dựa trên ngữ cảnh toán học.
|
| 146 |
|
| 147 |
+
CHỈ TRẢ VỀ NỘI DUNG MARKDOWN.
|
| 148 |
"""
|
| 149 |
|
| 150 |
+
# ===== STITCHING ALGORITHM =====
|
| 151 |
def stitch_text(text_a: str, text_b: str, min_overlap_chars: int = 20) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
if not text_a: return text_b
|
| 153 |
if not text_b: return text_a
|
| 154 |
|
| 155 |
a_lines = text_a.splitlines()
|
| 156 |
b_lines = text_b.splitlines()
|
| 157 |
|
|
|
|
|
|
|
| 158 |
scan_window = min(len(a_lines), len(b_lines), 30)
|
|
|
|
| 159 |
best_overlap_idx = 0
|
| 160 |
|
|
|
|
| 161 |
for i in range(scan_window, 0, -1):
|
|
|
|
| 162 |
tail_a = "\n".join(a_lines[-i:]).strip()
|
|
|
|
| 163 |
head_b = "\n".join(b_lines[:i]).strip()
|
| 164 |
|
|
|
|
|
|
|
| 165 |
if len(tail_a) >= min_overlap_chars and tail_a == head_b:
|
| 166 |
best_overlap_idx = i
|
| 167 |
+
break
|
| 168 |
|
| 169 |
if best_overlap_idx > 0:
|
|
|
|
|
|
|
| 170 |
return text_a + "\n" + "\n".join(b_lines[best_overlap_idx:])
|
| 171 |
else:
|
|
|
|
|
|
|
| 172 |
return text_a + "\n\n" + text_b
|
| 173 |
|
| 174 |
# ===== HELPER FUNCTIONS =====
|
| 175 |
def clean_latex_formulas(text: str) -> str:
|
| 176 |
+
# Chuẩn hóa khoảng trắng Latex
|
| 177 |
+
text = re.sub(r'\$\s+(.*?)\s+\$', lambda m: f'${m.group(1).strip()}$', text)
|
| 178 |
+
# Fix lỗi phổ biến khi OCR tiếng Việt bị dính ký tự
|
| 179 |
+
return text
|
| 180 |
|
| 181 |
def hash_password(password: str) -> str:
|
| 182 |
return hashlib.sha256(password.encode()).hexdigest()
|
|
|
|
| 184 |
def verify_password(password: str, hashed: str) -> bool:
|
| 185 |
return hash_password(password) == hashed
|
| 186 |
|
| 187 |
+
def safe_get_text(response) -> str:
|
| 188 |
+
"""
|
| 189 |
+
Trích xuất text an toàn từ Gemini Response.
|
| 190 |
+
Xử lý trường hợp bị chặn bản quyền (finish_reason=4).
|
| 191 |
+
"""
|
| 192 |
+
if not response.candidates:
|
| 193 |
+
return ""
|
| 194 |
+
|
| 195 |
+
candidate = response.candidates[0]
|
| 196 |
+
|
| 197 |
+
# Kiểm tra lý do kết thúc
|
| 198 |
+
# 1 = STOP (OK), 4 = RECITING_FROM_COPYRIGHTED_MATERIAL (Blocked)
|
| 199 |
+
if candidate.finish_reason == 4:
|
| 200 |
+
return "[BLOCKED_BY_COPYRIGHT]"
|
| 201 |
+
|
| 202 |
+
if candidate.finish_reason != 1:
|
| 203 |
+
# Có thể log warning ở đây với các reason khác (như SAFETY)
|
| 204 |
+
return ""
|
| 205 |
+
|
| 206 |
+
# Nếu an toàn, trích xuất text
|
| 207 |
+
parts = candidate.content.parts
|
| 208 |
+
texts = [p.text for p in parts if hasattr(p, "text")]
|
| 209 |
+
return "\n".join(texts)
|
| 210 |
+
|
| 211 |
+
async def fallback_ocr_tesseract(image: Image.Image) -> str:
|
| 212 |
+
"""
|
| 213 |
+
Fallback dùng Tesseract OCR khi Gemini từ chối phục vụ
|
| 214 |
+
"""
|
| 215 |
+
if pytesseract is None:
|
| 216 |
+
return "**[Lỗi] Gemini từ chối xử lý (Bản quyền) và Tesseract chưa được cài đặt.**"
|
| 217 |
+
|
| 218 |
+
print("[Fallback] Đang chạy Tesseract OCR...")
|
| 219 |
+
try:
|
| 220 |
+
# Chạy trong executor để không block event loop
|
| 221 |
+
loop = asyncio.get_running_loop()
|
| 222 |
+
# Chế độ: tiếng Việt + tiếng Anh + công thức toán (nếu train, ở đây dùng cơ bản)
|
| 223 |
+
text = await loop.run_in_executor(None, lambda: pytesseract.image_to_string(image, lang='vie+eng'))
|
| 224 |
+
return f"**[Lưu ý: Nội dung này được trích xuất bằng OCR dự phòng do Gemini chặn bản quyền]**\n\n{text}"
|
| 225 |
+
except Exception as e:
|
| 226 |
+
print(f"Tesseract Error: {e}")
|
| 227 |
+
return "**[Lỗi] Cả Gemini và Tesseract đều thất bại.**"
|
| 228 |
+
|
| 229 |
# ===== API ENDPOINTS =====
|
| 230 |
|
| 231 |
@app.get("/")
|
| 232 |
@app.get("/health")
|
| 233 |
async def root():
|
| 234 |
pandoc_status = "Not Found"
|
| 235 |
+
tesseract_status = "Not Found"
|
| 236 |
try:
|
| 237 |
pandoc_status = pypandoc.get_pandoc_version()
|
| 238 |
+
except: pass
|
| 239 |
+
|
| 240 |
+
try:
|
| 241 |
+
if pytesseract: tesseract_status = "Ready"
|
| 242 |
+
except: pass
|
| 243 |
+
|
| 244 |
return {
|
| 245 |
"status": "ok",
|
| 246 |
+
"service": "HT_MATH_WEB API v9.0 (Safe Mode)",
|
| 247 |
"keys_loaded": key_manager.get_key_count(),
|
| 248 |
+
"pandoc": pandoc_status,
|
| 249 |
+
"tesseract": tesseract_status
|
| 250 |
}
|
| 251 |
|
| 252 |
@app.get("/api/models")
|
|
|
|
| 307 |
# --- CORE CONVERT LOGIC ---
|
| 308 |
|
| 309 |
async def process_image_with_gemini(image: Image.Image, model_id: str, prompt: str, max_retries: int = 3) -> str:
|
| 310 |
+
"""Gửi 1 ảnh (hoặc mảnh ảnh) lên Gemini và nhận text, có fallback"""
|
| 311 |
for attempt in range(max_retries):
|
| 312 |
try:
|
| 313 |
api_key = key_manager.get_next_key()
|
| 314 |
if not api_key: raise ValueError("No API Key")
|
| 315 |
genai.configure(api_key=api_key)
|
| 316 |
|
| 317 |
+
generation_config = {"temperature": 0.2, "top_p": 1.0, "max_output_tokens": 8192}
|
| 318 |
model = genai.GenerativeModel(model_id, generation_config=generation_config)
|
| 319 |
+
|
| 320 |
+
# Gọi API
|
| 321 |
response = model.generate_content([prompt, image])
|
| 322 |
|
| 323 |
+
# Lấy text an toàn
|
| 324 |
+
text = safe_get_text(response)
|
| 325 |
+
|
| 326 |
+
# XỬ LÝ LỖI BẢN QUYỀN -> FALLBACK
|
| 327 |
+
if text == "[BLOCKED_BY_COPYRIGHT]":
|
| 328 |
+
print(f"Warning: Gemini blocked copyright content. Switching to fallback OCR...")
|
| 329 |
+
return await fallback_ocr_tesseract(image)
|
| 330 |
+
|
| 331 |
+
if text:
|
| 332 |
+
return text.strip()
|
| 333 |
+
|
| 334 |
except Exception as e:
|
| 335 |
if "429" in str(e) and attempt < max_retries - 1:
|
| 336 |
time.sleep(2)
|
| 337 |
continue
|
| 338 |
+
if attempt == max_retries - 1:
|
| 339 |
+
print(f"Error Gemini: {e}")
|
| 340 |
+
# Nếu lỗi mạng/quota quá nhiều, cũng fallback luôn cho chắc
|
| 341 |
+
return await fallback_ocr_tesseract(image)
|
| 342 |
+
|
| 343 |
return ""
|
| 344 |
|
| 345 |
async def process_large_image(image: Image.Image, model: str, prompt: str, semaphore: asyncio.Semaphore) -> str:
|
| 346 |
"""
|
| 347 |
+
Xử lý ảnh lớn: Cắt -> Gửi (kèm fallback) -> Ghép
|
| 348 |
"""
|
| 349 |
+
CHUNK_HEIGHT = 2048
|
| 350 |
+
OVERLAP_HEIGHT = 512
|
|
|
|
| 351 |
|
| 352 |
width, height = image.size
|
| 353 |
|
|
|
|
| 354 |
if height <= CHUNK_HEIGHT:
|
| 355 |
async with semaphore:
|
| 356 |
return await process_image_with_gemini(image, model, prompt)
|
| 357 |
|
| 358 |
+
# Cắt ảnh
|
| 359 |
chunks = []
|
| 360 |
y = 0
|
| 361 |
while y < height:
|
|
|
|
| 362 |
bottom = min(y + CHUNK_HEIGHT, height)
|
| 363 |
box = (0, y, width, bottom)
|
| 364 |
chunk = image.crop(box)
|
| 365 |
chunks.append(chunk)
|
| 366 |
+
if bottom == height: break
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 367 |
y += (CHUNK_HEIGHT - OVERLAP_HEIGHT)
|
| 368 |
|
| 369 |
+
print(f"[Split] Image height {height}px -> {len(chunks)} chunks.")
|
| 370 |
|
|
|
|
| 371 |
async def process_chunk(chunk_img, index):
|
| 372 |
async with semaphore:
|
| 373 |
text = await process_image_with_gemini(chunk_img, model, prompt)
|
|
|
|
| 376 |
tasks = [process_chunk(chunk, i) for i, chunk in enumerate(chunks)]
|
| 377 |
chunk_results = await asyncio.gather(*tasks)
|
| 378 |
|
|
|
|
| 379 |
chunk_results.sort(key=lambda x: x[0])
|
| 380 |
ordered_texts = [text for _, text in chunk_results]
|
| 381 |
|
|
|
|
| 382 |
final_text = ordered_texts[0]
|
| 383 |
for i in range(1, len(ordered_texts)):
|
| 384 |
final_text = stitch_text(final_text, ordered_texts[i], min_overlap_chars=20)
|
|
|
|
| 402 |
file_content = await file.read()
|
| 403 |
file_ext = os.path.splitext(file.filename)[1].lower()
|
| 404 |
|
|
|
|
|
|
|
| 405 |
global_semaphore = asyncio.Semaphore(MAX_THREADS)
|
| 406 |
|
| 407 |
results = []
|
| 408 |
|
| 409 |
if file_ext == ".pdf":
|
| 410 |
doc = fitz.open(stream=file_content, filetype="pdf")
|
|
|
|
|
|
|
| 411 |
async def process_page_wrapper(page, idx):
|
|
|
|
| 412 |
pix = page.get_pixmap(dpi=300)
|
| 413 |
img = Image.open(io.BytesIO(pix.tobytes("png")))
|
|
|
|
| 414 |
text = await process_large_image(img, model, prompt, global_semaphore)
|
| 415 |
return idx, text
|
| 416 |
|
|
|
|
| 421 |
|
| 422 |
elif file_ext in [".png", ".jpg", ".jpeg", ".bmp"]:
|
| 423 |
img = Image.open(io.BytesIO(file_content))
|
|
|
|
| 424 |
text = await process_large_image(img, model, prompt, global_semaphore)
|
| 425 |
results.append(text)
|
| 426 |
else:
|
|
|
|
| 434 |
traceback.print_exc()
|
| 435 |
raise HTTPException(status_code=500, detail=str(e))
|
| 436 |
|
|
|
|
| 437 |
@app.post("/api/export-docx")
|
| 438 |
async def export_docx(markdown_text: str = Form(...)):
|
| 439 |
try:
|
|
|
|
| 451 |
return FileResponse(
|
| 452 |
output_filename,
|
| 453 |
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
| 454 |
+
filename="HT_MATH_OUTPUT.docx"
|
| 455 |
)
|
| 456 |
except Exception as e:
|
| 457 |
import traceback
|