From feb326fc40f06c6e6c104d2291a46434bb88938c Mon Sep 17 00:00:00 2001 From: Bruno Deanoz Date: Wed, 24 Jun 2026 10:30:52 +0200 Subject: [PATCH] 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). --- .../java/dev/kaizen/app/chat/ChatModels.kt | 18 ++++++++ .../kaizen/app/chat/ChatScreenViewModel.kt | 8 +++- .../java/dev/kaizen/app/chat/StreamBlocks.kt | 33 +++++++++++--- .../java/dev/kaizen/app/DetectSearchTest.kt | 45 +++++++++++++++++++ 4 files changed, 95 insertions(+), 9 deletions(-) create mode 100644 app/src/test/java/dev/kaizen/app/DetectSearchTest.kt diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt b/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt index b51ecbc..1aadd99 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatModels.kt @@ -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) diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatScreenViewModel.kt b/app/src/main/java/dev/kaizen/app/chat/ChatScreenViewModel.kt index 9e9ecc8..974b0f3 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatScreenViewModel.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreenViewModel.kt @@ -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(), diff --git a/app/src/main/java/dev/kaizen/app/chat/StreamBlocks.kt b/app/src/main/java/dev/kaizen/app/chat/StreamBlocks.kt index 6a9908a..8d42254 100644 --- a/app/src/main/java/dev/kaizen/app/chat/StreamBlocks.kt +++ b/app/src/main/java/dev/kaizen/app/chat/StreamBlocks.kt @@ -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), diff --git a/app/src/test/java/dev/kaizen/app/DetectSearchTest.kt b/app/src/test/java/dev/kaizen/app/DetectSearchTest.kt new file mode 100644 index 0000000..9a8fcdb --- /dev/null +++ b/app/src/test/java/dev/kaizen/app/DetectSearchTest.kt @@ -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")) + } +}