LiKenun commited on
Commit
97c8f25
·
1 Parent(s): 75ab456
README.md CHANGED
@@ -39,6 +39,27 @@ when the container restarts, the room is empty again.
39
  Python stage installs the backend deps, copies the built assets in,
40
  and runs `uvicorn`.
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  ## Run with Docker (matches the HF Space build)
43
 
44
  ```bash
 
39
  Python stage installs the backend deps, copies the built assets in,
40
  and runs `uvicorn`.
41
 
42
+ ## Optional: the always-online chat agent (MUCA)
43
+
44
+ If `OPENAI_API_KEY` is set in the container environment, a small
45
+ LLM-backed participant — the **M**ulti-**U**ser **C**hat **A**gent —
46
+ joins the room by default. It keeps a short rolling buffer of recent
47
+ messages and, after each human message, makes a single OpenAI call to
48
+ decide whether to respond. The default personality is intentionally
49
+ restrained: it stays quiet most of the time.
50
+
51
+ | Variable | Default | Purpose |
52
+ | -------------------- | ------------- | ---------------------------------------- |
53
+ | `OPENAI_API_KEY` | _(unset)_ | **Required** to enable the MUCA. |
54
+ | `MUCA_NAME` | `muca` | Display name in the roster. |
55
+ | `MUCA_SYSTEM_PROMPT` | _(built-in)_ | Personality / instructions. |
56
+ | `MUCA_MODEL` | `gpt-4o-mini` | OpenAI chat model. |
57
+ | `MUCA_BUFFER_SIZE` | `30` | Max messages kept as context (2..200). |
58
+
59
+ On Hugging Face Spaces, set these as Space *secrets* (for the API key)
60
+ and *variables* (for the rest). See `backend/README.md` for the design
61
+ notes.
62
+
63
  ## Run with Docker (matches the HF Space build)
64
 
65
  ```bash
backend/README.md CHANGED
@@ -34,6 +34,33 @@ If the requested username collides with an existing one the server picks
34
  `name-2`, `name-3`, ... and tells the client which name was actually
35
  assigned in the `welcome` frame.
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  ## Running locally
38
 
39
  ```bash
@@ -43,5 +70,7 @@ pip install -r requirements.txt
43
  # Point at the locally built frontend (optional). Without this, only the
44
  # API is served.
45
  export YAP_STATIC_DIR=../frontend/dist
 
 
46
  uvicorn app.main:app --host 0.0.0.0 --port 7860 --reload
47
  ```
 
34
  `name-2`, `name-3`, ... and tells the client which name was actually
35
  assigned in the `welcome` frame.
36
 
37
+ ## MUCA — the Multi-User Chat Agent
38
+
39
+ If `OPENAI_API_KEY` is set, an LLM-backed participant joins the room as
40
+ a permanent member. It keeps a small rolling buffer of recent messages
41
+ and, after each *human* message, makes a single LLM call to decide
42
+ whether to respond — the default system prompt asks it to mostly stay
43
+ quiet so the chatroom doesn't feel like a chatbot demo.
44
+
45
+ | Variable | Default | Purpose |
46
+ | -------------------- | ------------ | ----------------------------------------------------------------------- |
47
+ | `OPENAI_API_KEY` | _(unset)_ | **Required** to enable the MUCA. Without it the room runs as before. |
48
+ | `MUCA_NAME` | `muca` | The agent's display name in the roster and message history. |
49
+ | `MUCA_SYSTEM_PROMPT` | _(built-in restraint prompt)_ | Personality / instructions. The "reply as JSON" protocol bit is appended automatically. |
50
+ | `MUCA_MODEL` | `gpt-5-nano` | OpenAI chat-completions model used for both the judgment and the reply. |
51
+ | `MUCA_BUFFER_SIZE` | `30` | Max messages of history kept as context (clamped to 2..200). |
52
+
53
+ Implementation notes:
54
+
55
+ * Only one judgment / generation is ever in-flight. Triggers that
56
+ arrive while the agent is "thinking" are coalesced — at most one
57
+ follow-up pass runs after the current one finishes.
58
+ * The agent observes its own messages so it doesn't re-greet or repeat
59
+ itself, but only *human* messages trigger a fresh judgment (otherwise
60
+ it would talk to itself in a loop).
61
+ * The agent name is reserved in the roster: a real user requesting the
62
+ same name gets the usual `name-2`, `name-3`, ... suffix.
63
+
64
  ## Running locally
65
 
66
  ```bash
 
70
  # Point at the locally built frontend (optional). Without this, only the
71
  # API is served.
72
  export YAP_STATIC_DIR=../frontend/dist
73
+ # Optional: enable the MUCA.
74
+ export OPENAI_API_KEY=sk-...
75
  uvicorn app.main:app --host 0.0.0.0 --port 7860 --reload
76
  ```
backend/app/main.py CHANGED
@@ -7,6 +7,10 @@ A FastAPI application that serves:
7
 
8
  A single uvicorn process serves the entire application, which keeps the
9
  Hugging Face Space image simple.
 
 
 
 
10
  """
11
 
12
  from __future__ import annotations
@@ -17,12 +21,14 @@ import os
17
  from contextlib import asynccontextmanager
18
  from datetime import datetime, timezone
19
  from pathlib import Path
20
- from typing import Any
21
 
22
  from fastapi import FastAPI, WebSocket, WebSocketDisconnect
23
  from fastapi.responses import JSONResponse
24
  from fastapi.staticfiles import StaticFiles
25
 
 
 
26
  logger = logging.getLogger("yap")
27
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
28
 
@@ -32,21 +38,43 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name
32
  STATIC_DIR = Path(os.environ.get("YAP_STATIC_DIR", "/app/static")).resolve()
33
 
34
 
 
 
 
35
  class ChatRoom:
36
  """In-memory broadcast hub for a single chat room.
37
 
38
  Yap Space is intentionally ephemeral: nothing is persisted, and once
39
  the process restarts the room is empty again. This matches the
40
  "your yap eventually meets the void" pitch in the README.
 
 
 
 
 
 
 
 
 
 
 
41
  """
42
 
43
  def __init__(self) -> None:
44
  self._members: dict[WebSocket, str] = {}
 
 
45
  self._lock = asyncio.Lock()
46
 
47
  @property
48
  def usernames(self) -> list[str]:
49
- return sorted(self._members.values())
 
 
 
 
 
 
50
 
51
  async def join(self, websocket: WebSocket, username: str) -> str:
52
  username = self._unique_name(username)
@@ -70,19 +98,19 @@ class ChatRoom:
70
  text = text.strip()
71
  if not text:
72
  return
73
- await self._broadcast(
74
- {
75
- "type": "message",
76
- "username": username,
77
- "text": text[:2000],
78
- "timestamp": _now_iso(),
79
- }
80
- )
81
 
82
  def _unique_name(self, requested: str) -> str:
83
  base = requested.strip() or "anon"
84
  base = base[:32]
85
- existing = set(self._members.values())
86
  if base not in existing:
87
  return base
88
  suffix = 2
@@ -90,6 +118,22 @@ class ChatRoom:
90
  suffix += 1
91
  return f"{base}-{suffix}"
92
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  async def _broadcast(self, payload: dict[str, Any]) -> None:
94
  # Snapshot to avoid mutating the dict while iterating.
95
  async with self._lock:
@@ -116,11 +160,50 @@ def _now_iso() -> str:
116
  return datetime.now(timezone.utc).isoformat()
117
 
118
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
119
  @asynccontextmanager
120
  async def lifespan(app: FastAPI):
121
- app.state.room = ChatRoom()
 
 
122
  logger.info("Yap Space online; static dir=%s", STATIC_DIR)
123
- yield
 
 
 
 
124
 
125
 
126
  app = FastAPI(title="Yap Space", lifespan=lifespan)
@@ -129,7 +212,13 @@ app = FastAPI(title="Yap Space", lifespan=lifespan)
129
  @app.get("/api/health")
130
  async def health() -> JSONResponse:
131
  room: ChatRoom = app.state.room
132
- return JSONResponse({"status": "ok", "users": len(room.usernames)})
 
 
 
 
 
 
133
 
134
 
135
  @app.websocket("/api/ws")
 
7
 
8
  A single uvicorn process serves the entire application, which keeps the
9
  Hugging Face Space image simple.
10
+
11
+ If ``OPENAI_API_KEY`` is set in the environment, an LLM-backed
12
+ participant (the MUCA — Multi-User Chat Agent) joins the room as a
13
+ permanent member. See ``app/muca.py`` for details.
14
  """
15
 
16
  from __future__ import annotations
 
21
  from contextlib import asynccontextmanager
22
  from datetime import datetime, timezone
23
  from pathlib import Path
24
+ from typing import Any, Awaitable, Callable
25
 
26
  from fastapi import FastAPI, WebSocket, WebSocketDisconnect
27
  from fastapi.responses import JSONResponse
28
  from fastapi.staticfiles import StaticFiles
29
 
30
+ from app.muca import MucaAgent, MucaConfig
31
+
32
  logger = logging.getLogger("yap")
33
  logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
34
 
 
38
  STATIC_DIR = Path(os.environ.get("YAP_STATIC_DIR", "/app/static")).resolve()
39
 
40
 
41
+ MessageObserver = Callable[[str, str], Awaitable[None]]
42
+
43
+
44
  class ChatRoom:
45
  """In-memory broadcast hub for a single chat room.
46
 
47
  Yap Space is intentionally ephemeral: nothing is persisted, and once
48
  the process restarts the room is empty again. This matches the
49
  "your yap eventually meets the void" pitch in the README.
50
+
51
+ The room also supports two extension points used by the MUCA:
52
+
53
+ * ``add_virtual_member(name)`` registers a participant that has no
54
+ WebSocket of its own but should still appear in the roster.
55
+ * ``add_message_observer(fn)`` subscribes a coroutine that gets
56
+ called with ``(author, text)`` after every chat message. The
57
+ MUCA uses this to update its context buffer and decide whether
58
+ to respond.
59
+ * ``post(name, text)`` lets a virtual member broadcast a message
60
+ as if they were a real client.
61
  """
62
 
63
  def __init__(self) -> None:
64
  self._members: dict[WebSocket, str] = {}
65
+ self._virtual_members: set[str] = set()
66
+ self._observers: list[MessageObserver] = []
67
  self._lock = asyncio.Lock()
68
 
69
  @property
70
  def usernames(self) -> list[str]:
71
+ return sorted(set(self._members.values()) | self._virtual_members)
72
+
73
+ def add_virtual_member(self, name: str) -> None:
74
+ self._virtual_members.add(name)
75
+
76
+ def add_message_observer(self, observer: MessageObserver) -> None:
77
+ self._observers.append(observer)
78
 
79
  async def join(self, websocket: WebSocket, username: str) -> str:
80
  username = self._unique_name(username)
 
98
  text = text.strip()
99
  if not text:
100
  return
101
+ await self._publish_message(username, text)
102
+
103
+ async def post(self, username: str, text: str) -> None:
104
+ """Broadcast a message as a virtual (non-WebSocket) member."""
105
+ text = text.strip()
106
+ if not text:
107
+ return
108
+ await self._publish_message(username, text)
109
 
110
  def _unique_name(self, requested: str) -> str:
111
  base = requested.strip() or "anon"
112
  base = base[:32]
113
+ existing = set(self._members.values()) | self._virtual_members
114
  if base not in existing:
115
  return base
116
  suffix = 2
 
118
  suffix += 1
119
  return f"{base}-{suffix}"
120
 
121
+ async def _publish_message(self, username: str, text: str) -> None:
122
+ text = text[:2000]
123
+ await self._broadcast(
124
+ {
125
+ "type": "message",
126
+ "username": username,
127
+ "text": text,
128
+ "timestamp": _now_iso(),
129
+ }
130
+ )
131
+ for observer in self._observers:
132
+ try:
133
+ await observer(username, text)
134
+ except Exception: # noqa: BLE001 - never let an observer break broadcasting
135
+ logger.exception("Message observer raised")
136
+
137
  async def _broadcast(self, payload: dict[str, Any]) -> None:
138
  # Snapshot to avoid mutating the dict while iterating.
139
  async with self._lock:
 
160
  return datetime.now(timezone.utc).isoformat()
161
 
162
 
163
+ def _attach_muca(room: ChatRoom) -> MucaAgent | None:
164
+ """Create a MUCA from environment config and wire it into the room.
165
+
166
+ Returns ``None`` when no API key is configured, in which case the
167
+ backend behaves as a plain chatroom.
168
+ """
169
+ config = MucaConfig.from_env()
170
+ if config is None:
171
+ logger.info("MUCA disabled (set OPENAI_API_KEY to enable)")
172
+ return None
173
+
174
+ agent = MucaAgent(config, post=room.post)
175
+ room.add_virtual_member(agent.name)
176
+
177
+ async def on_message(author: str, text: str) -> None:
178
+ # Always feed the buffer so the MUCA has a memory of every
179
+ # message, including its own. Only trigger a fresh judgment when
180
+ # a *human* spoke — otherwise the MUCA would keep responding to
181
+ # itself in a loop.
182
+ await agent.observe(author, text)
183
+ if author != agent.name:
184
+ agent.maybe_respond()
185
+
186
+ room.add_message_observer(on_message)
187
+ logger.info(
188
+ "MUCA enabled as %r using model %s (buffer=%d)",
189
+ agent.name,
190
+ config.model,
191
+ config.buffer_size,
192
+ )
193
+ return agent
194
+
195
+
196
  @asynccontextmanager
197
  async def lifespan(app: FastAPI):
198
+ room = ChatRoom()
199
+ app.state.room = room
200
+ app.state.muca = _attach_muca(room)
201
  logger.info("Yap Space online; static dir=%s", STATIC_DIR)
202
+ try:
203
+ yield
204
+ finally:
205
+ if app.state.muca is not None:
206
+ await app.state.muca.shutdown()
207
 
208
 
209
  app = FastAPI(title="Yap Space", lifespan=lifespan)
 
212
  @app.get("/api/health")
213
  async def health() -> JSONResponse:
214
  room: ChatRoom = app.state.room
215
+ return JSONResponse(
216
+ {
217
+ "status": "ok",
218
+ "users": len(room.usernames),
219
+ "muca": app.state.muca.name if app.state.muca else None,
220
+ }
221
+ )
222
 
223
 
224
  @app.websocket("/api/ws")
backend/app/muca.py ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Multi-User Chat Agent (MUCA).
2
+
3
+ A small "always online" participant in the chatroom backed by an LLM.
4
+
5
+ Design notes
6
+ ------------
7
+
8
+ * The agent keeps a bounded rolling buffer of recent messages so it has
9
+ some context to reason about, without ever growing unboundedly.
10
+ * After every *human* message the agent decides — via a single LLM call
11
+ in JSON mode — whether it actually wants to say something. The default
12
+ system prompt instructs it to stay silent most of the time so the room
13
+ doesn't feel like a chatbot demo.
14
+ * Only one judgment / generation is ever in-flight. Additional triggers
15
+ while the agent is "thinking" are coalesced via a `_pending` flag: if
16
+ any messages arrived during a generation, exactly one more pass runs
17
+ immediately afterwards. This both prevents the MUCA from spamming and
18
+ ensures we don't drop a trigger that arrived mid-flight.
19
+ * The module is self-contained: if `OPENAI_API_KEY` is unset, no MUCA is
20
+ created and the rest of the backend behaves exactly as before.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import json
27
+ import logging
28
+ import os
29
+ from collections import deque
30
+ from dataclasses import dataclass
31
+ from typing import Awaitable, Callable, Deque
32
+
33
+ from openai import AsyncOpenAI
34
+
35
+ logger = logging.getLogger("yap.muca")
36
+
37
+ DEFAULT_NAME = "muca"
38
+ DEFAULT_MODEL = "gpt-5-nano"
39
+ DEFAULT_BUFFER_SIZE = 30
40
+ DEFAULT_SYSTEM_PROMPT = (
41
+ "You are a quiet, thoughtful participant in a casual group chat. "
42
+ "Most of the time you just listen. Speak only when you genuinely "
43
+ "have something useful, curious, or in-character to add. Avoid "
44
+ "responding to every message. Avoid greeting people you've already "
45
+ "greeted. Keep replies short and conversational."
46
+ )
47
+
48
+ # Appended to whatever system prompt the operator configured. This part
49
+ # is non-negotiable because it defines the wire protocol the rest of the
50
+ # code depends on.
51
+ _PROTOCOL_SUFFIX = (
52
+ 'You will be shown the most recent chatroom messages and must reply '
53
+ 'with a single JSON object and nothing else. Use '
54
+ '{{"respond": true, "message": "<your message>"}} to speak, or '
55
+ '{{"respond": false}} to stay silent. Stay silent by default — only '
56
+ "speak when it's clearly worth it. Your name in the room is "
57
+ '"{name}". Do not prefix your message with your own name.'
58
+ )
59
+
60
+
61
+ PostFn = Callable[[str, str], Awaitable[None]]
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class MucaConfig:
66
+ api_key: str
67
+ name: str = DEFAULT_NAME
68
+ system_prompt: str = DEFAULT_SYSTEM_PROMPT
69
+ model: str = DEFAULT_MODEL
70
+ buffer_size: int = DEFAULT_BUFFER_SIZE
71
+
72
+ @classmethod
73
+ def from_env(cls) -> "MucaConfig | None":
74
+ """Build a config from environment variables.
75
+
76
+ Returns ``None`` when ``OPENAI_API_KEY`` is unset, which is the
77
+ signal to disable the MUCA entirely.
78
+ """
79
+ api_key = os.environ.get("OPENAI_API_KEY", "").strip()
80
+ if not api_key:
81
+ return None
82
+
83
+ name = os.environ.get("MUCA_NAME", "").strip() or DEFAULT_NAME
84
+ system_prompt = (
85
+ os.environ.get("MUCA_SYSTEM_PROMPT", "").strip() or DEFAULT_SYSTEM_PROMPT
86
+ )
87
+ model = os.environ.get("MUCA_MODEL", "").strip() or DEFAULT_MODEL
88
+
89
+ try:
90
+ buffer_size = int(os.environ.get("MUCA_BUFFER_SIZE", DEFAULT_BUFFER_SIZE))
91
+ except ValueError:
92
+ buffer_size = DEFAULT_BUFFER_SIZE
93
+ buffer_size = max(2, min(buffer_size, 200))
94
+
95
+ return cls(
96
+ api_key=api_key,
97
+ name=name[:32],
98
+ system_prompt=system_prompt,
99
+ model=model,
100
+ buffer_size=buffer_size,
101
+ )
102
+
103
+
104
+ @dataclass
105
+ class _BufferedMessage:
106
+ author: str
107
+ text: str
108
+
109
+
110
+ class MucaAgent:
111
+ """An LLM-backed participant in the chatroom."""
112
+
113
+ def __init__(self, config: MucaConfig, post: PostFn) -> None:
114
+ self.config = config
115
+ self._post = post
116
+ self._client = AsyncOpenAI(api_key=config.api_key)
117
+ self._buffer: Deque[_BufferedMessage] = deque(maxlen=config.buffer_size)
118
+ self._buffer_lock = asyncio.Lock()
119
+ self._task: asyncio.Task[None] | None = None
120
+ # Set whenever a fresh trigger arrives. The judgment loop drains
121
+ # this flag every iteration; if it's still set after a pass, we
122
+ # immediately run another one.
123
+ self._pending = False
124
+
125
+ @property
126
+ def name(self) -> str:
127
+ return self.config.name
128
+
129
+ async def observe(self, author: str, text: str) -> None:
130
+ """Record a message in the rolling context buffer.
131
+
132
+ Both human messages and the MUCA's own messages should be
133
+ observed so the agent has a memory of what it has already said.
134
+ """
135
+ text = text.strip()
136
+ if not text:
137
+ return
138
+ async with self._buffer_lock:
139
+ self._buffer.append(_BufferedMessage(author=author, text=text))
140
+
141
+ def maybe_respond(self) -> None:
142
+ """Signal that the agent should consider responding.
143
+
144
+ Cheap and non-blocking — call this after every *human* message
145
+ (not after the agent's own messages, otherwise it will talk to
146
+ itself forever).
147
+ """
148
+ self._pending = True
149
+ if self._task is None or self._task.done():
150
+ self._task = asyncio.create_task(self._loop())
151
+
152
+ async def shutdown(self) -> None:
153
+ if self._task is not None and not self._task.done():
154
+ self._task.cancel()
155
+ try:
156
+ await self._task
157
+ except asyncio.CancelledError:
158
+ pass
159
+ except Exception: # noqa: BLE001
160
+ logger.exception("MUCA task raised during shutdown")
161
+
162
+ async def _loop(self) -> None:
163
+ # Drain triggers until the buffer hasn't changed since the last
164
+ # decision. New triggers that arrive *during* a decision flip
165
+ # `_pending` back on so we run one more pass.
166
+ while self._pending:
167
+ self._pending = False
168
+ try:
169
+ await self._consider_and_respond()
170
+ except Exception: # noqa: BLE001
171
+ logger.exception("MUCA judgment loop failed")
172
+
173
+ async def _consider_and_respond(self) -> None:
174
+ async with self._buffer_lock:
175
+ history = list(self._buffer)
176
+
177
+ decision = await self._ask_llm(history)
178
+ if decision is None:
179
+ return
180
+ message = decision.strip()
181
+ if not message:
182
+ return
183
+ # Cap to the same length the WebSocket handler enforces for users.
184
+ await self._post(self.name, message[:2000])
185
+
186
+ async def _ask_llm(self, history: list[_BufferedMessage]) -> str | None:
187
+ system_content = (
188
+ f"{self.config.system_prompt}\n\n"
189
+ + _PROTOCOL_SUFFIX.format(name=self.config.name)
190
+ )
191
+ transcript = "\n".join(f"{m.author}: {m.text}" for m in history)
192
+ if not transcript:
193
+ transcript = "(no messages yet)"
194
+ user_content = (
195
+ "Recent chatroom messages (oldest first):\n\n"
196
+ f"{transcript}\n\n"
197
+ "Reply with the JSON decision now."
198
+ )
199
+
200
+ try:
201
+ # Note: we deliberately do not pass `temperature`. Newer
202
+ # OpenAI models (the o-series and some gpt-5.x variants)
203
+ # reject any non-default temperature, and the default is
204
+ # fine for this use case.
205
+ response = await self._client.chat.completions.create(
206
+ model=self.config.model,
207
+ response_format={"type": "json_object"},
208
+ messages=[
209
+ {"role": "system", "content": system_content},
210
+ {"role": "user", "content": user_content},
211
+ ],
212
+ )
213
+ except Exception: # noqa: BLE001 - log and stay silent on any API failure
214
+ logger.exception("OpenAI request failed; MUCA staying silent")
215
+ return None
216
+
217
+ raw = (response.choices[0].message.content or "").strip()
218
+ try:
219
+ payload = json.loads(raw)
220
+ except json.JSONDecodeError:
221
+ logger.warning("MUCA returned non-JSON response: %r", raw[:200])
222
+ return None
223
+
224
+ if not isinstance(payload, dict) or not payload.get("respond"):
225
+ return None
226
+ message = payload.get("message")
227
+ if not isinstance(message, str):
228
+ return None
229
+ return message
backend/requirements.txt CHANGED
@@ -1,3 +1,4 @@
1
  fastapi==0.115.4
2
  uvicorn[standard]==0.32.0
3
  websockets==13.1
 
 
1
  fastapi==0.115.4
2
  uvicorn[standard]==0.32.0
3
  websockets==13.1
4
+ openai==2.33.0