quirework 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.
- quirework/__init__.py +3 -0
- quirework/__main__.py +3 -0
- quirework/cli.py +431 -0
- quirework/core/__init__.py +1 -0
- quirework/core/artifact_paths.py +86 -0
- quirework/core/checks.py +235 -0
- quirework/core/frontmatter.py +83 -0
- quirework/core/globs.py +45 -0
- quirework/core/pinned.py +169 -0
- quirework/core/usage.py +604 -0
- quirework/delivery/__init__.py +1 -0
- quirework/delivery/artifacts.py +210 -0
- quirework/delivery/text.py +166 -0
- quirework/manuscript/__init__.py +1 -0
- quirework/manuscript/backfill.py +1972 -0
- quirework/manuscript/editions.py +111 -0
- quirework/manuscript/footprint.py +95 -0
- quirework/manuscript/formats.py +208 -0
- quirework/manuscript/lifecycle.py +191 -0
- quirework/manuscript/notes.py +2301 -0
- quirework/manuscript/pages.py +284 -0
- quirework/manuscript/quarto.py +416 -0
- quirework/manuscript/quotes.py +512 -0
- quirework/manuscript/rename.py +309 -0
- quirework/manuscript/sources.py +286 -0
- quirework/manuscript/trail.py +1875 -0
- quirework/manuscript/units.py +416 -0
- quirework/project.py +645 -0
- quirework/provenance/__init__.py +1 -0
- quirework/provenance/agyfilter.py +436 -0
- quirework/provenance/backup/__init__.py +469 -0
- quirework/provenance/backup/base.py +290 -0
- quirework/provenance/backup/command.py +107 -0
- quirework/provenance/backup/directory.py +142 -0
- quirework/provenance/backup/git.py +150 -0
- quirework/provenance/backup/rclone.py +153 -0
- quirework/provenance/capture/__init__.py +1 -0
- quirework/provenance/capture/commands.py +28 -0
- quirework/provenance/capture/extract_artifacts.py +110 -0
- quirework/provenance/capture/extract_files.py +118 -0
- quirework/provenance/capture/fetch_conversation.py +127 -0
- quirework/provenance/capture/fetch_gemini.py +149 -0
- quirework/provenance/capture/render_transcript.py +126 -0
- quirework/provenance/capture/transform.py +22 -0
- quirework/provenance/catalog.py +733 -0
- quirework/provenance/ccfilter.py +1126 -0
- quirework/provenance/codexfilter.py +883 -0
- quirework/provenance/coldread.py +1731 -0
- quirework/provenance/curate.py +67 -0
- quirework/provenance/humaninput.py +811 -0
- quirework/provenance/ingest.py +31 -0
- quirework/provenance/ingestion/__init__.py +1 -0
- quirework/provenance/ingestion/agy.py +192 -0
- quirework/provenance/ingestion/claude.py +493 -0
- quirework/provenance/ingestion/codex.py +296 -0
- quirework/provenance/lineage.py +148 -0
- quirework/provenance/metrics.py +1247 -0
- quirework/provenance/rulings.py +478 -0
- quirework/provenance/scope.py +73 -0
- quirework/provenance/search.py +455 -0
- quirework/provenance/semantic.py +475 -0
- quirework/provenance/show.py +1213 -0
- quirework/provenance/store.py +1271 -0
- quirework/provenance/trees.py +133 -0
- quirework/provenance/worktrees.py +199 -0
- quirework/providers/__init__.py +1 -0
- quirework/providers/blind.py +2192 -0
- quirework/providers/canary.py +685 -0
- quirework/providers/catalog.py +44 -0
- quirework/providers/claude_login.py +92 -0
- quirework/providers/exits.py +196 -0
- quirework/providers/harness.py +830 -0
- quirework/providers/images.py +92 -0
- quirework/providers/models.py +1284 -0
- quirework/providers/quota.py +588 -0
- quirework/providers/reads.py +474 -0
- quirework/providers/replies.py +175 -0
- quirework/publish/__init__.py +1 -0
- quirework/publish/backends.py +112 -0
- quirework/publish/bookstage.py +1086 -0
- quirework/publish/build.py +60 -0
- quirework/publish/editions.py +48 -0
- quirework/publish/epubcheck.py +287 -0
- quirework/publish/experiment.py +1097 -0
- quirework/publish/figfonts.py +234 -0
- quirework/publish/fignumbers.py +293 -0
- quirework/publish/figures.py +729 -0
- quirework/publish/folios.py +62 -0
- quirework/publish/fonts.py +307 -0
- quirework/publish/graybuild.py +73 -0
- quirework/publish/hugo.py +117 -0
- quirework/publish/kindle.js +411 -0
- quirework/publish/kindle.py +1393 -0
- quirework/publish/panelkit.py +918 -0
- quirework/publish/plans.py +651 -0
- quirework/publish/proof.py +792 -0
- quirework/publish/redpen.py +1654 -0
- quirework/publish/render.py +942 -0
- quirework/publish/targets.py +216 -0
- quirework/publish/variant.py +918 -0
- quirework/runs/__init__.py +1 -0
- quirework/runs/__main__.py +15 -0
- quirework/runs/commands.py +278 -0
- quirework/runs/coverage.py +86 -0
- quirework/runs/engine.py +2143 -0
- quirework/runs/feed.py +853 -0
- quirework/runs/observe.py +379 -0
- quirework/runs/storage.py +155 -0
- quirework/settings.py +492 -0
- quirework/sops/__init__.py +1 -0
- quirework/sops/catalog.py +386 -0
- quirework/sops/commands.py +567 -0
- quirework/sops/composition.py +382 -0
- quirework/sops/instruments.py +184 -0
- quirework/sops/personas.py +296 -0
- quirework/sops/review.py +2568 -0
- quirework/sops/room.py +2791 -0
- quirework/sops/roundtable.py +2578 -0
- quirework/sops/sections.py +169 -0
- quirework/sops/workshop.py +1629 -0
- quirework/templates/backup.toml +39 -0
- quirework/templates/book.toml +88 -0
- quirework/templates/run.toml +32 -0
- quirework/templates/site.toml +90 -0
- quirework/templates/tells.toml +64 -0
- quirework/templates/terminology.toml +20 -0
- quirework/templates/tmpfiles.conf +42 -0
- quirework/testing.py +345 -0
- quirework/text/__init__.py +1 -0
- quirework/text/jargon.py +545 -0
- quirework/text/mdwrap.py +1001 -0
- quirework/text/tells.py +547 -0
- quirework-0.1.0.dist-info/METADATA +444 -0
- quirework-0.1.0.dist-info/RECORD +138 -0
- quirework-0.1.0.dist-info/WHEEL +5 -0
- quirework-0.1.0.dist-info/entry_points.txt +2 -0
- quirework-0.1.0.dist-info/licenses/LICENSE +21 -0
- quirework-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1972 @@
|
|
|
1
|
+
"""Backfilling a spec trail: packets for cheap readers, and a ledger of leads.
|
|
2
|
+
|
|
3
|
+
A trail kept from the start needs none of this. The agent that heard the
|
|
4
|
+
author adds each breadcrumb as it happens. A spec that grew before it had a
|
|
5
|
+
trail needs its history read, and a corpus of hundreds of sessions is too
|
|
6
|
+
large for a strong model to read whole. So the work is split by what it
|
|
7
|
+
costs. A cheap model reads each session once, as a compact packet, and flags
|
|
8
|
+
every author turn that might have shaped any section. A strong model then
|
|
9
|
+
works one section at a time through everything flagged for it, and decides.
|
|
10
|
+
Reading a session costs the same whether it is read for one section or for
|
|
11
|
+
all of them, which is why each session is read once, against the whole menu.
|
|
12
|
+
|
|
13
|
+
This module owns what sits between the two readers:
|
|
14
|
+
|
|
15
|
+
* the packet a reader is handed, and the menu it names sections from;
|
|
16
|
+
* a run plan that hands packets to readers through the run engine, one seat
|
|
17
|
+
per batch, so no reader's context grows from one batch to the next;
|
|
18
|
+
* the ledger: each reader's flags, imported and checked against the corpus,
|
|
19
|
+
and the ruling reader's rejections, so no later pass pays to read the same
|
|
20
|
+
candidate again.
|
|
21
|
+
|
|
22
|
+
A flag is a lead and never a breadcrumb; a breadcrumb is written with
|
|
23
|
+
``trail add``. Only the ruling reader drops a candidate, and it records why.
|
|
24
|
+
|
|
25
|
+
The ledger lives where ``manuscript.spec_trail_leads`` puts it. It is as private as
|
|
26
|
+
the trail, and more so: a reader's reasons paraphrase the author's turns.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import functools
|
|
33
|
+
import hashlib
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import re
|
|
37
|
+
import subprocess
|
|
38
|
+
import sys
|
|
39
|
+
import tempfile
|
|
40
|
+
from collections import Counter
|
|
41
|
+
from dataclasses import dataclass, field
|
|
42
|
+
from datetime import date, datetime, timezone
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
from quirework.manuscript import notes, trail
|
|
46
|
+
from quirework.project import ConfigurationError, current_project
|
|
47
|
+
|
|
48
|
+
# Bump when a packet shows a reader something materially different, so the
|
|
49
|
+
# ledger can tell a session read from the current packet from an older one.
|
|
50
|
+
PACKET_VERSION = 1
|
|
51
|
+
|
|
52
|
+
# How much of the reply a turn answered is shown. The end is kept, because a
|
|
53
|
+
# reply closes on what it proposes, and that is what a bare "yes" accepts.
|
|
54
|
+
REPLY_CHARS = 1200
|
|
55
|
+
# A long turn is usually pasted material; its head and tail carry the
|
|
56
|
+
# author's own framing of it. The ruling reader opens the whole turn.
|
|
57
|
+
TURN_HEAD = 4000
|
|
58
|
+
TURN_TAIL = 1500
|
|
59
|
+
GIST_CHARS = 280
|
|
60
|
+
BATCH_TOKENS = 30000
|
|
61
|
+
# A seat's answer is bounded by its model's output limit, and its thinking
|
|
62
|
+
# counts against that limit. The token budget bounds it for conversations:
|
|
63
|
+
# across 55 conversation seats in six passes, none held more than 100 author
|
|
64
|
+
# turns, and none wrote more than 58,901 output tokens. Red-pen marks are
|
|
65
|
+
# short, so the same budget packed 124 of them into one seat, and its answer
|
|
66
|
+
# stopped mid-line at 64,853 of its model's 65,536. A cap on turns lowers that
|
|
67
|
+
# risk and cannot remove it. Thinking depends on what the turns hold: a round
|
|
68
|
+
# of 52 marks, alone in its seat, was cut off the same way, and both cut-off
|
|
69
|
+
# seats had stopped thinking within five tokens of 62,910, which no finished
|
|
70
|
+
# seat came near. Import reports such an answer, and a plan with a lower cap
|
|
71
|
+
# reads that session in parts.
|
|
72
|
+
SEAT_TURNS = 80
|
|
73
|
+
# A seat that flags almost nothing over many turns may have written its done
|
|
74
|
+
# lines without reading. Seats in one real pass flagged 12% to 79% of their
|
|
75
|
+
# author turns, by what the sessions were about, so the floor is absolute and
|
|
76
|
+
# low: it catches a seat that skipped, not one that read tooling sessions.
|
|
77
|
+
DILIGENCE_TURNS = 20
|
|
78
|
+
DILIGENCE_RATE = 0.05
|
|
79
|
+
|
|
80
|
+
CONFIDENCE = ("high", "medium", "low")
|
|
81
|
+
READ_KINDS = (*trail.KINDS, "unsure")
|
|
82
|
+
|
|
83
|
+
# The classes search's extractor gives a human turn, as a reader should read
|
|
84
|
+
# them: whether the words are the author's own is the question that matters.
|
|
85
|
+
_SHAPES = {
|
|
86
|
+
"TRIVIAL": "short",
|
|
87
|
+
"STRUCTURED": "document-shaped",
|
|
88
|
+
"RELAY": "mostly pasted",
|
|
89
|
+
"REVIEW": "comments on a quote",
|
|
90
|
+
"AMBIGUOUS": "long",
|
|
91
|
+
"TYPED": "typed",
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# A review-sheet ruling is read like a session too, and its parts need the
|
|
95
|
+
# same telling: which words the author wrote and which were written for them.
|
|
96
|
+
RULINGS_NOTE = """
|
|
97
|
+
This is not a conversation. It is a review sheet's ruling: items an agent put
|
|
98
|
+
to the author (a proposed edit, a decision between options, a finding), and
|
|
99
|
+
the author's ruling on each, one item per turn. The reply above a turn is what
|
|
100
|
+
the sheet presented, written by an agent. A turn opens with the item's label
|
|
101
|
+
and the option the author chose; that option's wording was written for the
|
|
102
|
+
author to choose, not by the author. Only a comment after it, and the closing
|
|
103
|
+
notes, are the author's own words. A choice still shows what the author
|
|
104
|
+
accepted or refused.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
# A red-pen round is read like a session, but it is not a conversation, and
|
|
108
|
+
# the reader is told what its parts are before it reads one.
|
|
109
|
+
REDPEN_NOTE = """
|
|
110
|
+
This is not a conversation. It is a red-pen round: the author's marks on a
|
|
111
|
+
rendered copy of the book, one mark per turn, with no replies. A comment's
|
|
112
|
+
anchor quotes the book, not the author; its note is the author's. A tracked
|
|
113
|
+
edit is the author's.
|
|
114
|
+
|
|
115
|
+
A review-notes entry compares a note block as the author left it with the
|
|
116
|
+
same block rebuilt later from the book's source. Its "+ now" lines are the
|
|
117
|
+
author's copy and its "- was" lines are the rebuild's. The copy the author
|
|
118
|
+
was sent can hold more than the rebuild, so a "+ now" line can be something
|
|
119
|
+
the copy already held: a heading that says what a part of the block is for,
|
|
120
|
+
or a note an agent wrote earlier, which usually opens with a label in
|
|
121
|
+
parentheses and speaks of the author in the third person. Those are not the
|
|
122
|
+
author's words. The author's own lines carry no label, and are often in the
|
|
123
|
+
first person or addressed to whoever edits the book. A "- was" line is not
|
|
124
|
+
evidence that the author deleted it.
|
|
125
|
+
|
|
126
|
+
A comment's "->" line is where the archive matched the text the comment is
|
|
127
|
+
anchored to. A short anchor, such as a single word, can match the wrong place,
|
|
128
|
+
so read a comment by what its note says.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
# A round records the book revision its marks were matched against. A "+ now"
|
|
132
|
+
# line the book already held at that revision was not written in the round:
|
|
133
|
+
# it is a note the build printed into the block, usually an agent's. Shorter
|
|
134
|
+
# lines, such as headings, are too generic to match safely.
|
|
135
|
+
_ROUND_REVISION = re.compile(r"^canon-sha:\s*([0-9a-f]{7,40})\s*$", re.M)
|
|
136
|
+
_HELD_CHARS = 20
|
|
137
|
+
_FOLD = str.maketrans(
|
|
138
|
+
{
|
|
139
|
+
"\u2014": "-",
|
|
140
|
+
"\u2013": "-",
|
|
141
|
+
"\u201c": '"',
|
|
142
|
+
"\u201d": '"',
|
|
143
|
+
"\u2018": "'",
|
|
144
|
+
"\u2019": "'",
|
|
145
|
+
"\u2026": "...",
|
|
146
|
+
}
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _folded(text):
|
|
151
|
+
"""Text as a red-pen round and `publish compare` see it: plain punctuation, one space.
|
|
152
|
+
|
|
153
|
+
A round folds a dash to one hyphen, where the source may spell it "--".
|
|
154
|
+
"""
|
|
155
|
+
return " ".join(re.sub(r"-{2,}", "-", text.translate(_FOLD)).split())
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@functools.lru_cache(maxsize=16)
|
|
159
|
+
def _book_at(root, source, revision):
|
|
160
|
+
"""The book's source text at ``revision``, folded; ``None`` if git cannot say."""
|
|
161
|
+
try:
|
|
162
|
+
top = trail._git(root, "rev-parse", "--show-toplevel").strip()
|
|
163
|
+
where = os.path.relpath(source, top)
|
|
164
|
+
names = trail._git(
|
|
165
|
+
top, "ls-tree", "-r", "--name-only", revision, "--", where
|
|
166
|
+
).split()
|
|
167
|
+
texts = [
|
|
168
|
+
trail._git(top, "show", f"{revision}:{name}")
|
|
169
|
+
for name in names
|
|
170
|
+
if name.endswith((".md", ".qmd"))
|
|
171
|
+
]
|
|
172
|
+
except (OSError, subprocess.CalledProcessError):
|
|
173
|
+
return None
|
|
174
|
+
return _folded("\n".join(texts)) if texts else None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _round_book(path):
|
|
178
|
+
"""``(revision, the book's text then)`` for a red-pen round, either ``None``."""
|
|
179
|
+
found = _ROUND_REVISION.search(Path(path).read_text(encoding="utf-8"))
|
|
180
|
+
if not found:
|
|
181
|
+
return None, None
|
|
182
|
+
project = current_project()
|
|
183
|
+
held = _book_at(
|
|
184
|
+
str(project.root), str(project.path("manuscript.source")), found.group(1)
|
|
185
|
+
)
|
|
186
|
+
return found.group(1), held
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _unheld(text, held):
|
|
190
|
+
"""A mark without the "+ now" lines the book already held; and how many."""
|
|
191
|
+
kept, dropped = [], 0
|
|
192
|
+
for line in text.splitlines():
|
|
193
|
+
now = re.match(r"\s*\+ now: (.*)$", line)
|
|
194
|
+
if (
|
|
195
|
+
now
|
|
196
|
+
and len(now.group(1).strip()) >= _HELD_CHARS
|
|
197
|
+
and _folded(now.group(1)) in held
|
|
198
|
+
):
|
|
199
|
+
dropped += 1
|
|
200
|
+
continue
|
|
201
|
+
kept.append(line)
|
|
202
|
+
return "\n".join(kept), dropped
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
INSTRUCTIONS = """\
|
|
206
|
+
You are reading conversations in which an author worked out the ideas of a
|
|
207
|
+
book. The book keeps a spec: a list of sections, each stating one thing the
|
|
208
|
+
book holds. The menu below lists them. The packets below hold the author's
|
|
209
|
+
turns from one or more sessions, each turn with the end of the reply it
|
|
210
|
+
answered.
|
|
211
|
+
|
|
212
|
+
Flag every author turn that may have shaped what a section holds, so that a
|
|
213
|
+
later reader can decide. Flag generously. A turn you are unsure about is worth
|
|
214
|
+
flagging with low confidence, because a turn nobody flags is lost, while a
|
|
215
|
+
later, careful reader rejects what does not belong.
|
|
216
|
+
|
|
217
|
+
A turn shapes a section when the author, in their own words:
|
|
218
|
+
- seed: first raises the idea;
|
|
219
|
+
- sharpen: makes it more precise, or changes its words;
|
|
220
|
+
- correct: rejects a wording or claim the model had proposed;
|
|
221
|
+
- reverse: moves off a position the author held earlier;
|
|
222
|
+
- accept: adopts a wording or claim the model drafted, even with a bare "yes";
|
|
223
|
+
- probe: tries the idea out, or wonders, without settling it.
|
|
224
|
+
Use "unsure" when a turn bears on a section but you cannot tell how.
|
|
225
|
+
|
|
226
|
+
Rules:
|
|
227
|
+
- Only the author's words count. Text the author pasted from elsewhere (a
|
|
228
|
+
review, another model's output, a document) shapes nothing by itself; the
|
|
229
|
+
author's own comment on it may.
|
|
230
|
+
- A bare approval ("yes", "go", "commit") matters only when the reply it
|
|
231
|
+
answered proposed something specific about a section. Then it is an accept.
|
|
232
|
+
- The conversations may use older names for things than the menu does.
|
|
233
|
+
Match by meaning, not by words.
|
|
234
|
+
- Name sections only by the slugs in the menu. A turn may shape several
|
|
235
|
+
sections; list each one.
|
|
236
|
+
- Leave out turns that shape no section.
|
|
237
|
+
|
|
238
|
+
Answer with JSON lines and nothing else. One line per flagged turn:
|
|
239
|
+
{"session": "<the id after '# Session'>", "turn": <its number>, "sections": [{"slug": "<slug>", "kind": "<kind>", "confidence": "high|medium|low", "reason": "<one short sentence>"}]}
|
|
240
|
+
After a session's last flagged turn, one line saying you finished it, with
|
|
241
|
+
one sentence, in your own words, on what the author and the model were doing
|
|
242
|
+
across the whole session:
|
|
243
|
+
{"session": "<id>", "done": true, "about": "<one sentence>"}
|
|
244
|
+
Every session in the packets needs its done line, even one with nothing to
|
|
245
|
+
flag. The about sentence is how a later reader knows the session was read, so
|
|
246
|
+
write it from the turns, not from the first one alone.
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# --------------------------------------------------------------------------- #
|
|
251
|
+
# configuration
|
|
252
|
+
# --------------------------------------------------------------------------- #
|
|
253
|
+
def ledger():
|
|
254
|
+
"""The ledger's directory: what readers flagged, and what was rejected."""
|
|
255
|
+
project = current_project()
|
|
256
|
+
if not trail.configured():
|
|
257
|
+
raise ConfigurationError(
|
|
258
|
+
"a backfill ledger belongs to a spec trail: set manuscript.spec_trail first"
|
|
259
|
+
)
|
|
260
|
+
if project.get("manuscript.spec_trail_leads") is None:
|
|
261
|
+
raise ConfigurationError(
|
|
262
|
+
"this project keeps no backfill ledger: set manuscript.spec_trail_leads in "
|
|
263
|
+
"quirework.toml to the directory that should hold it"
|
|
264
|
+
)
|
|
265
|
+
return project.path("manuscript.spec_trail_leads")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _write_private(path, text):
|
|
269
|
+
"""Write ``path`` atomically, readable by its owner only, like the trail."""
|
|
270
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
271
|
+
handle, temporary = tempfile.mkstemp(
|
|
272
|
+
dir=path.parent, prefix=f".{path.name}.", suffix=".tmp"
|
|
273
|
+
)
|
|
274
|
+
try:
|
|
275
|
+
with os.fdopen(handle, "w", encoding="utf-8") as out:
|
|
276
|
+
out.write(text)
|
|
277
|
+
out.flush()
|
|
278
|
+
os.fsync(out.fileno())
|
|
279
|
+
os.replace(temporary, path)
|
|
280
|
+
except BaseException:
|
|
281
|
+
if os.path.exists(temporary):
|
|
282
|
+
os.unlink(temporary)
|
|
283
|
+
raise
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _utc(moment):
|
|
287
|
+
"""A logged time as a reader sees it, to the second."""
|
|
288
|
+
if moment is None:
|
|
289
|
+
return None
|
|
290
|
+
return moment.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _instant(moment):
|
|
294
|
+
"""A logged time as the ledger stores it: exact, since turns share seconds."""
|
|
295
|
+
if moment is None:
|
|
296
|
+
return None
|
|
297
|
+
return moment.astimezone(timezone.utc).isoformat(timespec="microseconds")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# --------------------------------------------------------------------------- #
|
|
301
|
+
# what a reader is handed
|
|
302
|
+
# --------------------------------------------------------------------------- #
|
|
303
|
+
def _gist(body):
|
|
304
|
+
text = " ".join(re.sub(r"<!--.*?-->", " ", body, flags=re.S).split())
|
|
305
|
+
if len(text) <= GIST_CHARS:
|
|
306
|
+
return text
|
|
307
|
+
return text[: GIST_CHARS - 1].rsplit(" ", 1)[0] + "…"
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def menu():
|
|
311
|
+
"""The spec's sections as a reader names them.
|
|
312
|
+
|
|
313
|
+
Each line gives the slug, the title, how the section opens, and the
|
|
314
|
+
units that bind it. A conversation often names the chapter or post it was
|
|
315
|
+
working on rather than the idea, and the binding is how a reader gets from
|
|
316
|
+
one to the other. The wording changes nothing a read is keyed on: reads
|
|
317
|
+
are current by `PACKET_VERSION`.
|
|
318
|
+
"""
|
|
319
|
+
from quirework.manuscript import units
|
|
320
|
+
|
|
321
|
+
noun = units.noun()
|
|
322
|
+
bound = {}
|
|
323
|
+
for unit, slugs in notes.chapter_bindings().items():
|
|
324
|
+
for slug in slugs:
|
|
325
|
+
bound.setdefault(slug, set()).add(unit)
|
|
326
|
+
lines = [
|
|
327
|
+
"# The spec's sections",
|
|
328
|
+
"",
|
|
329
|
+
(
|
|
330
|
+
"Name a section only by its slug. Each line gives the section's "
|
|
331
|
+
f"title, how it opens, and the {noun}s that express it."
|
|
332
|
+
),
|
|
333
|
+
"",
|
|
334
|
+
]
|
|
335
|
+
for section in notes.spec_sections():
|
|
336
|
+
if section.slug:
|
|
337
|
+
named = ", ".join(sorted(bound.get(section.slug, ()))) or "none"
|
|
338
|
+
lines.append(
|
|
339
|
+
f"- `{section.slug}`: {section.title}. {_gist(section.body)} "
|
|
340
|
+
f"[{noun}s: {named}]"
|
|
341
|
+
)
|
|
342
|
+
return "\n".join(lines) + "\n"
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _short(sid):
|
|
346
|
+
"""A session as messages name it: a named source whole, an id by its head."""
|
|
347
|
+
return sid if sid.startswith(trail._NAMED) else sid[:8]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _tail(text, limit):
|
|
351
|
+
text = text.strip()
|
|
352
|
+
return text if len(text) <= limit else "… " + text[-limit:].lstrip()
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _clip(text):
|
|
356
|
+
text = text.strip()
|
|
357
|
+
if len(text) <= TURN_HEAD + TURN_TAIL + 200:
|
|
358
|
+
return text
|
|
359
|
+
cut = len(text) - TURN_HEAD - TURN_TAIL
|
|
360
|
+
return (
|
|
361
|
+
f"{text[:TURN_HEAD].rstrip()}\n\n[… {cut:,} characters not shown …]\n\n"
|
|
362
|
+
f"{text[-TURN_TAIL:].lstrip()}"
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _quoted(text):
|
|
367
|
+
return "\n".join(f"> {line}" if line.strip() else ">" for line in text.splitlines())
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _listed(numbers):
|
|
371
|
+
"""Turn numbers as a sentence lists them: "3, 17 and 40"."""
|
|
372
|
+
words = [str(n) for n in numbers]
|
|
373
|
+
return words[0] if len(words) == 1 else f"{', '.join(words[:-1])} and {words[-1]}"
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def packet(sid, family, path, only=None):
|
|
377
|
+
"""One session as a reader reads it: each author turn, and what it answered.
|
|
378
|
+
|
|
379
|
+
Turns are numbered as ``provenance show`` numbers them, and stamped with
|
|
380
|
+
the logged UTC time, so a flag can be followed back to the turn it names.
|
|
381
|
+
``only``, a list of turn numbers, hands out just those turns: one part of
|
|
382
|
+
a session too long for one reader, or the turns of a session that no
|
|
383
|
+
reader has seen. The numbers stay the session's own, and the header says
|
|
384
|
+
which turns the packet holds.
|
|
385
|
+
"""
|
|
386
|
+
from quirework.provenance import humaninput, show
|
|
387
|
+
|
|
388
|
+
rows = show.exchanges(family, path)
|
|
389
|
+
total = len(rows)
|
|
390
|
+
if only is not None:
|
|
391
|
+
wanted = set(only)
|
|
392
|
+
rows = [row for row in rows if row[0] in wanted]
|
|
393
|
+
if not rows:
|
|
394
|
+
raise ValueError(f"{_short(sid)} has none of the turns asked for")
|
|
395
|
+
held_note = []
|
|
396
|
+
if family == "redpen":
|
|
397
|
+
revision, held = _round_book(path)
|
|
398
|
+
if held is None:
|
|
399
|
+
held_note = [
|
|
400
|
+
"",
|
|
401
|
+
"The book's text at this round's revision could not be read, so "
|
|
402
|
+
"'+ now' lines the book already held are shown with the rest.",
|
|
403
|
+
]
|
|
404
|
+
else:
|
|
405
|
+
left_out = 0
|
|
406
|
+
for i, (number, logged, text, reply) in enumerate(rows):
|
|
407
|
+
text, dropped = _unheld(text, held)
|
|
408
|
+
rows[i] = (number, logged, text, reply)
|
|
409
|
+
left_out += dropped
|
|
410
|
+
if left_out:
|
|
411
|
+
held_note = [
|
|
412
|
+
"",
|
|
413
|
+
f"{left_out} '+ now' line(s) are left out: the book already "
|
|
414
|
+
f"held them at revision {revision}, so they were not written "
|
|
415
|
+
"in this round.",
|
|
416
|
+
]
|
|
417
|
+
day = show.session_date(family, path)
|
|
418
|
+
begun = f"started {day:%Y-%m-%d}" if day else "undated"
|
|
419
|
+
lines = [
|
|
420
|
+
f"# Session {sid}",
|
|
421
|
+
"",
|
|
422
|
+
f"{trail._HARNESS_OF[family]} · {begun} · {total} author turn(s)",
|
|
423
|
+
]
|
|
424
|
+
if only is not None:
|
|
425
|
+
numbers = [row[0] for row in rows]
|
|
426
|
+
if numbers == list(range(numbers[0], numbers[-1] + 1)):
|
|
427
|
+
held = (
|
|
428
|
+
f"This packet holds only turns {numbers[0]} to {numbers[-1]} of "
|
|
429
|
+
f"the session's {total}; other readers read the rest."
|
|
430
|
+
)
|
|
431
|
+
else:
|
|
432
|
+
held = (
|
|
433
|
+
f"This packet holds only {len(numbers)} of the session's {total} "
|
|
434
|
+
f"turns: {_listed(numbers)}. Other readers read the rest, so the "
|
|
435
|
+
"turns between these are not shown."
|
|
436
|
+
)
|
|
437
|
+
lines += [
|
|
438
|
+
"",
|
|
439
|
+
f"{held} Flag only these turns, and write the session's done line "
|
|
440
|
+
"when you have read them.",
|
|
441
|
+
]
|
|
442
|
+
if family == "redpen":
|
|
443
|
+
lines += ["", REDPEN_NOTE.strip(), *held_note]
|
|
444
|
+
if family == "rulings":
|
|
445
|
+
lines += ["", RULINGS_NOTE.strip()]
|
|
446
|
+
for number, logged, text, reply in rows:
|
|
447
|
+
shape = _SHAPES.get(humaninput.classify(text), "typed")
|
|
448
|
+
lines += [
|
|
449
|
+
"",
|
|
450
|
+
f"## Turn {number} · {_utc(logged) or 'no logged time'} · {shape}",
|
|
451
|
+
]
|
|
452
|
+
if reply.strip():
|
|
453
|
+
lines += [
|
|
454
|
+
"",
|
|
455
|
+
"It answered the end of this reply:",
|
|
456
|
+
"",
|
|
457
|
+
_quoted(_tail(reply, REPLY_CHARS)),
|
|
458
|
+
]
|
|
459
|
+
lines += ["", "The author wrote:", "", _clip(text) or "(nothing)"]
|
|
460
|
+
return "\n".join(lines) + "\n"
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
# --------------------------------------------------------------------------- #
|
|
464
|
+
# the ledger's reads
|
|
465
|
+
# --------------------------------------------------------------------------- #
|
|
466
|
+
def _reads():
|
|
467
|
+
"""Every imported read, as the dicts the ledger stores."""
|
|
468
|
+
root = ledger() / "reads"
|
|
469
|
+
return [
|
|
470
|
+
json.loads(p.read_text(encoding="utf-8")) for p in sorted(root.glob("*/*.json"))
|
|
471
|
+
]
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _current(read):
|
|
475
|
+
"""Whether a read was made from today's packet.
|
|
476
|
+
|
|
477
|
+
A section added since is deliberately not a reason to read the session
|
|
478
|
+
again: that would hand out the whole corpus for one section, whose
|
|
479
|
+
history is mostly in the session that added it. ``status`` counts such
|
|
480
|
+
reads so the choice stays visible.
|
|
481
|
+
"""
|
|
482
|
+
return read.get("packet_version") == PACKET_VERSION
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _keys(turns):
|
|
486
|
+
"""A key for each of a session's turns that a renumbering leaves alone.
|
|
487
|
+
|
|
488
|
+
``turns`` are ``(logged, text)`` pairs in the session's order. A read
|
|
489
|
+
records the keys of the turns it was handed, not their numbers, because
|
|
490
|
+
numbers move: a reader that learns to count a new kind of turn renumbers
|
|
491
|
+
every turn after it. A turn is known by the instant it was logged. Where
|
|
492
|
+
that does not tell it apart, because its source logs no times or another
|
|
493
|
+
turn shares the instant, the key adds a digest of what the turn says, and
|
|
494
|
+
an ordinal where two turns say the same.
|
|
495
|
+
"""
|
|
496
|
+
turns = [(_instant(logged), text) for logged, text in turns]
|
|
497
|
+
shared = Counter(instant for instant, _ in turns)
|
|
498
|
+
keys, used = [], Counter()
|
|
499
|
+
for instant, text in turns:
|
|
500
|
+
if instant is not None and shared[instant] == 1:
|
|
501
|
+
keys.append(instant)
|
|
502
|
+
continue
|
|
503
|
+
digest = hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
|
|
504
|
+
key = f"{instant or 'untimed'}/{digest}"
|
|
505
|
+
used[key] += 1
|
|
506
|
+
keys.append(key if used[key] == 1 else f"{key}#{used[key]}")
|
|
507
|
+
return keys
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
class _Session:
|
|
511
|
+
"""A session's author turns as the ledger compares them, read once."""
|
|
512
|
+
|
|
513
|
+
def __init__(self, family, path):
|
|
514
|
+
from quirework.provenance import show
|
|
515
|
+
|
|
516
|
+
rows = show.exchanges(family, path)
|
|
517
|
+
self.family, self.path = family, path
|
|
518
|
+
self.numbers = [number for number, _, _, _ in rows]
|
|
519
|
+
self.instants = [_instant(logged) for _, logged, _, _ in rows]
|
|
520
|
+
self.keys = _keys((logged, text) for _, logged, text, _ in rows)
|
|
521
|
+
self._origins = None
|
|
522
|
+
|
|
523
|
+
def origins(self):
|
|
524
|
+
"""How each turn reached the log (``show.turn_origins``)."""
|
|
525
|
+
if self._origins is None:
|
|
526
|
+
from quirework.provenance import show
|
|
527
|
+
|
|
528
|
+
self._origins = show.turn_origins(self.family, self.path)
|
|
529
|
+
return self._origins
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _seen(read, session):
|
|
533
|
+
"""The keys of the turns ``read`` was handed, as ``session`` holds them now.
|
|
534
|
+
|
|
535
|
+
A read records them since reads began keeping keys. An older read kept
|
|
536
|
+
only how many turns it read, the part it read if it read one, and the
|
|
537
|
+
number and logged instant of each turn it flagged. Its turns are found by
|
|
538
|
+
replaying the readers it could have run on: today's, and each one from
|
|
539
|
+
before a path to the author's turns was learned (``show.ORIGINS``), which
|
|
540
|
+
lists the session without the turns that arrived that way. A reader fits
|
|
541
|
+
when it lists as many turns as the read counted, or enough to hold the
|
|
542
|
+
part, and puts every flagged turn at the number the read gave it.
|
|
543
|
+
Readers that fit and disagree on which turns a part held leave it only
|
|
544
|
+
the turns they agree on, so a turn in doubt is handed out again. A read
|
|
545
|
+
no reader fits vouches for no turn, and is ``None``: its session gained
|
|
546
|
+
turns some other way since, as when a capture is rebuilt more completely,
|
|
547
|
+
and only a new read can say which turns it saw.
|
|
548
|
+
"""
|
|
549
|
+
if "seen" in read:
|
|
550
|
+
return set(read["seen"])
|
|
551
|
+
from quirework.provenance import show
|
|
552
|
+
|
|
553
|
+
learned = show.ORIGINS[:0:-1]
|
|
554
|
+
part = read.get("part")
|
|
555
|
+
fits = []
|
|
556
|
+
for count in range(len(learned) + 1):
|
|
557
|
+
unknown = set(learned[:count])
|
|
558
|
+
listed = [
|
|
559
|
+
i for i, origin in enumerate(session.origins()) if origin not in unknown
|
|
560
|
+
]
|
|
561
|
+
if part is None:
|
|
562
|
+
first, last = 1, len(listed)
|
|
563
|
+
if last != read["turns"]:
|
|
564
|
+
continue
|
|
565
|
+
else:
|
|
566
|
+
first, last = part["first"], part["last"]
|
|
567
|
+
if last > len(listed):
|
|
568
|
+
continue
|
|
569
|
+
if all(
|
|
570
|
+
1 <= flag["turn"] <= len(listed)
|
|
571
|
+
and (
|
|
572
|
+
flag.get("logged") is None
|
|
573
|
+
or session.instants[listed[flag["turn"] - 1]] == flag["logged"]
|
|
574
|
+
)
|
|
575
|
+
for flag in read["candidates"]
|
|
576
|
+
):
|
|
577
|
+
fits.append({session.keys[i] for i in listed[first - 1 : last]})
|
|
578
|
+
return set.intersection(*fits) if fits else None
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _coverage(sessions, reads=None):
|
|
582
|
+
"""What current reads saw of ``sessions``, as ``(cover, stale)``.
|
|
583
|
+
|
|
584
|
+
``sessions`` maps an id to its ``_Session``. ``cover`` maps every session
|
|
585
|
+
a current read covers to the keys of the turns those reads were handed;
|
|
586
|
+
a session counts as read once that is every turn it holds. ``stale`` is
|
|
587
|
+
the sessions with a read that vouches for no turn (``_seen``).
|
|
588
|
+
"""
|
|
589
|
+
cover, stale = {}, set()
|
|
590
|
+
for read in _reads() if reads is None else reads:
|
|
591
|
+
sid = read["session"]
|
|
592
|
+
if not _current(read) or sid not in sessions:
|
|
593
|
+
continue
|
|
594
|
+
seen = _seen(read, sessions[sid])
|
|
595
|
+
if seen is None:
|
|
596
|
+
stale.add(sid)
|
|
597
|
+
cover.setdefault(sid, set()).update(seen or ())
|
|
598
|
+
return cover, stale
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _unseen(session, seen):
|
|
602
|
+
"""The numbers of ``session``'s turns whose keys are not in ``seen``."""
|
|
603
|
+
return [n for n, key in zip(session.numbers, session.keys) if key not in seen]
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _chunks(numbers, size):
|
|
607
|
+
"""``numbers`` cut into as few runs of at most ``size`` as it takes, of
|
|
608
|
+
nearly equal length, so no part is a stub."""
|
|
609
|
+
count = len(numbers)
|
|
610
|
+
pieces = -(-count // max(size, 1))
|
|
611
|
+
step = -(-count // pieces)
|
|
612
|
+
return [numbers[at : at + step] for at in range(0, count, step)]
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _kept(corpus):
|
|
616
|
+
"""Captured sessions curation has accepted, as ``sid -> (family, path)``.
|
|
617
|
+
|
|
618
|
+
Each is read at its accepted version, never at a pending capture made
|
|
619
|
+
since: a lead is recorded against what the corpus holds.
|
|
620
|
+
"""
|
|
621
|
+
from quirework.provenance import show
|
|
622
|
+
|
|
623
|
+
return {
|
|
624
|
+
show.session_id(family, path): (family, path)
|
|
625
|
+
for family, path in show.sessions()
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
# --------------------------------------------------------------------------- #
|
|
630
|
+
# a run plan
|
|
631
|
+
# --------------------------------------------------------------------------- #
|
|
632
|
+
def run_directory(model):
|
|
633
|
+
"""A new run directory in the project's private state, named for today.
|
|
634
|
+
|
|
635
|
+
Packets and the run record hold corpus text, so they belong outside the
|
|
636
|
+
checkout; a second pass on the same day takes the next free suffix.
|
|
637
|
+
"""
|
|
638
|
+
root = current_project().state_root
|
|
639
|
+
stem = f"trail-read-{date.today().isoformat()}-{re.sub(r'[^A-Za-z0-9.-]+', '-', model)}"
|
|
640
|
+
candidate, n = root / stem, 1
|
|
641
|
+
while candidate.exists():
|
|
642
|
+
n += 1
|
|
643
|
+
candidate = root / f"{stem}-{n}"
|
|
644
|
+
return candidate
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def plan(
|
|
648
|
+
out,
|
|
649
|
+
*,
|
|
650
|
+
harness,
|
|
651
|
+
model,
|
|
652
|
+
effort,
|
|
653
|
+
tier="cheap",
|
|
654
|
+
sessions=None,
|
|
655
|
+
scope=None,
|
|
656
|
+
limit=None,
|
|
657
|
+
budget=BATCH_TOKENS,
|
|
658
|
+
turns=SEAT_TURNS,
|
|
659
|
+
materials=(),
|
|
660
|
+
reread=False,
|
|
661
|
+
external=False,
|
|
662
|
+
):
|
|
663
|
+
"""Write packets and a run plan that hands them to readers; return a summary.
|
|
664
|
+
|
|
665
|
+
Sessions are taken oldest first from the curated corpus, or those named
|
|
666
|
+
in ``sessions``, and each hands out only the turns no current read was
|
|
667
|
+
handed (``_coverage``), all of them with ``reread``. A session read
|
|
668
|
+
whole before its reader learned a new kind of turn, or read in parts that
|
|
669
|
+
left some out, hands out the turns no reader saw. A source that logs no
|
|
670
|
+
times but records its date, a gemini review or a red-pen round, takes its
|
|
671
|
+
place by that date; one with neither sorts first, and the summary names
|
|
672
|
+
it. ``out`` of ``None`` plans into a new ``run_directory``, as
|
|
673
|
+
``RUN/packets``, beside which the record goes. Each seat gets one batch
|
|
674
|
+
of packets up to ``budget`` estimated tokens and ``turns`` author turns,
|
|
675
|
+
with the menu and any book ``materials`` in front, and no tools. A
|
|
676
|
+
session with more turns to hand out than ``turns`` is cut into parts of
|
|
677
|
+
nearly equal length, and a seat never holds two parts of one session.
|
|
678
|
+
The summary names every session handed out in parts, counts the turns
|
|
679
|
+
of sessions read before, and names each session handed out whole again
|
|
680
|
+
because its old read no longer fits its turns.
|
|
681
|
+
"""
|
|
682
|
+
from quirework.provenance import scope as SCOPE
|
|
683
|
+
from quirework.provenance import show
|
|
684
|
+
|
|
685
|
+
out = (
|
|
686
|
+
run_directory(model) / "packets"
|
|
687
|
+
if out is None
|
|
688
|
+
else Path(out).expanduser().resolve()
|
|
689
|
+
)
|
|
690
|
+
project = current_project()
|
|
691
|
+
if out.is_relative_to(project.root) and not out.is_relative_to(
|
|
692
|
+
project.path("publish.build", "build")
|
|
693
|
+
):
|
|
694
|
+
raise ValueError(
|
|
695
|
+
"packets hold private corpus text: write them outside the checkout, "
|
|
696
|
+
"or under build/"
|
|
697
|
+
)
|
|
698
|
+
if out.exists() and any(out.iterdir()):
|
|
699
|
+
raise ValueError(f"{out} is not empty; plan into a new directory")
|
|
700
|
+
ledger()
|
|
701
|
+
corpus = trail.Corpus()
|
|
702
|
+
kept = _kept(corpus)
|
|
703
|
+
slugs = [s.slug for s in notes.spec_sections() if s.slug]
|
|
704
|
+
if sessions:
|
|
705
|
+
chosen = []
|
|
706
|
+
for prefix in sessions:
|
|
707
|
+
sid, family, path, live = corpus.session(prefix)
|
|
708
|
+
if live or sid not in kept:
|
|
709
|
+
raise LookupError(f"{_short(sid)} is not in the curated corpus")
|
|
710
|
+
chosen.append(sid)
|
|
711
|
+
else:
|
|
712
|
+
chosen = [
|
|
713
|
+
sid for sid in kept if scope is None or SCOPE.scope_of_id(sid) == scope
|
|
714
|
+
]
|
|
715
|
+
every = {sid: _Session(*kept[sid]) for sid in chosen}
|
|
716
|
+
cover, stale = ({}, set()) if reread else _coverage(every)
|
|
717
|
+
dated, undated, spans = [], set(), {}
|
|
718
|
+
for sid in chosen:
|
|
719
|
+
family, path = kept[sid]
|
|
720
|
+
unseen = _unseen(every[sid], cover.get(sid, set()))
|
|
721
|
+
if not unseen:
|
|
722
|
+
continue
|
|
723
|
+
spans[sid] = _chunks(unseen, turns)
|
|
724
|
+
day = show.session_date(family, path)
|
|
725
|
+
start = show.session_start(family, path) or (
|
|
726
|
+
datetime.combine(day, datetime.min.time(), timezone.utc) if day else None
|
|
727
|
+
)
|
|
728
|
+
if start is None:
|
|
729
|
+
undated.add(sid)
|
|
730
|
+
dated.append((start or datetime.min.replace(tzinfo=timezone.utc), sid))
|
|
731
|
+
dated.sort()
|
|
732
|
+
if limit is not None:
|
|
733
|
+
dated = dated[:limit]
|
|
734
|
+
|
|
735
|
+
(out / "packets").mkdir(parents=True, exist_ok=True)
|
|
736
|
+
(out / "menu.md").write_text(menu(), encoding="utf-8")
|
|
737
|
+
extra = [
|
|
738
|
+
{"source": str(Path(m).expanduser().resolve(strict=True)), "delivery": "text"}
|
|
739
|
+
for m in materials
|
|
740
|
+
]
|
|
741
|
+
batches, current, size, held = [], [], 0, 0
|
|
742
|
+
for _, sid in dated:
|
|
743
|
+
family, path = kept[sid]
|
|
744
|
+
session = every[sid]
|
|
745
|
+
key_of = dict(zip(session.numbers, session.keys))
|
|
746
|
+
for numbers in spans[sid]:
|
|
747
|
+
only = None if numbers == session.numbers else numbers
|
|
748
|
+
text = packet(sid, family, path, only)
|
|
749
|
+
name = (
|
|
750
|
+
f"packets/{sid}.md"
|
|
751
|
+
if only is None
|
|
752
|
+
else f"packets/{sid}.turns-{numbers[0]}-{numbers[-1]}.md"
|
|
753
|
+
)
|
|
754
|
+
(out / name).write_text(text, encoding="utf-8")
|
|
755
|
+
tokens, count = len(text) // 4, len(numbers)
|
|
756
|
+
if current and (
|
|
757
|
+
size + tokens > budget
|
|
758
|
+
or held + count > turns
|
|
759
|
+
or any(handed[0] == sid for handed in current)
|
|
760
|
+
):
|
|
761
|
+
batches.append(current)
|
|
762
|
+
current, size, held = [], 0, 0
|
|
763
|
+
current.append((sid, name, tokens, only, [key_of[n] for n in numbers]))
|
|
764
|
+
size += tokens
|
|
765
|
+
held += count
|
|
766
|
+
if current:
|
|
767
|
+
batches.append(current)
|
|
768
|
+
|
|
769
|
+
seats = []
|
|
770
|
+
for index, batch in enumerate(batches, 1):
|
|
771
|
+
seat = {
|
|
772
|
+
"id": f"read-{index:03d}",
|
|
773
|
+
"harness": harness,
|
|
774
|
+
"model": model,
|
|
775
|
+
"effort": effort,
|
|
776
|
+
"tier": tier,
|
|
777
|
+
# agy audits file access and cannot enforce isolation; the seat
|
|
778
|
+
# has no tools either way, and its receipt says which it was.
|
|
779
|
+
"policy": {
|
|
780
|
+
"tools": "none",
|
|
781
|
+
"filesystem": "audited" if harness == "agy" else "enforced",
|
|
782
|
+
},
|
|
783
|
+
# agy keeps its login in the keyring, which every agy jail can
|
|
784
|
+
# reach; a seat with no tools cannot use it, so the flag is
|
|
785
|
+
# expected and only another one stops the import.
|
|
786
|
+
**({"accept_red_flags": ["session-bus"]} if harness == "agy" else {}),
|
|
787
|
+
"metadata": {
|
|
788
|
+
"trail_read": {
|
|
789
|
+
"sessions": [sid for sid, _, _, _, _ in batch],
|
|
790
|
+
# The keys of the turns each packet holds, in turn order,
|
|
791
|
+
# and the numbers of a packet that holds only some: what
|
|
792
|
+
# import records as the turns a read was handed.
|
|
793
|
+
"seen": {sid: keys for sid, _, _, _, keys in batch},
|
|
794
|
+
**(
|
|
795
|
+
{
|
|
796
|
+
"parts": {
|
|
797
|
+
sid: only
|
|
798
|
+
for sid, _, _, only, _ in batch
|
|
799
|
+
if only is not None
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
if any(only for _, _, _, only, _ in batch)
|
|
803
|
+
else {}
|
|
804
|
+
),
|
|
805
|
+
"packet_version": PACKET_VERSION,
|
|
806
|
+
"slugs": slugs,
|
|
807
|
+
}
|
|
808
|
+
},
|
|
809
|
+
"turns": [
|
|
810
|
+
{
|
|
811
|
+
"id": "read",
|
|
812
|
+
"prompt": INSTRUCTIONS,
|
|
813
|
+
"materials": [
|
|
814
|
+
{"source": "menu.md", "delivery": "text", "label": "menu"},
|
|
815
|
+
*extra,
|
|
816
|
+
*(
|
|
817
|
+
{"source": name, "delivery": "text", "label": _short(sid)}
|
|
818
|
+
for sid, name, _, _, _ in batch
|
|
819
|
+
),
|
|
820
|
+
],
|
|
821
|
+
"min_bytes": 20,
|
|
822
|
+
}
|
|
823
|
+
],
|
|
824
|
+
}
|
|
825
|
+
if external:
|
|
826
|
+
seat["mode"] = "external"
|
|
827
|
+
seats.append(seat)
|
|
828
|
+
if not seats:
|
|
829
|
+
raise LookupError("no session is left to read")
|
|
830
|
+
document = {
|
|
831
|
+
"title": f"Spec trail reading: {len(dated)} session(s)",
|
|
832
|
+
"metadata": {"trail_read": {"packet_version": PACKET_VERSION}},
|
|
833
|
+
"seats": seats,
|
|
834
|
+
}
|
|
835
|
+
(out / "plan.json").write_text(json.dumps(document, indent=2) + "\n")
|
|
836
|
+
# What every seat is handed besides its packets, estimated as packets are.
|
|
837
|
+
fixed = (
|
|
838
|
+
len(INSTRUCTIONS)
|
|
839
|
+
+ len((out / "menu.md").read_text(encoding="utf-8"))
|
|
840
|
+
+ sum(Path(m["source"]).stat().st_size for m in extra)
|
|
841
|
+
) // 4
|
|
842
|
+
packets = sum(t for batch in batches for _, _, t, _, _ in batch)
|
|
843
|
+
harnesses = {}
|
|
844
|
+
for _, sid in dated:
|
|
845
|
+
name = trail._HARNESS_OF[kept[sid][0]]
|
|
846
|
+
harnesses[name] = harnesses.get(name, 0) + 1
|
|
847
|
+
return {
|
|
848
|
+
"plan": str(out / "plan.json"),
|
|
849
|
+
"record": str(out.parent / "record") if out.name == "packets" else None,
|
|
850
|
+
"seats": len(seats),
|
|
851
|
+
"sessions": len(dated),
|
|
852
|
+
"harnesses": harnesses,
|
|
853
|
+
"undated": [sid for _, sid in dated if sid in undated],
|
|
854
|
+
"split": [
|
|
855
|
+
(sid, len(every[sid].numbers), spans[sid])
|
|
856
|
+
for _, sid in dated
|
|
857
|
+
if len(spans[sid]) > 1
|
|
858
|
+
],
|
|
859
|
+
"partial": [
|
|
860
|
+
(sid, sum(map(len, spans[sid])), len(every[sid].numbers))
|
|
861
|
+
for _, sid in dated
|
|
862
|
+
if cover.get(sid)
|
|
863
|
+
],
|
|
864
|
+
"stale": [sid for _, sid in dated if sid in stale and not cover.get(sid)],
|
|
865
|
+
"turns": turns,
|
|
866
|
+
"tokens": packets,
|
|
867
|
+
"total_tokens": packets + fixed * len(seats),
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _run(numbers):
|
|
872
|
+
"""One part's turns in a plan's summary: "3-9", or "3-40 (12 of them)"."""
|
|
873
|
+
first, last = numbers[0], numbers[-1]
|
|
874
|
+
if len(numbers) == last - first + 1:
|
|
875
|
+
return f"{first}-{last}"
|
|
876
|
+
return f"{first}-{last} ({len(numbers)} of them)"
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
def plan_lines(done):
|
|
880
|
+
"""A plan's summary as printable lines, ending with the commands to run."""
|
|
881
|
+
lines = [
|
|
882
|
+
f"{done['sessions']} session(s) in {done['seats']} seat(s): "
|
|
883
|
+
+ ", ".join(f"{n} {h}" for h, n in done["harnesses"].items()),
|
|
884
|
+
f"about {done['tokens']:,} tokens of packets, "
|
|
885
|
+
f"{done['total_tokens']:,} with each seat's menu and instructions",
|
|
886
|
+
]
|
|
887
|
+
if done["undated"]:
|
|
888
|
+
lines.append(
|
|
889
|
+
f"{len(done['undated'])} session(s) have no start time and are "
|
|
890
|
+
"planned first: " + ", ".join(sid[:16] for sid in done["undated"])
|
|
891
|
+
)
|
|
892
|
+
if done["partial"]:
|
|
893
|
+
lines.append(
|
|
894
|
+
f"{len(done['partial'])} session(s) read before hand out only the "
|
|
895
|
+
f"{sum(n for _, n, _ in done['partial']):,} turn(s) no reader saw"
|
|
896
|
+
)
|
|
897
|
+
if done["stale"]:
|
|
898
|
+
lines.append(
|
|
899
|
+
f"{len(done['stale'])} session(s) are read whole again, because an "
|
|
900
|
+
"earlier read no longer fits the turns they hold: "
|
|
901
|
+
+ ", ".join(_short(sid) for sid in done["stale"])
|
|
902
|
+
)
|
|
903
|
+
if done["split"]:
|
|
904
|
+
lines.append(
|
|
905
|
+
f"{len(done['split'])} session(s) handed out in parts of at most "
|
|
906
|
+
f"{done['turns']} turns, one part to a seat: "
|
|
907
|
+
+ ", ".join(
|
|
908
|
+
f"{_short(sid)} turns "
|
|
909
|
+
+ ", ".join(_run(part) for part in parts)
|
|
910
|
+
+ f" of {n}"
|
|
911
|
+
for sid, n, parts in done["split"]
|
|
912
|
+
)
|
|
913
|
+
)
|
|
914
|
+
record = done["record"] or "RECORD"
|
|
915
|
+
lines += [
|
|
916
|
+
f"plan: {done['plan']}",
|
|
917
|
+
"next:",
|
|
918
|
+
f" quirework sop runs prepare {done['plan']} {record}",
|
|
919
|
+
f" quirework sop runs start {record}",
|
|
920
|
+
f" quirework spec trail backfill import {record}",
|
|
921
|
+
]
|
|
922
|
+
return lines
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
# --------------------------------------------------------------------------- #
|
|
926
|
+
# importing what readers flagged
|
|
927
|
+
# --------------------------------------------------------------------------- #
|
|
928
|
+
def _objects(answer):
|
|
929
|
+
"""The JSON objects in an answer, one per line, tolerating fences and prose."""
|
|
930
|
+
found, skipped = [], []
|
|
931
|
+
for line in answer.splitlines():
|
|
932
|
+
start, end = line.find("{"), line.rfind("}")
|
|
933
|
+
if start < 0 or end <= start:
|
|
934
|
+
if line.strip() and not line.strip().startswith("```"):
|
|
935
|
+
skipped.append(line.strip()[:120])
|
|
936
|
+
continue
|
|
937
|
+
try:
|
|
938
|
+
value = json.loads(line[start : end + 1])
|
|
939
|
+
except json.JSONDecodeError:
|
|
940
|
+
skipped.append(line.strip()[:120])
|
|
941
|
+
continue
|
|
942
|
+
if isinstance(value, dict):
|
|
943
|
+
found.append(value)
|
|
944
|
+
else:
|
|
945
|
+
skipped.append(line.strip()[:120])
|
|
946
|
+
return found, skipped
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def _cut_off(answer):
|
|
950
|
+
"""Whether an answer stops partway through a line, as one cut off does.
|
|
951
|
+
|
|
952
|
+
A reader that runs out of output leaves its last line unfinished. A last
|
|
953
|
+
line that parses, or is a closing fence, is an answer that ended.
|
|
954
|
+
"""
|
|
955
|
+
lines = [line.strip() for line in answer.splitlines() if line.strip()]
|
|
956
|
+
if not lines or lines[-1].startswith("```"):
|
|
957
|
+
return False
|
|
958
|
+
found, _ = _objects(lines[-1])
|
|
959
|
+
return not found
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def parse_answer(answer, sessions, corpus, parts=None, seen=None):
|
|
963
|
+
"""``{sid: read}`` for each session the answer finished, plus what it dropped.
|
|
964
|
+
|
|
965
|
+
Every flag is checked against the corpus: the session must be one the seat
|
|
966
|
+
was handed, the turn one of the author's, and each slug a spec section. A
|
|
967
|
+
flag that fails is dropped and reported, never guessed into shape. The
|
|
968
|
+
logged time stored with a flag comes from the corpus, not the reader, so
|
|
969
|
+
the flag survives a later renumbering of the session's turns. ``seen``
|
|
970
|
+
maps each session to the keys of the turns its packet held (``_keys``),
|
|
971
|
+
which the read records, and ``parts`` a session handed only some turns to
|
|
972
|
+
their numbers: a flag on a turn the packet did not hold is dropped. A
|
|
973
|
+
plan made before reads kept keys gives no ``seen``, and its ``parts``
|
|
974
|
+
are ``[first, last]``, which the read records as the part it covers.
|
|
975
|
+
"""
|
|
976
|
+
titles = trail._spec_titles()
|
|
977
|
+
captured = corpus.captured()
|
|
978
|
+
parts = parts or {}
|
|
979
|
+
handed = {sid: captured[sid] for sid in sessions if sid in captured}
|
|
980
|
+
if seen is None:
|
|
981
|
+
turns = {
|
|
982
|
+
sid: {
|
|
983
|
+
t.number: t
|
|
984
|
+
for t in corpus.turns(sid, *handed[sid])
|
|
985
|
+
if sid not in parts or parts[sid][0] <= t.number <= parts[sid][1]
|
|
986
|
+
}
|
|
987
|
+
for sid in handed
|
|
988
|
+
}
|
|
989
|
+
else:
|
|
990
|
+
# The reader numbers a turn as its packet did; the key finds that turn
|
|
991
|
+
# today, wherever a renumbering since has moved it.
|
|
992
|
+
turns = {}
|
|
993
|
+
for sid, (family, path) in handed.items():
|
|
994
|
+
now = corpus.turns(sid, family, path)
|
|
995
|
+
by_key = dict(zip(_keys((t.logged, t.text) for t in now), now))
|
|
996
|
+
numbers = parts.get(sid) or range(1, len(seen[sid]) + 1)
|
|
997
|
+
turns[sid] = {
|
|
998
|
+
n: by_key[key] for n, key in zip(numbers, seen[sid]) if key in by_key
|
|
999
|
+
}
|
|
1000
|
+
objects, skipped = _objects(answer)
|
|
1001
|
+
dropped = [{"line": s, "why": "not a JSON object"} for s in skipped]
|
|
1002
|
+
dropped += [
|
|
1003
|
+
{"line": sid, "why": "no longer in the corpus"}
|
|
1004
|
+
for sid in sessions
|
|
1005
|
+
if sid not in handed
|
|
1006
|
+
]
|
|
1007
|
+
flags = {sid: {} for sid in handed}
|
|
1008
|
+
finished = set()
|
|
1009
|
+
about = {}
|
|
1010
|
+
|
|
1011
|
+
def which(value):
|
|
1012
|
+
hits = [
|
|
1013
|
+
s
|
|
1014
|
+
for s in handed
|
|
1015
|
+
if isinstance(value, str) and value and s.startswith(value)
|
|
1016
|
+
]
|
|
1017
|
+
return hits[0] if len(hits) == 1 else None
|
|
1018
|
+
|
|
1019
|
+
for obj in objects:
|
|
1020
|
+
sid = which(obj.get("session"))
|
|
1021
|
+
if sid is None:
|
|
1022
|
+
dropped.append({"line": json.dumps(obj)[:200], "why": "unknown session"})
|
|
1023
|
+
continue
|
|
1024
|
+
if obj.get("done") is True:
|
|
1025
|
+
finished.add(sid)
|
|
1026
|
+
about[sid] = " ".join(str(obj.get("about") or "").split())[:300] or None
|
|
1027
|
+
continue
|
|
1028
|
+
number = obj.get("turn")
|
|
1029
|
+
turn = turns[sid].get(number) if type(number) is int else None
|
|
1030
|
+
if turn is None:
|
|
1031
|
+
dropped.append(
|
|
1032
|
+
{"line": json.dumps(obj)[:200], "why": "no such author turn"}
|
|
1033
|
+
)
|
|
1034
|
+
continue
|
|
1035
|
+
for item in obj.get("sections") or []:
|
|
1036
|
+
if not isinstance(item, dict) or item.get("slug") not in titles:
|
|
1037
|
+
dropped.append(
|
|
1038
|
+
{"line": json.dumps(item)[:200], "why": "not a spec section"}
|
|
1039
|
+
)
|
|
1040
|
+
continue
|
|
1041
|
+
kind = item.get("kind") if item.get("kind") in READ_KINDS else "unsure"
|
|
1042
|
+
confidence = item.get("confidence")
|
|
1043
|
+
confidence = confidence if confidence in CONFIDENCE else "low"
|
|
1044
|
+
reason = " ".join(str(item.get("reason", "")).split())[:300]
|
|
1045
|
+
entry = flags[sid].setdefault(
|
|
1046
|
+
turn.number,
|
|
1047
|
+
{"turn": turn.number, "logged": _instant(turn.logged), "sections": []},
|
|
1048
|
+
)
|
|
1049
|
+
entry["sections"].append(
|
|
1050
|
+
{
|
|
1051
|
+
"slug": item["slug"],
|
|
1052
|
+
"kind": kind,
|
|
1053
|
+
"confidence": confidence,
|
|
1054
|
+
"reason": reason,
|
|
1055
|
+
}
|
|
1056
|
+
)
|
|
1057
|
+
reads = {
|
|
1058
|
+
sid: {
|
|
1059
|
+
"session": sid,
|
|
1060
|
+
"harness": trail._HARNESS_OF[handed[sid][0]],
|
|
1061
|
+
"turns": len(turns[sid]) if seen is None else len(seen[sid]),
|
|
1062
|
+
"about": about[sid],
|
|
1063
|
+
"candidates": [flags[sid][n] for n in sorted(flags[sid])],
|
|
1064
|
+
**(
|
|
1065
|
+
{"seen": seen[sid]}
|
|
1066
|
+
if seen is not None
|
|
1067
|
+
else {"part": {"first": parts[sid][0], "last": parts[sid][1]}}
|
|
1068
|
+
if sid in parts
|
|
1069
|
+
else {}
|
|
1070
|
+
),
|
|
1071
|
+
}
|
|
1072
|
+
for sid in finished
|
|
1073
|
+
}
|
|
1074
|
+
return reads, dropped
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def import_run(record, accept=()):
|
|
1078
|
+
"""Bring each finished seat's flags into the ledger; return one row per seat.
|
|
1079
|
+
|
|
1080
|
+
The record is verified first, so a changed answer or input is refused. A
|
|
1081
|
+
seat with a red flag its plan did not accept is held, and nothing it
|
|
1082
|
+
read is recorded: an unexpected flag is the author's call, not the
|
|
1083
|
+
importer's. ``accept`` names flag codes to accept here instead, for a
|
|
1084
|
+
record prepared before its plan could declare them, and only under the
|
|
1085
|
+
same policy a plan would need. A session whose done line is missing is
|
|
1086
|
+
not recorded as read: it is left for a later plan to hand out again.
|
|
1087
|
+
What an answer held that could not be checked is kept beside the reads,
|
|
1088
|
+
in the seat's own record, for audit.
|
|
1089
|
+
"""
|
|
1090
|
+
from quirework.provenance import show
|
|
1091
|
+
from quirework.runs.engine import ACCEPTABLE_FLAGS, Run, flag_code
|
|
1092
|
+
|
|
1093
|
+
unknown = set(accept) - set(ACCEPTABLE_FLAGS)
|
|
1094
|
+
if unknown:
|
|
1095
|
+
raise ValueError(f"no such flag code: {', '.join(sorted(unknown))}")
|
|
1096
|
+
run = Run(record)
|
|
1097
|
+
run.verify()
|
|
1098
|
+
corpus = trail.Corpus()
|
|
1099
|
+
root = ledger()
|
|
1100
|
+
tag = run.plan["id"][:8]
|
|
1101
|
+
report = []
|
|
1102
|
+
for name, seat in run.seats.items():
|
|
1103
|
+
meta = (seat.get("metadata") or {}).get("trail_read")
|
|
1104
|
+
if not meta:
|
|
1105
|
+
continue
|
|
1106
|
+
row = {
|
|
1107
|
+
"seat": name,
|
|
1108
|
+
"read": 0,
|
|
1109
|
+
"unread": [],
|
|
1110
|
+
"dropped": [],
|
|
1111
|
+
"answered": False,
|
|
1112
|
+
"held": [],
|
|
1113
|
+
"accepted": [],
|
|
1114
|
+
"model": None,
|
|
1115
|
+
"turns": 0,
|
|
1116
|
+
"flagged": 0,
|
|
1117
|
+
"empty": [],
|
|
1118
|
+
"sections": [],
|
|
1119
|
+
"sessions": [],
|
|
1120
|
+
"cut": None,
|
|
1121
|
+
"handed": len(meta["sessions"]),
|
|
1122
|
+
}
|
|
1123
|
+
report.append(row)
|
|
1124
|
+
done = run.state(name)["done"].get("read")
|
|
1125
|
+
if not done:
|
|
1126
|
+
continue
|
|
1127
|
+
row["answered"] = True
|
|
1128
|
+
receipt = done["receipt"]
|
|
1129
|
+
row["model"] = receipt.get("model")
|
|
1130
|
+
open_flags, row["accepted"] = run.red_flags(name)
|
|
1131
|
+
for flag in list(open_flags):
|
|
1132
|
+
code = flag_code(flag)
|
|
1133
|
+
if code in accept and ACCEPTABLE_FLAGS[code][1](seat["policy"]):
|
|
1134
|
+
open_flags.remove(flag)
|
|
1135
|
+
row["accepted"].append(flag)
|
|
1136
|
+
if open_flags:
|
|
1137
|
+
row["held"] = open_flags
|
|
1138
|
+
continue
|
|
1139
|
+
answer = (run.record(name, "read") / "answers" / "answer.md").read_text(
|
|
1140
|
+
encoding="utf-8"
|
|
1141
|
+
)
|
|
1142
|
+
reads, dropped = parse_answer(
|
|
1143
|
+
answer, meta["sessions"], corpus, meta.get("parts"), meta.get("seen")
|
|
1144
|
+
)
|
|
1145
|
+
captured = corpus.captured()
|
|
1146
|
+
for sid, read in reads.items():
|
|
1147
|
+
# What the operator checks a quiet seat against: the reader's own
|
|
1148
|
+
# account of each session beside the title the reader never saw.
|
|
1149
|
+
row["sessions"].append(
|
|
1150
|
+
{
|
|
1151
|
+
"session": sid,
|
|
1152
|
+
"turns": read["turns"],
|
|
1153
|
+
"flagged": len(read["candidates"]),
|
|
1154
|
+
"about": read["about"],
|
|
1155
|
+
"title": show.session_title(*captured[sid]),
|
|
1156
|
+
}
|
|
1157
|
+
)
|
|
1158
|
+
row["turns"] += read["turns"]
|
|
1159
|
+
row["flagged"] += len(read["candidates"])
|
|
1160
|
+
if not read["candidates"]:
|
|
1161
|
+
row["empty"].append((sid, read["turns"]))
|
|
1162
|
+
row["sections"] += [
|
|
1163
|
+
(s["slug"], s["confidence"])
|
|
1164
|
+
for c in read["candidates"]
|
|
1165
|
+
for s in c["sections"]
|
|
1166
|
+
]
|
|
1167
|
+
target = root / "reads" / sid / f"{tag}-{name}.json"
|
|
1168
|
+
if target.exists():
|
|
1169
|
+
continue
|
|
1170
|
+
read.update(
|
|
1171
|
+
packet_version=meta["packet_version"],
|
|
1172
|
+
slugs=meta["slugs"],
|
|
1173
|
+
run=run.plan["id"],
|
|
1174
|
+
seat=name,
|
|
1175
|
+
requested=seat["config"].get("model"),
|
|
1176
|
+
model=receipt.get("model"),
|
|
1177
|
+
effort=seat["config"].get("effort"),
|
|
1178
|
+
red_flags=receipt.get("red_flags", []),
|
|
1179
|
+
imported=date.today().isoformat(),
|
|
1180
|
+
)
|
|
1181
|
+
_write_private(target, json.dumps(read, indent=2) + "\n")
|
|
1182
|
+
row["read"] = len(reads)
|
|
1183
|
+
row["unread"] = [sid for sid in meta["sessions"] if sid not in reads]
|
|
1184
|
+
row["dropped"] = dropped
|
|
1185
|
+
if row["unread"] and _cut_off(answer):
|
|
1186
|
+
usage = receipt.get("usage") or {}
|
|
1187
|
+
row["cut"] = {
|
|
1188
|
+
"output": usage.get("output_tokens"),
|
|
1189
|
+
"thinking": usage.get("thinking_tokens"),
|
|
1190
|
+
}
|
|
1191
|
+
seat_record = {
|
|
1192
|
+
"run": run.plan["id"],
|
|
1193
|
+
"seat": name,
|
|
1194
|
+
"model": receipt.get("model"),
|
|
1195
|
+
"sessions": meta["sessions"],
|
|
1196
|
+
"unread": row["unread"],
|
|
1197
|
+
"dropped": dropped,
|
|
1198
|
+
}
|
|
1199
|
+
_write_private(
|
|
1200
|
+
root / "seats" / f"{tag}-{name}.json",
|
|
1201
|
+
json.dumps(seat_record, indent=2) + "\n",
|
|
1202
|
+
)
|
|
1203
|
+
return report
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
def import_lines(report):
|
|
1207
|
+
"""The import's report as printable lines: each seat, then the whole pass.
|
|
1208
|
+
|
|
1209
|
+
The pass lines are the numbers a ledger commit reports, so the operator
|
|
1210
|
+
copies them rather than recomputing them.
|
|
1211
|
+
"""
|
|
1212
|
+
lines = []
|
|
1213
|
+
for row in report:
|
|
1214
|
+
name = row["seat"]
|
|
1215
|
+
if not row["answered"]:
|
|
1216
|
+
lines.append(f" {name}: not answered yet")
|
|
1217
|
+
continue
|
|
1218
|
+
if row["held"]:
|
|
1219
|
+
lines.append(
|
|
1220
|
+
f" {name}: HELD, nothing recorded; a red flag its plan did not accept:"
|
|
1221
|
+
)
|
|
1222
|
+
lines += [f" ! {flag}" for flag in row["held"]]
|
|
1223
|
+
continue
|
|
1224
|
+
lines.append(
|
|
1225
|
+
f" {name}: {row['read']} session(s) read, "
|
|
1226
|
+
f"{row['flagged']} of {row['turns']} author turn(s) flagged"
|
|
1227
|
+
)
|
|
1228
|
+
for sid in row["unread"]:
|
|
1229
|
+
lines.append(f" {_short(sid)}: no done line, so not recorded as read")
|
|
1230
|
+
if row.get("cut"):
|
|
1231
|
+
spent = row["cut"]
|
|
1232
|
+
used = (
|
|
1233
|
+
f" after {spent['output']:,} output tokens"
|
|
1234
|
+
+ (
|
|
1235
|
+
f", {spent['thinking']:,} of them thinking"
|
|
1236
|
+
if spent["thinking"]
|
|
1237
|
+
else ""
|
|
1238
|
+
)
|
|
1239
|
+
if spent["output"]
|
|
1240
|
+
else ""
|
|
1241
|
+
)
|
|
1242
|
+
if row.get("handed", 2) > 1:
|
|
1243
|
+
advice = (
|
|
1244
|
+
"The next plan hands its unread sessions out again; give "
|
|
1245
|
+
"them fewer turns per seat (--turns)"
|
|
1246
|
+
)
|
|
1247
|
+
else:
|
|
1248
|
+
advice = (
|
|
1249
|
+
"It held one session: plan it again with fewer turns per "
|
|
1250
|
+
"seat (--turns), and it is read in parts"
|
|
1251
|
+
)
|
|
1252
|
+
lines.append(
|
|
1253
|
+
f" ! the answer stops mid-line{used}: the reader ran out of "
|
|
1254
|
+
f"room, most likely at its model's output limit. {advice}"
|
|
1255
|
+
)
|
|
1256
|
+
for drop in row["dropped"]:
|
|
1257
|
+
lines.append(f" dropped ({drop['why']}): {drop['line'][:100]}")
|
|
1258
|
+
if (
|
|
1259
|
+
row["turns"] >= DILIGENCE_TURNS
|
|
1260
|
+
and row["flagged"] < DILIGENCE_RATE * row["turns"]
|
|
1261
|
+
):
|
|
1262
|
+
lines.append(
|
|
1263
|
+
f" ! flagged under {DILIGENCE_RATE:.0%} of its turns: check that "
|
|
1264
|
+
"each account below matches its session before trusting the pass"
|
|
1265
|
+
)
|
|
1266
|
+
for s in row["sessions"]:
|
|
1267
|
+
lines += [
|
|
1268
|
+
f" {_short(s['session'])} · {s['turns']} turn(s), "
|
|
1269
|
+
f"{s['flagged']} flagged · {s['title']}",
|
|
1270
|
+
f" about: {s['about'] or '(no about line)'}",
|
|
1271
|
+
]
|
|
1272
|
+
rows = [r for r in report if r["answered"] and not r["held"]]
|
|
1273
|
+
if not rows:
|
|
1274
|
+
return lines
|
|
1275
|
+
handed = sum(r["read"] + len(r["unread"]) for r in rows)
|
|
1276
|
+
models = sorted({r["model"] or "unknown" for r in rows})
|
|
1277
|
+
sections = [s for r in rows for s in r["sections"]]
|
|
1278
|
+
confidence = {c: sum(1 for _, x in sections if x == c) for c in CONFIDENCE}
|
|
1279
|
+
empty = [e for r in rows for e in r["empty"]]
|
|
1280
|
+
held = [r for r in report if r["held"]]
|
|
1281
|
+
lines += [
|
|
1282
|
+
"pass:",
|
|
1283
|
+
f" {sum(r['read'] for r in rows)} of {handed} session(s) read in "
|
|
1284
|
+
f"{len(rows)} seat(s) on {', '.join(models)}",
|
|
1285
|
+
f" red flags: {sum(len(r['accepted']) for r in rows)} accepted, "
|
|
1286
|
+
f"{len(held)} seat(s) held",
|
|
1287
|
+
f" {len(sections):,} section flag(s) on {sum(r['flagged'] for r in rows):,} "
|
|
1288
|
+
f"of {sum(r['turns'] for r in rows):,} author turn(s), "
|
|
1289
|
+
f"{len({slug for slug, _ in sections})} section(s) "
|
|
1290
|
+
f"({', '.join(f'{n} {c}' for c, n in confidence.items())})",
|
|
1291
|
+
f" {sum(len(r['dropped']) for r in rows)} flag(s) dropped, "
|
|
1292
|
+
f"{len(empty)} session(s) with no flag",
|
|
1293
|
+
]
|
|
1294
|
+
bare = sum(1 for r in rows for s in r["sessions"] if not s["about"])
|
|
1295
|
+
if bare:
|
|
1296
|
+
lines.append(f" {bare} done line(s) without an about sentence")
|
|
1297
|
+
return lines
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
# --------------------------------------------------------------------------- #
|
|
1301
|
+
# rulings
|
|
1302
|
+
# --------------------------------------------------------------------------- #
|
|
1303
|
+
def _rulings_path(slug):
|
|
1304
|
+
return ledger() / "rulings" / f"{slug}.json"
|
|
1305
|
+
|
|
1306
|
+
|
|
1307
|
+
def rejections(slug):
|
|
1308
|
+
path = _rulings_path(slug)
|
|
1309
|
+
if not path.exists():
|
|
1310
|
+
return []
|
|
1311
|
+
return json.loads(path.read_text(encoding="utf-8")).get("rejected", [])
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
def _same(entry, turn):
|
|
1315
|
+
"""Whether a stored ``{session, turn, logged}`` names this corpus turn."""
|
|
1316
|
+
if entry["session"] != turn.session:
|
|
1317
|
+
return False
|
|
1318
|
+
if entry.get("logged") and turn.logged is not None:
|
|
1319
|
+
return entry["logged"] == _instant(turn.logged)
|
|
1320
|
+
return entry.get("turn") == turn.number
|
|
1321
|
+
|
|
1322
|
+
|
|
1323
|
+
def _rejection(slug, corpus, *, session, turn, reason):
|
|
1324
|
+
"""``(entry, target)`` for one rejection, checked but not yet written."""
|
|
1325
|
+
reason = " ".join(reason.split())
|
|
1326
|
+
if not reason:
|
|
1327
|
+
raise ValueError(
|
|
1328
|
+
"a rejection needs its reason, so no later pass reads it again"
|
|
1329
|
+
)
|
|
1330
|
+
sid, family, path, live = corpus.session(session)
|
|
1331
|
+
if live:
|
|
1332
|
+
raise LookupError(f"{_short(sid)} is not captured yet; capture it first")
|
|
1333
|
+
target = next(
|
|
1334
|
+
(t for t in corpus.turns(sid, family, path) if t.number == turn), None
|
|
1335
|
+
)
|
|
1336
|
+
if target is None:
|
|
1337
|
+
raise LookupError(f"{_short(sid)} has no author turn {turn}")
|
|
1338
|
+
entry = {
|
|
1339
|
+
"session": sid,
|
|
1340
|
+
"turn": turn,
|
|
1341
|
+
"logged": _instant(target.logged),
|
|
1342
|
+
"reason": reason,
|
|
1343
|
+
"date": date.today().isoformat(),
|
|
1344
|
+
}
|
|
1345
|
+
return entry, target
|
|
1346
|
+
|
|
1347
|
+
|
|
1348
|
+
def _record_rejections(slug, pairs):
|
|
1349
|
+
"""Write checked rejections to a section's ruling, all or none."""
|
|
1350
|
+
path = _rulings_path(slug)
|
|
1351
|
+
with trail._locked(path):
|
|
1352
|
+
rejected = rejections(slug)
|
|
1353
|
+
for i, (_, target) in enumerate(pairs):
|
|
1354
|
+
if any(_same(e, target) for e in rejected) or any(
|
|
1355
|
+
_same(e, target) for e, _ in pairs[:i]
|
|
1356
|
+
):
|
|
1357
|
+
raise LookupError(
|
|
1358
|
+
f"{target.short} turn {target.number} is already rejected "
|
|
1359
|
+
f"for #{slug}"
|
|
1360
|
+
)
|
|
1361
|
+
rejected += [entry for entry, _ in pairs]
|
|
1362
|
+
body = {"slug": slug, "rejected": rejected}
|
|
1363
|
+
_write_private(path, json.dumps(body, indent=2) + "\n")
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
|
+
def reject(slug, *, session, turn, reason):
|
|
1367
|
+
"""Record that a candidate does not belong on a section's trail, and why."""
|
|
1368
|
+
trail._known_slug(slug, trail._spec_titles())
|
|
1369
|
+
pair = _rejection(slug, trail.Corpus(), session=session, turn=turn, reason=reason)
|
|
1370
|
+
_record_rejections(slug, [pair])
|
|
1371
|
+
return pair[1]
|
|
1372
|
+
|
|
1373
|
+
|
|
1374
|
+
def rule(slug, lines):
|
|
1375
|
+
"""Apply a section's rulings from JSON lines; nothing is written unless all hold.
|
|
1376
|
+
|
|
1377
|
+
Each line keeps a turn or rejects it::
|
|
1378
|
+
|
|
1379
|
+
{"keep": "sharpen", "session": "ID", "turn": 4, "note": "…"}
|
|
1380
|
+
{"reject": "why it does not belong", "session": "ID", "turn": 5}
|
|
1381
|
+
|
|
1382
|
+
A keep may carry an ``excerpt``. Every line is checked against the corpus,
|
|
1383
|
+
the trail and the ledger before anything is written, so a mistake on the
|
|
1384
|
+
fortieth line leaves the first thirty-nine unapplied rather than half a
|
|
1385
|
+
section ruled. Returns ``(kept, rejected)`` as lists of turns.
|
|
1386
|
+
"""
|
|
1387
|
+
titles = trail._spec_titles()
|
|
1388
|
+
trail._known_slug(slug, titles)
|
|
1389
|
+
corpus = trail.Corpus()
|
|
1390
|
+
keeps, drops, errors = [], [], []
|
|
1391
|
+
for number, line in enumerate(lines, 1):
|
|
1392
|
+
if not line.strip() or line.lstrip().startswith("#"):
|
|
1393
|
+
continue
|
|
1394
|
+
try:
|
|
1395
|
+
item = json.loads(line)
|
|
1396
|
+
if not isinstance(item, dict):
|
|
1397
|
+
raise ValueError("not a JSON object")
|
|
1398
|
+
if ("keep" in item) == ("reject" in item):
|
|
1399
|
+
raise ValueError("give either keep (a kind) or reject (a reason)")
|
|
1400
|
+
if not isinstance(item.get("turn"), int) or not item.get("session"):
|
|
1401
|
+
raise ValueError("name the turn with session and an integer turn")
|
|
1402
|
+
if "keep" in item:
|
|
1403
|
+
keeps.append(
|
|
1404
|
+
trail.prepare(
|
|
1405
|
+
slug,
|
|
1406
|
+
item["keep"],
|
|
1407
|
+
session=item["session"],
|
|
1408
|
+
turn=item["turn"],
|
|
1409
|
+
note=item.get("note", ""),
|
|
1410
|
+
excerpt=item.get("excerpt", ""),
|
|
1411
|
+
corpus=corpus,
|
|
1412
|
+
titles=titles,
|
|
1413
|
+
)
|
|
1414
|
+
)
|
|
1415
|
+
else:
|
|
1416
|
+
drops.append(
|
|
1417
|
+
_rejection(
|
|
1418
|
+
slug,
|
|
1419
|
+
corpus,
|
|
1420
|
+
session=item["session"],
|
|
1421
|
+
turn=item["turn"],
|
|
1422
|
+
reason=str(item["reject"]),
|
|
1423
|
+
)
|
|
1424
|
+
)
|
|
1425
|
+
except (ValueError, LookupError) as exc:
|
|
1426
|
+
errors.append(f"line {number}: {exc}")
|
|
1427
|
+
for _, target in keeps:
|
|
1428
|
+
if any(_same(entry, target) for entry, _ in drops):
|
|
1429
|
+
errors.append(
|
|
1430
|
+
f"{target.short} turn {target.number} is both kept and rejected"
|
|
1431
|
+
)
|
|
1432
|
+
for entry in rejections(slug):
|
|
1433
|
+
for _, target in keeps:
|
|
1434
|
+
if _same(entry, target):
|
|
1435
|
+
errors.append(
|
|
1436
|
+
f"{target.short} turn {target.number} is already rejected "
|
|
1437
|
+
f"for #{slug}"
|
|
1438
|
+
)
|
|
1439
|
+
if errors:
|
|
1440
|
+
raise LookupError(
|
|
1441
|
+
"nothing was written; fix these and run it again:\n " + "\n ".join(errors)
|
|
1442
|
+
)
|
|
1443
|
+
if keeps:
|
|
1444
|
+
trail.append(slug, [crumb for crumb, _ in keeps], titles)
|
|
1445
|
+
if drops:
|
|
1446
|
+
_record_rejections(slug, drops)
|
|
1447
|
+
return [t for _, t in keeps], [t for _, t in drops]
|
|
1448
|
+
|
|
1449
|
+
|
|
1450
|
+
# --------------------------------------------------------------------------- #
|
|
1451
|
+
# a section's worklist
|
|
1452
|
+
# --------------------------------------------------------------------------- #
|
|
1453
|
+
@dataclass
|
|
1454
|
+
class Candidate:
|
|
1455
|
+
turn: trail.Turn
|
|
1456
|
+
flags: list = field(default_factory=list) # (model, flag as stored)
|
|
1457
|
+
commits: list = field(default_factory=list) # (sha8, subject, how)
|
|
1458
|
+
notes: list = field(default_factory=list) # what the ruler should know first
|
|
1459
|
+
reply: str = "" # the model's reply this turn answered
|
|
1460
|
+
|
|
1461
|
+
|
|
1462
|
+
def worklist(slug, *, commits=True, everything=False, also=()):
|
|
1463
|
+
"""``(pending, on_trail, rejected, stale)`` candidates for one section.
|
|
1464
|
+
|
|
1465
|
+
Candidates come from the readers' flags and, with ``commits``, from the
|
|
1466
|
+
commits that changed the section. ``also`` names neighbouring sections
|
|
1467
|
+
whose flags are candidates here too: a reader chooses one section for a
|
|
1468
|
+
turn that fits two, and the ruler of the other would never see it. One
|
|
1469
|
+
already on the trail or rejected is left out unless ``everything``. A flag
|
|
1470
|
+
whose turn the corpus no longer holds is ``stale``, reported rather than
|
|
1471
|
+
silently dropped.
|
|
1472
|
+
"""
|
|
1473
|
+
titles = trail._spec_titles()
|
|
1474
|
+
trail._known_slug(slug, titles)
|
|
1475
|
+
for other in also:
|
|
1476
|
+
trail._known_slug(other, titles)
|
|
1477
|
+
wanted = {slug, *also}
|
|
1478
|
+
corpus = trail.Corpus()
|
|
1479
|
+
found, stale = {}, []
|
|
1480
|
+
|
|
1481
|
+
def at(sid, number=None, logged=None):
|
|
1482
|
+
captured = corpus.captured().get(sid)
|
|
1483
|
+
if captured is None:
|
|
1484
|
+
return None
|
|
1485
|
+
for t in corpus.turns(sid, *captured):
|
|
1486
|
+
if logged and t.logged is not None:
|
|
1487
|
+
if _instant(t.logged) == logged:
|
|
1488
|
+
return t
|
|
1489
|
+
elif t.number == number:
|
|
1490
|
+
return t
|
|
1491
|
+
return None
|
|
1492
|
+
|
|
1493
|
+
for read in _reads():
|
|
1494
|
+
for entry in read["candidates"]:
|
|
1495
|
+
for item in entry["sections"]:
|
|
1496
|
+
if item["slug"] not in wanted:
|
|
1497
|
+
continue
|
|
1498
|
+
turn = at(read["session"], entry["turn"], entry.get("logged"))
|
|
1499
|
+
if turn is None:
|
|
1500
|
+
stale.append((read["session"], entry["turn"]))
|
|
1501
|
+
continue
|
|
1502
|
+
c = found.setdefault((turn.session, turn.number), Candidate(turn))
|
|
1503
|
+
c.flags.append(
|
|
1504
|
+
(read.get("model") or read.get("requested") or "?", item)
|
|
1505
|
+
)
|
|
1506
|
+
if commits:
|
|
1507
|
+
leads, _ = trail.suggest_leads(slug, corpus)
|
|
1508
|
+
for lead in leads:
|
|
1509
|
+
for sid, _, before in lead.sessions:
|
|
1510
|
+
for t in before:
|
|
1511
|
+
c = found.setdefault((t.session, t.number), Candidate(t))
|
|
1512
|
+
c.commits.append(
|
|
1513
|
+
(lead.sha[:8], lead.subject, "made in this session")
|
|
1514
|
+
)
|
|
1515
|
+
for quote, hits, _ in lead.quotes:
|
|
1516
|
+
for t in hits:
|
|
1517
|
+
c = found.setdefault((t.session, t.number), Candidate(t))
|
|
1518
|
+
c.commits.append(
|
|
1519
|
+
(lead.sha[:8], lead.subject, f"quoted “{quote[:60]}”")
|
|
1520
|
+
)
|
|
1521
|
+
|
|
1522
|
+
_annotate(found.values(), corpus)
|
|
1523
|
+
crumbs = [c for s, c in trail.load().crumbs() if s.slug == slug]
|
|
1524
|
+
rejected = rejections(slug)
|
|
1525
|
+
pending, on_trail, dropped = [], [], []
|
|
1526
|
+
for c in found.values():
|
|
1527
|
+
if any(trail._points_at(crumb, c.turn) for crumb in crumbs):
|
|
1528
|
+
on_trail.append(c)
|
|
1529
|
+
elif any(_same(e, c.turn) for e in rejected):
|
|
1530
|
+
dropped.append(c)
|
|
1531
|
+
else:
|
|
1532
|
+
pending.append(c)
|
|
1533
|
+
|
|
1534
|
+
days = {}
|
|
1535
|
+
|
|
1536
|
+
def order(c):
|
|
1537
|
+
return (_when(c.turn, corpus, days), c.turn.session, c.turn.number)
|
|
1538
|
+
|
|
1539
|
+
if everything:
|
|
1540
|
+
pending = pending + on_trail + dropped
|
|
1541
|
+
return sorted(pending, key=order), on_trail, dropped, stale
|
|
1542
|
+
|
|
1543
|
+
|
|
1544
|
+
def _day(turn, corpus, days):
|
|
1545
|
+
"""The day a timeless turn's session is dated, cached per session."""
|
|
1546
|
+
if turn.session not in days:
|
|
1547
|
+
from quirework.provenance import show
|
|
1548
|
+
|
|
1549
|
+
family, path = corpus.captured().get(turn.session, (turn.family, turn.path))
|
|
1550
|
+
days[turn.session] = show.session_date(family, path)
|
|
1551
|
+
return days[turn.session]
|
|
1552
|
+
|
|
1553
|
+
|
|
1554
|
+
def _when(turn, corpus, days):
|
|
1555
|
+
"""Where a turn sorts: its logged time, else its session's date, else first."""
|
|
1556
|
+
if turn.logged is not None:
|
|
1557
|
+
return turn.logged
|
|
1558
|
+
day = _day(turn, corpus, days)
|
|
1559
|
+
if day is not None:
|
|
1560
|
+
return datetime.combine(day, datetime.min.time(), timezone.utc)
|
|
1561
|
+
return datetime.min.replace(tzinfo=timezone.utc)
|
|
1562
|
+
|
|
1563
|
+
|
|
1564
|
+
def _annotate(candidates, corpus):
|
|
1565
|
+
"""Note the two copies a ruler should see before ruling on either.
|
|
1566
|
+
|
|
1567
|
+
A turn sent twice: the author resent the same words, usually because the
|
|
1568
|
+
first copy drew no reply, and only the copy that was answered is the one
|
|
1569
|
+
the session went on from. A turn captured twice: a resumed or forked
|
|
1570
|
+
session repeats the history it inherited under a new id, logged at the
|
|
1571
|
+
same instant with the same words.
|
|
1572
|
+
"""
|
|
1573
|
+
from quirework.provenance import show
|
|
1574
|
+
|
|
1575
|
+
candidates = list(candidates)
|
|
1576
|
+
replies = {}
|
|
1577
|
+
for c in candidates:
|
|
1578
|
+
t = c.turn
|
|
1579
|
+
if (t.family, t.path) not in replies:
|
|
1580
|
+
rows = show.exchanges(t.family, t.path)
|
|
1581
|
+
replies[(t.family, t.path)] = {
|
|
1582
|
+
n: (text, reply) for n, _, text, reply in rows
|
|
1583
|
+
}
|
|
1584
|
+
rows = replies[(t.family, t.path)]
|
|
1585
|
+
if t.number in rows:
|
|
1586
|
+
c.reply = rows[t.number][1]
|
|
1587
|
+
mine = trail._squash(t.text)
|
|
1588
|
+
for other in (t.number - 1, t.number + 1):
|
|
1589
|
+
if other in rows and trail._squash(rows[other][0]) == mine:
|
|
1590
|
+
after = rows.get(t.number + 1)
|
|
1591
|
+
if after is None:
|
|
1592
|
+
reply = "the session ends before its reply"
|
|
1593
|
+
elif after[1].strip():
|
|
1594
|
+
reply = "this copy drew a reply"
|
|
1595
|
+
else:
|
|
1596
|
+
reply = "this copy drew none"
|
|
1597
|
+
c.notes.append(f"same words as Human {other}; {reply}")
|
|
1598
|
+
twins = {}
|
|
1599
|
+
for c in candidates:
|
|
1600
|
+
if c.turn.logged is not None:
|
|
1601
|
+
key = (c.turn.logged, trail._squash(c.turn.text))
|
|
1602
|
+
twins.setdefault(key, []).append(c)
|
|
1603
|
+
for group in twins.values():
|
|
1604
|
+
if len({c.turn.session for c in group}) > 1:
|
|
1605
|
+
for c in group:
|
|
1606
|
+
others = ", ".join(
|
|
1607
|
+
f"{o.turn.short} Human {o.turn.number}"
|
|
1608
|
+
for o in group
|
|
1609
|
+
if o.turn.session != c.turn.session
|
|
1610
|
+
)
|
|
1611
|
+
c.notes.append(f"the same turn is captured again as {others}")
|
|
1612
|
+
|
|
1613
|
+
|
|
1614
|
+
def _worklist_lines(slug, *, commits, everything, utc, also=(), read=False):
|
|
1615
|
+
titles = trail._spec_titles()
|
|
1616
|
+
pending, on_trail, rejected, stale = worklist(
|
|
1617
|
+
slug, commits=commits, everything=everything, also=also
|
|
1618
|
+
)
|
|
1619
|
+
zone = timezone.utc if utc else None
|
|
1620
|
+
lines = [
|
|
1621
|
+
f"#{slug} — {titles[slug]}",
|
|
1622
|
+
(
|
|
1623
|
+
f"{len(pending)} candidate(s) to rule on, oldest first; "
|
|
1624
|
+
f"{len(on_trail)} on the trail, {len(rejected)} rejected. These are "
|
|
1625
|
+
f"leads, not breadcrumbs: read each turn before ruling on it."
|
|
1626
|
+
),
|
|
1627
|
+
(
|
|
1628
|
+
f" keep: quirework spec trail add {slug} --kind KIND "
|
|
1629
|
+
f'--session ID --turn N --note "…"'
|
|
1630
|
+
),
|
|
1631
|
+
(
|
|
1632
|
+
f" reject: quirework spec trail backfill reject {slug} "
|
|
1633
|
+
f'--session ID --turn N --reason "…"'
|
|
1634
|
+
),
|
|
1635
|
+
f" many: quirework spec trail backfill rule {slug} FILE",
|
|
1636
|
+
]
|
|
1637
|
+
if also:
|
|
1638
|
+
lines.append(
|
|
1639
|
+
" with the flags of "
|
|
1640
|
+
+ ", ".join(f"#{s}" for s in also)
|
|
1641
|
+
+ ", marked where a reader named that section instead"
|
|
1642
|
+
)
|
|
1643
|
+
if stale:
|
|
1644
|
+
lines.append(
|
|
1645
|
+
f" {len(stale)} flag(s) name a turn the corpus no longer holds: "
|
|
1646
|
+
+ ", ".join(f"{_short(s)} turn {n}" for s, n in stale[:5])
|
|
1647
|
+
)
|
|
1648
|
+
corpus, days = trail.Corpus(), {}
|
|
1649
|
+
for c in pending:
|
|
1650
|
+
t = c.turn
|
|
1651
|
+
if t.logged:
|
|
1652
|
+
when = f"{t.logged.astimezone(zone):%Y-%m-%d %H:%M}"
|
|
1653
|
+
else:
|
|
1654
|
+
day = _day(t, corpus, days)
|
|
1655
|
+
when = f"{day:%Y-%m-%d} (day only)" if day else "undated"
|
|
1656
|
+
lines += ["", f"{when} {t.harness} {t.short} Human {t.number}"]
|
|
1657
|
+
for model, item in c.flags:
|
|
1658
|
+
where = f" [#{item['slug']}]" if item["slug"] != slug else ""
|
|
1659
|
+
lines.append(
|
|
1660
|
+
f" {model}{where}: {item['kind']} ({item['confidence']}) "
|
|
1661
|
+
f"{item['reason']}"
|
|
1662
|
+
)
|
|
1663
|
+
for sha, subject, how in c.commits:
|
|
1664
|
+
lines.append(f" commit {sha} {trail._opening(subject, 50)}: {how}")
|
|
1665
|
+
for note in c.notes:
|
|
1666
|
+
lines.append(f" note {note}")
|
|
1667
|
+
if read:
|
|
1668
|
+
lines += _whole(c)
|
|
1669
|
+
else:
|
|
1670
|
+
lines.append(f" opens {trail._opening(t.text, 110)}")
|
|
1671
|
+
lines.append(f" read quirework provenance show {t.short} --turn {t.number}")
|
|
1672
|
+
return lines
|
|
1673
|
+
|
|
1674
|
+
|
|
1675
|
+
def _whole(candidate):
|
|
1676
|
+
"""A candidate as the ruler reads it: the turn, and the reply it answered.
|
|
1677
|
+
|
|
1678
|
+
The reply is cut to its end, as a packet cuts it, because a reply closes on
|
|
1679
|
+
what it proposes, and a bare "yes, go" accepts only that. The turn is
|
|
1680
|
+
clipped only where it is long enough to be mostly pasted material.
|
|
1681
|
+
"""
|
|
1682
|
+
lines = []
|
|
1683
|
+
if candidate.reply.strip():
|
|
1684
|
+
lines += ["", " It answered the end of this reply:", ""]
|
|
1685
|
+
lines += [
|
|
1686
|
+
f" {line}".rstrip()
|
|
1687
|
+
for line in _quoted(_tail(candidate.reply, REPLY_CHARS)).splitlines()
|
|
1688
|
+
]
|
|
1689
|
+
lines += ["", " The author wrote:", ""]
|
|
1690
|
+
body = _clip(candidate.turn.text) or "(nothing)"
|
|
1691
|
+
lines += [f" {line}".rstrip() for line in body.splitlines()]
|
|
1692
|
+
return lines + [""]
|
|
1693
|
+
|
|
1694
|
+
|
|
1695
|
+
# --------------------------------------------------------------------------- #
|
|
1696
|
+
# coverage
|
|
1697
|
+
# --------------------------------------------------------------------------- #
|
|
1698
|
+
def status():
|
|
1699
|
+
"""How far the backfill has come: sessions read, and sections left to rule."""
|
|
1700
|
+
from quirework.provenance import scope as SCOPE
|
|
1701
|
+
|
|
1702
|
+
corpus = trail.Corpus()
|
|
1703
|
+
kept = _kept(corpus)
|
|
1704
|
+
slugs = [s.slug for s in notes.spec_sections() if s.slug]
|
|
1705
|
+
reads = _reads()
|
|
1706
|
+
every = {sid: _Session(family, path) for sid, (family, path) in kept.items()}
|
|
1707
|
+
cover, stale = _coverage(every, reads)
|
|
1708
|
+
older = {r["session"] for r in reads} - set(cover)
|
|
1709
|
+
unoffered = {
|
|
1710
|
+
slug
|
|
1711
|
+
for r in reads
|
|
1712
|
+
if _current(r)
|
|
1713
|
+
for slug in slugs
|
|
1714
|
+
if slug not in r.get("slugs", [])
|
|
1715
|
+
}
|
|
1716
|
+
rows = {}
|
|
1717
|
+
for sid, (family, path) in kept.items():
|
|
1718
|
+
if not every[sid].numbers:
|
|
1719
|
+
continue
|
|
1720
|
+
key = (trail._HARNESS_OF[family], SCOPE.scope_of_id(sid))
|
|
1721
|
+
row = rows.setdefault(key, [0, 0, 0, 0, 0])
|
|
1722
|
+
unread = _unseen(every[sid], cover[sid]) if cover.get(sid) else None
|
|
1723
|
+
row[0] += 1
|
|
1724
|
+
row[1] += unread == []
|
|
1725
|
+
row[2] += sid in older
|
|
1726
|
+
row[3] += bool(unread)
|
|
1727
|
+
row[4] += len(unread or ())
|
|
1728
|
+
lines = ["sessions with author turns, by harness and scope:"]
|
|
1729
|
+
for (harness, scope), (total, done, old, part, left) in sorted(rows.items()):
|
|
1730
|
+
extra = f", {old} read only from an older packet" if old else ""
|
|
1731
|
+
extra += f", {part} read only in part ({left:,} turns unread)" if part else ""
|
|
1732
|
+
lines.append(f" {harness:<12} {scope:<7} {done:>4} of {total:>4} read{extra}")
|
|
1733
|
+
whole = sorted(sid for sid in stale if not cover.get(sid))
|
|
1734
|
+
if whole:
|
|
1735
|
+
lines.append(
|
|
1736
|
+
f" {len(whole)} session(s) will be read whole again, because an "
|
|
1737
|
+
"earlier read no longer fits the turns they hold: "
|
|
1738
|
+
+ ", ".join(_short(sid) for sid in whole)
|
|
1739
|
+
)
|
|
1740
|
+
if unoffered:
|
|
1741
|
+
lines.append(
|
|
1742
|
+
f" {len(unoffered)} section(s) are newer than some reads, which "
|
|
1743
|
+
f"were not offered them: " + ", ".join(f"#{s}" for s in sorted(unoffered))
|
|
1744
|
+
)
|
|
1745
|
+
flagged = {}
|
|
1746
|
+
for read in reads:
|
|
1747
|
+
for entry in read["candidates"]:
|
|
1748
|
+
for item in entry["sections"]:
|
|
1749
|
+
flagged.setdefault(item["slug"], set()).add(
|
|
1750
|
+
(read["session"], entry.get("logged") or entry["turn"])
|
|
1751
|
+
)
|
|
1752
|
+
held = trail.counts()
|
|
1753
|
+
lines += [
|
|
1754
|
+
"",
|
|
1755
|
+
"sections: turns the readers flagged, breadcrumbs, rejections",
|
|
1756
|
+
"(a section's worklist adds the turns its commits lead to):",
|
|
1757
|
+
]
|
|
1758
|
+
for slug in slugs:
|
|
1759
|
+
n = len(flagged.get(slug, ()))
|
|
1760
|
+
if n or held.get(slug) or rejections(slug):
|
|
1761
|
+
lines.append(
|
|
1762
|
+
f" #{slug:<34} {n:>4} flagged {held.get(slug, 0):>3} on the "
|
|
1763
|
+
f"trail {len(rejections(slug)):>3} rejected"
|
|
1764
|
+
)
|
|
1765
|
+
return lines
|
|
1766
|
+
|
|
1767
|
+
|
|
1768
|
+
# --------------------------------------------------------------------------- #
|
|
1769
|
+
# command line
|
|
1770
|
+
# --------------------------------------------------------------------------- #
|
|
1771
|
+
USAGE = """\
|
|
1772
|
+
backfilling a trail from the corpus, in three steps:
|
|
1773
|
+
|
|
1774
|
+
1. hand every session to a cheap reader, once:
|
|
1775
|
+
quirework spec trail backfill plan --harness H --model M --effort E [--out DIR]
|
|
1776
|
+
then the prepare, start and import commands it prints; import holds a
|
|
1777
|
+
seat whose receipt carries a red flag its plan did not accept. A plan
|
|
1778
|
+
hands out only the turns no reader saw, so one run after a reader
|
|
1779
|
+
learns a new kind of turn hands out just those turns; --reread hands
|
|
1780
|
+
out every turn of the sessions it picks
|
|
1781
|
+
2. rule on one section at a time, with a strong reader:
|
|
1782
|
+
quirework spec trail backfill SLUG the section's candidates, oldest first
|
|
1783
|
+
--also OTHER with the turns readers flagged for
|
|
1784
|
+
a neighbouring section instead
|
|
1785
|
+
--read each turn whole, with the end of
|
|
1786
|
+
the reply it answered
|
|
1787
|
+
quirework spec trail add SLUG ... keep one as a breadcrumb
|
|
1788
|
+
quirework spec trail backfill reject SLUG --session ID --turn N --reason TEXT
|
|
1789
|
+
quirework spec trail backfill rule SLUG FILE
|
|
1790
|
+
keep and reject many at once, from
|
|
1791
|
+
JSON lines; all or nothing
|
|
1792
|
+
3. see how far it has come:
|
|
1793
|
+
quirework spec trail backfill status
|
|
1794
|
+
|
|
1795
|
+
quirework spec trail backfill packet ID... what a reader is handed
|
|
1796
|
+
quirework spec trail backfill menu the sections a reader names
|
|
1797
|
+
"""
|
|
1798
|
+
|
|
1799
|
+
|
|
1800
|
+
def main(argv):
|
|
1801
|
+
if not argv or argv[0] in ("-h", "--help"):
|
|
1802
|
+
print(USAGE)
|
|
1803
|
+
return 0
|
|
1804
|
+
command, rest = argv[0], argv[1:]
|
|
1805
|
+
try:
|
|
1806
|
+
if command == "packet":
|
|
1807
|
+
corpus = trail.Corpus()
|
|
1808
|
+
for prefix in rest:
|
|
1809
|
+
sid, family, path, _ = corpus.session(prefix)
|
|
1810
|
+
print(packet(sid, family, path))
|
|
1811
|
+
return 0
|
|
1812
|
+
if command == "menu":
|
|
1813
|
+
print(menu(), end="")
|
|
1814
|
+
return 0
|
|
1815
|
+
if command == "plan":
|
|
1816
|
+
ap = argparse.ArgumentParser(prog="quirework spec trail backfill plan")
|
|
1817
|
+
ap.add_argument(
|
|
1818
|
+
"--out",
|
|
1819
|
+
help="a new directory for packets (default: RUN/packets in a "
|
|
1820
|
+
"new run directory in the project's private state)",
|
|
1821
|
+
)
|
|
1822
|
+
ap.add_argument(
|
|
1823
|
+
"--harness", required=True, choices=("claude", "codex", "agy")
|
|
1824
|
+
)
|
|
1825
|
+
ap.add_argument("--model", required=True, help="a model family or version")
|
|
1826
|
+
ap.add_argument("--effort", required=True)
|
|
1827
|
+
ap.add_argument(
|
|
1828
|
+
"--tier", default="cheap", choices=("cheap", "standard", "premium")
|
|
1829
|
+
)
|
|
1830
|
+
ap.add_argument(
|
|
1831
|
+
"--session",
|
|
1832
|
+
action="append",
|
|
1833
|
+
dest="sessions",
|
|
1834
|
+
metavar="ID",
|
|
1835
|
+
help="only this session, and only its turns no reader saw",
|
|
1836
|
+
)
|
|
1837
|
+
ap.add_argument("--scope", help="only sessions of this provenance scope")
|
|
1838
|
+
ap.add_argument("--limit", type=int, help="at most N sessions")
|
|
1839
|
+
ap.add_argument(
|
|
1840
|
+
"--budget",
|
|
1841
|
+
type=int,
|
|
1842
|
+
default=BATCH_TOKENS,
|
|
1843
|
+
help="estimated tokens of packets per seat",
|
|
1844
|
+
)
|
|
1845
|
+
ap.add_argument(
|
|
1846
|
+
"--turns",
|
|
1847
|
+
type=int,
|
|
1848
|
+
default=SEAT_TURNS,
|
|
1849
|
+
help="author turns per seat; a longer session is read in parts",
|
|
1850
|
+
)
|
|
1851
|
+
ap.add_argument(
|
|
1852
|
+
"--material",
|
|
1853
|
+
action="append",
|
|
1854
|
+
default=[],
|
|
1855
|
+
metavar="FILE",
|
|
1856
|
+
help="book guidance every reader gets, such as retired names",
|
|
1857
|
+
)
|
|
1858
|
+
ap.add_argument(
|
|
1859
|
+
"--reread",
|
|
1860
|
+
action="store_true",
|
|
1861
|
+
help="hand out every turn, including those a current read saw",
|
|
1862
|
+
)
|
|
1863
|
+
ap.add_argument(
|
|
1864
|
+
"--external",
|
|
1865
|
+
action="store_true",
|
|
1866
|
+
help="seats whose answers are imported by hand",
|
|
1867
|
+
)
|
|
1868
|
+
a = ap.parse_args(rest)
|
|
1869
|
+
done = plan(
|
|
1870
|
+
a.out,
|
|
1871
|
+
harness=a.harness,
|
|
1872
|
+
model=a.model,
|
|
1873
|
+
effort=a.effort,
|
|
1874
|
+
tier=a.tier,
|
|
1875
|
+
sessions=a.sessions,
|
|
1876
|
+
scope=a.scope,
|
|
1877
|
+
limit=a.limit,
|
|
1878
|
+
budget=a.budget,
|
|
1879
|
+
turns=a.turns,
|
|
1880
|
+
materials=a.material,
|
|
1881
|
+
reread=a.reread,
|
|
1882
|
+
external=a.external,
|
|
1883
|
+
)
|
|
1884
|
+
print("\n".join(plan_lines(done)))
|
|
1885
|
+
return 0
|
|
1886
|
+
if command == "import":
|
|
1887
|
+
ap = argparse.ArgumentParser(prog="quirework spec trail backfill import")
|
|
1888
|
+
ap.add_argument("record")
|
|
1889
|
+
ap.add_argument(
|
|
1890
|
+
"--accept",
|
|
1891
|
+
action="append",
|
|
1892
|
+
default=[],
|
|
1893
|
+
metavar="CODE",
|
|
1894
|
+
help="a red flag to accept for a record whose plan predates "
|
|
1895
|
+
"declaring it (session-bus)",
|
|
1896
|
+
)
|
|
1897
|
+
a = ap.parse_args(rest)
|
|
1898
|
+
report = import_run(a.record, accept=a.accept)
|
|
1899
|
+
print("\n".join(import_lines(report)))
|
|
1900
|
+
return 1 if any(row["held"] for row in report) else 0
|
|
1901
|
+
if command == "reject":
|
|
1902
|
+
ap = argparse.ArgumentParser(prog="quirework spec trail backfill reject")
|
|
1903
|
+
ap.add_argument("slug")
|
|
1904
|
+
ap.add_argument("--session", required=True, metavar="ID")
|
|
1905
|
+
ap.add_argument("--turn", required=True, type=int, metavar="N")
|
|
1906
|
+
ap.add_argument("--reason", required=True)
|
|
1907
|
+
a = ap.parse_args(rest)
|
|
1908
|
+
t = reject(a.slug, session=a.session, turn=a.turn, reason=a.reason)
|
|
1909
|
+
print(f"rejected for #{a.slug}: {t.harness} {t.short} Human {t.number}")
|
|
1910
|
+
return 0
|
|
1911
|
+
if command == "rule":
|
|
1912
|
+
ap = argparse.ArgumentParser(
|
|
1913
|
+
prog="quirework spec trail backfill rule",
|
|
1914
|
+
description="Keep and reject a section's candidates from a file of "
|
|
1915
|
+
'JSON lines: {"keep": KIND, "session": ID, "turn": N, "note": '
|
|
1916
|
+
'"…"} or {"reject": REASON, "session": ID, "turn": N}. Every '
|
|
1917
|
+
"line is checked before any is written.",
|
|
1918
|
+
)
|
|
1919
|
+
ap.add_argument("slug")
|
|
1920
|
+
ap.add_argument("file", help="the rulings, or - for standard input")
|
|
1921
|
+
a = ap.parse_args(rest)
|
|
1922
|
+
source = (
|
|
1923
|
+
sys.stdin
|
|
1924
|
+
if a.file == "-"
|
|
1925
|
+
else open(Path(a.file).expanduser(), encoding="utf-8")
|
|
1926
|
+
)
|
|
1927
|
+
with source:
|
|
1928
|
+
kept, dropped = rule(a.slug, source.read().splitlines())
|
|
1929
|
+
print(f"#{a.slug}: {len(kept)} kept on the trail, {len(dropped)} rejected")
|
|
1930
|
+
return 0
|
|
1931
|
+
if command == "status":
|
|
1932
|
+
print("\n".join(status()))
|
|
1933
|
+
return 0
|
|
1934
|
+
ap = argparse.ArgumentParser(prog="quirework spec trail backfill")
|
|
1935
|
+
ap.add_argument("slug")
|
|
1936
|
+
ap.add_argument("--all", action="store_true", help="include ruled candidates")
|
|
1937
|
+
ap.add_argument(
|
|
1938
|
+
"--no-commits",
|
|
1939
|
+
action="store_true",
|
|
1940
|
+
help="only the readers' flags, not the commit history",
|
|
1941
|
+
)
|
|
1942
|
+
ap.add_argument("--utc", action="store_true")
|
|
1943
|
+
ap.add_argument(
|
|
1944
|
+
"--also",
|
|
1945
|
+
action="append",
|
|
1946
|
+
default=[],
|
|
1947
|
+
metavar="SLUG",
|
|
1948
|
+
help="also the turns readers flagged for this neighbouring section",
|
|
1949
|
+
)
|
|
1950
|
+
ap.add_argument(
|
|
1951
|
+
"--read",
|
|
1952
|
+
action="store_true",
|
|
1953
|
+
help="print each turn whole, with the end of the reply it answered",
|
|
1954
|
+
)
|
|
1955
|
+
a = ap.parse_args(argv)
|
|
1956
|
+
print(
|
|
1957
|
+
"\n".join(
|
|
1958
|
+
_worklist_lines(
|
|
1959
|
+
a.slug,
|
|
1960
|
+
commits=not a.no_commits,
|
|
1961
|
+
everything=a.all,
|
|
1962
|
+
utc=a.utc,
|
|
1963
|
+
also=a.also,
|
|
1964
|
+
read=a.read,
|
|
1965
|
+
)
|
|
1966
|
+
)
|
|
1967
|
+
)
|
|
1968
|
+
print("\n" + trail._CONTEXT)
|
|
1969
|
+
return 0
|
|
1970
|
+
except (LookupError, ValueError) as exc:
|
|
1971
|
+
print(f"backfill: {exc}", file=sys.stderr)
|
|
1972
|
+
return 2
|