Make EasyCom adjudication resumable

This commit is contained in:
Xin Wang
2026-09-15 03:25:12 +08:00
parent 31d3fc1757
commit e2c305facc
3 changed files with 443 additions and 53 deletions
+259 -37
View File
@@ -41,6 +41,16 @@ HARD_NEGATIVE_TYPES = {
"INT_WEARER_HOLDS_FLOOR", "INT_WEARER_HOLDS_FLOOR",
"OTHER_HARD_NEGATIVE", "OTHER_HARD_NEGATIVE",
} }
class BatchValidationError(RuntimeError):
"""The provider responded, but the batch output violated the label schema."""
class APIRequestError(RuntimeError):
"""The provider could not return a usable response after transport retries."""
def _progress_line( def _progress_line(
completed: int, total: int, *, label: str, elapsed_seconds: float, width: int = 26 completed: int, total: int, *, label: str, elapsed_seconds: float, width: int = 26
) -> str: ) -> str:
@@ -274,7 +284,9 @@ def validate_decisions(
if verdict != "POSITIVE" and event_type is not None: if verdict != "POSITIVE" and event_type is not None:
raise ValueError(f"Only POSITIVE may have event_type for {uid}") raise ValueError(f"Only POSITIVE may have event_type for {uid}")
if verdict == "HARD_NEGATIVE" and subtype not in HARD_NEGATIVE_TYPES: if verdict == "HARD_NEGATIVE" and subtype not in HARD_NEGATIVE_TYPES:
raise ValueError(f"HARD_NEGATIVE requires a valid subtype for {uid}") raise ValueError(
f"HARD_NEGATIVE requires a valid subtype for {uid}; got {subtype!r}"
)
if verdict != "HARD_NEGATIVE" and subtype is not None: if verdict != "HARD_NEGATIVE" and subtype is not None:
raise ValueError(f"Only HARD_NEGATIVE may have subtype for {uid}") raise ValueError(f"Only HARD_NEGATIVE may have subtype for {uid}")
if verdict == "EXCLUDE" and exclude_reason not in EXCLUDE_REASONS: if verdict == "EXCLUDE" and exclude_reason not in EXCLUDE_REASONS:
@@ -357,6 +369,19 @@ def _post(
try: try:
with urllib.request.urlopen(request, timeout=timeout_seconds) as response: with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
envelope = json.loads(response.read().decode("utf-8")) envelope = json.loads(response.read().decode("utf-8"))
except (
urllib.error.HTTPError,
urllib.error.URLError,
TimeoutError,
) as error:
last_error = error
if attempt >= max_retries:
raise APIRequestError(
f"API request failed after {max_retries + 1} attempts: {last_error}"
) from error
time.sleep(min(30.0, 2.0**attempt))
continue
try:
content = envelope["choices"][0]["message"]["content"] content = envelope["choices"][0]["message"]["content"]
parsed = json.loads(content) parsed = json.loads(content)
allowed_opportunities = {} allowed_opportunities = {}
@@ -383,19 +408,93 @@ def _post(
decision["uid"] = original_by_transport[decision["uid"]] decision["uid"] = original_by_transport[decision["uid"]]
return decisions, envelope.get("usage", {}) return decisions, envelope.get("usage", {})
except ( except (
urllib.error.HTTPError,
urllib.error.URLError,
TimeoutError,
KeyError, KeyError,
TypeError, TypeError,
json.JSONDecodeError, json.JSONDecodeError,
ValueError, ValueError,
) as error: ) as error:
last_error = error last_error = error
# A malformed multi-item answer is immediately bisected. Retrying
# the identical large prompt is both expensive and unlikely to
# identify which item caused the schema violation. A singleton is
# retried before it is placed in the human queue.
if len(rows) > 1:
raise BatchValidationError(str(error)) from error
if attempt >= max_retries: if attempt >= max_retries:
break raise BatchValidationError(
f"singleton validation failed after {max_retries + 1} attempts: {error}"
) from error
time.sleep(min(30.0, 2.0**attempt)) time.sleep(min(30.0, 2.0**attempt))
raise RuntimeError(f"DeepSeek request failed after {max_retries + 1} attempts: {last_error}") raise RuntimeError(f"Unreachable request state: {last_error}")
def _atomic_write_json(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
try:
temporary.write_text(
json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
os.replace(temporary, path)
finally:
if temporary.exists():
temporary.unlink()
def _atomic_write_jsonl(path: Path, rows: Sequence[dict[str, Any]]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
try:
with temporary.open("w", encoding="utf-8") as handle:
for row in rows:
handle.write(json.dumps(row, ensure_ascii=False) + "\n")
os.replace(temporary, path)
finally:
if temporary.exists():
temporary.unlink()
def _run_fingerprint(
rows: Sequence[dict[str, Any]], *, model: str, base_url: str, passes: int, batch_size: int
) -> str:
payload = {
"prompt_version": PROMPT_VERSION,
"model": model,
"base_url": base_url.rstrip("/"),
"passes": passes,
"batch_size": batch_size,
"candidates": [candidate_for_prompt(row) for row in rows],
}
return hashlib.sha256(
json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode(
"utf-8"
)
).hexdigest()
def _initial_checkpoint(
rows: Sequence[dict[str, Any]], *, model: str, base_url: str, passes: int,
batch_size: int,
) -> dict[str, Any]:
return {
"schema_version": 1,
"status": "running",
"run_fingerprint": _run_fingerprint(
rows, model=model, base_url=base_url, passes=passes, batch_size=batch_size
),
"config": {
"prompt_version": PROMPT_VERSION,
"model": model,
"base_url": base_url.rstrip("/"),
"passes": passes,
"batch_size": batch_size,
},
"candidate_uids": [row["uid"] for row in rows],
"decisions": {},
"prompt_hashes": {},
"pending": {},
"usage": {},
}
def add_suggestions( def add_suggestions(
@@ -408,29 +507,81 @@ def add_suggestions(
batch_size: int = 8, batch_size: int = 8,
timeout_seconds: float, timeout_seconds: float,
max_retries: int, max_retries: int,
) -> tuple[list[dict[str, Any]], Counter[str]]: checkpoint_path: Path | None = None,
by_uid: dict[str, list[dict[str, Any]]] = {row["uid"]: [] for row in rows} pending_output_path: Path | None = None,
prompt_hashes: dict[str, list[str]] = {row["uid"]: [] for row in rows} resume: bool = True,
usage: Counter[str] = Counter() ) -> tuple[list[dict[str, Any]], list[dict[str, Any]], Counter[str]]:
if batch_size <= 0: if batch_size <= 0:
raise ValueError("batch_size must be positive") raise ValueError("batch_size must be positive")
total_work = len(rows) * passes expected_state = _initial_checkpoint(
batches_per_pass = (len(rows) + batch_size - 1) // batch_size rows, model=model, base_url=base_url, passes=passes, batch_size=batch_size
completed_work = 0
progress = _ProgressBar(total_work)
progress.update(0, f"pass 1/{passes} · waiting")
try:
for pass_index in range(passes):
for batch_index, start in enumerate(range(0, len(rows), batch_size), 1):
batch = rows[start:start + batch_size]
progress.update(
completed_work,
f"pass {pass_index + 1}/{passes} · batch {batch_index}/{batches_per_pass}",
) )
if checkpoint_path is not None and resume and checkpoint_path.exists():
state = json.loads(checkpoint_path.read_text(encoding="utf-8"))
if state.get("run_fingerprint") != expected_state["run_fingerprint"]:
raise ValueError(
f"Checkpoint does not match this input/model/prompt: {checkpoint_path}. "
"Pass --overwrite to start a new run."
)
else:
state = expected_state
if checkpoint_path is not None:
_atomic_write_json(checkpoint_path, state)
decisions_by_uid: dict[str, dict[str, dict[str, Any]]] = state["decisions"]
hashes_by_uid: dict[str, dict[str, str]] = state["prompt_hashes"]
pending_by_uid: dict[str, dict[str, Any]] = state["pending"]
usage: Counter[str] = Counter(state.get("usage", {}))
source_by_uid = {row["uid"]: row for row in rows}
def materialize_pending() -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for uid in state["candidate_uids"]:
if uid not in pending_by_uid:
continue
pending = dict(source_by_uid[uid])
pending.update(
human_status="UNREVIEWED",
human_verdict=None,
human_event_type=None,
human_subtype=None,
exclude_reason=None,
)
pending["llm_adjudication_failure"] = pending_by_uid[uid]
result.append(pending)
return result
def save_checkpoint() -> None:
state["usage"] = dict(usage)
if checkpoint_path is not None:
_atomic_write_json(checkpoint_path, state)
if pending_output_path is not None:
_atomic_write_jsonl(pending_output_path, materialize_pending())
total_work = len(rows) * passes
completed_work = len(pending_by_uid) * passes + sum(
len(pass_decisions)
for uid, pass_decisions in decisions_by_uid.items()
if uid not in pending_by_uid
)
progress = _ProgressBar(total_work)
if completed_work:
print(
f"Resuming {checkpoint_path}: completed={completed_work}/{total_work} "
f"pending_human={len(pending_by_uid)}"
)
progress.update(completed_work, f"pass 1/{passes} · waiting")
def process_batch(batch: Sequence[dict[str, Any]], pass_index: int) -> None:
nonlocal completed_work
if not batch:
return
transported, _ = _transport_rows(batch) transported, _ = _transport_rows(batch)
prompt_hash = hashlib.sha256( prompt_hash = hashlib.sha256(
(SYSTEM_PROMPT + "\n" + build_user_prompt(transported, pass_index)).encode("utf-8") (SYSTEM_PROMPT + "\n" + build_user_prompt(transported, pass_index)).encode("utf-8")
).hexdigest() ).hexdigest()
progress.update(completed_work, f"pass {pass_index + 1}/{passes} · request {len(batch)}")
try:
decisions, pass_usage = _post( decisions, pass_usage = _post(
rows=batch, rows=batch,
pass_index=pass_index, pass_index=pass_index,
@@ -440,25 +591,67 @@ def add_suggestions(
timeout_seconds=timeout_seconds, timeout_seconds=timeout_seconds,
max_retries=max_retries, max_retries=max_retries,
) )
except BatchValidationError as error:
if len(batch) > 1:
midpoint = len(batch) // 2
progress.update(
completed_work,
f"validation split {len(batch)}→{midpoint}+{len(batch) - midpoint}",
)
process_batch(batch[:midpoint], pass_index)
process_batch(batch[midpoint:], pass_index)
return
uid = batch[0]["uid"]
pending_by_uid[uid] = {
"failed_pass": pass_index + 1,
"error_type": type(error).__name__,
"error": str(error),
"model": model,
"base_url": base_url.rstrip("/"),
"prompt_version": PROMPT_VERSION,
"request_prompt_sha256": prompt_hash,
}
completed_work += passes - pass_index
save_checkpoint()
progress.update(completed_work, f"queued for human review: {uid}")
return
pass_key = str(pass_index)
for decision in decisions: for decision in decisions:
by_uid[decision["uid"]].append(decision) uid = decision["uid"]
prompt_hashes[decision["uid"]].append(prompt_hash) decisions_by_uid.setdefault(uid, {})[pass_key] = decision
hashes_by_uid.setdefault(uid, {})[pass_key] = prompt_hash
for key, value in pass_usage.items(): for key, value in pass_usage.items():
if isinstance(value, int): if isinstance(value, int):
usage[key] += value usage[key] += value
completed_work += len(batch) completed_work += len(batch)
progress.update( save_checkpoint()
completed_work, progress.update(completed_work, f"pass {pass_index + 1}/{passes} · saved {len(batch)}")
f"pass {pass_index + 1}/{passes} · batch {batch_index}/{batches_per_pass}",
) try:
for pass_index in range(passes):
eligible = [
row for row in rows
if row["uid"] not in pending_by_uid
and str(pass_index) not in decisions_by_uid.get(row["uid"], {})
]
for start in range(0, len(eligible), batch_size):
process_batch(eligible[start:start + batch_size], pass_index)
except Exception: except Exception:
progress.update(completed_work, "failed", final=True) progress.update(completed_work, "failed; checkpoint retained", final=True)
raise raise
progress.update(completed_work, "complete", final=True) progress.update(completed_work, "complete", final=True)
output: list[dict[str, Any]] = [] output: list[dict[str, Any]] = []
for source in rows: for source in rows:
decisions = by_uid[source["uid"]] uid = source["uid"]
if uid in pending_by_uid:
continue
pass_decisions = decisions_by_uid.get(uid, {})
missing_passes = [index + 1 for index in range(passes) if str(index) not in pass_decisions]
if missing_passes:
raise RuntimeError(f"Internal checkpoint error: {uid} missing passes {missing_passes}")
decisions = [pass_decisions[str(index)] for index in range(passes)]
labels = { labels = {
(d["verdict"], d["event_type"], d["subtype"], d["exclude_reason"]) (d["verdict"], d["event_type"], d["subtype"], d["exclude_reason"])
for d in decisions for d in decisions
@@ -531,14 +724,20 @@ def add_suggestions(
) )
enriched["llm_adjudication"] = { enriched["llm_adjudication"] = {
"model": model, "model": model,
"base_url": base_url.rstrip("/"),
"prompt_version": PROMPT_VERSION, "prompt_version": PROMPT_VERSION,
"passes": passes, "passes": passes,
"request_prompt_sha256": prompt_hashes[source["uid"]], "request_prompt_sha256": [
hashes_by_uid[uid][str(index)] for index in range(passes)
],
"decisions": decisions, "decisions": decisions,
"human_review_required": True, "human_review_required": True,
} }
output.append(enriched) output.append(enriched)
return output, usage state["status"] = "complete"
save_checkpoint()
pending_output = materialize_pending()
return output, pending_output, usage
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
@@ -553,6 +752,16 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--selection-seed", type=int, default=2027) parser.add_argument("--selection-seed", type=int, default=2027)
parser.add_argument("--timeout-seconds", type=float, default=120.0) parser.add_argument("--timeout-seconds", type=float, default=120.0)
parser.add_argument("--max-retries", type=int, default=3) parser.add_argument("--max-retries", type=int, default=3)
parser.add_argument(
"--checkpoint",
type=Path,
help="Resume state (default: OUTPUT.checkpoint.json).",
)
parser.add_argument(
"--pending-output",
type=Path,
help="Singleton validation failures (default: OUTPUT stem + _pending.jsonl).",
)
parser.add_argument("--dry-run", action="store_true") parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--allow-external-transcript-upload", action="store_true") parser.add_argument("--allow-external-transcript-upload", action="store_true")
parser.add_argument("--overwrite", action="store_true") parser.add_argument("--overwrite", action="store_true")
@@ -591,7 +800,17 @@ def main() -> None:
output = args.output.expanduser().resolve() output = args.output.expanduser().resolve()
if output.exists() and not args.overwrite: if output.exists() and not args.overwrite:
raise FileExistsError(f"Refusing to overwrite {output}; pass --overwrite") raise FileExistsError(f"Refusing to overwrite {output}; pass --overwrite")
enriched, usage = add_suggestions( checkpoint = (
args.checkpoint.expanduser().resolve()
if args.checkpoint is not None
else output.with_suffix(output.suffix + ".checkpoint.json")
)
pending_output_path = (
args.pending_output.expanduser().resolve()
if args.pending_output is not None
else output.with_name(output.stem + "_pending.jsonl")
)
enriched, pending, usage = add_suggestions(
rows, rows,
api_key=api_key, api_key=api_key,
model=args.model, model=args.model,
@@ -600,12 +819,15 @@ def main() -> None:
batch_size=args.batch_size, batch_size=args.batch_size,
timeout_seconds=args.timeout_seconds, timeout_seconds=args.timeout_seconds,
max_retries=args.max_retries, max_retries=args.max_retries,
checkpoint_path=checkpoint,
pending_output_path=pending_output_path,
resume=not args.overwrite,
) )
output.parent.mkdir(parents=True, exist_ok=True) _atomic_write_jsonl(output, enriched)
with output.open("w", encoding="utf-8") as handle: _atomic_write_jsonl(pending_output_path, pending)
for row in enriched:
handle.write(json.dumps(row, ensure_ascii=False) + "\n")
print(f"Saved {len(enriched)} suggested candidates to {output}") print(f"Saved {len(enriched)} suggested candidates to {output}")
print(f"Saved {len(pending)} pending-human candidates to {pending_output_path}")
print(f"Checkpoint: {checkpoint}")
print("Usage:", dict(usage)) print("Usage:", dict(usage))
+24
View File
@@ -118,6 +118,30 @@ python3 adjudicate_easycom_eot_int_candidates.py \
--allow-external-transcript-upload --allow-external-transcript-upload
``` ```
For SiliconFlow, keep the same API-key environment variable and select its
OpenAI-compatible endpoint explicitly:
```bash
python3 adjudicate_easycom_eot_int_candidates.py \
--input reports/easycom_entry_int_sessions1_3_v14/easycom_eot_int_candidates.jsonl \
--output reports/easycom_entry_int_sessions1_3_v14/easycom_eot_int_deepseek.jsonl \
--base-url https://api.siliconflow.cn/v1 \
--model deepseek-ai/DeepSeek-V4-Flash \
--allow-external-transcript-upload
```
The default request batch remains 8. If a returned batch violates the strict
label schema, the adjudicator isolates the bad record by recursively splitting
8 to 4, 2, and 1. Each successful leaf batch is atomically recorded in
`OUTPUT.checkpoint.json`; rerunning the identical command resumes from that
checkpoint and does not request completed passes again. A singleton that still
fails after `--max-retries` is written to `OUTPUT_STEM_pending.jsonl` for manual
relabeling, while the remaining candidates continue. Provider/network failure
does not create a semantic pending item: the command stops with its checkpoint
intact and the identical command resumes later. Use `--checkpoint` or
`--pending-output` to override the derived paths. `--overwrite` intentionally
starts a fresh run instead of resuming.
DeepSeek sees candidate source, structured timing, per-speaker activity, and DeepSeek sees candidate source, structured timing, per-speaker activity, and
transcript together. It returns one of `POSITIVE`, `HARD_NEGATIVE`, or transcript together. It returns one of `POSITIVE`, `HARD_NEGATIVE`, or
`EXCLUDE`; positives additionally receive `EXCLUDE`; positives additionally receive
+145 -1
View File
@@ -3,18 +3,45 @@
from __future__ import annotations from __future__ import annotations
import unittest import unittest
from unittest import mock
from pathlib import Path from pathlib import Path
import json import json
import tempfile import tempfile
from adjudicate_easycom_eot_int_candidates import ( from adjudicate_easycom_eot_int_candidates import (
PROMPT_VERSION, SYSTEM_PROMPT, _progress_line, _transport_rows, build_user_prompt, APIRequestError, BatchValidationError, PROMPT_VERSION, SYSTEM_PROMPT,
_progress_line, _transport_rows, add_suggestions, build_user_prompt,
exclude_reason_group, read_candidates, exclude_reason_group, read_candidates,
requires_semantic_adjudication, validate_decisions, requires_semantic_adjudication, validate_decisions,
) )
class EntryINTAdjudicationTests(unittest.TestCase): class EntryINTAdjudicationTests(unittest.TestCase):
@staticmethod
def _rows(count: int) -> list[dict[str, object]]:
return [
{"uid": f"u{index}", "candidate_source": "WEARER_ONSET", "task": "wearer_entry"}
for index in range(count)
]
@staticmethod
def _decisions(rows: list[dict[str, object]]) -> list[dict[str, object]]:
return [
{
"uid": row["uid"],
"verdict": "EXCLUDE",
"event_type": None,
"subtype": None,
"exclude_reason": "INSUFFICIENT_EVIDENCE",
"exclude_reason_group": "INSUFFICIENT_EVIDENCE",
"selected_opportunity_uid": None,
"confidence": 0.5,
"reason": "Needs human context.",
"needs_human_review": True,
}
for row in rows
]
def test_progress_line_reports_items_percent_pass_and_elapsed_time(self) -> None: def test_progress_line_reports_items_percent_pass_and_elapsed_time(self) -> None:
line = _progress_line(25, 100, label="pass 1/2 · batch 4/13", elapsed_seconds=65) line = _progress_line(25, 100, label="pass 1/2 · batch 4/13", elapsed_seconds=65)
self.assertIn("25/100 ( 25.0%)", line) self.assertIn("25/100 ( 25.0%)", line)
@@ -258,6 +285,123 @@ class EntryINTAdjudicationTests(unittest.TestCase):
self.assertFalse(requires_semantic_adjudication({"candidate_source": "WEARER_ONSET_CONTROL"})) self.assertFalse(requires_semantic_adjudication({"candidate_source": "WEARER_ONSET_CONTROL"}))
self.assertFalse(requires_semantic_adjudication({"candidate_source": "WEARER_RESUME"})) self.assertFalse(requires_semantic_adjudication({"candidate_source": "WEARER_RESUME"}))
def test_validation_failure_recursively_splits_batch_to_singletons(self) -> None:
rows = self._rows(4)
batch_sizes: list[int] = []
def fake_post(**kwargs):
batch = list(kwargs["rows"])
batch_sizes.append(len(batch))
if len(batch) > 1:
raise BatchValidationError("one item has an invalid subtype")
return self._decisions(batch), {"total_tokens": 10}
with tempfile.TemporaryDirectory() as temporary, mock.patch(
"adjudicate_easycom_eot_int_candidates._post", side_effect=fake_post
):
output, pending, usage = add_suggestions(
rows,
api_key="test",
model="model",
base_url="https://example.test/v1",
passes=1,
batch_size=4,
timeout_seconds=1,
max_retries=0,
checkpoint_path=Path(temporary) / "checkpoint.json",
)
self.assertEqual(batch_sizes, [4, 2, 1, 1, 2, 1, 1])
self.assertEqual(len(output), 4)
self.assertEqual(pending, [])
self.assertEqual(usage["total_tokens"], 40)
def test_singleton_validation_failure_goes_to_pending_queue(self) -> None:
rows = self._rows(1)
with tempfile.TemporaryDirectory() as temporary, mock.patch(
"adjudicate_easycom_eot_int_candidates._post",
side_effect=BatchValidationError("invalid singleton label"),
):
checkpoint = Path(temporary) / "checkpoint.json"
pending_path = Path(temporary) / "pending.jsonl"
output, pending, _ = add_suggestions(
rows,
api_key="test",
model="model",
base_url="https://example.test/v1",
passes=2,
batch_size=8,
timeout_seconds=1,
max_retries=0,
checkpoint_path=checkpoint,
pending_output_path=pending_path,
)
state = json.loads(checkpoint.read_text(encoding="utf-8"))
saved_pending = [
json.loads(line)
for line in pending_path.read_text(encoding="utf-8").splitlines()
]
self.assertEqual(output, [])
self.assertEqual([row["uid"] for row in pending], ["u0"])
self.assertIn("invalid singleton label", pending[0]["llm_adjudication_failure"]["error"])
self.assertEqual(state["status"], "complete")
self.assertIn("u0", state["pending"])
self.assertEqual([row["uid"] for row in saved_pending], ["u0"])
def test_resume_skips_batches_already_saved_in_checkpoint(self) -> None:
rows = self._rows(4)
with tempfile.TemporaryDirectory() as temporary:
checkpoint = Path(temporary) / "checkpoint.json"
first_sizes: list[int] = []
def interrupted_post(**kwargs):
batch = list(kwargs["rows"])
first_sizes.append(len(batch))
if len(first_sizes) == 2:
raise APIRequestError("temporary provider outage")
return self._decisions(batch), {"total_tokens": 20}
with mock.patch(
"adjudicate_easycom_eot_int_candidates._post", side_effect=interrupted_post
), self.assertRaises(APIRequestError):
add_suggestions(
rows,
api_key="test",
model="model",
base_url="https://example.test/v1",
passes=1,
batch_size=2,
timeout_seconds=1,
max_retries=0,
checkpoint_path=checkpoint,
)
resumed_uids: list[list[str]] = []
def resumed_post(**kwargs):
batch = list(kwargs["rows"])
resumed_uids.append([str(row["uid"]) for row in batch])
return self._decisions(batch), {"total_tokens": 20}
with mock.patch(
"adjudicate_easycom_eot_int_candidates._post", side_effect=resumed_post
):
output, pending, usage = add_suggestions(
rows,
api_key="test",
model="model",
base_url="https://example.test/v1",
passes=1,
batch_size=2,
timeout_seconds=1,
max_retries=0,
checkpoint_path=checkpoint,
)
self.assertEqual(first_sizes, [2, 2])
self.assertEqual(resumed_uids, [["u2", "u3"]])
self.assertEqual(len(output), 4)
self.assertEqual(pending, [])
self.assertEqual(usage["total_tokens"], 40)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()