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.
- eno_service-0.1.0/.gitignore +18 -0
- eno_service-0.1.0/PKG-INFO +11 -0
- eno_service-0.1.0/pyproject.toml +24 -0
- eno_service-0.1.0/src/eno_service/__init__.py +3 -0
- eno_service-0.1.0/src/eno_service/server.py +272 -0
- eno_service-0.1.0/tests/__init__.py +0 -0
- eno_service-0.1.0/tests/test_server.py +213 -0
|
@@ -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,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")
|