From dc240b6abee805a042661f831ebb68937c7b3d12 Mon Sep 17 00:00:00 2001 From: yibo Date: Wed, 10 Jun 2026 15:08:59 +0800 Subject: [PATCH] feat(tools): update_person_profile 覆盖简历/AI印象/真名三个直接列 --- lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogTools.kt | 189 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------ lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt | 6 ++++++ 2 files changed, 183 insertions(+), 12 deletions(-) diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogTools.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogTools.kt index 021a624..8163898 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogTools.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogTools.kt @@ -51,10 +51,12 @@ object LifelogTools { )) put(schema( name = "update_person_profile", - desc = "更新联系人的结构化档案字段(MBTI、八字、性格描述、社媒账号等)", + desc = "更新联系人档案的任意字段(简历/AI印象/真名/MBTI/八字/性格/社媒等)。" + + "直接列字段:realName(真名)/ resume(简历)/ impression(AI印象)。" + + "结构化档案字段:mbti / bazi / personality / blog / github / linkedin / x / youtube / xiaohongshu / douyin / weibo / website / notes。", props = mapOf( "name" to prop("string", "人物姓名或备注名"), - "field" to prop("string", "要更新的字段:mbti / bazi / personality / blog / github / linkedin / x / youtube / xiaohongshu / douyin / weibo / website / notes"), + "field" to prop("string", "要更新的字段名,见 desc"), "value" to prop("string", "新值;空字符串表示清除该字段"), ), required = listOf("name", "field", "value"), @@ -152,6 +154,45 @@ object LifelogTools { ), required = listOf("person_name"), )) + put(schema( + name = "get_topics", + desc = "获取从录音中抽取的话题/实体列表(组织/项目/事件),按出现热度排序", + props = mapOf( + "kind" to prop("string", "过滤类型:org(组织)/ project(项目)/ event(事件),不填则返回全部"), + "limit" to prop("integer", "返回条数,默认 20"), + ), + required = emptyList(), + )) + put(schema( + name = "search_by_time", + desc = "按时间段查询录音转写(如「昨天下午 3-5 点说了什么」,需提供 Unix 毫秒时间戳)", + props = mapOf( + "start_ts" to prop("integer", "开始时间戳(毫秒)"), + "end_ts" to prop("integer", "结束时间戳(毫秒)"), + "limit" to prop("integer", "返回条数,默认 20"), + ), + required = listOf("start_ts", "end_ts"), + )) + put(schema( + name = "get_conversation_context", + desc = "给定一段录音的 segmentId 或时间戳,返回其前后 N 分钟的对话上下文", + props = mapOf( + "segment_id" to prop("integer", "录音段 ID(与 timestamp 二选一)"), + "timestamp_ms" to prop("integer", "时间戳毫秒(与 segment_id 二选一)"), + "window_minutes" to prop("integer", "前后各几分钟,默认 5"), + "limit" to prop("integer", "返回段数上限,默认 30"), + ), + required = emptyList(), + )) + put(schema( + name = "get_person_timeline", + desc = "获取某人所有关联事件的时间线:声纹录音段 + 被提到 + 微信聊天 + 朋友圈,按时间倒序", + props = mapOf( + "person_name" to prop("string", "人物姓名"), + "limit" to prop("integer", "每类最多取多少条,默认 10"), + ), + required = listOf("person_name"), + )) } } @@ -190,9 +231,13 @@ object LifelogTools { "get_moments" -> getMoments(repo, args) "search_moments" -> searchMoments(repo, args) "get_person_photos" -> getPersonPhotos(repo, args) - "get_relations" -> getRelations(repo, args) - "get_mentions" -> getMentions(repo, args) - else -> "未知工具:$name" + "get_relations" -> getRelations(repo, args) + "get_mentions" -> getMentions(repo, args) + "get_topics" -> getTopics(repo, args) + "search_by_time" -> searchByTime(repo, args) + "get_conversation_context" -> getConversationContext(repo, args) + "get_person_timeline" -> getPersonTimeline(repo, args) + else -> "未知工具:$name" } } catch (t: Throwable) { "工具执行出错($name):${t.message?.take(120)}" @@ -262,19 +307,29 @@ object LifelogTools { private suspend fun updatePersonProfile(repo: Repository, args: JSONObject): String { val q = args.optString("name", "").ifBlank { return "请提供人物姓名" } - val field = args.optString("field", "").ifBlank { return "请提供字段名(如 mbti, github, personality 等)" } + val field = args.optString("field", "").ifBlank { return "请提供字段名" } val value = args.optString("value", "") - val allowed = setOf("mbti", "bazi", "personality", "blog", "github", "linkedin", "x", "youtube", "xiaohongshu", "douyin", "weibo", "website", "notes") - if (field !in allowed) return "不支持的字段「$field」。可用字段:${allowed.joinToString(", ")}" + val directCols = setOf("realName", "resume", "impression") + val jsonFields = setOf("mbti", "bazi", "personality", "blog", "github", "linkedin", "x", "youtube", "xiaohongshu", "douyin", "weibo", "website", "notes") + if (field !in directCols && field !in jsonFields) + return "不支持的字段「$field」。可用:${(directCols + jsonFields).joinToString(", ")}" val persons = repo.persons.all().filter { p -> p.name.contains(q) || p.realName?.contains(q) == true || p.nickname?.contains(q) == true || p.remark?.contains(q) == true } if (persons.isEmpty()) return "未找到与「$q」匹配的联系人。" val p = persons.first() - val obj = try { JSONObject(p.profileJson ?: "{}") } catch (_: Exception) { JSONObject() } - if (value.isBlank()) obj.remove(field) else obj.put(field, value.trim()) - repo.persons.updateProfileJson(p.id, if (obj.length() == 0) null else obj.toString()) - return "已更新 ${p.name} 的档案字段 $field${if (value.isBlank()) "(已清除)" else " = ${value.trim()}"}" + val v = value.trim().ifBlank { null } + when (field) { + "resume" -> repo.persons.updateResume(p.id, v) + "impression" -> repo.persons.updateImpression(p.id, v ?: "") + "realName" -> repo.persons.updateRealName(p.id, v) + else -> { + val obj = try { JSONObject(p.profileJson ?: "{}") } catch (_: Exception) { JSONObject() } + if (v == null) obj.remove(field) else obj.put(field, v) + repo.persons.updateProfileJson(p.id, if (obj.length() == 0) null else obj.toString()) + } + } + return "已更新 ${p.name} 的 $field${if (v == null) "(已清除)" else " = $v"}" } private suspend fun listPersons(repo: Repository, args: JSONObject): String { @@ -489,4 +544,114 @@ object LifelogTools { } } } + + // ── 话题实体 ────────────────────────────────────────────────────────────── + + private suspend fun getTopics(repo: Repository, args: JSONObject): String { + val kind = args.optString("kind", "").ifBlank { null } + val limit = args.optInt("limit", 20).coerceIn(1, 100) + val all = repo.topics.observeAll().first() + val rows = (if (kind != null) all.filter { it.kind == kind } else all).take(limit) + if (rows.isEmpty()) return "暂无话题实体(需录音 + LLM 抽取后才有数据)。" + val kindLabel = mapOf("org" to "组织", "project" to "项目", "event" to "事件") + return buildString { + appendLine("话题实体(${rows.size} 条${if (kind != null) ",类型=${kindLabel[kind] ?: kind}" else ""}):") + rows.forEach { t -> + appendLine("- [${kindLabel[t.kind] ?: t.kind}] ${t.name}(被提到 ${t.count} 次,最近 ${TS.format(Date(t.lastTs))})") + } + } + } + + // ── 时间段查询 ─────────────────────────────────────────────────────────── + + private suspend fun searchByTime(repo: Repository, args: JSONObject): String { + val start = args.optLong("start_ts", 0).takeIf { it > 0 } ?: return "请提供 start_ts(毫秒时间戳)" + val end = args.optLong("end_ts", 0).takeIf { it > 0 } ?: return "请提供 end_ts(毫秒时间戳)" + if (end <= start) return "end_ts 必须大于 start_ts" + val limit = args.optInt("limit", 20).coerceIn(1, 60) + val rows = repo.segments.searchByTimeRange(start, end, limit) + if (rows.isEmpty()) return "该时间段(${TS.format(Date(start))}–${TS.format(Date(end))})内无录音记录。" + return buildString { + appendLine("时间段 ${TS.format(Date(start))}–${TS.format(Date(end))} 内录音 ${rows.size} 段:") + rows.forEach { s -> + val who = s.speakerName ?: s.personName ?: "未知" + appendLine("[${TSL.format(Date(s.startTs))}] $who:${s.text?.trim()?.take(200) ?: "(无转写)"}") + } + } + } + + // ── 对话上下文 ─────────────────────────────────────────────────────────── + + private suspend fun getConversationContext(repo: Repository, args: JSONObject): String { + val segId = args.optLong("segment_id", 0).takeIf { it > 0 } + val tsArg = args.optLong("timestamp_ms", 0).takeIf { it > 0 } + val window = args.optInt("window_minutes", 5).coerceIn(1, 30) + val limit = args.optInt("limit", 30).coerceIn(1, 60) + + val centerTs: Long = when { + segId != null -> repo.segments.getSegMeta(segId)?.startTs + ?: return "找不到 segment_id=$segId 的录音段" + tsArg != null -> tsArg + else -> return "请提供 segment_id 或 timestamp_ms" + } + val halfMs = window * 60_000L + val rows = repo.segments.contextAround(centerTs - halfMs, centerTs + halfMs, limit) + if (rows.isEmpty()) return "该时间点(${TSL.format(Date(centerTs))})前后 ${window} 分钟内无录音。" + return buildString { + appendLine("时间点 ${TSL.format(Date(centerTs))} 前后 ${window} 分钟上下文(${rows.size} 段):") + rows.forEach { s -> + val who = s.speakerName ?: s.personName ?: "未知" + val marker = if (segId != null && s.id == segId) " ◀ 当前段" else "" + appendLine("[${TSL.format(Date(s.startTs))}] $who:${s.text?.trim()?.take(200) ?: "(无转写)"}$marker") + } + } + } + + // ── 人物时间线 ─────────────────────────────────────────────────────────── + + private suspend fun getPersonTimeline(repo: Repository, args: JSONObject): String { + val name = args.optString("person_name", "").ifBlank { return "请提供人物姓名" } + val limit = args.optInt("limit", 10).coerceIn(1, 30) + + val person = repo.persons.all().firstOrNull { p -> + p.name.contains(name) || p.realName?.contains(name) == true || + p.nickname?.contains(name) == true + } ?: return "未找到与「$name」匹配的联系人。" + + data class Event(val ts: Long, val kind: String, val desc: String) + val events = mutableListOf() + + // 声纹录音段(TA 自己说的) + if (person.voiceSpeakerId != null) { + repo.segments.observeBySpeaker(person.voiceSpeakerId, limit).first().forEach { s -> + events.add(Event(s.startTs, "🎙 录音", s.text?.trim()?.take(100) ?: "(无转写)")) + } + } + // 被提到的录音段 + repo.mentions.observeMentionsOf(person.id, limit).first().forEach { s -> + val who = s.speakerName ?: "某人" + events.add(Event(s.startTs, "💬 被提到", "$who 说:${s.text?.trim()?.take(80) ?: "…"}")) + } + // 微信聊天 + val wxid = person.contactRef?.removePrefix("wechat:") + if (wxid != null) { + repo.wechatMessages.observeByTalker(wxid).first().takeLast(limit).forEach { m -> + val dir = if (m.isSend) "我→${person.name}" else "${person.name}→我" + events.add(Event(m.createTime, "💌 微信", "$dir:${m.content.take(80)}")) + } + // 朋友圈 + repo.moments.byUser(wxid, limit).forEach { m -> + events.add(Event(m.createTime, "🌐 朋友圈", m.text.take(100))) + } + } + + if (events.isEmpty()) return "「${person.name}」暂无任何关联事件记录。" + return buildString { + appendLine("「${person.name}」时间线(${events.size} 条事件,时间倒序):") + events.sortedByDescending { it.ts }.take(limit * 3).forEach { e -> + appendLine("[${TS.format(Date(e.ts))}] ${e.kind} ${e.desc}") + } + } + } + } diff --git a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt index 2f62851..bca960b 100644 --- a/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt +++ b/lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt @@ -646,6 +646,12 @@ interface PersonDao { @Query("UPDATE persons SET profileJson = :json WHERE id = :id") suspend fun updateProfileJson(id: Long, json: String?) + @Query("UPDATE persons SET resume = :resume WHERE id = :id") + suspend fun updateResume(id: Long, resume: String?) + + @Query("UPDATE persons SET realName = :name WHERE id = :id") + suspend fun updateRealName(id: Long, name: String?) + /** 合并人物:写回合并后的整条记录(按主键更新)。 */ @Update suspend fun update(p: Person) -- libgit2 0.21.4