ginigen-ai commited on
Commit
40fd39a
·
verified ·
1 Parent(s): 1190382

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +29 -1698
app.py CHANGED
@@ -1,1702 +1,33 @@
1
  import os
2
- import json
3
- import base64
4
- import asyncio
5
- import httpx
6
- import uvicorn
7
- import threading
8
- from datetime import datetime, timezone, timedelta
9
- from collections import Counter, defaultdict
10
- from pathlib import Path
11
- from fastapi import FastAPI, Request, BackgroundTasks
12
- import aiosqlite, shutil
13
- from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response, StreamingResponse
14
- from fastapi.middleware.cors import CORSMiddleware
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
- except Exception as e:
103
- print(f"🚨 [SA-backup] DB unreadable — skipped: {e}")
104
- return
105
- def _sync():
106
- from huggingface_hub import HfApi
107
- api = HfApi(token=HF_TOKEN)
108
- try: api.create_repo(repo_id=SA_BACKUP_REPO, repo_type="dataset", private=True, exist_ok=True)
109
- except: pass
110
- from datetime import datetime
111
- ts = datetime.now().strftime("%Y%m%d_%H%M%S")
112
- api.upload_file(path_or_fileobj=SA_DB_PATH, path_in_repo=f"backup/siteagent_{ts}.db", repo_id=SA_BACKUP_REPO, repo_type="dataset")
113
- api.upload_file(path_or_fileobj=SA_DB_PATH, path_in_repo="latest/siteagent.db", repo_id=SA_BACKUP_REPO, repo_type="dataset")
114
- return ts
115
- try:
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
- uvicorn.run(app, host="0.0.0.0", port=7860)
 
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()