enowiki 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.
- enowiki-0.1.0/.gitignore +18 -0
- enowiki-0.1.0/PKG-INFO +9 -0
- enowiki-0.1.0/pyproject.toml +27 -0
- enowiki-0.1.0/src/eno/__init__.py +3 -0
- enowiki-0.1.0/src/eno/backend.py +404 -0
- enowiki-0.1.0/src/eno/cli.py +999 -0
- enowiki-0.1.0/src/eno/client.py +51 -0
- enowiki-0.1.0/src/eno/config.py +35 -0
- enowiki-0.1.0/src/eno/db.py +13 -0
- enowiki-0.1.0/src/eno/excerpt.py +42 -0
- enowiki-0.1.0/src/eno/fold.py +1577 -0
- enowiki-0.1.0/src/eno/garden.py +387 -0
- enowiki-0.1.0/src/eno/hygiene.py +318 -0
- enowiki-0.1.0/src/eno/indexer.py +196 -0
- enowiki-0.1.0/src/eno/parser.py +166 -0
- enowiki-0.1.0/src/eno/queries.py +414 -0
- enowiki-0.1.0/src/eno/schema.py +65 -0
- enowiki-0.1.0/src/eno/tiling.py +329 -0
- enowiki-0.1.0/src/eno/views.py +266 -0
- enowiki-0.1.0/src/eno/writes.py +220 -0
- enowiki-0.1.0/tests/__init__.py +0 -0
- enowiki-0.1.0/tests/test_backend.py +118 -0
- enowiki-0.1.0/tests/test_cli.py +28 -0
- enowiki-0.1.0/tests/test_cli_subcommands.py +240 -0
- enowiki-0.1.0/tests/test_excerpt.py +36 -0
- enowiki-0.1.0/tests/test_fold.py +691 -0
- enowiki-0.1.0/tests/test_garden.py +202 -0
- enowiki-0.1.0/tests/test_hygiene.py +280 -0
- enowiki-0.1.0/tests/test_indexer.py +176 -0
- enowiki-0.1.0/tests/test_parser.py +102 -0
- enowiki-0.1.0/tests/test_queries.py +375 -0
- enowiki-0.1.0/tests/test_tiling.py +261 -0
- enowiki-0.1.0/tests/test_writes.py +203 -0
enowiki-0.1.0/.gitignore
ADDED
|
@@ -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
|
enowiki-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
# Distribution name is `enowiki` (the bare `eno` is taken on PyPI by an
|
|
3
|
+
# unrelated 2015 package). The import package and CLI stay `eno`.
|
|
4
|
+
name = "enowiki"
|
|
5
|
+
version = "0.1.0"
|
|
6
|
+
description = "eno — vault indexing, parsing, and query primitives"
|
|
7
|
+
requires-python = ">=3.12"
|
|
8
|
+
license = { text = "MIT" }
|
|
9
|
+
dependencies = [
|
|
10
|
+
"pyyaml>=6.0",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[project.optional-dependencies]
|
|
14
|
+
# LLM-driven features (fold, tiling) route through somm, which owns provider
|
|
15
|
+
# selection (ollama-local-first, etc). The structural core — index, query,
|
|
16
|
+
# hygiene, gardening, MCP read tools — needs none of this.
|
|
17
|
+
llm = ["somm>=0.2"]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
eno = "eno.cli:main"
|
|
21
|
+
|
|
22
|
+
[build-system]
|
|
23
|
+
requires = ["hatchling"]
|
|
24
|
+
build-backend = "hatchling.build"
|
|
25
|
+
|
|
26
|
+
[tool.hatch.build.targets.wheel]
|
|
27
|
+
packages = ["src/eno"]
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""Read backend abstraction.
|
|
2
|
+
|
|
3
|
+
LocalBackend hits the vault's sqlite index directly — the workstation/dev path.
|
|
4
|
+
ServiceBackend hits eno-service over HTTP — the dash-main path and the path
|
|
5
|
+
other surfaces (MCP, plugin) take.
|
|
6
|
+
|
|
7
|
+
`make_backend()` chooses based on env var (ENO_SERVICE_URL) and explicit args,
|
|
8
|
+
so the CLI doesn't need a service running for single-host work.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import sqlite3
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Protocol
|
|
15
|
+
|
|
16
|
+
from . import garden as garden_mod
|
|
17
|
+
from . import hygiene as hygiene_mod
|
|
18
|
+
from . import queries
|
|
19
|
+
from . import writes as writes_mod
|
|
20
|
+
from .client import EnoClient
|
|
21
|
+
from .config import index_path, vault_dir
|
|
22
|
+
from .db import open_index
|
|
23
|
+
from .excerpt import excerpt
|
|
24
|
+
from .views import (
|
|
25
|
+
ApplyResult,
|
|
26
|
+
BrokenLink,
|
|
27
|
+
ConceptCandidate,
|
|
28
|
+
DriftCandidate,
|
|
29
|
+
DuplicatePair,
|
|
30
|
+
FrontierNote,
|
|
31
|
+
GardenReport,
|
|
32
|
+
HeadingView,
|
|
33
|
+
Hit,
|
|
34
|
+
HotCache,
|
|
35
|
+
HygieneIssue,
|
|
36
|
+
HygieneReport,
|
|
37
|
+
Neighborhood,
|
|
38
|
+
NoteRef,
|
|
39
|
+
NoteView,
|
|
40
|
+
Proposal,
|
|
41
|
+
ProposalReport,
|
|
42
|
+
TilingReport,
|
|
43
|
+
WriteResult,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Backend(Protocol):
|
|
48
|
+
def search(
|
|
49
|
+
self, q: str, *, kind: str = "title", limit: int = 20
|
|
50
|
+
) -> list[Hit]: ...
|
|
51
|
+
def note(self, path: str, *, with_excerpt: bool = True) -> NoteView | None: ...
|
|
52
|
+
def neighbors(self, path: str) -> Neighborhood | None: ...
|
|
53
|
+
def orphans(
|
|
54
|
+
self, *, folder: str | None = None, min_words: int = 0, limit: int = 100
|
|
55
|
+
) -> list[NoteRef]: ...
|
|
56
|
+
def stubs(self, *, max_words: int = 80, limit: int = 100) -> list[NoteRef]: ...
|
|
57
|
+
def stale(
|
|
58
|
+
self,
|
|
59
|
+
*,
|
|
60
|
+
older_than_days: int = 180,
|
|
61
|
+
stages: list[str] | None = None,
|
|
62
|
+
limit: int = 100,
|
|
63
|
+
) -> list[NoteRef]: ...
|
|
64
|
+
def broken_links(self, *, limit: int = 200) -> list[BrokenLink]: ...
|
|
65
|
+
def frontier(
|
|
66
|
+
self,
|
|
67
|
+
*,
|
|
68
|
+
folder: str | None = None,
|
|
69
|
+
halflife_days: float = 30.0,
|
|
70
|
+
limit: int = 20,
|
|
71
|
+
include_nonpositive: bool = False,
|
|
72
|
+
exclude_types: list[str] | None = None,
|
|
73
|
+
) -> list[FrontierNote]: ...
|
|
74
|
+
def hot(self, *, agent_name: str = "") -> HotCache: ...
|
|
75
|
+
def tiling(
|
|
76
|
+
self,
|
|
77
|
+
*,
|
|
78
|
+
threshold: float = 0.80,
|
|
79
|
+
error_threshold: float = 0.90,
|
|
80
|
+
folder: str | None = None,
|
|
81
|
+
min_words: int = 80,
|
|
82
|
+
model: str = "nomic-embed-text",
|
|
83
|
+
) -> TilingReport: ...
|
|
84
|
+
def hygiene(self) -> HygieneReport: ...
|
|
85
|
+
def hygiene_propose(
|
|
86
|
+
self, *, include_unknown: bool = False
|
|
87
|
+
) -> ProposalReport: ...
|
|
88
|
+
def hygiene_apply(
|
|
89
|
+
self, proposals: list[Proposal], *, dry_run: bool = False
|
|
90
|
+
) -> list[ApplyResult]: ...
|
|
91
|
+
def garden(self, **kwargs) -> GardenReport: ...
|
|
92
|
+
def classify_broken_links(
|
|
93
|
+
self,
|
|
94
|
+
) -> tuple[list[DriftCandidate], list[ConceptCandidate]]: ...
|
|
95
|
+
def create_note(
|
|
96
|
+
self,
|
|
97
|
+
path: str,
|
|
98
|
+
body: str,
|
|
99
|
+
*,
|
|
100
|
+
frontmatter: dict | None = None,
|
|
101
|
+
overwrite: bool = False,
|
|
102
|
+
author: str | None = None,
|
|
103
|
+
) -> WriteResult: ...
|
|
104
|
+
def append_to_note(
|
|
105
|
+
self,
|
|
106
|
+
path: str,
|
|
107
|
+
content: str,
|
|
108
|
+
*,
|
|
109
|
+
under_heading: str | None = None,
|
|
110
|
+
) -> WriteResult: ...
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class LocalBackend:
|
|
114
|
+
def __init__(self, vault: Path):
|
|
115
|
+
self.vault = vault
|
|
116
|
+
self._db: sqlite3.Connection | None = None
|
|
117
|
+
|
|
118
|
+
def _conn(self) -> sqlite3.Connection:
|
|
119
|
+
if self._db is None:
|
|
120
|
+
self._db = open_index(index_path(self.vault))
|
|
121
|
+
return self._db
|
|
122
|
+
|
|
123
|
+
def search(self, q, *, kind="title", limit=20):
|
|
124
|
+
return queries.search(self._conn(), q, kind=kind, limit=limit)
|
|
125
|
+
|
|
126
|
+
def note(self, path, *, with_excerpt=True):
|
|
127
|
+
view = queries.note(self._conn(), path)
|
|
128
|
+
if view and with_excerpt:
|
|
129
|
+
view.excerpt = excerpt(self.vault, path)
|
|
130
|
+
return view
|
|
131
|
+
|
|
132
|
+
def neighbors(self, path):
|
|
133
|
+
return queries.neighbors(self._conn(), path)
|
|
134
|
+
|
|
135
|
+
def orphans(self, **kwargs):
|
|
136
|
+
return queries.orphans(self._conn(), **kwargs)
|
|
137
|
+
|
|
138
|
+
def stubs(self, **kwargs):
|
|
139
|
+
return queries.stubs(self._conn(), **kwargs)
|
|
140
|
+
|
|
141
|
+
def stale(self, **kwargs):
|
|
142
|
+
return queries.stale(self._conn(), **kwargs)
|
|
143
|
+
|
|
144
|
+
def broken_links(self, **kwargs):
|
|
145
|
+
return queries.broken_links(self._conn(), **kwargs)
|
|
146
|
+
|
|
147
|
+
def frontier(self, **kwargs):
|
|
148
|
+
return queries.frontier(self._conn(), **kwargs)
|
|
149
|
+
|
|
150
|
+
def hot(self, *, agent_name=""):
|
|
151
|
+
return queries.hot(self._conn(), agent_name=agent_name)
|
|
152
|
+
|
|
153
|
+
def tiling(self, **kwargs):
|
|
154
|
+
from . import tiling as tiling_mod
|
|
155
|
+
return tiling_mod.find_semantic_duplicates(
|
|
156
|
+
self._conn(), self.vault, **kwargs
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def hygiene(self):
|
|
160
|
+
return queries.hygiene(self._conn())
|
|
161
|
+
|
|
162
|
+
def hygiene_propose(self, *, include_unknown=False):
|
|
163
|
+
return hygiene_mod.propose_all(
|
|
164
|
+
self._conn(), include_unknown=include_unknown
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
def hygiene_apply(self, proposals, *, dry_run=False):
|
|
168
|
+
return hygiene_mod.apply_all(self.vault, proposals, dry_run=dry_run)
|
|
169
|
+
|
|
170
|
+
def garden(self, **kwargs):
|
|
171
|
+
return garden_mod.garden(self._conn(), **kwargs)
|
|
172
|
+
|
|
173
|
+
def classify_broken_links(self):
|
|
174
|
+
return garden_mod.classify_broken_links(self._conn())
|
|
175
|
+
|
|
176
|
+
def create_note(self, path, body, *, frontmatter=None, overwrite=False, author=None):
|
|
177
|
+
result = writes_mod.create_note(
|
|
178
|
+
self.vault, path, body,
|
|
179
|
+
frontmatter=frontmatter, overwrite=overwrite, author=author,
|
|
180
|
+
)
|
|
181
|
+
if result.ok:
|
|
182
|
+
self._reindex_after_write()
|
|
183
|
+
result.indexed = True
|
|
184
|
+
return result
|
|
185
|
+
|
|
186
|
+
def append_to_note(self, path, content, *, under_heading=None):
|
|
187
|
+
result = writes_mod.append_to_note(
|
|
188
|
+
self.vault, path, content, under_heading=under_heading
|
|
189
|
+
)
|
|
190
|
+
if result.ok:
|
|
191
|
+
self._reindex_after_write()
|
|
192
|
+
result.indexed = True
|
|
193
|
+
return result
|
|
194
|
+
|
|
195
|
+
def _reindex_after_write(self) -> None:
|
|
196
|
+
# Drop the cached connection so the post-index state is visible to
|
|
197
|
+
# subsequent reads through this backend.
|
|
198
|
+
from .indexer import index_vault
|
|
199
|
+
if self._db is not None:
|
|
200
|
+
self._db.close()
|
|
201
|
+
self._db = None
|
|
202
|
+
index_vault(self.vault)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class ServiceBackend:
|
|
206
|
+
def __init__(self, base_url: str):
|
|
207
|
+
self.client = EnoClient(base_url)
|
|
208
|
+
|
|
209
|
+
def search(self, q, *, kind="title", limit=20):
|
|
210
|
+
rows = self.client.get("/search", {"q": q, "kind": kind, "limit": limit})
|
|
211
|
+
return [Hit(**r) for r in (rows or [])]
|
|
212
|
+
|
|
213
|
+
def note(self, path, *, with_excerpt=True):
|
|
214
|
+
data = self.client.get(
|
|
215
|
+
"/note", {"path": path, "excerpt": 1 if with_excerpt else 0}
|
|
216
|
+
)
|
|
217
|
+
return _hydrate_note_view(data) if data else None
|
|
218
|
+
|
|
219
|
+
def neighbors(self, path):
|
|
220
|
+
data = self.client.get("/neighbors", {"path": path})
|
|
221
|
+
return _hydrate_neighborhood(data) if data else None
|
|
222
|
+
|
|
223
|
+
def orphans(self, **kwargs):
|
|
224
|
+
rows = self.client.get("/orphans", kwargs)
|
|
225
|
+
return [NoteRef(**r) for r in (rows or [])]
|
|
226
|
+
|
|
227
|
+
def stubs(self, **kwargs):
|
|
228
|
+
rows = self.client.get("/stubs", kwargs)
|
|
229
|
+
return [NoteRef(**r) for r in (rows or [])]
|
|
230
|
+
|
|
231
|
+
def stale(self, **kwargs):
|
|
232
|
+
params = dict(kwargs)
|
|
233
|
+
# Service expects repeated `stages` query params; client.get does this via doseq.
|
|
234
|
+
if "stages" in params and params["stages"] is None:
|
|
235
|
+
params.pop("stages")
|
|
236
|
+
rows = self.client.get("/stale", params)
|
|
237
|
+
return [NoteRef(**r) for r in (rows or [])]
|
|
238
|
+
|
|
239
|
+
def broken_links(self, **kwargs):
|
|
240
|
+
rows = self.client.get("/broken-links", kwargs)
|
|
241
|
+
return [BrokenLink(**r) for r in (rows or [])]
|
|
242
|
+
|
|
243
|
+
def frontier(self, **kwargs):
|
|
244
|
+
params = dict(kwargs)
|
|
245
|
+
if "exclude_types" in params and params["exclude_types"] is None:
|
|
246
|
+
params.pop("exclude_types")
|
|
247
|
+
rows = self.client.get("/frontier", params)
|
|
248
|
+
return [FrontierNote(**r) for r in (rows or [])]
|
|
249
|
+
|
|
250
|
+
def hot(self, *, agent_name=""):
|
|
251
|
+
data = self.client.get("/hot", {"agent_name": agent_name})
|
|
252
|
+
return _hydrate_hot_cache(data) if data else HotCache(generated_at="")
|
|
253
|
+
|
|
254
|
+
def tiling(self, **kwargs):
|
|
255
|
+
data = self.client.post("/tiling", kwargs)
|
|
256
|
+
return _hydrate_tiling_report(data) if data else TilingReport(
|
|
257
|
+
error="no response from service"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
def hygiene(self):
|
|
261
|
+
data = self.client.get("/hygiene")
|
|
262
|
+
return _hydrate_hygiene(data) if data else HygieneReport()
|
|
263
|
+
|
|
264
|
+
def hygiene_propose(self, *, include_unknown=False):
|
|
265
|
+
data = self.client.post(
|
|
266
|
+
"/hygiene/propose", {"include_unknown": include_unknown}
|
|
267
|
+
)
|
|
268
|
+
return _hydrate_proposal_report(data) if data else ProposalReport()
|
|
269
|
+
|
|
270
|
+
def hygiene_apply(self, proposals, *, dry_run=False):
|
|
271
|
+
data = self.client.post(
|
|
272
|
+
"/hygiene/apply",
|
|
273
|
+
{
|
|
274
|
+
"proposals": [
|
|
275
|
+
{
|
|
276
|
+
"path": p.path,
|
|
277
|
+
"add": p.add,
|
|
278
|
+
"confidence": p.confidence,
|
|
279
|
+
"reason": p.reason,
|
|
280
|
+
}
|
|
281
|
+
for p in proposals
|
|
282
|
+
],
|
|
283
|
+
"dry_run": dry_run,
|
|
284
|
+
},
|
|
285
|
+
)
|
|
286
|
+
if not data:
|
|
287
|
+
return []
|
|
288
|
+
return [ApplyResult(**r) for r in data.get("results", [])]
|
|
289
|
+
|
|
290
|
+
def garden(self, **kwargs):
|
|
291
|
+
data = self.client.post("/garden", kwargs)
|
|
292
|
+
return _hydrate_garden_report(data) if data else GardenReport()
|
|
293
|
+
|
|
294
|
+
def classify_broken_links(self):
|
|
295
|
+
data = self.client.get("/classify-broken-links")
|
|
296
|
+
if not data:
|
|
297
|
+
return [], []
|
|
298
|
+
drift = [DriftCandidate(**d) for d in data.get("drift", []) or []]
|
|
299
|
+
concepts = [ConceptCandidate(**c) for c in data.get("concepts", []) or []]
|
|
300
|
+
return drift, concepts
|
|
301
|
+
|
|
302
|
+
def create_note(self, path, body, *, frontmatter=None, overwrite=False, author=None):
|
|
303
|
+
data = self.client.post(
|
|
304
|
+
"/note/create",
|
|
305
|
+
{
|
|
306
|
+
"path": path,
|
|
307
|
+
"body": body,
|
|
308
|
+
"frontmatter": frontmatter,
|
|
309
|
+
"overwrite": overwrite,
|
|
310
|
+
"author": author,
|
|
311
|
+
},
|
|
312
|
+
)
|
|
313
|
+
return WriteResult(**data) if data else WriteResult(path=path, ok=False, error="no response")
|
|
314
|
+
|
|
315
|
+
def append_to_note(self, path, content, *, under_heading=None):
|
|
316
|
+
data = self.client.post(
|
|
317
|
+
"/note/append",
|
|
318
|
+
{"path": path, "content": content, "under_heading": under_heading},
|
|
319
|
+
)
|
|
320
|
+
return WriteResult(**data) if data else WriteResult(path=path, ok=False, error="no response")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def make_backend(
|
|
324
|
+
*, vault: Path | None = None, service_url: str | None = None
|
|
325
|
+
) -> Backend:
|
|
326
|
+
"""Pick a backend. Explicit args win; else $ENO_SERVICE_URL; else LocalBackend."""
|
|
327
|
+
url = service_url or os.environ.get("ENO_SERVICE_URL")
|
|
328
|
+
if url:
|
|
329
|
+
return ServiceBackend(url)
|
|
330
|
+
return LocalBackend(vault or vault_dir())
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _hydrate_note_view(data: dict) -> NoteView:
|
|
334
|
+
return NoteView(
|
|
335
|
+
path=data["path"],
|
|
336
|
+
title=data["title"],
|
|
337
|
+
word_count=data.get("word_count", 0),
|
|
338
|
+
frontmatter=data.get("frontmatter", {}) or {},
|
|
339
|
+
headings=[HeadingView(**h) for h in data.get("headings", []) or []],
|
|
340
|
+
excerpt=data.get("excerpt"),
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _hydrate_neighborhood(data: dict) -> Neighborhood:
|
|
345
|
+
return Neighborhood(
|
|
346
|
+
path=data["path"],
|
|
347
|
+
title=data["title"],
|
|
348
|
+
backlinks=[NoteRef(**r) for r in data.get("backlinks", []) or []],
|
|
349
|
+
outbound=[NoteRef(**r) for r in data.get("outbound", []) or []],
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _hydrate_tiling_report(data: dict) -> TilingReport:
|
|
354
|
+
return TilingReport(
|
|
355
|
+
error_pairs=[DuplicatePair(**p) for p in data.get("error_pairs", []) or []],
|
|
356
|
+
review_pairs=[DuplicatePair(**p) for p in data.get("review_pairs", []) or []],
|
|
357
|
+
pages_scanned=data.get("pages_scanned", 0),
|
|
358
|
+
pages_embedded=data.get("pages_embedded", 0),
|
|
359
|
+
cache_hits=data.get("cache_hits", 0),
|
|
360
|
+
skipped=data.get("skipped", {}) or {},
|
|
361
|
+
model=data.get("model", ""),
|
|
362
|
+
error_threshold=data.get("error_threshold", 0.90),
|
|
363
|
+
review_threshold=data.get("review_threshold", 0.80),
|
|
364
|
+
error=data.get("error"),
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _hydrate_hot_cache(data: dict) -> HotCache:
|
|
369
|
+
return HotCache(
|
|
370
|
+
generated_at=data.get("generated_at", ""),
|
|
371
|
+
frontier=[FrontierNote(**f) for f in data.get("frontier", []) or []],
|
|
372
|
+
recent_appends=[NoteRef(**r) for r in data.get("recent_appends", []) or []],
|
|
373
|
+
top_concepts=[ConceptCandidate(**c) for c in data.get("top_concepts", []) or []],
|
|
374
|
+
agent_recent=[NoteRef(**r) for r in data.get("agent_recent", []) or []],
|
|
375
|
+
agent_name=data.get("agent_name", ""),
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _hydrate_hygiene(data: dict) -> HygieneReport:
|
|
380
|
+
return HygieneReport(
|
|
381
|
+
issues=[HygieneIssue(**i) for i in data.get("issues", []) or []],
|
|
382
|
+
counts=data.get("counts", {}) or {},
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _hydrate_proposal_report(data: dict) -> ProposalReport:
|
|
387
|
+
return ProposalReport(
|
|
388
|
+
proposals=[Proposal(**p) for p in data.get("proposals", []) or []],
|
|
389
|
+
total_notes=data.get("total_notes", 0),
|
|
390
|
+
eligible=data.get("eligible", 0),
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _hydrate_garden_report(data: dict) -> GardenReport:
|
|
395
|
+
return GardenReport(
|
|
396
|
+
generated_at=data.get("generated_at", ""),
|
|
397
|
+
resurfacing=[NoteRef(**r) for r in data.get("resurfacing", []) or []],
|
|
398
|
+
concepts=[ConceptCandidate(**c) for c in data.get("concepts", []) or []],
|
|
399
|
+
drift=[DriftCandidate(**d) for d in data.get("drift", []) or []],
|
|
400
|
+
stubs=[NoteRef(**s) for s in data.get("stubs", []) or []],
|
|
401
|
+
stale=[NoteRef(**s) for s in data.get("stale", []) or []],
|
|
402
|
+
duplicates=[DuplicatePair(**p) for p in data.get("duplicates", []) or []],
|
|
403
|
+
stats=data.get("stats", {}) or {},
|
|
404
|
+
)
|