Commit 1a7b48915fef107fda5a93406baa8e05ed3b1020
1 parent
708b7c69
feat(lifelog/crm): P3 #3 待跟进 — 本地 4B 从录音抽待办
端上 CRM #3 关系分析的「待跟进」部分(社群发现/关系链/followups 表已随 2a6f991 入库)。 - FollowupExtractor:取最近 80 段、切 25 条/窗(≤4 窗控耗时),喂本地 4B 抽行式待办, 解析去 bullet/编号/「无」,词典子串关联 Person;写 followups(去重键 sourceTs+text) - FollowupsScreen + 人物 tab「✅ 待办」入口:列表(完成勾选/删除) + 右上「扫描」 - PhotologViewModel scanFollowups/setFollowupDone/deleteFollowup;Repository 接 followups dao 真机验证:扫描 4 窗 → 15 条真实待办(如「明天每人汇报个人规划」「给高宇提要求放开平台功能」), 人物关联命中(高宇),可勾选/删除,无错。需 llama-swap(8080) 在跑。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Showing
5 changed files
with
175 additions
and
1 deletions
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/FollowupsScreen.kt
0 → 100644
| 1 | +package com.lemon.lifelog | |
| 2 | + | |
| 3 | +import androidx.compose.foundation.layout.Arrangement | |
| 4 | +import androidx.compose.foundation.layout.Box | |
| 5 | +import androidx.compose.foundation.layout.Column | |
| 6 | +import androidx.compose.foundation.layout.Row | |
| 7 | +import androidx.compose.foundation.layout.fillMaxSize | |
| 8 | +import androidx.compose.foundation.layout.fillMaxWidth | |
| 9 | +import androidx.compose.foundation.layout.padding | |
| 10 | +import androidx.compose.foundation.lazy.LazyColumn | |
| 11 | +import androidx.compose.foundation.lazy.items | |
| 12 | +import androidx.compose.material.icons.Icons | |
| 13 | +import androidx.compose.material.icons.automirrored.filled.ArrowBack | |
| 14 | +import androidx.compose.material3.Checkbox | |
| 15 | +import androidx.compose.material3.ExperimentalMaterial3Api | |
| 16 | +import androidx.compose.material3.HorizontalDivider | |
| 17 | +import androidx.compose.material3.Icon | |
| 18 | +import androidx.compose.material3.IconButton | |
| 19 | +import androidx.compose.material3.MaterialTheme | |
| 20 | +import androidx.compose.material3.Text | |
| 21 | +import androidx.compose.material3.TextButton | |
| 22 | +import androidx.compose.material3.TopAppBar | |
| 23 | +import androidx.compose.runtime.Composable | |
| 24 | +import androidx.compose.runtime.collectAsState | |
| 25 | +import androidx.compose.runtime.getValue | |
| 26 | +import androidx.compose.ui.Alignment | |
| 27 | +import androidx.compose.ui.Modifier | |
| 28 | +import androidx.compose.ui.text.style.TextDecoration | |
| 29 | +import androidx.compose.ui.unit.dp | |
| 30 | +import java.text.SimpleDateFormat | |
| 31 | +import java.util.Date | |
| 32 | +import java.util.Locale | |
| 33 | + | |
| 34 | +private val FOLLOWUP_FMT = SimpleDateFormat("MM-dd HH:mm", Locale.US) | |
| 35 | + | |
| 36 | +/** | |
| 37 | + * 待跟进:本地 4B 从最近录音抽出的「待办/承诺」。勾掉=完成,可删。点「扫描」重新抽。 | |
| 38 | + */ | |
| 39 | +@OptIn(ExperimentalMaterial3Api::class) | |
| 40 | +@Composable | |
| 41 | +internal fun FollowupsScreen(vm: PhotologViewModel, onBack: () -> Unit) { | |
| 42 | + val items by vm.repo.followups.observeAll().collectAsState(initial = emptyList()) | |
| 43 | + val scanning by vm.followupScanning.collectAsState() | |
| 44 | + | |
| 45 | + Column(Modifier.fillMaxSize()) { | |
| 46 | + TopAppBar( | |
| 47 | + title = { Text("待跟进") }, | |
| 48 | + navigationIcon = { | |
| 49 | + IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回") } | |
| 50 | + }, | |
| 51 | + actions = { | |
| 52 | + TextButton(onClick = { vm.scanFollowups() }, enabled = !scanning) { | |
| 53 | + Text(if (scanning) "扫描中…" else "扫描") | |
| 54 | + } | |
| 55 | + }, | |
| 56 | + ) | |
| 57 | + if (items.isEmpty()) { | |
| 58 | + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) { | |
| 59 | + Text( | |
| 60 | + if (scanning) "本地 4B 正在从最近录音里抽待办…" | |
| 61 | + else "还没有待办。点右上「扫描」让本地 4B 从最近录音里抽「待办/承诺」。\n(需 llama-swap 在跑)", | |
| 62 | + style = MaterialTheme.typography.bodyMedium, | |
| 63 | + ) | |
| 64 | + } | |
| 65 | + } else { | |
| 66 | + LazyColumn(Modifier.fillMaxSize()) { | |
| 67 | + items(items, key = { it.id }) { f -> | |
| 68 | + Row( | |
| 69 | + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), | |
| 70 | + verticalAlignment = Alignment.CenterVertically, | |
| 71 | + horizontalArrangement = Arrangement.spacedBy(6.dp), | |
| 72 | + ) { | |
| 73 | + Checkbox(checked = f.done, onCheckedChange = { vm.setFollowupDone(f.id, it) }) | |
| 74 | + Column(Modifier.weight(1f)) { | |
| 75 | + Text( | |
| 76 | + f.text, | |
| 77 | + style = MaterialTheme.typography.bodyLarge, | |
| 78 | + textDecoration = if (f.done) TextDecoration.LineThrough else null, | |
| 79 | + color = if (f.done) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface, | |
| 80 | + ) | |
| 81 | + Text( | |
| 82 | + (f.personName?.let { "👤 $it · " } ?: "") + FOLLOWUP_FMT.format(Date(f.sourceTs)), | |
| 83 | + style = MaterialTheme.typography.labelSmall, | |
| 84 | + ) | |
| 85 | + } | |
| 86 | + IconButton(onClick = { vm.deleteFollowup(f.id) }) { Text("✕") } | |
| 87 | + } | |
| 88 | + HorizontalDivider() | |
| 89 | + } | |
| 90 | + } | |
| 91 | + } | |
| 92 | + } | |
| 93 | +} | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt
| ... | ... | @@ -99,6 +99,7 @@ internal fun ContactsTab(repo: Repository) { |
| 99 | 99 | var showPicker by remember { mutableStateOf(false) } |
| 100 | 100 | var showPhonePicker by remember { mutableStateOf(false) } |
| 101 | 101 | var showCommunities by remember { mutableStateOf(false) } |
| 102 | + var showFollowups by remember { mutableStateOf(false) } | |
| 102 | 103 | var mergeForId by remember { mutableStateOf<Long?>(null) } |
| 103 | 104 | // 人物 tab 只显示「我管理的人物」;全部微信联系人是池子(在选人页里)。 |
| 104 | 105 | val managed by vm.repo.persons.observeManaged().collectAsState(initial = emptyList()) |
| ... | ... | @@ -120,6 +121,7 @@ internal fun ContactsTab(repo: Repository) { |
| 120 | 121 | onBack = { showCommunities = false }, |
| 121 | 122 | onOpenPerson = { vm.touchPerson(it); showCommunities = false; profilePersonId = it }, |
| 122 | 123 | ) |
| 124 | + showFollowups -> FollowupsScreen(vm = vm, onBack = { showFollowups = false }) | |
| 123 | 125 | showPicker -> WeChatPickerScreen( |
| 124 | 126 | vm = vm, |
| 125 | 127 | onBack = { showPicker = false }, |
| ... | ... | @@ -130,6 +132,7 @@ internal fun ContactsTab(repo: Repository) { |
| 130 | 132 | onOpen = { vm.touchPerson(it); profilePersonId = it }, |
| 131 | 133 | onOpenPicker = { showPicker = true }, |
| 132 | 134 | onOpenCommunities = { showCommunities = true }, |
| 135 | + onOpenFollowups = { showFollowups = true }, | |
| 133 | 136 | ) |
| 134 | 137 | } |
| 135 | 138 | detailPhotoId?.let { id -> |
| ... | ... | @@ -640,7 +643,7 @@ private fun TagFaceDialog( |
| 640 | 643 | /** 「我管理的人物」列表:搜索 + 最近置顶 + 拼音 A–Z,点进档案页。顶部入口去「微信通讯录」选人。 */ |
| 641 | 644 | @OptIn(ExperimentalFoundationApi::class) |
| 642 | 645 | @Composable |
| 643 | -private fun ContactList(vm: PhotologViewModel, persons: List<Person>, onOpen: (Long) -> Unit, onOpenPicker: () -> Unit, onOpenCommunities: () -> Unit) { | |
| 646 | +private fun ContactList(vm: PhotologViewModel, persons: List<Person>, onOpen: (Long) -> Unit, onOpenPicker: () -> Unit, onOpenCommunities: () -> Unit, onOpenFollowups: () -> Unit) { | |
| 644 | 647 | var query by remember { mutableStateOf("") } |
| 645 | 648 | |
| 646 | 649 | val sections = remember(persons, query) { |
| ... | ... | @@ -671,6 +674,7 @@ private fun ContactList(vm: PhotologViewModel, persons: List<Person>, onOpen: (L |
| 671 | 674 | ) { |
| 672 | 675 | Button(onClick = onOpenPicker) { Text("📇 通讯录") } |
| 673 | 676 | OutlinedButton(onClick = onOpenCommunities) { Text("🕸 圈子") } |
| 677 | + OutlinedButton(onClick = onOpenFollowups) { Text("✅ 待办") } | |
| 674 | 678 | } |
| 675 | 679 | HorizontalDivider() |
| 676 | 680 | if (persons.isEmpty()) { | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt
| ... | ... | @@ -172,6 +172,20 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { |
| 172 | 172 | } |
| 173 | 173 | } |
| 174 | 174 | |
| 175 | + /** 待跟进:本地 4B 从最近录音抽待办(需 llama-swap 在跑)。 */ | |
| 176 | + private val _followupScanning = MutableStateFlow(false) | |
| 177 | + val followupScanning: StateFlow<Boolean> = _followupScanning | |
| 178 | + fun scanFollowups() { | |
| 179 | + if (_followupScanning.value) return | |
| 180 | + _followupScanning.value = true | |
| 181 | + viewModelScope.launch { | |
| 182 | + runCatching { com.lemon.lifelog.crm.FollowupExtractor.scan(getApplication(), repo) } | |
| 183 | + _followupScanning.value = false | |
| 184 | + } | |
| 185 | + } | |
| 186 | + fun setFollowupDone(id: Long, done: Boolean) = viewModelScope.launch { repo.followups.setDone(id, done) } | |
| 187 | + fun deleteFollowup(id: Long) = viewModelScope.launch { repo.followups.delete(id) } | |
| 188 | + | |
| 175 | 189 | fun clearFace(faceId: Long) { |
| 176 | 190 | viewModelScope.launch { repo.faces.assignPerson(faceId, null, null) } |
| 177 | 191 | } | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/FollowupExtractor.kt
0 → 100644
| 1 | +package com.lemon.lifelog.crm | |
| 2 | + | |
| 3 | +import android.content.Context | |
| 4 | +import android.util.Log | |
| 5 | +import com.lemon.lifelog.chat.ChatClient | |
| 6 | +import com.lemon.lifelog.chat.ChatConfig | |
| 7 | +import com.lemon.lifelog.data.Followup | |
| 8 | +import com.lemon.lifelog.data.Repository | |
| 9 | +import kotlinx.coroutines.Dispatchers | |
| 10 | +import kotlinx.coroutines.withContext | |
| 11 | + | |
| 12 | +/** | |
| 13 | + * 端上 CRM #3 待跟进:本地 4B LLM 从最近录音转写里抽「待办/承诺」。 | |
| 14 | + * | |
| 15 | + * 取最近 [RECENT_SEGMENTS] 段、按 [WINDOW] 条切窗(最多 [MAX_WINDOWS] 窗,控住 LLM 调用数/耗时), | |
| 16 | + * 每窗喂 4B 抽行式待办 → 解析 → 关联 Person(词典子串)→ 写 followups(去重键 sourceTs+text)。 | |
| 17 | + * 依赖 llama-swap(8080) 在跑;挂了某窗跳过。**全在 IO 线程。** | |
| 18 | + */ | |
| 19 | +object FollowupExtractor { | |
| 20 | + private const val TAG = "FollowupExtractor" | |
| 21 | + private const val RECENT_SEGMENTS = 80 | |
| 22 | + private const val WINDOW = 25 | |
| 23 | + private const val MAX_WINDOWS = 4 | |
| 24 | + | |
| 25 | + suspend fun scan(ctx: Context, repo: Repository): Int = withContext(Dispatchers.IO) { | |
| 26 | + val rows = repo.segments.timelineList(RECENT_SEGMENTS).filter { !it.text.isNullOrBlank() } | |
| 27 | + if (rows.isEmpty()) return@withContext 0 | |
| 28 | + val windows = rows.chunked(WINDOW).take(MAX_WINDOWS) | |
| 29 | + val target = ChatConfig.targetById(ctx, ChatConfig.ID_4B) | |
| 30 | + val dict = MentionScanner.loadDictionary(repo) | |
| 31 | + val out = ArrayList<Followup>() | |
| 32 | + for (w in windows) { | |
| 33 | + val text = w.joinToString("\n") { "- ${it.text}" } | |
| 34 | + val prompt = "下面是我最近的录音转写片段。请抽出其中明确的「待办 / 承诺」——谁要做什么(可含时间)," + | |
| 35 | + "每行一条,简洁中文,**只基于原文、不要编造**。如果没有,就只输出:无\n\n$text" | |
| 36 | + val reply = try { | |
| 37 | + ChatClient().stream(target, listOf("user" to prompt)) {} | |
| 38 | + } catch (t: Throwable) { | |
| 39 | + Log.w(TAG, "llm window failed: ${t.message}"); continue | |
| 40 | + } | |
| 41 | + val sourceTs = w.maxOf { it.startTs } | |
| 42 | + for (item in parseItems(reply)) { | |
| 43 | + val pid = dict.firstOrNull { pa -> pa.aliases.any { it in item } }?.personId | |
| 44 | + out.add(Followup(text = item, personId = pid, sourceTs = sourceTs)) | |
| 45 | + } | |
| 46 | + } | |
| 47 | + if (out.isNotEmpty()) repo.followups.insertAll(out) | |
| 48 | + Log.i(TAG, "scan: ${windows.size} windows → ${out.size} followups") | |
| 49 | + out.size | |
| 50 | + } | |
| 51 | + | |
| 52 | + /** 把 LLM 行式回复清成待办条目:去 bullet/编号、过滤「无」/空/过长。 */ | |
| 53 | + private fun parseItems(reply: String): List<String> = | |
| 54 | + reply.lineSequence() | |
| 55 | + .map { it.trim().trimStart('-', '•', '*', ' ').trim() } | |
| 56 | + .map { it.trimStart('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '、', ')', ')', ' ').trim() } | |
| 57 | + .filter { it.length in 3..120 } | |
| 58 | + .filter { it != "无" && !it.startsWith("无 ") && !it.startsWith("无,") && !it.contains("没有明确") && !it.contains("无待办") } | |
| 59 | + .distinct() | |
| 60 | + .take(20) | |
| 61 | + .toList() | |
| 62 | +} | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt