embers-memory 0.2.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.
- embers/__init__.py +3 -0
- embers/__main__.py +3 -0
- embers/build.py +399 -0
- embers/cli.py +1017 -0
- embers/dag.py +458 -0
- embers/detect.py +49 -0
- embers/flinch.py +248 -0
- embers/ingest.py +634 -0
- embers/records.py +353 -0
- embers/skill/SKILL.md +176 -0
- embers/skillinstall.py +37 -0
- embers/summarize.py +331 -0
- embers_memory-0.2.0.dist-info/METADATA +67 -0
- embers_memory-0.2.0.dist-info/RECORD +18 -0
- embers_memory-0.2.0.dist-info/WHEEL +5 -0
- embers_memory-0.2.0.dist-info/entry_points.txt +3 -0
- embers_memory-0.2.0.dist-info/licenses/LICENSE +21 -0
- embers_memory-0.2.0.dist-info/top_level.txt +1 -0
embers/__init__.py
ADDED
embers/__main__.py
ADDED
embers/build.py
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""Build orchestration: node -> prompt pack -> summarizer -> flinch detector -> accepted / flagged / manual.
|
|
2
|
+
|
|
3
|
+
Idempotent: only pending (or interrupted-flagged) nodes are built; a re-run after a re-ingest rebuilds
|
|
4
|
+
only what the new data invalidated. Every node is committed on its own, so an interrupted or
|
|
5
|
+
budget-stopped build resumes where it left off.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import dataclasses
|
|
10
|
+
import json
|
|
11
|
+
import sqlite3
|
|
12
|
+
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
|
13
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
|
|
14
|
+
|
|
15
|
+
from . import dag
|
|
16
|
+
from .dag import LEVELS, READY, PROSE_CLASSES
|
|
17
|
+
from .flinch import Baseline, FlinchReport, baseline_for_level, evaluate, extract_proper_nouns
|
|
18
|
+
from .ingest import now_iso
|
|
19
|
+
from .summarize import FALLBACK_TIER, BudgetExceeded, PromptPack, SummaryResult, Summarizer, PRICES, UNKNOWN_PRICE
|
|
20
|
+
|
|
21
|
+
NOISE_CLASSES = ("tool_use", "tool_result", "attachment", "system", "summary")
|
|
22
|
+
NOISE_PREVIEW = 120
|
|
23
|
+
MAX_NOISE_LINES = 60
|
|
24
|
+
EST_RATIO = 0.15 # dry-run guess: summary bytes / source bytes
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _preview(text: str, n: int = NOISE_PREVIEW) -> str:
|
|
28
|
+
s = " ".join((text or "").split())
|
|
29
|
+
return s[:n]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _span(first: Optional[str], last: Optional[str]) -> str:
|
|
33
|
+
return "%s -> %s" % ((first or "?")[:16], (last or "?")[:16])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ----------------------------------------------------------------------------- packs
|
|
37
|
+
|
|
38
|
+
def leaf_pack(conn: sqlite3.Connection, node: sqlite3.Row, meta: Dict[str, Any]) -> PromptPack:
|
|
39
|
+
rows = conn.execute(
|
|
40
|
+
"""SELECT b.* FROM node_sources ns JOIN blocks b ON b.uuid = ns.uuid WHERE ns.node_id = ?
|
|
41
|
+
ORDER BY COALESCE(b.ts, ''), b.line_no, b.block_index""", (node["id"],)).fetchall()
|
|
42
|
+
segments, noise = [], []
|
|
43
|
+
lines = ["LEVEL: leaf SPAN: %s MESSAGES: %d" % (_span(node["first_ts"], node["last_ts"]), node["n_sources"]),
|
|
44
|
+
"--- transcript ---"]
|
|
45
|
+
for b in rows:
|
|
46
|
+
cls = b["class"]
|
|
47
|
+
text = (b["text"] or "").strip()
|
|
48
|
+
if cls in PROSE_CLASSES:
|
|
49
|
+
if text:
|
|
50
|
+
segments.append((cls, text))
|
|
51
|
+
lines.append("[%s] %s" % (cls, text))
|
|
52
|
+
elif cls in NOISE_CLASSES and text:
|
|
53
|
+
if cls == "tool_use":
|
|
54
|
+
name = b["tool_name"] or "?"
|
|
55
|
+
body = text[len(name):] if text.startswith(name) else text
|
|
56
|
+
entry = "tool_use %s: %s" % (name, _preview(body))
|
|
57
|
+
else:
|
|
58
|
+
entry = "%s%s: %s" % (cls, "[%s]" % b["block_type"] if b["block_type"] else "", _preview(text))
|
|
59
|
+
noise.append(entry)
|
|
60
|
+
elif b["skipped_kind"] in ("image", "document"):
|
|
61
|
+
noise.append("%s attached (%d bytes, not shown)" % (b["skipped_kind"], b["skipped_bytes"]))
|
|
62
|
+
if len(noise) > MAX_NOISE_LINES:
|
|
63
|
+
extra = len(noise) - MAX_NOISE_LINES
|
|
64
|
+
noise = noise[:MAX_NOISE_LINES] + ["(+%d more tool/system items)" % extra]
|
|
65
|
+
lines += ["· " + n for n in noise]
|
|
66
|
+
lines.append("--- end ---")
|
|
67
|
+
prose = "\n".join(t for _, t in segments)
|
|
68
|
+
return PromptPack(level="leaf", text="\n".join(lines), source_bytes=node["source_bytes"],
|
|
69
|
+
proper_nouns=extract_proper_nouns(prose), sensitivity=meta.get("sensitivity", "normal"),
|
|
70
|
+
segments=segments, noise=noise, span=_span(node["first_ts"], node["last_ts"]),
|
|
71
|
+
n_sources=node["n_sources"])
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def children_of(conn: sqlite3.Connection, node_id: str) -> List[sqlite3.Row]:
|
|
75
|
+
return conn.execute("SELECT * FROM nodes WHERE parent_id = ? ORDER BY COALESCE(first_ts, ''), id", (node_id,)).fetchall()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def upper_pack(node: sqlite3.Row, children: List[sqlite3.Row], meta: Dict[str, Any]) -> PromptPack:
|
|
79
|
+
segments = []
|
|
80
|
+
kind = meta.get("kind")
|
|
81
|
+
kind_note = {"hearth": " KIND: hearth (live conversations)",
|
|
82
|
+
"scheduled": " KIND: scheduled (unattended automated runs)"}.get(kind, "")
|
|
83
|
+
lines = ["LEVEL: %s SPAN: %s PARTS: %d%s" % (node["level"], _span(node["first_ts"], node["last_ts"]),
|
|
84
|
+
len(children), kind_note),
|
|
85
|
+
"--- parts ---"]
|
|
86
|
+
for i, ch in enumerate(children, start=1):
|
|
87
|
+
if ch["status"] == "ok":
|
|
88
|
+
body = ch["summary"].strip()
|
|
89
|
+
else:
|
|
90
|
+
body = "[summary unavailable: this part was flagged for manual summarization (%d sources)]" % ch["n_sources"]
|
|
91
|
+
head = "[part %d, %s, %s]" % (i, ch["level"], _span(ch["first_ts"], ch["last_ts"]))
|
|
92
|
+
segments.append(("child", body))
|
|
93
|
+
lines.append(head + "\n" + body)
|
|
94
|
+
lines.append("--- end ---")
|
|
95
|
+
joined = "\n".join(t for _, t in segments)
|
|
96
|
+
return PromptPack(level=node["level"], text="\n".join(lines), source_bytes=len(joined.encode("utf-8")),
|
|
97
|
+
proper_nouns=extract_proper_nouns(joined), sensitivity=meta.get("sensitivity", "normal"),
|
|
98
|
+
segments=segments, span=_span(node["first_ts"], node["last_ts"]), n_sources=len(children))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def make_pack(conn: sqlite3.Connection, node: sqlite3.Row) -> Optional[PromptPack]:
|
|
102
|
+
"""The prompt pack for a node, or None while any child is not yet accepted/manual."""
|
|
103
|
+
meta = json.loads(node["metrics_json"] or "{}")
|
|
104
|
+
if node["level"] == "leaf":
|
|
105
|
+
return leaf_pack(conn, node, meta)
|
|
106
|
+
kids = children_of(conn, node["id"])
|
|
107
|
+
if not kids or any(k["status"] not in READY for k in kids):
|
|
108
|
+
return None
|
|
109
|
+
return upper_pack(node, kids, meta)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ----------------------------------------------------------------------------- build
|
|
113
|
+
|
|
114
|
+
@dataclasses.dataclass
|
|
115
|
+
class BuildOptions:
|
|
116
|
+
levels: Sequence[str] = LEVELS
|
|
117
|
+
thread_ids: Optional[List[str]] = None
|
|
118
|
+
chunk_bytes: int = dag.DEFAULT_CHUNK_BYTES
|
|
119
|
+
limit: int = 0
|
|
120
|
+
dry_run: bool = False
|
|
121
|
+
workers: int = 1 # concurrent summarizer calls within a level (network-bound; SQLite stays single-writer)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _pending(conn: sqlite3.Connection, level: str, scope: Optional[List[str]]) -> List[sqlite3.Row]:
|
|
125
|
+
rows = conn.execute("""SELECT * FROM nodes WHERE level = ? AND status IN ('pending', 'flagged')
|
|
126
|
+
ORDER BY COALESCE(first_ts, ''), id""", (level,)).fetchall()
|
|
127
|
+
if scope is not None and level != "month":
|
|
128
|
+
keep = set(scope)
|
|
129
|
+
rows = [r for r in rows if r["thread_id"] in keep]
|
|
130
|
+
return rows
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _store(conn: sqlite3.Connection, node: sqlite3.Row, pack: PromptPack, res: SummaryResult, status: str,
|
|
134
|
+
attempts: int, report: FlinchReport, history: List[Dict[str, Any]], kids: Optional[List[sqlite3.Row]]) -> None:
|
|
135
|
+
meta = json.loads(node["metrics_json"] or "{}")
|
|
136
|
+
meta.update(sensitivity=pack.sensitivity, report=report.as_dict(), history=history[-4:],
|
|
137
|
+
usage={"input_tokens": res.input_tokens, "output_tokens": res.output_tokens, "cost_usd": res.cost_usd})
|
|
138
|
+
if res.refused:
|
|
139
|
+
meta["refused"] = res.detail
|
|
140
|
+
if kids is not None:
|
|
141
|
+
meta["children_sig"] = dag.children_sig(kids)
|
|
142
|
+
conn.execute("""UPDATE nodes SET summary=?, summary_bytes=?, model=?, backend=?, status=?, attempts=?, metrics_json=?,
|
|
143
|
+
source_bytes=?, created_at=? WHERE id=?""",
|
|
144
|
+
(res.text, len(res.text.encode("utf-8")), res.model, res.backend, status, attempts, json.dumps(meta),
|
|
145
|
+
pack.source_bytes, now_iso(), node["id"]))
|
|
146
|
+
conn.execute("DELETE FROM nodes_fts WHERE node_id = ?", (node["id"],))
|
|
147
|
+
if status == "ok" and res.text:
|
|
148
|
+
conn.execute("INSERT INTO nodes_fts(node_id, summary) VALUES (?, ?)", (node["id"], res.text))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def set_manual_summary(conn: sqlite3.Connection, node: sqlite3.Row, text: str) -> None:
|
|
152
|
+
"""Store an operator-written summary: backend `manual`, model `human`, status `ok`, indexed in nodes_fts.
|
|
153
|
+
The metrics (report, earlier history) are kept and a {"tier": "manual"} entry is appended. Ancestors built
|
|
154
|
+
over this node's placeholder are rebuilt by the next `mem build` (their child signature changes)."""
|
|
155
|
+
text = text.strip()
|
|
156
|
+
if not text:
|
|
157
|
+
raise ValueError("empty summary")
|
|
158
|
+
meta = json.loads(node["metrics_json"] or "{}")
|
|
159
|
+
nbytes = len(text.encode("utf-8"))
|
|
160
|
+
meta["history"] = (meta.get("history") or []) + [{"tier": "manual", "model": "human", "summary_bytes": nbytes,
|
|
161
|
+
"failed": [], "detail": {}, "evasion_hits": None}]
|
|
162
|
+
meta["manual"] = True
|
|
163
|
+
conn.execute("""UPDATE nodes SET summary=?, summary_bytes=?, model='human', backend='manual', status='ok',
|
|
164
|
+
metrics_json=?, created_at=? WHERE id=?""", (text, nbytes, json.dumps(meta), now_iso(), node["id"]))
|
|
165
|
+
conn.execute("DELETE FROM nodes_fts WHERE node_id = ?", (node["id"],))
|
|
166
|
+
conn.execute("INSERT INTO nodes_fts(node_id, summary) VALUES (?, ?)", (node["id"], text))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclasses.dataclass
|
|
170
|
+
class _Job:
|
|
171
|
+
"""One node moving through its attempts. Owned by the main thread; workers only see snapshots."""
|
|
172
|
+
node: sqlite3.Row
|
|
173
|
+
pack: PromptPack
|
|
174
|
+
kids: Optional[List[sqlite3.Row]]
|
|
175
|
+
failures: int
|
|
176
|
+
history: List[Dict[str, Any]]
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class Builder:
|
|
180
|
+
"""Escalation per node: small tier (leaf) / mid (upper) -> rerun tier -> (only if the rerun was refused)
|
|
181
|
+
small tier at effort medium -> manual. Every outcome is committed by the MAIN thread; worker threads
|
|
182
|
+
only call `summarizer.summarize()` and `flinch.evaluate()`, so SQLite has a single writer and an
|
|
183
|
+
interruption leaves finished nodes stored and unfinished ones untouched."""
|
|
184
|
+
|
|
185
|
+
def __init__(self, conn: sqlite3.Connection, summarizer: Summarizer, log: Callable[[str], None] = lambda s: None,
|
|
186
|
+
workers: int = 1):
|
|
187
|
+
self.conn = conn
|
|
188
|
+
self.summarizer = summarizer
|
|
189
|
+
self.log = log
|
|
190
|
+
self.workers = max(1, int(workers or 1))
|
|
191
|
+
self._baselines: Dict[str, Optional[Baseline]] = {}
|
|
192
|
+
self._since: Dict[str, int] = {}
|
|
193
|
+
|
|
194
|
+
def baseline(self, level: str) -> Optional[Baseline]:
|
|
195
|
+
"""Read from the DB (main thread) once per batch of 25 accepted nodes, not per node."""
|
|
196
|
+
n = self._since.get(level, 10 ** 9)
|
|
197
|
+
if level not in self._baselines or n >= 25:
|
|
198
|
+
self._baselines[level] = baseline_for_level(self.conn, level)
|
|
199
|
+
self._since[level] = 0
|
|
200
|
+
return self._baselines[level]
|
|
201
|
+
|
|
202
|
+
# ---- the pieces
|
|
203
|
+
def _tier(self, level: str, step: int) -> str:
|
|
204
|
+
if step <= 0:
|
|
205
|
+
return "small" if level == "leaf" else "mid"
|
|
206
|
+
if step == 1:
|
|
207
|
+
return self.summarizer.rerun_tier(level)
|
|
208
|
+
return FALLBACK_TIER
|
|
209
|
+
|
|
210
|
+
def _attempt(self, job_pack: PromptPack, node_level: str, tier: str,
|
|
211
|
+
baseline: Optional[Baseline]) -> Tuple[SummaryResult, FlinchReport]:
|
|
212
|
+
"""WORKER THREAD. No database access."""
|
|
213
|
+
res = self.summarizer.summarize(job_pack, tier)
|
|
214
|
+
report = evaluate(res.text, level=node_level, source_bytes=job_pack.source_bytes,
|
|
215
|
+
proper_nouns=job_pack.proper_nouns, sensitivity=job_pack.sensitivity,
|
|
216
|
+
baseline=baseline, source_text=job_pack.text)
|
|
217
|
+
return res, report
|
|
218
|
+
|
|
219
|
+
def _submit(self, ex: ThreadPoolExecutor, inflight: Dict[Any, Tuple[_Job, int, str]], job: _Job) -> None:
|
|
220
|
+
step = min(job.failures, 2)
|
|
221
|
+
tier = self._tier(job.node["level"], step)
|
|
222
|
+
p = job.pack
|
|
223
|
+
if job.history:
|
|
224
|
+
if "truncated" in (job.history[-1].get("failed") or []):
|
|
225
|
+
from .summarize import MAX_WORDS
|
|
226
|
+
p = dataclasses.replace(p, feedback="it was CUT OFF by the output length limit. Write at most %d words "
|
|
227
|
+
"this time: raise the altitude, group rather than enumerate, keep "
|
|
228
|
+
"every concrete name that matters."
|
|
229
|
+
% MAX_WORDS.get(job.node["level"], 900))
|
|
230
|
+
else:
|
|
231
|
+
p = dataclasses.replace(p, feedback="it failed the quality checks (%s); cover every concrete name, "
|
|
232
|
+
"decision and event, and do not omit or soften anything."
|
|
233
|
+
% ", ".join(job.history[-1].get("failed") or ["?"]))
|
|
234
|
+
fut = ex.submit(self._attempt, p, job.node["level"], tier, self.baseline(job.node["level"]))
|
|
235
|
+
inflight[fut] = (job, step, tier)
|
|
236
|
+
|
|
237
|
+
def _apply(self, job: _Job, step: int, tier: str, res: SummaryResult, report: FlinchReport) -> str:
|
|
238
|
+
"""MAIN THREAD. Record the attempt, store and commit. Returns ok | flagged (rerun pending) | manual."""
|
|
239
|
+
node, conn = job.node, self.conn
|
|
240
|
+
entry: Dict[str, Any] = {
|
|
241
|
+
"tier": tier, "model": res.model, "summary_bytes": len(res.text.encode("utf-8")),
|
|
242
|
+
"failed": report.failed, "detail": res.detail,
|
|
243
|
+
"evasion_hits": next((c.detail.get("hits") for c in report.checks if c.name == "evasion"), None)}
|
|
244
|
+
if step >= 2:
|
|
245
|
+
entry.update(effort="medium", reason="rerun refused; one more try on the small tier at effort medium")
|
|
246
|
+
if res.refused:
|
|
247
|
+
entry["refused"] = True
|
|
248
|
+
truncated = bool(res.detail.get("truncated")) if isinstance(res.detail, dict) else False
|
|
249
|
+
if truncated:
|
|
250
|
+
entry["failed"] = list(report.failed) + ["truncated"] # a cut-off summary never passes, whatever the checks say
|
|
251
|
+
job.history.append(entry)
|
|
252
|
+
if report.passed and not truncated:
|
|
253
|
+
_store(conn, node, job.pack, res, "ok", job.failures, report, job.history, job.kids)
|
|
254
|
+
self._since[node["level"]] = self._since.get(node["level"], 0) + 1
|
|
255
|
+
conn.commit()
|
|
256
|
+
return "ok"
|
|
257
|
+
job.failures += 1
|
|
258
|
+
if step == 0:
|
|
259
|
+
status = "flagged"
|
|
260
|
+
elif step == 1 and res.refused:
|
|
261
|
+
status = "flagged"
|
|
262
|
+
entry["note"] = "refused; next try: small tier at effort medium"
|
|
263
|
+
else:
|
|
264
|
+
status = "manual"
|
|
265
|
+
_store(conn, node, job.pack, res, status, job.failures, report, job.history, job.kids)
|
|
266
|
+
conn.commit()
|
|
267
|
+
self.log("%s %s %s: failed %s" % (node["level"], node["id"],
|
|
268
|
+
"-> manual" if status == "manual" else "-> rerun on " + self._tier(node["level"], job.failures),
|
|
269
|
+
",".join(entry["failed"])))
|
|
270
|
+
return status
|
|
271
|
+
|
|
272
|
+
# ---- one level
|
|
273
|
+
def run_level(self, level: str, pending: List[sqlite3.Row], remaining: Optional[int],
|
|
274
|
+
lv: Dict[str, int], out: Dict[str, Any]) -> int:
|
|
275
|
+
"""Build `pending` with up to `self.workers` calls in flight. Returns the number of nodes started.
|
|
276
|
+
Sets out['stopped'] to 'limit' or 'budget' when it stops early. A worker exception stops dispatch,
|
|
277
|
+
lets the calls already in flight finish and be stored, then re-raises (the failed node stays as it was)."""
|
|
278
|
+
conn = self.conn
|
|
279
|
+
nodes = iter(pending)
|
|
280
|
+
inflight: Dict[Any, Tuple[_Job, int, str]] = {}
|
|
281
|
+
started = 0
|
|
282
|
+
exhausted = False
|
|
283
|
+
first_exc: Optional[BaseException] = None
|
|
284
|
+
ex = ThreadPoolExecutor(max_workers=self.workers)
|
|
285
|
+
try:
|
|
286
|
+
while True:
|
|
287
|
+
while not out["stopped"] and first_exc is None and not exhausted and len(inflight) < self.workers:
|
|
288
|
+
node = next(nodes, None)
|
|
289
|
+
if node is None:
|
|
290
|
+
exhausted = True
|
|
291
|
+
break
|
|
292
|
+
if remaining is not None and started >= remaining:
|
|
293
|
+
out["stopped"] = "limit"
|
|
294
|
+
break
|
|
295
|
+
pack = make_pack(conn, node)
|
|
296
|
+
if pack is None:
|
|
297
|
+
lv["not_ready"] += 1
|
|
298
|
+
out["not_ready"] += 1
|
|
299
|
+
continue
|
|
300
|
+
kids = children_of(conn, node["id"]) if level != "leaf" else None
|
|
301
|
+
failures = node["attempts"]
|
|
302
|
+
history = json.loads(node["metrics_json"] or "{}").get("history", []) if failures else []
|
|
303
|
+
self._submit(ex, inflight, _Job(node, pack, kids, failures, history))
|
|
304
|
+
started += 1
|
|
305
|
+
if not inflight:
|
|
306
|
+
break
|
|
307
|
+
done, _ = wait(list(inflight), return_when=FIRST_COMPLETED)
|
|
308
|
+
for fut in done:
|
|
309
|
+
job, step, tier = inflight.pop(fut)
|
|
310
|
+
try:
|
|
311
|
+
res, report = fut.result()
|
|
312
|
+
except BudgetExceeded as e:
|
|
313
|
+
out["stopped"], out["stop_reason"] = "budget", str(e) # node untouched: still pending/flagged
|
|
314
|
+
continue
|
|
315
|
+
except Exception as e: # noqa: BLE001
|
|
316
|
+
first_exc = first_exc or e
|
|
317
|
+
continue
|
|
318
|
+
status = self._apply(job, step, tier, res, report)
|
|
319
|
+
if status == "flagged":
|
|
320
|
+
if out["stopped"] != "budget" and first_exc is None:
|
|
321
|
+
self._submit(ex, inflight, job)
|
|
322
|
+
continue # else left flagged: next build resumes it
|
|
323
|
+
lv[status] += 1
|
|
324
|
+
out["built"] += 1 if status == "ok" else 0
|
|
325
|
+
out["manual"] += 1 if status == "manual" else 0
|
|
326
|
+
if status == "ok" and job.failures:
|
|
327
|
+
out["flagged_then_ok"] += 1
|
|
328
|
+
except BaseException:
|
|
329
|
+
# Ctrl-C or an unexpected error in the main thread: stop everything not yet running, keep what is stored.
|
|
330
|
+
ex.shutdown(wait=False, cancel_futures=True)
|
|
331
|
+
conn.rollback()
|
|
332
|
+
raise
|
|
333
|
+
ex.shutdown(wait=True)
|
|
334
|
+
if first_exc is not None:
|
|
335
|
+
raise first_exc
|
|
336
|
+
return started
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def run_build(conn: sqlite3.Connection, summarizer: Summarizer, opts: BuildOptions,
|
|
340
|
+
log: Callable[[str], None] = lambda s: None) -> Dict[str, Any]:
|
|
341
|
+
"""Sync the node structure, then build pending nodes level by level. Returns a stats dict."""
|
|
342
|
+
if opts.dry_run and not conn.in_transaction:
|
|
343
|
+
conn.execute("BEGIN")
|
|
344
|
+
dag.ensure_schema(conn)
|
|
345
|
+
scope = opts.thread_ids
|
|
346
|
+
sync = dag.sync_structure(conn, opts.chunk_bytes, scope, months=scope is None)
|
|
347
|
+
out: Dict[str, Any] = {"sync": sync, "levels": {}, "built": 0, "flagged_then_ok": 0, "manual": 0,
|
|
348
|
+
"not_ready": 0, "stopped": None, "dry_run": opts.dry_run, "workers": max(1, opts.workers)}
|
|
349
|
+
if opts.dry_run:
|
|
350
|
+
out["plan"] = plan_stats(conn, opts.levels, scope, summarizer)
|
|
351
|
+
conn.rollback()
|
|
352
|
+
return out
|
|
353
|
+
conn.commit()
|
|
354
|
+
b = Builder(conn, summarizer, log, workers=opts.workers)
|
|
355
|
+
done = 0
|
|
356
|
+
for level in LEVELS: # a level starts only when every node of the one below has finished
|
|
357
|
+
if level not in opts.levels:
|
|
358
|
+
continue
|
|
359
|
+
lv = out["levels"].setdefault(level, {"ok": 0, "manual": 0, "not_ready": 0})
|
|
360
|
+
done += b.run_level(level, _pending(conn, level, scope), (opts.limit - done) if opts.limit else None, lv, out)
|
|
361
|
+
if out["stopped"]:
|
|
362
|
+
break
|
|
363
|
+
out["spent_usd"] = round(getattr(summarizer, "spent_usd", 0.0), 6)
|
|
364
|
+
return out
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# ----------------------------------------------------------------------------- dry-run plan
|
|
368
|
+
|
|
369
|
+
def plan_stats(conn: sqlite3.Connection, levels: Sequence[str], scope: Optional[List[str]],
|
|
370
|
+
summarizer: Summarizer) -> Dict[str, Any]:
|
|
371
|
+
"""What a build would do right now: per level, nodes pending, source bytes and estimated tokens.
|
|
372
|
+
Upper-level sources are estimated from existing summaries or EST_RATIO of their own sources."""
|
|
373
|
+
est: Dict[str, float] = {} # node id -> estimated summary bytes
|
|
374
|
+
plan: Dict[str, Any] = {}
|
|
375
|
+
keep = set(scope) if scope is not None else None
|
|
376
|
+
for level in LEVELS:
|
|
377
|
+
rows = conn.execute("SELECT * FROM nodes WHERE level = ?", (level,)).fetchall()
|
|
378
|
+
n_pending = 0
|
|
379
|
+
src_total = 0.0
|
|
380
|
+
for r in rows:
|
|
381
|
+
if level == "leaf":
|
|
382
|
+
src = float(r["source_bytes"])
|
|
383
|
+
else:
|
|
384
|
+
src = sum(est.get(k["id"], 0.0) for k in children_of(conn, r["id"]))
|
|
385
|
+
est[r["id"]] = float(r["summary_bytes"]) if r["status"] in READY and r["summary_bytes"] else src * EST_RATIO
|
|
386
|
+
is_pending = r["status"] in ("pending", "flagged")
|
|
387
|
+
in_scope = keep is None or (level != "month" and r["thread_id"] in keep)
|
|
388
|
+
if is_pending and in_scope:
|
|
389
|
+
n_pending += 1
|
|
390
|
+
src_total += src
|
|
391
|
+
if level in levels:
|
|
392
|
+
tokens = int(src_total / 4)
|
|
393
|
+
tier = "small" if level == "leaf" else "mid"
|
|
394
|
+
model = getattr(summarizer, "models", {}).get(tier)
|
|
395
|
+
pin, pout = PRICES.get(model, UNKNOWN_PRICE) if model else (0.0, 0.0)
|
|
396
|
+
plan[level] = {"nodes": len(rows), "pending": n_pending, "source_bytes": int(src_total),
|
|
397
|
+
"est_input_tokens": tokens,
|
|
398
|
+
"est_cost_usd": round((tokens * pin + tokens * EST_RATIO * pout) / 1e6, 4)}
|
|
399
|
+
return plan
|