Commit c6699b266d074a0966814c604a31fae091deb951

Authored by yibo
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>
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
... ... @@ -15,4 +15,5 @@ class Repository(ctx: Context) {
15 15 val wechatMessages = db.wechatMessageDao()
16 16 val moments = db.momentDao()
17 17 val mentions = db.mentionDao()
  18 + val relations = db.relationDao()
18 19 }
... ...