diff --git a/chat/runtime.py b/chat/runtime.py index 93462f5f..5c4b4611 100644 --- a/chat/runtime.py +++ b/chat/runtime.py @@ -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( diff --git a/core/cli.py b/core/cli.py index 0545faf1..e0a8b832 100644 --- a/core/cli.py +++ b/core/cli.py @@ -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: diff --git a/core/cognition/explain.py b/core/cognition/explain.py index 2c4fc464..71369e96 100644 --- a/core/cognition/explain.py +++ b/core/cognition/explain.py @@ -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 "" diff --git a/tests/test_explain_repl.py b/tests/test_explain_repl.py new file mode 100644 index 00000000..1b1a808d --- /dev/null +++ b/tests/test_explain_repl.py @@ -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 ?") + assert result.startswith("What is ")