| 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"} |
|
|
| |
| @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": "λ°μ΄ν°λ₯Ό μ°Ύμ μ μμ΅λλ€."} |
|
|
| |
| @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: |
| |
| 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 |
|
|
| |
| 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 = [["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"]) |
| |
| |
| parts = key.split(":") |
| if len(parts) >= 4: |
| |
| 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"} |
| |
| class SubNoteRequest(BaseModel): |
| email: str |
| trackKey: str |
| content: dict |
|
|
| 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} |
|
|