Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,16 @@ class LitertLmTextGenerator(
}

if (output.isNotEmpty()) {
emit(AiTokenOrFinal.Token(output.toString()))
// Qwen3 (the default .litertlm model) emits <think>…</think>
// 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 <think>…</think> and an unterminated trailing <think>….
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
Expand Down Expand Up @@ -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 <think>…</think> (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 <think> the model opened but never closed (generation cut off).
private val THINK_BLOCK = Regex("(?is)<think>.*?</think>")
private val THINK_UNTERMINATED = Regex("(?is)<think>.*")

/**
* Remove `<think>…</think>` chain-of-thought blocks from [text]. Handles
* both closed blocks and an unterminated trailing `<think>`. Returns the
* cleaned text, trimmed. If no `<think>` 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()
}
}
}
Original file line number Diff line number Diff line change
@@ -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 `<think>` 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 `<think>…</think>`
* 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 = "<think>Let me consider this move. Does it give check? No.</think>\nNf3 develops the knight."
assertEquals("Nf3 develops the knight.", stripThinkBlocks(raw))
}

@Test
fun `strips think block with newlines inside`() {
val raw = "<think>\nOkay, the move is g1 to h3.\nThis develops the knight.\n</think>\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: <think> opened but never closed.
val raw = "Nh3 develops the knight. <think>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 = "<THINK>reasoning</THINK>Answer."
assertEquals("Answer.", stripThinkBlocks(raw))
}

@Test
fun `returns_empty_string_for_input_that_was_only_think`() {
assertEquals("", stripThinkBlocks("<think>all reasoning, no answer</think>"))
}
}
Loading