Commit a3d829d7340123fc4c93db3ea17337a2abc69f13
1 parent
4ab9635a
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,6 +38,7 @@ import androidx.compose.material3.OutlinedTextField | ||
| 38 | import androidx.compose.material3.RadioButton | 38 | import androidx.compose.material3.RadioButton |
| 39 | import androidx.compose.material3.Surface | 39 | import androidx.compose.material3.Surface |
| 40 | import androidx.compose.material3.Tab | 40 | import androidx.compose.material3.Tab |
| 41 | +import androidx.compose.material3.ScrollableTabRow | ||
| 41 | import androidx.compose.material3.TabRow | 42 | import androidx.compose.material3.TabRow |
| 42 | import androidx.compose.material3.Text | 43 | import androidx.compose.material3.Text |
| 43 | import androidx.compose.material3.TextButton | 44 | import androidx.compose.material3.TextButton |
| @@ -60,6 +61,7 @@ import coil.compose.AsyncImage | @@ -60,6 +61,7 @@ import coil.compose.AsyncImage | ||
| 60 | import com.lemon.lifelog.audio.SegmentPlayer | 61 | import com.lemon.lifelog.audio.SegmentPlayer |
| 61 | import com.lemon.lifelog.data.Moment | 62 | import com.lemon.lifelog.data.Moment |
| 62 | import com.lemon.lifelog.data.Person | 63 | import com.lemon.lifelog.data.Person |
| 64 | +import com.lemon.lifelog.data.RelatedPerson | ||
| 63 | import com.lemon.lifelog.data.SpeakerProfile | 65 | import com.lemon.lifelog.data.SpeakerProfile |
| 64 | import com.lemon.lifelog.data.WechatMessage | 66 | import com.lemon.lifelog.data.WechatMessage |
| 65 | import java.io.File | 67 | import java.io.File |
| @@ -77,6 +79,7 @@ internal fun PersonProfileScreen( | @@ -77,6 +79,7 @@ internal fun PersonProfileScreen( | ||
| 77 | onBack: () -> Unit, | 79 | onBack: () -> Unit, |
| 78 | onOpenPhoto: (Long) -> Unit, | 80 | onOpenPhoto: (Long) -> Unit, |
| 79 | onRequestMerge: () -> Unit, | 81 | onRequestMerge: () -> Unit, |
| 82 | + onOpenPerson: (Long) -> Unit, | ||
| 80 | ) { | 83 | ) { |
| 81 | val person by vm.repo.persons.observe(personId).collectAsState(initial = null) | 84 | val person by vm.repo.persons.observe(personId).collectAsState(initial = null) |
| 82 | val photos by vm.repo.photos.observePhotosOfPerson(personId).collectAsState(initial = emptyList()) | 85 | val photos by vm.repo.photos.observePhotosOfPerson(personId).collectAsState(initial = emptyList()) |
| @@ -97,6 +100,8 @@ internal fun PersonProfileScreen( | @@ -97,6 +100,8 @@ internal fun PersonProfileScreen( | ||
| 97 | }.collectAsState(initial = emptyList()) | 100 | }.collectAsState(initial = emptyList()) |
| 98 | val mentioned by remember(personId) { vm.repo.mentions.observeMentionsOf(personId) } | 101 | val mentioned by remember(personId) { vm.repo.mentions.observeMentionsOf(personId) } |
| 99 | .collectAsState(initial = emptyList()) | 102 | .collectAsState(initial = emptyList()) |
| 103 | + val related by remember(personId) { vm.repo.relations.observeRelatedTo(personId) } | ||
| 104 | + .collectAsState(initial = emptyList()) | ||
| 100 | val mentionScanning by vm.mentionScanning.collectAsState() | 105 | val mentionScanning by vm.mentionScanning.collectAsState() |
| 101 | 106 | ||
| 102 | var bindVoice by remember { mutableStateOf(false) } | 107 | var bindVoice by remember { mutableStateOf(false) } |
| @@ -143,12 +148,13 @@ internal fun PersonProfileScreen( | @@ -143,12 +148,13 @@ internal fun PersonProfileScreen( | ||
| 143 | placeholder = { Text("在 TA 的记录里搜索…") }, | 148 | placeholder = { Text("在 TA 的记录里搜索…") }, |
| 144 | modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), | 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 | Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("照片 ${photos.size}") }) | 152 | Tab(selected = tab == 0, onClick = { tab = 0 }, text = { Text("照片 ${photos.size}") }) |
| 148 | Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("语音 ${audio.size}") }) | 153 | Tab(selected = tab == 1, onClick = { tab = 1 }, text = { Text("语音 ${audio.size}") }) |
| 149 | Tab(selected = tab == 2, onClick = { tab = 2 }, text = { Text("聊天 ${messages.size}") }) | 154 | Tab(selected = tab == 2, onClick = { tab = 2 }, text = { Text("聊天 ${messages.size}") }) |
| 150 | Tab(selected = tab == 3, onClick = { tab = 3 }, text = { Text("朋友圈 ${moments.size}") }) | 155 | Tab(selected = tab == 3, onClick = { tab = 3 }, text = { Text("朋友圈 ${moments.size}") }) |
| 151 | Tab(selected = tab == 4, onClick = { tab = 4 }, text = { Text("被提到 ${mentioned.size}") }) | 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 | Box(modifier = Modifier.weight(1f).fillMaxWidth()) { | 159 | Box(modifier = Modifier.weight(1f).fillMaxWidth()) { |
| 154 | when (tab) { | 160 | when (tab) { |
| @@ -179,7 +185,7 @@ internal fun PersonProfileScreen( | @@ -179,7 +185,7 @@ internal fun PersonProfileScreen( | ||
| 179 | else if (fMoments.isEmpty()) EmptyHint(if (q.isBlank()) "没有 TA 的朋友圈记录。" else "没有匹配的朋友圈。") | 185 | else if (fMoments.isEmpty()) EmptyHint(if (q.isBlank()) "没有 TA 的朋友圈记录。" else "没有匹配的朋友圈。") |
| 180 | else LazyColumn(Modifier.fillMaxSize()) { items(fMoments, key = { it.id }) { mo -> MomentRow(mo) } } | 186 | else LazyColumn(Modifier.fillMaxSize()) { items(fMoments, key = { it.id }) { mo -> MomentRow(mo) } } |
| 181 | // 被提到:别人录音里提到 TA 的段(按声纹=谁说的 展示)。 | 187 | // 被提到:别人录音里提到 TA 的段(按声纹=谁说的 展示)。 |
| 182 | - else -> Column(Modifier.fillMaxSize()) { | 188 | + 4 -> Column(Modifier.fillMaxSize()) { |
| 183 | Row( | 189 | Row( |
| 184 | Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), | 190 | Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp), |
| 185 | horizontalArrangement = Arrangement.SpaceBetween, | 191 | horizontalArrangement = Arrangement.SpaceBetween, |
| @@ -203,6 +209,14 @@ internal fun PersonProfileScreen( | @@ -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,6 +312,7 @@ private fun ProfileHero( | ||
| 298 | onEnrich: () -> Unit, | 312 | onEnrich: () -> Unit, |
| 299 | onEditProfile: () -> Unit, | 313 | onEditProfile: () -> Unit, |
| 300 | onMerge: () -> Unit, | 314 | onMerge: () -> Unit, |
| 315 | + onDownload: () -> Unit, | ||
| 301 | ) { | 316 | ) { |
| 302 | val avatarSize = 84.dp | 317 | val avatarSize = 84.dp |
| 303 | Column(modifier = Modifier.fillMaxWidth()) { | 318 | Column(modifier = Modifier.fillMaxWidth()) { |
| @@ -373,6 +388,9 @@ private fun ProfileHero( | @@ -373,6 +388,9 @@ private fun ProfileHero( | ||
| 373 | TextButton(onClick = onRecordVoice, contentPadding = pad) { Text("🎤 录一段", style = MaterialTheme.typography.labelMedium) } | 388 | TextButton(onClick = onRecordVoice, contentPadding = pad) { Text("🎤 录一段", style = MaterialTheme.typography.labelMedium) } |
| 374 | TextButton(onClick = onBind, contentPadding = pad) { Text(if (p.voiceSpeakerId == null) "选声纹" else "改绑", style = MaterialTheme.typography.labelMedium) } | 389 | TextButton(onClick = onBind, contentPadding = pad) { Text(if (p.voiceSpeakerId == null) "选声纹" else "改绑", style = MaterialTheme.typography.labelMedium) } |
| 375 | TextButton(onClick = onMerge, contentPadding = pad) { Text("🔗 合并", style = MaterialTheme.typography.labelMedium) } | 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 | TextButton(onClick = onEnrich, contentPadding = pad) { Text(if (p.impression == null) "🧠 AI印象" else "🧠 重生成", style = MaterialTheme.typography.labelMedium) } | 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,6 +489,25 @@ private fun AudioRow(row: com.lemon.lifelog.data.SegmentRow, playing: Boolean, o | ||
| 471 | } | 489 | } |
| 472 | 490 | ||
| 473 | @Composable | 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 | private fun MomentRow(m: Moment) { | 511 | private fun MomentRow(m: Moment) { |
| 475 | Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) { | 512 | Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 6.dp)) { |
| 476 | Text(PROFILE_FMT.format(Date(m.createTime)), style = MaterialTheme.typography.labelSmall) | 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,6 +111,7 @@ internal fun ContactsTab(repo: Repository) { | ||
| 111 | onBack = { profilePersonId = null }, | 111 | onBack = { profilePersonId = null }, |
| 112 | onOpenPhoto = { detailPhotoId = it }, | 112 | onOpenPhoto = { detailPhotoId = it }, |
| 113 | onRequestMerge = { mergeForId = pid }, | 113 | onRequestMerge = { mergeForId = pid }, |
| 114 | + onOpenPerson = { vm.touchPerson(it); profilePersonId = it }, | ||
| 114 | ) | 115 | ) |
| 115 | showPhonePicker -> PhoneContactPickerScreen(vm = vm, onBack = { showPhonePicker = false }) | 116 | showPhonePicker -> PhoneContactPickerScreen(vm = vm, onBack = { showPhonePicker = false }) |
| 116 | showPicker -> WeChatPickerScreen( | 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,7 +160,11 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) { | ||
| 160 | if (_mentionScanning.value) return | 160 | if (_mentionScanning.value) return |
| 161 | _mentionScanning.value = true | 161 | _mentionScanning.value = true |
| 162 | viewModelScope.launch { | 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 | _mentionScanning.value = false | 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,8 +23,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase | ||
| 23 | Moment::class, | 23 | Moment::class, |
| 24 | DeletedGallery::class, | 24 | DeletedGallery::class, |
| 25 | Mention::class, | 25 | Mention::class, |
| 26 | + Relation::class, | ||
| 26 | ], | 27 | ], |
| 27 | - version = 16, | 28 | + version = 17, |
| 28 | exportSchema = false, | 29 | exportSchema = false, |
| 29 | ) | 30 | ) |
| 30 | abstract class AppDatabase : RoomDatabase() { | 31 | abstract class AppDatabase : RoomDatabase() { |
| @@ -39,6 +40,7 @@ abstract class AppDatabase : RoomDatabase() { | @@ -39,6 +40,7 @@ abstract class AppDatabase : RoomDatabase() { | ||
| 39 | abstract fun wechatMessageDao(): WechatMessageDao | 40 | abstract fun wechatMessageDao(): WechatMessageDao |
| 40 | abstract fun momentDao(): MomentDao | 41 | abstract fun momentDao(): MomentDao |
| 41 | abstract fun mentionDao(): MentionDao | 42 | abstract fun mentionDao(): MentionDao |
| 43 | + abstract fun relationDao(): RelationDao | ||
| 42 | 44 | ||
| 43 | companion object { | 45 | companion object { |
| 44 | @Volatile | 46 | @Volatile |
| @@ -171,6 +173,18 @@ abstract class AppDatabase : RoomDatabase() { | @@ -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 | fun get(ctx: Context): AppDatabase = | 188 | fun get(ctx: Context): AppDatabase = |
| 175 | instance ?: synchronized(this) { | 189 | instance ?: synchronized(this) { |
| 176 | instance ?: Room.databaseBuilder( | 190 | instance ?: Room.databaseBuilder( |
| @@ -178,7 +192,7 @@ abstract class AppDatabase : RoomDatabase() { | @@ -178,7 +192,7 @@ abstract class AppDatabase : RoomDatabase() { | ||
| 178 | AppDatabase::class.java, | 192 | AppDatabase::class.java, |
| 179 | "lifelog.db", | 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 | // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, | 196 | // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, |
| 183 | // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 | 197 | // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 |
| 184 | .build() | 198 | .build() |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
| @@ -96,6 +96,10 @@ interface SegmentDao { | @@ -96,6 +96,10 @@ interface SegmentDao { | ||
| 96 | @Query("SELECT * FROM segments WHERE embedding IS NOT NULL") | 96 | @Query("SELECT * FROM segments WHERE embedding IS NOT NULL") |
| 97 | suspend fun allWithEmbedding(): List<Segment> | 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 | @Query("SELECT speakerId AS speakerId, COUNT(*) AS count FROM segments WHERE speakerId IS NOT NULL GROUP BY speakerId") | 104 | @Query("SELECT speakerId AS speakerId, COUNT(*) AS count FROM segments WHERE speakerId IS NOT NULL GROUP BY speakerId") |
| 101 | fun observeSpeakerCounts(): Flow<List<SpeakerCount>> | 105 | fun observeSpeakerCounts(): Flow<List<SpeakerCount>> |
| @@ -292,6 +296,10 @@ interface MentionDao { | @@ -292,6 +296,10 @@ interface MentionDao { | ||
| 292 | @Query("SELECT COUNT(*) FROM mentions WHERE personId = :pid") | 296 | @Query("SELECT COUNT(*) FROM mentions WHERE personId = :pid") |
| 293 | fun observeCountForPerson(pid: Long): Flow<Int> | 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 | /** 某人「被提到」的录音段(join 转写 + 段 + 说话人=谁说的)。 */ | 303 | /** 某人「被提到」的录音段(join 转写 + 段 + 说话人=谁说的)。 */ |
| 296 | @Query( | 304 | @Query( |
| 297 | """ | 305 | """ |
| @@ -314,6 +322,44 @@ interface MentionDao { | @@ -314,6 +322,44 @@ interface MentionDao { | ||
| 314 | fun observeMentionsOf(personId: Long, limit: Int = 500): Flow<List<SegmentRow>> | 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 | @Dao | 363 | @Dao |
| 318 | interface SummaryDao { | 364 | interface SummaryDao { |
| 319 | @Insert | 365 | @Insert |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt
| @@ -211,3 +211,19 @@ data class Mention( | @@ -211,3 +211,19 @@ data class Mention( | ||
| 211 | val matchedText: String, | 211 | val matchedText: String, |
| 212 | val createdAt: Long = System.currentTimeMillis(), | 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
| @@ -15,4 +15,5 @@ class Repository(ctx: Context) { | @@ -15,4 +15,5 @@ class Repository(ctx: Context) { | ||
| 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 | val mentions = db.mentionDao() |
| 18 | + val relations = db.relationDao() | ||
| 18 | } | 19 | } |