Skip to content

Commit 1dc1fb3

Browse files
Zawwarsami16claude
andcommitted
phase 4.1: entity extensions — operators grow per-hub recipes
new endpoints (auth: any registered publisher's bearer key, requires --db): POST /entity/extend add a recipe {section, title, body} GET /entity/extend list all extensions on this hub DELETE /entity/extend/{id} remove one extensions are appended: - inline at the end of GET /entity/<section> - matched-by-title appended to GET /entity/errors/<code> - in an appendix at the bottom of GET /entity (full) each extension is rendered as `### \`<title>\` *(user-added by <pub> on <date>)*` followed by the body, so any AI fetching the entity sees operator-specific knowledge alongside the shipped baseline. shipped recipes always present first; user content is additive context, never overrides canonical answers. caps: 8KB body, 200 extensions per hub. persistence via new sqlite table entity_extensions(id, section, title, body, added_by, added_at). extensions survive hub restarts. tests: - storage: add/list/filter/delete/count/persistence (7) - http: 401 without bearer, 200 with valid key, surfaces in full /entity, surfaces in /entity/<section>, error-section extension appears in /entity/errors/<code>, list+delete round-trip, oversized body → 413 (6) 120/120 pytest now. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c747c97 commit 1dc1fb3

4 files changed

Lines changed: 363 additions & 14 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,9 @@ Tests: `pytest -v`. The e2e tests spin up the hub in-process and run the full pu
8585
- **Phase 3.0** ✅ — `zhub/entity.md` + `GET /entity` + `GET /entity/<section>` + `GET /entity/errors/<code>`. Single source of truth so any AI attaching to the hub becomes instantly fluent (routes, errors, patterns, debug recipes, perf tips).
8686
- **Phase 3.0b** ✅ — `X-Zhub-Entity-Hint` header on 4xx/5xx responses pointing at `/entity/errors/<code>`. Closes the entity loop: any AI hitting an error gets a self-debug pointer.
8787
- **Phase 4.0** ✅ — `zhub/brains/` package: `BrainAdapter` ABC + four streaming adapters (Ollama, Groq, OpenAI, Cerebras). `detect()` walks them in priority order. `examples/multi_brain_publisher.py` exposes `--brain auto|ollama|groq|openai|cerebras` so the brain underneath any zhub publisher is one CLI flag away from a swap. External clients (Pocket/Loki/curl/MCP) see no change; key stays stable across brain swaps via persistence.
88+
- **Phase 4.1** ✅ — Entity v2: operator-extensible. `POST/GET /entity/extend` and `DELETE /entity/extend/{id}` (auth: any registered publisher's bearer key). Extensions persist in SQLite (`entity_extensions` table), surface inline in `/entity/<section>` and at the title-matched code under `/entity/errors/<code>`, and live alongside shipped recipes (shipped wins on canonical conflicts). Caps: 8KB per body, 200 per hub. Each hub now grows its own institutional memory.
8889

89-
**Next (not started):** tool streaming via SSE (Phase 1.8c — would need to detect tool_calls during stream and pause), Entity v2 (operator-extensible recipes), multi-tier API keys, real ZAI integration via `zai_publish.py`.
90+
**Next (not started):** tool streaming via SSE (Phase 1.8c — would need to detect tool_calls during stream and pause), multi-tier API keys, real ZAI integration via `zai_publish.py`.
9091

9192
## 6. File layout (what's where)
9293

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
"""HTTP-level tests for entity extensions: add / list / delete + merge into /entity."""
2+
3+
import asyncio
4+
import socket
5+
import threading
6+
import time
7+
8+
import pytest
9+
10+
try:
11+
import fastapi # noqa
12+
import uvicorn # noqa
13+
import httpx # noqa
14+
DEPS_AVAILABLE = True
15+
except ImportError:
16+
DEPS_AVAILABLE = False
17+
18+
if DEPS_AVAILABLE:
19+
from zhub.server import create_app
20+
from zhub import publish
21+
22+
23+
def _free_port() -> int:
24+
with socket.socket() as s:
25+
s.bind(("", 0))
26+
return s.getsockname()[1]
27+
28+
29+
@pytest.fixture
30+
def hub(tmp_path):
31+
if not DEPS_AVAILABLE:
32+
pytest.skip("fastapi/uvicorn/httpx not installed")
33+
port = _free_port()
34+
db_path = str(tmp_path / "ext.db")
35+
app = create_app(db_path=db_path)
36+
37+
def run():
38+
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning")
39+
asyncio.run(uvicorn.Server(config).serve())
40+
41+
threading.Thread(target=run, daemon=True).start()
42+
for _ in range(30):
43+
try:
44+
with socket.create_connection(("127.0.0.1", port), timeout=0.1):
45+
break
46+
except OSError:
47+
time.sleep(0.1)
48+
yield port
49+
50+
51+
@pytest.mark.asyncio
52+
async def test_extend_unauthorized_without_bearer(hub):
53+
async with httpx.AsyncClient(timeout=5.0) as c:
54+
r = await c.post(
55+
f"http://127.0.0.1:{hub}/entity/extend",
56+
json={"section": "patterns", "title": "x", "body": "y"},
57+
)
58+
assert r.status_code == 401
59+
60+
61+
@pytest.mark.asyncio
62+
async def test_extend_with_valid_publisher_key_succeeds(hub):
63+
pub = publish(
64+
name="ext-bot",
65+
description="extender",
66+
chat_handler=lambda m, o: "ok",
67+
hub_url=f"ws://127.0.0.1:{hub}",
68+
)
69+
for _ in range(50):
70+
if pub.api_key:
71+
break
72+
await asyncio.sleep(0.1)
73+
74+
async with httpx.AsyncClient(timeout=5.0) as c:
75+
r = await c.post(
76+
f"http://127.0.0.1:{hub}/entity/extend",
77+
json={
78+
"section": "patterns",
79+
"title": "loki-whatsapp",
80+
"body": "Call /v1/invoke directly when sending whatsapp.",
81+
},
82+
headers={"Authorization": f"Bearer {pub.api_key}"},
83+
)
84+
assert r.status_code == 200, r.text
85+
body = r.json()
86+
assert isinstance(body["id"], int)
87+
assert body["section"] == "patterns"
88+
assert body["title"] == "loki-whatsapp"
89+
assert body["added_by"] == "ext-bot"
90+
91+
92+
@pytest.mark.asyncio
93+
async def test_extension_surfaces_in_entity_full_and_section(hub):
94+
pub = publish(
95+
name="ext-bot-2",
96+
description="x",
97+
chat_handler=lambda m, o: "ok",
98+
hub_url=f"ws://127.0.0.1:{hub}",
99+
)
100+
for _ in range(50):
101+
if pub.api_key:
102+
break
103+
await asyncio.sleep(0.1)
104+
105+
async with httpx.AsyncClient(timeout=5.0) as c:
106+
await c.post(
107+
f"http://127.0.0.1:{hub}/entity/extend",
108+
json={"section": "patterns",
109+
"title": "loki-shortcut",
110+
"body": "Use /v1/invoke for known calls."},
111+
headers={"Authorization": f"Bearer {pub.api_key}"},
112+
)
113+
114+
full = (await c.get(f"http://127.0.0.1:{hub}/entity")).text
115+
assert "loki-shortcut" in full
116+
assert "Use /v1/invoke for known calls." in full
117+
assert "user-added by ext-bot-2" in full
118+
119+
section = (await c.get(f"http://127.0.0.1:{hub}/entity/patterns")).text
120+
assert "loki-shortcut" in section
121+
assert "## extensions" not in section.split("loki-shortcut")[0]
122+
123+
124+
@pytest.mark.asyncio
125+
async def test_extension_to_errors_section_surfaces_in_error_lookup(hub):
126+
pub = publish(
127+
name="ext-bot-3",
128+
description="x",
129+
chat_handler=lambda m, o: "ok",
130+
hub_url=f"ws://127.0.0.1:{hub}",
131+
)
132+
for _ in range(50):
133+
if pub.api_key:
134+
break
135+
await asyncio.sleep(0.1)
136+
137+
async with httpx.AsyncClient(timeout=5.0) as c:
138+
await c.post(
139+
f"http://127.0.0.1:{hub}/entity/extend",
140+
json={"section": "errors", "title": "401",
141+
"body": "Also check that the publisher restarted with --db."},
142+
headers={"Authorization": f"Bearer {pub.api_key}"},
143+
)
144+
145+
recipe = (await c.get(f"http://127.0.0.1:{hub}/entity/errors/401")).text
146+
# shipped recipe still present
147+
assert "Bearer key" in recipe or "api key" in recipe.lower()
148+
# user extension appended
149+
assert "Also check that the publisher restarted with --db." in recipe
150+
151+
152+
@pytest.mark.asyncio
153+
async def test_list_and_delete_extensions(hub):
154+
pub = publish(
155+
name="ext-bot-4",
156+
description="x",
157+
chat_handler=lambda m, o: "ok",
158+
hub_url=f"ws://127.0.0.1:{hub}",
159+
)
160+
for _ in range(50):
161+
if pub.api_key:
162+
break
163+
await asyncio.sleep(0.1)
164+
165+
auth = {"Authorization": f"Bearer {pub.api_key}"}
166+
167+
async with httpx.AsyncClient(timeout=5.0) as c:
168+
e1 = (await c.post(
169+
f"http://127.0.0.1:{hub}/entity/extend",
170+
json={"section": "patterns", "title": "a", "body": "b"},
171+
headers=auth,
172+
)).json()
173+
e2 = (await c.post(
174+
f"http://127.0.0.1:{hub}/entity/extend",
175+
json={"section": "debug", "title": "c", "body": "d"},
176+
headers=auth,
177+
)).json()
178+
179+
listing = (await c.get(f"http://127.0.0.1:{hub}/entity/extend",
180+
headers=auth)).json()["extensions"]
181+
ids = sorted(e["id"] for e in listing)
182+
assert ids == sorted([e1["id"], e2["id"]])
183+
184+
# delete the first
185+
d = await c.delete(f"http://127.0.0.1:{hub}/entity/extend/{e1['id']}",
186+
headers=auth)
187+
assert d.status_code == 200
188+
assert d.json()["deleted"] is True
189+
190+
listing2 = (await c.get(f"http://127.0.0.1:{hub}/entity/extend",
191+
headers=auth)).json()["extensions"]
192+
assert [e["id"] for e in listing2] == [e2["id"]]
193+
194+
195+
@pytest.mark.asyncio
196+
async def test_extend_rejects_oversized_body(hub):
197+
pub = publish(
198+
name="ext-bot-5", description="x",
199+
chat_handler=lambda m, o: "ok",
200+
hub_url=f"ws://127.0.0.1:{hub}",
201+
)
202+
for _ in range(50):
203+
if pub.api_key:
204+
break
205+
await asyncio.sleep(0.1)
206+
207+
async with httpx.AsyncClient(timeout=5.0) as c:
208+
r = await c.post(
209+
f"http://127.0.0.1:{hub}/entity/extend",
210+
json={"section": "patterns", "title": "huge", "body": "x" * 9000},
211+
headers={"Authorization": f"Bearer {pub.api_key}"},
212+
)
213+
assert r.status_code == 413

zhub/entity.md

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,25 @@ validated against the cap's JSON schema before invoke. Returns
8484
reasoning** — fastest path, lowest cost.
8585

8686
### `GET /entity`, `GET /entity/<section>`, `GET /entity/errors/<code>`
87-
This file. Served plain markdown.
87+
This file. Served plain markdown. The full file and per-section views
88+
also include any operator-added extensions for that hub.
89+
90+
### `POST /entity/extend`
91+
Append an operator's own recipe to the entity. Auth: any registered
92+
publisher's `Bearer <api_key>`. Body: `{section, title, body}`. Caps:
93+
8 KB per body, 200 extensions per hub. Persists across restarts (only
94+
when the hub started with `--db <path>`). The extension surfaces in
95+
`GET /entity` (in an appendix), in `GET /entity/<section>` (appended
96+
inside the section), and in `GET /entity/errors/<code>` (when the
97+
title matches the code) — so any AI fetching the entity sees what
98+
this hub specifically has learned.
99+
100+
### `GET /entity/extend`
101+
List all extensions on this hub. Same auth as POST. Returns
102+
`{extensions: [{id, section, title, body, added_by, added_at}]}`.
103+
104+
### `DELETE /entity/extend/{id}`
105+
Remove an extension. Same auth.
88106

89107
### `WS /ws/publish`
90108
Publisher long-lived WebSocket. Send `register-publisher` first.

0 commit comments

Comments
 (0)