Auto-search detection + live reasoning preview

Auto-search:
- detectSearchQuery() pattern-matches common search prompts in DE+EN
  (weather, news, prices, scores, current events, etc.)
- send() enables webSearch automatically when detected, no manual
  pill needed. Manual pill still works as explicit override.
- 30+ patterns ported from backend lib/chat/detection.ts

Live reasoning preview:
- ReasoningBlock shows first meaningful line as preview label when
  collapsed (max 80 chars, ellipsis). Updates live during streaming.
- "Gedankengang" header + preview line stacked vertically.
- Preview hidden when block is expanded (full text visible).

Tests: 4 new tests for detectSearchQuery (DE patterns, EN patterns,
normal prompts not triggered, short input ignored).
This commit is contained in:
Bruno Deanoz 2026-06-24 10:30:52 +02:00
parent 77d2d8e5df
commit feb326fc40
4 changed files with 95 additions and 9 deletions

View file

@ -65,6 +65,24 @@ val chatModes = listOf(
ChatMode(R.string.mode_audio, KaizenIcons.Music),
)
private val SEARCH_PATTERNS = listOf(
"such nach", "suche nach", "was ist aktuell", "aktuelle", "neueste",
"wie ist das wetter", "wetter in", "was passiert gerade", "was ist heute",
"aktuelle news", "nachrichten", "was kostet", "aktienkurs", "kurs von",
"wer hat gewonnen", "ergebnis von", "stand von", "tabelle von",
"öffnungszeiten", "wie spät", "uhrzeit in", "einwohner von",
"search for", "look up", "find me", "what is happening",
"latest news", "current events", "weather in", "weather today",
"stock price", "who won", "what happened", "how much is",
"when did", "when was", "who is the current", "what are the latest",
"score of", "results of", "opening hours", "population of",
).map { Regex(it, RegexOption.IGNORE_CASE) }
fun detectSearchQuery(text: String): Boolean {
if (text.length < 8) return false
return SEARCH_PATTERNS.any { it.containsMatchIn(text) }
}
@Composable
fun greeting(hour: Int): String = when (hour) {
in 5..11 -> stringResource(R.string.greeting_morning)

View file

@ -255,13 +255,17 @@ class ChatScreenViewModel(
async { KaizenApi.createConversation(cfg.baseUrl, cfg.token) }
} else null
val searchManual = activeMode == R.string.mode_search
val searchAuto = !searchManual && detectSearchQuery(trimmed)
val useSearch = searchManual || searchAuto
KaizenApi.chat(
cfg.baseUrl, cfg.token, cfg.model, history,
reasoningEffort = reasoningPreset.effort,
preferThroughput = reasoningPreset.throughput,
attachments = uploadedAtts.takeIf { it.isNotEmpty() },
webSearch = activeMode == R.string.mode_search,
webSearchProvider = if (activeMode == R.string.mode_search && cfg.model.startsWith("vertex:")) webSearchProvider else null,
webSearch = useSearch,
webSearchProvider = if (useSearch && cfg.model.startsWith("vertex:")) webSearchProvider else null,
temperature = samplingPrefs.temperature.takeIf { it.enabled }?.value,
topP = samplingPrefs.topP.takeIf { it.enabled }?.value,
topK = samplingPrefs.topK.takeIf { it.enabled }?.value?.toInt(),

View file

@ -54,6 +54,14 @@ fun ReasoningBlock(reasoning: String, streaming: Boolean = false) {
val accent = LocalKaizenAccent.current
var expanded by remember { mutableStateOf(streaming) }
val previewLine = remember(reasoning) {
reasoning.lineSequence()
.map { it.trim() }
.firstOrNull { it.length > 3 }
?.take(80)
?.let { if (it.length == 80) "$it" else it }
}
val bg = accent.primary.copy(alpha = if (isDark) 0.06f else 0.04f)
val borderBrush = remember(isDark, accent) {
Brush.verticalGradient(
@ -80,13 +88,24 @@ fun ReasoningBlock(reasoning: String, streaming: Boolean = false) {
) {
Icon(KaizenIcons.Brain, null, tint = accent.primary.copy(alpha = 0.75f), modifier = Modifier.size(17.dp))
Spacer(Modifier.width(9.dp))
Text(
stringResource(R.string.stream_reasoning),
color = cs.onSurfaceVariant.copy(alpha = 0.75f),
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f),
)
Column(Modifier.weight(1f)) {
Text(
stringResource(R.string.stream_reasoning),
color = cs.onSurfaceVariant.copy(alpha = 0.75f),
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
)
if (!expanded && previewLine != null) {
Text(
previewLine,
color = cs.onSurfaceVariant.copy(alpha = 0.40f),
fontSize = 11.sp,
fontWeight = FontWeight.W300,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Icon(
if (expanded) KaizenIcons.ChevronUp else KaizenIcons.ChevronDown,
null, tint = cs.onSurfaceVariant.copy(alpha = 0.40f), modifier = Modifier.size(15.dp),

View file

@ -0,0 +1,45 @@
package dev.kaizen.app
import dev.kaizen.app.chat.detectSearchQuery
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class DetectSearchTest {
@Test
fun detectsGermanSearchPatterns() {
assertTrue(detectSearchQuery("Was ist aktuell in der Politik los?"))
assertTrue(detectSearchQuery("Suche nach den neuesten Nachrichten"))
assertTrue(detectSearchQuery("Wie ist das Wetter in Berlin?"))
assertTrue(detectSearchQuery("Was kostet ein Tesla Model 3?"))
assertTrue(detectSearchQuery("Aktuelle News zu Apple"))
assertTrue(detectSearchQuery("Aktienkurs von Nvidia"))
}
@Test
fun detectsEnglishSearchPatterns() {
assertTrue(detectSearchQuery("Search for the latest news about AI"))
assertTrue(detectSearchQuery("What is happening in Ukraine?"))
assertTrue(detectSearchQuery("Weather in Tokyo today"))
assertTrue(detectSearchQuery("Who won the Champions League?"))
assertTrue(detectSearchQuery("Stock price of Apple"))
assertTrue(detectSearchQuery("Look up population of Germany"))
}
@Test
fun doesNotTriggerOnNormalPrompts() {
assertFalse(detectSearchQuery("Erkläre mir Quantencomputing"))
assertFalse(detectSearchQuery("Schreib mir ein Gedicht"))
assertFalse(detectSearchQuery("Was ist der Unterschied zwischen Java und Kotlin?"))
assertFalse(detectSearchQuery("Fasse diesen Text zusammen"))
assertFalse(detectSearchQuery("Hello"))
}
@Test
fun shortInputIsIgnored() {
assertFalse(detectSearchQuery("hi"))
assertFalse(detectSearchQuery(""))
assertFalse(detectSearchQuery("abc"))
}
}