From 1a7b48915fef107fda5a93406baa8e05ed3b1020 Mon Sep 17 00:00:00 2001 From: yibo Date: Tue, 9 Jun 2026 18:12:14 +0800 Subject: [PATCH] feat(lifelog/crm): P3 #3 待跟进 — 本地 4B 从录音抽待办 --- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/FollowupsScreen.kt | 93 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt | 6 +++++- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt | 14 ++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/FollowupExtractor.kt | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt | 1 + 5 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/FollowupsScreen.kt create mode 100644 lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/FollowupExtractor.kt diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/FollowupsScreen.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/FollowupsScreen.kt new file mode 100644 index 0000000..39d01a9 --- /dev/null +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/FollowupsScreen.kt @@ -0,0 +1,93 @@ +package com.lemon.lifelog + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Checkbox +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +private val FOLLOWUP_FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US) + +/** + * 待跟进:本地 4B 从最近录音抽出的「待办/承诺」。勾掉=完成,可删。点「扫描」重新抽。 + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun FollowupsScreen(vm: PhotologViewModel, onBack: () -> Unit) { + val items by vm.repo.followups.observeAll().collectAsState(initial = emptyList()) + val scanning by vm.followupScanning.collectAsState() + + Column(Modifier.fillMaxSize()) { + TopAppBar( + title = { Text("待跟进") }, + navigationIcon = { + IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") } + }, + actions = { + TextButton(onClick = { vm.scanFollowups() }, enabled = !scanning) { + Text(if (scanning) "扫描中…" else "扫描") + } + }, + ) + if (items.isEmpty()) { + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { + Text( + if (scanning) "本地 4B 正在从最近录音里抽待办…" + else "还没有待办。点右上「扫描」让本地 4B 从最近录音里抽「待办/承诺」。\n(需 llama-swap 在跑)", + style = MaterialTheme.typography.bodyMedium, + ) + } + } else { + LazyColumn(Modifier.fillMaxSize()) { + items(items, key = { it.id }) { f -> + Row( + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Checkbox(checked = f.done, onCheckedChange = { vm.setFollowupDone(f.id, it) }) + Column(Modifier.weight(1f)) { + Text( + f.text, + style = MaterialTheme.typography.bodyLarge, + textDecoration = if (f.done) TextDecoration.LineThrough else null, + color = if (f.done) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface, + ) + Text( + (f.personName?.let { "👤 $it · " } ?: "") + FOLLOWUP_FMT.format(Date(f.sourceTs)), + style = MaterialTheme.typography.labelSmall, + ) + } + IconButton(onClick = { vm.deleteFollowup(f.id) }) { Text("✕") } + } + HorizontalDivider() + } + } + } + } +} diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt index 04ce1a7..167547e 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt @@ -99,6 +99,7 @@ internal fun ContactsTab(repo: Repository) { var showPicker by remember { mutableStateOf(false) } var showPhonePicker by remember { mutableStateOf(false) } var showCommunities by remember { mutableStateOf(false) } + var showFollowups by remember { mutableStateOf(false) } var mergeForId by remember { mutableStateOf(null) } // 人物 tab 只显示「我管理的人物」;全部微信联系人是池子(在选人页里)。 val managed by vm.repo.persons.observeManaged().collectAsState(initial = emptyList()) @@ -120,6 +121,7 @@ internal fun ContactsTab(repo: Repository) { onBack = { showCommunities = false }, onOpenPerson = { vm.touchPerson(it); showCommunities = false; profilePersonId = it }, ) + showFollowups -> FollowupsScreen(vm = vm, onBack = { showFollowups = false }) showPicker -> WeChatPickerScreen( vm = vm, onBack = { showPicker = false }, @@ -130,6 +132,7 @@ internal fun ContactsTab(repo: Repository) { onOpen = { vm.touchPerson(it); profilePersonId = it }, onOpenPicker = { showPicker = true }, onOpenCommunities = { showCommunities = true }, + onOpenFollowups = { showFollowups = true }, ) } detailPhotoId?.let { id -> @@ -640,7 +643,7 @@ private fun TagFaceDialog( /** 「我管理的人物」列表:搜索 + 最近置顶 + 拼音 A–Z,点进档案页。顶部入口去「微信通讯录」选人。 */ @OptIn(ExperimentalFoundationApi::class) @Composable -private fun ContactList(vm: PhotologViewModel, persons: List, onOpen: (Long) -> Unit, onOpenPicker: () -> Unit, onOpenCommunities: () -> Unit) { +private fun ContactList(vm: PhotologViewModel, persons: List, onOpen: (Long) -> Unit, onOpenPicker: () -> Unit, onOpenCommunities: () -> Unit, onOpenFollowups: () -> Unit) { var query by remember { mutableStateOf("") } val sections = remember(persons, query) { @@ -671,6 +674,7 @@ private fun ContactList(vm: PhotologViewModel, persons: List, onOpen: (L ) { Button(onClick = onOpenPicker) { Text("📇 通讯录") } OutlinedButton(onClick = onOpenCommunities) { Text("🕸 圈子") } + OutlinedButton(onClick = onOpenFollowups) { Text("✅ 待办") } } HorizontalDivider() if (persons.isEmpty()) { diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt index f65fd17..85e3434 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt @@ -172,6 +172,20 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { } } + /** 待跟进:本地 4B 从最近录音抽待办(需 llama-swap 在跑)。 */ + private val _followupScanning = MutableStateFlow(false) + val followupScanning: StateFlow = _followupScanning + fun scanFollowups() { + if (_followupScanning.value) return + _followupScanning.value = true + viewModelScope.launch { + runCatching { com.lemon.lifelog.crm.FollowupExtractor.scan(getApplication(), repo) } + _followupScanning.value = false + } + } + fun setFollowupDone(id: Long, done: Boolean) = viewModelScope.launch { repo.followups.setDone(id, done) } + fun deleteFollowup(id: Long) = viewModelScope.launch { repo.followups.delete(id) } + fun clearFace(faceId: Long) { viewModelScope.launch { repo.faces.assignPerson(faceId, null, null) } } diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/FollowupExtractor.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/FollowupExtractor.kt new file mode 100644 index 0000000..fff4a49 --- /dev/null +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/FollowupExtractor.kt @@ -0,0 +1,62 @@ +package com.lemon.lifelog.crm + +import android.content.Context +import android.util.Log +import com.lemon.lifelog.chat.ChatClient +import com.lemon.lifelog.chat.ChatConfig +import com.lemon.lifelog.data.Followup +import com.lemon.lifelog.data.Repository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * 端上 CRM #3 待跟进:本地 4B LLM 从最近录音转写里抽「待办/承诺」。 + * + * 取最近 [RECENT_SEGMENTS] 段、按 [WINDOW] 条切窗(最多 [MAX_WINDOWS] 窗,控住 LLM 调用数/耗时), + * 每窗喂 4B 抽行式待办 → 解析 → 关联 Person(词典子串)→ 写 followups(去重键 sourceTs+text)。 + * 依赖 llama-swap(8080) 在跑;挂了某窗跳过。**全在 IO 线程。** + */ +object FollowupExtractor { + private const val TAG = "FollowupExtractor" + private const val RECENT_SEGMENTS = 80 + private const val WINDOW = 25 + private const val MAX_WINDOWS = 4 + + suspend fun scan(ctx: Context, repo: Repository): Int = withContext(Dispatchers.IO) { + val rows = repo.segments.timelineList(RECENT_SEGMENTS).filter { !it.text.isNullOrBlank() } + if (rows.isEmpty()) return@withContext 0 + val windows = rows.chunked(WINDOW).take(MAX_WINDOWS) + val target = ChatConfig.targetById(ctx, ChatConfig.ID_4B) + val dict = MentionScanner.loadDictionary(repo) + val out = ArrayList() + for (w in windows) { + val text = w.joinToString("\n") { "- ${it.text}" } + val prompt = "下面是我最近的录音转写片段。请抽出其中明确的「待办 / 承诺」——谁要做什么(可含时间)," + + "每行一条,简洁中文,**只基于原文、不要编造**。如果没有,就只输出:无\n\n$text" + val reply = try { + ChatClient().stream(target, listOf("user" to prompt)) {} + } catch (t: Throwable) { + Log.w(TAG, "llm window failed: ${t.message}"); continue + } + val sourceTs = w.maxOf { it.startTs } + for (item in parseItems(reply)) { + val pid = dict.firstOrNull { pa -> pa.aliases.any { it in item } }?.personId + out.add(Followup(text = item, personId = pid, sourceTs = sourceTs)) + } + } + if (out.isNotEmpty()) repo.followups.insertAll(out) + Log.i(TAG, "scan: ${windows.size} windows → ${out.size} followups") + out.size + } + + /** 把 LLM 行式回复清成待办条目:去 bullet/编号、过滤「无」/空/过长。 */ + private fun parseItems(reply: String): List = + reply.lineSequence() + .map { it.trim().trimStart('-', '•', '*', ' ').trim() } + .map { it.trimStart('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '、', ')', ')', ' ').trim() } + .filter { it.length in 3..120 } + .filter { it != "无" && !it.startsWith("无 ") && !it.startsWith("无,") && !it.contains("没有明确") && !it.contains("无待办") } + .distinct() + .take(20) + .toList() +} diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt index 5e8eeaa..5397e39 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt @@ -16,4 +16,5 @@ class Repository(ctx: Context) { val moments = db.momentDao() val mentions = db.mentionDao() val relations = db.relationDao() + val followups = db.followupDao() } -- libgit2 0.21.4