Commit a3d829d7340123fc4c93db3ea17337a2abc69f13

Authored by yibo
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>
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 }