pushback 0.1.2__tar.gz → 0.1.3__tar.gz
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.
- {pushback-0.1.2/src/pushback.egg-info → pushback-0.1.3}/PKG-INFO +19 -7
- {pushback-0.1.2 → pushback-0.1.3}/README.md +18 -6
- {pushback-0.1.2 → pushback-0.1.3}/pyproject.toml +1 -1
- pushback-0.1.3/src/pushback/__init__.py +1 -0
- {pushback-0.1.2 → pushback-0.1.3}/src/pushback/cli.py +24 -9
- {pushback-0.1.2 → pushback-0.1.3}/src/pushback/extract.py +1 -0
- {pushback-0.1.2 → pushback-0.1.3}/src/pushback/rules.py +215 -154
- {pushback-0.1.2 → pushback-0.1.3/src/pushback.egg-info}/PKG-INFO +19 -7
- {pushback-0.1.2 → pushback-0.1.3}/tests/test_pushback.py +143 -70
- pushback-0.1.2/src/pushback/__init__.py +0 -1
- {pushback-0.1.2 → pushback-0.1.3}/LICENSE +0 -0
- {pushback-0.1.2 → pushback-0.1.3}/setup.cfg +0 -0
- {pushback-0.1.2 → pushback-0.1.3}/src/pushback/audit.py +0 -0
- {pushback-0.1.2 → pushback-0.1.3}/src/pushback/export.py +0 -0
- {pushback-0.1.2 → pushback-0.1.3}/src/pushback/label.py +0 -0
- {pushback-0.1.2 → pushback-0.1.3}/src/pushback/prompt.py +0 -0
- {pushback-0.1.2 → pushback-0.1.3}/src/pushback/report.py +0 -0
- {pushback-0.1.2 → pushback-0.1.3}/src/pushback/stats.py +0 -0
- {pushback-0.1.2 → pushback-0.1.3}/src/pushback.egg-info/SOURCES.txt +0 -0
- {pushback-0.1.2 → pushback-0.1.3}/src/pushback.egg-info/dependency_links.txt +0 -0
- {pushback-0.1.2 → pushback-0.1.3}/src/pushback.egg-info/entry_points.txt +0 -0
- {pushback-0.1.2 → pushback-0.1.3}/src/pushback.egg-info/requires.txt +0 -0
- {pushback-0.1.2 → pushback-0.1.3}/src/pushback.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pushback
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: How often do you correct your coding agent, and at what kind of work? Measured from your own Claude Code transcripts.
|
|
5
5
|
Author: Intikhab Azam
|
|
6
6
|
License: MIT
|
|
@@ -54,7 +54,7 @@ shows what you use your AI coding agent for, how often you correct it on each
|
|
|
54
54
|
kind of work (code, writing, media, research, ops, meta), and how often a
|
|
55
55
|
correction needs correcting again. It drafts
|
|
56
56
|
**CLAUDE.md rules** from the corrections you keep repeating, shows which of
|
|
57
|
-
your existing rules
|
|
57
|
+
your existing rules those corrections run into, and exports your corrections as
|
|
58
58
|
**preference pairs** (prompt / chosen / rejected). A hand-audit step tells you
|
|
59
59
|
how far to trust every number.
|
|
60
60
|
|
|
@@ -67,7 +67,7 @@ pip install pushback
|
|
|
67
67
|
The author's own 1,633 messages to Claude Code:
|
|
68
68
|
|
|
69
69
|
<p align="center">
|
|
70
|
-
<img src="assets/rates.svg" width="100%" alt="Correction rate by task: media 43.0%, writing 24.7%, research 6.9%, code 5.9%, meta 4.7%, ops 4.5%. One in three corrections needed a second correction.
|
|
70
|
+
<img src="assets/rates.svg" width="100%" alt="Correction rate by task: media 43.0%, writing 24.7%, research 6.9%, code 5.9%, meta 4.7%, ops 4.5%. One in three corrections needed a second correction. One existing rule kept getting broken after it was written.">
|
|
71
71
|
</p>
|
|
72
72
|
|
|
73
73
|
| task | messages | share of use | corrections | rate | 95% CI | corrected again |
|
|
@@ -94,7 +94,7 @@ Most corrected topics (at least 5 messages each):
|
|
|
94
94
|
|
|
95
95
|
- Against 91 hand-checked messages the correction labels had **precision 0.97 and recall 0.80**. Topic labels were spot-checked, not audited.
|
|
96
96
|
- **1 in 3 corrections needed a second one.** 70 of 201 fixes got corrected again (35%), 41% on writing and 44% on images.
|
|
97
|
-
- **
|
|
97
|
+
- **1 rule the author had already written kept getting broken**: "images must not look AI-generated" was corrected 3 more times after it was written. Two other matching rules turned out to be written *because of* the corrections, which is why every rule now shows its evidence dates.
|
|
98
98
|
|
|
99
99
|
> [!NOTE]
|
|
100
100
|
> These rates describe a workflow, not a model. The author's code work runs
|
|
@@ -177,17 +177,29 @@ pushback rules # drafts rules into pushback-dat
|
|
|
177
177
|
pushback rules --existing CLAUDE.md notes/*.md # also check against the rules you already have
|
|
178
178
|
pushback rules --prompt-file # no API key? writes the request to a file instead
|
|
179
179
|
pushback rules --from-response reply.json # ...and reads Claude's answer back
|
|
180
|
+
pushback rules --runs 3 # ask 3 times, keep only the rules that keep coming back
|
|
180
181
|
```
|
|
181
182
|
|
|
182
183
|
The model groups corrections that share a cause and drafts one CLAUDE.md
|
|
183
184
|
instruction per group. Support is counted from the correction ids it cites,
|
|
184
185
|
not from its own numbers, and a rule needs at least 3 real corrections.
|
|
185
186
|
|
|
187
|
+
**Stable rules.** A model won't write the same rules twice. With `--runs 3`
|
|
188
|
+
(or three replies passed to `--from-response`), pushback matches rules across
|
|
189
|
+
runs by the corrections they cite, not by their wording, and keeps only the
|
|
190
|
+
ones that come back in at least 2 of 3 runs. Each rule shows how many runs it
|
|
191
|
+
appeared in, and the unstable ones are listed separately instead of vanishing.
|
|
192
|
+
|
|
193
|
+
On the author's 214 corrections, three independent runs wrote 12, 10 and 7
|
|
194
|
+
rules: 16 distinct rules in total. 5 came back in all three runs, 3 in two,
|
|
195
|
+
and **8 showed up only once**. A single run mixes real rules with one-offs.
|
|
196
|
+
|
|
186
197
|
When you pass your existing rule files, the output splits in two:
|
|
187
198
|
|
|
188
|
-
- **Rules you already have that
|
|
189
|
-
|
|
190
|
-
|
|
199
|
+
- **Rules you already have that these corrections hit.** If the corrections
|
|
200
|
+
came after you wrote the rule, the rule isn't working: it's too vague, or the
|
|
201
|
+
agent doesn't read it at the right moment. pushback can't know when you wrote
|
|
202
|
+
a rule, so check the evidence dates.
|
|
191
203
|
- **New rules to consider.** Recurring corrections with no rule behind them.
|
|
192
204
|
|
|
193
205
|
> [!TIP]
|
|
@@ -27,7 +27,7 @@ shows what you use your AI coding agent for, how often you correct it on each
|
|
|
27
27
|
kind of work (code, writing, media, research, ops, meta), and how often a
|
|
28
28
|
correction needs correcting again. It drafts
|
|
29
29
|
**CLAUDE.md rules** from the corrections you keep repeating, shows which of
|
|
30
|
-
your existing rules
|
|
30
|
+
your existing rules those corrections run into, and exports your corrections as
|
|
31
31
|
**preference pairs** (prompt / chosen / rejected). A hand-audit step tells you
|
|
32
32
|
how far to trust every number.
|
|
33
33
|
|
|
@@ -40,7 +40,7 @@ pip install pushback
|
|
|
40
40
|
The author's own 1,633 messages to Claude Code:
|
|
41
41
|
|
|
42
42
|
<p align="center">
|
|
43
|
-
<img src="assets/rates.svg" width="100%" alt="Correction rate by task: media 43.0%, writing 24.7%, research 6.9%, code 5.9%, meta 4.7%, ops 4.5%. One in three corrections needed a second correction.
|
|
43
|
+
<img src="assets/rates.svg" width="100%" alt="Correction rate by task: media 43.0%, writing 24.7%, research 6.9%, code 5.9%, meta 4.7%, ops 4.5%. One in three corrections needed a second correction. One existing rule kept getting broken after it was written.">
|
|
44
44
|
</p>
|
|
45
45
|
|
|
46
46
|
| task | messages | share of use | corrections | rate | 95% CI | corrected again |
|
|
@@ -67,7 +67,7 @@ Most corrected topics (at least 5 messages each):
|
|
|
67
67
|
|
|
68
68
|
- Against 91 hand-checked messages the correction labels had **precision 0.97 and recall 0.80**. Topic labels were spot-checked, not audited.
|
|
69
69
|
- **1 in 3 corrections needed a second one.** 70 of 201 fixes got corrected again (35%), 41% on writing and 44% on images.
|
|
70
|
-
- **
|
|
70
|
+
- **1 rule the author had already written kept getting broken**: "images must not look AI-generated" was corrected 3 more times after it was written. Two other matching rules turned out to be written *because of* the corrections, which is why every rule now shows its evidence dates.
|
|
71
71
|
|
|
72
72
|
> [!NOTE]
|
|
73
73
|
> These rates describe a workflow, not a model. The author's code work runs
|
|
@@ -150,17 +150,29 @@ pushback rules # drafts rules into pushback-dat
|
|
|
150
150
|
pushback rules --existing CLAUDE.md notes/*.md # also check against the rules you already have
|
|
151
151
|
pushback rules --prompt-file # no API key? writes the request to a file instead
|
|
152
152
|
pushback rules --from-response reply.json # ...and reads Claude's answer back
|
|
153
|
+
pushback rules --runs 3 # ask 3 times, keep only the rules that keep coming back
|
|
153
154
|
```
|
|
154
155
|
|
|
155
156
|
The model groups corrections that share a cause and drafts one CLAUDE.md
|
|
156
157
|
instruction per group. Support is counted from the correction ids it cites,
|
|
157
158
|
not from its own numbers, and a rule needs at least 3 real corrections.
|
|
158
159
|
|
|
160
|
+
**Stable rules.** A model won't write the same rules twice. With `--runs 3`
|
|
161
|
+
(or three replies passed to `--from-response`), pushback matches rules across
|
|
162
|
+
runs by the corrections they cite, not by their wording, and keeps only the
|
|
163
|
+
ones that come back in at least 2 of 3 runs. Each rule shows how many runs it
|
|
164
|
+
appeared in, and the unstable ones are listed separately instead of vanishing.
|
|
165
|
+
|
|
166
|
+
On the author's 214 corrections, three independent runs wrote 12, 10 and 7
|
|
167
|
+
rules: 16 distinct rules in total. 5 came back in all three runs, 3 in two,
|
|
168
|
+
and **8 showed up only once**. A single run mixes real rules with one-offs.
|
|
169
|
+
|
|
159
170
|
When you pass your existing rule files, the output splits in two:
|
|
160
171
|
|
|
161
|
-
- **Rules you already have that
|
|
162
|
-
|
|
163
|
-
|
|
172
|
+
- **Rules you already have that these corrections hit.** If the corrections
|
|
173
|
+
came after you wrote the rule, the rule isn't working: it's too vague, or the
|
|
174
|
+
agent doesn't read it at the right moment. pushback can't know when you wrote
|
|
175
|
+
a rule, so check the evidence dates.
|
|
164
176
|
- **New rules to consider.** Recurring corrections with no rule behind them.
|
|
165
177
|
|
|
166
178
|
> [!TIP]
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pushback"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.3"
|
|
8
8
|
description = "How often do you correct your coding agent, and at what kind of work? Measured from your own Claude Code transcripts."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.3"
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
6
|
import json
|
|
7
|
+
import math
|
|
7
8
|
import os
|
|
8
9
|
import sys
|
|
9
10
|
|
|
@@ -157,9 +158,11 @@ def cmd_rules(a):
|
|
|
157
158
|
if a.from_response:
|
|
158
159
|
with open(p["rules_items"], encoding="utf-8") as fh:
|
|
159
160
|
items = json.load(fh)
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
161
|
+
raws = []
|
|
162
|
+
for path in a.from_response:
|
|
163
|
+
with open(path, encoding="utf-8") as fh:
|
|
164
|
+
text = fh.read()
|
|
165
|
+
raws.append(json.loads(text[text.find("{"): text.rfind("}") + 1])) # tolerate prose or fences
|
|
163
166
|
else:
|
|
164
167
|
if not os.path.exists(p["turns"]):
|
|
165
168
|
sys.exit(f"{p['turns']} not found. Re-run `pushback extract`.")
|
|
@@ -184,20 +187,30 @@ def cmd_rules(a):
|
|
|
184
187
|
print(f"prompt written to {p['rules_prompt']}")
|
|
185
188
|
print("Give it to Claude (for example: ask Claude Code to answer the file), save the JSON reply,")
|
|
186
189
|
print("then run: pushback rules --from-response <reply file>")
|
|
190
|
+
print("For stable rules, answer it 3 times in fresh sessions and pass all three replies.")
|
|
187
191
|
return
|
|
188
192
|
|
|
189
193
|
base = os.environ.get("ANTHROPIC_BASE_URL")
|
|
190
|
-
print(f"Sending them to {base or 'the Anthropic API'} with model {a.model}
|
|
194
|
+
print(f"Sending them to {base or 'the Anthropic API'} with model {a.model}"
|
|
195
|
+
+ (f", {a.runs} times" if a.runs > 1 else "") + ".")
|
|
191
196
|
if not a.yes and input("Continue? [y/N] ").strip().lower() != "y":
|
|
192
197
|
sys.exit("aborted")
|
|
193
198
|
import anthropic
|
|
194
|
-
|
|
199
|
+
client = anthropic.Anthropic()
|
|
200
|
+
raws = [rules_mod.ask(client, request) for _ in range(a.runs)]
|
|
195
201
|
|
|
196
|
-
|
|
202
|
+
runs = [rules_mod.parse(raw, items, a.min_support) for raw in raws]
|
|
203
|
+
if len(runs) == 1:
|
|
204
|
+
found, unstable = runs[0], []
|
|
205
|
+
else:
|
|
206
|
+
min_runs = a.min_runs or max(1, math.ceil(2 * len(runs) / 3))
|
|
207
|
+
found, unstable = rules_mod.stabilize(runs, min_runs)
|
|
197
208
|
with open(p["rules"], "w", encoding="utf-8") as fh:
|
|
198
|
-
fh.write(rules_mod.render(found, len(items)))
|
|
209
|
+
fh.write(rules_mod.render(found, len(items), unstable, {x['id']: x.get('date', '') for x in items}))
|
|
199
210
|
broken = sum(1 for r in found if r["covered_by"])
|
|
200
|
-
print(f"{len(found)} rules ({broken} you already have
|
|
211
|
+
print(f"{len(found)} rules ({broken} match rules you already have) -> {p['rules']}")
|
|
212
|
+
if len(runs) > 1:
|
|
213
|
+
print(f" kept rules that came back in at least {min_runs} of {len(runs)} runs; {len(unstable)} dropped as unstable")
|
|
201
214
|
|
|
202
215
|
|
|
203
216
|
def main(argv=None):
|
|
@@ -245,7 +258,9 @@ def main(argv=None):
|
|
|
245
258
|
ru.add_argument("--model", default=label_mod.DEFAULT_MODEL)
|
|
246
259
|
ru.add_argument("--effort", default="high", choices=["low", "medium", "high"])
|
|
247
260
|
ru.add_argument("--prompt-file", action="store_true", help="write the request to a file instead of calling the API")
|
|
248
|
-
ru.add_argument("--from-response", help="read the model's JSON reply
|
|
261
|
+
ru.add_argument("--from-response", nargs="+", help="read the model's JSON reply (or several, for stability)")
|
|
262
|
+
ru.add_argument("--runs", type=int, default=1, help="ask N times and keep only rules that keep coming back")
|
|
263
|
+
ru.add_argument("--min-runs", type=int, help="runs a rule must appear in (default: two thirds of --runs)")
|
|
249
264
|
ru.add_argument("-y", "--yes", action="store_true")
|
|
250
265
|
ru.set_defaults(func=cmd_rules)
|
|
251
266
|
|
|
@@ -139,6 +139,7 @@ def _extract_file(path, session, out, max_user_chars, max_context_chars):
|
|
|
139
139
|
"user": text[:max_user_chars].replace("\n", " "),
|
|
140
140
|
})
|
|
141
141
|
out.turns[f"{session}:{n}"] = {
|
|
142
|
+
"date": str(entry.get("timestamp", ""))[:10], # YYYY-MM-DD, for rule evidence dates
|
|
142
143
|
"prompt": text,
|
|
143
144
|
"prev_turn": "\n\n".join(turn_text),
|
|
144
145
|
"prev_turn_tools": turn_tools,
|
|
@@ -1,154 +1,215 @@
|
|
|
1
|
-
"""Turn recurring corrections into rules you can paste into CLAUDE.md.
|
|
2
|
-
|
|
3
|
-
The model groups corrections that share a cause and drafts one instruction per
|
|
4
|
-
group. Two checks keep that honest:
|
|
5
|
-
|
|
6
|
-
- Support is counted here from the correction ids the model cites, never taken
|
|
7
|
-
from the model's own numbers, and a rule needs `min_support` real corrections.
|
|
8
|
-
- Given your existing rule files, the model marks rules you already have. Those
|
|
9
|
-
are the interesting ones: a rule that exists and still gets broken isn't working.
|
|
10
|
-
"""
|
|
11
|
-
|
|
12
|
-
from __future__ import annotations
|
|
13
|
-
|
|
14
|
-
import json
|
|
15
|
-
|
|
16
|
-
from .prompt import TASKS
|
|
17
|
-
|
|
18
|
-
AGENT_CHARS = 500
|
|
19
|
-
CORRECTION_CHARS = 400
|
|
20
|
-
|
|
21
|
-
SYSTEM = """You are given corrections a user made to their AI agent. Each item has an id, the task type, the end of the agent's turn that was corrected, and the user's correction.
|
|
22
|
-
|
|
23
|
-
Find recurring patterns: the same kind of mistake corrected at least 3 times. For each pattern, write one rule the agent can follow to avoid it.
|
|
24
|
-
|
|
25
|
-
Rules:
|
|
26
|
-
- Write each rule as an instruction for a CLAUDE.md file, in plain words. Make it specific and checkable. "Be careful" or "write better" is not a rule.
|
|
27
|
-
- Generalise. Never include client names, company names, people's names, credentials or quoted message text in a rule.
|
|
28
|
-
- "why" is one sentence on what kept going wrong.
|
|
29
|
-
- "ids" lists every correction that supports the pattern. Only cite ids that really show the same mistake.
|
|
30
|
-
- If existing rules are provided and one of them already asks for this, put a short quote of it (15 words max) in "covered_by". Otherwise leave "covered_by" as an empty string.
|
|
31
|
-
- Prefer fewer, sharper rules. Skip one-off corrections."""
|
|
32
|
-
|
|
33
|
-
SCHEMA = {
|
|
34
|
-
"type": "object",
|
|
35
|
-
"properties": {
|
|
36
|
-
"rules": {
|
|
37
|
-
"type": "array",
|
|
38
|
-
"items": {
|
|
39
|
-
"type": "object",
|
|
40
|
-
"properties": {
|
|
41
|
-
"rule": {"type": "string"},
|
|
42
|
-
"why": {"type": "string"},
|
|
43
|
-
"task": {"type": "string", "enum": TASKS},
|
|
44
|
-
"ids": {"type": "array", "items": {"type": "integer"}},
|
|
45
|
-
"covered_by": {"type": "string"},
|
|
46
|
-
},
|
|
47
|
-
"required": ["rule", "why", "task", "ids", "covered_by"],
|
|
48
|
-
"additionalProperties": False,
|
|
49
|
-
},
|
|
50
|
-
}
|
|
51
|
-
},
|
|
52
|
-
"required": ["rules"],
|
|
53
|
-
"additionalProperties": False,
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def collect(labels: dict[str, dict], turns: dict[str, dict], tasks=None, ctypes=None, limit=400) -> list[dict]:
|
|
58
|
-
"""Every labelled correction with the context the model needs. Newest last, capped at `limit`."""
|
|
59
|
-
items = []
|
|
60
|
-
for mid, lab in labels.items():
|
|
61
|
-
if not lab["correction"] or (tasks and lab["task"] not in tasks) or (ctypes and lab["ctype"] not in ctypes):
|
|
62
|
-
continue
|
|
63
|
-
turn = turns.get(mid, {})
|
|
64
|
-
items.append({
|
|
65
|
-
"id": mid,
|
|
66
|
-
"task": lab["task"],
|
|
67
|
-
"agent": turn.get("prev_turn", "")[-AGENT_CHARS:],
|
|
68
|
-
"correction": turn.get("prompt", "")[:CORRECTION_CHARS],
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
"
|
|
83
|
-
"
|
|
84
|
-
"
|
|
85
|
-
"
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
"
|
|
104
|
-
"
|
|
105
|
-
"
|
|
106
|
-
"
|
|
107
|
-
"
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
1
|
+
"""Turn recurring corrections into rules you can paste into CLAUDE.md.
|
|
2
|
+
|
|
3
|
+
The model groups corrections that share a cause and drafts one instruction per
|
|
4
|
+
group. Two checks keep that honest:
|
|
5
|
+
|
|
6
|
+
- Support is counted here from the correction ids the model cites, never taken
|
|
7
|
+
from the model's own numbers, and a rule needs `min_support` real corrections.
|
|
8
|
+
- Given your existing rule files, the model marks rules you already have. Those
|
|
9
|
+
are the interesting ones: a rule that exists and still gets broken isn't working.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
|
|
16
|
+
from .prompt import TASKS
|
|
17
|
+
|
|
18
|
+
AGENT_CHARS = 500
|
|
19
|
+
CORRECTION_CHARS = 400
|
|
20
|
+
|
|
21
|
+
SYSTEM = """You are given corrections a user made to their AI agent. Each item has an id, the task type, the end of the agent's turn that was corrected, and the user's correction.
|
|
22
|
+
|
|
23
|
+
Find recurring patterns: the same kind of mistake corrected at least 3 times. For each pattern, write one rule the agent can follow to avoid it.
|
|
24
|
+
|
|
25
|
+
Rules:
|
|
26
|
+
- Write each rule as an instruction for a CLAUDE.md file, in plain words. Make it specific and checkable. "Be careful" or "write better" is not a rule.
|
|
27
|
+
- Generalise. Never include client names, company names, people's names, credentials or quoted message text in a rule.
|
|
28
|
+
- "why" is one sentence on what kept going wrong.
|
|
29
|
+
- "ids" lists every correction that supports the pattern. Only cite ids that really show the same mistake.
|
|
30
|
+
- If existing rules are provided and one of them already asks for this, put a short quote of it (15 words max) in "covered_by". Otherwise leave "covered_by" as an empty string.
|
|
31
|
+
- Prefer fewer, sharper rules. Skip one-off corrections."""
|
|
32
|
+
|
|
33
|
+
SCHEMA = {
|
|
34
|
+
"type": "object",
|
|
35
|
+
"properties": {
|
|
36
|
+
"rules": {
|
|
37
|
+
"type": "array",
|
|
38
|
+
"items": {
|
|
39
|
+
"type": "object",
|
|
40
|
+
"properties": {
|
|
41
|
+
"rule": {"type": "string"},
|
|
42
|
+
"why": {"type": "string"},
|
|
43
|
+
"task": {"type": "string", "enum": TASKS},
|
|
44
|
+
"ids": {"type": "array", "items": {"type": "integer"}},
|
|
45
|
+
"covered_by": {"type": "string"},
|
|
46
|
+
},
|
|
47
|
+
"required": ["rule", "why", "task", "ids", "covered_by"],
|
|
48
|
+
"additionalProperties": False,
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"required": ["rules"],
|
|
53
|
+
"additionalProperties": False,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def collect(labels: dict[str, dict], turns: dict[str, dict], tasks=None, ctypes=None, limit=400) -> list[dict]:
|
|
58
|
+
"""Every labelled correction with the context the model needs. Newest last, capped at `limit`."""
|
|
59
|
+
items = []
|
|
60
|
+
for mid, lab in labels.items():
|
|
61
|
+
if not lab["correction"] or (tasks and lab["task"] not in tasks) or (ctypes and lab["ctype"] not in ctypes):
|
|
62
|
+
continue
|
|
63
|
+
turn = turns.get(mid, {})
|
|
64
|
+
items.append({
|
|
65
|
+
"id": mid,
|
|
66
|
+
"task": lab["task"],
|
|
67
|
+
"agent": turn.get("prev_turn", "")[-AGENT_CHARS:],
|
|
68
|
+
"correction": turn.get("prompt", "")[:CORRECTION_CHARS],
|
|
69
|
+
"date": turn.get("date", ""),
|
|
70
|
+
})
|
|
71
|
+
items.sort(key=lambda x: (x["id"].rpartition(":")[0], int(x["id"].rpartition(":")[2])))
|
|
72
|
+
return items[-limit:]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def build_request(items: list[dict], existing: str, model: str, effort: str = "high") -> dict:
|
|
76
|
+
payload = [{"n": n, "task": x["task"], "agent": x["agent"], "correction": x["correction"]}
|
|
77
|
+
for n, x in enumerate(items)]
|
|
78
|
+
content = "CORRECTIONS (the \"n\" field is the id to cite):\n" + json.dumps(payload, ensure_ascii=False)
|
|
79
|
+
if existing.strip():
|
|
80
|
+
content = "EXISTING RULES:\n" + existing.strip() + "\n\n" + content
|
|
81
|
+
return {
|
|
82
|
+
"model": model,
|
|
83
|
+
"max_tokens": 16000,
|
|
84
|
+
"system": SYSTEM,
|
|
85
|
+
"messages": [{"role": "user", "content": content}],
|
|
86
|
+
"output_config": {"effort": effort, "format": {"type": "json_schema", "schema": SCHEMA}},
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def parse(raw: dict, items: list[dict], min_support: int = 3) -> list[dict]:
|
|
91
|
+
"""Map cited numbers back to correction ids, recount support, drop weak or malformed rules."""
|
|
92
|
+
rules = []
|
|
93
|
+
for r in raw.get("rules", []):
|
|
94
|
+
if not isinstance(r, dict) or not str(r.get("rule", "")).strip():
|
|
95
|
+
continue
|
|
96
|
+
ids = []
|
|
97
|
+
for i in r.get("ids", []):
|
|
98
|
+
if isinstance(i, int) and 0 <= i < len(items) and items[i]["id"] not in ids:
|
|
99
|
+
ids.append(items[i]["id"])
|
|
100
|
+
if len(ids) < min_support:
|
|
101
|
+
continue
|
|
102
|
+
rules.append({
|
|
103
|
+
"rule": r["rule"].strip(),
|
|
104
|
+
"why": str(r.get("why", "")).strip(),
|
|
105
|
+
"task": r.get("task") if r.get("task") in TASKS else "meta",
|
|
106
|
+
"support": len(ids),
|
|
107
|
+
"ids": ids,
|
|
108
|
+
"covered_by": str(r.get("covered_by", "")).strip(),
|
|
109
|
+
})
|
|
110
|
+
rules.sort(key=lambda r: r["support"], reverse=True)
|
|
111
|
+
return rules
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def ask(client, request: dict) -> dict:
|
|
115
|
+
response = client.messages.create(**request)
|
|
116
|
+
if response.stop_reason == "refusal":
|
|
117
|
+
raise RuntimeError("model declined (stop_reason=refusal)")
|
|
118
|
+
if response.stop_reason == "max_tokens":
|
|
119
|
+
raise RuntimeError("output hit max_tokens; narrow the input with --task or --limit")
|
|
120
|
+
text = next((b.text for b in response.content if b.type == "text"), "")
|
|
121
|
+
return json.loads(text)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def stabilize(runs: list[list[dict]], min_runs: int, min_overlap: float = 0.5) -> tuple[list[dict], list[dict]]:
|
|
125
|
+
"""Group the same rule across several runs, and keep only the rules that keep coming back.
|
|
126
|
+
|
|
127
|
+
Rules are matched by their evidence, not their wording: two rules from different runs are the
|
|
128
|
+
same rule when their cited corrections overlap by at least `min_overlap` (the share of the
|
|
129
|
+
smaller set). A cluster takes at most one rule per run. Its text comes from its best-supported
|
|
130
|
+
member; its evidence is the corrections cited by at least half of its members.
|
|
131
|
+
Returns (stable, unstable), each rule annotated with how many runs it appeared in.
|
|
132
|
+
"""
|
|
133
|
+
clusters: list[dict] = []
|
|
134
|
+
members = sorted(((ri, r) for ri, rs in enumerate(runs) for r in rs), key=lambda x: -x[1]["support"])
|
|
135
|
+
for ri, r in members:
|
|
136
|
+
ids = set(r["ids"])
|
|
137
|
+
best, best_overlap = None, 0.0
|
|
138
|
+
for c in clusters:
|
|
139
|
+
if ri in c["runs"]:
|
|
140
|
+
continue
|
|
141
|
+
overlap = len(ids & c["ids"]) / min(len(ids), len(c["ids"]))
|
|
142
|
+
if overlap >= min_overlap and overlap > best_overlap:
|
|
143
|
+
best, best_overlap = c, overlap
|
|
144
|
+
if best is None:
|
|
145
|
+
clusters.append({"runs": {ri}, "ids": set(ids), "rules": [r]})
|
|
146
|
+
else:
|
|
147
|
+
best["runs"].add(ri)
|
|
148
|
+
best["ids"] |= ids
|
|
149
|
+
best["rules"].append(r)
|
|
150
|
+
|
|
151
|
+
stable, unstable = [], []
|
|
152
|
+
for c in clusters:
|
|
153
|
+
rep = max(c["rules"], key=lambda r: r["support"])
|
|
154
|
+
counts: dict[str, int] = {}
|
|
155
|
+
for r in c["rules"]:
|
|
156
|
+
for i in r["ids"]:
|
|
157
|
+
counts[i] = counts.get(i, 0) + 1
|
|
158
|
+
need = (len(c["rules"]) + 1) // 2
|
|
159
|
+
core = [i for i, k in sorted(counts.items(), key=lambda kv: -kv[1]) if k >= need]
|
|
160
|
+
covered = [r["covered_by"] for r in c["rules"] if r["covered_by"]]
|
|
161
|
+
out = {**rep, "ids": core, "support": len(core), "runs": len(c["runs"]), "n_runs": len(runs),
|
|
162
|
+
"covered_by": covered[0] if len(covered) * 2 >= len(c["rules"]) else ""}
|
|
163
|
+
(stable if out["runs"] >= min_runs else unstable).append(out)
|
|
164
|
+
stable.sort(key=lambda r: (r["runs"], r["support"]), reverse=True)
|
|
165
|
+
unstable.sort(key=lambda r: (r["runs"], r["support"]), reverse=True)
|
|
166
|
+
return stable, unstable
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _short(message_id: str) -> str:
|
|
170
|
+
"""session-uuid:12 -> first 8 chars of the session, which is enough to find it."""
|
|
171
|
+
session, _, n = message_id.rpartition(":")
|
|
172
|
+
return f"{session[:8]}:{n}"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def render(rules: list[dict], n_corrections: int, unstable: list[dict] | None = None,
|
|
176
|
+
dates: dict[str, str] | None = None) -> str:
|
|
177
|
+
broken = [r for r in rules if r["covered_by"]]
|
|
178
|
+
new = [r for r in rules if not r["covered_by"]]
|
|
179
|
+
out = [f"# Rules from {n_corrections} corrections", ""]
|
|
180
|
+
out.append("Each rule is backed by at least the number of corrections shown. Read the evidence ids "
|
|
181
|
+
"in your own data before adopting a rule; the model drafted these, you decide.")
|
|
182
|
+
n_runs = rules[0]["n_runs"] if rules and "n_runs" in rules[0] else (unstable[0]["n_runs"] if unstable else 1)
|
|
183
|
+
if n_runs > 1:
|
|
184
|
+
out.append("")
|
|
185
|
+
out.append(f"Asked {n_runs} times. Only rules that came back in several runs, citing mostly the same "
|
|
186
|
+
"corrections, are kept; the rest are listed at the end.")
|
|
187
|
+
|
|
188
|
+
def block(title, note, group):
|
|
189
|
+
if not group:
|
|
190
|
+
return
|
|
191
|
+
out.extend(["", f"## {title}", "", note, ""])
|
|
192
|
+
for r in group:
|
|
193
|
+
out.append(f"- **{r['rule']}** ")
|
|
194
|
+
runs = f", in {r['runs']}/{r['n_runs']} runs" if r.get("n_runs", 1) > 1 else ""
|
|
195
|
+
out.append(f" {r['why']} ({r['task']}, {r['support']} corrections{runs})")
|
|
196
|
+
if r["covered_by"]:
|
|
197
|
+
out.append(f" You already have: \"{r['covered_by']}\"")
|
|
198
|
+
shown = [_short(i) for i in r["ids"][:8]]
|
|
199
|
+
when = sorted(dates[i] for i in r["ids"] if dates and dates.get(i))
|
|
200
|
+
span = f" ({when[0]} to {when[-1]})" if when else ""
|
|
201
|
+
out.append(f" Evidence{span}: {', '.join(shown)}" + (" …" if len(r["ids"]) > 8 else ""))
|
|
202
|
+
|
|
203
|
+
block("Rules you already have that these corrections hit",
|
|
204
|
+
"If these corrections happened after you wrote the rule, the rule isn't working: make it more specific, "
|
|
205
|
+
"move it somewhere the agent reads at the right moment, or turn it into a check. If you wrote the rule "
|
|
206
|
+
"after them, it may already be doing its job; check the evidence dates.", broken)
|
|
207
|
+
block("New rules to consider", "Recurring corrections with no rule behind them yet.", new)
|
|
208
|
+
if unstable:
|
|
209
|
+
out.extend(["", "## Not stable enough to keep", "",
|
|
210
|
+
"These showed up in too few runs. They may still be real; they're just not reliable yet.", ""])
|
|
211
|
+
for r in unstable:
|
|
212
|
+
out.append(f"- {r['rule']} ({r['runs']}/{r['n_runs']} runs, {r['support']} corrections)")
|
|
213
|
+
if not rules:
|
|
214
|
+
out.extend(["", "No pattern reached the minimum support. Try more data or a lower --min-support."])
|
|
215
|
+
return "\n".join(out) + "\n"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pushback
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: How often do you correct your coding agent, and at what kind of work? Measured from your own Claude Code transcripts.
|
|
5
5
|
Author: Intikhab Azam
|
|
6
6
|
License: MIT
|
|
@@ -54,7 +54,7 @@ shows what you use your AI coding agent for, how often you correct it on each
|
|
|
54
54
|
kind of work (code, writing, media, research, ops, meta), and how often a
|
|
55
55
|
correction needs correcting again. It drafts
|
|
56
56
|
**CLAUDE.md rules** from the corrections you keep repeating, shows which of
|
|
57
|
-
your existing rules
|
|
57
|
+
your existing rules those corrections run into, and exports your corrections as
|
|
58
58
|
**preference pairs** (prompt / chosen / rejected). A hand-audit step tells you
|
|
59
59
|
how far to trust every number.
|
|
60
60
|
|
|
@@ -67,7 +67,7 @@ pip install pushback
|
|
|
67
67
|
The author's own 1,633 messages to Claude Code:
|
|
68
68
|
|
|
69
69
|
<p align="center">
|
|
70
|
-
<img src="assets/rates.svg" width="100%" alt="Correction rate by task: media 43.0%, writing 24.7%, research 6.9%, code 5.9%, meta 4.7%, ops 4.5%. One in three corrections needed a second correction.
|
|
70
|
+
<img src="assets/rates.svg" width="100%" alt="Correction rate by task: media 43.0%, writing 24.7%, research 6.9%, code 5.9%, meta 4.7%, ops 4.5%. One in three corrections needed a second correction. One existing rule kept getting broken after it was written.">
|
|
71
71
|
</p>
|
|
72
72
|
|
|
73
73
|
| task | messages | share of use | corrections | rate | 95% CI | corrected again |
|
|
@@ -94,7 +94,7 @@ Most corrected topics (at least 5 messages each):
|
|
|
94
94
|
|
|
95
95
|
- Against 91 hand-checked messages the correction labels had **precision 0.97 and recall 0.80**. Topic labels were spot-checked, not audited.
|
|
96
96
|
- **1 in 3 corrections needed a second one.** 70 of 201 fixes got corrected again (35%), 41% on writing and 44% on images.
|
|
97
|
-
- **
|
|
97
|
+
- **1 rule the author had already written kept getting broken**: "images must not look AI-generated" was corrected 3 more times after it was written. Two other matching rules turned out to be written *because of* the corrections, which is why every rule now shows its evidence dates.
|
|
98
98
|
|
|
99
99
|
> [!NOTE]
|
|
100
100
|
> These rates describe a workflow, not a model. The author's code work runs
|
|
@@ -177,17 +177,29 @@ pushback rules # drafts rules into pushback-dat
|
|
|
177
177
|
pushback rules --existing CLAUDE.md notes/*.md # also check against the rules you already have
|
|
178
178
|
pushback rules --prompt-file # no API key? writes the request to a file instead
|
|
179
179
|
pushback rules --from-response reply.json # ...and reads Claude's answer back
|
|
180
|
+
pushback rules --runs 3 # ask 3 times, keep only the rules that keep coming back
|
|
180
181
|
```
|
|
181
182
|
|
|
182
183
|
The model groups corrections that share a cause and drafts one CLAUDE.md
|
|
183
184
|
instruction per group. Support is counted from the correction ids it cites,
|
|
184
185
|
not from its own numbers, and a rule needs at least 3 real corrections.
|
|
185
186
|
|
|
187
|
+
**Stable rules.** A model won't write the same rules twice. With `--runs 3`
|
|
188
|
+
(or three replies passed to `--from-response`), pushback matches rules across
|
|
189
|
+
runs by the corrections they cite, not by their wording, and keeps only the
|
|
190
|
+
ones that come back in at least 2 of 3 runs. Each rule shows how many runs it
|
|
191
|
+
appeared in, and the unstable ones are listed separately instead of vanishing.
|
|
192
|
+
|
|
193
|
+
On the author's 214 corrections, three independent runs wrote 12, 10 and 7
|
|
194
|
+
rules: 16 distinct rules in total. 5 came back in all three runs, 3 in two,
|
|
195
|
+
and **8 showed up only once**. A single run mixes real rules with one-offs.
|
|
196
|
+
|
|
186
197
|
When you pass your existing rule files, the output splits in two:
|
|
187
198
|
|
|
188
|
-
- **Rules you already have that
|
|
189
|
-
|
|
190
|
-
|
|
199
|
+
- **Rules you already have that these corrections hit.** If the corrections
|
|
200
|
+
came after you wrote the rule, the rule isn't working: it's too vague, or the
|
|
201
|
+
agent doesn't read it at the right moment. pushback can't know when you wrote
|
|
202
|
+
a rule, so check the evidence dates.
|
|
191
203
|
- **New rules to consider.** Recurring corrections with no rule behind them.
|
|
192
204
|
|
|
193
205
|
> [!TIP]
|
|
@@ -309,7 +309,7 @@ def test_rules_render_separates_broken_rules():
|
|
|
309
309
|
{"rule": "B", "why": "w", "task": "media", "support": 3, "ids": ["s:2"], "covered_by": ""},
|
|
310
310
|
]
|
|
311
311
|
md = rules.render(found, 40)
|
|
312
|
-
assert md.index("
|
|
312
|
+
assert md.index("these corrections hit") < md.index("**A**") < md.index("New rules") < md.index("**B**")
|
|
313
313
|
assert "No pattern" in rules.render([], 3)
|
|
314
314
|
|
|
315
315
|
|
|
@@ -337,7 +337,7 @@ def test_rules_cli_round_trip_without_api(tmp_path, capsys):
|
|
|
337
337
|
cli.main(["--data", str(d), "rules", "--from-response", str(reply)])
|
|
338
338
|
md = (d / "rules.md").read_text(encoding="utf-8")
|
|
339
339
|
assert "Keep posts under 80 words" in md and "s:1, s:3, s:5" in md
|
|
340
|
-
assert "1 rules (1 you already have" in capsys.readouterr().out
|
|
340
|
+
assert "1 rules (1 match rules you already have" in capsys.readouterr().out
|
|
341
341
|
|
|
342
342
|
|
|
343
343
|
def test_rules_render_shortens_session_ids():
|
|
@@ -388,71 +388,144 @@ def test_prompt_topics_are_in_schema_and_system():
|
|
|
388
388
|
from pushback import prompt
|
|
389
389
|
assert "cli-tool" in prompt.SYSTEM and "cli-tool" in prompt.ALL_TOPICS
|
|
390
390
|
assert all("other" in ts for ts in prompt.TOPICS.values())
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
# --- label without an API key -----------------------------------------------
|
|
394
|
-
|
|
395
|
-
def _label_data(tmp_path, n=5):
|
|
396
|
-
d = tmp_path / "data"
|
|
397
|
-
d.mkdir()
|
|
398
|
-
with open(d / "messages.jsonl", "w", encoding="utf-8") as fh:
|
|
399
|
-
for i in range(n):
|
|
400
|
-
fh.write(json.dumps({"id": f"s:{i}", "session": "s", "prev_assistant": f"agent {i}",
|
|
401
|
-
"user": f"user {i}"}) + "\n")
|
|
402
|
-
return d
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
def _answer(ids, correction=False):
|
|
406
|
-
return json.dumps({"labels": [{"id": i, "task": "code", "topic": "cli-tool", "correction": correction,
|
|
407
|
-
"ctype": "code" if correction else "none", "conf": "high"} for i in ids]})
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
def test_label_prompt_file_round_trip(tmp_path, capsys):
|
|
411
|
-
d = _label_data(tmp_path, 5)
|
|
412
|
-
cli.main(["--data", str(d), "label", "--prompt-file", "--batch-size", "2"])
|
|
413
|
-
prompts, answers = d / "label-prompts", d / "label-responses"
|
|
414
|
-
assert sorted(x.name for x in prompts.glob("batch_*.md")) == ["batch_000.md", "batch_001.md", "batch_002.md"]
|
|
415
|
-
instructions = (prompts / "INSTRUCTIONS.md").read_text(encoding="utf-8")
|
|
416
|
-
assert "cli-tool" in instructions and str(answers.resolve()).replace("\\", "/") in instructions
|
|
417
|
-
assert "No API key needed" in capsys.readouterr().out
|
|
418
|
-
assert "s:0" not in (prompts / "batch_000.md").read_text(encoding="utf-8") # the model never sees real ids
|
|
419
|
-
|
|
420
|
-
(answers / "batch_000.json").write_text("Here are the labels:\n```json\n" + _answer([0, 1], True) + "\n```",
|
|
421
|
-
encoding="utf-8")
|
|
422
|
-
(answers / "batch_001.json").write_text("not json at all", encoding="utf-8")
|
|
423
|
-
cli.main(["--data", str(d), "label", "--from-responses"])
|
|
424
|
-
out = capsys.readouterr().out
|
|
425
|
-
assert "1 batches read, 2 new labels" in out and "batch_001" in out and "not answered yet: 1" in out
|
|
426
|
-
labels = label.load_labels(str(d / "labels.jsonl"))
|
|
427
|
-
assert set(labels) == {"s:0", "s:1"} and labels["s:0"]["topic"] == "cli-tool"
|
|
428
|
-
|
|
429
|
-
# the next job only covers what is still unlabelled, and old answers can't leak into it
|
|
430
|
-
cli.main(["--data", str(d), "label", "--prompt-file", "--batch-size", "2"])
|
|
431
|
-
assert sorted(x.name for x in prompts.glob("batch_*.md")) == ["batch_000.md", "batch_001.md"]
|
|
432
|
-
assert not list(answers.glob("batch_*.json"))
|
|
433
|
-
(answers / "batch_000.json").write_text(_answer([0, 1]), encoding="utf-8")
|
|
434
|
-
(answers / "batch_001.json").write_text(_answer([0]), encoding="utf-8")
|
|
435
|
-
cli.main(["--data", str(d), "label", "--from-responses"])
|
|
436
|
-
assert set(label.load_labels(str(d / "labels.jsonl"))) == {f"s:{i}" for i in range(5)}
|
|
437
|
-
assert "all 5 messages already labelled" not in capsys.readouterr().out
|
|
438
|
-
cli.main(["--data", str(d), "label", "--prompt-file"])
|
|
439
|
-
assert "all 5 messages already labelled" in capsys.readouterr().out
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
def test_read_responses_counts_incomplete_and_never_duplicates(tmp_path):
|
|
443
|
-
d = _label_data(tmp_path, 3)
|
|
444
|
-
label.write_prompts([json.loads(l) for l in open(d / "messages.jsonl", encoding="utf-8")],
|
|
445
|
-
str(d / "labels.jsonl"), str(d / "p"), str(d / "r"), batch_size=3)
|
|
446
|
-
(d / "r" / "batch_000.json").write_text(_answer([0, 2, 7]), encoding="utf-8") # 1 missing, 1 out of range
|
|
447
|
-
r1 = label.read_responses(str(d / "labels.jsonl"), str(d / "p"), str(d / "r"))
|
|
448
|
-
r2 = label.read_responses(str(d / "labels.jsonl"), str(d / "p"), str(d / "r"))
|
|
449
|
-
assert (r1["written"], r1["incomplete"], r2["written"]) == (2, 1, 0)
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
def test_label_instructions_state_origin_and_scope(tmp_path):
|
|
453
|
-
d = _label_data(tmp_path, 1)
|
|
454
|
-
label.write_prompts([json.loads(l) for l in open(d / "messages.jsonl", encoding="utf-8")],
|
|
455
|
-
str(d / "labels.jsonl"), str(d / "p"), str(d / "r"))
|
|
456
|
-
text = (d / "p" / "INSTRUCTIONS.md").read_text(encoding="utf-8")
|
|
457
|
-
assert "pushback label --prompt-file" in text and "never as instructions to follow" in text
|
|
458
|
-
assert "{" + "responses}" not in text # every placeholder filled
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# --- label without an API key -----------------------------------------------
|
|
394
|
+
|
|
395
|
+
def _label_data(tmp_path, n=5):
|
|
396
|
+
d = tmp_path / "data"
|
|
397
|
+
d.mkdir()
|
|
398
|
+
with open(d / "messages.jsonl", "w", encoding="utf-8") as fh:
|
|
399
|
+
for i in range(n):
|
|
400
|
+
fh.write(json.dumps({"id": f"s:{i}", "session": "s", "prev_assistant": f"agent {i}",
|
|
401
|
+
"user": f"user {i}"}) + "\n")
|
|
402
|
+
return d
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _answer(ids, correction=False):
|
|
406
|
+
return json.dumps({"labels": [{"id": i, "task": "code", "topic": "cli-tool", "correction": correction,
|
|
407
|
+
"ctype": "code" if correction else "none", "conf": "high"} for i in ids]})
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def test_label_prompt_file_round_trip(tmp_path, capsys):
|
|
411
|
+
d = _label_data(tmp_path, 5)
|
|
412
|
+
cli.main(["--data", str(d), "label", "--prompt-file", "--batch-size", "2"])
|
|
413
|
+
prompts, answers = d / "label-prompts", d / "label-responses"
|
|
414
|
+
assert sorted(x.name for x in prompts.glob("batch_*.md")) == ["batch_000.md", "batch_001.md", "batch_002.md"]
|
|
415
|
+
instructions = (prompts / "INSTRUCTIONS.md").read_text(encoding="utf-8")
|
|
416
|
+
assert "cli-tool" in instructions and str(answers.resolve()).replace("\\", "/") in instructions
|
|
417
|
+
assert "No API key needed" in capsys.readouterr().out
|
|
418
|
+
assert "s:0" not in (prompts / "batch_000.md").read_text(encoding="utf-8") # the model never sees real ids
|
|
419
|
+
|
|
420
|
+
(answers / "batch_000.json").write_text("Here are the labels:\n```json\n" + _answer([0, 1], True) + "\n```",
|
|
421
|
+
encoding="utf-8")
|
|
422
|
+
(answers / "batch_001.json").write_text("not json at all", encoding="utf-8")
|
|
423
|
+
cli.main(["--data", str(d), "label", "--from-responses"])
|
|
424
|
+
out = capsys.readouterr().out
|
|
425
|
+
assert "1 batches read, 2 new labels" in out and "batch_001" in out and "not answered yet: 1" in out
|
|
426
|
+
labels = label.load_labels(str(d / "labels.jsonl"))
|
|
427
|
+
assert set(labels) == {"s:0", "s:1"} and labels["s:0"]["topic"] == "cli-tool"
|
|
428
|
+
|
|
429
|
+
# the next job only covers what is still unlabelled, and old answers can't leak into it
|
|
430
|
+
cli.main(["--data", str(d), "label", "--prompt-file", "--batch-size", "2"])
|
|
431
|
+
assert sorted(x.name for x in prompts.glob("batch_*.md")) == ["batch_000.md", "batch_001.md"]
|
|
432
|
+
assert not list(answers.glob("batch_*.json"))
|
|
433
|
+
(answers / "batch_000.json").write_text(_answer([0, 1]), encoding="utf-8")
|
|
434
|
+
(answers / "batch_001.json").write_text(_answer([0]), encoding="utf-8")
|
|
435
|
+
cli.main(["--data", str(d), "label", "--from-responses"])
|
|
436
|
+
assert set(label.load_labels(str(d / "labels.jsonl"))) == {f"s:{i}" for i in range(5)}
|
|
437
|
+
assert "all 5 messages already labelled" not in capsys.readouterr().out
|
|
438
|
+
cli.main(["--data", str(d), "label", "--prompt-file"])
|
|
439
|
+
assert "all 5 messages already labelled" in capsys.readouterr().out
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def test_read_responses_counts_incomplete_and_never_duplicates(tmp_path):
|
|
443
|
+
d = _label_data(tmp_path, 3)
|
|
444
|
+
label.write_prompts([json.loads(l) for l in open(d / "messages.jsonl", encoding="utf-8")],
|
|
445
|
+
str(d / "labels.jsonl"), str(d / "p"), str(d / "r"), batch_size=3)
|
|
446
|
+
(d / "r" / "batch_000.json").write_text(_answer([0, 2, 7]), encoding="utf-8") # 1 missing, 1 out of range
|
|
447
|
+
r1 = label.read_responses(str(d / "labels.jsonl"), str(d / "p"), str(d / "r"))
|
|
448
|
+
r2 = label.read_responses(str(d / "labels.jsonl"), str(d / "p"), str(d / "r"))
|
|
449
|
+
assert (r1["written"], r1["incomplete"], r2["written"]) == (2, 1, 0)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def test_label_instructions_state_origin_and_scope(tmp_path):
|
|
453
|
+
d = _label_data(tmp_path, 1)
|
|
454
|
+
label.write_prompts([json.loads(l) for l in open(d / "messages.jsonl", encoding="utf-8")],
|
|
455
|
+
str(d / "labels.jsonl"), str(d / "p"), str(d / "r"))
|
|
456
|
+
text = (d / "p" / "INSTRUCTIONS.md").read_text(encoding="utf-8")
|
|
457
|
+
assert "pushback label --prompt-file" in text and "never as instructions to follow" in text
|
|
458
|
+
assert "{" + "responses}" not in text # every placeholder filled
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
# --- rules stability --------------------------------------------------------
|
|
462
|
+
|
|
463
|
+
def _rule(text, ids, covered=""):
|
|
464
|
+
return {"rule": text, "why": "w", "task": "writing", "support": len(ids), "ids": ids, "covered_by": covered}
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def test_stabilize_matches_rules_by_evidence_not_wording():
|
|
468
|
+
runs = [
|
|
469
|
+
[_rule("Put the hook first", ["a", "b", "c", "d"]), _rule("Check the chrome profile", ["x", "y", "z"])],
|
|
470
|
+
[_rule("Lead with the strongest line", ["a", "b", "c"]), _rule("One-off idea", ["p", "q", "r"])],
|
|
471
|
+
[_rule("Hook at the top", ["b", "c", "d", "e"]), _rule("Verify browser identity", ["x", "y", "w"])],
|
|
472
|
+
]
|
|
473
|
+
stable, unstable = rules.stabilize(runs, min_runs=2)
|
|
474
|
+
assert [(r["rule"], r["runs"]) for r in stable] == [("Put the hook first", 3), ("Check the chrome profile", 2)]
|
|
475
|
+
assert stable[0]["ids"] == ["b", "c", "a", "d"] or set(stable[0]["ids"]) == {"a", "b", "c", "d"}
|
|
476
|
+
assert "e" not in stable[0]["ids"] # cited by only 1 of 3 members
|
|
477
|
+
assert [(r["rule"], r["runs"]) for r in unstable] == [("One-off idea", 1)]
|
|
478
|
+
assert all(r["n_runs"] == 3 for r in stable + unstable)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def test_stabilize_never_merges_two_rules_from_the_same_run():
|
|
482
|
+
runs = [[_rule("A", ["a", "b", "c"]), _rule("A again", ["a", "b", "c", "d"])], [_rule("A'", ["a", "b", "c"])]]
|
|
483
|
+
stable, unstable = rules.stabilize(runs, min_runs=2)
|
|
484
|
+
assert len(stable) == 1 and len(unstable) == 1
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def test_stabilize_covered_by_needs_half_the_members():
|
|
488
|
+
runs = [[_rule("R", ["a", "b", "c"], "no tables")], [_rule("R", ["a", "b", "c"])], [_rule("R", ["a", "b", "c"])]]
|
|
489
|
+
assert rules.stabilize(runs, 2)[0][0]["covered_by"] == ""
|
|
490
|
+
runs[1][0]["covered_by"] = "no tables"
|
|
491
|
+
assert rules.stabilize(runs, 2)[0][0]["covered_by"] == "no tables"
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def test_rules_cli_multiple_responses_reports_stability(tmp_path, capsys):
|
|
495
|
+
labels, turns = _rules_fixture()
|
|
496
|
+
d = tmp_path / "data"
|
|
497
|
+
d.mkdir()
|
|
498
|
+
with open(d / "labels.jsonl", "w", encoding="utf-8") as fh:
|
|
499
|
+
for mid, lab in labels.items():
|
|
500
|
+
fh.write(json.dumps({"id": mid, **lab}) + "\n")
|
|
501
|
+
with open(d / "turns.jsonl", "w", encoding="utf-8") as fh:
|
|
502
|
+
for mid, t in turns.items():
|
|
503
|
+
fh.write(json.dumps({"id": mid, **t}) + "\n")
|
|
504
|
+
cli.main(["--data", str(d), "rules", "--prompt-file", "--existing"])
|
|
505
|
+
replies = []
|
|
506
|
+
for k, rs in enumerate([
|
|
507
|
+
[{"rule": "Keep posts short", "ids": [0, 1, 2]}, {"rule": "Fluke", "ids": [2, 3, 4]}],
|
|
508
|
+
[{"rule": "Posts under 80 words", "ids": [0, 1, 2]}],
|
|
509
|
+
[{"rule": "Short posts", "ids": [0, 1, 3]}],
|
|
510
|
+
]):
|
|
511
|
+
f = tmp_path / f"r{k}.json"
|
|
512
|
+
f.write_text(json.dumps({"rules": [{**r, "why": "w", "task": "writing", "covered_by": ""} for r in rs]}),
|
|
513
|
+
encoding="utf-8")
|
|
514
|
+
replies.append(str(f))
|
|
515
|
+
cli.main(["--data", str(d), "rules", "--from-response", *replies])
|
|
516
|
+
out = capsys.readouterr().out
|
|
517
|
+
assert "1 rules" in out and "at least 2 of 3 runs; 1 dropped" in out
|
|
518
|
+
md = (d / "rules.md").read_text(encoding="utf-8")
|
|
519
|
+
assert "in 3/3 runs" in md and "Not stable enough" in md and "Fluke (1/3 runs" in md
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def test_rules_evidence_shows_date_range(tmp_path):
|
|
523
|
+
found = [{"rule": "A", "why": "w", "task": "ops", "support": 3, "ids": ["s:1", "s:2", "s:3"], "covered_by": "x"}]
|
|
524
|
+
md = rules.render(found, 3, dates={"s:1": "2026-09-03", "s:2": "2026-08-27", "s:3": ""})
|
|
525
|
+
assert "Evidence (2026-08-27 to 2026-09-03):" in md
|
|
526
|
+
assert "Evidence:" in rules.render(found, 3) # no dates known, no range
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def test_extract_records_message_dates(tmp_path):
|
|
530
|
+
_write_session(tmp_path, "d", [dict(_user("hello"), timestamp="2026-09-21T10:00:00.000Z")])
|
|
531
|
+
assert extract.extract(str(tmp_path)).turns["d:0"]["date"] == "2026-09-21"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.2"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|