Files
legal-ai/scripts/backfill_case_status_trim.py
Chaim 5867f7175f
All checks were successful
G12 Leak-Guard / leak-guard (pull_request) Successful in 3s
Lint — undefined names / undefined-names (pull_request) Successful in 10s
docs(status): align agent status maps to the canonical (nested) status model
Follow-up to the status SSoT (#382): analyst_verified + research_complete are
now first-class canonical statuses, so refresh the docs that still framed them
as legacy/decorative.

- HEARTBEAT.md §7: rewritten to point at the single source
  (case_status_model.py / GET /api/status-model), list the 12 canonical statuses
  by phase, and state the two intermediates are canonical (not legacy); genuinely
  old markers (proofread/analysis_enriched/…) are display-only fallbacks.
- legal-qa.md / legal-ceo.md status maps: dropped the "legacy/optional" framing
  on research_complete; point to the canonical model.
- scripts/backfill_case_status_trim.py: removed analyst_verified +
  research_complete from the downgrade map (they are valid statuses again — must
  NOT be downgraded) + updated the docstring.

Docs/prompts only — no behaviour change. py_compile clean.
NOTE: the host ~/legal-ai needs `git pull` for the Paperclip agents to pick up
the refreshed prompts (they run on the host tree, not the container).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 19:52:27 +00:00

116 lines
4.1 KiB
Python

"""Backfill case.status after the 17 → 10 status-menu trim.
Why this exists: the manual status menu was trimmed from 17 to 10 core
statuses (decorative mid-stage markers that no pipeline code ever set were
removed). Existing rows that currently hold a removed status would otherwise
be "stuck" on a value no longer in the dropdown / SSoT, rendering via the
Hebrew legacy fallback. This maps each removed status to the nearest
*preceding* kept status in the lifecycle order, so a case keeps the closest
truthful position.
Mapping (removed → kept):
uploading → processing
in_progress → outcome_set
brainstorming → outcome_set
analysis_enriched → direction_approved
ready_for_writing → direction_approved
drafting → direction_approved
qa_failed → qa_review
Idempotent: a second run is a no-op (no rows match the removed statuses).
Dry-run by default — prints the affected counts; pass --apply to write.
Usage (runs inside the legal-ai container — shared Postgres on :5433):
docker cp scripts/backfill_case_status_trim.py <c>:/tmp/
docker exec <c> python /tmp/backfill_case_status_trim.py # dry-run
docker exec <c> python /tmp/backfill_case_status_trim.py --apply # write
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import sys
from pathlib import Path
def _setup_paths():
here = Path(__file__).resolve().parent
mcp_src = here.parent / "mcp-server" / "src"
if mcp_src.is_dir() and str(mcp_src) not in sys.path:
sys.path.insert(0, str(mcp_src))
_setup_paths()
from legal_mcp.services import db # noqa: E402
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
log = logging.getLogger("status-trim")
# removed status → nearest preceding kept status.
# NOTE (2026-06-30): analyst_verified + research_complete were RE-CANONICALISED
# into the status model (legal_mcp/case_status_model.py, "thinking" phase) — they
# are valid statuses again, so they were removed from this map. Do NOT downgrade
# them; only genuinely-removed markers remain below.
STATUS_MAP = {
"uploading": "processing",
"in_progress": "outcome_set",
"brainstorming": "outcome_set",
"analysis_enriched": "direction_approved",
"ready_for_writing": "direction_approved",
"drafting": "direction_approved",
"qa_failed": "qa_review",
}
async def backfill(apply: bool) -> int:
pool = await db.get_pool()
# Show the full current distribution for context.
dist = await pool.fetch("SELECT status, count(*) AS n FROM cases GROUP BY status ORDER BY n DESC")
log.info("Current status distribution:")
for r in dist:
log.info(" %-22s %d", r["status"], r["n"])
affected = {r["status"]: r["n"] for r in dist if r["status"] in STATUS_MAP}
total = sum(affected.values())
if not total:
log.info("Nothing to migrate — no rows hold a removed status. ✓")
return 0
log.info("Rows to migrate (%d total):", total)
for old, n in affected.items():
log.info(" %-22s%-20s (%d)", old, STATUS_MAP[old], n)
if not apply:
log.info("DRY-RUN — no changes written. Re-run with --apply to migrate.")
return total
migrated = 0
for old, new in STATUS_MAP.items():
if old not in affected:
continue
res = await pool.execute(
"UPDATE cases SET status = $1, updated_at = now() WHERE status = $2",
new, old,
)
# res like "UPDATE 3"
n = int(res.split()[-1]) if res and res.split()[-1].isdigit() else 0
migrated += n
log.info(" migrated %-22s%-20s (%d)", old, new, n)
log.info("Done — migrated %d rows.", migrated)
return migrated
def main() -> int:
parser = argparse.ArgumentParser(description="Backfill case.status after the 17→10 status trim")
parser.add_argument("--apply", action="store_true", help="Write changes (default: dry-run)")
args = parser.parse_args()
return 0 if asyncio.run(backfill(args.apply)) >= 0 else 1
if __name__ == "__main__":
raise SystemExit(main())