Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,7 @@ test_*.csv
*.csv
!requirements.csv

# Local debugging scripts
apps/worker/scripts/
# Local debugging scripts (apps/worker/scripts/ is tracked)
apps/worker/experiments/
apps/worker/start_celery_worker.py
apps/worker/start_celery_debug.sh
Expand Down
53 changes: 19 additions & 34 deletions apps/worker/app/services/document_agent/agents/calibration/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@ into **page-numbering regimes** (distinct numbering systems / label shapes:
decimal digits, roman numerals, prefixed folio labels, etc.).

- Do not mix samples across regimes when computing an offset.
- Include `entry_indices` (0-based indices into `toc_region.entries`) for each
regime you submit.
- Run the same initial-calibration procedure independently for each regime that
has usable entries.

Expand Down Expand Up @@ -53,49 +51,36 @@ offset: treat that sample / regime as **not found**, submit whatever regimes you
already confirmed (or `status=failed`), and let production fallback handle the
rest. Do not guess pages.

## Phase 2 — Completion (deterministic after submit; production path)

For each TOC region, every regime with a candidate offset is completed
independently, then merged by **physical page**:

1. Build TitleNodes via production `extract_toc_nodes` (regime-aware parse:
decimal / roman / prefixed labels → `printed_page` + `page_kind`).
2. For **each** regime with an offset:
- Project leaves belonging to that regime
- Run production Phase-2: prune → tail verify → binary-search →
small-step recalibrate (single-leaf regimes apply offset directly)
3. Merge all regime `match_overrides` (physical pages), then null-page parent
locate once on the combined tree.
4. Emit production `SkeletonAnchor` (`offset` = primary decimal summary,
`match_overrides` = union of all regimes, `null_page_report`, `bulk_count`,
`pruned_count`, `locate_agent`).
5. On recalibrate/budget failure inside one regime: keep that regime's complete
**prefix**; **drop** unresolved **suffix** leaves from the TOC tree (no TOC),
then run null-page parent locate on what remains. Never fall back to a fixed
post-TOC window.

## Usability bar

- Coarse structure may use the result when `SkeletonAnchor.offset_status=ok`
and `bulk_count > 0` (at least one complete production segment).
- Otherwise downstream treats the document as no-TOC / Root fallback.
## Phase 2 — Completion (deterministic after submit)

Not your job and not yours to describe. After submit, production completes each
regime independently (prune → tail verify → binary search → small-step
recalibrate), merges the regimes by physical page, and emits the
`SkeletonAnchor`. It recomputes segment coverage, per-regime status and the
no-TOC entry set itself, so do not submit those.

## Tools

- `inspect.pages`: primary tool for Phase 1. Open physical pages, render, answer
your question. Prefer the progressive 1→3→5 schedule above. Per-call page
count is capped; overall spend is limited by the calibration visual token
budget and `max_rounds`.
- `calibration.submit`: finish Phase 1. Pass the full result under
- `calibration.submit`: finish Phase 1. Pass the result under
`tool_args.result` (or result fields directly in `tool_args`).

## Output rules

- Submit `status`, `regimes`, top-level `offset` / `offset_status` for the
primary decimal-digit regime when identifiable, `tool_calls`, `notes`.
- Each regime must include `kind`, candidate `offset`, `offset_status`,
`entry_indices`, `samples` (with `title`, `printed_label`, `physical` when
known), and `posterior` if you already inspected a late check.
Submit exactly the fields in the `calibration.submit` schema — `status`,
`regimes`, `notes` — and nothing else:

- Per regime: `kind` and the candidate `offset`. Add `entry_indices` only when
the regime is not simply the entries whose printed-label shape matches `kind`,
and `samples` (`title` + `physical`) only for anchors you actually confirmed.
- `notes`: one short sentence saying why. When you found no offset, submit
`status=failed` and say why in that one sentence.
- Keep `kind` values consistent within one run (`decimal`, `roman`, `prefixed`,
or `other`).
- Anything else — per-regime status, segment coverage, no-TOC entries, tool call
counts, region index — is recomputed after submit; emitting it only risks the
submit being cut off by the output limit, which ends the run with no result.
- Stay within the token / round budgets announced in the payload.
75 changes: 62 additions & 13 deletions apps/worker/app/services/document_agent/agents/calibration/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@
finalize_calibration_result,
)
from app.services.document_agent.agents.calibration.types import (
FAILURE_BUDGET_EXHAUSTED,
FAILURE_INVALID_JSON,
FAILURE_LLM_ERROR,
FAILURE_MAX_ROUNDS,
FAILURE_MODEL_MISSING,
FAILURE_NO_OFFSET,
FAILURE_TOC_EMPTY,
CalibrationResult,
calibration_result_from_dict,
)
Expand All @@ -33,6 +40,8 @@

_SKILL_PATH = Path(__file__).resolve().parent / "SKILL.md"

_DECISION_MAX_TOKENS = 2500

_DECISION_INSTRUCTIONS = """
You are the calibration SubAgent. Follow the Skill strictly.
Each turn return a JSON object with keys:
Expand All @@ -45,6 +54,8 @@
Phase 2 (tail verify, binary search, small-step recalibrate) runs automatically
after submit. Do not use a fixed post-TOC page window.
Hard limits are token budgets and max_rounds — not a total page-count ledger.
Emit exactly the fields in the calibration.submit schema and nothing else; an
oversized submit is cut off by the output limit and ends the run.
Include the word json in your response.
""".strip()

Expand Down Expand Up @@ -125,7 +136,11 @@ def run_calibration_phase1(
if no_links:
hierarchies = strip_toc_links(hierarchies)
if not hierarchies:
return CalibrationResult(status="failed", notes="toc_hierarchies empty")
return CalibrationResult(
status="failed",
notes="toc_hierarchies empty",
failure_kind=FAILURE_TOC_EMPTY,
)
region_payload = _toc_region_payload(hierarchies, region_index)
resolved_page_count = int(
page_count or ctx.blackboard.page_count or 0
Expand All @@ -139,9 +154,6 @@ def run_calibration_phase1(
blackboard = ctx.blackboard
blackboard.global_signals["calibration_region_index"] = region_index
blackboard.global_signals["calibration_tool_calls"] = 0
blackboard.global_signals["calibration_inspect_pages_used"] = int(
blackboard.global_signals.get("calibration_inspect_pages_used") or 0
)
blackboard.global_signals["calibration_done"] = False
blackboard.global_signals.pop("calibration_result", None)

Expand Down Expand Up @@ -174,9 +186,6 @@ def run_calibration_phase1(
"calibration_visual": calib_stage,
"plan": snap.get("plan") if isinstance(snap, dict) else None,
"visual": snap.get("visual") if isinstance(snap, dict) else None,
"inspect_pages_used_diagnostic": blackboard.global_signals.get(
"calibration_inspect_pages_used"
),
},
"toc_region": region_payload,
"history_tail": history[-8:],
Expand All @@ -191,6 +200,7 @@ def run_calibration_phase1(
CalibrationResult(
status="failed",
notes="planner model missing",
failure_kind=FAILURE_MODEL_MISSING,
region_index=region_index,
),
history,
Expand All @@ -202,6 +212,7 @@ def run_calibration_phase1(
CalibrationResult(
status="failed",
notes="planner budget exhausted",
failure_kind=FAILURE_BUDGET_EXHAUSTED,
region_index=region_index,
tool_calls=int(
blackboard.global_signals.get("calibration_tool_calls") or 0
Expand All @@ -218,19 +229,51 @@ def run_calibration_phase1(
messages=[{"role": "user", "content": prompt}],
model=model,
temperature=0.0,
max_tokens=2500,
max_tokens=_DECISION_MAX_TOKENS,
response_format={"type": "json_object"},
usage_task="calibration.react_loop",
)
ctx.budget.commit("plan", actual=usage.get("total_tokens", est), est=est)
decision = _parse_decision(raw)
except Exception as exc:
ctx.budget.refund("plan", est=est)
logger.warning("[calibration] decision failed round={}: {}", round_index, exc)
logger.warning("[calibration] llm call failed round={}: {}", round_index, exc)
return _attach_history(
CalibrationResult(
status="failed",
notes=f"llm call failed: {exc}",
failure_kind=FAILURE_LLM_ERROR,
region_index=region_index,
tool_calls=int(
blackboard.global_signals.get("calibration_tool_calls") or 0
),
),
history,
)

ctx.budget.commit("plan", actual=usage.get("total_tokens", est), est=est)
try:
decision = _parse_decision(raw)
except json.JSONDecodeError as exc:
history.append(
{
"round": round_index,
"error": f"decision output not parseable: {exc}",
"completion_tokens": usage.get("completion_tokens"),
"max_tokens": _DECISION_MAX_TOKENS,
}
)
logger.warning(
"[calibration] decision output not parseable round={} "
"completion_tokens={} max_tokens={}: {}",
round_index,
usage.get("completion_tokens"),
_DECISION_MAX_TOKENS,
exc,
)
return _attach_history(
CalibrationResult(
status="failed",
notes=f"decision failed: {exc}",
notes=f"decision output not parseable: {exc}",
failure_kind=FAILURE_INVALID_JSON,
region_index=region_index,
tool_calls=int(
blackboard.global_signals.get("calibration_tool_calls") or 0
Expand Down Expand Up @@ -283,6 +326,7 @@ def run_calibration_phase1(
CalibrationResult(
status="failed",
notes=f"budget exhausted: {tool_result.error}",
failure_kind=FAILURE_BUDGET_EXHAUSTED,
region_index=region_index,
tool_calls=int(
blackboard.global_signals.get("calibration_tool_calls") or 0
Expand All @@ -305,6 +349,7 @@ def run_calibration_phase1(
CalibrationResult(
status="failed",
notes="max rounds reached without calibration.submit",
failure_kind=FAILURE_MAX_ROUNDS,
region_index=region_index,
tool_calls=int(blackboard.global_signals.get("calibration_tool_calls") or 0),
),
Expand Down Expand Up @@ -411,6 +456,7 @@ def run_calibration_for_all_regions(
"regimes": [],
"regions": [],
"notes": "toc_hierarchies empty",
"failure_kind": FAILURE_TOC_EMPTY,
"tool_calls": 0,
"no_links": no_links,
}
Expand Down Expand Up @@ -463,7 +509,9 @@ def run_calibration_for_all_regions(
}
if primary_result is None:
primary_result = CalibrationResult(
status="failed", notes="no region produced offset"
status="failed",
notes="no region produced offset",
failure_kind=FAILURE_NO_OFFSET,
)

anchor = deserialize_skeleton_anchor(primary_anchor)
Expand All @@ -481,6 +529,7 @@ def run_calibration_for_all_regions(
offset_status=anchor.offset_status,
tool_calls=tool_calls,
notes=primary_result.notes,
failure_kind=primary_result.failure_kind,
),
no_links=no_links,
region_payloads=region_results,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def anchor_hierarchy(
page_texts=page_texts,
page_count=page_count,
)
if phase1.status == "failed" and not phase1.regimes and phase1.offset is None:
if phase1.status == "failed" and not phase1.regimes:
return anchor_hierarchy_from_offset(
nodes=nodes,
offset_hint=None,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,6 @@ def pick_primary_offset(result: CalibrationResult) -> int | None:
for regime in result.regimes:
if regime.offset is not None:
return int(regime.offset)
if result.offset is not None:
return int(result.offset)
return None


Expand Down Expand Up @@ -115,8 +113,7 @@ def seed_overrides_from_samples(
candidates=[int(sample.physical)],
evidence={
"calibration": True,
"printed_label": sample.printed_label,
"method": sample.method or "agent_phase1",
"method": "agent_phase1",
"regime_kind": regime.kind,
},
)
Expand Down Expand Up @@ -284,14 +281,6 @@ def anchor_hierarchy_from_regimes(
for regime in result.regimes
if regime.offset is not None
]
if not usable_regimes and result.offset is not None:
usable_regimes = [
CalibrationRegime(
kind="decimal",
offset=int(result.offset),
offset_status="ok",
)
]

for regime in usable_regimes:
kind = normalize_kind(regime.kind)
Expand Down Expand Up @@ -493,7 +482,6 @@ def _annotate_regimes_from_anchor(
offset_status="ok" if segments else "failed",
entry_indices=indices,
samples=list(regime.samples),
posterior=list(regime.posterior),
segments=segments,
no_toc_entry_indices=no_toc,
notes=(
Expand Down Expand Up @@ -564,6 +552,7 @@ def finalize_calibration_result(
offset_status=anchor.offset_status,
tool_calls=result.tool_calls,
notes="; ".join(p for p in notes_parts if p),
failure_kind=result.failure_kind,
region_index=result.region_index,
history_tail=list(result.history_tail),
)
Expand All @@ -590,6 +579,7 @@ def build_calibration_payload(
"regions": list(region_payloads or []),
"tool_calls": int(tool_calls if tool_calls is not None else result.tool_calls),
"notes": result.notes,
"failure_kind": result.failure_kind,
"no_links": no_links,
}
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,9 @@ def calibrate_offset(
return CalibrationResult(status="failed", notes=str(exc))

logger.info(
"[calibration] Phase-1 status={} regimes={} primary_offset={}",
"[calibration] Phase-1 status={} failure_kind={} regime_offsets={}",
phase1.status,
len(phase1.regimes),
phase1.offset,
phase1.failure_kind,
[regime.offset for regime in phase1.regimes],
)
return phase1
Loading
Loading