abstractnote 0.1.0__py3-none-any.whl
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.
- abstractnote/__init__.py +18 -0
- abstractnote/cli.py +240 -0
- abstractnote/flows/__init__.py +2 -0
- abstractnote/flows/build.py +970 -0
- abstractnote/flows/bundle.py +62 -0
- abstractnote/flows/contract.py +88 -0
- abstractnote/flows/graph.py +540 -0
- abstractnote/flows/json/note-capture.json +2280 -0
- abstractnote/flows/json/note-drain.json +2012 -0
- abstractnote/flows/json/note-mine.json +2392 -0
- abstractnote/flows/json/note-process.json +1853 -0
- abstractnote/flows/json/note-recall.json +1876 -0
- abstractnote/flows/json/note-reflect.json +2057 -0
- abstractnote/flows/prompts_fallback.py +119 -0
- abstractnote/flows/runner.py +320 -0
- abstractnote/flows/schemas_fallback.py +175 -0
- abstractnote/gateway/__init__.py +24 -0
- abstractnote/gateway/appspec.py +10 -0
- abstractnote/gateway/router.py +571 -0
- abstractnote/gateway/tools.py +74 -0
- abstractnote/ids.py +177 -0
- abstractnote/importers/__init__.py +142 -0
- abstractnote/importers/__main__.py +3 -0
- abstractnote/importers/agora.py +203 -0
- abstractnote/importers/claude_sessions.py +415 -0
- abstractnote/importers/cli.py +113 -0
- abstractnote/importers/files.py +72 -0
- abstractnote/importers/markdown.py +165 -0
- abstractnote/ingest/__init__.py +7 -0
- abstractnote/ingest/blobs.py +47 -0
- abstractnote/ingest/capture.py +265 -0
- abstractnote/ingest/extract.py +118 -0
- abstractnote/ingest/normalise.py +95 -0
- abstractnote/model.py +229 -0
- abstractnote/process/__init__.py +5 -0
- abstractnote/process/context.py +218 -0
- abstractnote/process/merge.py +60 -0
- abstractnote/process/prompts/__init__.py +39 -0
- abstractnote/process/prompts/extract.md +30 -0
- abstractnote/process/prompts/link.md +32 -0
- abstractnote/process/prompts/mine_dossier.md +13 -0
- abstractnote/process/prompts/mine_findings.md +13 -0
- abstractnote/process/prompts/mine_plan.md +13 -0
- abstractnote/process/prompts/quick_filing.md +23 -0
- abstractnote/process/prompts/reflect.md +10 -0
- abstractnote/process/prompts/synthesize.md +17 -0
- abstractnote/process/resolve.py +96 -0
- abstractnote/process/revise.py +410 -0
- abstractnote/process/schemas.py +377 -0
- abstractnote/process/vocabulary.py +68 -0
- abstractnote/recall/__init__.py +1 -0
- abstractnote/recall/changes.py +191 -0
- abstractnote/recall/expand.py +379 -0
- abstractnote/recall/insights.py +62 -0
- abstractnote/recall/reflect.py +202 -0
- abstractnote/recall/review.py +191 -0
- abstractnote/recall/search.py +205 -0
- abstractnote/recall/shapes.py +52 -0
- abstractnote/recall/then_now.py +100 -0
- abstractnote/store/__init__.py +12 -0
- abstractnote/store/archive.py +166 -0
- abstractnote/store/embeddings.py +275 -0
- abstractnote/store/kg.py +219 -0
- abstractnote/store/notebook.py +829 -0
- abstractnote/store/notes.py +1190 -0
- abstractnote/store/queue.py +159 -0
- abstractnote/store/schema.py +348 -0
- abstractnote/store/vector_index.py +88 -0
- abstractnote/tools/__init__.py +215 -0
- abstractnote/tools/capture.py +121 -0
- abstractnote/tools/context.py +19 -0
- abstractnote/tools/process.py +22 -0
- abstractnote/tools/queue.py +23 -0
- abstractnote/tools/recall.py +64 -0
- abstractnote-0.1.0.dist-info/METADATA +145 -0
- abstractnote-0.1.0.dist-info/RECORD +80 -0
- abstractnote-0.1.0.dist-info/WHEEL +5 -0
- abstractnote-0.1.0.dist-info/entry_points.txt +2 -0
- abstractnote-0.1.0.dist-info/licenses/LICENSE +21 -0
- abstractnote-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
"""The note read API (docs/api.md "Note read API"), mounted by the gateway at ``/api/gateway/note``.
|
|
2
|
+
|
|
3
|
+
Every request resolves the caller's own plane through the gateway (``get_gateway_service()`` ->
|
|
4
|
+
``svc.config.data_dir / "abstractnote"``): clients never name a user, tenant or runtime, and an
|
|
5
|
+
id from another notebook simply is not there (404). Every read delegates to the core lane's
|
|
6
|
+
``Notebook`` so "as of" and history have one implementation (review 01 S4). The write actions
|
|
7
|
+
here are bookkeeping (user-authored revisions, acknowledgements, queue retries, settings,
|
|
8
|
+
automation set-up), never knowledge; knowledge changes go through the flows.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import threading
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
from fastapi import APIRouter, HTTPException, Query, Request
|
|
19
|
+
from fastapi.responses import Response
|
|
20
|
+
from pydantic import BaseModel, ConfigDict
|
|
21
|
+
|
|
22
|
+
from ..flows.build import BUNDLE_ID, DRAIN_DEFAULTS, NOTE_TOOLS
|
|
23
|
+
from .tools import notebook_dir
|
|
24
|
+
|
|
25
|
+
router = APIRouter(prefix="/note", tags=["note"])
|
|
26
|
+
|
|
27
|
+
PROCESSOR_REQUEST_ID = "abstractnote-processor"
|
|
28
|
+
REFLECT_REQUEST_ID = "abstractnote-reflect"
|
|
29
|
+
|
|
30
|
+
_notebooks: Dict[str, Any] = {}
|
|
31
|
+
_notebooks_lock = threading.Lock()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# --------------------------------------------------------------------------- plumbing
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _principal(request: Request) -> Any:
|
|
38
|
+
from abstractgateway.routes.gateway import _principal_from_request
|
|
39
|
+
|
|
40
|
+
return _principal_from_request(request)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _service() -> Any:
|
|
44
|
+
from abstractgateway.service import get_gateway_service
|
|
45
|
+
|
|
46
|
+
return get_gateway_service()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def plane_embedder(data_dir: Path) -> Any:
|
|
50
|
+
"""The plane's embedding route (``None`` when none is configured: the notebook keeps its pin)."""
|
|
51
|
+
from abstractgateway.memory_store import build_gateway_memory_embedder
|
|
52
|
+
|
|
53
|
+
return build_gateway_memory_embedder(base_dir=Path(data_dir))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def notebook_for(data_dir: Path) -> Any:
|
|
57
|
+
"""The plane's Notebook, opened once per process (W1's ``Notebook.open``)."""
|
|
58
|
+
from abstractnote.store.notebook import Notebook # core lane
|
|
59
|
+
|
|
60
|
+
path = str(notebook_dir(Path(data_dir)).resolve())
|
|
61
|
+
with _notebooks_lock:
|
|
62
|
+
nb = _notebooks.get(path)
|
|
63
|
+
if nb is None:
|
|
64
|
+
nb = Notebook.open(path, embedder=plane_embedder(Path(data_dir)))
|
|
65
|
+
_notebooks[path] = nb
|
|
66
|
+
return nb
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def close_notebooks() -> None:
|
|
70
|
+
"""Close every cached notebook (tests, shutdown)."""
|
|
71
|
+
with _notebooks_lock:
|
|
72
|
+
for nb in _notebooks.values():
|
|
73
|
+
try:
|
|
74
|
+
nb.close()
|
|
75
|
+
except Exception: # noqa: BLE001 - closing is best effort
|
|
76
|
+
pass
|
|
77
|
+
_notebooks.clear()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _missing(what: str) -> HTTPException:
|
|
81
|
+
return HTTPException(status_code=404, detail=f"That {what} is not in your notebook.")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _exists(nb: Any, kind: str, ident: str) -> bool:
|
|
85
|
+
"""Is this id in the caller's notebook? (an existence check, so an absent id is 404 and only
|
|
86
|
+
genuinely bad input is 400; the core lane signals both with ValueError)."""
|
|
87
|
+
store = nb.store
|
|
88
|
+
if kind == "note":
|
|
89
|
+
return store.note_row(ident) is not None
|
|
90
|
+
if kind == "entity":
|
|
91
|
+
return store.entity_row(ident) is not None
|
|
92
|
+
if kind == "claim":
|
|
93
|
+
return store.claim_row(ident) is not None
|
|
94
|
+
if kind == "task":
|
|
95
|
+
return store.q1("SELECT 1 FROM tasks WHERE task_id=?", (ident,)) is not None
|
|
96
|
+
raise ValueError(kind)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _require(nb: Any, kind: str, ident: str, what: str) -> None:
|
|
100
|
+
if not _exists(nb, kind, ident):
|
|
101
|
+
raise _missing(what)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _call(nb: Any, name: str, *args: Any, **kwargs: Any) -> Any:
|
|
105
|
+
fn = getattr(nb, name, None)
|
|
106
|
+
if not callable(fn): # a core-lane method this version does not have yet: loud, never a fake answer
|
|
107
|
+
raise HTTPException(status_code=501, detail=f"This notebook cannot answer {name} yet.")
|
|
108
|
+
try:
|
|
109
|
+
return fn(*args, **kwargs)
|
|
110
|
+
except ValueError as e:
|
|
111
|
+
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _clean(**kwargs: Any) -> Dict[str, Any]:
|
|
115
|
+
return {k: v for k, v in kwargs.items() if v is not None}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _csv(value: Optional[str]) -> Optional[List[str]]:
|
|
119
|
+
if value is None:
|
|
120
|
+
return None
|
|
121
|
+
items = [v.strip() for v in str(value).split(",") if v.strip()]
|
|
122
|
+
return items or None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def _run(request: Request, work: Callable[[Any, Any, Any], Any]) -> Any:
|
|
126
|
+
"""Resolve principal + plane on the request, run ``work(nb, svc, principal)`` off the loop."""
|
|
127
|
+
principal = _principal(request)
|
|
128
|
+
svc = _service()
|
|
129
|
+
data_dir = Path(svc.config.data_dir)
|
|
130
|
+
|
|
131
|
+
def _job() -> Any:
|
|
132
|
+
return work(notebook_for(data_dir), svc, principal)
|
|
133
|
+
|
|
134
|
+
return await asyncio.to_thread(_job)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _account(principal: Any) -> str:
|
|
138
|
+
return str(getattr(principal, "user_id", "") or "")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# The app-server session proxy marks every request it relays with the app's id (it drops any value
|
|
142
|
+
# the browser sent): `X-AbstractFramework-App-Proxy: abstractnote` is the Note web app.
|
|
143
|
+
APP_PROXY_HEADER = "X-AbstractFramework-App-Proxy"
|
|
144
|
+
APP_CLIENTS = {"abstractnote": "web"}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def action_client(request: Request) -> str:
|
|
148
|
+
"""``source.client`` of a user action: ``web`` through the Note web app's proxy, else ``api``.
|
|
149
|
+
Informational only; the signed-in account (the principal) is what is authoritative."""
|
|
150
|
+
app = str(request.headers.get(APP_PROXY_HEADER) or "").strip().lower()
|
|
151
|
+
return APP_CLIENTS.get(app, "api")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _items(value: Any) -> Dict[str, Any]:
|
|
155
|
+
"""Every list answers ``{items, next_cursor}`` (api.md); ``next_cursor`` is null when the list
|
|
156
|
+
does not page."""
|
|
157
|
+
if isinstance(value, dict) and "items" in value:
|
|
158
|
+
return {**value, "next_cursor": value.get("next_cursor")}
|
|
159
|
+
return {"items": list(value or []), "next_cursor": None}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# --------------------------------------------------------------------------- status and changes
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _automation_ids(nb: Any) -> Dict[str, Optional[str]]:
|
|
166
|
+
return {"processor_id": nb.meta("processor_automation_id"), "reflect_id": nb.meta("reflect_automation_id")}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@router.get("/status")
|
|
170
|
+
async def note_status(request: Request) -> Dict[str, Any]:
|
|
171
|
+
def work(nb, svc, principal):
|
|
172
|
+
out = dict(_call(nb, "status"))
|
|
173
|
+
out["automations"] = _automation_ids(nb)
|
|
174
|
+
out.pop("notebook", None) # a server path: not for clients
|
|
175
|
+
return out
|
|
176
|
+
|
|
177
|
+
return await _run(request, work)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@router.get("/changes")
|
|
181
|
+
async def note_changes(request: Request, since: Optional[str] = None, limit: int = Query(100, ge=1, le=1000),
|
|
182
|
+
as_received: Optional[str] = None) -> Dict[str, Any]:
|
|
183
|
+
return await _run(request, lambda nb, svc, p: _call(nb, "changes", since=since, limit=limit,
|
|
184
|
+
**_clean(as_received=as_received or None)))
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@router.get("/overview")
|
|
188
|
+
async def note_overview(request: Request, days: int = Query(30, ge=1, le=3660)) -> Dict[str, Any]:
|
|
189
|
+
return await _run(request, lambda nb, svc, p: _call(nb, "overview", days=days))
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# --------------------------------------------------------------------------- notes
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@router.get("/notes")
|
|
196
|
+
async def note_list(
|
|
197
|
+
request: Request, q: Optional[str] = None, since: Optional[str] = None, until: Optional[str] = None,
|
|
198
|
+
kind: Optional[str] = None, topic: Optional[str] = None, entity: Optional[str] = None,
|
|
199
|
+
status: Optional[str] = None, cursor: Optional[str] = None, limit: int = Query(50, ge=1, le=500),
|
|
200
|
+
) -> Dict[str, Any]:
|
|
201
|
+
return await _run(request, lambda nb, svc, p: _items(_call(nb, "notes", **_clean(
|
|
202
|
+
q=q, since=since, until=until, kind=kind, topic=topic, entity=entity, status=status, cursor=cursor, limit=limit))))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@router.get("/notes/{note_id}")
|
|
206
|
+
async def note_get(request: Request, note_id: str) -> Dict[str, Any]:
|
|
207
|
+
def work(nb, svc, p):
|
|
208
|
+
note = _call(nb, "note", note_id)
|
|
209
|
+
if note is None:
|
|
210
|
+
raise _missing("note")
|
|
211
|
+
return note
|
|
212
|
+
|
|
213
|
+
return await _run(request, work)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@router.get("/notes/{note_id}/attachments/{attachment_id}")
|
|
217
|
+
async def note_attachment(request: Request, note_id: str, attachment_id: str) -> Response:
|
|
218
|
+
def work(nb, svc, p):
|
|
219
|
+
_require(nb, "note", note_id, "note")
|
|
220
|
+
try:
|
|
221
|
+
blob = nb.attachment_bytes(note_id, attachment_id)
|
|
222
|
+
except ValueError:
|
|
223
|
+
raise _missing("attachment")
|
|
224
|
+
return blob
|
|
225
|
+
|
|
226
|
+
blob = await _run(request, work)
|
|
227
|
+
filename = str(blob.get("filename") or attachment_id).replace('"', "")
|
|
228
|
+
return Response(
|
|
229
|
+
content=blob["data"], media_type=str(blob.get("mime") or "application/octet-stream"),
|
|
230
|
+
headers={"Content-Disposition": f'inline; filename="{filename}"', "Cache-Control": "private, max-age=3600"},
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@router.post("/notes/{note_id}/archive")
|
|
235
|
+
async def note_archive(request: Request, note_id: str) -> Dict[str, Any]:
|
|
236
|
+
def work(nb, svc, p):
|
|
237
|
+
_require(nb, "note", note_id, "note")
|
|
238
|
+
return _call(nb, "archive_note", note_id)
|
|
239
|
+
|
|
240
|
+
return await _run(request, work)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# --------------------------------------------------------------------------- search and entities
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@router.get("/search")
|
|
247
|
+
async def note_search(
|
|
248
|
+
request: Request, q: str = Query(..., min_length=1), since: Optional[str] = None, until: Optional[str] = None,
|
|
249
|
+
kinds: Optional[str] = None, entities: Optional[str] = None, topics: Optional[str] = None,
|
|
250
|
+
limit: int = Query(20, ge=1, le=200),
|
|
251
|
+
) -> Dict[str, Any]:
|
|
252
|
+
return await _run(request, lambda nb, svc, p: _call(nb, "search", q, **_clean(
|
|
253
|
+
since=since, until=until, kinds=_csv(kinds), entities=_csv(entities), topics=_csv(topics), limit=limit)))
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@router.get("/entities")
|
|
257
|
+
async def note_entities(
|
|
258
|
+
request: Request, q: Optional[str] = None, kind: Optional[str] = None, sort: Optional[str] = None,
|
|
259
|
+
cursor: Optional[str] = None, limit: int = Query(50, ge=1, le=500), include_merged: bool = False,
|
|
260
|
+
) -> Dict[str, Any]:
|
|
261
|
+
return await _run(request, lambda nb, svc, p: _items(_call(nb, "entities", **_clean(
|
|
262
|
+
q=q, kind=kind, sort=sort, cursor=cursor, limit=limit, include_merged=include_merged or None))))
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@router.get("/entities/{entity_id}")
|
|
266
|
+
async def note_entity(request: Request, entity_id: str, at: Optional[str] = None,
|
|
267
|
+
as_received: Optional[str] = None, as_merged: bool = False) -> Dict[str, Any]:
|
|
268
|
+
def work(nb, svc, p):
|
|
269
|
+
_require(nb, "entity", entity_id, "entity")
|
|
270
|
+
if as_merged:
|
|
271
|
+
if at or as_received:
|
|
272
|
+
raise HTTPException(status_code=400, detail="An entry's own record has no date; leave out at.")
|
|
273
|
+
return _call(nb, "entity_record", entity_id)
|
|
274
|
+
return _call(nb, "entity", entity_id, **_clean(at=at or None, as_received=as_received or None))
|
|
275
|
+
|
|
276
|
+
return await _run(request, work)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _entity_or_404(nb: Any, entity_id: str) -> None:
|
|
280
|
+
_require(nb, "entity", entity_id, "entity")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@router.get("/entities/{entity_id}/timeline")
|
|
284
|
+
async def note_entity_timeline(request: Request, entity_id: str, since: Optional[str] = None,
|
|
285
|
+
until: Optional[str] = None) -> Dict[str, Any]:
|
|
286
|
+
def work(nb, svc, p):
|
|
287
|
+
_entity_or_404(nb, entity_id)
|
|
288
|
+
return _call(nb, "entity_timeline", entity_id, **_clean(since=since, until=until))
|
|
289
|
+
|
|
290
|
+
return await _run(request, work)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@router.get("/entities/{entity_id}/then-now")
|
|
294
|
+
async def note_entity_then_now(request: Request, entity_id: str) -> Dict[str, Any]:
|
|
295
|
+
def work(nb, svc, p):
|
|
296
|
+
_entity_or_404(nb, entity_id)
|
|
297
|
+
return _call(nb, "then_now", entity_id)
|
|
298
|
+
|
|
299
|
+
return await _run(request, work)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@router.get("/relations/{a}/{b}")
|
|
303
|
+
async def note_relation(request: Request, a: str, b: str, since: Optional[str] = None,
|
|
304
|
+
until: Optional[str] = None) -> Dict[str, Any]:
|
|
305
|
+
def work(nb, svc, p):
|
|
306
|
+
_entity_or_404(nb, a)
|
|
307
|
+
_entity_or_404(nb, b)
|
|
308
|
+
return _call(nb, "relation", a, b, **_clean(since=since, until=until))
|
|
309
|
+
|
|
310
|
+
return await _run(request, work)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class UnmergeBody(BaseModel):
|
|
314
|
+
model_config = ConfigDict(extra="forbid")
|
|
315
|
+
|
|
316
|
+
from_id: str
|
|
317
|
+
reason: str = ""
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@router.post("/entities/{entity_id}/unmerge")
|
|
321
|
+
async def note_unmerge(request: Request, entity_id: str, body: UnmergeBody) -> Dict[str, Any]:
|
|
322
|
+
def work(nb, svc, p):
|
|
323
|
+
_entity_or_404(nb, entity_id)
|
|
324
|
+
_entity_or_404(nb, body.from_id)
|
|
325
|
+
return _call(nb, "unmerge_entity", entity_id, body.from_id, _account(p), body.reason,
|
|
326
|
+
client=action_client(request))
|
|
327
|
+
|
|
328
|
+
return await _run(request, work)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# --------------------------------------------------------------------------- review
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@router.get("/review")
|
|
335
|
+
async def note_review(request: Request, limit: int = Query(50, ge=1, le=500)) -> Dict[str, Any]:
|
|
336
|
+
return await _run(request, lambda nb, svc, p: _items(_call(nb, "review", limit=limit)))
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class ConfirmBody(BaseModel):
|
|
340
|
+
model_config = ConfigDict(extra="forbid")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@router.post("/review/{review_id}/confirm")
|
|
344
|
+
async def note_review_confirm(request: Request, review_id: str, body: Optional[ConfirmBody] = None) -> Dict[str, Any]:
|
|
345
|
+
def work(nb, svc, p):
|
|
346
|
+
if _call(nb, "review_item", review_id) is None:
|
|
347
|
+
raise HTTPException(status_code=404, detail="That match is not waiting for review.")
|
|
348
|
+
return _call(nb, "confirm_match", review_id, _account(p), client=action_client(request))
|
|
349
|
+
|
|
350
|
+
return await _run(request, work)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# --------------------------------------------------------------------------- claims, tasks, cues
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@router.get("/claims/{claim_id}")
|
|
357
|
+
async def note_claim(request: Request, claim_id: str) -> Dict[str, Any]:
|
|
358
|
+
def work(nb, svc, p):
|
|
359
|
+
_require(nb, "claim", claim_id, "statement")
|
|
360
|
+
return _call(nb, "claim", claim_id)
|
|
361
|
+
|
|
362
|
+
return await _run(request, work)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
class RetractBody(BaseModel):
|
|
366
|
+
model_config = ConfigDict(extra="forbid")
|
|
367
|
+
|
|
368
|
+
reason: str
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@router.post("/claims/{claim_id}/retract")
|
|
372
|
+
async def note_claim_retract(request: Request, claim_id: str, body: RetractBody) -> Dict[str, Any]:
|
|
373
|
+
def work(nb, svc, p):
|
|
374
|
+
_require(nb, "claim", claim_id, "statement")
|
|
375
|
+
return _call(nb, "retract_claim", claim_id, _account(p), body.reason, client=action_client(request))
|
|
376
|
+
|
|
377
|
+
return await _run(request, work)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
class CloseTaskBody(BaseModel):
|
|
381
|
+
model_config = ConfigDict(extra="forbid")
|
|
382
|
+
|
|
383
|
+
note: Optional[str] = None
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@router.post("/tasks/{task_id}/close")
|
|
387
|
+
async def note_task_close(request: Request, task_id: str, body: Optional[CloseTaskBody] = None) -> Dict[str, Any]:
|
|
388
|
+
body = body or CloseTaskBody()
|
|
389
|
+
|
|
390
|
+
def work(nb, svc, p):
|
|
391
|
+
_require(nb, "task", task_id, "task")
|
|
392
|
+
return _call(nb, "close_task", task_id, _account(p), body.note, client=action_client(request))
|
|
393
|
+
|
|
394
|
+
return await _run(request, work)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
@router.get("/tasks")
|
|
398
|
+
async def note_tasks(request: Request, status: Optional[str] = "open", entity: Optional[str] = None,
|
|
399
|
+
limit: int = Query(100, ge=1, le=1000)) -> Dict[str, Any]:
|
|
400
|
+
return await _run(request, lambda nb, svc, p: _items(_call(nb, "tasks", status=status, entity=entity, limit=limit)))
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
@router.get("/cues")
|
|
404
|
+
async def note_cues(request: Request, limit: int = Query(3, ge=1, le=50)) -> Dict[str, Any]:
|
|
405
|
+
return await _run(request, lambda nb, svc, p: _items(_call(nb, "cues", limit=limit)))
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
# --------------------------------------------------------------------------- graph and time
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
@router.get("/graph")
|
|
412
|
+
async def note_graph(
|
|
413
|
+
request: Request, focus: Optional[str] = None, at: Optional[str] = None, depth: int = Query(1, ge=1, le=3),
|
|
414
|
+
since: Optional[str] = None, until: Optional[str] = None, kinds: Optional[str] = None,
|
|
415
|
+
min_count: int = Query(1, ge=1), limit: int = Query(200, ge=1, le=2000), as_received: Optional[str] = None,
|
|
416
|
+
) -> Dict[str, Any]:
|
|
417
|
+
return await _run(request, lambda nb, svc, p: _call(nb, "graph", **_clean(
|
|
418
|
+
focus=_csv(focus), at=at, depth=depth, since=since, until=until, kinds=_csv(kinds), min_count=min_count,
|
|
419
|
+
limit=limit, as_received=as_received or None)))
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
@router.get("/timeline")
|
|
423
|
+
async def note_timeline(request: Request, since: Optional[str] = None, until: Optional[str] = None,
|
|
424
|
+
granularity: str = Query("day", pattern="^(day|week|month)$")) -> Dict[str, Any]:
|
|
425
|
+
return await _run(request, lambda nb, svc, p: _call(nb, "timeline", since=since, until=until, granularity=granularity))
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
@router.get("/day/{day}")
|
|
429
|
+
async def note_day(request: Request, day: str, tz: Optional[str] = None,
|
|
430
|
+
as_received: Optional[str] = None) -> Dict[str, Any]:
|
|
431
|
+
return await _run(request, lambda nb, svc, p: _call(nb, "day", day, tz, as_received or None))
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@router.get("/revisions")
|
|
435
|
+
async def note_revisions(request: Request, since: Optional[str] = None, until: Optional[str] = None,
|
|
436
|
+
entity: Optional[str] = None, cursor: Optional[str] = None,
|
|
437
|
+
limit: int = Query(50, ge=1, le=500)) -> Dict[str, Any]:
|
|
438
|
+
return await _run(request, lambda nb, svc, p: _items(_call(nb, "revisions", **_clean(
|
|
439
|
+
since=since, until=until, entity=entity, cursor=cursor, limit=limit))))
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
@router.get("/topics")
|
|
443
|
+
async def note_topics(request: Request) -> Dict[str, Any]:
|
|
444
|
+
return await _run(request, lambda nb, svc, p: _items(_call(nb, "topics")))
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
# --------------------------------------------------------------------------- insights and queue
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
@router.get("/insights")
|
|
451
|
+
async def note_insights(request: Request, kind: Optional[str] = None, acked: Optional[bool] = None,
|
|
452
|
+
limit: int = Query(50, ge=1, le=500)) -> Dict[str, Any]:
|
|
453
|
+
return await _run(request, lambda nb, svc, p: _items(_call(nb, "insights", kind=kind, acked=acked, limit=limit)))
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
@router.post("/insights/{note_id}/ack")
|
|
457
|
+
async def note_insight_ack(request: Request, note_id: str) -> Dict[str, Any]:
|
|
458
|
+
def work(nb, svc, p):
|
|
459
|
+
_require(nb, "note", note_id, "insight")
|
|
460
|
+
return _call(nb, "ack_insight", note_id)
|
|
461
|
+
|
|
462
|
+
return await _run(request, work)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
@router.get("/queue")
|
|
466
|
+
async def note_queue(request: Request, limit: int = Query(50, ge=1, le=500)) -> Dict[str, Any]:
|
|
467
|
+
def work(nb, svc, p):
|
|
468
|
+
out = dict(_call(nb.queue, "summary", limit=limit))
|
|
469
|
+
out["automations"] = _automation_ids(nb)
|
|
470
|
+
out["processor"] = _call(nb, "processor_throughput")
|
|
471
|
+
return out
|
|
472
|
+
|
|
473
|
+
return await _run(request, work)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
@router.post("/queue/{note_id}/retry")
|
|
477
|
+
async def note_queue_retry(request: Request, note_id: str) -> Dict[str, Any]:
|
|
478
|
+
def work(nb, svc, p):
|
|
479
|
+
_require(nb, "note", note_id, "note")
|
|
480
|
+
return _call(nb.queue, "retry", note_id)
|
|
481
|
+
|
|
482
|
+
return await _run(request, work)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
# --------------------------------------------------------------------------- settings and automations
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
@router.get("/settings")
|
|
489
|
+
async def note_settings(request: Request) -> Dict[str, Any]:
|
|
490
|
+
return await _run(request, lambda nb, svc, p: _call(nb, "settings"))
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
@router.put("/settings")
|
|
494
|
+
async def note_settings_put(request: Request, changes: Dict[str, Any]) -> Dict[str, Any]:
|
|
495
|
+
return await _run(request, lambda nb, svc, p: _call(nb, "update_settings", changes))
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _model_inputs(model_setting: Any) -> Dict[str, Any]:
|
|
499
|
+
m = model_setting if isinstance(model_setting, dict) else {}
|
|
500
|
+
return {k: str(m[k]) for k in ("provider", "model") if isinstance(m.get(k), str) and m[k].strip()}
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _existing_automation(svc: Any, automation_id: Optional[str]) -> Optional[str]:
|
|
504
|
+
if not automation_id:
|
|
505
|
+
return None
|
|
506
|
+
from abstractgateway.automation_errors import AutomationError
|
|
507
|
+
from abstractgateway.routes.automations import load_automation_controller
|
|
508
|
+
|
|
509
|
+
try:
|
|
510
|
+
load_automation_controller(svc, automation_id)
|
|
511
|
+
except AutomationError as e:
|
|
512
|
+
if e.status_code == 404:
|
|
513
|
+
return None
|
|
514
|
+
raise
|
|
515
|
+
return automation_id
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _after_one_interval(every: str) -> Dict[str, str]:
|
|
519
|
+
"""``{start_at}`` one interval from now: the schedule's first occurrence is its next scheduled
|
|
520
|
+
time, not the moment it is created (a fixed-interval schedule anchors its grid on ``start_at``)."""
|
|
521
|
+
from datetime import datetime, timezone
|
|
522
|
+
|
|
523
|
+
from abstractruntime.triggers.protocol import format_timestamp, parse_duration
|
|
524
|
+
|
|
525
|
+
return {"start_at": format_timestamp(datetime.now(timezone.utc) + parse_duration(every, field="every"))}
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def ensure_automations(nb: Any, svc: Any, principal: Any) -> Dict[str, Any]:
|
|
529
|
+
"""Create the processor and reflect automations of this notebook when missing (idempotent).
|
|
530
|
+
|
|
531
|
+
The processor's first drain runs at once (an empty queue completes without a model call); the
|
|
532
|
+
reflect automation's first occurrence is one interval after creation, so opening an empty
|
|
533
|
+
notebook never starts an answering-model call ("Reflect now" = ``automation.run_now``)."""
|
|
534
|
+
from abstractgateway.routes.automations import CreateAutomationBody, _create
|
|
535
|
+
|
|
536
|
+
settings = nb.settings()
|
|
537
|
+
reflect_every = str((settings.get("reflect_schedule") or {}).get("every") or "7d")
|
|
538
|
+
processor = dict(settings["processor_schedule"])
|
|
539
|
+
out: Dict[str, Any] = {}
|
|
540
|
+
created: List[str] = []
|
|
541
|
+
plans = (
|
|
542
|
+
("processor_automation_id", PROCESSOR_REQUEST_ID, "Process my notes", "note-drain",
|
|
543
|
+
{**DRAIN_DEFAULTS, "max_items": int(processor["max_items"]), **_model_inputs(settings.get("filing_model"))},
|
|
544
|
+
{"kind": "every", "every": str(processor["every"])}),
|
|
545
|
+
("reflect_automation_id", REFLECT_REQUEST_ID, "Reflect on my notes", "note-reflect",
|
|
546
|
+
{"limit": 10, **_model_inputs(settings.get("answering_model"))},
|
|
547
|
+
{"kind": "every", "every": reflect_every, **_after_one_interval(reflect_every)}),
|
|
548
|
+
)
|
|
549
|
+
for meta_key, request_id, title, flow_id, inputs, schedule in plans:
|
|
550
|
+
automation_id = _existing_automation(svc, nb.meta(meta_key))
|
|
551
|
+
if automation_id is None:
|
|
552
|
+
body = CreateAutomationBody(
|
|
553
|
+
request_id=request_id, title=title,
|
|
554
|
+
target={"bundle_ref": BUNDLE_ID, "flow_id": flow_id,
|
|
555
|
+
"input_data": {**inputs, "_runtime": {"allowed_tools": list(NOTE_TOOLS)}}},
|
|
556
|
+
trigger={"source_id": "schedule", "source_version": 2, "config": dict(schedule)},
|
|
557
|
+
context={"mode": "independent"},
|
|
558
|
+
)
|
|
559
|
+
automation_id = str(_create(svc, principal, body)["automation_id"])
|
|
560
|
+
nb.set_meta(meta_key, automation_id)
|
|
561
|
+
if flow_id == "note-drain": # what GET /note/queue reports as the processor's throughput
|
|
562
|
+
nb.set_meta("processor_schedule_applied", processor)
|
|
563
|
+
created.append(flow_id)
|
|
564
|
+
out[meta_key.replace("_automation_id", "_id")] = automation_id
|
|
565
|
+
out["created"] = created
|
|
566
|
+
return out
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
@router.post("/processor/ensure")
|
|
570
|
+
async def note_processor_ensure(request: Request) -> Dict[str, Any]:
|
|
571
|
+
return await _run(request, ensure_automations)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""The note tools bound to one gateway plane (review 01 B3, M1, M10).
|
|
2
|
+
|
|
3
|
+
``bound_tool_map`` is what the gateway's bundle host merges into its per-plane tool map next to
|
|
4
|
+
``read_skill``. Inside the gateway the tools never see a filesystem path: attachments are only
|
|
5
|
+
``{"$artifact": id}`` references, resolved through the HOST's artifact store (the one the
|
|
6
|
+
runtime already uses for uploads), and ``source.account`` is stamped with the plane's account.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Callable, Dict, Mapping, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
from ..flows.build import NOTE_TOOLS
|
|
15
|
+
|
|
16
|
+
#: The tool names the gateway adds to its approval auto-approve set (they read and write the
|
|
17
|
+
#: caller's own notebook only; a capture must never park on an approval wait).
|
|
18
|
+
AUTO_APPROVE_TOOL_NAMES: Tuple[str, ...] = tuple(NOTE_TOOLS)
|
|
19
|
+
|
|
20
|
+
NOTEBOOK_DIRNAME = "abstractnote"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def notebook_dir(data_root: Path) -> Path:
|
|
24
|
+
"""The notebook of a plane: ``<plane data dir>/abstractnote``."""
|
|
25
|
+
return Path(data_root) / NOTEBOOK_DIRNAME
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ArtifactReader:
|
|
29
|
+
"""``reader(ref) -> (bytes, mime, filename)`` over the host's artifact store.
|
|
30
|
+
|
|
31
|
+
Accepts only ``{"$artifact": id, ...}`` (the upload API's reference). Anything else, notably
|
|
32
|
+
``{"path": ...}``, is refused: inside the gateway a tool runs as the gateway's own user, so a
|
|
33
|
+
path would let any signed-in user read the machine.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, artifact_store: Any) -> None:
|
|
37
|
+
if artifact_store is None or not callable(getattr(artifact_store, "load", None)):
|
|
38
|
+
raise ValueError("The note tools need the gateway's artifact store.")
|
|
39
|
+
self._store = artifact_store
|
|
40
|
+
|
|
41
|
+
def __call__(self, ref: Any) -> Tuple[bytes, str, Optional[str]]:
|
|
42
|
+
if not isinstance(ref, Mapping):
|
|
43
|
+
raise ValueError("An attachment must be an uploaded file reference.")
|
|
44
|
+
if "path" in ref:
|
|
45
|
+
raise ValueError("Files must be uploaded first; paths are not accepted here.")
|
|
46
|
+
artifact_id = str(ref.get("$artifact") or ref.get("artifact_id") or "").strip()
|
|
47
|
+
if not artifact_id:
|
|
48
|
+
raise ValueError("An attachment must be an uploaded file reference.")
|
|
49
|
+
artifact = self._store.load(artifact_id)
|
|
50
|
+
if artifact is None:
|
|
51
|
+
raise ValueError("That attachment was not found; upload it again.")
|
|
52
|
+
meta = artifact.metadata
|
|
53
|
+
tags = dict(getattr(meta, "tags", None) or {})
|
|
54
|
+
filename = tags.get("filename") or ref.get("filename") or None
|
|
55
|
+
mime = str(getattr(meta, "content_type", "") or ref.get("content_type") or "application/octet-stream")
|
|
56
|
+
return bytes(artifact.content), mime, filename
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def bound_tool_map(data_root: Path, embedder: Any, artifact_store: Any, account: Optional[str]) -> Dict[str, Callable[..., Any]]:
|
|
60
|
+
"""The note tools bound to ``<data_root>/abstractnote`` for one plane.
|
|
61
|
+
|
|
62
|
+
``data_root`` is the plane's data dir (the host's ``data_root``), ``embedder`` the plane's
|
|
63
|
+
embedding route (``None`` keeps the notebook's own pin), ``artifact_store`` the host's
|
|
64
|
+
artifact store and ``account`` the plane's user id. Local paths are never allowed here.
|
|
65
|
+
"""
|
|
66
|
+
from abstractnote.tools import bind # core lane: an ImportError here is a real gap, surfaced
|
|
67
|
+
|
|
68
|
+
tools = dict(bind(
|
|
69
|
+
notebook_dir(data_root), embedder=embedder, artifact_reader=ArtifactReader(artifact_store), account=account,
|
|
70
|
+
))
|
|
71
|
+
missing = sorted(set(AUTO_APPROVE_TOOL_NAMES) - set(tools))
|
|
72
|
+
if missing:
|
|
73
|
+
raise RuntimeError("abstractnote.tools.bind does not provide " + ", ".join(missing))
|
|
74
|
+
return {name: tools[name] for name in AUTO_APPROVE_TOOL_NAMES}
|