diff --git a/onDeviceAi/src/desktopMain/kotlin/com/example/ondeviceai/litertlm/LitertLmTextGenerator.kt b/onDeviceAi/src/desktopMain/kotlin/com/example/ondeviceai/litertlm/LitertLmTextGenerator.kt
index 1026818..4090386 100644
--- a/onDeviceAi/src/desktopMain/kotlin/com/example/ondeviceai/litertlm/LitertLmTextGenerator.kt
+++ b/onDeviceAi/src/desktopMain/kotlin/com/example/ondeviceai/litertlm/LitertLmTextGenerator.kt
@@ -104,7 +104,16 @@ class LitertLmTextGenerator(
}
if (output.isNotEmpty()) {
- emit(AiTokenOrFinal.Token(output.toString()))
+ // Qwen3 (the default .litertlm model) emits …
+ // chain-of-thought blocks before its answer. Strip them so the
+ // Move Coach never shows internal deliberation to the user — and
+ // so downstream validation (MoveCoachResponseValidator) judges
+ // only the delivered answer, not the reasoning. Matches both a
+ // closed … and an unterminated trailing ….
+ val cleaned = stripThinkBlocks(output.toString())
+ if (cleaned.isNotEmpty()) {
+ emit(AiTokenOrFinal.Token(cleaned))
+ }
}
// getTokenCount() is @ExperimentalApi in litertlm-jvm and BenchmarkInfo is
// gated too; we avoid both and derive a rough token count from output length
@@ -174,5 +183,25 @@ class LitertLmTextGenerator(
const val DEFAULT_CONTEXT_SIZE = 1024
const val DEFAULT_TOP_K = 40
const val DEFAULT_TOP_P = 1.0
+
+ // Matches … (case-insensitive, DOTALL so it spans newlines).
+ // LiteRT-LM's Qwen3 model emits chain-of-thought before its answer; the
+ // Move Coach contract wants only the delivered answer, so [generate]
+ // strips these blocks before emitting. The unterminated variant catches
+ // a the model opened but never closed (generation cut off).
+ private val THINK_BLOCK = Regex("(?is).*?")
+ private val THINK_UNTERMINATED = Regex("(?is).*")
+
+ /**
+ * Remove `…` chain-of-thought blocks from [text]. Handles
+ * both closed blocks and an unterminated trailing ``. Returns the
+ * cleaned text, trimmed. If no `` is present, returns the text
+ * trimmed. Public on the companion so it can be unit-tested directly.
+ */
+ fun stripThinkBlocks(text: String): String {
+ var cleaned = THINK_BLOCK.replace(text, "")
+ cleaned = THINK_UNTERMINATED.replace(cleaned, "")
+ return cleaned.trim()
+ }
}
}
diff --git a/onDeviceAi/src/desktopTest/kotlin/com/example/ondeviceai/litertlm/LitertLmTextGeneratorStripThinkTest.kt b/onDeviceAi/src/desktopTest/kotlin/com/example/ondeviceai/litertlm/LitertLmTextGeneratorStripThinkTest.kt
new file mode 100644
index 0000000..47e41b6
--- /dev/null
+++ b/onDeviceAi/src/desktopTest/kotlin/com/example/ondeviceai/litertlm/LitertLmTextGeneratorStripThinkTest.kt
@@ -0,0 +1,53 @@
+package com.example.ondeviceai.litertlm
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+import com.example.ondeviceai.litertlm.LitertLmTextGenerator.Companion.stripThinkBlocks
+
+/**
+ * Tests for [LitertLmTextGenerator.stripThinkBlocks] — the CoT-stripping that
+ * keeps the Move Coach from leaking Qwen3's `` deliberation to the user.
+ *
+ * Desktop-only because LitertLmTextGenerator is desktop-only. The cases mirror
+ * the real outputs observed from the LiteRT-LM driver: a leading `…`
+ * block followed by the final answer, and the unterminated edge case.
+ */
+class LitertLmTextGeneratorStripThinkTest {
+
+ @Test
+ fun `strips a closed think block and keeps the answer`() {
+ val raw = "Let me consider this move. Does it give check? No.\nNf3 develops the knight."
+ assertEquals("Nf3 develops the knight.", stripThinkBlocks(raw))
+ }
+
+ @Test
+ fun `strips think block with newlines inside`() {
+ val raw = "\nOkay, the move is g1 to h3.\nThis develops the knight.\n\n\nNh3 develops the knight toward the edge."
+ assertEquals("Nh3 develops the knight toward the edge.", stripThinkBlocks(raw))
+ }
+
+ @Test
+ fun `strips an unterminated trailing think block`() {
+ // A generation cut off mid-reasoning: opened but never closed.
+ val raw = "Nh3 develops the knight. Wait, let me reconsider whether"
+ assertEquals("Nh3 develops the knight.", stripThinkBlocks(raw))
+ }
+
+ @Test
+ fun `returns_text_unchanged_when_no_think_block`() {
+ val raw = "Nf3 develops the knight toward the center."
+ assertEquals("Nf3 develops the knight toward the center.", stripThinkBlocks(raw))
+ }
+
+ @Test
+ fun `handles_case_insensitive_think_tag`() {
+ val raw = "reasoningAnswer."
+ assertEquals("Answer.", stripThinkBlocks(raw))
+ }
+
+ @Test
+ fun `returns_empty_string_for_input_that_was_only_think`() {
+ assertEquals("", stripThinkBlocks("all reasoning, no answer"))
+ }
+}