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,970 @@
|
|
|
1
|
+
"""Generate the AbstractNote VisualFlow documents (docs/cognition.md) into ``flows/json/``.
|
|
2
|
+
|
|
3
|
+
python -m abstractnote.flows.build # write src/abstractnote/flows/json/*.json
|
|
4
|
+
python -m abstractnote.flows.build --check # fail when the committed JSON is stale
|
|
5
|
+
|
|
6
|
+
Judgement happens in ``llm_call`` nodes with structured output (``resp_schema``); deterministic
|
|
7
|
+
work happens in note tools called from ``tool_calls`` nodes (ADR 0002). Every model node takes
|
|
8
|
+
``provider`` and ``model`` from the start pins (blank = the gateway's default text model).
|
|
9
|
+
Tool arguments are built by a code node whose input pins ARE the tool's argument names, so the
|
|
10
|
+
contract check (tests/test_flows_contract.py) can compare them with each tool's definition.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Dict, List
|
|
20
|
+
|
|
21
|
+
from .graph import Flow
|
|
22
|
+
from .prompts_fallback import FALLBACK_PROMPTS, FLOW_PROMPT_VARS
|
|
23
|
+
from .schemas_fallback import SCHEMAS as FALLBACK_SCHEMAS
|
|
24
|
+
|
|
25
|
+
JSON_DIR = Path(__file__).resolve().parent / "json"
|
|
26
|
+
|
|
27
|
+
BUNDLE_ID = "abstractnote"
|
|
28
|
+
ENTRYPOINTS = ["note-capture", "note-drain", "note-recall", "note-reflect"]
|
|
29
|
+
INTERNAL_FLOWS = ["note-process", "note-mine"]
|
|
30
|
+
ALL_FLOWS = ENTRYPOINTS + INTERNAL_FLOWS
|
|
31
|
+
|
|
32
|
+
INTERFACES = {
|
|
33
|
+
"note-capture": ["abstractnote.capture.v1"],
|
|
34
|
+
"note-drain": ["abstractnote.drain.v1"],
|
|
35
|
+
"note-recall": ["abstractnote.recall.v1", "abstractcode.agent.v1"],
|
|
36
|
+
"note-reflect": ["abstractnote.reflect.v1"],
|
|
37
|
+
"note-process": [],
|
|
38
|
+
"note-mine": [],
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
#: Tools each flow may call (the automations' `_runtime.allowed_tools` is the union).
|
|
42
|
+
NOTE_TOOLS = [
|
|
43
|
+
"note_capture", "note_file", "note_context", "note_resolve_candidates", "note_apply_revision",
|
|
44
|
+
"note_queue_lease", "note_queue_ack", "note_queue_stats", "note_search", "note_expand",
|
|
45
|
+
"note_changes", "note_entity", "note_timeline", "note_open_loops", "note_contradictions",
|
|
46
|
+
"note_resurface", "note_anniversaries", "note_insight_write",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
DRAIN_DEFAULTS = {"max_items": 20, "budget_s": 90}
|
|
50
|
+
AUTOMATION_DEFAULTS = {
|
|
51
|
+
"note-drain": {
|
|
52
|
+
"schema_version": 1,
|
|
53
|
+
"title": "Note processor",
|
|
54
|
+
"trigger": {"source_id": "schedule", "source_version": 2, "config": {"kind": "every", "every": "2m"}},
|
|
55
|
+
"context": {"mode": "independent"},
|
|
56
|
+
"input_data": {**DRAIN_DEFAULTS, "_runtime": {"allowed_tools": NOTE_TOOLS}},
|
|
57
|
+
},
|
|
58
|
+
"note-reflect": {
|
|
59
|
+
"schema_version": 1,
|
|
60
|
+
"title": "Note reflection",
|
|
61
|
+
"trigger": {"source_id": "schedule", "source_version": 2, "config": {"kind": "every", "every": "7d"}},
|
|
62
|
+
"context": {"mode": "independent"},
|
|
63
|
+
"input_data": {"limit": 10, "_runtime": {"allowed_tools": NOTE_TOOLS}},
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
SYSTEM_JSON = "You file and connect a person's notes. Output JSON only, matching the schema."
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ------------------------------------------------------------------ inputs
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _skeleton(schema: Any) -> Any:
|
|
74
|
+
"""Structure of a JSON Schema that both sides must agree on (no descriptions or limits)."""
|
|
75
|
+
if not isinstance(schema, dict):
|
|
76
|
+
return schema
|
|
77
|
+
out: Dict[str, Any] = {}
|
|
78
|
+
t = schema.get("type")
|
|
79
|
+
if t is not None:
|
|
80
|
+
out["type"] = sorted(t) if isinstance(t, list) else t
|
|
81
|
+
if "enum" in schema:
|
|
82
|
+
out["enum"] = sorted(map(str, schema["enum"]))
|
|
83
|
+
if isinstance(schema.get("properties"), dict):
|
|
84
|
+
out["properties"] = {k: _skeleton(v) for k, v in sorted(schema["properties"].items())}
|
|
85
|
+
out["required"] = sorted(schema.get("required") or [])
|
|
86
|
+
if "items" in schema:
|
|
87
|
+
out["items"] = _skeleton(schema["items"])
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def load_schemas() -> Dict[str, Dict[str, Any]]:
|
|
92
|
+
"""The structured-output schemas: the core lane's when importable, checked against ours."""
|
|
93
|
+
import importlib
|
|
94
|
+
import importlib.util
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
spec = importlib.util.find_spec("abstractnote.process.schemas")
|
|
98
|
+
except ModuleNotFoundError:
|
|
99
|
+
spec = None
|
|
100
|
+
if spec is None: # the core lane's schemas module is not there yet
|
|
101
|
+
return dict(FALLBACK_SCHEMAS)
|
|
102
|
+
core = importlib.import_module("abstractnote.process.schemas")
|
|
103
|
+
core_map = getattr(core, "SCHEMAS", None)
|
|
104
|
+
if not isinstance(core_map, dict):
|
|
105
|
+
raise RuntimeError(
|
|
106
|
+
"abstractnote.process.schemas exists but has no SCHEMAS mapping {name: JSON Schema dict}; "
|
|
107
|
+
"the flow generator needs it to attach resp_schema"
|
|
108
|
+
)
|
|
109
|
+
problems = []
|
|
110
|
+
for name, fallback in FALLBACK_SCHEMAS.items():
|
|
111
|
+
if name not in core_map:
|
|
112
|
+
problems.append(f"{name} missing in abstractnote.process.schemas.SCHEMAS")
|
|
113
|
+
elif _skeleton(core_map[name]) != _skeleton(fallback):
|
|
114
|
+
problems.append(f"{name} differs between abstractnote.process.schemas and flows/schemas_fallback.py")
|
|
115
|
+
if problems:
|
|
116
|
+
raise RuntimeError("; ".join(problems))
|
|
117
|
+
return {name: core_map[name] for name in FALLBACK_SCHEMAS}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def load_prompts() -> Dict[str, str]:
|
|
121
|
+
"""Prompt templates: the core lane's Markdown files when present, else ours."""
|
|
122
|
+
prompts_dir = Path(__file__).resolve().parents[1] / "process" / "prompts"
|
|
123
|
+
out: Dict[str, str] = {}
|
|
124
|
+
for name, fallback in FALLBACK_PROMPTS.items():
|
|
125
|
+
path = prompts_dir / f"{name}.md"
|
|
126
|
+
out[name] = path.read_text(encoding="utf-8") if path.is_file() else fallback
|
|
127
|
+
return out
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _version() -> str:
|
|
131
|
+
from abstractnote import __version__
|
|
132
|
+
|
|
133
|
+
return str(__version__)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ------------------------------------------------------------------ flows
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _model_pins() -> List[tuple]:
|
|
140
|
+
return [("provider", "provider_text"), ("model", "model")]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def build_capture(schemas: Dict[str, Any], prompts: Dict[str, str]) -> Dict[str, Any]:
|
|
144
|
+
f = Flow(
|
|
145
|
+
"note-capture", "Capture a note",
|
|
146
|
+
"Store a note, file it quickly, answer, and queue it for understanding (optionally right away).",
|
|
147
|
+
interfaces=INTERFACES["note-capture"],
|
|
148
|
+
)
|
|
149
|
+
f.start(
|
|
150
|
+
[("text", "string"), ("title", "string"), ("occurred_at", "string"), ("location", "object"),
|
|
151
|
+
("source", "object"), ("idempotency_key", "string"), ("priority", "number"),
|
|
152
|
+
("process_inline", "boolean"), ("session_id", "string"), ("context", "object"), *_model_pins()],
|
|
153
|
+
{"text": "", "process_inline": False, "provider": "", "model": ""},
|
|
154
|
+
)
|
|
155
|
+
S = lambda p: ("start", p) # noqa: E731
|
|
156
|
+
f.node("attachments", "get", "Attachments",
|
|
157
|
+
inputs=[{"id": "object", "label": "object", "type": "object"},
|
|
158
|
+
{"id": "key", "label": "key", "type": "string"},
|
|
159
|
+
{"id": "default", "label": "default", "type": "any"}],
|
|
160
|
+
outputs=[{"id": "value", "label": "value", "type": "any"}],
|
|
161
|
+
pin_defaults={"key": "attachments", "default": []})
|
|
162
|
+
f.wire(S("context"), "attachments", "object")
|
|
163
|
+
f.node("about", "get", "What it is about",
|
|
164
|
+
inputs=[{"id": "object", "label": "object", "type": "object"},
|
|
165
|
+
{"id": "key", "label": "key", "type": "string"},
|
|
166
|
+
{"id": "default", "label": "default", "type": "any"}],
|
|
167
|
+
outputs=[{"id": "value", "label": "value", "type": "any"}],
|
|
168
|
+
pin_defaults={"key": "about", "default": None})
|
|
169
|
+
f.wire(S("context"), "about", "object")
|
|
170
|
+
f.node("answer", "get", "A saved answer?",
|
|
171
|
+
inputs=[{"id": "object", "label": "object", "type": "object"},
|
|
172
|
+
{"id": "key", "label": "key", "type": "string"},
|
|
173
|
+
{"id": "default", "label": "default", "type": "any"}],
|
|
174
|
+
outputs=[{"id": "value", "label": "value", "type": "any"}],
|
|
175
|
+
pin_defaults={"key": "answer", "default": None})
|
|
176
|
+
f.wire(S("context"), "answer", "object")
|
|
177
|
+
cap = f.tool_step(
|
|
178
|
+
"capture", "Store the note", "note_capture",
|
|
179
|
+
{
|
|
180
|
+
"text": ("string", S("text")), "title": ("string", S("title")),
|
|
181
|
+
"occurred_at": ("string", S("occurred_at")), "location": ("object", S("location")),
|
|
182
|
+
"source": ("object", S("source")), "idempotency_key": ("string", S("idempotency_key")),
|
|
183
|
+
"priority": ("number", S("priority")), "session_id": ("string", S("session_id")),
|
|
184
|
+
"attachments": ("array", ("attachments", "value")),
|
|
185
|
+
"about": ("object", ("about", "value")),
|
|
186
|
+
"answer": ("object", ("answer", "value")),
|
|
187
|
+
},
|
|
188
|
+
[("note_id", "string"), ("status", "string"), ("first_saved_at", "string"), ("similar", "array"),
|
|
189
|
+
("revisits", "array"), ("topics", "array"), ("warnings", "array")],
|
|
190
|
+
after=("start", "exec-out"),
|
|
191
|
+
)
|
|
192
|
+
f.code(
|
|
193
|
+
"is_dup", "Saved before?",
|
|
194
|
+
"return {\"duplicate\": status == \"duplicate\"}",
|
|
195
|
+
inputs={"status": ("string", (cap, "status"))}, outputs=[("duplicate", "boolean")], exec_lane=False,
|
|
196
|
+
)
|
|
197
|
+
f.if_("dup_branch", "Already saved?", ("is_dup", "duplicate"))
|
|
198
|
+
f.then(cap, "dup_branch")
|
|
199
|
+
f.code(
|
|
200
|
+
"dup_msg", "Already-saved answer",
|
|
201
|
+
"when = str(first_saved_at or \"\")[:10]\n"
|
|
202
|
+
"return {\"message\": (\"Already saved on \" + when + \".\") if when else \"Already saved.\"}",
|
|
203
|
+
inputs={"first_saved_at": ("string", (cap, "first_saved_at"))}, outputs=[("message", "string")],
|
|
204
|
+
)
|
|
205
|
+
f.then("dup_branch", "dup_msg", "true")
|
|
206
|
+
f.answer_user("dup_answer", "Say it was saved before", ("dup_msg", "message"))
|
|
207
|
+
f.then("dup_msg", "dup_answer")
|
|
208
|
+
f.end("end_duplicate", "Done (already saved)", {
|
|
209
|
+
"response": ("string", ("dup_msg", "message")), "note_id": ("string", (cap, "note_id")),
|
|
210
|
+
"status": ("string", "duplicate"),
|
|
211
|
+
})
|
|
212
|
+
f.then("dup_answer", "end_duplicate")
|
|
213
|
+
|
|
214
|
+
f.template("filing_prompt", "Filing prompt", prompts["quick_filing"], {
|
|
215
|
+
"title": S("title"), "text": S("text"), "similar": (cap, "similar"),
|
|
216
|
+
"revisits": (cap, "revisits"), "topics": (cap, "topics"),
|
|
217
|
+
})
|
|
218
|
+
f.llm("filing", "File it quickly", schema=schemas["QuickFiling"], prompt=("filing_prompt", "result"),
|
|
219
|
+
provider=S("provider"), model=S("model"), system=SYSTEM_JSON)
|
|
220
|
+
f.then("dup_branch", "filing", "false")
|
|
221
|
+
f.break_object("filing_fields", "Filing", ("filing", "data"), [
|
|
222
|
+
("title", "string"), ("kind", "string"), ("topics", "array"), ("urgency", "string"),
|
|
223
|
+
("language", "string"), ("one_line", "string"),
|
|
224
|
+
])
|
|
225
|
+
filed = f.tool_step(
|
|
226
|
+
"file", "File the note", "note_file",
|
|
227
|
+
{
|
|
228
|
+
"note_id": ("string", (cap, "note_id")), "title": ("string", ("filing_fields", "title")),
|
|
229
|
+
"kind": ("string", ("filing_fields", "kind")), "topics": ("array", ("filing_fields", "topics")),
|
|
230
|
+
"urgency": ("string", ("filing_fields", "urgency")),
|
|
231
|
+
"language": ("string", ("filing_fields", "language")),
|
|
232
|
+
"one_line": ("string", ("filing_fields", "one_line")),
|
|
233
|
+
},
|
|
234
|
+
[("priority", "number"), ("status", "string")],
|
|
235
|
+
after=("filing", "exec-out"),
|
|
236
|
+
)
|
|
237
|
+
f.code(
|
|
238
|
+
"saved_msg", "Saved answer",
|
|
239
|
+
"titles = []\n"
|
|
240
|
+
"for s in similar or []:\n"
|
|
241
|
+
" t = s.get(\"title\") if isinstance(s, dict) else None\n"
|
|
242
|
+
" if t and t not in titles:\n"
|
|
243
|
+
" titles.append(t)\n"
|
|
244
|
+
"parts = [\"Saved as \" + str(kind or \"note\") + \" \\u201c\" + str(title or \"\") + \"\\u201d.\"]\n"
|
|
245
|
+
"if titles:\n"
|
|
246
|
+
" parts.append(\"Related: \" + \", \".join(titles[:3]) + \".\")\n"
|
|
247
|
+
"dates = [str(r.get(\"occurred_at\") or \"\")[:10] for r in (revisits or []) if isinstance(r, dict)]\n"
|
|
248
|
+
"dates = [d for d in dates if d]\n"
|
|
249
|
+
"if dates:\n"
|
|
250
|
+
" parts.append(\"You noted this on \" + dates[0] + \".\")\n"
|
|
251
|
+
"for w in warnings or []:\n"
|
|
252
|
+
" if isinstance(w, str) and w.strip():\n"
|
|
253
|
+
" parts.append(w.strip())\n"
|
|
254
|
+
"return {\"message\": \" \".join(parts)}",
|
|
255
|
+
inputs={
|
|
256
|
+
"kind": ("string", ("filing_fields", "kind")), "title": ("string", ("filing_fields", "title")),
|
|
257
|
+
"similar": ("array", (cap, "similar")), "revisits": ("array", (cap, "revisits")),
|
|
258
|
+
"warnings": ("array", (cap, "warnings")),
|
|
259
|
+
},
|
|
260
|
+
outputs=[("message", "string")],
|
|
261
|
+
)
|
|
262
|
+
f.then(filed, "saved_msg")
|
|
263
|
+
f.answer_user("saved_answer", "Say where it was filed", ("saved_msg", "message"))
|
|
264
|
+
f.then("saved_msg", "saved_answer")
|
|
265
|
+
f.if_("inline_branch", "Process now?", S("process_inline"))
|
|
266
|
+
f.then("saved_answer", "inline_branch")
|
|
267
|
+
f.end("end_queued", "Done (queued)", {
|
|
268
|
+
"response": ("string", ("saved_msg", "message")), "note_id": ("string", (cap, "note_id")),
|
|
269
|
+
"status": ("string", "queued"),
|
|
270
|
+
})
|
|
271
|
+
f.then("inline_branch", "end_queued", "false")
|
|
272
|
+
f.subflow("process", "Understand the note", "note-process", {
|
|
273
|
+
"note_id": ("string", (cap, "note_id")), "provider": ("provider_text", S("provider")),
|
|
274
|
+
"model": ("model", S("model")),
|
|
275
|
+
}, [("response", "string"), ("revision_id", "string"), ("revision", "object")])
|
|
276
|
+
f.then("inline_branch", "process", "true")
|
|
277
|
+
f.child_check("process_ok", "Understood?", "process")
|
|
278
|
+
ack = f.tool_step(
|
|
279
|
+
"inline_ack", "Mark it processed", "note_queue_ack",
|
|
280
|
+
{
|
|
281
|
+
"note_id": ("string", (cap, "note_id")), "outcome": ("string", "ok"),
|
|
282
|
+
"revision_id": ("string", ("process", "revision_id")),
|
|
283
|
+
},
|
|
284
|
+
[("status", "string")],
|
|
285
|
+
after=("process_ok", "exec-out"),
|
|
286
|
+
)
|
|
287
|
+
f.code(
|
|
288
|
+
"processed_msg", "Processed answer",
|
|
289
|
+
"extra = str(summary or \"\").strip()\n"
|
|
290
|
+
"return {\"message\": (str(message) + \" \" + extra).strip()}",
|
|
291
|
+
inputs={"message": ("string", ("saved_msg", "message")), "summary": ("string", ("process", "response"))},
|
|
292
|
+
outputs=[("message", "string")],
|
|
293
|
+
)
|
|
294
|
+
f.then(ack, "processed_msg")
|
|
295
|
+
f.end("end_processed", "Done (processed)", {
|
|
296
|
+
"response": ("string", ("processed_msg", "message")), "note_id": ("string", (cap, "note_id")),
|
|
297
|
+
"status": ("string", "processed"), "revision_summary": ("string", ("process", "response")),
|
|
298
|
+
"revision": ("object", ("process", "revision")),
|
|
299
|
+
})
|
|
300
|
+
f.then("processed_msg", "end_processed")
|
|
301
|
+
return f.finish()
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
MERGE_EXTRACTIONS = r'''
|
|
305
|
+
parts = [e for e in (extractions or []) if isinstance(e, dict)]
|
|
306
|
+
if not parts:
|
|
307
|
+
raise ValueError("The note could not be read.")
|
|
308
|
+
if len(parts) == 1:
|
|
309
|
+
merged = parts[0]
|
|
310
|
+
else:
|
|
311
|
+
mentions, refmap, by_key = [], {}, {}
|
|
312
|
+
claims, tasks, time_refs, topics, summaries = [], [], [], [], []
|
|
313
|
+
for i, part in enumerate(parts):
|
|
314
|
+
for m in part.get("mentions") or []:
|
|
315
|
+
key = (str(m.get("name") or "").casefold(), str(m.get("kind") or ""))
|
|
316
|
+
if key in by_key:
|
|
317
|
+
kept = by_key[key]
|
|
318
|
+
for a in m.get("aliases") or []:
|
|
319
|
+
if a not in kept["aliases"]:
|
|
320
|
+
kept["aliases"].append(a)
|
|
321
|
+
else:
|
|
322
|
+
kept = dict(m, ref="m" + str(len(mentions) + 1), aliases=list(m.get("aliases") or []))
|
|
323
|
+
by_key[key] = kept
|
|
324
|
+
mentions.append(kept)
|
|
325
|
+
refmap[(i, m.get("ref"))] = kept["ref"]
|
|
326
|
+
for c in part.get("claims") or []:
|
|
327
|
+
c2 = dict(c)
|
|
328
|
+
c2["subject"] = refmap.get((i, c.get("subject")), c.get("subject"))
|
|
329
|
+
if not c.get("object_is_literal"):
|
|
330
|
+
c2["object"] = refmap.get((i, c.get("object")), c.get("object"))
|
|
331
|
+
claims.append(c2)
|
|
332
|
+
tasks.extend(part.get("tasks") or [])
|
|
333
|
+
time_refs.extend(part.get("time_refs") or [])
|
|
334
|
+
for t in part.get("topics") or []:
|
|
335
|
+
if t not in topics:
|
|
336
|
+
topics.append(t)
|
|
337
|
+
if part.get("summary"):
|
|
338
|
+
summaries.append(str(part.get("summary")))
|
|
339
|
+
merged = {"summary": " ".join(summaries), "mentions": mentions, "claims": claims,
|
|
340
|
+
"tasks": tasks, "time_refs": time_refs, "topics": topics}
|
|
341
|
+
names = [{"ref": m.get("ref"), "name": m.get("name"), "kind": m.get("kind"), "aliases": m.get("aliases") or []}
|
|
342
|
+
for m in merged.get("mentions") or []]
|
|
343
|
+
return {"extraction": merged, "mentions": names}
|
|
344
|
+
'''
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def build_process(schemas: Dict[str, Any], prompts: Dict[str, str]) -> Dict[str, Any]:
|
|
348
|
+
f = Flow(
|
|
349
|
+
"note-process", "Understand a note",
|
|
350
|
+
"Read one note against what the notebook knows, extract, link and revise the graph.",
|
|
351
|
+
interfaces=INTERFACES["note-process"],
|
|
352
|
+
)
|
|
353
|
+
f.start([("note_id", "string"), *_model_pins()], {"note_id": "", "provider": "", "model": ""})
|
|
354
|
+
S = lambda p: ("start", p) # noqa: E731
|
|
355
|
+
ctx = f.tool_step(
|
|
356
|
+
"context", "Look at what is known", "note_context",
|
|
357
|
+
{"note_id": ("string", S("note_id"))},
|
|
358
|
+
[("note", "object"), ("sections", "array"), ("similar_notes", "array"),
|
|
359
|
+
("candidate_entities", "array"), ("open_tasks", "array"), ("topics", "array"),
|
|
360
|
+
("vocabulary", "array")],
|
|
361
|
+
after=("start", "exec-out"),
|
|
362
|
+
)
|
|
363
|
+
f.code(
|
|
364
|
+
"sections", "Sections to read",
|
|
365
|
+
"if not isinstance(sections, list) or not sections:\n"
|
|
366
|
+
" raise ValueError(\"The note has no text to read.\")\n"
|
|
367
|
+
"return {\"items\": sections}",
|
|
368
|
+
inputs={"sections": ("array", (ctx, "sections"))}, outputs=[("items", "array")],
|
|
369
|
+
)
|
|
370
|
+
f.then(ctx, "sections")
|
|
371
|
+
f.for_each("each_section", "For each section", ("sections", "items"))
|
|
372
|
+
f.then("sections", "each_section")
|
|
373
|
+
f.template("extract_prompt", "Extraction prompt", prompts["extract"], {
|
|
374
|
+
"note": (ctx, "note"), "section": ("each_section", "item"), "similar_notes": (ctx, "similar_notes"),
|
|
375
|
+
"candidate_entities": (ctx, "candidate_entities"), "open_tasks": (ctx, "open_tasks"),
|
|
376
|
+
"topics": (ctx, "topics"), "vocabulary": (ctx, "vocabulary"),
|
|
377
|
+
})
|
|
378
|
+
f.llm("extract", "Extract", schema=schemas["Extraction"], prompt=("extract_prompt", "result"),
|
|
379
|
+
provider=S("provider"), model=S("model"), system=SYSTEM_JSON)
|
|
380
|
+
f.then("each_section", "extract", "loop")
|
|
381
|
+
f.add_to_list("keep_extraction", "Keep the extraction", "extractions", ("extract", "data"))
|
|
382
|
+
f.then("extract", "keep_extraction")
|
|
383
|
+
f.get_var("extractions", "extractions", [])
|
|
384
|
+
f.code("merge", "Merge the sections", MERGE_EXTRACTIONS,
|
|
385
|
+
inputs={"extractions": ("array", ("extractions", "value"))},
|
|
386
|
+
outputs=[("extraction", "object"), ("mentions", "array")])
|
|
387
|
+
f.then("each_section", "merge", "done")
|
|
388
|
+
res = f.tool_step(
|
|
389
|
+
"resolve", "Find candidate entities", "note_resolve_candidates",
|
|
390
|
+
{"note_id": ("string", S("note_id")), "mentions": ("array", ("merge", "mentions"))},
|
|
391
|
+
[("mentions", "array")],
|
|
392
|
+
after=("merge", "exec-out"),
|
|
393
|
+
)
|
|
394
|
+
f.template("link_prompt", "Linking prompt", prompts["link"], {
|
|
395
|
+
"note": (ctx, "note"), "extraction": ("merge", "extraction"), "candidates": (res, "mentions"),
|
|
396
|
+
"packet": (ctx, "tool_output"),
|
|
397
|
+
})
|
|
398
|
+
f.llm("link", "Link", schema=schemas["Linking"], prompt=("link_prompt", "result"),
|
|
399
|
+
provider=S("provider"), model=S("model"), system=SYSTEM_JSON)
|
|
400
|
+
f.then(res, "link")
|
|
401
|
+
rev = f.tool_step(
|
|
402
|
+
"apply", "Revise the graph", "note_apply_revision",
|
|
403
|
+
{"note_id": ("string", S("note_id")), "extraction": ("object", ("merge", "extraction")),
|
|
404
|
+
"linking": ("object", ("link", "data"))},
|
|
405
|
+
[("revision_id", "string"), ("summary", "string")],
|
|
406
|
+
after=("link", "exec-out"),
|
|
407
|
+
)
|
|
408
|
+
f.end("end", "Done", {
|
|
409
|
+
"response": ("string", (rev, "summary")), "note_id": ("string", S("note_id")),
|
|
410
|
+
"revision_id": ("string", (rev, "revision_id")), "revision": ("object", (rev, "tool_output")),
|
|
411
|
+
"status": ("string", "processed"),
|
|
412
|
+
})
|
|
413
|
+
f.then(rev, "end")
|
|
414
|
+
return f.finish()
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def build_drain(schemas: Dict[str, Any], prompts: Dict[str, str]) -> Dict[str, Any]:
|
|
418
|
+
f = Flow(
|
|
419
|
+
"note-drain", "Process queued notes",
|
|
420
|
+
"Take queued notes by priority and understand them one at a time, within a count and time budget.",
|
|
421
|
+
interfaces=INTERFACES["note-drain"],
|
|
422
|
+
)
|
|
423
|
+
f.start([("max_items", "number"), ("budget_s", "number"), *_model_pins()],
|
|
424
|
+
{**DRAIN_DEFAULTS, "provider": "", "model": ""})
|
|
425
|
+
S = lambda p: ("start", p) # noqa: E731
|
|
426
|
+
f.code(
|
|
427
|
+
"init", "Budget",
|
|
428
|
+
"import time\n"
|
|
429
|
+
"left = int(max_items if max_items is not None else 20)\n"
|
|
430
|
+
"if left < 1:\n"
|
|
431
|
+
" raise ValueError(\"max_items must be at least 1.\")\n"
|
|
432
|
+
"budget = float(budget_s if budget_s is not None else 90)\n"
|
|
433
|
+
"now = time.time()\n"
|
|
434
|
+
"return {\"updates\": {\"drain_deadline\": now + budget, \"drain_left\": left, \"drain_continue\": True,\n"
|
|
435
|
+
" \"drain_processed\": 0, \"drain_failed\": 0, \"drain_errors\": [],\n"
|
|
436
|
+
" \"drain_worker\": \"drain-\" + str(int(now * 1000))}}",
|
|
437
|
+
inputs={"max_items": ("number", S("max_items")), "budget_s": ("number", S("budget_s"))},
|
|
438
|
+
outputs=[("updates", "object")],
|
|
439
|
+
)
|
|
440
|
+
f.then("start", "init")
|
|
441
|
+
f.set_vars("init_vars", "Remember the budget", ("init", "updates"))
|
|
442
|
+
f.then("init", "init_vars")
|
|
443
|
+
f.get_var("continue", "drain_continue", False)
|
|
444
|
+
f.while_("drain_loop", "While there is budget", ("continue", "value"))
|
|
445
|
+
f.then("init_vars", "drain_loop")
|
|
446
|
+
f.get_var("worker", "drain_worker", "")
|
|
447
|
+
lease, lease_ok = f.loop_tool_step(
|
|
448
|
+
"lease", "Take the next note", "note_queue_lease",
|
|
449
|
+
{"worker_id": ("string", ("worker", "value")), "max_items": ("number", 1)},
|
|
450
|
+
[("items", "array")],
|
|
451
|
+
after=("drain_loop", "loop"),
|
|
452
|
+
)
|
|
453
|
+
f.code(
|
|
454
|
+
"pick", "Next note",
|
|
455
|
+
"first = items[0] if isinstance(items, list) and items else None\n"
|
|
456
|
+
"return {\"has_item\": isinstance(first, dict), \"note_id\": first.get(\"note_id\") if isinstance(first, dict) else None}",
|
|
457
|
+
inputs={"items": ("array", (lease, "items"))}, outputs=[("has_item", "boolean"), ("note_id", "string")],
|
|
458
|
+
)
|
|
459
|
+
f.then(lease_ok[0], "pick", lease_ok[1])
|
|
460
|
+
f.if_("has_item", "Anything queued?", ("pick", "has_item"))
|
|
461
|
+
f.then("pick", "has_item")
|
|
462
|
+
f.set_var("stop", "Stop: queue empty", "drain_continue", False)
|
|
463
|
+
f.then("has_item", "stop", "false")
|
|
464
|
+
f.subflow("process", "Understand the note", "note-process", {
|
|
465
|
+
"note_id": ("string", ("pick", "note_id")), "provider": ("provider_text", S("provider")),
|
|
466
|
+
"model": ("model", S("model")),
|
|
467
|
+
}, [("revision_id", "string")])
|
|
468
|
+
f.then("has_item", "process", "true")
|
|
469
|
+
f.code(
|
|
470
|
+
"outcome", "Outcome",
|
|
471
|
+
"o = output if isinstance(output, dict) else {}\n"
|
|
472
|
+
"c = child_output if isinstance(child_output, dict) else {}\n"
|
|
473
|
+
"err = str(c.get(\"error\") or o.get(\"error\") or \"\")\n"
|
|
474
|
+
"for prefix in (\"Runtime error: \", \"Execution error: \"):\n"
|
|
475
|
+
" if err.startswith(prefix):\n"
|
|
476
|
+
" err = err[len(prefix):]\n"
|
|
477
|
+
"failed = c.get(\"success\") is False or o.get(\"success\") is False or bool(err) or not o.get(\"revision_id\")\n"
|
|
478
|
+
"return {\"outcome\": \"error\" if failed else \"ok\",\n"
|
|
479
|
+
" \"failure\": (str(err) if err else \"The note could not be processed.\") if failed else None,\n"
|
|
480
|
+
" \"revision_id\": None if failed else o.get(\"revision_id\")}",
|
|
481
|
+
inputs={"output": ("object", ("process", "output")), "child_output": ("object", ("process", "child_output"))},
|
|
482
|
+
outputs=[("outcome", "string"), ("failure", "string"), ("revision_id", "string")],
|
|
483
|
+
)
|
|
484
|
+
f.then("process", "outcome")
|
|
485
|
+
ack, ack_ok = f.loop_tool_step(
|
|
486
|
+
"ack", "Record the outcome", "note_queue_ack",
|
|
487
|
+
{"note_id": ("string", ("pick", "note_id")), "outcome": ("string", ("outcome", "outcome")),
|
|
488
|
+
"error": ("string", ("outcome", "failure")), "revision_id": ("string", ("outcome", "revision_id"))},
|
|
489
|
+
[("status", "string")],
|
|
490
|
+
after=("outcome", "exec-out"),
|
|
491
|
+
)
|
|
492
|
+
for vid, name, default in (("left", "drain_left", 0), ("deadline", "drain_deadline", 0),
|
|
493
|
+
("processed", "drain_processed", 0), ("failed", "drain_failed", 0),
|
|
494
|
+
("errors", "drain_errors", [])):
|
|
495
|
+
f.get_var(vid, name, default)
|
|
496
|
+
f.code(
|
|
497
|
+
"budget", "Budget left?",
|
|
498
|
+
"import time\n"
|
|
499
|
+
"ok = outcome == \"ok\"\n"
|
|
500
|
+
"errs = list(errors or [])\n"
|
|
501
|
+
"if not ok:\n"
|
|
502
|
+
" errs.append({\"note_id\": note_id, \"error\": failure})\n"
|
|
503
|
+
"remaining = int(left or 0) - 1\n"
|
|
504
|
+
"return {\"updates\": {\"drain_left\": remaining,\n"
|
|
505
|
+
" \"drain_processed\": int(processed or 0) + (1 if ok else 0),\n"
|
|
506
|
+
" \"drain_failed\": int(failed or 0) + (0 if ok else 1), \"drain_errors\": errs,\n"
|
|
507
|
+
" \"drain_continue\": remaining > 0 and time.time() < float(deadline or 0)}}",
|
|
508
|
+
inputs={
|
|
509
|
+
"outcome": ("string", ("outcome", "outcome")), "failure": ("string", ("outcome", "failure")),
|
|
510
|
+
"note_id": ("string", ("pick", "note_id")), "left": ("number", ("left", "value")),
|
|
511
|
+
"deadline": ("number", ("deadline", "value")), "processed": ("number", ("processed", "value")),
|
|
512
|
+
"failed": ("number", ("failed", "value")), "errors": ("array", ("errors", "value")),
|
|
513
|
+
},
|
|
514
|
+
outputs=[("updates", "object")],
|
|
515
|
+
)
|
|
516
|
+
f.then(ack_ok[0], "budget", ack_ok[1])
|
|
517
|
+
f.set_vars("budget_vars", "Remember the budget", ("budget", "updates"))
|
|
518
|
+
f.then("budget", "budget_vars")
|
|
519
|
+
for vid, name, default in (("r_processed", "drain_processed", 0), ("r_failed", "drain_failed", 0),
|
|
520
|
+
("r_errors", "drain_errors", [])):
|
|
521
|
+
f.get_var(vid, name, default)
|
|
522
|
+
f.code(
|
|
523
|
+
"report", "Report",
|
|
524
|
+
"n = int(processed or 0)\n"
|
|
525
|
+
"k = int(failed or 0)\n"
|
|
526
|
+
"if n == 0 and k == 0:\n"
|
|
527
|
+
" msg = \"Nothing to process.\"\n"
|
|
528
|
+
"else:\n"
|
|
529
|
+
" msg = \"Processed \" + str(n) + (\" note.\" if n == 1 else \" notes.\")\n"
|
|
530
|
+
" if k:\n"
|
|
531
|
+
" msg = msg[:-1] + \"; \" + str(k) + (\" could not be processed.\" if k > 1 else \" could not be processed.\")\n"
|
|
532
|
+
"return {\"message\": msg, \"processed\": n, \"failed\": k, \"errors\": list(errors or [])}",
|
|
533
|
+
inputs={"processed": ("number", ("r_processed", "value")), "failed": ("number", ("r_failed", "value")),
|
|
534
|
+
"errors": ("array", ("r_errors", "value"))},
|
|
535
|
+
outputs=[("message", "string"), ("processed", "number"), ("failed", "number"), ("errors", "array")],
|
|
536
|
+
)
|
|
537
|
+
f.then("drain_loop", "report", "done")
|
|
538
|
+
f.end("end", "Done", {
|
|
539
|
+
"response": ("string", ("report", "message")), "processed": ("number", ("report", "processed")),
|
|
540
|
+
"failed": ("number", ("report", "failed")), "errors": ("array", ("report", "errors")),
|
|
541
|
+
})
|
|
542
|
+
f.then("report", "end")
|
|
543
|
+
return f.finish(automation_defaults=AUTOMATION_DEFAULTS["note-drain"])
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
RECALL_OUT_PINS = [
|
|
547
|
+
("response", "string"), ("answer", "string"), ("evidence", "array"), ("evolution", "object"),
|
|
548
|
+
("open_loops", "array"), ("follow_ups", "array"), ("hits", "object"), ("dossier_note_id", "string"),
|
|
549
|
+
]
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _recall_end(f: Flow, node_id: str, label: str, src: str, effort: str, hits: tuple) -> str:
|
|
553
|
+
values: Dict[str, Any] = {p: (t, (src, p)) for p, t in RECALL_OUT_PINS if p != "hits"}
|
|
554
|
+
values["hits"] = ("object", hits)
|
|
555
|
+
values["effort"] = ("string", effort)
|
|
556
|
+
return f.end(node_id, label, values)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def build_recall(schemas: Dict[str, Any], prompts: Dict[str, str]) -> Dict[str, Any]:
|
|
560
|
+
f = Flow(
|
|
561
|
+
"note-recall", "Ask your notes",
|
|
562
|
+
"Answer a question from the notes at the requested effort (quick, standard or deep). "
|
|
563
|
+
"Also serves the chat agent contract: prompt in, response out.",
|
|
564
|
+
interfaces=INTERFACES["note-recall"],
|
|
565
|
+
)
|
|
566
|
+
f.start(
|
|
567
|
+
[("question", "string"), ("prompt", "string"), ("effort", "string"), ("as_of", "string"),
|
|
568
|
+
("since", "string"), ("until", "string"), ("entities", "array"), ("topics", "array"), *_model_pins()],
|
|
569
|
+
{"effort": "standard", "provider": "", "model": ""},
|
|
570
|
+
)
|
|
571
|
+
S = lambda p: ("start", p) # noqa: E731
|
|
572
|
+
f.code(
|
|
573
|
+
"ask", "The question",
|
|
574
|
+
"q = question if isinstance(question, str) and question.strip() else prompt\n"
|
|
575
|
+
"if not isinstance(q, str) or not q.strip():\n"
|
|
576
|
+
" raise ValueError(\"Ask a question.\")\n"
|
|
577
|
+
"e = str(effort or \"standard\").strip().lower()\n"
|
|
578
|
+
"if e not in (\"quick\", \"standard\", \"deep\"):\n"
|
|
579
|
+
" raise ValueError(\"Effort must be quick, standard or deep.\")\n"
|
|
580
|
+
"return {\"question_text\": q.strip(), \"effort_level\": e}",
|
|
581
|
+
inputs={"question": ("string", S("question")), "prompt": ("string", S("prompt")),
|
|
582
|
+
"effort": ("string", S("effort"))},
|
|
583
|
+
outputs=[("question_text", "string"), ("effort_level", "string")],
|
|
584
|
+
)
|
|
585
|
+
f.then("start", "ask")
|
|
586
|
+
hits = f.tool_step(
|
|
587
|
+
"search", "Search the notes", "note_search",
|
|
588
|
+
{"query": ("string", ("ask", "question_text")), "since": ("string", S("since")),
|
|
589
|
+
"until": ("string", S("until")), "entities": ("array", S("entities")),
|
|
590
|
+
"topics": ("array", S("topics")), "limit": ("number", 20)},
|
|
591
|
+
[("notes", "array"), ("entities", "array")],
|
|
592
|
+
after=("ask", "exec-out"),
|
|
593
|
+
)
|
|
594
|
+
f.switch("by_effort", "How hard to think", ("ask", "effort_level"), ["quick", "standard", "deep"])
|
|
595
|
+
f.then(hits, "by_effort")
|
|
596
|
+
# quick: no model, the hits are the answer.
|
|
597
|
+
f.code(
|
|
598
|
+
"quick", "List what was found",
|
|
599
|
+
"found = [n for n in (notes or []) if isinstance(n, dict)]\n"
|
|
600
|
+
"evidence = [{\"note_id\": n.get(\"note_id\"), \"title\": n.get(\"title\"), \"kind\": n.get(\"kind\"),\n"
|
|
601
|
+
" \"occurred_at\": n.get(\"occurred_at\"), \"excerpt\": n.get(\"excerpt\")} for n in found]\n"
|
|
602
|
+
"if not found:\n"
|
|
603
|
+
" text = \"I found nothing in your notes about that.\"\n"
|
|
604
|
+
"else:\n"
|
|
605
|
+
" items = [\"\\u201c\" + str(n.get(\"title\") or \"Untitled\") + \"\\u201d (\" + str(n.get(\"occurred_at\") or \"\")[:10] + \") [\" + str(n.get(\"note_id\")) + \"]\" for n in found[:5]]\n"
|
|
606
|
+
" text = \"I found \" + str(len(found)) + (\" note: \" if len(found) == 1 else \" notes: \") + \"; \".join(items) + \".\"\n"
|
|
607
|
+
"return {\"response\": text, \"answer\": text, \"evidence\": evidence, \"evolution\": {}, \"open_loops\": [],\n"
|
|
608
|
+
" \"follow_ups\": [], \"dossier_note_id\": None}",
|
|
609
|
+
inputs={"notes": ("array", (hits, "notes"))},
|
|
610
|
+
outputs=[(p, t) for p, t in RECALL_OUT_PINS if p != "hits"],
|
|
611
|
+
)
|
|
612
|
+
f.then("by_effort", "quick", "case:quick")
|
|
613
|
+
_recall_end(f, "end_quick", "Done (quick)", "quick", "quick", (hits, "tool_output"))
|
|
614
|
+
f.then("quick", "end_quick")
|
|
615
|
+
# standard: expand, then one synthesis call.
|
|
616
|
+
f.code(
|
|
617
|
+
"ids", "Notes and entities found",
|
|
618
|
+
"return {\"note_ids\": [n.get(\"note_id\") for n in (notes or []) if isinstance(n, dict) and n.get(\"note_id\")],\n"
|
|
619
|
+
" \"entity_ids\": [x.get(\"entity_id\") for x in (entities or []) if isinstance(x, dict) and x.get(\"entity_id\")]}",
|
|
620
|
+
inputs={"notes": ("array", (hits, "notes")), "entities": ("array", (hits, "entities"))},
|
|
621
|
+
outputs=[("note_ids", "array"), ("entity_ids", "array")], exec_lane=False,
|
|
622
|
+
)
|
|
623
|
+
exp = f.tool_step(
|
|
624
|
+
"expand", "Look closer", "note_expand",
|
|
625
|
+
{"note_ids": ("array", ("ids", "note_ids")), "entity_ids": ("array", ("ids", "entity_ids")),
|
|
626
|
+
"as_of": ("string", S("as_of"))},
|
|
627
|
+
[],
|
|
628
|
+
after=("by_effort", "case:standard"),
|
|
629
|
+
)
|
|
630
|
+
f.template("answer_prompt", "Answer prompt", prompts["synthesize"], {
|
|
631
|
+
"question": ("ask", "question_text"), "as_of": S("as_of"), "hits": (hits, "tool_output"),
|
|
632
|
+
"expansion": (exp, "tool_output"),
|
|
633
|
+
})
|
|
634
|
+
f.llm("synthesize", "Answer", schema=schemas["Answer"], prompt=("answer_prompt", "result"),
|
|
635
|
+
provider=S("provider"), model=S("model"), system="You answer questions from a person's notes. "
|
|
636
|
+
"Output JSON only, matching the schema.")
|
|
637
|
+
f.then(exp, "synthesize")
|
|
638
|
+
f.code(
|
|
639
|
+
"standard", "Shape the answer",
|
|
640
|
+
"d = data if isinstance(data, dict) else {}\n"
|
|
641
|
+
"text = str(d.get(\"answer\") or \"\").strip()\n"
|
|
642
|
+
"if not text:\n"
|
|
643
|
+
" raise ValueError(\"The answer came back empty.\")\n"
|
|
644
|
+
"found = {n.get(\"note_id\"): n for n in (notes or []) if isinstance(n, dict) and n.get(\"note_id\")}\n"
|
|
645
|
+
"evidence, seen = [], set()\n"
|
|
646
|
+
"for e in d.get(\"evidence\") or []:\n"
|
|
647
|
+
" n = found.get(e.get(\"note_id\")) if isinstance(e, dict) else None\n"
|
|
648
|
+
" if n is None or n[\"note_id\"] in seen:\n"
|
|
649
|
+
" continue\n"
|
|
650
|
+
" seen.add(n[\"note_id\"])\n"
|
|
651
|
+
" evidence.append({\"note_id\": n[\"note_id\"], \"title\": n.get(\"title\"), \"kind\": n.get(\"kind\"),\n"
|
|
652
|
+
" \"occurred_at\": n.get(\"occurred_at\"), \"excerpt\": e.get(\"excerpt\") or n.get(\"excerpt\")})\n"
|
|
653
|
+
"return {\"response\": text, \"answer\": text, \"evidence\": evidence,\n"
|
|
654
|
+
" \"evolution\": d.get(\"evolution\") or {}, \"open_loops\": d.get(\"open_loops\") or [],\n"
|
|
655
|
+
" \"follow_ups\": d.get(\"follow_ups\") or [], \"dossier_note_id\": None}",
|
|
656
|
+
inputs={"data": ("object", ("synthesize", "data")), "notes": ("array", (hits, "notes"))},
|
|
657
|
+
outputs=[(p, t) for p, t in RECALL_OUT_PINS if p != "hits"],
|
|
658
|
+
)
|
|
659
|
+
f.then("synthesize", "standard")
|
|
660
|
+
_recall_end(f, "end_standard", "Done (standard)", "standard", "standard", (hits, "tool_output"))
|
|
661
|
+
f.then("standard", "end_standard")
|
|
662
|
+
# deep: mining subflow writes a dossier note.
|
|
663
|
+
f.subflow("mine", "Dig through time", "note-mine", {
|
|
664
|
+
"question": ("string", ("ask", "question_text")), "since": ("string", S("since")),
|
|
665
|
+
"until": ("string", S("until")), "provider": ("provider_text", S("provider")), "model": ("model", S("model")),
|
|
666
|
+
}, [(p, t) for p, t in RECALL_OUT_PINS if p != "hits"])
|
|
667
|
+
f.then("by_effort", "mine", "case:deep")
|
|
668
|
+
f.child_check("mine_ok", "Dug through?", "mine")
|
|
669
|
+
_recall_end(f, "end_deep", "Done (deep)", "mine", "deep", (hits, "tool_output"))
|
|
670
|
+
f.then("mine_ok", "end_deep")
|
|
671
|
+
return f.finish()
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def build_mine(schemas: Dict[str, Any], prompts: Dict[str, str]) -> Dict[str, Any]:
|
|
675
|
+
f = Flow(
|
|
676
|
+
"note-mine", "Dig through your notes",
|
|
677
|
+
"Plan a multi-step search across time, read each slice, and write a dossier note.",
|
|
678
|
+
interfaces=INTERFACES["note-mine"],
|
|
679
|
+
)
|
|
680
|
+
f.start([("question", "string"), ("since", "string"), ("until", "string"), *_model_pins()],
|
|
681
|
+
{"question": "", "provider": "", "model": ""})
|
|
682
|
+
S = lambda p: ("start", p) # noqa: E731
|
|
683
|
+
first = f.tool_step(
|
|
684
|
+
"search", "First search", "note_search",
|
|
685
|
+
{"query": ("string", S("question")), "since": ("string", S("since")), "until": ("string", S("until")),
|
|
686
|
+
"limit": ("number", 20)},
|
|
687
|
+
[("notes", "array"), ("entities", "array")],
|
|
688
|
+
after=("start", "exec-out"),
|
|
689
|
+
)
|
|
690
|
+
f.template("plan_prompt", "Planning prompt", prompts["mine_plan"], {
|
|
691
|
+
"question": S("question"), "since": S("since"), "until": S("until"), "hits": (first, "tool_output"),
|
|
692
|
+
})
|
|
693
|
+
f.llm("plan", "Plan the search", schema=schemas["MiningPlan"], prompt=("plan_prompt", "result"),
|
|
694
|
+
provider=S("provider"), model=S("model"), system=SYSTEM_JSON)
|
|
695
|
+
f.then(first, "plan")
|
|
696
|
+
f.code(
|
|
697
|
+
"steps", "Steps",
|
|
698
|
+
"p = plan if isinstance(plan, dict) else {}\n"
|
|
699
|
+
"rounds = int(p.get(\"rounds\") or 6)\n"
|
|
700
|
+
"rounds = max(1, min(rounds, 6))\n"
|
|
701
|
+
"steps = [s for s in (p.get(\"steps\") or []) if isinstance(s, dict) and str(s.get(\"question\") or \"\").strip()]\n"
|
|
702
|
+
"if not steps:\n"
|
|
703
|
+
" steps = [{\"question\": question, \"since\": None, \"until\": None, \"entities\": []}]\n"
|
|
704
|
+
"return {\"items\": steps[:rounds]}",
|
|
705
|
+
inputs={"plan": ("object", ("plan", "data")), "question": ("string", S("question"))},
|
|
706
|
+
outputs=[("items", "array")],
|
|
707
|
+
)
|
|
708
|
+
f.then("plan", "steps")
|
|
709
|
+
f.for_each("each_step", "For each step", ("steps", "items"))
|
|
710
|
+
f.then("steps", "each_step")
|
|
711
|
+
f.code(
|
|
712
|
+
"step_query", "Step query",
|
|
713
|
+
"s = item if isinstance(item, dict) else {}\n"
|
|
714
|
+
"return {\"query\": str(s.get(\"question\") or \"\"), \"step_since\": s.get(\"since\") or since,\n"
|
|
715
|
+
" \"step_until\": s.get(\"until\") or until}",
|
|
716
|
+
inputs={"item": ("object", ("each_step", "item")), "since": ("string", S("since")),
|
|
717
|
+
"until": ("string", S("until"))},
|
|
718
|
+
outputs=[("query", "string"), ("step_since", "string"), ("step_until", "string")],
|
|
719
|
+
)
|
|
720
|
+
f.then("each_step", "step_query", "loop")
|
|
721
|
+
step_hits, step_ok = f.loop_tool_step(
|
|
722
|
+
"step_search", "Search this slice", "note_search",
|
|
723
|
+
{"query": ("string", ("step_query", "query")), "since": ("string", ("step_query", "step_since")),
|
|
724
|
+
"until": ("string", ("step_query", "step_until")), "limit": ("number", 20)},
|
|
725
|
+
[("notes", "array")],
|
|
726
|
+
after=("step_query", "exec-out"),
|
|
727
|
+
)
|
|
728
|
+
f.template("findings_prompt", "Findings prompt", prompts["mine_findings"], {
|
|
729
|
+
"question": S("question"), "step": ("each_step", "item"), "hits": (step_hits, "tool_output"),
|
|
730
|
+
})
|
|
731
|
+
f.llm("findings", "Read this slice", schema=schemas["SliceFindings"], prompt=("findings_prompt", "result"),
|
|
732
|
+
provider=S("provider"), model=S("model"), system=SYSTEM_JSON)
|
|
733
|
+
f.then(step_ok[0], "findings", step_ok[1])
|
|
734
|
+
f.code(
|
|
735
|
+
"finding", "Keep the findings",
|
|
736
|
+
"return {\"entry\": {\"step\": item, \"findings\": data if isinstance(data, dict) else {},\n"
|
|
737
|
+
" \"notes\": [n for n in (notes or []) if isinstance(n, dict)]}}",
|
|
738
|
+
inputs={"item": ("object", ("each_step", "item")), "data": ("object", ("findings", "data")),
|
|
739
|
+
"notes": ("array", (step_hits, "notes"))},
|
|
740
|
+
outputs=[("entry", "object")],
|
|
741
|
+
)
|
|
742
|
+
f.then("findings", "finding")
|
|
743
|
+
f.add_to_list("keep_finding", "Keep the findings", "mine_findings", ("finding", "entry"))
|
|
744
|
+
f.then("finding", "keep_finding")
|
|
745
|
+
f.get_var("all_findings", "mine_findings", [])
|
|
746
|
+
f.template("dossier_prompt", "Dossier prompt", prompts["mine_dossier"], {
|
|
747
|
+
"question": S("question"), "plan": ("plan", "data"), "findings": ("all_findings", "value"),
|
|
748
|
+
})
|
|
749
|
+
f.llm("dossier", "Write the dossier", schema=schemas["Dossier"], prompt=("dossier_prompt", "result"),
|
|
750
|
+
provider=S("provider"), model=S("model"), system=SYSTEM_JSON)
|
|
751
|
+
f.then("each_step", "dossier", "done")
|
|
752
|
+
f.code(
|
|
753
|
+
"dossier_note", "Dossier note",
|
|
754
|
+
"d = data if isinstance(data, dict) else {}\n"
|
|
755
|
+
"seen, evidence = set(), []\n"
|
|
756
|
+
"pools = [first_notes or []] + [e.get(\"notes\") or [] for e in (findings or []) if isinstance(e, dict)]\n"
|
|
757
|
+
"for pool in pools:\n"
|
|
758
|
+
" for n in pool:\n"
|
|
759
|
+
" nid = n.get(\"note_id\") if isinstance(n, dict) else None\n"
|
|
760
|
+
" if nid and nid not in seen:\n"
|
|
761
|
+
" seen.add(nid)\n"
|
|
762
|
+
" evidence.append({\"note_id\": nid, \"title\": n.get(\"title\"), \"kind\": n.get(\"kind\"), \"occurred_at\": n.get(\"occurred_at\"),\n"
|
|
763
|
+
" \"excerpt\": n.get(\"excerpt\")})\n"
|
|
764
|
+
"entity_ids = [x.get(\"entity_id\") for x in (first_entities or []) if isinstance(x, dict) and x.get(\"entity_id\")]\n"
|
|
765
|
+
"narrative = str(d.get(\"narrative\") or \"\").strip()\n"
|
|
766
|
+
"if not narrative:\n"
|
|
767
|
+
" raise ValueError(\"The dossier came back empty.\")\n"
|
|
768
|
+
"lines = [narrative]\n"
|
|
769
|
+
"def section(title, items):\n"
|
|
770
|
+
" rows = [str(i).strip() for i in (items or []) if str(i).strip()]\n"
|
|
771
|
+
" if rows:\n"
|
|
772
|
+
" lines.append(\"\")\n"
|
|
773
|
+
" lines.append(\"## \" + title)\n"
|
|
774
|
+
" lines.extend([\"- \" + r for r in rows])\n"
|
|
775
|
+
"section(\"Timeline\", [str(t.get(\"at\") or \"\") + \": \" + str(t.get(\"event\") or \"\") for t in (d.get(\"timeline\") or []) if isinstance(t, dict)])\n"
|
|
776
|
+
"if str(d.get(\"evolution\") or \"\").strip():\n"
|
|
777
|
+
" lines.extend([\"\", \"## How the view evolved\", str(d.get(\"evolution\")).strip()])\n"
|
|
778
|
+
"section(\"Contradictions\", d.get(\"contradictions\"))\n"
|
|
779
|
+
"section(\"Worth revisiting\", d.get(\"forgotten\"))\n"
|
|
780
|
+
"section(\"Suggestions\", d.get(\"suggestions\"))\n"
|
|
781
|
+
"if str(d.get(\"confidence\") or \"\").strip():\n"
|
|
782
|
+
" lines.extend([\"\", \"## Confidence\", str(d.get(\"confidence\")).strip()])\n"
|
|
783
|
+
"return {\"title\": str(d.get(\"title\") or question)[:120], \"body\": \"\\n\".join(lines), \"narrative\": narrative,\n"
|
|
784
|
+
" \"evidence\": evidence, \"evidence_note_ids\": [e[\"note_id\"] for e in evidence], \"entity_ids\": entity_ids}",
|
|
785
|
+
inputs={"data": ("object", ("dossier", "data")), "findings": ("array", ("all_findings", "value")),
|
|
786
|
+
"first_notes": ("array", (first, "notes")), "first_entities": ("array", (first, "entities")),
|
|
787
|
+
"question": ("string", S("question"))},
|
|
788
|
+
outputs=[("title", "string"), ("body", "string"), ("narrative", "string"), ("evidence", "array"),
|
|
789
|
+
("evidence_note_ids", "array"), ("entity_ids", "array")],
|
|
790
|
+
)
|
|
791
|
+
f.then("dossier", "dossier_note")
|
|
792
|
+
wrote = f.tool_step(
|
|
793
|
+
"write", "Keep the dossier", "note_insight_write",
|
|
794
|
+
{"kind": ("string", "dossier"), "title": ("string", ("dossier_note", "title")),
|
|
795
|
+
"body": ("string", ("dossier_note", "body")),
|
|
796
|
+
"evidence_note_ids": ("array", ("dossier_note", "evidence_note_ids")),
|
|
797
|
+
"entity_ids": ("array", ("dossier_note", "entity_ids"))},
|
|
798
|
+
[("note_id", "string")],
|
|
799
|
+
after=("dossier_note", "exec-out"),
|
|
800
|
+
)
|
|
801
|
+
f.code(
|
|
802
|
+
"shape", "Shape the answer",
|
|
803
|
+
"d = data if isinstance(data, dict) else {}\n"
|
|
804
|
+
"ev = d.get(\"evolution\")\n"
|
|
805
|
+
"return {\"response\": narrative, \"answer\": narrative, \"evidence\": evidence,\n"
|
|
806
|
+
" \"evolution\": {\"summary\": ev} if ev else {}, \"open_loops\": d.get(\"forgotten\") or [],\n"
|
|
807
|
+
" \"follow_ups\": d.get(\"suggestions\") or [], \"dossier_note_id\": note_id}",
|
|
808
|
+
inputs={"data": ("object", ("dossier", "data")), "narrative": ("string", ("dossier_note", "narrative")),
|
|
809
|
+
"evidence": ("array", ("dossier_note", "evidence")), "note_id": ("string", (wrote, "note_id"))},
|
|
810
|
+
outputs=[(p, t) for p, t in RECALL_OUT_PINS if p != "hits"],
|
|
811
|
+
)
|
|
812
|
+
f.then(wrote, "shape")
|
|
813
|
+
f.end("end", "Done", {p: (t, ("shape", p)) for p, t in RECALL_OUT_PINS if p != "hits"})
|
|
814
|
+
f.then("shape", "end")
|
|
815
|
+
return f.finish()
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def build_reflect(schemas: Dict[str, Any], prompts: Dict[str, str]) -> Dict[str, Any]:
|
|
819
|
+
f = Flow(
|
|
820
|
+
"note-reflect", "Reflect on your notes",
|
|
821
|
+
"Gather open loops, contradictions, resurfacing material and anniversaries; choose what deserves "
|
|
822
|
+
"attention and write it down as insights.",
|
|
823
|
+
interfaces=INTERFACES["note-reflect"],
|
|
824
|
+
)
|
|
825
|
+
f.start([("limit", "number"), ("since", "string"), *_model_pins()], {"limit": 10, "provider": "", "model": ""})
|
|
826
|
+
S = lambda p: ("start", p) # noqa: E731
|
|
827
|
+
prev = ("start", "exec-out")
|
|
828
|
+
gathered = {}
|
|
829
|
+
for tool, label in (("note_open_loops", "Open loops"), ("note_contradictions", "Contradictions"),
|
|
830
|
+
("note_resurface", "Gone quiet"), ("note_anniversaries", "Anniversaries")):
|
|
831
|
+
step = tool.replace("note_", "")
|
|
832
|
+
out = f.tool_step(step, label, tool, {"limit": ("number", S("limit")), "since": ("string", S("since"))},
|
|
833
|
+
[("items", "array")], after=prev)
|
|
834
|
+
gathered[step] = out
|
|
835
|
+
prev = (out, "exec-out")
|
|
836
|
+
f.code(
|
|
837
|
+
"candidates", "Candidates",
|
|
838
|
+
"cands = []\n"
|
|
839
|
+
"for source, items in ((\"open_loops\", open_loops), (\"contradictions\", contradictions),\n"
|
|
840
|
+
" (\"resurface\", resurface), (\"anniversaries\", anniversaries)):\n"
|
|
841
|
+
" for it in items or []:\n"
|
|
842
|
+
" if isinstance(it, dict):\n"
|
|
843
|
+
" cands.append(dict(it, index=len(cands), source=source))\n"
|
|
844
|
+
"return {\"candidates\": cands, \"count\": len(cands)}",
|
|
845
|
+
inputs={k: ("array", (v, "items")) for k, v in (
|
|
846
|
+
("open_loops", gathered["open_loops"]), ("contradictions", gathered["contradictions"]),
|
|
847
|
+
("resurface", gathered["resurface"]), ("anniversaries", gathered["anniversaries"]))},
|
|
848
|
+
outputs=[("candidates", "array"), ("count", "number")],
|
|
849
|
+
)
|
|
850
|
+
f.then(prev[0], "candidates", prev[1])
|
|
851
|
+
f.template("reflect_prompt", "Reflection prompt", prompts["reflect"], {
|
|
852
|
+
"candidates": ("candidates", "candidates"), "limit": S("limit"),
|
|
853
|
+
})
|
|
854
|
+
f.llm("reflect", "Choose what matters", schema=schemas["Reflections"], prompt=("reflect_prompt", "result"),
|
|
855
|
+
provider=S("provider"), model=S("model"), system=SYSTEM_JSON)
|
|
856
|
+
f.then("candidates", "reflect")
|
|
857
|
+
f.code(
|
|
858
|
+
"chosen", "Chosen insights",
|
|
859
|
+
"d = data if isinstance(data, dict) else {}\n"
|
|
860
|
+
"by_index = {c.get(\"index\"): c for c in (candidates or []) if isinstance(c, dict)}\n"
|
|
861
|
+
"out, dropped = [], 0\n"
|
|
862
|
+
"for it in d.get(\"items\") or []:\n"
|
|
863
|
+
" c = by_index.get(it.get(\"candidate\")) if isinstance(it, dict) else None\n"
|
|
864
|
+
" if c is None:\n"
|
|
865
|
+
" dropped += 1\n"
|
|
866
|
+
" continue\n"
|
|
867
|
+
" out.append({\"kind\": it.get(\"kind\"), \"title\": str(it.get(\"title\") or \"\"), \"body\": str(it.get(\"body\") or \"\"),\n"
|
|
868
|
+
" \"evidence_note_ids\": list(c.get(\"note_ids\") or []), \"entity_ids\": list(c.get(\"entity_ids\") or [])})\n"
|
|
869
|
+
"cap = int(limit or 10)\n"
|
|
870
|
+
"return {\"items\": out[:cap], \"dropped\": dropped}",
|
|
871
|
+
inputs={"data": ("object", ("reflect", "data")), "candidates": ("array", ("candidates", "candidates")),
|
|
872
|
+
"limit": ("number", S("limit"))},
|
|
873
|
+
outputs=[("items", "array"), ("dropped", "number")],
|
|
874
|
+
)
|
|
875
|
+
f.then("reflect", "chosen")
|
|
876
|
+
f.for_each("each_insight", "For each insight", ("chosen", "items"))
|
|
877
|
+
f.then("chosen", "each_insight")
|
|
878
|
+
f.break_object("insight", "Insight", ("each_insight", "item"), [
|
|
879
|
+
("kind", "string"), ("title", "string"), ("body", "string"), ("evidence_note_ids", "array"),
|
|
880
|
+
("entity_ids", "array"),
|
|
881
|
+
])
|
|
882
|
+
wrote, wrote_ok = f.loop_tool_step(
|
|
883
|
+
"write", "Write the insight", "note_insight_write",
|
|
884
|
+
{"kind": ("string", ("insight", "kind")), "title": ("string", ("insight", "title")),
|
|
885
|
+
"body": ("string", ("insight", "body")), "evidence_note_ids": ("array", ("insight", "evidence_note_ids")),
|
|
886
|
+
"entity_ids": ("array", ("insight", "entity_ids"))},
|
|
887
|
+
[("note_id", "string")],
|
|
888
|
+
after=("each_insight", "loop"),
|
|
889
|
+
)
|
|
890
|
+
f.add_to_list("keep_written", "Remember it", "reflect_written", (wrote, "note_id"))
|
|
891
|
+
f.then(wrote_ok[0], "keep_written", wrote_ok[1])
|
|
892
|
+
f.get_var("written", "reflect_written", [])
|
|
893
|
+
f.code(
|
|
894
|
+
"report", "Report",
|
|
895
|
+
"n = len(written or [])\n"
|
|
896
|
+
"msg = \"Nothing needs your attention.\" if n == 0 else (\"Wrote \" + str(n) + (\" insight.\" if n == 1 else \" insights.\"))\n"
|
|
897
|
+
"return {\"message\": msg, \"insight_note_ids\": list(written or []), \"considered\": int(count or 0)}",
|
|
898
|
+
inputs={"written": ("array", ("written", "value")), "count": ("number", ("candidates", "count"))},
|
|
899
|
+
outputs=[("message", "string"), ("insight_note_ids", "array"), ("considered", "number")],
|
|
900
|
+
)
|
|
901
|
+
f.then("each_insight", "report", "done")
|
|
902
|
+
f.end("end", "Done", {
|
|
903
|
+
"response": ("string", ("report", "message")), "insight_note_ids": ("array", ("report", "insight_note_ids")),
|
|
904
|
+
"considered": ("number", ("report", "considered")),
|
|
905
|
+
})
|
|
906
|
+
f.then("report", "end")
|
|
907
|
+
return f.finish(automation_defaults=AUTOMATION_DEFAULTS["note-reflect"])
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
BUILDERS = {
|
|
911
|
+
"note-capture": build_capture,
|
|
912
|
+
"note-process": build_process,
|
|
913
|
+
"note-drain": build_drain,
|
|
914
|
+
"note-recall": build_recall,
|
|
915
|
+
"note-mine": build_mine,
|
|
916
|
+
"note-reflect": build_reflect,
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def build_all() -> Dict[str, Dict[str, Any]]:
|
|
921
|
+
schemas = load_schemas()
|
|
922
|
+
prompts = load_prompts()
|
|
923
|
+
for name, allowed in FLOW_PROMPT_VARS.items():
|
|
924
|
+
if name not in prompts:
|
|
925
|
+
raise RuntimeError(f"prompt {name!r} is missing")
|
|
926
|
+
return {fid: BUILDERS[fid](schemas, prompts) for fid in ALL_FLOWS}
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def render(flow: Dict[str, Any]) -> str:
|
|
930
|
+
return json.dumps(flow, indent=2, ensure_ascii=False, sort_keys=False) + "\n"
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def write_all(out_dir: Path = JSON_DIR) -> List[Path]:
|
|
934
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
935
|
+
paths = []
|
|
936
|
+
for fid, flow in build_all().items():
|
|
937
|
+
path = out_dir / f"{fid}.json"
|
|
938
|
+
path.write_text(render(flow), encoding="utf-8")
|
|
939
|
+
paths.append(path)
|
|
940
|
+
return paths
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def stale(out_dir: Path = JSON_DIR) -> List[str]:
|
|
944
|
+
out = []
|
|
945
|
+
for fid, flow in build_all().items():
|
|
946
|
+
path = out_dir / f"{fid}.json"
|
|
947
|
+
if not path.is_file() or path.read_text(encoding="utf-8") != render(flow):
|
|
948
|
+
out.append(fid)
|
|
949
|
+
return out
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def main(argv: List[str] | None = None) -> int:
|
|
953
|
+
ap = argparse.ArgumentParser(prog="python -m abstractnote.flows.build", description=__doc__.splitlines()[0])
|
|
954
|
+
ap.add_argument("--out", type=Path, default=JSON_DIR, help="output directory (default: the package's flows/json)")
|
|
955
|
+
ap.add_argument("--check", action="store_true", help="exit 1 when the JSON on disk differs from the generator")
|
|
956
|
+
args = ap.parse_args(argv)
|
|
957
|
+
if args.check:
|
|
958
|
+
bad = stale(args.out)
|
|
959
|
+
if bad:
|
|
960
|
+
print("stale flows: " + ", ".join(bad), file=sys.stderr)
|
|
961
|
+
return 1
|
|
962
|
+
print("flows up to date")
|
|
963
|
+
return 0
|
|
964
|
+
for p in write_all(args.out):
|
|
965
|
+
print(p)
|
|
966
|
+
return 0
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
if __name__ == "__main__":
|
|
970
|
+
raise SystemExit(main())
|