Plan 003: Persist deferred merge disputes to the review queue and add a CLI viewer
Executor instructions: Follow this plan step by step. Run every
verification command and confirm the expected result before moving to the
next step. If anything in the "STOP conditions" section occurs, stop and
report — do not improvise. When done, update the status row for this plan
in plans/README.md.
Drift check (run first): git diff --stat 5b5c634..HEAD -- src/engine/mergers.py src/engine/merge_dispute_agent.py src/process_and_extract.py justfile
If any in-scope file changed since this plan was written, compare the
"Current state" excerpts against the live code before proceeding; on a
mismatch, treat it as a STOP condition.
Status
- Priority: P1
- Effort: S
- Risk: LOW
- Depends on: none
- Category: bug
- Planned at: commit
5b5c634, 2026-06-12
Why this matters
When an entity match falls in the "gray band" (similarity near the threshold,
match confidence low), a second-stage LLM — the merge dispute agent — decides
MERGE / SKIP / DEFER. The agent already contains fully built, tested code to
append DEFER cases to a JSONL review queue (review_queue_path parameter),
but the only caller never passes that parameter, so every deferred case —
exactly the ones flagged as needing a human eye — is silently dropped. The
agent also returns DEFER on API errors, so those are lost too. After this
plan: DEFER records land in {output_dir}/merge_review_queue.jsonl by
default, and just review-disputes pretty-prints them so a researcher can
actually triage.
Current state
-
src/engine/merge_dispute_agent.py — the dispute agent.
run_merge_dispute_agent(...) (line 83) accepts keyword-only args
including review_queue_path: Optional[str] = None (line 98).
- On
decision.action == MergeDisputeAction.DEFER and review_queue_path
(line 155) it builds a record via _build_review_record(...) and calls
append_merge_dispute_review_queue(review_queue_path, record) (line 169).
- On any LLM exception it returns a DEFER decision with
reason=f"Dispute agent API error: ..." (lines 142–148), which also
flows through the persistence branch.
-
src/engine/mergers.py — the only caller.
-
src/process_and_extract.py — merge_and_finalize() (line 606) constructs
EntityMerger(entity_type) and calls merger.merge_entities(...) with
domain=processor.domain, domain_config=domain_cfg (see lines ~300–316).
domain_cfg.get_output_dir() returns the domain output directory.
-
justfile — task runner; recipes follow the pattern
name *args:\n uv run python scripts/<script>.py {{args}}.
-
scripts/ — utility scripts (e.g. scripts/list_domains.py); Rich is a
transitive dependency already used for console output across the repo
(from rich.console import Console, from rich.table import Table are
available).
-
Existing tests for the routing logic: tests/test_merge_dispute_agent_routing.py
— read it before writing tests and reuse its mocking approach for LLM calls.
Commands you will need
| Purpose |
Command |
Expected on success |
| Tests |
just test |
all pass |
| Targeted |
uv run pytest tests/test_merge_dispute_agent_routing.py -v |
all pass |
| Lint+format |
just format && just lint |
exit 0 |
| Full CI parity |
just ci |
exit 0 |
| Viewer smoke |
uv run python scripts/review_disputes.py --domain guantanamo |
table or "queue empty" message |
Scope
In scope (the only files you should modify/create):
src/engine/mergers.py (signature + pass-through only)
src/process_and_extract.py (compute and pass the default path in merge_and_finalize)
scripts/review_disputes.py (create)
justfile (one new recipe)
tests/test_merge_dispute_agent_routing.py (extend)
Out of scope (do NOT touch):
src/engine/merge_dispute_agent.py — the persistence code already works;
do not modify it.
- Any frontend route — a
/disputes web page is a separate, future plan.
- Accept/override workflow — the viewer is read-only triage for now.
Git workflow
- Branch:
advisor/003-wire-dispute-review-queue
- Commit style: imperative subject, backticks around code identifiers.
- Do NOT push or open a PR unless the operator instructed it.
Steps
Step 1: Thread review_queue_path through merge_entities
In src/engine/mergers.py:
- Add
review_queue_path: Optional[str] = None, to the
merge_entities signature (after domain_config).
- Add
review_queue_path=review_queue_path, to the
run_merge_dispute_agent(...) call at line 927.
Verify: grep -n "review_queue_path" src/engine/mergers.py → 2+ matches
(signature + call site); just lint → exit 0
Step 2: Pass a default path from the pipeline
In src/process_and_extract.py, inside merge_and_finalize() where
domain_cfg is resolved and before the per-entity-type merge loop, add:
review_queue_path = os.path.join(
domain_cfg.get_output_dir(), "merge_review_queue.jsonl"
)
and pass review_queue_path=review_queue_path in the
merger.merge_entities(...) call. (os is already imported in this module.)
Verify: grep -n "merge_review_queue.jsonl" src/process_and_extract.py → 1 match; just test → all pass
Step 3: CLI viewer script
Create scripts/review_disputes.py:
- Args:
--domain (default "guantanamo"), --limit (default 50, newest
last). Resolve the queue path the same way the pipeline does:
DomainConfig(domain).get_output_dir() + merge_review_queue.jsonl.
- If the file is missing or empty: print
No deferred merge disputes for domain '<domain>'. and exit 0.
- Otherwise parse JSONL (skip and count malformed lines) and render a Rich
Table with columns drawn from the record fields. Read
_build_review_record in src/engine/merge_dispute_agent.py (it starts at
the line after the run_merge_dispute_agent body, ~line 175) to get the
exact field names — expect entity_type, new/existing names, similarity
score + threshold, match confidence, agent reason, article_id, domain.
Print a one-line summary at the end:
<N> deferred disputes (<M> malformed lines skipped).
- No LLM calls, read-only — the script must work offline.
Add to justfile under the Data Management section:
# Show deferred merge disputes awaiting human review
review-disputes domain="guantanamo" *args:
uv run python scripts/review_disputes.py --domain {{domain}} {{args}}
Verify: uv run python scripts/review_disputes.py --domain guantanamo →
exits 0 with either the table or the empty-queue message;
just review-disputes → same
Step 4: Tests
Extend tests/test_merge_dispute_agent_routing.py (reuse its existing LLM
mocking pattern — note from repo conventions: when a function is lazily
imported inside a function body, patch it at the source module; when
imported at module top level, patch it in the importing module's namespace.
Copy whatever the existing tests in this file already do — they pass in CI).
- DEFER persists: mock the generation call to return a
MergeDisputeDecision(action=DEFER, ...); call run_merge_dispute_agent
with review_queue_path=str(tmp_path / "q.jsonl") → the file exists and
contains exactly 1 JSON line whose entity_type / names match the input.
- MERGE does not persist: same setup, decision MERGE → file does not exist.
- No path, no write: DEFER with
review_queue_path=None → no file
created anywhere under tmp_path (and no exception).
Verify: uv run pytest tests/test_merge_dispute_agent_routing.py -v → all pass, including 3 new tests
Step 5: Full suite
Verify: just ci → exit 0
Test plan
Covered in Step 4 — three new tests in
tests/test_merge_dispute_agent_routing.py, modeled on that file's existing
mock style.
Done criteria
STOP conditions
Stop and report back (do not improvise) if:
run_merge_dispute_agent no longer has the review_queue_path parameter,
or the call at mergers.py:927 already passes it (drift — the finding was
fixed independently).
merge_and_finalize no longer constructs EntityMerger directly (the
call chain changed).
append_merge_dispute_review_queue turns out to do anything other than
append a JSON line to a file (e.g. it needs a lock or directory that
doesn't exist) — check whether it creates parent directories; if it
doesn't and get_output_dir() may not exist yet, report rather than
adding directory-creation logic to the agent module (which is out of scope).
Maintenance notes
- The queue only ever grows. A follow-up (deliberately deferred) is a
resolve/dismiss workflow — either a --resolve <id> flag on the script or
a frontend page — plus dedup of repeat disputes for the same entity pair
across runs.
- If Plan 001 (checkpointing) and this land together, note the queue file is
append-on-decision, not checkpoint-batched — a crash cannot lose queue
entries that were already written.
- Reviewer should scrutinize: that the default-on behavior (always writing
the queue file) is acceptable — it was chosen deliberately because the
file only appears when DEFER actually happens.
Plan 003: Persist deferred merge disputes to the review queue and add a CLI viewer
Status
5b5c634, 2026-06-12Why this matters
When an entity match falls in the "gray band" (similarity near the threshold,
match confidence low), a second-stage LLM — the merge dispute agent — decides
MERGE / SKIP / DEFER. The agent already contains fully built, tested code to
append DEFER cases to a JSONL review queue (
review_queue_pathparameter),but the only caller never passes that parameter, so every deferred case —
exactly the ones flagged as needing a human eye — is silently dropped. The
agent also returns DEFER on API errors, so those are lost too. After this
plan: DEFER records land in
{output_dir}/merge_review_queue.jsonlbydefault, and
just review-disputespretty-prints them so a researcher canactually triage.
Current state
src/engine/merge_dispute_agent.py— the dispute agent.run_merge_dispute_agent(...)(line 83) accepts keyword-only argsincluding
review_queue_path: Optional[str] = None(line 98).decision.action == MergeDisputeAction.DEFER and review_queue_path(line 155) it builds a record via
_build_review_record(...)and callsappend_merge_dispute_review_queue(review_queue_path, record)(line 169).reason=f"Dispute agent API error: ..."(lines 142–148), which alsoflows through the persistence branch.
src/engine/mergers.py— the only caller.EntityMerger.merge_entities(...)(line 707) signature today:The dispute-agent call (line 927) passes everything except
review_queue_path:src/process_and_extract.py—merge_and_finalize()(line 606) constructsEntityMerger(entity_type)and callsmerger.merge_entities(...)withdomain=processor.domain, domain_config=domain_cfg(see lines ~300–316).domain_cfg.get_output_dir()returns the domain output directory.justfile— task runner; recipes follow the patternname *args:\n uv run python scripts/<script>.py {{args}}.scripts/— utility scripts (e.g.scripts/list_domains.py); Rich is atransitive dependency already used for console output across the repo
(
from rich.console import Console,from rich.table import Tableareavailable).
Existing tests for the routing logic:
tests/test_merge_dispute_agent_routing.py— read it before writing tests and reuse its mocking approach for LLM calls.
Commands you will need
just testuv run pytest tests/test_merge_dispute_agent_routing.py -vjust format && just lintjust ciuv run python scripts/review_disputes.py --domain guantanamoScope
In scope (the only files you should modify/create):
src/engine/mergers.py(signature + pass-through only)src/process_and_extract.py(compute and pass the default path inmerge_and_finalize)scripts/review_disputes.py(create)justfile(one new recipe)tests/test_merge_dispute_agent_routing.py(extend)Out of scope (do NOT touch):
src/engine/merge_dispute_agent.py— the persistence code already works;do not modify it.
/disputesweb page is a separate, future plan.Git workflow
advisor/003-wire-dispute-review-queueSteps
Step 1: Thread
review_queue_paththroughmerge_entitiesIn
src/engine/mergers.py:review_queue_path: Optional[str] = None,to themerge_entitiessignature (afterdomain_config).review_queue_path=review_queue_path,to therun_merge_dispute_agent(...)call at line 927.Verify:
grep -n "review_queue_path" src/engine/mergers.py→ 2+ matches(signature + call site);
just lint→ exit 0Step 2: Pass a default path from the pipeline
In
src/process_and_extract.py, insidemerge_and_finalize()wheredomain_cfgis resolved and before the per-entity-type merge loop, add:and pass
review_queue_path=review_queue_pathin themerger.merge_entities(...)call. (osis already imported in this module.)Verify:
grep -n "merge_review_queue.jsonl" src/process_and_extract.py→ 1 match;just test→ all passStep 3: CLI viewer script
Create
scripts/review_disputes.py:--domain(default"guantanamo"),--limit(default 50, newestlast). Resolve the queue path the same way the pipeline does:
DomainConfig(domain).get_output_dir()+merge_review_queue.jsonl.No deferred merge disputes for domain '<domain>'.and exit 0.Tablewith columns drawn from the record fields. Read_build_review_recordinsrc/engine/merge_dispute_agent.py(it starts atthe line after the
run_merge_dispute_agentbody, ~line 175) to get theexact field names — expect entity_type, new/existing names, similarity
score + threshold, match confidence, agent reason, article_id, domain.
Print a one-line summary at the end:
<N> deferred disputes (<M> malformed lines skipped).Add to
justfileunder the Data Management section:Verify:
uv run python scripts/review_disputes.py --domain guantanamo→exits 0 with either the table or the empty-queue message;
just review-disputes→ sameStep 4: Tests
Extend
tests/test_merge_dispute_agent_routing.py(reuse its existing LLMmocking pattern — note from repo conventions: when a function is lazily
imported inside a function body, patch it at the source module; when
imported at module top level, patch it in the importing module's namespace.
Copy whatever the existing tests in this file already do — they pass in CI).
MergeDisputeDecision(action=DEFER, ...); callrun_merge_dispute_agentwith
review_queue_path=str(tmp_path / "q.jsonl")→ the file exists andcontains exactly 1 JSON line whose
entity_type/ names match the input.review_queue_path=None→ no filecreated anywhere under
tmp_path(and no exception).Verify:
uv run pytest tests/test_merge_dispute_agent_routing.py -v→ all pass, including 3 new testsStep 5: Full suite
Verify:
just ci→ exit 0Test plan
Covered in Step 4 — three new tests in
tests/test_merge_dispute_agent_routing.py, modeled on that file's existingmock style.
Done criteria
just ciexits 0grep -n "review_queue_path=review_queue_path" src/engine/mergers.py→ 1 matchgrep -n "merge_review_queue.jsonl" src/process_and_extract.py→ 1 matchscripts/review_disputes.pyexists;just review-disputesexits 0tests/test_merge_dispute_agent_routing.pygit status)plans/README.mdstatus row updatedSTOP conditions
Stop and report back (do not improvise) if:
run_merge_dispute_agentno longer has thereview_queue_pathparameter,or the call at
mergers.py:927already passes it (drift — the finding wasfixed independently).
merge_and_finalizeno longer constructsEntityMergerdirectly (thecall chain changed).
append_merge_dispute_review_queueturns out to do anything other thanappend a JSON line to a file (e.g. it needs a lock or directory that
doesn't exist) — check whether it creates parent directories; if it
doesn't and
get_output_dir()may not exist yet, report rather thanadding directory-creation logic to the agent module (which is out of scope).
Maintenance notes
resolve/dismiss workflow — either a
--resolve <id>flag on the script ora frontend page — plus dedup of repeat disputes for the same entity pair
across runs.
append-on-decision, not checkpoint-batched — a crash cannot lose queue
entries that were already written.
the queue file) is acceptable — it was chosen deliberately because the
file only appears when DEFER actually happens.