From eb6e13a4ea1758b4f21c4034b7790c77de4190a3 Mon Sep 17 00:00:00 2001 From: yibo Date: Tue, 9 Jun 2026 16:37:09 +0800 Subject: [PATCH] feat(lifelog/crm): P0 #5 Transcript→Person 提及关系(词典反查) --- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt | 51 ++++++++++++++++++++++++++++++++++++++++----------- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt | 17 +++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/MentionExtractor.kt | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt | 25 +++++++++++++++++++++++-- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt | 58 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt | 23 +++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt | 1 + lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/BlockProcessor.kt | 14 +++++++++++++- 8 files changed, 264 insertions(+), 14 deletions(-) create mode 100644 lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/MentionExtractor.kt diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt index f392886..bef73d9 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt @@ -95,6 +95,9 @@ internal fun PersonProfileScreen( if (wxid == null) kotlinx.coroutines.flow.flowOf(emptyList()) else vm.repo.moments.observeByUser(wxid) }.collectAsState(initial = emptyList()) + val mentioned by remember(personId) { vm.repo.mentions.observeMentionsOf(personId) } + .collectAsState(initial = emptyList()) + val mentionScanning by vm.mentionScanning.collectAsState() var bindVoice by remember { mutableStateOf(false) } var recordVoice by remember { mutableStateOf(false) } @@ -103,13 +106,20 @@ internal fun PersonProfileScreen( var playingId by remember { mutableStateOf(null) } DisposableEffect(player) { onDispose { player.stop() } } - var tab by remember { mutableStateOf(0) } // 0照片 1语音 2聊天 3朋友圈 + var tab by remember { mutableStateOf(0) } // 0照片 1语音 2聊天 3朋友圈 4被提到 var query by remember { mutableStateOf("") } val q = query.trim() val fPhotos = remember(photos, q) { if (q.isBlank()) photos else photos.filter { it.caption?.contains(q, true) == true } } val fAudio = remember(audio, q) { if (q.isBlank()) audio else audio.filter { it.text?.contains(q, true) == true } } val fMsgs = remember(messages, q) { if (q.isBlank()) messages else messages.filter { it.content.contains(q, true) } } val fMoments = remember(moments, q) { if (q.isBlank()) moments else moments.filter { it.text.contains(q, true) } } + val fMentioned = remember(mentioned, q) { if (q.isBlank()) mentioned else mentioned.filter { it.text?.contains(q, true) == true } } + + // 列表里点播某段(块完整录音按 audioStartMs+durationMs 切片)。 + fun togglePlay(row: com.lemon.lifelog.data.SegmentRow) { + if (playingId == row.id) { player.stop(); playingId = null } + else { playingId = row.id; player.play(row.audioPath, row.audioStartMs, row.durationMs) { playingId = null } } + } Column(modifier = Modifier.fillMaxSize()) { TopAppBar( @@ -138,6 +148,7 @@ internal fun PersonProfileScreen( Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("语音 ${audio.size}") }) Tab(selected = tab == 2, onClick = { tab = 2 }, text = { Text("聊天 ${messages.size}") }) Tab(selected = tab == 3, onClick = { tab = 3 }, text = { Text("朋友圈 ${moments.size}") }) + Tab(selected = tab == 4, onClick = { tab = 4 }, text = { Text("被提到 ${mentioned.size}") }) } Box(modifier = Modifier.weight(1f).fillMaxWidth()) { when (tab) { @@ -157,23 +168,41 @@ internal fun PersonProfileScreen( else if (fAudio.isEmpty()) EmptyHint(if (q.isBlank()) "这个声纹名下还没有录音段。" else "没有匹配的录音。") else LazyColumn(Modifier.fillMaxSize()) { items(fAudio, key = { it.id }) { row -> - AudioRow(row, playing = playingId == row.id) { - if (playingId == row.id) { player.stop(); playingId = null } - else { - playingId = row.id - // 块级管线:turn 是块完整录音里的一段,必须按 audioStartMs+durationMs 切片, - // 否则从头播整块、跟这行的文字对不上。 - player.play(row.audioPath, row.audioStartMs, row.durationMs) { playingId = null } - } - } + // 块级管线:turn 是块完整录音里的一段,按 audioStartMs+durationMs 切片才对得上文字。 + AudioRow(row, playing = playingId == row.id) { togglePlay(row) } } } 2 -> if (wxid == null) EmptyHint("非微信联系人。") else if (fMsgs.isEmpty()) EmptyHint(if (q.isBlank()) "这台设备上没有跟 TA 的聊天记录。" else "没有匹配的聊天。") else LazyColumn(Modifier.fillMaxSize()) { items(fMsgs, key = { it.id }) { m -> ChatBubble(m) } } - else -> if (wxid == null) EmptyHint("非微信联系人。") + 3 -> if (wxid == null) EmptyHint("非微信联系人。") else if (fMoments.isEmpty()) EmptyHint(if (q.isBlank()) "没有 TA 的朋友圈记录。" else "没有匹配的朋友圈。") else LazyColumn(Modifier.fillMaxSize()) { items(fMoments, key = { it.id }) { mo -> MomentRow(mo) } } + // 被提到:别人录音里提到 TA 的段(按声纹=谁说的 展示)。 + else -> Column(Modifier.fillMaxSize()) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("录音里提到 TA 的段", style = MaterialTheme.typography.labelMedium) + TextButton(onClick = { vm.rescanMentions() }, enabled = !mentionScanning) { + Text(if (mentionScanning) "扫描中…" else "重新扫描") + } + } + if (fMentioned.isEmpty()) { + EmptyHint( + if (q.isBlank()) "还没扫到提及。点「重新扫描」对历史录音反查;新录音会自动入库。\n(需 TA 在「我管理的人物」里,且名字/备注/昵称能在转写文本中被认出)" + else "没有匹配的提及。", + ) + } else { + LazyColumn(Modifier.fillMaxSize()) { + items(fMentioned, key = { it.id }) { row -> + AudioRow(row, playing = playingId == row.id) { togglePlay(row) } + } + } + } + } } } } diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt index 64f72c8..d4b6fd4 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt @@ -153,6 +153,18 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { viewModelScope.launch { repo.persons.setManaged(personId, managed) } } + /** 提及反查:全量重扫所有转写,按当前 managed 人物词典重建 mentions(档案「被提到」tab 用)。 */ + private val _mentionScanning = MutableStateFlow(false) + val mentionScanning: StateFlow = _mentionScanning + fun rescanMentions() { + if (_mentionScanning.value) return + _mentionScanning.value = true + viewModelScope.launch { + runCatching { com.lemon.lifelog.crm.MentionScanner.rescanAll(repo) } + _mentionScanning.value = false + } + } + fun clearFace(faceId: Long) { viewModelScope.launch { repo.faces.assignPerson(faceId, null, null) } } @@ -428,6 +440,11 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { fun deletePhoto(photoId: Long) { viewModelScope.launch { withContext(Dispatchers.IO) { + val p = repo.photos.get(photoId) + // 相册源:记墓碑,否则下次全相册扫描会把它当成「新图」重新导入。 + if (p?.source == "gallery") p.mediaId?.let { repo.photos.tombstoneMedia(it) } + // 导入源:删私有目录里的副本文件回收空间(相册源是原地引用,不能删用户原图)。 + else if (p != null) runCatching { File(p.path).takeIf { it.exists() }?.delete() } repo.faces.deleteByPhoto(photoId) repo.photos.delete(photoId) } diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/MentionExtractor.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/MentionExtractor.kt new file mode 100644 index 0000000..e455f71 --- /dev/null +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/MentionExtractor.kt @@ -0,0 +1,89 @@ +package com.lemon.lifelog.crm + +import android.util.Log +import com.lemon.lifelog.data.Mention +import com.lemon.lifelog.data.Person +import com.lemon.lifelog.data.Repository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * 端上 CRM #5:从转写文本里**词典反查**出提到了哪些已知联系人(不依赖 LLM)。 + * + * 思路:只认「已在通讯录里管理的人」(managed=true)——拿他们的名字/备注/昵称/微信号当词典, + * 去 `Transcript.text` 里扫子串命中。个人库行数少、名字是已知集合,子串反查比通用 NER 更快更准 + * (也零幻觉)。命中写 `mentions(segmentId, personId, matchedText)`。 + * + * 局限(已知,后续可叠 LLM):只发现通讯录内的人;重名会同时命中多个人;< [MIN_ALIAS_LEN] 的别名忽略 + * (单字名会匹配到一切)。 + */ +object MentionExtractor { + private const val TAG = "MentionExtractor" + private const val MIN_ALIAS_LEN = 2 + + /** 一个人的可匹配别名集合(去重、过滤过短)。 */ + data class PersonAliases(val personId: Long, val aliases: List) + + fun aliasesOf(p: Person): PersonAliases { + val set = LinkedHashSet() + for (a in listOf(p.name, p.realName, p.nickname, p.remark, p.alias)) { + val s = a?.trim() ?: continue + if (s.length >= MIN_ALIAS_LEN) set.add(s) + } + return PersonAliases(p.id, set.toList()) + } + + /** 在 [text] 里反查每个人的别名;每人最多记一次(命中的第一个别名)。返回 (personId, matchedText)。 */ + fun match(text: String, people: List): List> { + if (text.isBlank() || people.isEmpty()) return emptyList() + val hits = ArrayList>() + for (pa in people) { + for (alias in pa.aliases) { + if (text.contains(alias)) { + hits.add(pa.personId to alias) + break + } + } + } + return hits + } +} + +/** 提及反查的批量/单段执行入口。 */ +object MentionScanner { + private const val TAG = "MentionScanner" + + /** 取当前「我管理的人物」的别名词典(有别名的才纳入)。 */ + suspend fun loadDictionary(repo: Repository): List = + repo.persons.all() + .filter { it.managed } + .map { MentionExtractor.aliasesOf(it) } + .filter { it.aliases.isNotEmpty() } + + /** 全量重扫:清空 mentions,对所有转写按当前词典重建。返回命中条数。 */ + suspend fun rescanAll(repo: Repository): Int = withContext(Dispatchers.IO) { + val dict = loadDictionary(repo) + repo.mentions.clearAll() + if (dict.isEmpty()) return@withContext 0 + val texts = repo.transcripts.allTexts() + val batch = ArrayList() + var n = 0 + for (t in texts) { + for ((pid, matched) in MentionExtractor.match(t.text, dict)) { + batch.add(Mention(segmentId = t.segmentId, personId = pid, matchedText = matched)) + n++ + } + if (batch.size >= 500) { repo.mentions.insertAll(batch); batch.clear() } + } + if (batch.isNotEmpty()) repo.mentions.insertAll(batch) + Log.i(TAG, "rescanAll: ${texts.size} transcripts × ${dict.size} people → $n mentions") + n + } + + /** 单段实时扫描(块处理时用,词典已预载好)。 */ + suspend fun scanSegment(repo: Repository, segmentId: Long, text: String, dict: List) { + val hits = MentionExtractor.match(text, dict) + if (hits.isEmpty()) return + repo.mentions.insertAll(hits.map { Mention(segmentId = segmentId, personId = it.first, matchedText = it.second) }) + } +} diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt index cc200b3..02ec4f6 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt @@ -21,8 +21,10 @@ import androidx.sqlite.db.SupportSQLiteDatabase Face::class, WechatMessage::class, Moment::class, + DeletedGallery::class, + Mention::class, ], - version = 14, + version = 16, exportSchema = false, ) abstract class AppDatabase : RoomDatabase() { @@ -36,6 +38,7 @@ abstract class AppDatabase : RoomDatabase() { abstract fun faceDao(): FaceDao abstract fun wechatMessageDao(): WechatMessageDao abstract fun momentDao(): MomentDao + abstract fun mentionDao(): MentionDao companion object { @Volatile @@ -150,6 +153,24 @@ abstract class AppDatabase : RoomDatabase() { } } + /** v0.8.1 删除墓碑:记住被删的相册照片 MediaStore _ID,防止重扫又导入。 */ + private val MIGRATION_14_15 = object : Migration(14, 15) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("CREATE TABLE IF NOT EXISTS `deleted_gallery` (`mediaId` INTEGER NOT NULL, PRIMARY KEY(`mediaId`))") + } + } + + /** v0.9 端上 CRM #5:Transcript→Person「提及」关系表。 */ + private val MIGRATION_15_16 = object : Migration(15, 16) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "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)", + ) + db.execSQL("CREATE INDEX IF NOT EXISTS `index_mentions_personId` ON `mentions` (`personId`)") + db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_mentions_segmentId_personId` ON `mentions` (`segmentId`, `personId`)") + } + } + fun get(ctx: Context): AppDatabase = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( @@ -157,7 +178,7 @@ abstract class AppDatabase : RoomDatabase() { AppDatabase::class.java, "lifelog.db", ) - .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) + .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) // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 .build() diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt index 2483a56..f7b89c0 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt @@ -2,6 +2,7 @@ package com.lemon.lifelog.data import androidx.room.Dao import androidx.room.Insert +import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update import kotlinx.coroutines.flow.Flow @@ -262,6 +263,55 @@ interface TranscriptDao { @Query("DELETE FROM transcripts WHERE segmentId = :id") suspend fun deleteBySegment(id: Long) + + /** 全部转写文本(提及反查 backfill 用)。 */ + @Query("SELECT segmentId, text FROM transcripts") + suspend fun allTexts(): List +} + +data class TranscriptText(val segmentId: Long, val text: String) + +@Dao +interface MentionDao { + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertAll(ms: List) + + @Query("DELETE FROM mentions") + suspend fun clearAll() + + @Query("DELETE FROM mentions WHERE segmentId = :sid") + suspend fun deleteBySegment(sid: Long) + + @Query("DELETE FROM mentions WHERE personId = :pid") + suspend fun deleteByPerson(pid: Long) + + @Query("SELECT COUNT(*) FROM mentions") + suspend fun count(): Int + + /** 某人被提到的次数(档案页 tab 角标)。 */ + @Query("SELECT COUNT(*) FROM mentions WHERE personId = :pid") + fun observeCountForPerson(pid: Long): Flow + + /** 某人「被提到」的录音段(join 转写 + 段 + 说话人=谁说的)。 */ + @Query( + """ + SELECT s.id AS id, s.startTs AS startTs, s.durationMs AS durationMs, + s.audioStartMs AS audioStartMs, + t.text AS text, s.audioPath AS audioPath, + s.speakerId AS speakerId, s.speakerSim AS speakerSim, + sp.name AS speakerName, + p.id AS personId, p.name AS personName + FROM mentions m + JOIN segments s ON s.id = m.segmentId + LEFT JOIN transcripts t ON t.segmentId = s.id + LEFT JOIN speaker_profiles sp ON sp.id = s.speakerId + LEFT JOIN persons p ON p.voiceSpeakerId = s.speakerId + WHERE m.personId = :personId + ORDER BY s.startTs DESC + LIMIT :limit + """, + ) + fun observeMentionsOf(personId: Long, limit: Int = 500): Flow> } @Dao @@ -425,6 +475,14 @@ interface PhotoDao { @Query("SELECT mediaId FROM photos WHERE mediaId IS NOT NULL") suspend fun knownMediaIds(): List + /** 删除墓碑里的 MediaStore _ID(删过的相册图,扫描时一并跳过,不再重导)。 */ + @Query("SELECT mediaId FROM deleted_gallery") + suspend fun deletedMediaIds(): List + + /** 删相册照片时记墓碑(INSERT OR IGNORE 防重复)。 */ + @Query("INSERT OR IGNORE INTO deleted_gallery(mediaId) VALUES(:mediaId)") + suspend fun tombstoneMedia(mediaId: Long) + /** 已登记的最大 MediaStore _ID(增量扫描可只看更大的,但默认仍全量去重)。 */ @Query("SELECT MAX(mediaId) FROM photos WHERE source = 'gallery'") suspend fun maxMediaId(): Long? diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt index 9dbf7e1..302f52f 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt @@ -153,6 +153,12 @@ data class Photo( val processedAt: Long? = null, ) +/** 删除墓碑:用户在 app 里删掉的相册照片,记下 MediaStore _ID,防止全相册扫描又把它重新导入。 */ +@Entity(tableName = "deleted_gallery") +data class DeletedGallery( + @PrimaryKey val mediaId: Long, +) + /** 朋友圈(从 SnsMicroMsg SnsInfo 表导入,userName=发布者 wxid,text=正文)。 */ @Entity(tableName = "moments") data class Moment( @@ -188,3 +194,20 @@ data class Face( val personId: Long? = null, val personSim: Float? = null, ) + +/** + * 「提及」关系:某段录音的转写文本里提到了某个 Person(词典反查命中其名字/别名)。 + * 区别于声纹(=谁说的)——这是「谁被说到」。一个段可提及多人,(segmentId,personId) 唯一。 + */ +@Entity( + tableName = "mentions", + indices = [Index("personId"), Index(value = ["segmentId", "personId"], unique = true)], +) +data class Mention( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val segmentId: Long, + val personId: Long, + /** 命中的别名(名字/备注/昵称/微信号),用于展示与调试 */ + val matchedText: String, + val createdAt: Long = System.currentTimeMillis(), +) diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt index 9c8abfa..f7234ff 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt @@ -14,4 +14,5 @@ class Repository(ctx: Context) { val faces = db.faceDao() val wechatMessages = db.wechatMessageDao() val moments = db.momentDao() + val mentions = db.mentionDao() } diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/BlockProcessor.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/BlockProcessor.kt index f29eaca..2656811 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/BlockProcessor.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/service/BlockProcessor.kt @@ -7,6 +7,7 @@ import com.lemon.lifelog.asr.SpeakerClusterer import com.lemon.lifelog.asr.SpeakerEmbedder import com.lemon.lifelog.asr.toLeBytes import com.lemon.lifelog.audio.SegmentWriter +import com.lemon.lifelog.crm.MentionScanner import com.lemon.lifelog.data.Repository import com.lemon.lifelog.data.Segment import com.lemon.lifelog.data.Transcript @@ -76,6 +77,11 @@ class BlockProcessor( // 整块存「一个完整录音」.opus;各 turn 共享它,只记块内偏移(不再逐句切片)。 val blockPath = withContext(Dispatchers.IO) { segWriter.write(samples, loaded.startTs, sr) } + // 提及反查词典(managed 人物别名)预载一次,本块各 turn 复用。 + val mentionDict = withContext(Dispatchers.IO) { + runCatching { MentionScanner.loadDictionary(repo) }.getOrDefault(emptyList()) + } + var written = 0 for (t in turns) { val a = (t.startSec * sr).toInt().coerceIn(0, samples.size) @@ -111,7 +117,13 @@ class BlockProcessor( speakerSim = assign.sim, ), ) - if (text.isNotBlank()) repo.transcripts.insert(Transcript(segmentId = id, text = text)) + if (text.isNotBlank()) { + repo.transcripts.insert(Transcript(segmentId = id, text = text)) + // 提及反查:这段文字提到了哪些联系人 → mentions 表。 + if (mentionDict.isNotEmpty()) { + runCatching { MentionScanner.scanSegment(repo, id, text, mentionDict) } + } + } } written++ } -- libgit2 0.21.4