From 4de555448454cc7a2d92a4d322fcdc876a6cb182 Mon Sep 17 00:00:00 2001 From: yibo Date: Tue, 9 Jun 2026 16:37:36 +0800 Subject: [PATCH] feat(lifelog/crm): 录音提及回链人物(mentions)+ 相册删除墓碑 + 照片详情显示路径 --- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt | 53 +++++++++++++++++++++++++++++++++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/RelationBuilder.kt | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/GalleryScanner.kt | 1 + 3 files changed, 124 insertions(+), 0 deletions(-) create mode 100644 lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/RelationBuilder.kt diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt index 871b5a5..93f6fe6 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt @@ -404,6 +404,7 @@ private fun PhotoDetailSheet(vm: PhotologViewModel, photoId: Long, persons: List val faces by remember(photoId) { vm.repo.faces.observeByPhoto(photoId) }.collectAsState(initial = emptyList()) val nameOf = remember(persons) { persons.associate { it.id to it.name } } var tagFace by remember { mutableStateOf(null) } + val ctx = LocalContext.current ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheet) { Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { @@ -415,6 +416,23 @@ private fun PhotoDetailSheet(vm: PhotologViewModel, photoId: Long, persons: List modifier = Modifier.fillMaxWidth().height(280.dp), ) Spacer(Modifier.height(8.dp)) + // 文件名 + 存储地址(gallery 源从 MediaStore 反查真实名/相对路径)。可长按复制。 + val loc by produceState(initialValue = null, p.id) { + value = withContext(Dispatchers.IO) { resolvePhotoLoc(ctx, p.source, p.path) } + } + androidx.compose.foundation.text.selection.SelectionContainer { + Column { + Text(loc?.name ?: File(p.path).name, style = MaterialTheme.typography.titleSmall) + Text(loc?.address ?: p.path, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + val srcLabel = if (p.source == "gallery") "相册" else "导入" + val stateLabel = when (p.scanState) { 0 -> "待索引"; 1 -> "人脸已铺·待理解"; else -> "已理解" } + Text( + "来源 $srcLabel · $stateLabel" + (p.captionModel?.let { " · 模型 $it" } ?: "") + " · #${p.id}", + style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(4.dp)) Text(PHOTO_FMT.format(Date(p.takenTs)) + (if (p.lat != null) " 📍${"%.4f".format(p.lat)},${"%.4f".format(p.lon)}" else ""), style = MaterialTheme.typography.labelMedium) 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 ) } } + +/** 照片的可读定位:文件名 + 存储地址(用于人工核对哪张图)。 */ +private data class PhotoLoc(val name: String, val address: String) + +/** + * imported 源:path 即私有目录绝对路径,名=文件名。 + * gallery 源:path 是 content:// uri,从 MediaStore 反查 DISPLAY_NAME + RELATIVE_PATH, + * 拼成「Pictures/Screenshots/Screenshot_xxx.png」这种人能认的地址。 + */ +private fun resolvePhotoLoc(ctx: android.content.Context, source: String, path: String): PhotoLoc { + if (source != "gallery") { + val f = File(path) + return PhotoLoc(f.name, path) + } + return try { + val uri = android.net.Uri.parse(path) + ctx.contentResolver.query( + uri, + arrayOf( + android.provider.MediaStore.MediaColumns.DISPLAY_NAME, + android.provider.MediaStore.MediaColumns.RELATIVE_PATH, + ), + null, null, null, + )?.use { c -> + if (c.moveToFirst()) { + val name = c.getString(0) ?: path.substringAfterLast('/') + val rel = runCatching { c.getString(1) }.getOrNull().orEmpty() + return PhotoLoc(name, rel + name) + } + } + PhotoLoc(path.substringAfterLast('/'), path) + } catch (t: Throwable) { + PhotoLoc(path.substringAfterLast('/'), path) + } +} diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/RelationBuilder.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/RelationBuilder.kt new file mode 100644 index 0000000..df3472b --- /dev/null +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/crm/RelationBuilder.kt @@ -0,0 +1,70 @@ +package com.lemon.lifelog.crm + +import android.util.Log +import com.lemon.lifelog.data.Relation +import com.lemon.lifelog.data.Repository +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * 端上 CRM #1:从「会话」重建人↔人共现边。 + * + * 会话 = 按 5min 静音切的一段连续录音(同时间轴分组口径)。一个会话里「涉及的人」= + * - **在场**:段的声纹 → 已绑定的 Person(speakerId → voiceSpeakerId) + * - **被提到**:mentions 表里该段命中的 Person + * 两两配对 → 共现边,weight = 共现过的会话数。规范化 personA() + var start = 0 + val n = segs.size + while (start < n) { + var end = start + 1 + while (end < n && segs[end].startTs - segs[end - 1].startTs < SESSION_GAP_MS) end++ + // 会话 = segs[start until end] + val involved = LinkedHashSet() + var lastTs = segs[start].startTs + for (k in start until end) { + val sg = segs[k] + if (sg.startTs > lastTs) lastTs = sg.startTs + sg.speakerId?.let { sp -> speakerToPerson[sp]?.let { involved.add(it) } } + mentionsBySeg[sg.id]?.let { involved.addAll(it) } + } + if (involved.size >= 2) { + val list = involved.toList() + for (i in list.indices) for (j in i + 1 until list.size) { + val a = minOf(list[i], list[j]) + val b = maxOf(list[i], list[j]) + val e = acc.getOrPut("$a:$b") { Acc() } + e.weight++ + if (lastTs > e.lastTs) e.lastTs = lastTs + } + } + start = end + } + + val rows = acc.entries.map { (k, v) -> + val (a, b) = k.split(":") + Relation(personA = a.toLong(), personB = b.toLong(), weight = v.weight, lastTs = v.lastTs) + } + rows.chunked(500).forEach { repo.relations.insertAll(it) } + Log.i(TAG, "rebuild: ${segs.size} segs → ${rows.size} relations") + rows.size + } +} diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/GalleryScanner.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/GalleryScanner.kt index a15bafe..fbada62 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/GalleryScanner.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/GalleryScanner.kt @@ -22,6 +22,7 @@ object GalleryScanner { /** 扫一遍相册,登记新图片。返回本轮新增数。 */ suspend fun scan(ctx: Context, repo: Repository): Int { val known = repo.photos.knownMediaIds().toHashSet() + known.addAll(repo.photos.deletedMediaIds()) // 用户删过的别再导回来 val collection = MediaStore.Images.Media.getContentUri(MediaStore.VOLUME_EXTERNAL) val proj = arrayOf( MediaStore.Images.Media._ID, -- libgit2 0.21.4