Update backend/server.py

#7
Files changed (1) hide show
  1. backend/server.py +214 -68
backend/server.py CHANGED
@@ -1,20 +1,25 @@
1
  """FastAPI routes attached to gr.Server.
2
 
3
- Key changes vs previous build:
4
- • /api/turn now accepts up to 2 attachments (images or PDFs) via repeated
5
- 'attachments' multipart fields.
6
- Hypergraph maintains per-node spawn parent so frontend can draw correct
7
- mycelium threads.
8
- Returns a `metrics` block with civilization-meaningful values
9
- (mycelium_density, council_activity, knowledge_growth, civilization_age)
10
- instead of fake compliance/laws numbers.
11
- Audio drama segments are returned per-agent so the frontend can sync
12
- play/pause + speaking highlight to each utterance.
13
- Removes the raw-JSON dump from the canvas: the frontend never displays
14
- raw JSON; it consumes it for nodes / edges / council / TTS only.
 
 
 
 
15
  """
16
  import io
17
  import json
 
18
  import time
19
  import traceback
20
  from typing import List, Optional
@@ -42,6 +47,11 @@ HG: Hypergraph = persistence.load()
42
  GRAMMAR = load_grammar()
43
  CIVILIZATION_START_TS = time.time()
44
 
 
 
 
 
 
45
 
46
  # ─── GPU-bound inference ────────────────────────────────────────────────────
47
  @spaces.GPU(duration=120)
@@ -56,51 +66,193 @@ def _gpu_infer(messages: list, max_tokens: int = 4096) -> str:
56
  return out["choices"][0]["message"]["content"]
57
 
58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  def _fallback_envelope(user_text: str, err: str) -> dict:
 
60
  meta = new_session_meta()
 
 
61
  resp = ElysiumResponse(
62
  session_id=meta["session_id"],
63
  timestamp_utc=meta["timestamp_utc"],
64
  interaction_type="SIMPLE_REPLY",
65
- direct_answer=f"(fallback) {err}",
66
  )
67
- return {"user_msg": user_text, "elysium_response": resp.model_dump()}
 
 
 
 
68
 
69
 
70
- def _civilization_metrics(resp: ElysiumResponse) -> dict:
71
- """Real, meaningful metrics derived from the hypergraph state."""
 
 
 
 
 
 
 
 
 
 
 
 
72
  nodes = HG.node_count()
73
  edges = HG.edge_count()
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
- # Mycelium density = edges per node, normalized to 0-100%
76
- density = 0.0
77
- if nodes > 0:
78
- density = min(1.0, edges / max(1, nodes * 1.4))
79
 
80
- # Council activity = number of active agents (capped at 5)
 
 
 
81
  council_active = len(resp.council_deliberation.agent_outputs or [])
82
-
83
- # Knowledge growth (this turn) = nodes added
84
  knowledge_growth = len(resp.hypergraph_delta.nodes_added or [])
85
-
86
- # Coherence = inverse of cognitive strain
87
  coherence = 1.0 - float(resp.strain_metadata.cognitive_strain or 0.3)
88
-
89
- age_seconds = time.time() - CIVILIZATION_START_TS
90
- age_minutes = int(age_seconds / 60)
91
-
92
  return {
93
  "mycelium_density_pct": round(density * 100),
94
  "council_active": council_active,
95
  "knowledge_growth": knowledge_growth,
96
  "coherence_pct": round(max(0.0, min(1.0, coherence)) * 100),
97
- "civilization_age_min": age_minutes,
98
  "nodes": nodes,
99
  "edges": edges,
100
  "alert_level": resp.ui_directives.alert_level or "CALM",
101
  }
102
 
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  async def _load_attachment(uf: UploadFile) -> Optional[dict]:
105
  if uf is None or not uf.filename:
106
  return None
@@ -112,7 +264,6 @@ async def _load_attachment(uf: UploadFile) -> Optional[dict]:
112
  "error": f"file too large ({len(raw)} > {MAX_UPLOAD_BYTES} bytes)"}
113
  mime = (uf.content_type or "").lower()
114
  if mime not in ALLOWED_MIME_TYPES:
115
- # also accept by extension as last resort
116
  low = uf.filename.lower()
117
  if low.endswith(".pdf"):
118
  mime = "application/pdf"
@@ -123,7 +274,6 @@ async def _load_attachment(uf: UploadFile) -> Optional[dict]:
123
 
124
  if mime == "application/pdf":
125
  return {"kind": "pdf", "bytes": raw, "name": uf.filename}
126
- # image
127
  try:
128
  img = Image.open(io.BytesIO(raw))
129
  img.load()
@@ -172,9 +322,12 @@ def attach(app):
172
  "node_count": HG.node_count(), "edge_count": HG.edge_count(),
173
  }
174
 
 
 
 
 
175
  @app.get("/api/node/{node_id}")
176
  async def node_detail(node_id: str):
177
- """Detail view for one node: payload, connections, related agents."""
178
  if node_id not in HG._idx:
179
  raise HTTPException(404, "node not found")
180
  idx = HG._idx[node_id]
@@ -225,40 +378,34 @@ def attach(app):
225
  # 2. Build messages with hypergraph context
226
  messages = build_messages(user_text, valid, HG.context_summary())
227
 
228
- # 3. GPU inference (returns strict JSON)
229
  raw = _gpu_infer(messages)
 
 
230
 
231
- # 4. Parse
232
- try:
233
- envelope = ElysiumEnvelope.model_validate_json(raw)
234
- except Exception as parse_err:
235
- try:
236
- blob = json.loads(raw)
237
- if "elysium_response" not in blob:
238
- meta = new_session_meta()
239
- envelope = ElysiumEnvelope(
240
- user_msg=user_text,
241
- elysium_response=ElysiumResponse(
242
- session_id=meta["session_id"],
243
- timestamp_utc=meta["timestamp_utc"],
244
- interaction_type="SIMPLE_REPLY",
245
- direct_answer=str(blob)[:600]))
246
- else:
247
- envelope = ElysiumEnvelope.model_validate(blob)
248
- except Exception:
249
- return JSONResponse(
250
- _fallback_envelope(user_text, f"parse_error: {parse_err}"))
251
 
252
  resp = envelope.elysium_response
253
 
254
  # 5. Apply hypergraph delta
255
- HG.apply_delta(resp.hypergraph_delta)
256
- persistence.save(HG)
257
-
258
- # 6. Execute tools
259
- tool_results = execute_all(resp.tool_calls) if resp.tool_calls else []
 
 
 
 
 
 
 
 
260
 
261
- # 7. Build audio drama if needed (combined + per-agent)
262
  audio_url = None
263
  per_agent_audio = []
264
  if resp.council_deliberation.debate_mode in ("AUDIO_DRAMA", "SILENT") \
@@ -274,14 +421,13 @@ def attach(app):
274
 
275
  payload = envelope.model_dump()
276
  payload["_runtime"] = {
277
- "tool_results": tool_results,
278
- "audio_url": audio_url,
279
- "per_agent_audio": per_agent_audio,
280
- "metrics": _civilization_metrics(resp),
281
- "attachment_errors": [{"name": e["name"], "error": e["error"]} for e in errors],
282
- "attachments_processed": [
283
- {"kind": a["kind"], "name": a["name"]} for a in valid
284
- ],
285
  }
286
  return JSONResponse(payload)
287
 
 
1
  """FastAPI routes attached to gr.Server.
2
 
3
+ CRITICAL FIXES vs previous build:
4
+ Strips <think>...</think> blocks (and other prefixes) from raw model output
5
+ BEFORE JSON parsing. The fine-tuned MiniCPM-V emits a `<think>\n\n</think>`
6
+ preamble that was breaking parse and triggering the
7
+ "raw JSON toast on top" + repeated `Inference failed` errors.
8
+ Accepts BOTH formats:
9
+ (A) bare ElysiumResponse — what the model actually emits
10
+ (B) {user_msg, elysium_response} envelope — historical schema
11
+ Auto-wraps (A) into (B) so downstream code is unchanged.
12
+ Fallback never dumps raw JSON into `direct_answer`; instead returns a
13
+ short, human-readable error string so the UI cannot accidentally toast
14
+ a 2 KB blob of JSON.
15
+ • Always attaches a complete `_runtime` block (metrics, audio, tool_results,
16
+ per_agent_audio, civilization_map) so the frontend never hits null/undefined.
17
+ • Adds /api/civilization_map endpoint returning a compact spatial snapshot
18
+ for the minimap (id, type, color, x_norm, y_norm, edges).
19
  """
20
  import io
21
  import json
22
+ import re
23
  import time
24
  import traceback
25
  from typing import List, Optional
 
47
  GRAMMAR = load_grammar()
48
  CIVILIZATION_START_TS = time.time()
49
 
50
+ # Pre-compiled regex to strip <think>...</think> blocks (including newlines)
51
+ _THINK_RE = re.compile(r"<think\b[^>]*>.*?</think\s*>", re.DOTALL | re.IGNORECASE)
52
+ # Match the first balanced JSON object in arbitrary text
53
+ _JSON_OBJECT_RE = re.compile(r"\{.*\}", re.DOTALL)
54
+
55
 
56
  # ─── GPU-bound inference ────────────────────────────────────────────────────
57
  @spaces.GPU(duration=120)
 
66
  return out["choices"][0]["message"]["content"]
67
 
68
 
69
+ # ────────────────────────────────────────────────────────────────────────────
70
+ # Robust raw-output → ElysiumEnvelope parser
71
+ # ────────────────────────────────────────────────────────────────────────────
72
+ def _clean_raw_model_output(raw: str) -> str:
73
+ """Strip thinking blocks, code fences, and surrounding whitespace.
74
+
75
+ Handles all of the following patterns observed in real model outputs:
76
+ <think>...</think>\n\n{json}
77
+ ```json\n{json}\n```
78
+ Some text {json} trailing text
79
+ """
80
+ if not raw:
81
+ return ""
82
+ s = raw.strip()
83
+ # Strip <think>...</think>
84
+ s = _THINK_RE.sub("", s).strip()
85
+ # Strip ```json ... ``` fences
86
+ if s.startswith("```"):
87
+ s = re.sub(r"^```[a-zA-Z]*\s*", "", s)
88
+ s = re.sub(r"\s*```\s*$", "", s)
89
+ s = s.strip()
90
+ return s
91
+
92
+
93
+ def _extract_json_blob(cleaned: str) -> Optional[dict]:
94
+ """Try direct json.loads, then fall back to first-balanced-object extract."""
95
+ if not cleaned:
96
+ return None
97
+ try:
98
+ return json.loads(cleaned)
99
+ except Exception:
100
+ pass
101
+ m = _JSON_OBJECT_RE.search(cleaned)
102
+ if not m:
103
+ return None
104
+ try:
105
+ return json.loads(m.group(0))
106
+ except Exception:
107
+ # Try a more aggressive cleanup: collapse stray newlines INSIDE strings
108
+ # (the model sometimes wraps long string values across lines)
109
+ candidate = m.group(0)
110
+ # Replace literal newlines inside likely string contexts. This is a
111
+ # best-effort: only safe for the model's typical output shape.
112
+ try:
113
+ candidate2 = re.sub(r'(?<!\\)\n', ' ', candidate)
114
+ return json.loads(candidate2)
115
+ except Exception:
116
+ return None
117
+
118
+
119
+ def _coerce_to_envelope(raw: str, user_text: str) -> Optional[ElysiumEnvelope]:
120
+ """Parse arbitrary model output into a validated ElysiumEnvelope.
121
+
122
+ Returns None only when nothing JSON-shaped can be extracted at all.
123
+ """
124
+ cleaned = _clean_raw_model_output(raw)
125
+ blob = _extract_json_blob(cleaned)
126
+ if blob is None:
127
+ return None
128
+
129
+ # Case A: bare ElysiumResponse → wrap it
130
+ if "elysium_response" not in blob and ("schema_version" in blob or
131
+ "interaction_type" in blob or
132
+ "hypergraph_delta" in blob):
133
+ try:
134
+ resp = ElysiumResponse.model_validate(blob)
135
+ return ElysiumEnvelope(user_msg=user_text, elysium_response=resp)
136
+ except Exception as e:
137
+ print(f"[parse] bare-response validate failed: {e}")
138
+ # Fall through to envelope attempt
139
+
140
+ # Case B: full envelope
141
+ try:
142
+ return ElysiumEnvelope.model_validate(blob)
143
+ except Exception as e:
144
+ print(f"[parse] envelope validate failed: {e}")
145
+
146
+ # Last resort: synthesize a minimal envelope, keeping any direct_answer
147
+ meta = new_session_meta()
148
+ direct = ""
149
+ if isinstance(blob, dict):
150
+ direct = str(blob.get("direct_answer", "") or "")[:300]
151
+ return ElysiumEnvelope(
152
+ user_msg=user_text,
153
+ elysium_response=ElysiumResponse(
154
+ session_id=meta["session_id"],
155
+ timestamp_utc=meta["timestamp_utc"],
156
+ interaction_type="SIMPLE_REPLY",
157
+ direct_answer=direct or "(model response could not be fully parsed)",
158
+ ),
159
+ )
160
+
161
+
162
  def _fallback_envelope(user_text: str, err: str) -> dict:
163
+ """Last-resort fallback that NEVER contains raw JSON in direct_answer."""
164
  meta = new_session_meta()
165
+ # Keep direct_answer short & human readable. Never echo raw JSON.
166
+ safe_err = (err or "unknown error").splitlines()[0][:200]
167
  resp = ElysiumResponse(
168
  session_id=meta["session_id"],
169
  timestamp_utc=meta["timestamp_utc"],
170
  interaction_type="SIMPLE_REPLY",
171
+ direct_answer=f"The civilization could not process that turn ({safe_err}). Try again.",
172
  )
173
+ return {
174
+ "user_msg": user_text,
175
+ "elysium_response": resp.model_dump(),
176
+ "_runtime": _empty_runtime(),
177
+ }
178
 
179
 
180
+ def _empty_runtime() -> dict:
181
+ """Always-present runtime envelope; never None on any field."""
182
+ return {
183
+ "tool_results": [],
184
+ "audio_url": None,
185
+ "per_agent_audio": [],
186
+ "metrics": _baseline_metrics(),
187
+ "attachment_errors": [],
188
+ "attachments_processed": [],
189
+ "civilization_map": _civilization_map_snapshot(),
190
+ }
191
+
192
+
193
+ def _baseline_metrics() -> dict:
194
  nodes = HG.node_count()
195
  edges = HG.edge_count()
196
+ density = 0.0 if nodes == 0 else min(1.0, edges / max(1, nodes * 1.4))
197
+ age_min = int((time.time() - CIVILIZATION_START_TS) / 60)
198
+ return {
199
+ "mycelium_density_pct": round(density * 100),
200
+ "council_active": 0,
201
+ "knowledge_growth": 0,
202
+ "coherence_pct": 70,
203
+ "civilization_age_min": age_min,
204
+ "nodes": nodes,
205
+ "edges": edges,
206
+ "alert_level": "CALM",
207
+ }
208
 
 
 
 
 
209
 
210
+ def _civilization_metrics(resp: ElysiumResponse) -> dict:
211
+ nodes = HG.node_count()
212
+ edges = HG.edge_count()
213
+ density = 0.0 if nodes == 0 else min(1.0, edges / max(1, nodes * 1.4))
214
  council_active = len(resp.council_deliberation.agent_outputs or [])
 
 
215
  knowledge_growth = len(resp.hypergraph_delta.nodes_added or [])
 
 
216
  coherence = 1.0 - float(resp.strain_metadata.cognitive_strain or 0.3)
217
+ age_min = int((time.time() - CIVILIZATION_START_TS) / 60)
 
 
 
218
  return {
219
  "mycelium_density_pct": round(density * 100),
220
  "council_active": council_active,
221
  "knowledge_growth": knowledge_growth,
222
  "coherence_pct": round(max(0.0, min(1.0, coherence)) * 100),
223
+ "civilization_age_min": age_min,
224
  "nodes": nodes,
225
  "edges": edges,
226
  "alert_level": resp.ui_directives.alert_level or "CALM",
227
  }
228
 
229
 
230
+ def _civilization_map_snapshot() -> dict:
231
+ """Compact spatial summary used by the minimap on cold start
232
+ or as a sync source. Frontend already maintains its own spatial layout;
233
+ this endpoint provides the canonical ids/types/edges."""
234
+ nodes_out = []
235
+ for i in HG.g.node_indexes():
236
+ d = HG.g[i]
237
+ nodes_out.append({
238
+ "node_id": d["node_id"],
239
+ "label": d.get("label", d["node_id"]),
240
+ "type": d.get("node_type", "DOMAIN"),
241
+ })
242
+ edges_out = []
243
+ for s, t in HG.g.edge_list():
244
+ d = HG.g.get_edge_data(s, t)
245
+ edges_out.append({
246
+ "edge_id": d.get("edge_id", f"e_{s}_{t}"),
247
+ "source": HG.g[s]["node_id"],
248
+ "target": HG.g[t]["node_id"],
249
+ "type": d.get("edge_type", "GENERIC"),
250
+ "weight": d.get("weight", 0.5),
251
+ })
252
+ return {"nodes": nodes_out, "edges": edges_out,
253
+ "node_count": HG.node_count(), "edge_count": HG.edge_count()}
254
+
255
+
256
  async def _load_attachment(uf: UploadFile) -> Optional[dict]:
257
  if uf is None or not uf.filename:
258
  return None
 
264
  "error": f"file too large ({len(raw)} > {MAX_UPLOAD_BYTES} bytes)"}
265
  mime = (uf.content_type or "").lower()
266
  if mime not in ALLOWED_MIME_TYPES:
 
267
  low = uf.filename.lower()
268
  if low.endswith(".pdf"):
269
  mime = "application/pdf"
 
274
 
275
  if mime == "application/pdf":
276
  return {"kind": "pdf", "bytes": raw, "name": uf.filename}
 
277
  try:
278
  img = Image.open(io.BytesIO(raw))
279
  img.load()
 
322
  "node_count": HG.node_count(), "edge_count": HG.edge_count(),
323
  }
324
 
325
+ @app.get("/api/civilization_map")
326
+ async def civilization_map():
327
+ return _civilization_map_snapshot()
328
+
329
  @app.get("/api/node/{node_id}")
330
  async def node_detail(node_id: str):
 
331
  if node_id not in HG._idx:
332
  raise HTTPException(404, "node not found")
333
  idx = HG._idx[node_id]
 
378
  # 2. Build messages with hypergraph context
379
  messages = build_messages(user_text, valid, HG.context_summary())
380
 
381
+ # 3. GPU inference (returns strict JSON, possibly wrapped in <think>)
382
  raw = _gpu_infer(messages)
383
+ if not raw:
384
+ return JSONResponse(_fallback_envelope(user_text, "empty model output"))
385
 
386
+ # 4. Robust parse: strips <think>, extracts JSON, accepts bare ElysiumResponse
387
+ envelope = _coerce_to_envelope(raw, user_text)
388
+ if envelope is None:
389
+ return JSONResponse(_fallback_envelope(user_text, "no JSON in model output"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
390
 
391
  resp = envelope.elysium_response
392
 
393
  # 5. Apply hypergraph delta
394
+ try:
395
+ HG.apply_delta(resp.hypergraph_delta)
396
+ persistence.save(HG)
397
+ except Exception as e:
398
+ print(f"[hypergraph] apply_delta failed: {e}")
399
+ traceback.print_exc()
400
+
401
+ # 6. Execute tools (best-effort)
402
+ tool_results = []
403
+ try:
404
+ tool_results = execute_all(resp.tool_calls) if resp.tool_calls else []
405
+ except Exception as e:
406
+ print(f"[tools] execute_all failed: {e}")
407
 
408
+ # 7. Audio drama (combined + per-agent), best-effort
409
  audio_url = None
410
  per_agent_audio = []
411
  if resp.council_deliberation.debate_mode in ("AUDIO_DRAMA", "SILENT") \
 
421
 
422
  payload = envelope.model_dump()
423
  payload["_runtime"] = {
424
+ "tool_results": tool_results,
425
+ "audio_url": audio_url,
426
+ "per_agent_audio": per_agent_audio,
427
+ "metrics": _civilization_metrics(resp),
428
+ "attachment_errors": [{"name": e["name"], "error": e["error"]} for e in errors],
429
+ "attachments_processed": [{"kind": a["kind"], "name": a["name"]} for a in valid],
430
+ "civilization_map": _civilization_map_snapshot(),
 
431
  }
432
  return JSONResponse(payload)
433