Skip to content

Commit 4ffa6b2

Browse files
feat(cli): polish-summaries — Batch-API LLM upgrade of workspace summaries.json (#90)
* feat(cli): polish-summaries — Batch-API LLM upgrade of workspace summaries.json New attune-author polish-summaries subcommand: rewrites mechanically seeded one-line summaries in a workspace's path-keyed .help/summaries.json via the Anthropic Batch API on the capable tier (fable pins swap to opus-4-8 — Batch rejects fallbacks). Provenance in .help/summaries.meta.json (polished_at/model/source_hash/ summary_hash); hand-edited entries always win; cost gate aborts above --max-usd (default $2); --dry-run/--force/--resume. 23 mock-only tests. Spec: specs/summaries-polish (attune workspace repo), task 1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(anthropic): return first TEXT block — fable responses lead with thinking blocks call_anthropic read response.content[0].text; live fable responses can lead with a BetaThinkingBlock (no .text) even though explicit thinking params are rejected, crashing the polish path ('BetaThinkingBlock' object has no attribute 'text' — hit by the commit regen hook 2026-07-10). Walk content for the first text block instead; only-thinking responses return ''. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(polish-summaries): cover codecov patch gaps JSON error paths (unreadable/non-object sidecar), _source_hash body fallback, apply_results truncation branch + meta flag, and the CLI --resume happy path (poll + apply + state cleared, no resubmit). summaries_polish.py 93% -> 99%; new CLI handler fully covered. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 107c3af commit 4ffa6b2

5 files changed

Lines changed: 810 additions & 2 deletions

File tree

src/attune_author/cli.py

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,44 @@ def _build_parser() -> argparse.ArgumentParser:
405405
help="Replace any existing SKILL.md instead of skipping it.",
406406
)
407407

408+
p_sums = sub.add_parser(
409+
"polish-summaries",
410+
help="LLM-polish a workspace's path-keyed .help/summaries.json (Batch API)",
411+
description=(
412+
"Rewrite mechanically seeded one-line summaries in a workspace's "
413+
".help/summaries.json into purpose-written catalog lines via the "
414+
"Anthropic Batch API on the capable tier. Provenance goes to "
415+
".help/summaries.meta.json; hand-edited entries are never "
416+
"overwritten. Spec: specs/summaries-polish (attune workspace repo)."
417+
),
418+
)
419+
p_sums.add_argument(
420+
"--help-dir",
421+
default=".help",
422+
help="Workspace help directory containing summaries.json (default: %(default)s).",
423+
)
424+
p_sums.add_argument(
425+
"--max-usd",
426+
type=float,
427+
default=2.0,
428+
help="Abort before submitting if the cost estimate exceeds this (default: %(default)s).",
429+
)
430+
p_sums.add_argument(
431+
"--force",
432+
action="store_true",
433+
help="Re-polish entries already marked polished in summaries.meta.json.",
434+
)
435+
p_sums.add_argument(
436+
"--dry-run",
437+
action="store_true",
438+
help="Print request count + cost estimate and exit without submitting.",
439+
)
440+
p_sums.add_argument(
441+
"--resume",
442+
action="store_true",
443+
help="Poll a previously submitted batch (state in .help/.summaries-batch-state.json).",
444+
)
445+
408446
return parser
409447

410448

@@ -438,6 +476,7 @@ def _dispatch(args: argparse.Namespace, parser: argparse.ArgumentParser) -> int:
438476
"auth": _cmd_auth,
439477
"edit": _cmd_edit,
440478
"export-skills": _cmd_export_skills,
479+
"polish-summaries": _cmd_polish_summaries,
441480
}
442481
handler = handlers.get(args.command)
443482
if handler is None:
@@ -1261,5 +1300,82 @@ def _cmd_export_skills(args: argparse.Namespace) -> int:
12611300
return 0
12621301

12631302

1303+
def _cmd_polish_summaries(args: argparse.Namespace) -> int:
1304+
"""LLM-polish the workspace summaries sidecar via the Batch API.
1305+
1306+
Submit + poll in one invocation; ``--resume`` re-enters polling for
1307+
a previously submitted batch after a timeout. Exit codes: 0 applied
1308+
(or dry run), 2 bad input / cost gate / no pending state, 3 batch
1309+
ended without applying (timed out — state kept for --resume).
1310+
"""
1311+
from attune_author import summaries_polish as sp
1312+
from attune_author.doc_gen._anthropic_batch import (
1313+
adaptive_timeout_secs,
1314+
poll_batch,
1315+
submit_batch,
1316+
)
1317+
1318+
help_dir = Path(args.help_dir).expanduser().resolve()
1319+
model = sp.polish_model()
1320+
1321+
try:
1322+
if args.resume:
1323+
state = sp.load_state(help_dir)
1324+
batch_id = state["batch_id"]
1325+
model = state.get("model") or model
1326+
report = None
1327+
expected = sp.build_requests(help_dir, model, force=True).requests
1328+
expected = [r for r in expected if r.custom_id in set(state["custom_ids"])]
1329+
else:
1330+
report = sp.build_requests(help_dir, model, force=args.force)
1331+
expected = report.requests
1332+
except sp.SummariesPolishError as exc:
1333+
print(f"error: {exc}", file=sys.stderr)
1334+
return 2
1335+
1336+
if report is not None:
1337+
estimate = sp.estimate_cost_usd(report.requests)
1338+
print(
1339+
f"{len(report.requests)} to polish "
1340+
f"({len(report.skipped_polished)} already polished, "
1341+
f"{len(report.skipped_hand_edited)} hand-edited kept), "
1342+
f"model {model}, est. ~${estimate:.2f} (Batch API)"
1343+
)
1344+
if not report.requests:
1345+
return 0
1346+
if estimate > args.max_usd:
1347+
print(
1348+
f"error: estimate ${estimate:.2f} exceeds --max-usd "
1349+
f"{args.max_usd:.2f}; nothing submitted",
1350+
file=sys.stderr,
1351+
)
1352+
return 2
1353+
if args.dry_run:
1354+
return 0
1355+
batch_id = submit_batch(report.requests)
1356+
sp.save_state(help_dir, batch_id, report.requests)
1357+
print(f"submitted batch {batch_id}")
1358+
1359+
status, results = poll_batch(
1360+
batch_id,
1361+
expected,
1362+
timeout_secs=adaptive_timeout_secs(len(expected)),
1363+
)
1364+
if status != "ended":
1365+
print(
1366+
f"batch {batch_id} status={status}; state kept — rerun with --resume",
1367+
file=sys.stderr,
1368+
)
1369+
return 3
1370+
counters = sp.apply_results(help_dir, results, model=model)
1371+
sp.clear_state(help_dir)
1372+
print(
1373+
f"applied {counters['applied']} "
1374+
f"(truncated {counters['truncated']}, errored {counters['errored']}, "
1375+
f"empty {counters['empty']}) -> {help_dir / 'summaries.json'}"
1376+
)
1377+
return 0
1378+
1379+
12641380
if __name__ == "__main__":
12651381
sys.exit(main())

src/attune_author/doc_gen/_anthropic.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,8 +202,13 @@ def call_anthropic(
202202
creation, read = _log_cache_usage(response, model)
203203
if on_cache_usage is not None:
204204
on_cache_usage(creation, read, model)
205-
if response.content:
206-
return response.content[0].text
205+
# First TEXT block, not content[0]: fable responses can lead
206+
# with a BetaThinkingBlock (no .text) even though explicit
207+
# thinking params are rejected — hit live 2026-07-10.
208+
for block in response.content or []:
209+
text = getattr(block, "text", None)
210+
if text:
211+
return text
207212
return ""
208213
except ModelRefusalError:
209214
# Not transport noise: the model (and its whole fallback

0 commit comments

Comments
 (0)