pushback 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.
- pushback/__init__.py +1 -0
- pushback/audit.py +63 -0
- pushback/cli.py +231 -0
- pushback/export.py +133 -0
- pushback/extract.py +147 -0
- pushback/label.py +120 -0
- pushback/prompt.py +71 -0
- pushback/report.py +81 -0
- pushback/rules.py +154 -0
- pushback/stats.py +45 -0
- pushback-0.1.0.dist-info/METADATA +168 -0
- pushback-0.1.0.dist-info/RECORD +16 -0
- pushback-0.1.0.dist-info/WHEEL +5 -0
- pushback-0.1.0.dist-info/entry_points.txt +2 -0
- pushback-0.1.0.dist-info/licenses/LICENSE +21 -0
- pushback-0.1.0.dist-info/top_level.txt +1 -0
pushback/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
pushback/audit.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Hand-check a stratified sample so the report can say how far to trust the labels.
|
|
2
|
+
|
|
3
|
+
Half the sample comes from messages the model called corrections, half from
|
|
4
|
+
the rest. Your answers never leave your machine.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import random
|
|
12
|
+
|
|
13
|
+
PROMPT = "Is this a correction of the agent? [y]es / [n]o / [s]kip / [q]uit: "
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_audit(path: str) -> dict[str, dict]:
|
|
17
|
+
audit: dict[str, dict] = {}
|
|
18
|
+
if os.path.exists(path):
|
|
19
|
+
with open(path, encoding="utf-8") as fh:
|
|
20
|
+
for line in fh:
|
|
21
|
+
if line.strip():
|
|
22
|
+
d = json.loads(line)
|
|
23
|
+
audit[d["id"]] = d
|
|
24
|
+
return audit
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def sample(messages: dict[str, dict], labels: dict[str, dict], done: set[str], n: int, seed: int) -> list[tuple[str, str]]:
|
|
28
|
+
rng = random.Random(seed)
|
|
29
|
+
pos = sorted(i for i, d in labels.items() if d["correction"] and i not in done and i in messages)
|
|
30
|
+
neg = sorted(i for i, d in labels.items() if not d["correction"] and i not in done and i in messages)
|
|
31
|
+
rng.shuffle(pos)
|
|
32
|
+
rng.shuffle(neg)
|
|
33
|
+
half = n // 2
|
|
34
|
+
picked = [(i, "flagged") for i in pos[:half]] + [(i, "unflagged") for i in neg[: n - half]]
|
|
35
|
+
rng.shuffle(picked) # don't let the order hint at the model's answer
|
|
36
|
+
return picked
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run(messages, labels, audit_path, n=40, seed=0, ask=input, show=print) -> int:
|
|
40
|
+
done = load_audit(audit_path)
|
|
41
|
+
todo = sample(messages, labels, set(done), n, seed)
|
|
42
|
+
if not todo:
|
|
43
|
+
show("nothing left to audit")
|
|
44
|
+
return 0
|
|
45
|
+
answered = 0
|
|
46
|
+
with open(audit_path, "a", encoding="utf-8") as out:
|
|
47
|
+
for k, (i, stratum) in enumerate(todo, 1):
|
|
48
|
+
m = messages[i]
|
|
49
|
+
show(f"\n--- {k}/{len(todo)} ---")
|
|
50
|
+
show(f"AGENT: ...{m['prev_assistant'][-300:]}")
|
|
51
|
+
show(f"YOU: {m['user']}")
|
|
52
|
+
while True:
|
|
53
|
+
a = ask(PROMPT).strip().lower()[:1]
|
|
54
|
+
if a in ("y", "n", "s", "q"):
|
|
55
|
+
break
|
|
56
|
+
if a == "q":
|
|
57
|
+
break
|
|
58
|
+
if a == "s":
|
|
59
|
+
continue
|
|
60
|
+
out.write(json.dumps({"id": i, "stratum": stratum, "human": a == "y"}) + "\n")
|
|
61
|
+
out.flush()
|
|
62
|
+
answered += 1
|
|
63
|
+
return answered
|
pushback/cli.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""pushback: how often do you correct your coding agent, and at what kind of work?"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from . import audit as audit_mod
|
|
11
|
+
from . import export as export_mod
|
|
12
|
+
from . import extract as extract_mod
|
|
13
|
+
from . import label as label_mod
|
|
14
|
+
from . import report as report_mod
|
|
15
|
+
from . import rules as rules_mod
|
|
16
|
+
|
|
17
|
+
DATA = "pushback-data"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _paths(data_dir: str) -> dict[str, str]:
|
|
21
|
+
return {k: os.path.join(data_dir, f) for k, f in {
|
|
22
|
+
"messages": "messages.jsonl", "labels": "labels.jsonl",
|
|
23
|
+
"audit": "audit.jsonl", "silent": "silent.json", "report": "report.md",
|
|
24
|
+
"turns": "turns.jsonl", "dpo": "dpo.jsonl",
|
|
25
|
+
"rules": "rules.md", "rules_items": "rules-items.json", "rules_prompt": "rules-prompt.md",
|
|
26
|
+
}.items()}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _load_messages(path: str) -> dict[str, dict]:
|
|
30
|
+
if not os.path.exists(path):
|
|
31
|
+
sys.exit(f"{path} not found. Run `pushback extract` first.")
|
|
32
|
+
with open(path, encoding="utf-8") as fh:
|
|
33
|
+
return {m["id"]: m for m in map(json.loads, fh) if m}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def cmd_extract(a):
|
|
37
|
+
p = _paths(a.data)
|
|
38
|
+
os.makedirs(a.data, exist_ok=True)
|
|
39
|
+
ex = extract_mod.extract(a.root, tuple(a.exclude or ()))
|
|
40
|
+
with open(p["messages"], "w", encoding="utf-8") as fh:
|
|
41
|
+
for m in ex.messages:
|
|
42
|
+
fh.write(json.dumps(m, ensure_ascii=False) + "\n")
|
|
43
|
+
with open(p["silent"], "w", encoding="utf-8") as fh:
|
|
44
|
+
json.dump({"rejections": ex.rejections, "interrupts": ex.interrupts}, fh)
|
|
45
|
+
with open(p["turns"], "w", encoding="utf-8") as fh:
|
|
46
|
+
for mid, t in ex.turns.items():
|
|
47
|
+
fh.write(json.dumps({"id": mid, **t}, ensure_ascii=False) + "\n")
|
|
48
|
+
print(f"{ex.sessions} sessions -> {len(ex.messages)} messages, "
|
|
49
|
+
f"{sum(ex.rejections.values())} rejected tool calls, {sum(ex.interrupts.values())} interrupts")
|
|
50
|
+
print(f"written to {a.data}/ (this folder holds your raw messages; keep it out of git)")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def cmd_label(a):
|
|
54
|
+
p = _paths(a.data)
|
|
55
|
+
messages = list(_load_messages(p["messages"]).values())
|
|
56
|
+
base = os.environ.get("ANTHROPIC_BASE_URL")
|
|
57
|
+
print(f"Sending {len(messages)} messages to {base or 'the Anthropic API'} with model {a.model}.")
|
|
58
|
+
print("Your messages leave this machine for that provider. Check its data policy before using client logs.")
|
|
59
|
+
if not a.yes and input("Continue? [y/N] ").strip().lower() != "y":
|
|
60
|
+
sys.exit("aborted")
|
|
61
|
+
written, failed = label_mod.run(messages, p["labels"], a.model, a.batch_size, a.workers, a.effort)
|
|
62
|
+
print(f"done: {written} new labels, {failed} failed batches" + (" (re-run to retry them)" if failed else ""))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def cmd_audit(a):
|
|
66
|
+
p = _paths(a.data)
|
|
67
|
+
messages = _load_messages(p["messages"])
|
|
68
|
+
labels = label_mod.load_labels(p["labels"])
|
|
69
|
+
if not labels:
|
|
70
|
+
sys.exit("no labels yet. Run `pushback label` first.")
|
|
71
|
+
n = audit_mod.run(messages, labels, p["audit"], a.n, a.seed)
|
|
72
|
+
print(f"\nsaved {n} answers to {p['audit']}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def cmd_report(a):
|
|
76
|
+
p = _paths(a.data)
|
|
77
|
+
labels = label_mod.load_labels(p["labels"])
|
|
78
|
+
if not labels:
|
|
79
|
+
sys.exit("no labels yet. Run `pushback label` first.")
|
|
80
|
+
silent = json.load(open(p["silent"], encoding="utf-8")) if os.path.exists(p["silent"]) else None
|
|
81
|
+
text = report_mod.render(report_mod.build(labels, audit_mod.load_audit(p["audit"]), silent))
|
|
82
|
+
print(text)
|
|
83
|
+
if a.markdown:
|
|
84
|
+
with open(p["report"], "w", encoding="utf-8") as fh:
|
|
85
|
+
fh.write(text + "\n")
|
|
86
|
+
print(f"\nwritten to {p['report']} (counts only, safe to share)")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def cmd_export(a):
|
|
90
|
+
p = _paths(a.data)
|
|
91
|
+
messages = list(_load_messages(p["messages"]).values())
|
|
92
|
+
labels = label_mod.load_labels(p["labels"])
|
|
93
|
+
if not labels:
|
|
94
|
+
sys.exit("no labels yet. Run `pushback label` first.")
|
|
95
|
+
if not os.path.exists(p["turns"]):
|
|
96
|
+
sys.exit(f"{p['turns']} not found. Re-run `pushback extract` (it now saves full agent turns).")
|
|
97
|
+
with open(p["turns"], encoding="utf-8") as fh:
|
|
98
|
+
turns = {t["id"]: t for t in map(json.loads, fh)}
|
|
99
|
+
pairs, skipped = export_mod.build_pairs(
|
|
100
|
+
messages, labels, turns,
|
|
101
|
+
tasks=set(a.task) if a.task else None, ctypes=set(a.ctype) if a.ctype else None,
|
|
102
|
+
high_only=a.high_only, max_tools=a.max_tools,
|
|
103
|
+
)
|
|
104
|
+
redacted = export_mod.scrub(pairs)
|
|
105
|
+
if a.minimal:
|
|
106
|
+
pairs = [{k: x[k] for k in ("prompt", "chosen", "rejected")} for x in pairs]
|
|
107
|
+
out = a.out or p["dpo"]
|
|
108
|
+
with open(out, "w", encoding="utf-8") as fh:
|
|
109
|
+
for x in pairs:
|
|
110
|
+
fh.write(json.dumps(x, ensure_ascii=False) + "\n")
|
|
111
|
+
corrections = sum(1 for d in labels.values() if d["correction"])
|
|
112
|
+
print(f"{len(pairs)} pairs from {corrections} corrections -> {out}")
|
|
113
|
+
for reason, n in skipped.most_common():
|
|
114
|
+
print(f" skipped {n}: {reason}")
|
|
115
|
+
print(f" {redacted} likely secrets replaced with [REDACTED] (best effort: read the file before using it)")
|
|
116
|
+
print("This file holds raw agent and user text. It stays on your machine unless you move it.")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _default_existing() -> list[str]:
|
|
120
|
+
path = os.path.join(os.path.expanduser("~"), ".claude", "CLAUDE.md")
|
|
121
|
+
return [path] if os.path.exists(path) else []
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def cmd_rules(a):
|
|
125
|
+
p = _paths(a.data)
|
|
126
|
+
labels = label_mod.load_labels(p["labels"])
|
|
127
|
+
if not labels:
|
|
128
|
+
sys.exit("no labels yet. Run `pushback label` first.")
|
|
129
|
+
|
|
130
|
+
if a.from_response:
|
|
131
|
+
with open(p["rules_items"], encoding="utf-8") as fh:
|
|
132
|
+
items = json.load(fh)
|
|
133
|
+
with open(a.from_response, encoding="utf-8") as fh:
|
|
134
|
+
text = fh.read()
|
|
135
|
+
raw = json.loads(text[text.find("{"): text.rfind("}") + 1]) # tolerate prose or fences around the JSON
|
|
136
|
+
else:
|
|
137
|
+
if not os.path.exists(p["turns"]):
|
|
138
|
+
sys.exit(f"{p['turns']} not found. Re-run `pushback extract`.")
|
|
139
|
+
with open(p["turns"], encoding="utf-8") as fh:
|
|
140
|
+
turns = {t["id"]: t for t in map(json.loads, fh)}
|
|
141
|
+
items = rules_mod.collect(labels, turns, set(a.task) if a.task else None,
|
|
142
|
+
set(a.ctype) if a.ctype else None, a.limit)
|
|
143
|
+
if not items:
|
|
144
|
+
sys.exit("no corrections match those filters.")
|
|
145
|
+
existing_paths = a.existing if a.existing is not None else _default_existing()
|
|
146
|
+
existing = "\n".join(open(x, encoding="utf-8", errors="ignore").read() for x in existing_paths)
|
|
147
|
+
request = rules_mod.build_request(items, existing, a.model, a.effort)
|
|
148
|
+
with open(p["rules_items"], "w", encoding="utf-8") as fh:
|
|
149
|
+
json.dump(items, fh, ensure_ascii=False)
|
|
150
|
+
print(f"{len(items)} corrections, checked against {len(existing_paths)} existing rule file(s)")
|
|
151
|
+
|
|
152
|
+
if a.prompt_file:
|
|
153
|
+
with open(p["rules_prompt"], "w", encoding="utf-8") as fh:
|
|
154
|
+
fh.write(request["system"] + "\n\n" + request["messages"][0]["content"] + "\n\n"
|
|
155
|
+
+ "Reply with only a JSON object matching this schema:\n"
|
|
156
|
+
+ json.dumps(rules_mod.SCHEMA, indent=2) + "\n")
|
|
157
|
+
print(f"prompt written to {p['rules_prompt']}")
|
|
158
|
+
print("Give it to Claude (for example: ask Claude Code to answer the file), save the JSON reply,")
|
|
159
|
+
print("then run: pushback rules --from-response <reply file>")
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
base = os.environ.get("ANTHROPIC_BASE_URL")
|
|
163
|
+
print(f"Sending them to {base or 'the Anthropic API'} with model {a.model}.")
|
|
164
|
+
if not a.yes and input("Continue? [y/N] ").strip().lower() != "y":
|
|
165
|
+
sys.exit("aborted")
|
|
166
|
+
import anthropic
|
|
167
|
+
raw = rules_mod.ask(anthropic.Anthropic(), request)
|
|
168
|
+
|
|
169
|
+
found = rules_mod.parse(raw, items, a.min_support)
|
|
170
|
+
with open(p["rules"], "w", encoding="utf-8") as fh:
|
|
171
|
+
fh.write(rules_mod.render(found, len(items)))
|
|
172
|
+
broken = sum(1 for r in found if r["covered_by"])
|
|
173
|
+
print(f"{len(found)} rules ({broken} you already have but keep breaking) -> {p['rules']}")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def main(argv=None):
|
|
177
|
+
ap = argparse.ArgumentParser(prog="pushback", description=__doc__)
|
|
178
|
+
ap.add_argument("--data", default=DATA, help=f"working folder (default: ./{DATA})")
|
|
179
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
180
|
+
|
|
181
|
+
e = sub.add_parser("extract", help="pull your messages out of Claude Code transcripts")
|
|
182
|
+
e.add_argument("--root", default=extract_mod.DEFAULT_ROOT, help="transcripts folder")
|
|
183
|
+
e.add_argument("--exclude", nargs="*", help="session id prefixes to skip")
|
|
184
|
+
e.set_defaults(func=cmd_extract)
|
|
185
|
+
|
|
186
|
+
lb = sub.add_parser("label", help="label each message with Claude (resumable)")
|
|
187
|
+
lb.add_argument("--model", default=label_mod.DEFAULT_MODEL)
|
|
188
|
+
lb.add_argument("--effort", default="low", choices=["low", "medium", "high"])
|
|
189
|
+
lb.add_argument("--batch-size", type=int, default=40)
|
|
190
|
+
lb.add_argument("--workers", type=int, default=4)
|
|
191
|
+
lb.add_argument("-y", "--yes", action="store_true", help="skip the data-leaves-your-machine prompt")
|
|
192
|
+
lb.set_defaults(func=cmd_label)
|
|
193
|
+
|
|
194
|
+
au = sub.add_parser("audit", help="hand-check a sample so the report can bound the error")
|
|
195
|
+
au.add_argument("-n", type=int, default=40)
|
|
196
|
+
au.add_argument("--seed", type=int, default=0)
|
|
197
|
+
au.set_defaults(func=cmd_audit)
|
|
198
|
+
|
|
199
|
+
ex = sub.add_parser("export", help="write correction pairs as prompt/chosen/rejected JSONL")
|
|
200
|
+
ex.add_argument("--task", nargs="*", help="only these tasks, e.g. --task writing media")
|
|
201
|
+
ex.add_argument("--ctype", nargs="*",
|
|
202
|
+
help="only these correction types; writing_content and tone_style make the cleanest pairs")
|
|
203
|
+
ex.add_argument("--high-only", action="store_true", help="only high-confidence labels")
|
|
204
|
+
ex.add_argument("--max-tools", type=int, help="drop pairs where either turn made more tool calls than this")
|
|
205
|
+
ex.add_argument("--minimal", action="store_true", help="only prompt/chosen/rejected (TRL DPO columns)")
|
|
206
|
+
ex.add_argument("--out", help="output path (default: <data>/dpo.jsonl)")
|
|
207
|
+
ex.set_defaults(func=cmd_export)
|
|
208
|
+
|
|
209
|
+
ru = sub.add_parser("rules", help="draft CLAUDE.md rules from your recurring corrections")
|
|
210
|
+
ru.add_argument("--task", nargs="*")
|
|
211
|
+
ru.add_argument("--ctype", nargs="*")
|
|
212
|
+
ru.add_argument("--limit", type=int, default=400, help="most recent N corrections (default 400)")
|
|
213
|
+
ru.add_argument("--min-support", type=int, default=3, help="corrections needed per rule (default 3)")
|
|
214
|
+
ru.add_argument("--existing", nargs="*", help="rule files to check against (default: ~/.claude/CLAUDE.md)")
|
|
215
|
+
ru.add_argument("--model", default=label_mod.DEFAULT_MODEL)
|
|
216
|
+
ru.add_argument("--effort", default="high", choices=["low", "medium", "high"])
|
|
217
|
+
ru.add_argument("--prompt-file", action="store_true", help="write the request to a file instead of calling the API")
|
|
218
|
+
ru.add_argument("--from-response", help="read the model's JSON reply from this file")
|
|
219
|
+
ru.add_argument("-y", "--yes", action="store_true")
|
|
220
|
+
ru.set_defaults(func=cmd_rules)
|
|
221
|
+
|
|
222
|
+
rp = sub.add_parser("report", help="print the correction-rate table")
|
|
223
|
+
rp.add_argument("--markdown", action="store_true", help="also write report.md")
|
|
224
|
+
rp.set_defaults(func=cmd_report)
|
|
225
|
+
|
|
226
|
+
args = ap.parse_args(argv)
|
|
227
|
+
args.func(args)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
if __name__ == "__main__":
|
|
231
|
+
main()
|
pushback/export.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Turn labelled corrections into preference pairs (prompt / chosen / rejected).
|
|
2
|
+
|
|
3
|
+
For a correction C in a session ... P, [agent turn R], C, [agent turn A], N ...
|
|
4
|
+
|
|
5
|
+
prompt = P, the message the agent was answering
|
|
6
|
+
rejected = R, the agent turn you corrected
|
|
7
|
+
chosen = A, the agent's reply to C, kept only if your next message N exists and is NOT a correction
|
|
8
|
+
feedback = C, your correction itself
|
|
9
|
+
|
|
10
|
+
"You didn't correct it again" is weak evidence that A was good, and A was
|
|
11
|
+
written after seeing your feedback. Treat these pairs as a personal dataset
|
|
12
|
+
to inspect and filter, not as clean ground truth.
|
|
13
|
+
|
|
14
|
+
Only agent *text* is exported. Edits and commands the agent ran are tool
|
|
15
|
+
calls, so a code correction's pair may carry the explanation but not the
|
|
16
|
+
diff. The tool-call counts are kept so you can filter those out.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
from collections import Counter
|
|
23
|
+
|
|
24
|
+
# Best-effort scrub of common credential shapes. It will miss things; read your export.
|
|
25
|
+
SECRET_PATTERNS = [
|
|
26
|
+
re.compile(r"sk-ant-[A-Za-z0-9_\-]{20,}"),
|
|
27
|
+
re.compile(r"sk-[A-Za-z0-9_\-]{20,}"),
|
|
28
|
+
re.compile(r"gh[pousr]_[A-Za-z0-9]{30,}"),
|
|
29
|
+
re.compile(r"github_pat_[A-Za-z0-9_]{30,}"),
|
|
30
|
+
re.compile(r"AKIA[0-9A-Z]{16}"),
|
|
31
|
+
re.compile(r"xox[baprs]-[A-Za-z0-9\-]{10,}"),
|
|
32
|
+
re.compile(r"AIza[0-9A-Za-z_\-]{35}"),
|
|
33
|
+
re.compile(r"(?i)bearer\s+[A-Za-z0-9._\-]{20,}"),
|
|
34
|
+
re.compile(r"eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}"), # JWT
|
|
35
|
+
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----"),
|
|
36
|
+
]
|
|
37
|
+
# NAME_API_KEY=value / SECRET: value with a long value. The name is kept so the text still reads;
|
|
38
|
+
# placeholders like "your-key-here" are shorter than 24 characters and pass through.
|
|
39
|
+
ASSIGNMENT = re.compile(
|
|
40
|
+
r"(?i)((?:api[_-]?key|secret|token|password|passwd)\w*\s*[:=]\s*['\"]?)[A-Za-z0-9_\-./+]{24,}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def redact(text: str) -> tuple[str, int]:
|
|
44
|
+
hits = 0
|
|
45
|
+
for pat in SECRET_PATTERNS:
|
|
46
|
+
text, n = pat.subn("[REDACTED]", text)
|
|
47
|
+
hits += n
|
|
48
|
+
text, n = ASSIGNMENT.subn(r"\1[REDACTED]", text)
|
|
49
|
+
return text, hits + n
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _order_key(message_id: str) -> tuple[str, int]:
|
|
53
|
+
session, _, n = message_id.rpartition(":")
|
|
54
|
+
return session, int(n)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def build_pairs(
|
|
58
|
+
messages: list[dict],
|
|
59
|
+
labels: dict[str, dict],
|
|
60
|
+
turns: dict[str, dict],
|
|
61
|
+
tasks: set[str] | None = None,
|
|
62
|
+
ctypes: set[str] | None = None,
|
|
63
|
+
high_only: bool = False,
|
|
64
|
+
max_tools: int | None = None,
|
|
65
|
+
) -> tuple[list[dict], Counter]:
|
|
66
|
+
skipped: Counter = Counter()
|
|
67
|
+
by_id = {m["id"]: m for m in messages}
|
|
68
|
+
ids = sorted(by_id, key=_order_key)
|
|
69
|
+
pos = {i: k for k, i in enumerate(ids)}
|
|
70
|
+
pairs = []
|
|
71
|
+
|
|
72
|
+
for mid in ids:
|
|
73
|
+
lab = labels.get(mid)
|
|
74
|
+
if not lab or not lab["correction"]:
|
|
75
|
+
continue
|
|
76
|
+
if tasks and lab["task"] not in tasks:
|
|
77
|
+
skipped["task filtered"] += 1
|
|
78
|
+
continue
|
|
79
|
+
if ctypes and lab["ctype"] not in ctypes:
|
|
80
|
+
skipped["correction type filtered"] += 1
|
|
81
|
+
continue
|
|
82
|
+
if high_only and lab.get("conf") != "high":
|
|
83
|
+
skipped["low confidence"] += 1
|
|
84
|
+
continue
|
|
85
|
+
session, n = _order_key(mid)
|
|
86
|
+
prev_id, next_id = f"{session}:{n - 1}", f"{session}:{n + 1}"
|
|
87
|
+
if prev_id not in pos:
|
|
88
|
+
skipped["no earlier message"] += 1
|
|
89
|
+
continue
|
|
90
|
+
# Your next message (n+1) is both what follows the agent's new reply and your verdict on it.
|
|
91
|
+
if next_id not in pos:
|
|
92
|
+
skipped["session ended after correction"] += 1
|
|
93
|
+
continue
|
|
94
|
+
verdict = labels.get(next_id)
|
|
95
|
+
if verdict is None:
|
|
96
|
+
skipped["reaction not labelled"] += 1
|
|
97
|
+
continue
|
|
98
|
+
if verdict["correction"]:
|
|
99
|
+
skipped["new reply was corrected too"] += 1
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
rejected = turns.get(mid, {}).get("prev_turn", "")
|
|
103
|
+
chosen = turns.get(next_id, {}).get("prev_turn", "")
|
|
104
|
+
if not rejected.strip() or not chosen.strip():
|
|
105
|
+
skipped["agent turn had no text"] += 1
|
|
106
|
+
continue
|
|
107
|
+
r_tools = turns[mid].get("prev_turn_tools", 0)
|
|
108
|
+
c_tools = turns[next_id].get("prev_turn_tools", 0)
|
|
109
|
+
if max_tools is not None and max(r_tools, c_tools) > max_tools:
|
|
110
|
+
skipped["too many tool calls"] += 1
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
pairs.append({
|
|
114
|
+
"prompt": turns.get(prev_id, {}).get("prompt", ""),
|
|
115
|
+
"rejected": rejected,
|
|
116
|
+
"chosen": chosen,
|
|
117
|
+
"feedback": turns[mid].get("prompt", ""),
|
|
118
|
+
"id": mid,
|
|
119
|
+
"task": lab["task"],
|
|
120
|
+
"ctype": lab["ctype"],
|
|
121
|
+
"rejected_tool_calls": r_tools,
|
|
122
|
+
"chosen_tool_calls": c_tools,
|
|
123
|
+
})
|
|
124
|
+
return pairs, skipped
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def scrub(pairs: list[dict]) -> int:
|
|
128
|
+
total = 0
|
|
129
|
+
for p in pairs:
|
|
130
|
+
for k in ("prompt", "rejected", "chosen", "feedback"):
|
|
131
|
+
p[k], n = redact(p[k])
|
|
132
|
+
total += n
|
|
133
|
+
return total
|
pushback/extract.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Pull human messages (with the assistant turn they respond to) out of Claude Code transcripts.
|
|
2
|
+
|
|
3
|
+
Claude Code writes one JSONL file per session under ~/.claude/projects/<project>/.
|
|
4
|
+
We keep only what a person typed: tool results, system reminders, slash-command
|
|
5
|
+
output and subagent (sidechain) traffic are dropped. Silent corrections -- a
|
|
6
|
+
rejected tool call or an interrupt -- carry no text, so they are counted
|
|
7
|
+
separately and attributed to the kind of action that was cut off.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import glob
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
from collections import Counter
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
|
|
18
|
+
DEFAULT_ROOT = os.path.join(os.path.expanduser("~"), ".claude", "projects")
|
|
19
|
+
|
|
20
|
+
REJECTED = "doesn't want to proceed"
|
|
21
|
+
INTERRUPTED = "[Request interrupted by user"
|
|
22
|
+
|
|
23
|
+
CODE_EXT = {
|
|
24
|
+
".py", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".sql", ".prisma", ".json", ".yml", ".yaml",
|
|
25
|
+
".sh", ".css", ".html", ".go", ".rs", ".toml", ".java", ".kt", ".rb", ".php", ".cs",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Extracted:
|
|
31
|
+
messages: list[dict] = field(default_factory=list)
|
|
32
|
+
rejections: Counter = field(default_factory=Counter)
|
|
33
|
+
interrupts: Counter = field(default_factory=Counter)
|
|
34
|
+
# Full text for `export`: the whole agent turn before each message, and the untruncated message.
|
|
35
|
+
turns: dict[str, dict] = field(default_factory=dict)
|
|
36
|
+
sessions: int = 0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def action_kind(name: str, inp: dict | None) -> str:
|
|
40
|
+
"""Bucket a tool call so silent corrections can be attributed without storing its content."""
|
|
41
|
+
inp = inp or {}
|
|
42
|
+
if name in ("Edit", "Write", "MultiEdit", "NotebookEdit"):
|
|
43
|
+
ext = os.path.splitext(inp.get("file_path") or "")[1].lower()
|
|
44
|
+
return "edit-code" if ext in CODE_EXT else "edit-other"
|
|
45
|
+
if name in ("Bash", "PowerShell"):
|
|
46
|
+
cmd = inp.get("command") or ""
|
|
47
|
+
if "git " in cmd or "gh " in cmd:
|
|
48
|
+
return "shell-git"
|
|
49
|
+
if any(k in cmd for k in ("npm", "pnpm", "yarn", "pytest", "python", "node", "tsc", "npx", "cargo", "go ")):
|
|
50
|
+
return "shell-build"
|
|
51
|
+
return "shell-other"
|
|
52
|
+
if name.startswith("mcp__"):
|
|
53
|
+
return "mcp"
|
|
54
|
+
return "other-tool"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _is_human_text(text: str) -> bool:
|
|
58
|
+
if not text:
|
|
59
|
+
return False
|
|
60
|
+
# Harness-injected blocks (<system-reminder>, <command-name>, ...) start with a tag.
|
|
61
|
+
# Pasted content is still something the person chose to send.
|
|
62
|
+
return not (text.startswith("<") and not text.startswith("<pasted"))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def extract(
|
|
66
|
+
root: str = DEFAULT_ROOT,
|
|
67
|
+
exclude_sessions: tuple[str, ...] = (),
|
|
68
|
+
max_user_chars: int = 500,
|
|
69
|
+
max_context_chars: int = 400,
|
|
70
|
+
) -> Extracted:
|
|
71
|
+
out = Extracted()
|
|
72
|
+
files = sorted(glob.glob(os.path.join(root, "*", "*.jsonl")))
|
|
73
|
+
for path in files:
|
|
74
|
+
session = os.path.splitext(os.path.basename(path))[0]
|
|
75
|
+
if any(session.startswith(x) for x in exclude_sessions):
|
|
76
|
+
continue
|
|
77
|
+
out.sessions += 1
|
|
78
|
+
_extract_file(path, session, out, max_user_chars, max_context_chars)
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _extract_file(path, session, out, max_user_chars, max_context_chars):
|
|
83
|
+
last_text = ""
|
|
84
|
+
n = 0
|
|
85
|
+
tools: dict[str, tuple[str, dict]] = {}
|
|
86
|
+
last_tool: tuple[str, dict] | None = None
|
|
87
|
+
# One agent turn = every text block and tool call between two human messages.
|
|
88
|
+
turn_text: list[str] = []
|
|
89
|
+
turn_tools = 0
|
|
90
|
+
with open(path, encoding="utf-8", errors="ignore") as fh:
|
|
91
|
+
for line in fh:
|
|
92
|
+
try:
|
|
93
|
+
entry = json.loads(line)
|
|
94
|
+
except json.JSONDecodeError:
|
|
95
|
+
continue
|
|
96
|
+
if entry.get("isSidechain"):
|
|
97
|
+
continue
|
|
98
|
+
message = entry.get("message")
|
|
99
|
+
content = message.get("content") if isinstance(message, dict) else None
|
|
100
|
+
|
|
101
|
+
if entry.get("type") == "assistant" and isinstance(content, list):
|
|
102
|
+
text = " ".join(b.get("text", "") for b in content if b.get("type") == "text").strip()
|
|
103
|
+
if text:
|
|
104
|
+
last_text = text
|
|
105
|
+
turn_text.append(text)
|
|
106
|
+
for b in content:
|
|
107
|
+
if b.get("type") == "tool_use":
|
|
108
|
+
turn_tools += 1
|
|
109
|
+
tools[b.get("id")] = (b.get("name", ""), b.get("input"))
|
|
110
|
+
last_tool = tools[b.get("id")]
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
if entry.get("type") != "user" or entry.get("isMeta"):
|
|
114
|
+
continue
|
|
115
|
+
|
|
116
|
+
if isinstance(content, list):
|
|
117
|
+
texts = []
|
|
118
|
+
for b in content:
|
|
119
|
+
if b.get("type") == "tool_result" and REJECTED in json.dumps(b.get("content")):
|
|
120
|
+
name, inp = tools.get(b.get("tool_use_id"), ("", None))
|
|
121
|
+
out.rejections[action_kind(name, inp)] += 1
|
|
122
|
+
elif b.get("type") == "text":
|
|
123
|
+
texts.append(b.get("text", ""))
|
|
124
|
+
else:
|
|
125
|
+
texts = [content or ""]
|
|
126
|
+
|
|
127
|
+
for text in texts:
|
|
128
|
+
text = text.strip()
|
|
129
|
+
if INTERRUPTED in text:
|
|
130
|
+
out.interrupts[action_kind(*last_tool) if last_tool else "none"] += 1
|
|
131
|
+
continue
|
|
132
|
+
if not _is_human_text(text):
|
|
133
|
+
continue
|
|
134
|
+
out.messages.append({
|
|
135
|
+
# Stable across re-extracts: sessions only ever append, so session:index never shifts.
|
|
136
|
+
"id": f"{session}:{n}",
|
|
137
|
+
"session": session[:8],
|
|
138
|
+
"prev_assistant": last_text[-max_context_chars:].replace("\n", " "),
|
|
139
|
+
"user": text[:max_user_chars].replace("\n", " "),
|
|
140
|
+
})
|
|
141
|
+
out.turns[f"{session}:{n}"] = {
|
|
142
|
+
"prompt": text,
|
|
143
|
+
"prev_turn": "\n\n".join(turn_text),
|
|
144
|
+
"prev_turn_tools": turn_tools,
|
|
145
|
+
}
|
|
146
|
+
turn_text, turn_tools = [], 0
|
|
147
|
+
n += 1
|
pushback/label.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Send batches of messages to Claude and store one label per message.
|
|
2
|
+
|
|
3
|
+
Resumable: labels are appended to labels.jsonl batch by batch, and a re-run
|
|
4
|
+
skips every id already present. A batch that fails is logged and left
|
|
5
|
+
unlabelled, so a network blip never becomes a silent "not a correction".
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import threading
|
|
13
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
14
|
+
|
|
15
|
+
import anthropic
|
|
16
|
+
|
|
17
|
+
from .prompt import CTYPES, SCHEMA, SYSTEM, TASKS
|
|
18
|
+
|
|
19
|
+
DEFAULT_MODEL = "claude-opus-5"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_labels(path: str) -> dict[str, dict]:
|
|
23
|
+
labels: dict[str, dict] = {}
|
|
24
|
+
if os.path.exists(path):
|
|
25
|
+
with open(path, encoding="utf-8") as fh:
|
|
26
|
+
for line in fh:
|
|
27
|
+
if line.strip():
|
|
28
|
+
d = json.loads(line)
|
|
29
|
+
labels[d["id"]] = d
|
|
30
|
+
return labels
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def validate(raw: list[dict], keys: list[str]) -> list[dict]:
|
|
34
|
+
"""Map the model's per-batch numbers back to message ids, keeping only well-formed labels.
|
|
35
|
+
|
|
36
|
+
Anything missing or malformed stays unlabelled and is retried on the next run.
|
|
37
|
+
"""
|
|
38
|
+
good = []
|
|
39
|
+
seen = set()
|
|
40
|
+
for d in raw:
|
|
41
|
+
try:
|
|
42
|
+
i = int(d["id"])
|
|
43
|
+
except (KeyError, TypeError, ValueError):
|
|
44
|
+
continue
|
|
45
|
+
if not 0 <= i < len(keys) or i in seen:
|
|
46
|
+
continue
|
|
47
|
+
if d.get("task") not in TASKS or d.get("ctype") not in CTYPES or not isinstance(d.get("correction"), bool):
|
|
48
|
+
continue
|
|
49
|
+
if not d["correction"]:
|
|
50
|
+
d["ctype"] = "none"
|
|
51
|
+
seen.add(i)
|
|
52
|
+
good.append({"id": keys[i], **{k: d[k] for k in ("task", "correction", "ctype", "conf") if k in d}})
|
|
53
|
+
return good
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _request_kwargs(model: str, batch: list[dict], effort: str) -> dict:
|
|
57
|
+
items = [{"id": n, "prev_assistant": m["prev_assistant"], "user": m["user"]} for n, m in enumerate(batch)]
|
|
58
|
+
return {
|
|
59
|
+
"model": model,
|
|
60
|
+
"max_tokens": 16000,
|
|
61
|
+
"system": SYSTEM,
|
|
62
|
+
"messages": [{"role": "user", "content": json.dumps(items, ensure_ascii=False)}],
|
|
63
|
+
"output_config": {"effort": effort, "format": {"type": "json_schema", "schema": SCHEMA}},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def label_batch(client, model: str, batch: list[dict], effort: str = "low") -> list[dict]:
|
|
68
|
+
response = client.messages.create(**_request_kwargs(model, batch, effort))
|
|
69
|
+
if response.stop_reason == "refusal":
|
|
70
|
+
raise RuntimeError("model declined this batch (stop_reason=refusal)")
|
|
71
|
+
if response.stop_reason == "max_tokens":
|
|
72
|
+
raise RuntimeError("output hit max_tokens; use a smaller --batch-size")
|
|
73
|
+
text = next((b.text for b in response.content if b.type == "text"), "")
|
|
74
|
+
return validate(json.loads(text).get("labels", []), [m["id"] for m in batch])
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def run(
|
|
78
|
+
messages: list[dict],
|
|
79
|
+
labels_path: str,
|
|
80
|
+
model: str = DEFAULT_MODEL,
|
|
81
|
+
batch_size: int = 40,
|
|
82
|
+
workers: int = 4,
|
|
83
|
+
effort: str = "low",
|
|
84
|
+
client=None,
|
|
85
|
+
log=print,
|
|
86
|
+
) -> tuple[int, int]:
|
|
87
|
+
done = load_labels(labels_path)
|
|
88
|
+
todo = [m for m in messages if m["id"] not in done]
|
|
89
|
+
batches = [todo[i:i + batch_size] for i in range(0, len(todo), batch_size)]
|
|
90
|
+
if not batches:
|
|
91
|
+
log(f"all {len(messages)} messages already labelled")
|
|
92
|
+
return 0, 0
|
|
93
|
+
|
|
94
|
+
client = client or anthropic.Anthropic()
|
|
95
|
+
lock = threading.Lock()
|
|
96
|
+
written = failed = 0
|
|
97
|
+
|
|
98
|
+
def work(batch):
|
|
99
|
+
return batch, label_batch(client, model, batch, effort)
|
|
100
|
+
|
|
101
|
+
with ThreadPoolExecutor(max_workers=workers) as pool, open(labels_path, "a", encoding="utf-8") as out:
|
|
102
|
+
futures = [pool.submit(work, b) for b in batches]
|
|
103
|
+
for n, fut in enumerate(as_completed(futures), 1):
|
|
104
|
+
try:
|
|
105
|
+
batch, labels = fut.result()
|
|
106
|
+
except anthropic.AuthenticationError:
|
|
107
|
+
raise
|
|
108
|
+
except (anthropic.APIError, RuntimeError, json.JSONDecodeError) as e:
|
|
109
|
+
# APIError covers status errors AND connection errors; both leave the batch for a re-run.
|
|
110
|
+
failed += 1
|
|
111
|
+
log(f"[{n}/{len(batches)}] batch failed, will retry on next run: {type(e).__name__}: {e}")
|
|
112
|
+
continue
|
|
113
|
+
with lock:
|
|
114
|
+
for d in labels:
|
|
115
|
+
out.write(json.dumps(d) + "\n")
|
|
116
|
+
out.flush()
|
|
117
|
+
written += len(labels)
|
|
118
|
+
missing = len(batch) - len(labels)
|
|
119
|
+
log(f"[{n}/{len(batches)}] labelled {len(labels)}" + (f", {missing} left for re-run" if missing else ""))
|
|
120
|
+
return written, failed
|
pushback/prompt.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""The labelling instructions and the JSON schema the model must answer in.
|
|
2
|
+
|
|
3
|
+
These are the instructions that scored precision 0.97 / recall 0.80 against 91
|
|
4
|
+
hand labels on the author's own logs. Change them and re-run `pushback audit`
|
|
5
|
+
before trusting new numbers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
TASKS = ["code", "writing", "media", "research", "ops", "meta"]
|
|
9
|
+
CTYPES = [
|
|
10
|
+
"none", "writing_content", "tone_style", "wrong_assumption", "scope",
|
|
11
|
+
"broken_output", "code", "misread_request", "process",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
SYSTEM = """You label messages a user sent to an AI coding agent. The agent is also used for writing, research and ops.
|
|
15
|
+
|
|
16
|
+
Each item has: id, prev_assistant (the end of the agent's previous reply), user (the user's next message).
|
|
17
|
+
Give every item three labels.
|
|
18
|
+
|
|
19
|
+
task: the work in progress, judged from BOTH prev_assistant and user. Pick one.
|
|
20
|
+
- code: writing, fixing or reviewing code, tests, builds, PRs, schemas
|
|
21
|
+
- writing: prose for humans, such as social posts, business messages, emails, scripts, docs, READMEs, proposals
|
|
22
|
+
- media: images, thumbnails, video, animation, design visuals
|
|
23
|
+
- research: benchmarks, experiments, data analysis, paper reading, market research
|
|
24
|
+
- ops: deploys, git/GitHub admin, accounts, config, credentials, infra, tool setup, browser automation
|
|
25
|
+
- meta: planning and strategy talk, advice, saving memory or context, chit-chat, session management
|
|
26
|
+
|
|
27
|
+
correction: true ONLY if the user rejects, fixes or redirects something the agent just did, said, wrote or proposed because it was wrong, unwanted, incomplete or not what they asked.
|
|
28
|
+
It is false for all of these:
|
|
29
|
+
- new tasks, next steps, "now do X", moving on, "save to memory"
|
|
30
|
+
- answers to the agent's questions (a plain "no" to a yes/no question is an answer)
|
|
31
|
+
- choosing between options the agent offered, or reprioritising ("first do X")
|
|
32
|
+
- approvals, thanks, reassurance, jokes
|
|
33
|
+
- pasted logs or content with no reaction to the agent's work
|
|
34
|
+
- complaints about third parties or tools
|
|
35
|
+
- the user's own new idea or realisation
|
|
36
|
+
|
|
37
|
+
ctype: "none" unless correction is true, otherwise one of:
|
|
38
|
+
- writing_content: the text missed or misstated content ("you didn't mention X", "start it with Y")
|
|
39
|
+
- tone_style: voice, length, format, "sounds AI", "looks AI", design style
|
|
40
|
+
- wrong_assumption: the agent assumed something false about the user, their state or their facts
|
|
41
|
+
- scope: did too much, added unrequested things, offered unwanted things
|
|
42
|
+
- broken_output: a non-code output (file, image, video, deploy) is missing, stale or broken
|
|
43
|
+
- code: a code bug, failing build or test, or wrong technical implementation
|
|
44
|
+
- misread_request: misunderstood what was asked
|
|
45
|
+
- process: how the agent works (tool or model choice, don't ask, verify first)
|
|
46
|
+
|
|
47
|
+
conf: "high" or "low".
|
|
48
|
+
Return exactly one label per input id, and never repeat message text."""
|
|
49
|
+
|
|
50
|
+
SCHEMA = {
|
|
51
|
+
"type": "object",
|
|
52
|
+
"properties": {
|
|
53
|
+
"labels": {
|
|
54
|
+
"type": "array",
|
|
55
|
+
"items": {
|
|
56
|
+
"type": "object",
|
|
57
|
+
"properties": {
|
|
58
|
+
"id": {"type": "integer"},
|
|
59
|
+
"task": {"type": "string", "enum": TASKS},
|
|
60
|
+
"correction": {"type": "boolean"},
|
|
61
|
+
"ctype": {"type": "string", "enum": CTYPES},
|
|
62
|
+
"conf": {"type": "string", "enum": ["high", "low"]},
|
|
63
|
+
},
|
|
64
|
+
"required": ["id", "task", "correction", "ctype", "conf"],
|
|
65
|
+
"additionalProperties": False,
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
"required": ["labels"],
|
|
70
|
+
"additionalProperties": False,
|
|
71
|
+
}
|
pushback/report.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Turn labels (and an optional audit) into the correction-rate table. Counts only, never message text."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter, defaultdict
|
|
6
|
+
|
|
7
|
+
from .prompt import TASKS
|
|
8
|
+
from .stats import corrected_count, wilson
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build(labels: dict[int, dict], audit: dict[int, dict] | None = None, silent: dict | None = None) -> dict:
|
|
12
|
+
total = Counter(d["task"] for d in labels.values())
|
|
13
|
+
corr = Counter(d["task"] for d in labels.values() if d["correction"])
|
|
14
|
+
ctypes = defaultdict(Counter)
|
|
15
|
+
for d in labels.values():
|
|
16
|
+
if d["correction"]:
|
|
17
|
+
ctypes[d["task"]][d["ctype"]] += 1
|
|
18
|
+
|
|
19
|
+
rows = []
|
|
20
|
+
for task in TASKS:
|
|
21
|
+
n, k = total[task], corr[task]
|
|
22
|
+
if not n:
|
|
23
|
+
continue
|
|
24
|
+
lo, hi = wilson(k, n)
|
|
25
|
+
rows.append({"task": task, "messages": n, "corrections": k, "rate": k / n, "low": lo, "high": hi,
|
|
26
|
+
"top": ctypes[task].most_common(3)})
|
|
27
|
+
rows.sort(key=lambda r: r["rate"], reverse=True)
|
|
28
|
+
|
|
29
|
+
flagged = sum(corr.values())
|
|
30
|
+
out = {"messages": len(labels), "flagged": flagged, "rows": rows, "audit": None,
|
|
31
|
+
"silent": silent or {}}
|
|
32
|
+
|
|
33
|
+
if audit:
|
|
34
|
+
pos = [a for a in audit.values() if a["stratum"] == "flagged"]
|
|
35
|
+
neg = [a for a in audit.values() if a["stratum"] == "unflagged"]
|
|
36
|
+
out["audit"] = {
|
|
37
|
+
"n": len(audit),
|
|
38
|
+
**corrected_count(
|
|
39
|
+
flagged, len(labels) - flagged,
|
|
40
|
+
sum(a["human"] for a in pos), len(pos),
|
|
41
|
+
sum(a["human"] for a in neg), len(neg),
|
|
42
|
+
),
|
|
43
|
+
}
|
|
44
|
+
return out
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def render(r: dict) -> str:
|
|
48
|
+
lines = []
|
|
49
|
+
n, f = r["messages"], r["flagged"]
|
|
50
|
+
lines.append(f"{n} messages labelled, {f} flagged as corrections ({f / n:.1%})." if n else "no labels yet")
|
|
51
|
+
lines.append("")
|
|
52
|
+
lines.append("| task | messages | corrections | rate | 95% CI | most common |")
|
|
53
|
+
lines.append("|---|---:|---:|---:|---|---|")
|
|
54
|
+
for row in r["rows"]:
|
|
55
|
+
top = ", ".join(f"{c} {k}" for c, k in row["top"])
|
|
56
|
+
lines.append(f"| {row['task']} | {row['messages']} | {row['corrections']} | {row['rate']:.1%} "
|
|
57
|
+
f"| {row['low']:.1%}-{row['high']:.1%} | {top} |")
|
|
58
|
+
|
|
59
|
+
a = r["audit"]
|
|
60
|
+
lines.append("")
|
|
61
|
+
if not a:
|
|
62
|
+
lines.append("No audit yet: these are raw model labels. Run `pushback audit` before quoting any number.")
|
|
63
|
+
elif a["estimate"] is None:
|
|
64
|
+
lines.append(f"Audit has {a['n']} answers but needs both flagged and unflagged samples.")
|
|
65
|
+
else:
|
|
66
|
+
lines.append(f"Audit ({a['n']} hand checks): precision {a['precision']:.0%}, "
|
|
67
|
+
f"miss rate {a['miss_rate']:.1%} on unflagged messages.")
|
|
68
|
+
lines.append(f"Estimated true corrections: {a['estimate']:.0f} "
|
|
69
|
+
f"(range {a['low']:.0f}-{a['high']:.0f}) = {a['estimate'] / n:.1%} of messages.")
|
|
70
|
+
if a["n"] < 40:
|
|
71
|
+
lines.append("Fewer than 40 hand checks: the range is wide. Audit more before posting.")
|
|
72
|
+
|
|
73
|
+
s = r["silent"]
|
|
74
|
+
if s and (s.get("rejections") or s.get("interrupts")):
|
|
75
|
+
rej, intr = s.get("rejections", {}), s.get("interrupts", {})
|
|
76
|
+
lines.append("")
|
|
77
|
+
lines.append(f"Silent corrections (not in the table): {sum(rej.values())} rejected tool calls, "
|
|
78
|
+
f"{sum(intr.values())} interrupts.")
|
|
79
|
+
both = Counter(rej) + Counter(intr)
|
|
80
|
+
lines.append("By action: " + ", ".join(f"{k} {v}" for k, v in both.most_common()))
|
|
81
|
+
return "\n".join(lines)
|
pushback/rules.py
ADDED
|
@@ -0,0 +1,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
|
+
})
|
|
70
|
+
items.sort(key=lambda x: (x["id"].rpartition(":")[0], int(x["id"].rpartition(":")[2])))
|
|
71
|
+
return items[-limit:]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def build_request(items: list[dict], existing: str, model: str, effort: str = "high") -> dict:
|
|
75
|
+
payload = [{"n": n, "task": x["task"], "agent": x["agent"], "correction": x["correction"]}
|
|
76
|
+
for n, x in enumerate(items)]
|
|
77
|
+
content = "CORRECTIONS (the \"n\" field is the id to cite):\n" + json.dumps(payload, ensure_ascii=False)
|
|
78
|
+
if existing.strip():
|
|
79
|
+
content = "EXISTING RULES:\n" + existing.strip() + "\n\n" + content
|
|
80
|
+
return {
|
|
81
|
+
"model": model,
|
|
82
|
+
"max_tokens": 16000,
|
|
83
|
+
"system": SYSTEM,
|
|
84
|
+
"messages": [{"role": "user", "content": content}],
|
|
85
|
+
"output_config": {"effort": effort, "format": {"type": "json_schema", "schema": SCHEMA}},
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def parse(raw: dict, items: list[dict], min_support: int = 3) -> list[dict]:
|
|
90
|
+
"""Map cited numbers back to correction ids, recount support, drop weak or malformed rules."""
|
|
91
|
+
rules = []
|
|
92
|
+
for r in raw.get("rules", []):
|
|
93
|
+
if not isinstance(r, dict) or not str(r.get("rule", "")).strip():
|
|
94
|
+
continue
|
|
95
|
+
ids = []
|
|
96
|
+
for i in r.get("ids", []):
|
|
97
|
+
if isinstance(i, int) and 0 <= i < len(items) and items[i]["id"] not in ids:
|
|
98
|
+
ids.append(items[i]["id"])
|
|
99
|
+
if len(ids) < min_support:
|
|
100
|
+
continue
|
|
101
|
+
rules.append({
|
|
102
|
+
"rule": r["rule"].strip(),
|
|
103
|
+
"why": str(r.get("why", "")).strip(),
|
|
104
|
+
"task": r.get("task") if r.get("task") in TASKS else "meta",
|
|
105
|
+
"support": len(ids),
|
|
106
|
+
"ids": ids,
|
|
107
|
+
"covered_by": str(r.get("covered_by", "")).strip(),
|
|
108
|
+
})
|
|
109
|
+
rules.sort(key=lambda r: r["support"], reverse=True)
|
|
110
|
+
return rules
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def ask(client, request: dict) -> dict:
|
|
114
|
+
response = client.messages.create(**request)
|
|
115
|
+
if response.stop_reason == "refusal":
|
|
116
|
+
raise RuntimeError("model declined (stop_reason=refusal)")
|
|
117
|
+
if response.stop_reason == "max_tokens":
|
|
118
|
+
raise RuntimeError("output hit max_tokens; narrow the input with --task or --limit")
|
|
119
|
+
text = next((b.text for b in response.content if b.type == "text"), "")
|
|
120
|
+
return json.loads(text)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _short(message_id: str) -> str:
|
|
124
|
+
"""session-uuid:12 -> first 8 chars of the session, which is enough to find it."""
|
|
125
|
+
session, _, n = message_id.rpartition(":")
|
|
126
|
+
return f"{session[:8]}:{n}"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def render(rules: list[dict], n_corrections: int) -> str:
|
|
130
|
+
broken = [r for r in rules if r["covered_by"]]
|
|
131
|
+
new = [r for r in rules if not r["covered_by"]]
|
|
132
|
+
out = [f"# Rules from {n_corrections} corrections", ""]
|
|
133
|
+
out.append("Each rule is backed by at least the number of corrections shown. Read the evidence ids "
|
|
134
|
+
"in your own data before adopting a rule; the model drafted these, you decide.")
|
|
135
|
+
|
|
136
|
+
def block(title, note, group):
|
|
137
|
+
if not group:
|
|
138
|
+
return
|
|
139
|
+
out.extend(["", f"## {title}", "", note, ""])
|
|
140
|
+
for r in group:
|
|
141
|
+
out.append(f"- **{r['rule']}** ")
|
|
142
|
+
out.append(f" {r['why']} ({r['task']}, {r['support']} corrections)")
|
|
143
|
+
if r["covered_by"]:
|
|
144
|
+
out.append(f" You already have: \"{r['covered_by']}\"")
|
|
145
|
+
shown = [_short(i) for i in r["ids"][:8]]
|
|
146
|
+
out.append(f" Evidence: {', '.join(shown)}" + (" …" if len(r["ids"]) > 8 else ""))
|
|
147
|
+
|
|
148
|
+
block("Rules you already have that keep getting broken",
|
|
149
|
+
"A rule that exists and still gets corrected isn't working. Make it more specific, "
|
|
150
|
+
"move it somewhere the agent reads at the right moment, or turn it into a check.", broken)
|
|
151
|
+
block("New rules to consider", "Recurring corrections with no rule behind them yet.", new)
|
|
152
|
+
if not rules:
|
|
153
|
+
out.extend(["", "No pattern reached the minimum support. Try more data or a lower --min-support."])
|
|
154
|
+
return "\n".join(out) + "\n"
|
pushback/stats.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Small, dependency-free statistics: Wilson intervals and the audit-corrected estimate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def wilson(k: int, n: int, z: float = 1.96) -> tuple[float, float]:
|
|
9
|
+
if n == 0:
|
|
10
|
+
return (0.0, 0.0)
|
|
11
|
+
p = k / n
|
|
12
|
+
denom = 1 + z * z / n
|
|
13
|
+
centre = p + z * z / (2 * n)
|
|
14
|
+
half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n))
|
|
15
|
+
return ((centre - half) / denom, (centre + half) / denom)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def corrected_count(
|
|
19
|
+
flagged: int, unflagged: int,
|
|
20
|
+
audit_pos_yes: int, audit_pos_n: int,
|
|
21
|
+
audit_neg_yes: int, audit_neg_n: int,
|
|
22
|
+
) -> dict:
|
|
23
|
+
"""Estimate true corrections from a stratified audit.
|
|
24
|
+
|
|
25
|
+
The audit samples separately from messages the model flagged and messages it
|
|
26
|
+
did not. Precision comes from the flagged sample, the miss rate from the
|
|
27
|
+
unflagged one, and each is scaled back to its stratum's size:
|
|
28
|
+
|
|
29
|
+
true ~= flagged * precision + unflagged * miss_rate
|
|
30
|
+
"""
|
|
31
|
+
precision = audit_pos_yes / audit_pos_n if audit_pos_n else None
|
|
32
|
+
miss = audit_neg_yes / audit_neg_n if audit_neg_n else None
|
|
33
|
+
if precision is None or miss is None:
|
|
34
|
+
return {"precision": precision, "miss_rate": miss, "estimate": None, "low": None, "high": None}
|
|
35
|
+
p_lo, p_hi = wilson(audit_pos_yes, audit_pos_n)
|
|
36
|
+
m_lo, m_hi = wilson(audit_neg_yes, audit_neg_n)
|
|
37
|
+
est = flagged * precision + unflagged * miss
|
|
38
|
+
# Conservative bounds: combine the interval ends of both strata.
|
|
39
|
+
return {
|
|
40
|
+
"precision": precision,
|
|
41
|
+
"miss_rate": miss,
|
|
42
|
+
"estimate": est,
|
|
43
|
+
"low": flagged * p_lo + unflagged * m_lo,
|
|
44
|
+
"high": flagged * p_hi + unflagged * m_hi,
|
|
45
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pushback
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: How often do you correct your coding agent, and at what kind of work? Measured from your own Claude Code transcripts.
|
|
5
|
+
Author: Intikhab Azam
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/intikhab49/pushback
|
|
8
|
+
Project-URL: Repository, https://github.com/intikhab49/pushback
|
|
9
|
+
Project-URL: Issues, https://github.com/intikhab49/pushback/issues
|
|
10
|
+
Keywords: claude-code,coding-agents,ai-agents,llm,claude,claude-md,dpo,preference-data,llm-evaluation
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Topic :: Software Development
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: anthropic>=1.0
|
|
21
|
+
Provides-Extra: test
|
|
22
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# pushback
|
|
26
|
+
|
|
27
|
+
How often do you correct your coding agent, and on what kind of work?
|
|
28
|
+
|
|
29
|
+
`pushback` reads your Claude Code transcripts, labels every message you sent
|
|
30
|
+
with Claude, and tells you what share of your messages were corrections,
|
|
31
|
+
broken down by the kind of work in progress (code, writing, media, research,
|
|
32
|
+
ops, meta). A hand-check step tells you how far to trust the labels.
|
|
33
|
+
|
|
34
|
+
On the author's own 1,633 messages:
|
|
35
|
+
|
|
36
|
+
| task | messages | corrections | rate | 95% CI |
|
|
37
|
+
|---|---:|---:|---:|---|
|
|
38
|
+
| media | 114 | 49 | 43.0% | 34.3%–52.2% |
|
|
39
|
+
| writing | 429 | 106 | 24.7% | 20.9%–29.0% |
|
|
40
|
+
| research | 188 | 13 | 6.9% | 4.1%–11.5% |
|
|
41
|
+
| code | 340 | 20 | 5.9% | 3.8%–8.9% |
|
|
42
|
+
| meta | 297 | 14 | 4.7% | 2.8%–7.8% |
|
|
43
|
+
| ops | 265 | 12 | 4.5% | 2.6%–7.7% |
|
|
44
|
+
|
|
45
|
+
Against 91 hand-checked messages the labels had precision 0.97 and recall 0.80.
|
|
46
|
+
That's one person's logs. Run it on yours.
|
|
47
|
+
|
|
48
|
+
These rates describe a workflow, not a model. The author's code work runs
|
|
49
|
+
through skills, reference files, memory and a plan before the agent writes
|
|
50
|
+
anything, and CI catches mistakes before a human has to. Writing got none of
|
|
51
|
+
that. A low rate means the process around the agent is doing its job.
|
|
52
|
+
|
|
53
|
+
## Run it
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
pip install pushback # or, from a clone: pip install -e .
|
|
57
|
+
pushback extract # reads ~/.claude/projects, writes ./pushback-data/
|
|
58
|
+
pushback label # sends your messages to Claude, resumable
|
|
59
|
+
pushback audit # hand-check 40 messages
|
|
60
|
+
pushback report --markdown # the table, with the audit folded in
|
|
61
|
+
pushback rules # draft CLAUDE.md rules from your recurring corrections
|
|
62
|
+
pushback export # your corrections as prompt/chosen/rejected pairs
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`label` uses the Anthropic SDK, so it picks up `ANTHROPIC_API_KEY` or an
|
|
66
|
+
`ant auth login` profile. It defaults to `claude-opus-5` at low effort. Change
|
|
67
|
+
it with `--model`. To send through a gateway, set `ANTHROPIC_BASE_URL`.
|
|
68
|
+
|
|
69
|
+
## Turn recurring corrections into rules
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
pushback rules # drafts rules into pushback-data/rules.md
|
|
73
|
+
pushback rules --existing CLAUDE.md notes/*.md # also check against the rules you already have
|
|
74
|
+
pushback rules --prompt-file # no API key? writes the request to a file instead
|
|
75
|
+
pushback rules --from-response reply.json # ...and reads Claude's answer back
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The model groups corrections that share a cause and drafts one CLAUDE.md
|
|
79
|
+
instruction per group. Support is counted from the correction ids it cites,
|
|
80
|
+
not from its own numbers, and a rule needs at least 3 real corrections.
|
|
81
|
+
|
|
82
|
+
When you pass your existing rule files, the output splits in two:
|
|
83
|
+
|
|
84
|
+
- **Rules you already have that keep getting broken.** These are the useful
|
|
85
|
+
ones. A rule that exists and still gets corrected isn't working: it's too
|
|
86
|
+
vague, or the agent doesn't read it at the right moment.
|
|
87
|
+
- **New rules to consider.** Recurring corrections with no rule behind them.
|
|
88
|
+
|
|
89
|
+
No API key: `--prompt-file` writes the whole request to `rules-prompt.md`.
|
|
90
|
+
Ask Claude Code to answer it, save the JSON reply, then run `--from-response`.
|
|
91
|
+
|
|
92
|
+
## Export your corrections as preference pairs
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
pushback export # all pairs -> pushback-data/dpo.jsonl
|
|
96
|
+
pushback export --ctype writing_content tone_style # the cleanest pairs
|
|
97
|
+
pushback export --minimal # only prompt/chosen/rejected (TRL DPO columns)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Each correction you made becomes one row:
|
|
101
|
+
|
|
102
|
+
- `prompt`: your message the agent was answering
|
|
103
|
+
- `rejected`: the agent turn you corrected
|
|
104
|
+
- `chosen`: the agent's reply to your correction, kept only if your next message wasn't another correction
|
|
105
|
+
- `feedback`: the correction itself, for critique-and-revise formats
|
|
106
|
+
|
|
107
|
+
On the author's logs, 214 corrections gave 124 pairs. The biggest loss:
|
|
108
|
+
70 corrections (a third) had their fix corrected too, so there was no
|
|
109
|
+
accepted answer to pair with.
|
|
110
|
+
|
|
111
|
+
What to know before you train on it:
|
|
112
|
+
|
|
113
|
+
- "You didn't correct it again" is weak evidence that the fix was good.
|
|
114
|
+
- The fix was written after seeing your feedback. For corrections of a wrong
|
|
115
|
+
assumption ("I already sent that"), `chosen` answers the correction rather
|
|
116
|
+
than the prompt. Those make poor DPO pairs, so filter with `--ctype`.
|
|
117
|
+
- Only agent text is exported. File edits and commands are tool calls, so a
|
|
118
|
+
code correction's pair may hold the explanation without the diff. Use
|
|
119
|
+
`--max-tools` to drop tool-heavy turns.
|
|
120
|
+
- Secrets are scrubbed on a best-effort basis (API key shapes, tokens,
|
|
121
|
+
`NAME_KEY=value`). Read the file before you use it.
|
|
122
|
+
- Your transcripts probably contain other people's information. Keep the
|
|
123
|
+
export local unless every conversation in it is yours to share.
|
|
124
|
+
- If the agent is Claude, Anthropic's terms restrict using its outputs to
|
|
125
|
+
build competing models. Treat this as a personal dataset or eval set.
|
|
126
|
+
|
|
127
|
+
## What counts as a correction
|
|
128
|
+
|
|
129
|
+
A message where you reject, fix or redirect something the agent just did,
|
|
130
|
+
said, wrote or proposed. New tasks, answers to its questions, picking between
|
|
131
|
+
its options, approvals and pasted logs don't count. The full rubric is in
|
|
132
|
+
`src/pushback/prompt.py`. If you change it, run `audit` again.
|
|
133
|
+
|
|
134
|
+
Rejected tool calls and interrupts carry no text, so they're counted
|
|
135
|
+
separately and bucketed by the action you stopped (a code edit, a shell
|
|
136
|
+
command, and so on).
|
|
137
|
+
|
|
138
|
+
## Privacy
|
|
139
|
+
|
|
140
|
+
- `extract` and `report` never leave your machine. `report` prints counts only.
|
|
141
|
+
- `label` sends each message, plus the end of the agent reply before it, to the
|
|
142
|
+
model provider. If your logs contain client work, check that provider's data
|
|
143
|
+
policy first. `label` asks before it sends anything.
|
|
144
|
+
- `pushback-data/` holds your raw messages. It's in `.gitignore`. Keep it there.
|
|
145
|
+
|
|
146
|
+
## Your history is shorter than you think
|
|
147
|
+
|
|
148
|
+
Claude Code deletes transcripts older than 30 days by default. To keep more,
|
|
149
|
+
set `cleanupPeriodDays` in `~/.claude/settings.json`, for example
|
|
150
|
+
`"cleanupPeriodDays": 365`. It only affects transcripts that still exist.
|
|
151
|
+
|
|
152
|
+
## How the numbers are computed
|
|
153
|
+
|
|
154
|
+
- Rates per task come with Wilson 95% intervals.
|
|
155
|
+
- `audit` samples half from messages the model flagged and half from the rest.
|
|
156
|
+
The report estimates the true count as
|
|
157
|
+
`flagged × precision + unflagged × miss rate`, with a range built from both
|
|
158
|
+
strata's intervals.
|
|
159
|
+
- A batch that fails stays unlabelled and is retried on the next run. It's
|
|
160
|
+
never counted as "not a correction".
|
|
161
|
+
|
|
162
|
+
## Limits
|
|
163
|
+
|
|
164
|
+
- Claude Code transcripts only, for now.
|
|
165
|
+
- Task type is judged from the last agent reply and your message, not the whole session.
|
|
166
|
+
- The rubric was tuned on one person's logs.
|
|
167
|
+
|
|
168
|
+
MIT licensed.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
pushback/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
|
|
2
|
+
pushback/audit.py,sha256=-1K939SLre8IletzqlMts0iGSxNjxJQFOMXnzaszcwI,2249
|
|
3
|
+
pushback/cli.py,sha256=f__k1j3yz9dVmPvrlkNMoGp3F8kFvrEnmtUPKLsQ66U,11163
|
|
4
|
+
pushback/export.py,sha256=PKuzwe6kj2tlyAvplPgSjhx3_WwykU3nI0Ug0hcjrf8,4955
|
|
5
|
+
pushback/extract.py,sha256=j-SS3lyW9H4SlbTDKAoSCTBPyMfvQeTAkizwscUpEGs,5899
|
|
6
|
+
pushback/label.py,sha256=0a9zRAC57725BMPxQPgsNXeRae57-q_uZPkccJ-x9pg,4483
|
|
7
|
+
pushback/prompt.py,sha256=y11dy6oGhv07t8S3WJL2hOOQa6Y2jFowu6v_y0vqvHI,3507
|
|
8
|
+
pushback/report.py,sha256=LM0lBLLEpvBMuKJKjpBL0ahmKlZL0wDTRzauzSQMtC4,3488
|
|
9
|
+
pushback/rules.py,sha256=NvhqFtjLyeleub43D89v6KnjBYrpIKaO6RNGH6EMh6A,6908
|
|
10
|
+
pushback/stats.py,sha256=LgonOTb0d4qbdV7_hiBiy22TzCRvttiLuyjTGuzhs_0,1660
|
|
11
|
+
pushback-0.1.0.dist-info/licenses/LICENSE,sha256=06Uabh9RYOxqqsXpZVspeFKTQ1dOjWytufNmNiG9Qvg,1070
|
|
12
|
+
pushback-0.1.0.dist-info/METADATA,sha256=Y2xjagII8L0hiqjWMRDPKfFpA2oY6iFqMq8QqWLTL3U,7750
|
|
13
|
+
pushback-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
14
|
+
pushback-0.1.0.dist-info/entry_points.txt,sha256=fH1HzcrKGg0OvRNi7Z99ixoQehjS5DoA5ZFfO5MsJCA,47
|
|
15
|
+
pushback-0.1.0.dist-info/top_level.txt,sha256=BYG9bCjnor0P-1h5upRN9oACJaNVSV4mP99DR0aeg00,9
|
|
16
|
+
pushback-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Intikhab Azam
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pushback
|