Commit 1f08686ec09cdde48678fde1c5353f7bbabbb61b
1 parent
76b3421b
feat(lifelog): 统一「设置」tab — 集中所有可调参数
新增第 6 个 tab「设置」(SettingsScreen.kt),把散落各处的配置收进一页: - 纪要:会话间隔 / 自动跑周期(改完即重排 WorkManager) / 会话最短语音 / 后端模型(2B/4B/Hermes) - 声纹:聚类阈值(0.45–0.65) - 本地模型端点:baseUrl / 2B / 4B model 名(ChatConfig) - 录音/电池:白名单状态 + 申请 配套把写死的参数改成可配: - SummaryWorker:KEY_PERIOD_MIN / KEY_MIN_SPEECH_SEC,min-speech 从 prefs 读 - SummaryScheduler:周期从 prefs 读(默认 15,硬下限 15) - SpeakerClusterer:thresholdFromPrefs(),RecorderService 的 live 聚类器 + SpeakersTab 的 reclusterAll 都用 prefs 阈值 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Showing
6 changed files
with
211 additions
and
8 deletions
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/MainActivity.kt
| ... | ... | @@ -25,6 +25,7 @@ import androidx.compose.foundation.layout.fillMaxSize |
| 25 | 25 | import androidx.compose.foundation.layout.fillMaxWidth |
| 26 | 26 | import androidx.compose.foundation.layout.height |
| 27 | 27 | import androidx.compose.foundation.layout.heightIn |
| 28 | +import androidx.compose.foundation.layout.imePadding | |
| 28 | 29 | import androidx.compose.foundation.layout.padding |
| 29 | 30 | import androidx.compose.foundation.layout.size |
| 30 | 31 | import androidx.compose.foundation.layout.width |
| ... | ... | @@ -104,6 +105,9 @@ import kotlinx.coroutines.withContext |
| 104 | 105 | class MainActivity : ComponentActivity() { |
| 105 | 106 | override fun onCreate(savedInstanceState: Bundle?) { |
| 106 | 107 | super.onCreate(savedInstanceState) |
| 108 | + // edge-to-edge:让 IME / 系统栏 insets 传到 Compose(Android 15 targetSdk35 起必须), | |
| 109 | + // 否则键盘弹出会盖住聊天内容。Scaffold contentWindowInsets 默认 systemBars,会补回状态栏/导航栏 padding。 | |
| 110 | + androidx.core.view.WindowCompat.setDecorFitsSystemWindows(window, false) | |
| 107 | 111 | setContent { |
| 108 | 112 | MaterialTheme { |
| 109 | 113 | Surface(modifier = Modifier.fillMaxSize()) { |
| ... | ... | @@ -120,6 +124,7 @@ private enum class Tab(val label: String, val icon: String) { |
| 120 | 124 | Timeline("时间轴", "≣"), |
| 121 | 125 | Speakers("声纹", "👤"), |
| 122 | 126 | Summaries("纪要", "✎"), |
| 127 | + Settings("设置", "⚙"), | |
| 123 | 128 | } |
| 124 | 129 | |
| 125 | 130 | enum class SortMode(val label: String) { |
| ... | ... | @@ -149,6 +154,8 @@ private fun MainScreen() { |
| 149 | 154 | val speakers by repo.speakers.observeAll().collectAsState(initial = emptyList()) |
| 150 | 155 | |
| 151 | 156 | Scaffold( |
| 157 | + // imePadding:键盘弹出时整个 Scaffold(含底部 5 个 tab)抬到键盘上方,tab 仍可见可点。 | |
| 158 | + modifier = Modifier.imePadding(), | |
| 152 | 159 | bottomBar = { |
| 153 | 160 | NavigationBar { |
| 154 | 161 | Tab.entries.forEach { t -> |
| ... | ... | @@ -191,6 +198,7 @@ private fun MainScreen() { |
| 191 | 198 | tab = Tab.Timeline |
| 192 | 199 | }, |
| 193 | 200 | ) |
| 201 | + Tab.Settings -> SettingsScreen(activity) | |
| 194 | 202 | } |
| 195 | 203 | } |
| 196 | 204 | } |
| ... | ... | @@ -707,6 +715,7 @@ private fun SpeakersTab( |
| 707 | 715 | onFilterBySpeaker: (Long) -> Unit, |
| 708 | 716 | ) { |
| 709 | 717 | val scope = rememberCoroutineScope() |
| 718 | + val ctx = LocalContext.current | |
| 710 | 719 | val counts by repo.segments.observeSpeakerCounts().collectAsState(initial = emptyList()) |
| 711 | 720 | val countMap = remember(counts) { counts.associate { it.speakerId to it.count } } |
| 712 | 721 | var reclustering by remember { mutableStateOf(false) } |
| ... | ... | @@ -724,7 +733,9 @@ private fun SpeakersTab( |
| 724 | 733 | onClick = { |
| 725 | 734 | reclustering = true |
| 726 | 735 | scope.launch { |
| 727 | - val n = withContext(Dispatchers.IO) { SpeakerClusterer.reclusterAll(repo) } | |
| 736 | + val n = withContext(Dispatchers.IO) { | |
| 737 | + SpeakerClusterer.reclusterAll(repo, matchThreshold = SpeakerClusterer.thresholdFromPrefs(ctx)) | |
| 738 | + } | |
| 728 | 739 | reclustering = false |
| 729 | 740 | reclusterMsg = "重新聚类完成:$n 个说话人" |
| 730 | 741 | } | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/SettingsScreen.kt
0 → 100644
| 1 | +package com.lemon.lifelog | |
| 2 | + | |
| 3 | +import android.annotation.SuppressLint | |
| 4 | +import android.content.Context | |
| 5 | +import android.content.Intent | |
| 6 | +import android.net.Uri | |
| 7 | +import android.os.PowerManager | |
| 8 | +import android.provider.Settings | |
| 9 | +import androidx.compose.foundation.layout.Arrangement | |
| 10 | +import androidx.compose.foundation.layout.Box | |
| 11 | +import androidx.compose.foundation.layout.Column | |
| 12 | +import androidx.compose.foundation.layout.Row | |
| 13 | +import androidx.compose.foundation.layout.Spacer | |
| 14 | +import androidx.compose.foundation.layout.fillMaxSize | |
| 15 | +import androidx.compose.foundation.layout.fillMaxWidth | |
| 16 | +import androidx.compose.foundation.layout.height | |
| 17 | +import androidx.compose.foundation.layout.padding | |
| 18 | +import androidx.compose.foundation.rememberScrollState | |
| 19 | +import androidx.compose.foundation.verticalScroll | |
| 20 | +import androidx.compose.material3.Button | |
| 21 | +import androidx.compose.material3.DropdownMenu | |
| 22 | +import androidx.compose.material3.DropdownMenuItem | |
| 23 | +import androidx.compose.material3.ExperimentalMaterial3Api | |
| 24 | +import androidx.compose.material3.FilterChip | |
| 25 | +import androidx.compose.material3.HorizontalDivider | |
| 26 | +import androidx.compose.material3.MaterialTheme | |
| 27 | +import androidx.compose.material3.OutlinedTextField | |
| 28 | +import androidx.compose.material3.Text | |
| 29 | +import androidx.compose.runtime.Composable | |
| 30 | +import androidx.compose.runtime.getValue | |
| 31 | +import androidx.compose.runtime.mutableStateOf | |
| 32 | +import androidx.compose.runtime.remember | |
| 33 | +import androidx.compose.runtime.setValue | |
| 34 | +import androidx.compose.ui.Alignment | |
| 35 | +import androidx.compose.ui.Modifier | |
| 36 | +import androidx.compose.ui.unit.dp | |
| 37 | +import com.lemon.lifelog.asr.SpeakerClusterer | |
| 38 | +import com.lemon.lifelog.chat.ChatConfig | |
| 39 | +import com.lemon.lifelog.summary.SummaryScheduler | |
| 40 | +import com.lemon.lifelog.summary.SummaryWorker | |
| 41 | + | |
| 42 | +/** 统一配置页:纪要 / 声纹 / 本地端点 / 录音电池。改完即存 SharedPreferences,下次跑生效。 */ | |
| 43 | +@OptIn(ExperimentalMaterial3Api::class) | |
| 44 | +@Composable | |
| 45 | +fun SettingsScreen(activity: Context) { | |
| 46 | + val prefs = remember { activity.getSharedPreferences(SummaryWorker.PREFS, Context.MODE_PRIVATE) } | |
| 47 | + | |
| 48 | + var gapMin by remember { mutableStateOf(prefs.getInt(SummaryWorker.KEY_GAP_MIN, SummaryWorker.DEFAULT_GAP_MIN)) } | |
| 49 | + var periodMin by remember { mutableStateOf(prefs.getInt(SummaryWorker.KEY_PERIOD_MIN, SummaryWorker.DEFAULT_PERIOD_MIN)) } | |
| 50 | + var minSpeech by remember { mutableStateOf(prefs.getInt(SummaryWorker.KEY_MIN_SPEECH_SEC, SummaryWorker.DEFAULT_MIN_SPEECH_SEC)) } | |
| 51 | + var backendId by remember { mutableStateOf(prefs.getString(SummaryWorker.KEY_BACKEND, ChatConfig.ID_2B) ?: ChatConfig.ID_2B) } | |
| 52 | + var threshold by remember { mutableStateOf(SpeakerClusterer.thresholdFromPrefs(activity)) } | |
| 53 | + val backendTargets = remember { ChatConfig.targets(activity) } | |
| 54 | + | |
| 55 | + var localBase by remember { mutableStateOf(ChatConfig.localBase(activity)) } | |
| 56 | + var m2b by remember { mutableStateOf(ChatConfig.model2b(activity)) } | |
| 57 | + var m4b by remember { mutableStateOf(ChatConfig.model4b(activity)) } | |
| 58 | + var endpointMsg by remember { mutableStateOf("") } | |
| 59 | + | |
| 60 | + var batteryExempt by remember { mutableStateOf(isBatteryExempt(activity)) } | |
| 61 | + | |
| 62 | + Column( | |
| 63 | + modifier = Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState()), | |
| 64 | + verticalArrangement = Arrangement.spacedBy(10.dp), | |
| 65 | + ) { | |
| 66 | + Text("设置", style = MaterialTheme.typography.headlineSmall) | |
| 67 | + | |
| 68 | + Text("纪要", style = MaterialTheme.typography.titleMedium) | |
| 69 | + DropdownSetting("会话间隔(多久静音算另一场)", "$gapMin 分钟", | |
| 70 | + listOf(2, 5, 10, 15, 20, 30).map { "$it 分钟" to it }) { | |
| 71 | + gapMin = it; prefs.edit().putInt(SummaryWorker.KEY_GAP_MIN, it).apply() | |
| 72 | + } | |
| 73 | + DropdownSetting("自动跑纪要周期(≥15min 硬下限)", "$periodMin 分钟", | |
| 74 | + listOf(15, 30, 60).map { "$it 分钟" to it }) { | |
| 75 | + periodMin = it | |
| 76 | + prefs.edit().putInt(SummaryWorker.KEY_PERIOD_MIN, it).apply() | |
| 77 | + SummaryScheduler.schedulePeriodic(activity) // 立即重排 | |
| 78 | + } | |
| 79 | + DropdownSetting("会话最短语音(短于此跳过)", "$minSpeech 秒", | |
| 80 | + listOf(10, 30, 60, 120).map { "$it 秒" to it }) { | |
| 81 | + minSpeech = it; prefs.edit().putInt(SummaryWorker.KEY_MIN_SPEECH_SEC, it).apply() | |
| 82 | + } | |
| 83 | + DropdownSetting("纪要模型后端", backendTargets.firstOrNull { it.id == backendId }?.label ?: "本地 2B", | |
| 84 | + backendTargets.map { it.label to it.id }) { | |
| 85 | + backendId = it; prefs.edit().putString(SummaryWorker.KEY_BACKEND, it).apply() | |
| 86 | + } | |
| 87 | + | |
| 88 | + HorizontalDivider() | |
| 89 | + Text("声纹", style = MaterialTheme.typography.titleMedium) | |
| 90 | + DropdownSetting("聚类阈值(越大分越细)", "%.2f".format(threshold), | |
| 91 | + listOf(0.45f, 0.50f, 0.55f, 0.60f, 0.65f).map { "%.2f".format(it) to it }) { | |
| 92 | + threshold = it; prefs.edit().putFloat(SpeakerClusterer.KEY_THRESHOLD, it).apply() | |
| 93 | + } | |
| 94 | + Text("改完到「声纹」tab 点「重新聚类」对历史段生效;新录音自动用新值。", | |
| 95 | + style = MaterialTheme.typography.labelSmall) | |
| 96 | + | |
| 97 | + HorizontalDivider() | |
| 98 | + Text("本地模型端点(对话 + 纪要共用 llama-swap)", style = MaterialTheme.typography.titleMedium) | |
| 99 | + OutlinedTextField(value = localBase, onValueChange = { localBase = it }, | |
| 100 | + label = { Text("baseUrl") }, singleLine = true, modifier = Modifier.fillMaxWidth()) | |
| 101 | + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { | |
| 102 | + OutlinedTextField(value = m2b, onValueChange = { m2b = it }, | |
| 103 | + label = { Text("2B model 名") }, singleLine = true, modifier = Modifier.weight(1f)) | |
| 104 | + OutlinedTextField(value = m4b, onValueChange = { m4b = it }, | |
| 105 | + label = { Text("4B model 名") }, singleLine = true, modifier = Modifier.weight(1f)) | |
| 106 | + } | |
| 107 | + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { | |
| 108 | + Button(onClick = { | |
| 109 | + ChatConfig.save(activity, localBase, m2b, m4b) | |
| 110 | + endpointMsg = "已保存" | |
| 111 | + }) { Text("保存端点") } | |
| 112 | + if (endpointMsg.isNotEmpty()) Text(endpointMsg, style = MaterialTheme.typography.labelSmall) | |
| 113 | + } | |
| 114 | + | |
| 115 | + HorizontalDivider() | |
| 116 | + Text("录音 / 电池", style = MaterialTheme.typography.titleMedium) | |
| 117 | + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { | |
| 118 | + Text(if (batteryExempt) "✓ 已加入电池白名单" else "⚠ 未加入电池白名单(Doze 会杀后台)", | |
| 119 | + style = MaterialTheme.typography.bodySmall) | |
| 120 | + if (!batteryExempt) { | |
| 121 | + Button(onClick = { | |
| 122 | + requestBatteryExempt(activity) | |
| 123 | + batteryExempt = isBatteryExempt(activity) | |
| 124 | + }) { Text("申请豁免") } | |
| 125 | + } | |
| 126 | + } | |
| 127 | + | |
| 128 | + Spacer(Modifier.height(24.dp)) | |
| 129 | + } | |
| 130 | +} | |
| 131 | + | |
| 132 | +@OptIn(ExperimentalMaterial3Api::class) | |
| 133 | +@Composable | |
| 134 | +private fun <T> DropdownSetting( | |
| 135 | + label: String, | |
| 136 | + current: String, | |
| 137 | + options: List<Pair<String, T>>, | |
| 138 | + onPick: (T) -> Unit, | |
| 139 | +) { | |
| 140 | + var open by remember { mutableStateOf(false) } | |
| 141 | + Row( | |
| 142 | + modifier = Modifier.fillMaxWidth(), | |
| 143 | + verticalAlignment = Alignment.CenterVertically, | |
| 144 | + horizontalArrangement = Arrangement.spacedBy(8.dp), | |
| 145 | + ) { | |
| 146 | + Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) | |
| 147 | + Box { | |
| 148 | + FilterChip(selected = true, onClick = { open = true }, label = { Text(current) }) | |
| 149 | + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { | |
| 150 | + options.forEach { (lbl, value) -> | |
| 151 | + DropdownMenuItem(text = { Text(lbl) }, onClick = { onPick(value); open = false }) | |
| 152 | + } | |
| 153 | + } | |
| 154 | + } | |
| 155 | + } | |
| 156 | +} | |
| 157 | + | |
| 158 | +private fun isBatteryExempt(ctx: Context): Boolean { | |
| 159 | + val pm = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager | |
| 160 | + return pm.isIgnoringBatteryOptimizations(ctx.packageName) | |
| 161 | +} | |
| 162 | + | |
| 163 | +@SuppressLint("BatteryLife") | |
| 164 | +private fun requestBatteryExempt(ctx: Context) { | |
| 165 | + runCatching { | |
| 166 | + ctx.startActivity( | |
| 167 | + Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { | |
| 168 | + data = Uri.parse("package:${ctx.packageName}") | |
| 169 | + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) | |
| 170 | + }, | |
| 171 | + ) | |
| 172 | + } | |
| 173 | +} | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/asr/SpeakerClusterer.kt
| ... | ... | @@ -97,10 +97,18 @@ class SpeakerClusterer( |
| 97 | 97 | /** 自动簇命名前缀。重命名后不再以此开头,即视为「人工命名」。 */ |
| 98 | 98 | const val AUTO_PREFIX = "说话人" |
| 99 | 99 | |
| 100 | - /** CAMPPlus 余弦阈值:同人通常 > 0.55,异人 < 0.45。0.5 偏松易过度合并 | |
| 101 | - * (4B 测试时「说话人4」吃了 23 段),收紧到 0.55 分得更细。再细可上 0.6。 */ | |
| 100 | + /** CAMPPlus 余弦阈值默认值:同人通常 > 0.55,异人 < 0.45。0.5 偏松易过度合并 | |
| 101 | + * (4B 测试时「说话人4」吃了 23 段),0.55 分得更细。设置页可调,再细可上 0.6。 */ | |
| 102 | 102 | const val MATCH_THRESHOLD = 0.55f |
| 103 | 103 | |
| 104 | + /** 阈值持久化(设置页可调),存在 lifelog_prefs。 */ | |
| 105 | + const val PREFS = "lifelog_prefs" | |
| 106 | + const val KEY_THRESHOLD = "speaker_threshold" | |
| 107 | + | |
| 108 | + fun thresholdFromPrefs(ctx: android.content.Context): Float = | |
| 109 | + ctx.getSharedPreferences(PREFS, android.content.Context.MODE_PRIVATE) | |
| 110 | + .getFloat(KEY_THRESHOLD, MATCH_THRESHOLD) | |
| 111 | + | |
| 104 | 112 | /** 短于此时长的段不开新簇(短段 embedding 噪声大,易产生伪说话人)。 */ |
| 105 | 113 | const val MIN_NEW_CLUSTER_MS = 1000L |
| 106 | 114 | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/RecorderService.kt
| ... | ... | @@ -202,7 +202,7 @@ class RecorderService : Service() { |
| 202 | 202 | } |
| 203 | 203 | |
| 204 | 204 | // 2d. Online speaker clusterer (auto-discovers 说话人N; persists as speaker_profiles). |
| 205 | - clusterer = SpeakerClusterer(repo).also { it.load() } | |
| 205 | + clusterer = SpeakerClusterer(repo, matchThreshold = SpeakerClusterer.thresholdFromPrefs(this)).also { it.load() } | |
| 206 | 206 | // Reflect UI-side enroll / rename / delete / recluster back into the live clusterer. |
| 207 | 207 | // load() is mutex-serialised vs assign() and idempotent, so reloading on every |
| 208 | 208 | // speaker_profiles change (incl. the clusterer's own write-throughs) is safe. | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryScheduler.kt
| ... | ... | @@ -20,9 +20,11 @@ object SummaryScheduler { |
| 20 | 20 | .setRequiredNetworkType(NetworkType.NOT_REQUIRED) // 后端都在本机 |
| 21 | 21 | .setRequiresBatteryNotLow(true) |
| 22 | 22 | .build() |
| 23 | - // 15 分钟是 WorkManager periodic 的硬下限(做不到正好 10min)。会话关闭后 | |
| 24 | - // 最多 ~15min 出纪要。worker 自带并发互斥 + 时间预算,频繁触发也不会堆积。 | |
| 25 | - val request = PeriodicWorkRequestBuilder<SummaryWorker>(15, TimeUnit.MINUTES) | |
| 23 | + // 周期从设置页读(默认 15min);WorkManager periodic 硬下限 15,做不到更短。 | |
| 24 | + val periodMin = ctx.getSharedPreferences(SummaryWorker.PREFS, Context.MODE_PRIVATE) | |
| 25 | + .getInt(SummaryWorker.KEY_PERIOD_MIN, SummaryWorker.DEFAULT_PERIOD_MIN) | |
| 26 | + .coerceAtLeast(15) | |
| 27 | + val request = PeriodicWorkRequestBuilder<SummaryWorker>(periodMin.toLong(), TimeUnit.MINUTES) | |
| 26 | 28 | .setConstraints(constraints) |
| 27 | 29 | .build() |
| 28 | 30 | WorkManager.getInstance(ctx).enqueueUniquePeriodicWork( | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/summary/SummaryWorker.kt
| ... | ... | @@ -51,6 +51,14 @@ class SummaryWorker( |
| 51 | 51 | * 端点/model 由 ChatConfig 解析(与对话共用配置)。 */ |
| 52 | 52 | const val KEY_BACKEND = "summary_backend" |
| 53 | 53 | |
| 54 | + /** 自动跑纪要周期(分钟)。WorkManager periodic 硬下限 15。 */ | |
| 55 | + const val KEY_PERIOD_MIN = "summary_period_min" | |
| 56 | + const val DEFAULT_PERIOD_MIN = 15 | |
| 57 | + | |
| 58 | + /** 会话最短语音(秒):低于此跳过不总结。 */ | |
| 59 | + const val KEY_MIN_SPEECH_SEC = "summary_min_speech_sec" | |
| 60 | + const val DEFAULT_MIN_SPEECH_SEC = 30 | |
| 61 | + | |
| 54 | 62 | /** 防并发:periodic + one-shot + RETRY 可能同时触发,多个 worker 并发打同一个 |
| 55 | 63 | * llama-server 会把上游搞 502(实测)。进程内 flag 保证同时只跑一个 worker body。 */ |
| 56 | 64 | private val isRunning = java.util.concurrent.atomic.AtomicBoolean(false) |
| ... | ... | @@ -95,6 +103,7 @@ class SummaryWorker( |
| 95 | 103 | val prefs = applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE) |
| 96 | 104 | val gapMs = inputData.getLong(INPUT_GAP_MS, 0L).takeIf { it > 0 } |
| 97 | 105 | ?: (prefs.getInt(KEY_GAP_MIN, DEFAULT_GAP_MIN) * 60_000L) |
| 106 | + val minSpeechMs = prefs.getInt(KEY_MIN_SPEECH_SEC, DEFAULT_MIN_SPEECH_SEC) * 1000L | |
| 98 | 107 | |
| 99 | 108 | // 解析后端(UI 选的 ChatTarget:local-2b / local-4b / hermes)。 |
| 100 | 109 | val target = com.lemon.lifelog.chat.ChatConfig.targetById( |
| ... | ... | @@ -127,7 +136,7 @@ class SummaryWorker( |
| 127 | 136 | // 未结束的会话不动(可能还会有新段并入)。 |
| 128 | 137 | if (now - end <= gapMs) { skipped++; continue } |
| 129 | 138 | // 太短跳过。 |
| 130 | - if (session.sumOf { it.durationMs } < MIN_SESSION_SPEECH_MS) { skipped++; continue } | |
| 139 | + if (session.sumOf { it.durationMs } < minSpeechMs) { skipped++; continue } | |
| 131 | 140 | // 已总结过(时间范围重叠)跳过。 |
| 132 | 141 | if (existing.any { it.windowStartTs <= end && it.windowEndTs >= start }) { skipped++; continue } |
| 133 | 142 | ... | ... |