Commit c6699b266d074a0966814c604a31fae091deb951
1 parent
4de55544
feat(lifelog/crm): P1 #1 人↔人共现关系(会话内在场/被提到)
端上 CRM #1:把录音会话里同时出现的人连成关系图基础。 - 新表 relations(personA<personB, weight, lastTs),Room v16→17 - RelationBuilder:按 5min 静音切「会话」→ 会话内「在场」(声纹→已绑 Person) ∪「被提到」(mentions) 两两配对成无向共现边,weight=共现会话数;全量重建(接在 mentions 重扫之后) - PersonProfileScreen 加「相关」tab(共现的人,按次数排,点击跳该人档案);tab 增至 6 个改 ScrollableTabRow - 加 onOpenPerson 人→人导航(复用 profilePersonId) 真机验证:迁移成功不清库;rescan 后 RelationBuilder rebuild 1668 段→0 边(仅 1 人 managed,正确), 「相关」tab 渲染空状态无崩溃。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Showing
7 changed files
with
124 additions
and
5 deletions
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PersonProfileScreen.kt
| ... | ... | @@ -38,6 +38,7 @@ import androidx.compose.material3.OutlinedTextField |
| 38 | 38 | import androidx.compose.material3.RadioButton |
| 39 | 39 | import androidx.compose.material3.Surface |
| 40 | 40 | import androidx.compose.material3.Tab |
| 41 | +import androidx.compose.material3.ScrollableTabRow | |
| 41 | 42 | import androidx.compose.material3.TabRow |
| 42 | 43 | import androidx.compose.material3.Text |
| 43 | 44 | import androidx.compose.material3.TextButton |
| ... | ... | @@ -60,6 +61,7 @@ import coil.compose.AsyncImage |
| 60 | 61 | import com.lemon.lifelog.audio.SegmentPlayer |
| 61 | 62 | import com.lemon.lifelog.data.Moment |
| 62 | 63 | import com.lemon.lifelog.data.Person |
| 64 | +import com.lemon.lifelog.data.RelatedPerson | |
| 63 | 65 | import com.lemon.lifelog.data.SpeakerProfile |
| 64 | 66 | import com.lemon.lifelog.data.WechatMessage |
| 65 | 67 | import java.io.File |
| ... | ... | @@ -77,6 +79,7 @@ internal fun PersonProfileScreen( |
| 77 | 79 | onBack: () -> Unit, |
| 78 | 80 | onOpenPhoto: (Long) -> Unit, |
| 79 | 81 | onRequestMerge: () -> Unit, |
| 82 | + onOpenPerson: (Long) -> Unit, | |
| 80 | 83 | ) { |
| 81 | 84 | val person by vm.repo.persons.observe(personId).collectAsState(initial = null) |
| 82 | 85 | val photos by vm.repo.photos.observePhotosOfPerson(personId).collectAsState(initial = emptyList()) |
| ... | ... | @@ -97,6 +100,8 @@ internal fun PersonProfileScreen( |
| 97 | 100 | }.collectAsState(initial = emptyList()) |
| 98 | 101 | val mentioned by remember(personId) { vm.repo.mentions.observeMentionsOf(personId) } |
| 99 | 102 | .collectAsState(initial = emptyList()) |
| 103 | + val related by remember(personId) { vm.repo.relations.observeRelatedTo(personId) } | |
| 104 | + .collectAsState(initial = emptyList()) | |
| 100 | 105 | val mentionScanning by vm.mentionScanning.collectAsState() |
| 101 | 106 | |
| 102 | 107 | var bindVoice by remember { mutableStateOf(false) } |
| ... | ... | @@ -143,12 +148,13 @@ internal fun PersonProfileScreen( |
| 143 | 148 | placeholder = { Text("在 TA 的记录里搜索…") }, |
| 144 | 149 | modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), |
| 145 | 150 | ) |
| 146 | - TabRow(selectedTabIndex = tab) { | |
| 151 | + ScrollableTabRow(selectedTabIndex = tab, edgePadding = 0.dp) { | |
| 147 | 152 | Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("照片 ${photos.size}") }) |
| 148 | 153 | Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("语音 ${audio.size}") }) |
| 149 | 154 | Tab(selected = tab == 2, onClick = { tab = 2 }, text = { Text("聊天 ${messages.size}") }) |
| 150 | 155 | Tab(selected = tab == 3, onClick = { tab = 3 }, text = { Text("朋友圈 ${moments.size}") }) |
| 151 | 156 | Tab(selected = tab == 4, onClick = { tab = 4 }, text = { Text("被提到 ${mentioned.size}") }) |
| 157 | + Tab(selected = tab == 5, onClick = { tab = 5 }, text = { Text("相关 ${related.size}") }) | |
| 152 | 158 | } |
| 153 | 159 | Box(modifier = Modifier.weight(1f).fillMaxWidth()) { |
| 154 | 160 | when (tab) { |
| ... | ... | @@ -179,7 +185,7 @@ internal fun PersonProfileScreen( |
| 179 | 185 | else if (fMoments.isEmpty()) EmptyHint(if (q.isBlank()) "没有 TA 的朋友圈记录。" else "没有匹配的朋友圈。") |
| 180 | 186 | else LazyColumn(Modifier.fillMaxSize()) { items(fMoments, key = { it.id }) { mo -> MomentRow(mo) } } |
| 181 | 187 | // 被提到:别人录音里提到 TA 的段(按声纹=谁说的 展示)。 |
| 182 | - else -> Column(Modifier.fillMaxSize()) { | |
| 188 | + 4 -> Column(Modifier.fillMaxSize()) { | |
| 183 | 189 | Row( |
| 184 | 190 | Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), |
| 185 | 191 | horizontalArrangement = Arrangement.SpaceBetween, |
| ... | ... | @@ -203,6 +209,14 @@ internal fun PersonProfileScreen( |
| 203 | 209 | } |
| 204 | 210 | } |
| 205 | 211 | } |
| 212 | + // 相关:同会话共现(在场/被提到)的人,按共现次数排。 | |
| 213 | + else -> if (related.isEmpty()) EmptyHint( | |
| 214 | + "还没有相关的人。共现关系来自录音会话:同一段时间里同时出现/被提到的人会连起来。\n到「被提到」tab 点「重新扫描」可重建。", | |
| 215 | + ) else LazyColumn(Modifier.fillMaxSize()) { | |
| 216 | + items(related, key = { it.id }) { rp -> | |
| 217 | + RelatedRow(rp) { onOpenPerson(rp.id) } | |
| 218 | + } | |
| 219 | + } | |
| 206 | 220 | } |
| 207 | 221 | } |
| 208 | 222 | } |
| ... | ... | @@ -298,6 +312,7 @@ private fun ProfileHero( |
| 298 | 312 | onEnrich: () -> Unit, |
| 299 | 313 | onEditProfile: () -> Unit, |
| 300 | 314 | onMerge: () -> Unit, |
| 315 | + onDownload: () -> Unit, | |
| 301 | 316 | ) { |
| 302 | 317 | val avatarSize = 84.dp |
| 303 | 318 | Column(modifier = Modifier.fillMaxWidth()) { |
| ... | ... | @@ -373,6 +388,9 @@ private fun ProfileHero( |
| 373 | 388 | TextButton(onClick = onRecordVoice, contentPadding = pad) { Text("🎤 录一段", style = MaterialTheme.typography.labelMedium) } |
| 374 | 389 | TextButton(onClick = onBind, contentPadding = pad) { Text(if (p.voiceSpeakerId == null) "选声纹" else "改绑", style = MaterialTheme.typography.labelMedium) } |
| 375 | 390 | TextButton(onClick = onMerge, contentPadding = pad) { Text("🔗 合并", style = MaterialTheme.typography.labelMedium) } |
| 391 | + if (p.contactRef?.startsWith("wechat:") == true) { | |
| 392 | + TextButton(onClick = onDownload, contentPadding = pad) { Text("⬇ 下载图片", style = MaterialTheme.typography.labelMedium) } | |
| 393 | + } | |
| 376 | 394 | TextButton(onClick = onEnrich, contentPadding = pad) { Text(if (p.impression == null) "🧠 AI印象" else "🧠 重生成", style = MaterialTheme.typography.labelMedium) } |
| 377 | 395 | } |
| 378 | 396 | |
| ... | ... | @@ -471,6 +489,25 @@ private fun AudioRow(row: com.lemon.lifelog.data.SegmentRow, playing: Boolean, o |
| 471 | 489 | } |
| 472 | 490 | |
| 473 | 491 | @Composable |
| 492 | +private fun RelatedRow(rp: RelatedPerson, onClick: () -> Unit) { | |
| 493 | + Row( | |
| 494 | + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(horizontal = 16.dp, vertical = 10.dp), | |
| 495 | + horizontalArrangement = Arrangement.spacedBy(12.dp), | |
| 496 | + verticalAlignment = Alignment.CenterVertically, | |
| 497 | + ) { | |
| 498 | + Text("👤", style = MaterialTheme.typography.titleMedium) | |
| 499 | + Column(modifier = Modifier.weight(1f)) { | |
| 500 | + Text(rp.name, style = MaterialTheme.typography.bodyLarge) | |
| 501 | + Text( | |
| 502 | + "共现 ${rp.weight} 次 · 最近 ${PROFILE_FMT.format(Date(rp.lastTs))}", | |
| 503 | + style = MaterialTheme.typography.labelSmall, | |
| 504 | + ) | |
| 505 | + } | |
| 506 | + Text("›", style = MaterialTheme.typography.titleMedium) | |
| 507 | + } | |
| 508 | +} | |
| 509 | + | |
| 510 | +@Composable | |
| 474 | 511 | private fun MomentRow(m: Moment) { |
| 475 | 512 | Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) { |
| 476 | 513 | Text(PROFILE_FMT.format(Date(m.createTime)), style = MaterialTheme.typography.labelSmall) | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt
| ... | ... | @@ -111,6 +111,7 @@ internal fun ContactsTab(repo: Repository) { |
| 111 | 111 | onBack = { profilePersonId = null }, |
| 112 | 112 | onOpenPhoto = { detailPhotoId = it }, |
| 113 | 113 | onRequestMerge = { mergeForId = pid }, |
| 114 | + onOpenPerson = { vm.touchPerson(it); profilePersonId = it }, | |
| 114 | 115 | ) |
| 115 | 116 | showPhonePicker -> PhoneContactPickerScreen(vm = vm, onBack = { showPhonePicker = false }) |
| 116 | 117 | showPicker -> WeChatPickerScreen( | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt
| ... | ... | @@ -160,7 +160,11 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { |
| 160 | 160 | if (_mentionScanning.value) return |
| 161 | 161 | _mentionScanning.value = true |
| 162 | 162 | viewModelScope.launch { |
| 163 | - runCatching { com.lemon.lifelog.crm.MentionScanner.rescanAll(repo) } | |
| 163 | + runCatching { | |
| 164 | + com.lemon.lifelog.crm.MentionScanner.rescanAll(repo) | |
| 165 | + // 提及变了 → 重建人↔人共现边(依赖 mentions + 声纹在场)。 | |
| 166 | + com.lemon.lifelog.crm.RelationBuilder.rebuild(repo) | |
| 167 | + } | |
| 164 | 168 | _mentionScanning.value = false |
| 165 | 169 | } |
| 166 | 170 | } | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt
| ... | ... | @@ -23,8 +23,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase |
| 23 | 23 | Moment::class, |
| 24 | 24 | DeletedGallery::class, |
| 25 | 25 | Mention::class, |
| 26 | + Relation::class, | |
| 26 | 27 | ], |
| 27 | - version = 16, | |
| 28 | + version = 17, | |
| 28 | 29 | exportSchema = false, |
| 29 | 30 | ) |
| 30 | 31 | abstract class AppDatabase : RoomDatabase() { |
| ... | ... | @@ -39,6 +40,7 @@ abstract class AppDatabase : RoomDatabase() { |
| 39 | 40 | abstract fun wechatMessageDao(): WechatMessageDao |
| 40 | 41 | abstract fun momentDao(): MomentDao |
| 41 | 42 | abstract fun mentionDao(): MentionDao |
| 43 | + abstract fun relationDao(): RelationDao | |
| 42 | 44 | |
| 43 | 45 | companion object { |
| 44 | 46 | @Volatile |
| ... | ... | @@ -171,6 +173,18 @@ abstract class AppDatabase : RoomDatabase() { |
| 171 | 173 | } |
| 172 | 174 | } |
| 173 | 175 | |
| 176 | + /** v0.9 端上 CRM #1:人↔人共现边表。 */ | |
| 177 | + private val MIGRATION_16_17 = object : Migration(16, 17) { | |
| 178 | + override fun migrate(db: SupportSQLiteDatabase) { | |
| 179 | + db.execSQL( | |
| 180 | + "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)", | |
| 181 | + ) | |
| 182 | + db.execSQL("CREATE INDEX IF NOT EXISTS `index_relations_personA` ON `relations` (`personA`)") | |
| 183 | + db.execSQL("CREATE INDEX IF NOT EXISTS `index_relations_personB` ON `relations` (`personB`)") | |
| 184 | + db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_relations_personA_personB` ON `relations` (`personA`, `personB`)") | |
| 185 | + } | |
| 186 | + } | |
| 187 | + | |
| 174 | 188 | fun get(ctx: Context): AppDatabase = |
| 175 | 189 | instance ?: synchronized(this) { |
| 176 | 190 | instance ?: Room.databaseBuilder( |
| ... | ... | @@ -178,7 +192,7 @@ abstract class AppDatabase : RoomDatabase() { |
| 178 | 192 | AppDatabase::class.java, |
| 179 | 193 | "lifelog.db", |
| 180 | 194 | ) |
| 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) | |
| 195 | + .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) | |
| 182 | 196 | // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, |
| 183 | 197 | // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 |
| 184 | 198 | .build() | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
| ... | ... | @@ -96,6 +96,10 @@ interface SegmentDao { |
| 96 | 96 | @Query("SELECT * FROM segments WHERE embedding IS NOT NULL") |
| 97 | 97 | suspend fun allWithEmbedding(): List<Segment> |
| 98 | 98 | |
| 99 | + /** 段元信息(按时间升序,共现重建用)。 */ | |
| 100 | + @Query("SELECT id, startTs, speakerId FROM segments ORDER BY startTs ASC") | |
| 101 | + suspend fun allMeta(): List<SegMeta> | |
| 102 | + | |
| 99 | 103 | /** 每个说话人当前实际归属的段数(用于声纹列表展示)。 */ |
| 100 | 104 | @Query("SELECT speakerId AS speakerId, COUNT(*) AS count FROM segments WHERE speakerId IS NOT NULL GROUP BY speakerId") |
| 101 | 105 | fun observeSpeakerCounts(): Flow<List<SpeakerCount>> |
| ... | ... | @@ -292,6 +296,10 @@ interface MentionDao { |
| 292 | 296 | @Query("SELECT COUNT(*) FROM mentions WHERE personId = :pid") |
| 293 | 297 | fun observeCountForPerson(pid: Long): Flow<Int> |
| 294 | 298 | |
| 299 | + /** 所有提及对(personId 反查 + 共现重建用)。 */ | |
| 300 | + @Query("SELECT segmentId, personId FROM mentions") | |
| 301 | + suspend fun allPairs(): List<MentionPair> | |
| 302 | + | |
| 295 | 303 | /** 某人「被提到」的录音段(join 转写 + 段 + 说话人=谁说的)。 */ |
| 296 | 304 | @Query( |
| 297 | 305 | """ |
| ... | ... | @@ -314,6 +322,44 @@ interface MentionDao { |
| 314 | 322 | fun observeMentionsOf(personId: Long, limit: Int = 500): Flow<List<SegmentRow>> |
| 315 | 323 | } |
| 316 | 324 | |
| 325 | +data class MentionPair(val segmentId: Long, val personId: Long) | |
| 326 | + | |
| 327 | +/** 段元信息(共现重建:按时间切会话 + 声纹在场)。 */ | |
| 328 | +data class SegMeta(val id: Long, val startTs: Long, val speakerId: Long?) | |
| 329 | + | |
| 330 | +/** 「相关的人」投影(档案「相关」tab)。 */ | |
| 331 | +data class RelatedPerson( | |
| 332 | + val id: Long, | |
| 333 | + val name: String, | |
| 334 | + val avatarPath: String?, | |
| 335 | + val weight: Int, | |
| 336 | + val lastTs: Long, | |
| 337 | +) | |
| 338 | + | |
| 339 | +@Dao | |
| 340 | +interface RelationDao { | |
| 341 | + @Insert(onConflict = OnConflictStrategy.IGNORE) | |
| 342 | + suspend fun insertAll(rs: List<Relation>) | |
| 343 | + | |
| 344 | + @Query("DELETE FROM relations") | |
| 345 | + suspend fun clearAll() | |
| 346 | + | |
| 347 | + @Query("SELECT COUNT(*) FROM relations WHERE personA = :pid OR personB = :pid") | |
| 348 | + fun observeCountForPerson(pid: Long): Flow<Int> | |
| 349 | + | |
| 350 | + /** 与某人有共现边的人(取边的另一端),按权重降序。 */ | |
| 351 | + @Query( | |
| 352 | + """ | |
| 353 | + SELECT p.id AS id, p.name AS name, p.avatarPath AS avatarPath, r.weight AS weight, r.lastTs AS lastTs | |
| 354 | + FROM relations r | |
| 355 | + JOIN persons p ON p.id = (CASE WHEN r.personA = :pid THEN r.personB ELSE r.personA END) | |
| 356 | + WHERE r.personA = :pid OR r.personB = :pid | |
| 357 | + ORDER BY r.weight DESC, r.lastTs DESC | |
| 358 | + """, | |
| 359 | + ) | |
| 360 | + fun observeRelatedTo(pid: Long): Flow<List<RelatedPerson>> | |
| 361 | +} | |
| 362 | + | |
| 317 | 363 | @Dao |
| 318 | 364 | interface SummaryDao { |
| 319 | 365 | @Insert | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt
| ... | ... | @@ -211,3 +211,19 @@ data class Mention( |
| 211 | 211 | val matchedText: String, |
| 212 | 212 | val createdAt: Long = System.currentTimeMillis(), |
| 213 | 213 | ) |
| 214 | + | |
| 215 | +/** | |
| 216 | + * 人↔人「共现」无向边:同一会话(5min 静音切)里同时**在场**(声纹匹配)或**被提到**(mentions) | |
| 217 | + * 的两个人 → 一条边,[weight] = 共现过的会话数。规范化存 [personA] < [personB],(A,B) 唯一。 | |
| 218 | + */ | |
| 219 | +@Entity( | |
| 220 | + tableName = "relations", | |
| 221 | + indices = [Index("personA"), Index("personB"), Index(value = ["personA", "personB"], unique = true)], | |
| 222 | +) | |
| 223 | +data class Relation( | |
| 224 | + @PrimaryKey(autoGenerate = true) val id: Long = 0, | |
| 225 | + val personA: Long, | |
| 226 | + val personB: Long, | |
| 227 | + val weight: Int, | |
| 228 | + val lastTs: Long, | |
| 229 | +) | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt