NPCverse / app.py
LazyHuman10
fix: add sampling to model generation + surface errors in logs
1f637de
Raw
History Blame Contribute Delete
5.4 kB
"""Gradio Server backend for the NPCverse hackathon app."""
from __future__ import annotations
import base64
import io
import json
import os
from typing import Any
from dotenv import load_dotenv
from fastapi.responses import HTMLResponse, JSONResponse
from gradio import Server
load_dotenv()
from model_engine import ( # noqa: E402
DEFAULT_NPC,
analyze_image,
chat_respond,
check_new_secrets,
generate_npc,
get_friendship_label,
)
from share_card import generate_share_card # noqa: E402
app = Server(title="NPCverse")
@app.get("/", response_class=HTMLResponse)
async def homepage() -> HTMLResponse:
"""Serve the custom NPCverse frontend."""
index_path = os.path.join(os.path.dirname(__file__), "index.html")
try:
with open(index_path, "r", encoding="utf-8") as index_file:
return HTMLResponse(index_file.read())
except FileNotFoundError:
return HTMLResponse("<main><h1>NPCverse</h1><p>UI loading...</p></main>")
@app.get("/health")
async def health() -> JSONResponse:
"""Return a lightweight health check for deployment probes."""
return JSONResponse({"status": "ok", "model": "MiniCPM-V-4.6"})
@app.api(name="summon_npc")
def summon_npc(image_path: Any) -> dict:
"""Analyze an uploaded image and summon a complete NPC profile."""
import traceback
try:
path = _extract_file_path(image_path)
print(f"[NPCverse] summon_npc: extracted path = {path!r}")
description = analyze_image(path)
print(f"[NPCverse] summon_npc: image description = {description!r}")
npc = generate_npc(description)
print(f"[NPCverse] summon_npc: generated NPC name = {npc.get('name')!r}")
return _json_safe_dict(npc)
except Exception as e:
print(f"[NPCverse] summon_npc FAILED: {e}")
traceback.print_exc()
return _json_safe_dict(DEFAULT_NPC)
@app.api(name="chat_with_npc")
def chat_with_npc_api(
npc_json: str,
history_json: str,
user_message: str,
msg_count: int,
unlocked_secrets_json: str,
) -> dict:
"""Continue an in-character NPC conversation and update progression state."""
npc = _loads_or_default(npc_json, DEFAULT_NPC)
history = _loads_or_default(history_json, [])
unlocked_secrets = _loads_or_default(unlocked_secrets_json, [])
if not isinstance(npc, dict):
npc = DEFAULT_NPC
if not isinstance(history, list):
history = []
if not isinstance(unlocked_secrets, list):
unlocked_secrets = []
current_msg_count = _safe_int(msg_count)
new_msg_count = current_msg_count + 1
newly_unlocked = check_new_secrets(new_msg_count, unlocked_secrets)
secret_texts = _get_secret_texts(npc, newly_unlocked)
response = chat_respond(
npc=npc,
history=history,
user_message=user_message,
msg_count=new_msg_count,
unlocked_secrets=[*unlocked_secrets, *newly_unlocked],
)
return {
"response": str(response),
"new_msg_count": new_msg_count,
"friendship_label": get_friendship_label(new_msg_count),
"friendship_pct": min(100.0, (current_msg_count / 55) * 100),
"newly_unlocked": newly_unlocked,
"secret_texts": secret_texts,
}
@app.api(name="generate_share_card")
def get_share_card(npc_json: str) -> dict:
"""Generate a base64 PNG social share card for an NPC profile."""
npc = _loads_or_default(npc_json, DEFAULT_NPC)
if not isinstance(npc, dict):
npc = DEFAULT_NPC
image = generate_share_card(npc)
buffer = io.BytesIO()
image.save(buffer, format="PNG")
image_b64 = base64.b64encode(buffer.getvalue()).decode("utf-8")
return {"image_b64": f"data:image/png;base64,{image_b64}"}
def _extract_file_path(file_value: Any) -> str:
"""Extract an uploaded file path from Gradio's possible file payloads."""
if isinstance(file_value, str):
return file_value
if isinstance(file_value, dict):
return str(file_value.get("path") or file_value.get("name") or "")
path = getattr(file_value, "path", None) or getattr(file_value, "name", None)
return str(path or "")
def _loads_or_default(raw_json: str, default: Any) -> Any:
"""Parse JSON input from the frontend, returning a fallback on invalid data."""
try:
return json.loads(raw_json)
except (TypeError, json.JSONDecodeError):
return default
def _safe_int(value: Any) -> int:
"""Convert a value to a non-negative integer message count."""
try:
return max(0, int(value))
except (TypeError, ValueError):
return 0
def _get_secret_texts(npc: dict, secret_indices: list[int]) -> list[str]:
"""Resolve unlocked secret indices to their corresponding secret text."""
secrets = npc.get("secrets", [])
if not isinstance(secrets, list):
return []
secret_texts: list[str] = []
for index in secret_indices:
try:
secret_texts.append(str(secrets[int(index)]))
except (TypeError, ValueError, IndexError):
continue
return secret_texts
def _json_safe_dict(payload: dict) -> dict:
"""Round-trip a dictionary through JSON to ensure API-safe primitives."""
return json.loads(json.dumps(payload, ensure_ascii=False))
if __name__ == "__main__":
app.launch(server_name="0.0.0.0", server_port=7860, show_error=True)