GitHub Actions commited on
Commit
27be63b
·
1 Parent(s): 90a59b3

feat: activate real services, fix gossip, wire sponsor backends + EventLog

Browse files

P1 gossip: _HttpxSyncClient adapter fixes _gossip_loop (wrong HttpClient args + missing httpx .get/.post). P2/P3: install_extended_services registers Embedding(bge-small)/Rerank/Ocr/Translation/Stt/Tts/Image* with graceful unavailable. P4: EvidenceService + CivilDefenseService bus adapters (research=True). P6: app.py uses real RagService+FederatedRagService with seeded corpus, multi-backend LlmService (HF + opt-in Nemotron/Modal/MiniCPM), opens EventLog and injects into Marketplace/Chat. Adds sentence-transformers, httpx, pytest-asyncio. No mocks; optional deps degrade honestly.

app.py CHANGED
@@ -29,6 +29,7 @@ See docs/HOWTO.md for Raspberry Pi, Docker, and multi-node mesh setup.
29
 
30
  from __future__ import annotations
31
 
 
32
  import os
33
 
34
  # ─────────────────────────────────────────────────────────────────────────────
@@ -169,7 +170,6 @@ def _build_node():
169
 
170
  from hearthnet.node import HearthNode
171
  from hearthnet.services.chat.service import ChatService
172
- from hearthnet.services.demo import RagService as DemoRagService
173
  from hearthnet.services.files.service import FileService
174
  from hearthnet.services.llm.backends.hf_local import HfLocalBackend
175
  from hearthnet.services.llm.service import LlmService
@@ -252,36 +252,134 @@ def _build_node():
252
 
253
  HfLocalBackend.chat = _patched_chat # type: ignore[method-assign]
254
 
255
- llm = LlmService(backends=[backend])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  except Exception:
257
  llm = LlmService() # _UnavailableBackend — shows clear error
258
 
259
  node.bus.register_service(llm)
260
 
261
- # RAG pre-seeded community corpus using demo RagService (in-memory)
262
- rag = DemoRagService(corpus="community")
263
- rag.documents = list(SEED_CORPUS)
264
- node.bus.register_service(rag)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
 
266
- # Register a synthetic rag.list_corpora so the Ask tab can discover corpora
267
- from hearthnet.bus.capability import CapabilityDescriptor, RouteRequest
 
 
 
 
 
 
 
 
 
 
268
 
269
- async def _list_corpora(req: RouteRequest) -> dict:
270
- return {"output": {"corpora": ["community"]}, "meta": {}}
271
 
272
- node.bus.register_capability(
273
- CapabilityDescriptor(name="rag.list_corpora", version=(1, 0)),
274
- _list_corpora,
 
 
275
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
 
277
- # Marketplace, Chat, Files
278
- node.bus.register_service(MarketplaceService())
279
- node.bus.register_service(ChatService(node.node_id))
 
 
 
 
 
 
 
280
  node.bus.register_service(FileService())
281
 
282
  return node
283
 
284
 
 
285
  # Build node and Gradio app at import time (HF Spaces requires module-level `demo`)
286
  _node = _build_node()
287
 
 
29
 
30
  from __future__ import annotations
31
 
32
+ import contextlib
33
  import os
34
 
35
  # ─────────────────────────────────────────────────────────────────────────────
 
170
 
171
  from hearthnet.node import HearthNode
172
  from hearthnet.services.chat.service import ChatService
 
173
  from hearthnet.services.files.service import FileService
174
  from hearthnet.services.llm.backends.hf_local import HfLocalBackend
175
  from hearthnet.services.llm.service import LlmService
 
252
 
253
  HfLocalBackend.chat = _patched_chat # type: ignore[method-assign]
254
 
255
+ backends: list = [backend]
256
+ # ── Sponsor cloud backends (opt-in via env) ───────────────────────
257
+ # NVIDIA Nemotron (prize track) — cloud NIM, no local availability check.
258
+ if os.getenv("NVIDIA_API_KEY"):
259
+ try:
260
+ from hearthnet.services.llm.backends.nemotron import NemotronBackend
261
+
262
+ backends.append(NemotronBackend(api_key_env="NVIDIA_API_KEY"))
263
+ except Exception:
264
+ pass
265
+ # Modal serverless GPU (prize track).
266
+ if os.getenv("MODAL_ENDPOINT"):
267
+ try:
268
+ from hearthnet.services.llm.backends.modal_backend import ModalBackend
269
+
270
+ modal_b = ModalBackend()
271
+ if modal_b.is_available():
272
+ backends.append(modal_b)
273
+ except Exception:
274
+ pass
275
+ # MiniCPM local server (OpenBMB prize track).
276
+ _minicpm_url = os.getenv("MINICPM_URL")
277
+ if _minicpm_url:
278
+ try:
279
+ from hearthnet.services.llm.backends.openbmb import OpenBmbBackend
280
+
281
+ minicpm = OpenBmbBackend(base_url=_minicpm_url)
282
+ if minicpm.is_available():
283
+ backends.append(minicpm)
284
+ except Exception:
285
+ pass
286
+
287
+ llm = LlmService(backends=backends)
288
  except Exception:
289
  llm = LlmService() # _UnavailableBackend — shows clear error
290
 
291
  node.bus.register_service(llm)
292
 
293
+ # ── Durable event log (ZeroGPU-safe; no mDNS/transport on a single Space) ──
294
+ event_log = None
295
+ try:
296
+ import tempfile
297
+ from pathlib import Path
298
+
299
+ from hearthnet.events import EventLog
300
+
301
+ _data_dir = Path(os.getenv("HEARTHNET_DATA_DIR", tempfile.gettempdir())) / "hearthnet-space"
302
+ _data_dir.mkdir(parents=True, exist_ok=True)
303
+ event_log = EventLog(_data_dir / "events.db", node.community_id, node.node_id)
304
+ node._event_log = event_log
305
+ except Exception:
306
+ event_log = None
307
+
308
+ # ── Blob store for content-addressed RAG documents ────────────────────
309
+ blob_store = None
310
+ try:
311
+ import tempfile
312
+ from pathlib import Path
313
+
314
+ from hearthnet.blobs.store import BlobStore
315
 
316
+ blob_store = BlobStore(
317
+ Path(os.getenv("HEARTHNET_DATA_DIR", tempfile.gettempdir()))
318
+ / "hearthnet-space"
319
+ / "blobs"
320
+ )
321
+ except Exception:
322
+ blob_store = None
323
+
324
+ # ── Real semantic RAG (replaces the in-memory demo corpus) ────────────
325
+ from hearthnet.bus.capability import RouteRequest
326
+ from hearthnet.services.rag.federated import FederatedRagService
327
+ from hearthnet.services.rag.service import RagService
328
 
329
+ # Register the embedding backend first so rag.query routes through embed.text.
330
+ node.install_extended_services(research=True)
331
 
332
+ rag = RagService(
333
+ corpus="community",
334
+ bus=node.bus,
335
+ event_log=event_log,
336
+ blob_store=blob_store,
337
  )
338
+ node.bus.register_service(rag)
339
+ node.bus.register_service(FederatedRagService(node.bus, corpus="community"))
340
+
341
+ # Seed the corpus through the real ingest path (content-addressed + logged).
342
+ async def _seed_corpus() -> None:
343
+ for doc in SEED_CORPUS:
344
+ with contextlib.suppress(Exception):
345
+ await rag.handle_ingest(
346
+ RouteRequest(
347
+ capability="rag.ingest",
348
+ version_req=(1, 0),
349
+ body={
350
+ "input": {
351
+ "corpus": "community",
352
+ "documents": [
353
+ {
354
+ "id": doc["id"],
355
+ "title": doc["title"],
356
+ "text": doc["text"],
357
+ }
358
+ ],
359
+ }
360
+ },
361
+ caller=node.node_id,
362
+ trace_id="seed",
363
+ deadline_ms=0,
364
+ )
365
+ )
366
 
367
+ with contextlib.suppress(Exception):
368
+ import asyncio
369
+
370
+ asyncio.run(_seed_corpus())
371
+
372
+ # Marketplace, Chat, Files — now durably event-sourced where supported.
373
+ node.bus.register_service(
374
+ MarketplaceService(event_log=event_log, node_id=node.node_id)
375
+ )
376
+ node.bus.register_service(ChatService(node.node_id, event_log=event_log))
377
  node.bus.register_service(FileService())
378
 
379
  return node
380
 
381
 
382
+
383
  # Build node and Gradio app at import time (HF Spaces requires module-level `demo`)
384
  _node = _build_node()
385
 
hearthnet/civdef/service.py CHANGED
@@ -213,3 +213,103 @@ class CivilDefenseService:
213
  "chain_valid": self._audit.verify_integrity(),
214
  "length": self._audit.length(),
215
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  "chain_valid": self._audit.verify_integrity(),
214
  "length": self._audit.length(),
215
  }
216
+
217
+ # ── Capability-bus adapter (registered only under research=True) ────────
218
+
219
+ name = "civdef"
220
+ version = "1.0"
221
+
222
+ def capabilities(self) -> list[tuple]:
223
+ from hearthnet.bus.capability import CapabilityDescriptor
224
+
225
+ return [
226
+ (
227
+ CapabilityDescriptor(
228
+ name="civdef.alert.issue",
229
+ version=(1, 0),
230
+ stability="experimental",
231
+ trust_required="trusted",
232
+ ),
233
+ self.handle_issue,
234
+ None,
235
+ ),
236
+ (
237
+ CapabilityDescriptor(
238
+ name="civdef.alert.list",
239
+ version=(1, 0),
240
+ stability="experimental",
241
+ idempotent=True,
242
+ ),
243
+ self.handle_list,
244
+ None,
245
+ ),
246
+ (
247
+ CapabilityDescriptor(
248
+ name="civdef.cert.verify",
249
+ version=(1, 0),
250
+ stability="experimental",
251
+ idempotent=True,
252
+ ),
253
+ self.handle_verify,
254
+ None,
255
+ ),
256
+ (
257
+ CapabilityDescriptor(
258
+ name="civdef.audit.export",
259
+ version=(1, 0),
260
+ stability="experimental",
261
+ idempotent=True,
262
+ ),
263
+ self.handle_audit,
264
+ None,
265
+ ),
266
+ ]
267
+
268
+ def register(self, bus: Any) -> None:
269
+ self._bus = bus
270
+ for cap, handler, predicate in self.capabilities():
271
+ bus.register_capability(cap, handler, predicate)
272
+
273
+ @staticmethod
274
+ def _alert_to_dict(alert: Alert) -> dict[str, Any]:
275
+ return {
276
+ "alert_id": alert.alert_id,
277
+ "severity": alert.severity,
278
+ "title": alert.title,
279
+ "body": alert.body,
280
+ "area": alert.area_description,
281
+ "issuer_node_id": alert.issuer_node_id,
282
+ "community_id": alert.community_id,
283
+ "issued_at": alert.issued_at,
284
+ "expires_at": alert.expires_at,
285
+ }
286
+
287
+ async def handle_issue(self, req: Any) -> dict:
288
+ inp = req.body.get("input", {})
289
+ title = str(inp.get("title", ""))
290
+ body = str(inp.get("body", ""))
291
+ area = str(inp.get("area", ""))
292
+ if not title or not area:
293
+ return {"error": "bad_request", "message": "title and area are required"}
294
+ alert = self.issue_alert(
295
+ severity=str(inp.get("severity", AlertSeverity.WARNING)),
296
+ title=title,
297
+ body=body,
298
+ area=area,
299
+ role_cert_id=inp.get("role_cert_id"),
300
+ community_id=str(inp.get("community_id", "")),
301
+ expires_in_hours=inp.get("expires_in_hours", 24.0),
302
+ )
303
+ return {"output": {"alert": self._alert_to_dict(alert)}, "meta": {}}
304
+
305
+ async def handle_list(self, req: Any) -> dict:
306
+ alerts = [self._alert_to_dict(a) for a in self.list_active_alerts()]
307
+ return {"output": {"alerts": alerts}, "meta": {"count": len(alerts)}}
308
+
309
+ async def handle_verify(self, req: Any) -> dict:
310
+ cert_id = str(req.body.get("input", {}).get("cert_id", ""))
311
+ return {"output": self.verify_cert(cert_id), "meta": {}}
312
+
313
+ async def handle_audit(self, req: Any) -> dict:
314
+ return {"output": self.export_audit(), "meta": {}}
315
+
hearthnet/evidence/service.py ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """M30 — Evidence Graph bus service (experimental, Phase 3).
2
+
3
+ Wraps the real in-memory :class:`ClaimStore` as capability-bus handlers so the
4
+ content-addressed claim graph is reachable over the mesh. Registered only when a
5
+ node opts into research features (``install_extended_services(research=True)``).
6
+
7
+ Capabilities:
8
+ evidence.claim.add@1.0 — assert a claim, returns its content-addressed id
9
+ evidence.claim.attest@1.0 — vouch for an existing claim
10
+ evidence.claim.dispute@1.0 — dispute an existing claim
11
+ evidence.claim.find@1.0 — list claims about a subject
12
+ evidence.summary@1.0 — store statistics
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any
18
+
19
+ from hearthnet.bus.capability import CapabilityDescriptor, RouteRequest
20
+ from hearthnet.evidence.store import (
21
+ Attestation,
22
+ Claim,
23
+ ClaimID,
24
+ ClaimSource,
25
+ ClaimStore,
26
+ Dispute,
27
+ SourceID,
28
+ )
29
+
30
+
31
+ class EvidenceService:
32
+ name = "evidence"
33
+ version = "1.0"
34
+
35
+ def __init__(self, community_id: str = "", store: ClaimStore | None = None) -> None:
36
+ self._community_id = community_id
37
+ self._store = store or ClaimStore()
38
+
39
+ def capabilities(self) -> list[tuple]:
40
+ return [
41
+ (
42
+ CapabilityDescriptor(
43
+ name="evidence.claim.add",
44
+ version=(1, 0),
45
+ stability="experimental",
46
+ trust_required="trusted",
47
+ idempotent=True,
48
+ ),
49
+ self.handle_add,
50
+ None,
51
+ ),
52
+ (
53
+ CapabilityDescriptor(
54
+ name="evidence.claim.attest",
55
+ version=(1, 0),
56
+ stability="experimental",
57
+ trust_required="trusted",
58
+ ),
59
+ self.handle_attest,
60
+ None,
61
+ ),
62
+ (
63
+ CapabilityDescriptor(
64
+ name="evidence.claim.dispute",
65
+ version=(1, 0),
66
+ stability="experimental",
67
+ trust_required="trusted",
68
+ ),
69
+ self.handle_dispute,
70
+ None,
71
+ ),
72
+ (
73
+ CapabilityDescriptor(
74
+ name="evidence.claim.find",
75
+ version=(1, 0),
76
+ stability="experimental",
77
+ idempotent=True,
78
+ ),
79
+ self.handle_find,
80
+ None,
81
+ ),
82
+ (
83
+ CapabilityDescriptor(
84
+ name="evidence.summary",
85
+ version=(1, 0),
86
+ stability="experimental",
87
+ idempotent=True,
88
+ ),
89
+ self.handle_summary,
90
+ None,
91
+ ),
92
+ ]
93
+
94
+ def register(self, bus: Any) -> None:
95
+ for cap, handler, predicate in self.capabilities():
96
+ bus.register_capability(cap, handler, predicate)
97
+
98
+ # ── Handlers ───────────────────────────────────────────────────────────
99
+
100
+ async def handle_add(self, req: RouteRequest) -> dict:
101
+ inp = req.body.get("input", {})
102
+ subject = str(inp.get("subject", ""))
103
+ predicate = str(inp.get("predicate", "asserts"))
104
+ object_ = str(inp.get("object", ""))
105
+ if not subject or not object_:
106
+ return {"error": "bad_request", "message": "subject and object are required"}
107
+ sources = tuple(
108
+ ClaimSource(
109
+ source_id=SourceID(str(s.get("source_id", ""))),
110
+ source_type=str(s.get("source_type", "manual")),
111
+ url=s.get("url"),
112
+ reliability_score=float(s.get("reliability_score", 1.0)),
113
+ )
114
+ for s in inp.get("sources", [])
115
+ )
116
+ claim = Claim(
117
+ claim_id=ClaimID(""), # replaced by content_id() inside add_claim
118
+ subject=subject,
119
+ predicate=predicate,
120
+ object_=object_,
121
+ asserted_by=str(req.caller or "unknown"),
122
+ sources=sources,
123
+ community_id=self._community_id,
124
+ confidence=float(inp.get("confidence", 1.0)),
125
+ )
126
+ cid = self._store.add_claim(claim)
127
+ return {"output": {"claim_id": cid}, "meta": {}}
128
+
129
+ async def handle_attest(self, req: RouteRequest) -> dict:
130
+ inp = req.body.get("input", {})
131
+ claim_id = ClaimID(str(inp.get("claim_id", "")))
132
+ if self._store.get_claim(claim_id) is None:
133
+ return {"error": "not_found", "message": "unknown claim_id"}
134
+ self._store.attest(
135
+ Attestation(claim_id=claim_id, attested_by=str(req.caller or "unknown"))
136
+ )
137
+ return {
138
+ "output": {
139
+ "claim_id": claim_id,
140
+ "attestations": self._store.attestation_count(claim_id),
141
+ },
142
+ "meta": {},
143
+ }
144
+
145
+ async def handle_dispute(self, req: RouteRequest) -> dict:
146
+ inp = req.body.get("input", {})
147
+ claim_id = ClaimID(str(inp.get("claim_id", "")))
148
+ if self._store.get_claim(claim_id) is None:
149
+ return {"error": "not_found", "message": "unknown claim_id"}
150
+ counter = inp.get("counter_claim_id")
151
+ self._store.dispute(
152
+ Dispute(
153
+ claim_id=claim_id,
154
+ disputed_by=str(req.caller or "unknown"),
155
+ reason=str(inp.get("reason", "")),
156
+ counter_claim_id=ClaimID(str(counter)) if counter else None,
157
+ )
158
+ )
159
+ return {"output": {"claim_id": claim_id, "disputed": True}, "meta": {}}
160
+
161
+ async def handle_find(self, req: RouteRequest) -> dict:
162
+ inp = req.body.get("input", {})
163
+ subject = str(inp.get("subject", ""))
164
+ claims = self._store.find_by_subject(subject)
165
+ return {
166
+ "output": {
167
+ "claims": [
168
+ {
169
+ "claim_id": c.content_id(),
170
+ "subject": c.subject,
171
+ "predicate": c.predicate,
172
+ "object": c.object_,
173
+ "asserted_by": c.asserted_by,
174
+ "confidence": c.confidence,
175
+ "attestations": self._store.attestation_count(c.content_id()),
176
+ "disputed": self._store.is_disputed(c.content_id()),
177
+ }
178
+ for c in claims
179
+ ]
180
+ },
181
+ "meta": {"count": len(claims)},
182
+ }
183
+
184
+ async def handle_summary(self, req: RouteRequest) -> dict:
185
+ return {"output": self._store.summary(), "meta": {}}
hearthnet/node.py CHANGED
@@ -32,6 +32,45 @@ _log = logging.getLogger(__name__)
32
  _GOSSIP_INTERVAL_SECONDS = 30
33
 
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  @dataclass
36
  class NodeManifest:
37
  node_id: NodeID
@@ -258,9 +297,124 @@ class HearthNode:
258
  for service in services:
259
  self.bus.register_service(service)
260
 
261
- # ------------------------------------------------------------------
262
- # 15-step startup / shutdown
263
- # ------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
 
265
  async def start(
266
  self,
@@ -340,9 +494,7 @@ class HearthNode:
340
 
341
  # ── Step 8: Emergency detector ────────────────────────────────
342
  try:
343
- self._emergency_task = asyncio.create_task(
344
- self.detector.run(), name="emergency-detector"
345
- )
346
  except Exception as exc:
347
  _log.warning("Emergency detector start failed (non-fatal): %s", exc)
348
 
@@ -474,32 +626,39 @@ class HearthNode:
474
  async def _gossip_loop(self, interval: int) -> None:
475
  """Periodically sync event log with all known peers (X02 gossip)."""
476
  from hearthnet.events.sync import SyncClient
477
- from hearthnet.transport.client import HttpClient
478
 
479
- http_client = HttpClient(self.node_id, self.community_id)
 
 
 
480
  sync_client = SyncClient(self._event_log, http_client)
481
 
482
- while True:
483
- await asyncio.sleep(interval)
484
- for peer in self.peers.all():
485
- if not peer.endpoints:
486
- continue
487
- ep = peer.endpoints[0]
488
- if ep.transport == "memory":
489
- continue # in-process; no HTTP needed
490
- peer_url = f"http://{ep.host}:{ep.port}"
491
- try:
492
- result = await sync_client.sync_with(peer_url, self.community_id)
493
- if result.received_count or result.sent_count:
 
 
 
 
 
 
 
 
 
494
  _log.debug(
495
- "Gossip with %s: sent=%d recv=%d ms=%d",
496
- peer.display_name,
497
- result.sent_count,
498
- result.received_count,
499
- result.duration_ms,
500
  )
501
- except Exception as exc:
502
- _log.debug("Gossip sync with %s failed: %s", peer.display_name, exc)
503
 
504
  async def _state_bus_to_pubsub(self) -> None:
505
  """Forward StateBus state changes to the WebSocket pubsub (X06)."""
 
32
  _GOSSIP_INTERVAL_SECONDS = 30
33
 
34
 
35
+ class _HttpxSyncClient:
36
+ """Minimal httpx adapter for :class:`SyncClient`.
37
+
38
+ SyncClient treats a dict response as already-parsed JSON, so we return the
39
+ decoded body directly from ``get``/``post`` (avoiding SyncClient's
40
+ aiohttp-style ``await resp.json()`` path). Degrades to a no-op when httpx is
41
+ not installed.
42
+ """
43
+
44
+ def __init__(self) -> None:
45
+ self._client: Any = None
46
+ self.unavailable = False
47
+ try:
48
+ import httpx
49
+
50
+ self._client = httpx.AsyncClient(timeout=30.0)
51
+ except ImportError:
52
+ self.unavailable = True
53
+
54
+ async def get(self, url: str) -> dict[str, Any]:
55
+ resp = await self._client.get(url)
56
+ resp.raise_for_status()
57
+ return resp.json()
58
+
59
+ async def post(
60
+ self, url: str, *, data: Any = None, headers: dict[str, str] | None = None
61
+ ) -> dict[str, Any]:
62
+ resp = await self._client.post(url, content=data, headers=headers)
63
+ resp.raise_for_status()
64
+ return resp.json()
65
+
66
+ async def aclose(self) -> None:
67
+ if self._client is not None:
68
+ try:
69
+ await self._client.aclose()
70
+ except Exception:
71
+ pass
72
+
73
+
74
  @dataclass
75
  class NodeManifest:
76
  node_id: NodeID
 
297
  for service in services:
298
  self.bus.register_service(service)
299
 
300
+ # Register the real auxiliary services (embed/rerank/ocr/translation/
301
+ # speech/image). Phase-3 research services stay off unless opted in.
302
+ self.install_extended_services(research=False)
303
+
304
+ def install_extended_services(
305
+ self,
306
+ *,
307
+ research: bool = False,
308
+ embed_model: str = "BAAI/bge-small-en-v1.5",
309
+ ) -> None:
310
+ """Register the real auxiliary services beyond the core set.
311
+
312
+ Always (each degrades gracefully to an "unavailable" response when its
313
+ optional backend/model is missing — never a mock):
314
+ M11 EmbeddingService embed.text (real semantic vectors when
315
+ sentence-transformers present)
316
+ M24 RerankService rerank.text
317
+ M17 OcrService ocr.image / ocr.pdf
318
+ M18 TranslationService trans.text
319
+ M19 Stt/TtsService stt.transcribe / tts.speak
320
+ M20 Image services image.describe / image.generate
321
+
322
+ When ``research=True`` (opt-in; the demo Space enables it), also registers
323
+ the real Phase-3 services:
324
+ M30 EvidenceService evidence.claim.*
325
+ M31 CivilDefenseService civdef.*
326
+
327
+ Every registration is wrapped so a missing optional dependency can never
328
+ break node startup.
329
+ """
330
+
331
+ def _register(svc: Any) -> None:
332
+ if hasattr(svc, "capabilities"):
333
+ self.bus.register_service(svc)
334
+ elif hasattr(svc, "register"):
335
+ svc.register(self.bus)
336
+
337
+ # ── M11 Embedding (core for real RAG) ──────────────────────────────
338
+ try:
339
+ import importlib.util
340
+
341
+ from hearthnet.services.embedding.service import EmbeddingService
342
+
343
+ backend = None
344
+ if importlib.util.find_spec("sentence_transformers") is not None:
345
+ from hearthnet.services.embedding.backends import (
346
+ SentenceTransformerBackend,
347
+ )
348
+
349
+ backend = SentenceTransformerBackend(model=embed_model)
350
+ _register(EmbeddingService(backend=backend))
351
+ except Exception as exc:
352
+ _log.warning("EmbeddingService registration skipped: %s", exc)
353
+
354
+ # ── Remaining always-on auxiliary services ─────────────────────────
355
+ _aux: list[tuple[str, Any]] = []
356
+ try:
357
+ from hearthnet.services.rerank.service import RerankService
358
+
359
+ _aux.append(("rerank", RerankService()))
360
+ except Exception as exc:
361
+ _log.debug("RerankService unavailable: %s", exc)
362
+ try:
363
+ from hearthnet.services.ocr.service import OcrService
364
+
365
+ _aux.append(("ocr", OcrService()))
366
+ except Exception as exc:
367
+ _log.debug("OcrService unavailable: %s", exc)
368
+ try:
369
+ from hearthnet.services.translation.service import TranslationService
370
+
371
+ _aux.append(("translation", TranslationService()))
372
+ except Exception as exc:
373
+ _log.debug("TranslationService unavailable: %s", exc)
374
+ try:
375
+ from hearthnet.services.speech.stt_service import SttService
376
+ from hearthnet.services.speech.tts_service import TtsService
377
+
378
+ _aux.append(("stt", SttService()))
379
+ _aux.append(("tts", TtsService()))
380
+ except Exception as exc:
381
+ _log.debug("Speech services unavailable: %s", exc)
382
+ try:
383
+ from hearthnet.services.image.describe_service import ImageDescribeService
384
+
385
+ _aux.append(("image.describe", ImageDescribeService()))
386
+ except Exception as exc:
387
+ _log.debug("ImageDescribeService unavailable: %s", exc)
388
+ try:
389
+ from hearthnet.services.image.generate_service import ImageGenerateService
390
+
391
+ _aux.append(("image.generate", ImageGenerateService()))
392
+ except Exception as exc:
393
+ _log.debug("ImageGenerateService unavailable: %s", exc)
394
+
395
+ for label, svc in _aux:
396
+ try:
397
+ _register(svc)
398
+ except Exception as exc:
399
+ _log.warning("%s registration skipped: %s", label, exc)
400
+
401
+ if not research:
402
+ return
403
+
404
+ # ── Phase-3 research services (opt-in only) ────────────────────────
405
+ try:
406
+ from hearthnet.evidence.service import EvidenceService
407
+
408
+ _register(EvidenceService(community_id=self.community_id))
409
+ except Exception as exc:
410
+ _log.warning("EvidenceService registration skipped: %s", exc)
411
+ try:
412
+ from hearthnet.civdef.service import CivilDefenseService
413
+
414
+ _register(CivilDefenseService())
415
+ except Exception as exc:
416
+ _log.warning("CivilDefenseService registration skipped: %s", exc)
417
+
418
 
419
  async def start(
420
  self,
 
494
 
495
  # ── Step 8: Emergency detector ────────────────────────────────
496
  try:
497
+ await self.detector.start()
 
 
498
  except Exception as exc:
499
  _log.warning("Emergency detector start failed (non-fatal): %s", exc)
500
 
 
626
  async def _gossip_loop(self, interval: int) -> None:
627
  """Periodically sync event log with all known peers (X02 gossip)."""
628
  from hearthnet.events.sync import SyncClient
 
629
 
630
+ http_client = _HttpxSyncClient()
631
+ if http_client.unavailable:
632
+ _log.info("Gossip sync disabled: httpx not installed")
633
+ return
634
  sync_client = SyncClient(self._event_log, http_client)
635
 
636
+ try:
637
+ while True:
638
+ await asyncio.sleep(interval)
639
+ for peer in self.peers.all():
640
+ if not peer.endpoints:
641
+ continue
642
+ ep = peer.endpoints[0]
643
+ if ep.transport == "memory":
644
+ continue # in-process; no HTTP needed
645
+ peer_url = f"http://{ep.host}:{ep.port}"
646
+ try:
647
+ result = await sync_client.sync_with(peer_url, self.community_id)
648
+ if result.received_count or result.sent_count:
649
+ _log.debug(
650
+ "Gossip with %s: sent=%d recv=%d ms=%d",
651
+ peer.display_name,
652
+ result.sent_count,
653
+ result.received_count,
654
+ result.duration_ms,
655
+ )
656
+ except Exception as exc:
657
  _log.debug(
658
+ "Gossip sync with %s failed: %s", peer.display_name, exc
 
 
 
 
659
  )
660
+ finally:
661
+ await http_client.aclose()
662
 
663
  async def _state_bus_to_pubsub(self) -> None:
664
  """Forward StateBus state changes to the WebSocket pubsub (X06)."""
requirements-dev.txt CHANGED
@@ -4,4 +4,5 @@ bandit[toml]>=1.7.10,<2.0
4
  mypy>=1.10,<2.0
5
  pylint>=3.2,<4.0
6
  pytest>=8.5.0,<9.0
 
7
  ruff>=0.5.0,<1.0
 
4
  mypy>=1.10,<2.0
5
  pylint>=3.2,<4.0
6
  pytest>=8.5.0,<9.0
7
+ pytest-asyncio>=0.24,<1.0
8
  ruff>=0.5.0,<1.0
requirements.txt CHANGED
@@ -8,3 +8,5 @@ sentencepiece>=0.2.0
8
  torch==2.11.0
9
  transformers>=4.45.0
10
  qrcode[svg]>=7.4
 
 
 
8
  torch==2.11.0
9
  transformers>=4.45.0
10
  qrcode[svg]>=7.4
11
+ sentence-transformers>=3.0.0
12
+ httpx>=0.27.0
tests/test_extended_services.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """install_extended_services registers the real auxiliary services."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ from hearthnet.node import HearthNode
8
+
9
+
10
+ def _node() -> HearthNode:
11
+ node = HearthNode("ed25519:ext", "Ext", "ed25519:test-community")
12
+ node.install_extended_services(research=False)
13
+ return node
14
+
15
+
16
+ def test_extended_services_register() -> None:
17
+ caps = {e.descriptor.name for e in _node().bus.registry.all_local()}
18
+ for cap in ("embed.text", "rerank.text", "ocr.image", "trans.text"):
19
+ assert cap in caps, f"missing {cap}"
20
+
21
+
22
+ def test_embed_text_returns_vectors() -> None:
23
+ """embed.text must return one vector per input, even without ML deps
24
+ (SimpleHashBackend fallback) — no mock, real deterministic vectors."""
25
+ node = _node()
26
+
27
+ async def _run() -> dict:
28
+ return await node.bus.call(
29
+ "embed.text", (1, 0), {"input": {"texts": ["hello", "world"]}}
30
+ )
31
+
32
+ out = asyncio.run(_run())
33
+ embeddings = out["output"]["embeddings"]
34
+ assert len(embeddings) == 2
35
+ assert all(isinstance(v, (int, float)) for v in embeddings[0])
36
+
37
+
38
+ def test_rag_query_uses_registered_embedder() -> None:
39
+ """With embed.text registered, rag.query routes embeddings through the bus."""
40
+ from hearthnet.services.rag.service import RagService
41
+
42
+ node = _node()
43
+ rag = RagService(corpus="community", bus=node.bus)
44
+ node.bus.register_service(rag)
45
+
46
+ async def _run() -> dict:
47
+ await node.bus.call(
48
+ "rag.ingest",
49
+ (1, 0),
50
+ {
51
+ "input": {
52
+ "corpus": "community",
53
+ "documents": [
54
+ {"id": "d1", "title": "Water", "text": "Boil rainwater before drinking."}
55
+ ],
56
+ }
57
+ },
58
+ )
59
+ return await node.bus.call(
60
+ "rag.query",
61
+ (1, 0),
62
+ {"params": {"corpus": "community"}, "input": {"query": "water", "k": 1}},
63
+ )
64
+
65
+ out = asyncio.run(_run())
66
+ assert out["output"]["chunks"]
tests/test_gossip_sync.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regression: _gossip_loop builds a working httpx adapter (not a broken HttpClient)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+
8
+ from hearthnet.node import _HttpxSyncClient
9
+
10
+
11
+ def test_httpx_sync_client_constructs() -> None:
12
+ client = _HttpxSyncClient()
13
+ # httpx is a declared dependency, so the adapter must be available.
14
+ assert client.unavailable is False
15
+ asyncio.run(client.aclose())
16
+
17
+
18
+ def test_sync_client_accepts_adapter() -> None:
19
+ """SyncClient must accept the adapter without raising at construction."""
20
+ from hearthnet.events.sync import SyncClient
21
+
22
+ class _FakeLog:
23
+ def head(self) -> int:
24
+ return 0
25
+
26
+ def since(self, _n: int):
27
+ return []
28
+
29
+ def append_received(self, _e) -> bool:
30
+ return False
31
+
32
+ adapter = _HttpxSyncClient()
33
+ sync = SyncClient(_FakeLog(), adapter)
34
+ assert sync is not None
35
+ asyncio.run(adapter.aclose())
36
+
37
+
38
+ def test_gossip_loop_no_peers_is_safe() -> None:
39
+ """A node with no peers must run the gossip loop body without raising."""
40
+ from hearthnet.node import HearthNode
41
+
42
+ node = HearthNode("ed25519:g", "G", "ed25519:test-community")
43
+
44
+ class _Log:
45
+ def head(self) -> int:
46
+ return 0
47
+
48
+ node._event_log = _Log()
49
+
50
+ async def _run() -> None:
51
+ task = asyncio.create_task(node._gossip_loop(interval=0))
52
+ await asyncio.sleep(0.05)
53
+ task.cancel()
54
+ with contextlib.suppress(asyncio.CancelledError):
55
+ await task
56
+
57
+ asyncio.run(_run())
tests/test_phase3_services.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phase 3 research services (M30 Evidence, M31 Civil Defense) — real impls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+
7
+ from hearthnet.node import HearthNode
8
+
9
+
10
+ def _research_node() -> HearthNode:
11
+ node = HearthNode("ed25519:research", "Research", "ed25519:test-community")
12
+ node.install_extended_services(research=True)
13
+ return node
14
+
15
+
16
+ def test_research_services_register_only_when_opted_in() -> None:
17
+ plain = HearthNode("ed25519:plain", "Plain", "ed25519:test-community")
18
+ plain.install_extended_services(research=False)
19
+ plain_caps = {e.descriptor.name for e in plain.bus.registry.all_local()}
20
+ assert "evidence.claim.add" not in plain_caps
21
+ assert "civdef.alert.issue" not in plain_caps
22
+
23
+ node = _research_node()
24
+ caps = {e.descriptor.name for e in node.bus.registry.all_local()}
25
+ for cap in (
26
+ "evidence.claim.add",
27
+ "evidence.claim.attest",
28
+ "evidence.claim.find",
29
+ "civdef.alert.issue",
30
+ "civdef.alert.list",
31
+ "civdef.audit.export",
32
+ ):
33
+ assert cap in caps
34
+
35
+
36
+ def test_evidence_claim_roundtrip() -> None:
37
+ node = _research_node()
38
+
39
+ async def _run() -> dict:
40
+ add = await node.bus.call(
41
+ "evidence.claim.add",
42
+ (1, 0),
43
+ {
44
+ "input": {
45
+ "subject": "well:village-1",
46
+ "predicate": "water_status",
47
+ "object": "potable",
48
+ }
49
+ },
50
+ )
51
+ claim_id = add["output"]["claim_id"]
52
+ await node.bus.call(
53
+ "evidence.claim.attest", (1, 0), {"input": {"claim_id": claim_id}}
54
+ )
55
+ return await node.bus.call(
56
+ "evidence.claim.find", (1, 0), {"input": {"subject": "well:village-1"}}
57
+ )
58
+
59
+ found = asyncio.run(_run())
60
+ claims = found["output"]["claims"]
61
+ assert len(claims) == 1
62
+ assert claims[0]["object"] == "potable"
63
+ assert claims[0]["attestations"] == 1
64
+
65
+
66
+ def test_civdef_alert_and_audit_chain() -> None:
67
+ node = _research_node()
68
+
69
+ async def _run() -> tuple[dict, dict]:
70
+ await node.bus.call(
71
+ "civdef.alert.issue",
72
+ (1, 0),
73
+ {
74
+ "input": {
75
+ "severity": "warning",
76
+ "title": "Boil water notice",
77
+ "body": "Boil tap water before drinking.",
78
+ "area": "Issum, Kreis Kleve, NRW",
79
+ }
80
+ },
81
+ )
82
+ listed = await node.bus.call("civdef.alert.list", (1, 0), {"input": {}})
83
+ audit = await node.bus.call("civdef.audit.export", (1, 0), {"input": {}})
84
+ return listed, audit
85
+
86
+ listed, audit = asyncio.run(_run())
87
+ assert len(listed["output"]["alerts"]) == 1
88
+ assert listed["output"]["alerts"][0]["title"] == "Boil water notice"
89
+ # Tamper-evident audit chain must verify.
90
+ assert audit["output"]["chain_valid"] is True
91
+ assert audit["output"]["length"] >= 1