feat(w019): wire core teaching propose-miner/propose-curriculum CLI commands (#266)
Closes W-019 wiring debt. Per Phase 2 operator decision (path a):
wire CLI — smallest reachability fix, no architectural commitment.
teaching/from_miner.py and teaching/from_curriculum.py (ADR-0095/ADR-0104)
correctly build source-stamped PackMutationProposals but had no CLI or
runtime caller — test-live only. Now reachable:
core teaching propose-miner \
--findings <jsonl> --miner-id <id> [--revision <rev>] [--out <jsonl>]
core teaching propose-curriculum \
--findings <jsonl> --curriculum-id <id> [--revision <rev>] [--out <jsonl>]
Changes to core/cli.py:
- _load_findings_jsonl(): deserializes ContemplationFinding records from
operator-provided JSONL (as_dict() round-trip format).
- _read_jsonl_file(): shared JSONL line reader.
- cmd_teaching_propose_miner(): calls from_miner.from_findings(); writes
proposals to --out JSONL or stdout; prints proposals/rejections summary
to stderr. Returns 0 if any proposals built, 1 otherwise.
- cmd_teaching_propose_curriculum(): same shape for curriculum path.
- _current_git_revision(): --revision default, falls back to "unknown".
- _write_miner_curriculum_batch(): shared proposal serialisation + summary.
- Two new subcommands registered: propose-miner, propose-curriculum.
tests/test_teaching_propose_cli.py: 5 tests covering round-trip loading,
stdout output, file output, and empty-findings error path.
This commit is contained in:
parent
a1a085057e
commit
2c5eb2e36b
2 changed files with 330 additions and 0 deletions
169
core/cli.py
169
core/cli.py
|
|
@ -1230,6 +1230,130 @@ def cmd_teaching_propose(args: argparse.Namespace) -> int:
|
|||
return 0 if rec["state"] in ("pending", "accepted") else 1
|
||||
|
||||
|
||||
def _load_findings_jsonl(path: str) -> list:
|
||||
"""Load ContemplationFinding objects from a JSONL file (W-019)."""
|
||||
from core.contemplation.schema import (
|
||||
ContemplationEvidenceRef, ContemplationFinding, FindingKind,
|
||||
)
|
||||
from teaching.epistemic import EpistemicStatus
|
||||
|
||||
findings = []
|
||||
for raw in _read_jsonl_file(Path(path)):
|
||||
evidence_refs = tuple(
|
||||
ContemplationEvidenceRef(
|
||||
source_type=e["source_type"],
|
||||
source_id=e["source_id"],
|
||||
pointer=e["pointer"],
|
||||
summary=e.get("summary", ""),
|
||||
)
|
||||
for e in raw.get("evidence_refs", [])
|
||||
)
|
||||
findings.append(ContemplationFinding(
|
||||
kind=FindingKind(raw["kind"]),
|
||||
subject=raw["subject"],
|
||||
predicate=raw["predicate"],
|
||||
object=raw.get("object"),
|
||||
evidence_refs=evidence_refs,
|
||||
proposed_action=raw["proposed_action"],
|
||||
substrate_hash=raw.get("substrate_hash", ""),
|
||||
epistemic_status=EpistemicStatus(
|
||||
raw.get("epistemic_status", EpistemicStatus.SPECULATIVE.value)
|
||||
),
|
||||
finding_id=raw.get("finding_id", ""),
|
||||
))
|
||||
return findings
|
||||
|
||||
|
||||
def _read_jsonl_file(path: Path) -> list:
|
||||
"""Read a JSONL file and return a list of parsed dicts."""
|
||||
lines = []
|
||||
with path.open(encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if line:
|
||||
lines.append(json.loads(line))
|
||||
return lines
|
||||
|
||||
|
||||
def cmd_teaching_propose_miner(args: argparse.Namespace) -> int:
|
||||
"""W-019: build PackMutationProposals from miner ContemplationFinding JSONL."""
|
||||
from teaching.from_miner import MinerProposalError, from_findings
|
||||
|
||||
findings = _load_findings_jsonl(args.findings)
|
||||
if not findings:
|
||||
_die(f"no findings in {args.findings}", code=1)
|
||||
|
||||
revision = args.revision or _current_git_revision()
|
||||
try:
|
||||
batch = from_findings(
|
||||
findings,
|
||||
miner_id=args.miner_id,
|
||||
emitted_at_revision=revision,
|
||||
)
|
||||
except MinerProposalError as exc:
|
||||
_die(f"batch construction failed: {exc}", code=1)
|
||||
|
||||
out_path = Path(args.out) if args.out else None
|
||||
_write_miner_curriculum_batch(batch.proposals, batch.rejections, out_path)
|
||||
return 0 if batch.proposals else 1
|
||||
|
||||
|
||||
def cmd_teaching_propose_curriculum(args: argparse.Namespace) -> int:
|
||||
"""W-019: build PackMutationProposals from curriculum ContemplationFinding JSONL."""
|
||||
from teaching.from_curriculum import CurriculumProposalError, from_findings
|
||||
|
||||
findings = _load_findings_jsonl(args.findings)
|
||||
if not findings:
|
||||
_die(f"no findings in {args.findings}", code=1)
|
||||
|
||||
revision = args.revision or _current_git_revision()
|
||||
try:
|
||||
batch = from_findings(
|
||||
findings,
|
||||
curriculum_id=args.curriculum_id,
|
||||
emitted_at_revision=revision,
|
||||
)
|
||||
except CurriculumProposalError as exc:
|
||||
_die(f"batch construction failed: {exc}", code=1)
|
||||
|
||||
out_path = Path(args.out) if args.out else None
|
||||
_write_miner_curriculum_batch(batch.proposals, batch.rejections, out_path)
|
||||
return 0 if batch.proposals else 1
|
||||
|
||||
|
||||
def _current_git_revision() -> str:
|
||||
"""Return the current git HEAD SHA (first 12 chars) or 'unknown'."""
|
||||
import subprocess
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--short=12", "HEAD"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
)
|
||||
return result.stdout.strip() or "unknown"
|
||||
except Exception: # noqa: BLE001
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _write_miner_curriculum_batch(
|
||||
proposals: tuple,
|
||||
rejections: tuple,
|
||||
out_path: Path | None,
|
||||
) -> None:
|
||||
"""Write PackMutationProposal batch to JSONL and print summary."""
|
||||
lines = [json.dumps(p.as_dict(), sort_keys=True, ensure_ascii=False) for p in proposals]
|
||||
if out_path is not None:
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
|
||||
print(f"wrote {len(proposals)} proposal(s) → {out_path}")
|
||||
else:
|
||||
for line in lines:
|
||||
print(line)
|
||||
print(f"proposals : {len(proposals)}", file=sys.stderr)
|
||||
print(f"rejections: {len(rejections)}", file=sys.stderr)
|
||||
for rej in rejections:
|
||||
print(f" rejected {rej.get('finding_id', '?')}: {rej.get('reason', '?')}", file=sys.stderr)
|
||||
|
||||
|
||||
def cmd_teaching_proposals(args: argparse.Namespace) -> int:
|
||||
from teaching.proposals import ProposalLog
|
||||
|
||||
|
|
@ -3383,6 +3507,51 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
)
|
||||
teaching_propose.set_defaults(func=cmd_teaching_propose)
|
||||
|
||||
# W-019 — miner and curriculum proposal construction paths (ADR-0095/0104)
|
||||
teaching_propose_miner = teaching_sub.add_parser(
|
||||
"propose-miner",
|
||||
help="build PackMutationProposals from miner ContemplationFinding JSONL (ADR-0095)",
|
||||
)
|
||||
teaching_propose_miner.add_argument(
|
||||
"--findings", required=True,
|
||||
help="path to JSONL file of ContemplationFinding records (kind=pack_mutation_candidate)",
|
||||
)
|
||||
teaching_propose_miner.add_argument(
|
||||
"--miner-id", required=True,
|
||||
help="miner identifier stamped on proposals (e.g. 'articulation_quality_v1')",
|
||||
)
|
||||
teaching_propose_miner.add_argument(
|
||||
"--revision", default=None,
|
||||
help="emitted_at_revision string (defaults to current git HEAD SHA)",
|
||||
)
|
||||
teaching_propose_miner.add_argument(
|
||||
"--out", default=None,
|
||||
help="output JSONL path for proposals (default: stdout)",
|
||||
)
|
||||
teaching_propose_miner.set_defaults(func=cmd_teaching_propose_miner)
|
||||
|
||||
teaching_propose_curriculum = teaching_sub.add_parser(
|
||||
"propose-curriculum",
|
||||
help="build PackMutationProposals from curriculum ContemplationFinding JSONL (ADR-0104)",
|
||||
)
|
||||
teaching_propose_curriculum.add_argument(
|
||||
"--findings", required=True,
|
||||
help="path to JSONL file of ContemplationFinding records (kind=pack_mutation_candidate)",
|
||||
)
|
||||
teaching_propose_curriculum.add_argument(
|
||||
"--curriculum-id", required=True,
|
||||
help="curriculum identifier stamped on proposals (e.g. 'gsm8k_curriculum_v1')",
|
||||
)
|
||||
teaching_propose_curriculum.add_argument(
|
||||
"--revision", default=None,
|
||||
help="emitted_at_revision string (defaults to current git HEAD SHA)",
|
||||
)
|
||||
teaching_propose_curriculum.add_argument(
|
||||
"--out", default=None,
|
||||
help="output JSONL path for proposals (default: stdout)",
|
||||
)
|
||||
teaching_propose_curriculum.set_defaults(func=cmd_teaching_propose_curriculum)
|
||||
|
||||
teaching_proposals = teaching_sub.add_parser(
|
||||
"proposals",
|
||||
help="list proposals in the append-only log",
|
||||
|
|
|
|||
161
tests/test_teaching_propose_cli.py
Normal file
161
tests/test_teaching_propose_cli.py
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
"""Tests for W-019: core teaching propose-miner / propose-curriculum CLI commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from core.contemplation.schema import (
|
||||
ContemplationEvidenceRef,
|
||||
ContemplationFinding,
|
||||
FindingKind,
|
||||
)
|
||||
from teaching.epistemic import EpistemicStatus
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _good_finding(
|
||||
*,
|
||||
subject: str = "knowledge",
|
||||
predicate: str = "requires",
|
||||
object_: str | None = "evidence",
|
||||
proposed_action: str = "extend cognition pack with knowledge→evidence chain",
|
||||
substrate_hash: str = "abc123",
|
||||
) -> ContemplationFinding:
|
||||
return ContemplationFinding(
|
||||
kind=FindingKind.PACK_MUTATION_CANDIDATE,
|
||||
subject=subject,
|
||||
predicate=predicate,
|
||||
object=object_,
|
||||
evidence_refs=(
|
||||
ContemplationEvidenceRef(
|
||||
source_type="articulation_observation",
|
||||
source_id="run-1",
|
||||
pointer="turn:1",
|
||||
summary="weak surface",
|
||||
),
|
||||
),
|
||||
proposed_action=proposed_action,
|
||||
substrate_hash=substrate_hash,
|
||||
)
|
||||
|
||||
|
||||
def _write_findings_jsonl(tmp_path: Path, findings: list) -> Path:
|
||||
path = tmp_path / "findings.jsonl"
|
||||
lines = [json.dumps(f.as_dict(), sort_keys=True, ensure_ascii=False) for f in findings]
|
||||
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _load_findings_jsonl — unit test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_load_findings_jsonl_round_trips(tmp_path: Path) -> None:
|
||||
from core.cli import _load_findings_jsonl
|
||||
finding = _good_finding()
|
||||
jsonl_path = _write_findings_jsonl(tmp_path, [finding])
|
||||
loaded = _load_findings_jsonl(str(jsonl_path))
|
||||
assert len(loaded) == 1
|
||||
assert loaded[0].subject == "knowledge"
|
||||
assert loaded[0].kind is FindingKind.PACK_MUTATION_CANDIDATE
|
||||
assert loaded[0].epistemic_status is EpistemicStatus.SPECULATIVE
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cmd_teaching_propose_miner
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_propose_miner_writes_proposals_to_stdout(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
import argparse
|
||||
from core.cli import cmd_teaching_propose_miner
|
||||
|
||||
finding = _good_finding()
|
||||
jsonl_path = _write_findings_jsonl(tmp_path, [finding])
|
||||
|
||||
args = argparse.Namespace(
|
||||
findings=str(jsonl_path),
|
||||
miner_id="test_miner_v1",
|
||||
revision="test-revision",
|
||||
out=None,
|
||||
)
|
||||
rc = cmd_teaching_propose_miner(args)
|
||||
assert rc == 0
|
||||
captured = capsys.readouterr()
|
||||
proposal = json.loads(captured.out.strip().splitlines()[0])
|
||||
assert "proposal_id" in proposal
|
||||
assert proposal["subject"] == "knowledge"
|
||||
|
||||
|
||||
def test_propose_miner_writes_to_file(tmp_path: Path) -> None:
|
||||
import argparse
|
||||
from core.cli import cmd_teaching_propose_miner
|
||||
|
||||
finding = _good_finding()
|
||||
jsonl_path = _write_findings_jsonl(tmp_path, [finding])
|
||||
out_path = tmp_path / "out.jsonl"
|
||||
|
||||
args = argparse.Namespace(
|
||||
findings=str(jsonl_path),
|
||||
miner_id="test_miner_v1",
|
||||
revision="test-revision",
|
||||
out=str(out_path),
|
||||
)
|
||||
rc = cmd_teaching_propose_miner(args)
|
||||
assert rc == 0
|
||||
lines = out_path.read_text(encoding="utf-8").strip().splitlines()
|
||||
assert len(lines) == 1
|
||||
proposal = json.loads(lines[0])
|
||||
assert proposal["subject"] == "knowledge"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cmd_teaching_propose_curriculum
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_propose_curriculum_writes_proposals_to_stdout(
|
||||
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
import argparse
|
||||
from core.cli import cmd_teaching_propose_curriculum
|
||||
|
||||
finding = _good_finding(subject="truth", predicate="grounds", proposed_action="ground truth chain")
|
||||
jsonl_path = _write_findings_jsonl(tmp_path, [finding])
|
||||
|
||||
args = argparse.Namespace(
|
||||
findings=str(jsonl_path),
|
||||
curriculum_id="gsm8k_curriculum_v1",
|
||||
revision="test-revision",
|
||||
out=None,
|
||||
)
|
||||
rc = cmd_teaching_propose_curriculum(args)
|
||||
assert rc == 0
|
||||
captured = capsys.readouterr()
|
||||
proposal = json.loads(captured.out.strip().splitlines()[0])
|
||||
assert "proposal_id" in proposal
|
||||
assert proposal["subject"] == "truth"
|
||||
|
||||
|
||||
def test_propose_miner_returns_nonzero_on_empty_findings(tmp_path: Path) -> None:
|
||||
import argparse
|
||||
from core.cli import cmd_teaching_propose_miner
|
||||
|
||||
empty_path = tmp_path / "empty.jsonl"
|
||||
empty_path.write_text("", encoding="utf-8")
|
||||
|
||||
args = argparse.Namespace(
|
||||
findings=str(empty_path),
|
||||
miner_id="test_miner_v1",
|
||||
revision="test-revision",
|
||||
out=None,
|
||||
)
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
cmd_teaching_propose_miner(args)
|
||||
assert exc_info.value.code == 1
|
||||
Loading…
Reference in a new issue