Commit eb6e13a4ea1758b4f21c4034b7790c77de4190a3
1 parent
c7f16ba1
feat(lifelog/crm): P0 #5 Transcript→Person 提及关系(词典反查)
端上 CRM #5:从转写文本反查提到了哪些已知联系人,不依赖 LLM。 - 新表 mentions(segmentId, personId, matchedText),Room v15→16 - MentionExtractor/MentionScanner:拿 managed 人物的 名字/真名/昵称/备注/微信号(≥2字) 当词典, 在 Transcript.text 里扫子串命中;零幻觉、可离线 backfill - BlockProcessor 实时挂钩:转写出文字后顺手扫提及入库(词典每块预载一次) - PhotologViewModel.rescanMentions:全量重扫按钮 - PersonProfileScreen 加「被提到」tab(别人录音里提到 TA 的段,按声纹=谁说的 展示,可播放可搜) 真机验证:迁移成功不清库;rescan 1613 转写×1人→0 提及(仅自己 managed,名字不出现在转写里,正确)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Showing
8 changed files
with
264 additions
and
14 deletions
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt
| @@ -95,6 +95,9 @@ internal fun PersonProfileScreen( | @@ -95,6 +95,9 @@ internal fun PersonProfileScreen( | ||
| 95 | if (wxid == null) kotlinx.coroutines.flow.flowOf(emptyList()) | 95 | if (wxid == null) kotlinx.coroutines.flow.flowOf(emptyList()) |
| 96 | else vm.repo.moments.observeByUser(wxid) | 96 | else vm.repo.moments.observeByUser(wxid) |
| 97 | }.collectAsState(initial = emptyList()) | 97 | }.collectAsState(initial = emptyList()) |
| 98 | + val mentioned by remember(personId) { vm.repo.mentions.observeMentionsOf(personId) } | ||
| 99 | + .collectAsState(initial = emptyList()) | ||
| 100 | + val mentionScanning by vm.mentionScanning.collectAsState() | ||
| 98 | 101 | ||
| 99 | var bindVoice by remember { mutableStateOf(false) } | 102 | var bindVoice by remember { mutableStateOf(false) } |
| 100 | var recordVoice by remember { mutableStateOf(false) } | 103 | var recordVoice by remember { mutableStateOf(false) } |
| @@ -103,13 +106,20 @@ internal fun PersonProfileScreen( | @@ -103,13 +106,20 @@ internal fun PersonProfileScreen( | ||
| 103 | var playingId by remember { mutableStateOf<Long?>(null) } | 106 | var playingId by remember { mutableStateOf<Long?>(null) } |
| 104 | DisposableEffect(player) { onDispose { player.stop() } } | 107 | DisposableEffect(player) { onDispose { player.stop() } } |
| 105 | 108 | ||
| 106 | - var tab by remember { mutableStateOf(0) } // 0照片 1语音 2聊天 3朋友圈 | 109 | + var tab by remember { mutableStateOf(0) } // 0照片 1语音 2聊天 3朋友圈 4被提到 |
| 107 | var query by remember { mutableStateOf("") } | 110 | var query by remember { mutableStateOf("") } |
| 108 | val q = query.trim() | 111 | val q = query.trim() |
| 109 | val fPhotos = remember(photos, q) { if (q.isBlank()) photos else photos.filter { it.caption?.contains(q, true) == true } } | 112 | val fPhotos = remember(photos, q) { if (q.isBlank()) photos else photos.filter { it.caption?.contains(q, true) == true } } |
| 110 | val fAudio = remember(audio, q) { if (q.isBlank()) audio else audio.filter { it.text?.contains(q, true) == true } } | 113 | val fAudio = remember(audio, q) { if (q.isBlank()) audio else audio.filter { it.text?.contains(q, true) == true } } |
| 111 | val fMsgs = remember(messages, q) { if (q.isBlank()) messages else messages.filter { it.content.contains(q, true) } } | 114 | val fMsgs = remember(messages, q) { if (q.isBlank()) messages else messages.filter { it.content.contains(q, true) } } |
| 112 | val fMoments = remember(moments, q) { if (q.isBlank()) moments else moments.filter { it.text.contains(q, true) } } | 115 | val fMoments = remember(moments, q) { if (q.isBlank()) moments else moments.filter { it.text.contains(q, true) } } |
| 116 | + val fMentioned = remember(mentioned, q) { if (q.isBlank()) mentioned else mentioned.filter { it.text?.contains(q, true) == true } } | ||
| 117 | + | ||
| 118 | + // 列表里点播某段(块完整录音按 audioStartMs+durationMs 切片)。 | ||
| 119 | + fun togglePlay(row: com.lemon.lifelog.data.SegmentRow) { | ||
| 120 | + if (playingId == row.id) { player.stop(); playingId = null } | ||
| 121 | + else { playingId = row.id; player.play(row.audioPath, row.audioStartMs, row.durationMs) { playingId = null } } | ||
| 122 | + } | ||
| 113 | 123 | ||
| 114 | Column(modifier = Modifier.fillMaxSize()) { | 124 | Column(modifier = Modifier.fillMaxSize()) { |
| 115 | TopAppBar( | 125 | TopAppBar( |
| @@ -138,6 +148,7 @@ internal fun PersonProfileScreen( | @@ -138,6 +148,7 @@ internal fun PersonProfileScreen( | ||
| 138 | Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("语音 ${audio.size}") }) | 148 | Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("语音 ${audio.size}") }) |
| 139 | Tab(selected = tab == 2, onClick = { tab = 2 }, text = { Text("聊天 ${messages.size}") }) | 149 | Tab(selected = tab == 2, onClick = { tab = 2 }, text = { Text("聊天 ${messages.size}") }) |
| 140 | Tab(selected = tab == 3, onClick = { tab = 3 }, text = { Text("朋友圈 ${moments.size}") }) | 150 | Tab(selected = tab == 3, onClick = { tab = 3 }, text = { Text("朋友圈 ${moments.size}") }) |
| 151 | + Tab(selected = tab == 4, onClick = { tab = 4 }, text = { Text("被提到 ${mentioned.size}") }) | ||
| 141 | } | 152 | } |
| 142 | Box(modifier = Modifier.weight(1f).fillMaxWidth()) { | 153 | Box(modifier = Modifier.weight(1f).fillMaxWidth()) { |
| 143 | when (tab) { | 154 | when (tab) { |
| @@ -157,23 +168,41 @@ internal fun PersonProfileScreen( | @@ -157,23 +168,41 @@ internal fun PersonProfileScreen( | ||
| 157 | else if (fAudio.isEmpty()) EmptyHint(if (q.isBlank()) "这个声纹名下还没有录音段。" else "没有匹配的录音。") | 168 | else if (fAudio.isEmpty()) EmptyHint(if (q.isBlank()) "这个声纹名下还没有录音段。" else "没有匹配的录音。") |
| 158 | else LazyColumn(Modifier.fillMaxSize()) { | 169 | else LazyColumn(Modifier.fillMaxSize()) { |
| 159 | items(fAudio, key = { it.id }) { row -> | 170 | items(fAudio, key = { it.id }) { row -> |
| 160 | - AudioRow(row, playing = playingId == row.id) { | ||
| 161 | - if (playingId == row.id) { player.stop(); playingId = null } | ||
| 162 | - else { | ||
| 163 | - playingId = row.id | ||
| 164 | - // 块级管线:turn 是块完整录音里的一段,必须按 audioStartMs+durationMs 切片, | ||
| 165 | - // 否则从头播整块、跟这行的文字对不上。 | ||
| 166 | - player.play(row.audioPath, row.audioStartMs, row.durationMs) { playingId = null } | ||
| 167 | - } | ||
| 168 | - } | 171 | + // 块级管线:turn 是块完整录音里的一段,按 audioStartMs+durationMs 切片才对得上文字。 |
| 172 | + AudioRow(row, playing = playingId == row.id) { togglePlay(row) } | ||
| 169 | } | 173 | } |
| 170 | } | 174 | } |
| 171 | 2 -> if (wxid == null) EmptyHint("非微信联系人。") | 175 | 2 -> if (wxid == null) EmptyHint("非微信联系人。") |
| 172 | else if (fMsgs.isEmpty()) EmptyHint(if (q.isBlank()) "这台设备上没有跟 TA 的聊天记录。" else "没有匹配的聊天。") | 176 | else if (fMsgs.isEmpty()) EmptyHint(if (q.isBlank()) "这台设备上没有跟 TA 的聊天记录。" else "没有匹配的聊天。") |
| 173 | else LazyColumn(Modifier.fillMaxSize()) { items(fMsgs, key = { it.id }) { m -> ChatBubble(m) } } | 177 | else LazyColumn(Modifier.fillMaxSize()) { items(fMsgs, key = { it.id }) { m -> ChatBubble(m) } } |
| 174 | - else -> if (wxid == null) EmptyHint("非微信联系人。") | 178 | + 3 -> if (wxid == null) EmptyHint("非微信联系人。") |
| 175 | else if (fMoments.isEmpty()) EmptyHint(if (q.isBlank()) "没有 TA 的朋友圈记录。" else "没有匹配的朋友圈。") | 179 | else if (fMoments.isEmpty()) EmptyHint(if (q.isBlank()) "没有 TA 的朋友圈记录。" else "没有匹配的朋友圈。") |
| 176 | else LazyColumn(Modifier.fillMaxSize()) { items(fMoments, key = { it.id }) { mo -> MomentRow(mo) } } | 180 | else LazyColumn(Modifier.fillMaxSize()) { items(fMoments, key = { it.id }) { mo -> MomentRow(mo) } } |
| 181 | + // 被提到:别人录音里提到 TA 的段(按声纹=谁说的 展示)。 | ||
| 182 | + else -> Column(Modifier.fillMaxSize()) { | ||
| 183 | + Row( | ||
| 184 | + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), | ||
| 185 | + horizontalArrangement = Arrangement.SpaceBetween, | ||
| 186 | + verticalAlignment = Alignment.CenterVertically, | ||
| 187 | + ) { | ||
| 188 | + Text("录音里提到 TA 的段", style = MaterialTheme.typography.labelMedium) | ||
| 189 | + TextButton(onClick = { vm.rescanMentions() }, enabled = !mentionScanning) { | ||
| 190 | + Text(if (mentionScanning) "扫描中…" else "重新扫描") | ||
| 191 | + } | ||
| 192 | + } | ||
| 193 | + if (fMentioned.isEmpty()) { | ||
| 194 | + EmptyHint( | ||
| 195 | + if (q.isBlank()) "还没扫到提及。点「重新扫描」对历史录音反查;新录音会自动入库。\n(需 TA 在「我管理的人物」里,且名字/备注/昵称能在转写文本中被认出)" | ||
| 196 | + else "没有匹配的提及。", | ||
| 197 | + ) | ||
| 198 | + } else { | ||
| 199 | + LazyColumn(Modifier.fillMaxSize()) { | ||
| 200 | + items(fMentioned, key = { it.id }) { row -> | ||
| 201 | + AudioRow(row, playing = playingId == row.id) { togglePlay(row) } | ||
| 202 | + } | ||
| 203 | + } | ||
| 204 | + } | ||
| 205 | + } | ||
| 177 | } | 206 | } |
| 178 | } | 207 | } |
| 179 | } | 208 | } |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt
| @@ -153,6 +153,18 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { | @@ -153,6 +153,18 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { | ||
| 153 | viewModelScope.launch { repo.persons.setManaged(personId, managed) } | 153 | viewModelScope.launch { repo.persons.setManaged(personId, managed) } |
| 154 | } | 154 | } |
| 155 | 155 | ||
| 156 | + /** 提及反查:全量重扫所有转写,按当前 managed 人物词典重建 mentions(档案「被提到」tab 用)。 */ | ||
| 157 | + private val _mentionScanning = MutableStateFlow(false) | ||
| 158 | + val mentionScanning: StateFlow<Boolean> = _mentionScanning | ||
| 159 | + fun rescanMentions() { | ||
| 160 | + if (_mentionScanning.value) return | ||
| 161 | + _mentionScanning.value = true | ||
| 162 | + viewModelScope.launch { | ||
| 163 | + runCatching { com.lemon.lifelog.crm.MentionScanner.rescanAll(repo) } | ||
| 164 | + _mentionScanning.value = false | ||
| 165 | + } | ||
| 166 | + } | ||
| 167 | + | ||
| 156 | fun clearFace(faceId: Long) { | 168 | fun clearFace(faceId: Long) { |
| 157 | viewModelScope.launch { repo.faces.assignPerson(faceId, null, null) } | 169 | viewModelScope.launch { repo.faces.assignPerson(faceId, null, null) } |
| 158 | } | 170 | } |
| @@ -428,6 +440,11 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { | @@ -428,6 +440,11 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { | ||
| 428 | fun deletePhoto(photoId: Long) { | 440 | fun deletePhoto(photoId: Long) { |
| 429 | viewModelScope.launch { | 441 | viewModelScope.launch { |
| 430 | withContext(Dispatchers.IO) { | 442 | withContext(Dispatchers.IO) { |
| 443 | + val p = repo.photos.get(photoId) | ||
| 444 | + // 相册源:记墓碑,否则下次全相册扫描会把它当成「新图」重新导入。 | ||
| 445 | + if (p?.source == "gallery") p.mediaId?.let { repo.photos.tombstoneMedia(it) } | ||
| 446 | + // 导入源:删私有目录里的副本文件回收空间(相册源是原地引用,不能删用户原图)。 | ||
| 447 | + else if (p != null) runCatching { File(p.path).takeIf { it.exists() }?.delete() } | ||
| 431 | repo.faces.deleteByPhoto(photoId) | 448 | repo.faces.deleteByPhoto(photoId) |
| 432 | repo.photos.delete(photoId) | 449 | repo.photos.delete(photoId) |
| 433 | } | 450 | } |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/MentionExtractor.kt
0 → 100644
| 1 | +package com.lemon.lifelog.crm | ||
| 2 | + | ||
| 3 | +import android.util.Log | ||
| 4 | +import com.lemon.lifelog.data.Mention | ||
| 5 | +import com.lemon.lifelog.data.Person | ||
| 6 | +import com.lemon.lifelog.data.Repository | ||
| 7 | +import kotlinx.coroutines.Dispatchers | ||
| 8 | +import kotlinx.coroutines.withContext | ||
| 9 | + | ||
| 10 | +/** | ||
| 11 | + * 端上 CRM #5:从转写文本里**词典反查**出提到了哪些已知联系人(不依赖 LLM)。 | ||
| 12 | + * | ||
| 13 | + * 思路:只认「已在通讯录里管理的人」(managed=true)——拿他们的名字/备注/昵称/微信号当词典, | ||
| 14 | + * 去 `Transcript.text` 里扫子串命中。个人库行数少、名字是已知集合,子串反查比通用 NER 更快更准 | ||
| 15 | + * (也零幻觉)。命中写 `mentions(segmentId, personId, matchedText)`。 | ||
| 16 | + * | ||
| 17 | + * 局限(已知,后续可叠 LLM):只发现通讯录内的人;重名会同时命中多个人;< [MIN_ALIAS_LEN] 的别名忽略 | ||
| 18 | + * (单字名会匹配到一切)。 | ||
| 19 | + */ | ||
| 20 | +object MentionExtractor { | ||
| 21 | + private const val TAG = "MentionExtractor" | ||
| 22 | + private const val MIN_ALIAS_LEN = 2 | ||
| 23 | + | ||
| 24 | + /** 一个人的可匹配别名集合(去重、过滤过短)。 */ | ||
| 25 | + data class PersonAliases(val personId: Long, val aliases: List<String>) | ||
| 26 | + | ||
| 27 | + fun aliasesOf(p: Person): PersonAliases { | ||
| 28 | + val set = LinkedHashSet<String>() | ||
| 29 | + for (a in listOf(p.name, p.realName, p.nickname, p.remark, p.alias)) { | ||
| 30 | + val s = a?.trim() ?: continue | ||
| 31 | + if (s.length >= MIN_ALIAS_LEN) set.add(s) | ||
| 32 | + } | ||
| 33 | + return PersonAliases(p.id, set.toList()) | ||
| 34 | + } | ||
| 35 | + | ||
| 36 | + /** 在 [text] 里反查每个人的别名;每人最多记一次(命中的第一个别名)。返回 (personId, matchedText)。 */ | ||
| 37 | + fun match(text: String, people: List<PersonAliases>): List<Pair<Long, String>> { | ||
| 38 | + if (text.isBlank() || people.isEmpty()) return emptyList() | ||
| 39 | + val hits = ArrayList<Pair<Long, String>>() | ||
| 40 | + for (pa in people) { | ||
| 41 | + for (alias in pa.aliases) { | ||
| 42 | + if (text.contains(alias)) { | ||
| 43 | + hits.add(pa.personId to alias) | ||
| 44 | + break | ||
| 45 | + } | ||
| 46 | + } | ||
| 47 | + } | ||
| 48 | + return hits | ||
| 49 | + } | ||
| 50 | +} | ||
| 51 | + | ||
| 52 | +/** 提及反查的批量/单段执行入口。 */ | ||
| 53 | +object MentionScanner { | ||
| 54 | + private const val TAG = "MentionScanner" | ||
| 55 | + | ||
| 56 | + /** 取当前「我管理的人物」的别名词典(有别名的才纳入)。 */ | ||
| 57 | + suspend fun loadDictionary(repo: Repository): List<MentionExtractor.PersonAliases> = | ||
| 58 | + repo.persons.all() | ||
| 59 | + .filter { it.managed } | ||
| 60 | + .map { MentionExtractor.aliasesOf(it) } | ||
| 61 | + .filter { it.aliases.isNotEmpty() } | ||
| 62 | + | ||
| 63 | + /** 全量重扫:清空 mentions,对所有转写按当前词典重建。返回命中条数。 */ | ||
| 64 | + suspend fun rescanAll(repo: Repository): Int = withContext(Dispatchers.IO) { | ||
| 65 | + val dict = loadDictionary(repo) | ||
| 66 | + repo.mentions.clearAll() | ||
| 67 | + if (dict.isEmpty()) return@withContext 0 | ||
| 68 | + val texts = repo.transcripts.allTexts() | ||
| 69 | + val batch = ArrayList<Mention>() | ||
| 70 | + var n = 0 | ||
| 71 | + for (t in texts) { | ||
| 72 | + for ((pid, matched) in MentionExtractor.match(t.text, dict)) { | ||
| 73 | + batch.add(Mention(segmentId = t.segmentId, personId = pid, matchedText = matched)) | ||
| 74 | + n++ | ||
| 75 | + } | ||
| 76 | + if (batch.size >= 500) { repo.mentions.insertAll(batch); batch.clear() } | ||
| 77 | + } | ||
| 78 | + if (batch.isNotEmpty()) repo.mentions.insertAll(batch) | ||
| 79 | + Log.i(TAG, "rescanAll: ${texts.size} transcripts × ${dict.size} people → $n mentions") | ||
| 80 | + n | ||
| 81 | + } | ||
| 82 | + | ||
| 83 | + /** 单段实时扫描(块处理时用,词典已预载好)。 */ | ||
| 84 | + suspend fun scanSegment(repo: Repository, segmentId: Long, text: String, dict: List<MentionExtractor.PersonAliases>) { | ||
| 85 | + val hits = MentionExtractor.match(text, dict) | ||
| 86 | + if (hits.isEmpty()) return | ||
| 87 | + repo.mentions.insertAll(hits.map { Mention(segmentId = segmentId, personId = it.first, matchedText = it.second) }) | ||
| 88 | + } | ||
| 89 | +} |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt
| @@ -21,8 +21,10 @@ import androidx.sqlite.db.SupportSQLiteDatabase | @@ -21,8 +21,10 @@ import androidx.sqlite.db.SupportSQLiteDatabase | ||
| 21 | Face::class, | 21 | Face::class, |
| 22 | WechatMessage::class, | 22 | WechatMessage::class, |
| 23 | Moment::class, | 23 | Moment::class, |
| 24 | + DeletedGallery::class, | ||
| 25 | + Mention::class, | ||
| 24 | ], | 26 | ], |
| 25 | - version = 14, | 27 | + version = 16, |
| 26 | exportSchema = false, | 28 | exportSchema = false, |
| 27 | ) | 29 | ) |
| 28 | abstract class AppDatabase : RoomDatabase() { | 30 | abstract class AppDatabase : RoomDatabase() { |
| @@ -36,6 +38,7 @@ abstract class AppDatabase : RoomDatabase() { | @@ -36,6 +38,7 @@ abstract class AppDatabase : RoomDatabase() { | ||
| 36 | abstract fun faceDao(): FaceDao | 38 | abstract fun faceDao(): FaceDao |
| 37 | abstract fun wechatMessageDao(): WechatMessageDao | 39 | abstract fun wechatMessageDao(): WechatMessageDao |
| 38 | abstract fun momentDao(): MomentDao | 40 | abstract fun momentDao(): MomentDao |
| 41 | + abstract fun mentionDao(): MentionDao | ||
| 39 | 42 | ||
| 40 | companion object { | 43 | companion object { |
| 41 | @Volatile | 44 | @Volatile |
| @@ -150,6 +153,24 @@ abstract class AppDatabase : RoomDatabase() { | @@ -150,6 +153,24 @@ abstract class AppDatabase : RoomDatabase() { | ||
| 150 | } | 153 | } |
| 151 | } | 154 | } |
| 152 | 155 | ||
| 156 | + /** v0.8.1 删除墓碑:记住被删的相册照片 MediaStore _ID,防止重扫又导入。 */ | ||
| 157 | + private val MIGRATION_14_15 = object : Migration(14, 15) { | ||
| 158 | + override fun migrate(db: SupportSQLiteDatabase) { | ||
| 159 | + db.execSQL("CREATE TABLE IF NOT EXISTS `deleted_gallery` (`mediaId` INTEGER NOT NULL, PRIMARY KEY(`mediaId`))") | ||
| 160 | + } | ||
| 161 | + } | ||
| 162 | + | ||
| 163 | + /** v0.9 端上 CRM #5:Transcript→Person「提及」关系表。 */ | ||
| 164 | + private val MIGRATION_15_16 = object : Migration(15, 16) { | ||
| 165 | + override fun migrate(db: SupportSQLiteDatabase) { | ||
| 166 | + db.execSQL( | ||
| 167 | + "CREATE TABLE IF NOT EXISTS `mentions` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `segmentId` INTEGER NOT NULL, `personId` INTEGER NOT NULL, `matchedText` TEXT NOT NULL, `createdAt` INTEGER NOT NULL)", | ||
| 168 | + ) | ||
| 169 | + db.execSQL("CREATE INDEX IF NOT EXISTS `index_mentions_personId` ON `mentions` (`personId`)") | ||
| 170 | + db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_mentions_segmentId_personId` ON `mentions` (`segmentId`, `personId`)") | ||
| 171 | + } | ||
| 172 | + } | ||
| 173 | + | ||
| 153 | fun get(ctx: Context): AppDatabase = | 174 | fun get(ctx: Context): AppDatabase = |
| 154 | instance ?: synchronized(this) { | 175 | instance ?: synchronized(this) { |
| 155 | instance ?: Room.databaseBuilder( | 176 | instance ?: Room.databaseBuilder( |
| @@ -157,7 +178,7 @@ abstract class AppDatabase : RoomDatabase() { | @@ -157,7 +178,7 @@ abstract class AppDatabase : RoomDatabase() { | ||
| 157 | AppDatabase::class.java, | 178 | AppDatabase::class.java, |
| 158 | "lifelog.db", | 179 | "lifelog.db", |
| 159 | ) | 180 | ) |
| 160 | - .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14) | 181 | + .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16) |
| 161 | // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, | 182 | // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, |
| 162 | // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 | 183 | // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 |
| 163 | .build() | 184 | .build() |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
| @@ -2,6 +2,7 @@ package com.lemon.lifelog.data | @@ -2,6 +2,7 @@ package com.lemon.lifelog.data | ||
| 2 | 2 | ||
| 3 | import androidx.room.Dao | 3 | import androidx.room.Dao |
| 4 | import androidx.room.Insert | 4 | import androidx.room.Insert |
| 5 | +import androidx.room.OnConflictStrategy | ||
| 5 | import androidx.room.Query | 6 | import androidx.room.Query |
| 6 | import androidx.room.Update | 7 | import androidx.room.Update |
| 7 | import kotlinx.coroutines.flow.Flow | 8 | import kotlinx.coroutines.flow.Flow |
| @@ -262,6 +263,55 @@ interface TranscriptDao { | @@ -262,6 +263,55 @@ interface TranscriptDao { | ||
| 262 | 263 | ||
| 263 | @Query("DELETE FROM transcripts WHERE segmentId = :id") | 264 | @Query("DELETE FROM transcripts WHERE segmentId = :id") |
| 264 | suspend fun deleteBySegment(id: Long) | 265 | suspend fun deleteBySegment(id: Long) |
| 266 | + | ||
| 267 | + /** 全部转写文本(提及反查 backfill 用)。 */ | ||
| 268 | + @Query("SELECT segmentId, text FROM transcripts") | ||
| 269 | + suspend fun allTexts(): List<TranscriptText> | ||
| 270 | +} | ||
| 271 | + | ||
| 272 | +data class TranscriptText(val segmentId: Long, val text: String) | ||
| 273 | + | ||
| 274 | +@Dao | ||
| 275 | +interface MentionDao { | ||
| 276 | + @Insert(onConflict = OnConflictStrategy.IGNORE) | ||
| 277 | + suspend fun insertAll(ms: List<Mention>) | ||
| 278 | + | ||
| 279 | + @Query("DELETE FROM mentions") | ||
| 280 | + suspend fun clearAll() | ||
| 281 | + | ||
| 282 | + @Query("DELETE FROM mentions WHERE segmentId = :sid") | ||
| 283 | + suspend fun deleteBySegment(sid: Long) | ||
| 284 | + | ||
| 285 | + @Query("DELETE FROM mentions WHERE personId = :pid") | ||
| 286 | + suspend fun deleteByPerson(pid: Long) | ||
| 287 | + | ||
| 288 | + @Query("SELECT COUNT(*) FROM mentions") | ||
| 289 | + suspend fun count(): Int | ||
| 290 | + | ||
| 291 | + /** 某人被提到的次数(档案页 tab 角标)。 */ | ||
| 292 | + @Query("SELECT COUNT(*) FROM mentions WHERE personId = :pid") | ||
| 293 | + fun observeCountForPerson(pid: Long): Flow<Int> | ||
| 294 | + | ||
| 295 | + /** 某人「被提到」的录音段(join 转写 + 段 + 说话人=谁说的)。 */ | ||
| 296 | + @Query( | ||
| 297 | + """ | ||
| 298 | + SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs, | ||
| 299 | + s.audioStartMs AS audioStartMs, | ||
| 300 | + t.text AS text, s.audioPath AS audioPath, | ||
| 301 | + s.speakerId AS speakerId, s.speakerSim AS speakerSim, | ||
| 302 | + sp.name AS speakerName, | ||
| 303 | + p.id AS personId, p.name AS personName | ||
| 304 | + FROM mentions m | ||
| 305 | + JOIN segments s ON s.id = m.segmentId | ||
| 306 | + LEFT JOIN transcripts t ON t.segmentId = s.id | ||
| 307 | + LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId | ||
| 308 | + LEFT JOIN persons p ON p.voiceSpeakerId = s.speakerId | ||
| 309 | + WHERE m.personId = :personId | ||
| 310 | + ORDER BY s.startTs DESC | ||
| 311 | + LIMIT :limit | ||
| 312 | + """, | ||
| 313 | + ) | ||
| 314 | + fun observeMentionsOf(personId: Long, limit: Int = 500): Flow<List<SegmentRow>> | ||
| 265 | } | 315 | } |
| 266 | 316 | ||
| 267 | @Dao | 317 | @Dao |
| @@ -425,6 +475,14 @@ interface PhotoDao { | @@ -425,6 +475,14 @@ interface PhotoDao { | ||
| 425 | @Query("SELECT mediaId FROM photos WHERE mediaId IS NOT NULL") | 475 | @Query("SELECT mediaId FROM photos WHERE mediaId IS NOT NULL") |
| 426 | suspend fun knownMediaIds(): List<Long> | 476 | suspend fun knownMediaIds(): List<Long> |
| 427 | 477 | ||
| 478 | + /** 删除墓碑里的 MediaStore _ID(删过的相册图,扫描时一并跳过,不再重导)。 */ | ||
| 479 | + @Query("SELECT mediaId FROM deleted_gallery") | ||
| 480 | + suspend fun deletedMediaIds(): List<Long> | ||
| 481 | + | ||
| 482 | + /** 删相册照片时记墓碑(INSERT OR IGNORE 防重复)。 */ | ||
| 483 | + @Query("INSERT OR IGNORE INTO deleted_gallery(mediaId) VALUES(:mediaId)") | ||
| 484 | + suspend fun tombstoneMedia(mediaId: Long) | ||
| 485 | + | ||
| 428 | /** 已登记的最大 MediaStore _ID(增量扫描可只看更大的,但默认仍全量去重)。 */ | 486 | /** 已登记的最大 MediaStore _ID(增量扫描可只看更大的,但默认仍全量去重)。 */ |
| 429 | @Query("SELECT MAX(mediaId) FROM photos WHERE source = 'gallery'") | 487 | @Query("SELECT MAX(mediaId) FROM photos WHERE source = 'gallery'") |
| 430 | suspend fun maxMediaId(): Long? | 488 | suspend fun maxMediaId(): Long? |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt
| @@ -153,6 +153,12 @@ data class Photo( | @@ -153,6 +153,12 @@ data class Photo( | ||
| 153 | val processedAt: Long? = null, | 153 | val processedAt: Long? = null, |
| 154 | ) | 154 | ) |
| 155 | 155 | ||
| 156 | +/** 删除墓碑:用户在 app 里删掉的相册照片,记下 MediaStore _ID,防止全相册扫描又把它重新导入。 */ | ||
| 157 | +@Entity(tableName = "deleted_gallery") | ||
| 158 | +data class DeletedGallery( | ||
| 159 | + @PrimaryKey val mediaId: Long, | ||
| 160 | +) | ||
| 161 | + | ||
| 156 | /** 朋友圈(从 SnsMicroMsg SnsInfo 表导入,userName=发布者 wxid,text=正文)。 */ | 162 | /** 朋友圈(从 SnsMicroMsg SnsInfo 表导入,userName=发布者 wxid,text=正文)。 */ |
| 157 | @Entity(tableName = "moments") | 163 | @Entity(tableName = "moments") |
| 158 | data class Moment( | 164 | data class Moment( |
| @@ -188,3 +194,20 @@ data class Face( | @@ -188,3 +194,20 @@ data class Face( | ||
| 188 | val personId: Long? = null, | 194 | val personId: Long? = null, |
| 189 | val personSim: Float? = null, | 195 | val personSim: Float? = null, |
| 190 | ) | 196 | ) |
| 197 | + | ||
| 198 | +/** | ||
| 199 | + * 「提及」关系:某段录音的转写文本里提到了某个 Person(词典反查命中其名字/别名)。 | ||
| 200 | + * 区别于声纹(=谁说的)——这是「谁被说到」。一个段可提及多人,(segmentId,personId) 唯一。 | ||
| 201 | + */ | ||
| 202 | +@Entity( | ||
| 203 | + tableName = "mentions", | ||
| 204 | + indices = [Index("personId"), Index(value = ["segmentId", "personId"], unique = true)], | ||
| 205 | +) | ||
| 206 | +data class Mention( | ||
| 207 | + @PrimaryKey(autoGenerate = true) val id: Long = 0, | ||
| 208 | + val segmentId: Long, | ||
| 209 | + val personId: Long, | ||
| 210 | + /** 命中的别名(名字/备注/昵称/微信号),用于展示与调试 */ | ||
| 211 | + val matchedText: String, | ||
| 212 | + val createdAt: Long = System.currentTimeMillis(), | ||
| 213 | +) |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt
| @@ -14,4 +14,5 @@ class Repository(ctx: Context) { | @@ -14,4 +14,5 @@ class Repository(ctx: Context) { | ||
| 14 | val faces = db.faceDao() | 14 | val faces = db.faceDao() |
| 15 | val wechatMessages = db.wechatMessageDao() | 15 | val wechatMessages = db.wechatMessageDao() |
| 16 | val moments = db.momentDao() | 16 | val moments = db.momentDao() |
| 17 | + val mentions = db.mentionDao() | ||
| 17 | } | 18 | } |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/BlockProcessor.kt
| @@ -7,6 +7,7 @@ import com.lemon.lifelog.asr.SpeakerClusterer | @@ -7,6 +7,7 @@ import com.lemon.lifelog.asr.SpeakerClusterer | ||
| 7 | import com.lemon.lifelog.asr.SpeakerEmbedder | 7 | import com.lemon.lifelog.asr.SpeakerEmbedder |
| 8 | import com.lemon.lifelog.asr.toLeBytes | 8 | import com.lemon.lifelog.asr.toLeBytes |
| 9 | import com.lemon.lifelog.audio.SegmentWriter | 9 | import com.lemon.lifelog.audio.SegmentWriter |
| 10 | +import com.lemon.lifelog.crm.MentionScanner | ||
| 10 | import com.lemon.lifelog.data.Repository | 11 | import com.lemon.lifelog.data.Repository |
| 11 | import com.lemon.lifelog.data.Segment | 12 | import com.lemon.lifelog.data.Segment |
| 12 | import com.lemon.lifelog.data.Transcript | 13 | import com.lemon.lifelog.data.Transcript |
| @@ -76,6 +77,11 @@ class BlockProcessor( | @@ -76,6 +77,11 @@ class BlockProcessor( | ||
| 76 | // 整块存「一个完整录音」.opus;各 turn 共享它,只记块内偏移(不再逐句切片)。 | 77 | // 整块存「一个完整录音」.opus;各 turn 共享它,只记块内偏移(不再逐句切片)。 |
| 77 | val blockPath = withContext(Dispatchers.IO) { segWriter.write(samples, loaded.startTs, sr) } | 78 | val blockPath = withContext(Dispatchers.IO) { segWriter.write(samples, loaded.startTs, sr) } |
| 78 | 79 | ||
| 80 | + // 提及反查词典(managed 人物别名)预载一次,本块各 turn 复用。 | ||
| 81 | + val mentionDict = withContext(Dispatchers.IO) { | ||
| 82 | + runCatching { MentionScanner.loadDictionary(repo) }.getOrDefault(emptyList()) | ||
| 83 | + } | ||
| 84 | + | ||
| 79 | var written = 0 | 85 | var written = 0 |
| 80 | for (t in turns) { | 86 | for (t in turns) { |
| 81 | val a = (t.startSec * sr).toInt().coerceIn(0, samples.size) | 87 | val a = (t.startSec * sr).toInt().coerceIn(0, samples.size) |
| @@ -111,7 +117,13 @@ class BlockProcessor( | @@ -111,7 +117,13 @@ class BlockProcessor( | ||
| 111 | speakerSim = assign.sim, | 117 | speakerSim = assign.sim, |
| 112 | ), | 118 | ), |
| 113 | ) | 119 | ) |
| 114 | - if (text.isNotBlank()) repo.transcripts.insert(Transcript(segmentId = id, text = text)) | 120 | + if (text.isNotBlank()) { |
| 121 | + repo.transcripts.insert(Transcript(segmentId = id, text = text)) | ||
| 122 | + // 提及反查:这段文字提到了哪些联系人 → mentions 表。 | ||
| 123 | + if (mentionDict.isNotEmpty()) { | ||
| 124 | + runCatching { MentionScanner.scanSegment(repo, id, text, mentionDict) } | ||
| 125 | + } | ||
| 126 | + } | ||
| 115 | } | 127 | } |
| 116 | written++ | 128 | written++ |
| 117 | } | 129 | } |