Commit eb6e13a4ea1758b4f21c4034b7790c77de4190a3

Authored by yibo
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>
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 }