feat: implement CORE Workbench W3 Ratification Corridor
This commit is contained in:
parent
2cb09223e6
commit
6d1f6bba0c
26 changed files with 1923 additions and 57 deletions
|
|
@ -236,6 +236,7 @@ def apply_composition_claim(
|
|||
surface_pattern: str | None = None,
|
||||
pack_root: Path | None = None,
|
||||
evidence_source: str = "math_audit",
|
||||
ratifier_kind: str = "cli",
|
||||
) -> CompositionRatificationReceipt:
|
||||
"""Apply a reviewed composition claim to a math composition source file.
|
||||
|
||||
|
|
@ -343,6 +344,7 @@ def apply_composition_claim(
|
|||
)
|
||||
existing_evidence.append(claim.evidence_hash)
|
||||
existing["evidence_hashes"] = sorted(set(existing_evidence))
|
||||
existing["ratifier_kind"] = ratifier_kind
|
||||
is_duplicate_evidence = True
|
||||
else:
|
||||
entries.append(
|
||||
|
|
@ -352,6 +354,7 @@ def apply_composition_claim(
|
|||
"polarity": polarity,
|
||||
"provenance": provenance,
|
||||
"evidence_hashes": [claim.evidence_hash],
|
||||
"ratifier_kind": ratifier_kind,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -223,6 +223,7 @@ def apply_frame_claim(
|
|||
reviewer: str,
|
||||
pack_root: Path | None = None,
|
||||
evidence_source: str = "math_audit",
|
||||
ratifier_kind: str = "cli",
|
||||
) -> FrameRatificationReceipt:
|
||||
"""Apply a reviewed frame claim to a math frame source file.
|
||||
|
||||
|
|
@ -320,6 +321,7 @@ def apply_frame_claim(
|
|||
)
|
||||
existing_evidence.append(claim.evidence_hash)
|
||||
existing["evidence_hashes"] = sorted(set(existing_evidence))
|
||||
existing["ratifier_kind"] = ratifier_kind
|
||||
is_duplicate_evidence = True
|
||||
else:
|
||||
entries.append(
|
||||
|
|
@ -329,6 +331,7 @@ def apply_frame_claim(
|
|||
"polarity": polarity,
|
||||
"provenance": provenance,
|
||||
"evidence_hashes": [claim.evidence_hash],
|
||||
"ratifier_kind": ratifier_kind,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -197,6 +197,7 @@ def apply_lexical_claim(
|
|||
category: str,
|
||||
reviewer: str,
|
||||
pack_root: Path | None = None,
|
||||
ratifier_kind: str = "cli",
|
||||
) -> RatificationReceipt:
|
||||
"""Apply a reviewed lexical claim to a math source lexicon file.
|
||||
|
||||
|
|
@ -267,6 +268,7 @@ def apply_lexical_claim(
|
|||
)
|
||||
aliases.append(lemma)
|
||||
parent["aliases"] = sorted(set(aliases))
|
||||
parent["ratifier_kind"] = ratifier_kind
|
||||
is_alias = True
|
||||
aliased_to = parent_lemma
|
||||
else:
|
||||
|
|
@ -276,6 +278,7 @@ def apply_lexical_claim(
|
|||
"category": category,
|
||||
"aliases": [],
|
||||
"provenance": provenance,
|
||||
"ratifier_kind": ratifier_kind,
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
70
tests/test_workbench_operator_telemetry.py
Normal file
70
tests/test_workbench_operator_telemetry.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from chat.telemetry import JsonlBufferSink
|
||||
from tests.workbench_test_helper import setup_isolated_workbench, make_and_write_proposal
|
||||
|
||||
|
||||
def test_workbench_operator_telemetry_emission_and_redaction(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
api = setup_isolated_workbench(tmp_path, monkeypatch)
|
||||
sink = JsonlBufferSink()
|
||||
api.attach_telemetry_sink(sink)
|
||||
|
||||
# Generate proposal
|
||||
proposal = make_and_write_proposal(
|
||||
api,
|
||||
proposed_change_kind="vocabulary_addition",
|
||||
evidence_surface="testlemma",
|
||||
evidence_sub_type="lexical",
|
||||
missing_operator="drain_token",
|
||||
refusal_reason="unknown_word",
|
||||
)
|
||||
|
||||
# 1. Test operator_ratify success
|
||||
body = json.dumps({"category": "drain_token"}).encode("utf-8")
|
||||
response_ratify = api.handle("POST", f"/math-proposals/{proposal.proposal_id}/ratify", body)
|
||||
assert response_ratify.status == 200
|
||||
|
||||
assert len(sink.lines) == 1
|
||||
event = json.loads(sink.lines[-1])
|
||||
assert event["event"] == "operator_ratify"
|
||||
assert event["proposal_id"] == proposal.proposal_id
|
||||
assert event["handler"] == "LexicalClaim"
|
||||
assert event["outcome"] == "applied"
|
||||
assert event["ratifier_kind"] == "workbench"
|
||||
# Ensure no evidence surface is leaked
|
||||
assert "testlemma" not in sink.lines[-1]
|
||||
|
||||
# 2. Test operator_ratify failure (rejected_precondition)
|
||||
# Re-ratifying will fail (AlreadyRatified)
|
||||
api.handle("POST", f"/math-proposals/{proposal.proposal_id}/ratify", body)
|
||||
assert len(sink.lines) == 2
|
||||
event_fail = json.loads(sink.lines[-1])
|
||||
assert event_fail["event"] == "operator_ratify"
|
||||
assert event_fail["outcome"] == "rejected_precondition"
|
||||
assert "testlemma" not in sink.lines[-1]
|
||||
|
||||
# 3. Test operator_reject
|
||||
body_reject = json.dumps({"note": "Spam proposal"}).encode("utf-8")
|
||||
response_reject = api.handle("POST", f"/math-proposals/{proposal.proposal_id}/reject", body_reject)
|
||||
assert response_reject.status == 200
|
||||
|
||||
assert len(sink.lines) == 3
|
||||
event_reject = json.loads(sink.lines[-1])
|
||||
assert event_reject["event"] == "operator_reject"
|
||||
assert event_reject["note"] == "Spam proposal"
|
||||
assert event_reject["handler"] == "LexicalClaim"
|
||||
assert "testlemma" not in sink.lines[-1]
|
||||
|
||||
# 4. Test operator_defer
|
||||
response_defer = api.handle("POST", f"/math-proposals/{proposal.proposal_id}/defer", b"")
|
||||
assert response_defer.status == 200
|
||||
|
||||
assert len(sink.lines) == 4
|
||||
event_defer = json.loads(sink.lines[-1])
|
||||
assert event_defer["event"] == "operator_defer"
|
||||
assert event_defer["handler"] == "LexicalClaim"
|
||||
assert "testlemma" not in sink.lines[-1]
|
||||
82
tests/test_workbench_ratify_case_0050_hazard_pin.py
Normal file
82
tests/test_workbench_ratify_case_0050_hazard_pin.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import functools
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from generate.comprehension import lexicon as comprehension_lexicon
|
||||
from generate.comprehension import lifecycle
|
||||
from generate.comprehension.audit import audit_problem
|
||||
from generate.comprehension.state import ReaderRefusal
|
||||
from tests.workbench_test_helper import setup_isolated_workbench, make_and_write_proposal
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
CASES_PATH = REPO_ROOT / "evals" / "gsm8k_math" / "train_sample" / "v1" / "cases.jsonl"
|
||||
|
||||
|
||||
def _use_pack_for_reader(monkeypatch: pytest.MonkeyPatch, pack_root: Path) -> None:
|
||||
comprehension_lexicon._CACHE.clear()
|
||||
|
||||
@functools.cache
|
||||
def _tmp_lexicon():
|
||||
return comprehension_lexicon.load_lexicon(pack_root)
|
||||
|
||||
monkeypatch.setattr(lifecycle, "_get_lexicon", _tmp_lexicon)
|
||||
|
||||
|
||||
def test_case_0050_remains_refused_after_ui_ratification(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
api = setup_isolated_workbench(tmp_path, monkeypatch)
|
||||
pack_root = tmp_path / "en_core_math_v1"
|
||||
|
||||
# 1. UI Ratify Lexical Claim
|
||||
proposal_lex = make_and_write_proposal(
|
||||
api,
|
||||
proposed_change_kind="vocabulary_addition",
|
||||
evidence_surface="testlemma",
|
||||
evidence_sub_type="lexical",
|
||||
missing_operator="drain_token",
|
||||
refusal_reason="unknown_word",
|
||||
)
|
||||
body_lex = json.dumps({"category": "drain_token"}).encode("utf-8")
|
||||
resp_lex = api.handle("POST", f"/math-proposals/{proposal_lex.proposal_id}/ratify", body_lex)
|
||||
assert resp_lex.status == 200
|
||||
|
||||
# 2. UI Ratify Frame Claim
|
||||
proposal_frame = make_and_write_proposal(
|
||||
api,
|
||||
proposed_change_kind="frame_reclassification",
|
||||
evidence_surface="gives",
|
||||
evidence_sub_type="frame",
|
||||
missing_operator="transfer_frame",
|
||||
refusal_reason="unexpected_category",
|
||||
)
|
||||
body_frame = json.dumps({"category": "transfer_frame", "polarity": "affirms"}).encode("utf-8")
|
||||
resp_frame = api.handle("POST", f"/math-proposals/{proposal_frame.proposal_id}/ratify", body_frame)
|
||||
assert resp_frame.status == 200
|
||||
|
||||
# 3. UI Ratify Composition Claim
|
||||
proposal_comp = make_and_write_proposal(
|
||||
api,
|
||||
proposed_change_kind="composition_reclassification",
|
||||
evidence_surface="each",
|
||||
evidence_sub_type="composition",
|
||||
missing_operator="quantity_extraction",
|
||||
refusal_reason="incomplete_operation",
|
||||
)
|
||||
body_comp = json.dumps({"category": "multiplicative_composition", "polarity": "affirms"}).encode("utf-8")
|
||||
resp_comp = api.handle("POST", f"/math-proposals/{proposal_comp.proposal_id}/ratify", body_comp)
|
||||
assert resp_comp.status == 200
|
||||
|
||||
# Hook the reader to use the mutated pack copy
|
||||
_use_pack_for_reader(monkeypatch, pack_root)
|
||||
|
||||
# Load and check case 0050
|
||||
cases = [json.loads(line) for line in CASES_PATH.read_text().splitlines()]
|
||||
case = next(c for c in cases if c["case_id"] == "gsm8k-train-sample-v1-0050")
|
||||
|
||||
result, _rows = audit_problem(case["question"], case_id=case["case_id"])
|
||||
|
||||
assert isinstance(result, ReaderRefusal), "case 0050 must remain refused after ratification"
|
||||
assert result.sentence_index == 0
|
||||
46
tests/test_workbench_ratify_category_allowlist.py
Normal file
46
tests/test_workbench_ratify_category_allowlist.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from tests.workbench_test_helper import setup_isolated_workbench, make_and_write_proposal
|
||||
|
||||
|
||||
def test_workbench_ratify_category_allowlist_enforcement(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
api = setup_isolated_workbench(tmp_path, monkeypatch)
|
||||
pack_root = tmp_path / "en_core_math_v1"
|
||||
|
||||
# 1. Test missing category
|
||||
proposal_lex = make_and_write_proposal(
|
||||
api,
|
||||
proposed_change_kind="vocabulary_addition",
|
||||
evidence_surface="testlemma",
|
||||
evidence_sub_type="lexical",
|
||||
)
|
||||
|
||||
# Sending POST with empty body should be dry-run (returns 200, applied=False)
|
||||
response_dry = api.handle("POST", f"/math-proposals/{proposal_lex.proposal_id}/ratify", b"")
|
||||
assert response_dry.status == 200
|
||||
assert response_dry.payload["data"]["applied"] is False
|
||||
|
||||
# Sending invalid/empty category in body should raise 400
|
||||
body_empty = json.dumps({"category": ""}).encode("utf-8")
|
||||
response_empty = api.handle("POST", f"/math-proposals/{proposal_lex.proposal_id}/ratify", body_empty)
|
||||
assert response_empty.status == 400
|
||||
assert response_empty.payload["ok"] is False
|
||||
|
||||
bad_file = pack_root / "lexicon" / "accumulation_verb.jsonl"
|
||||
before_content = bad_file.read_bytes() if bad_file.exists() else b""
|
||||
|
||||
# 2. Test off-allowlist category
|
||||
body_bad = json.dumps({"category": "accumulation_verb"}).encode("utf-8") # Lexical safe list is only drain_token
|
||||
response_bad = api.handle("POST", f"/math-proposals/{proposal_lex.proposal_id}/ratify", body_bad)
|
||||
|
||||
assert response_bad.status == 400
|
||||
assert response_bad.payload["ok"] is False
|
||||
assert "drain-class" in response_bad.payload["error"]["message"] or "restricted" in response_bad.payload["error"]["message"]
|
||||
|
||||
# Verify nothing was written to accumulation_verb.jsonl
|
||||
after_content = bad_file.read_bytes() if bad_file.exists() else b""
|
||||
assert before_content == after_content
|
||||
63
tests/test_workbench_ratify_composition.py
Normal file
63
tests/test_workbench_ratify_composition.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import getpass
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from teaching.math_composition_ratification import apply_composition_claim
|
||||
from tests.workbench_test_helper import setup_isolated_workbench, make_and_write_proposal
|
||||
|
||||
|
||||
def test_workbench_ratify_composition_byte_equivalence(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
api = setup_isolated_workbench(tmp_path, monkeypatch)
|
||||
pack_root = tmp_path / "en_core_math_v1"
|
||||
target_file = pack_root / "compositions" / "multiplicative_composition.jsonl"
|
||||
|
||||
# Write a clean target file
|
||||
target_file.write_text("", encoding="utf-8")
|
||||
|
||||
# Generate proposal
|
||||
proposal = make_and_write_proposal(
|
||||
api,
|
||||
proposed_change_kind="composition_reclassification",
|
||||
evidence_surface="each",
|
||||
evidence_sub_type="composition",
|
||||
missing_operator="quantity_extraction",
|
||||
refusal_reason="incomplete_operation",
|
||||
)
|
||||
|
||||
# 1. UI Ratify
|
||||
body = json.dumps({"category": "multiplicative_composition", "polarity": "affirms"}).encode("utf-8")
|
||||
response = api.handle("POST", f"/math-proposals/{proposal.proposal_id}/ratify", body)
|
||||
|
||||
assert response.status == 200
|
||||
assert response.payload["ok"] is True
|
||||
assert response.payload["data"]["applied"] is True
|
||||
|
||||
ui_lines = target_file.read_bytes().splitlines()
|
||||
assert len(ui_lines) == 1
|
||||
|
||||
# Clear target file
|
||||
target_file.write_text("", encoding="utf-8")
|
||||
|
||||
# 2. CLI Ratify
|
||||
claim = proposal.evidence_pointers[0]
|
||||
apply_composition_claim(
|
||||
claim=claim,
|
||||
composition_category="multiplicative_composition",
|
||||
polarity="affirms",
|
||||
reviewer=getpass.getuser(),
|
||||
pack_root=pack_root,
|
||||
ratifier_kind="cli",
|
||||
)
|
||||
|
||||
cli_lines = target_file.read_bytes().splitlines()
|
||||
assert len(cli_lines) == 1
|
||||
|
||||
# 3. Assert byte equivalence except for ratifier_kind
|
||||
ui_line = ui_lines[0].decode("utf-8")
|
||||
cli_line = cli_lines[0].decode("utf-8")
|
||||
|
||||
assert ui_line.replace('"ratifier_kind": "workbench"', '"ratifier_kind": "cli"').replace('"ratifier_kind":"workbench"', '"ratifier_kind":"cli"') == cli_line
|
||||
|
||||
50
tests/test_workbench_ratify_cors.py
Normal file
50
tests/test_workbench_ratify_cors.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import Mock
|
||||
import pytest
|
||||
from workbench.server import WorkbenchRequestHandler
|
||||
|
||||
|
||||
def test_cors_host_and_origin_verification() -> None:
|
||||
handler = Mock(spec=WorkbenchRequestHandler)
|
||||
handler.headers = {"Host": "malicious.com", "Origin": "http://malicious-origin.com"}
|
||||
handler.command = "GET"
|
||||
handler.path = "/health"
|
||||
handler.wfile = Mock()
|
||||
|
||||
# Bind the real method to the mock instance
|
||||
handler._handle = WorkbenchRequestHandler._handle.__get__(handler, WorkbenchRequestHandler)
|
||||
handler._send_common_headers = Mock()
|
||||
|
||||
# Run the handler
|
||||
handler._handle()
|
||||
|
||||
# Assert response was 400 Bad Request
|
||||
handler.send_response.assert_called_with(400)
|
||||
|
||||
# Verify wfile.write payload
|
||||
handler.wfile.write.assert_called()
|
||||
payload = json.loads(handler.wfile.write.call_args[0][0].decode("utf-8"))
|
||||
assert payload["ok"] is False
|
||||
assert "CORS check failed" in payload["error"]
|
||||
|
||||
|
||||
def test_cors_loopback_host_and_origin_allowed() -> None:
|
||||
handler = Mock(spec=WorkbenchRequestHandler)
|
||||
handler.headers = {"Host": "127.0.0.1:8765", "Origin": "http://localhost:5173"}
|
||||
handler.command = "GET"
|
||||
handler.path = "/health"
|
||||
handler.wfile = Mock()
|
||||
handler.api = Mock()
|
||||
handler.api.handle.return_value = Mock(status=200, payload={"ok": True})
|
||||
|
||||
# Bind the real method
|
||||
handler._handle = WorkbenchRequestHandler._handle.__get__(handler, WorkbenchRequestHandler)
|
||||
handler._send_common_headers = Mock()
|
||||
|
||||
# Run the handler
|
||||
handler._handle()
|
||||
|
||||
# Assert response was 200 OK (not 400)
|
||||
handler.send_response.assert_called_with(200)
|
||||
44
tests/test_workbench_ratify_exception_surface.py
Normal file
44
tests/test_workbench_ratify_exception_surface.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from tests.workbench_test_helper import setup_isolated_workbench, make_and_write_proposal
|
||||
|
||||
|
||||
def test_workbench_ratify_exception_surface_verbatim(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
api = setup_isolated_workbench(tmp_path, monkeypatch)
|
||||
|
||||
# 1. Trigger WrongClaimSubType: Lexical proposal but frame sub_type
|
||||
proposal_lex = make_and_write_proposal(
|
||||
api,
|
||||
proposed_change_kind="vocabulary_addition",
|
||||
evidence_surface="testlemma",
|
||||
evidence_sub_type="frame", # wrong sub_type for LexicalClaim
|
||||
missing_operator="drain_token",
|
||||
refusal_reason="unknown_word",
|
||||
)
|
||||
body = json.dumps({"category": "drain_token"}).encode("utf-8")
|
||||
response_sub = api.handle("POST", f"/math-proposals/{proposal_lex.proposal_id}/ratify", body)
|
||||
|
||||
assert response_sub.status == 400
|
||||
assert response_sub.payload["ok"] is False
|
||||
# Verbatim error message from WrongClaimSubType
|
||||
assert "Lexical ratification requires sub_type='lexical'" in response_sub.payload["error"]["message"]
|
||||
|
||||
# 2. Trigger WrongCompositionCategory
|
||||
proposal_comp = make_and_write_proposal(
|
||||
api,
|
||||
proposed_change_kind="composition_reclassification",
|
||||
evidence_surface="each",
|
||||
evidence_sub_type="composition",
|
||||
missing_operator="quantity_extraction",
|
||||
refusal_reason="incomplete_operation",
|
||||
)
|
||||
body_bad_comp = json.dumps({"category": "distributive_composition", "polarity": "affirms"}).encode("utf-8")
|
||||
response_comp = api.handle("POST", f"/math-proposals/{proposal_comp.proposal_id}/ratify", body_bad_comp)
|
||||
|
||||
assert response_comp.status == 400
|
||||
assert response_comp.payload["ok"] is False
|
||||
assert "CompositionClaim ratification is allowlist-only" in response_comp.payload["error"]["message"]
|
||||
63
tests/test_workbench_ratify_frame.py
Normal file
63
tests/test_workbench_ratify_frame.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import getpass
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from teaching.math_frame_ratification import apply_frame_claim
|
||||
from tests.workbench_test_helper import setup_isolated_workbench, make_and_write_proposal
|
||||
|
||||
|
||||
def test_workbench_ratify_frame_byte_equivalence(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
api = setup_isolated_workbench(tmp_path, monkeypatch)
|
||||
pack_root = tmp_path / "en_core_math_v1"
|
||||
target_file = pack_root / "frames" / "transfer_frame.jsonl"
|
||||
|
||||
# Write a clean target file
|
||||
target_file.write_text("", encoding="utf-8")
|
||||
|
||||
# Generate proposal
|
||||
proposal = make_and_write_proposal(
|
||||
api,
|
||||
proposed_change_kind="frame_reclassification",
|
||||
evidence_surface="gives",
|
||||
evidence_sub_type="frame",
|
||||
missing_operator="transfer_frame",
|
||||
refusal_reason="unexpected_category",
|
||||
)
|
||||
|
||||
# 1. UI Ratify
|
||||
body = json.dumps({"category": "transfer_frame", "polarity": "affirms"}).encode("utf-8")
|
||||
response = api.handle("POST", f"/math-proposals/{proposal.proposal_id}/ratify", body)
|
||||
|
||||
assert response.status == 200
|
||||
assert response.payload["ok"] is True
|
||||
assert response.payload["data"]["applied"] is True
|
||||
|
||||
ui_lines = target_file.read_bytes().splitlines()
|
||||
assert len(ui_lines) == 1
|
||||
|
||||
# Clear target file
|
||||
target_file.write_text("", encoding="utf-8")
|
||||
|
||||
# 2. CLI Ratify
|
||||
claim = proposal.evidence_pointers[0]
|
||||
apply_frame_claim(
|
||||
claim=claim,
|
||||
frame_category="transfer_frame",
|
||||
polarity="affirms",
|
||||
reviewer=getpass.getuser(),
|
||||
pack_root=pack_root,
|
||||
ratifier_kind="cli",
|
||||
)
|
||||
|
||||
cli_lines = target_file.read_bytes().splitlines()
|
||||
assert len(cli_lines) == 1
|
||||
|
||||
# 3. Assert byte equivalence except for ratifier_kind
|
||||
ui_line = ui_lines[0].decode("utf-8")
|
||||
cli_line = cli_lines[0].decode("utf-8")
|
||||
|
||||
assert ui_line.replace('"ratifier_kind": "workbench"', '"ratifier_kind": "cli"').replace('"ratifier_kind":"workbench"', '"ratifier_kind":"cli"') == cli_line
|
||||
|
||||
33
tests/test_workbench_ratify_idempotent.py
Normal file
33
tests/test_workbench_ratify_idempotent.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from tests.workbench_test_helper import setup_isolated_workbench, make_and_write_proposal
|
||||
|
||||
|
||||
def test_workbench_ratify_idempotent_raises_already_ratified(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
api = setup_isolated_workbench(tmp_path, monkeypatch)
|
||||
|
||||
# Generate proposal
|
||||
proposal = make_and_write_proposal(
|
||||
api,
|
||||
proposed_change_kind="vocabulary_addition",
|
||||
evidence_surface="testlemma",
|
||||
evidence_sub_type="lexical",
|
||||
missing_operator="drain_token",
|
||||
refusal_reason="unknown_word",
|
||||
)
|
||||
body = json.dumps({"category": "drain_token"}).encode("utf-8")
|
||||
|
||||
# First ratification succeeds
|
||||
response1 = api.handle("POST", f"/math-proposals/{proposal.proposal_id}/ratify", body)
|
||||
assert response1.status == 200
|
||||
assert response1.payload["data"]["applied"] is True
|
||||
|
||||
# Second ratification fails with 409 Conflict (AlreadyRatified)
|
||||
response2 = api.handle("POST", f"/math-proposals/{proposal.proposal_id}/ratify", body)
|
||||
assert response2.status == 409
|
||||
assert response2.payload["ok"] is False
|
||||
assert "already ratified" in response2.payload["error"]["message"].lower()
|
||||
63
tests/test_workbench_ratify_lexical.py
Normal file
63
tests/test_workbench_ratify_lexical.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import getpass
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from teaching.math_lexical_ratification import apply_lexical_claim
|
||||
from tests.workbench_test_helper import setup_isolated_workbench, make_and_write_proposal
|
||||
|
||||
|
||||
def test_workbench_ratify_lexical_byte_equivalence(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
api = setup_isolated_workbench(tmp_path, monkeypatch)
|
||||
pack_root = tmp_path / "en_core_math_v1"
|
||||
target_file = pack_root / "lexicon" / "drain_token.jsonl"
|
||||
|
||||
# Write a clean target file
|
||||
target_file.write_text("", encoding="utf-8")
|
||||
|
||||
# Generate proposal
|
||||
proposal = make_and_write_proposal(
|
||||
api,
|
||||
proposed_change_kind="vocabulary_addition",
|
||||
evidence_surface="testlemma",
|
||||
evidence_sub_type="lexical",
|
||||
missing_operator="drain_token",
|
||||
refusal_reason="unknown_word",
|
||||
)
|
||||
|
||||
# 1. UI Ratify
|
||||
body = json.dumps({"category": "drain_token"}).encode("utf-8")
|
||||
response = api.handle("POST", f"/math-proposals/{proposal.proposal_id}/ratify", body)
|
||||
|
||||
assert response.status == 200
|
||||
assert response.payload["ok"] is True
|
||||
assert response.payload["data"]["applied"] is True
|
||||
|
||||
ui_lines = target_file.read_bytes().splitlines()
|
||||
assert len(ui_lines) == 1
|
||||
|
||||
# Clear target file
|
||||
target_file.write_text("", encoding="utf-8")
|
||||
|
||||
# 2. CLI Ratify
|
||||
claim = proposal.evidence_pointers[0]
|
||||
apply_lexical_claim(
|
||||
claim=claim,
|
||||
category="drain_token",
|
||||
reviewer=getpass.getuser(),
|
||||
pack_root=pack_root,
|
||||
ratifier_kind="cli",
|
||||
)
|
||||
|
||||
cli_lines = target_file.read_bytes().splitlines()
|
||||
assert len(cli_lines) == 1
|
||||
|
||||
# 3. Assert byte equivalence except for ratifier_kind
|
||||
ui_line = ui_lines[0].decode("utf-8")
|
||||
cli_line = cli_lines[0].decode("utf-8")
|
||||
|
||||
# Replacing ratifier_kind="workbench" with "cli" in the UI-serialized line should make it identical to the CLI-serialized line.
|
||||
assert ui_line.replace('"ratifier_kind": "workbench"', '"ratifier_kind": "cli"').replace('"ratifier_kind":"workbench"', '"ratifier_kind":"cli"') == cli_line
|
||||
|
||||
31
tests/test_workbench_ratify_no_auto.py
Normal file
31
tests/test_workbench_ratify_no_auto.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from tests.workbench_test_helper import setup_isolated_workbench, make_and_write_proposal
|
||||
|
||||
|
||||
def test_workbench_ratify_no_auto_transition(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
api = setup_isolated_workbench(tmp_path, monkeypatch)
|
||||
pack_root = tmp_path / "en_core_math_v1"
|
||||
|
||||
# 1. Create a replay-equivalent proposal in the queue
|
||||
proposal = make_and_write_proposal(
|
||||
api,
|
||||
proposed_change_kind="vocabulary_addition",
|
||||
evidence_surface="testlemma",
|
||||
evidence_sub_type="lexical",
|
||||
missing_operator="drain_token",
|
||||
refusal_reason="unknown_word",
|
||||
)
|
||||
|
||||
# 2. Check that no entries have been written to the target file
|
||||
lex_file = pack_root / "lexicon" / "drain_token.jsonl"
|
||||
# It might contain existing lemmas from the copy, but should NOT contain "testlemma"
|
||||
content_before = lex_file.read_text(encoding="utf-8") if lex_file.exists() else ""
|
||||
assert "testlemma" not in content_before
|
||||
|
||||
# 3. Emulate polling/idle state: assert that even after time/polling, nothing mutates the target file
|
||||
content_after_idle = lex_file.read_text(encoding="utf-8") if lex_file.exists() else ""
|
||||
assert "testlemma" not in content_after_idle
|
||||
45
tests/test_workbench_ratify_partition.py
Normal file
45
tests/test_workbench_ratify_partition.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
from tests.workbench_test_helper import setup_isolated_workbench, make_and_write_proposal
|
||||
|
||||
|
||||
def test_workbench_ratify_partition_enforcement(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
api = setup_isolated_workbench(tmp_path, monkeypatch)
|
||||
|
||||
# Create a proposal with domain="math" initially (so build_proposal succeeds)
|
||||
proposal = make_and_write_proposal(
|
||||
api,
|
||||
domain="math",
|
||||
proposed_change_kind="vocabulary_addition",
|
||||
evidence_surface="testlemma",
|
||||
evidence_sub_type="lexical",
|
||||
missing_operator="drain_token",
|
||||
refusal_reason="unknown_word",
|
||||
)
|
||||
|
||||
# Overwrite the domain key directly on disk to "cognition" to test partition isolation
|
||||
import workbench.readers as readers
|
||||
path = readers.MATH_PROPOSALS_JSONL
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
modified_lines = []
|
||||
for line in lines:
|
||||
if line.strip():
|
||||
record = json.loads(line)
|
||||
if record["proposal_id"] == proposal.proposal_id:
|
||||
record["domain"] = "cognition"
|
||||
modified_lines.append(json.dumps(record, ensure_ascii=False, separators=(",", ":")))
|
||||
path.write_text("\n".join(modified_lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
# Try to ratify it via the math API
|
||||
body = json.dumps({"category": "drain_token"}).encode("utf-8")
|
||||
response = api.handle("POST", f"/math-proposals/{proposal.proposal_id}/ratify", body)
|
||||
|
||||
# Should be rejected with 400 Bad Request
|
||||
assert response.status == 400
|
||||
assert response.payload["ok"] is False
|
||||
assert "partition" in response.payload["error"]["message"].lower()
|
||||
130
tests/workbench_test_helper.py
Normal file
130
tests/workbench_test_helper.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
import pytest
|
||||
|
||||
from evals.refusal_taxonomy.shape_categories import ShapeCategory
|
||||
from generate.comprehension.audit import AuditRow
|
||||
from teaching.math_contemplation_proposal import build_proposal, to_jsonl_record
|
||||
from teaching.math_evidence import from_audit_row
|
||||
from teaching.math_reasoning_trace import ReasoningStep, build_trace
|
||||
from workbench.api import WorkbenchApi
|
||||
from workbench import readers
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
PACK_ROOT = REPO_ROOT / "language_packs" / "data" / "en_core_math_v1"
|
||||
|
||||
def setup_isolated_workbench(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> WorkbenchApi:
|
||||
# 1. Copy en_core_math_v1 to tmp_path
|
||||
target_pack = tmp_path / "en_core_math_v1"
|
||||
shutil.copytree(PACK_ROOT, target_pack)
|
||||
(target_pack / "lexicon").mkdir(exist_ok=True)
|
||||
(target_pack / "frames").mkdir(exist_ok=True)
|
||||
(target_pack / "compositions").mkdir(exist_ok=True)
|
||||
|
||||
# Clear caches
|
||||
from generate.comprehension import lexicon as comprehension_lexicon
|
||||
from generate.comprehension import lifecycle
|
||||
comprehension_lexicon._CACHE.clear()
|
||||
lifecycle._get_lexicon.cache_clear()
|
||||
|
||||
# 2. Redirect pack root in ratification handlers
|
||||
monkeypatch.setattr("teaching.math_lexical_ratification._default_pack_root", lambda: target_pack)
|
||||
monkeypatch.setattr("teaching.math_frame_ratification._default_pack_root", lambda: target_pack)
|
||||
monkeypatch.setattr("teaching.math_composition_ratification._default_pack_root", lambda: target_pack)
|
||||
|
||||
# Redirect proposal file
|
||||
proposals_jsonl = tmp_path / "proposals.jsonl"
|
||||
proposals_jsonl.touch()
|
||||
monkeypatch.setattr(readers, "MATH_PROPOSALS_JSONL", proposals_jsonl)
|
||||
|
||||
# Create WorkbenchApi
|
||||
api = WorkbenchApi()
|
||||
return api
|
||||
|
||||
def build_test_claim(surface: str, sub_type: str, case_id: str | None = None, missing_operator: str = "lexicon_entry", refusal_reason: str = "unknown_word") -> Any:
|
||||
row = AuditRow(
|
||||
case_id=case_id or f"case-{surface}",
|
||||
sentence_index=0,
|
||||
token_index=2,
|
||||
token_text=surface,
|
||||
recognized_terms=("Ava", "counts"),
|
||||
skipped_frame=None,
|
||||
missing_operator=missing_operator,
|
||||
refusal_reason=refusal_reason,
|
||||
refusal_detail=f"test refusal for '{surface}'",
|
||||
)
|
||||
return from_audit_row(row, sub_type)
|
||||
|
||||
def write_proposal_record(api: WorkbenchApi, proposal_id: str, record: dict[str, Any]) -> None:
|
||||
# Append to the mocked proposals.jsonl
|
||||
path = readers.MATH_PROPOSALS_JSONL
|
||||
with path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n")
|
||||
|
||||
def make_and_write_proposal(
|
||||
api: WorkbenchApi,
|
||||
*,
|
||||
domain: str = "math",
|
||||
shape_category: ShapeCategory = ShapeCategory.UNCATEGORIZED,
|
||||
proposed_change_kind: str = "vocabulary_addition",
|
||||
evidence_surface: str = "widgets",
|
||||
evidence_sub_type: str = "lexical",
|
||||
missing_operator: str = "lexicon_entry",
|
||||
refusal_reason: str = "unknown_word",
|
||||
wrong_zero_assertion: str = "Proposal wrong zero assertion must be at least forty chars long.",
|
||||
) -> Any:
|
||||
ev1 = build_test_claim(evidence_surface, evidence_sub_type, case_id="case-1", missing_operator=missing_operator, refusal_reason=refusal_reason)
|
||||
ev2 = build_test_claim(evidence_surface, evidence_sub_type, case_id="case-2", missing_operator=missing_operator, refusal_reason=refusal_reason)
|
||||
steps = (
|
||||
ReasoningStep(
|
||||
step_index=0,
|
||||
step_kind="observation",
|
||||
input_pointers=("case-1", "case-2"),
|
||||
claim="refusals share pattern",
|
||||
justification="grouped by key",
|
||||
output_payload={"evidence_count": 2},
|
||||
),
|
||||
ReasoningStep(
|
||||
step_index=1,
|
||||
step_kind="grouping",
|
||||
input_pointers=("case-1", "case-2"),
|
||||
claim="group key",
|
||||
justification="pair equality",
|
||||
output_payload={"k": "v"},
|
||||
),
|
||||
ReasoningStep(
|
||||
step_index=2,
|
||||
step_kind="hypothesis",
|
||||
input_pointers=("case-1", "case-2"),
|
||||
claim="change fits",
|
||||
justification="fits shape",
|
||||
output_payload={"proposed_change_kind": proposed_change_kind},
|
||||
),
|
||||
ReasoningStep(
|
||||
step_index=3,
|
||||
step_kind="conclusion",
|
||||
input_pointers=("case-1", "case-2"),
|
||||
claim=f"propose {proposed_change_kind}",
|
||||
justification="evidence-only proposal",
|
||||
output_payload={"proposed_change_kind": proposed_change_kind},
|
||||
),
|
||||
)
|
||||
trace = build_trace(steps)
|
||||
proposal = build_proposal(
|
||||
domain=domain, # type: ignore[arg-type]
|
||||
shape_category=shape_category,
|
||||
structural_commonality="test commonality",
|
||||
evidence_pointers=(ev1, ev2),
|
||||
proposed_change_kind=proposed_change_kind,
|
||||
proposed_change_payload={"k": "v"},
|
||||
wrong_zero_assertion=wrong_zero_assertion,
|
||||
replay_equivalence_hash="0" * 64,
|
||||
reasoning_trace=trace,
|
||||
)
|
||||
record = to_jsonl_record(proposal)
|
||||
write_proposal_record(api, proposal.proposal_id, record)
|
||||
return proposal
|
||||
|
|
@ -104,7 +104,7 @@
|
|||
"path": "str",
|
||||
"original": "Any",
|
||||
"replay": "Any",
|
||||
"severity": "Literal['info', 'warning', 'failure']"
|
||||
"severity": "ReplayDivergenceSeverity"
|
||||
}
|
||||
},
|
||||
"ReplayComparison": {
|
||||
|
|
@ -115,6 +115,51 @@
|
|||
"equivalent": "bool",
|
||||
"divergences": "list[ReplayDivergence]"
|
||||
}
|
||||
},
|
||||
"MathReasoningStep": {
|
||||
"fields": {
|
||||
"step_index": "int",
|
||||
"step_kind": "str",
|
||||
"claim": "str",
|
||||
"justification": "str",
|
||||
"input_pointers": "list[str]",
|
||||
"output_payload": "Any"
|
||||
}
|
||||
},
|
||||
"MathProposalSummary": {
|
||||
"fields": {
|
||||
"proposal_id": "str",
|
||||
"domain": "Literal['math']",
|
||||
"shape_category": "str",
|
||||
"proposed_change_kind": "str",
|
||||
"structural_commonality": "str",
|
||||
"evidence_count": "int",
|
||||
"replay_equivalence_hash": "str"
|
||||
}
|
||||
},
|
||||
"MathProposalDetail": {
|
||||
"fields": {
|
||||
"wrong_zero_assertion": "str",
|
||||
"proposed_change_payload": "Any",
|
||||
"reasoning_trace_id": "str",
|
||||
"reasoning_trace_steps": "list[MathReasoningStep]",
|
||||
"evidence_hashes": "list[str]",
|
||||
"handler_name": "str | None",
|
||||
"suggested_ratify_cli": "str | None"
|
||||
}
|
||||
},
|
||||
"MathRatifyResult": {
|
||||
"fields": {
|
||||
"proposal_id": "str",
|
||||
"change_kind": "str",
|
||||
"handler_name": "str",
|
||||
"routing_status": "Literal['routed', 'not_implemented']",
|
||||
"message": "str",
|
||||
"suggested_cli": "str | None",
|
||||
"applied": "bool",
|
||||
"target_path": "str | None",
|
||||
"evidence_hash": "str | None"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ import type {
|
|||
ProposalDetail,
|
||||
ProposalState,
|
||||
ProposalSummary,
|
||||
MathProposalSummary,
|
||||
MathProposalDetail,
|
||||
MathRatifyResult,
|
||||
} from "../types/api";
|
||||
|
||||
export class WorkbenchApiError extends Error {
|
||||
|
|
@ -103,3 +106,52 @@ export async function fetchProposals(
|
|||
export async function fetchProposalDetail(proposalId: string): Promise<ProposalDetail> {
|
||||
return apiFetch<ProposalDetail>(`/proposals/${encodeURIComponent(proposalId)}`);
|
||||
}
|
||||
|
||||
export async function fetchMathProposals(): Promise<MathProposalSummary[]> {
|
||||
const envelope = await apiFetch<ItemsEnvelope<MathProposalSummary>>("/math-proposals");
|
||||
return envelope.items;
|
||||
}
|
||||
|
||||
export async function fetchMathProposalDetail(proposalId: string): Promise<MathProposalDetail> {
|
||||
return apiFetch<MathProposalDetail>(`/math-proposals/${encodeURIComponent(proposalId)}`);
|
||||
}
|
||||
|
||||
export async function ratifyMathProposal(
|
||||
proposalId: string,
|
||||
category?: string,
|
||||
polarity?: string,
|
||||
dryRun?: boolean,
|
||||
): Promise<MathRatifyResult> {
|
||||
return apiFetch<MathRatifyResult>(`/math-proposals/${encodeURIComponent(proposalId)}/ratify`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ category, polarity, dry_run: dryRun }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function rejectMathProposal(
|
||||
proposalId: string,
|
||||
note?: string,
|
||||
): Promise<{ proposal_id: string; rejected: boolean }> {
|
||||
return apiFetch<{ proposal_id: string; rejected: boolean }>(
|
||||
`/math-proposals/${encodeURIComponent(proposalId)}/reject`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ note }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function deferMathProposal(
|
||||
proposalId: string,
|
||||
): Promise<{ proposal_id: string; deferred: boolean }> {
|
||||
return apiFetch<{ proposal_id: string; deferred: boolean }>(
|
||||
`/math-proposals/${encodeURIComponent(proposalId)}/defer`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ import {
|
|||
fetchReplayComparison,
|
||||
fetchProposalDetail,
|
||||
fetchProposals,
|
||||
fetchMathProposals,
|
||||
fetchMathProposalDetail,
|
||||
ratifyMathProposal,
|
||||
rejectMathProposal,
|
||||
deferMathProposal,
|
||||
type ProposalStateFilter,
|
||||
} from "./client";
|
||||
import type { WorkbenchApiError } from "./client";
|
||||
|
|
@ -27,6 +32,9 @@ import type {
|
|||
ChatTurnResult,
|
||||
EvalRunRequest,
|
||||
ReplayComparison,
|
||||
MathProposalSummary,
|
||||
MathProposalDetail,
|
||||
MathRatifyResult,
|
||||
} from "../types/api";
|
||||
|
||||
export { QueryClientProvider };
|
||||
|
|
@ -136,3 +144,76 @@ export function useChatTurn() {
|
|||
});
|
||||
}
|
||||
|
||||
export function useMathProposals() {
|
||||
return useQuery<MathProposalSummary[]>({
|
||||
queryKey: ["api", "math-proposals"],
|
||||
queryFn: fetchMathProposals,
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMathProposalDetail(proposalId: string) {
|
||||
return useQuery<MathProposalDetail>({
|
||||
queryKey: ["api", "math-proposal", proposalId],
|
||||
queryFn: () => fetchMathProposalDetail(proposalId),
|
||||
enabled: !!proposalId,
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMathRatify() {
|
||||
return useMutation<
|
||||
MathRatifyResult,
|
||||
WorkbenchApiError,
|
||||
{ proposalId: string; category?: string; polarity?: string; dryRun?: boolean }
|
||||
>({
|
||||
mutationKey: ["math-ratify"],
|
||||
mutationFn: ({ proposalId, category, polarity, dryRun }) =>
|
||||
ratifyMathProposal(proposalId, category, polarity, dryRun),
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["api", "proposals"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["api", "math-proposals"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["api", "proposal", variables.proposalId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["api", "math-proposal", variables.proposalId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMathReject() {
|
||||
return useMutation<
|
||||
{ proposal_id: string; rejected: boolean },
|
||||
WorkbenchApiError,
|
||||
{ proposalId: string; note?: string }
|
||||
>({
|
||||
mutationKey: ["math-reject"],
|
||||
mutationFn: ({ proposalId, note }) => rejectMathProposal(proposalId, note),
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["api", "proposals"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["api", "math-proposals"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["api", "proposal", variables.proposalId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["api", "math-proposal", variables.proposalId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMathDefer() {
|
||||
return useMutation<
|
||||
{ proposal_id: string; deferred: boolean },
|
||||
WorkbenchApiError,
|
||||
{ proposalId: string }
|
||||
>({
|
||||
mutationKey: ["math-defer"],
|
||||
mutationFn: ({ proposalId }) => deferMathProposal(proposalId),
|
||||
onSuccess: (data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["api", "proposals"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["api", "math-proposals"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["api", "proposal", variables.proposalId] });
|
||||
queryClient.invalidateQueries({ queryKey: ["api", "math-proposal", variables.proposalId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -11,10 +11,12 @@ const ROW_INCREMENT = 40;
|
|||
export function ProposalTable({
|
||||
proposals,
|
||||
selectedProposalId,
|
||||
focusedProposalId,
|
||||
onSelect,
|
||||
}: {
|
||||
proposals: ProposalSummary[];
|
||||
selectedProposalId: string | null;
|
||||
focusedProposalId?: string | null;
|
||||
onSelect: (proposalId: string) => void;
|
||||
}) {
|
||||
const [visibleCount, setVisibleCount] = useState(INITIAL_ROWS);
|
||||
|
|
@ -44,14 +46,22 @@ export function ProposalTable({
|
|||
<div className="max-h-[calc(100vh-17rem)] overflow-y-auto">
|
||||
{visibleProposals.map((proposal) => {
|
||||
const selected = proposal.proposal_id === selectedProposalId;
|
||||
const focused = proposal.proposal_id === focusedProposalId;
|
||||
return (
|
||||
<div
|
||||
aria-current={selected ? "true" : undefined}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="grid w-full grid-cols-[minmax(7rem,1fr)_auto_auto_minmax(7rem,1fr)_minmax(9rem,1fr)] items-center gap-3 border-b border-[var(--color-border-subtle)] px-3 py-2 text-left text-sm text-[var(--color-text-primary)] hover:bg-[var(--color-surface-inset)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-inset focus-visible:outline-[var(--color-focus-ring)] aria-[current=true]:bg-[var(--color-surface-inset)]"
|
||||
className={`grid w-full grid-cols-[minmax(7rem,1fr)_auto_auto_minmax(7rem,1fr)_minmax(9rem,1fr)] items-center gap-3 border-b border-[var(--color-border-subtle)] px-3 py-2 text-left text-sm text-[var(--color-text-primary)] hover:bg-[var(--color-surface-inset)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-inset focus-visible:outline-[var(--color-focus-ring)] transition-all ${
|
||||
selected ? "bg-[var(--color-surface-inset)]" : ""
|
||||
} ${
|
||||
focused
|
||||
? "bg-[var(--color-surface-inset)] border-l-2 border-[var(--color-focus-ring)] pl-[10px]"
|
||||
: "border-l-2 border-transparent pl-[10px]"
|
||||
}`}
|
||||
key={proposal.proposal_id}
|
||||
onClick={() => onSelect(proposal.proposal_id)}
|
||||
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { WorkbenchApiError, type ProposalStateFilter } from "../../api/client";
|
||||
import { useProposalDetail, useProposals } from "../../api/queries";
|
||||
import { useProposalDetail, useProposals, useMathProposals, useMathProposalDetail } from "../../api/queries";
|
||||
import type { ProposalSummary, MathProposalDetail, ProposalState, DownstreamEffect, MathReasoningStep } from "../../types/api";
|
||||
import { Button } from "../../design/components/primitives/Button";
|
||||
import { EmptyState } from "../../design/components/states/EmptyState";
|
||||
import { ErrorState } from "../../design/components/states/ErrorState";
|
||||
|
|
@ -11,11 +13,19 @@ import { ProposalProvenanceViewer } from "./ProposalProvenanceViewer";
|
|||
import { ProposalSummaryCard } from "./ProposalSummaryCard";
|
||||
import { ProposalTable } from "./ProposalTable";
|
||||
import { ReplayEvidenceCard } from "./ReplayEvidenceCard";
|
||||
import { RatificationCommandPanel } from "./RatificationCommandPanel";
|
||||
|
||||
const filters: ProposalStateFilter[] = ["pending", "accepted", "rejected", "all"];
|
||||
|
||||
function isProposalFilter(value: string | null): value is ProposalStateFilter {
|
||||
return value === "pending" || value === "accepted" || value === "rejected" || value === "withdrawn" || value === "unknown" || value === "all";
|
||||
return (
|
||||
value === "pending" ||
|
||||
value === "accepted" ||
|
||||
value === "rejected" ||
|
||||
value === "withdrawn" ||
|
||||
value === "unknown" ||
|
||||
value === "all"
|
||||
);
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
|
|
@ -26,13 +36,32 @@ export function ProposalsRoute() {
|
|||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const selectedFromUrl = searchParams.get("proposal_id");
|
||||
const filterFromUrl = searchParams.get("state");
|
||||
const domainFromUrl = searchParams.get("domain");
|
||||
|
||||
const [domain, setDomain] = useState<"math" | "cognition">(
|
||||
domainFromUrl === "math" ? "math" : "cognition"
|
||||
);
|
||||
|
||||
const [filter, setFilter] = useState<ProposalStateFilter>(
|
||||
isProposalFilter(filterFromUrl) ? filterFromUrl : "pending",
|
||||
);
|
||||
const proposalsQuery = useProposals(filter);
|
||||
const selectedProposalId = selectedFromUrl ?? null;
|
||||
const detailQuery = useProposalDetail(selectedProposalId ?? "");
|
||||
|
||||
const [focusedIndex, setFocusedIndex] = useState<number>(0);
|
||||
|
||||
// Queries
|
||||
const mathProposalsQuery = useMathProposals();
|
||||
const cognitionProposalsQuery = useProposals(filter);
|
||||
|
||||
const selectedProposalId = selectedFromUrl ?? null;
|
||||
|
||||
const mathDetailQuery = useMathProposalDetail(
|
||||
domain === "math" ? (selectedProposalId ?? "") : ""
|
||||
);
|
||||
const cognitionDetailQuery = useProposalDetail(
|
||||
domain === "cognition" ? (selectedProposalId ?? "") : ""
|
||||
);
|
||||
|
||||
// Synchronize state from URL
|
||||
useEffect(() => {
|
||||
const urlFilter = searchParams.get("state");
|
||||
if (isProposalFilter(urlFilter) && urlFilter !== filter) {
|
||||
|
|
@ -40,12 +69,62 @@ export function ProposalsRoute() {
|
|||
}
|
||||
}, [filter, searchParams]);
|
||||
|
||||
const proposals = useMemo(() => proposalsQuery.data ?? [], [proposalsQuery.data]);
|
||||
useEffect(() => {
|
||||
const urlDomain = searchParams.get("domain");
|
||||
if ((urlDomain === "math" || urlDomain === "cognition") && urlDomain !== domain) {
|
||||
setDomain(urlDomain as "math" | "cognition");
|
||||
}
|
||||
}, [domain, searchParams]);
|
||||
|
||||
function updateRoute(next: { proposalId?: string | null; state?: ProposalStateFilter }) {
|
||||
// Load appropriate proposals list
|
||||
const rawProposals = useMemo(() => {
|
||||
if (domain === "math") {
|
||||
return mathProposalsQuery.data ?? [];
|
||||
} else {
|
||||
return cognitionProposalsQuery.data ?? [];
|
||||
}
|
||||
}, [domain, mathProposalsQuery.data, cognitionProposalsQuery.data]);
|
||||
|
||||
// Map to unified ProposalSummary structure
|
||||
const proposals: ProposalSummary[] = useMemo(() => {
|
||||
if (domain === "math") {
|
||||
return (rawProposals as any[]).map((mp) => ({
|
||||
proposal_id: mp.proposal_id,
|
||||
state: "pending" as ProposalState,
|
||||
source_kind: `math / ${mp.proposed_change_kind}`,
|
||||
replay_equivalent: true,
|
||||
created_at: null,
|
||||
downstream_effect: "unknown" as DownstreamEffect,
|
||||
}));
|
||||
} else {
|
||||
return (rawProposals as ProposalSummary[]) ?? [];
|
||||
}
|
||||
}, [domain, rawProposals]);
|
||||
|
||||
const focusedProposalId = useMemo(() => {
|
||||
if (proposals.length > 0 && focusedIndex >= 0 && focusedIndex < proposals.length) {
|
||||
return proposals[focusedIndex].proposal_id;
|
||||
}
|
||||
return null;
|
||||
}, [proposals, focusedIndex]);
|
||||
|
||||
// Reset focus when domain or filter changes
|
||||
useEffect(() => {
|
||||
setFocusedIndex(0);
|
||||
}, [domain, filter]);
|
||||
|
||||
function updateRoute(next: { proposalId?: string | null; state?: ProposalStateFilter; domain?: "math" | "cognition" }) {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
const nextDomain = next.domain ?? domain;
|
||||
const nextState = next.state ?? filter;
|
||||
|
||||
if (nextDomain === "math") {
|
||||
params.set("domain", "math");
|
||||
} else {
|
||||
params.delete("domain");
|
||||
}
|
||||
params.set("state", nextState);
|
||||
|
||||
if (next.proposalId === null) {
|
||||
params.delete("proposal_id");
|
||||
} else if (next.proposalId) {
|
||||
|
|
@ -59,30 +138,126 @@ export function ProposalsRoute() {
|
|||
updateRoute({ state: nextFilter, proposalId: null });
|
||||
}
|
||||
|
||||
function changeDomain(nextDomain: "math" | "cognition") {
|
||||
setDomain(nextDomain);
|
||||
updateRoute({ domain: nextDomain, proposalId: null });
|
||||
}
|
||||
|
||||
function selectProposal(proposalId: string) {
|
||||
const index = proposals.findIndex((p) => p.proposal_id === proposalId);
|
||||
if (index !== -1) {
|
||||
setFocusedIndex(index);
|
||||
}
|
||||
updateRoute({ proposalId });
|
||||
}
|
||||
|
||||
if (proposalsQuery.isLoading) {
|
||||
// Keyboard navigation wires
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (document.activeElement?.tagName === "INPUT" || document.activeElement?.tagName === "TEXTAREA") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "j" || e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setFocusedIndex((prev) => Math.min(prev + 1, proposals.length - 1));
|
||||
} else if (e.key === "k" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setFocusedIndex((prev) => Math.max(prev - 1, 0));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
if (proposals[focusedIndex]) {
|
||||
selectProposal(proposals[focusedIndex].proposal_id);
|
||||
}
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
updateRoute({ proposalId: null });
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [proposals, focusedIndex]);
|
||||
|
||||
// Auto-advance focus on success
|
||||
const autoAdvance = () => {
|
||||
let nextIndex = -1;
|
||||
for (let i = focusedIndex + 1; i < proposals.length; i++) {
|
||||
if (proposals[i].state === "pending" || domain === "math") {
|
||||
nextIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (nextIndex === -1) {
|
||||
for (let i = 0; i < focusedIndex; i++) {
|
||||
if (proposals[i].state === "pending" || domain === "math") {
|
||||
nextIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nextIndex !== -1) {
|
||||
setFocusedIndex(nextIndex);
|
||||
selectProposal(proposals[nextIndex].proposal_id);
|
||||
} else {
|
||||
updateRoute({ proposalId: null });
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = domain === "math" ? mathProposalsQuery.isLoading : cognitionProposalsQuery.isLoading;
|
||||
const isError = domain === "math" ? mathProposalsQuery.isError : cognitionProposalsQuery.isError;
|
||||
const error = domain === "math" ? mathProposalsQuery.error : cognitionProposalsQuery.error;
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingState label="Loading proposal queue..." />;
|
||||
}
|
||||
|
||||
if (proposalsQuery.isError) {
|
||||
if (isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
whatFailed={errorMessage(proposalsQuery.error)}
|
||||
whatFailed={errorMessage(error)}
|
||||
mutationStatus="No proposal mutation occurred."
|
||||
reproducer="curl /proposals"
|
||||
reproducer={`curl /${domain === "math" ? "math-" : ""}proposals`}
|
||||
retrySafety="Retry: safe"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const detailQuery = domain === "math" ? mathDetailQuery : cognitionDetailQuery;
|
||||
|
||||
return (
|
||||
<div className="grid h-full min-h-0 gap-4 xl:grid-cols-[minmax(34rem,0.95fr)_minmax(32rem,1.05fr)]">
|
||||
<section className="grid min-h-0 content-start gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="m-0 text-base font-semibold text-[var(--color-text-primary)]">Proposal Queue</h1>
|
||||
{/* Domain Selector Tabs */}
|
||||
<div className="flex bg-[var(--color-surface-inset)] p-0.5 rounded border border-[var(--color-border-subtle)]">
|
||||
<button
|
||||
onClick={() => changeDomain("math")}
|
||||
className={`px-3 py-1 rounded text-xs font-semibold transition-all ${
|
||||
domain === "math"
|
||||
? "bg-[var(--color-surface-raised)] text-[var(--color-text-primary)] shadow-sm"
|
||||
: "text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)]"
|
||||
}`}
|
||||
>
|
||||
Math Corridor
|
||||
</button>
|
||||
<button
|
||||
onClick={() => changeDomain("cognition")}
|
||||
className={`px-3 py-1 rounded text-xs font-semibold transition-all ${
|
||||
domain === "cognition"
|
||||
? "bg-[var(--color-surface-raised)] text-[var(--color-text-primary)] shadow-sm"
|
||||
: "text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)]"
|
||||
}`}
|
||||
>
|
||||
Cognition Queue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{domain === "cognition" && (
|
||||
<div className="flex flex-wrap gap-2" role="group" aria-label="Proposal state filter">
|
||||
{filters.map((state) => (
|
||||
<Button
|
||||
|
|
@ -96,23 +271,29 @@ export function ProposalsRoute() {
|
|||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{proposals.length === 0 ? (
|
||||
<EmptyState
|
||||
statement="No proposals match this queue view."
|
||||
nextAction={{ kind: "cli", command: "core teaching proposals --state pending" }}
|
||||
statement={domain === "math" ? "No math proposals match this queue view." : "No proposals match this queue view."}
|
||||
|
||||
nextAction={{
|
||||
kind: "cli",
|
||||
command: domain === "math" ? "core eval gsm8k_math" : "core teaching proposals --state pending",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ProposalTable
|
||||
proposals={proposals}
|
||||
selectedProposalId={selectedProposalId}
|
||||
focusedProposalId={focusedProposalId}
|
||||
onSelect={selectProposal}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="min-h-0 overflow-y-auto">
|
||||
<section className="min-h-0 overflow-y-auto pr-1">
|
||||
{!selectedProposalId ? (
|
||||
<EmptyState
|
||||
statement="Select a proposal to inspect replay evidence, chain records, and provenance."
|
||||
|
|
@ -124,18 +305,147 @@ export function ProposalsRoute() {
|
|||
<ErrorState
|
||||
whatFailed={errorMessage(detailQuery.error)}
|
||||
mutationStatus="No proposal mutation occurred."
|
||||
reproducer={`curl /proposals/${selectedProposalId}`}
|
||||
reproducer={`curl /${domain === "math" ? "math-" : ""}proposals/${selectedProposalId}`}
|
||||
retrySafety="Retry: safe"
|
||||
/>
|
||||
) : detailQuery.data ? (
|
||||
domain === "math" ? (
|
||||
<MathProposalDetailView
|
||||
proposal={detailQuery.data as MathProposalDetail}
|
||||
state="pending"
|
||||
replayEquivalent={true}
|
||||
onSuccess={autoAdvance}
|
||||
onDefer={autoAdvance}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
<ProposalSummaryCard proposal={detailQuery.data} />
|
||||
<ReplayEvidenceCard proposal={detailQuery.data} />
|
||||
<ProposalChainViewer proposal={detailQuery.data} />
|
||||
<ProposalProvenanceViewer proposal={detailQuery.data} />
|
||||
<ProposalSummaryCard proposal={detailQuery.data as any} />
|
||||
<ReplayEvidenceCard proposal={detailQuery.data as any} />
|
||||
<ProposalChainViewer proposal={detailQuery.data as any} />
|
||||
<ProposalProvenanceViewer proposal={detailQuery.data as any} />
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MathProposalDetailViewProps {
|
||||
proposal: MathProposalDetail;
|
||||
state: string;
|
||||
replayEquivalent: boolean | null;
|
||||
onSuccess: () => void;
|
||||
onDefer: () => void;
|
||||
}
|
||||
|
||||
function MathProposalDetailView({
|
||||
proposal,
|
||||
state,
|
||||
replayEquivalent,
|
||||
onSuccess,
|
||||
onDefer,
|
||||
}: MathProposalDetailViewProps) {
|
||||
const steps = proposal.reasoning_trace_steps || [];
|
||||
const hashes = proposal.evidence_hashes || [];
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
{/* Summary Card */}
|
||||
<section className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="m-0 font-mono text-sm font-semibold text-[var(--color-text-primary)]">
|
||||
{proposal.proposal_id}
|
||||
</h2>
|
||||
<span className="text-xs font-semibold px-2 py-0.5 rounded bg-[var(--color-state-neutral-bg)] border border-[var(--color-state-neutral-border)] text-[var(--color-state-neutral-text)]">
|
||||
{proposal.shape_category}
|
||||
</span>
|
||||
<span className="text-xs font-semibold px-2 py-0.5 rounded bg-[var(--color-state-success-bg)] border border-[var(--color-state-success-border)] text-[var(--color-state-success-text)] font-mono">
|
||||
math_contemplation
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-[var(--color-text-secondary)]">
|
||||
Proposed change: <strong className="text-[var(--color-text-primary)] font-mono">{proposal.proposed_change_kind}</strong>
|
||||
</p>
|
||||
<dl className="mt-4 grid grid-cols-2 gap-3 text-xs">
|
||||
<div>
|
||||
<dt className="text-[var(--color-text-muted)]">Structural Commonality</dt>
|
||||
<dd className="m-0 text-[var(--color-text-primary)] font-medium mt-0.5">{proposal.structural_commonality}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[var(--color-text-muted)]">Replay Equivalence Hash</dt>
|
||||
<dd className="m-0 text-[var(--color-text-primary)] font-mono truncate mt-0.5" title={proposal.replay_equivalence_hash}>
|
||||
{proposal.replay_equivalence_hash}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{/* Wrong Zero Assertion Card */}
|
||||
<section className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] p-4">
|
||||
<h3 className="m-0 text-sm font-semibold text-[var(--color-text-primary)] flex items-center gap-2">
|
||||
<AlertTriangle size={15} className="text-[var(--color-state-warning-text)]" />
|
||||
Wrong Zero Assertion
|
||||
</h3>
|
||||
<p className="mt-2.5 text-xs font-mono text-[var(--color-state-warning-text)] bg-[var(--color-surface-inset)] p-3 rounded border border-[var(--color-border-subtle)] whitespace-pre-wrap leading-relaxed">
|
||||
{proposal.wrong_zero_assertion}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Proposed Change Payload Card */}
|
||||
<section className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] p-4">
|
||||
<h3 className="m-0 text-sm font-semibold text-[var(--color-text-primary)] mb-3">Proposed Change Payload</h3>
|
||||
<pre className="p-3 bg-[var(--color-surface-inset)] rounded border border-[var(--color-border-subtle)] font-mono text-xs text-[var(--color-text-primary)] overflow-x-auto">
|
||||
{JSON.stringify(proposal.proposed_change_payload, null, 2)}
|
||||
</pre>
|
||||
</section>
|
||||
|
||||
{/* Reasoning Trace Steps */}
|
||||
<section className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] p-4">
|
||||
<h3 className="m-0 text-sm font-semibold text-[var(--color-text-primary)] mb-3">
|
||||
Reasoning Steps ({steps.length})
|
||||
</h3>
|
||||
<div className="grid gap-3 max-h-[30rem] overflow-y-auto pr-1">
|
||||
{steps.map((step: MathReasoningStep) => (
|
||||
<div key={step.step_index} className="p-3 bg-[var(--color-surface-inset)] rounded border border-[var(--color-border-subtle)] text-xs">
|
||||
<div className="flex items-center justify-between mb-1.5 font-medium text-[var(--color-text-primary)]">
|
||||
<span>Step {step.step_index}: <span className="text-[var(--color-link)] font-mono">{step.step_kind}</span></span>
|
||||
<span className="text-[var(--color-text-muted)] font-mono">[{step.input_pointers?.join(", ")}]</span>
|
||||
</div>
|
||||
<p className="m-0 text-[var(--color-text-primary)] font-semibold mb-1">
|
||||
{step.claim}
|
||||
</p>
|
||||
<p className="m-0 text-[var(--color-text-secondary)] italic">
|
||||
{step.justification}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Evidence Hashes */}
|
||||
<section className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] p-4">
|
||||
<h3 className="m-0 text-sm font-semibold text-[var(--color-text-primary)] mb-3">Evidence Hashes</h3>
|
||||
<div className="grid gap-1.5">
|
||||
{hashes.map((hash: string, i: number) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<span className="text-xs text-[var(--color-text-muted)] font-mono">#{i + 1}</span>
|
||||
<code className="bg-[var(--color-surface-inset)] px-2 py-1 rounded font-mono text-xs text-[var(--color-text-primary)] truncate flex-1">
|
||||
{hash}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Ratification Command Panel */}
|
||||
<RatificationCommandPanel
|
||||
proposal={proposal}
|
||||
state={state}
|
||||
replayEquivalent={replayEquivalent}
|
||||
onSuccess={onSuccess}
|
||||
onDefer={onDefer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
353
workbench-ui/src/app/proposals/RatificationCommandPanel.tsx
Normal file
353
workbench-ui/src/app/proposals/RatificationCommandPanel.tsx
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Check, X, Clock, Terminal, AlertTriangle } from "lucide-react";
|
||||
import { useMathRatify, useMathReject, useMathDefer } from "../../api/queries";
|
||||
import type { MathProposalDetail } from "../../types/api";
|
||||
import { Button } from "../../design/components/primitives/Button";
|
||||
import { copyText } from "../../design/lib";
|
||||
|
||||
interface RatificationCommandPanelProps {
|
||||
proposal: MathProposalDetail;
|
||||
state: string;
|
||||
replayEquivalent: boolean | null;
|
||||
onSuccess?: () => void;
|
||||
onDefer?: () => void;
|
||||
}
|
||||
|
||||
const CATEGORY_ALLOWLISTS: Record<string, string[]> = {
|
||||
LexicalClaim: ["drain_token"],
|
||||
FrameClaim: ["increment_frame", "decrement_frame", "transfer_frame", "remainder_frame"],
|
||||
CompositionClaim: ["multiplicative_composition", "additive_composition", "subtractive_composition"],
|
||||
};
|
||||
|
||||
export function RatificationCommandPanel({
|
||||
proposal,
|
||||
state,
|
||||
replayEquivalent,
|
||||
onSuccess,
|
||||
onDefer,
|
||||
}: RatificationCommandPanelProps) {
|
||||
const handlerName = proposal.handler_name ?? "";
|
||||
const isPending = state === "pending";
|
||||
const isReplayEquivalent = replayEquivalent === true;
|
||||
const isSupportedHandler = ["LexicalClaim", "FrameClaim", "CompositionClaim"].includes(handlerName);
|
||||
|
||||
const isEnabled = isPending && isReplayEquivalent && isSupportedHandler;
|
||||
|
||||
const allowedCategories = CATEGORY_ALLOWLISTS[handlerName] || [];
|
||||
const [selectedCategory, setSelectedCategory] = useState("");
|
||||
const [selectedPolarity, setSelectedPolarity] = useState("affirms");
|
||||
const [showNoteInput, setShowNoteInput] = useState(false);
|
||||
const [note, setNote] = useState("");
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
const [statusType, setStatusType] = useState<"success" | "error" | "info" | null>(null);
|
||||
|
||||
const ratifyMutation = useMathRatify();
|
||||
const rejectMutation = useMathReject();
|
||||
const deferMutation = useMathDefer();
|
||||
|
||||
// Reset states when proposal changes
|
||||
useEffect(() => {
|
||||
if (allowedCategories.length > 0) {
|
||||
setSelectedCategory(allowedCategories[0]);
|
||||
} else {
|
||||
setSelectedCategory("");
|
||||
}
|
||||
setSelectedPolarity("affirms");
|
||||
setShowNoteInput(false);
|
||||
setNote("");
|
||||
setStatusMessage(null);
|
||||
setStatusType(null);
|
||||
}, [proposal.proposal_id, handlerName]);
|
||||
|
||||
const handleRatify = async () => {
|
||||
if (!isEnabled) {
|
||||
if (!isPending) {
|
||||
setStatusMessage(`Cannot ratify: proposal state is ${state}`);
|
||||
setStatusType("error");
|
||||
} else if (!isReplayEquivalent) {
|
||||
setStatusMessage("Cannot ratify: proposal is not replay equivalent");
|
||||
setStatusType("error");
|
||||
} else {
|
||||
setStatusMessage("Cannot ratify: unsupported handler name");
|
||||
setStatusType("error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setStatusMessage("Executing ratification...");
|
||||
setStatusType("info");
|
||||
|
||||
ratifyMutation.mutate(
|
||||
{
|
||||
proposalId: proposal.proposal_id,
|
||||
category: selectedCategory || undefined,
|
||||
polarity: handlerName !== "LexicalClaim" ? selectedPolarity : undefined,
|
||||
dryRun: false,
|
||||
},
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
if (result.applied) {
|
||||
setStatusMessage(`Ratification succeeded: ${result.message}`);
|
||||
setStatusType("success");
|
||||
setTimeout(() => {
|
||||
if (onSuccess) onSuccess();
|
||||
}, 800);
|
||||
} else {
|
||||
setStatusMessage(`Dry run validation routed but not applied: ${result.message}`);
|
||||
setStatusType("info");
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
setStatusMessage(err.message);
|
||||
setStatusType("error");
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleReject = async () => {
|
||||
setStatusMessage("Recording rejection...");
|
||||
setStatusType("info");
|
||||
|
||||
rejectMutation.mutate(
|
||||
{
|
||||
proposalId: proposal.proposal_id,
|
||||
note: note || undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setStatusMessage("Proposal rejected successfully");
|
||||
setStatusType("success");
|
||||
setShowNoteInput(false);
|
||||
setNote("");
|
||||
setTimeout(() => {
|
||||
if (onSuccess) onSuccess();
|
||||
}, 800);
|
||||
},
|
||||
onError: (err) => {
|
||||
setStatusMessage(err.message);
|
||||
setStatusType("error");
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleDefer = async () => {
|
||||
setStatusMessage("Recording deferral...");
|
||||
setStatusType("info");
|
||||
|
||||
deferMutation.mutate(
|
||||
{
|
||||
proposalId: proposal.proposal_id,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setStatusMessage("Proposal deferred successfully");
|
||||
setStatusType("success");
|
||||
setTimeout(() => {
|
||||
if (onDefer) onDefer();
|
||||
}, 800);
|
||||
},
|
||||
onError: (err) => {
|
||||
setStatusMessage(err.message);
|
||||
setStatusType("error");
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleCopyCli = async () => {
|
||||
if (proposal.suggested_ratify_cli) {
|
||||
await copyText(proposal.suggested_ratify_cli);
|
||||
setStatusMessage("Suggested CLI command copied to clipboard");
|
||||
setStatusType("success");
|
||||
setTimeout(() => {
|
||||
setStatusMessage(null);
|
||||
setStatusType(null);
|
||||
}, 3000);
|
||||
} else {
|
||||
setStatusMessage("No suggested CLI command available");
|
||||
setStatusType("error");
|
||||
}
|
||||
};
|
||||
|
||||
// Keyboard shortcut listener
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// If typing in input, don't trigger global shortcuts
|
||||
if (document.activeElement?.tagName === "INPUT" || document.activeElement?.tagName === "TEXTAREA") {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setShowNoteInput(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "r") {
|
||||
e.preventDefault();
|
||||
handleRatify();
|
||||
} else if (e.key === "x") {
|
||||
e.preventDefault();
|
||||
setShowNoteInput(true);
|
||||
} else if (e.key === "d") {
|
||||
e.preventDefault();
|
||||
handleDefer();
|
||||
} else if (e.key === "y") {
|
||||
e.preventDefault();
|
||||
handleCopyCli();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [proposal.proposal_id, isEnabled, selectedCategory, selectedPolarity, note, state, replayEquivalent]);
|
||||
|
||||
const handleNoteKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleReject();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] p-4 shadow-panel mt-4 transition-all">
|
||||
<div className="flex items-center justify-between border-b border-[var(--color-border-subtle)] pb-2 mb-3">
|
||||
<h3 className="m-0 text-sm font-semibold text-[var(--color-text-primary)] flex items-center gap-2">
|
||||
<Terminal size={16} className="text-[var(--color-text-secondary)]" />
|
||||
Ratification Corridor
|
||||
</h3>
|
||||
<span className="text-xs font-mono text-[var(--color-text-muted)] bg-[var(--color-surface-inset)] px-2 py-0.5 rounded">
|
||||
{handlerName || "No handler"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!isEnabled ? (
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--color-state-warning-text)] bg-[var(--color-state-warning-bg)] border border-[var(--color-state-warning-border)] p-3 rounded-md mb-2">
|
||||
<AlertTriangle size={16} className="shrink-0" />
|
||||
<div>
|
||||
<strong>Corridor Gated:</strong>{" "}
|
||||
{!isPending
|
||||
? `This proposal is already ${state}.`
|
||||
: !isReplayEquivalent
|
||||
? "Replay verification failed or is not equivalent. Ratification disabled."
|
||||
: !isSupportedHandler
|
||||
? `Ratification handler '${handlerName}' not admitted.`
|
||||
: "Preconditions failed."}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 mb-4">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--color-text-secondary)] mb-1">
|
||||
Allowlisted Category
|
||||
</label>
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||
className="w-full bg-[var(--color-surface-inset)] border border-[var(--color-border-subtle)] rounded-md px-3 py-1.5 text-sm text-[var(--color-text-primary)] focus:border-[var(--color-focus-ring)] focus:outline-none"
|
||||
>
|
||||
{allowedCategories.map((cat) => (
|
||||
<option key={cat} value={cat}>
|
||||
{cat}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{handlerName !== "LexicalClaim" && (
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--color-text-secondary)] mb-1">
|
||||
Polarity Mapping
|
||||
</label>
|
||||
<select
|
||||
value={selectedPolarity}
|
||||
onChange={(e) => setSelectedPolarity(e.target.value)}
|
||||
className="w-full bg-[var(--color-surface-inset)] border border-[var(--color-border-subtle)] rounded-md px-3 py-1.5 text-sm text-[var(--color-text-primary)] focus:border-[var(--color-focus-ring)] focus:outline-none"
|
||||
>
|
||||
<option value="affirms">affirms</option>
|
||||
<option value="falsifies">falsifies</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNoteInput && isEnabled && (
|
||||
<div className="bg-[var(--color-surface-inset)] p-3 border border-[var(--color-border-subtle)] rounded-md mb-3 transition-all">
|
||||
<label className="block text-xs font-medium text-[var(--color-text-secondary)] mb-1">
|
||||
Rejection Note (Enter to commit, Esc to cancel)
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
onKeyDown={handleNoteKeyDown}
|
||||
placeholder="e.g. unexpected slot constraints, wrong zero hazard"
|
||||
className="flex-1 bg-[var(--color-surface-base)] border border-[var(--color-border-subtle)] rounded-md px-3 py-1 text-sm text-[var(--color-text-primary)] focus:border-[var(--color-focus-ring)] focus:outline-none"
|
||||
/>
|
||||
<Button onClick={handleReject} variant="quiet" className="text-[var(--color-review-rejected)]">
|
||||
Reject
|
||||
</Button>
|
||||
<Button onClick={() => setShowNoteInput(false)} variant="quiet">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 pt-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
onClick={handleRatify}
|
||||
disabled={!isEnabled || ratifyMutation.isPending}
|
||||
variant={isEnabled ? "primary" : "quiet"}
|
||||
title="Ratify (r)"
|
||||
>
|
||||
<Check size={14} className="mr-1" />
|
||||
Ratify <span className="text-xs opacity-60 ml-1">(r)</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => setShowNoteInput(true)}
|
||||
disabled={!isEnabled || rejectMutation.isPending}
|
||||
variant="quiet"
|
||||
title="Reject (x)"
|
||||
>
|
||||
<X size={14} className="mr-1" />
|
||||
Reject <span className="text-xs opacity-60 ml-1">(x)</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleDefer}
|
||||
disabled={!isEnabled || deferMutation.isPending}
|
||||
variant="quiet"
|
||||
title="Defer (d)"
|
||||
>
|
||||
<Clock size={14} className="mr-1" />
|
||||
Defer <span className="text-xs opacity-60 ml-1">(d)</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleCopyCli} variant="quiet" title="Copy Suggested CLI (y)">
|
||||
Copy CLI <span className="text-xs opacity-60 ml-1">(y)</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{statusMessage && (
|
||||
<div
|
||||
className={`mt-3 p-2.5 rounded-md text-xs font-mono border ${
|
||||
statusType === "success"
|
||||
? "bg-[var(--color-state-success-bg)] border-[var(--color-state-success-border)] text-[var(--color-state-success-text)]"
|
||||
: statusType === "error"
|
||||
? "bg-[var(--color-state-danger-bg)] border-[var(--color-state-danger-border)] text-[var(--color-state-danger-text)]"
|
||||
: "bg-[var(--color-state-info-bg)] border-[var(--color-state-info-border)] text-[var(--color-state-info-text)]"
|
||||
}`}
|
||||
>
|
||||
{statusMessage}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -183,3 +183,45 @@ export interface ApiError {
|
|||
}
|
||||
|
||||
export type ApiResponse<T> = ApiOk<T> | ApiError;
|
||||
|
||||
export interface MathReasoningStep {
|
||||
step_index: number;
|
||||
step_kind: string;
|
||||
claim: string;
|
||||
justification: string;
|
||||
input_pointers: string[];
|
||||
output_payload: unknown;
|
||||
}
|
||||
|
||||
export interface MathProposalSummary {
|
||||
proposal_id: string;
|
||||
domain: "math";
|
||||
shape_category: string;
|
||||
proposed_change_kind: string;
|
||||
structural_commonality: string;
|
||||
evidence_count: number;
|
||||
replay_equivalence_hash: string;
|
||||
}
|
||||
|
||||
export interface MathProposalDetail extends MathProposalSummary {
|
||||
wrong_zero_assertion: string;
|
||||
proposed_change_payload: unknown;
|
||||
reasoning_trace_id: string;
|
||||
reasoning_trace_steps: MathReasoningStep[];
|
||||
evidence_hashes: string[];
|
||||
handler_name: string | null;
|
||||
suggested_ratify_cli: string | null;
|
||||
}
|
||||
|
||||
export interface MathRatifyResult {
|
||||
proposal_id: string;
|
||||
change_kind: string;
|
||||
handler_name: string;
|
||||
routing_status: "routed" | "not_implemented";
|
||||
message: string;
|
||||
suggested_cli: string | null;
|
||||
applied: boolean;
|
||||
target_path: string | null;
|
||||
evidence_hash: string | null;
|
||||
}
|
||||
|
||||
|
|
|
|||
140
workbench/api.py
140
workbench/api.py
|
|
@ -33,6 +33,36 @@ class ApiResponse:
|
|||
|
||||
|
||||
class WorkbenchApi:
|
||||
def __init__(self, telemetry_sink: Any | None = None) -> None:
|
||||
self._telemetry_sink = telemetry_sink
|
||||
|
||||
def attach_telemetry_sink(self, sink: Any | None) -> None:
|
||||
self._telemetry_sink = sink
|
||||
|
||||
def _emit_operator_telemetry(
|
||||
self,
|
||||
event_name: str,
|
||||
proposal_id: str,
|
||||
outcome: str | None = None,
|
||||
handler: str | None = None,
|
||||
note: str | None = None,
|
||||
) -> None:
|
||||
if self._telemetry_sink is None:
|
||||
return
|
||||
payload: dict[str, Any] = {
|
||||
"event": event_name,
|
||||
"proposal_id": proposal_id,
|
||||
"ratifier_kind": "workbench",
|
||||
}
|
||||
if handler is not None:
|
||||
payload["handler"] = handler
|
||||
if outcome is not None:
|
||||
payload["outcome"] = outcome
|
||||
if note is not None:
|
||||
payload["note"] = note
|
||||
line = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||
self._telemetry_sink.emit(line)
|
||||
|
||||
def handle(self, method: str, raw_path: str, body: bytes = b"") -> ApiResponse:
|
||||
parsed = urlparse(raw_path)
|
||||
path = parsed.path.rstrip("/") or "/"
|
||||
|
|
@ -42,7 +72,11 @@ class WorkbenchApi:
|
|||
except json.JSONDecodeError as exc:
|
||||
return ApiResponse(400, error("bad_request", "invalid JSON body", detail=str(exc)))
|
||||
except ValueError as exc:
|
||||
return ApiResponse(400, error("bad_request", str(exc)))
|
||||
status = 400
|
||||
msg = str(exc)
|
||||
if "already ratified" in msg.lower():
|
||||
status = 409
|
||||
return ApiResponse(status, error("bad_request", msg))
|
||||
except FileNotFoundError as exc:
|
||||
missing = str(exc) or "resource"
|
||||
return ApiResponse(404, error("not_found", f"not found: {missing}"))
|
||||
|
|
@ -79,7 +113,13 @@ class WorkbenchApi:
|
|||
return ApiResponse(200, ok({"items": readers.list_math_proposals()}))
|
||||
if method == "POST" and path.endswith("/ratify") and path.startswith("/math-proposals/"):
|
||||
proposal_id = unquote(path.removeprefix("/math-proposals/").removesuffix("/ratify"))
|
||||
return self._math_ratify(proposal_id)
|
||||
return self._math_ratify(proposal_id, body)
|
||||
if method == "POST" and path.endswith("/reject") and path.startswith("/math-proposals/"):
|
||||
proposal_id = unquote(path.removeprefix("/math-proposals/").removesuffix("/reject"))
|
||||
return self._math_reject(proposal_id, body)
|
||||
if method == "POST" and path.endswith("/defer") and path.startswith("/math-proposals/"):
|
||||
proposal_id = unquote(path.removeprefix("/math-proposals/").removesuffix("/defer"))
|
||||
return self._math_defer(proposal_id)
|
||||
if method == "GET" and path.startswith("/math-proposals/"):
|
||||
proposal_id = unquote(path.removeprefix("/math-proposals/"))
|
||||
return ApiResponse(200, ok(readers.read_math_proposal(proposal_id)))
|
||||
|
|
@ -111,14 +151,104 @@ class WorkbenchApi:
|
|||
return ApiResponse(501, error("unsupported", "route is deferred beyond W-026"))
|
||||
return ApiResponse(404, error("not_found", f"route not found: {method} {path}"))
|
||||
|
||||
def _math_ratify(self, proposal_id: str) -> ApiResponse:
|
||||
"""Route ratification by change_kind; 501 for unimplemented handlers."""
|
||||
def _math_ratify(self, proposal_id: str, body: bytes) -> ApiResponse:
|
||||
"""Route ratification by change_kind; in-process execution with allowlist checks."""
|
||||
category: str | None = None
|
||||
polarity: str | None = None
|
||||
dry_run: bool = False
|
||||
|
||||
if body:
|
||||
try:
|
||||
result: MathRatifyResult = readers.ratify_math_proposal(proposal_id)
|
||||
req = json.loads(body.decode("utf-8") or "{}")
|
||||
if isinstance(req, dict):
|
||||
category = req.get("category")
|
||||
polarity = req.get("polarity")
|
||||
dry_run = bool(req.get("dry_run", False))
|
||||
except Exception as exc:
|
||||
return ApiResponse(400, error("bad_request", "invalid JSON body", detail=str(exc)))
|
||||
|
||||
import getpass
|
||||
reviewer = getpass.getuser()
|
||||
|
||||
try:
|
||||
result: MathRatifyResult = readers.ratify_math_proposal(
|
||||
proposal_id,
|
||||
category=category,
|
||||
polarity=polarity,
|
||||
reviewer=reviewer,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
except NotImplementedError as exc:
|
||||
return ApiResponse(501, error("unsupported", str(exc)))
|
||||
except (ValueError, FileNotFoundError) as exc:
|
||||
msg = str(exc)
|
||||
handler = "unknown"
|
||||
try:
|
||||
prop = readers.read_math_proposal(proposal_id)
|
||||
handler = prop.handler_name or "unknown"
|
||||
except Exception:
|
||||
pass
|
||||
status_code = 400
|
||||
exc_class_name = exc.__class__.__name__
|
||||
if exc_class_name == "AlreadyRatified" or "already ratified" in msg.lower():
|
||||
status_code = 409
|
||||
|
||||
self._emit_operator_telemetry(
|
||||
event_name="operator_ratify",
|
||||
proposal_id=proposal_id,
|
||||
outcome="rejected_precondition",
|
||||
handler=handler,
|
||||
)
|
||||
return ApiResponse(status_code, error("bad_request", msg))
|
||||
except Exception as exc:
|
||||
return ApiResponse(500, error("runtime_unavailable", f"internal error: {exc}"))
|
||||
|
||||
if result.applied:
|
||||
self._emit_operator_telemetry(
|
||||
event_name="operator_ratify",
|
||||
proposal_id=proposal_id,
|
||||
outcome="applied",
|
||||
handler=result.handler_name,
|
||||
)
|
||||
return ApiResponse(200, ok(result))
|
||||
|
||||
def _math_reject(self, proposal_id: str, body: bytes) -> ApiResponse:
|
||||
note: str = ""
|
||||
if body:
|
||||
try:
|
||||
req = json.loads(body.decode("utf-8") or "{}")
|
||||
if isinstance(req, dict):
|
||||
note = str(req.get("note", ""))
|
||||
except Exception as exc:
|
||||
return ApiResponse(400, error("bad_request", "invalid JSON body", detail=str(exc)))
|
||||
try:
|
||||
prop = readers.read_math_proposal(proposal_id)
|
||||
handler = prop.handler_name or "unknown"
|
||||
except FileNotFoundError as exc:
|
||||
return ApiResponse(404, error("not_found", str(exc)))
|
||||
|
||||
self._emit_operator_telemetry(
|
||||
event_name="operator_reject",
|
||||
proposal_id=proposal_id,
|
||||
handler=handler,
|
||||
note=note,
|
||||
)
|
||||
return ApiResponse(200, ok({"proposal_id": proposal_id, "rejected": True}))
|
||||
|
||||
def _math_defer(self, proposal_id: str) -> ApiResponse:
|
||||
try:
|
||||
prop = readers.read_math_proposal(proposal_id)
|
||||
handler = prop.handler_name or "unknown"
|
||||
except FileNotFoundError as exc:
|
||||
return ApiResponse(404, error("not_found", str(exc)))
|
||||
|
||||
self._emit_operator_telemetry(
|
||||
event_name="operator_defer",
|
||||
proposal_id=proposal_id,
|
||||
handler=handler,
|
||||
)
|
||||
return ApiResponse(200, ok({"proposal_id": proposal_id, "deferred": True}))
|
||||
|
||||
def _chat_turn(self, body: bytes) -> ApiResponse:
|
||||
"""Execute one live runtime turn.
|
||||
|
||||
|
|
|
|||
|
|
@ -328,7 +328,8 @@ def _math_proposal_summary(record: dict[str, Any]) -> MathProposalSummary:
|
|||
def list_math_proposals(*, jsonl_path: Path | None = None) -> list[MathProposalSummary]:
|
||||
path = jsonl_path or MATH_PROPOSALS_JSONL
|
||||
records = _load_math_proposals_raw(path)
|
||||
return [_math_proposal_summary(r) for r in records]
|
||||
return [_math_proposal_summary(r) for r in records if r.get("domain") == "math"]
|
||||
|
||||
|
||||
|
||||
def _math_trace_steps_from_record(record: dict[str, Any]) -> list[MathReasoningStep]:
|
||||
|
|
@ -376,6 +377,9 @@ def read_math_proposal(
|
|||
record = next((r for r in records if r.get("proposal_id") == proposal_id), None)
|
||||
if record is None:
|
||||
raise FileNotFoundError(proposal_id)
|
||||
if record.get("domain") != "math":
|
||||
raise ValueError(f"Partition isolation violation: proposal domain must be 'math', got {record.get('domain')!r}")
|
||||
|
||||
|
||||
change_kind = str(record.get("proposed_change_kind", ""))
|
||||
handler_name = _HANDLER_DISPATCH.get(change_kind)
|
||||
|
|
@ -435,23 +439,25 @@ def read_math_proposal(
|
|||
def ratify_math_proposal(
|
||||
proposal_id: str,
|
||||
*,
|
||||
category: str | None = None,
|
||||
polarity: str | None = None,
|
||||
reviewer: str | None = None,
|
||||
dry_run: bool = False,
|
||||
jsonl_path: Path | None = None,
|
||||
) -> MathRatifyResult:
|
||||
"""Dispatch ratification by change_kind; fail loudly for unimplemented handlers.
|
||||
"""Dispatch ratification by change_kind.
|
||||
|
||||
ADR-0160 "Proposal before mutation" doctrine: this function validates
|
||||
routing and returns the handler name + suggested CLI without applying
|
||||
the change. Mutation requires an explicit operator action outside the
|
||||
workbench (e.g. calling apply_lexical_claim() directly).
|
||||
|
||||
Raises FileNotFoundError if proposal_id not found.
|
||||
Raises NotImplementedError with a clear message for unhandled change_kinds.
|
||||
If dry_run is False and category is provided, this applies the ratification mutation in-process.
|
||||
Otherwise, it validates routing and returns the handler name + suggested CLI.
|
||||
"""
|
||||
path = jsonl_path or MATH_PROPOSALS_JSONL
|
||||
records = _load_math_proposals_raw(path)
|
||||
record = next((r for r in records if r.get("proposal_id") == proposal_id), None)
|
||||
if record is None:
|
||||
raise FileNotFoundError(proposal_id)
|
||||
if record.get("domain") != "math":
|
||||
raise ValueError(f"Partition isolation violation: proposal domain must be 'math', got {record.get('domain')!r}")
|
||||
|
||||
|
||||
change_kind = str(record.get("proposed_change_kind", ""))
|
||||
handler_name = _HANDLER_DISPATCH.get(change_kind)
|
||||
|
|
@ -483,6 +489,7 @@ def ratify_math_proposal(
|
|||
f"polarity='affirms', reviewer='<you>')"
|
||||
)
|
||||
|
||||
if dry_run or category is None:
|
||||
return MathRatifyResult(
|
||||
proposal_id=proposal_id,
|
||||
change_kind=change_kind,
|
||||
|
|
@ -490,8 +497,88 @@ def ratify_math_proposal(
|
|||
routing_status="routed",
|
||||
message=f"routed to {handler_name} handler",
|
||||
suggested_cli=suggested_cli,
|
||||
applied=False,
|
||||
)
|
||||
|
||||
# In-process application
|
||||
from teaching.math_contemplation_proposal import from_jsonl_record
|
||||
proposal = from_jsonl_record(record)
|
||||
if not proposal.evidence_pointers:
|
||||
raise ValueError(f"Proposal {proposal_id} has no evidence pointers")
|
||||
claim = proposal.evidence_pointers[0]
|
||||
|
||||
import getpass
|
||||
effective_reviewer = reviewer or getpass.getuser()
|
||||
|
||||
if handler_name == "LexicalClaim":
|
||||
from teaching.math_lexical_ratification import apply_lexical_claim
|
||||
receipt = apply_lexical_claim(
|
||||
claim=claim,
|
||||
category=category,
|
||||
reviewer=effective_reviewer,
|
||||
ratifier_kind="workbench",
|
||||
)
|
||||
return MathRatifyResult(
|
||||
proposal_id=proposal_id,
|
||||
change_kind=change_kind,
|
||||
handler_name=handler_name,
|
||||
routing_status="routed",
|
||||
message=f"Applied LexicalClaim to {receipt.target_file}",
|
||||
suggested_cli=suggested_cli,
|
||||
applied=True,
|
||||
target_path=receipt.target_file,
|
||||
evidence_hash=receipt.evidence_hash,
|
||||
)
|
||||
|
||||
elif handler_name == "FrameClaim":
|
||||
from teaching.math_frame_ratification import apply_frame_claim
|
||||
if not polarity:
|
||||
raise ValueError("Polarity is required for FrameClaim ratification")
|
||||
receipt = apply_frame_claim(
|
||||
claim=claim,
|
||||
frame_category=category,
|
||||
polarity=polarity,
|
||||
reviewer=effective_reviewer,
|
||||
ratifier_kind="workbench",
|
||||
)
|
||||
return MathRatifyResult(
|
||||
proposal_id=proposal_id,
|
||||
change_kind=change_kind,
|
||||
handler_name=handler_name,
|
||||
routing_status="routed",
|
||||
message=f"Applied FrameClaim to {receipt.target_file}",
|
||||
suggested_cli=suggested_cli,
|
||||
applied=True,
|
||||
target_path=receipt.target_file,
|
||||
evidence_hash=receipt.evidence_hash,
|
||||
)
|
||||
|
||||
elif handler_name == "CompositionClaim":
|
||||
from teaching.math_composition_ratification import apply_composition_claim
|
||||
if not polarity:
|
||||
raise ValueError("Polarity is required for CompositionClaim ratification")
|
||||
receipt = apply_composition_claim(
|
||||
claim=claim,
|
||||
composition_category=category,
|
||||
polarity=polarity,
|
||||
reviewer=effective_reviewer,
|
||||
ratifier_kind="workbench",
|
||||
)
|
||||
return MathRatifyResult(
|
||||
proposal_id=proposal_id,
|
||||
change_kind=change_kind,
|
||||
handler_name=handler_name,
|
||||
routing_status="routed",
|
||||
message=f"Applied CompositionClaim to {receipt.target_file}",
|
||||
suggested_cli=suggested_cli,
|
||||
applied=True,
|
||||
target_path=receipt.target_file,
|
||||
evidence_hash=receipt.evidence_hash,
|
||||
)
|
||||
|
||||
else:
|
||||
raise NotImplementedError(f"handler {handler_name} application not implemented")
|
||||
|
||||
|
||||
def run_safe_eval_lane(
|
||||
lane_name: str,
|
||||
|
|
|
|||
|
|
@ -243,3 +243,6 @@ class MathRatifyResult:
|
|||
routing_status: Literal["routed", "not_implemented"]
|
||||
message: str
|
||||
suggested_cli: str | None = None
|
||||
applied: bool = False
|
||||
target_path: str | None = None
|
||||
evidence_hash: str | None = None
|
||||
|
|
|
|||
|
|
@ -34,6 +34,30 @@ class WorkbenchRequestHandler(BaseHTTPRequestHandler):
|
|||
)
|
||||
|
||||
def _handle(self) -> None:
|
||||
host = self.headers.get("Host", "")
|
||||
origin = self.headers.get("Origin", "")
|
||||
|
||||
host_name = host.split(":")[0] if ":" in host else host
|
||||
if host_name not in {"127.0.0.1", "localhost"}:
|
||||
payload = json.dumps({"ok": False, "error": "CORS check failed: Host is non-local"}).encode("utf-8")
|
||||
self.send_response(400)
|
||||
self._send_common_headers(len(payload))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
return
|
||||
|
||||
if origin:
|
||||
import urllib.parse
|
||||
parsed_origin = urllib.parse.urlparse(origin)
|
||||
origin_host = parsed_origin.hostname
|
||||
if origin_host not in {"127.0.0.1", "localhost"}:
|
||||
payload = json.dumps({"ok": False, "error": "CORS check failed: Origin is non-local"}).encode("utf-8")
|
||||
self.send_response(400)
|
||||
self._send_common_headers(len(payload))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
return
|
||||
|
||||
try:
|
||||
length = max(0, int(self.headers.get("Content-Length") or "0"))
|
||||
except ValueError:
|
||||
|
|
|
|||
Loading…
Reference in a new issue