| import json |
| import os |
| import re |
| import secrets |
| from datetime import datetime, timezone |
| from pathlib import Path |
| from typing import Any |
| from urllib.parse import urlencode |
|
|
| import httpx |
| from fastapi import Cookie, FastAPI, HTTPException, Request |
| from fastapi.responses import FileResponse, RedirectResponse |
| from fastapi.staticfiles import StaticFiles |
| from pydantic import BaseModel |
|
|
| APP_DIR = Path(__file__).resolve().parent.parent |
| DIST_DIR = APP_DIR / "dist" |
| LOCAL_DATA_DIR = APP_DIR / "data" |
| DEFAULT_DATA_DIR = Path(os.environ.get("DATA_DIR", "/data/usersync")) |
| TARGET_API_BASE_URL = os.environ.get("TARGET_API_BASE_URL", "https://auxteam-usersyncui.hf.space").rstrip("/") |
|
|
| OAUTH_CLIENT_ID = os.environ.get("OAUTH_CLIENT_ID") |
| OAUTH_CLIENT_SECRET = os.environ.get("OAUTH_CLIENT_SECRET") |
| OAUTH_SCOPES = os.environ.get("OAUTH_SCOPES", "openid profile") |
| OPENID_PROVIDER_URL = os.environ.get("OPENID_PROVIDER_URL", "https://huggingface.co").rstrip("/") |
| SPACE_HOST = os.environ.get("SPACE_HOST") or "leon4gr45-usersync.hf.space" |
| PUBLIC_BASE_URL = os.environ.get("PUBLIC_BASE_URL", f"https://{SPACE_HOST}").rstrip("/") |
| REDIRECT_URI = os.environ.get("OAUTH_REDIRECT_URI", f"{PUBLIC_BASE_URL}/oauth/callback") |
| COOKIE_SECURE = PUBLIC_BASE_URL.startswith("https://") |
|
|
| app = FastAPI( |
| title="UserSync", |
| description="FastAPI backend for the UserSync Hugging Face Space UI and API proxy.", |
| version="1.0.0", |
| ) |
|
|
|
|
| class CraftRequest(BaseModel): |
| content: str |
| variation: str | None = None |
|
|
|
|
| class SaveDataRequest(BaseModel): |
| type: str |
| data: Any |
| user: str |
|
|
|
|
| def _safe_segment(value: str) -> str: |
| return re.sub(r"[^a-zA-Z0-9]", "_", str(value)) |
|
|
|
|
| def _data_dir() -> Path: |
| try: |
| DEFAULT_DATA_DIR.mkdir(parents=True, exist_ok=True) |
| return DEFAULT_DATA_DIR |
| except OSError: |
| LOCAL_DATA_DIR.mkdir(parents=True, exist_ok=True) |
| return LOCAL_DATA_DIR |
|
|
|
|
| def _proxy_headers() -> dict[str, str]: |
| headers = {"Content-Type": "application/json", "Accept": "application/json"} |
| token = os.environ.get("HF_TOKEN") or os.environ.get("HF_API_KEY") |
| if token: |
| headers["Authorization"] = f"Bearer {token}" |
| return headers |
|
|
|
|
| async def _proxy_request(method: str, path: str, json_body: Any | None = None) -> Any: |
| async with httpx.AsyncClient(timeout=60.0) as client: |
| response = await client.request( |
| method, |
| f"{TARGET_API_BASE_URL}{path}", |
| headers=_proxy_headers(), |
| json=json_body, |
| ) |
| if response.status_code >= 400: |
| raise HTTPException(status_code=response.status_code, detail=response.text) |
| return response.json() |
|
|
|
|
| @app.get("/health") |
| async def health() -> dict[str, str]: |
| return {"status": "ok"} |
|
|
|
|
| @app.post("/api/craft") |
| async def craft(payload: CraftRequest) -> dict[str, str]: |
| api_key = os.environ.get("BLABLADOR_API_KEY") |
| if not api_key: |
| raise HTTPException(status_code=500, detail="BLABLADOR_API_KEY is not configured on the server.") |
|
|
| model = "alias-large" if len(payload.content) > 500 else "alias-fast" |
| prompt = ( |
| f"You are a professional content creator. Help me craft a " |
| f"{payload.variation or 'social media post'} based on the following content:\n\n" |
| f"{payload.content}\n\nProvide 3 distinct and engaging variations." |
| ) |
|
|
| async with httpx.AsyncClient(timeout=60.0) as client: |
| response = await client.post( |
| "https://api.helmholtz-blablador.fz-juelich.de/v1/chat/completions", |
| headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, |
| json={ |
| "model": model, |
| "messages": [ |
| { |
| "role": "system", |
| "content": "You are a helpful assistant that helps craft engaging marketing and social media content.", |
| }, |
| {"role": "user", "content": prompt}, |
| ], |
| "temperature": 0.7, |
| }, |
| ) |
|
|
| if response.status_code >= 400: |
| raise HTTPException(status_code=response.status_code, detail=response.text) |
|
|
| data = response.json() |
| return {"result": data["choices"][0]["message"]["content"]} |
|
|
|
|
| @app.get("/api/config") |
| async def config() -> dict[str, str | None]: |
| return {"clientId": OAUTH_CLIENT_ID, "scopes": OAUTH_SCOPES} |
|
|
|
|
| @app.get("/login") |
| async def login() -> RedirectResponse: |
| if not OAUTH_CLIENT_ID: |
| raise HTTPException(status_code=500, detail="OAuth is not configured (missing OAUTH_CLIENT_ID)") |
|
|
| state = secrets.token_hex(16) |
| auth_url = f"{OPENID_PROVIDER_URL}/oauth/authorize?" + urlencode( |
| { |
| "client_id": OAUTH_CLIENT_ID, |
| "redirect_uri": REDIRECT_URI, |
| "scope": OAUTH_SCOPES, |
| "response_type": "code", |
| "state": state, |
| } |
| ) |
| response = RedirectResponse(auth_url) |
| response.set_cookie("oauth_state", state, httponly=True, max_age=600, samesite="lax", secure=COOKIE_SECURE) |
| return response |
|
|
|
|
| @app.get("/oauth/callback") |
| async def oauth_callback(code: str, state: str, oauth_state: str | None = Cookie(default=None)) -> RedirectResponse: |
| if not state or state != oauth_state: |
| raise HTTPException(status_code=403, detail="Invalid OAuth state") |
| if not OAUTH_CLIENT_ID or not OAUTH_CLIENT_SECRET: |
| raise HTTPException(status_code=500, detail="OAuth client credentials are not configured") |
|
|
| async with httpx.AsyncClient(timeout=60.0) as client: |
| token_resp = await client.post( |
| f"{OPENID_PROVIDER_URL}/oauth/token", |
| auth=(OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET), |
| headers={"Content-Type": "application/x-www-form-urlencoded"}, |
| data={ |
| "grant_type": "authorization_code", |
| "code": code, |
| "redirect_uri": REDIRECT_URI, |
| "client_id": OAUTH_CLIENT_ID, |
| }, |
| ) |
| if token_resp.status_code >= 400: |
| raise HTTPException(status_code=400, detail="Failed to retrieve access token") |
|
|
| access_token = token_resp.json()["access_token"] |
| async with httpx.AsyncClient(timeout=60.0) as client: |
| user_resp = await client.get( |
| "https://huggingface.co/api/whoami-v2", |
| headers={"Authorization": f"Bearer {access_token}"}, |
| ) |
| if user_resp.status_code >= 400: |
| raise HTTPException(status_code=user_resp.status_code, detail="Failed to retrieve Hugging Face user") |
|
|
| response = RedirectResponse("/") |
| response.delete_cookie("oauth_state") |
| response.set_cookie("hf_user", json.dumps(user_resp.json()), path="/", httponly=False, samesite="lax", secure=COOKIE_SECURE) |
| return response |
|
|
|
|
| @app.get("/api/user") |
| async def user(hf_user: str | None = Cookie(default=None)) -> Any: |
| if not hf_user: |
| raise HTTPException(status_code=401, detail="Not authenticated") |
| try: |
| return json.loads(hf_user) |
| except json.JSONDecodeError as exc: |
| raise HTTPException(status_code=400, detail="Invalid user cookie") from exc |
|
|
|
|
| @app.get("/api/logout") |
| async def logout() -> RedirectResponse: |
| response = RedirectResponse("/") |
| response.delete_cookie("hf_user", path="/") |
| return response |
|
|
|
|
| @app.post("/api/save-data") |
| async def save_data(payload: SaveDataRequest) -> dict[str, Any]: |
| user = _safe_segment(payload.user) |
| data_type = _safe_segment(payload.type) |
| timestamp = datetime.now(timezone.utc).isoformat().replace(":", "-").replace(".", "-") |
| filename = f"{user}_{data_type}_{timestamp}.json" |
| file_path = _data_dir() / filename |
| file_path.write_text( |
| json.dumps({"user": user, "type": data_type, "timestamp": timestamp, "data": payload.data}, indent=2), |
| encoding="utf-8", |
| ) |
| return {"success": True, "message": f"Data saved as {filename}"} |
|
|
|
|
| @app.get("/api/list-data") |
| async def list_data(type: str | None = None, user: str | None = None) -> list[Any]: |
| data_dir = _data_dir() |
| safe_user = _safe_segment(user) if user else None |
| safe_type = _safe_segment(type) if type else None |
| results: list[Any] = [] |
|
|
| for file_path in data_dir.glob("*.json"): |
| try: |
| record = json.loads(file_path.read_text(encoding="utf-8")) |
| except (OSError, json.JSONDecodeError): |
| continue |
| if safe_type and record.get("type") != safe_type: |
| continue |
| if safe_user and record.get("user") != safe_user: |
| continue |
| results.append(record) |
| return results |
|
|
|
|
| @app.get("/api/v1/personas") |
| async def personas() -> Any: |
| return await _proxy_request("GET", "/api/v1/personas") |
|
|
|
|
| @app.post("/api/v1/personas/generate") |
| async def generate_personas(request: Request) -> Any: |
| return await _proxy_request("POST", "/api/v1/personas/generate", await request.json()) |
|
|
|
|
| @app.post("/api/v1/simulations") |
| async def create_simulation(request: Request) -> Any: |
| return await _proxy_request("POST", "/api/v1/simulations", await request.json()) |
|
|
|
|
| @app.get("/api/v1/simulations/{job_id}") |
| async def simulation_status(job_id: str) -> Any: |
| return await _proxy_request("GET", f"/api/v1/simulations/{job_id}") |
|
|
|
|
| TAB_DEFINITIONS = [ |
| {"id": "focus-groups", "title": "1. Focus Groups", "upstream": "GET /api/v1/personas"}, |
| {"id": "generate-personas", "title": "2. Generate Personas", "upstream": "POST /api/v1/personas/generate"}, |
| {"id": "identify-personas", "title": "3. Identify Personas", "upstream": "local placeholder"}, |
| {"id": "social-network", "title": "4. Social Network", "upstream": "local placeholder"}, |
| {"id": "start-simulation", "title": "5. Start Simulation", "upstream": "POST /api/v1/simulations"}, |
| {"id": "simulation-status", "title": "6. Simulation Status", "upstream": "GET /api/v1/simulations/{job_id}"}, |
| {"id": "chat-message", "title": "7. Chat Message", "upstream": "local persistence"}, |
| {"id": "chat-history", "title": "8. Chat History", "upstream": "local persistence"}, |
| {"id": "variants", "title": "9. Generate Variants", "upstream": "local placeholder"}, |
| {"id": "export", "title": "10. Export & Personas", "upstream": "GET /api/v1/simulations/{job_id}"}, |
| ] |
|
|
|
|
| def _tab_response(tab_id: str, payload: dict[str, Any], result: Any) -> dict[str, Any]: |
| return {"tab_id": tab_id, "space_url": PUBLIC_BASE_URL, "payload": payload, "result": result} |
|
|
|
|
| @app.get("/api/tabs") |
| async def api_tabs() -> list[dict[str, str]]: |
| return TAB_DEFINITIONS |
|
|
|
|
| @app.post("/api/tabs/{tab_id}/run") |
| async def run_api_tab(tab_id: str, request: Request) -> dict[str, Any]: |
| payload = await request.json() |
|
|
| if tab_id == "focus-groups": |
| return _tab_response(tab_id, payload, await personas()) |
|
|
| if tab_id == "generate-personas": |
| body = { |
| "business_description": payload.get("business_description", ""), |
| "customer_profile": payload.get("customer_profile", ""), |
| "num_personas": int(payload.get("num_personas") or 1), |
| } |
| return _tab_response(tab_id, body, await _proxy_request("POST", "/api/v1/personas/generate", body)) |
|
|
| if tab_id == "identify-personas": |
| context = payload.get("context", "") |
| return _tab_response(tab_id, payload, {"context": context, "status": "queued_for_persona_identification"}) |
|
|
| if tab_id == "social-network": |
| return _tab_response(tab_id, payload, {"status": "network_request_recorded", "network": payload}) |
|
|
| if tab_id == "start-simulation": |
| body = { |
| "focus_group_id": payload.get("simulation_id", ""), |
| "content_type": payload.get("format") or "text", |
| "content_payload": payload.get("content_text", ""), |
| "parameters": {}, |
| } |
| return _tab_response(tab_id, body, await _proxy_request("POST", "/api/v1/simulations", body)) |
|
|
| if tab_id == "simulation-status": |
| simulation_id = payload.get("simulation_id", "") |
| return _tab_response(tab_id, payload, await _proxy_request("GET", f"/api/v1/simulations/{simulation_id}")) |
|
|
| if tab_id == "chat-message": |
| record = { |
| "simulation_id": payload.get("simulation_id", ""), |
| "sender": payload.get("sender") or "User", |
| "message": payload.get("message", ""), |
| "timestamp": datetime.now(timezone.utc).isoformat(), |
| } |
| chat_file = _data_dir() / f"chat_{_safe_segment(record['simulation_id'])}.jsonl" |
| with chat_file.open("a", encoding="utf-8") as file: |
| file.write(json.dumps(record) + "\n") |
| return _tab_response(tab_id, payload, {"saved": True, "message": record}) |
|
|
| if tab_id == "chat-history": |
| chat_file = _data_dir() / f"chat_{_safe_segment(payload.get('simulation_id', ''))}.jsonl" |
| history = [] |
| if chat_file.exists(): |
| history = [json.loads(line) for line in chat_file.read_text(encoding="utf-8").splitlines() if line.strip()] |
| return _tab_response(tab_id, payload, {"history": history}) |
|
|
| if tab_id == "variants": |
| content = payload.get("content_text", "") |
| count = int(payload.get("num_variants") or 5) |
| variants = [{"variant": index + 1, "content": f"Variant {index + 1}: {content}"} for index in range(count)] |
| return _tab_response(tab_id, payload, {"variants": variants}) |
|
|
| if tab_id == "export": |
| action = payload.get("action") or "export_simulation" |
| simulation_id = payload.get("simulation_id", "") |
| if action in {"export_simulation", "get_network_graph"}: |
| return _tab_response(tab_id, payload, await _proxy_request("GET", f"/api/v1/simulations/{simulation_id}")) |
| if action == "list_personas": |
| return _tab_response(tab_id, payload, await personas()) |
| if action == "get_persona": |
| return _tab_response(tab_id, payload, {"simulation_id": simulation_id, "persona_name": payload.get("persona_name", ""), "status": "persona_lookup_ready"}) |
| if action == "delete_simulation": |
| return _tab_response(tab_id, payload, {"simulation_id": simulation_id, "status": "delete_requested"}) |
| return _tab_response(tab_id, payload, {"status": "unknown_export_action", "action": action}) |
|
|
| raise HTTPException(status_code=404, detail=f"Unknown tab id: {tab_id}") |
|
|
|
|
| if DIST_DIR.exists(): |
| app.mount("/assets", StaticFiles(directory=DIST_DIR / "assets"), name="assets") |
|
|
|
|
| @app.get("/{full_path:path}") |
| async def spa(full_path: str) -> FileResponse: |
| index_file = DIST_DIR / "index.html" |
| if not index_file.exists(): |
| raise HTTPException(status_code=503, detail="Frontend build is not available. Run npm run build before serving.") |
|
|
| requested = (DIST_DIR / full_path).resolve() |
| if str(requested).startswith(str(DIST_DIR.resolve())) and requested.is_file(): |
| return FileResponse(requested) |
| return FileResponse(index_file) |
|
|