Spaces:
Sleeping
Sleeping
| import os | |
| from dotenv import load_dotenv | |
| load_dotenv() # .env ํ์ผ ์๋ ๋ก๋ | |
| import re | |
| import json | |
| import time | |
| import base64 | |
| import io | |
| import secrets | |
| import hashlib | |
| from datetime import datetime, timedelta, timezone | |
| from pathlib import Path | |
| import httpx | |
| import cv2 | |
| import numpy as np | |
| from PIL import Image, ImageDraw, ImageFont | |
| from json_repair import repair_json | |
| from fastapi import FastAPI, HTTPException, Header | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.staticfiles import StaticFiles | |
| from pydantic import BaseModel | |
| app = FastAPI(title="Dalibaba API") | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_methods=["POST", "GET"], | |
| allow_headers=["*"], | |
| ) | |
| GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "") | |
| GROQ_URL = "https://api.groq.com/openai/v1/chat/completions" | |
| GROQ_MODEL = "meta-llama/llama-4-scout-17b-16e-instruct" | |
| DEEPL_API_KEY = os.environ.get("DEEPL_API_KEY", "") | |
| DEEPL_URL = "https://api-free.deepl.com/v2/translate" | |
| KST = timezone(timedelta(hours=9)) | |
| DATA_DIR = Path(__file__).resolve().parent / "data" | |
| STORE_PATH = DATA_DIR / "store.json" | |
| FREE_DAILY_AI_LIMIT = 3 | |
| PAID_DAILY_AI_LIMIT = 100 | |
| PLANS = [ | |
| {"id": "pass_1d", "kind": "pass", "days": 1, "label": "1์ผ๊ถ", "priceUsd": 1.99}, | |
| {"id": "pass_3d", "kind": "pass", "days": 3, "label": "3์ผ๊ถ", "priceUsd": 3.99}, | |
| {"id": "pass_7d", "kind": "pass", "days": 7, "label": "7์ผ๊ถ", "priceUsd": 7.99}, | |
| {"id": "sub_month", "kind": "subscription", "days": 30, "label": "์ ๊ตฌ๋ ", "priceUsd": 8.99}, | |
| {"id": "sub_year", "kind": "subscription", "days": 365, "label": "์ฐ ๊ตฌ๋ ", "priceUsd": 59.99}, | |
| ] | |
| def _now() -> datetime: | |
| return datetime.now(KST) | |
| def _today_key() -> str: | |
| return _now().strftime("%Y-%m-%d") | |
| DATABASE_URL = os.environ.get("DATABASE_URL", "") | |
| _EMPTY_STORE = {"users": {}, "sessions": {}, "usage": {}} | |
| if DATABASE_URL: | |
| import psycopg | |
| from psycopg.types.json import Json | |
| def _db_conn(): | |
| return psycopg.connect(DATABASE_URL) | |
| def _ensure_table() -> None: | |
| try: | |
| with _db_conn() as conn: | |
| conn.execute("CREATE TABLE IF NOT EXISTS app_store (id INT PRIMARY KEY, data JSONB NOT NULL)") | |
| conn.execute( | |
| "INSERT INTO app_store (id, data) VALUES (1, %s) ON CONFLICT (id) DO NOTHING", | |
| (Json(_EMPTY_STORE),), | |
| ) | |
| conn.commit() | |
| except Exception as e: | |
| print(f"[DB] ์ด๊ธฐํ ์คํจ: {e}") | |
| _ensure_table() | |
| def _load_store() -> dict: | |
| try: | |
| with _db_conn() as conn: | |
| row = conn.execute("SELECT data FROM app_store WHERE id = 1").fetchone() | |
| if row and isinstance(row[0], dict): | |
| data = row[0] | |
| return { | |
| "users": data.get("users", {}), | |
| "sessions": data.get("sessions", {}), | |
| "usage": data.get("usage", {}), | |
| } | |
| except Exception as e: | |
| print(f"[DB] ์กฐํ ์คํจ: {e}") | |
| return {"users": {}, "sessions": {}, "usage": {}} | |
| def _save_store(store: dict) -> None: | |
| try: | |
| with _db_conn() as conn: | |
| conn.execute( | |
| "UPDATE app_store SET data = %s WHERE id = 1", | |
| (Json(store),), | |
| ) | |
| conn.commit() | |
| except Exception as e: | |
| print(f"[DB] ์ ์ฅ ์คํจ: {e}") | |
| else: | |
| def _load_store() -> dict: | |
| if not STORE_PATH.exists(): | |
| return {"users": {}, "sessions": {}, "usage": {}} | |
| try: | |
| return json.loads(STORE_PATH.read_text(encoding="utf-8")) | |
| except Exception: | |
| return {"users": {}, "sessions": {}, "usage": {}} | |
| def _save_store(store: dict) -> None: | |
| DATA_DIR.mkdir(parents=True, exist_ok=True) | |
| STORE_PATH.write_text(json.dumps(store, ensure_ascii=False, indent=2), encoding="utf-8") | |
| def _hash_password(password: str, salt: str | None = None) -> tuple[str, str]: | |
| salt = salt or secrets.token_hex(16) | |
| digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), 120000) | |
| return salt, digest.hex() | |
| def _active_entitlement(user: dict | None) -> dict | None: | |
| if not user: | |
| return None | |
| entitlement = user.get("entitlement") | |
| if not entitlement: | |
| return None | |
| expires_at = entitlement.get("expiresAt") | |
| if not expires_at: | |
| return None | |
| try: | |
| if datetime.fromisoformat(expires_at) > _now(): | |
| return entitlement | |
| except ValueError: | |
| return None | |
| return None | |
| def _public_user(user: dict | None) -> dict | None: | |
| if not user: | |
| return None | |
| entitlement = _active_entitlement(user) | |
| return { | |
| "email": user.get("email"), | |
| "createdAt": user.get("createdAt"), | |
| "entitlement": entitlement, | |
| } | |
| def _get_user_by_token(authorization: str | None) -> tuple[str | None, dict | None, dict]: | |
| store = _load_store() | |
| if not authorization or not authorization.startswith("Bearer "): | |
| return None, None, store | |
| token = authorization.replace("Bearer ", "", 1).strip() | |
| email = store.get("sessions", {}).get(token) | |
| user = store.get("users", {}).get(email or "") | |
| return email, user, store | |
| def _usage_identity(user_email: str | None, guest_id: str | None) -> str: | |
| if user_email: | |
| return f"user:{user_email.lower()}" | |
| guest = (guest_id or "").strip()[:80] | |
| return f"guest:{guest or 'anonymous'}" | |
| def _usage_status(store: dict, identity: str, user: dict | None) -> dict: | |
| entitlement = _active_entitlement(user) | |
| limit = PAID_DAILY_AI_LIMIT if entitlement else FREE_DAILY_AI_LIMIT | |
| date_key = _today_key() | |
| bucket = store.setdefault("usage", {}).setdefault(identity, {}) | |
| used = int(bucket.get(date_key, 0)) | |
| return { | |
| "date": date_key, | |
| "used": used, | |
| "limit": limit, | |
| "remaining": max(0, limit - used), | |
| "plan": entitlement, | |
| } | |
| # โโ ํฐํธ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| FONT_PATHS = [ | |
| "/usr/share/fonts/truetype/nanum/NanumGothic.ttf", | |
| "/usr/share/fonts/nanum/NanumGothic.ttf", | |
| "C:/Windows/Fonts/malgun.ttf", | |
| "C:/Windows/Fonts/NanumGothic.ttf", | |
| "/System/Library/Fonts/AppleSDGothicNeo.ttc", | |
| ] | |
| def get_font(size: int): | |
| for path in FONT_PATHS: | |
| if os.path.exists(path): | |
| try: | |
| return ImageFont.truetype(path, max(10, size)) | |
| except Exception: | |
| pass | |
| return ImageFont.load_default() | |
| # โโ ํ๋กฌํํธ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| PRICE_PROMPT = """์ด ์ด๋ฏธ์ง๋ฅผ ๋ถ์ํ๊ณ menu, receipt, other ์ค ํ๋๋ก ๋ถ๋ฅํ์ธ์. | |
| ๋จผ์ ์ด๋ฏธ์ง ์ข ๋ฅ๋ฅผ ํ๋จํ์ธ์: | |
| - ๋ฉ๋ดํ์ด๋ฉด documentType = "menu" | |
| - ๊ฒฐ์ ์์์ฆ/์ฃผ๋ฌธ ์์์ฆ์ด๋ฉด documentType = "receipt" | |
| - ๊ทธ ์ธ ๋ชจ๋ ์ด๋ฏธ์ง(์๋ฅ ํ, ์๋ด๋ฌธ, ํฌ์ฅ์ง, ํ์งํ, ๋ฌธ์ ๋ฑ)๋ documentType = "other" | |
| other ๋ถ์ ๊ท์น: | |
| - ์ด๋ฏธ์ง์์ ์ฝ์ ์ ์๋ ์๋ฏธ ์๋ ํ ์คํธ๋ฅผ ์์์ ์๋, ์ผ์ชฝ์์ ์ค๋ฅธ์ชฝ ์์๋ก ์ถ์ถํ ๊ฒ | |
| - originalText์๋ ์ด๋ฏธ์ง์ ์ ํ ํ ์คํธ๋ฅผ ๊ธ์์ ์ซ์ ๊ทธ๋๋ก ๋ฃ์ ๊ฒ | |
| - translatedText์๋ originalText๋ฅผ ์์ฐ์ค๋ฌ์ด ํ๊ตญ์ด๋ก ๋ฒ์ญํ ๊ฒ | |
| - ๋ธ๋๋๋ช , ๋ชจ๋ธ๋ช , ์ ํ ์ฝ๋์ฒ๋ผ ๋ฒ์ญํ๋ฉด ์ ๋๋ ๊ณ ์ ๋ช ์ฌ๋ ์๋ฌธ์ ์ ์งํ ๊ฒ | |
| - ๊ฐ์ ๋ฌธ๊ตฌ๋ฅผ ์ค๋ณตํด์ ๋ฃ์ง ๋ง๊ณ , ์ฝ์ ์ ์๋ ๋ด์ฉ์ ์ถ์ธกํ์ง ๋ง ๊ฒ | |
| - other์ธ ๊ฒฝ์ฐ prices๋ ๋น ๋ฐฐ์ด๋ก ๋ฐํํ ๊ฒ | |
| โ ๊ฐ์ฅ ์ค์ํ ๊ท์น โ | |
| ๊ฐ ๋ฉ๋ด ํญ๋ชฉ๋ง๋ค ๋ ๊ฐ์ง ์ด๋ฆ์ ๋ฐ๋ก ์ ์ด์ผ ํฉ๋๋ค: | |
| 1. originalText = ์ด๋ฏธ์ง์ ์ค์ ๋ก ์ธ์๋ ๊ธ์ ๊ทธ๋๋ก (์ผ๋ณธ์ด๋ฉด ์ผ๋ณธ์ด, ์์ด๋ฉด ์์ด, ์ ๋ ๋ฒ์ญ ๊ธ์ง) | |
| 2. context = ์ ๋ฉ๋ด๋ฅผ ํ๊ตญ์ด๋ก ๋ฒ์ญํ ์ด๋ฆ | |
| ์์ (์ผ๋ณธ์ด ๋ฉ๋ดํ): | |
| - originalText: "ๅๆใๅฎ้ฃ" โ context: "๋ญ ํ๊น ์ ์" | |
| - originalText: "็ใใผใซ" โ context: "์๋งฅ์ฃผ" | |
| - originalText: "ๆตท่ๅคฉไธผ" โ context: "์์ฐํ๊น ๋ฎ๋ฐฅ" | |
| โ ์ฌ์ด์ฆ/์ต์ ๋ณํ ์ฒ๋ฆฌ ๊ท์น โ | |
| ํ๋์ ๋ฉ๋ด์ ๅฐ/ไธญ/ๅคง, S/M/L, ํํ/ํ ๋ฑ ํฌ๊ธฐ๋ ์ต์ ๋ณ๋ก ๊ฐ๊ฒฉ์ด ๋ค๋ฅธ ๊ฒฝ์ฐ: | |
| โ ๊ฐ๊ฐ์ ๋ณ๋ ํญ๋ชฉ์ผ๋ก ๋ถ๋ฆฌํ ๊ฒ | |
| ์์: | |
| - originalText: "็ๆใซใซใ ๅฐ" โ context: "์์ฑ ๊ฐ๋น (์)" / amount: 1500 | |
| - originalText: "็ๆใซใซใ ไธญ" โ context: "์์ฑ ๊ฐ๋น (์ค)" / amount: 2500 | |
| ์ฃผ์์ฌํญ: | |
| - ์ด๋ฏธ์ง๋ฅผ ์์์ ์๋, ์ผ์ชฝ์์ ์ค๋ฅธ์ชฝ์ผ๋ก ๋น ์ง์์ด ์ค์บํ ๊ฒ | |
| - ๊ฐ๊ฒฉ์ด ์๋ ํญ๋ชฉ์ ๋ชจ๋ ํฌํจํ ๊ฒ (์ฌ์ด์ฆ ๋ณํ ํฌํจ) | |
| - ๋ง์ง๋ง์ ์ ์ฒด ํญ๋ชฉ ์๋ฅผ ๋ค์ ์ธ์ด ๋๋ฝ์ด ์๋์ง ํ์ธํ ๊ฒ | |
| ์์์ฆ ๋ถ์ ๊ท์น: | |
| - ์์์ฆ์ด๋ฉด ์ฃผ๋ฌธ ํญ๋ชฉ๋ง prices์ ๋ฃ๊ณ ์ธ๊ธ, ํฉ๊ณ, ํ ์ธ, ์นด๋ ์น์ธ๋ฒํธ, ์ฌ์ ์๋ฒํธ, ๋ ์ง๋ ์ ์ธํ ๊ฒ | |
| - ์๋์ด ๋ณด์ด๋ฉด qty์ ์ซ์๋ก ๋ฃ์ ๊ฒ. ์๋์ด ์์ผ๋ฉด qty: 1 | |
| - ํญ๋ชฉ๋ณ ๊ธ์ก์ด ์ด์ก์ด๊ณ ์๋์ด 2 ์ด์์ด๋ฉด amount๋ 1๊ฐ๋น ๋จ๊ฐ๋ก ๊ณ์ฐํ ๊ฒ | |
| - ๋จ๊ฐ๋ฅผ ์ ์ ์๊ณ ์ค ์ด์ก๋ง ์์ผ๋ฉด amount๋ ์ค ์ด์ก, qty๋ 1๋ก ์ฒ๋ฆฌํ ๊ฒ | |
| - ์์์ฆ์์๋ ์ด๋ฏธ ์ฃผ๋ฌธ๋ ํญ๋ชฉ์ด๋ฏ๋ก ํ๋ก ํธ๊ฐ ๋ฐ๋ก ์ฃผ๋ฌธ์๋ก ์ ์ฅํ ์ ์๊ฒ qty๋ฅผ ๋ฐ๋์ ํฌํจํ ๊ฒ | |
| ๋ฐ๋์ ๋ค์ JSON ํ์์ผ๋ก๋ง ์๋ตํ์ธ์ (JSON ์ธ ๋ค๋ฅธ ํ ์คํธ ์์): | |
| { | |
| "documentType": "menu ๋๋ receipt ๋๋ other", | |
| "detectedLanguage": "๊ฐ์ง๋ ์ธ์ด ์ด๋ฆ (์: ์ผ๋ณธ์ด, ์์ด, ํ๊ตญ์ด)", | |
| "detectedCurrency": "์ฃผ์ ํตํ ISO ์ฝ๋ (USD/EUR/JPY/CNY/THB/VND/MYR/IDR/PHP/SGD/HKD/AUD/GBP/CAD ๋๋ null)", | |
| "textBlocks": [ | |
| { | |
| "originalText": "์ด๋ฏธ์ง์ ์ ํ ์๋ฌธ ๊ทธ๋๋ก", | |
| "translatedText": "์์ฐ์ค๋ฌ์ด ํ๊ตญ์ด ๋ฒ์ญ" | |
| } | |
| ], | |
| "prices": [ | |
| { | |
| "amount": ์ซ์๊ฐ๋ง, | |
| "currency": "ISO ํตํ ์ฝ๋", | |
| "qty": ์๋ ์ซ์๊ฐ, | |
| "originalText": "์ด๋ฏธ์ง์ ์ธ์๋ ์๋ณธ ํ ์คํธ (๋ฒ์ญ ์ ๋ ๊ธ์ง, ์๋ณธ ์ธ์ด ๊ทธ๋๋ก)", | |
| "context": "ํ๊ตญ์ด ๋ฒ์ญ๋ช ", | |
| "category": "food/drink/alcohol/dessert/side/other ์ค ํ๋", | |
| "x": ํ ์คํธ_์ค์ฌ_๊ฐ๋ก์์น_ํผ์ผํธ(0~100), | |
| "y": ํ ์คํธ_์ค์ฌ_์ธ๋ก์์น_ํผ์ผํธ(0~100) | |
| } | |
| ] | |
| } | |
| menu ๋๋ receipt์ธ ๊ฒฝ์ฐ textBlocks๋ ๋น ๋ฐฐ์ด๋ก ๋ฐํํ์ธ์. | |
| context ๋ฒ์ญ ๊ท์น: | |
| - ์์ด ๋ฐ์์ ํ๊ธ๋ก ์์ฐจ ๊ธ์ง: โ "์นด๋ผ์๊ฒ" โ "๋ญ ํ๊น" | |
| - ใใฎใโ๋ฒ์ฏ, ๅต/ใใพใโ๋ฌ๊ฑ, ้ถโ๋ญ, ๆตท่/ใใณโ์์ฐ, ่ฑโ๋ผ์ง, ็โ์ | |
| - ๅๆใโ๋ญ ํ๊น, ๅคฉใทใโํ๊น, ๅบ่บซโํ, ใใฃใผใใณโ๋ณถ์๋ฐฅ | |
| - ํ๊ตญ์์ ์ฐ๋ ์ธ๋์ด๋ ๊ทธ๋๋ก (ํผ์, ํ์คํ, ์นด๋ , ์คํ ์ดํฌ, ๋ผ๋ฉ) | |
| - ๊ฐ๊ฒฉ ์์ผ๋ฉด prices: []""" | |
| OCR_PROMPT = """์ด ์ด๋ฏธ์ง์์ ํ ์คํธ๋ฅผ ๋ชจ๋ ์ฐพ์์ฃผ์ธ์. ๋ฒ์ญํ์ง ๋ง๊ณ ์๋ณธ ๊ทธ๋๋ก ์ถ์ถํ์ธ์. | |
| ๋ฐ๋์ ์๋ JSON ํ์์ผ๋ก๋ง ์๋ต (JSON ์ธ ํ ์คํธ ์ ๋ ์์): | |
| { | |
| "blocks": [ | |
| { | |
| "text": "์๋ณธ ํ ์คํธ", | |
| "x1": 10, "y1": 5, "x2": 60, "y2": 15, | |
| "dark_bg": false | |
| } | |
| ] | |
| } | |
| ๊ท์น: | |
| - x1,y1: ํ ์คํธ ๋ธ๋ก ์ข์๋จ / x2,y2: ์ฐํ๋จ (์ด๋ฏธ์ง ๊ธฐ์ค 0~100 ํผ์ผํธ) | |
| - ๊ฐ ํ ์คํธ ์ค์ ๊ฐ๋ณ ๋ธ๋ก์ผ๋ก ์์ฑ | |
| - dark_bg: ๋ฐฐ๊ฒฝ์ด ์ด๋์ฐ๋ฉด true, ๋ฐ์ผ๋ฉด false | |
| - ๊ฐ๊ฒฉยท์ซ์๋ ๊ทธ๋๋ก ์ ์ง | |
| - ์ฅ์์ฉ ๊ธฐํธยทํ ๋๋ฆฌ ๋ฑ ์๋ฏธ ์๋ ์์๋ ์ ์ธ | |
| - blocks ์ต๋ 40๊ฐ""" | |
| async def translate_with_deepl(texts: list) -> list: | |
| """DeepL๋ก ํ ์คํธ ๋ชฉ๋ก์ ํ๊ตญ์ด๋ก ๋ฒ์ญ""" | |
| if not texts or not DEEPL_API_KEY: | |
| return texts | |
| try: | |
| async with httpx.AsyncClient(timeout=10.0) as client: | |
| resp = await client.post( | |
| DEEPL_URL, | |
| headers={"Authorization": f"DeepL-Auth-Key {DEEPL_API_KEY}", | |
| "Content-Type": "application/json"}, | |
| json={"text": texts, "target_lang": "KO"}, | |
| ) | |
| if resp.is_success: | |
| return [t["text"] for t in resp.json().get("translations", [])] | |
| print(f"[DeepL] ์ค๋ฅ {resp.status_code}: {resp.text[:200]}") | |
| except Exception as e: | |
| print(f"[DeepL] ์์ธ: {e}") | |
| return texts | |
| class AnalyzeRequest(BaseModel): | |
| image_base64: str | |
| image_type: str = "image/jpeg" | |
| class AuthRequest(BaseModel): | |
| email: str | |
| password: str | |
| class PurchaseRequest(BaseModel): | |
| planId: str | |
| async def plans(): | |
| return {"plans": PLANS} | |
| async def register(req: AuthRequest): | |
| email = req.email.strip().lower() | |
| if not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", email): | |
| raise HTTPException(400, "์ด๋ฉ์ผ ํ์์ด ์ฌ๋ฐ๋ฅด์ง ์์ต๋๋ค.") | |
| if len(req.password) < 6: | |
| raise HTTPException(400, "๋น๋ฐ๋ฒํธ๋ 6์ ์ด์์ด์ด์ผ ํฉ๋๋ค.") | |
| store = _load_store() | |
| if email in store["users"]: | |
| raise HTTPException(409, "์ด๋ฏธ ๊ฐ์ ๋ ์ด๋ฉ์ผ์ ๋๋ค.") | |
| salt, password_hash = _hash_password(req.password) | |
| store["users"][email] = { | |
| "email": email, | |
| "salt": salt, | |
| "passwordHash": password_hash, | |
| "createdAt": _now().isoformat(), | |
| "entitlement": None, | |
| } | |
| token = secrets.token_urlsafe(32) | |
| store["sessions"][token] = email | |
| _save_store(store) | |
| return {"token": token, "user": _public_user(store["users"][email])} | |
| async def login(req: AuthRequest): | |
| email = req.email.strip().lower() | |
| store = _load_store() | |
| user = store["users"].get(email) | |
| if not user: | |
| raise HTTPException(401, "์ด๋ฉ์ผ ๋๋ ๋น๋ฐ๋ฒํธ๊ฐ ์ฌ๋ฐ๋ฅด์ง ์์ต๋๋ค.") | |
| _, password_hash = _hash_password(req.password, user["salt"]) | |
| if password_hash != user["passwordHash"]: | |
| raise HTTPException(401, "์ด๋ฉ์ผ ๋๋ ๋น๋ฐ๋ฒํธ๊ฐ ์ฌ๋ฐ๋ฅด์ง ์์ต๋๋ค.") | |
| token = secrets.token_urlsafe(32) | |
| store["sessions"][token] = email | |
| _save_store(store) | |
| return {"token": token, "user": _public_user(user)} | |
| async def social_login(provider: str): | |
| provider = provider.lower() | |
| if provider not in {"google", "kakao", "naver"}: | |
| raise HTTPException(400, "์ง์ํ์ง ์๋ ๋ก๊ทธ์ธ ์ ๊ณต์์ ๋๋ค.") | |
| raise HTTPException( | |
| 501, | |
| f"{provider} ๋ก๊ทธ์ธ์ ๊ฐ๋ฐ์ ์ฝ์์ client_id, client_secret, redirect URL ์ค์ ํ ์ฐ๊ฒฐํด์ผ ํฉ๋๋ค.", | |
| ) | |
| async def me( | |
| authorization: str | None = Header(default=None), | |
| x_guest_id: str | None = Header(default=None), | |
| ): | |
| email, user, store = _get_user_by_token(authorization) | |
| identity = _usage_identity(email, x_guest_id) | |
| return { | |
| "user": _public_user(user), | |
| "usage": _usage_status(store, identity, user), | |
| "plans": PLANS, | |
| } | |
| async def delete_account(authorization: str | None = Header(default=None)): | |
| email, user, store = _get_user_by_token(authorization) | |
| if not email or not user: | |
| raise HTTPException(401, "๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค.") | |
| store["users"].pop(email, None) | |
| store["sessions"] = {tok: em for tok, em in store.get("sessions", {}).items() if em != email} | |
| store.get("usage", {}).pop(f"user:{email.lower()}", None) | |
| _save_store(store) | |
| return {"ok": True} | |
| # โโ ์ฑ์คํ ์ด/ํ๋ ์ด์คํ ์ด ์์์ฆ ๊ฒ์ฆ โโโโโโโโโโโโโโโโโโโโโโโโโ | |
| APPLE_SHARED_SECRET = os.environ.get("APPLE_SHARED_SECRET", "") | |
| APPLE_VERIFY_URL_PROD = "https://buy.itunes.apple.com/verifyReceipt" | |
| APPLE_VERIFY_URL_SANDBOX = "https://sandbox.itunes.apple.com/verifyReceipt" | |
| GOOGLE_PACKAGE_NAME = os.environ.get("GOOGLE_PACKAGE_NAME", "") | |
| GOOGLE_SERVICE_ACCOUNT_JSON = os.environ.get("GOOGLE_SERVICE_ACCOUNT_JSON", "") | |
| # productId(์คํ ์ด) โ PLANS์ id ๋งคํ | |
| STORE_PRODUCT_TO_PLAN = { | |
| "dalibaba_pass_1d": "pass_1d", | |
| "dalibaba_pass_3d": "pass_3d", | |
| "dalibaba_pass_7d": "pass_7d", | |
| "dalibaba_sub_month": "sub_month", | |
| "dalibaba_sub_year": "sub_year", | |
| } | |
| def _grant_entitlement(user: dict, plan: dict) -> dict: | |
| current = _active_entitlement(user) | |
| start = _now() | |
| if current: | |
| try: | |
| current_expiry = datetime.fromisoformat(current["expiresAt"]) | |
| if current_expiry > start: | |
| start = current_expiry | |
| except ValueError: | |
| pass | |
| expires_at = start + timedelta(days=int(plan["days"])) | |
| user["entitlement"] = { | |
| "planId": plan["id"], | |
| "label": plan["label"], | |
| "kind": plan["kind"], | |
| "expiresAt": expires_at.isoformat(), | |
| } | |
| return user["entitlement"] | |
| async def _verify_apple_receipt(receipt_data: str) -> dict: | |
| if not APPLE_SHARED_SECRET: | |
| raise HTTPException(500, "APPLE_SHARED_SECRET์ด ์ค์ ๋์ง ์์์ต๋๋ค.") | |
| payload = {"receipt-data": receipt_data, "password": APPLE_SHARED_SECRET, "exclude-old-transactions": True} | |
| async with httpx.AsyncClient(timeout=15.0) as client: | |
| resp = await client.post(APPLE_VERIFY_URL_PROD, json=payload) | |
| body = resp.json() | |
| if body.get("status") == 21007: # ์๋๋ฐ์ค ์์์ฆ์ ์ด์ ์๋ฒ๋ก ๋ณด๋ธ ๊ฒฝ์ฐ | |
| resp = await client.post(APPLE_VERIFY_URL_SANDBOX, json=payload) | |
| body = resp.json() | |
| if body.get("status") != 0: | |
| raise HTTPException(400, f"์ ํ ์์์ฆ ๊ฒ์ฆ ์คํจ (status={body.get('status')})") | |
| latest = body.get("latest_receipt_info") or body.get("receipt", {}).get("in_app", []) | |
| if not latest: | |
| raise HTTPException(400, "์์์ฆ์์ ๊ตฌ๋งค ๋ด์ญ์ ์ฐพ์ ์ ์์ต๋๋ค.") | |
| latest.sort(key=lambda x: int(x.get("purchase_date_ms", 0)), reverse=True) | |
| product_id = latest[0].get("product_id") | |
| return {"productId": product_id, "transactionId": latest[0].get("transaction_id")} | |
| async def _verify_google_receipt(product_id: str, purchase_token: str) -> dict: | |
| if not GOOGLE_SERVICE_ACCOUNT_JSON or not GOOGLE_PACKAGE_NAME: | |
| raise HTTPException(500, "GOOGLE_SERVICE_ACCOUNT_JSON / GOOGLE_PACKAGE_NAME์ด ์ค์ ๋์ง ์์์ต๋๋ค.") | |
| try: | |
| from google.oauth2 import service_account | |
| from google.auth.transport.requests import Request as GoogleAuthRequest | |
| except ImportError: | |
| raise HTTPException(500, "google-auth ํจํค์ง๊ฐ ์ค์น๋์ง ์์์ต๋๋ค.") | |
| creds_info = json.loads(GOOGLE_SERVICE_ACCOUNT_JSON) | |
| credentials = service_account.Credentials.from_service_account_info( | |
| creds_info, scopes=["https://www.googleapis.com/auth/androidpublisher"], | |
| ) | |
| credentials.refresh(GoogleAuthRequest()) | |
| is_subscription = product_id.startswith("dalibaba_sub_") | |
| kind = "subscriptions" if is_subscription else "products" | |
| url = ( | |
| f"https://androidpublisher.googleapis.com/androidpublisher/v3/applications/" | |
| f"{GOOGLE_PACKAGE_NAME}/purchases/{kind}/{product_id}/tokens/{purchase_token}" | |
| ) | |
| async with httpx.AsyncClient(timeout=15.0) as client: | |
| resp = await client.get(url, headers={"Authorization": f"Bearer {credentials.token}"}) | |
| if not resp.is_success: | |
| raise HTTPException(400, f"๊ตฌ๊ธ ์์์ฆ ๊ฒ์ฆ ์คํจ: {resp.text[:200]}") | |
| body = resp.json() | |
| if is_subscription: | |
| if body.get("paymentState") not in (1, 2): | |
| raise HTTPException(400, "๊ตฌ๋ ๊ฒฐ์ ๊ฐ ์๋ฃ๋์ง ์์์ต๋๋ค.") | |
| else: | |
| if body.get("purchaseState") != 0: | |
| raise HTTPException(400, "๊ตฌ๋งค๊ฐ ์๋ฃ๋์ง ์์์ต๋๋ค.") | |
| return {"productId": product_id} | |
| class VerifyPurchaseRequest(BaseModel): | |
| platform: str # "ios" | "android" | |
| receiptData: str = "" # iOS: base64 ์์์ฆ | |
| productId: str = "" # Android: ์ํ ID | |
| purchaseToken: str = "" # Android: ๊ตฌ๋งค ํ ํฐ | |
| async def verify_purchase(req: VerifyPurchaseRequest, authorization: str | None = Header(default=None)): | |
| """์ฑ์คํ ์ด/ํ๋ ์ด์คํ ์ด ์ธ์ฑ๊ฒฐ์ ์์์ฆ์ ๊ฒ์ฆํ๊ณ ์ด์ฉ๊ถ์ ์ง๊ธํฉ๋๋ค.""" | |
| email, user, store = _get_user_by_token(authorization) | |
| if not email or not user: | |
| raise HTTPException(401, "๊ตฌ๋งค๋ฅผ ์ ์ฉํ๋ ค๋ฉด ๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค.") | |
| if req.platform == "ios": | |
| info = await _verify_apple_receipt(req.receiptData) | |
| elif req.platform == "android": | |
| info = await _verify_google_receipt(req.productId, req.purchaseToken) | |
| else: | |
| raise HTTPException(400, "platform์ ios ๋๋ android์ฌ์ผ ํฉ๋๋ค.") | |
| plan_id = STORE_PRODUCT_TO_PLAN.get(info["productId"]) | |
| plan = next((item for item in PLANS if item["id"] == plan_id), None) | |
| if not plan: | |
| raise HTTPException(400, f"์ ์ ์๋ ์ํ ID์ ๋๋ค: {info['productId']}") | |
| _grant_entitlement(user, plan) | |
| store["users"][email] = user | |
| _save_store(store) | |
| return {"user": _public_user(user), "plans": PLANS} | |
| async def purchase(req: PurchaseRequest, authorization: str | None = Header(default=None)): | |
| """๊ฐ๋ฐ/ํ ์คํธ์ฉ: ์ค์ ๊ฒฐ์ ์์ด ์ด์ฉ๊ถ์ ์ฆ์ ์ง๊ธ. ์ด์ ๋น๋์์๋ /purchase/verify๋ฅผ ์ฌ์ฉํ ๊ฒ.""" | |
| email, user, store = _get_user_by_token(authorization) | |
| if not email or not user: | |
| raise HTTPException(401, "๊ตฌ๋งคํ๋ ค๋ฉด ๋ก๊ทธ์ธ์ด ํ์ํฉ๋๋ค.") | |
| plan = next((item for item in PLANS if item["id"] == req.planId), None) | |
| if not plan: | |
| raise HTTPException(400, "์ ์ ์๋ ์๊ธ์ ์ ๋๋ค.") | |
| current = _active_entitlement(user) | |
| start = _now() | |
| if current: | |
| try: | |
| current_expiry = datetime.fromisoformat(current["expiresAt"]) | |
| if current_expiry > start: | |
| start = current_expiry | |
| except ValueError: | |
| pass | |
| expires_at = start + timedelta(days=int(plan["days"])) | |
| user["entitlement"] = { | |
| "planId": plan["id"], | |
| "label": plan["label"], | |
| "kind": plan["kind"], | |
| "expiresAt": expires_at.isoformat(), | |
| } | |
| store["users"][email] = user | |
| _save_store(store) | |
| return {"user": _public_user(user), "plans": PLANS} | |
| # โโ ํ์จ ์บ์ (1์๊ฐ) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| _rate_cache: dict = {"rates": {}, "ts": 0} | |
| async def get_usd_rates() -> dict: | |
| if time.time() - _rate_cache["ts"] < 3600 and _rate_cache["rates"]: | |
| return _rate_cache["rates"] | |
| try: | |
| async with httpx.AsyncClient(timeout=10.0) as client: | |
| res = await client.get("https://open.er-api.com/v6/latest/USD") | |
| rates = res.json().get("rates", {}) | |
| if rates: | |
| _rate_cache["rates"] = rates | |
| _rate_cache["ts"] = time.time() | |
| print(f"[ํ์จ] ๊ฐฑ์ ์๋ฃ โ USD/KRW={rates.get('KRW')}") | |
| return rates | |
| except Exception as e: | |
| print(f"[ํ์จ] ์กฐํ ์คํจ: {e}") | |
| return _rate_cache["rates"] | |
| async def fetch_krw_rate(currency: str) -> float | None: | |
| if currency == "KRW": | |
| return 1.0 | |
| rates = await get_usd_rates() | |
| usd_to_krw = rates.get("KRW") | |
| usd_to_cur = rates.get(currency) | |
| if usd_to_krw and usd_to_cur: | |
| return usd_to_krw / usd_to_cur | |
| return None | |
| # โโ ์ด๋ฏธ์ง ๋ ๋๋ง ํฌํผ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| def render_blocks(img_pil: Image.Image, blocks: list) -> Image.Image: | |
| """์ธํ์ธํ ๋ ์ด๋ฏธ์ง ์์ ๋ฒ์ญ๋ฌธ์ ๋ฐํฌ๋ช ๋ฐ์ค๋ก ๋ ๋๋ง""" | |
| iw, ih = img_pil.size | |
| img_arr = np.array(img_pil) | |
| img_rgba = img_pil.convert('RGBA') | |
| overlay = Image.new('RGBA', img_pil.size, (0, 0, 0, 0)) | |
| draw = ImageDraw.Draw(overlay) | |
| for b in blocks: | |
| x1, y1, x2, y2 = b["px"] | |
| bw, bh = x2 - x1, y2 - y1 | |
| text = b["translated"] | |
| # ๋ฐฐ๊ฒฝ ๋ฐ๊ธฐ ์ธก์ โ ๊ธ์์ ์๋ ๊ฒฐ์ | |
| region = img_arr[max(0,y1):min(ih,y2), max(0,x1):min(iw,x2)] | |
| if region.size > 0: | |
| avg = region.mean(axis=(0, 1)) | |
| brightness = 0.299*avg[0] + 0.587*avg[1] + 0.114*avg[2] | |
| dark_bg = brightness < 128 | |
| else: | |
| dark_bg = b.get("dark_bg", False) | |
| text_fill = (255, 255, 255, 255) if dark_bg else (15, 23, 42, 255) | |
| bg_fill = (10, 10, 20, 215) if dark_bg else (255, 255, 255, 215) | |
| # ํฐํธ ํฌ๊ธฐ = ๋ฐ์ค ๋์ด, ๋๋ฌด ๋์ผ๋ฉด ์๋ ์ถ์ | |
| font_size = max(10, bh - 2) | |
| font = get_font(font_size) | |
| while font_size > 10: | |
| try: | |
| tb = draw.textbbox((0, 0), text, font=font) | |
| if (tb[2] - tb[0]) <= bw: | |
| break | |
| except Exception: | |
| break | |
| font_size -= 1 | |
| font = get_font(font_size) | |
| draw.rectangle([x1, y1, x2, y2], fill=bg_fill) | |
| try: | |
| tb = draw.textbbox((0, 0), text, font=font) | |
| tw, th = tb[2] - tb[0], tb[3] - tb[1] | |
| tx = x1 + max(0, (bw - tw) // 2) | |
| ty = y1 + max(0, (bh - th) // 2) | |
| except Exception: | |
| tx, ty = x1 + 2, y1 + 2 | |
| draw.text((tx, ty), text, fill=text_fill, font=font) | |
| return Image.alpha_composite(img_rgba, overlay).convert('RGB') | |
| class ChatMessage(BaseModel): | |
| role: str # "user" | "assistant" | |
| content: str | |
| class ChatRequest(BaseModel): | |
| message: str | |
| history: list[ChatMessage] = [] | |
| menuContext: str = "" # ๋ฉ๋ด ๋ชฉ๋ก ์์ฝ ํ ์คํธ | |
| async def chat( | |
| req: ChatRequest, | |
| authorization: str | None = Header(default=None), | |
| x_guest_id: str | None = Header(default=None), | |
| ): | |
| if not GROQ_API_KEY: | |
| raise HTTPException(500, "์๋ฒ์ GROQ_API_KEY๊ฐ ์ค์ ๋์ง ์์์ต๋๋ค.") | |
| email, user, store = _get_user_by_token(authorization) | |
| identity = _usage_identity(email, x_guest_id) | |
| usage = _usage_status(store, identity, user) | |
| if usage["remaining"] <= 0: | |
| raise HTTPException( | |
| 429, | |
| { | |
| "message": "์ค๋ ๋ฌด๋ฃ AI ์ง๋ฌธ 3ํ๋ฅผ ๋ชจ๋ ์ฌ์ฉํ์ต๋๋ค.", | |
| "usage": usage, | |
| }, | |
| ) | |
| system_prompt = f"""๋น์ ์ ํด์ธ ์ฌํ ์ค์ธ ํ๊ตญ์ธ์ ๋๋ ์น์ ํ ์์ ์ ๋ฌธ๊ฐ AI์์. | |
| ํ์ฌ ๋ฉ๋ดํ์์ ๋ถ์๋ ๋ฉ๋ด ๋ชฉ๋ก์ ๋ค์๊ณผ ๊ฐ์์: | |
| {req.menuContext if req.menuContext else '(๋ฉ๋ด ์ ๋ณด ์์)'} | |
| ์ ๋ฉ๋ด๋ฅผ ์ฐธ๊ณ ํด์ ์ฌ์ฉ์ ์ง๋ฌธ์ ํ๊ตญ์ด๋ก ์น์ ํ๊ณ ๊ฐ๊ฒฐํ๊ฒ ๋ตํด์ฃผ์ธ์. | |
| ์ฌ๋ฃ, ๋ง, ์กฐ๋ฆฌ๋ฒ, ํ๊ตญ ์์๊ณผ์ ๋น๊ต, ์ฃผ๋ฌธ ํ ๋ฑ์ ์์ฐ์ค๋ฝ๊ฒ ์ค๋ช ํด์ฃผ์ธ์. | |
| ๋ชจ๋ฅด๋ ๋ด์ฉ์ ์์งํ๊ฒ ๋ชจ๋ฅธ๋ค๊ณ ๋งํ์ธ์. ๋ต๋ณ์ 3~5๋ฌธ์ฅ ์ด๋ด๋ก.""" | |
| messages = [{"role": "system", "content": system_prompt}] | |
| for m in req.history[-6:]: # ์ต๊ทผ 6๊ฐ๋ง ์ ์ง | |
| messages.append({"role": m.role, "content": m.content}) | |
| messages.append({"role": "user", "content": req.message}) | |
| payload = { | |
| "model": GROQ_MODEL, | |
| "messages": messages, | |
| "max_tokens": 500, | |
| "temperature": 0.7, | |
| } | |
| try: | |
| async with httpx.AsyncClient(timeout=30.0) as client: | |
| resp = await client.post( | |
| GROQ_URL, | |
| headers={"Authorization": f"Bearer {GROQ_API_KEY}", "Content-Type": "application/json"}, | |
| json=payload, | |
| ) | |
| if not resp.is_success: | |
| raise HTTPException(resp.status_code, resp.text[:200]) | |
| answer = resp.json()["choices"][0]["message"]["content"].strip() | |
| date_key = _today_key() | |
| bucket = store.setdefault("usage", {}).setdefault(identity, {}) | |
| bucket[date_key] = int(bucket.get(date_key, 0)) + 1 | |
| _save_store(store) | |
| return {"answer": answer, "usage": _usage_status(store, identity, user)} | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| raise HTTPException(500, str(e)) | |
| class TranslateItemsRequest(BaseModel): | |
| items: list[str] # ํ๊ตญ์ด ๋ฉ๋ด๋ช ๋ชฉ๋ก | |
| target_lang: str = "ja" # ๋ฒ์ญ ๋ชฉํ ์ธ์ด (ISO ์ฝ๋) | |
| lang_name: str = "์ผ๋ณธ์ด" # ์ธ์ด ์ด๋ฆ (ํ๋กฌํํธ์ฉ) | |
| async def translate_items(req: TranslateItemsRequest): | |
| if not GROQ_API_KEY: | |
| raise HTTPException(500, "GROQ_API_KEY๊ฐ ์ค์ ๋์ง ์์์ต๋๋ค.") | |
| items_text = "\n".join(f"{i+1}. {name}" for i, name in enumerate(req.items)) | |
| prompt = f"""์๋ ํ๊ตญ์ด ์์ ๋ฉ๋ด ์ด๋ฆ๋ค์ {req.lang_name}๋ก ๋ฒ์ญํด์ฃผ์ธ์. | |
| ๋ฒ์ญ ์ ์ค์ {req.lang_name} ์๋น์์ ์ฐ๋ ์์ฐ์ค๋ฌ์ด ํํ์ ์ฌ์ฉํ์ธ์. | |
| ๋ฐ๋์ ์๋ JSON ํ์์ผ๋ก๋ง ์๋ตํ์ธ์ (JSON ์ธ ํ ์คํธ ์์): | |
| {{"translations": ["๋ฒ์ญ1", "๋ฒ์ญ2", ...]}} | |
| ๋ฉ๋ด ๋ชฉ๋ก: | |
| {items_text}""" | |
| payload = { | |
| "model": GROQ_MODEL, | |
| "messages": [{"role": "user", "content": prompt}], | |
| "max_tokens": 1000, | |
| "temperature": 0.2, | |
| } | |
| try: | |
| async with httpx.AsyncClient(timeout=30.0) as client: | |
| resp = await client.post( | |
| GROQ_URL, | |
| headers={"Authorization": f"Bearer {GROQ_API_KEY}", "Content-Type": "application/json"}, | |
| json=payload, | |
| ) | |
| if not resp.is_success: | |
| raise HTTPException(resp.status_code, resp.text[:200]) | |
| content = resp.json()["choices"][0]["message"]["content"] | |
| match = re.search(r"\{[\s\S]*\}", content) | |
| if not match: | |
| raise HTTPException(500, "๋ฒ์ญ ๊ฒฐ๊ณผ๋ฅผ ํ์ฑํ ์ ์์ต๋๋ค.") | |
| result = json.loads(repair_json(match.group())) | |
| translations = result.get("translations", req.items) | |
| return {"translations": translations} | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| raise HTTPException(500, str(e)) | |
| class ClientErrorRequest(BaseModel): | |
| message: str | |
| stack: str = "" | |
| url: str = "" | |
| userAgent: str = "" | |
| MAX_CLIENT_ERRORS = 200 | |
| async def report_client_error( | |
| req: ClientErrorRequest, | |
| x_guest_id: str | None = Header(default=None), | |
| ): | |
| """ํ๋ก ํธ์๋ ๋ฐํ์ ์ค๋ฅ๋ฅผ ์์งํด ์๋ฒ store์ ๋ณด๊ด (์ต๊ทผ 200๊ฑด)""" | |
| store = _load_store() | |
| errors = store.setdefault("clientErrors", []) | |
| errors.append({ | |
| "at": _now().isoformat(), | |
| "message": req.message[:500], | |
| "stack": req.stack[:2000], | |
| "url": req.url[:300], | |
| "userAgent": req.userAgent[:300], | |
| "guestId": (x_guest_id or "")[:80], | |
| }) | |
| del errors[:-MAX_CLIENT_ERRORS] | |
| _save_store(store) | |
| return {"ok": True} | |
| async def list_client_errors(authorization: str | None = Header(default=None)): | |
| """์์ง๋ ์ค๋ฅ ๋ก๊ทธ ์กฐํ (๊ด๋ฆฌ์ ํ ํฐ ํ์: ADMIN_TOKEN env)""" | |
| admin_token = os.environ.get("ADMIN_TOKEN", "") | |
| token = (authorization or "").replace("Bearer ", "", 1).strip() | |
| if not admin_token or token != admin_token: | |
| raise HTTPException(403, "๊ถํ์ด ์์ต๋๋ค.") | |
| store = _load_store() | |
| return {"errors": store.get("clientErrors", [])} | |
| async def health(): | |
| return {"status": "ok", "model": GROQ_MODEL} | |
| async def get_rates(): | |
| rates = await get_usd_rates() | |
| if not rates: | |
| raise HTTPException(503, "ํ์จ ์ ๋ณด๋ฅผ ๊ฐ์ ธ์ฌ ์ ์์ต๋๋ค.") | |
| return {"base": "USD", "rates": rates} | |
| async def analyze(req: AnalyzeRequest): | |
| if not GROQ_API_KEY: | |
| raise HTTPException(500, "์๋ฒ์ GROQ_API_KEY๊ฐ ์ค์ ๋์ง ์์์ต๋๋ค.") | |
| image_url = f"data:{req.image_type};base64,{req.image_base64}" | |
| payload = { | |
| "model": GROQ_MODEL, | |
| "messages": [{ | |
| "role": "user", | |
| "content": [ | |
| {"type": "image_url", "image_url": {"url": image_url}}, | |
| {"type": "text", "text": PRICE_PROMPT}, | |
| ], | |
| }], | |
| "max_tokens": 4000, | |
| "temperature": 0.1, | |
| } | |
| try: | |
| async with httpx.AsyncClient(timeout=60.0) as client: | |
| resp = await client.post( | |
| GROQ_URL, | |
| headers={"Authorization": f"Bearer {GROQ_API_KEY}", "Content-Type": "application/json"}, | |
| json=payload, | |
| ) | |
| print(f"[Groq/analyze] status={resp.status_code}") | |
| if not resp.is_success: | |
| detail = resp.json().get("error", {}).get("message", resp.text[:200]) | |
| raise HTTPException(resp.status_code, detail) | |
| content = resp.json()["choices"][0]["message"]["content"] | |
| match = re.search(r"\{[\s\S]*\}", content) | |
| if not match: | |
| raise HTTPException(500, f"๋ชจ๋ธ์ด JSON์ ๋ฐํํ์ง ์์์ต๋๋ค: {content[:200]}") | |
| result = json.loads(repair_json(match.group())) | |
| document_type = str(result.get("documentType") or "other").lower() | |
| if document_type not in {"menu", "receipt", "other"}: | |
| document_type = "other" | |
| result["documentType"] = document_type | |
| result["prices"] = result.get("prices") if isinstance(result.get("prices"), list) else [] | |
| result["textBlocks"] = result.get("textBlocks") if isinstance(result.get("textBlocks"), list) else [] | |
| if document_type == "other": | |
| result["prices"] = [] | |
| result["detectedCurrency"] = None | |
| result["textBlocks"] = [ | |
| { | |
| "originalText": str(block.get("originalText") or "").strip(), | |
| "translatedText": str(block.get("translatedText") or "").strip(), | |
| } | |
| for block in result["textBlocks"] | |
| if isinstance(block, dict) and str(block.get("originalText") or "").strip() | |
| ] | |
| if result.get("prices"): | |
| currencies = list({p.get("currency") for p in result["prices"] if p.get("currency")}) | |
| rates: dict[str, float | None] = {} | |
| async with httpx.AsyncClient(timeout=5.0) as client: | |
| for cur in currencies: | |
| rates[cur] = await fetch_krw_rate(cur) | |
| for price in result["prices"]: | |
| price["qty"] = max(1, int(float(price.get("qty") or 1))) | |
| rate = rates.get(price.get("currency")) | |
| price["krwAmount"] = round(float(price.get("amount") or 0) * rate) if rate else None | |
| return result | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| print(f"[ERROR/analyze] {e}") | |
| raise HTTPException(500, str(e)) | |
| async def translate_image(req: AnalyzeRequest): | |
| if not GROQ_API_KEY: | |
| raise HTTPException(500, "์๋ฒ์ GROQ_API_KEY๊ฐ ์ค์ ๋์ง ์์์ต๋๋ค.") | |
| try: | |
| # 1. Groq ๋น์ ํธ์ถ โ OCR๋ง (ํ ์คํธ ์์น ๊ฐ์ง) | |
| image_url = f"data:{req.image_type};base64,{req.image_base64}" | |
| payload = { | |
| "model": GROQ_MODEL, | |
| "messages": [{ | |
| "role": "user", | |
| "content": [ | |
| {"type": "image_url", "image_url": {"url": image_url}}, | |
| {"type": "text", "text": OCR_PROMPT}, | |
| ], | |
| }], | |
| "max_tokens": 2000, | |
| "temperature": 0.1, | |
| } | |
| async with httpx.AsyncClient(timeout=60.0) as client: | |
| resp = await client.post( | |
| GROQ_URL, | |
| headers={"Authorization": f"Bearer {GROQ_API_KEY}", "Content-Type": "application/json"}, | |
| json=payload, | |
| ) | |
| print(f"[Groq/OCR] status={resp.status_code}") | |
| if not resp.is_success: | |
| raise HTTPException(resp.status_code, resp.text[:200]) | |
| content = resp.json()["choices"][0]["message"]["content"] | |
| print(f"[Groq/OCR] {content[:300]}") | |
| match = re.search(r"\{[\s\S]*\}", content) | |
| if not match: | |
| raise HTTPException(500, f"๋ชจ๋ธ์ด JSON์ ๋ฐํํ์ง ์์์ต๋๋ค: {content[:200]}") | |
| blocks = json.loads(repair_json(match.group())).get("blocks", []) | |
| if not blocks: | |
| return {"translated_image": None, "message": "๋ฒ์ญํ ํ ์คํธ๋ฅผ ์ฐพ์ง ๋ชปํ์ต๋๋ค."} | |
| # 1-2. DeepL๋ก ์ถ์ถ๋ ํ ์คํธ ๋ฒ์ญ (๋ณ๋ ฌ ์ฒ๋ฆฌ) | |
| original_texts = [b.get("text", "") for b in blocks] | |
| translated_texts = await translate_with_deepl(original_texts) | |
| for b, t in zip(blocks, translated_texts): | |
| b["translated"] = t | |
| # 2. ์ด๋ฏธ์ง ๋์ฝ๋ฉ + ํผ์ผํธ ์ขํ โ ํฝ์ ๋ณํ | |
| img_bytes = base64.b64decode(req.image_base64) | |
| img_pil = Image.open(io.BytesIO(img_bytes)).convert("RGB") | |
| iw, ih = img_pil.size | |
| px_blocks = [] | |
| for b in blocks: | |
| x1 = int(b.get("x1", 0) / 100 * iw) | |
| y1 = int(b.get("y1", 0) / 100 * ih) | |
| x2 = int(b.get("x2", 0) / 100 * iw) | |
| y2 = int(b.get("y2", 0) / 100 * ih) | |
| if x2 - x1 < 4 or y2 - y1 < 4: | |
| continue | |
| translated = b.get("translated") or b.get("text", "") | |
| px_blocks.append({ | |
| "px": (x1, y1, x2, y2), | |
| "translated": translated, | |
| "dark_bg": b.get("dark_bg", False), | |
| }) | |
| if not px_blocks: | |
| return {"translated_image": None, "message": "์ ํจํ ํ ์คํธ ์์ญ์ ์ฐพ์ง ๋ชปํ์ต๋๋ค."} | |
| # 3. OpenCV ์ธํ์ธํ ์ผ๋ก ์๋ณธ ํ ์คํธ ์ ๊ฑฐ | |
| pad = 6 | |
| img_arr = np.ascontiguousarray(np.array(img_pil), dtype=np.uint8) | |
| img_cv = cv2.cvtColor(img_arr, cv2.COLOR_RGB2BGR) | |
| mask = np.zeros((ih, iw), dtype=np.uint8) | |
| for b in px_blocks: | |
| x1, y1, x2, y2 = b["px"] | |
| cv2.rectangle( | |
| mask, | |
| (max(0, x1 - pad), max(0, y1 - pad)), | |
| (min(iw - 1, x2 + pad), min(ih - 1, y2 + pad)), | |
| 255, -1, | |
| ) | |
| try: | |
| inpainted = cv2.inpaint(img_cv, mask, 7, cv2.INPAINT_TELEA) | |
| img_pil = Image.fromarray(cv2.cvtColor(inpainted, cv2.COLOR_BGR2RGB)) | |
| except Exception as cv_err: | |
| print(f"[WARN/inpaint] OpenCV ์คํจ, ์๋ณธ ์ฌ์ฉ: {cv_err}") | |
| img_pil = Image.fromarray(cv2.cvtColor(img_cv, cv2.COLOR_BGR2RGB)) | |
| # 4. ๋ฒ์ญ๋ฌธ ๋ ๋๋ง | |
| img_pil = render_blocks(img_pil, px_blocks) | |
| # 5. JPEG base64 ๋ฐํ | |
| out_buf = io.BytesIO() | |
| img_pil.save(out_buf, format="JPEG", quality=85) | |
| out_b64 = base64.b64encode(out_buf.getvalue()).decode() | |
| return {"translated_image": out_b64} | |
| except HTTPException: | |
| raise | |
| except Exception as e: | |
| print(f"[ERROR/translate] {e}") | |
| raise HTTPException(500, str(e)) | |
| if os.path.isdir("dist"): | |
| app.mount("/", StaticFiles(directory="dist", html=True), name="frontend") | |