File size: 12,009 Bytes
4fbda48
 
 
 
 
daf2c98
4fbda48
 
 
 
 
 
 
 
 
1d7f30d
4fbda48
 
1d7f30d
4fbda48
 
1d7f30d
4fbda48
 
 
 
 
1d7f30d
4fbda48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
daf2c98
4fbda48
 
 
 
 
daf2c98
4fbda48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1d7f30d
daf2c98
4fbda48
1d7f30d
 
4fbda48
1d7f30d
 
 
 
daf2c98
4fbda48
 
 
 
 
b419497
4fbda48
 
 
 
 
 
 
daf2c98
4fbda48
 
 
daf2c98
1d7f30d
 
 
 
daf2c98
1d7f30d
 
4fbda48
 
 
 
1d7f30d
 
 
4fbda48
 
 
 
daf2c98
4fbda48
 
 
daf2c98
1d7f30d
4fbda48
 
 
 
 
daf2c98
b419497
1d7f30d
 
 
4fbda48
1d7f30d
daf2c98
1d7f30d
4fbda48
 
 
 
1d7f30d
4fbda48
daf2c98
 
1d7f30d
daf2c98
1d7f30d
daf2c98
 
 
1d7f30d
daf2c98
1d7f30d
daf2c98
 
4fbda48
1d7f30d
 
 
 
4fbda48
1d7f30d
 
4fbda48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1d7f30d
4fbda48
 
 
 
 
 
 
 
 
 
 
1d7f30d
4fbda48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
daf2c98
1d7f30d
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
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
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
"""
Backend API HỢP NHẤT (Web + Desktop) - HT MATH V6
Chạy trên Hugging Face Spaces (Docker Version)
"""

import os
import io
import time
import asyncio
import re
import tempfile
import hashlib
import secrets
import uuid
import math
import base64
import random
from typing import List, Optional

# --- THƯ VIỆN CHÍNH ---
from fastapi import FastAPI, File, UploadFile, HTTPException, Form, Request, Body
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel # Thêm Pydantic cho Desktop App

# --- THƯ VIỆN XỬ LÝ ẢNH & AI ---
from PIL import Image
import fitz  # PyMuPDF
import google.generativeai as genai

# --- PANDOC IMPORT ---
try:
    import pypandoc
    print(f"INFO: Pandoc version detected: {pypandoc.get_pandoc_version()}")
except ImportError:
    print("CRITICAL WARNING: pypandoc module not found.")
except OSError:
    print("CRITICAL WARNING: pandoc binary not found in system path.")

# --- SUPABASE ---
try:
    from supabase import create_client, Client
    SUPABASE_AVAILABLE = True
except ImportError:
    SUPABASE_AVAILABLE = False
    Client = None
    create_client = None

# ===== CẤU HÌNH =====
# Load biến môi trường
GEMINI_API_KEYS = os.getenv("GEMINI_API_KEYS", "").split(",")
# Lọc bỏ key rỗng
GEMINI_API_KEYS = [k.strip() for k in GEMINI_API_KEYS if k.strip()]

GEMINI_MODELS = os.getenv("GEMINI_MODELS", "gemini-2.5-flash,gemini-1.5-pro").split(",")
GEMINI_MODELS = [m.strip() for m in GEMINI_MODELS if m.strip()]

SUPABASE_URL = os.getenv("SUPABASE_URL")
SUPABASE_KEY = os.getenv("SUPABASE_KEY")

# Khởi tạo Supabase
supabase: Optional[Client] = None
if SUPABASE_AVAILABLE and SUPABASE_URL and SUPABASE_KEY:
    try:
        supabase = create_client(SUPABASE_URL, SUPABASE_KEY)
        print("INFO: Supabase connected successfully.")
    except Exception as e:
        print(f"ERROR: Failed to connect to Supabase: {e}")

# Khởi tạo FastAPI
app = FastAPI(title="HT MATH UNIFIED SERVER")

# Cấu hình CORS (Cho phép cả Web và Desktop App)
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# --- HELPER FUNCTIONS ---
def get_random_api_key():
    if not GEMINI_API_KEYS:
        raise HTTPException(status_code=500, detail="Server chưa cấu hình GEMINI_API_KEYS")
    return secrets.choice(GEMINI_API_KEYS)

def clean_latex_formulas(text):
    """Làm sạch và chuẩn hóa LaTeX"""
    text = re.sub(r'\\\(', '$', text)
    text = re.sub(r'\\\)', '$', text)
    text = re.sub(r'\\\[', '$$', text)
    text = re.sub(r'\\\]', '$$', text)
    return text

# ==============================================================================
# PHẦN 1: API DÀNH RIÊNG CHO DESKTOP APP (HT MATH V6 CLIENT)
# ==============================================================================

class DesktopGenerateRequest(BaseModel):
    prompt: str
    model: Optional[str] = "gemini-1.5-flash"
    image: Optional[str] = None # Base64 string

@app.get("/api/models")
async def get_models_desktop():
    """API trả về danh sách model cho Desktop App cập nhật vào ComboBox"""
    # Nếu danh sách rỗng, trả về default để tránh lỗi app
    models = GEMINI_MODELS if GEMINI_MODELS else ["gemini-1.5-flash"]
    return {"models": models}

@app.post("/api/generate")
async def generate_content_desktop(req: DesktopGenerateRequest):
    """
    API xử lý AI cho Desktop App.
    Khác với Web App (nhận Multipart), API này nhận JSON chứa Base64 image.
    """
    try:
        # 1. Chọn Key ngẫu nhiên (Load Balancing)
        api_key = get_random_api_key()
        genai.configure(api_key=api_key)
        
        # 2. Chọn Model
        # Nếu model client gửi lên không có trong danh sách hỗ trợ, dùng model đầu tiên
        model_name = req.model
        if model_name not in GEMINI_MODELS and GEMINI_MODELS:
            model_name = GEMINI_MODELS[0]
            
        model = genai.GenerativeModel(model_name)

        # 3. Chuẩn bị nội dung gửi đi
        content_parts = [req.prompt]

        # 4. Xử lý ảnh (Base64 -> Image)
        if req.image:
            try:
                # Desktop App gửi ảnh dạng Base64 string
                # Cần xử lý trường hợp có prefix data:image/...;base64,
                if "," in req.image:
                    req.image = req.image.split(",")[1]
                
                image_bytes = base64.b64decode(req.image)
                image = Image.open(io.BytesIO(image_bytes))
                content_parts.append(image)
            except Exception as e:
                raise HTTPException(status_code=400, detail=f"Lỗi xử lý ảnh base64: {str(e)}")

        # 5. Gọi Google Gemini
        response = model.generate_content(content_parts)
        
        if response.text:
            return {"result": response.text}
        else:
            raise HTTPException(status_code=500, detail="Gemini không trả về nội dung text.")

    except Exception as e:
        print(f"Error Desktop API: {e}")
        raise HTTPException(status_code=500, detail=str(e))


# ==============================================================================
# PHẦN 2: API DÀNH CHO WEB APP (HT MATH WEB V6)
# ==============================================================================

# --- AUTHENTICATION (WEB) ---
@app.post("/api/auth/register")
async def register(request: Request):
    if not supabase:
        raise HTTPException(status_code=503, detail="Database service unavailable")
    
    data = await request.json()
    email = data.get("email")
    password = data.get("password")
    full_name = data.get("full_name")

    if not email or not password:
        raise HTTPException(status_code=400, detail="Vui lòng nhập Email và Mật khẩu")

    try:
        # 1. Đăng ký Auth user
        auth_res = supabase.auth.sign_up({
            "email": email,
            "password": password,
            "options": {"data": {"full_name": full_name}}
        })

        if not auth_res.user:
             raise HTTPException(status_code=400, detail="Đăng ký thất bại (Auth)")

        # 2. Lưu vào bảng users (public)
        user_data = {
            "id": auth_res.user.id,
            "email": email,
            "full_name": full_name,
            "role": "user",
            "created_at": "now()"
        }
        supabase.table("users").insert(user_data).execute()

        return {"success": True, "message": "Đăng ký thành công! Vui lòng kiểm tra email xác nhận."}

    except Exception as e:
        print(f"Register Error: {str(e)}")
        # Xử lý lỗi Supabase trả về
        msg = str(e)
        if "User already registered" in msg:
             raise HTTPException(status_code=400, detail="Email này đã được đăng ký.")
        raise HTTPException(status_code=500, detail=f"Lỗi đăng ký: {msg}")

@app.post("/api/auth/login")
async def login(request: Request):
    if not supabase:
        raise HTTPException(status_code=503, detail="Database service unavailable")

    data = await request.json()
    email = data.get("email")
    password = data.get("password")

    try:
        res = supabase.auth.sign_in_with_password({"email": email, "password": password})
        if res.user:
            # Lấy thông tin role từ bảng users
            user_info = supabase.table("users").select("*").eq("id", res.user.id).execute()
            role = "user"
            full_name = ""
            if user_info.data:
                role = user_info.data[0].get("role", "user")
                full_name = user_info.data[0].get("full_name", "")

            return {
                "success": True,
                "access_token": res.session.access_token,
                "user": {
                    "id": res.user.id,
                    "email": res.user.email,
                    "role": role,
                    "full_name": full_name
                }
            }
        raise HTTPException(status_code=401, detail="Email hoặc mật khẩu không đúng")
    except Exception as e:
        raise HTTPException(status_code=401, detail=str(e))

# --- IMAGE PROCESSING UTILS (WEB) ---
async def process_large_image(image: Image.Image, model_name: str, prompt: str, semaphore: asyncio.Semaphore) -> str:
    """Xử lý ảnh lớn bằng cách cắt nhỏ (Overlap Stitching)"""
    width, height = image.size
    
    # Nếu ảnh nhỏ, xử lý trực tiếp
    if height < 2000:
        async with semaphore:
             return await call_gemini_vision(image, model_name, prompt)

    # Cấu hình cắt ảnh
    segment_height = 1500
    overlap = 300
    segments = []
    
    for y in range(0, height, segment_height - overlap):
        box = (0, y, width, min(y + segment_height, height))
        segment = image.crop(box)
        segments.append(segment)
        if y + segment_height >= height:
            break

    # Gọi API song song cho các phần
    tasks = []
    for seg in segments:
        tasks.append(call_gemini_vision(seg, model_name, prompt))
    
    results = await asyncio.gather(*tasks)
    return "\n".join(results) # Ghép kết quả đơn giản

async def call_gemini_vision(image: Image.Image, model_name: str, prompt: str) -> str:
    """Hàm wrapper gọi Gemini Vision"""
    try:
        api_key = get_random_api_key()
        genai.configure(api_key=api_key)
        model = genai.GenerativeModel(model_name)
        response = await model.generate_content_async([prompt, image])
        return response.text if response.text else ""
    except Exception as e:
        print(f"Gemini Error: {e}")
        return ""

# --- MAIN API: PROCESS IMAGE (WEB) ---
@app.post("/api/process-image")
async def process_image_web(
    file: UploadFile = File(...),
    model: str = Form("gemini-1.5-pro"),
    prompt: str = Form("Hãy chuyển đổi nội dung trong ảnh thành định dạng Markdown LaTeX.")
):
    try:
        contents = await file.read()
        image = Image.open(io.BytesIO(contents))
        
        # Giới hạn số luồng xử lý đồng thời để tránh Rate Limit
        global_semaphore = asyncio.Semaphore(5) 
        
        # Xử lý ảnh
        result_text = await process_large_image(image, model, prompt, global_semaphore)
        
        return {"success": True, "result": clean_latex_formulas(result_text)}

    except Exception as e:
        import traceback
        traceback.print_exc()
        raise HTTPException(status_code=500, detail=str(e))

# --- WORD EXPORT API (PANDOC NATIVE) ---
@app.post("/api/export-docx")
async def export_docx(markdown_text: str = Form(...)):
    try:
        with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as tmp_file:
            output_filename = tmp_file.name

        # Dùng Pypandoc để convert
        pypandoc.convert_text(
            markdown_text,
            to='docx',
            format='markdown',
            outputfile=output_filename,
            extra_args=['--standalone']
        )

        return FileResponse(
            output_filename,
            media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
            filename="Ket_qua_HT_MATH.docx"
        )
    except Exception as e:
        import traceback
        traceback.print_exc()
        raise HTTPException(status_code=500, detail=f"Lỗi xuất Word: {str(e)}")

# --- TEST ENDPOINT ---
@app.get("/")
def home():
    return {
        "server": "HT MATH UNIFIED (Web + Desktop)",
        "status": "online", 
        "pandoc": "detected" if 'pypandoc' in globals() else "missing",
        "desktop_api_ready": True
    }

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=7860)