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:
parent
9b1c94704c
commit
a1a085057e
4 changed files with 154 additions and 0 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
88
tests/test_explain_repl.py
Normal file
88
tests/test_explain_repl.py
Normal 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 ")
|
||||
Loading…
Reference in a new issue