import os import json import httpx from fastapi import APIRouter from pydantic import BaseModel from typing import Optional router = APIRouter() @router.get("/health") async def health(): return {"ok": True} # ── 공통 헬퍼 ────────────────────────────────────────────────────────── def _upstash_headers(): token = os.environ.get("UPSTASH_REDIS_REST_TOKEN", "") return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} def _upstash_url(): return os.environ.get("UPSTASH_REDIS_REST_URL", "") def _safe_email(email: str) -> str: return email.replace("@", "_").replace(".", "_") # ── 요청 모델 ────────────────────────────────────────────────────────── class AnalyzeRequest(BaseModel): text: str class ProgressRequest(BaseModel): key: str track: str position: float playCount: int listenSeconds: int updatedAt: str trackIdx: Optional[int] = None class SessionRequest(ProgressRequest): pass # 읽기 전용 요청 모델 (이메일만 필요) class EmailRequest(BaseModel): email: str # ── 분석 ─────────────────────────────────────────────────────────────── @router.post("/api/analyze") async def analyze_text(req: AnalyzeRequest): groq_key = os.environ.get("GROQ_API_KEY") if not groq_key: return {"reply": "서버에 Groq API Key가 설정되지 않았습니다. (HF Secrets 확인 필요)"} prompt = f"""You are a British English language coach. Analyze this text for a Korean learner practicing shadowing: "{req.text}" Provide in this EXACT format (Korean labels, English explanations): **어휘**: Key words/phrases explained simply **발음 팁**: British pronunciation notes (specific sounds, stress) **문법**: Grammar structure if notable **유사 표현**: 1-2 similar natural alternatives Be concise. Max 120 words total.""" async with httpx.AsyncClient() as client: response = await client.post( "https://api.groq.com/openai/v1/chat/completions", headers={"Authorization": f"Bearer {groq_key}"}, json={ "model": "llama-3.3-70b-versatile", "messages": [{"role": "user", "content": prompt}], "max_tokens": 300, "temperature": 0.4 }, timeout=10.0 ) if response.status_code != 200: return {"reply": f"Groq API 연동 오류 발생: {response.text}"} data = response.json() return {"reply": data["choices"][0]["message"]["content"]} # ── 진도 저장 ────────────────────────────────────────────────────────── @router.post("/api/progress") async def save_progress(req: ProgressRequest): url, headers = _upstash_url(), _upstash_headers() if not url: return {"status": "error", "message": "Upstash 인증 정보가 없습니다."} payload = req.model_dump() if hasattr(req, "model_dump") else req.dict() async with httpx.AsyncClient() as client: r = await client.post(url + "/", headers=headers, json=["SET", req.key, json.dumps(payload)], timeout=10.0) return {"status": "success" if r.status_code == 200 else "error"} # ── 세션 저장 ────────────────────────────────────────────────────────── @router.post("/api/session") async def save_session(req: SessionRequest): url, headers = _upstash_url(), _upstash_headers() if not url: return {"status": "error", "message": "Upstash 인증 정보가 없습니다."} payload = req.model_dump() if hasattr(req, "model_dump") else req.dict() async with httpx.AsyncClient() as client: r = await client.post(url + "/", headers=headers, json=["SET", req.key, json.dumps(payload)], timeout=10.0) return {"status": "success" if r.status_code == 200 else "error"} # ── 세션 조회 (기존 GET — 하위 호환 유지) ────────────────────────────── @router.get("/api/session/{key}") async def get_session(key: str): url, headers = _upstash_url(), {"Authorization": _upstash_headers()["Authorization"]} if not url: return {"status": "error", "message": "Upstash 인증 정보 없음"} async with httpx.AsyncClient() as client: r = await client.get(f"{url}/get/{key}", headers=headers) if r.status_code == 200: d = r.json() if d.get("result"): return json.loads(d["result"]) return {"status": "error", "message": "데이터를 찾을 수 없습니다."} # ── ★ 신규: 마지막 세션 조회 (이메일 기반, cfg 불필요) ──────────────── @router.post("/api/session/last") async def get_last_session(req: EmailRequest): """ 클라이언트가 cfg.upstashToken 없이도 호출 가능. 새 브라우저 / 모바일에서 로그인 직후 마지막 세션을 가져올 때 사용. """ url = _upstash_url() headers = {"Authorization": _upstash_headers()["Authorization"]} if not url: return {"status": "error", "message": "Upstash 인증 정보 없음"} safe = _safe_email(req.email) key = f"shadowing:{safe}:_last_session" async with httpx.AsyncClient() as client: r = await client.get(f"{url}/get/{key}", headers=headers) if r.status_code == 200: d = r.json() if d.get("result"): return json.loads(d["result"]) return {"status": "not_found"} # ── ★ 신규: 전체 트랙 완주 횟수 조회 (이메일 기반) ──────────────────── @router.post("/api/counts") async def get_all_counts(req: EmailRequest): """ 해당 유저의 shadowing:{email}:* 키를 모두 스캔해 { "comprehension_drill": 3, "news_drill": 1, ... } 형태로 반환. 클라이언트는 manifest label과 매칭해서 ×N 표시에 활용. """ url = _upstash_url() headers_auth = {"Authorization": _upstash_headers()["Authorization"], "Content-Type": "application/json"} if not url: return {"counts": {}} safe = _safe_email(req.email) pattern = f"shadowing:{safe}:*" counts = {} async with httpx.AsyncClient() as client: # SCAN으로 해당 유저의 모든 키 수집 cursor = "0" all_keys = [] while True: r = await client.post( f"{url}/", headers=headers_auth, json=["SCAN", cursor, "MATCH", pattern, "COUNT", "200"], timeout=10.0 ) result = r.json().get("result", ["0", []]) cursor = result[0] all_keys.extend(result[1]) if cursor == "0": break # 내부 메타 키 제외 (_last_session, _meta, _consent) track_keys = [k for k in all_keys if not any(k.endswith(s) for s in ["_last_session", "_meta", "_consent"])] if not track_keys: return {"counts": {}} # pipeline으로 한 번에 조회 pipeline = [["GET", k] for k in track_keys] pr = await client.post(f"{url}/pipeline", headers=headers_auth, json=pipeline, timeout=10.0) results = pr.json() for key, res in zip(track_keys, results): if res.get("result"): try: d = json.loads(res["result"]) # key 형식: shadowing:{safe_email}:{safeTitle}:{trackKey} # 예: shadowing:hoon1018_knou_ac_kr:comprehension_drill:bm_christopher_03_1 parts = key.split(":") if len(parts) >= 4: # {safeTitle}:{trackKey} 복합키로 반환 composite = f"{parts[-2]}:{parts[-1]}" else: composite = parts[-1] counts[composite] = d.get("playCount", 0) except Exception: pass return {"counts": counts} # ── ★ 신규: 동의 저장 ───────────────────────────────────────────────── class ConsentRequest(BaseModel): email: str agreed: bool @router.post("/api/consent") async def save_consent(req: ConsentRequest): url, headers = _upstash_url(), _upstash_headers() if not url: return {"status": "error"} safe = _safe_email(req.email) key = f"shadowing:{safe}:_consent" payload = {"email": req.email, "agreed": req.agreed} async with httpx.AsyncClient() as client: r = await client.post(url + "/", headers=headers, json=["SET", key, json.dumps(payload)], timeout=5.0) return {"status": "success" if r.status_code == 200 else "error"} @router.post("/api/consent/get") async def get_consent(req: EmailRequest): url = _upstash_url() headers = {"Authorization": _upstash_headers()["Authorization"]} if not url: return {"status": "error"} safe = _safe_email(req.email) key = f"shadowing:{safe}:_consent" async with httpx.AsyncClient() as client: r = await client.get(f"{url}/get/{key}", headers=headers) if r.status_code == 200: d = r.json() if d.get("result"): return json.loads(d["result"]) return {"status": "not_found"} # ── ★ SUB-NOTE: 트랙별 편집 노트 저장/조회 ────────────────── class SubNoteRequest(BaseModel): email: str trackKey: str content: dict # TipTap JSON class SubNoteGetRequest(BaseModel): email: str trackKey: str @router.post("/api/subnote/set") async def set_subnote(req: SubNoteRequest): url, headers = _upstash_url(), _upstash_headers() if not url: return {"status": "error", "message": "Upstash 인증 정보 없음"} safe = _safe_email(req.email) key = f"shadowing:{safe}:subnote:{req.trackKey}" payload = { "email": req.email, "trackKey": req.trackKey, "content": req.content, "updatedAt": __import__('datetime').datetime.utcnow().isoformat() } async with httpx.AsyncClient() as client: r = await client.post(url + "/", headers=headers, json=["SET", key, json.dumps(payload)], timeout=10.0) return {"status": "success" if r.status_code == 200 else "error"} @router.post("/api/subnote/get") async def get_subnote(req: SubNoteGetRequest): url = _upstash_url() headers = {"Authorization": _upstash_headers()["Authorization"]} if not url: return {"status": "error"} safe = _safe_email(req.email) key = f"shadowing:{safe}:subnote:{req.trackKey}" async with httpx.AsyncClient() as client: r = await client.get(f"{url}/get/{key}", headers=headers) if r.status_code == 200: d = r.json() if d.get("result"): return json.loads(d["result"]) return {"status": "not_found", "content": None}