Commit aef8755d9551c12780e927a20f85dd4e0030f9a3

Authored by yibo
1 parent 599ca8d7

feat(lifelog/crm): P3 #3 待跟进 — 本地 4B 从录音抽待办

端上 CRM #3 关系分析的「待跟进」部分(社群发现/关系链/followups 表已随 7a40a4a 入库)。
- 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>
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,6 +99,7 @@ internal fun ContactsTab(repo: Repository) {
99 var showPicker by remember { mutableStateOf(false) } 99 var showPicker by remember { mutableStateOf(false) }
100 var showPhonePicker by remember { mutableStateOf(false) } 100 var showPhonePicker by remember { mutableStateOf(false) }
101 var showCommunities by remember { mutableStateOf(false) } 101 var showCommunities by remember { mutableStateOf(false) }
  102 + var showFollowups by remember { mutableStateOf(false) }
102 var mergeForId by remember { mutableStateOf<Long?>(null) } 103 var mergeForId by remember { mutableStateOf<Long?>(null) }
103 // 人物 tab 只显示「我管理的人物」;全部微信联系人是池子(在选人页里)。 104 // 人物 tab 只显示「我管理的人物」;全部微信联系人是池子(在选人页里)。
104 val managed by vm.repo.persons.observeManaged().collectAsState(initial = emptyList()) 105 val managed by vm.repo.persons.observeManaged().collectAsState(initial = emptyList())
@@ -120,6 +121,7 @@ internal fun ContactsTab(repo: Repository) { @@ -120,6 +121,7 @@ internal fun ContactsTab(repo: Repository) {
120 onBack = { showCommunities = false }, 121 onBack = { showCommunities = false },
121 onOpenPerson = { vm.touchPerson(it); showCommunities = false; profilePersonId = it }, 122 onOpenPerson = { vm.touchPerson(it); showCommunities = false; profilePersonId = it },
122 ) 123 )
  124 + showFollowups -> FollowupsScreen(vm = vm, onBack = { showFollowups = false })
123 showPicker -> WeChatPickerScreen( 125 showPicker -> WeChatPickerScreen(
124 vm = vm, 126 vm = vm,
125 onBack = { showPicker = false }, 127 onBack = { showPicker = false },
@@ -130,6 +132,7 @@ internal fun ContactsTab(repo: Repository) { @@ -130,6 +132,7 @@ internal fun ContactsTab(repo: Repository) {
130 onOpen = { vm.touchPerson(it); profilePersonId = it }, 132 onOpen = { vm.touchPerson(it); profilePersonId = it },
131 onOpenPicker = { showPicker = true }, 133 onOpenPicker = { showPicker = true },
132 onOpenCommunities = { showCommunities = true }, 134 onOpenCommunities = { showCommunities = true },
  135 + onOpenFollowups = { showFollowups = true },
133 ) 136 )
134 } 137 }
135 detailPhotoId?.let { id -> 138 detailPhotoId?.let { id ->
@@ -640,7 +643,7 @@ private fun TagFaceDialog( @@ -640,7 +643,7 @@ private fun TagFaceDialog(
640 /** 「我管理的人物」列表:搜索 + 最近置顶 + 拼音 A–Z,点进档案页。顶部入口去「微信通讯录」选人。 */ 643 /** 「我管理的人物」列表:搜索 + 最近置顶 + 拼音 A–Z,点进档案页。顶部入口去「微信通讯录」选人。 */
641 @OptIn(ExperimentalFoundationApi::class) 644 @OptIn(ExperimentalFoundationApi::class)
642 @Composable 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 var query by remember { mutableStateOf("") } 647 var query by remember { mutableStateOf("") }
645 648
646 val sections = remember(persons, query) { 649 val sections = remember(persons, query) {
@@ -671,6 +674,7 @@ private fun ContactList(vm: PhotologViewModel, persons: List&lt;Person&gt;, onOpen: (L @@ -671,6 +674,7 @@ private fun ContactList(vm: PhotologViewModel, persons: List&lt;Person&gt;, onOpen: (L
671 ) { 674 ) {
672 Button(onClick = onOpenPicker) { Text("📇 通讯录") } 675 Button(onClick = onOpenPicker) { Text("📇 通讯录") }
673 OutlinedButton(onClick = onOpenCommunities) { Text("🕸 圈子") } 676 OutlinedButton(onClick = onOpenCommunities) { Text("🕸 圈子") }
  677 + OutlinedButton(onClick = onOpenFollowups) { Text("✅ 待办") }
674 } 678 }
675 HorizontalDivider() 679 HorizontalDivider()
676 if (persons.isEmpty()) { 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,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 fun clearFace(faceId: Long) { 189 fun clearFace(faceId: Long) {
176 viewModelScope.launch { repo.faces.assignPerson(faceId, null, null) } 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
@@ -16,4 +16,5 @@ class Repository(ctx: Context) { @@ -16,4 +16,5 @@ class Repository(ctx: Context) {
16 val moments = db.momentDao() 16 val moments = db.momentDao()
17 val mentions = db.mentionDao() 17 val mentions = db.mentionDao()
18 val relations = db.relationDao() 18 val relations = db.relationDao()
  19 + val followups = db.followupDao()
19 } 20 }