Commit dc240b6abee805a042661f831ebb68937c7b3d12

Authored by yibo
1 parent 8f47214f

feat(tools): update_person_profile 覆盖简历/AI印象/真名三个直接列

扩展 update_person_profile 工具支持 resume / impression / realName
三个直接列(原来只能改 profileJson 里的 JSON 字段);PersonDao 加
updateResume + updateRealName 两个精确列更新查询。AI 现在可用单一
工具写入所有联系人档案字段,无需多个工具。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogTools.kt
... ... @@ -51,10 +51,12 @@ object LifelogTools {
51 51 ))
52 52 put(schema(
53 53 name = "update_person_profile",
54   - desc = "更新联系人的结构化档案字段(MBTI、八字、性格描述、社媒账号等)",
  54 + desc = "更新联系人档案的任意字段(简历/AI印象/真名/MBTI/八字/性格/社媒等)。" +
  55 + "直接列字段:realName(真名)/ resume(简历)/ impression(AI印象)。" +
  56 + "结构化档案字段:mbti / bazi / personality / blog / github / linkedin / x / youtube / xiaohongshu / douyin / weibo / website / notes。",
55 57 props = mapOf(
56 58 "name" to prop("string", "人物姓名或备注名"),
57   - "field" to prop("string", "要更新的字段:mbti / bazi / personality / blog / github / linkedin / x / youtube / xiaohongshu / douyin / weibo / website / notes"),
  59 + "field" to prop("string", "要更新的字段名,见 desc"),
58 60 "value" to prop("string", "新值;空字符串表示清除该字段"),
59 61 ),
60 62 required = listOf("name", "field", "value"),
... ... @@ -152,6 +154,45 @@ object LifelogTools {
152 154 ),
153 155 required = listOf("person_name"),
154 156 ))
  157 + put(schema(
  158 + name = "get_topics",
  159 + desc = "获取从录音中抽取的话题/实体列表(组织/项目/事件),按出现热度排序",
  160 + props = mapOf(
  161 + "kind" to prop("string", "过滤类型:org(组织)/ project(项目)/ event(事件),不填则返回全部"),
  162 + "limit" to prop("integer", "返回条数,默认 20"),
  163 + ),
  164 + required = emptyList(),
  165 + ))
  166 + put(schema(
  167 + name = "search_by_time",
  168 + desc = "按时间段查询录音转写(如「昨天下午 3-5 点说了什么」,需提供 Unix 毫秒时间戳)",
  169 + props = mapOf(
  170 + "start_ts" to prop("integer", "开始时间戳(毫秒)"),
  171 + "end_ts" to prop("integer", "结束时间戳(毫秒)"),
  172 + "limit" to prop("integer", "返回条数,默认 20"),
  173 + ),
  174 + required = listOf("start_ts", "end_ts"),
  175 + ))
  176 + put(schema(
  177 + name = "get_conversation_context",
  178 + desc = "给定一段录音的 segmentId 或时间戳,返回其前后 N 分钟的对话上下文",
  179 + props = mapOf(
  180 + "segment_id" to prop("integer", "录音段 ID(与 timestamp 二选一)"),
  181 + "timestamp_ms" to prop("integer", "时间戳毫秒(与 segment_id 二选一)"),
  182 + "window_minutes" to prop("integer", "前后各几分钟,默认 5"),
  183 + "limit" to prop("integer", "返回段数上限,默认 30"),
  184 + ),
  185 + required = emptyList(),
  186 + ))
  187 + put(schema(
  188 + name = "get_person_timeline",
  189 + desc = "获取某人所有关联事件的时间线:声纹录音段 + 被提到 + 微信聊天 + 朋友圈,按时间倒序",
  190 + props = mapOf(
  191 + "person_name" to prop("string", "人物姓名"),
  192 + "limit" to prop("integer", "每类最多取多少条,默认 10"),
  193 + ),
  194 + required = listOf("person_name"),
  195 + ))
155 196 }
156 197 }
157 198  
... ... @@ -190,9 +231,13 @@ object LifelogTools {
190 231 "get_moments" -> getMoments(repo, args)
191 232 "search_moments" -> searchMoments(repo, args)
192 233 "get_person_photos" -> getPersonPhotos(repo, args)
193   - "get_relations" -> getRelations(repo, args)
194   - "get_mentions" -> getMentions(repo, args)
195   - else -> "未知工具:$name"
  234 + "get_relations" -> getRelations(repo, args)
  235 + "get_mentions" -> getMentions(repo, args)
  236 + "get_topics" -> getTopics(repo, args)
  237 + "search_by_time" -> searchByTime(repo, args)
  238 + "get_conversation_context" -> getConversationContext(repo, args)
  239 + "get_person_timeline" -> getPersonTimeline(repo, args)
  240 + else -> "未知工具:$name"
196 241 }
197 242 } catch (t: Throwable) {
198 243 "工具执行出错($name):${t.message?.take(120)}"
... ... @@ -262,19 +307,29 @@ object LifelogTools {
262 307  
263 308 private suspend fun updatePersonProfile(repo: Repository, args: JSONObject): String {
264 309 val q = args.optString("name", "").ifBlank { return "请提供人物姓名" }
265   - val field = args.optString("field", "").ifBlank { return "请提供字段名(如 mbti, github, personality 等)" }
  310 + val field = args.optString("field", "").ifBlank { return "请提供字段名" }
266 311 val value = args.optString("value", "")
267   - val allowed = setOf("mbti", "bazi", "personality", "blog", "github", "linkedin", "x", "youtube", "xiaohongshu", "douyin", "weibo", "website", "notes")
268   - if (field !in allowed) return "不支持的字段「$field」。可用字段:${allowed.joinToString(", ")}"
  312 + val directCols = setOf("realName", "resume", "impression")
  313 + val jsonFields = setOf("mbti", "bazi", "personality", "blog", "github", "linkedin", "x", "youtube", "xiaohongshu", "douyin", "weibo", "website", "notes")
  314 + if (field !in directCols && field !in jsonFields)
  315 + return "不支持的字段「$field」。可用:${(directCols + jsonFields).joinToString(", ")}"
269 316 val persons = repo.persons.all().filter { p ->
270 317 p.name.contains(q) || p.realName?.contains(q) == true || p.nickname?.contains(q) == true || p.remark?.contains(q) == true
271 318 }
272 319 if (persons.isEmpty()) return "未找到与「$q」匹配的联系人。"
273 320 val p = persons.first()
274   - val obj = try { JSONObject(p.profileJson ?: "{}") } catch (_: Exception) { JSONObject() }
275   - if (value.isBlank()) obj.remove(field) else obj.put(field, value.trim())
276   - repo.persons.updateProfileJson(p.id, if (obj.length() == 0) null else obj.toString())
277   - return "已更新 ${p.name} 的档案字段 $field${if (value.isBlank()) "(已清除)" else " = ${value.trim()}"}"
  321 + val v = value.trim().ifBlank { null }
  322 + when (field) {
  323 + "resume" -> repo.persons.updateResume(p.id, v)
  324 + "impression" -> repo.persons.updateImpression(p.id, v ?: "")
  325 + "realName" -> repo.persons.updateRealName(p.id, v)
  326 + else -> {
  327 + val obj = try { JSONObject(p.profileJson ?: "{}") } catch (_: Exception) { JSONObject() }
  328 + if (v == null) obj.remove(field) else obj.put(field, v)
  329 + repo.persons.updateProfileJson(p.id, if (obj.length() == 0) null else obj.toString())
  330 + }
  331 + }
  332 + return "已更新 ${p.name} 的 $field${if (v == null) "(已清除)" else " = $v"}"
278 333 }
279 334  
280 335 private suspend fun listPersons(repo: Repository, args: JSONObject): String {
... ... @@ -489,4 +544,114 @@ object LifelogTools {
489 544 }
490 545 }
491 546 }
  547 +
  548 + // ── 话题实体 ──────────────────────────────────────────────────────────────
  549 +
  550 + private suspend fun getTopics(repo: Repository, args: JSONObject): String {
  551 + val kind = args.optString("kind", "").ifBlank { null }
  552 + val limit = args.optInt("limit", 20).coerceIn(1, 100)
  553 + val all = repo.topics.observeAll().first()
  554 + val rows = (if (kind != null) all.filter { it.kind == kind } else all).take(limit)
  555 + if (rows.isEmpty()) return "暂无话题实体(需录音 + LLM 抽取后才有数据)。"
  556 + val kindLabel = mapOf("org" to "组织", "project" to "项目", "event" to "事件")
  557 + return buildString {
  558 + appendLine("话题实体(${rows.size} 条${if (kind != null) ",类型=${kindLabel[kind] ?: kind}" else ""}):")
  559 + rows.forEach { t ->
  560 + appendLine("- [${kindLabel[t.kind] ?: t.kind}] ${t.name}(被提到 ${t.count} 次,最近 ${TS.format(Date(t.lastTs))})")
  561 + }
  562 + }
  563 + }
  564 +
  565 + // ── 时间段查询 ───────────────────────────────────────────────────────────
  566 +
  567 + private suspend fun searchByTime(repo: Repository, args: JSONObject): String {
  568 + val start = args.optLong("start_ts", 0).takeIf { it > 0 } ?: return "请提供 start_ts(毫秒时间戳)"
  569 + val end = args.optLong("end_ts", 0).takeIf { it > 0 } ?: return "请提供 end_ts(毫秒时间戳)"
  570 + if (end <= start) return "end_ts 必须大于 start_ts"
  571 + val limit = args.optInt("limit", 20).coerceIn(1, 60)
  572 + val rows = repo.segments.searchByTimeRange(start, end, limit)
  573 + if (rows.isEmpty()) return "该时间段(${TS.format(Date(start))}–${TS.format(Date(end))})内无录音记录。"
  574 + return buildString {
  575 + appendLine("时间段 ${TS.format(Date(start))}–${TS.format(Date(end))} 内录音 ${rows.size} 段:")
  576 + rows.forEach { s ->
  577 + val who = s.speakerName ?: s.personName ?: "未知"
  578 + appendLine("[${TSL.format(Date(s.startTs))}] $who:${s.text?.trim()?.take(200) ?: "(无转写)"}")
  579 + }
  580 + }
  581 + }
  582 +
  583 + // ── 对话上下文 ───────────────────────────────────────────────────────────
  584 +
  585 + private suspend fun getConversationContext(repo: Repository, args: JSONObject): String {
  586 + val segId = args.optLong("segment_id", 0).takeIf { it > 0 }
  587 + val tsArg = args.optLong("timestamp_ms", 0).takeIf { it > 0 }
  588 + val window = args.optInt("window_minutes", 5).coerceIn(1, 30)
  589 + val limit = args.optInt("limit", 30).coerceIn(1, 60)
  590 +
  591 + val centerTs: Long = when {
  592 + segId != null -> repo.segments.getSegMeta(segId)?.startTs
  593 + ?: return "找不到 segment_id=$segId 的录音段"
  594 + tsArg != null -> tsArg
  595 + else -> return "请提供 segment_id 或 timestamp_ms"
  596 + }
  597 + val halfMs = window * 60_000L
  598 + val rows = repo.segments.contextAround(centerTs - halfMs, centerTs + halfMs, limit)
  599 + if (rows.isEmpty()) return "该时间点(${TSL.format(Date(centerTs))})前后 ${window} 分钟内无录音。"
  600 + return buildString {
  601 + appendLine("时间点 ${TSL.format(Date(centerTs))} 前后 ${window} 分钟上下文(${rows.size} 段):")
  602 + rows.forEach { s ->
  603 + val who = s.speakerName ?: s.personName ?: "未知"
  604 + val marker = if (segId != null && s.id == segId) " ◀ 当前段" else ""
  605 + appendLine("[${TSL.format(Date(s.startTs))}] $who:${s.text?.trim()?.take(200) ?: "(无转写)"}$marker")
  606 + }
  607 + }
  608 + }
  609 +
  610 + // ── 人物时间线 ───────────────────────────────────────────────────────────
  611 +
  612 + private suspend fun getPersonTimeline(repo: Repository, args: JSONObject): String {
  613 + val name = args.optString("person_name", "").ifBlank { return "请提供人物姓名" }
  614 + val limit = args.optInt("limit", 10).coerceIn(1, 30)
  615 +
  616 + val person = repo.persons.all().firstOrNull { p ->
  617 + p.name.contains(name) || p.realName?.contains(name) == true ||
  618 + p.nickname?.contains(name) == true
  619 + } ?: return "未找到与「$name」匹配的联系人。"
  620 +
  621 + data class Event(val ts: Long, val kind: String, val desc: String)
  622 + val events = mutableListOf<Event>()
  623 +
  624 + // 声纹录音段(TA 自己说的)
  625 + if (person.voiceSpeakerId != null) {
  626 + repo.segments.observeBySpeaker(person.voiceSpeakerId, limit).first().forEach { s ->
  627 + events.add(Event(s.startTs, "🎙 录音", s.text?.trim()?.take(100) ?: "(无转写)"))
  628 + }
  629 + }
  630 + // 被提到的录音段
  631 + repo.mentions.observeMentionsOf(person.id, limit).first().forEach { s ->
  632 + val who = s.speakerName ?: "某人"
  633 + events.add(Event(s.startTs, "💬 被提到", "$who 说:${s.text?.trim()?.take(80) ?: "…"}"))
  634 + }
  635 + // 微信聊天
  636 + val wxid = person.contactRef?.removePrefix("wechat:")
  637 + if (wxid != null) {
  638 + repo.wechatMessages.observeByTalker(wxid).first().takeLast(limit).forEach { m ->
  639 + val dir = if (m.isSend) "我→${person.name}" else "${person.name}→我"
  640 + events.add(Event(m.createTime, "💌 微信", "$dir:${m.content.take(80)}"))
  641 + }
  642 + // 朋友圈
  643 + repo.moments.byUser(wxid, limit).forEach { m ->
  644 + events.add(Event(m.createTime, "🌐 朋友圈", m.text.take(100)))
  645 + }
  646 + }
  647 +
  648 + if (events.isEmpty()) return "「${person.name}」暂无任何关联事件记录。"
  649 + return buildString {
  650 + appendLine("「${person.name}」时间线(${events.size} 条事件,时间倒序):")
  651 + events.sortedByDescending { it.ts }.take(limit * 3).forEach { e ->
  652 + appendLine("[${TS.format(Date(e.ts))}] ${e.kind} ${e.desc}")
  653 + }
  654 + }
  655 + }
  656 +
492 657 }
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
... ... @@ -646,6 +646,12 @@ interface PersonDao {
646 646 @Query("UPDATE persons SET profileJson = :json WHERE id = :id")
647 647 suspend fun updateProfileJson(id: Long, json: String?)
648 648  
  649 + @Query("UPDATE persons SET resume = :resume WHERE id = :id")
  650 + suspend fun updateResume(id: Long, resume: String?)
  651 +
  652 + @Query("UPDATE persons SET realName = :name WHERE id = :id")
  653 + suspend fun updateRealName(id: Long, name: String?)
  654 +
649 655 /** 合并人物:写回合并后的整条记录(按主键更新)。 */
650 656 @Update
651 657 suspend fun update(p: Person)
... ...