feat(w013): wire explain_last_turn() into core chat /explain REPL command (#265)

Closes W-013 wiring debt. Per Phase 2 operator decision: wire
core.cognition.explain into the live core chat REPL.

Changes:
- core/cognition/explain.py: add explain_from_intent(intent, correction_text)
  companion to explain() — same dispatch table, skips the full
  CognitiveTurnResult round-trip. Callers with only a DialogueIntent can
  use this directly.
- chat/runtime.py: add _last_intent and _last_input_text instance fields;
  store intent on every classify_intent_from_input() call (pack-grounded
  path and stub/empty-vault path); add explain_last_turn() -> str method
  that calls explain_from_intent(_last_intent, correction_text=_last_input_text).
- core/cli.py: in cmd_chat REPL loop, handle "/explain" command — calls
  runtime.explain_last_turn() and prints the canonical prompt restatement
  (or a "no prior turn" message to stderr if no turn has run yet).
- tests/test_explain_repl.py: 11 tests pinning explain_from_intent dispatch
  for all intent tags and the ChatRuntime.explain_last_turn() contract.

Per ADR-0017 (Responsive-with-Axiology): introspection is per-turn and
operator-invoked, never autonomous — the /explain command is correct
placement for this feature.
This commit is contained in:
Shay 2026-05-25 06:09:49 -07:00 committed by GitHub
parent 9b1c94704c
commit a1a085057e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 154 additions and 0 deletions

View file

@ -602,6 +602,9 @@ class ChatRuntime:
# append-only, fail-fast on sink errors, deterministic JSONL.
self._articulation_sink: Any | None = None
self._articulation_turn_counter: int = 0
# W-013 — last classified intent, updated each turn for /explain REPL use.
self._last_intent: Any | None = None
self._last_input_text: str = ""
@property
def session(self) -> SessionContext:
@ -637,6 +640,22 @@ class ChatRuntime:
"""
return self._last_plan_metrics
def explain_last_turn(self) -> str:
"""Return a canonical natural-language restatement of the last turn (W-013).
Feeds the last classified intent through ``core.cognition.explain``'s
dispatch table and returns the resulting canonical prompt string.
This is the ``/explain`` REPL command's backing method.
Returns an empty string when no turn has been processed yet or when
the intent could not be classified (UNKNOWN tag).
"""
from core.cognition.explain import explain_from_intent
return explain_from_intent(
self._last_intent,
correction_text=self._last_input_text,
)
def attach_telemetry_sink(
self,
sink: TurnEventSink | None,
@ -906,6 +925,7 @@ class ChatRuntime:
from generate.intent import IntentTag
from generate.intent_bridge import classify_intent_from_input
intent = classify_intent_from_input(text)
self._last_intent = intent # W-013: expose for /explain
if intent.tag is IntentTag.COMPARISON:
lemma_a = (intent.subject or "").strip().rstrip(".,?!;:")
lemma_b = (intent.secondary_subject or "").strip().rstrip(".,?!;:")
@ -1660,6 +1680,7 @@ class ChatRuntime:
)
def chat(self, text: str, max_tokens: int | None = None) -> ChatResponse:
self._last_input_text = text # W-013: store for explain_last_turn()
tokens = self._tokenize(text)
filtered = self._apply_oov_policy(tokens)
if not filtered:
@ -1746,6 +1767,7 @@ class ChatRuntime:
):
from generate.intent_bridge import classify_intent_from_input
_intent = classify_intent_from_input(text)
self._last_intent = _intent # W-013
discovery_intent_tag = _intent.tag
discovery_intent_subject = _intent.subject
stub_articulation = ArticulationPlan(

View file

@ -243,6 +243,13 @@ def cmd_chat(args: argparse.Namespace) -> int:
break
if not text:
continue
if text == "/explain":
explanation = runtime.explain_last_turn()
if explanation:
print(f"[explain] {explanation}")
else:
print("[explain] no prior turn to explain", file=sys.stderr)
continue
try:
response = runtime.chat(text)
except (KeyError, ValueError) as exc:

View file

@ -23,6 +23,7 @@ from generate.intent import IntentTag
if TYPE_CHECKING:
from core.cognition.result import CognitiveTurnResult
from generate.intent import DialogueIntent
# Reverse map of generate.intent._RELATION_NORMALIZE — picks one surface
@ -122,3 +123,39 @@ def explain(result: "CognitiveTurnResult") -> str:
if tag is IntentTag.RECALL:
return _explain_recall(subject)
return ""
def explain_from_intent(
intent: "DialogueIntent | None",
correction_text: str = "",
) -> str:
"""Lightweight variant for callers that have only a classified intent.
Identical dispatch to :func:`explain`; skips the full
``CognitiveTurnResult`` round-trip. ``correction_text`` is only
meaningful when the intent tag is ``CORRECTION``; callers may pass
the original user text as a reasonable approximation.
"""
if intent is None:
return ""
tag = intent.tag
subject = intent.subject or ""
if tag is IntentTag.DEFINITION:
return _explain_definition(subject)
if tag is IntentTag.TRANSITIVE_QUERY:
return _explain_transitive_query(subject, intent.relation)
if tag is IntentTag.CAUSE:
return _explain_cause(subject)
if tag is IntentTag.PROCEDURE:
return _explain_procedure(subject)
if tag is IntentTag.COMPARISON:
return _explain_comparison(subject, intent.secondary_subject)
if tag is IntentTag.CORRECTION:
return _explain_correction(subject, correction_text)
if tag is IntentTag.VERIFICATION:
return _explain_verification(subject)
if tag is IntentTag.RECALL:
return _explain_recall(subject)
return ""

View file

@ -0,0 +1,88 @@
"""Tests for W-013: explain_last_turn() wired into core chat REPL."""
from __future__ import annotations
from core.cognition.explain import explain_from_intent
from generate.intent import DialogueIntent, IntentTag
# ---------------------------------------------------------------------------
# explain_from_intent — unit tests (mirrors explain() dispatch)
# ---------------------------------------------------------------------------
def _intent(tag: IntentTag, subject: str = "truth") -> DialogueIntent:
return DialogueIntent(tag=tag, subject=subject)
def test_explain_from_intent_none_returns_empty() -> None:
assert explain_from_intent(None) == ""
def test_explain_from_intent_definition() -> None:
result = explain_from_intent(_intent(IntentTag.DEFINITION, "wisdom"))
assert result == "What is wisdom?"
def test_explain_from_intent_cause() -> None:
result = explain_from_intent(_intent(IntentTag.CAUSE, "light reveals"))
assert result == "Why light reveals?"
def test_explain_from_intent_procedure() -> None:
result = explain_from_intent(_intent(IntentTag.PROCEDURE, "ground a claim"))
assert result == "How do I ground a claim?"
def test_explain_from_intent_comparison() -> None:
result = explain_from_intent(
DialogueIntent(
tag=IntentTag.COMPARISON,
subject="knowledge",
secondary_subject="wisdom",
)
)
assert result == "Compare knowledge and wisdom."
def test_explain_from_intent_verification() -> None:
result = explain_from_intent(_intent(IntentTag.VERIFICATION, "truth is coherent"))
assert result == "Is truth is coherent?"
def test_explain_from_intent_recall() -> None:
result = explain_from_intent(_intent(IntentTag.RECALL, "truth"))
assert result == "Remember truth."
def test_explain_from_intent_correction_uses_correction_text() -> None:
result = explain_from_intent(
_intent(IntentTag.CORRECTION, "truth"),
correction_text="Actually truth is coherent.",
)
assert result == "Actually truth is coherent."
def test_explain_from_intent_correction_falls_back_to_subject() -> None:
result = explain_from_intent(_intent(IntentTag.CORRECTION, "truth"), correction_text="")
assert "truth" in result
# ---------------------------------------------------------------------------
# ChatRuntime.explain_last_turn — integration test
# ---------------------------------------------------------------------------
def test_explain_last_turn_no_prior_turn_returns_empty() -> None:
from chat.runtime import ChatRuntime
runtime = ChatRuntime()
assert runtime.explain_last_turn() == ""
def test_explain_last_turn_after_definition_turn() -> None:
from chat.runtime import ChatRuntime
runtime = ChatRuntime()
runtime.chat("What is truth?")
result = runtime.explain_last_turn()
# Should produce a definition form ("What is <subject>?")
assert result.startswith("What is ")