grask 0.1.0rc1__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.
- grask/SKILL.md +62 -0
- grask/__init__.py +3 -0
- grask/ask.py +224 -0
- grask/capture.py +158 -0
- grask/capture_run.py +236 -0
- grask/cli.py +257 -0
- grask/dialogue.py +183 -0
- grask/hook.py +59 -0
- grask/llm.py +323 -0
- grask/probe.py +361 -0
- grask/py.typed +0 -0
- grask/seed.py +216 -0
- grask/select.py +64 -0
- grask/storage.py +403 -0
- grask/survey.py +136 -0
- grask/transcript.py +170 -0
- grask/triage.py +362 -0
- grask/triage_run.py +154 -0
- grask-0.1.0rc1.dist-info/METADATA +233 -0
- grask-0.1.0rc1.dist-info/RECORD +23 -0
- grask-0.1.0rc1.dist-info/WHEEL +4 -0
- grask-0.1.0rc1.dist-info/entry_points.txt +4 -0
- grask-0.1.0rc1.dist-info/licenses/LICENSE +21 -0
grask/SKILL.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: grask
|
|
3
|
+
description: Serve the next pending grask probe — a multiple-choice question about code the developer recently shipped — using the native question UI. Use when the user types /grask.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Serving a grask probe
|
|
7
|
+
|
|
8
|
+
Grask quizzes the developer on mechanisms they shipped without fully
|
|
9
|
+
understanding. You are the delivery surface only: you serve the question and
|
|
10
|
+
relay the answer. You never grade, never guess, and never see the answer key.
|
|
11
|
+
|
|
12
|
+
## Hard rules
|
|
13
|
+
|
|
14
|
+
- Never open or query grask's database directly. The two subcommands below are
|
|
15
|
+
the entire interface.
|
|
16
|
+
- Never speculate about which option is correct — not in text, not in labels,
|
|
17
|
+
not in previews, not before or after the pick. Grading happens in
|
|
18
|
+
`grask record`.
|
|
19
|
+
- One native question, one round. No confidence round, no follow-ups.
|
|
20
|
+
|
|
21
|
+
## Flow
|
|
22
|
+
|
|
23
|
+
1. Run:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
grask serve --json
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
If the output is `{"pending": null}`, say there is nothing pending and stop.
|
|
30
|
+
If the command is not found, grask is not installed on this PATH — say so and
|
|
31
|
+
stop rather than hunting for a checkout to `cd` into.
|
|
32
|
+
|
|
33
|
+
2. Ask ONE native question, preview-style (like plan-mode option picks), built
|
|
34
|
+
entirely from the served JSON:
|
|
35
|
+
|
|
36
|
+
- `question`: a context line from `topic` and `created_at`, then the full
|
|
37
|
+
question text — e.g.
|
|
38
|
+
`from 2026-07-21 · idempotency of the retry path — What would happen if …?`
|
|
39
|
+
The question must be readable inside the picker itself; do not rely on
|
|
40
|
+
markdown printed before it.
|
|
41
|
+
- One option per stored option, in stored order:
|
|
42
|
+
- `label`: the letter plus the first few distinguishing words of the
|
|
43
|
+
option (e.g. `a) dedup to a no-op`). Keep labels short; they are not the
|
|
44
|
+
full text.
|
|
45
|
+
- `preview`: the full stored option text, verbatim and unabridged. The
|
|
46
|
+
side-by-side preview pane is where the developer reads the option.
|
|
47
|
+
- In exactly one option's preview (or the question text), append a footer
|
|
48
|
+
note: "Other" accepts `skip`, or `wrong: <what's off>` if the question
|
|
49
|
+
misreads what happened.
|
|
50
|
+
|
|
51
|
+
3. Record the result:
|
|
52
|
+
- Picked letter L: `grask record <probe_id> --pick L`
|
|
53
|
+
- Skipped: `grask record <probe_id> --skip`
|
|
54
|
+
- Premise rejected: `grask record <probe_id> --wrong --objection
|
|
55
|
+
"<their words>"` (omit `--objection` if they gave no reason).
|
|
56
|
+
|
|
57
|
+
Show the result: ✓ or ✗ from `outcome`, then the `explanation` verbatim. If
|
|
58
|
+
the command prints `{"error": ...}`, show the error and stop — do not retry
|
|
59
|
+
with different flags.
|
|
60
|
+
|
|
61
|
+
4. Run `serve` again. If another probe is pending, say so and offer to
|
|
62
|
+
continue — do not auto-serve it.
|
grask/__init__.py
ADDED
grask/ask.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""One probe, asked and graded mechanically.
|
|
2
|
+
|
|
3
|
+
Pure logic. The console is injected, exactly as `capture_session` injects its
|
|
4
|
+
stages, so the whole flow is drivable by a scripted console. No argparse, no
|
|
5
|
+
terminal control codes, no TTY — those belong to `cli.py`, and keeping them out
|
|
6
|
+
is what leaves the delivery question open. `ask` does not care whether a human
|
|
7
|
+
typed `grask` or a hook called it.
|
|
8
|
+
|
|
9
|
+
There is no model call anywhere in this path. Picking an option IS the answer:
|
|
10
|
+
the verdict is `pick == correct_idx`, the explanation was written at generation
|
|
11
|
+
time, and the only `error` outcome left is a stored row too malformed to serve.
|
|
12
|
+
That is a property of the design, not of careful coding — a judge cannot be
|
|
13
|
+
slow, expensive, or cowardly if there is no judge.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Protocol
|
|
20
|
+
|
|
21
|
+
from grask.probe import Rubric
|
|
22
|
+
|
|
23
|
+
# Written to a TEXT column and grouped on by hand later, so the strings are the
|
|
24
|
+
# schema. `premise_rejected` is its own outcome rather than a flavour of skip:
|
|
25
|
+
# it is the zealot rate, and a rate you cannot query is a rate nobody checks.
|
|
26
|
+
PASSED = "passed"
|
|
27
|
+
FAILED = "failed"
|
|
28
|
+
SKIPPED = "skipped"
|
|
29
|
+
PREMISE_REJECTED = "premise_rejected"
|
|
30
|
+
ERROR = "error"
|
|
31
|
+
|
|
32
|
+
WRONG = "/wrong"
|
|
33
|
+
|
|
34
|
+
# Option letters, and therefore the hard cap on how many options a stored row
|
|
35
|
+
# may carry and still be served.
|
|
36
|
+
LETTERS = "abcde"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Console(Protocol):
|
|
40
|
+
"""Everything the interrogation needs from the outside world."""
|
|
41
|
+
|
|
42
|
+
def show(self, text: str) -> None: ...
|
|
43
|
+
|
|
44
|
+
def prompt(self, text: str) -> str: ...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class PendingProbe:
|
|
49
|
+
"""A stored probe, ready to be asked.
|
|
50
|
+
|
|
51
|
+
Lives here rather than in `storage.py` so that the import runs
|
|
52
|
+
storage -> ask, matching how storage already imports `Probe` and `Seed` from
|
|
53
|
+
the modules that produce them, and avoiding the cycle the other direction
|
|
54
|
+
would create.
|
|
55
|
+
|
|
56
|
+
`correct_idx` is `int | None` because the row is trusted nowhere: a mangled
|
|
57
|
+
row surfaces as `None`, an empty tuple, or an out-of-range index, and `ask`
|
|
58
|
+
records `error` instead of guessing.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
probe_id: int
|
|
62
|
+
question: str
|
|
63
|
+
options: tuple[str, ...]
|
|
64
|
+
correct_idx: int | None
|
|
65
|
+
explanation: str
|
|
66
|
+
rubric: Rubric
|
|
67
|
+
created_at: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class AnswerTurn:
|
|
72
|
+
"""One developer turn: the question, and the option text they chose."""
|
|
73
|
+
|
|
74
|
+
turn: int
|
|
75
|
+
question: str
|
|
76
|
+
answer: str
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class Interrogation:
|
|
81
|
+
"""Everything that happened, ready to be stored. No database in sight."""
|
|
82
|
+
|
|
83
|
+
probe_id: int
|
|
84
|
+
outcome: str
|
|
85
|
+
# Always None since the confidence round was cut; the column stays nullable
|
|
86
|
+
# so historical rows keep their numbers.
|
|
87
|
+
confidence: int | None
|
|
88
|
+
objection: str | None
|
|
89
|
+
turns: tuple[AnswerTurn, ...]
|
|
90
|
+
cost_usd: float | None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
OBJECTION_PROMPT = "what's wrong with it? (enter to skip)"
|
|
94
|
+
MALFORMED = "this probe's stored options are unusable; recording an error."
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def context_line(pending: PendingProbe) -> str:
|
|
98
|
+
"""One line of orientation above the question.
|
|
99
|
+
|
|
100
|
+
Without it the developer reads a question about work they cannot place, which
|
|
101
|
+
is the version of this tool that feels like a quiz. `created_at` is the
|
|
102
|
+
probe's, which is the session's within a few seconds of it.
|
|
103
|
+
"""
|
|
104
|
+
when = pending.created_at[:10]
|
|
105
|
+
return f"from {when} · {pending.rubric.topic}"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def render_options(options: tuple[str, ...]) -> str:
|
|
109
|
+
return "\n".join(f" {LETTERS[i]}) {option}" for i, option in enumerate(options))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def pick_prompt(count: int) -> str:
|
|
113
|
+
return f"pick [a-{LETTERS[count - 1]}] · enter = skip · /wrong"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def pick_hint(count: int) -> str:
|
|
117
|
+
return f"type a single letter, a-{LETTERS[count - 1]}."
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _unservable(pending: PendingProbe) -> bool:
|
|
121
|
+
"""A row the ask cannot honestly grade.
|
|
122
|
+
|
|
123
|
+
Storage already filters `options IS NULL`, so what arrives here failed to
|
|
124
|
+
parse or carries an index that names no option. Grading either would invent
|
|
125
|
+
a verdict, which is worse than admitting the row is broken.
|
|
126
|
+
"""
|
|
127
|
+
return (
|
|
128
|
+
len(pending.options) < 2
|
|
129
|
+
or len(pending.options) > len(LETTERS)
|
|
130
|
+
or pending.correct_idx is None
|
|
131
|
+
or not 0 <= pending.correct_idx < len(pending.options)
|
|
132
|
+
or not pending.explanation.strip()
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def resolution(
|
|
137
|
+
pending: PendingProbe,
|
|
138
|
+
outcome: str,
|
|
139
|
+
*,
|
|
140
|
+
objection: str | None = None,
|
|
141
|
+
turns: tuple[AnswerTurn, ...] = (),
|
|
142
|
+
) -> Interrogation:
|
|
143
|
+
"""An Interrogation for `pending`, however it ended.
|
|
144
|
+
|
|
145
|
+
Shared by the interactive loop and the non-interactive record path so both
|
|
146
|
+
write identically shaped rows. `cost_usd` is always 0.0: there is no model
|
|
147
|
+
call anywhere in an ask, whichever surface drove it.
|
|
148
|
+
"""
|
|
149
|
+
return Interrogation(
|
|
150
|
+
probe_id=pending.probe_id,
|
|
151
|
+
outcome=outcome,
|
|
152
|
+
confidence=None,
|
|
153
|
+
objection=objection,
|
|
154
|
+
turns=turns,
|
|
155
|
+
cost_usd=0.0,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def grade(pending: PendingProbe, pick: str) -> Interrogation:
|
|
160
|
+
"""(pending, pick letter) -> a mechanically graded Interrogation.
|
|
161
|
+
|
|
162
|
+
The one place a pick becomes a verdict; `ask` and `grask record` both land
|
|
163
|
+
here. Raises ValueError rather than guessing on bad input — the caller
|
|
164
|
+
decides how to surface it. Never call this on a row `_unservable` flags.
|
|
165
|
+
"""
|
|
166
|
+
valid = LETTERS[: len(pending.options)]
|
|
167
|
+
letter = pick.strip().lower()
|
|
168
|
+
if len(letter) != 1 or letter not in valid:
|
|
169
|
+
raise ValueError(f"pick must be a single letter, a-{valid[-1]}")
|
|
170
|
+
picked = valid.index(letter)
|
|
171
|
+
return resolution(
|
|
172
|
+
pending,
|
|
173
|
+
PASSED if picked == pending.correct_idx else FAILED,
|
|
174
|
+
turns=(AnswerTurn(turn=0, question=pending.question, answer=pending.options[picked]),),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def ask(pending: PendingProbe, console: Console) -> Interrogation:
|
|
179
|
+
"""Run one probe to a verdict: pick, mechanical grade."""
|
|
180
|
+
|
|
181
|
+
def done(
|
|
182
|
+
outcome: str,
|
|
183
|
+
*,
|
|
184
|
+
objection: str | None = None,
|
|
185
|
+
turns: tuple[AnswerTurn, ...] = (),
|
|
186
|
+
) -> Interrogation:
|
|
187
|
+
return resolution(
|
|
188
|
+
pending,
|
|
189
|
+
outcome,
|
|
190
|
+
objection=objection,
|
|
191
|
+
turns=turns,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def ask_objection() -> str | None:
|
|
195
|
+
"""Shared `/wrong` handling: prompt once for an optional reason.
|
|
196
|
+
|
|
197
|
+
Optional because requiring an argument to escape is how you get an escape
|
|
198
|
+
hatch nobody uses. The outcome is the signal; the text is a bonus.
|
|
199
|
+
"""
|
|
200
|
+
typed = console.prompt(OBJECTION_PROMPT).strip()
|
|
201
|
+
return typed or None
|
|
202
|
+
|
|
203
|
+
if _unservable(pending):
|
|
204
|
+
console.show(MALFORMED)
|
|
205
|
+
return done(ERROR)
|
|
206
|
+
|
|
207
|
+
console.show(context_line(pending))
|
|
208
|
+
console.show(pending.question)
|
|
209
|
+
console.show(render_options(pending.options))
|
|
210
|
+
|
|
211
|
+
valid = LETTERS[: len(pending.options)]
|
|
212
|
+
while True:
|
|
213
|
+
typed = console.prompt(pick_prompt(len(pending.options))).strip().lower()
|
|
214
|
+
if not typed:
|
|
215
|
+
return done(SKIPPED)
|
|
216
|
+
if typed == WRONG:
|
|
217
|
+
return done(PREMISE_REJECTED, objection=ask_objection())
|
|
218
|
+
if len(typed) == 1 and typed in valid:
|
|
219
|
+
break
|
|
220
|
+
console.show(pick_hint(len(pending.options)))
|
|
221
|
+
|
|
222
|
+
graded = grade(pending, typed)
|
|
223
|
+
console.show(f"{'✓' if graded.outcome == PASSED else '✗'} {pending.explanation}")
|
|
224
|
+
return graded
|
grask/capture.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""The whole pipeline, end to end, for one session.
|
|
2
|
+
|
|
3
|
+
Runs detached from a SessionEnd hook with nothing watching its exit code. That
|
|
4
|
+
single fact decides the error handling: an exception here reaches no one, so
|
|
5
|
+
every failure has to become a row and a log line instead. `capture_session` does
|
|
6
|
+
not raise. If it ever does, a session ends and grask silently forgets it.
|
|
7
|
+
|
|
8
|
+
Order is extract → triage → seed → probe, cheapest first. Stage 0 is free and
|
|
9
|
+
filters sessions with no human in them; triage is one call and filters the
|
|
10
|
+
majority; only what survives both pays for stages 2 and 3.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import sys
|
|
16
|
+
import traceback
|
|
17
|
+
from datetime import UTC, datetime
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from grask.dialogue import extract_dialogue as _extract_dialogue
|
|
21
|
+
from grask.probe import probe as _probe
|
|
22
|
+
from grask.seed import seed as _seed
|
|
23
|
+
from grask.select import select
|
|
24
|
+
from grask.storage import Store, grask_home
|
|
25
|
+
from grask.transcript import extract
|
|
26
|
+
from grask.triage import triage as _triage
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def log(message: str) -> None:
|
|
30
|
+
"""Append to the capture log. Never raises — this is the failure path itself."""
|
|
31
|
+
try:
|
|
32
|
+
path = grask_home() / "grask.log"
|
|
33
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
stamp = datetime.now(UTC).isoformat()
|
|
35
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
36
|
+
handle.write(f"{stamp} {message}\n")
|
|
37
|
+
except Exception:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def capture_session(
|
|
42
|
+
transcript_path: Path,
|
|
43
|
+
store: Store,
|
|
44
|
+
*,
|
|
45
|
+
triage=_triage,
|
|
46
|
+
seed=_seed,
|
|
47
|
+
probe=_probe,
|
|
48
|
+
extract_dialogue=_extract_dialogue,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Triage one ended session and persist whatever it earned.
|
|
51
|
+
|
|
52
|
+
The stages are injectable so the control flow can be tested without spending
|
|
53
|
+
money on a model. Defaults are the real thing.
|
|
54
|
+
"""
|
|
55
|
+
session_id = Path(transcript_path).stem
|
|
56
|
+
try:
|
|
57
|
+
if store.has_session(session_id):
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
session = extract(Path(transcript_path))
|
|
61
|
+
|
|
62
|
+
if not session.turns:
|
|
63
|
+
# Stage 0's floor. No human said anything, so there is nothing to ask
|
|
64
|
+
# about and no reason to pay triage to confirm it.
|
|
65
|
+
store.record_session(
|
|
66
|
+
session_id=session.session_id,
|
|
67
|
+
transcript_path=str(transcript_path),
|
|
68
|
+
cwd=session.cwd,
|
|
69
|
+
git_branch=session.git_branch,
|
|
70
|
+
verdict="silent",
|
|
71
|
+
)
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
verdict = triage(session)
|
|
75
|
+
|
|
76
|
+
# triage() never raises; it reports failure by returning silent with
|
|
77
|
+
# `.error` set. Recording that as silence would make a broken model call
|
|
78
|
+
# look like a boring session, and the failure-rate number would be a lie.
|
|
79
|
+
if verdict.error:
|
|
80
|
+
log(f"{session_id} triage error: {verdict.error}")
|
|
81
|
+
store.record_session(
|
|
82
|
+
session_id=session.session_id,
|
|
83
|
+
transcript_path=str(transcript_path),
|
|
84
|
+
cwd=session.cwd,
|
|
85
|
+
git_branch=session.git_branch,
|
|
86
|
+
verdict="error",
|
|
87
|
+
cost_usd=verdict.cost_usd,
|
|
88
|
+
duration_ms=verdict.duration_ms,
|
|
89
|
+
)
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
if not verdict.kept:
|
|
93
|
+
store.record_session(
|
|
94
|
+
session_id=session.session_id,
|
|
95
|
+
transcript_path=str(transcript_path),
|
|
96
|
+
cwd=session.cwd,
|
|
97
|
+
git_branch=session.git_branch,
|
|
98
|
+
verdict="silent",
|
|
99
|
+
cost_usd=verdict.cost_usd,
|
|
100
|
+
duration_ms=verdict.duration_ms,
|
|
101
|
+
)
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
# The verdict carries the selected moment's fields but not the Moment
|
|
105
|
+
# itself, and stage 2 needs the object. `select` is pure, so re-running it
|
|
106
|
+
# over the same moments returns the same one triage chose.
|
|
107
|
+
moment = select(verdict.moments)
|
|
108
|
+
if moment is None:
|
|
109
|
+
raise RuntimeError("verdict is 'ask' but no moment survives selection")
|
|
110
|
+
|
|
111
|
+
dialogue = extract_dialogue(Path(transcript_path))
|
|
112
|
+
the_seed = seed(dialogue, moment)
|
|
113
|
+
the_probe = probe(the_seed, dialogue)
|
|
114
|
+
|
|
115
|
+
store.record_session(
|
|
116
|
+
session_id=session.session_id,
|
|
117
|
+
transcript_path=str(transcript_path),
|
|
118
|
+
cwd=session.cwd,
|
|
119
|
+
git_branch=session.git_branch,
|
|
120
|
+
verdict="ask",
|
|
121
|
+
signal=verdict.signal,
|
|
122
|
+
topic=verdict.topic,
|
|
123
|
+
cost_usd=verdict.cost_usd,
|
|
124
|
+
duration_ms=verdict.duration_ms,
|
|
125
|
+
)
|
|
126
|
+
seed_id = store.add_seed(the_seed)
|
|
127
|
+
store.add_probe(seed_id, the_probe)
|
|
128
|
+
|
|
129
|
+
except Exception:
|
|
130
|
+
log(f"{session_id} capture failed:\n{traceback.format_exc()}")
|
|
131
|
+
try:
|
|
132
|
+
store.record_session(
|
|
133
|
+
session_id=session_id,
|
|
134
|
+
transcript_path=str(transcript_path),
|
|
135
|
+
cwd=None,
|
|
136
|
+
git_branch=None,
|
|
137
|
+
verdict="error",
|
|
138
|
+
)
|
|
139
|
+
except Exception:
|
|
140
|
+
log(f"{session_id} could not even record the error:\n{traceback.format_exc()}")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def main(argv: list[str] | None = None) -> int:
|
|
144
|
+
"""The detached worker: `python -m grask.capture <transcript_path>`."""
|
|
145
|
+
args = sys.argv[1:] if argv is None else argv
|
|
146
|
+
if not args:
|
|
147
|
+
log("worker started with no transcript path")
|
|
148
|
+
return 0
|
|
149
|
+
try:
|
|
150
|
+
with Store() as store:
|
|
151
|
+
capture_session(Path(args[0]), store)
|
|
152
|
+
except Exception:
|
|
153
|
+
log(f"worker failed before capture:\n{traceback.format_exc()}")
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
if __name__ == "__main__":
|
|
158
|
+
raise SystemExit(main())
|
grask/capture_run.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Run the capture pipeline over transcripts the hook never saw.
|
|
2
|
+
|
|
3
|
+
The SessionEnd hook only ever captures sessions that end from now on, and the
|
|
4
|
+
sessions that end from now on are overwhelmingly grask's own. Waiting for the
|
|
5
|
+
queue to fill measures grask on grask. This walks the corpus already on disk
|
|
6
|
+
instead, skips grask's own project by default, and pays for a bounded number of
|
|
7
|
+
real sessions from other work.
|
|
8
|
+
|
|
9
|
+
Costs money, so it does not spend by default: without `--go` it prints the plan
|
|
10
|
+
and an estimate and stops.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
uv run python -m grask.capture_run [--limit N] [--go]
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import sys
|
|
20
|
+
from collections.abc import Callable, Sequence
|
|
21
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from grask.capture import capture_session
|
|
26
|
+
from grask.storage import Store
|
|
27
|
+
from grask.transcript import extract, find_transcripts
|
|
28
|
+
|
|
29
|
+
DEFAULT_WORKERS = 6
|
|
30
|
+
DEFAULT_LIMIT = 40
|
|
31
|
+
|
|
32
|
+
# Any transcript whose project directory contains one of these is not corpus.
|
|
33
|
+
# "grask" keeps the tool from being evaluated on its own construction; the
|
|
34
|
+
# scratchpad dirs are agent side-sessions with no developer in them at all.
|
|
35
|
+
DEFAULT_EXCLUDE = ("grask", "scratchpad")
|
|
36
|
+
|
|
37
|
+
# Measured across this runner's own batches, which is the only population that
|
|
38
|
+
# predicts this runner: 60 sessions selected, 25 kept, $16.36 spent.
|
|
39
|
+
#
|
|
40
|
+
# Deliberately one all-in number per session rather than a triage/seed/probe
|
|
41
|
+
# decomposition. The decomposition looks more principled and fits worse: the two
|
|
42
|
+
# observed batches ($1.62/10 and $14.74/50) cannot be reconciled by any single
|
|
43
|
+
# pair of per-stage costs, because session length varies more than stage price
|
|
44
|
+
# does. A model that cannot fit the two points it was built from should not be
|
|
45
|
+
# dressed up as three constants.
|
|
46
|
+
#
|
|
47
|
+
# The first version of this was fitted to n=14 from a different population (the
|
|
48
|
+
# hook's grask-only sessions) and under-quoted a 50-session run by 64%. Erring
|
|
49
|
+
# low is the harmful direction — it under-quotes spend the developer then
|
|
50
|
+
# authorises — so treat the band, not the point, as the estimate.
|
|
51
|
+
COST_PER_SESSION = 0.27
|
|
52
|
+
COST_BAND = 0.4 # observed batch error against the point estimate, both ways
|
|
53
|
+
KEPT_RATE = 0.42 # 25 of 60; drives the probe-count estimate, not the cost one
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Plan:
|
|
58
|
+
"""What a run would touch, and what it declined to."""
|
|
59
|
+
|
|
60
|
+
selected: list[Path] = field(default_factory=list)
|
|
61
|
+
excluded: int = 0
|
|
62
|
+
already_captured: int = 0
|
|
63
|
+
no_human_turns: int = 0
|
|
64
|
+
over_limit: int = 0
|
|
65
|
+
|
|
66
|
+
def estimate_usd(self) -> float:
|
|
67
|
+
return len(self.selected) * COST_PER_SESSION
|
|
68
|
+
|
|
69
|
+
def estimate_range_usd(self) -> tuple[float, float]:
|
|
70
|
+
"""The number to quote. A point estimate here has already misled once."""
|
|
71
|
+
point = self.estimate_usd()
|
|
72
|
+
return point * (1 - COST_BAND), point * (1 + COST_BAND)
|
|
73
|
+
|
|
74
|
+
def estimate_probes(self) -> int:
|
|
75
|
+
return round(len(self.selected) * KEPT_RATE)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def plan(
|
|
79
|
+
paths: Sequence[Path],
|
|
80
|
+
*,
|
|
81
|
+
exclude: Sequence[str],
|
|
82
|
+
is_captured: Callable[[str], bool],
|
|
83
|
+
has_human_turns: Callable[[Path], bool],
|
|
84
|
+
limit: int | None,
|
|
85
|
+
) -> Plan:
|
|
86
|
+
"""Decide which transcripts to spend on. Pure — every side effect is injected.
|
|
87
|
+
|
|
88
|
+
The three filters are ordered by what they cost to evaluate: the project name
|
|
89
|
+
is free, the captured check is one indexed read, and the human-turn check
|
|
90
|
+
parses the whole file. The limit applies last, so `--limit 40` means forty
|
|
91
|
+
sessions that will actually reach triage rather than forty rows of scratchpad.
|
|
92
|
+
"""
|
|
93
|
+
result = Plan()
|
|
94
|
+
for path in paths:
|
|
95
|
+
project = path.parent.name
|
|
96
|
+
if any(marker in project for marker in exclude):
|
|
97
|
+
result.excluded += 1
|
|
98
|
+
continue
|
|
99
|
+
if is_captured(path.stem):
|
|
100
|
+
result.already_captured += 1
|
|
101
|
+
continue
|
|
102
|
+
if not has_human_turns(path):
|
|
103
|
+
result.no_human_turns += 1
|
|
104
|
+
continue
|
|
105
|
+
if limit is not None and len(result.selected) >= limit:
|
|
106
|
+
result.over_limit += 1
|
|
107
|
+
continue
|
|
108
|
+
result.selected.append(path)
|
|
109
|
+
return result
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def has_human_turns(path: Path) -> bool:
|
|
113
|
+
"""Stage 0's filter, run early so empty sessions never eat the limit."""
|
|
114
|
+
try:
|
|
115
|
+
return bool(extract(path).turns)
|
|
116
|
+
except OSError:
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def capture_one(path: Path) -> None:
|
|
121
|
+
"""One session, one connection.
|
|
122
|
+
|
|
123
|
+
sqlite3 connections are not shareable across threads, so each task opens its
|
|
124
|
+
own. Writes are brief next to the model calls they follow, and the driver's
|
|
125
|
+
default busy timeout absorbs the overlap.
|
|
126
|
+
"""
|
|
127
|
+
with Store() as store:
|
|
128
|
+
capture_session(path, store)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def outcomes(store: Store, session_ids: Sequence[str]) -> str:
|
|
132
|
+
"""Read back what the run actually produced.
|
|
133
|
+
|
|
134
|
+
`capture_session` reports nothing — it is built to run detached, where the
|
|
135
|
+
only honest channel is a row. So the report is a query, not a return value.
|
|
136
|
+
"""
|
|
137
|
+
if not session_ids:
|
|
138
|
+
return "Nothing ran."
|
|
139
|
+
marks = ",".join("?" * len(session_ids))
|
|
140
|
+
verdicts = store.conn.execute(
|
|
141
|
+
f"SELECT verdict, COUNT(*) n, SUM(COALESCE(cost_usd, 0)) c "
|
|
142
|
+
f"FROM sessions WHERE session_id IN ({marks}) GROUP BY verdict",
|
|
143
|
+
tuple(session_ids),
|
|
144
|
+
).fetchall()
|
|
145
|
+
probes = store.conn.execute(
|
|
146
|
+
f"SELECT COUNT(*) n, SUM(COALESCE(p.cost_usd, 0) + COALESCE(s.cost_usd, 0)) c "
|
|
147
|
+
f"FROM probes p JOIN seeds s ON s.id = p.seed_id "
|
|
148
|
+
f"WHERE s.session_id IN ({marks})",
|
|
149
|
+
tuple(session_ids),
|
|
150
|
+
).fetchone()
|
|
151
|
+
|
|
152
|
+
spent = sum(row["c"] or 0.0 for row in verdicts) + (probes["c"] or 0.0)
|
|
153
|
+
lines = [
|
|
154
|
+
"=" * 72,
|
|
155
|
+
f"CAPTURED {len(session_ids)} sessions",
|
|
156
|
+
"=" * 72,
|
|
157
|
+
]
|
|
158
|
+
for row in sorted(verdicts, key=lambda r: -r["n"]):
|
|
159
|
+
lines.append(f" {row['verdict']:<8} {row['n']:>4d} ${row['c'] or 0.0:.2f}")
|
|
160
|
+
lines += [
|
|
161
|
+
"",
|
|
162
|
+
f" probes minted {probes['n']:>4d}",
|
|
163
|
+
f" spent ${spent:.2f}",
|
|
164
|
+
]
|
|
165
|
+
if not probes["n"]:
|
|
166
|
+
lines.append("")
|
|
167
|
+
lines.append(" No probes. Either triage kept nothing or every kept session errored —")
|
|
168
|
+
lines.append(" check grask.log before spending on a second batch.")
|
|
169
|
+
return "\n".join(lines)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def describe(result: Plan, *, limit: int | None) -> str:
|
|
173
|
+
low, high = result.estimate_range_usd()
|
|
174
|
+
lines = [
|
|
175
|
+
"=" * 72,
|
|
176
|
+
f"BACKFILL PLAN: {len(result.selected)} sessions to capture",
|
|
177
|
+
"=" * 72,
|
|
178
|
+
f" skipped, excluded project {result.excluded:>5d}",
|
|
179
|
+
f" skipped, already captured {result.already_captured:>5d}",
|
|
180
|
+
f" skipped, no human turns {result.no_human_turns:>5d}",
|
|
181
|
+
f" skipped, over the limit {result.over_limit:>5d}" if limit is not None else "",
|
|
182
|
+
"",
|
|
183
|
+
f" estimated cost ${low:.2f} - ${high:.2f}"
|
|
184
|
+
f" (~${COST_PER_SESSION:.2f}/session, +/-{COST_BAND:.0%})",
|
|
185
|
+
f" expected probes {result.estimate_probes():>5d} (at {KEPT_RATE:.0%} kept)",
|
|
186
|
+
"",
|
|
187
|
+
"-" * 72,
|
|
188
|
+
]
|
|
189
|
+
for path in result.selected:
|
|
190
|
+
lines.append(f" {path.stem[:8]} {path.parent.name}")
|
|
191
|
+
return "\n".join(line for line in lines if line != "")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def main(argv: list[str] | None = None) -> int:
|
|
195
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
196
|
+
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT)
|
|
197
|
+
parser.add_argument("--workers", type=int, default=DEFAULT_WORKERS)
|
|
198
|
+
parser.add_argument("--root", type=Path, default=None, help="transcript root")
|
|
199
|
+
parser.add_argument(
|
|
200
|
+
"--exclude",
|
|
201
|
+
action="append",
|
|
202
|
+
default=None,
|
|
203
|
+
help=f"project-name substring to skip; repeatable (default: {DEFAULT_EXCLUDE})",
|
|
204
|
+
)
|
|
205
|
+
parser.add_argument("--go", action="store_true", help="actually spend; otherwise dry run")
|
|
206
|
+
args = parser.parse_args(argv)
|
|
207
|
+
|
|
208
|
+
exclude = tuple(args.exclude) if args.exclude else DEFAULT_EXCLUDE
|
|
209
|
+
with Store() as store:
|
|
210
|
+
result = plan(
|
|
211
|
+
find_transcripts(args.root),
|
|
212
|
+
exclude=exclude,
|
|
213
|
+
is_captured=store.has_session,
|
|
214
|
+
has_human_turns=has_human_turns,
|
|
215
|
+
limit=args.limit,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
print(describe(result, limit=args.limit))
|
|
219
|
+
if not result.selected:
|
|
220
|
+
return 0
|
|
221
|
+
if not args.go:
|
|
222
|
+
print("\nDry run. Re-run with --go to spend.")
|
|
223
|
+
return 0
|
|
224
|
+
|
|
225
|
+
print(f"\ncapturing on {args.workers} workers...", file=sys.stderr)
|
|
226
|
+
with ThreadPoolExecutor(max_workers=args.workers) as pool:
|
|
227
|
+
list(pool.map(capture_one, result.selected))
|
|
228
|
+
|
|
229
|
+
with Store() as store:
|
|
230
|
+
print()
|
|
231
|
+
print(outcomes(store, [p.stem for p in result.selected]))
|
|
232
|
+
return 0
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
if __name__ == "__main__":
|
|
236
|
+
raise SystemExit(main())
|