diff --git a/core/epistemic_disclosure/__init__.py b/core/epistemic_disclosure/__init__.py index 57bfc46a..b612c998 100644 --- a/core/epistemic_disclosure/__init__.py +++ b/core/epistemic_disclosure/__init__.py @@ -19,6 +19,9 @@ Shipped so far (all off-serving — nothing here imports ``generate.derivation`` ``EpistemicState × LimitationAssessment × DisclosureClaim → ServedDisposition``. Mapping scaffold only — no rendering, no bus, no ``verify.py``; nothing consumes it yet. + * :mod:`~core.epistemic_disclosure.ask_serving` — a narrow Q1-D served-ASK artifact + adapter. It validates already-rendered question artifacts and returns a typed + decision; it does not render prose and does not acquire runtime contemplation. * :mod:`~core.epistemic_disclosure.verified_contract` (P1-A) — the VERIFIED contract: the obligation, the proof shape, the validator, and the single sanctioned route to ``EpistemicState.VERIFIED`` / ``DisclosureClaim.VERIFIED``. Contract only — no @@ -27,6 +30,10 @@ Shipped so far (all off-serving — nothing here imports ``generate.derivation`` from __future__ import annotations +from core.epistemic_disclosure.ask_serving import ( + ServedAskDecision, + evaluate_served_ask, +) from core.epistemic_disclosure.disclosure_claim import ( DEFAULT_DISCLOSURE_CLAIM, DisclosureClaim, @@ -64,6 +71,7 @@ __all__ = [ "LimitationKind", "MissingSlot", "ResolutionAction", + "ServedAskDecision", "ServedDisposition", "VerificationObligation", "VerificationProof", @@ -73,6 +81,7 @@ __all__ = [ "assess_from_family", "choose_served_disposition", "disclosure_for_verification", + "evaluate_served_ask", "evaluate_verification", "terminal_for_action", ] diff --git a/core/epistemic_disclosure/ask_serving.py b/core/epistemic_disclosure/ask_serving.py new file mode 100644 index 00000000..61add380 --- /dev/null +++ b/core/epistemic_disclosure/ask_serving.py @@ -0,0 +1,171 @@ +"""Stage 2 ASK served-surface artifact adapter. + +This module is intentionally narrow: it validates a pre-rendered Q1-D +``DeliveredQuestion`` artifact and decides whether that artifact is eligible to +be exposed as a served ASK/QUESTION_NEEDED surface. It does not acquire +contemplation results from runtime and does not render question prose. + +Validation enforces the Q1-D artifact contract: + +- top-level JSON object only; +- ``status == "question_only"``; +- ``requires_review is True``; +- ``served is False``; +- ``answer_binding`` is absent or ``None``; +- ``question`` is an object; +- ``question.text`` is a non-empty string; +- ``question.slot_name`` is a non-empty string; +- ``question_path`` exists on disk and differs from ``proposal_path``. + +Any validation failure fails closed to the caller's fallback surface and +standing disposition. The served text is consumed from the artifact exactly; no +runtime prose construction or mutation happens here. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from core.epistemic_disclosure.disposition import ServedDisposition, choose_served_disposition +from core.epistemic_disclosure.limitation import LimitationAssessment +from core.epistemic_questions.serving_gate import ask_serving_enabled +from core.epistemic_state import EpistemicState + +_MISSING = object() + + +@dataclass(frozen=True, slots=True) +class ServedAskDecision: + """The adapter's served-ASK decision.""" + + served: bool + terminal: str + surface: str + disposition: ServedDisposition + + +def _terminal_value(contemplation_result: Any) -> str: + terminal = getattr(contemplation_result, "terminal", None) + if terminal is None: + return "NO_PROGRESS" + return str(getattr(terminal, "value", terminal)) + + +def _fallback_disposition(terminal: str) -> ServedDisposition: + if terminal == "PROPOSAL_EMITTED": + return ServedDisposition.PROPOSE + if terminal == "SOLVED_VERIFIED": + return ServedDisposition.COMMIT + return ServedDisposition.REFUSE + + +def _fallback_decision(contemplation_result: Any, fallback_surface: str) -> ServedAskDecision: + terminal = _terminal_value(contemplation_result) + return ServedAskDecision( + served=False, + terminal=terminal, + surface=fallback_surface, + disposition=_fallback_disposition(terminal), + ) + + +def _validate_question_artifact(data: Any, *, question_path: Path, proposal_path: Any) -> str | None: + """Return the valid question text, or ``None`` for any contract violation.""" + + if not isinstance(data, dict): + return None + if data.get("status") != "question_only": + return None + if data.get("requires_review") is not True: + return None + served = data.get("served", _MISSING) + if served is _MISSING or served is not False: + return None + answer_binding = data.get("answer_binding", _MISSING) + if answer_binding is not _MISSING and answer_binding is not None: + return None + + question = data.get("question") + if not isinstance(question, dict): + return None + + text = question.get("text") + if not isinstance(text, str) or not text.strip(): + return None + + slot_name = question.get("slot_name") + if not isinstance(slot_name, str) or not slot_name.strip(): + return None + + if proposal_path is not None and str(question_path) == str(proposal_path): + return None + + return text.strip() + + +def evaluate_served_ask( + config: Any, + contemplation_result: Any, + fallback_surface: str, +) -> ServedAskDecision: + """Evaluate whether a Q1-D question artifact may be surfaced as ASK. + + This is a bus/disposition adapter, not a renderer and not the runtime + acquisition path. The caller supplies a contemplation result that already + points to a delivered question artifact. When the gate is disabled or any + artifact invariant fails, the adapter returns the fallback surface and the + standing fallback disposition. + """ + + if not ask_serving_enabled(config): + return _fallback_decision(contemplation_result, fallback_surface) + + if _terminal_value(contemplation_result) != "QUESTION_NEEDED": + return _fallback_decision(contemplation_result, fallback_surface) + + question_path_value = getattr(contemplation_result, "question_path", None) + proposal_path_value = getattr(contemplation_result, "proposal_path", None) + if not question_path_value or question_path_value == proposal_path_value: + return _fallback_decision(contemplation_result, fallback_surface) + + question_path = Path(question_path_value) + if not question_path.is_file(): + return _fallback_decision(contemplation_result, fallback_surface) + + try: + payload = json.loads(question_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return _fallback_decision(contemplation_result, fallback_surface) + + question_text = _validate_question_artifact( + payload, + question_path=question_path, + proposal_path=proposal_path_value, + ) + if question_text is None: + return _fallback_decision(contemplation_result, fallback_surface) + + limitation = LimitationAssessment( + limitation_kind="missing_information", + resolution_action="ask_question", + epistemic_state=EpistemicState.UNDETERMINED, + owner_organ=payload.get("owner_organ"), + blocking_reason=str(payload.get("blocking_reason", "")), + ) + disposition = choose_served_disposition( + epistemic_state=EpistemicState.UNDETERMINED, + limitation=limitation, + ) + + return ServedAskDecision( + served=True, + terminal="QUESTION_NEEDED", + surface=question_text, + disposition=disposition, + ) + + +__all__ = ["ServedAskDecision", "evaluate_served_ask"] diff --git a/tests/test_ask_serving_integration.py b/tests/test_ask_serving_integration.py new file mode 100644 index 00000000..3301c028 --- /dev/null +++ b/tests/test_ask_serving_integration.py @@ -0,0 +1,287 @@ +"""Focused tests for the Stage 2 served ASK artifact adapter. + +These tests intentionally avoid ``chat.runtime``. This slice is adapter-only: +it validates Q1-D question artifacts and returns a typed decision, but does not +wire runtime acquisition of ``ContemplationResult``. +""" + +from __future__ import annotations + +import ast +import json +from pathlib import Path + +from core.config import RuntimeConfig +from core.epistemic_disclosure import ServedAskDecision, evaluate_served_ask +from core.epistemic_disclosure.disposition import ServedDisposition, choose_served_disposition + + +class DummyTerminal: + def __init__(self, value: str): + self.value = value + + +class DummyContemplationResult: + def __init__( + self, + terminal: str, + *, + question_path: str | None = None, + proposal_path: str | None = None, + family: str | None = None, + ) -> None: + self.terminal = DummyTerminal(terminal) + self.question_path = question_path + self.proposal_path = proposal_path + self.family = family + + +def _write_artifact(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data), encoding="utf-8") + + +def _valid_payload(text: str = "How many crates are left?") -> dict: + return { + "status": "question_only", + "blocking_reason": "missing_total_count", + "owner_organ": "r2_constraint", + "question": { + "text": text, + "reason": "missing_total_count", + "slot_name": "total_count", + "expected_unit_or_type": "count_int", + "binding_target": "collective_unit_total", + }, + "answer_binding": None, + "requires_review": True, + "served": False, + } + + +def _question_result(q_path: Path, p_path: Path | None = None) -> DummyContemplationResult: + return DummyContemplationResult( + "QUESTION_NEEDED", + question_path=str(q_path), + proposal_path=str(p_path) if p_path is not None else None, + ) + + +def test_ask_serving_disabled_preserves_existing_fallback_surface(tmp_path: Path) -> None: + q_path = tmp_path / "questions" / "q.json" + _write_artifact(q_path, _valid_payload()) + + decision = evaluate_served_ask( + RuntimeConfig(ask_serving_enabled=False), + _question_result(q_path), + "fallback", + ) + + assert isinstance(decision, ServedAskDecision) + assert decision.served is False + assert decision.terminal == "QUESTION_NEEDED" + assert decision.surface == "fallback" + assert decision.disposition is ServedDisposition.REFUSE + + +def test_ask_serving_enabled_surfaces_question_needed_from_artifact(tmp_path: Path) -> None: + q_path = tmp_path / "questions" / "q.json" + p_path = tmp_path / "proposals" / "p.json" + _write_artifact(q_path, _valid_payload("How many crates are left?")) + + decision = evaluate_served_ask( + RuntimeConfig(ask_serving_enabled=True), + _question_result(q_path, p_path), + "fallback", + ) + + assert decision.served is True + assert decision.terminal == "QUESTION_NEEDED" + assert decision.surface == "How many crates are left?" + assert decision.disposition is ServedDisposition.ASK + + +def test_served_ask_uses_governance_disposition_bus(monkeypatch, tmp_path: Path) -> None: + q_path = tmp_path / "questions" / "q.json" + _write_artifact(q_path, _valid_payload()) + calls = [] + + def traced_choose_served_disposition(*args, **kwargs): + calls.append((args, kwargs)) + return choose_served_disposition(*args, **kwargs) + + monkeypatch.setattr( + "core.epistemic_disclosure.ask_serving.choose_served_disposition", + traced_choose_served_disposition, + ) + + decision = evaluate_served_ask( + RuntimeConfig(ask_serving_enabled=True), + _question_result(q_path), + "fallback", + ) + + assert decision.served is True + assert decision.disposition is ServedDisposition.ASK + assert len(calls) == 1 + assert calls[0][1]["limitation"].resolution_action == "ask_question" + + +def test_question_path_must_not_equal_proposal_path(tmp_path: Path) -> None: + q_path = tmp_path / "questions" / "same.json" + _write_artifact(q_path, _valid_payload()) + + decision = evaluate_served_ask( + RuntimeConfig(ask_serving_enabled=True), + _question_result(q_path, q_path), + "fallback", + ) + + assert decision.served is False + assert decision.surface == "fallback" + + +def test_non_question_needed_terminal_preserves_proposal_signal(tmp_path: Path) -> None: + q_path = tmp_path / "questions" / "q.json" + _write_artifact(q_path, _valid_payload()) + result = DummyContemplationResult( + "PROPOSAL_EMITTED", + question_path=str(q_path), + proposal_path=str(tmp_path / "proposals" / "p.json"), + ) + + decision = evaluate_served_ask( + RuntimeConfig(ask_serving_enabled=True), + result, + "fallback", + ) + + assert decision.served is False + assert decision.terminal == "PROPOSAL_EMITTED" + assert decision.surface == "fallback" + assert decision.disposition is ServedDisposition.PROPOSE + + +def test_missing_or_unreadable_question_artifact_fails_closed(tmp_path: Path) -> None: + missing = tmp_path / "questions" / "missing.json" + + decision = evaluate_served_ask( + RuntimeConfig(ask_serving_enabled=True), + _question_result(missing), + "fallback", + ) + + assert decision.served is False + assert decision.surface == "fallback" + + +def test_malformed_question_artifact_fails_closed(tmp_path: Path) -> None: + q_path = tmp_path / "questions" / "q.json" + q_path.parent.mkdir(parents=True, exist_ok=True) + q_path.write_text("{bad json", encoding="utf-8") + + decision = evaluate_served_ask( + RuntimeConfig(ask_serving_enabled=True), + _question_result(q_path), + "fallback", + ) + + assert decision.served is False + assert decision.surface == "fallback" + + +def test_rejects_artifact_status_proposal_only(tmp_path: Path) -> None: + q_path = tmp_path / "questions" / "q.json" + data = _valid_payload() + data["status"] = "proposal_only" + _write_artifact(q_path, data) + + decision = evaluate_served_ask(RuntimeConfig(ask_serving_enabled=True), _question_result(q_path), "fallback") + + assert decision.served is False + assert decision.surface == "fallback" + + +def test_rejects_artifact_served_true(tmp_path: Path) -> None: + q_path = tmp_path / "questions" / "q.json" + data = _valid_payload() + data["served"] = True + _write_artifact(q_path, data) + + decision = evaluate_served_ask(RuntimeConfig(ask_serving_enabled=True), _question_result(q_path), "fallback") + + assert decision.served is False + + +def test_rejects_artifact_requires_review_false(tmp_path: Path) -> None: + q_path = tmp_path / "questions" / "q.json" + data = _valid_payload() + data["requires_review"] = False + _write_artifact(q_path, data) + + decision = evaluate_served_ask(RuntimeConfig(ask_serving_enabled=True), _question_result(q_path), "fallback") + + assert decision.served is False + + +def test_rejects_artifact_non_null_answer_binding(tmp_path: Path) -> None: + q_path = tmp_path / "questions" / "q.json" + data = _valid_payload() + data["answer_binding"] = {"slot": "total_count", "value": 12} + _write_artifact(q_path, data) + + decision = evaluate_served_ask(RuntimeConfig(ask_serving_enabled=True), _question_result(q_path), "fallback") + + assert decision.served is False + + +def test_rejects_missing_or_blank_question_text(tmp_path: Path) -> None: + for value in (None, ""): + q_path = tmp_path / f"questions_{value!r}" / "q.json" + data = _valid_payload() + if value is None: + del data["question"]["text"] + else: + data["question"]["text"] = " " + _write_artifact(q_path, data) + + decision = evaluate_served_ask(RuntimeConfig(ask_serving_enabled=True), _question_result(q_path), "fallback") + + assert decision.served is False + assert decision.surface == "fallback" + + +def test_rejects_missing_or_blank_slot_name(tmp_path: Path) -> None: + for value in (None, ""): + q_path = tmp_path / f"questions_slot_{value!r}" / "q.json" + data = _valid_payload() + if value is None: + del data["question"]["slot_name"] + else: + data["question"]["slot_name"] = " " + _write_artifact(q_path, data) + + decision = evaluate_served_ask(RuntimeConfig(ask_serving_enabled=True), _question_result(q_path), "fallback") + + assert decision.served is False + assert decision.surface == "fallback" + + +def test_adapter_does_not_import_question_renderer_or_construct_prose() -> None: + path = Path(__file__).resolve().parents[1] / "core" / "epistemic_disclosure" / "ask_serving.py" + source = path.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(path)) + + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module: + assert node.module != "core.epistemic_questions.render" + assert not node.module.startswith("core.epistemic_questions.render.") + if isinstance(node, ast.Import): + for alias in node.names: + assert alias.name != "core.epistemic_questions.render" + assert not alias.name.startswith("core.epistemic_questions.render.") + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name): + assert node.func.id != "render_question" + + for forbidden_template in ("How many", "What ", "Which ", "Please provide"): + assert forbidden_template not in source