brain-framework 9.0.1__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.
bf/index.py ADDED
@@ -0,0 +1,559 @@
1
+ """A disposable SQLite cache that refreshes changed files and answers lexical and time-window queries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import sqlite3
8
+ from collections.abc import Iterator
9
+ from contextlib import closing, contextmanager, suppress
10
+ from datetime import UTC, datetime, timedelta
11
+ from pathlib import Path
12
+
13
+ from bf import records
14
+ from bf.markdown import authored, note
15
+ from bf.models import AUTHORED, Error, Query, Record, moment
16
+ from bf.storage import BusyError, Store, reader, writer
17
+
18
+ SCHEMA = 12
19
+ # Active projects whose note is older than this are listed for review; a reminder, never a failure.
20
+ REVIEW_DAYS = 14
21
+ CACHE = ".bf/index.sqlite"
22
+ _DDL = """
23
+ CREATE TABLE files(path TEXT PRIMARY KEY, size INTEGER, mtime INTEGER, ctime INTEGER, inode INTEGER,
24
+ error TEXT NOT NULL DEFAULT '');
25
+ CREATE TABLE items(id INTEGER PRIMARY KEY, ref TEXT NOT NULL UNIQUE, path TEXT NOT NULL, kind TEXT NOT NULL,
26
+ source TEXT NOT NULL, title TEXT NOT NULL, time TEXT NOT NULL, type TEXT NOT NULL,
27
+ status TEXT NOT NULL, lead TEXT NOT NULL, url TEXT NOT NULL,
28
+ updated TEXT NOT NULL, observed TEXT NOT NULL, partial INTEGER NOT NULL);
29
+ CREATE INDEX items_path ON items(path);
30
+ CREATE INDEX items_time ON items(time);
31
+ CREATE TABLE passages(id INTEGER PRIMARY KEY, item INTEGER NOT NULL, fragment TEXT NOT NULL, title TEXT NOT NULL);
32
+ CREATE INDEX passages_item ON passages(item);
33
+ CREATE VIRTUAL TABLE search USING fts5(title, text, names, tokenize='porter unicode61 remove_diacritics 2');
34
+ CREATE TABLE names(name TEXT NOT NULL, item INTEGER NOT NULL, PRIMARY KEY(name, item)) WITHOUT ROWID;
35
+ CREATE INDEX names_item ON names(item);
36
+ CREATE TABLE links(item INTEGER NOT NULL, target TEXT NOT NULL, PRIMARY KEY(item, target)) WITHOUT ROWID;
37
+ CREATE INDEX links_target ON links(target);
38
+ """
39
+ # Only function words are dropped: a subject such as "resume" or "active" stays literal.
40
+ _STOP = frozenset(
41
+ [
42
+ "a",
43
+ "an",
44
+ "and",
45
+ "are",
46
+ "as",
47
+ "at",
48
+ "be",
49
+ "by",
50
+ "can",
51
+ "could",
52
+ "did",
53
+ "do",
54
+ "does",
55
+ "for",
56
+ "from",
57
+ "how",
58
+ "i",
59
+ "in",
60
+ "is",
61
+ "it",
62
+ "me",
63
+ "my",
64
+ "of",
65
+ "on",
66
+ "or",
67
+ "please",
68
+ "that",
69
+ "the",
70
+ "their",
71
+ "this",
72
+ "to",
73
+ "was",
74
+ "we",
75
+ "were",
76
+ "what",
77
+ "when",
78
+ "where",
79
+ "which",
80
+ "who",
81
+ "why",
82
+ "with",
83
+ "would",
84
+ "you",
85
+ "your",
86
+ "au",
87
+ "aux",
88
+ "avec",
89
+ "ce",
90
+ "ces",
91
+ "cet",
92
+ "cette",
93
+ "comment",
94
+ "d",
95
+ "dans",
96
+ "de",
97
+ "des",
98
+ "du",
99
+ "elle",
100
+ "elles",
101
+ "en",
102
+ "est",
103
+ "et",
104
+ "eux",
105
+ "il",
106
+ "ils",
107
+ "je",
108
+ "l",
109
+ "la",
110
+ "le",
111
+ "les",
112
+ "leur",
113
+ "leurs",
114
+ "lui",
115
+ "mais",
116
+ "mes",
117
+ "mon",
118
+ "nos",
119
+ "notre",
120
+ "nous",
121
+ "où",
122
+ "par",
123
+ "pour",
124
+ "pourquoi",
125
+ "qu",
126
+ "que",
127
+ "quel",
128
+ "quelle",
129
+ "quelles",
130
+ "quels",
131
+ "qui",
132
+ "sa",
133
+ "se",
134
+ "ses",
135
+ "son",
136
+ "sur",
137
+ "tes",
138
+ "toi",
139
+ "ton",
140
+ "tu",
141
+ "un",
142
+ "une",
143
+ "vos",
144
+ "votre",
145
+ "vous",
146
+ "à",
147
+ ]
148
+ )
149
+ _IDENTITY = re.compile(r"[a-z][a-z0-9+.-]*:\S+")
150
+
151
+
152
+ def inputs(store: Store) -> dict[str, tuple[int, int, int, int]]:
153
+ """Authored notes and record partitions with their file fingerprints."""
154
+ names = [n for d in AUTHORED for n in store.files(d) if authored(n)] + records.partitions(store)
155
+ current = {}
156
+ for name in names:
157
+ with suppress(FileNotFoundError):
158
+ current[name] = store.fingerprint(name)
159
+ return current
160
+
161
+
162
+ def _path(store: Store) -> Path:
163
+ directory = store.root / ".bf"
164
+ if directory.is_symlink() or (directory.exists() and not directory.is_dir()):
165
+ raise Error(".bf must be a directory")
166
+ directory.mkdir(mode=0o700, exist_ok=True)
167
+ path = store.root / CACHE
168
+ for suffix in ("", "-wal", "-shm", "-journal"):
169
+ with suppress(FileNotFoundError):
170
+ store.fingerprint(CACHE + suffix)
171
+ return path
172
+
173
+
174
+ def _open(store: Store) -> sqlite3.Connection | None:
175
+ """The current cache generation, or None when it is missing, outdated or unreadable."""
176
+ path = _path(store)
177
+ if not path.exists():
178
+ return None
179
+ connection = sqlite3.connect(path, timeout=30)
180
+ connection.row_factory = sqlite3.Row
181
+ try:
182
+ if connection.execute("PRAGMA user_version").fetchone()[0] == SCHEMA:
183
+ # Version alone does not establish that an interrupted or damaged cache is usable.
184
+ for statement in (
185
+ "SELECT path,size,mtime,ctime,inode,error FROM files LIMIT 0",
186
+ "SELECT ref,path,kind,source,title,time,type,status,lead,url,updated,observed,partial FROM items LIMIT 0",
187
+ "SELECT id,item,fragment,title FROM passages LIMIT 0",
188
+ "SELECT name,item FROM names LIMIT 0",
189
+ "SELECT item,target FROM links LIMIT 0",
190
+ "SELECT title,text,names FROM search LIMIT 0",
191
+ ):
192
+ connection.execute(statement)
193
+ return connection
194
+ except sqlite3.DatabaseError:
195
+ pass
196
+ connection.close()
197
+ return None
198
+
199
+
200
+ def _create(store: Store) -> sqlite3.Connection:
201
+ path = _path(store)
202
+ for suffix in ("", "-wal", "-shm", "-journal"):
203
+ path.with_name(path.name + suffix).unlink(missing_ok=True)
204
+ connection = sqlite3.connect(path, timeout=30)
205
+ # Version zero withholds this generation until the ingestion transaction commits.
206
+ connection.executescript(_DDL)
207
+ connection.execute("PRAGMA journal_mode=WAL")
208
+ connection.row_factory = sqlite3.Row
209
+ return connection
210
+
211
+
212
+ def _known(connection: sqlite3.Connection) -> dict[str, tuple[int, int, int, int]]:
213
+ return {
214
+ row["path"]: (row["size"], row["mtime"], row["ctime"], row["inode"])
215
+ for row in connection.execute("SELECT * FROM files")
216
+ }
217
+
218
+
219
+ def _drop(connection: sqlite3.Connection, path: str) -> None:
220
+ items = [row[0] for row in connection.execute("SELECT id FROM items WHERE path=?", (path,))]
221
+ for item in items:
222
+ connection.execute("DELETE FROM search WHERE rowid IN (SELECT id FROM passages WHERE item=?)", (item,))
223
+ for table in ("passages", "names", "links"):
224
+ connection.execute(f"DELETE FROM {table} WHERE item=?", (item,)) # noqa: S608 - fixed table names
225
+ connection.execute("DELETE FROM items WHERE path=?", (path,))
226
+ connection.execute("DELETE FROM files WHERE path=?", (path,))
227
+
228
+
229
+ def _insert(
230
+ connection: sqlite3.Connection,
231
+ values: dict[str, str | int],
232
+ passages: list[tuple[str, str, str, str, str]],
233
+ names: list[str],
234
+ links: list[str],
235
+ ) -> None:
236
+ try:
237
+ cursor = connection.execute(
238
+ "INSERT INTO items(ref,path,kind,source,title,time,type,status,lead,url,updated,observed,partial) "
239
+ "VALUES(:ref,:path,:kind,:source,:title,:time,:type,:status,:lead,:url,:updated,:observed,:partial)",
240
+ values,
241
+ )
242
+ except sqlite3.IntegrityError:
243
+ raise Error(f"duplicate reference {values['ref']}; run bf validate") from None
244
+ item = cursor.lastrowid
245
+ # `heading` is what ranks; `title` is what readers see, such as "Note — Section".
246
+ for fragment, title, heading, text, extra in passages:
247
+ passage = connection.execute(
248
+ "INSERT INTO passages(item,fragment,title) VALUES(?,?,?)", (item, fragment, title)
249
+ ).lastrowid
250
+ connection.execute(
251
+ "INSERT INTO search(rowid,title,text,names) VALUES(?,?,?,?)", (passage, heading, text, extra)
252
+ )
253
+ connection.executemany("INSERT OR IGNORE INTO names VALUES(?,?)", ((name, item) for name in names))
254
+ connection.executemany("INSERT OR IGNORE INTO links VALUES(?,?)", ((item, link) for link in links))
255
+
256
+
257
+ def _index(connection: sqlite3.Connection, store: Store, path: str) -> list[str]:
258
+ """Insert one file; parse failures raise, while duplicate record ids are skipped and reported."""
259
+ if authored(path):
260
+ projection = note(path, store.read(path, 4 << 20))
261
+ knowledge = projection.knowledge
262
+ metadata = " ".join([knowledge.type, *knowledge.tags, *knowledge.aliases, *projection.links])
263
+ _insert(
264
+ connection,
265
+ {
266
+ "ref": path,
267
+ "path": path,
268
+ "kind": "note",
269
+ "source": "",
270
+ "title": projection.title,
271
+ "time": f"{knowledge.updated}T00:00:00.000000Z" if knowledge.updated else "",
272
+ "type": knowledge.type,
273
+ "status": knowledge.status,
274
+ "lead": projection.lead,
275
+ "url": "",
276
+ "updated": f"{knowledge.updated}T00:00:00.000000Z" if knowledge.updated else "",
277
+ "observed": "",
278
+ "partial": 0,
279
+ },
280
+ [
281
+ (p.fragment, p.title, p.heading, p.text, projection.title if p.fragment else metadata)
282
+ for p in projection.passages
283
+ ],
284
+ knowledge.aliases,
285
+ projection.links,
286
+ )
287
+ return []
288
+ source = records.source_of(path)
289
+ problems = []
290
+ for record in records.load(store, path):
291
+ identity = " ".join([source, *record.aliases, *record.links, record.url])
292
+ try:
293
+ _insert(
294
+ connection,
295
+ {
296
+ "ref": f"{source}:{record.id}",
297
+ "path": path,
298
+ "kind": "record",
299
+ "source": source,
300
+ "title": record.title,
301
+ "time": record.time,
302
+ "type": "record",
303
+ "status": "",
304
+ "lead": lead(record),
305
+ "url": record.url,
306
+ "updated": record.updated,
307
+ "observed": record.observed,
308
+ "partial": int(record.attributes.get("partial") is True),
309
+ },
310
+ [("", record.title, record.title, record.text, identity)],
311
+ record.aliases,
312
+ sorted({*record.links, *([record.url] if record.url else [])}),
313
+ )
314
+ except Error as error:
315
+ problems.append(str(error))
316
+ return problems
317
+
318
+
319
+ def lead(record: Record) -> str:
320
+ return re.sub(r"\s+", " ", record.text).strip()[:320]
321
+
322
+
323
+ def refresh(store: Store, *, full: bool = False, wait: float = 30) -> dict[str, object]:
324
+ """Re-index only changed files; a file that fails to parse is skipped and reported, not fatal."""
325
+ try:
326
+ return _refresh(store, full=full, wait=wait)
327
+ except sqlite3.DatabaseError as error:
328
+ raise Error("could not refresh the search cache; check free space and run bf build") from error
329
+
330
+
331
+ def _refresh(store: Store, *, full: bool, wait: float) -> dict[str, object]:
332
+ with writer(store, wait):
333
+ if full:
334
+ records.recover(store)
335
+ else:
336
+ records.require_ready(store)
337
+ connection = (None if full else _open(store)) or _create(store)
338
+ with closing(connection):
339
+ current = inputs(store)
340
+ known = _known(connection)
341
+ changed = sorted(path for path, info in current.items() if known.get(path) != info)
342
+ removed = sorted(known.keys() - current.keys())
343
+ if changed or removed:
344
+ # A duplicate can disappear because another partition changed; its own bytes need not change.
345
+ retry = {row[0] for row in connection.execute("SELECT path FROM files WHERE error!=''")}
346
+ changed = sorted(set(changed) | (retry & current.keys()))
347
+ with connection:
348
+ connection.execute("BEGIN")
349
+ for path in removed + changed:
350
+ _drop(connection, path)
351
+ for path in changed:
352
+ connection.execute("SAVEPOINT file")
353
+ try:
354
+ problems = _index(connection, store, path)
355
+ except (Error, UnicodeError) as problem:
356
+ connection.execute("ROLLBACK TO file")
357
+ problems = [str(problem) or "invalid file"]
358
+ except OSError:
359
+ connection.execute("ROLLBACK TO file")
360
+ problems = ["inaccessible file; check permissions"]
361
+ connection.execute("RELEASE file")
362
+ error = (
363
+ problems[0] + (f" (and {len(problems) - 1} more)" if len(problems) > 1 else "")
364
+ if problems
365
+ else ""
366
+ )
367
+ connection.execute("INSERT INTO files VALUES(?,?,?,?,?,?)", (path, *current[path], error))
368
+ # PRAGMA has no bound-parameter form; SCHEMA is an internal integer constant.
369
+ connection.execute(f"PRAGMA user_version={SCHEMA}") # nosemgrep: formatted-sql-query
370
+ skipped = connection.execute("SELECT count(*) FROM files WHERE error!=''").fetchone()[0]
371
+ return {"files": len(current), "changed": len(changed), "removed": len(removed), "problems": skipped}
372
+
373
+
374
+ def fresh(store: Store) -> str:
375
+ """Refresh a stale cache, or keep serving it as `stale` while another writer holds the brain."""
376
+ connection = _open(store)
377
+ try:
378
+ with reader(store, wait=0):
379
+ # A live writer may have a pending journal; only an abandoned one blocks cached reads.
380
+ records.require_ready(store)
381
+ if connection is not None and _known(connection) == inputs(store):
382
+ return "ready"
383
+ except BusyError:
384
+ if connection is not None:
385
+ return "stale"
386
+ finally:
387
+ if connection is not None:
388
+ connection.close()
389
+ try:
390
+ # Without any usable generation there is nothing to serve, so wait for the other writer.
391
+ refresh(store, wait=0 if connection is not None else 120)
392
+ except BusyError:
393
+ return "stale"
394
+ return "ready"
395
+
396
+
397
+ @contextmanager
398
+ def database(store: Store) -> Iterator[tuple[sqlite3.Connection, str]]:
399
+ # A full rebuild may replace the generation between the freshness check and open.
400
+ # Retry once: fresh() waits for an in-progress generation instead of serving it.
401
+ for _ in range(2):
402
+ state = fresh(store)
403
+ connection = _open(store)
404
+ if connection is not None:
405
+ break
406
+ else:
407
+ raise Error("the search cache is unavailable; run bf build")
408
+ with closing(connection):
409
+ connection.execute("PRAGMA query_only=ON")
410
+ yield connection, state
411
+
412
+
413
+ def terms(text: str) -> list[str]:
414
+ words = list(dict.fromkeys(re.findall(r"[^\W_]+", text.casefold())))
415
+ return ([w for w in words if w not in _STOP] or words)[:32]
416
+
417
+
418
+ def identity(text: str) -> bool:
419
+ return _IDENTITY.fullmatch(text.strip()) is not None
420
+
421
+
422
+ def identity_owners(connection: sqlite3.Connection, text: str) -> set[str]:
423
+ """Explicit owners for cross-brain identity ordering, kept separate from public results."""
424
+ return {
425
+ row[0]
426
+ for row in connection.execute(
427
+ "SELECT ref FROM items WHERE ref=? OR id IN (SELECT item FROM names WHERE name=?)",
428
+ (text.strip(), text.strip()),
429
+ )
430
+ }
431
+
432
+
433
+ def _note_time(value: str) -> str:
434
+ # Notes carry dates, not instants. Resolve midnight on the reading machine, including DST,
435
+ # at query time so changing timezone never requires rebuilding a shared/disposable cache.
436
+ return moment(value[:10]) if value else ""
437
+
438
+
439
+ _TIME = "CASE WHEN i.kind='note' THEN note_time(i.time) ELSE i.time END"
440
+ _UPDATED = "CASE WHEN i.kind='note' THEN note_time(i.time) ELSE coalesce(nullif(i.updated,''),i.time) END"
441
+ _FIELDS = f"i.ref,i.kind,i.source,({_TIME}) AS time,i.type,i.status,i.url,i.updated,i.observed,i.partial"
442
+ _FILTERS = f"""(:source='' OR i.source=:source) AND (:type='' OR i.type=:type) AND (:status='' OR i.status=:status)
443
+ AND ((:since='' AND :until='') OR i.time!='') AND (:since='' OR ({_TIME})>=:since) AND (:until='' OR ({_TIME})<:until)
444
+ AND (:changed_since='' OR ({_UPDATED})>=:changed_since)
445
+ AND (:current=0 OR i.kind='note' OR i.source IN (SELECT value FROM json_each(:active_sources)))""" # noqa: S608 - fixed SQL fragments
446
+
447
+
448
+ def _lexical(connection: sqlite3.Connection, params: dict[str, object], match: str) -> list[dict[str, object]]:
449
+ rows = connection.execute(
450
+ f"""WITH matches AS MATERIALIZED (
451
+ SELECT {_FIELDS},p.fragment,p.title,-bm25(search,10.0,1.0,0.5) AS score,
452
+ snippet(search,1,'','',' … ',48) AS excerpt
453
+ FROM search JOIN passages p ON p.id=search.rowid JOIN items i ON i.id=p.item
454
+ WHERE search MATCH :match AND {_FILTERS}),
455
+ ranked AS (SELECT *,row_number() OVER (PARTITION BY ref ORDER BY score DESC,fragment) AS position
456
+ FROM matches)
457
+ SELECT * FROM ranked WHERE position=1
458
+ ORDER BY CASE WHEN :recent THEN time END DESC,CASE WHEN :recent THEN ref END DESC,
459
+ status IN ('deprecated','archived'),score * CASE WHEN kind='note' THEN 2 ELSE 1 END DESC,ref
460
+ LIMIT :limit""", # noqa: S608 - fixed SQL
461
+ {**params, "match": match},
462
+ ).fetchall()
463
+ return [dict(row) for row in rows]
464
+
465
+
466
+ def _identity(connection: sqlite3.Connection, params: dict[str, object]) -> list[dict[str, object]]:
467
+ rows = connection.execute(
468
+ f"""WITH candidates AS (
469
+ SELECT id AS item,2e6 AS score FROM items WHERE ref=:text
470
+ UNION ALL SELECT item,1e6 FROM names WHERE name=:text
471
+ UNION ALL SELECT item,1e3 FROM links WHERE target=:text),
472
+ owners AS (SELECT item,max(score) AS score FROM candidates GROUP BY item)
473
+ SELECT {_FIELDS},'' AS fragment,i.title,c.score,i.lead AS excerpt FROM owners c
474
+ JOIN items i ON i.id=c.item WHERE {_FILTERS}
475
+ ORDER BY c.score DESC,time DESC,i.ref LIMIT :limit""", # noqa: S608 - fixed SQL
476
+ params,
477
+ ).fetchall()
478
+ return [dict(row) for row in rows]
479
+
480
+
481
+ def search(
482
+ connection: sqlite3.Connection, query: Query, *, active_sources: set[str] | None = None
483
+ ) -> list[dict[str, object]]:
484
+ """Exact identities, lexical all-term then any-term matches, or a filtered listing."""
485
+ connection.create_function("note_time", 1, _note_time, deterministic=True)
486
+ params: dict[str, object] = query.model_dump()
487
+ params["text"] = query.text.strip()
488
+ params["active_sources"] = json.dumps(sorted(active_sources or ()))
489
+ if not params["text"]:
490
+ rows = connection.execute(
491
+ f"""SELECT {_FIELDS},'' AS fragment,i.title,0 AS score,i.lead AS excerpt FROM items i
492
+ WHERE {_FILTERS} ORDER BY time DESC,i.ref LIMIT :limit""", # noqa: S608 - fixed SQL
493
+ params,
494
+ ).fetchall()
495
+ return [_clean(dict(row)) for row in rows]
496
+ if identity(str(params["text"])):
497
+ return [_clean(row) for row in _identity(connection, params)]
498
+ groups = []
499
+ tokens = terms(str(params["text"]))
500
+ if tokens:
501
+ quoted = ['"' + t.replace('"', '""') + '"' for t in tokens]
502
+ groups.append(_lexical(connection, params, " ".join(quoted)))
503
+ if len(tokens) > 1:
504
+ groups.append(_lexical(connection, params, " OR ".join(quoted)))
505
+ selected: dict[str, dict[str, object]] = {}
506
+ for group in groups:
507
+ for row in group:
508
+ selected.setdefault(str(row["ref"]), row)
509
+ items = list(selected.values())
510
+ if query.recent:
511
+ items.sort(key=lambda r: (str(r["time"]), str(r["ref"])), reverse=True)
512
+ return [_clean(row) for row in items[: query.limit]]
513
+
514
+
515
+ def _clean(row: dict[str, object]) -> dict[str, object]:
516
+ fragment = row.pop("fragment", "")
517
+ if fragment:
518
+ row["ref"] = f"{row['ref']}#{fragment}"
519
+ row.pop("score", None)
520
+ row.pop("position", None)
521
+ if row.get("partial"):
522
+ row["partial"] = True
523
+ else:
524
+ row.pop("partial", None)
525
+ if row["kind"] == "note":
526
+ row.pop("source", None)
527
+ row.pop("updated", None)
528
+ return {key: value for key, value in row.items() if value not in ("", None)}
529
+
530
+
531
+ def problems(connection: sqlite3.Connection) -> list[str]:
532
+ """Bound diagnostics shared by search and status; full validation remains available."""
533
+ return [
534
+ f"{row['path']}: {row['error']}"
535
+ for row in connection.execute("SELECT path,error FROM files WHERE error!='' ORDER BY path LIMIT 200")
536
+ ]
537
+
538
+
539
+ def status(store: Store, now: datetime | None = None) -> dict[str, object]:
540
+ """Cache state, counts, skipped files, and active projects whose note has not been updated recently."""
541
+ cutoff = ((now or datetime.now(UTC)) - timedelta(days=REVIEW_DAYS)).strftime("%Y-%m-%d")
542
+ with database(store) as (connection, state):
543
+ sources = {
544
+ row["source"]: {"records": row["records"], "latest": row["latest"] or ""}
545
+ for row in connection.execute(
546
+ "SELECT source,count(*) AS records,max(time) AS latest FROM items WHERE kind='record' GROUP BY source"
547
+ )
548
+ }
549
+ notes = connection.execute("SELECT count(*) FROM items WHERE kind='note'").fetchone()[0]
550
+ skipped = problems(connection)
551
+ review = [
552
+ {"ref": row["ref"], "title": row["title"], "updated": row["time"][:10]}
553
+ for row in connection.execute(
554
+ """SELECT ref,title,time FROM items WHERE kind='note' AND type='project'
555
+ AND status IN ('active','blocked') AND (time='' OR time<?) ORDER BY time,ref""",
556
+ (cutoff,),
557
+ )
558
+ ]
559
+ return {"index": state, "notes": notes, "sources": sources, "problems": skipped, "review": review}