Commit 57c3cb486d156046e740d9daf3bfff99aa2c6fdc

Authored by yibo
1 parent d2837118

feat(lifelog/chat): lifelog 模式 RAG 问答 + 会话状态持久化 + 键盘修复

- LifelogRetriever:「lifelog 模式」开关下,发送前本地检索纪要 + 转写拼成
  system context 注入(本地 2B/4B 和 Hermes 通用,不落库)。中文用 bigram +
  重叠打分召回(FTS4 unicode61 对中文短语不命中),命中多者优先、抑制单 bigram 噪声
- Daos:加 timelineList / searchTextLike / summaries.recentList 三个 suspend 检索方法
- ChatViewModel:会话状态搬进 VM(随 Activity 存活)。修复切 tab 回来:
  流式请求被取消(viewModelScope 不取消) / 变成新会话 / lifelog 开关重置——
  currentConvId / selectedTargetId / lifelogMode 均持久化到 prefs,重启也记住
- 键盘 inset:Activity setDecorFitsSystemWindows(false) + manifest adjustResize
  + Scaffold imePadding,键盘弹出时整个界面(含底部 tab)抬到键盘上方、可见

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lifelog-recorder/app/src/main/AndroidManifest.xml
... ... @@ -25,6 +25,7 @@
25 25 android:name=".MainActivity"
26 26 android:exported="true"
27 27 android:label="Lifelog"
  28 + android:windowSoftInputMode="adjustResize"
28 29 android:theme="@style/Theme.Lifelog">
29 30 <intent-filter>
30 31 <action android:name="android.intent.action.MAIN" />
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/ChatScreen.kt
... ... @@ -22,6 +22,7 @@ import androidx.compose.material3.DropdownMenu
22 22 import androidx.compose.material3.DropdownMenuItem
23 23 import androidx.compose.material3.ExperimentalMaterial3Api
24 24 import androidx.compose.material3.FilledTonalButton
  25 +import androidx.compose.material3.FilterChip
25 26 import androidx.compose.material3.HorizontalDivider
26 27 import androidx.compose.material3.Icon
27 28 import androidx.compose.material3.IconButton
... ... @@ -54,119 +55,43 @@ import androidx.compose.ui.Modifier
54 55 import androidx.compose.ui.platform.LocalContext
55 56 import androidx.compose.ui.text.style.TextOverflow
56 57 import androidx.compose.ui.unit.dp
57   -import com.lemon.lifelog.chat.ChatClient
58 58 import com.lemon.lifelog.chat.ChatConfig
59 59 import com.lemon.lifelog.chat.ChatTarget
60   -import com.lemon.lifelog.data.ChatConversation
61   -import com.lemon.lifelog.data.ChatMessage
  60 +import com.lemon.lifelog.chat.ChatViewModel
62 61 import com.lemon.lifelog.data.Repository
63   -import kotlinx.coroutines.Dispatchers
64 62 import kotlinx.coroutines.launch
65   -import kotlinx.coroutines.withContext
66 63  
67 64 @OptIn(ExperimentalMaterial3Api::class)
68 65 @Composable
69 66 internal fun ChatTab(repo: Repository) {
70 67 val ctx = LocalContext.current
71 68 val scope = rememberCoroutineScope()
72   - val client = remember { ChatClient() }
  69 + // 会话状态都在 ViewModel 里(随 Activity 存活),切 tab 回来不丢、流式不被取消。
  70 + val vm: ChatViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
73 71  
74   - // 配置刷新计数器:改完端点设置后 bump 一下重新读 targets。
75   - var configRev by remember { mutableStateOf(0) }
76   - val targets = remember(configRev) { ChatConfig.targets(ctx) }
  72 + val targets = remember(vm.configRev) { ChatConfig.targets(ctx) }
  73 + val selectedTarget = remember(vm.selectedTargetId, vm.configRev) { ChatConfig.targetById(ctx, vm.selectedTargetId) }
77 74  
78   - val conversations by repo.chats.observeConversations().collectAsState(initial = emptyList())
79   -
80   - var currentConvId by remember { mutableStateOf<Long?>(null) }
81   - var selectedTargetId by remember { mutableStateOf(targets.first().id) }
82   - val selectedTarget = remember(selectedTargetId, configRev) { ChatConfig.targetById(ctx, selectedTargetId) }
  75 + val conversations by vm.repo.chats.observeConversations().collectAsState(initial = emptyList())
83 76  
84 77 // 当前会话的消息流。
85   - val messages by remember(currentConvId) {
86   - val cid = currentConvId
  78 + val messages by remember(vm.currentConvId) {
  79 + val cid = vm.currentConvId
87 80 if (cid == null) kotlinx.coroutines.flow.flowOf(emptyList())
88   - else repo.chats.observeMessages(cid)
  81 + else vm.repo.chats.observeMessages(cid)
89 82 }.collectAsState(initial = emptyList())
90 83  
91   - // 流式中的 assistant 回复(还没落库)。
92   - var streaming by remember { mutableStateOf(false) }
93   - var streamingText by remember { mutableStateOf("") }
94   -
95 84 var showSettings by remember { mutableStateOf(false) }
96   - var input by remember { mutableStateOf("") }
97 85  
98 86 val drawerState = rememberDrawerState(DrawerValue.Closed)
99 87 val listState = rememberLazyListState()
100 88  
101 89 // 新消息 / 流式更新时滚到底。
102   - androidx.compose.runtime.LaunchedEffect(messages.size, streamingText) {
103   - val total = messages.size + if (streaming) 1 else 0
  90 + androidx.compose.runtime.LaunchedEffect(messages.size, vm.streamingText) {
  91 + val total = messages.size + if (vm.streaming) 1 else 0
104 92 if (total > 0) listState.animateScrollToItem(total - 1)
105 93 }
106 94  
107   - fun send() {
108   - val text = input.trim()
109   - if (text.isEmpty() || streaming) return
110   - input = ""
111   - val target = selectedTarget
112   - scope.launch {
113   - val now = System.currentTimeMillis()
114   - var convId = currentConvId
115   - if (convId == null) {
116   - convId = withContext(Dispatchers.IO) {
117   - repo.chats.insertConversation(
118   - ChatConversation(
119   - title = text.take(20),
120   - targetId = target.id,
121   - createdAt = now,
122   - updatedAt = now,
123   - ),
124   - )
125   - }
126   - currentConvId = convId
127   - }
128   - withContext(Dispatchers.IO) {
129   - repo.chats.insertMessage(ChatMessage(conversationId = convId!!, role = "user", content = text, createdAt = now))
130   - repo.chats.touch(convId!!, now)
131   - }
132   - val history = withContext(Dispatchers.IO) { repo.chats.messagesOf(convId!!) }
133   - .map { it.role to it.content }
134   -
135   - streaming = true
136   - streamingText = ""
137   - try {
138   - val full = client.stream(target, history) { delta -> streamingText += delta }
139   - withContext(Dispatchers.IO) {
140   - repo.chats.insertMessage(
141   - ChatMessage(
142   - conversationId = convId!!,
143   - role = "assistant",
144   - content = full.ifBlank { "(空回复)" },
145   - model = target.model,
146   - createdAt = System.currentTimeMillis(),
147   - ),
148   - )
149   - repo.chats.touch(convId!!, System.currentTimeMillis())
150   - }
151   - } catch (t: Throwable) {
152   - withContext(Dispatchers.IO) {
153   - repo.chats.insertMessage(
154   - ChatMessage(
155   - conversationId = convId!!,
156   - role = "assistant",
157   - content = "⚠ 出错:${t.message ?: t.toString()}",
158   - model = target.model,
159   - createdAt = System.currentTimeMillis(),
160   - ),
161   - )
162   - }
163   - } finally {
164   - streaming = false
165   - streamingText = ""
166   - }
167   - }
168   - }
169   -
170 95 ModalNavigationDrawer(
171 96 drawerState = drawerState,
172 97 drawerContent = {
... ... @@ -175,8 +100,7 @@ internal fun ChatTab(repo: Repository) {
175 100 Text("对话历史", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(8.dp))
176 101 FilledTonalButton(
177 102 onClick = {
178   - currentConvId = null
179   - streamingText = ""
  103 + vm.newConversation()
180 104 scope.launch { drawerState.close() }
181 105 },
182 106 modifier = Modifier.fillMaxWidth(),
... ... @@ -197,23 +121,15 @@ internal fun ChatTab(repo: Repository) {
197 121 overflow = TextOverflow.Ellipsis,
198 122 )
199 123 },
200   - selected = conv.id == currentConvId,
  124 + selected = conv.id == vm.currentConvId,
201 125 onClick = {
202   - currentConvId = conv.id
203   - selectedTargetId = conv.targetId
204   - streamingText = ""
  126 + vm.openConversation(conv.id, conv.targetId)
205 127 scope.launch { drawerState.close() }
206 128 },
207 129 badge = {
208   - IconButton(onClick = {
209   - scope.launch {
210   - withContext(Dispatchers.IO) {
211   - repo.chats.deleteMessages(conv.id)
212   - repo.chats.deleteConversation(conv.id)
213   - }
214   - if (currentConvId == conv.id) currentConvId = null
215   - }
216   - }) { Icon(Icons.Filled.Delete, contentDescription = "删除") }
  130 + IconButton(onClick = { vm.deleteConversation(conv.id) }) {
  131 + Icon(Icons.Filled.Delete, contentDescription = "删除")
  132 + }
217 133 },
218 134 )
219 135 }
... ... @@ -226,13 +142,7 @@ internal fun ChatTab(repo: Repository) {
226 142 // 顶栏:菜单 + 聊天对象选择 + 设置。
227 143 TopAppBar(
228 144 title = { TargetSelector(targets = targets, selected = selectedTarget) { picked ->
229   - selectedTargetId = picked.id
230   - val cid = currentConvId
231   - if (cid != null) {
232   - scope.launch {
233   - withContext(Dispatchers.IO) { repo.chats.updateTarget(cid, picked.id, System.currentTimeMillis()) }
234   - }
235   - }
  145 + vm.selectTarget(picked.id)
236 146 } },
237 147 navigationIcon = {
238 148 IconButton(onClick = { scope.launch { drawerState.open() } }) {
... ... @@ -240,7 +150,7 @@ internal fun ChatTab(repo: Repository) {
240 150 }
241 151 },
242 152 actions = {
243   - IconButton(onClick = { currentConvId = null; streamingText = "" }) {
  153 + IconButton(onClick = { vm.newConversation() }) {
244 154 Icon(Icons.Filled.Add, contentDescription = "新对话")
245 155 }
246 156 IconButton(onClick = { showSettings = true }) {
... ... @@ -251,7 +161,7 @@ internal fun ChatTab(repo: Repository) {
251 161  
252 162 // 消息列表。
253 163 Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
254   - if (messages.isEmpty() && !streaming) {
  164 + if (messages.isEmpty() && !vm.streaming) {
255 165 Column(
256 166 modifier = Modifier.fillMaxSize().padding(24.dp),
257 167 verticalArrangement = Arrangement.Center,
... ... @@ -273,15 +183,32 @@ internal fun ChatTab(repo: Repository) {
273 183 verticalArrangement = Arrangement.spacedBy(8.dp),
274 184 ) {
275 185 items(messages, key = { it.id }) { m -> MessageBubble(m.role, m.content) }
276   - if (streaming) {
  186 + if (vm.streaming) {
277 187 item(key = "streaming") {
278   - MessageBubble("assistant", streamingText.ifEmpty { "…" }, inProgress = true)
  188 + MessageBubble("assistant", vm.streamingText.ifEmpty { "…" }, inProgress = true)
279 189 }
280 190 }
281 191 }
282 192 }
283 193 }
284 194  
  195 + // lifelog 模式开关。
  196 + Row(
  197 + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
  198 + verticalAlignment = Alignment.CenterVertically,
  199 + horizontalArrangement = Arrangement.spacedBy(8.dp),
  200 + ) {
  201 + FilterChip(
  202 + selected = vm.lifelogMode,
  203 + onClick = { vm.setLifelog(!vm.lifelogMode) },
  204 + label = { Text("🧠 lifelog 模式") },
  205 + )
  206 + Text(
  207 + if (vm.lifelogMode) "回答会带上你的纪要/转写检索" else "纯聊天,不读 lifelog",
  208 + style = MaterialTheme.typography.labelSmall,
  209 + )
  210 + }
  211 +
285 212 HorizontalDivider()
286 213  
287 214 // 输入区。
... ... @@ -291,14 +218,14 @@ internal fun ChatTab(repo: Repository) {
291 218 horizontalArrangement = Arrangement.spacedBy(8.dp),
292 219 ) {
293 220 OutlinedTextField(
294   - value = input,
295   - onValueChange = { input = it },
  221 + value = vm.input,
  222 + onValueChange = { vm.input = it },
296 223 modifier = Modifier.weight(1f),
297 224 placeholder = { Text("发消息给 ${selectedTarget.label}…") },
298 225 maxLines = 5,
299 226 )
300   - IconButton(onClick = { send() }, enabled = input.isNotBlank() && !streaming) {
301   - if (streaming) {
  227 + IconButton(onClick = { vm.send() }, enabled = vm.input.isNotBlank() && !vm.streaming) {
  228 + if (vm.streaming) {
302 229 CircularProgressIndicator(modifier = Modifier.height(22.dp), strokeWidth = 2.dp)
303 230 } else {
304 231 Icon(Icons.AutoMirrored.Filled.Send, contentDescription = "发送")
... ... @@ -316,7 +243,7 @@ internal fun ChatTab(repo: Repository) {
316 243 onDismiss = { showSettings = false },
317 244 onSave = { base, m2, m4 ->
318 245 ChatConfig.save(ctx, base, m2, m4)
319   - configRev++
  246 + vm.bumpConfig()
320 247 showSettings = false
321 248 },
322 249 )
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatViewModel.kt 0 → 100644
  1 +package com.lemon.lifelog.chat
  2 +
  3 +import android.app.Application
  4 +import android.content.Context
  5 +import androidx.compose.runtime.getValue
  6 +import androidx.compose.runtime.mutableStateOf
  7 +import androidx.compose.runtime.setValue
  8 +import androidx.lifecycle.AndroidViewModel
  9 +import androidx.lifecycle.viewModelScope
  10 +import com.lemon.lifelog.data.ChatConversation
  11 +import com.lemon.lifelog.data.ChatMessage
  12 +import com.lemon.lifelog.data.Repository
  13 +import kotlinx.coroutines.Dispatchers
  14 +import kotlinx.coroutines.launch
  15 +import kotlinx.coroutines.withContext
  16 +
  17 +/**
  18 + * 聊天会话状态 + 发送逻辑的宿主。放在 ViewModel 里(随 Activity 存活)而不是 ChatTab
  19 + * composable 里,是为了:切到别的 tab 再回来时——
  20 + * 1. 正在跑的流式请求不被取消(viewModelScope,不随 ChatTab 离开 composition 而 cancel);
  21 + * 2. 当前会话 / 选中模型 / lifelog 开关不丢(且持久化到 prefs,连重启都记住)。
  22 + */
  23 +class ChatViewModel(app: Application) : AndroidViewModel(app) {
  24 + val repo = Repository(app)
  25 + private val client = ChatClient()
  26 + private val prefs = app.getSharedPreferences(ChatConfig.PREFS, Context.MODE_PRIVATE)
  27 +
  28 + var currentConvId by mutableStateOf(prefs.getLong(K_CONV, -1L).takeIf { it > 0 })
  29 + private set
  30 + var selectedTargetId by mutableStateOf(
  31 + prefs.getString(K_TARGET, null) ?: ChatConfig.targets(app).first().id,
  32 + )
  33 + private set
  34 + var lifelogMode by mutableStateOf(prefs.getBoolean(K_LIFELOG, false))
  35 + private set
  36 +
  37 + var input by mutableStateOf("")
  38 + var streaming by mutableStateOf(false)
  39 + private set
  40 + var streamingText by mutableStateOf("")
  41 + private set
  42 + var configRev by mutableStateOf(0)
  43 + private set
  44 +
  45 + fun setLifelog(on: Boolean) {
  46 + lifelogMode = on
  47 + prefs.edit().putBoolean(K_LIFELOG, on).apply()
  48 + }
  49 +
  50 + fun selectTarget(id: String) {
  51 + selectedTargetId = id
  52 + prefs.edit().putString(K_TARGET, id).apply()
  53 + val cid = currentConvId
  54 + if (cid != null) viewModelScope.launch {
  55 + withContext(Dispatchers.IO) { repo.chats.updateTarget(cid, id, System.currentTimeMillis()) }
  56 + }
  57 + }
  58 +
  59 + fun openConversation(id: Long, targetId: String) {
  60 + currentConvId = id
  61 + prefs.edit().putLong(K_CONV, id).apply()
  62 + selectedTargetId = targetId
  63 + prefs.edit().putString(K_TARGET, targetId).apply()
  64 + streamingText = ""
  65 + }
  66 +
  67 + fun newConversation() {
  68 + currentConvId = null
  69 + prefs.edit().remove(K_CONV).apply()
  70 + streamingText = ""
  71 + }
  72 +
  73 + fun bumpConfig() {
  74 + configRev++
  75 + }
  76 +
  77 + fun deleteConversation(id: Long) {
  78 + viewModelScope.launch {
  79 + withContext(Dispatchers.IO) {
  80 + repo.chats.deleteMessages(id)
  81 + repo.chats.deleteConversation(id)
  82 + }
  83 + if (currentConvId == id) newConversation()
  84 + }
  85 + }
  86 +
  87 + fun send() {
  88 + val text = input.trim()
  89 + if (text.isEmpty() || streaming) return
  90 + input = ""
  91 + val app = getApplication<Application>()
  92 + val target = ChatConfig.targetById(app, selectedTargetId)
  93 + val useLifelog = lifelogMode
  94 + viewModelScope.launch {
  95 + val now = System.currentTimeMillis()
  96 + var convId = currentConvId
  97 + if (convId == null) {
  98 + convId = withContext(Dispatchers.IO) {
  99 + repo.chats.insertConversation(
  100 + ChatConversation(title = text.take(20), targetId = target.id, createdAt = now, updatedAt = now),
  101 + )
  102 + }
  103 + currentConvId = convId
  104 + prefs.edit().putLong(K_CONV, convId!!).apply()
  105 + }
  106 + val cid = convId!!
  107 + withContext(Dispatchers.IO) {
  108 + repo.chats.insertMessage(ChatMessage(conversationId = cid, role = "user", content = text, createdAt = now))
  109 + repo.chats.touch(cid, now)
  110 + }
  111 + val history = withContext(Dispatchers.IO) { repo.chats.messagesOf(cid) }.map { it.role to it.content }
  112 +
  113 + streaming = true
  114 + streamingText = ""
  115 + try {
  116 + val messagesToSend = if (useLifelog) {
  117 + val c = LifelogRetriever.buildContext(repo, text)
  118 + if (c != null) listOf("system" to c) + history else history
  119 + } else {
  120 + history
  121 + }
  122 + val full = client.stream(target, messagesToSend) { delta -> streamingText += delta }
  123 + withContext(Dispatchers.IO) {
  124 + repo.chats.insertMessage(
  125 + ChatMessage(
  126 + conversationId = cid, role = "assistant",
  127 + content = full.ifBlank { "(空回复)" }, model = target.model,
  128 + createdAt = System.currentTimeMillis(),
  129 + ),
  130 + )
  131 + repo.chats.touch(cid, System.currentTimeMillis())
  132 + }
  133 + } catch (t: Throwable) {
  134 + withContext(Dispatchers.IO) {
  135 + repo.chats.insertMessage(
  136 + ChatMessage(
  137 + conversationId = cid, role = "assistant",
  138 + content = "⚠ 出错:${t.message ?: t.toString()}", model = target.model,
  139 + createdAt = System.currentTimeMillis(),
  140 + ),
  141 + )
  142 + }
  143 + } finally {
  144 + streaming = false
  145 + streamingText = ""
  146 + }
  147 + }
  148 + }
  149 +
  150 + companion object {
  151 + private const val K_LIFELOG = "lifelog_mode"
  152 + private const val K_TARGET = "selected_target"
  153 + private const val K_CONV = "current_conv"
  154 + }
  155 +}
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogRetriever.kt 0 → 100644
  1 +package com.lemon.lifelog.chat
  2 +
  3 +import android.util.Log
  4 +import com.lemon.lifelog.data.Repository
  5 +import com.lemon.lifelog.data.SegmentRow
  6 +import kotlinx.coroutines.Dispatchers
  7 +import kotlinx.coroutines.withContext
  8 +import java.text.SimpleDateFormat
  9 +import java.util.Date
  10 +import java.util.Locale
  11 +
  12 +/**
  13 + * 「lifelog 模式」的本地检索(RAG)。聊天时把跟用户问题相关的 lifelog 内容
  14 + * 拼成一段 system context 注入到模型请求里——本地 2B/4B 和 Hermes 通用。
  15 + *
  16 + * 检索三路(高信号优先):
  17 + * 1. 会话纪要(已 LLM 压缩,覆盖面广、最省 token)——backbone。
  18 + * 2. 关键词子串命中的转写片段(LIKE,对中文可靠)——精确细节。
  19 + * 3. 最近时间轴片段——兜底「最近/今天」类问题。
  20 + */
  21 +object LifelogRetriever {
  22 + private val FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US)
  23 + private val TS_FMT = SimpleDateFormat("MM-dd HH:mm:ss", Locale.US)
  24 +
  25 + /** 注入 context 的字符上限(≈3–4k 中文 token,兼顾本地 4B 的上下文窗口)。 */
  26 + private const val MAX_CHARS = 6000
  27 + private const val SUMMARY_LIMIT = 20
  28 + private const val SUMMARY_EACH_CHARS = 400
  29 + private const val HITS_PER_TERM = 12
  30 + private const val HITS_TOTAL = 30
  31 + private const val RECENT_LIMIT = 20
  32 + private const val MAX_TERMS = 16
  33 +
  34 + // 纯虚词/高频功能 bigram:命中无意义、只会污染候选,检索时丢掉。
  35 + private val STOP_BIGRAMS = setOf(
  36 + "什么", "怎么", "这个", "那个", "一下", "这样", "那样", "可以", "就是", "然后",
  37 + "因为", "所以", "但是", "如果", "的话", "我们", "你们", "他们", "今天", "昨天",
  38 + "最近", "这周", "本周", "上周", "有没", "没有", "知道", "觉得", "可能",
  39 + )
  40 +
  41 + /** 给定用户问题,返回要注入的 system context;无任何数据返回 null。 */
  42 + suspend fun buildContext(repo: Repository, question: String): String? = withContext(Dispatchers.IO) {
  43 + val summaries = repo.summaries.recentList(SUMMARY_LIMIT)
  44 + val terms = searchTerms(question)
  45 +
  46 + // bigram 召回 + 重叠打分:一段转写命中越多 query bigram 越相关,
  47 + // 单 bigram 噪声(如某段只含"内容")被排到尾部丢掉。
  48 + val cand = LinkedHashMap<Long, SegmentRow>()
  49 + for (t in terms) {
  50 + repo.segments.searchTextLike(t, HITS_PER_TERM).forEach { row ->
  51 + if (row.text != null) cand.putIfAbsent(row.id, row)
  52 + }
  53 + }
  54 + val scored = cand.values
  55 + .map { row -> row to terms.count { row.text!!.contains(it) } }
  56 + .filter { it.second > 0 }
  57 + // 有 ≥2 命中的强匹配时,丢掉只命中 1 个 bigram 的弱噪声。
  58 + val minScore = if (scored.any { it.second >= 2 }) 2 else 1
  59 + val hits = scored
  60 + .filter { it.second >= minScore }
  61 + .sortedWith(compareByDescending<Pair<SegmentRow, Int>> { it.second }.thenByDescending { it.first.startTs })
  62 + .take(HITS_TOTAL)
  63 + .map { it.first }
  64 + val recent = repo.segments.timelineList(RECENT_LIMIT).filter { it.text != null }
  65 +
  66 + if (summaries.isEmpty() && hits.isEmpty() && recent.isEmpty()) return@withContext null
  67 +
  68 + val sb = StringBuilder()
  69 + sb.appendLine(
  70 + "你是用户的私人 lifelog 助手。lifelog 是用户 24/7 录音转写出来的个人记忆库。" +
  71 + "下面是从中检索出的相关内容,请**只依据这些内容**回答用户的问题;" +
  72 + "若不足以回答,明确说「我的 lifelog 里没找到相关内容」,不要编造。" +
  73 + "引用具体事实时可带上 [时间戳]。",
  74 + )
  75 + sb.appendLine()
  76 +
  77 + if (summaries.isNotEmpty()) {
  78 + sb.appendLine("【会话纪要】(共 ${summaries.size} 篇,时间倒序)")
  79 + summaries.forEach { s ->
  80 + val range = "${FMT.format(Date(s.windowStartTs))}–${FMT.format(Date(s.windowEndTs))}"
  81 + val body = s.markdown.trim().replace("\n", " ").take(SUMMARY_EACH_CHARS)
  82 + sb.appendLine("- [$range] $body")
  83 + }
  84 + sb.appendLine()
  85 + }
  86 +
  87 + if (hits.isNotEmpty()) {
  88 + sb.appendLine("【相关转写片段】(按问题关键词命中,相关度优先)")
  89 + hits.forEach { sb.appendLine(formatSeg(it)) }
  90 + sb.appendLine()
  91 + }
  92 +
  93 + if (recent.isNotEmpty()) {
  94 + sb.appendLine("【最近转写】(最近 ${recent.size} 段,供「最近/今天」类问题参考)")
  95 + recent.forEach { sb.appendLine(formatSeg(it)) }
  96 + }
  97 +
  98 + var ctx = sb.toString()
  99 + if (ctx.length > MAX_CHARS) ctx = ctx.take(MAX_CHARS) + "\n…(上下文已截断)"
  100 + Log.i(
  101 + "LifelogRAG",
  102 + "q=\"${question.take(40)}\" terms=$terms " +
  103 + "纪要=${summaries.size} 命中=${hits.size} 最近=${recent.size} chars=${ctx.length}",
  104 + )
  105 + ctx
  106 + }
  107 +
  108 + private fun formatSeg(r: SegmentRow): String {
  109 + val who = r.speakerName ?: "未知"
  110 + return "[${TS_FMT.format(Date(r.startTs))}] $who:${r.text?.trim().orEmpty()}"
  111 + }
  112 +
  113 + /**
  114 + * 把问题切成检索词:英文/数字整词保留;中文按**重叠 bigram**切。
  115 + * 当前 FTS4 unicode61 对中文把整段 CJK 当一个 token、短语检索不命中;
  116 + * bigram + LIKE 才能让"黑神话悟空"这种被真正召回(黑神/神话/悟空…多 bigram 命中)。
  117 + */
  118 + private fun searchTerms(question: String): List<String> {
  119 + val cleaned = question.lowercase()
  120 + .replace(Regex("[\\p{Punct},。!?、;:“”‘’()《》【】…—~·\\s]+"), " ")
  121 + val terms = LinkedHashSet<String>()
  122 + for (tok in cleaned.split(" ")) {
  123 + if (tok.isBlank()) continue
  124 + if (tok.all { it.code < 128 }) {
  125 + if (tok.length >= 2) terms += tok // 英文/数字整词
  126 + } else {
  127 + val s = tok.filter { !it.isWhitespace() }
  128 + for (i in 0 until s.length - 1) {
  129 + val bg = s.substring(i, i + 2) // CJK 重叠 bigram
  130 + if (bg !in STOP_BIGRAMS) terms += bg
  131 + }
  132 + }
  133 + if (terms.size >= MAX_TERMS) break
  134 + }
  135 + return terms.take(MAX_TERMS).toList()
  136 + }
  137 +}
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
... ... @@ -101,6 +101,43 @@ interface SegmentDao {
101 101 )
102 102 fun observeTimeline(limit: Int = 100): Flow<List<SegmentRow>>
103 103  
  104 + /** 一次性取最近时间轴(RAG 检索用,非 Flow)。 */
  105 + @Query(
  106 + """
  107 + SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs,
  108 + t.text AS text, s.audioPath AS audioPath,
  109 + s.speakerId AS speakerId, s.speakerSim AS speakerSim,
  110 + sp.name AS speakerName
  111 + FROM segments s
  112 + LEFT JOIN transcripts t ON t.segmentId = s.id
  113 + LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId
  114 + ORDER BY s.startTs DESC
  115 + LIMIT :limit
  116 + """,
  117 + )
  118 + suspend fun timelineList(limit: Int): List<SegmentRow>
  119 +
  120 + /**
  121 + * 中文子串检索(RAG 用):LIKE '%kw%'。
  122 + * 当前 FTS4 是 unicode61,对中文把整段 CJK 当一个 token、短语检索几乎不命中;
  123 + * 个人库行数少,直接 LIKE 子串扫最稳。
  124 + */
  125 + @Query(
  126 + """
  127 + SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs,
  128 + t.text AS text, s.audioPath AS audioPath,
  129 + s.speakerId AS speakerId, s.speakerSim AS speakerSim,
  130 + sp.name AS speakerName
  131 + FROM segments s
  132 + INNER JOIN transcripts t ON t.segmentId = s.id
  133 + LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId
  134 + WHERE t.text LIKE '%' || :kw || '%'
  135 + ORDER BY s.startTs DESC
  136 + LIMIT :limit
  137 + """,
  138 + )
  139 + suspend fun searchTextLike(kw: String, limit: Int): List<SegmentRow>
  140 +
104 141 /** Full-text search via FTS4 virtual table. Falls back to LIKE if MATCH query is empty. */
105 142 @Query(
106 143 """
... ... @@ -144,6 +181,10 @@ interface SummaryDao {
144 181 @Query("SELECT * FROM summaries ORDER BY windowStartTs DESC LIMIT :n")
145 182 fun recent(n: Int): Flow<List<Summary>>
146 183  
  184 + /** 一次性取最近 n 篇纪要(RAG 检索用,非 Flow)。 */
  185 + @Query("SELECT * FROM summaries ORDER BY windowStartTs DESC LIMIT :n")
  186 + suspend fun recentList(n: Int): List<Summary>
  187 +
147 188 @Query("SELECT * FROM summaries ORDER BY windowStartTs DESC")
148 189 suspend fun all(): List<Summary>
149 190  
... ...