Spaces:
Running
Running
Update app.py
Browse files
app.py
CHANGED
|
@@ -1,1702 +1,33 @@
|
|
| 1 |
import os
|
| 2 |
-
import
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
app = FastAPI()
|
| 17 |
-
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
@app.exception_handler(Exception)
|
| 21 |
-
async def global_exception_handler(request, exc):
|
| 22 |
-
"""모든 미처리 예외를 JSON으로 반환 — 'Internal Server Error' 텍스트 방지"""
|
| 23 |
-
import traceback
|
| 24 |
-
traceback.print_exc()
|
| 25 |
-
return JSONResponse(
|
| 26 |
-
status_code=500,
|
| 27 |
-
content={"error": {"message": str(exc)[:200], "type": "server_error"}},
|
| 28 |
-
)
|
| 29 |
-
|
| 30 |
-
GROQ_API_KEY = os.environ.get("GROQ_API_KEY", "")
|
| 31 |
-
FAL_KEY = os.environ.get("FAL_KEY", "")
|
| 32 |
-
ADMIN_EMAIL = "arxivgpt@gmail.com"
|
| 33 |
-
SA_BACKUP_REPO = "ginigen-ai/siteagent-db"
|
| 34 |
-
|
| 35 |
-
# ── 영구 스토리지 DB ────────────────────────────────────────
|
| 36 |
-
import asyncio as _asyncio
|
| 37 |
-
_PERSISTENT_DIR = "/data"
|
| 38 |
-
_LOCAL_DIR = "./data"
|
| 39 |
-
|
| 40 |
-
def _get_db_path():
|
| 41 |
-
if os.path.exists(_PERSISTENT_DIR):
|
| 42 |
-
try:
|
| 43 |
-
tf = os.path.join(_PERSISTENT_DIR, ".write_test")
|
| 44 |
-
with open(tf, "w") as f: f.write("ok")
|
| 45 |
-
os.remove(tf)
|
| 46 |
-
print(f"✅ Persistent storage: {_PERSISTENT_DIR}")
|
| 47 |
-
return os.path.join(_PERSISTENT_DIR, "siteagent.db")
|
| 48 |
-
except: pass
|
| 49 |
-
os.makedirs(_LOCAL_DIR, exist_ok=True)
|
| 50 |
-
print(f"🟡 Local fallback: {_LOCAL_DIR}")
|
| 51 |
-
return os.path.join(_LOCAL_DIR, "siteagent.db")
|
| 52 |
-
|
| 53 |
-
SA_DB_PATH = _get_db_path()
|
| 54 |
-
_db_lock = None
|
| 55 |
-
|
| 56 |
-
def _get_lock():
|
| 57 |
-
global _db_lock
|
| 58 |
-
if _db_lock is None: _db_lock = _asyncio.Lock()
|
| 59 |
-
return _db_lock
|
| 60 |
-
|
| 61 |
-
async def _db_write(sql, params=None):
|
| 62 |
-
"""안전한 DB 쓰기 (락 + 재시도)"""
|
| 63 |
-
lock = _get_lock()
|
| 64 |
-
for attempt in range(5):
|
| 65 |
-
try:
|
| 66 |
-
async with lock:
|
| 67 |
-
async with aiosqlite.connect(SA_DB_PATH, timeout=30.0) as db:
|
| 68 |
-
await db.execute("PRAGMA journal_mode=WAL")
|
| 69 |
-
cursor = await db.execute(sql, params or ())
|
| 70 |
-
await db.commit()
|
| 71 |
-
return cursor
|
| 72 |
-
except Exception as e:
|
| 73 |
-
if "database is locked" in str(e) and attempt < 4:
|
| 74 |
-
await _asyncio.sleep(0.5 * (attempt + 1))
|
| 75 |
-
continue
|
| 76 |
-
raise
|
| 77 |
-
|
| 78 |
-
async def _db_read(sql, params=None):
|
| 79 |
-
"""DB 읽기"""
|
| 80 |
-
async with aiosqlite.connect(SA_DB_PATH, timeout=10.0) as db:
|
| 81 |
-
db.row_factory = aiosqlite.Row
|
| 82 |
-
cursor = await db.execute(sql, params or ())
|
| 83 |
-
return await cursor.fetchall()
|
| 84 |
-
|
| 85 |
-
async def _db_read_one(sql, params=None):
|
| 86 |
-
async with aiosqlite.connect(SA_DB_PATH, timeout=10.0) as db:
|
| 87 |
-
db.row_factory = aiosqlite.Row
|
| 88 |
-
cursor = await db.execute(sql, params or ())
|
| 89 |
-
return await cursor.fetchone()
|
| 90 |
-
|
| 91 |
-
async def _sa_backup_db():
|
| 92 |
-
"""DB를 HF Hub에 백업 (무결성 검사 후)"""
|
| 93 |
-
if not HF_TOKEN or not os.path.exists(SA_DB_PATH): return
|
| 94 |
-
try:
|
| 95 |
-
import sqlite3
|
| 96 |
-
conn = sqlite3.connect(SA_DB_PATH)
|
| 97 |
-
result = conn.execute("PRAGMA integrity_check").fetchone()
|
| 98 |
-
conn.close()
|
| 99 |
-
if result[0] != 'ok':
|
| 100 |
-
print(f"🚨 [SA-backup] DB corrupt — skipped: {result[0][:100]}")
|
| 101 |
return
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
import asyncio
|
| 117 |
-
ts = await asyncio.to_thread(_sync)
|
| 118 |
-
print(f"✅ [SA-backup] Hub backup: {ts}")
|
| 119 |
-
except Exception as e:
|
| 120 |
-
print(f"⚠️ [SA-backup] error: {e}")
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
async def _sa_restore_db():
|
| 124 |
-
"""Hub에서 DB 복원 (시작 시)"""
|
| 125 |
-
if not HF_TOKEN: return False
|
| 126 |
-
if os.path.exists(SA_DB_PATH) and os.path.getsize(SA_DB_PATH) > 0:
|
| 127 |
-
try:
|
| 128 |
-
import sqlite3
|
| 129 |
-
conn = sqlite3.connect(SA_DB_PATH)
|
| 130 |
-
result = conn.execute("PRAGMA integrity_check").fetchone()
|
| 131 |
-
conn.close()
|
| 132 |
-
if result[0] == 'ok':
|
| 133 |
-
print("📍 [SA-restore] Existing DB healthy, skip restore")
|
| 134 |
-
return False
|
| 135 |
-
else:
|
| 136 |
-
print(f"⚠️ [SA-restore] Existing DB corrupt, restoring...")
|
| 137 |
-
os.remove(SA_DB_PATH)
|
| 138 |
-
except:
|
| 139 |
-
os.remove(SA_DB_PATH)
|
| 140 |
-
def _sync():
|
| 141 |
-
from huggingface_hub import HfApi, hf_hub_download
|
| 142 |
-
try:
|
| 143 |
-
api = HfApi(token=HF_TOKEN)
|
| 144 |
-
api.create_repo(repo_id=SA_BACKUP_REPO, repo_type="dataset", private=True, exist_ok=True)
|
| 145 |
-
except: pass
|
| 146 |
-
return hf_hub_download(repo_id=SA_BACKUP_REPO, filename="latest/siteagent.db", repo_type="dataset", token=HF_TOKEN)
|
| 147 |
-
try:
|
| 148 |
-
import asyncio
|
| 149 |
-
downloaded = await asyncio.to_thread(_sync)
|
| 150 |
-
import sqlite3
|
| 151 |
-
conn = sqlite3.connect(downloaded)
|
| 152 |
-
result = conn.execute("PRAGMA integrity_check").fetchone()
|
| 153 |
-
conn.close()
|
| 154 |
-
if result[0] != 'ok':
|
| 155 |
-
print("🚨 [SA-restore] Hub backup corrupt, starting fresh")
|
| 156 |
-
return False
|
| 157 |
-
for suffix in ['-wal', '-shm']:
|
| 158 |
-
wf = SA_DB_PATH + suffix
|
| 159 |
-
if os.path.exists(wf): os.remove(wf)
|
| 160 |
-
shutil.copy(downloaded, SA_DB_PATH)
|
| 161 |
-
print("✅ [SA-restore] DB restored from Hub")
|
| 162 |
-
return True
|
| 163 |
-
except Exception as e:
|
| 164 |
-
print(f"⚠️ [SA-restore] failed (first run?): {e}")
|
| 165 |
-
return False
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
async def init_siteagent_db():
|
| 169 |
-
"""SiteAgent 영구 DB 초기화"""
|
| 170 |
-
await _sa_restore_db()
|
| 171 |
-
async with aiosqlite.connect(SA_DB_PATH, timeout=30.0) as db:
|
| 172 |
-
await db.execute("PRAGMA journal_mode=WAL")
|
| 173 |
-
|
| 174 |
-
# 사용자
|
| 175 |
-
await db.execute("""CREATE TABLE IF NOT EXISTS users (
|
| 176 |
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 177 |
-
email TEXT UNIQUE NOT NULL,
|
| 178 |
-
nickname TEXT,
|
| 179 |
-
role TEXT DEFAULT 'user',
|
| 180 |
-
visit_count INTEGER DEFAULT 0,
|
| 181 |
-
last_seen_at REAL,
|
| 182 |
-
created_at REAL
|
| 183 |
-
)""")
|
| 184 |
-
|
| 185 |
-
# 페이지 방문 기록
|
| 186 |
-
await db.execute("""CREATE TABLE IF NOT EXISTS page_visits (
|
| 187 |
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 188 |
-
email TEXT NOT NULL,
|
| 189 |
-
url_hash TEXT NOT NULL,
|
| 190 |
-
url TEXT,
|
| 191 |
-
title TEXT,
|
| 192 |
-
domain TEXT,
|
| 193 |
-
visit_count INTEGER DEFAULT 1,
|
| 194 |
-
total_duration_sec REAL DEFAULT 0,
|
| 195 |
-
last_visited_at REAL,
|
| 196 |
-
created_at REAL,
|
| 197 |
-
UNIQUE(email, url_hash)
|
| 198 |
-
)""")
|
| 199 |
-
|
| 200 |
-
# 사용자 입력 (프롬프트/명령어) 이력
|
| 201 |
-
await db.execute("""CREATE TABLE IF NOT EXISTS user_inputs (
|
| 202 |
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 203 |
-
email TEXT NOT NULL,
|
| 204 |
-
url_hash TEXT,
|
| 205 |
-
url TEXT,
|
| 206 |
-
input_type TEXT,
|
| 207 |
-
input_text TEXT,
|
| 208 |
-
feature TEXT,
|
| 209 |
-
result_length INTEGER DEFAULT 0,
|
| 210 |
-
created_at REAL
|
| 211 |
-
)""")
|
| 212 |
-
|
| 213 |
-
# 기능별 사용 통계
|
| 214 |
-
await db.execute("""CREATE TABLE IF NOT EXISTS feature_usage (
|
| 215 |
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 216 |
-
email TEXT NOT NULL,
|
| 217 |
-
feature TEXT NOT NULL,
|
| 218 |
-
use_count INTEGER DEFAULT 1,
|
| 219 |
-
last_used_at REAL,
|
| 220 |
-
UNIQUE(email, feature)
|
| 221 |
-
)""")
|
| 222 |
-
|
| 223 |
-
# 인덱스
|
| 224 |
-
await db.execute("CREATE INDEX IF NOT EXISTS idx_visits_email ON page_visits(email, last_visited_at DESC)")
|
| 225 |
-
await db.execute("CREATE INDEX IF NOT EXISTS idx_inputs_email ON user_inputs(email, created_at DESC)")
|
| 226 |
-
await db.execute("CREATE INDEX IF NOT EXISTS idx_usage_email ON feature_usage(email, use_count DESC)")
|
| 227 |
-
await db.execute("CREATE INDEX IF NOT EXISTS idx_visits_domain ON page_visits(domain, visit_count DESC)")
|
| 228 |
-
|
| 229 |
-
# 관리자 자동 등록
|
| 230 |
-
import time
|
| 231 |
-
cursor = await db.execute("SELECT email FROM users WHERE email=?", (ADMIN_EMAIL,))
|
| 232 |
-
if not await cursor.fetchone():
|
| 233 |
-
await db.execute("INSERT INTO users (email,nickname,role,created_at) VALUES (?,?,?,?)",
|
| 234 |
-
(ADMIN_EMAIL, "🔑관리자", "admin", time.time()))
|
| 235 |
-
|
| 236 |
-
await db.commit()
|
| 237 |
-
count = await db.execute("SELECT COUNT(*) FROM users")
|
| 238 |
-
cnt = (await count.fetchone())[0]
|
| 239 |
-
print(f"✅ SiteAgent DB initialized: {SA_DB_PATH} ({cnt} users)")
|
| 240 |
-
HF_TOKEN = os.environ.get("HF_TOKEN", "")
|
| 241 |
-
DATASET_ID = "ginigen-ai/siteagent"
|
| 242 |
-
LOG_FILE = "/tmp/sa_events.jsonl"
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
# ── 서로게이트 문자 제거 (YouTube 이모지 등) ────────────────────
|
| 246 |
-
import re as _re
|
| 247 |
-
|
| 248 |
-
def _sanitize_text(text):
|
| 249 |
-
"""lone surrogate(\\ud800-\\udfff) 제거 — UTF-8 인코딩 에러 방지"""
|
| 250 |
-
if not isinstance(text, str):
|
| 251 |
-
return text
|
| 252 |
-
clean = _re.sub(r'[\ud800-\udfff]', '', text)
|
| 253 |
-
try:
|
| 254 |
-
clean.encode('utf-8')
|
| 255 |
-
return clean
|
| 256 |
-
except UnicodeEncodeError:
|
| 257 |
-
return clean.encode('utf-8', 'replace').decode('utf-8')
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
def _sanitize_messages(messages):
|
| 261 |
-
"""메시지 배열 전체에서 서로게이트 제거"""
|
| 262 |
-
if not messages:
|
| 263 |
-
return messages
|
| 264 |
-
clean = []
|
| 265 |
-
for msg in messages:
|
| 266 |
-
m = dict(msg)
|
| 267 |
-
if isinstance(m.get("content"), str):
|
| 268 |
-
m["content"] = _sanitize_text(m["content"])
|
| 269 |
-
elif isinstance(m.get("content"), list):
|
| 270 |
-
m["content"] = [
|
| 271 |
-
{**item, "text": _sanitize_text(item["text"])} if isinstance(item, dict) and "text" in item else item
|
| 272 |
-
for item in m["content"]
|
| 273 |
-
]
|
| 274 |
-
clean.append(m)
|
| 275 |
-
return clean
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
def _strip_response_tables(data):
|
| 279 |
-
"""JSONResponse 데이터 내 모든 content에서 표 제거"""
|
| 280 |
-
try:
|
| 281 |
-
if isinstance(data, dict):
|
| 282 |
-
for ch in data.get("choices", []):
|
| 283 |
-
msg = ch.get("message", {})
|
| 284 |
-
if isinstance(msg.get("content"), str):
|
| 285 |
-
msg["content"] = _strip_md_table(msg["content"])
|
| 286 |
-
except Exception:
|
| 287 |
-
pass
|
| 288 |
-
return data
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
def _strip_md_table(text):
|
| 292 |
-
"""마크다운 표를 개조식 리스트로 강제 변환"""
|
| 293 |
-
if not isinstance(text, str) or '|' not in text:
|
| 294 |
-
return text
|
| 295 |
-
lines = text.split('\n')
|
| 296 |
-
out = []
|
| 297 |
-
tbl = []
|
| 298 |
-
in_tbl = False
|
| 299 |
-
for line in lines:
|
| 300 |
-
if _re.match(r'^\s*\|.+\|\s*$', line):
|
| 301 |
-
in_tbl = True
|
| 302 |
-
tbl.append(line)
|
| 303 |
-
else:
|
| 304 |
-
if in_tbl:
|
| 305 |
-
_flush_table(tbl, out)
|
| 306 |
-
tbl = []
|
| 307 |
-
in_tbl = False
|
| 308 |
-
out.append(line)
|
| 309 |
-
if in_tbl:
|
| 310 |
-
_flush_table(tbl, out)
|
| 311 |
-
return '\n'.join(out)
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
def _flush_table(tbl, out):
|
| 315 |
-
if len(tbl) < 2:
|
| 316 |
-
out.extend(tbl)
|
| 317 |
-
return
|
| 318 |
-
headers = [c.strip() for c in tbl[0].strip('|').split('|')]
|
| 319 |
-
for i, tr in enumerate(tbl):
|
| 320 |
-
cells = [c.strip() for c in tr.strip('|').split('|')]
|
| 321 |
-
if i == 0:
|
| 322 |
-
out.append('**' + ' · '.join(c for c in cells if c) + '**')
|
| 323 |
-
elif i == 1 and _re.match(r'^[\s:\-|]+$', tr):
|
| 324 |
-
continue
|
| 325 |
-
else:
|
| 326 |
-
parts = []
|
| 327 |
-
for j, cell in enumerate(cells):
|
| 328 |
-
if cell:
|
| 329 |
-
if j < len(headers) and headers[j] and headers[j] != cell:
|
| 330 |
-
parts.append(f'**{headers[j]}**: {cell}')
|
| 331 |
-
else:
|
| 332 |
-
parts.append(cell)
|
| 333 |
-
out.append('• ' + ' / '.join(parts))
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
# ── page-agent JS + 커스텀 UI JS 메모리 로드 ────────────────────
|
| 337 |
-
_PAGE_AGENT_JS: bytes = b""
|
| 338 |
-
|
| 339 |
-
def _load_js():
|
| 340 |
-
global _PAGE_AGENT_JS
|
| 341 |
-
local = Path(__file__).parent / "secure-pageagent.extend.js"
|
| 342 |
-
if local.exists():
|
| 343 |
-
_PAGE_AGENT_JS = local.read_bytes()
|
| 344 |
-
print(f"✅ secure-pageagent.extend.js loaded ({len(_PAGE_AGENT_JS):,} bytes)")
|
| 345 |
-
else:
|
| 346 |
-
print("❌ secure-pageagent.extend.js not found")
|
| 347 |
-
|
| 348 |
-
_load_js()
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
# ── JS를 Base64로 반환 (CSP 완전 우회) ──────────────────────────
|
| 352 |
-
@app.get("/api/agent-script")
|
| 353 |
-
async def agent_script():
|
| 354 |
-
if not _PAGE_AGENT_JS:
|
| 355 |
-
return JSONResponse({"error": "agent script not loaded"}, status_code=500)
|
| 356 |
-
return JSONResponse({
|
| 357 |
-
"js": base64.b64encode(_PAGE_AGENT_JS).decode(),
|
| 358 |
-
"size": len(_PAGE_AGENT_JS)
|
| 359 |
-
})
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
# ── gpt-oss-120b 통합 프록시 (tools→텍스트 변환→파싱→래핑) + 2회 재시도 ──
|
| 363 |
-
GROQ_URL = "https://api.groq.com/openai/v1/chat/completions"
|
| 364 |
-
MAX_RETRIES = 2
|
| 365 |
-
|
| 366 |
-
# PageAgent 액션을 텍스트 지시로 변환
|
| 367 |
-
ACTION_INSTRUCTIONS = """
|
| 368 |
-
## 응답 형식 (반드시 JSON으로만 응답)
|
| 369 |
-
아래 JSON 형식으로만 응답하라. 설명 텍스트 없이 JSON만 출력:
|
| 370 |
-
|
| 371 |
-
{
|
| 372 |
-
"evaluation_previous_goal": "이전 액션 평가 (1줄)",
|
| 373 |
-
"memory": "현재까지 파악한 정보 요약",
|
| 374 |
-
"next_goal": "다음에 할 일",
|
| 375 |
-
"action": { ... 아래 액션 중 하나 선택 ... }
|
| 376 |
-
}
|
| 377 |
-
|
| 378 |
-
## 사용 가능한 액션:
|
| 379 |
-
|
| 380 |
-
1. **완료** (최종 답변을 사용자에게 전달):
|
| 381 |
-
"action": {"done": {"text": "사용자에게 보여줄 최종 답변", "success": true}}
|
| 382 |
-
|
| 383 |
-
2. **요소 클릭** (인덱스 번호로 클릭):
|
| 384 |
-
"action": {"click_element_by_index": {"index": 42}}
|
| 385 |
-
|
| 386 |
-
3. **텍스트 입력** (입력 필드에 텍스트 입력):
|
| 387 |
-
"action": {"input_text": {"index": 15, "text": "입력할 텍스트"}}
|
| 388 |
-
|
| 389 |
-
4. **드롭다운 선택**:
|
| 390 |
-
"action": {"select_dropdown_option": {"index": 8, "text": "선택할 옵션"}}
|
| 391 |
-
|
| 392 |
-
5. **세로 스크롤** (위/아래):
|
| 393 |
-
"action": {"scroll": {"down": true, "num_pages": 1}}
|
| 394 |
-
|
| 395 |
-
6. **가로 스크롤** (좌/우, 넓은 테이블 등):
|
| 396 |
-
"action": {"scroll_horizontally": {"right": true, "pixels": 300}}
|
| 397 |
-
|
| 398 |
-
7. **대기**:
|
| 399 |
-
"action": {"wait": {"seconds": 2}}
|
| 400 |
-
|
| 401 |
-
8. **JavaScript 실행** (다른 액션으로 불가능할 때만):
|
| 402 |
-
"action": {"execute_javascript": {"script": "document.querySelector('video').play()"}}
|
| 403 |
-
- 비디오 재생: document.querySelector('video').play()
|
| 404 |
-
- 요소 표시: document.querySelector('.hidden-panel').style.display='block'
|
| 405 |
-
- 값 읽기: document.querySelector('.price').textContent
|
| 406 |
-
|
| 407 |
-
9. **사용자에게 질문** (정보가 부족하여 추가 확인이 필요할 때):
|
| 408 |
-
"action": {"ask_user": {"question": "출발 날짜를 알려주세요"}}
|
| 409 |
-
- 날짜, 인원수, 옵션 등 필수 정보가 누락된 경우 사용
|
| 410 |
-
- 추측하지 말고 반드시 물어보라
|
| 411 |
-
|
| 412 |
-
## 핵심 규칙
|
| 413 |
-
- JSON만 출력. 마크다운 코드블록(```) 금지
|
| 414 |
-
- done.text는 반드시 자연스러운 한국어
|
| 415 |
-
- 페이지에서 정보를 찾아야 하면 클릭/스크롤 액션을 사용
|
| 416 |
-
- 이미 답을 알면 바로 done으로 응답
|
| 417 |
-
- 필수 정보가 누락되면 추측하지 말고 ask_user로 물어보라
|
| 418 |
-
|
| 419 |
-
## 중요: 실패 처리
|
| 420 |
-
- 같은 액션이 실패(⚠️)하면 절대 반복하지 마라. 다른 액션을 시도하라.
|
| 421 |
-
- 스크롤이 실패하면("Already at the bottom/top") 더 이상 스크롤하지 마라. 클릭이나 다른 방법을 시도하라.
|
| 422 |
-
- 3번 이상 같은 종류의 액션이 실패하면 done으로 사용자에게 상황을 설명하라.
|
| 423 |
-
|
| 424 |
-
## 중요: 액션 선택 우선순위
|
| 425 |
-
- 버튼, 링크, 비디오 재생 등 조작 요청 → click_element_by_index를 먼저 시도하라.
|
| 426 |
-
- 비디오 재생 요청 → 재생 버튼(play, ▶) 인덱스를 찾아 클릭. 없으면 execute_javascript로 document.querySelector('video').play() 실행.
|
| 427 |
-
- 정보가 보이지 않을 때만 스크롤. 스크롤은 최대 3회까지만.
|
| 428 |
-
- 넓은 표에서 오른쪽 데이터가 잘리면 scroll_horizontally를 사용하라.
|
| 429 |
-
- 요청을 5스텝 이내에 완료할 수 없으면 현재까지 결과를 done으로 보고하라.
|
| 430 |
-
"""
|
| 431 |
-
|
| 432 |
-
@app.post("/api/chat/completions")
|
| 433 |
-
async def chat_proxy(request: Request):
|
| 434 |
-
try:
|
| 435 |
-
body = await request.json()
|
| 436 |
-
except Exception:
|
| 437 |
-
return JSONResponse({"error": "invalid json"}, status_code=400)
|
| 438 |
-
|
| 439 |
-
# ── 기본 데모 모드 (Groq gpt-oss-120b) ──
|
| 440 |
-
if not GROQ_API_KEY:
|
| 441 |
-
return JSONResponse({"error": "GROQ_API_KEY not set"}, status_code=500)
|
| 442 |
-
|
| 443 |
-
# tools 저장 후 제거 (gpt-oss-120b는 텍스트로 처리)
|
| 444 |
-
original_tools = body.pop("tools", None)
|
| 445 |
-
body.pop("tool_choice", None)
|
| 446 |
-
body.pop("parallel_tool_calls", None)
|
| 447 |
-
|
| 448 |
-
# ★ Groq 내장 도구 요청 감지 (X-SA-Mode 헤더 또는 sa_mode 파라미터)
|
| 449 |
-
sa_mode = body.pop("sa_mode", None)
|
| 450 |
-
_sa_email_val = body.pop("sa_email", "")
|
| 451 |
-
_sa_url_val = body.pop("sa_url", "")
|
| 452 |
-
|
| 453 |
-
# ★ 서로게이트 문자 제거 (YouTube 이모지 등 — 모든 분기 전에 적용)
|
| 454 |
-
if "messages" in body:
|
| 455 |
-
body["messages"] = _sanitize_messages(body["messages"])
|
| 456 |
-
|
| 457 |
-
# ★ 사용자 입력 자동 DB 기록 (모든 채팅/검색/계산)
|
| 458 |
-
try:
|
| 459 |
-
import time as _time, hashlib as _hl
|
| 460 |
-
_sa_email = _sa_email_val
|
| 461 |
-
_sa_url = _sa_url_val
|
| 462 |
-
if _sa_email:
|
| 463 |
-
_msgs = body.get("messages", [])
|
| 464 |
-
_last_user = ""
|
| 465 |
-
for _m in reversed(_msgs):
|
| 466 |
-
if _m.get("role") == "user":
|
| 467 |
-
_ct = _m.get("content", "")
|
| 468 |
-
_last_user = _ct if isinstance(_ct, str) else str(_ct)[:500]
|
| 469 |
-
break
|
| 470 |
-
if _last_user:
|
| 471 |
-
_uh = _hl.sha256(_sa_url.encode()).hexdigest()[:16] if _sa_url else ""
|
| 472 |
-
_feat = sa_mode or "chat"
|
| 473 |
-
_asyncio.create_task(_db_write(
|
| 474 |
-
"INSERT INTO user_inputs (email,url_hash,url,input_type,input_text,feature,created_at) VALUES (?,?,?,?,?,?,?)",
|
| 475 |
-
(_sa_email, _uh, _sa_url[:500], "chat", _last_user[:1000], _feat, _time.time())
|
| 476 |
-
))
|
| 477 |
-
_asyncio.create_task(_db_write(
|
| 478 |
-
"INSERT INTO feature_usage (email,feature,use_count,last_used_at) VALUES (?,?,1,?) ON CONFLICT(email,feature) DO UPDATE SET use_count=use_count+1, last_used_at=?",
|
| 479 |
-
(_sa_email, _feat, _time.time(), _time.time())
|
| 480 |
-
))
|
| 481 |
-
except Exception as _le:
|
| 482 |
-
print(f"[log] chat input logging error: {_le}")
|
| 483 |
-
if not sa_mode:
|
| 484 |
-
sa_mode = request.headers.get("X-SA-Mode", "")
|
| 485 |
-
|
| 486 |
-
body["model"] = "openai/gpt-oss-120b"
|
| 487 |
-
body["stream"] = False
|
| 488 |
-
body["temperature"] = 1
|
| 489 |
-
body["top_p"] = 1
|
| 490 |
-
body["stop"] = None
|
| 491 |
-
for k in ["max_tokens", "thinking", "verbosity"]:
|
| 492 |
-
body.pop(k, None)
|
| 493 |
-
|
| 494 |
-
# ★ 모드별 분기
|
| 495 |
-
if sa_mode == "search":
|
| 496 |
-
# ── 웹 검색 모드: browser_search 활성화 ──
|
| 497 |
-
body["tools"] = [{"type": "browser_search"}]
|
| 498 |
-
body["tool_choice"] = "required"
|
| 499 |
-
body["max_completion_tokens"] = 4096
|
| 500 |
-
body["reasoning_effort"] = "low"
|
| 501 |
-
original_tools = None # tool_call 래핑 안 함
|
| 502 |
-
msgs = body.get("messages", [])
|
| 503 |
-
if msgs and msgs[0].get("role") == "system":
|
| 504 |
-
msgs[0]["content"] += "\n\n[중요] 반드시 자연스러운 한국어로 답변하라."
|
| 505 |
-
elif msgs:
|
| 506 |
-
msgs.insert(0, {"role": "system", "content": "반드시 자연스러운 한국어로 답변하라."})
|
| 507 |
-
body["messages"] = msgs
|
| 508 |
-
print("[proxy] mode: browser_search")
|
| 509 |
-
|
| 510 |
-
elif sa_mode == "calc":
|
| 511 |
-
# ── 계산 모드: code_interpreter 활성화 ──
|
| 512 |
-
body["tools"] = [{"type": "code_interpreter"}]
|
| 513 |
-
body["tool_choice"] = "required"
|
| 514 |
-
body["max_completion_tokens"] = 4096
|
| 515 |
-
body["reasoning_effort"] = "medium"
|
| 516 |
-
original_tools = None
|
| 517 |
-
msgs = body.get("messages", [])
|
| 518 |
-
if msgs and msgs[0].get("role") == "system":
|
| 519 |
-
msgs[0]["content"] += "\n\n[중요] 계산 결과를 자연스러운 한국어로 설명하라."
|
| 520 |
-
elif msgs:
|
| 521 |
-
msgs.insert(0, {"role": "system", "content": "계산 결과를 자연스러운 한국어로 설명하라."})
|
| 522 |
-
body["messages"] = msgs
|
| 523 |
-
print("[proxy] mode: code_interpreter")
|
| 524 |
-
|
| 525 |
-
elif original_tools:
|
| 526 |
-
# ── DOM 액션 모드 (기존): ACTION_INSTRUCTIONS 주입 ──
|
| 527 |
-
body["max_completion_tokens"] = 8192
|
| 528 |
-
# ★ 동적 reasoning_effort: 첫 스텝은 medium, 이후 low (속도 최적화)
|
| 529 |
-
msgs = body.get("messages", [])
|
| 530 |
-
user_msgs = [m for m in msgs if m.get("role") == "user"]
|
| 531 |
-
body["reasoning_effort"] = "medium" if len(user_msgs) <= 2 else "low"
|
| 532 |
-
|
| 533 |
-
if msgs[0].get("role") == "system":
|
| 534 |
-
msgs[0]["content"] = msgs[0]["content"] + "\n" + ACTION_INSTRUCTIONS
|
| 535 |
-
else:
|
| 536 |
-
msgs.insert(0, {"role": "system", "content": ACTION_INSTRUCTIONS})
|
| 537 |
-
body["messages"] = msgs
|
| 538 |
-
print(f"[proxy] mode: dom_action (reasoning: {body['reasoning_effort']})")
|
| 539 |
-
|
| 540 |
-
else:
|
| 541 |
-
# ── 일반 텍스트 모드 ──
|
| 542 |
-
body["max_completion_tokens"] = 4096
|
| 543 |
-
body["reasoning_effort"] = "low"
|
| 544 |
-
msgs = body.get("messages", [])
|
| 545 |
-
if msgs and msgs[0].get("role") == "system":
|
| 546 |
-
if "한국어" not in msgs[0]["content"]:
|
| 547 |
-
msgs[0]["content"] += "\n\n[중요] 최종 답변은 반드시 자연스러운 한국어로 작성하라."
|
| 548 |
-
body["messages"] = msgs
|
| 549 |
-
print("[proxy] mode: text")
|
| 550 |
-
|
| 551 |
-
headers = {
|
| 552 |
-
"Authorization": f"Bearer {GROQ_API_KEY}",
|
| 553 |
-
"Content-Type": "application/json",
|
| 554 |
-
}
|
| 555 |
-
|
| 556 |
-
# ── 2회 재시도 ──
|
| 557 |
-
data = None
|
| 558 |
-
status = 500
|
| 559 |
-
for attempt in range(MAX_RETRIES + 1):
|
| 560 |
-
try:
|
| 561 |
-
async with httpx.AsyncClient(timeout=180) as client:
|
| 562 |
-
resp = await client.post(GROQ_URL, headers=headers, json=body)
|
| 563 |
-
try:
|
| 564 |
-
data = resp.json()
|
| 565 |
-
except Exception:
|
| 566 |
-
print(f"[proxy] Groq returned non-JSON: {resp.text[:200]}")
|
| 567 |
-
data = {"error": {"message": "Groq returned invalid response", "type": "proxy_error"}}
|
| 568 |
-
status = resp.status_code
|
| 569 |
-
|
| 570 |
-
if status == 429:
|
| 571 |
-
wait = 2 * (attempt + 1)
|
| 572 |
-
print(f"[proxy] 429 → retry {attempt+1}/{MAX_RETRIES} ({wait}s)")
|
| 573 |
-
if attempt < MAX_RETRIES:
|
| 574 |
-
await asyncio.sleep(wait)
|
| 575 |
-
continue
|
| 576 |
-
elif status >= 500:
|
| 577 |
-
print(f"[proxy] {status} → retry {attempt+1}/{MAX_RETRIES}")
|
| 578 |
-
if attempt < MAX_RETRIES:
|
| 579 |
-
await asyncio.sleep(1)
|
| 580 |
-
continue
|
| 581 |
-
break
|
| 582 |
-
except Exception as e:
|
| 583 |
-
print(f"[proxy] exception: {e}")
|
| 584 |
-
if attempt < MAX_RETRIES:
|
| 585 |
-
await asyncio.sleep(1)
|
| 586 |
-
continue
|
| 587 |
-
return JSONResponse({"error": {"message": str(e)}}, status_code=502)
|
| 588 |
-
|
| 589 |
-
if status != 200:
|
| 590 |
-
return JSONResponse(data or {"error": "unknown"}, status_code=status)
|
| 591 |
-
|
| 592 |
-
# ★ search/calc 모드: executed_tools에서 결과 추출하여 깔끔하게 반환
|
| 593 |
-
if sa_mode in ("search", "calc"):
|
| 594 |
-
try:
|
| 595 |
-
content = data["choices"][0]["message"].get("content", "")
|
| 596 |
-
# executed_tools 로깅
|
| 597 |
-
executed = data["choices"][0]["message"].get("executed_tools", [])
|
| 598 |
-
if executed:
|
| 599 |
-
print(f"[proxy] executed_tools: {len(executed)} tools used")
|
| 600 |
-
# Groq 인용 태그 제거
|
| 601 |
-
import re
|
| 602 |
-
try:
|
| 603 |
-
msg = data["choices"][0]["message"]
|
| 604 |
-
if msg.get("content"):
|
| 605 |
-
msg["content"] = re.sub(r'【[^】]*】', '', msg["content"]).strip()
|
| 606 |
-
except: pass
|
| 607 |
-
return JSONResponse(data)
|
| 608 |
-
except Exception as e:
|
| 609 |
-
print(f"[proxy] search/calc extract error: {e}")
|
| 610 |
-
return JSONResponse(data)
|
| 611 |
-
|
| 612 |
-
# ── tools가 있었으면: LLM 텍스트 응답 → AgentOutput tool_call 래핑 ──
|
| 613 |
-
if original_tools:
|
| 614 |
-
raw_text = ""
|
| 615 |
-
try:
|
| 616 |
-
raw_text = data["choices"][0]["message"]["content"] or ""
|
| 617 |
-
except Exception:
|
| 618 |
-
pass
|
| 619 |
-
|
| 620 |
-
print(f"[proxy] raw ({len(raw_text)}): {raw_text[:300]}...")
|
| 621 |
-
|
| 622 |
-
# JSON 추출 (코드블록 제거 포함)
|
| 623 |
-
agent_output = _extract_agent_output(raw_text)
|
| 624 |
-
|
| 625 |
-
wrapped = {
|
| 626 |
-
"id": data.get("id", "sa"),
|
| 627 |
-
"object": "chat.completion",
|
| 628 |
-
"created": data.get("created", 0),
|
| 629 |
-
"model": "openai/gpt-oss-120b",
|
| 630 |
-
"choices": [{
|
| 631 |
-
"index": 0,
|
| 632 |
-
"message": {
|
| 633 |
-
"role": "assistant",
|
| 634 |
-
"tool_calls": [{
|
| 635 |
-
"id": "call_sa_" + str(data.get("created", 0)),
|
| 636 |
-
"type": "function",
|
| 637 |
-
"function": {
|
| 638 |
-
"name": "AgentOutput",
|
| 639 |
-
"arguments": agent_output
|
| 640 |
-
}
|
| 641 |
-
}]
|
| 642 |
-
},
|
| 643 |
-
"finish_reason": "tool_calls"
|
| 644 |
-
}],
|
| 645 |
-
"usage": data.get("usage", {})
|
| 646 |
-
}
|
| 647 |
-
return JSONResponse(wrapped)
|
| 648 |
-
|
| 649 |
-
_strip_response_tables(data)
|
| 650 |
-
return JSONResponse(data)
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
def _extract_agent_output(raw: str) -> str:
|
| 654 |
-
"""LLM 텍스트 응답에서 AgentOutput JSON 추출"""
|
| 655 |
-
text = raw.strip()
|
| 656 |
-
|
| 657 |
-
# 마크다운 코드블록 제거
|
| 658 |
-
if text.startswith("```"):
|
| 659 |
-
text = text.split("\n", 1)[-1] if "\n" in text else text[3:]
|
| 660 |
-
if text.endswith("```"):
|
| 661 |
-
text = text[:-3].strip()
|
| 662 |
-
if text.startswith("json"):
|
| 663 |
-
text = text[4:].strip()
|
| 664 |
-
|
| 665 |
-
# JSON 파싱 시도
|
| 666 |
-
parsed = None
|
| 667 |
-
try:
|
| 668 |
-
parsed = json.loads(text)
|
| 669 |
-
except json.JSONDecodeError:
|
| 670 |
-
# JSON이 텍스트에 묻혀있으면 첫 번째 { ~ 마지막 } 추출
|
| 671 |
-
start = text.find("{")
|
| 672 |
-
end = text.rfind("}")
|
| 673 |
-
if start >= 0 and end > start:
|
| 674 |
-
try:
|
| 675 |
-
parsed = json.loads(text[start:end+1])
|
| 676 |
-
except json.JSONDecodeError:
|
| 677 |
-
pass
|
| 678 |
-
|
| 679 |
-
if parsed and isinstance(parsed, dict) and "action" in parsed:
|
| 680 |
-
# 유효한 AgentOutput
|
| 681 |
-
action = parsed["action"]
|
| 682 |
-
|
| 683 |
-
# done.text가 있으면 한국어 확인
|
| 684 |
-
if isinstance(action, dict) and "done" in action:
|
| 685 |
-
done = action["done"]
|
| 686 |
-
if isinstance(done, dict) and done.get("text"):
|
| 687 |
-
print(f"[proxy] → done: {done['text'][:80]}...")
|
| 688 |
-
elif isinstance(done, dict) and not done.get("text"):
|
| 689 |
-
# text 비었으면 memory/next_goal에서 보충
|
| 690 |
-
fill = parsed.get("memory", "") or parsed.get("next_goal", "")
|
| 691 |
-
if fill:
|
| 692 |
-
done["text"] = fill
|
| 693 |
-
parsed["action"]["done"] = done
|
| 694 |
-
|
| 695 |
-
# click/scroll 등 액션은 그대로 전달
|
| 696 |
-
for act_name in ["click_element_by_index", "input_text", "scroll",
|
| 697 |
-
"select_dropdown_option", "wait", "scroll_horizontally",
|
| 698 |
-
"execute_javascript", "ask_user"]:
|
| 699 |
-
if act_name in action:
|
| 700 |
-
print(f"[proxy] → action: {act_name} {action[act_name]}")
|
| 701 |
-
|
| 702 |
-
return json.dumps(parsed, ensure_ascii=False)
|
| 703 |
-
|
| 704 |
-
# JSON 파싱 실패 → 전체를 done.text로
|
| 705 |
-
final_text = raw.strip()
|
| 706 |
-
if parsed and isinstance(parsed, dict):
|
| 707 |
-
# JSON이긴 한데 action이 없는 경우 → 텍스트 추출
|
| 708 |
-
for key in ["text", "content", "answer", "response", "result"]:
|
| 709 |
-
if key in parsed and isinstance(parsed[key], str):
|
| 710 |
-
final_text = parsed[key]
|
| 711 |
-
break
|
| 712 |
-
|
| 713 |
-
print(f"[proxy] → fallback done: {final_text[:80]}...")
|
| 714 |
-
return json.dumps({
|
| 715 |
-
"evaluation_previous_goal": "Processed",
|
| 716 |
-
"memory": "",
|
| 717 |
-
"next_goal": "",
|
| 718 |
-
"action": {"done": {"text": final_text or "응답을 생성할 수 없습니다.", "success": bool(final_text)}}
|
| 719 |
-
}, ensure_ascii=False)
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
# ── 검색/계산 직접 호출 (커스텀 UI용) ────────────────────────────
|
| 723 |
-
@app.post("/api/search")
|
| 724 |
-
async def search_api(request: Request):
|
| 725 |
-
"""browser_search를 사용한 웹 검색"""
|
| 726 |
-
try:
|
| 727 |
-
body = await request.json()
|
| 728 |
-
except Exception:
|
| 729 |
-
return JSONResponse({"error": "invalid json"}, status_code=400)
|
| 730 |
-
|
| 731 |
-
query = body.get("query", "")
|
| 732 |
-
if not query:
|
| 733 |
-
return JSONResponse({"error": "query required"}, status_code=400)
|
| 734 |
-
if not GROQ_API_KEY:
|
| 735 |
-
return JSONResponse({"error": "GROQ_API_KEY not set"}, status_code=500)
|
| 736 |
-
|
| 737 |
-
payload = {
|
| 738 |
-
"model": "openai/gpt-oss-120b",
|
| 739 |
-
"messages": [
|
| 740 |
-
{"role": "system", "content": "반드시 자연스러운 한국어로 답변하라. 핵심만 간결하게. 표(table)는 절대 사용하지 마라. 개조식 서술형으로 작성하라."},
|
| 741 |
-
{"role": "user", "content": query}
|
| 742 |
-
],
|
| 743 |
-
"tools": [{"type": "browser_search"}],
|
| 744 |
-
"tool_choice": "required",
|
| 745 |
-
"max_completion_tokens": 4096,
|
| 746 |
-
"reasoning_effort": "low",
|
| 747 |
-
"stream": False
|
| 748 |
-
}
|
| 749 |
-
|
| 750 |
-
headers = {"Authorization": f"Bearer {GROQ_API_KEY}", "Content-Type": "application/json"}
|
| 751 |
-
try:
|
| 752 |
-
async with httpx.AsyncClient(timeout=120) as client:
|
| 753 |
-
resp = await client.post(GROQ_URL, headers=headers, json=payload)
|
| 754 |
-
data = resp.json()
|
| 755 |
-
content = data.get("choices", [{}])[0].get("message", {}).get("content", "검색 결과를 가져올 수 없습니다.")
|
| 756 |
-
# Groq browser_search 인용 태그 제거 (【0†L4-L8】 등)
|
| 757 |
-
import re
|
| 758 |
-
content = re.sub(r'【[^】]*】', '', content).strip()
|
| 759 |
-
content = re.sub(r'\s{2,}', ' ', content)
|
| 760 |
-
return JSONResponse({"result": content})
|
| 761 |
-
except Exception as e:
|
| 762 |
-
return JSONResponse({"error": str(e)}, status_code=502)
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
@app.post("/api/calc")
|
| 766 |
-
async def calc_api(request: Request):
|
| 767 |
-
"""code_interpreter를 사용한 계산"""
|
| 768 |
-
try:
|
| 769 |
-
body = await request.json()
|
| 770 |
-
except Exception:
|
| 771 |
-
return JSONResponse({"error": "invalid json"}, status_code=400)
|
| 772 |
-
|
| 773 |
-
query = body.get("query", "")
|
| 774 |
-
if not query:
|
| 775 |
-
return JSONResponse({"error": "query required"}, status_code=400)
|
| 776 |
-
if not GROQ_API_KEY:
|
| 777 |
-
return JSONResponse({"error": "GROQ_API_KEY not set"}, status_code=500)
|
| 778 |
-
|
| 779 |
-
payload = {
|
| 780 |
-
"model": "openai/gpt-oss-120b",
|
| 781 |
-
"messages": [
|
| 782 |
-
{"role": "system", "content": "계산 결과를 자연스러운 한국어로 설명하라."},
|
| 783 |
-
{"role": "user", "content": query}
|
| 784 |
-
],
|
| 785 |
-
"tools": [{"type": "code_interpreter"}],
|
| 786 |
-
"tool_choice": "required",
|
| 787 |
-
"max_completion_tokens": 4096,
|
| 788 |
-
"reasoning_effort": "medium",
|
| 789 |
-
"stream": False
|
| 790 |
-
}
|
| 791 |
-
|
| 792 |
-
headers = {"Authorization": f"Bearer {GROQ_API_KEY}", "Content-Type": "application/json"}
|
| 793 |
-
try:
|
| 794 |
-
async with httpx.AsyncClient(timeout=120) as client:
|
| 795 |
-
resp = await client.post(GROQ_URL, headers=headers, json=payload)
|
| 796 |
-
data = resp.json()
|
| 797 |
-
content = data.get("choices", [{}])[0].get("message", {}).get("content", "계산 결과를 가져올 수 없습니다.")
|
| 798 |
-
return JSONResponse({"result": content})
|
| 799 |
-
except Exception as e:
|
| 800 |
-
return JSONResponse({"error": str(e)}, status_code=502)
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
# ── 트래킹 ──────────────────────────────────────────────────────
|
| 804 |
-
_log_lock = threading.Lock()
|
| 805 |
-
|
| 806 |
-
def push_event_sync(event: dict):
|
| 807 |
-
line = json.dumps(event, ensure_ascii=False) + "\n"
|
| 808 |
-
with _log_lock:
|
| 809 |
-
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
| 810 |
-
f.write(line)
|
| 811 |
-
if not HF_TOKEN:
|
| 812 |
-
return
|
| 813 |
-
try:
|
| 814 |
-
from huggingface_hub import HfApi, hf_hub_download
|
| 815 |
-
api = HfApi(token=HF_TOKEN)
|
| 816 |
-
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
| 817 |
-
path_in_repo = f"logs/{date_str}.jsonl"
|
| 818 |
-
existing = ""
|
| 819 |
-
try:
|
| 820 |
-
local = hf_hub_download(repo_id=DATASET_ID, filename=path_in_repo,
|
| 821 |
-
repo_type="dataset", token=HF_TOKEN)
|
| 822 |
-
with open(local, encoding="utf-8") as f:
|
| 823 |
-
existing = f.read()
|
| 824 |
-
except Exception:
|
| 825 |
-
pass
|
| 826 |
-
api.upload_file(
|
| 827 |
-
path_or_fileobj=(existing + line).encode("utf-8"),
|
| 828 |
-
path_in_repo=path_in_repo, repo_id=DATASET_ID,
|
| 829 |
-
repo_type="dataset", token=HF_TOKEN,
|
| 830 |
-
commit_message=f"track {date_str}")
|
| 831 |
-
except Exception as e:
|
| 832 |
-
print(f"[push_event] {e}")
|
| 833 |
-
|
| 834 |
-
|
| 835 |
-
def calc_stats():
|
| 836 |
-
events = []
|
| 837 |
-
if os.path.exists(LOG_FILE):
|
| 838 |
-
with _log_lock:
|
| 839 |
-
with open(LOG_FILE, encoding="utf-8") as f:
|
| 840 |
-
for line in f:
|
| 841 |
-
line = line.strip()
|
| 842 |
-
if line:
|
| 843 |
-
try:
|
| 844 |
-
events.append(json.loads(line))
|
| 845 |
-
except Exception:
|
| 846 |
-
pass
|
| 847 |
-
if not events:
|
| 848 |
-
return {"total_sessions":0,"total_commands":0,"total_installs":0,
|
| 849 |
-
"providers":{},"top_domains":[],"daily_active":[],"events_today":0}
|
| 850 |
-
sessions=set(); commands=installs=0
|
| 851 |
-
providers=Counter(); domains=Counter(); daily=defaultdict(set)
|
| 852 |
-
for e in events:
|
| 853 |
-
sid=e.get("session_id","")
|
| 854 |
-
if sid: sessions.add(sid)
|
| 855 |
-
ev=e.get("event","")
|
| 856 |
-
if ev=="command": commands+=1
|
| 857 |
-
if ev=="install": installs+=1
|
| 858 |
-
if ev=="settings_save":
|
| 859 |
-
prov=e.get("provider","")
|
| 860 |
-
if prov: providers[prov]+=1
|
| 861 |
-
domain=e.get("domain","")
|
| 862 |
-
if domain: domains[domain]+=1
|
| 863 |
-
ts=e.get("ts","")
|
| 864 |
-
if ts and sid: daily[ts[:10]].add(sid)
|
| 865 |
-
today=datetime.now(timezone.utc).date()
|
| 866 |
-
daily_active=[{"date":(today-timedelta(days=i)).isoformat(),
|
| 867 |
-
"users":len(daily.get((today-timedelta(days=i)).isoformat(),set()))}
|
| 868 |
-
for i in range(13,-1,-1)]
|
| 869 |
-
today_str=today.isoformat()
|
| 870 |
-
return {"total_sessions":len(sessions),"total_commands":commands,"total_installs":installs,
|
| 871 |
-
"providers":dict(providers.most_common(10)),
|
| 872 |
-
"top_domains":[{"domain":d,"count":c} for d,c in domains.most_common(10)],
|
| 873 |
-
"daily_active":daily_active,
|
| 874 |
-
"events_today":sum(1 for e in events if e.get("ts","")[:10]==today_str)}
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
@app.on_event("startup")
|
| 878 |
-
async def startup_init_db():
|
| 879 |
-
await init_siteagent_db()
|
| 880 |
-
# 주기적 백업 (30분마다)
|
| 881 |
-
import asyncio
|
| 882 |
-
async def _periodic_backup():
|
| 883 |
-
while True:
|
| 884 |
-
await asyncio.sleep(1800) # 30분
|
| 885 |
-
try:
|
| 886 |
-
await _sa_backup_db()
|
| 887 |
-
except Exception as e:
|
| 888 |
-
print(f"⚠️ Periodic backup error: {e}")
|
| 889 |
-
asyncio.create_task(_periodic_backup())
|
| 890 |
-
# 첫 백업 (5분 후)
|
| 891 |
-
async def _first_backup():
|
| 892 |
-
await asyncio.sleep(300)
|
| 893 |
-
await _sa_backup_db()
|
| 894 |
-
asyncio.create_task(_first_backup())
|
| 895 |
-
|
| 896 |
-
@app.on_event("startup")
|
| 897 |
-
def restore_logs_from_hf():
|
| 898 |
-
if not HF_TOKEN:
|
| 899 |
-
return
|
| 900 |
-
try:
|
| 901 |
-
from huggingface_hub import HfApi, hf_hub_download
|
| 902 |
-
api = HfApi(token=HF_TOKEN)
|
| 903 |
-
files = [
|
| 904 |
-
f.rfilename for f in api.list_repo_tree(
|
| 905 |
-
repo_id=DATASET_ID, repo_type="dataset", token=HF_TOKEN
|
| 906 |
-
)
|
| 907 |
-
if f.rfilename.startswith("logs/") and f.rfilename.endswith(".jsonl")
|
| 908 |
-
]
|
| 909 |
-
restored = 0
|
| 910 |
-
with open(LOG_FILE, "a", encoding="utf-8") as out:
|
| 911 |
-
for fname in sorted(files):
|
| 912 |
-
try:
|
| 913 |
-
local = hf_hub_download(
|
| 914 |
-
repo_id=DATASET_ID, filename=fname,
|
| 915 |
-
repo_type="dataset", token=HF_TOKEN
|
| 916 |
-
)
|
| 917 |
-
with open(local, encoding="utf-8") as f:
|
| 918 |
-
for line in f:
|
| 919 |
-
line = line.strip()
|
| 920 |
-
if line:
|
| 921 |
-
out.write(line + "\n")
|
| 922 |
-
restored += 1
|
| 923 |
-
except Exception as e:
|
| 924 |
-
print(f"[restore] skip {fname}: {e}")
|
| 925 |
-
print(f"[restore] {restored} events restored from HF dataset")
|
| 926 |
-
except Exception as e:
|
| 927 |
-
print(f"[restore] failed: {e}")
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
@app.post("/api/track")
|
| 931 |
-
async def track(request: Request, background_tasks: BackgroundTasks):
|
| 932 |
-
try:
|
| 933 |
-
body = await request.json()
|
| 934 |
-
except Exception:
|
| 935 |
-
return JSONResponse({"ok": False}, status_code=400)
|
| 936 |
-
allowed = {"event","session_id","provider","domain","ts","browser","os","lang"}
|
| 937 |
-
event = {k:v for k,v in body.items() if k in allowed}
|
| 938 |
-
event["ts"] = event.get("ts") or datetime.now(timezone.utc).isoformat()
|
| 939 |
-
background_tasks.add_task(push_event_sync, event)
|
| 940 |
-
return JSONResponse({"ok": True})
|
| 941 |
-
|
| 942 |
-
|
| 943 |
-
# ── 사용자 관리 API ────────────────────────────────────────
|
| 944 |
-
@app.post("/api/user/register")
|
| 945 |
-
async def user_register(request: Request):
|
| 946 |
-
"""사용자 자동 등록 (Gmail 기반)"""
|
| 947 |
-
import time
|
| 948 |
-
try:
|
| 949 |
-
body = await request.json()
|
| 950 |
-
email = body.get("email", "").strip().lower()
|
| 951 |
-
if not email or "@" not in email:
|
| 952 |
-
return JSONResponse({"error": "invalid email"}, status_code=400)
|
| 953 |
-
|
| 954 |
-
existing = await _db_read_one("SELECT id,role FROM users WHERE email=?", (email,))
|
| 955 |
-
if existing:
|
| 956 |
-
await _db_write("UPDATE users SET visit_count=visit_count+1, last_seen_at=? WHERE email=?",
|
| 957 |
-
(time.time(), email))
|
| 958 |
-
return {"status": "exists", "role": dict(existing)["role"]}
|
| 959 |
-
|
| 960 |
-
# 신규 등록 — 동물 닉네임 자동 생성
|
| 961 |
-
import random
|
| 962 |
-
animals = ["🐱고양이","🐶강아지","🦊여우","🐻곰","🐼팬더","🐨코알라","🦁사자","🐯호랑이","🐰토끼","🦝너구리","🐸개구리","🦉올빼미","🦋나비","🐬돌고래","🦈상어","🐧펭귄","🦜앵무새","🐢거북이"]
|
| 963 |
-
nick = "익명" + random.choice(animals) + str(random.randint(10,99))
|
| 964 |
-
role = "admin" if email == ADMIN_EMAIL else "user"
|
| 965 |
-
|
| 966 |
-
await _db_write("INSERT OR IGNORE INTO users (email,nickname,role,visit_count,last_seen_at,created_at) VALUES (?,?,?,1,?,?)",
|
| 967 |
-
(email, nick, role, time.time(), time.time()))
|
| 968 |
-
return {"status": "created", "nickname": nick, "role": role}
|
| 969 |
-
except Exception as e:
|
| 970 |
-
return JSONResponse({"error": str(e)[:100]}, status_code=500)
|
| 971 |
-
|
| 972 |
-
|
| 973 |
-
@app.post("/api/user/log")
|
| 974 |
-
async def user_log_activity(request: Request):
|
| 975 |
-
"""사용자 활동 기록 (방문, 입력, 기능 사용)"""
|
| 976 |
-
import time, hashlib
|
| 977 |
-
try:
|
| 978 |
-
body = await request.json()
|
| 979 |
-
email = body.get("email", "").strip().lower()
|
| 980 |
-
if not email: return JSONResponse({"error": "email required"}, status_code=400)
|
| 981 |
-
|
| 982 |
-
action = body.get("action", "")
|
| 983 |
-
url = body.get("url", "")
|
| 984 |
-
title = body.get("title", "")
|
| 985 |
-
input_text = _sanitize_text(body.get("input_text", ""))
|
| 986 |
-
feature = body.get("feature", "")
|
| 987 |
-
|
| 988 |
-
url_hash = hashlib.sha256(url.encode()).hexdigest()[:16] if url else ""
|
| 989 |
-
domain = ""
|
| 990 |
-
if url:
|
| 991 |
-
try:
|
| 992 |
-
from urllib.parse import urlparse
|
| 993 |
-
domain = urlparse(url).netloc
|
| 994 |
-
except: pass
|
| 995 |
-
|
| 996 |
-
now = time.time()
|
| 997 |
-
|
| 998 |
-
if action == "visit" and url:
|
| 999 |
-
existing = await _db_read_one("SELECT id FROM page_visits WHERE email=? AND url_hash=?", (email, url_hash))
|
| 1000 |
-
if existing:
|
| 1001 |
-
await _db_write("UPDATE page_visits SET visit_count=visit_count+1, title=?, last_visited_at=? WHERE email=? AND url_hash=?",
|
| 1002 |
-
(title[:200] if title else None, now, email, url_hash))
|
| 1003 |
-
else:
|
| 1004 |
-
await _db_write("INSERT INTO page_visits (email,url_hash,url,title,domain,last_visited_at,created_at) VALUES (?,?,?,?,?,?,?)",
|
| 1005 |
-
(email, url_hash, url[:500], title[:200] if title else None, domain, now, now))
|
| 1006 |
-
|
| 1007 |
-
elif action == "input" and input_text:
|
| 1008 |
-
await _db_write("INSERT INTO user_inputs (email,url_hash,url,input_type,input_text,feature,result_length,created_at) VALUES (?,?,?,?,?,?,?,?)",
|
| 1009 |
-
(email, url_hash, url[:500] if url else None, body.get("input_type","chat"), input_text[:1000], feature, body.get("result_length",0), now))
|
| 1010 |
-
|
| 1011 |
-
if feature:
|
| 1012 |
-
existing = await _db_read_one("SELECT id FROM feature_usage WHERE email=? AND feature=?", (email, feature))
|
| 1013 |
-
if existing:
|
| 1014 |
-
await _db_write("UPDATE feature_usage SET use_count=use_count+1, last_used_at=? WHERE email=? AND feature=?",
|
| 1015 |
-
(now, email, feature))
|
| 1016 |
-
else:
|
| 1017 |
-
await _db_write("INSERT INTO feature_usage (email,feature,use_count,last_used_at) VALUES (?,?,1,?)",
|
| 1018 |
-
(email, feature, now))
|
| 1019 |
-
|
| 1020 |
-
return {"status": "ok"}
|
| 1021 |
-
except Exception as e:
|
| 1022 |
-
print(f"[user-log] error: {e}")
|
| 1023 |
-
return JSONResponse({"error": str(e)[:100]}, status_code=500)
|
| 1024 |
-
|
| 1025 |
-
|
| 1026 |
-
@app.get("/api/user/profile")
|
| 1027 |
-
async def user_profile(email: str = ""):
|
| 1028 |
-
"""사용자 프로필 + 활동 요약"""
|
| 1029 |
-
email = email.strip().lower()
|
| 1030 |
-
if not email: return JSONResponse({"error": "email required"}, status_code=400)
|
| 1031 |
-
user = await _db_read_one("SELECT * FROM users WHERE email=?", (email,))
|
| 1032 |
-
if not user: return JSONResponse({"error": "user not found"}, status_code=404)
|
| 1033 |
-
user_dict = dict(user)
|
| 1034 |
-
|
| 1035 |
-
# 최근 방문 TOP 10
|
| 1036 |
-
visits = await _db_read("SELECT domain, SUM(visit_count) as cnt FROM page_visits WHERE email=? GROUP BY domain ORDER BY cnt DESC LIMIT 10", (email,))
|
| 1037 |
-
user_dict["top_domains"] = [{"domain": dict(v)["domain"], "count": dict(v)["cnt"]} for v in visits]
|
| 1038 |
-
|
| 1039 |
-
# 기능 사용 통계
|
| 1040 |
-
usage = await _db_read("SELECT feature, use_count FROM feature_usage WHERE email=? ORDER BY use_count DESC", (email,))
|
| 1041 |
-
user_dict["feature_usage"] = [{"feature": dict(u)["feature"], "count": dict(u)["use_count"]} for u in usage]
|
| 1042 |
-
|
| 1043 |
-
# 최근 입력 5건
|
| 1044 |
-
inputs = await _db_read("SELECT input_text, feature, url, created_at FROM user_inputs WHERE email=? ORDER BY created_at DESC LIMIT 10", (email,))
|
| 1045 |
-
user_dict["recent_inputs"] = [dict(i) for i in inputs]
|
| 1046 |
-
|
| 1047 |
-
return user_dict
|
| 1048 |
-
|
| 1049 |
-
|
| 1050 |
-
@app.delete("/api/admin/user/{email}")
|
| 1051 |
-
async def admin_delete_user(email: str, request: Request):
|
| 1052 |
-
"""관리자: 사용자 및 관련 데이터 삭제"""
|
| 1053 |
-
admin_email = request.headers.get("X-Admin-Email", "")
|
| 1054 |
-
if admin_email != ADMIN_EMAIL:
|
| 1055 |
-
return JSONResponse({"error": "unauthorized"}, status_code=403)
|
| 1056 |
-
await _db_write("DELETE FROM user_inputs WHERE email=?", (email,))
|
| 1057 |
-
await _db_write("DELETE FROM page_visits WHERE email=?", (email,))
|
| 1058 |
-
await _db_write("DELETE FROM feature_usage WHERE email=?", (email,))
|
| 1059 |
-
await _db_write("DELETE FROM users WHERE email=?", (email,))
|
| 1060 |
-
return {"status": "deleted", "email": email}
|
| 1061 |
-
|
| 1062 |
-
|
| 1063 |
-
@app.delete("/api/admin/record/{table}/{record_id}")
|
| 1064 |
-
async def admin_delete_record(table: str, record_id: int, request: Request):
|
| 1065 |
-
"""관리자: 개별 레코드 삭제"""
|
| 1066 |
-
admin_email = request.headers.get("X-Admin-Email", "")
|
| 1067 |
-
if admin_email != ADMIN_EMAIL:
|
| 1068 |
-
return JSONResponse({"error": "unauthorized"}, status_code=403)
|
| 1069 |
-
allowed = {"users","page_visits","user_inputs","feature_usage"}
|
| 1070 |
-
if table not in allowed:
|
| 1071 |
-
return JSONResponse({"error": f"table not allowed: {table}"}, status_code=400)
|
| 1072 |
-
await _db_write(f"DELETE FROM {table} WHERE id=?", (record_id,))
|
| 1073 |
-
return {"status": "deleted", "table": table, "id": record_id}
|
| 1074 |
-
|
| 1075 |
-
|
| 1076 |
-
@app.get("/api/admin/dashboard")
|
| 1077 |
-
async def admin_dashboard(request: Request):
|
| 1078 |
-
"""관리자: 전체 통계 대시보드"""
|
| 1079 |
-
admin_email = request.query_params.get("admin", "")
|
| 1080 |
-
if admin_email != ADMIN_EMAIL:
|
| 1081 |
-
return JSONResponse({"error": "unauthorized"}, status_code=403)
|
| 1082 |
-
users = await _db_read("SELECT COUNT(*) as cnt FROM users")
|
| 1083 |
-
visits = await _db_read("SELECT COUNT(*) as cnt FROM page_visits")
|
| 1084 |
-
inputs = await _db_read("SELECT COUNT(*) as cnt FROM user_inputs")
|
| 1085 |
-
|
| 1086 |
-
top_domains = await _db_read("SELECT domain, SUM(visit_count) as cnt FROM page_visits GROUP BY domain ORDER BY cnt DESC LIMIT 20")
|
| 1087 |
-
top_features = await _db_read("SELECT feature, SUM(use_count) as cnt FROM feature_usage GROUP BY feature ORDER BY cnt DESC")
|
| 1088 |
-
recent_users = await _db_read("SELECT email, nickname, role, visit_count, last_seen_at FROM users ORDER BY last_seen_at DESC LIMIT 20")
|
| 1089 |
-
|
| 1090 |
-
return {
|
| 1091 |
-
"total_users": dict(users[0])["cnt"],
|
| 1092 |
-
"total_visits": dict(visits[0])["cnt"],
|
| 1093 |
-
"total_inputs": dict(inputs[0])["cnt"],
|
| 1094 |
-
"top_domains": [dict(d) for d in top_domains],
|
| 1095 |
-
"top_features": [dict(f) for f in top_features],
|
| 1096 |
-
"recent_users": [dict(u) for u in recent_users],
|
| 1097 |
-
}
|
| 1098 |
-
|
| 1099 |
-
|
| 1100 |
-
@app.get("/admin")
|
| 1101 |
-
async def admin_page(request: Request):
|
| 1102 |
-
"""관리자 대시보드 HTML UI"""
|
| 1103 |
-
admin = request.query_params.get("key", "")
|
| 1104 |
-
if admin != ADMIN_EMAIL:
|
| 1105 |
-
return HTMLResponse("<h1>🔒 접근 거부</h1><p>?key=관리자이메일 필요</p>", status_code=403)
|
| 1106 |
-
html_path = Path(__file__).parent / "admin_dashboard.html"
|
| 1107 |
-
if html_path.exists():
|
| 1108 |
-
return HTMLResponse(html_path.read_text(encoding="utf-8"))
|
| 1109 |
-
return HTMLResponse("<h1>❌ admin_dashboard.html not found</h1>", status_code=500)
|
| 1110 |
-
|
| 1111 |
-
|
| 1112 |
-
@app.get("/api/admin/all-visits")
|
| 1113 |
-
async def admin_all_visits(request: Request):
|
| 1114 |
-
"""관리자: 전체 방문 기록"""
|
| 1115 |
-
admin_email = request.query_params.get("admin", "")
|
| 1116 |
-
if admin_email != ADMIN_EMAIL:
|
| 1117 |
-
return JSONResponse({"error": "unauthorized"}, status_code=403)
|
| 1118 |
-
rows = await _db_read(
|
| 1119 |
-
"SELECT id,email,url_hash,url,title,domain,visit_count,last_visited_at FROM page_visits ORDER BY last_visited_at DESC LIMIT 100"
|
| 1120 |
-
)
|
| 1121 |
-
return {"visits": [dict(r) for r in rows]}
|
| 1122 |
-
|
| 1123 |
-
|
| 1124 |
-
@app.get("/api/admin/inputs")
|
| 1125 |
-
async def admin_inputs(request: Request):
|
| 1126 |
-
"""관리자: 최근 입력 로그"""
|
| 1127 |
-
admin_email = request.query_params.get("admin", "")
|
| 1128 |
-
if admin_email != ADMIN_EMAIL:
|
| 1129 |
-
return JSONResponse({"error": "unauthorized"}, status_code=403)
|
| 1130 |
-
rows = await _db_read("SELECT id,email,feature,input_text,url,created_at FROM user_inputs ORDER BY created_at DESC LIMIT 100")
|
| 1131 |
-
return {"inputs": [dict(r) for r in rows]}
|
| 1132 |
-
|
| 1133 |
-
|
| 1134 |
-
@app.get("/api/stats")
|
| 1135 |
-
async def stats():
|
| 1136 |
-
return JSONResponse(calc_stats())
|
| 1137 |
-
|
| 1138 |
-
|
| 1139 |
-
|
| 1140 |
-
|
| 1141 |
-
# ── MARL 테스트 (디버그용) ────────────────────────────────────
|
| 1142 |
-
@app.get("/api/marl-test")
|
| 1143 |
-
async def marl_test():
|
| 1144 |
-
_init_marl()
|
| 1145 |
-
result = {"marl_available": MARL_AVAILABLE, "groq_key_set": bool(GROQ_API_KEY)}
|
| 1146 |
-
if MARL_AVAILABLE:
|
| 1147 |
-
try:
|
| 1148 |
-
config = MarlConfig(mode="insight", return_final_only=True, language="ko", budget_scale=0.5)
|
| 1149 |
-
ml = Marl(call_fn=_groq_call_fn, config=config)
|
| 1150 |
-
import asyncio
|
| 1151 |
-
r = await asyncio.to_thread(ml.run, "1+1=? 한 줄로 답하라.", "한국어로 답변")
|
| 1152 |
-
result["test_answer"] = r.answer[:200] if r.answer else "EMPTY"
|
| 1153 |
-
result["test_elapsed"] = round(r.elapsed, 2)
|
| 1154 |
-
result["test_ok"] = bool(r.answer and not r.answer.startswith("[ERROR"))
|
| 1155 |
-
except Exception as e:
|
| 1156 |
-
result["test_error"] = str(e)
|
| 1157 |
-
result["test_ok"] = False
|
| 1158 |
-
else:
|
| 1159 |
-
result["test_ok"] = False
|
| 1160 |
-
result["test_error"] = "MARL not available"
|
| 1161 |
-
return JSONResponse(result)
|
| 1162 |
-
|
| 1163 |
-
|
| 1164 |
-
# ── 정적 파일 ────────────────────────────────────────────────────
|
| 1165 |
-
@app.get("/secure-pageagent.extend.js")
|
| 1166 |
-
async def serve_js():
|
| 1167 |
-
if _PAGE_AGENT_JS:
|
| 1168 |
-
return Response(_PAGE_AGENT_JS, media_type="application/javascript")
|
| 1169 |
-
return JSONResponse({"error": "js not found"}, status_code=404)
|
| 1170 |
-
|
| 1171 |
-
@app.get("/style.css")
|
| 1172 |
-
async def serve_css():
|
| 1173 |
-
return Response("", media_type="text/css")
|
| 1174 |
-
|
| 1175 |
-
@app.get("/")
|
| 1176 |
-
@app.get("/index.html")
|
| 1177 |
-
async def serve_index():
|
| 1178 |
-
path = "index.html"
|
| 1179 |
-
if not os.path.isfile(path):
|
| 1180 |
-
return JSONResponse({"error": "index.html not found"}, status_code=404)
|
| 1181 |
-
return FileResponse(path, media_type="text/html")
|
| 1182 |
-
|
| 1183 |
-
|
| 1184 |
-
|
| 1185 |
-
|
| 1186 |
-
|
| 1187 |
-
# ── MARL 통합 (메타인지 + 창발성) ─────────────────────────────
|
| 1188 |
-
# ── 이미지 생성 (fal.ai xai/grok-imagine) ────────────────────
|
| 1189 |
-
@app.post("/api/imagine")
|
| 1190 |
-
async def imagine_endpoint(request: Request):
|
| 1191 |
-
"""fal.ai Grok Imagine으로 이미지 생성 + Ginigen.AI 워터마크"""
|
| 1192 |
-
if not FAL_KEY:
|
| 1193 |
-
return JSONResponse({"error": "FAL_KEY not set"}, status_code=500)
|
| 1194 |
-
|
| 1195 |
-
try:
|
| 1196 |
-
body = await request.json()
|
| 1197 |
-
except Exception:
|
| 1198 |
-
return JSONResponse({"error": "invalid json"}, status_code=400)
|
| 1199 |
-
|
| 1200 |
-
prompt = _sanitize_text(body.get("prompt", ""))
|
| 1201 |
-
_marl_email = body.get("sa_email", "")
|
| 1202 |
-
_marl_url = body.get("sa_url", "")
|
| 1203 |
-
# ★ MARL 입력 자동 로깅
|
| 1204 |
-
if _marl_email and prompt:
|
| 1205 |
-
try:
|
| 1206 |
-
import time as _t, hashlib as _h2
|
| 1207 |
-
_uh2 = _h2.sha256(_marl_url.encode()).hexdigest()[:16] if _marl_url else ""
|
| 1208 |
-
_feat2 = "deep_analysis" if mode == "insight" else ("idea_" + engine if mode == "emergence" else "marl")
|
| 1209 |
-
_asyncio.create_task(_db_write(
|
| 1210 |
-
"INSERT INTO user_inputs (email,url_hash,url,input_type,input_text,feature,created_at) VALUES (?,?,?,?,?,?,?)",
|
| 1211 |
-
(_marl_email, _uh2, _marl_url[:500], "marl", prompt[:1000], _feat2, _t.time())
|
| 1212 |
-
))
|
| 1213 |
-
_asyncio.create_task(_db_write(
|
| 1214 |
-
"INSERT INTO feature_usage (email,feature,use_count,last_used_at) VALUES (?,?,1,?) ON CONFLICT(email,feature) DO UPDATE SET use_count=use_count+1, last_used_at=?",
|
| 1215 |
-
(_marl_email, _feat2, _t.time(), _t.time())
|
| 1216 |
-
))
|
| 1217 |
-
except Exception as _le2:
|
| 1218 |
-
print(f"[log] marl input logging error: {_le2}")
|
| 1219 |
-
if not prompt:
|
| 1220 |
-
return JSONResponse({"error": "prompt required"}, status_code=400)
|
| 1221 |
-
|
| 1222 |
-
aspect = body.get("aspect_ratio", "1:1")
|
| 1223 |
-
num = min(int(body.get("num_images", 1)), 4)
|
| 1224 |
-
|
| 1225 |
-
import httpx as hx
|
| 1226 |
-
import asyncio
|
| 1227 |
-
|
| 1228 |
-
async def _generate_one(idx):
|
| 1229 |
-
"""단일 이미지 생성 (fal.ai queue)"""
|
| 1230 |
-
try:
|
| 1231 |
-
async with hx.AsyncClient(timeout=120.0) as client:
|
| 1232 |
-
# 1) Submit
|
| 1233 |
-
sub_resp = await client.post(
|
| 1234 |
-
"https://queue.fal.run/xai/grok-imagine-image",
|
| 1235 |
-
headers={"Authorization": f"Key {FAL_KEY}", "Content-Type": "application/json"},
|
| 1236 |
-
json={"prompt": prompt, "num_images": 1, "aspect_ratio": aspect, "output_format": "jpeg"}
|
| 1237 |
-
)
|
| 1238 |
-
if sub_resp.status_code != 200:
|
| 1239 |
-
return {"error": f"submit failed: {sub_resp.status_code}"}
|
| 1240 |
-
|
| 1241 |
-
data = sub_resp.json()
|
| 1242 |
-
|
| 1243 |
-
# fal.ai가 즉시 결과를 줄 수도 있고, request_id를 줄 수도 있음
|
| 1244 |
-
if "images" in data:
|
| 1245 |
-
img_url = data["images"][0]["url"]
|
| 1246 |
-
elif "request_id" in data:
|
| 1247 |
-
req_id = data["request_id"]
|
| 1248 |
-
# 2) Poll for result
|
| 1249 |
-
for _ in range(60): # 최대 60초
|
| 1250 |
-
await asyncio.sleep(1)
|
| 1251 |
-
status_resp = await client.get(
|
| 1252 |
-
f"https://queue.fal.run/xai/grok-imagine-image/requests/{req_id}/status",
|
| 1253 |
-
headers={"Authorization": f"Key {FAL_KEY}"}
|
| 1254 |
-
)
|
| 1255 |
-
st = status_resp.json()
|
| 1256 |
-
if st.get("status") == "COMPLETED":
|
| 1257 |
-
result_resp = await client.get(
|
| 1258 |
-
f"https://queue.fal.run/xai/grok-imagine-image/requests/{req_id}",
|
| 1259 |
-
headers={"Authorization": f"Key {FAL_KEY}"}
|
| 1260 |
-
)
|
| 1261 |
-
rdata = result_resp.json()
|
| 1262 |
-
img_url = rdata.get("images", [{}])[0].get("url", "")
|
| 1263 |
-
break
|
| 1264 |
-
elif st.get("status") in ("FAILED", "CANCELLED"):
|
| 1265 |
-
return {"error": "generation failed"}
|
| 1266 |
-
else:
|
| 1267 |
-
return {"error": "timeout"}
|
| 1268 |
-
else:
|
| 1269 |
-
return {"error": "unexpected response"}
|
| 1270 |
-
|
| 1271 |
-
if not img_url:
|
| 1272 |
-
return {"error": "no image url"}
|
| 1273 |
-
|
| 1274 |
-
# 3) 이미지 다운로드 + 워터마크
|
| 1275 |
-
img_resp = await client.get(img_url)
|
| 1276 |
-
if img_resp.status_code != 200:
|
| 1277 |
-
return {"url": img_url} # 워터마크 없이 원본 URL 반환
|
| 1278 |
-
|
| 1279 |
-
# PIL 워터마크
|
| 1280 |
-
try:
|
| 1281 |
-
from io import BytesIO
|
| 1282 |
-
from PIL import Image, ImageDraw, ImageFont
|
| 1283 |
-
import base64
|
| 1284 |
-
|
| 1285 |
-
img = Image.open(BytesIO(img_resp.content)).convert("RGBA")
|
| 1286 |
-
overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
|
| 1287 |
-
draw = ImageDraw.Draw(overlay)
|
| 1288 |
-
|
| 1289 |
-
# 워터마크 텍스트
|
| 1290 |
-
wm_text = "Ginigen.AI"
|
| 1291 |
-
font_size = max(14, img.width // 30)
|
| 1292 |
-
try:
|
| 1293 |
-
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", font_size)
|
| 1294 |
-
except:
|
| 1295 |
-
font = ImageFont.load_default()
|
| 1296 |
-
|
| 1297 |
-
bbox = draw.textbbox((0, 0), wm_text, font=font)
|
| 1298 |
-
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
| 1299 |
-
pad = 8
|
| 1300 |
-
x = img.width - tw - pad * 2 - 10
|
| 1301 |
-
y = img.height - th - pad * 2 - 10
|
| 1302 |
-
|
| 1303 |
-
# 반투명 배경 박스
|
| 1304 |
-
draw.rectangle([x - pad, y - pad, x + tw + pad, y + th + pad], fill=(0, 0, 0, 120))
|
| 1305 |
-
draw.text((x, y), wm_text, font=font, fill=(255, 255, 255, 200))
|
| 1306 |
-
|
| 1307 |
-
result_img = Image.alpha_composite(img, overlay).convert("RGB")
|
| 1308 |
-
|
| 1309 |
-
buf = BytesIO()
|
| 1310 |
-
result_img.save(buf, format="JPEG", quality=92)
|
| 1311 |
-
b64 = base64.b64encode(buf.getvalue()).decode()
|
| 1312 |
-
return {"data_uri": f"data:image/jpeg;base64,{b64}", "width": img.width, "height": img.height}
|
| 1313 |
-
except Exception as e:
|
| 1314 |
-
print(f"[imagine] watermark failed: {e}, returning raw url")
|
| 1315 |
-
return {"url": img_url}
|
| 1316 |
-
|
| 1317 |
-
except Exception as e:
|
| 1318 |
-
print(f"[imagine] error: {e}")
|
| 1319 |
-
return {"error": str(e)}
|
| 1320 |
-
|
| 1321 |
-
# 병렬 생성
|
| 1322 |
-
tasks = [_generate_one(i) for i in range(num)]
|
| 1323 |
-
results = await asyncio.gather(*tasks)
|
| 1324 |
-
|
| 1325 |
-
return JSONResponse({
|
| 1326 |
-
"images": results,
|
| 1327 |
-
"prompt": prompt,
|
| 1328 |
-
"revised_prompt": "",
|
| 1329 |
-
})
|
| 1330 |
-
|
| 1331 |
-
|
| 1332 |
-
# ── 이미지 편집 (fal.ai xai/grok-imagine edit) ───────────────
|
| 1333 |
-
@app.post("/api/imagine-edit")
|
| 1334 |
-
async def imagine_edit_endpoint(request: Request):
|
| 1335 |
-
"""fal.ai Grok Imagine Edit — 이미지+프롬프트로 편집 + 워터마크"""
|
| 1336 |
-
if not FAL_KEY:
|
| 1337 |
-
return JSONResponse({"error": "FAL_KEY not set"}, status_code=500)
|
| 1338 |
-
|
| 1339 |
-
import json as _json
|
| 1340 |
-
try:
|
| 1341 |
-
raw = await request.body()
|
| 1342 |
-
raw_str = raw.decode('utf-8', errors='replace')
|
| 1343 |
-
print(f"[imagine-edit] body size: {len(raw)} bytes, first 50: {raw_str[:50]}")
|
| 1344 |
-
body = _json.loads(raw_str)
|
| 1345 |
-
except Exception as e:
|
| 1346 |
-
print(f"[imagine-edit] JSON parse error: {e}")
|
| 1347 |
-
return JSONResponse({"error": f"JSON parse error: {str(e)[:100]}"}, status_code=400)
|
| 1348 |
-
|
| 1349 |
-
prompt = _sanitize_text(body.get("prompt", ""))
|
| 1350 |
-
image_url = body.get("image_url", "")
|
| 1351 |
-
|
| 1352 |
-
# 클라이언트에서 1024px로 압축된 data URI → fal.ai에 직접 전달
|
| 1353 |
-
print(f"[imagine-edit] image_url type: {'data_uri' if image_url.startswith('data:') else 'url'}, size: {len(image_url)} chars")
|
| 1354 |
-
if not prompt:
|
| 1355 |
-
return JSONResponse({"error": "prompt required"}, status_code=400)
|
| 1356 |
-
if not image_url:
|
| 1357 |
-
return JSONResponse({"error": "image_url required"}, status_code=400)
|
| 1358 |
-
|
| 1359 |
-
import httpx as hx
|
| 1360 |
-
import asyncio
|
| 1361 |
-
|
| 1362 |
-
try:
|
| 1363 |
-
async with hx.AsyncClient(timeout=120.0) as client:
|
| 1364 |
-
# ★ Step 0: data URI → fal.ai storage 업로드 → hosted URL
|
| 1365 |
-
actual_image_url = image_url
|
| 1366 |
-
if image_url.startswith("data:"):
|
| 1367 |
-
import base64 as b64mod
|
| 1368 |
-
try:
|
| 1369 |
-
header, b64data = image_url.split(",", 1)
|
| 1370 |
-
img_bytes = b64mod.b64decode(b64data)
|
| 1371 |
-
ct = "image/jpeg"
|
| 1372 |
-
if "png" in header: ct = "image/png"
|
| 1373 |
-
|
| 1374 |
-
print(f"[imagine-edit] Uploading {len(img_bytes)} bytes to fal.ai storage...")
|
| 1375 |
-
up_resp = await client.put(
|
| 1376 |
-
"https://rest.alpha.fal.ai/storage/upload",
|
| 1377 |
-
headers={
|
| 1378 |
-
"Authorization": f"Key {FAL_KEY}",
|
| 1379 |
-
"Content-Type": ct,
|
| 1380 |
-
"X-Fal-File-Name": "edit_input.jpg",
|
| 1381 |
-
},
|
| 1382 |
-
content=img_bytes,
|
| 1383 |
-
timeout=30.0
|
| 1384 |
-
)
|
| 1385 |
-
print(f"[imagine-edit] Upload status: {up_resp.status_code}, body: {up_resp.text[:200]}")
|
| 1386 |
-
|
| 1387 |
-
if up_resp.status_code == 200:
|
| 1388 |
-
up_data = up_resp.json()
|
| 1389 |
-
actual_image_url = up_data.get("url", up_data.get("file_url", ""))
|
| 1390 |
-
print(f"[imagine-edit] Uploaded URL: {actual_image_url[:100]}")
|
| 1391 |
-
else:
|
| 1392 |
-
# PUT 실패 → POST 시도
|
| 1393 |
-
up_resp2 = await client.post(
|
| 1394 |
-
"https://rest.alpha.fal.ai/storage/upload",
|
| 1395 |
-
headers={
|
| 1396 |
-
"Authorization": f"Key {FAL_KEY}",
|
| 1397 |
-
"Content-Type": ct,
|
| 1398 |
-
},
|
| 1399 |
-
content=img_bytes,
|
| 1400 |
-
timeout=30.0
|
| 1401 |
-
)
|
| 1402 |
-
print(f"[imagine-edit] Upload POST status: {up_resp2.status_code}, body: {up_resp2.text[:200]}")
|
| 1403 |
-
if up_resp2.status_code == 200:
|
| 1404 |
-
up_data2 = up_resp2.json()
|
| 1405 |
-
actual_image_url = up_data2.get("url", up_data2.get("file_url", ""))
|
| 1406 |
-
else:
|
| 1407 |
-
# 최후 수단: data URI 그대로
|
| 1408 |
-
actual_image_url = image_url
|
| 1409 |
-
print("[imagine-edit] Upload failed, using data URI directly")
|
| 1410 |
-
except Exception as e:
|
| 1411 |
-
print(f"[imagine-edit] Upload error: {e}, using data URI")
|
| 1412 |
-
actual_image_url = image_url
|
| 1413 |
-
|
| 1414 |
-
# 1) Submit edit request
|
| 1415 |
-
submit_payload = {"prompt": prompt, "num_images": 1, "output_format": "jpeg", "image_urls": [actual_image_url]}
|
| 1416 |
-
print(f"[imagine-edit] Submit: image_url type={'hosted' if actual_image_url.startswith('http') else 'data_uri'}, len={len(actual_image_url)}")
|
| 1417 |
-
|
| 1418 |
-
sub_resp = await client.post(
|
| 1419 |
-
"https://queue.fal.run/xai/grok-imagine-image/edit",
|
| 1420 |
-
headers={"Authorization": f"Key {FAL_KEY}", "Content-Type": "application/json"},
|
| 1421 |
-
json=submit_payload
|
| 1422 |
-
)
|
| 1423 |
-
|
| 1424 |
-
print(f"[imagine-edit] Submit status: {sub_resp.status_code}")
|
| 1425 |
-
sub_text = sub_resp.text
|
| 1426 |
-
print(f"[imagine-edit] Submit response: {sub_text[:300]}")
|
| 1427 |
-
|
| 1428 |
-
if sub_resp.status_code != 200:
|
| 1429 |
-
return JSONResponse({"error": f"submit failed: {sub_resp.status_code} - {sub_text[:100]}"}, status_code=502)
|
| 1430 |
-
|
| 1431 |
-
try:
|
| 1432 |
-
data = sub_resp.json()
|
| 1433 |
-
except Exception as je:
|
| 1434 |
-
print(f"[imagine-edit] JSON parse error on submit response: {je}")
|
| 1435 |
-
return JSONResponse({"error": f"fal.ai response parse error: {sub_text[:100]}"}, status_code=502)
|
| 1436 |
-
img_url = ""
|
| 1437 |
-
|
| 1438 |
-
if "images" in data:
|
| 1439 |
-
img_url = data["images"][0]["url"]
|
| 1440 |
-
elif "request_id" in data:
|
| 1441 |
-
# fal.ai가 준 URL 직접 사용 (edit 경로 차이 자동 대응)
|
| 1442 |
-
status_url = data.get("status_url", "")
|
| 1443 |
-
response_url = data.get("response_url", "")
|
| 1444 |
-
if not status_url:
|
| 1445 |
-
req_id = data["request_id"]
|
| 1446 |
-
status_url = f"https://queue.fal.run/xai/grok-imagine-image/edit/requests/{req_id}/status"
|
| 1447 |
-
response_url = f"https://queue.fal.run/xai/grok-imagine-image/edit/requests/{req_id}"
|
| 1448 |
-
|
| 1449 |
-
print(f"[imagine-edit] Polling: {status_url}")
|
| 1450 |
-
for poll_i in range(60):
|
| 1451 |
-
await asyncio.sleep(1)
|
| 1452 |
-
try:
|
| 1453 |
-
status_resp = await client.get(status_url, headers={"Authorization": f"Key {FAL_KEY}"})
|
| 1454 |
-
st_text = status_resp.text
|
| 1455 |
-
st = status_resp.json()
|
| 1456 |
-
cur_status = st.get("status", "")
|
| 1457 |
-
if poll_i % 5 == 0:
|
| 1458 |
-
print(f"[imagine-edit] Poll {poll_i}: {cur_status}")
|
| 1459 |
-
|
| 1460 |
-
if cur_status == "COMPLETED":
|
| 1461 |
-
result_resp = await client.get(response_url, headers={"Authorization": f"Key {FAL_KEY}"})
|
| 1462 |
-
rdata = result_resp.json()
|
| 1463 |
-
img_url = rdata.get("images", [{}])[0].get("url", "")
|
| 1464 |
-
print(f"[imagine-edit] Completed! img_url: {img_url[:80] if img_url else 'NONE'}")
|
| 1465 |
-
break
|
| 1466 |
-
elif cur_status in ("FAILED", "CANCELLED"):
|
| 1467 |
-
err_msg = st.get("error", "edit failed")
|
| 1468 |
-
print(f"[imagine-edit] Failed: {err_msg}")
|
| 1469 |
-
return JSONResponse({"error": f"edit failed: {err_msg}"}, status_code=502)
|
| 1470 |
-
except Exception as pe:
|
| 1471 |
-
print(f"[imagine-edit] Poll error: {pe}")
|
| 1472 |
-
continue
|
| 1473 |
-
else:
|
| 1474 |
-
return JSONResponse({"error": "timeout (60s)"}, status_code=504)
|
| 1475 |
-
else:
|
| 1476 |
-
print(f"[imagine-edit] Unexpected response: {str(data)[:200]}")
|
| 1477 |
-
return JSONResponse({"error": "unexpected response"}, status_code=502)
|
| 1478 |
-
|
| 1479 |
-
if not img_url:
|
| 1480 |
-
return JSONResponse({"error": "no image url"}, status_code=502)
|
| 1481 |
-
|
| 1482 |
-
# 2) 이미지 다운로드 + 워터마크
|
| 1483 |
-
img_resp = await client.get(img_url)
|
| 1484 |
-
if img_resp.status_code != 200:
|
| 1485 |
-
return JSONResponse({"images": [{"url": img_url}], "prompt": prompt})
|
| 1486 |
-
|
| 1487 |
-
try:
|
| 1488 |
-
from io import BytesIO
|
| 1489 |
-
from PIL import Image, ImageDraw, ImageFont
|
| 1490 |
-
import base64
|
| 1491 |
-
|
| 1492 |
-
img = Image.open(BytesIO(img_resp.content)).convert("RGBA")
|
| 1493 |
-
overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
|
| 1494 |
-
draw = ImageDraw.Draw(overlay)
|
| 1495 |
-
wm_text = "Ginigen.AI"
|
| 1496 |
-
font_size = max(14, img.width // 30)
|
| 1497 |
-
try:
|
| 1498 |
-
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", font_size)
|
| 1499 |
-
except:
|
| 1500 |
-
font = ImageFont.load_default()
|
| 1501 |
-
bbox = draw.textbbox((0, 0), wm_text, font=font)
|
| 1502 |
-
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
| 1503 |
-
pad = 8
|
| 1504 |
-
x = img.width - tw - pad * 2 - 10
|
| 1505 |
-
y = img.height - th - pad * 2 - 10
|
| 1506 |
-
draw.rectangle([x - pad, y - pad, x + tw + pad, y + th + pad], fill=(0, 0, 0, 120))
|
| 1507 |
-
draw.text((x, y), wm_text, font=font, fill=(255, 255, 255, 200))
|
| 1508 |
-
result_img = Image.alpha_composite(img, overlay).convert("RGB")
|
| 1509 |
-
buf = BytesIO()
|
| 1510 |
-
result_img.save(buf, format="JPEG", quality=92)
|
| 1511 |
-
b64 = base64.b64encode(buf.getvalue()).decode()
|
| 1512 |
-
return JSONResponse({"images": [{"data_uri": f"data:image/jpeg;base64,{b64}", "width": img.width, "height": img.height}], "prompt": prompt})
|
| 1513 |
-
except Exception as e:
|
| 1514 |
-
print(f"[imagine-edit] watermark failed: {e}")
|
| 1515 |
-
return JSONResponse({"images": [{"url": img_url}], "prompt": prompt})
|
| 1516 |
-
|
| 1517 |
-
except Exception as e:
|
| 1518 |
-
print(f"[imagine-edit] error: {e}")
|
| 1519 |
-
return JSONResponse({"error": str(e)}, status_code=500)
|
| 1520 |
-
|
| 1521 |
-
|
| 1522 |
-
# core.py lazy import — 실패해도 서버 시작 차단 안 함
|
| 1523 |
-
MARL_AVAILABLE = False
|
| 1524 |
-
Marl = None
|
| 1525 |
-
MarlConfig = None
|
| 1526 |
-
|
| 1527 |
-
def _init_marl():
|
| 1528 |
-
global MARL_AVAILABLE, Marl, MarlConfig
|
| 1529 |
-
if Marl is not None:
|
| 1530 |
-
return MARL_AVAILABLE
|
| 1531 |
-
try:
|
| 1532 |
-
import sys
|
| 1533 |
-
_dir = os.path.dirname(os.path.abspath(__file__))
|
| 1534 |
-
if _dir not in sys.path:
|
| 1535 |
-
sys.path.insert(0, _dir)
|
| 1536 |
-
from core import Marl as _M, MarlConfig as _MC
|
| 1537 |
-
Marl = _M
|
| 1538 |
-
MarlConfig = _MC
|
| 1539 |
-
MARL_AVAILABLE = True
|
| 1540 |
-
print("✅ MARL core loaded (lazy)")
|
| 1541 |
-
except Exception as e:
|
| 1542 |
-
MARL_AVAILABLE = False
|
| 1543 |
-
print(f"⚠️ MARL core not available: {e}")
|
| 1544 |
-
import traceback; traceback.print_exc()
|
| 1545 |
-
return MARL_AVAILABLE
|
| 1546 |
-
|
| 1547 |
-
|
| 1548 |
-
def _groq_call_fn(prompt: str, system: str, max_tokens: int, temperature: float) -> str:
|
| 1549 |
-
"""Groq API를 사용하는 MARL call_fn (sync, httpx)"""
|
| 1550 |
-
import httpx as hx
|
| 1551 |
-
prompt = _sanitize_text(prompt)
|
| 1552 |
-
system = _sanitize_text(system)
|
| 1553 |
-
if not GROQ_API_KEY:
|
| 1554 |
-
print("[marl-call] ERROR: no API key")
|
| 1555 |
-
return "[ERROR] GROQ_API_KEY not set"
|
| 1556 |
-
try:
|
| 1557 |
-
print(f"[marl-call] system={system[:60]}... prompt={prompt[:60]}... max_tok={max_tokens} temp={temperature}")
|
| 1558 |
-
resp = hx.post(
|
| 1559 |
-
GROQ_URL,
|
| 1560 |
-
headers={"Authorization": f"Bearer {GROQ_API_KEY}", "Content-Type": "application/json"},
|
| 1561 |
-
json={
|
| 1562 |
-
"model": "openai/gpt-oss-120b",
|
| 1563 |
-
"messages": [
|
| 1564 |
-
{"role": "system", "content": system[:4000]},
|
| 1565 |
-
{"role": "user", "content": prompt[:12000]}
|
| 1566 |
-
],
|
| 1567 |
-
"max_completion_tokens": min(max_tokens, 4096),
|
| 1568 |
-
"temperature": temperature,
|
| 1569 |
-
"stream": False
|
| 1570 |
-
},
|
| 1571 |
-
timeout=120.0
|
| 1572 |
-
)
|
| 1573 |
-
if resp.status_code != 200:
|
| 1574 |
-
print(f"[marl-call] HTTP {resp.status_code}: {resp.text[:200]}")
|
| 1575 |
-
return f"[ERROR] HTTP {resp.status_code}"
|
| 1576 |
-
data = resp.json()
|
| 1577 |
-
content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
| 1578 |
-
print(f"[marl-call] OK: {len(content)} chars")
|
| 1579 |
-
return content if content else "[ERROR] Empty response"
|
| 1580 |
-
except Exception as e:
|
| 1581 |
-
print(f"[marl-call] EXCEPTION: {e}")
|
| 1582 |
-
return f"[ERROR] {e}"
|
| 1583 |
-
|
| 1584 |
-
|
| 1585 |
-
@app.post("/api/marl")
|
| 1586 |
-
async def marl_endpoint(request: Request):
|
| 1587 |
-
"""MARL 5단계 파이프라인 — 메타인지(insight) 또는 창발성(emergence)"""
|
| 1588 |
-
_init_marl()
|
| 1589 |
-
if not MARL_AVAILABLE:
|
| 1590 |
-
return JSONResponse({"error": "MARL core not available. Check server logs."}, status_code=500)
|
| 1591 |
-
if not GROQ_API_KEY:
|
| 1592 |
-
return JSONResponse({"error": "GROQ_API_KEY not set"}, status_code=500)
|
| 1593 |
-
|
| 1594 |
-
try:
|
| 1595 |
-
body = await request.json()
|
| 1596 |
-
except Exception:
|
| 1597 |
-
return JSONResponse({"error": "invalid json"}, status_code=400)
|
| 1598 |
-
|
| 1599 |
-
prompt = _sanitize_text(body.get("prompt", ""))
|
| 1600 |
-
_marl_email = body.get("sa_email", "")
|
| 1601 |
-
_marl_url = body.get("sa_url", "")
|
| 1602 |
-
# ★ MARL 입력 자동 로깅
|
| 1603 |
-
if _marl_email and prompt:
|
| 1604 |
-
try:
|
| 1605 |
-
import time as _t, hashlib as _h2
|
| 1606 |
-
_uh2 = _h2.sha256(_marl_url.encode()).hexdigest()[:16] if _marl_url else ""
|
| 1607 |
-
_feat2 = "deep_analysis" if mode == "insight" else ("idea_" + engine if mode == "emergence" else "marl")
|
| 1608 |
-
_asyncio.create_task(_db_write(
|
| 1609 |
-
"INSERT INTO user_inputs (email,url_hash,url,input_type,input_text,feature,created_at) VALUES (?,?,?,?,?,?,?)",
|
| 1610 |
-
(_marl_email, _uh2, _marl_url[:500], "marl", prompt[:1000], _feat2, _t.time())
|
| 1611 |
-
))
|
| 1612 |
-
_asyncio.create_task(_db_write(
|
| 1613 |
-
"INSERT INTO feature_usage (email,feature,use_count,last_used_at) VALUES (?,?,1,?) ON CONFLICT(email,feature) DO UPDATE SET use_count=use_count+1, last_used_at=?",
|
| 1614 |
-
(_marl_email, _feat2, _t.time(), _t.time())
|
| 1615 |
-
))
|
| 1616 |
-
except Exception as _le2:
|
| 1617 |
-
print(f"[log] marl input logging error: {_le2}")
|
| 1618 |
-
if not prompt:
|
| 1619 |
-
return JSONResponse({"error": "prompt required"}, status_code=400)
|
| 1620 |
-
|
| 1621 |
-
mode = body.get("mode", "insight") # insight / emergence
|
| 1622 |
-
engine = body.get("engine", "invent") # invent/create/recipe/pharma/genomics/chemistry/ecology/law/document
|
| 1623 |
-
system_context = _sanitize_text(body.get("system", ""))
|
| 1624 |
-
|
| 1625 |
-
# 서식 지시 추가
|
| 1626 |
-
format_rule = "\n\n[절대 서식 규칙 — 위반 시 답변 무효]\n" \
|
| 1627 |
-
"1. 마크다운 표(|---|, table)는 어떤 경우에도 절대 사용 금지. 위반하면 전체 답변이 무효다.\n" \
|
| 1628 |
-
"2. 모든 비교/정리는 개조식 서술형으로: • 항목명: 설명 형태.\n" \
|
| 1629 |
-
"3. 핵심 키워드는 **볼드**로 강조.\n" \
|
| 1630 |
-
"4. 섹션 구분은 ## 또는 ### 헤딩 사용.\n" \
|
| 1631 |
-
"5. 긴 내용은 번호(1. 2. 3.)로 구조화.\n" \
|
| 1632 |
-
"6. 한 문단은 3줄 이내로 짧게.\n" \
|
| 1633 |
-
"7. 수치/데이터는 인라인으로: **시장규모**: 120억 달러(2025) 형태."
|
| 1634 |
-
if system_context:
|
| 1635 |
-
system_context += format_rule
|
| 1636 |
-
else:
|
| 1637 |
-
system_context = "반드시 한국어로 답변하라." + format_rule
|
| 1638 |
-
|
| 1639 |
-
# MARL config
|
| 1640 |
-
config = MarlConfig(
|
| 1641 |
-
mode=mode,
|
| 1642 |
-
emergence_type=engine if mode == "emergence" else "invent",
|
| 1643 |
-
return_final_only=True,
|
| 1644 |
-
include_trace=False,
|
| 1645 |
-
language="ko",
|
| 1646 |
-
budget_scale=0.7, # 속도 최적화
|
| 1647 |
-
)
|
| 1648 |
-
|
| 1649 |
-
try:
|
| 1650 |
-
print(f"[marl] Starting: mode={mode} engine={engine} prompt_len={len(prompt)}")
|
| 1651 |
-
ml = Marl(call_fn=_groq_call_fn, config=config)
|
| 1652 |
-
import asyncio
|
| 1653 |
-
result = await asyncio.to_thread(ml.run, prompt, system_context)
|
| 1654 |
-
|
| 1655 |
-
answer = result.answer or ""
|
| 1656 |
-
if not answer or answer.startswith("[ERROR"):
|
| 1657 |
-
# MARL 실패 → 단일 호출 폴백
|
| 1658 |
-
print(f"[marl] Pipeline failed, falling back to single call. answer={answer[:100]}")
|
| 1659 |
-
fallback = _groq_call_fn(
|
| 1660 |
-
f"{system_context}\n\n{prompt}" if system_context else prompt,
|
| 1661 |
-
"반드시 한국어로 답변하라. 깊이 있는 분석을 제공하라.",
|
| 1662 |
-
4096, 0.7
|
| 1663 |
-
)
|
| 1664 |
-
answer = fallback if fallback and not fallback.startswith("[ERROR") else "처리 중 오류가 발생했습니다."
|
| 1665 |
-
answer = _strip_md_table(answer)
|
| 1666 |
-
return JSONResponse({
|
| 1667 |
-
"answer": answer,
|
| 1668 |
-
"fixes": [],
|
| 1669 |
-
"elapsed": round(result.elapsed, 2),
|
| 1670 |
-
"mode": mode + " (fallback)",
|
| 1671 |
-
"engine": engine if mode == "emergence" else None,
|
| 1672 |
-
"metadata": {"fallback": True}
|
| 1673 |
-
})
|
| 1674 |
-
|
| 1675 |
-
# Groq 인용 태그 제거
|
| 1676 |
-
import re
|
| 1677 |
-
answer = re.sub(r'【[^】]*】', '', answer).strip()
|
| 1678 |
-
answer = _strip_md_table(answer)
|
| 1679 |
-
print(f"[marl] Success: {len(answer)} chars, {round(result.elapsed,1)}s, {len(result.fixes)} fixes")
|
| 1680 |
-
return JSONResponse({
|
| 1681 |
-
"answer": answer,
|
| 1682 |
-
"fixes": result.fixes,
|
| 1683 |
-
"elapsed": round(result.elapsed, 2),
|
| 1684 |
-
"mode": mode,
|
| 1685 |
-
"engine": engine if mode == "emergence" else None,
|
| 1686 |
-
"metadata": result.metadata
|
| 1687 |
-
})
|
| 1688 |
-
except Exception as e:
|
| 1689 |
-
print(f"[marl] EXCEPTION: {e}")
|
| 1690 |
-
import traceback; traceback.print_exc()
|
| 1691 |
-
# 최종 폴백: 단일 Groq 호출
|
| 1692 |
-
try:
|
| 1693 |
-
fallback = _groq_call_fn(prompt[:8000], "반드시 한국어로 답변하라.", 4096, 0.7)
|
| 1694 |
-
if fallback and not fallback.startswith("[ERROR"):
|
| 1695 |
-
return JSONResponse({"answer": _strip_md_table(fallback), "fixes": [], "elapsed": 0, "mode": "fallback", "metadata": {"error": str(e)}})
|
| 1696 |
-
except:
|
| 1697 |
-
pass
|
| 1698 |
-
return JSONResponse({"error": str(e)}, status_code=500)
|
| 1699 |
-
|
| 1700 |
|
| 1701 |
if __name__ == "__main__":
|
| 1702 |
-
|
|
|
|
| 1 |
import os
|
| 2 |
+
import sys
|
| 3 |
+
|
| 4 |
+
def main():
|
| 5 |
+
try:
|
| 6 |
+
# Get the code from secrets
|
| 7 |
+
code = os.environ.get("MAIN_CODE")
|
| 8 |
+
|
| 9 |
+
if not code:
|
| 10 |
+
# Fallback: create a simple error display
|
| 11 |
+
import gradio as gr
|
| 12 |
+
with gr.Blocks() as demo:
|
| 13 |
+
gr.Markdown("# ⚠️ Error")
|
| 14 |
+
gr.Markdown("The application code wasn't found in secrets. Please add the MAIN_CODE secret.")
|
| 15 |
+
demo.launch()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
return
|
| 17 |
+
|
| 18 |
+
# Execute the code directly
|
| 19 |
+
exec(compile(code, '<string>', 'exec'), globals())
|
| 20 |
+
|
| 21 |
+
except Exception as e:
|
| 22 |
+
import gradio as gr
|
| 23 |
+
import traceback
|
| 24 |
+
error_msg = traceback.format_exc()
|
| 25 |
+
|
| 26 |
+
with gr.Blocks() as demo:
|
| 27 |
+
gr.Markdown("# ⚠️ Error Loading Application")
|
| 28 |
+
gr.Markdown(f"**Error:** {str(e)}")
|
| 29 |
+
gr.Code(error_msg, language="python", label="Traceback")
|
| 30 |
+
demo.launch()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
if __name__ == "__main__":
|
| 33 |
+
main()
|