feat(ask): add served ASK artifact adapter
Adds the Stage 2 served ASK artifact adapter and adapter-only tests. - Validates Q1-D DeliveredQuestion artifacts before ASK eligibility - Uses ask_serving_enabled as a default-dark gate - Uses the disclosure bus disposition mapping - Fails closed to fallback surface/disposition on invalid artifacts - Does not wire chat/runtime, telemetry, TurnEvent, ChatResponse, CLAIMS, or metrics - Does not retire Q1B_ASK_CARVE_OUT or flip proposal_allowed
This commit is contained in:
parent
426adbd197
commit
4308519ca9
3 changed files with 467 additions and 0 deletions
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
171
core/epistemic_disclosure/ask_serving.py
Normal file
171
core/epistemic_disclosure/ask_serving.py
Normal file
|
|
@ -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"]
|
||||
287
tests/test_ask_serving_integration.py
Normal file
287
tests/test_ask_serving_integration.py
Normal file
|
|
@ -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
|
||||
Loading…
Reference in a new issue