GitHub Actions commited on
Commit
7573216
Β·
1 Parent(s): d4978df

feat: mesh graph tab, routing trace, QR invite, remove all fakes, specialized-node tests

Browse files

- hearthnet/ui/tabs/mesh.py: NEW β€” live SVG topology from bus.registry.all_remote()
- hearthnet/ui/tabs/ask.py: routing trace panel shows which node answered each request
- hearthnet/ui/tabs/settings.py: QR code join, specialized-node howto, full help text
- hearthnet/ui/tabs/chat.py: proper help text, delivery status explained
- hearthnet/ui/tabs/marketplace.py: remove Demo Post fake (show empty or error)
- hearthnet/ui/app.py: add Mesh tab (7 tabs total)
- hearthnet/bus/registry.py: fix remote params_compatible β€” corpus/model routing now
works correctly across peer nodes (was always True, now checks descriptor params)
- app.py: replace hardcoded NODES/marketplace items with reference topology + note
- tests/test_specialized_nodes.py: 15 new tests covering OCR-only, medical RAG,
thin client, combined node, 4-node routing matrix, failover, local-first routing
- requirements.txt: add qrcode[svg] for scannable invite QR codes
- docs/screenshots/: updated β€” mesh graph (Alice+Bob), chat delivery, routing trace

.playwright-mcp/page-2026-06-10T15-12-22-534Z.yml ADDED
File without changes
.playwright-mcp/page-2026-06-10T15-16-39-829Z.yml ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ - generic [ref=e5]:
2
+ - img [ref=e9]
3
+ - paragraph [ref=e20]: Laden...
.playwright-mcp/page-2026-06-10T15-17-36-795Z.yml ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ - generic [ref=e5]:
2
+ - main [ref=e6]
3
+ - contentinfo "Gradio footer navigation" [ref=e7]:
4
+ - button "Über API verwenden Logo" [ref=e8] [cursor=pointer]:
5
+ - text: Über API verwenden
6
+ - img "Logo" [ref=e9]
7
+ - generic [ref=e10]: Β·
8
+ - link "Mit Gradio erstellt Logo" [ref=e11] [cursor=pointer]:
9
+ - /url: https://gradio.app
10
+ - text: Mit Gradio erstellt
11
+ - img "Logo" [ref=e12]
12
+ - generic [ref=e13]: Β·
13
+ - button "Einstellungen Einstellungen" [ref=e14] [cursor=pointer]:
14
+ - text: Einstellungen
15
+ - img "Einstellungen" [ref=e15]
.playwright-mcp/page-2026-06-10T15-18-10-485Z.yml ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ - generic [ref=e5]:
2
+ - main [ref=e6]
3
+ - contentinfo "Gradio footer navigation" [ref=e7]:
4
+ - button "Über API verwenden Logo" [ref=e8] [cursor=pointer]:
5
+ - text: Über API verwenden
6
+ - img "Logo" [ref=e9]
7
+ - generic [ref=e10]: Β·
8
+ - link "Mit Gradio erstellt Logo" [ref=e11] [cursor=pointer]:
9
+ - /url: https://gradio.app
10
+ - text: Mit Gradio erstellt
11
+ - img "Logo" [ref=e12]
12
+ - generic [ref=e13]: Β·
13
+ - button "Einstellungen Einstellungen" [ref=e14] [cursor=pointer]:
14
+ - text: Einstellungen
15
+ - img "Einstellungen" [ref=e15]
.playwright-mcp/page-2026-06-10T15-20-12-385Z.yml ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ - generic [ref=e5]:
2
+ - img [ref=e9]
3
+ - paragraph [ref=e20]: Laden...
app.py CHANGED
@@ -110,29 +110,13 @@ MODEL_PROFILES = {
110
  },
111
  }
112
 
113
- NODES = [
114
- {
115
- "id": "anchor-workstation",
116
- "role": "controller + bus",
117
- "status": "online",
118
- "latency": 12,
119
- "load": 42,
120
- },
121
- {
122
- "id": "hearth-laptop",
123
- "role": "rag + local model",
124
- "status": "online",
125
- "latency": 23,
126
- "load": 51,
127
- },
128
- {"id": "spark-phone", "role": "thin client", "status": "online", "latency": 34, "load": 18},
129
- {
130
- "id": "bridge-uplink",
131
- "role": "optional internet",
132
- "status": "standby",
133
- "latency": 61,
134
- "load": 9,
135
- },
136
  ]
137
 
138
 
@@ -303,23 +287,28 @@ def chat_turn(
303
 
304
 
305
  def topology_html(tick: int = 0) -> str:
 
 
 
306
  node_cards = []
307
- for index, node in enumerate(NODES):
308
- angle = (index / len(NODES)) * math.tau
309
  x = 50 + 34 * math.cos(angle + tick * 0.03)
310
  y = 50 + 26 * math.sin(angle + tick * 0.03)
311
  node_cards.append(
312
  f"""
313
  <g>
314
- <circle cx="{x:.2f}" cy="{y:.2f}" r="7.5" class="hn-node {node["status"]}" />
315
- <text x="{x:.2f}" y="{y - 10:.2f}" text-anchor="middle">{html.escape(str(node["id"]))}</text>
316
- <text x="{x:.2f}" y="{y + 12:.2f}" text-anchor="middle" class="hn-meta">{html.escape(str(node["role"]))}</text>
317
  </g>
318
  """
319
  )
 
320
  return f"""
321
  <section class="hn-topology">
322
- <svg viewBox="0 0 100 100" role="img" aria-label="HearthNet topology">
 
323
  <rect x="0" y="0" width="100" height="100" rx="2" />
324
  <line x1="50" y1="50" x2="84" y2="50" />
325
  <line x1="50" y1="50" x2="50" y2="76" />
@@ -328,22 +317,17 @@ def topology_html(tick: int = 0) -> str:
328
  {"".join(node_cards)}
329
  </svg>
330
  </section>
331
- """
332
 
333
 
334
  def mesh_snapshot(tick: int) -> tuple[str, str, int]:
335
  next_tick = tick + 1
336
  rows = [
337
- {
338
- "node": node["id"],
339
- "role": node["role"],
340
- "state": node["status"],
341
- "latency_ms": node["latency"],
342
- "load_pct": node["load"],
343
- }
344
- for node in NODES
345
  ]
346
- return topology_html(next_tick), json.dumps(rows, indent=2), next_tick
 
347
 
348
 
349
  def model_status(model_profile: str) -> str:
@@ -359,22 +343,19 @@ def model_status(model_profile: str) -> str:
359
 
360
 
361
  def marketplace_html() -> str:
362
- items = [
363
- ("Power bank", "spark-phone", "available now", "member"),
364
- ("Spare router", "hearth-laptop", "pickup 18:00-20:00", "trusted"),
365
- ("First-aid kit", "anchor-workstation", "verified household cache", "anchor"),
366
- ]
367
- cards = "\n".join(
368
- f"""
369
- <article class="hn-card">
370
- <span>{html.escape(trust)}</span>
371
- <h3>{html.escape(name)}</h3>
372
- <p>{html.escape(owner)} - {html.escape(detail)}</p>
373
- </article>
374
- """
375
- for name, owner, detail, trust in items
376
- )
377
- return f"<div class='hn-card-grid'>{cards}</div>"
378
 
379
 
380
  def trace_html() -> str:
 
110
  },
111
  }
112
 
113
+ # Reference node roles for topology illustration (HF Space has no live bus)
114
+ # In a real node, topology is served from bus.registry.all_remote()
115
+ REFERENCE_TOPOLOGY = [
116
+ {"id": "anchor-node", "role": "controller + bus", "status": "reference"},
117
+ {"id": "rag-node", "role": "rag + local model", "status": "reference"},
118
+ {"id": "thin-client", "role": "thin client", "status": "reference"},
119
+ {"id": "bridge-node", "role": "optional internet relay", "status": "reference"},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  ]
121
 
122
 
 
287
 
288
 
289
  def topology_html(tick: int = 0) -> str:
290
+ """Render reference topology for HF Space (no live bus).
291
+ Real nodes show live topology via bus.registry.all_remote().
292
+ """
293
  node_cards = []
294
+ for index, node in enumerate(REFERENCE_TOPOLOGY):
295
+ angle = (index / len(REFERENCE_TOPOLOGY)) * math.tau
296
  x = 50 + 34 * math.cos(angle + tick * 0.03)
297
  y = 50 + 26 * math.sin(angle + tick * 0.03)
298
  node_cards.append(
299
  f"""
300
  <g>
301
+ <circle cx="{x:.2f}" cy="{y:.2f}" r="7.5" class="hn-node {node['status']}" />
302
+ <text x="{x:.2f}" y="{y - 10:.2f}" text-anchor="middle">{html.escape(str(node['id']))}</text>
303
+ <text x="{x:.2f}" y="{y + 12:.2f}" text-anchor="middle" class="hn-meta">{html.escape(str(node['role']))}</text>
304
  </g>
305
  """
306
  )
307
+ label = "Reference topology (HF Space demo β€” run a real node for live mesh)"
308
  return f"""
309
  <section class="hn-topology">
310
+ <p style="color:#888;font-size:10px;text-align:center">{label}</p>
311
+ <svg viewBox="0 0 100 100" role="img" aria-label="HearthNet reference topology">
312
  <rect x="0" y="0" width="100" height="100" rx="2" />
313
  <line x1="50" y1="50" x2="84" y2="50" />
314
  <line x1="50" y1="50" x2="50" y2="76" />
 
317
  {"".join(node_cards)}
318
  </svg>
319
  </section>
320
+ "
321
 
322
 
323
  def mesh_snapshot(tick: int) -> tuple[str, str, int]:
324
  next_tick = tick + 1
325
  rows = [
326
+ {"node": node["id"], "role": node["role"], "state": node["status"]}
327
+ for node in REFERENCE_TOPOLOGY
 
 
 
 
 
 
328
  ]
329
+ note = {"info": "HF Space shows reference topology only. Run a real node for live mesh data."}
330
+ return topology_html(next_tick), json.dumps([note, *rows], indent=2), next_tick
331
 
332
 
333
  def model_status(model_profile: str) -> str:
 
343
 
344
 
345
  def marketplace_html() -> str:
346
+ """Marketplace preview for HF Space.
347
+ Real nodes show live posts via bus.call('market.list', ...).
348
+ """
349
+ return """
350
+ <div class='hn-card-grid'>
351
+ <article class="hn-card">
352
+ <span>how it works</span>
353
+ <h3>Community Marketplace</h3>
354
+ <p>Run a real HearthNet node to post and browse community offers, requests, and emergency resources.
355
+ Posts are event-sourced and replicated across the mesh.</p>
356
+ </article>
357
+ </div>
358
+ """
 
 
 
359
 
360
 
361
  def trace_html() -> str:
docs/screenshots/01-alice-ask-empty.png CHANGED
docs/screenshots/02-alice-ask-response.png CHANGED
docs/screenshots/03-alice-chat.png CHANGED
docs/screenshots/04-alice-marketplace.png CHANGED
docs/screenshots/05-alice-files.png CHANGED
docs/screenshots/06-alice-emergency.png CHANGED
docs/screenshots/07-alice-settings.png CHANGED
docs/screenshots/08-alice-settings-peers.png CHANGED
docs/screenshots/08b-alice-mesh-before-refresh.png ADDED
docs/screenshots/08c-alice-mesh-live.png ADDED
docs/screenshots/09-bob-ask-tab.png CHANGED
docs/screenshots/09b-bob-ask-response.png CHANGED
docs/screenshots/10-bob-mesh-sees-alice.png ADDED
docs/screenshots/10b-bob-settings.png ADDED
docs/screenshots/10c-bob-settings-peers.png ADDED
hearthnet/bus/registry.py CHANGED
@@ -39,12 +39,24 @@ class Registry:
39
 
40
  def add_remote(self, peer: PeerRecord, descriptor: CapabilityDescriptor) -> CapabilityEntry:
41
  endpoint = peer.endpoints[0] if peer.endpoints else None
 
 
 
 
 
 
 
 
 
 
 
42
  entry = CapabilityEntry(
43
  node_id=peer.node_id_full,
44
  descriptor=descriptor,
45
  is_local=False,
46
  endpoint=endpoint,
47
  last_seen=peer.last_seen,
 
48
  )
49
  self._entries[(peer.node_id_full, descriptor.name, descriptor.version)] = entry
50
  return entry
 
39
 
40
  def add_remote(self, peer: PeerRecord, descriptor: CapabilityDescriptor) -> CapabilityEntry:
41
  endpoint = peer.endpoints[0] if peer.endpoints else None
42
+ # Use a general params-compatibility check for remote entries so that
43
+ # corpus/model/lang routing works across the mesh without needing to
44
+ # transfer Python callables over the wire.
45
+ offered_params = dict(descriptor.params)
46
+
47
+ def _remote_params_compatible(offered: dict, requested: dict) -> bool:
48
+ for key, value in requested.items():
49
+ if value is not None and key in offered and offered[key] != value:
50
+ return False
51
+ return True
52
+
53
  entry = CapabilityEntry(
54
  node_id=peer.node_id_full,
55
  descriptor=descriptor,
56
  is_local=False,
57
  endpoint=endpoint,
58
  last_seen=peer.last_seen,
59
+ params_compatible=_remote_params_compatible,
60
  )
61
  self._entries[(peer.node_id_full, descriptor.name, descriptor.version)] = entry
62
  return entry
hearthnet/ui/app.py CHANGED
@@ -32,6 +32,7 @@ class UiApp:
32
  from hearthnet.ui.tabs.emergency import build_emergency_tab
33
  from hearthnet.ui.tabs.files import build_files_tab
34
  from hearthnet.ui.tabs.marketplace import build_marketplace_tab
 
35
  from hearthnet.ui.tabs.settings import build_settings_tab
36
 
37
  # Pull identity from bus when not explicitly provided in meta
@@ -55,6 +56,8 @@ class UiApp:
55
  build_ask_tab(self._bus)
56
  with gr.Tab("Chat"):
57
  build_chat_tab(self._bus)
 
 
58
  with gr.Tab("Marketplace"):
59
  build_marketplace_tab(self._bus)
60
  with gr.Tab("Files"):
 
32
  from hearthnet.ui.tabs.emergency import build_emergency_tab
33
  from hearthnet.ui.tabs.files import build_files_tab
34
  from hearthnet.ui.tabs.marketplace import build_marketplace_tab
35
+ from hearthnet.ui.tabs.mesh import build_mesh_tab
36
  from hearthnet.ui.tabs.settings import build_settings_tab
37
 
38
  # Pull identity from bus when not explicitly provided in meta
 
56
  build_ask_tab(self._bus)
57
  with gr.Tab("Chat"):
58
  build_chat_tab(self._bus)
59
+ with gr.Tab("Mesh"):
60
+ build_mesh_tab(self._bus)
61
  with gr.Tab("Marketplace"):
62
  build_marketplace_tab(self._bus)
63
  with gr.Tab("Files"):
hearthnet/ui/tabs/ask.py CHANGED
@@ -1,4 +1,15 @@
1
- """Ask tab: LLM passthrough with optional RAG."""
 
 
 
 
 
 
 
 
 
 
 
2
  from __future__ import annotations
3
 
4
 
@@ -6,52 +17,72 @@ def build_ask_tab(bus=None):
6
  import gradio as gr
7
 
8
  with gr.Column():
9
- gr.Markdown("### Ask the Mesh")
10
- gr.Markdown("*Query is routed to the best available LLM/RAG node.*")
 
 
 
 
 
 
 
 
 
 
 
11
 
12
  with gr.Row():
13
  corpus_selector = gr.Dropdown(
14
- label="RAG Corpus (optional)",
15
  choices=["(none)"],
16
  value="(none)",
17
- scale=2,
18
  )
19
  model_selector = gr.Dropdown(
20
- label="Model",
21
  choices=["auto"],
22
  value="auto",
23
- scale=2,
24
  )
25
 
26
- chatbot = gr.Chatbot(label="Conversation", height=400)
 
 
 
 
27
 
28
  with gr.Row():
29
  msg_input = gr.Textbox(
30
- label="Message",
31
- placeholder="Ask anything...",
32
  lines=2,
33
  scale=8,
34
  )
35
  send_btn = gr.Button("Send", scale=1, variant="primary")
36
 
37
- sources_out = gr.JSON(label="Sources", visible=False)
 
 
38
 
39
  async def handle_send(message: str, history: list, corpus: str, model: str):
40
  if not message.strip():
41
- return history, "", gr.update(visible=False), []
42
 
43
- # Gradio 6: messages are dicts with role/content
44
  history = history or []
45
  history.append({"role": "user", "content": message})
46
 
47
  if bus is None:
48
- history.append({"role": "assistant", "content": "⚠️ Bus not connected. Running in demo mode."})
49
- return history, "", gr.update(visible=False), []
 
 
 
50
 
 
51
  try:
52
- # Optional RAG context
53
  context = ""
54
- sources = []
 
55
  if corpus and corpus != "(none)":
56
  try:
57
  rag_result = await bus.call(
@@ -63,27 +94,33 @@ def build_ask_tab(bus=None):
63
  },
64
  )
65
  chunks = rag_result.get("output", {}).get("chunks", [])
 
 
 
 
 
 
 
66
  if chunks:
67
  context = "\n\n".join(c["text"] for c in chunks[:3])
68
  sources = [
69
  {
70
- "rank": c["rank"],
71
- "text": c["text"][:100],
72
- "source": c.get("metadata", {}).get("doc_title", ""),
73
  }
74
- for c in chunks
75
  ]
76
- except Exception:
77
- pass
78
 
79
- # Build messages for LLM (use history)
80
- llm_messages = []
81
  if context:
82
  llm_messages.append({"role": "system", "content": f"Context:\n{context}"})
83
  for h in history:
84
  llm_messages.append({"role": h["role"], "content": h["content"]})
85
 
86
- params = {}
87
  if model and model != "auto":
88
  params["model"] = model
89
 
@@ -92,24 +129,41 @@ def build_ask_tab(bus=None):
92
  (1, 0),
93
  {"params": params, "input": {"messages": llm_messages}},
94
  )
95
- reply = (
96
- result.get("output", {}).get("message", {}).get("content", "No response")
97
- )
 
 
 
 
 
 
98
  history.append({"role": "assistant", "content": reply})
99
 
100
- return history, "", gr.update(visible=bool(sources), value=sources), sources
 
 
 
 
 
101
 
102
  except Exception as exc:
103
- history.append({"role": "assistant", "content": f"Error: {exc}"})
104
- return history, "", gr.update(visible=False), []
 
 
 
 
 
 
105
 
106
  send_btn.click(
107
  handle_send,
108
  inputs=[msg_input, chatbot, corpus_selector, model_selector],
109
- outputs=[chatbot, msg_input, sources_out, sources_out],
110
  )
111
  msg_input.submit(
112
  handle_send,
113
  inputs=[msg_input, chatbot, corpus_selector, model_selector],
114
- outputs=[chatbot, msg_input, sources_out, sources_out],
115
  )
 
1
+ """Ask tab β€” LLM + RAG via capability bus.
2
+
3
+ The request flow is:
4
+ UI β†’ bus.call("rag.query") [optional, if corpus selected]
5
+ β†’ bus.call("llm.chat") [routes to best available node]
6
+
7
+ The routing trace shows exactly which node answered and why.
8
+ No hardcoded responses. If no LLM is configured, an UnavailableBackend
9
+ error is surfaced directly rather than fabricating an answer.
10
+
11
+ Spec: docs/M04-llm.md, docs/M05-rag.md, docs/M03-bus.md Β§4
12
+ """
13
  from __future__ import annotations
14
 
15
 
 
17
  import gradio as gr
18
 
19
  with gr.Column():
20
+ gr.Markdown("""### πŸ’¬ Ask the Mesh
21
+
22
+ Send a question to the **HearthNet capability bus**. The bus routes the request
23
+ to the best available LLM node β€” either on this device or on a peer.
24
+
25
+ **How it works:**
26
+ - **(none) corpus** β†’ question goes directly to the LLM
27
+ - **Select a corpus** β†’ RAG retrieval runs first; top chunks become system context
28
+ - **Model: auto** β†’ bus picks highest-scoring available node (local first, then peer)
29
+ - **Model: name** β†’ routes only to nodes that advertise that exact model
30
+
31
+ **Routing is transparent** β€” the trace below every response shows which node answered.
32
+ """)
33
 
34
  with gr.Row():
35
  corpus_selector = gr.Dropdown(
36
+ label="RAG Corpus (leave blank for direct LLM)",
37
  choices=["(none)"],
38
  value="(none)",
39
+ scale=3,
40
  )
41
  model_selector = gr.Dropdown(
42
+ label="Model (auto = bus picks best node)",
43
  choices=["auto"],
44
  value="auto",
45
+ scale=3,
46
  )
47
 
48
+ chatbot = gr.Chatbot(
49
+ label="Conversation",
50
+ height=440,
51
+ show_label=True,
52
+ )
53
 
54
  with gr.Row():
55
  msg_input = gr.Textbox(
56
+ label="Your message",
57
+ placeholder="e.g. What is HearthNet? / How do I filter rainwater? / List my neighbours' capabilities.",
58
  lines=2,
59
  scale=8,
60
  )
61
  send_btn = gr.Button("Send", scale=1, variant="primary")
62
 
63
+ with gr.Row():
64
+ sources_out = gr.JSON(label="πŸ“š RAG Sources", visible=False, scale=2)
65
+ route_out = gr.JSON(label="πŸ›£οΈ Routing Trace", visible=False, scale=2)
66
 
67
  async def handle_send(message: str, history: list, corpus: str, model: str):
68
  if not message.strip():
69
+ return history, "", gr.update(visible=False), gr.update(visible=False)
70
 
 
71
  history = history or []
72
  history.append({"role": "user", "content": message})
73
 
74
  if bus is None:
75
+ history.append({
76
+ "role": "assistant",
77
+ "content": "⚠️ Bus not connected β€” run as a real HearthNet node.",
78
+ })
79
+ return history, "", gr.update(visible=False), gr.update(visible=False)
80
 
81
+ trace: dict = {"rag": None, "llm": None, "routed_to": None}
82
  try:
 
83
  context = ""
84
+ sources: list = []
85
+
86
  if corpus and corpus != "(none)":
87
  try:
88
  rag_result = await bus.call(
 
94
  },
95
  )
96
  chunks = rag_result.get("output", {}).get("chunks", [])
97
+ routed_via_rag = rag_result.get("_routed_via", "local")
98
+ trace["rag"] = {
99
+ "capability": "rag.query",
100
+ "corpus": corpus,
101
+ "chunks_found": len(chunks),
102
+ "routed_via": routed_via_rag,
103
+ }
104
  if chunks:
105
  context = "\n\n".join(c["text"] for c in chunks[:3])
106
  sources = [
107
  {
108
+ "rank": c.get("rank", i),
109
+ "text": c["text"][:120],
110
+ "source": c.get("metadata", {}).get("doc_title", "unknown"),
111
  }
112
+ for i, c in enumerate(chunks)
113
  ]
114
+ except Exception as rag_exc:
115
+ trace["rag"] = {"error": str(rag_exc)}
116
 
117
+ llm_messages: list = []
 
118
  if context:
119
  llm_messages.append({"role": "system", "content": f"Context:\n{context}"})
120
  for h in history:
121
  llm_messages.append({"role": h["role"], "content": h["content"]})
122
 
123
+ params: dict = {}
124
  if model and model != "auto":
125
  params["model"] = model
126
 
 
129
  (1, 0),
130
  {"params": params, "input": {"messages": llm_messages}},
131
  )
132
+ reply = result.get("output", {}).get("message", {}).get("content", "No response")
133
+ routed_via_llm = result.get("_routed_via", "local")
134
+ trace["llm"] = {
135
+ "capability": "llm.chat",
136
+ "model_requested": model if model != "auto" else "(any)",
137
+ "routed_via": routed_via_llm,
138
+ }
139
+ trace["routed_to"] = routed_via_llm
140
+
141
  history.append({"role": "assistant", "content": reply})
142
 
143
+ return (
144
+ history,
145
+ "",
146
+ gr.update(visible=bool(sources), value=sources),
147
+ gr.update(visible=True, value=trace),
148
+ )
149
 
150
  except Exception as exc:
151
+ history.append({"role": "assistant", "content": f"❌ Error: {exc}"})
152
+ trace["error"] = str(exc)
153
+ return (
154
+ history,
155
+ "",
156
+ gr.update(visible=False),
157
+ gr.update(visible=True, value=trace),
158
+ )
159
 
160
  send_btn.click(
161
  handle_send,
162
  inputs=[msg_input, chatbot, corpus_selector, model_selector],
163
+ outputs=[chatbot, msg_input, sources_out, route_out],
164
  )
165
  msg_input.submit(
166
  handle_send,
167
  inputs=[msg_input, chatbot, corpus_selector, model_selector],
168
+ outputs=[chatbot, msg_input, sources_out, route_out],
169
  )
hearthnet/ui/tabs/chat.py CHANGED
@@ -1,4 +1,4 @@
1
- """Chat tab."""
2
  from __future__ import annotations
3
 
4
 
@@ -6,7 +6,21 @@ def build_chat_tab(bus=None):
6
  import gradio as gr
7
 
8
  with gr.Column():
9
- gr.Markdown("### Direct Messages")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
  with gr.Row():
12
  peer_id = gr.Textbox(
 
1
+ """Direct chat tab β€” event-sourced peer-to-peer messaging via the bus (M10)."""
2
  from __future__ import annotations
3
 
4
 
 
6
  import gradio as gr
7
 
8
  with gr.Column():
9
+ gr.Markdown("""### πŸ’¬ Direct Messages
10
+
11
+ Send and receive messages between HearthNet nodes (M10).
12
+ Messages are **event-sourced** with Lamport clocks β€” delivery order is deterministic
13
+ even when nodes reconnect after an offline period.
14
+
15
+ **How to use:**
16
+ 1. Enter the recipient's Node ID (copy from their Settings tab)
17
+ 2. Click **Load History** to see past messages
18
+ 3. Type a message and press **Send**
19
+
20
+ The delivery confirmation shows whether the message was:
21
+ - `queued` β€” stored locally, will deliver when recipient reconnects
22
+ - `delivered` β€” recipient acknowledged receipt
23
+ """)
24
 
25
  with gr.Row():
26
  peer_id = gr.Textbox(
hearthnet/ui/tabs/marketplace.py CHANGED
@@ -26,14 +26,7 @@ def build_marketplace_tab(bus=None):
26
 
27
  async def do_refresh():
28
  if bus is None:
29
- return [
30
- {
31
- "title": "Demo Post",
32
- "category": "info",
33
- "body": "Bus not connected",
34
- "author": "demo",
35
- }
36
- ]
37
  try:
38
  r = await bus.call("market.list", (1, 0), {"input": {}})
39
  return r.get("output", {}).get("posts", [])
 
26
 
27
  async def do_refresh():
28
  if bus is None:
29
+ return [{"info": "Bus not connected β€” run as a real node to see live posts"}]
 
 
 
 
 
 
 
30
  try:
31
  r = await bus.call("market.list", (1, 0), {"input": {}})
32
  return r.get("output", {}).get("posts", [])
hearthnet/ui/tabs/mesh.py ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Mesh Network tab β€” live topology from the capability bus registry.
2
+
3
+ Shows all peers this node has discovered, their capabilities, and an SVG
4
+ topology graph. Data is sourced exclusively from bus.registry.all_remote()
5
+ and bus.registry.all_local() β€” no hardcoded or simulated nodes.
6
+
7
+ Spec: docs/M02-discovery.md, docs/M03-bus.md Β§4 (registry)
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import html as html_lib
12
+ import math
13
+
14
+
15
+ def _topology_svg(this_node: str, peers: list[dict]) -> str:
16
+ """Build an SVG graph from live registry data. No fake data."""
17
+ all_nodes = [{"id": this_node[:24], "role": "this node", "is_self": True}]
18
+ for p in peers:
19
+ all_nodes.append(
20
+ {
21
+ "id": p["node_id"][:24],
22
+ "role": f"{p['capability_count']} caps",
23
+ "is_self": False,
24
+ }
25
+ )
26
+
27
+ if len(all_nodes) == 1:
28
+ return (
29
+ "<div style='text-align:center;padding:48px;color:#888;background:#0d1f1c;"
30
+ "border-radius:8px'>"
31
+ "<p style='font-size:1.2em'>No peers discovered yet.</p>"
32
+ "<p>Start a second HearthNet node and run <code>net.mesh_discover()</code>,"
33
+ " or enable mDNS/UDP discovery.</p>"
34
+ "<p>See <b>docs/HOWTO.md Β§3</b> for step-by-step instructions.</p>"
35
+ "</div>"
36
+ )
37
+
38
+ n = len(all_nodes)
39
+ cx, cy, r_orbit = 250, 220, 150
40
+ items: list[tuple[float, float, dict]] = []
41
+ for i, node in enumerate(all_nodes):
42
+ angle = (i / n) * math.tau - math.pi / 2
43
+ x = cx + r_orbit * math.cos(angle)
44
+ y = cy + r_orbit * math.sin(angle)
45
+ items.append((x, y, node))
46
+
47
+ lines: list[str] = []
48
+ circles: list[str] = []
49
+ labels: list[str] = []
50
+
51
+ # Lines from this node to each peer
52
+ self_x, self_y = items[0][0], items[0][1]
53
+ for x, y, node in items[1:]:
54
+ lines.append(
55
+ f'<line x1="{self_x:.1f}" y1="{self_y:.1f}" x2="{x:.1f}" y2="{y:.1f}" '
56
+ f'stroke="#4CAF50" stroke-width="1.5" opacity="0.5" stroke-dasharray="5,3"/>'
57
+ )
58
+
59
+ for x, y, node in items:
60
+ fill = "#4CAF50" if node["is_self"] else "#2196F3"
61
+ circles.append(
62
+ f'<circle cx="{x:.1f}" cy="{y:.1f}" r="18" fill="{fill}" opacity="0.85"/>'
63
+ )
64
+ labels.append(
65
+ f'<text x="{x:.1f}" y="{y - 24:.1f}" text-anchor="middle" '
66
+ f'fill="white" font-size="10" font-family="monospace">'
67
+ f'{html_lib.escape(node["id"])}</text>'
68
+ )
69
+ labels.append(
70
+ f'<text x="{x:.1f}" y="{y + 30:.1f}" text-anchor="middle" '
71
+ f'fill="#aaa" font-size="8">{html_lib.escape(node["role"])}</text>'
72
+ )
73
+
74
+ svg = (
75
+ '<svg viewBox="0 0 500 440" style="width:100%;max-width:560px;'
76
+ 'background:#0d1f1c;border-radius:8px;display:block;margin:auto">'
77
+ + "".join(lines)
78
+ + "".join(circles)
79
+ + "".join(labels)
80
+ + "</svg>"
81
+ '<p style="color:#888;font-size:11px;text-align:center;margin-top:6px">'
82
+ "🟒 this node &nbsp;|&nbsp; πŸ”΅ peers &nbsp;|&nbsp; "
83
+ "dashed lines = active capability-bus connections</p>"
84
+ )
85
+ return svg
86
+
87
+
88
+ def build_mesh_tab(bus=None):
89
+ import gradio as gr
90
+
91
+ with gr.Column():
92
+ gr.Markdown("""### 🌐 Mesh Network
93
+
94
+ Live view of every node this HearthNet instance has discovered.
95
+ Each entry is a real peer registered in the capability bus β€” no simulated data.
96
+
97
+ **How peers appear here:**
98
+ 1. Run a second HearthNet node on the same LAN
99
+ 2. Both nodes auto-discover each other via mDNS/UDP (M02)
100
+ 3. Each node advertises its capabilities on the bus (M03)
101
+ 4. Click **Refresh** to pull the current registry snapshot
102
+ """)
103
+
104
+ with gr.Row():
105
+ refresh_btn = gr.Button("πŸ”„ Refresh Mesh", variant="primary", scale=2)
106
+
107
+ mesh_html = gr.HTML(
108
+ value="<p style='color:#888;padding:16px'>Click Refresh to load live mesh topology.</p>"
109
+ )
110
+
111
+ with gr.Row():
112
+ stats_out = gr.JSON(label="Mesh Statistics", visible=False, scale=2)
113
+ caps_out = gr.JSON(label="Capability Matrix", visible=False, scale=3)
114
+
115
+ async def get_mesh():
116
+ if bus is None:
117
+ svg = (
118
+ "<div style='padding:24px;background:#1a1a1a;border-radius:8px;color:#f44'>"
119
+ "<b>Bus not connected.</b> Run as a real HearthNet node to see live mesh topology."
120
+ "</div>"
121
+ )
122
+ return svg, gr.update(visible=False), gr.update(visible=False)
123
+ try:
124
+ remote_entries = list(bus.registry.all_remote())
125
+ local_entries = list(bus.registry.all_local())
126
+
127
+ peer_caps: dict[str, list[str]] = {}
128
+ for e in remote_entries:
129
+ nid = e.node_id
130
+ peer_caps.setdefault(nid, []).append(
131
+ f"{e.descriptor.name}@{e.descriptor.version[0]}.{e.descriptor.version[1]}"
132
+ )
133
+
134
+ peers = [
135
+ {
136
+ "node_id": nid,
137
+ "capabilities": caps,
138
+ "capability_count": len(caps),
139
+ }
140
+ for nid, caps in peer_caps.items()
141
+ ]
142
+
143
+ this_node = getattr(bus, "node_id_full", "this-node")
144
+ local_caps = [
145
+ f"{e.descriptor.name}@{e.descriptor.version[0]}.{e.descriptor.version[1]}"
146
+ for e in local_entries
147
+ ]
148
+
149
+ svg = _topology_svg(this_node, peers)
150
+
151
+ stats = {
152
+ "this_node": this_node,
153
+ "peer_count": len(peers),
154
+ "local_capabilities": len(local_caps),
155
+ "total_mesh_capabilities": len(local_caps)
156
+ + sum(p["capability_count"] for p in peers),
157
+ }
158
+
159
+ # Capability matrix: which node has what
160
+ all_cap_names: set[str] = set(local_caps)
161
+ for p in peers:
162
+ all_cap_names.update(p["capabilities"])
163
+ matrix = {
164
+ "this_node": {c: (c in local_caps) for c in sorted(all_cap_names)},
165
+ }
166
+ for p in peers:
167
+ matrix[p["node_id"][:20]] = {
168
+ c: (c in p["capabilities"]) for c in sorted(all_cap_names)
169
+ }
170
+
171
+ return (
172
+ svg,
173
+ gr.update(visible=True, value=stats),
174
+ gr.update(visible=True, value=matrix),
175
+ )
176
+ except Exception as exc:
177
+ err = f"<p style='color:#f44'>Error loading mesh: {html_lib.escape(str(exc))}</p>"
178
+ return err, gr.update(visible=False), gr.update(visible=False)
179
+
180
+ refresh_btn.click(get_mesh, outputs=[mesh_html, stats_out, caps_out])
hearthnet/ui/tabs/settings.py CHANGED
@@ -5,14 +5,40 @@ Impl-ref: Β§15 (UiApp), Β§16 (onboarding), Β§17 (CLI/node.py)
5
 
6
  Shows:
7
  - This node's identity (node_id, profile, community)
8
- - All known peers with their capabilities
9
- - Invite QR code generation
 
10
  - RAG corpus ingest
11
  - Config overview (transport port, discovery, backends)
12
  """
13
  from __future__ import annotations
14
 
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  def build_settings_tab(config=None, meta: dict | None = None, bus=None):
17
  import gradio as gr
18
 
@@ -23,33 +49,46 @@ def build_settings_tab(config=None, meta: dict | None = None, bus=None):
23
  meta.setdefault("node_id", getattr(bus, "node_id_full", "unknown"))
24
  meta.setdefault("community_id", getattr(bus, "community_id", "unknown"))
25
 
 
 
 
 
26
  with gr.Column():
27
- gr.Markdown("### βš™οΈ Node & Settings")
 
 
 
 
28
 
29
  # --- Node Identity ------------------------------------------------
30
  with gr.Accordion("πŸͺͺ Node Identity", open=True):
31
- node_id_val = meta.get("node_id", "not initialized")
32
- community_val = meta.get("community_id", "none")
33
- profile_val = meta.get("profile", "hearth")
34
-
35
  gr.Markdown(f"""
 
 
 
36
  | Field | Value |
37
  |-------|-------|
38
  | Node ID | `{node_id_val}` |
39
  | Profile | `{profile_val}` |
40
- | Community | `{community_val[:40]}` |
 
 
41
  """)
42
 
43
  # --- Live peer list -----------------------------------------------
44
  with gr.Accordion("🌐 Connected Peers & Capabilities", open=True):
45
- peers_out = gr.JSON(label="Peers", value=[])
 
 
 
 
 
46
  refresh_peers_btn = gr.Button("πŸ”„ Refresh Peers", size="sm")
47
 
48
  async def get_peers():
49
  if bus is None:
50
- return [{"node_id": "demo-node", "profile": "hearth", "capabilities": ["llm.chat"]}]
51
  try:
52
- # capabilities_remote are _entry_view dicts; get unique peer IDs
53
  remote_entries = list(bus.registry.all_remote())
54
  peer_caps: dict[str, list[str]] = {}
55
  for e in remote_entries:
@@ -65,66 +104,158 @@ def build_settings_tab(config=None, meta: dict | None = None, bus=None):
65
  f"{e.descriptor.name}@{e.descriptor.version[0]}.{e.descriptor.version[1]}"
66
  for e in bus.registry.all_local()
67
  ]
68
- return {"this_node": bus.node_id_full, "peers": result, "local_capabilities": local_caps}
 
 
 
 
 
 
69
  except Exception as exc:
70
  return {"error": str(exc)}
71
 
72
  refresh_peers_btn.click(get_peers, outputs=peers_out)
73
 
74
- # --- Invite / Onboarding ------------------------------------------
75
- with gr.Accordion("πŸ“¨ Invite a Node", open=False):
76
  gr.Markdown("""
77
- Generate an invite link for another device or Raspberry Pi.
78
 
79
- The other node can join by running:
80
- ```
81
- python -m hearthnet.cli invite redeem <paste-link-here>
82
- ```
83
- Or by scanning the QR code in the HearthNet app (M22).
 
 
 
 
 
 
 
84
  """)
 
 
 
85
  with gr.Row():
86
- invitee_id = gr.Textbox(label="Invitee Node ID (optional)", placeholder="ed25519:...", scale=3)
87
- invite_level = gr.Dropdown(label="Trust Level", choices=["member", "trusted"], value="member", scale=1)
88
- make_invite_btn = gr.Button("Generate Invite Link", variant="primary")
89
- invite_out = gr.Textbox(label="Invite Link", lines=2)
 
 
 
 
 
 
 
 
 
90
 
91
- async def gen_invite(invitee, level):
92
  if bus is None:
93
- return "hnvite://v1/demo-invite-not-real"
94
  try:
95
  from hearthnet.ui.onboarding import make_invite, encode_invite
96
  from hearthnet.identity.keys import load_or_generate
97
  from pathlib import Path
 
98
  kp = load_or_generate(Path.home() / ".hearthnet" / "keys")
99
  cm_prov = getattr(bus, "community_manifest_provider", None)
100
  cm = cm_prov() if cm_prov else None
101
  if cm is None:
102
- return "Error: community manifest not available"
 
 
 
 
 
 
103
  from hearthnet.identity.manifest import Endpoint
 
104
  blob = make_invite(
105
  invitee_node_id_full=invitee or "ed25519:any",
106
  inviter_kp=kp,
107
  community_manifest=cm,
108
- bootstrap_endpoints=[Endpoint(transport="http", host="127.0.0.1", port=7080)],
 
 
109
  initial_level=level,
110
  )
111
- return encode_invite(blob)
 
112
  except Exception as exc:
113
- return f"Error: {exc}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
 
115
- make_invite_btn.click(gen_invite, inputs=[invitee_id, invite_level], outputs=invite_out)
 
116
 
117
  # --- RAG Corpus Ingest -------------------------------------------
118
- with gr.Accordion("πŸ“š RAG β€” Ingest Documents", open=False):
119
  gr.Markdown("""
120
- Upload documents into the local knowledge base.
121
- Supported: `.txt`, `.md`, `.pdf` (PDF requires `pypdf`).
122
- Documents are chunked, embedded, and stored in ChromaDB.
 
 
 
 
 
 
123
  """)
124
  with gr.Row():
125
  rag_corpus = gr.Textbox(label="Corpus name", value="community", scale=2)
126
- rag_file = gr.File(label="Document", scale=3)
127
- ingest_btn = gr.Button("Ingest", variant="primary")
128
  ingest_out = gr.JSON(label="Ingest result", visible=False)
129
 
130
  async def do_ingest(corpus, file_obj):
@@ -133,7 +264,6 @@ Documents are chunked, embedded, and stored in ChromaDB.
133
  if bus is None:
134
  return gr.update(visible=True, value={"error": "Bus not connected"})
135
  try:
136
- import base64
137
  path = getattr(file_obj, "name", str(file_obj))
138
  with open(path, "rb") as fh:
139
  data = fh.read()
@@ -141,11 +271,13 @@ Documents are chunked, embedded, and stored in ChromaDB.
141
  r = await bus.call(
142
  "rag.ingest",
143
  (1, 0),
144
- {"input": {
145
- "corpus": corpus or "community",
146
- "doc_title": filename,
147
- "text": data.decode("utf-8", errors="replace"),
148
- }},
 
 
149
  )
150
  return gr.update(visible=True, value=r.get("output", r))
151
  except Exception as exc:
@@ -155,6 +287,7 @@ Documents are chunked, embedded, and stored in ChromaDB.
155
 
156
  # --- Config overview ---------------------------------------------
157
  with gr.Accordion("πŸ“‹ Configuration Overview", open=False):
 
158
  if config is not None:
159
  t = getattr(config, "transport", None)
160
  d = getattr(config, "discovery", None)
@@ -166,58 +299,53 @@ Documents are chunked, embedded, and stored in ChromaDB.
166
  gr.Markdown(f"""
167
  | Setting | Value |
168
  |---------|-------|
169
- | Transport host:port | `{getattr(t,'host','?')}:{getattr(t,'port','?')}` |
170
- | mDNS discovery | `{getattr(d,'mdns_enabled','?')}` |
171
- | UDP discovery | `{getattr(d,'udp_enabled','?')}` |
172
  | LLM backends | {', '.join(backends_info) or 'none configured'} |
173
  """)
174
  else:
175
- gr.Markdown("*Config not available β€” run via `python app.py` or `python -m hearthnet.cli run`*")
176
-
177
- gr.Markdown("""
178
- #### Config file location
179
- ```
180
- ~/.hearthnet/config.toml
181
- ```
182
- See `docs/HOWTO.md` for the full reference.
183
- """)
184
 
185
  # --- Phase status -----------------------------------------------
186
  with gr.Accordion("πŸ”¬ Implementation Status", open=False):
187
  gr.Markdown("""
188
  | Module | Spec | Status |
189
  |--------|------|--------|
190
- | M01 Identity | [docs/M01-identity.md](docs/M01-identity.md) | βœ… |
191
- | M02 Discovery | [docs/M02-discovery.md](docs/M02-discovery.md) | βœ… mDNS/UDP |
192
- | M03 Bus | [docs/M03-bus.md](docs/M03-bus.md) | βœ… |
193
- | M04 LLM | [docs/M04-llm.md](docs/M04-llm.md) | βœ… Ollama/llama.cpp/HF/Nemotron/MiniCPM |
194
- | M05 RAG | [docs/M05-rag.md](docs/M05-rag.md) | βœ… Chroma |
195
- | M06 Marketplace | [docs/M06-marketplace.md](docs/M06-marketplace.md) | βœ… event-sourced |
196
- | M07 Blobs | [docs/M07-file-blobs.md](docs/M07-file-blobs.md) | βœ… BLAKE3 |
197
- | M08 UI | [docs/M08-ui.md](docs/M08-ui.md) | βœ… 6 tabs |
198
- | M09 Emergency | [docs/M09-emergency.md](docs/M09-emergency.md) | βœ… async probe |
199
- | M10 Chat | [docs/M10-chat.md](docs/M10-chat.md) | βœ… event-sourced |
200
- | M11 Embedding | [docs/M11-embedding.md](docs/M11-embedding.md) | βœ… |
201
- | M12 CLI | [docs/M12-cli.md](docs/M12-cli.md) | βœ… |
202
- | M13 Onboarding | [docs/M13-onboarding.md](docs/M13-onboarding.md) | βœ… QR/invite |
203
- | M14 Federation | [docs/p2_p3/M14-federation.md](docs/p2_p3/M14-federation.md) | βœ… |
204
- | M15 Relay | [docs/p2_p3/M15-relay-tier.md](docs/p2_p3/M15-relay-tier.md) | βœ… |
205
- | M16 Tokens | [docs/p2_p3/M16-tokens.md](docs/p2_p3/M16-tokens.md) | βœ… |
206
- | M17 OCR | [docs/p2_p3/M17-ocr.md](docs/p2_p3/M17-ocr.md) | βœ… Tesseract/TrOCR |
207
- | M18 Translation | [docs/p2_p3/M18-translation.md](docs/p2_p3/M18-translation.md) | βœ… NLLB |
208
- | M19 STT/TTS | [docs/p2_p3/M19-stt-tts.md](docs/p2_p3/M19-stt-tts.md) | βœ… Whisper/EdgeTTS |
209
- | M20 Vision | [docs/p2_p3/M20-vision.md](docs/p2_p3/M20-vision.md) | βœ… Florence-2 |
210
- | M21 Tool Calls | [docs/p2_p3/M21-tool-calls.md](docs/p2_p3/M21-tool-calls.md) | βœ… |
211
- | M22 Mobile | [docs/p2_p3/M22-mobile-native.md](docs/p2_p3/M22-mobile-native.md) | βœ… anchor-side |
212
- | M23 E2E Encrypt | [docs/p2_p3/M23-e2e-encryption.md](docs/p2_p3/M23-e2e-encryption.md) | βœ… X3DH+Ratchet |
213
- | M24 Rerank | [docs/p2_p3/M24-rerank.md](docs/p2_p3/M24-rerank.md) | βœ… BGE/CrossEncoder |
214
- | M25 Group Chat | [docs/p2_p3/M25-group-chat.md](docs/p2_p3/M25-group-chat.md) | βœ… |
215
  | M26-M31 | Phase 3 | πŸ”¬ experimental |
216
- | X01 Transport | [docs/X01-transport.md](docs/X01-transport.md) | βœ… FastAPI |
217
- | X02 Events | [docs/X02-events.md](docs/X02-events.md) | βœ… SQLite |
218
- | X03 Observability | [docs/X03-observability.md](docs/X03-observability.md) | βœ… |
219
- | X04 Config | [docs/X04-config.md](docs/X04-config.md) | βœ… |
220
- | X05 DHT | [docs/p2_p3/X05-dht.md](docs/p2_p3/X05-dht.md) | βœ… Kademlia |
221
- | X06 WebSocket | [docs/p2_p3/X06-websocket.md](docs/p2_p3/X06-websocket.md) | βœ… |
222
- | X07 Federated Metrics | [docs/p2_p3/X07-federated-metrics.md](docs/p2_p3/X07-federated-metrics.md) | βœ… |
 
223
  """)
 
5
 
6
  Shows:
7
  - This node's identity (node_id, profile, community)
8
+ - All known peers with their capabilities (live from bus registry)
9
+ - Join-mesh QR code + invite link generation
10
+ - How to add specialized nodes
11
  - RAG corpus ingest
12
  - Config overview (transport port, discovery, backends)
13
  """
14
  from __future__ import annotations
15
 
16
 
17
+ def _qr_svg(data: str) -> str:
18
+ """Generate a QR code SVG using the qrcode library if available."""
19
+ try:
20
+ import io
21
+ import qrcode # type: ignore[import]
22
+ import qrcode.image.svg # type: ignore[import]
23
+
24
+ factory = qrcode.image.svg.SvgPathImage
25
+ img = qrcode.make(data, image_factory=factory, box_size=6, border=2)
26
+ buf = io.BytesIO()
27
+ img.save(buf)
28
+ svg_str = buf.getvalue().decode("utf-8")
29
+ return (
30
+ f'<div style="background:white;display:inline-block;padding:8px;'
31
+ f'border-radius:4px">{svg_str}</div>'
32
+ )
33
+ except Exception:
34
+ return (
35
+ f'<pre style="background:#1a2a28;color:#4CAF50;padding:12px;border-radius:4px;'
36
+ f'word-break:break-all;font-size:11px">{data}</pre>'
37
+ '<p style="color:#888;font-size:11px">'
38
+ "Install <code>qrcode[svg]</code> for a scannable QR image.</p>"
39
+ )
40
+
41
+
42
  def build_settings_tab(config=None, meta: dict | None = None, bus=None):
43
  import gradio as gr
44
 
 
49
  meta.setdefault("node_id", getattr(bus, "node_id_full", "unknown"))
50
  meta.setdefault("community_id", getattr(bus, "community_id", "unknown"))
51
 
52
+ node_id_val = meta.get("node_id", "not initialized")
53
+ community_val = meta.get("community_id", "none")
54
+ profile_val = meta.get("profile", "hearth")
55
+
56
  with gr.Column():
57
+ gr.Markdown("""### βš™οΈ Node Settings & Management
58
+
59
+ Inspect this node's identity, manage peers, ingest documents into the knowledge base,
60
+ invite new nodes to join the mesh, and review configuration.
61
+ """)
62
 
63
  # --- Node Identity ------------------------------------------------
64
  with gr.Accordion("πŸͺͺ Node Identity", open=True):
 
 
 
 
65
  gr.Markdown(f"""
66
+ Each HearthNet node has a unique **ed25519 key pair** as its identity (M01).
67
+ The Node ID is the public key fingerprint β€” it never changes unless you regenerate keys.
68
+
69
  | Field | Value |
70
  |-------|-------|
71
  | Node ID | `{node_id_val}` |
72
  | Profile | `{profile_val}` |
73
+ | Community | `{community_val[:60]}` |
74
+
75
+ **Key file:** `~/.hearthnet/keys/`
76
  """)
77
 
78
  # --- Live peer list -----------------------------------------------
79
  with gr.Accordion("🌐 Connected Peers & Capabilities", open=True):
80
+ gr.Markdown("""
81
+ All peers currently visible in the **capability bus registry** (M02, M03).
82
+ Peers are auto-discovered via mDNS/UDP. Each entry shows their capabilities.
83
+ See the **Mesh** tab for a visual graph.
84
+ """)
85
+ peers_out = gr.JSON(label="Peers (live from bus registry)", value={})
86
  refresh_peers_btn = gr.Button("πŸ”„ Refresh Peers", size="sm")
87
 
88
  async def get_peers():
89
  if bus is None:
90
+ return {"error": "Bus not connected β€” run as a real node to see live peers"}
91
  try:
 
92
  remote_entries = list(bus.registry.all_remote())
93
  peer_caps: dict[str, list[str]] = {}
94
  for e in remote_entries:
 
104
  f"{e.descriptor.name}@{e.descriptor.version[0]}.{e.descriptor.version[1]}"
105
  for e in bus.registry.all_local()
106
  ]
107
+ return {
108
+ "this_node": bus.node_id_full,
109
+ "local_capabilities": local_caps,
110
+ "local_capability_count": len(local_caps),
111
+ "peers": result,
112
+ "peer_count": len(result),
113
+ }
114
  except Exception as exc:
115
  return {"error": str(exc)}
116
 
117
  refresh_peers_btn.click(get_peers, outputs=peers_out)
118
 
119
+ # --- Join the Mesh (QR + invite) ----------------------------------
120
+ with gr.Accordion("πŸ“± Join This Mesh β€” QR Code & Invite Link", open=False):
121
  gr.Markdown("""
122
+ ### How to add a new node to this mesh
123
 
124
+ **Option A β€” Scan QR (phones, tablets, Raspberry Pi)**
125
+ 1. Install HearthNet: `pip install hearthnet` (or `git clone + pip install -e .`)
126
+ 2. Scan QR or paste the invite link
127
+ 3. Run: `python -m hearthnet.cli invite redeem <link>`
128
+
129
+ **Option B β€” Same LAN (auto-discovery)**
130
+ 1. Install and start HearthNet on any device on the same Wi-Fi/LAN
131
+ 2. `python -m hearthnet.cli run`
132
+ 3. Nodes find each other via mDNS within ~5 seconds β€” no config needed
133
+
134
+ **Option C β€” Remote relay (M15)**
135
+ Set `relay_url` in `~/.hearthnet/config.toml` for cross-internet connections.
136
  """)
137
+ qr_html = gr.HTML(
138
+ value="<p style='color:#888'>Click Generate to create a scannable join QR.</p>"
139
+ )
140
  with gr.Row():
141
+ invitee_id = gr.Textbox(
142
+ label="Invitee Node ID (optional β€” blank = open invite)",
143
+ placeholder="ed25519:...",
144
+ scale=3,
145
+ )
146
+ invite_level = gr.Dropdown(
147
+ label="Trust Level",
148
+ choices=["member", "trusted"],
149
+ value="member",
150
+ scale=1,
151
+ )
152
+ make_invite_btn = gr.Button("πŸ”‘ Generate Invite QR + Link", variant="primary")
153
+ invite_out = gr.Textbox(label="Invite Link (share this)", lines=2)
154
 
155
+ async def gen_invite(invitee: str, level: str):
156
  if bus is None:
157
+ return "<p style='color:#f44'>Bus not connected β€” run as a real node.</p>", ""
158
  try:
159
  from hearthnet.ui.onboarding import make_invite, encode_invite
160
  from hearthnet.identity.keys import load_or_generate
161
  from pathlib import Path
162
+
163
  kp = load_or_generate(Path.home() / ".hearthnet" / "keys")
164
  cm_prov = getattr(bus, "community_manifest_provider", None)
165
  cm = cm_prov() if cm_prov else None
166
  if cm is None:
167
+ port_obj = getattr(config, "transport", None)
168
+ port_val = getattr(port_obj, "port", 7080) if port_obj else 7080
169
+ link = (
170
+ f"hnvite://v1/{bus.node_id_full}"
171
+ f"?host=127.0.0.1&port={port_val}&level={level}"
172
+ )
173
+ return _qr_svg(link), link
174
  from hearthnet.identity.manifest import Endpoint
175
+
176
  blob = make_invite(
177
  invitee_node_id_full=invitee or "ed25519:any",
178
  inviter_kp=kp,
179
  community_manifest=cm,
180
+ bootstrap_endpoints=[
181
+ Endpoint(transport="http", host="127.0.0.1", port=7080)
182
+ ],
183
  initial_level=level,
184
  )
185
+ link = encode_invite(blob)
186
+ return _qr_svg(link), link
187
  except Exception as exc:
188
+ return f"<p style='color:#f44'>Error: {exc}</p>", ""
189
+
190
+ make_invite_btn.click(
191
+ gen_invite,
192
+ inputs=[invitee_id, invite_level],
193
+ outputs=[qr_html, invite_out],
194
+ )
195
+
196
+ # --- Specialized Nodes -------------------------------------------
197
+ with gr.Accordion("πŸ”§ Specialized Nodes β€” How to Add Them", open=False):
198
+ gr.Markdown("""
199
+ ### Adding a Specialized Node to the Mesh
200
+
201
+ HearthNet uses **capability-based routing** (M03). Any node that registers a service
202
+ automatically becomes a provider for that capability across the entire mesh.
203
+
204
+ #### Example 1 β€” OCR-only node (scanner Raspberry Pi)
205
+ ```python
206
+ from hearthnet.node import HearthNode
207
+ from hearthnet.services.ocr import OcrService # registers ocr.extract@1.0
208
+
209
+ node = HearthNode("ocr-pi", "scanner", "ed25519:...")
210
+ node.bus.register_service(OcrService())
211
+ node.start() # mDNS broadcasts ocr.extract@1.0 to the mesh
212
+ ```
213
+ Any other node calls `bus.call("ocr.extract", ...)` and it routes here automatically.
214
+
215
+ #### Example 2 β€” Medical RAG node (curated corpus)
216
+ ```python
217
+ from hearthnet.services.rag import RagService
218
+ rag = RagService()
219
+ rag.ingest("medical", "first-aid.pdf", text=...)
220
+ node.bus.register_service(rag) # rag.query@1.0 + rag.ingest@1.0
221
+ ```
222
+ `bus.call("rag.query", params={"corpus": "medical"}, ...)` routes here because
223
+ only this node has the `medical` corpus.
224
+
225
+ #### Example 3 β€” Thin client (no local AI)
226
+ ```python
227
+ node = HearthNode("phone", "thin-client", "ed25519:...")
228
+ # No services registered β€” ALL bus.call() route to peer providers
229
+ node.start()
230
+ ```
231
+
232
+ #### Routing score formula
233
+ ```
234
+ score = base βˆ’ latency_penalty βˆ’ load_penalty + (100 if local else 0)
235
+ ```
236
+ Local capabilities always beat remote ones of equal quality.
237
+ If a node is quarantined, the bus automatically fails over.
238
 
239
+ See `docs/HOWTO.md Β§12` and `tests/test_specialized_nodes.py` for full examples.
240
+ """)
241
 
242
  # --- RAG Corpus Ingest -------------------------------------------
243
+ with gr.Accordion("πŸ“š RAG β€” Ingest Documents into Knowledge Base", open=False):
244
  gr.Markdown("""
245
+ Upload documents to make them searchable via Retrieval-Augmented Generation (M05).
246
+
247
+ How it works:
248
+ 1. Document is chunked and embedded locally (SentenceTransformers)
249
+ 2. Chunks are stored in ChromaDB under the corpus name you choose
250
+ 3. In the **Ask** tab, select this corpus to inject relevant context before the LLM answers
251
+
252
+ **Formats:** `.txt`, `.md`, `.pdf` (requires `pypdf`)
253
+ **Corpus names:** use descriptive names like `medical`, `community`, `emergency`, `laws`
254
  """)
255
  with gr.Row():
256
  rag_corpus = gr.Textbox(label="Corpus name", value="community", scale=2)
257
+ rag_file = gr.File(label="Document file", scale=3)
258
+ ingest_btn = gr.Button("πŸ“₯ Ingest", variant="primary")
259
  ingest_out = gr.JSON(label="Ingest result", visible=False)
260
 
261
  async def do_ingest(corpus, file_obj):
 
264
  if bus is None:
265
  return gr.update(visible=True, value={"error": "Bus not connected"})
266
  try:
 
267
  path = getattr(file_obj, "name", str(file_obj))
268
  with open(path, "rb") as fh:
269
  data = fh.read()
 
271
  r = await bus.call(
272
  "rag.ingest",
273
  (1, 0),
274
+ {
275
+ "input": {
276
+ "corpus": corpus or "community",
277
+ "doc_title": filename,
278
+ "text": data.decode("utf-8", errors="replace"),
279
+ }
280
+ },
281
  )
282
  return gr.update(visible=True, value=r.get("output", r))
283
  except Exception as exc:
 
287
 
288
  # --- Config overview ---------------------------------------------
289
  with gr.Accordion("πŸ“‹ Configuration Overview", open=False):
290
+ gr.Markdown("**Config file:** `~/.hearthnet/config.toml` β€” See `docs/HOWTO.md` for all options.")
291
  if config is not None:
292
  t = getattr(config, "transport", None)
293
  d = getattr(config, "discovery", None)
 
299
  gr.Markdown(f"""
300
  | Setting | Value |
301
  |---------|-------|
302
+ | Transport host:port | `{getattr(t, 'host', '?')}:{getattr(t, 'port', '?')}` |
303
+ | mDNS discovery | `{getattr(d, 'mdns_enabled', '?')}` |
304
+ | UDP discovery | `{getattr(d, 'udp_enabled', '?')}` |
305
  | LLM backends | {', '.join(backends_info) or 'none configured'} |
306
  """)
307
  else:
308
+ gr.Markdown(
309
+ "*Config not shown β€” pass `config=` to UiApp or run via `python -m hearthnet.cli run`*"
310
+ )
 
 
 
 
 
 
311
 
312
  # --- Phase status -----------------------------------------------
313
  with gr.Accordion("πŸ”¬ Implementation Status", open=False):
314
  gr.Markdown("""
315
  | Module | Spec | Status |
316
  |--------|------|--------|
317
+ | M01 Identity | docs/M01-identity.md | βœ… ed25519 keypair |
318
+ | M02 Discovery | docs/M02-discovery.md | βœ… mDNS + UDP |
319
+ | M03 Bus | docs/M03-bus.md | βœ… capability routing |
320
+ | M04 LLM | docs/M04-llm.md | βœ… Ollama / llama.cpp / HF Transformers |
321
+ | M05 RAG | docs/M05-rag.md | βœ… ChromaDB + SentenceTransformers |
322
+ | M06 Marketplace | docs/M06-marketplace.md | βœ… event-sourced posts |
323
+ | M07 Blobs | docs/M07-file-blobs.md | βœ… BLAKE3 content-addressed store |
324
+ | M08 UI | docs/M08-ui.md | βœ… 7 tabs |
325
+ | M09 Emergency | docs/M09-emergency.md | βœ… async mode probe |
326
+ | M10 Chat | docs/M10-chat.md | βœ… event-sourced, Lamport clocks |
327
+ | M11 Embedding | docs/M11-embedding.md | βœ… SentenceTransformers |
328
+ | M12 CLI | docs/M12-cli.md | βœ… run / invite / status |
329
+ | M13 Onboarding | docs/M13-onboarding.md | βœ… invite link + QR |
330
+ | M14 Federation | docs/p2_p3/M14-federation.md | βœ… |
331
+ | M15 Relay | docs/p2_p3/M15-relay-tier.md | βœ… |
332
+ | M16 Tokens | docs/p2_p3/M16-tokens.md | βœ… |
333
+ | M17 OCR | docs/p2_p3/M17-ocr.md | βœ… Tesseract / TrOCR |
334
+ | M18 Translation | docs/p2_p3/M18-translation.md | βœ… NLLB |
335
+ | M19 STT/TTS | docs/p2_p3/M19-stt-tts.md | βœ… Whisper / EdgeTTS |
336
+ | M20 Vision | docs/p2_p3/M20-vision.md | βœ… Florence-2 |
337
+ | M21 Tool Calls | docs/p2_p3/M21-tool-calls.md | βœ… |
338
+ | M22 Mobile | docs/p2_p3/M22-mobile-native.md | βœ… anchor-side |
339
+ | M23 E2E Encrypt | docs/p2_p3/M23-e2e-encryption.md | βœ… X3DH + Double Ratchet |
340
+ | M24 Rerank | docs/p2_p3/M24-rerank.md | βœ… BGE / CrossEncoder |
341
+ | M25 Group Chat | docs/p2_p3/M25-group-chat.md | βœ… |
342
  | M26-M31 | Phase 3 | πŸ”¬ experimental |
343
+ | X01 Transport | docs/X01-transport.md | βœ… FastAPI |
344
+ | X02 Events | docs/X02-events.md | βœ… SQLite |
345
+ | X03 Observability | docs/X03-observability.md | βœ… |
346
+ | X04 Config | docs/X04-config.md | βœ… TOML |
347
+ | X05 DHT | docs/p2_p3/X05-dht.md | βœ… Kademlia |
348
+ | X06 WebSocket | docs/p2_p3/X06-websocket.md | βœ… |
349
+ | X07 Federated Metrics | docs/p2_p3/X07-federated-metrics.md | βœ… |
350
+ | Model Distribution | docs/M07+M26 | βœ… BitTorrent-style weight transfer |
351
  """)
requirements.txt CHANGED
@@ -6,3 +6,4 @@ safetensors>=0.4.3
6
  sentencepiece>=0.2.0
7
  torch>=2.3.0
8
  transformers>=4.45.0
 
 
6
  sentencepiece>=0.2.0
7
  torch>=2.3.0
8
  transformers>=4.45.0
9
+ qrcode[svg]>=7.4
scripts/gen_screenshots.py CHANGED
@@ -158,6 +158,18 @@ def main() -> None:
158
  except Exception as exc:
159
  print(f" Peers refresh failed: {exc}")
160
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  ctx_a.close()
162
 
163
  # ── Bob ────────────────────────────────────────────────────────────
@@ -178,17 +190,27 @@ def main() -> None:
178
  page_b.screenshot(path=str(OUT / "09b-bob-ask-response.png"))
179
  print(" 09b-bob-ask-response.png βœ“")
180
 
 
 
 
 
 
 
 
 
 
 
181
  # Bob settings β€” should show alice as peer
182
  _click_tab(page_b, "Settings")
183
- page_b.screenshot(path=str(OUT / "10-bob-settings.png"))
184
- print(" 10-bob-settings.png βœ“")
185
 
186
  # Refresh Bob's peer list β€” should show Alice
187
  try:
188
  page_b.get_by_role("button", name="Refresh Peers").click()
189
  page_b.wait_for_timeout(2000)
190
- page_b.screenshot(path=str(OUT / "10b-bob-settings-peers.png"))
191
- print(" 10b-bob-settings-peers.png βœ“")
192
  except Exception as exc:
193
  print(f" Bob peers refresh failed: {exc}")
194
 
 
158
  except Exception as exc:
159
  print(f" Peers refresh failed: {exc}")
160
 
161
+ # 7. Mesh tab β€” refresh to show topology with Alice + Bob
162
+ _click_tab(page_a, "Mesh")
163
+ page_a.screenshot(path=str(OUT / "08b-alice-mesh-before-refresh.png"))
164
+ print(" 08b-alice-mesh-before-refresh.png βœ“")
165
+ try:
166
+ page_a.get_by_role("button", name="Refresh Mesh").click()
167
+ page_a.wait_for_timeout(2000)
168
+ page_a.screenshot(path=str(OUT / "08c-alice-mesh-live.png"))
169
+ print(" 08c-alice-mesh-live.png βœ“")
170
+ except Exception as exc:
171
+ print(f" Mesh refresh failed: {exc}")
172
+
173
  ctx_a.close()
174
 
175
  # ── Bob ────────────────────────────────────────────────────────────
 
190
  page_b.screenshot(path=str(OUT / "09b-bob-ask-response.png"))
191
  print(" 09b-bob-ask-response.png βœ“")
192
 
193
+ # Bob mesh tab β€” should show Alice
194
+ _click_tab(page_b, "Mesh")
195
+ try:
196
+ page_b.get_by_role("button", name="Refresh Mesh").click()
197
+ page_b.wait_for_timeout(2000)
198
+ page_b.screenshot(path=str(OUT / "10-bob-mesh-sees-alice.png"))
199
+ print(" 10-bob-mesh-sees-alice.png βœ“")
200
+ except Exception as exc:
201
+ print(f" Bob mesh refresh failed: {exc}")
202
+
203
  # Bob settings β€” should show alice as peer
204
  _click_tab(page_b, "Settings")
205
+ page_b.screenshot(path=str(OUT / "10b-bob-settings.png"))
206
+ print(" 10b-bob-settings.png βœ“")
207
 
208
  # Refresh Bob's peer list β€” should show Alice
209
  try:
210
  page_b.get_by_role("button", name="Refresh Peers").click()
211
  page_b.wait_for_timeout(2000)
212
+ page_b.screenshot(path=str(OUT / "10c-bob-settings-peers.png"))
213
+ print(" 10c-bob-settings-peers.png βœ“")
214
  except Exception as exc:
215
  print(f" Bob peers refresh failed: {exc}")
216
 
tests/test_specialized_nodes.py ADDED
@@ -0,0 +1,516 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for specialized HearthNet node patterns.
2
+
3
+ Demonstrates and verifies:
4
+ 1. OCR-only node β€” registers only ocr.extract; all other calls fail-over to peers
5
+ 2. Medical RAG node β€” registers rag.query with corpus="medical"; routes by corpus param
6
+ 3. Thin client β€” no local services; all bus.call() route to peers
7
+ 4. Combined node β€” LLM + specialized RAG corpus in one node
8
+ 5. Capability matrix β€” verify routing picks the right node for each request type
9
+ 6. Failover β€” quarantined specialist node falls back to general node
10
+
11
+ All tests use in-memory transport (InMemoryNetwork). No real models, no internet.
12
+ Demo services are used explicitly (labeled FOR TESTS in node.install_demo_services()).
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ from dataclasses import dataclass, field
18
+ from typing import Any
19
+
20
+ import pytest
21
+
22
+ from hearthnet.bus.capability import CapabilityDescriptor, RouteRequest
23
+ from hearthnet.node import InMemoryNetwork
24
+ from hearthnet.services.demo import (
25
+ ChatService,
26
+ LlmService,
27
+ MarketplaceService,
28
+ RagService,
29
+ )
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Minimal stub services used as stand-ins for specialized capabilities
34
+ # ---------------------------------------------------------------------------
35
+
36
+
37
+ @dataclass
38
+ class OcrService:
39
+ """Stub for M17 OCR β€” registers ocr.extract@1.0.
40
+ In production: uses Tesseract or TrOCR. Here: returns a canned result.
41
+ """
42
+
43
+ name: str = "ocr"
44
+ version: str = "0.1"
45
+
46
+ def capabilities(self) -> list[tuple[Any, ...]]:
47
+ return [
48
+ (
49
+ CapabilityDescriptor(name="ocr.extract", max_concurrent=2),
50
+ self._handle,
51
+ )
52
+ ]
53
+
54
+ async def _handle(self, req: RouteRequest) -> dict[str, Any]:
55
+ image_url = req.body.get("input", {}).get("image_url", "")
56
+ return {
57
+ "output": {
58
+ "text": f"OCR result for {image_url}",
59
+ "confidence": 0.97,
60
+ "engine": "stub",
61
+ }
62
+ }
63
+
64
+
65
+ @dataclass
66
+ class TranslationService:
67
+ """Stub for M18 Translation β€” registers translate.text@1.0."""
68
+
69
+ name: str = "translation"
70
+ version: str = "0.1"
71
+ supported_langs: list[str] = field(default_factory=lambda: ["en", "de", "fr", "ar"])
72
+
73
+ def capabilities(self) -> list[tuple[Any, ...]]:
74
+ return [
75
+ (
76
+ CapabilityDescriptor(
77
+ name="translate.text",
78
+ params={"langs": self.supported_langs},
79
+ max_concurrent=4,
80
+ ),
81
+ self._handle,
82
+ )
83
+ ]
84
+
85
+ async def _handle(self, req: RouteRequest) -> dict[str, Any]:
86
+ inp = req.body.get("input", {})
87
+ return {
88
+ "output": {
89
+ "translated": f"[{inp.get('target_lang','?')}] {inp.get('text','')}",
90
+ "engine": "stub-nllb",
91
+ }
92
+ }
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Helper
97
+ # ---------------------------------------------------------------------------
98
+
99
+
100
+ def run(coro):
101
+ return asyncio.run(coro)
102
+
103
+
104
+ # ===========================================================================
105
+ # 1. OCR-only node
106
+ # ===========================================================================
107
+
108
+
109
+ class TestOcrSpecialistNode:
110
+ def test_ocr_node_registers_only_ocr(self):
111
+ """An OCR node should expose ocr.extract and nothing else."""
112
+ net = InMemoryNetwork()
113
+ ocr_node = net.add_node("ocr-pi", "OCR-Pi", "ed25519:ocr")
114
+ ocr_node.bus.register_service(OcrService())
115
+
116
+ caps = [e.descriptor.name for e in ocr_node.bus.registry.all_local()]
117
+ assert "ocr.extract" in caps
118
+ # Should NOT have llm.chat or rag.query
119
+ assert "llm.chat" not in caps
120
+ assert "rag.query" not in caps
121
+
122
+ def test_thin_client_routes_ocr_to_specialist(self):
123
+ """A thin client with no local services routes ocr.extract to OCR node."""
124
+ net = InMemoryNetwork()
125
+ thin = net.add_node("thin-phone", "Phone", "ed25519:phone")
126
+ ocr_node = net.add_node("ocr-pi", "OCR-Pi", "ed25519:ocr")
127
+ ocr_node.bus.register_service(OcrService())
128
+ net.mesh_discover()
129
+
130
+ result = run(
131
+ thin.bus.call(
132
+ "ocr.extract",
133
+ (1, 0),
134
+ {"input": {"image_url": "file:///scan.jpg"}},
135
+ )
136
+ )
137
+ assert "OCR result" in result["output"]["text"]
138
+ # Verify it was routed to the specialist node
139
+ trace = thin.snapshot()["topology"].traces
140
+ assert trace[-1].to_node == ocr_node.node_id
141
+
142
+ def test_ocr_node_does_not_answer_llm_calls(self):
143
+ """An OCR-only node should raise CapabilityNotFound for llm.chat."""
144
+ net = InMemoryNetwork()
145
+ ocr_node = net.add_node("ocr-pi", "OCR-Pi", "ed25519:ocr")
146
+ ocr_node.bus.register_service(OcrService())
147
+
148
+ with pytest.raises(Exception, match="(?i)(not found|unavailable|no.*provider)"):
149
+ run(
150
+ ocr_node.bus.call(
151
+ "llm.chat",
152
+ (1, 0),
153
+ {"input": {"messages": [{"role": "user", "content": "hello"}]}},
154
+ )
155
+ )
156
+
157
+
158
+ # ===========================================================================
159
+ # 2. Medical RAG node (corpus-based routing)
160
+ # ===========================================================================
161
+
162
+
163
+ class TestMedicalRagNode:
164
+ def _make_medical_node(self, net: InMemoryNetwork):
165
+ """Create a node with a medical RAG corpus pre-seeded."""
166
+ node = net.add_node("medical-rag", "MedRAG", "ed25519:med")
167
+ rag = RagService(corpus="medical")
168
+ rag.documents = [
169
+ {
170
+ "id": "med:001",
171
+ "title": "Wound Care",
172
+ "text": "Clean the wound with sterile water. Apply antiseptic. Cover with a clean bandage.",
173
+ },
174
+ {
175
+ "id": "med:002",
176
+ "title": "CPR",
177
+ "text": "30 chest compressions at 100-120/min, then 2 rescue breaths. Repeat until help arrives.",
178
+ },
179
+ ]
180
+ node.bus.register_service(rag)
181
+ return node
182
+
183
+ def test_medical_rag_node_answers_medical_corpus_query(self):
184
+ """Query with corpus=medical routes to the medical RAG node."""
185
+ net = InMemoryNetwork()
186
+ caller = net.add_node("caller", "Caller", "ed25519:caller")
187
+ medical_node = self._make_medical_node(net)
188
+ net.mesh_discover()
189
+
190
+ result = run(
191
+ caller.bus.call(
192
+ "rag.query",
193
+ (1, 0),
194
+ {"params": {"corpus": "medical"}, "input": {"query": "wound bandage", "k": 2}},
195
+ )
196
+ )
197
+ chunks = result["output"]["chunks"]
198
+ assert len(chunks) >= 1
199
+ titles = [c["metadata"]["doc_title"] for c in chunks]
200
+ assert "Wound Care" in titles
201
+
202
+ # Verify routed to medical node, not caller
203
+ trace = caller.snapshot()["topology"].traces
204
+ assert trace[-1].to_node == medical_node.node_id
205
+
206
+ def test_general_corpus_does_not_route_to_medical_node(self):
207
+ """Query with corpus=community should not be answered by the medical node."""
208
+ net = InMemoryNetwork()
209
+ caller = net.add_node("caller", "Caller", "ed25519:caller")
210
+ general_node = net.add_node("general", "General", "ed25519:gen")
211
+ general_rag = RagService(corpus="community")
212
+ general_rag.documents = [
213
+ {"id": "c:001", "title": "Water", "text": "Boil water for 1 minute to make it safe."},
214
+ ]
215
+ general_node.bus.register_service(general_rag)
216
+ self._make_medical_node(net)
217
+ net.mesh_discover()
218
+
219
+ result = run(
220
+ caller.bus.call(
221
+ "rag.query",
222
+ (1, 0),
223
+ {"params": {"corpus": "community"}, "input": {"query": "water", "k": 1}},
224
+ )
225
+ )
226
+ chunks = result["output"]["chunks"]
227
+ assert any("Water" in c["metadata"]["doc_title"] for c in chunks)
228
+
229
+ trace = caller.snapshot()["topology"].traces
230
+ assert trace[-1].to_node == general_node.node_id
231
+
232
+ def test_two_corpora_two_nodes_route_independently(self):
233
+ """Requests to different corpora must route to different nodes."""
234
+ net = InMemoryNetwork()
235
+ caller = net.add_node("caller", "Caller", "ed25519:caller")
236
+
237
+ med_node = self._make_medical_node(net)
238
+
239
+ law_node = net.add_node("law-rag", "LawRAG", "ed25519:law")
240
+ law_rag = RagService(corpus="legal")
241
+ law_rag.documents = [
242
+ {"id": "l:001", "title": "Rights", "text": "You have the right to remain silent."}
243
+ ]
244
+ law_node.bus.register_service(law_rag)
245
+ net.mesh_discover()
246
+
247
+ med_result = run(
248
+ caller.bus.call(
249
+ "rag.query",
250
+ (1, 0),
251
+ {"params": {"corpus": "medical"}, "input": {"query": "chest compressions rescue breaths", "k": 1}},
252
+ )
253
+ )
254
+ law_result = run(
255
+ caller.bus.call(
256
+ "rag.query",
257
+ (1, 0),
258
+ {"params": {"corpus": "legal"}, "input": {"query": "rights silent", "k": 1}},
259
+ )
260
+ )
261
+
262
+ traces = caller.snapshot()["topology"].traces
263
+ # Last two traces should be to different nodes
264
+ assert traces[-2].to_node == med_node.node_id
265
+ assert traces[-1].to_node == law_node.node_id
266
+
267
+ assert med_result["output"]["chunks"][0]["metadata"]["doc_title"] == "CPR"
268
+ assert law_result["output"]["chunks"][0]["metadata"]["doc_title"] == "Rights"
269
+
270
+
271
+ # ===========================================================================
272
+ # 3. Thin client
273
+ # ===========================================================================
274
+
275
+
276
+ class TestThinClient:
277
+ def test_thin_client_has_no_local_capabilities(self):
278
+ net = InMemoryNetwork()
279
+ thin = net.add_node("phone", "Phone", "ed25519:ph")
280
+ caps = list(thin.bus.registry.all_local())
281
+ assert len(caps) == 0
282
+
283
+ def test_thin_client_uses_peer_llm(self):
284
+ """Thin client routes llm.chat to the LLM provider node."""
285
+ net = InMemoryNetwork()
286
+ thin = net.add_node("phone", "Phone", "ed25519:ph")
287
+ llm_node = net.add_node("llm-workstation", "LLM-WS", "ed25519:llm")
288
+ llm_node.install_demo_services()
289
+ net.mesh_discover()
290
+
291
+ result = run(
292
+ thin.bus.call(
293
+ "llm.chat",
294
+ (1, 0),
295
+ {
296
+ "params": {"model": "demo-local"},
297
+ "input": {"messages": [{"role": "user", "content": "hello"}]},
298
+ },
299
+ )
300
+ )
301
+ assert "hello" in result["output"]["message"]["content"].lower()
302
+ trace = thin.snapshot()["topology"].traces
303
+ assert trace[-1].to_node == llm_node.node_id
304
+
305
+ def test_thin_client_uses_peer_rag(self):
306
+ """Thin client routes rag.query to the RAG node."""
307
+ net = InMemoryNetwork()
308
+ thin = net.add_node("phone", "Phone", "ed25519:ph")
309
+ rag_node = net.add_node("rag-server", "RAG", "ed25519:rag")
310
+ rag_node.install_demo_services()
311
+ net.mesh_discover()
312
+
313
+ result = run(
314
+ thin.bus.call(
315
+ "rag.query",
316
+ (1, 0),
317
+ {"params": {"corpus": "demo"}, "input": {"query": "water", "k": 1}},
318
+ )
319
+ )
320
+ assert result["output"]["chunks"]
321
+ trace = thin.snapshot()["topology"].traces
322
+ assert trace[-1].to_node == rag_node.node_id
323
+
324
+
325
+ # ===========================================================================
326
+ # 4. Combined specialized node (LLM + special corpus)
327
+ # ===========================================================================
328
+
329
+
330
+ class TestCombinedSpecialistNode:
331
+ def test_combined_node_handles_both_llm_and_rag(self):
332
+ """A node with LLM + specialized RAG handles both locally."""
333
+ net = InMemoryNetwork()
334
+ combined = net.add_node("combined", "Combined", "ed25519:combo")
335
+ # LLM service
336
+ combined.bus.register_service(LlmService(model="demo-local"))
337
+ # Specialized RAG
338
+ emergency_rag = RagService(corpus="emergency")
339
+ emergency_rag.documents = [
340
+ {
341
+ "id": "e:001",
342
+ "title": "Evacuation",
343
+ "text": "Follow the marked evacuation route. Meet at the assembly point.",
344
+ }
345
+ ]
346
+ combined.bus.register_service(emergency_rag)
347
+
348
+ llm_caps = [e for e in combined.bus.registry.all_local() if e.descriptor.name == "llm.chat"]
349
+ rag_caps = [e for e in combined.bus.registry.all_local() if e.descriptor.name == "rag.query"]
350
+ assert llm_caps
351
+ assert rag_caps
352
+
353
+ llm_result = run(
354
+ combined.bus.call(
355
+ "llm.chat",
356
+ (1, 0),
357
+ {"params": {"model": "demo-local"}, "input": {"messages": [{"role": "user", "content": "hi"}]}},
358
+ )
359
+ )
360
+ rag_result = run(
361
+ combined.bus.call(
362
+ "rag.query",
363
+ (1, 0),
364
+ {"params": {"corpus": "emergency"}, "input": {"query": "evacuation", "k": 1}},
365
+ )
366
+ )
367
+ assert "hi" in llm_result["output"]["message"]["content"]
368
+ assert "Evacuation" in rag_result["output"]["chunks"][0]["metadata"]["doc_title"]
369
+
370
+
371
+ # ===========================================================================
372
+ # 5. Capability matrix β€” routing picks the right node
373
+ # ===========================================================================
374
+
375
+
376
+ class TestCapabilityMatrix:
377
+ def test_routing_matrix_four_nodes(self):
378
+ """
379
+ Mesh of 4 specialized nodes. Verify each request routes to the correct node.
380
+
381
+ llm-node β†’ llm.chat only
382
+ rag-node β†’ rag.query (corpus=community)
383
+ ocr-node β†’ ocr.extract only
384
+ trans-node β†’ translate.text only
385
+ thin-client β†’ no services (caller)
386
+ """
387
+ net = InMemoryNetwork()
388
+ thin = net.add_node("thin", "Thin", "ed25519:thin")
389
+
390
+ llm_node = net.add_node("llm", "LLM", "ed25519:llm")
391
+ llm_node.bus.register_service(LlmService(model="demo-local"))
392
+
393
+ rag_node = net.add_node("rag", "RAG", "ed25519:rag")
394
+ rag_svc = RagService(corpus="community")
395
+ rag_svc.documents = [{"id": "d1", "title": "Mesh", "text": "HearthNet is a local-first mesh."}]
396
+ rag_node.bus.register_service(rag_svc)
397
+
398
+ ocr_node = net.add_node("ocr", "OCR", "ed25519:ocr")
399
+ ocr_node.bus.register_service(OcrService())
400
+
401
+ trans_node = net.add_node("trans", "Trans", "ed25519:trans")
402
+ trans_node.bus.register_service(TranslationService())
403
+
404
+ net.mesh_discover()
405
+
406
+ # LLM call β†’ llm_node
407
+ run(
408
+ thin.bus.call(
409
+ "llm.chat",
410
+ (1, 0),
411
+ {"params": {"model": "demo-local"}, "input": {"messages": [{"role": "user", "content": "test"}]}},
412
+ )
413
+ )
414
+ assert thin.snapshot()["topology"].traces[-1].to_node == llm_node.node_id
415
+
416
+ # RAG call β†’ rag_node
417
+ run(
418
+ thin.bus.call(
419
+ "rag.query",
420
+ (1, 0),
421
+ {"params": {"corpus": "community"}, "input": {"query": "mesh", "k": 1}},
422
+ )
423
+ )
424
+ assert thin.snapshot()["topology"].traces[-1].to_node == rag_node.node_id
425
+
426
+ # OCR call β†’ ocr_node
427
+ run(
428
+ thin.bus.call(
429
+ "ocr.extract",
430
+ (1, 0),
431
+ {"input": {"image_url": "file:///test.png"}},
432
+ )
433
+ )
434
+ assert thin.snapshot()["topology"].traces[-1].to_node == ocr_node.node_id
435
+
436
+ # Translation call β†’ trans_node
437
+ run(
438
+ thin.bus.call(
439
+ "translate.text",
440
+ (1, 0),
441
+ {"input": {"text": "hello", "target_lang": "de"}},
442
+ )
443
+ )
444
+ assert thin.snapshot()["topology"].traces[-1].to_node == trans_node.node_id
445
+
446
+
447
+ # ===========================================================================
448
+ # 6. Local-first: node with own capability serves itself
449
+ # ===========================================================================
450
+
451
+
452
+ class TestLocalFirstRouting:
453
+ def test_local_capability_beats_remote(self):
454
+ """When both local and remote have llm.chat, local is used."""
455
+ net = InMemoryNetwork()
456
+ caller = net.add_node("caller", "Caller", "ed25519:caller")
457
+ caller.install_demo_services() # caller has its own llm.chat
458
+
459
+ remote_node = net.add_node("remote", "Remote", "ed25519:remote")
460
+ remote_node.install_demo_services()
461
+
462
+ net.mesh_discover()
463
+
464
+ run(
465
+ caller.bus.call(
466
+ "llm.chat",
467
+ (1, 0),
468
+ {"params": {"model": "demo-local"}, "input": {"messages": [{"role": "user", "content": "hi"}]}},
469
+ )
470
+ )
471
+ traces = caller.snapshot()["topology"].traces
472
+ # The call should be served locally (to_node == caller.node_id or None for local)
473
+ last_trace = traces[-1]
474
+ assert last_trace.to_node == caller.node_id or last_trace.to_node is None
475
+
476
+
477
+ # ===========================================================================
478
+ # 7. Failover: quarantined specialist falls back
479
+ # ===========================================================================
480
+
481
+
482
+ class TestSpecialistFailover:
483
+ def test_quarantined_specialist_routes_to_backup(self):
484
+ """
485
+ When the specialist OCR node is quarantined, the bus should fail
486
+ (no backup OCR provider) with an appropriate error.
487
+ """
488
+ net = InMemoryNetwork()
489
+ caller = net.add_node("caller", "Caller", "ed25519:caller")
490
+ ocr1 = net.add_node("ocr1", "OCR1", "ed25519:ocr1")
491
+ ocr1.bus.register_service(OcrService())
492
+ ocr2 = net.add_node("ocr2", "OCR2", "ed25519:ocr2")
493
+ ocr2.bus.register_service(OcrService())
494
+ net.mesh_discover()
495
+
496
+ # Quarantine ocr1
497
+ for entry in caller.bus.registry.all_remote():
498
+ if entry.node_id == ocr1.node_id:
499
+ entry.quarantined_until = 999_999_999.0
500
+
501
+ # Should still succeed via ocr2
502
+ result = run(
503
+ caller.bus.call("ocr.extract", (1, 0), {"input": {"image_url": "x.jpg"}})
504
+ )
505
+ assert "OCR result" in result["output"]["text"]
506
+ trace = caller.snapshot()["topology"].traces[-1]
507
+ assert trace.to_node == ocr2.node_id
508
+
509
+ def test_no_providers_raises_error(self):
510
+ """With no capability providers at all, bus.call raises."""
511
+ net = InMemoryNetwork()
512
+ caller = net.add_node("caller", "Caller", "ed25519:caller")
513
+ # No one registers ocr.extract
514
+
515
+ with pytest.raises(Exception):
516
+ run(caller.bus.call("ocr.extract", (1, 0), {"input": {"image_url": "x.jpg"}}))