goldlabel 0.2.1__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.
@@ -0,0 +1,339 @@
1
+ Metadata-Version: 2.3
2
+ Name: goldlabel
3
+ Version: 0.2.1
4
+ Summary: Content analysis over a corpus (semantic search, keywords or the whole corpus, then an LLM classifier checked against human labels), as a Python library.
5
+ Author: Chris Hacker
6
+ Author-email: Chris Hacker <c.m.hacker1997@gmail.com>
7
+ Requires-Dist: pydantic-ai-slim[anthropic,openai]
8
+ Requires-Dist: numpy
9
+ Requires-Dist: genai-prices
10
+ Requires-Dist: pyyaml
11
+ Requires-Dist: pydantic-ai-slim[sentence-transformers] ; extra == 'local'
12
+ Requires-Dist: psycopg[binary] ; extra == 'pg'
13
+ Requires-Dist: psycopg[binary] ; extra == 'rds'
14
+ Requires-Dist: boto3 ; extra == 'rds'
15
+ Requires-Dist: boto3 ; extra == 's3'
16
+ Requires-Python: >=3.12
17
+ Provides-Extra: local
18
+ Provides-Extra: pg
19
+ Provides-Extra: rds
20
+ Provides-Extra: s3
21
+ Description-Content-Type: text/markdown
22
+
23
+ # goldlabel
24
+
25
+ A Python library for content analysis over a corpus: candidates come from semantic search with seed phrases,
26
+ from keywords or regular expressions, or from the whole corpus; an LLM classifier checked against a
27
+ hand-labelled gold set then produces classified output.
28
+
29
+ You build the analysis step by step in a Jupyter notebook (or a plain script), alone or with a coding
30
+ agent, by chaining function calls whose results display themselves. The one thing that needs a screen of
31
+ its own, labelling the gold set, is a small local web page that a function call opens. There is no
32
+ application, no console script and no stored status: the notebook is the record of what you did, and the
33
+ state is plain files.
34
+
35
+ ## Install
36
+
37
+ ```
38
+ pip install goldlabel
39
+ pip install "goldlabel[s3]" # boto3, for an S3 Vectors corpus
40
+ pip install "goldlabel[pg]" # psycopg, for a pgvector (PostgreSQL) corpus
41
+ pip install "goldlabel[rds]" # psycopg and boto3, for RDS / Aurora IAM database authentication
42
+ pip install "goldlabel[local]" # Sentence Transformers, for local query embeddings (Python < 3.14)
43
+ ```
44
+
45
+ Python 3.12 or newer. Tables in the examples are polars (`pip install polars`); every table also has
46
+ `.to_pandas()`, and `.rows` is always a plain list of dicts.
47
+
48
+ ## The whole chain
49
+
50
+ Run it cell by cell in a notebook. [`examples/sample/walkthrough.ipynb`](examples/sample/walkthrough.ipynb)
51
+ (and the same cells as a script, `walkthrough.py`) does exactly this over a tiny corpus that ships with the
52
+ repository.
53
+
54
+ **1. Candidates.** `get_candidates` takes a corpus and returns the items to classify. With seeds (phrases a
55
+ matching item could say) it is a semantic search and each item keeps its best similarity.
56
+
57
+ ```python
58
+ import goldlabel as gl
59
+
60
+ corpus = gl.PgCorpus(
61
+ table="posts", text_column="body"
62
+ ) # or gl.S3Corpus(...), gl.Table("posts.parquet", ...)
63
+ seeds = gl.read_seeds("seeds.csv") # or a plain list of strings
64
+ candidates = gl.get_candidates(
65
+ corpus, seeds=seeds, embedder="openai:text-embedding-3-small"
66
+ )
67
+ candidates # count, similarity bands, a warning if the backend capped the results
68
+ candidates.to_polars()
69
+ ```
70
+
71
+ Semantic search is one of three ways to get candidates, and the only one that needs embeddings. With no
72
+ filter you get the whole corpus; with `keywords=` you get the items that contain any term, ranked.
73
+
74
+ ```python
75
+ import re
76
+
77
+ everything = gl.get_candidates(corpus) # every item, in the corpus's order
78
+ hits = gl.get_candidates(
79
+ corpus,
80
+ keywords=["ballot", re.compile(r"elect(ion|ed)", re.IGNORECASE)],
81
+ limit=500, # keep the best 500
82
+ )
83
+ hits.to_polars() # id, text, terms_matched (distinct terms), matches (total)
84
+ ```
85
+
86
+ One filter per call: `seeds=` or `keywords=`, not both. A plain string is a literal, case-insensitive
87
+ term; a compiled pattern keeps its own flags and each term is searched separately; `regex=True` makes the
88
+ strings patterns, written as they are. Hits rank by distinct terms matched, then total matches, then
89
+ corpus order. A pattern that matches the empty string is refused, but zero-width patterns such as `\b`
90
+ still match every word. Keyword and whole-corpus lists have no similarity scores, so `above`, `bands`,
91
+ `top_seeds` and `band_sample` do not apply to them and say so.
92
+
93
+ On a `PgCorpus`, `server_side=True` lets the database do the matching, which suits a table too large to
94
+ read: plain terms use `ILIKE`, and with `regex=True` strings are sent as they are and use Postgres syntax
95
+ (no compiled patterns). Ranking and `limit` still happen in the library, and it warns when its own count
96
+ of a returned row disagrees with the server's. Rows are read in ascending id order; an `S3Corpus` is read in the order the
97
+ service lists it (the order is the service's, so a `limit` there is not repeatable).
98
+
99
+ **Your own table.** `gl.Table` takes a `.csv`, `.parquet` or `.jsonl` file, a list of row dicts, or a pandas
100
+ or polars DataFrame. Embeddings are only needed for seed search.
101
+
102
+ ```python
103
+ corpus = gl.Table("posts.parquet", text_column="body", id_column="post_id")
104
+
105
+ # for seeds= as well: a column with one list of numbers per row, and the model that made them
106
+ corpus = gl.Table(
107
+ "posts.parquet",
108
+ text_column="body",
109
+ id_column="post_id",
110
+ vector_column="embedding",
111
+ embedding_model="openai:text-embedding-3-small",
112
+ )
113
+ ```
114
+
115
+ The text column holds a non-empty string in every row. `id_column` is optional (the row's position is the
116
+ id) and its values must be unique. A CSV cannot hold the `vector_column`; use Parquet, JSONL or a DataFrame
117
+ for that. Nothing is dropped or repaired: the first bad row is named in the error.
118
+
119
+ **2. Labels and a prompt.**
120
+
121
+ ```python
122
+ taxonomy = gl.Taxonomy(
123
+ mode="multi", # or "single"; off_topic is built in
124
+ labels={
125
+ "scam": "Offers a fake prize or asks for money first.",
126
+ "satire": "A joke.",
127
+ },
128
+ )
129
+ clf = gl.Classifier(
130
+ "anthropic:claude-haiku-4-5", taxonomy, prompt=open("prompt.md").read()
131
+ )
132
+ ```
133
+
134
+ **3. Gold: people label.** `draw` writes pending rows with no labels. `gl.label` opens the labelling page
135
+ and returns when the person presses **Done**.
136
+
137
+ ```python
138
+ gold = gl.Gold() # .goldlabel/gold.jsonl
139
+ gold.draw(candidates, n=50, split="dev")
140
+ gl.label(gold, taxonomy)
141
+ ```
142
+
143
+ **4. Evaluate and tune.** Evaluating the same classifier on the same rows again makes no model calls; a
144
+ changed prompt costs one call per row. Tune on the dev split.
145
+
146
+ ```python
147
+ ev = gl.evaluate(clf, gold.dev, target=0.9)
148
+ ev # metrics, per-label table, PASS/FAIL against the target, where the labels came from
149
+ ev.disagreements_to_polars() # id, text, gold, predicted, reasoning
150
+
151
+ clf2 = clf.with_prompt(open("prompt.md").read()) # classifiers are immutable: keep both
152
+ ev2 = gl.evaluate(clf2, gold.dev, target=0.9)
153
+ ```
154
+
155
+ **5. The test split, once.**
156
+
157
+ ```python
158
+ gold.draw(candidates, n=50, split="test")
159
+ gl.label(gold, taxonomy)
160
+ final = gl.evaluate(clf2, gold.test, target=0.9)
161
+ ```
162
+
163
+ **6. Choose a cutoff, price the run, run it.**
164
+
165
+ ```python
166
+ report = gl.band_sample(
167
+ clf2, candidates, per_band=30
168
+ ) # off-topic rate per similarity band
169
+ chosen = candidates.above(0.70) # the cutoff is yours
170
+ est = clf2.estimate(chosen) # calls, dollars or "?", seconds; calls no model
171
+ results = clf2.classify(chosen, out="results.jsonl", max_cost=25.0)
172
+ results.to_polars()
173
+ ```
174
+
175
+ `max_cost` refuses before spending anything when the estimate is over it, and also when the model's price
176
+ is unknown. An unknown price is shown as `cost ?` with a warning, never as `$0`. `out=` makes the run
177
+ resumable: run the cell again and only the missing rows are classified.
178
+
179
+ **7. Keep the classifier.** See [Reusing a classifier](#reusing-a-classifier).
180
+
181
+ ## Where files go
182
+
183
+ One rule everywhere: a path argument beats the setting beats the default. The default root is `.goldlabel/` in
184
+ the current directory. Change it for the process with `gl.settings.root = Path("elsewhere")` (a `pathlib.Path`) or set
185
+ `GOLDLABEL_DIR`.
186
+
187
+ | File | Written by | Default |
188
+ |---|---|---|
189
+ | `candidates.jsonl` (+ `.meta.json`) | `gl.get_candidates` | seed searches only: `.goldlabel/candidates.jsonl`; `out=` moves it (and writes keyword and whole-corpus lists, which are otherwise kept in memory), `out=False` keeps a seed search in memory |
190
+ | `gold.jsonl` | `gl.Gold()` | `.goldlabel/gold.jsonl`; `gl.Gold("path")` |
191
+ | `evals.jsonl` | `gl.evaluate` | `.goldlabel/evals.jsonl`; `log=` moves it, `log=False` turns it off |
192
+ | `cache/` | model and embedding calls, and the cost ledger | `.goldlabel/cache`; `cache=` moves it, `cache=False` turns it off |
193
+ | `results.jsonl` | `clf.classify(out=...)` | none: a classification writes only where you tell it |
194
+
195
+ Inputs are always explicit: `gl.read_seeds("seeds.csv")`, `gl.Taxonomy.read("taxonomy.yaml")`,
196
+ `gl.Candidates.read("candidates.jsonl")`. `seeds.csv`, `taxonomy.yaml`, `prompt.md` and the notebook are
197
+ yours to commit; the library never runs git. Moving a project is moving its folder. The cache keeps
198
+ paying for itself: re-running a notebook top to bottom, or after a kernel restart, repeats no model call.
199
+
200
+ ## API keys
201
+
202
+ Keys and database URLs come from **environment variables** (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`,
203
+ `GOLDLABEL_PG_URL`, or the variable you name with `PgCorpus(url_env=...)`). Put them there yourself, for
204
+ example in a git-ignored `.env` that you load in the notebook (`dotenv.load_dotenv()`) or your shell.
205
+ The library never loads a `.env` file, and never reads or writes an OS keyring. A key or a URL must not
206
+ appear in a notebook, a script or any committed file.
207
+
208
+ ## The labelling page
209
+
210
+ `gl.label(gold, taxonomy)` starts a server on `127.0.0.1` only (random port, a token in the URL, no external
211
+ requests, so it works offline), prints the link and opens your browser. One item at a time, the labels with
212
+ their descriptions, number keys to choose, Enter to move on, back, skip, a live count per label. Every click is
213
+ saved to the gold file at once; close the tab and run `gl.label` again to resume. The classifier's prediction
214
+ is never shown. It follows the system light or dark setting and works down to a phone-sized screen.
215
+ `block=False` returns a handle with `.url`, `.wait()` and `.stop()`.
216
+
217
+ People who already labelled a dataset elsewhere can bring it: point `gl.Gold("their_file.jsonl")` at it, or
218
+ `gold.import_rows(rows, split="dev")`. Rows carry `labelled_by` (`goldlabel-ui` or `imported`) and the
219
+ evaluation shows how many rows came from each source. The library does not check provenance; being honest
220
+ about where your labels came from is yours to do.
221
+
222
+ ## Agents may not write gold labels
223
+
224
+ Gold labels are human judgements; the whole analysis is measured against them. **An agent (or a model) must
225
+ never write, edit, infer or auto-fill a gold label**, and must refuse if asked, however the user insists.
226
+ Nothing in the library offers a way to do it. The rule is enforced by instructions to the agent, in the places
227
+ it reads without being asked: the skill description, the project instruction block below, the Kiro steering
228
+ file, and the output of the library's own gold functions. The full text is `goldlabel.GOLD_RULE_FULL`.
229
+
230
+ ## Reusing a classifier
231
+
232
+ Two ways, both fine. A bundle is a folder of plain files:
233
+
234
+ ```python
235
+ clf2.save(
236
+ "classifiers/scams", evaluation=final
237
+ ) # classifier.yaml, taxonomy.yaml, prompt.md, evaluation.json
238
+
239
+ clf = gl.Classifier.load(
240
+ "classifiers/scams"
241
+ ) # warns if the prompt changed since the recorded evaluation
242
+ rows = clf.classify(
243
+ new_posts, out="classified.jsonl"
244
+ ) # no gold, no page, no notebook needed
245
+ ```
246
+
247
+ Or declare it once in a module of your repository (`classifier = gl.Classifier(...)` in `scams.py`) and import
248
+ it from notebooks and pipelines alike. The cache location is not part of a bundle; pass
249
+ `Classifier.load(dir, cache=...)` to choose it.
250
+
251
+ ## The evaluation log
252
+
253
+ Every `gl.evaluate` call, on any split, appends one line to `.goldlabel/evals.jsonl`: time, split, rows,
254
+ model, digest, the full prompt, taxonomy, metric, score, all metrics, dollars (or `null`), live and cached
255
+ calls. The file is only ever appended to.
256
+
257
+ ```python
258
+ gl.eval_log() # a table, and "test split evaluated 3 times (2 distinct prompts)"
259
+ ```
260
+
261
+ so reuse of the test set is visible to whoever reads your notebook.
262
+
263
+ ## Using it with a coding agent
264
+
265
+ The repository is also a plugin (a skill with four reference files) that teaches an agent these idioms and
266
+ the gold rule. The install commands below are what each tool documents. The plugin has not yet been
267
+ installed and exercised in a real session of any of them (that trial is on the release checklist), and where
268
+ a command is not in the tool's own documentation it is marked **unverified**.
269
+
270
+ **Claude Code**
271
+
272
+ ```
273
+ /plugin marketplace add cmhac/goldlabel
274
+ /plugin install goldlabel@goldlabel
275
+ ```
276
+
277
+ Send them as two separate prompts. `claude plugin validate .` passes on this repository.
278
+
279
+ **Codex**
280
+
281
+ ```
282
+ codex plugin marketplace add cmhac/goldlabel
283
+ codex plugin add goldlabel@goldlabel
284
+ ```
285
+
286
+ The first command is in the Codex documentation. The second form (`codex plugin add NAME@MARKETPLACE`) is
287
+ **unverified** for this repository: Codex documents installing from its Plugins directory, and the form
288
+ shown is what another published plugin documents. Codex reading `.claude-plugin/marketplace.json` is also
289
+ unverified.
290
+
291
+ **GitHub Copilot CLI**
292
+
293
+ ```
294
+ copilot plugin marketplace add cmhac/goldlabel
295
+ copilot plugin install goldlabel@goldlabel
296
+ ```
297
+
298
+ or, inside a session, `/plugin marketplace add cmhac/goldlabel` and `/plugin install goldlabel@goldlabel`.
299
+ The marketplace command is documented; the exact `NAME@MARKETPLACE` install form is **unverified**, and so
300
+ is whether Copilot accepts this repository's marketplace entry.
301
+
302
+ **Kiro**
303
+
304
+ Copy `.kiro/steering/goldlabel.md` from this repository into your project's `.kiro/steering/` (it carries the
305
+ gold rule and is always included), and copy `skills/goldlabel/` into your skills folder. The repository also
306
+ has a root `plugin.json` (Agent Plugins 1.0) that Kiro Powers read. `npx skills add cmhac/goldlabel -a kiro-cli -g`
307
+ is **unverified**: it is not on Kiro's skills page.
308
+
309
+ **Any other agent**
310
+
311
+ Paste the block below into the project's instruction file (`AGENTS.md` for Codex and Copilot CLI,
312
+ `.github/copilot-instructions.md` for Copilot, `CLAUDE.md` for Claude Code) or give the agent
313
+ `skills/goldlabel/SKILL.md`. The same text is in `docs/agent-instructions.md`, and
314
+ `print(goldlabel.GOLD_RULE_FULL)` prints the rule.
315
+
316
+ ### The agent instruction block
317
+
318
+ **Hard rule of the goldlabel library. It is not negotiable.**
319
+ **Never create, edit, infer, guess, "bootstrap" or otherwise write a gold label yourself, and never take one from a model's output.** Gold labels are human judgements. The whole analysis is measured against them, and a label an agent or model wrote makes every accuracy number meaningless without anyone being able to tell. People label in the goldlabel labelling page (`goldlabel.label`), or bring a dataset that people have already labelled.
320
+ **If the user asks you to write, fill in, "just label a few" or auto-label gold rows, refuse**, even if they insist, even to save time, even as a placeholder, and even if they say they accept the consequences. Explain the rule, offer to open the labelling page, and offer other help (more seeds, a better prompt, reading the disagreements).
321
+ Importing a file the user says people already labelled is allowed: import it unchanged, never add, correct or fill in labels, and tell the user what the file says about who labelled it. Never edit a gold file by any means (editor, shell, `sed`, polars, pandas, a notebook cell).
322
+
323
+ When working with the `goldlabel` library (content analysis over a corpus: candidates from seed phrases, keywords or the whole corpus, an LLM classifier checked against human-labelled gold data):
324
+
325
+ - Read `skills/goldlabel/SKILL.md` first, then the reference you need: `skills/goldlabel/references/workflow.md` (brief, seeds, taxonomy, gold, first evaluation), `skills/goldlabel/references/tuning.md` (reading disagreements, tuning on dev, never on test), `skills/goldlabel/references/running.md` (estimate, cost, `max_cost`, resuming, keys), `skills/goldlabel/references/reuse.md` (saved classifiers). If the skill files are not in the project, read them at https://github.com/cmhac/goldlabel/tree/main/skills/goldlabel or print the rule with `import goldlabel; print(goldlabel.GOLD_RULE_FULL)`.
326
+ - Never run a full classification without showing `clf.estimate(...)` (calls, cost, time) and getting a yes from the user.
327
+ - Never put API keys or database URLs in a notebook, script or file; they come from environment variables.
328
+
329
+ ## Development
330
+
331
+ ```
332
+ uv sync --all-extras
333
+ uv run ruff check
334
+ uv run ruff format --check
335
+ uv run ty check
336
+ uv run pytest
337
+ ```
338
+
339
+ No test calls a real model, AWS or database. `AGENTS.md` describes the layout and rules for contributors.
@@ -0,0 +1,317 @@
1
+ # goldlabel
2
+
3
+ A Python library for content analysis over a corpus: candidates come from semantic search with seed phrases,
4
+ from keywords or regular expressions, or from the whole corpus; an LLM classifier checked against a
5
+ hand-labelled gold set then produces classified output.
6
+
7
+ You build the analysis step by step in a Jupyter notebook (or a plain script), alone or with a coding
8
+ agent, by chaining function calls whose results display themselves. The one thing that needs a screen of
9
+ its own, labelling the gold set, is a small local web page that a function call opens. There is no
10
+ application, no console script and no stored status: the notebook is the record of what you did, and the
11
+ state is plain files.
12
+
13
+ ## Install
14
+
15
+ ```
16
+ pip install goldlabel
17
+ pip install "goldlabel[s3]" # boto3, for an S3 Vectors corpus
18
+ pip install "goldlabel[pg]" # psycopg, for a pgvector (PostgreSQL) corpus
19
+ pip install "goldlabel[rds]" # psycopg and boto3, for RDS / Aurora IAM database authentication
20
+ pip install "goldlabel[local]" # Sentence Transformers, for local query embeddings (Python < 3.14)
21
+ ```
22
+
23
+ Python 3.12 or newer. Tables in the examples are polars (`pip install polars`); every table also has
24
+ `.to_pandas()`, and `.rows` is always a plain list of dicts.
25
+
26
+ ## The whole chain
27
+
28
+ Run it cell by cell in a notebook. [`examples/sample/walkthrough.ipynb`](examples/sample/walkthrough.ipynb)
29
+ (and the same cells as a script, `walkthrough.py`) does exactly this over a tiny corpus that ships with the
30
+ repository.
31
+
32
+ **1. Candidates.** `get_candidates` takes a corpus and returns the items to classify. With seeds (phrases a
33
+ matching item could say) it is a semantic search and each item keeps its best similarity.
34
+
35
+ ```python
36
+ import goldlabel as gl
37
+
38
+ corpus = gl.PgCorpus(
39
+ table="posts", text_column="body"
40
+ ) # or gl.S3Corpus(...), gl.Table("posts.parquet", ...)
41
+ seeds = gl.read_seeds("seeds.csv") # or a plain list of strings
42
+ candidates = gl.get_candidates(
43
+ corpus, seeds=seeds, embedder="openai:text-embedding-3-small"
44
+ )
45
+ candidates # count, similarity bands, a warning if the backend capped the results
46
+ candidates.to_polars()
47
+ ```
48
+
49
+ Semantic search is one of three ways to get candidates, and the only one that needs embeddings. With no
50
+ filter you get the whole corpus; with `keywords=` you get the items that contain any term, ranked.
51
+
52
+ ```python
53
+ import re
54
+
55
+ everything = gl.get_candidates(corpus) # every item, in the corpus's order
56
+ hits = gl.get_candidates(
57
+ corpus,
58
+ keywords=["ballot", re.compile(r"elect(ion|ed)", re.IGNORECASE)],
59
+ limit=500, # keep the best 500
60
+ )
61
+ hits.to_polars() # id, text, terms_matched (distinct terms), matches (total)
62
+ ```
63
+
64
+ One filter per call: `seeds=` or `keywords=`, not both. A plain string is a literal, case-insensitive
65
+ term; a compiled pattern keeps its own flags and each term is searched separately; `regex=True` makes the
66
+ strings patterns, written as they are. Hits rank by distinct terms matched, then total matches, then
67
+ corpus order. A pattern that matches the empty string is refused, but zero-width patterns such as `\b`
68
+ still match every word. Keyword and whole-corpus lists have no similarity scores, so `above`, `bands`,
69
+ `top_seeds` and `band_sample` do not apply to them and say so.
70
+
71
+ On a `PgCorpus`, `server_side=True` lets the database do the matching, which suits a table too large to
72
+ read: plain terms use `ILIKE`, and with `regex=True` strings are sent as they are and use Postgres syntax
73
+ (no compiled patterns). Ranking and `limit` still happen in the library, and it warns when its own count
74
+ of a returned row disagrees with the server's. Rows are read in ascending id order; an `S3Corpus` is read in the order the
75
+ service lists it (the order is the service's, so a `limit` there is not repeatable).
76
+
77
+ **Your own table.** `gl.Table` takes a `.csv`, `.parquet` or `.jsonl` file, a list of row dicts, or a pandas
78
+ or polars DataFrame. Embeddings are only needed for seed search.
79
+
80
+ ```python
81
+ corpus = gl.Table("posts.parquet", text_column="body", id_column="post_id")
82
+
83
+ # for seeds= as well: a column with one list of numbers per row, and the model that made them
84
+ corpus = gl.Table(
85
+ "posts.parquet",
86
+ text_column="body",
87
+ id_column="post_id",
88
+ vector_column="embedding",
89
+ embedding_model="openai:text-embedding-3-small",
90
+ )
91
+ ```
92
+
93
+ The text column holds a non-empty string in every row. `id_column` is optional (the row's position is the
94
+ id) and its values must be unique. A CSV cannot hold the `vector_column`; use Parquet, JSONL or a DataFrame
95
+ for that. Nothing is dropped or repaired: the first bad row is named in the error.
96
+
97
+ **2. Labels and a prompt.**
98
+
99
+ ```python
100
+ taxonomy = gl.Taxonomy(
101
+ mode="multi", # or "single"; off_topic is built in
102
+ labels={
103
+ "scam": "Offers a fake prize or asks for money first.",
104
+ "satire": "A joke.",
105
+ },
106
+ )
107
+ clf = gl.Classifier(
108
+ "anthropic:claude-haiku-4-5", taxonomy, prompt=open("prompt.md").read()
109
+ )
110
+ ```
111
+
112
+ **3. Gold: people label.** `draw` writes pending rows with no labels. `gl.label` opens the labelling page
113
+ and returns when the person presses **Done**.
114
+
115
+ ```python
116
+ gold = gl.Gold() # .goldlabel/gold.jsonl
117
+ gold.draw(candidates, n=50, split="dev")
118
+ gl.label(gold, taxonomy)
119
+ ```
120
+
121
+ **4. Evaluate and tune.** Evaluating the same classifier on the same rows again makes no model calls; a
122
+ changed prompt costs one call per row. Tune on the dev split.
123
+
124
+ ```python
125
+ ev = gl.evaluate(clf, gold.dev, target=0.9)
126
+ ev # metrics, per-label table, PASS/FAIL against the target, where the labels came from
127
+ ev.disagreements_to_polars() # id, text, gold, predicted, reasoning
128
+
129
+ clf2 = clf.with_prompt(open("prompt.md").read()) # classifiers are immutable: keep both
130
+ ev2 = gl.evaluate(clf2, gold.dev, target=0.9)
131
+ ```
132
+
133
+ **5. The test split, once.**
134
+
135
+ ```python
136
+ gold.draw(candidates, n=50, split="test")
137
+ gl.label(gold, taxonomy)
138
+ final = gl.evaluate(clf2, gold.test, target=0.9)
139
+ ```
140
+
141
+ **6. Choose a cutoff, price the run, run it.**
142
+
143
+ ```python
144
+ report = gl.band_sample(
145
+ clf2, candidates, per_band=30
146
+ ) # off-topic rate per similarity band
147
+ chosen = candidates.above(0.70) # the cutoff is yours
148
+ est = clf2.estimate(chosen) # calls, dollars or "?", seconds; calls no model
149
+ results = clf2.classify(chosen, out="results.jsonl", max_cost=25.0)
150
+ results.to_polars()
151
+ ```
152
+
153
+ `max_cost` refuses before spending anything when the estimate is over it, and also when the model's price
154
+ is unknown. An unknown price is shown as `cost ?` with a warning, never as `$0`. `out=` makes the run
155
+ resumable: run the cell again and only the missing rows are classified.
156
+
157
+ **7. Keep the classifier.** See [Reusing a classifier](#reusing-a-classifier).
158
+
159
+ ## Where files go
160
+
161
+ One rule everywhere: a path argument beats the setting beats the default. The default root is `.goldlabel/` in
162
+ the current directory. Change it for the process with `gl.settings.root = Path("elsewhere")` (a `pathlib.Path`) or set
163
+ `GOLDLABEL_DIR`.
164
+
165
+ | File | Written by | Default |
166
+ |---|---|---|
167
+ | `candidates.jsonl` (+ `.meta.json`) | `gl.get_candidates` | seed searches only: `.goldlabel/candidates.jsonl`; `out=` moves it (and writes keyword and whole-corpus lists, which are otherwise kept in memory), `out=False` keeps a seed search in memory |
168
+ | `gold.jsonl` | `gl.Gold()` | `.goldlabel/gold.jsonl`; `gl.Gold("path")` |
169
+ | `evals.jsonl` | `gl.evaluate` | `.goldlabel/evals.jsonl`; `log=` moves it, `log=False` turns it off |
170
+ | `cache/` | model and embedding calls, and the cost ledger | `.goldlabel/cache`; `cache=` moves it, `cache=False` turns it off |
171
+ | `results.jsonl` | `clf.classify(out=...)` | none: a classification writes only where you tell it |
172
+
173
+ Inputs are always explicit: `gl.read_seeds("seeds.csv")`, `gl.Taxonomy.read("taxonomy.yaml")`,
174
+ `gl.Candidates.read("candidates.jsonl")`. `seeds.csv`, `taxonomy.yaml`, `prompt.md` and the notebook are
175
+ yours to commit; the library never runs git. Moving a project is moving its folder. The cache keeps
176
+ paying for itself: re-running a notebook top to bottom, or after a kernel restart, repeats no model call.
177
+
178
+ ## API keys
179
+
180
+ Keys and database URLs come from **environment variables** (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`,
181
+ `GOLDLABEL_PG_URL`, or the variable you name with `PgCorpus(url_env=...)`). Put them there yourself, for
182
+ example in a git-ignored `.env` that you load in the notebook (`dotenv.load_dotenv()`) or your shell.
183
+ The library never loads a `.env` file, and never reads or writes an OS keyring. A key or a URL must not
184
+ appear in a notebook, a script or any committed file.
185
+
186
+ ## The labelling page
187
+
188
+ `gl.label(gold, taxonomy)` starts a server on `127.0.0.1` only (random port, a token in the URL, no external
189
+ requests, so it works offline), prints the link and opens your browser. One item at a time, the labels with
190
+ their descriptions, number keys to choose, Enter to move on, back, skip, a live count per label. Every click is
191
+ saved to the gold file at once; close the tab and run `gl.label` again to resume. The classifier's prediction
192
+ is never shown. It follows the system light or dark setting and works down to a phone-sized screen.
193
+ `block=False` returns a handle with `.url`, `.wait()` and `.stop()`.
194
+
195
+ People who already labelled a dataset elsewhere can bring it: point `gl.Gold("their_file.jsonl")` at it, or
196
+ `gold.import_rows(rows, split="dev")`. Rows carry `labelled_by` (`goldlabel-ui` or `imported`) and the
197
+ evaluation shows how many rows came from each source. The library does not check provenance; being honest
198
+ about where your labels came from is yours to do.
199
+
200
+ ## Agents may not write gold labels
201
+
202
+ Gold labels are human judgements; the whole analysis is measured against them. **An agent (or a model) must
203
+ never write, edit, infer or auto-fill a gold label**, and must refuse if asked, however the user insists.
204
+ Nothing in the library offers a way to do it. The rule is enforced by instructions to the agent, in the places
205
+ it reads without being asked: the skill description, the project instruction block below, the Kiro steering
206
+ file, and the output of the library's own gold functions. The full text is `goldlabel.GOLD_RULE_FULL`.
207
+
208
+ ## Reusing a classifier
209
+
210
+ Two ways, both fine. A bundle is a folder of plain files:
211
+
212
+ ```python
213
+ clf2.save(
214
+ "classifiers/scams", evaluation=final
215
+ ) # classifier.yaml, taxonomy.yaml, prompt.md, evaluation.json
216
+
217
+ clf = gl.Classifier.load(
218
+ "classifiers/scams"
219
+ ) # warns if the prompt changed since the recorded evaluation
220
+ rows = clf.classify(
221
+ new_posts, out="classified.jsonl"
222
+ ) # no gold, no page, no notebook needed
223
+ ```
224
+
225
+ Or declare it once in a module of your repository (`classifier = gl.Classifier(...)` in `scams.py`) and import
226
+ it from notebooks and pipelines alike. The cache location is not part of a bundle; pass
227
+ `Classifier.load(dir, cache=...)` to choose it.
228
+
229
+ ## The evaluation log
230
+
231
+ Every `gl.evaluate` call, on any split, appends one line to `.goldlabel/evals.jsonl`: time, split, rows,
232
+ model, digest, the full prompt, taxonomy, metric, score, all metrics, dollars (or `null`), live and cached
233
+ calls. The file is only ever appended to.
234
+
235
+ ```python
236
+ gl.eval_log() # a table, and "test split evaluated 3 times (2 distinct prompts)"
237
+ ```
238
+
239
+ so reuse of the test set is visible to whoever reads your notebook.
240
+
241
+ ## Using it with a coding agent
242
+
243
+ The repository is also a plugin (a skill with four reference files) that teaches an agent these idioms and
244
+ the gold rule. The install commands below are what each tool documents. The plugin has not yet been
245
+ installed and exercised in a real session of any of them (that trial is on the release checklist), and where
246
+ a command is not in the tool's own documentation it is marked **unverified**.
247
+
248
+ **Claude Code**
249
+
250
+ ```
251
+ /plugin marketplace add cmhac/goldlabel
252
+ /plugin install goldlabel@goldlabel
253
+ ```
254
+
255
+ Send them as two separate prompts. `claude plugin validate .` passes on this repository.
256
+
257
+ **Codex**
258
+
259
+ ```
260
+ codex plugin marketplace add cmhac/goldlabel
261
+ codex plugin add goldlabel@goldlabel
262
+ ```
263
+
264
+ The first command is in the Codex documentation. The second form (`codex plugin add NAME@MARKETPLACE`) is
265
+ **unverified** for this repository: Codex documents installing from its Plugins directory, and the form
266
+ shown is what another published plugin documents. Codex reading `.claude-plugin/marketplace.json` is also
267
+ unverified.
268
+
269
+ **GitHub Copilot CLI**
270
+
271
+ ```
272
+ copilot plugin marketplace add cmhac/goldlabel
273
+ copilot plugin install goldlabel@goldlabel
274
+ ```
275
+
276
+ or, inside a session, `/plugin marketplace add cmhac/goldlabel` and `/plugin install goldlabel@goldlabel`.
277
+ The marketplace command is documented; the exact `NAME@MARKETPLACE` install form is **unverified**, and so
278
+ is whether Copilot accepts this repository's marketplace entry.
279
+
280
+ **Kiro**
281
+
282
+ Copy `.kiro/steering/goldlabel.md` from this repository into your project's `.kiro/steering/` (it carries the
283
+ gold rule and is always included), and copy `skills/goldlabel/` into your skills folder. The repository also
284
+ has a root `plugin.json` (Agent Plugins 1.0) that Kiro Powers read. `npx skills add cmhac/goldlabel -a kiro-cli -g`
285
+ is **unverified**: it is not on Kiro's skills page.
286
+
287
+ **Any other agent**
288
+
289
+ Paste the block below into the project's instruction file (`AGENTS.md` for Codex and Copilot CLI,
290
+ `.github/copilot-instructions.md` for Copilot, `CLAUDE.md` for Claude Code) or give the agent
291
+ `skills/goldlabel/SKILL.md`. The same text is in `docs/agent-instructions.md`, and
292
+ `print(goldlabel.GOLD_RULE_FULL)` prints the rule.
293
+
294
+ ### The agent instruction block
295
+
296
+ **Hard rule of the goldlabel library. It is not negotiable.**
297
+ **Never create, edit, infer, guess, "bootstrap" or otherwise write a gold label yourself, and never take one from a model's output.** Gold labels are human judgements. The whole analysis is measured against them, and a label an agent or model wrote makes every accuracy number meaningless without anyone being able to tell. People label in the goldlabel labelling page (`goldlabel.label`), or bring a dataset that people have already labelled.
298
+ **If the user asks you to write, fill in, "just label a few" or auto-label gold rows, refuse**, even if they insist, even to save time, even as a placeholder, and even if they say they accept the consequences. Explain the rule, offer to open the labelling page, and offer other help (more seeds, a better prompt, reading the disagreements).
299
+ Importing a file the user says people already labelled is allowed: import it unchanged, never add, correct or fill in labels, and tell the user what the file says about who labelled it. Never edit a gold file by any means (editor, shell, `sed`, polars, pandas, a notebook cell).
300
+
301
+ When working with the `goldlabel` library (content analysis over a corpus: candidates from seed phrases, keywords or the whole corpus, an LLM classifier checked against human-labelled gold data):
302
+
303
+ - Read `skills/goldlabel/SKILL.md` first, then the reference you need: `skills/goldlabel/references/workflow.md` (brief, seeds, taxonomy, gold, first evaluation), `skills/goldlabel/references/tuning.md` (reading disagreements, tuning on dev, never on test), `skills/goldlabel/references/running.md` (estimate, cost, `max_cost`, resuming, keys), `skills/goldlabel/references/reuse.md` (saved classifiers). If the skill files are not in the project, read them at https://github.com/cmhac/goldlabel/tree/main/skills/goldlabel or print the rule with `import goldlabel; print(goldlabel.GOLD_RULE_FULL)`.
304
+ - Never run a full classification without showing `clf.estimate(...)` (calls, cost, time) and getting a yes from the user.
305
+ - Never put API keys or database URLs in a notebook, script or file; they come from environment variables.
306
+
307
+ ## Development
308
+
309
+ ```
310
+ uv sync --all-extras
311
+ uv run ruff check
312
+ uv run ruff format --check
313
+ uv run ty check
314
+ uv run pytest
315
+ ```
316
+
317
+ No test calls a real model, AWS or database. `AGENTS.md` describes the layout and rules for contributors.