File size: 7,925 Bytes
fa81066
 
 
 
 
 
 
 
 
f6317e4
577f907
fa81066
e8af5ed
 
fa81066
 
 
1a588c5
fa81066
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f6317e4
fa81066
 
 
 
 
 
 
 
 
 
 
 
 
f6317e4
fa81066
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1a588c5
 
 
 
 
fa81066
 
 
f6317e4
 
fa81066
 
 
 
 
 
 
 
 
 
 
 
 
 
577f907
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
060241d
 
 
 
 
 
 
 
 
 
 
577f907
e8af5ed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fa81066
 
1a588c5
e8af5ed
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
"""Confidence Buddy API on Gradio Spaces (DeepSeek proxy + optional UI)."""

from __future__ import annotations

import json
import os
from pathlib import Path
from typing import Any

import gradio as gr
import gradio.routes
import httpx
from fastapi import HTTPException, Request
from fastapi.responses import JSONResponse

try:
    import spaces
except ImportError:
    spaces = None

PERSONALITIES_PATH = Path(__file__).with_name("personalities.json")
PERSONALITIES: dict[str, dict[str, Any]] = json.loads(
    PERSONALITIES_PATH.read_text(encoding="utf-8")
)

DEEPSEEK_URL = os.environ.get(
    "DEEPSEEK_API_URL", "https://api.deepseek.com/chat/completions"
)
MODEL = os.environ.get("DEEPSEEK_MODEL", "deepseek-chat")


def _api_key() -> str | None:
    return os.environ.get("DEEPSEEK_API_KEY")


def _app_secret() -> str | None:
    return os.environ.get("CHAT_API_SECRET")


def list_personalities() -> list[dict[str, Any]]:
    return [
        {
            "id": p["id"],
            "name": p["name"],
            "category": p["category"],
            "subcategory": p.get("subcategory"),
            "greeting": p["greeting"],
            "suggestions": p.get("suggestions", []),
        }
        for p in PERSONALITIES.values()
    ]


def get_personality(personality_id: str) -> dict[str, Any] | None:
    return PERSONALITIES.get(personality_id)


async def deepseek_chat(
    personality_id: str,
    message: str,
    history: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
    api_key = _api_key()
    if not api_key:
        raise HTTPException(status_code=500, detail="DEEPSEEK_API_KEY is not configured")

    if not personality_id or not isinstance(message, str) or not message.strip():
        raise HTTPException(
            status_code=400, detail="personalityId and message are required"
        )

    personality = get_personality(personality_id)
    if not personality:
        raise HTTPException(status_code=404, detail="Personality not found")

    history = history or []
    messages: list[dict[str, str]] = [
        {"role": "system", "content": personality["systemPrompt"]},
    ]
    for item in history:
        if not item:
            continue
        role = item.get("role")
        content = item.get("content")
        if role in ("user", "assistant") and isinstance(content, str):
            messages.append({"role": role, "content": content})
    messages = messages[:1] + messages[1:][-20:]
    messages.append({"role": "user", "content": message.strip()})

    try:
        async with httpx.AsyncClient(timeout=60.0) as client:
            response = await client.post(
                DEEPSEEK_URL,
                headers={
                    "Content-Type": "application/json",
                    "Authorization": f"Bearer {api_key}",
                },
                json={
                    "model": MODEL,
                    "messages": messages,
                    "temperature": 0.7,
                    "max_tokens": 1024,
                },
            )
            data = response.json()
    except Exception as exc:  # noqa: BLE001
        raise HTTPException(status_code=502, detail=str(exc)) from exc

    if response.status_code >= 400:
        detail = (
            data.get("error", {}).get("message")
            if isinstance(data, dict)
            else None
        ) or response.reason_phrase
        raise HTTPException(status_code=response.status_code, detail=detail)

    reply = (
        ((data.get("choices") or [{}])[0].get("message") or {}).get("content") or ""
    ).strip()
    if not reply:
        raise HTTPException(status_code=502, detail="Empty response from DeepSeek")

    return {
        "reply": reply,
        "personality": {
            "id": personality["id"],
            "name": personality["name"],
            "category": personality["category"],
            "subcategory": personality.get("subcategory"),
        },
    }


if spaces is not None:

    @spaces.GPU(duration=60)
    def _zero_gpu_placeholder() -> str:
        return "ok"


PERSONALITY_CHOICES = [
    (f"{p['name']} ({p['id']})", p["id"]) for p in PERSONALITIES.values()
]


async def ui_chat(
    personality_id: str,
    message: str,
    history: list[dict[str, str]],
):
    if not message or not message.strip():
        return history, ""
    try:
        result = await deepseek_chat(personality_id, message, history)
        reply = result["reply"]
    except HTTPException as exc:
        reply = f"Error: {exc.detail}"
    history = history + [
        {"role": "user", "content": message},
        {"role": "assistant", "content": reply},
    ]
    return history, ""


def _attach_api_routes(app) -> None:
    """Flutter-compatible REST routes. Must be attached when Gradio builds the app."""

    @app.middleware("http")
    async def optional_app_key(request: Request, call_next):
        if request.url.path == "/health":
            return await call_next(request)
        secret = _app_secret()
        if secret and request.headers.get("x-app-key") != secret:
            return JSONResponse({"error": "Unauthorized"}, status_code=401)
        return await call_next(request)

    @app.get("/health")
    async def health():
        return {"ok": True, "hasApiKey": bool(_api_key())}

    @app.get("/personalities")
    async def personalities_list():
        return list_personalities()

    @app.get("/personalities/{personality_id}")
    async def personality_detail(personality_id: str):
        personality = get_personality(personality_id)
        if not personality:
            return JSONResponse({"error": "Personality not found"}, status_code=404)
        return {
            "id": personality["id"],
            "name": personality["name"],
            "category": personality["category"],
            "subcategory": personality.get("subcategory"),
            "greeting": personality["greeting"],
            "suggestions": personality.get("suggestions", []),
        }

    @app.post("/chat")
    async def chat_endpoint(request: Request):
        body = await request.json()
        try:
            return await deepseek_chat(
                personality_id=body.get("personalityId", ""),
                message=body.get("message", ""),
                history=body.get("history") or [],
            )
        except HTTPException as exc:
            return JSONResponse({"error": exc.detail}, status_code=exc.status_code)


_original_create_app = gradio.routes.App.create_app


def _create_app_with_api_routes(blocks, *args, **kwargs):
    app = _original_create_app(blocks, *args, **kwargs)
    _attach_api_routes(app)
    return app


gradio.routes.App.create_app = staticmethod(_create_app_with_api_routes)

# Gradio SSR captures GET /health as an HTML page; disable it so REST routes win.
_original_launch = gr.Blocks.launch


def _launch_without_ssr(self, *args, **kwargs):
    kwargs.setdefault("ssr_mode", False)
    return _original_launch(self, *args, **kwargs)


gr.Blocks.launch = _launch_without_ssr


with gr.Blocks(title="Confidence Buddy API") as demo:
    gr.Markdown(
        "## Confidence Buddy API\n"
        "Flutter uses `POST /chat`. This UI is for quick manual checks.\n\n"
        f"Personalities loaded: **{len(PERSONALITIES)}** · "
        f"API key configured: **{bool(_api_key())}**"
    )
    personality = gr.Dropdown(
        choices=PERSONALITY_CHOICES,
        value=PERSONALITY_CHOICES[0][1] if PERSONALITY_CHOICES else None,
        label="Personality",
    )
    chatbot = gr.Chatbot(type="messages", height=420)
    msg = gr.Textbox(label="Message", placeholder="Type a message…")
    clear = gr.Button("Clear")
    msg.submit(ui_chat, [personality, msg, chatbot], [chatbot, msg])
    clear.click(lambda: ([], ""), outputs=[chatbot, msg])


if __name__ == "__main__":
    demo.launch(server_name="0.0.0.0", server_port=int(os.environ.get("PORT", "7860")))