Commit 4de555448454cc7a2d92a4d322fcdc876a6cb182
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>
Showing
3 changed files
with
124 additions
and
0 deletions
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, | ... | ... |