← Files ClaraARCHIVED FILE

scripts/approve_judgements.py

10.7 KB · Oct 2, 2026 · 00:29 UTC

↓ Download file

"""List candidate judgement entries or mark them for decision-pack use."""

from __future__ import annotations

# Direct CLI calls must select dependencies before importing workflow modules.
if __name__ == "__main__":
    import runpy as _runpy
    from pathlib import Path as _Path

    for _parent in _Path(__file__).resolve().parents:
        _launcher = _parent / "scripts" / "self_relaunch.py"
        if not _launcher.is_file():
            _launcher = _parent / "clara" / "scripts" / "self_relaunch.py"
        if _launcher.is_file():
            _runpy.run_path(str(_launcher))["ensure_running_in_managed_venv"](__file__)
            break
    else:
        # Standalone components retain their host's dependency setup.
        if any(
            (_p / "components.json").is_file()
            for _p in _Path(__file__).resolve().parents
        ):
            raise SystemExit(
                "Managed Python launcher is missing; rebuild the plugin package."
            )

import argparse
import json
import logging
import re
from pathlib import Path
from typing import Any

from advisor_case_core import (
    INCLUSION_BUNDLES_FILENAME,
    JUDGEMENT_STATUSES,
    CaseWorkspaceError,
    load_case_file,
    set_judgement_statuses,
)

__all__ = ["main"]

LOGGER = logging.getLogger(__name__)


def _compact_text(value: str, *, limit: int = 170) -> str:
    text = re.sub(r"\s+", " ", value).strip()
    if len(text) <= limit:
        return text
    return f"{text[:limit].rstrip()}..."


def _material_titles(case_dir: Path) -> dict[str, str]:
    registry = load_case_file(case_dir, "materials")
    return {
        str(material["id"]): str(material.get("title") or material["id"])
        for material in registry["materials"]
    }


def _pending_entries(case_dir: Path) -> list[dict[str, Any]]:
    judgement = load_case_file(case_dir, "judgement")
    return [entry for entry in judgement["entries"] if entry.get("status") == "pending"]


def _pending_bundle_groups(
    case_dir: Path,
    pending_entries: list[dict[str, Any]],
) -> list[dict[str, Any]]:
    bundles_path = case_dir / INCLUSION_BUNDLES_FILENAME
    if not bundles_path.exists():
        return []
    payload = json.loads(bundles_path.read_text(encoding="utf-8"))
    bundles = payload.get("bundles", []) if isinstance(payload, dict) else []
    pending_by_id = {str(entry["id"]): entry for entry in pending_entries}
    groups: list[dict[str, Any]] = []
    for bundle in bundles:
        if not isinstance(bundle, dict):
            continue
        entries = [
            pending_by_id[str(entry_id)]
            for entry_id in bundle.get("entry_ids", [])
            if str(entry_id) in pending_by_id
        ]
        if entries:
            groups.append(
                {
                    "id": str(bundle.get("id", "")).strip(),
                    "title": str(bundle.get("title", "")).strip(),
                    "entries": entries,
                }
            )
    return groups


def _log_pending_summary(
    case_dir: Path,
    *,
    show_ids: bool = False,
) -> list[dict[str, Any]]:
    pending_entries = _pending_entries(case_dir)
    pending_bundles = _pending_bundle_groups(case_dir, pending_entries)
    material_titles = _material_titles(case_dir)
    if not pending_entries:
        LOGGER.info("No candidate judgement entries waiting for decision-pack use.")
        return []

    LOGGER.info("Candidate decision-pack entries (%s):", len(pending_entries))
    if pending_bundles:
        LOGGER.info("Candidate inclusion bundles (%s):", len(pending_bundles))
        pending_index_by_id = {
            str(entry["id"]): index
            for index, entry in enumerate(pending_entries, start=1)
        }
        for index, bundle in enumerate(pending_bundles, start=1):
            item_numbers = [
                str(pending_index_by_id[str(entry["id"])])
                for entry in bundle["entries"]
            ]
            LOGGER.info(
                "Bundle %s. %s (%s item%s): items %s",
                index,
                bundle["title"] or bundle["id"],
                len(bundle["entries"]),
                "" if len(bundle["entries"]) == 1 else "s",
                ", ".join(item_numbers),
            )
    for index, entry in enumerate(pending_entries, start=1):
        source_titles = [
            material_titles.get(str(source_id), str(source_id))
            for source_id in entry.get("source_material_ids", [])
        ]
        source_text = ""
        if source_titles:
            source_text = " Sources: " + ", ".join(source_titles) + "."
        entry_label = f"{index}."
        if show_ids:
            entry_label = f"{entry_label} {entry['id']}"
        LOGGER.info(
            "%s [%s] %s%s",
            entry_label,
            entry["kind"],
            _compact_text(str(entry["text"])),
            source_text,
        )
    return pending_entries


def _log_next_actions() -> None:
    LOGGER.info("")
    LOGGER.info("Tell Clara: include all pending items.")
    LOGGER.info("Tell Clara: include bundle <number>.")
    LOGGER.info(
        "Tell Clara: include item <number>, exclude item <number>, or correct item <number>."
    )
    LOGGER.info(
        "Clara will record the inclusion decision mechanically after confirmation."
    )


def main() -> int:
    """Run candidate summary or bulk decision-pack status update."""

    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("case_dir", type=Path)
    parser.add_argument(
        "entry_ids",
        nargs="*",
        help="Advanced: specific judgement entry IDs to update after review.",
    )
    parser.add_argument(
        "--item",
        action="append",
        default=[],
        type=int,
        help="1-based item number from the candidate summary to update.",
    )
    parser.add_argument(
        "--bundle",
        action="append",
        default=[],
        help="1-based bundle number or bundle ID from the candidate summary to update.",
    )
    parser.add_argument(
        "--all-pending",
        action="store_true",
        help="Mark every candidate entry shown in the summary.",
    )
    status_group = parser.add_mutually_exclusive_group()
    status_group.add_argument(
        "--status",
        choices=sorted(JUDGEMENT_STATUSES),
        default=None,
        help="Advanced internal status to apply: approved=include, rejected=exclude.",
    )
    status_group.add_argument(
        "--include",
        action="store_true",
        help="Mark selected entries ready for the client pack.",
    )
    status_group.add_argument(
        "--exclude",
        action="store_true",
        help="Exclude selected entries from the client pack.",
    )
    parser.add_argument(
        "--reviewer",
        "--recorded-by",
        dest="reviewer",
        default="",
        help="Name recorded in the audit log; for solo work, use the advisor name.",
    )
    parser.add_argument("--review-note", default="")
    parser.add_argument(
        "--list-only",
        action="store_true",
        help="Show the candidate summary without applying any update.",
    )
    parser.add_argument(
        "--show-ids",
        action="store_true",
        help="Include stable judgement entry IDs in the pending summary.",
    )
    args = parser.parse_args()
    logging.basicConfig(level=logging.INFO, format="%(message)s")

    if args.all_pending and (args.entry_ids or args.item or args.bundle):
        parser.error(
            "use --all-pending, --bundle, --item, or explicit entry IDs; not together"
        )
    selected_modes = sum(
        bool(mode) for mode in (args.entry_ids, args.item, args.bundle)
    )
    if selected_modes > 1:
        parser.error("use --bundle, --item, or explicit entry IDs; not together")

    try:
        pending_entries = _log_pending_summary(args.case_dir, show_ids=args.show_ids)
    except (CaseWorkspaceError, json.JSONDecodeError) as exc:
        parser.error(str(exc))
    if args.list_only or (
        not args.all_pending
        and not args.entry_ids
        and not args.item
        and not args.bundle
    ):
        _log_next_actions()
        return 0
    if args.all_pending and not pending_entries:
        return 0

    if args.all_pending:
        entry_ids = [entry["id"] for entry in pending_entries]
    elif args.bundle:
        pending_bundles = _pending_bundle_groups(args.case_dir, pending_entries)
        selected_entry_ids: list[str] = []
        for raw_selector in args.bundle:
            selector = str(raw_selector).strip()
            selected_bundle: dict[str, Any] | None = None
            if selector.isdigit():
                bundle_number = int(selector)
                if bundle_number < 1 or bundle_number > len(pending_bundles):
                    parser.error(
                        f"candidate summary bundle number out of range: {selector}"
                    )
                selected_bundle = pending_bundles[bundle_number - 1]
            else:
                matches = [
                    bundle
                    for bundle in pending_bundles
                    if selector == bundle["id"]
                    or selector.lower() == str(bundle["title"]).strip().lower()
                ]
                if not matches:
                    parser.error(f"candidate summary bundle not found: {selector}")
                if len(matches) > 1:
                    parser.error(
                        f"candidate summary bundle selector is ambiguous: {selector}"
                    )
                selected_bundle = matches[0]
            selected_entry_ids.extend(
                entry["id"] for entry in selected_bundle["entries"]
            )
        entry_ids = selected_entry_ids
    elif args.item:
        invalid_items = [
            item for item in args.item if item < 1 or item > len(pending_entries)
        ]
        if invalid_items:
            parser.error(
                "candidate summary item number out of range: "
                + ", ".join(str(item) for item in invalid_items)
            )
        entry_ids = [pending_entries[item - 1]["id"] for item in args.item]
    else:
        entry_ids = args.entry_ids
    status = args.status
    if status is None:
        status = "rejected" if args.exclude else "approved"

    updated_entries = set_judgement_statuses(
        args.case_dir,
        entry_ids,
        status=status,
        reviewer=args.reviewer,
        review_note=args.review_note,
    )

    LOGGER.info("")
    LOGGER.info(
        "Judgement entries marked: %s status=%s.",
        len(updated_entries),
        status,
    )
    for entry in updated_entries:
        LOGGER.info(
            "- [%s] %s",
            entry["kind"],
            _compact_text(str(entry["text"]), limit=120),
        )
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

SHA-256: 31c259c97fdf447b213a95d34cf2899dc349347e2f96f3af895c7165a4d628ea