From c6699b266d074a0966814c604a31fae091deb951 Mon Sep 17 00:00:00 2001 From: yibo Date: Tue, 9 Jun 2026 16:42:47 +0800 Subject: [PATCH] feat(lifelog/crm): P1 #1 人↔人共现关系(会话内在场/被提到) --- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt | 41 +++++++++++++++++++++++++++++++++++++++-- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt | 1 + lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt | 6 +++++- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt | 18 ++++++++++++++++-- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt | 46 ++++++++++++++++++++++++++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt | 16 ++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt | 1 + 7 files changed, 124 insertions(+), 5 deletions(-) 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 bef73d9..b7bd10e 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 @@ -38,6 +38,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton import androidx.compose.material3.Surface import androidx.compose.material3.Tab +import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.TabRow import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -60,6 +61,7 @@ import coil.compose.AsyncImage import com.lemon.lifelog.audio.SegmentPlayer import com.lemon.lifelog.data.Moment import com.lemon.lifelog.data.Person +import com.lemon.lifelog.data.RelatedPerson import com.lemon.lifelog.data.SpeakerProfile import com.lemon.lifelog.data.WechatMessage import java.io.File @@ -77,6 +79,7 @@ internal fun PersonProfileScreen( onBack: () -> Unit, onOpenPhoto: (Long) -> Unit, onRequestMerge: () -> Unit, + onOpenPerson: (Long) -> Unit, ) { val person by vm.repo.persons.observe(personId).collectAsState(initial = null) val photos by vm.repo.photos.observePhotosOfPerson(personId).collectAsState(initial = emptyList()) @@ -97,6 +100,8 @@ internal fun PersonProfileScreen( }.collectAsState(initial = emptyList()) val mentioned by remember(personId) { vm.repo.mentions.observeMentionsOf(personId) } .collectAsState(initial = emptyList()) + val related by remember(personId) { vm.repo.relations.observeRelatedTo(personId) } + .collectAsState(initial = emptyList()) val mentionScanning by vm.mentionScanning.collectAsState() var bindVoice by remember { mutableStateOf(false) } @@ -143,12 +148,13 @@ internal fun PersonProfileScreen( placeholder = { Text("在 TA 的记录里搜索…") }, modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), ) - TabRow(selectedTabIndex = tab) { + ScrollableTabRow(selectedTabIndex = tab, edgePadding = 0.dp) { Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("照片 ${photos.size}") }) 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}") }) + Tab(selected = tab == 5, onClick = { tab = 5 }, text = { Text("相关 ${related.size}") }) } Box(modifier = Modifier.weight(1f).fillMaxWidth()) { when (tab) { @@ -179,7 +185,7 @@ internal fun PersonProfileScreen( 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()) { + 4 -> Column(Modifier.fillMaxSize()) { Row( Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), horizontalArrangement = Arrangement.SpaceBetween, @@ -203,6 +209,14 @@ internal fun PersonProfileScreen( } } } + // 相关:同会话共现(在场/被提到)的人,按共现次数排。 + else -> if (related.isEmpty()) EmptyHint( + "还没有相关的人。共现关系来自录音会话:同一段时间里同时出现/被提到的人会连起来。\n到「被提到」tab 点「重新扫描」可重建。", + ) else LazyColumn(Modifier.fillMaxSize()) { + items(related, key = { it.id }) { rp -> + RelatedRow(rp) { onOpenPerson(rp.id) } + } + } } } } @@ -298,6 +312,7 @@ private fun ProfileHero( onEnrich: () -> Unit, onEditProfile: () -> Unit, onMerge: () -> Unit, + onDownload: () -> Unit, ) { val avatarSize = 84.dp Column(modifier = Modifier.fillMaxWidth()) { @@ -373,6 +388,9 @@ private fun ProfileHero( TextButton(onClick = onRecordVoice, contentPadding = pad) { Text("🎤 录一段", style = MaterialTheme.typography.labelMedium) } TextButton(onClick = onBind, contentPadding = pad) { Text(if (p.voiceSpeakerId == null) "选声纹" else "改绑", style = MaterialTheme.typography.labelMedium) } TextButton(onClick = onMerge, contentPadding = pad) { Text("🔗 合并", style = MaterialTheme.typography.labelMedium) } + if (p.contactRef?.startsWith("wechat:") == true) { + TextButton(onClick = onDownload, contentPadding = pad) { Text("⬇ 下载图片", style = MaterialTheme.typography.labelMedium) } + } TextButton(onClick = onEnrich, contentPadding = pad) { Text(if (p.impression == null) "🧠 AI印象" else "🧠 重生成", style = MaterialTheme.typography.labelMedium) } } @@ -471,6 +489,25 @@ private fun AudioRow(row: com.lemon.lifelog.data.SegmentRow, playing: Boolean, o } @Composable +private fun RelatedRow(rp: RelatedPerson, onClick: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 16.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("👤", style = MaterialTheme.typography.titleMedium) + Column(modifier = Modifier.weight(1f)) { + Text(rp.name, style = MaterialTheme.typography.bodyLarge) + Text( + "共现 ${rp.weight} 次 · 最近 ${PROFILE_FMT.format(Date(rp.lastTs))}", + style = MaterialTheme.typography.labelSmall, + ) + } + Text("›", style = MaterialTheme.typography.titleMedium) + } +} + +@Composable private fun MomentRow(m: Moment) { Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) { Text(PROFILE_FMT.format(Date(m.createTime)), style = MaterialTheme.typography.labelSmall) diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt index 93f6fe6..de835a5 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt @@ -111,6 +111,7 @@ internal fun ContactsTab(repo: Repository) { onBack = { profilePersonId = null }, onOpenPhoto = { detailPhotoId = it }, onRequestMerge = { mergeForId = pid }, + onOpenPerson = { vm.touchPerson(it); profilePersonId = it }, ) showPhonePicker -> PhoneContactPickerScreen(vm = vm, onBack = { showPhonePicker = false }) showPicker -> WeChatPickerScreen( 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 d4b6fd4..18661b7 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 @@ -160,7 +160,11 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { if (_mentionScanning.value) return _mentionScanning.value = true viewModelScope.launch { - runCatching { com.lemon.lifelog.crm.MentionScanner.rescanAll(repo) } + runCatching { + com.lemon.lifelog.crm.MentionScanner.rescanAll(repo) + // 提及变了 → 重建人↔人共现边(依赖 mentions + 声纹在场)。 + com.lemon.lifelog.crm.RelationBuilder.rebuild(repo) + } _mentionScanning.value = false } } 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 02ec4f6..5268126 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 @@ -23,8 +23,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase Moment::class, DeletedGallery::class, Mention::class, + Relation::class, ], - version = 16, + version = 17, exportSchema = false, ) abstract class AppDatabase : RoomDatabase() { @@ -39,6 +40,7 @@ abstract class AppDatabase : RoomDatabase() { abstract fun wechatMessageDao(): WechatMessageDao abstract fun momentDao(): MomentDao abstract fun mentionDao(): MentionDao + abstract fun relationDao(): RelationDao companion object { @Volatile @@ -171,6 +173,18 @@ abstract class AppDatabase : RoomDatabase() { } } + /** v0.9 端上 CRM #1:人↔人共现边表。 */ + private val MIGRATION_16_17 = object : Migration(16, 17) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE IF NOT EXISTS `relations` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `personA` INTEGER NOT NULL, `personB` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `lastTs` INTEGER NOT NULL)", + ) + db.execSQL("CREATE INDEX IF NOT EXISTS `index_relations_personA` ON `relations` (`personA`)") + db.execSQL("CREATE INDEX IF NOT EXISTS `index_relations_personB` ON `relations` (`personB`)") + db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_relations_personA_personB` ON `relations` (`personA`, `personB`)") + } + } + fun get(ctx: Context): AppDatabase = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( @@ -178,7 +192,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, MIGRATION_14_15, MIGRATION_15_16) + .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, MIGRATION_16_17) // 不再 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 f7b89c0..b94743e 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 @@ -96,6 +96,10 @@ interface SegmentDao { @Query("SELECT * FROM segments WHERE embedding IS NOT NULL") suspend fun allWithEmbedding(): List + /** 段元信息(按时间升序,共现重建用)。 */ + @Query("SELECT id, startTs, speakerId FROM segments ORDER BY startTs ASC") + suspend fun allMeta(): List + /** 每个说话人当前实际归属的段数(用于声纹列表展示)。 */ @Query("SELECT speakerId AS speakerId, COUNT(*) AS count FROM segments WHERE speakerId IS NOT NULL GROUP BY speakerId") fun observeSpeakerCounts(): Flow> @@ -292,6 +296,10 @@ interface MentionDao { @Query("SELECT COUNT(*) FROM mentions WHERE personId = :pid") fun observeCountForPerson(pid: Long): Flow + /** 所有提及对(personId 反查 + 共现重建用)。 */ + @Query("SELECT segmentId, personId FROM mentions") + suspend fun allPairs(): List + /** 某人「被提到」的录音段(join 转写 + 段 + 说话人=谁说的)。 */ @Query( """ @@ -314,6 +322,44 @@ interface MentionDao { fun observeMentionsOf(personId: Long, limit: Int = 500): Flow> } +data class MentionPair(val segmentId: Long, val personId: Long) + +/** 段元信息(共现重建:按时间切会话 + 声纹在场)。 */ +data class SegMeta(val id: Long, val startTs: Long, val speakerId: Long?) + +/** 「相关的人」投影(档案「相关」tab)。 */ +data class RelatedPerson( + val id: Long, + val name: String, + val avatarPath: String?, + val weight: Int, + val lastTs: Long, +) + +@Dao +interface RelationDao { + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertAll(rs: List) + + @Query("DELETE FROM relations") + suspend fun clearAll() + + @Query("SELECT COUNT(*) FROM relations WHERE personA = :pid OR personB = :pid") + fun observeCountForPerson(pid: Long): Flow + + /** 与某人有共现边的人(取边的另一端),按权重降序。 */ + @Query( + """ + SELECT p.id AS id, p.name AS name, p.avatarPath AS avatarPath, r.weight AS weight, r.lastTs AS lastTs + FROM relations r + JOIN persons p ON p.id = (CASE WHEN r.personA = :pid THEN r.personB ELSE r.personA END) + WHERE r.personA = :pid OR r.personB = :pid + ORDER BY r.weight DESC, r.lastTs DESC + """, + ) + fun observeRelatedTo(pid: Long): Flow> +} + @Dao interface SummaryDao { @Insert 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 302f52f..7e071f0 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 @@ -211,3 +211,19 @@ data class Mention( val matchedText: String, val createdAt: Long = System.currentTimeMillis(), ) + +/** + * 人↔人「共现」无向边:同一会话(5min 静音切)里同时**在场**(声纹匹配)或**被提到**(mentions) + * 的两个人 → 一条边,[weight] = 共现过的会话数。规范化存 [personA] < [personB],(A,B) 唯一。 + */ +@Entity( + tableName = "relations", + indices = [Index("personA"), Index("personB"), Index(value = ["personA", "personB"], unique = true)], +) +data class Relation( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val personA: Long, + val personB: Long, + val weight: Int, + val lastTs: Long, +) 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 f7234ff..5e8eeaa 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 @@ -15,4 +15,5 @@ class Repository(ctx: Context) { val wechatMessages = db.wechatMessageDao() val moments = db.momentDao() val mentions = db.mentionDao() + val relations = db.relationDao() } -- libgit2 0.21.4