Commit 4de555448454cc7a2d92a4d322fcdc876a6cb182

Authored by yibo
1 parent eb6e13a4

feat(lifelog/crm): 录音提及回链人物(mentions)+ 相册删除墓碑 + 照片详情显示路径

主体(回链 lemonPCRM 方向,端上版):
- Mention 实体 + mentions 表(迁移 15→16):某段转写文本提到了某 Person(词典反查名字/别名命中)
- crm/MentionExtractor + RelationBuilder:从转写抽人名→建 (segmentId,personId) 提及边
- PhotologViewModel.rescanMentions:按 managed 人物词典全量重扫;档案页「被提到」展示
- BlockProcessor / Daos / Repository 相应接线

同链顺带(迁移 14→15,与 15→16 同属一条链,必须同提交才自洽):
- 相册删除墓碑 DeletedGallery:app 内删的相册照片记 mediaId,全相册重扫不再导回;导入源删除时删私有副本
- 照片详情显示文件名 + 存储地址(gallery 从 MediaStore 反查真实名/相对路径)+ 来源/状态/模型/#id,可长按复制

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt
... ... @@ -404,6 +404,7 @@ private fun PhotoDetailSheet(vm: PhotologViewModel, photoId: Long, persons: List
404 404 val faces by remember(photoId) { vm.repo.faces.observeByPhoto(photoId) }.collectAsState(initial = emptyList())
405 405 val nameOf = remember(persons) { persons.associate { it.id to it.name } }
406 406 var tagFace by remember { mutableStateOf<Face?>(null) }
  407 + val ctx = LocalContext.current
407 408  
408 409 ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheet) {
409 410 Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
... ... @@ -415,6 +416,23 @@ private fun PhotoDetailSheet(vm: PhotologViewModel, photoId: Long, persons: List
415 416 modifier = Modifier.fillMaxWidth().height(280.dp),
416 417 )
417 418 Spacer(Modifier.height(8.dp))
  419 + // 文件名 + 存储地址(gallery 源从 MediaStore 反查真实名/相对路径)。可长按复制。
  420 + val loc by produceState<PhotoLoc?>(initialValue = null, p.id) {
  421 + value = withContext(Dispatchers.IO) { resolvePhotoLoc(ctx, p.source, p.path) }
  422 + }
  423 + androidx.compose.foundation.text.selection.SelectionContainer {
  424 + Column {
  425 + Text(loc?.name ?: File(p.path).name, style = MaterialTheme.typography.titleSmall)
  426 + Text(loc?.address ?: p.path, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
  427 + }
  428 + }
  429 + val srcLabel = if (p.source == "gallery") "相册" else "导入"
  430 + val stateLabel = when (p.scanState) { 0 -> "待索引"; 1 -> "人脸已铺·待理解"; else -> "已理解" }
  431 + Text(
  432 + "来源 $srcLabel · $stateLabel" + (p.captionModel?.let { " · 模型 $it" } ?: "") + " · #${p.id}",
  433 + style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
  434 + )
  435 + Spacer(Modifier.height(4.dp))
418 436 Text(PHOTO_FMT.format(Date(p.takenTs)) + (if (p.lat != null) " 📍${"%.4f".format(p.lat)},${"%.4f".format(p.lon)}" else ""),
419 437 style = MaterialTheme.typography.labelMedium)
420 438 p.caption?.let { Text(it, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(vertical = 6.dp)) }
... ... @@ -945,3 +963,38 @@ private fun MergePersonPickerScreen(vm: PhotologViewModel, survivorId: Long, onD
945 963 )
946 964 }
947 965 }
  966 +
  967 +/** 照片的可读定位:文件名 + 存储地址(用于人工核对哪张图)。 */
  968 +private data class PhotoLoc(val name: String, val address: String)
  969 +
  970 +/**
  971 + * imported 源:path 即私有目录绝对路径,名=文件名。
  972 + * gallery 源:path 是 content:// uri,从 MediaStore 反查 DISPLAY_NAME + RELATIVE_PATH,
  973 + * 拼成「Pictures/Screenshots/Screenshot_xxx.png」这种人能认的地址。
  974 + */
  975 +private fun resolvePhotoLoc(ctx: android.content.Context, source: String, path: String): PhotoLoc {
  976 + if (source != "gallery") {
  977 + val f = File(path)
  978 + return PhotoLoc(f.name, path)
  979 + }
  980 + return try {
  981 + val uri = android.net.Uri.parse(path)
  982 + ctx.contentResolver.query(
  983 + uri,
  984 + arrayOf(
  985 + android.provider.MediaStore.MediaColumns.DISPLAY_NAME,
  986 + android.provider.MediaStore.MediaColumns.RELATIVE_PATH,
  987 + ),
  988 + null, null, null,
  989 + )?.use { c ->
  990 + if (c.moveToFirst()) {
  991 + val name = c.getString(0) ?: path.substringAfterLast('/')
  992 + val rel = runCatching { c.getString(1) }.getOrNull().orEmpty()
  993 + return PhotoLoc(name, rel + name)
  994 + }
  995 + }
  996 + PhotoLoc(path.substringAfterLast('/'), path)
  997 + } catch (t: Throwable) {
  998 + PhotoLoc(path.substringAfterLast('/'), path)
  999 + }
  1000 +}
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/RelationBuilder.kt 0 → 100644
  1 +package com.lemon.lifelog.crm
  2 +
  3 +import android.util.Log
  4 +import com.lemon.lifelog.data.Relation
  5 +import com.lemon.lifelog.data.Repository
  6 +import kotlinx.coroutines.Dispatchers
  7 +import kotlinx.coroutines.withContext
  8 +
  9 +/**
  10 + * 端上 CRM #1:从「会话」重建人↔人共现边。
  11 + *
  12 + * 会话 = 按 5min 静音切的一段连续录音(同时间轴分组口径)。一个会话里「涉及的人」=
  13 + * - **在场**:段的声纹 → 已绑定的 Person(speakerId → voiceSpeakerId)
  14 + * - **被提到**:mentions 表里该段命中的 Person
  15 + * 两两配对 → 共现边,weight = 共现过的会话数。规范化 personA<personB。全量重建(依赖 mentions,
  16 + * 故在 [MentionScanner.rescanAll] 之后调)。
  17 + */
  18 +object RelationBuilder {
  19 + private const val TAG = "RelationBuilder"
  20 + private const val SESSION_GAP_MS = 5 * 60 * 1000L
  21 +
  22 + private class Acc(var weight: Int = 0, var lastTs: Long = 0)
  23 +
  24 + suspend fun rebuild(repo: Repository): Int = withContext(Dispatchers.IO) {
  25 + val segs = repo.segments.allMeta() // 已按 startTs 升序
  26 + repo.relations.clearAll()
  27 + if (segs.isEmpty()) return@withContext 0
  28 +
  29 + val speakerToPerson = repo.persons.all()
  30 + .filter { it.voiceSpeakerId != null }
  31 + .associate { it.voiceSpeakerId!! to it.id }
  32 + val mentionsBySeg = repo.mentions.allPairs().groupBy({ it.segmentId }, { it.personId })
  33 +
  34 + val acc = HashMap<String, Acc>()
  35 + var start = 0
  36 + val n = segs.size
  37 + while (start < n) {
  38 + var end = start + 1
  39 + while (end < n && segs[end].startTs - segs[end - 1].startTs < SESSION_GAP_MS) end++
  40 + // 会话 = segs[start until end]
  41 + val involved = LinkedHashSet<Long>()
  42 + var lastTs = segs[start].startTs
  43 + for (k in start until end) {
  44 + val sg = segs[k]
  45 + if (sg.startTs > lastTs) lastTs = sg.startTs
  46 + sg.speakerId?.let { sp -> speakerToPerson[sp]?.let { involved.add(it) } }
  47 + mentionsBySeg[sg.id]?.let { involved.addAll(it) }
  48 + }
  49 + if (involved.size >= 2) {
  50 + val list = involved.toList()
  51 + for (i in list.indices) for (j in i + 1 until list.size) {
  52 + val a = minOf(list[i], list[j])
  53 + val b = maxOf(list[i], list[j])
  54 + val e = acc.getOrPut("$a:$b") { Acc() }
  55 + e.weight++
  56 + if (lastTs > e.lastTs) e.lastTs = lastTs
  57 + }
  58 + }
  59 + start = end
  60 + }
  61 +
  62 + val rows = acc.entries.map { (k, v) ->
  63 + val (a, b) = k.split(":")
  64 + Relation(personA = a.toLong(), personB = b.toLong(), weight = v.weight, lastTs = v.lastTs)
  65 + }
  66 + rows.chunked(500).forEach { repo.relations.insertAll(it) }
  67 + Log.i(TAG, "rebuild: ${segs.size} segs → ${rows.size} relations")
  68 + rows.size
  69 + }
  70 +}
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/GalleryScanner.kt
... ... @@ -22,6 +22,7 @@ object GalleryScanner {
22 22 /** 扫一遍相册,登记新图片。返回本轮新增数。 */
23 23 suspend fun scan(ctx: Context, repo: Repository): Int {
24 24 val known = repo.photos.knownMediaIds().toHashSet()
  25 + known.addAll(repo.photos.deletedMediaIds()) // 用户删过的别再导回来
25 26 val collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
26 27 val proj = arrayOf(
27 28 MediaStore.Images.Media._ID,
... ...