Commit a86903de22d0c3d3ea490ae03a8cba43394d190f
1 parent
6ae8135b
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 | 95 | if (wxid == null) kotlinx.coroutines.flow.flowOf(emptyList()) |
| 96 | 96 | else vm.repo.moments.observeByUser(wxid) |
| 97 | 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 | 102 | var bindVoice by remember { mutableStateOf(false) } |
| 100 | 103 | var recordVoice by remember { mutableStateOf(false) } |
| ... | ... | @@ -103,13 +106,20 @@ internal fun PersonProfileScreen( |
| 103 | 106 | var playingId by remember { mutableStateOf<Long?>(null) } |
| 104 | 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 | 110 | var query by remember { mutableStateOf("") } |
| 108 | 111 | val q = query.trim() |
| 109 | 112 | val fPhotos = remember(photos, q) { if (q.isBlank()) photos else photos.filter { it.caption?.contains(q, true) == true } } |
| 110 | 113 | val fAudio = remember(audio, q) { if (q.isBlank()) audio else audio.filter { it.text?.contains(q, true) == true } } |
| 111 | 114 | val fMsgs = remember(messages, q) { if (q.isBlank()) messages else messages.filter { it.content.contains(q, true) } } |
| 112 | 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 | 124 | Column(modifier = Modifier.fillMaxSize()) { |
| 115 | 125 | TopAppBar( |
| ... | ... | @@ -138,6 +148,7 @@ internal fun PersonProfileScreen( |
| 138 | 148 | Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("语音 ${audio.size}") }) |
| 139 | 149 | Tab(selected = tab == 2, onClick = { tab = 2 }, text = { Text("聊天 ${messages.size}") }) |
| 140 | 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 | 153 | Box(modifier = Modifier.weight(1f).fillMaxWidth()) { |
| 143 | 154 | when (tab) { |
| ... | ... | @@ -157,23 +168,41 @@ internal fun PersonProfileScreen( |
| 157 | 168 | else if (fAudio.isEmpty()) EmptyHint(if (q.isBlank()) "这个声纹名下还没有录音段。" else "没有匹配的录音。") |
| 158 | 169 | else LazyColumn(Modifier.fillMaxSize()) { |
| 159 | 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 | 175 | 2 -> if (wxid == null) EmptyHint("非微信联系人。") |
| 172 | 176 | else if (fMsgs.isEmpty()) EmptyHint(if (q.isBlank()) "这台设备上没有跟 TA 的聊天记录。" else "没有匹配的聊天。") |
| 173 | 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 | 179 | else if (fMoments.isEmpty()) EmptyHint(if (q.isBlank()) "没有 TA 的朋友圈记录。" else "没有匹配的朋友圈。") |
| 176 | 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 | 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 | 168 | fun clearFace(faceId: Long) { |
| 157 | 169 | viewModelScope.launch { repo.faces.assignPerson(faceId, null, null) } |
| 158 | 170 | } |
| ... | ... | @@ -428,6 +440,11 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { |
| 428 | 440 | fun deletePhoto(photoId: Long) { |
| 429 | 441 | viewModelScope.launch { |
| 430 | 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 | 448 | repo.faces.deleteByPhoto(photoId) |
| 432 | 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 | 21 | Face::class, |
| 22 | 22 | WechatMessage::class, |
| 23 | 23 | Moment::class, |
| 24 | + DeletedGallery::class, | |
| 25 | + Mention::class, | |
| 24 | 26 | ], |
| 25 | - version = 14, | |
| 27 | + version = 16, | |
| 26 | 28 | exportSchema = false, |
| 27 | 29 | ) |
| 28 | 30 | abstract class AppDatabase : RoomDatabase() { |
| ... | ... | @@ -36,6 +38,7 @@ abstract class AppDatabase : RoomDatabase() { |
| 36 | 38 | abstract fun faceDao(): FaceDao |
| 37 | 39 | abstract fun wechatMessageDao(): WechatMessageDao |
| 38 | 40 | abstract fun momentDao(): MomentDao |
| 41 | + abstract fun mentionDao(): MentionDao | |
| 39 | 42 | |
| 40 | 43 | companion object { |
| 41 | 44 | @Volatile |
| ... | ... | @@ -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 | 174 | fun get(ctx: Context): AppDatabase = |
| 154 | 175 | instance ?: synchronized(this) { |
| 155 | 176 | instance ?: Room.databaseBuilder( |
| ... | ... | @@ -157,7 +178,7 @@ abstract class AppDatabase : RoomDatabase() { |
| 157 | 178 | AppDatabase::class.java, |
| 158 | 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 | 182 | // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, |
| 162 | 183 | // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 |
| 163 | 184 | .build() | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
| ... | ... | @@ -2,6 +2,7 @@ package com.lemon.lifelog.data |
| 2 | 2 | |
| 3 | 3 | import androidx.room.Dao |
| 4 | 4 | import androidx.room.Insert |
| 5 | +import androidx.room.OnConflictStrategy | |
| 5 | 6 | import androidx.room.Query |
| 6 | 7 | import androidx.room.Update |
| 7 | 8 | import kotlinx.coroutines.flow.Flow |
| ... | ... | @@ -262,6 +263,55 @@ interface TranscriptDao { |
| 262 | 263 | |
| 263 | 264 | @Query("DELETE FROM transcripts WHERE segmentId = :id") |
| 264 | 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 | 317 | @Dao |
| ... | ... | @@ -425,6 +475,14 @@ interface PhotoDao { |
| 425 | 475 | @Query("SELECT mediaId FROM photos WHERE mediaId IS NOT NULL") |
| 426 | 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 | 486 | /** 已登记的最大 MediaStore _ID(增量扫描可只看更大的,但默认仍全量去重)。 */ |
| 429 | 487 | @Query("SELECT MAX(mediaId) FROM photos WHERE source = 'gallery'") |
| 430 | 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 | 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 | 162 | /** 朋友圈(从 SnsMicroMsg SnsInfo 表导入,userName=发布者 wxid,text=正文)。 */ |
| 157 | 163 | @Entity(tableName = "moments") |
| 158 | 164 | data class Moment( |
| ... | ... | @@ -188,3 +194,20 @@ data class Face( |
| 188 | 194 | val personId: Long? = null, |
| 189 | 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
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/BlockProcessor.kt
| ... | ... | @@ -7,6 +7,7 @@ import com.lemon.lifelog.asr.SpeakerClusterer |
| 7 | 7 | import com.lemon.lifelog.asr.SpeakerEmbedder |
| 8 | 8 | import com.lemon.lifelog.asr.toLeBytes |
| 9 | 9 | import com.lemon.lifelog.audio.SegmentWriter |
| 10 | +import com.lemon.lifelog.crm.MentionScanner | |
| 10 | 11 | import com.lemon.lifelog.data.Repository |
| 11 | 12 | import com.lemon.lifelog.data.Segment |
| 12 | 13 | import com.lemon.lifelog.data.Transcript |
| ... | ... | @@ -76,6 +77,11 @@ class BlockProcessor( |
| 76 | 77 | // 整块存「一个完整录音」.opus;各 turn 共享它,只记块内偏移(不再逐句切片)。 |
| 77 | 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 | 85 | var written = 0 |
| 80 | 86 | for (t in turns) { |
| 81 | 87 | val a = (t.startSec * sr).toInt().coerceIn(0, samples.size) |
| ... | ... | @@ -111,7 +117,13 @@ class BlockProcessor( |
| 111 | 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 | 128 | written++ |
| 117 | 129 | } | ... | ... |