Spaces:
Running on Zero
Running on Zero
GitHub Actions commited on
Commit ·
27be63b
1
Parent(s): 90a59b3
feat: activate real services, fix gossip, wire sponsor backends + EventLog
Browse filesP1 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 +114 -16
- hearthnet/civdef/service.py +100 -0
- hearthnet/evidence/service.py +185 -0
- hearthnet/node.py +186 -27
- requirements-dev.txt +1 -0
- requirements.txt +2 -0
- tests/test_extended_services.py +66 -0
- tests/test_gossip_sync.py +57 -0
- tests/test_phase3_services.py +91 -0
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
except Exception:
|
| 257 |
llm = LlmService() # _UnavailableBackend — shows clear error
|
| 258 |
|
| 259 |
node.bus.register_service(llm)
|
| 260 |
|
| 261 |
-
#
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
|
| 266 |
-
|
| 267 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
|
| 269 |
-
|
| 270 |
-
|
| 271 |
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
|
|
|
|
|
|
| 275 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 276 |
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 263 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
|
| 265 |
async def start(
|
| 266 |
self,
|
|
@@ -340,9 +494,7 @@ class HearthNode:
|
|
| 340 |
|
| 341 |
# ── Step 8: Emergency detector ────────────────────────────────
|
| 342 |
try:
|
| 343 |
-
self.
|
| 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 =
|
|
|
|
|
|
|
|
|
|
| 480 |
sync_client = SyncClient(self._event_log, http_client)
|
| 481 |
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 494 |
_log.debug(
|
| 495 |
-
"Gossip with %s:
|
| 496 |
-
peer.display_name,
|
| 497 |
-
result.sent_count,
|
| 498 |
-
result.received_count,
|
| 499 |
-
result.duration_ms,
|
| 500 |
)
|
| 501 |
-
|
| 502 |
-
|
| 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
|