eno-service 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,18 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ .mypy_cache/
7
+ *.egg-info/
8
+ dist/
9
+ build/
10
+ .somm/
11
+ .claude/worktrees/
12
+ .playwright-mcp/
13
+ .env
14
+
15
+ # obsidian plugin build artifacts (when packages/eno-plugin/ lands)
16
+ node_modules/
17
+ packages/eno-plugin/main.js
18
+ packages/eno-plugin/*.zip
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: eno-service
3
+ Version: 0.1.0
4
+ Summary: eno-service — FastAPI face on eno's read endpoints
5
+ License: MIT
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: enowiki==0.1.0
8
+ Requires-Dist: fastapi>=0.115
9
+ Requires-Dist: uvicorn>=0.30
10
+ Provides-Extra: test
11
+ Requires-Dist: httpx>=0.27; extra == 'test'
@@ -0,0 +1,24 @@
1
+ [project]
2
+ name = "eno-service"
3
+ version = "0.1.0"
4
+ description = "eno-service — FastAPI face on eno's read endpoints"
5
+ requires-python = ">=3.12"
6
+ license = { text = "MIT" }
7
+ dependencies = [
8
+ "enowiki==0.1.0", # the core package (imports as `eno`)
9
+ "fastapi>=0.115",
10
+ "uvicorn>=0.30",
11
+ ]
12
+
13
+ [project.optional-dependencies]
14
+ test = ["httpx>=0.27"]
15
+
16
+ [project.scripts]
17
+ eno-serve = "eno_service.server:main"
18
+
19
+ [build-system]
20
+ requires = ["hatchling"]
21
+ build-backend = "hatchling.build"
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["src/eno_service"]
@@ -0,0 +1,3 @@
1
+ """eno-service — FastAPI face on eno's read endpoints."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,272 @@
1
+ """FastAPI server exposing eno's read endpoints.
2
+
3
+ Per-request sqlite connection. WAL mode means concurrent reads are fine; the
4
+ slight overhead of opening a connection per request is microseconds at vault scale.
5
+ The service is read-only in v0 — `POST /index` is the one mutating exception.
6
+ """
7
+
8
+ import os
9
+ from contextlib import contextmanager
10
+ from dataclasses import asdict
11
+
12
+ from eno import garden, hygiene, queries, writes
13
+ from eno.config import index_path, vault_dir
14
+ from eno.db import open_index
15
+ from eno.excerpt import excerpt as build_excerpt
16
+ from eno.indexer import index_vault
17
+ from eno.views import Proposal
18
+ from fastapi import FastAPI, HTTPException, Query
19
+ from pydantic import BaseModel
20
+
21
+
22
+ @contextmanager
23
+ def _db():
24
+ conn = open_index(index_path(vault_dir()))
25
+ try:
26
+ yield conn
27
+ finally:
28
+ conn.close()
29
+
30
+
31
+ def create_app() -> FastAPI:
32
+ app = FastAPI(title="eno-service", version="0.0.1")
33
+
34
+ @app.get("/health")
35
+ def health():
36
+ return {"ok": True, "vault": str(vault_dir())}
37
+
38
+ @app.get("/search")
39
+ def search(q: str, kind: str = "title", limit: int = 20):
40
+ try:
41
+ with _db() as db:
42
+ return [asdict(h) for h in queries.search(db, q, kind=kind, limit=limit)]
43
+ except ValueError as e:
44
+ raise HTTPException(status_code=400, detail=str(e)) from e
45
+
46
+ @app.get("/note")
47
+ def note(path: str, excerpt: int = 1):
48
+ with _db() as db:
49
+ view = queries.note(db, path)
50
+ if not view:
51
+ raise HTTPException(status_code=404, detail=f"note not found: {path}")
52
+ if excerpt:
53
+ view.excerpt = build_excerpt(vault_dir(), path)
54
+ return asdict(view)
55
+
56
+ @app.get("/neighbors")
57
+ def neighbors(path: str):
58
+ with _db() as db:
59
+ n = queries.neighbors(db, path)
60
+ if not n:
61
+ raise HTTPException(status_code=404, detail=f"note not found: {path}")
62
+ return asdict(n)
63
+
64
+ @app.get("/orphans")
65
+ def orphans(folder: str | None = None, min_words: int = 0, limit: int = 100):
66
+ with _db() as db:
67
+ return [
68
+ asdict(r)
69
+ for r in queries.orphans(
70
+ db, folder=folder, min_words=min_words, limit=limit
71
+ )
72
+ ]
73
+
74
+ @app.get("/stubs")
75
+ def stubs(max_words: int = 80, limit: int = 100):
76
+ with _db() as db:
77
+ return [
78
+ asdict(r) for r in queries.stubs(db, max_words=max_words, limit=limit)
79
+ ]
80
+
81
+ @app.get("/stale")
82
+ def stale(
83
+ older_than_days: int = 180,
84
+ stages: list[str] | None = Query(default=None),
85
+ limit: int = 100,
86
+ ):
87
+ with _db() as db:
88
+ return [
89
+ asdict(r)
90
+ for r in queries.stale(
91
+ db, older_than_days=older_than_days, stages=stages, limit=limit
92
+ )
93
+ ]
94
+
95
+ @app.get("/broken-links")
96
+ def broken_links(limit: int = 200):
97
+ with _db() as db:
98
+ return [asdict(r) for r in queries.broken_links(db, limit=limit)]
99
+
100
+ @app.get("/frontier")
101
+ def frontier(
102
+ folder: str | None = None,
103
+ halflife_days: float = 30.0,
104
+ limit: int = 20,
105
+ include_nonpositive: bool = False,
106
+ exclude_types: list[str] | None = Query(default=None),
107
+ ):
108
+ with _db() as db:
109
+ return [
110
+ asdict(r)
111
+ for r in queries.frontier(
112
+ db,
113
+ folder=folder,
114
+ halflife_days=halflife_days,
115
+ limit=limit,
116
+ include_nonpositive=include_nonpositive,
117
+ exclude_types=exclude_types,
118
+ )
119
+ ]
120
+
121
+ @app.get("/hot")
122
+ def hot(agent_name: str = ""):
123
+ with _db() as db:
124
+ return asdict(queries.hot(db, agent_name=agent_name))
125
+
126
+ class TilingBody(BaseModel):
127
+ threshold: float = 0.80
128
+ error_threshold: float = 0.90
129
+ folder: str | None = None
130
+ min_words: int = 80
131
+ model: str = "nomic-embed-text"
132
+
133
+ @app.post("/tiling")
134
+ def tiling_endpoint(body: TilingBody | None = None):
135
+ from eno import tiling as tiling_mod
136
+ body = body or TilingBody()
137
+ with _db() as db:
138
+ return asdict(
139
+ tiling_mod.find_semantic_duplicates(
140
+ db,
141
+ vault_dir(),
142
+ threshold=body.threshold,
143
+ error_threshold=body.error_threshold,
144
+ folder=body.folder,
145
+ min_words=body.min_words,
146
+ model=body.model,
147
+ )
148
+ )
149
+
150
+ @app.get("/classify-broken-links")
151
+ def classify_broken_links_endpoint():
152
+ with _db() as db:
153
+ drift, concepts = garden.classify_broken_links(db)
154
+ return {
155
+ "drift": [asdict(d) for d in drift],
156
+ "concepts": [asdict(c) for c in concepts],
157
+ }
158
+
159
+ @app.get("/hygiene")
160
+ def hygiene_endpoint():
161
+ with _db() as db:
162
+ return asdict(queries.hygiene(db))
163
+
164
+ @app.post("/index")
165
+ def index(full: bool = False):
166
+ stats = index_vault(vault_dir(), full=full)
167
+ return asdict(stats)
168
+
169
+ class ProposeBody(BaseModel):
170
+ include_unknown: bool = False
171
+
172
+ @app.post("/hygiene/propose")
173
+ def hygiene_propose(body: ProposeBody | None = None):
174
+ body = body or ProposeBody()
175
+ with _db() as db:
176
+ return asdict(
177
+ hygiene.propose_all(db, include_unknown=body.include_unknown)
178
+ )
179
+
180
+ class ProposalIn(BaseModel):
181
+ path: str
182
+ add: dict[str, str]
183
+ confidence: str = "medium"
184
+ reason: str = ""
185
+
186
+ class ApplyBody(BaseModel):
187
+ proposals: list[ProposalIn]
188
+ dry_run: bool = False
189
+
190
+ @app.post("/hygiene/apply")
191
+ def hygiene_apply(body: ApplyBody):
192
+ proposals = [
193
+ Proposal(
194
+ path=p.path, add=p.add, confidence=p.confidence, reason=p.reason
195
+ )
196
+ for p in body.proposals
197
+ ]
198
+ results = hygiene.apply_all(vault_dir(), proposals, dry_run=body.dry_run)
199
+ return {"results": [asdict(r) for r in results]}
200
+
201
+ class GardenBody(BaseModel):
202
+ folder: str | None = None
203
+ resurfacing_min_words: int = 1000
204
+ stub_max_words: int = 80
205
+ stale_days: int = 180
206
+ drift_threshold: float = 0.85
207
+ duplicate_threshold: float = 0.80
208
+
209
+ @app.post("/garden")
210
+ def garden_endpoint(body: GardenBody | None = None):
211
+ body = body or GardenBody()
212
+ with _db() as db:
213
+ return asdict(garden.garden(db, **body.model_dump()))
214
+
215
+ class CreateBody(BaseModel):
216
+ path: str
217
+ body: str
218
+ frontmatter: dict | None = None
219
+ overwrite: bool = False
220
+ author: str | None = None
221
+
222
+ @app.post("/note/create")
223
+ def create_note_endpoint(body: CreateBody):
224
+ result = writes.create_note(
225
+ vault_dir(),
226
+ body.path,
227
+ body.body,
228
+ frontmatter=body.frontmatter,
229
+ overwrite=body.overwrite,
230
+ author=body.author,
231
+ )
232
+ if result.ok:
233
+ index_vault(vault_dir())
234
+ result.indexed = True
235
+ return asdict(result)
236
+
237
+ class AppendBody(BaseModel):
238
+ path: str
239
+ content: str
240
+ under_heading: str | None = None
241
+
242
+ @app.post("/note/append")
243
+ def append_to_note_endpoint(body: AppendBody):
244
+ result = writes.append_to_note(
245
+ vault_dir(),
246
+ body.path,
247
+ body.content,
248
+ under_heading=body.under_heading,
249
+ )
250
+ if result.ok:
251
+ index_vault(vault_dir())
252
+ result.indexed = True
253
+ return asdict(result)
254
+
255
+ return app
256
+
257
+
258
+ def serve(host: str = "127.0.0.1", port: int = 7891) -> None:
259
+ import uvicorn
260
+
261
+ uvicorn.run(create_app(), host=host, port=port, log_level="info")
262
+
263
+
264
+ def main() -> None:
265
+ """uv-friendly entrypoint reading host/port from env."""
266
+ host = os.environ.get("ENO_SERVICE_HOST", "127.0.0.1")
267
+ port = int(os.environ.get("ENO_SERVICE_PORT", "7891"))
268
+ serve(host=host, port=port)
269
+
270
+
271
+ if __name__ == "__main__":
272
+ main()
File without changes
@@ -0,0 +1,213 @@
1
+ """End-to-end server tests via FastAPI's TestClient. Confirms wiring,
2
+ not query semantics (those live in test_queries.py)."""
3
+
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+ from eno.indexer import index_vault
8
+ from eno_service.server import create_app
9
+ from fastapi.testclient import TestClient
10
+
11
+
12
+ @pytest.fixture()
13
+ def vault(tmp_path: Path, monkeypatch) -> Path:
14
+ (tmp_path / "Alpha.md").write_text(
15
+ "---\norigin: human\nstage: active\n---\n# Alpha\n\nlink to [[Beta]] and [[Ghost]]\n"
16
+ )
17
+ (tmp_path / "Beta.md").write_text("# Beta\n\nback to [[Alpha]]\n")
18
+ (tmp_path / "Orphan.md").write_text("# Orphan\n\nnothing inbound")
19
+ monkeypatch.setenv("ENO_VAULT_DIR", str(tmp_path))
20
+ monkeypatch.setenv("ENO_DIR", str(tmp_path / ".eno"))
21
+ index_vault(tmp_path)
22
+ return tmp_path
23
+
24
+
25
+ @pytest.fixture()
26
+ def client(vault: Path) -> TestClient:
27
+ return TestClient(create_app())
28
+
29
+
30
+ def test_health(client: TestClient):
31
+ r = client.get("/health")
32
+ assert r.status_code == 200
33
+ body = r.json()
34
+ assert body["ok"] is True
35
+
36
+
37
+ def test_search(client: TestClient):
38
+ r = client.get("/search", params={"q": "alpha"})
39
+ assert r.status_code == 200
40
+ paths = [h["path"] for h in r.json()]
41
+ assert "Alpha.md" in paths
42
+
43
+
44
+ def test_note_returns_view_with_excerpt(client: TestClient):
45
+ r = client.get("/note", params={"path": "Alpha.md"})
46
+ assert r.status_code == 200
47
+ body = r.json()
48
+ assert body["title"] == "Alpha"
49
+ assert body["frontmatter"]["origin"] == "human"
50
+ assert body["excerpt"]
51
+
52
+
53
+ def test_note_404(client: TestClient):
54
+ r = client.get("/note", params={"path": "Nope.md"})
55
+ assert r.status_code == 404
56
+
57
+
58
+ def test_neighbors(client: TestClient):
59
+ r = client.get("/neighbors", params={"path": "Alpha.md"})
60
+ assert r.status_code == 200
61
+ body = r.json()
62
+ assert any(b["path"] == "Beta.md" for b in body["backlinks"])
63
+ assert any(o["path"] == "Beta.md" for o in body["outbound"])
64
+
65
+
66
+ def test_orphans(client: TestClient):
67
+ r = client.get("/orphans")
68
+ assert r.status_code == 200
69
+ paths = [n["path"] for n in r.json()]
70
+ assert "Orphan.md" in paths
71
+
72
+
73
+ def test_broken_links(client: TestClient):
74
+ r = client.get("/broken-links")
75
+ assert r.status_code == 200
76
+ targets = [b["target_text"] for b in r.json()]
77
+ assert "Ghost" in targets
78
+
79
+
80
+ def test_hot(client: TestClient):
81
+ r = client.get("/hot", params={"agent_name": "Weaver"})
82
+ assert r.status_code == 200
83
+ body = r.json()
84
+ assert body["agent_name"] == "Weaver"
85
+ assert body["generated_at"]
86
+ # Fixture has Alpha→Beta+Ghost (out=1, in=1, score=0 → not in default frontier),
87
+ # but recent_appends should at least include some notes.
88
+ assert isinstance(body["frontier"], list)
89
+ assert isinstance(body["recent_appends"], list)
90
+ assert isinstance(body["top_concepts"], list)
91
+ assert isinstance(body["agent_recent"], list)
92
+
93
+
94
+ def test_frontier(client: TestClient):
95
+ r = client.get("/frontier")
96
+ assert r.status_code == 200
97
+ body = r.json()
98
+ assert isinstance(body, list)
99
+ # Alpha → Beta + Ghost: out=1 (Beta resolves; Ghost doesn't), in=1 (Beta).
100
+ # Score = 0 by default; expect Alpha excluded unless include_nonpositive.
101
+ r2 = client.get("/frontier", params={"include_nonpositive": "true"})
102
+ assert r2.status_code == 200
103
+ paths = [f["path"] for f in r2.json()]
104
+ assert "Alpha.md" in paths
105
+
106
+
107
+ def test_hygiene(client: TestClient):
108
+ r = client.get("/hygiene")
109
+ assert r.status_code == 200
110
+ body = r.json()
111
+ assert body["counts"]["total"] == 3
112
+ # Beta and Orphan lack frontmatter
113
+ assert body["counts"]["origin"] == 2
114
+
115
+
116
+ def test_search_unknown_kind_400(client: TestClient):
117
+ r = client.get("/search", params={"q": "x", "kind": "bogus"})
118
+ assert r.status_code == 400
119
+
120
+
121
+ def test_hygiene_propose(client: TestClient):
122
+ r = client.post("/hygiene/propose", json={"include_unknown": False})
123
+ assert r.status_code == 200
124
+ body = r.json()
125
+ assert "proposals" in body
126
+ assert body["total_notes"] >= 3
127
+ paths = {p["path"] for p in body["proposals"]}
128
+ # Alpha already has origin → not in proposals
129
+ assert "Alpha.md" not in paths
130
+ # Orphan and Beta lack origin → eligible
131
+ assert "Beta.md" in paths or "Orphan.md" in paths
132
+
133
+
134
+ def test_classify_broken_links_endpoint(client: TestClient):
135
+ r = client.get("/classify-broken-links")
136
+ assert r.status_code == 200
137
+ body = r.json()
138
+ assert "drift" in body
139
+ assert "concepts" in body
140
+ # Alpha has [[Ghost]] which doesn't resolve and doesn't fuzzy-match anything
141
+ targets = [c["target_text"] for c in body["concepts"]]
142
+ assert "Ghost" in targets
143
+
144
+
145
+ def test_garden_endpoint(client: TestClient):
146
+ r = client.post("/garden", json={})
147
+ assert r.status_code == 200
148
+ body = r.json()
149
+ assert "resurfacing" in body
150
+ assert "concepts" in body
151
+ assert "drift" in body
152
+ assert "duplicates" in body
153
+ assert "stubs" in body
154
+ assert "stale" in body
155
+ assert "stats" in body
156
+
157
+
158
+ def test_create_note_endpoint(vault: Path, client: TestClient):
159
+ r = client.post(
160
+ "/note/create",
161
+ json={
162
+ "path": "FromService.md",
163
+ "body": "service-created body",
164
+ "author": "TestAgent",
165
+ },
166
+ )
167
+ assert r.status_code == 200
168
+ data = r.json()
169
+ assert data["ok"]
170
+ assert data["indexed"]
171
+ assert (vault / "FromService.md").exists()
172
+ assert "[[TestAgent]]" in (vault / "FromService.md").read_text()
173
+
174
+
175
+ def test_create_note_endpoint_refuses_existing(vault: Path, client: TestClient):
176
+ r = client.post(
177
+ "/note/create",
178
+ json={"path": "Alpha.md", "body": "should not overwrite"},
179
+ )
180
+ assert r.status_code == 200
181
+ assert r.json()["ok"] is False
182
+
183
+
184
+ def test_append_to_note_endpoint(vault: Path, client: TestClient):
185
+ r = client.post(
186
+ "/note/append",
187
+ json={"path": "Beta.md", "content": "new line via service"},
188
+ )
189
+ assert r.status_code == 200
190
+ assert r.json()["ok"]
191
+ assert "new line via service" in (vault / "Beta.md").read_text()
192
+
193
+
194
+ def test_hygiene_apply(vault: Path, client: TestClient):
195
+ proposals = [
196
+ {
197
+ "path": "Beta.md",
198
+ "add": {"origin": "human"},
199
+ "confidence": "medium",
200
+ "reason": "test",
201
+ }
202
+ ]
203
+ r = client.post(
204
+ "/hygiene/apply", json={"proposals": proposals, "dry_run": False}
205
+ )
206
+ assert r.status_code == 200
207
+ results = r.json()["results"]
208
+ assert results[0]["ok"]
209
+ assert results[0]["applied"] == {"origin": "human"}
210
+ # Verify the file was actually mutated
211
+ new = (vault / "Beta.md").read_text()
212
+ assert "origin: human" in new
213
+ assert new.startswith("---\n")