Commit dc240b6abee805a042661f831ebb68937c7b3d12
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>
Showing
2 changed files
with
183 additions
and
12 deletions
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogTools.kt
| @@ -51,10 +51,12 @@ object LifelogTools { | @@ -51,10 +51,12 @@ object LifelogTools { | ||
| 51 | )) | 51 | )) |
| 52 | put(schema( | 52 | put(schema( |
| 53 | name = "update_person_profile", | 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 | props = mapOf( | 57 | props = mapOf( |
| 56 | "name" to prop("string", "人物姓名或备注名"), | 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 | "value" to prop("string", "新值;空字符串表示清除该字段"), | 60 | "value" to prop("string", "新值;空字符串表示清除该字段"), |
| 59 | ), | 61 | ), |
| 60 | required = listOf("name", "field", "value"), | 62 | required = listOf("name", "field", "value"), |
| @@ -152,6 +154,45 @@ object LifelogTools { | @@ -152,6 +154,45 @@ object LifelogTools { | ||
| 152 | ), | 154 | ), |
| 153 | required = listOf("person_name"), | 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,9 +231,13 @@ object LifelogTools { | ||
| 190 | "get_moments" -> getMoments(repo, args) | 231 | "get_moments" -> getMoments(repo, args) |
| 191 | "search_moments" -> searchMoments(repo, args) | 232 | "search_moments" -> searchMoments(repo, args) |
| 192 | "get_person_photos" -> getPersonPhotos(repo, args) | 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 | } catch (t: Throwable) { | 242 | } catch (t: Throwable) { |
| 198 | "工具执行出错($name):${t.message?.take(120)}" | 243 | "工具执行出错($name):${t.message?.take(120)}" |
| @@ -262,19 +307,29 @@ object LifelogTools { | @@ -262,19 +307,29 @@ object LifelogTools { | ||
| 262 | 307 | ||
| 263 | private suspend fun updatePersonProfile(repo: Repository, args: JSONObject): String { | 308 | private suspend fun updatePersonProfile(repo: Repository, args: JSONObject): String { |
| 264 | val q = args.optString("name", "").ifBlank { return "请提供人物姓名" } | 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 | val value = args.optString("value", "") | 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 | val persons = repo.persons.all().filter { p -> | 316 | val persons = repo.persons.all().filter { p -> |
| 270 | p.name.contains(q) || p.realName?.contains(q) == true || p.nickname?.contains(q) == true || p.remark?.contains(q) == true | 317 | p.name.contains(q) || p.realName?.contains(q) == true || p.nickname?.contains(q) == true || p.remark?.contains(q) == true |
| 271 | } | 318 | } |
| 272 | if (persons.isEmpty()) return "未找到与「$q」匹配的联系人。" | 319 | if (persons.isEmpty()) return "未找到与「$q」匹配的联系人。" |
| 273 | val p = persons.first() | 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 | private suspend fun listPersons(repo: Repository, args: JSONObject): String { | 335 | private suspend fun listPersons(repo: Repository, args: JSONObject): String { |
| @@ -489,4 +544,114 @@ object LifelogTools { | @@ -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,6 +646,12 @@ interface PersonDao { | ||
| 646 | @Query("UPDATE persons SET profileJson = :json WHERE id = :id") | 646 | @Query("UPDATE persons SET profileJson = :json WHERE id = :id") |
| 647 | suspend fun updateProfileJson(id: Long, json: String?) | 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 | @Update | 656 | @Update |
| 651 | suspend fun update(p: Person) | 657 | suspend fun update(p: Person) |