feat(llm): retune tags from stored verdicts when only parameters change
Some checks failed
CI / lint (push) Successful in 1m27s
CI / notebooks-smoke (push) Successful in 2m5s
Deploy / changes (push) Successful in 23s
CI / test (push) Failing after 4m56s
Infra CI / notebooks (push) Successful in 1m13s
Infra CI / zotero (push) Successful in 27s
Infra CI / docs (push) Successful in 38s
Infra CI / api (push) Successful in 1m43s
Infra CI / bcda (push) Successful in 2m22s
Infra CI / mc (push) Successful in 32s
Infra CI / llm (push) Successful in 1m42s
Deploy / zotero (push) Has been skipped
Deploy / report (push) Has been cancelled
Deploy / api (push) Has been cancelled
Deploy / bcda (push) Has been cancelled
Deploy / llm (push) Has been cancelled
Deploy / mc (push) Has been cancelled
Deploy / notebooks (push) Has been cancelled
Deploy / docs (push) Has been cancelled

Tag state now records the parameters it ran with (top, max_tags,
min_confidence). On unchanged content with other parameters, run()
re-derives the tags reusing every stored judge verdict: a threshold
change costs no model calls, a wider shortlist pays only for its new
candidates. Same content and same parameters still skip. The 315k
stored states carry their full judged shortlists, so a new threshold
applies corpus-wide without the GPU.
This commit is contained in:
kert
2026-11-11 15:29:43 -05:00
parent bef31d3856
commit 33cd44519d
4 changed files with 106 additions and 9 deletions

View File

@@ -341,7 +341,7 @@ def tag(
)
typer.echo(
f"tag {docket} (vocab v{rt['vocab'].version}, {rt['model']}{', dry run' if dry_run else ''}): "
f"seen={stats['seen']} tagged={stats['tagged']} unchanged={stats['skipped_state']} "
f"seen={stats['seen']} tagged={stats['tagged']} retuned={stats.get('retuned', 0)} unchanged={stats['skipped_state']} "
f"no_chunks={stats['no_chunks']} tags_written={stats['tags_written']}"
)

View File

@@ -151,14 +151,23 @@ def tag_comment(
top: int = 8,
max_tags: int = 3,
min_confidence: float = 0.60,
prior: Mapping[str, bool | None] | None = None,
) -> TagResult:
"""Shortlist, judge, keep up to *max_tags* judged-yes themes at or
above *min_confidence*. *prior* holds verdicts already given on this
exact content (same model and vocab); those candidates are not judged
again, so a threshold change costs no model calls and a wider
shortlist pays only for its new candidates."""
cands = shortlist(chunk_vecs, card_vecs, top=top)
judged: dict[str, bool | None] = {}
tags: dict[str, TagInfo] = {}
for c in cands:
if c.slug not in vocab:
continue
verdict = judge(vocab.get(c.slug), chunks[c.chunk_idx])
if prior is not None and c.slug in prior:
verdict = prior[c.slug]
else:
verdict = judge(vocab.get(c.slug), chunks[c.chunk_idx])
judged[c.slug] = verdict
if verdict and c.score >= min_confidence and len(tags) < max_tags:
tags[c.slug] = TagInfo(confidence=c.score, evidence_chunk=c.chunk_idx)
@@ -176,10 +185,17 @@ def state_matches(
)
def state_payload(result: TagResult, *, version: int, model: str) -> dict:
def state_payload(
result: TagResult,
*,
version: int,
model: str,
params: Mapping[str, Any] | None = None,
) -> dict:
return {
"version": version,
"model": model,
"params": dict(params or {}),
"content_hash": result.content_hash,
"tagged_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"tags": {s: asdict(i) for s, i in result.tags.items()},
@@ -260,9 +276,11 @@ def run(
"""Tag every comment in *docket* (newest first); resumable via the
item's stored state; ``dry_run`` computes and reports but writes
nothing."""
params = {"top": top, "max_tags": max_tags, "min_confidence": min_confidence}
stats = {
"seen": 0,
"tagged": 0,
"retuned": 0,
"skipped_state": 0,
"no_chunks": 0,
"tags_written": 0,
@@ -279,13 +297,25 @@ def run(
rows = sorted(rows, key=lambda r: r[0])
texts = [t for _, t, _ in rows]
digest = content_hash(texts)
if not force and state_matches(
same_content = not force and state_matches(
extra, version=vocab.version, model=model, digest=digest
):
)
stored = extra.get(STATE_KEY) or {}
if same_content and stored.get("params") == params:
stats["skipped_state"] += 1
if progress:
progress(key, None, "unchanged")
continue
# Same content, other parameters: reuse every verdict already given.
prior = (
{
x["slug"]: x.get("verdict")
for x in stored.get("shortlist") or []
if x.get("verdict") is not None
}
if same_content
else None
)
result = tag_comment(
key,
texts,
@@ -296,17 +326,22 @@ def run(
top=top,
max_tags=max_tags,
min_confidence=min_confidence,
prior=prior,
)
stats["tagged"] += 1
stats["retuned" if prior is not None else "tagged"] += 1
if not dry_run:
store.merge_extra(
key,
{STATE_KEY: state_payload(result, version=vocab.version, model=model)},
{
STATE_KEY: state_payload(
result, version=vocab.version, model=model, params=params
)
},
)
added, _removed = apply_tags(store, key, result.slugs, vocab=vocab)
stats["tags_written"] += added
if progress:
progress(key, result, "tagged")
progress(key, result, "retuned" if prior is not None else "tagged")
return stats

View File

@@ -67,7 +67,10 @@ class TestTag:
)
assert res.exit_code == 0, res.output
assert f"{key} telehealth" in res.output
assert "seen=1 tagged=1 unchanged=0 no_chunks=0 tags_written=1" in res.output
assert (
"seen=1 tagged=1 retuned=0 unchanged=0 no_chunks=0 tags_written=1"
in res.output
)
assert (
"llm:telehealth" in store.get(key).tags
and "llm:drugs" not in store.get(key).tags

View File

@@ -370,6 +370,7 @@ class TestRun:
assert stats == {
"seen": 2,
"tagged": 1,
"retuned": 0,
"skipped_state": 0,
"no_chunks": 1,
"tags_written": 2,
@@ -395,6 +396,7 @@ class TestRun:
judge=judge,
model="m",
top=2,
max_tags=2,
)
assert (
stats2["skipped_state"] == 1 and stats2["tagged"] == 0 and judge_calls == []
@@ -445,3 +447,60 @@ class TestRun:
k = keys["CMS-2026-2377-1"]
assert not any(t.startswith("llm:") for t in s.get(k).tags)
s.close()
class TestRetune:
"""A parameter change on unchanged content reuses stored verdicts."""
def _run(self, s, keys, judge, **kw):
return run(
s,
vocab=VOCAB,
docket="CMS-2026-2377",
load_chunks=TestRun()._chunks(keys),
card_vecs=CARDS,
judge=judge,
model="m",
**kw,
)
def test_threshold_change_rederives_tags_without_judging(self, tmp_path):
s, keys = _store(tmp_path)
calls = []
def judge(theme, evidence):
calls.append(theme.slug)
return True
self._run(s, keys, judge, top=2, max_tags=2, min_confidence=1.01)
k = keys["CMS-2026-2377-1"]
assert not [t for t in s.get(k).tags if t.startswith("llm:")]
calls.clear()
stats = self._run(s, keys, judge, top=2, max_tags=2, min_confidence=0.0)
assert calls == [] and stats["retuned"] == 1 and stats["tagged"] == 0
assert {"llm:telehealth", "llm:care-management"} <= set(s.get(k).tags)
extra = json.loads(
s._con()
.execute("SELECT extra_json FROM items WHERE key=?", (k,))
.fetchone()[0] # noqa: SLF001
)
assert extra["llm_tags"]["params"] == {
"top": 2,
"max_tags": 2,
"min_confidence": 0.0,
}
def test_wider_shortlist_judges_only_new_candidates(self, tmp_path):
s, keys = _store(tmp_path)
calls = []
def judge(theme, evidence):
calls.append(theme.slug)
return True
self._run(s, keys, judge, top=1, max_tags=3, min_confidence=0.0)
first = list(calls)
calls.clear()
stats = self._run(s, keys, judge, top=3, max_tags=3, min_confidence=0.0)
assert stats["retuned"] == 1
assert not set(calls) & set(first) and len(calls) == 2