Commit aef8755d9551c12780e927a20f85dd4e0030f9a3
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>
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,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<Person>, onOpen: (L | @@ -671,6 +674,7 @@ private fun ContactList(vm: PhotologViewModel, persons: List<Person>, 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 | } |