Commit eb247605b26f46c046fcb758ab1fc0dd1044f63a
1 parent
44c68767
feat(goal): P2 #6/#7/#8 — Goal OS 地基 + AI 工具
P2 #6 Goal 实体(DB v23→v24): - goals 表:id/text/status/deadline/parentGoalId/personId/notes/createdAt/updatedAt - status: active/paused/done/cancelled - parentGoalId 支持树形分解(创业→融资→找VC→约张总) - GoalDao:insert/get/all/observeAll/observeByStatus/roots/children/updateStatus/updateNotes/delete P2 #7 Followup→Goal 挂载: - followups 表加 goalId 列 - FollowupDao 加 updateGoal / observeByGoal / all(一次性查,供 LLM 文本匹配) - FollowupRow 加 goalId 字段 P2 #8 AI Goal 工具(4 个): - create_goal:建目标,支持截止日期/父目标/关联人物 - get_goals:树形展示目标+子目标+已挂待跟进,支持状态过滤 - link_followup_to_goal:按 id 或关键词匹配,批量关联 followup→goal - update_goal_status:标完成/暂停/重启/放弃 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Showing
6 changed files
with
285 additions
and
7 deletions
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/ChatViewModel.kt
| ... | ... | @@ -312,15 +312,21 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) { |
| 312 | 312 | 【关系网络 & 组织】 |
| 313 | 313 | ⑲ get_stale_contacts — 列出长期未联系的人(args: min_days默认30, limit默认20) |
| 314 | 314 | ⑳ get_org_members — 查某组织的成员列表(args: org_name) |
| 315 | - ㉑ link_person_org — 将人物关联到组织/公司(args: person_name, org_name, kind可选company|school|org|project, role可选, start_date可选如2023-01, end_date可选) | |
| 315 | + ㉑ link_person_org — 将人物关联到组织/公司(args: person_name, org_name, kind可选, role可选, start_date可选) | |
| 316 | 316 | ㉒ find_relevant_persons — 根据目标找最相关的人「谁能帮我」(args: goal, limit默认10) |
| 317 | 317 | |
| 318 | + 【目标管理 Goal OS】 | |
| 319 | + ㉓ create_goal — 创建目标(args: text, deadline可选如2026-09-01, parent_goal_id可选, person_name可选, notes可选) | |
| 320 | + ㉔ get_goals — 查目标列表,含子目标树+关联待跟进(args: status=active|paused|done|cancelled|all,默认active) | |
| 321 | + ㉕ link_followup_to_goal — 把待跟进事项挂到目标(args: goal_id, followup_id或followup_text) | |
| 322 | + ㉖ update_goal_status — 更新目标状态(args: goal_id, status=active|paused|done|cancelled) | |
| 323 | + | |
| 318 | 324 | 【数据刷新】 |
| 319 | - ㉓ refresh_person_data — 刷新某人微信聊天+朋友圈(args: person_name) | |
| 325 | + ㉗ refresh_person_data — 刷新某人微信聊天+朋友圈(args: person_name) | |
| 320 | 326 | ★ 查询某人聊天/朋友圈/时间线前,必须先调此工具确保数据最新! |
| 321 | 327 | |
| 322 | 328 | 【全网搜索】 |
| 323 | - ㉔ web_search — Tavily 搜索互联网(args: query, max_results默认5;需设置中配置 Key) | |
| 329 | + ㉘ web_search — Tavily 搜索互联网(args: query, max_results默认5;需设置中配置 Key) | |
| 324 | 330 | |
| 325 | 331 | 需要查询时,输出(每个工具独占一行,严格 JSON): |
| 326 | 332 | TOOL_CALL: {"name": "工具名", "args": {"参数": "值"}} | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/chat/LifelogTools.kt
| ... | ... | @@ -264,6 +264,45 @@ object LifelogTools { |
| 264 | 264 | ), |
| 265 | 265 | required = listOf("goal"), |
| 266 | 266 | )) |
| 267 | + put(schema( | |
| 268 | + name = "create_goal", | |
| 269 | + desc = "创建一个新目标(支持树形分解:可指定父目标 + 关联人物)", | |
| 270 | + props = mapOf( | |
| 271 | + "text" to prop("string", "目标内容,如「完成融资 A 轮」"), | |
| 272 | + "deadline" to prop("string", "截止日期 ISO 格式如 2026-09-01,可选"), | |
| 273 | + "parent_goal_id" to prop("integer", "父目标 id(子目标用),可选"), | |
| 274 | + "person_name" to prop("string", "关联人物姓名,可选"), | |
| 275 | + "notes" to prop("string", "备注,可选"), | |
| 276 | + ), | |
| 277 | + required = listOf("text"), | |
| 278 | + )) | |
| 279 | + put(schema( | |
| 280 | + name = "get_goals", | |
| 281 | + desc = "查看目标列表(支持按状态过滤,默认返回活跃目标;展示目标树结构 + 关联的待跟进事项)", | |
| 282 | + props = mapOf( | |
| 283 | + "status" to prop("string", "状态过滤:active(活跃)/ paused(暂停)/ done(完成)/ cancelled(放弃)/ all(全部),默认 active"), | |
| 284 | + ), | |
| 285 | + required = emptyList(), | |
| 286 | + )) | |
| 287 | + put(schema( | |
| 288 | + name = "link_followup_to_goal", | |
| 289 | + desc = "将一条待跟进事项(Followup)关联到某个目标,支持按 id 或关键词匹配", | |
| 290 | + props = mapOf( | |
| 291 | + "goal_id" to prop("integer", "目标 id(从 get_goals 获取)"), | |
| 292 | + "followup_id" to prop("integer", "待跟进事项 id(与 followup_text 二选一)"), | |
| 293 | + "followup_text" to prop("string", "待跟进事项关键词(模糊匹配,与 followup_id 二选一)"), | |
| 294 | + ), | |
| 295 | + required = listOf("goal_id"), | |
| 296 | + )) | |
| 297 | + put(schema( | |
| 298 | + name = "update_goal_status", | |
| 299 | + desc = "更新目标状态(完成/暂停/重启/放弃)", | |
| 300 | + props = mapOf( | |
| 301 | + "goal_id" to prop("integer", "目标 id"), | |
| 302 | + "status" to prop("string", "新状态:active / paused / done / cancelled"), | |
| 303 | + ), | |
| 304 | + required = listOf("goal_id", "status"), | |
| 305 | + )) | |
| 267 | 306 | } |
| 268 | 307 | } |
| 269 | 308 | |
| ... | ... | @@ -314,6 +353,10 @@ object LifelogTools { |
| 314 | 353 | "get_org_members" -> getOrgMembers(repo, args) |
| 315 | 354 | "link_person_org" -> linkPersonOrg(repo, args) |
| 316 | 355 | "find_relevant_persons" -> findRelevantPersons(repo, args) |
| 356 | + "create_goal" -> createGoal(repo, args) | |
| 357 | + "get_goals" -> getGoals(repo, args) | |
| 358 | + "link_followup_to_goal" -> linkFollowupToGoal(repo, args) | |
| 359 | + "update_goal_status" -> updateGoalStatus(repo, args) | |
| 317 | 360 | else -> "未知工具:$name" |
| 318 | 361 | } |
| 319 | 362 | } catch (t: Throwable) { |
| ... | ... | @@ -958,6 +1001,132 @@ object LifelogTools { |
| 958 | 1001 | } |
| 959 | 1002 | } |
| 960 | 1003 | |
| 1004 | + // ── P2 #8 Goal OS 工具 ──────────────────────────────────────────────────── | |
| 1005 | + | |
| 1006 | + private suspend fun createGoal(repo: Repository, args: JSONObject): String { | |
| 1007 | + val text = args.optString("text", "").ifBlank { return "请提供目标内容" } | |
| 1008 | + val deadline = args.optString("deadline", "").ifBlank { null } | |
| 1009 | + val parentId = args.optLong("parent_goal_id", 0).takeIf { it > 0 } | |
| 1010 | + val personName = args.optString("person_name", "").ifBlank { null } | |
| 1011 | + val notes = args.optString("notes", "").ifBlank { null } | |
| 1012 | + | |
| 1013 | + // 可选关联人物 | |
| 1014 | + val personId = personName?.let { q -> | |
| 1015 | + repo.persons.all().firstOrNull { p -> | |
| 1016 | + p.name.contains(q) || p.realName?.contains(q) == true || p.nickname?.contains(q) == true | |
| 1017 | + }?.id | |
| 1018 | + } | |
| 1019 | + | |
| 1020 | + // 校验父目标存在 | |
| 1021 | + if (parentId != null && repo.goals.get(parentId) == null) | |
| 1022 | + return "找不到父目标 id=$parentId,请先用 get_goals 确认 id。" | |
| 1023 | + | |
| 1024 | + val now = System.currentTimeMillis() | |
| 1025 | + val id = repo.goals.insert( | |
| 1026 | + com.lemon.lifelog.data.Goal( | |
| 1027 | + text = text, | |
| 1028 | + deadline = deadline, | |
| 1029 | + parentGoalId = parentId, | |
| 1030 | + personId = personId, | |
| 1031 | + notes = notes, | |
| 1032 | + createdAt = now, | |
| 1033 | + updatedAt = now, | |
| 1034 | + ) | |
| 1035 | + ) | |
| 1036 | + val parentLabel = if (parentId != null) repo.goals.get(parentId)?.text?.take(30)?.let { "「$it」的子目标" } ?: "" else "" | |
| 1037 | + val deadlineStr = deadline?.let { ",截止 $it" } ?: "" | |
| 1038 | + val personStr = personId?.let { repo.persons.get(it)?.name?.let { n -> ",关联:$n" } } ?: "" | |
| 1039 | + return "✅ 目标已创建(id=$id):${text}${deadlineStr}${personStr}${if (parentLabel.isNotBlank()) "\n→ $parentLabel" else ""}" | |
| 1040 | + } | |
| 1041 | + | |
| 1042 | + private suspend fun getGoals(repo: Repository, args: JSONObject): String { | |
| 1043 | + val statusArg = args.optString("status", "active").ifBlank { "active" } | |
| 1044 | + val allGoals = if (statusArg == "all") repo.goals.all() | |
| 1045 | + else repo.goals.all().filter { it.status == statusArg } | |
| 1046 | + if (allGoals.isEmpty()) return "暂无「$statusArg」状态的目标。" | |
| 1047 | + | |
| 1048 | + val allFollowups = repo.followups.all() | |
| 1049 | + val followupsByGoal = allFollowups.groupBy { it.goalId } | |
| 1050 | + val personMap = repo.persons.all().associate { it.id to (it.realName ?: it.nickname ?: it.name) } | |
| 1051 | + | |
| 1052 | + val statusLabel = mapOf("active" to "活跃", "paused" to "暂停", "done" to "完成", "cancelled" to "放弃") | |
| 1053 | + | |
| 1054 | + // 树形输出:先输出根目标,再缩进子目标 | |
| 1055 | + val rootGoals = allGoals.filter { it.parentGoalId == null } | |
| 1056 | + val childMap = allGoals.filter { it.parentGoalId != null }.groupBy { it.parentGoalId } | |
| 1057 | + | |
| 1058 | + fun goalBlock(g: com.lemon.lifelog.data.Goal, indent: String): String = buildString { | |
| 1059 | + val status = statusLabel[g.status] ?: g.status | |
| 1060 | + val dl = g.deadline?.let { " 截止$it" } ?: "" | |
| 1061 | + val who = g.personId?.let { personMap[it]?.let { n -> " 关联:$n" } } ?: "" | |
| 1062 | + appendLine("${indent}[${g.id}] $status ◆ ${g.text}$dl$who") | |
| 1063 | + g.notes?.takeIf { it.isNotBlank() }?.let { appendLine("${indent} 备注:$it") } | |
| 1064 | + val followups = followupsByGoal[g.id] | |
| 1065 | + if (!followups.isNullOrEmpty()) { | |
| 1066 | + val open = followups.filter { !it.done } | |
| 1067 | + val done = followups.filter { it.done } | |
| 1068 | + if (open.isNotEmpty()) open.forEach { f -> appendLine("${indent} ○ ${f.text}") } | |
| 1069 | + if (done.isNotEmpty()) done.take(3).forEach { f -> appendLine("${indent} ✓ ${f.text}") } | |
| 1070 | + } | |
| 1071 | + } | |
| 1072 | + | |
| 1073 | + return buildString { | |
| 1074 | + appendLine("目标列表(状态:$statusArg,共 ${allGoals.size} 条):") | |
| 1075 | + rootGoals.forEach { root -> | |
| 1076 | + append(goalBlock(root, "")) | |
| 1077 | + childMap[root.id]?.forEach { child -> | |
| 1078 | + append(goalBlock(child, " └ ")) | |
| 1079 | + childMap[child.id]?.forEach { grandchild -> | |
| 1080 | + append(goalBlock(grandchild, " └ ")) | |
| 1081 | + } | |
| 1082 | + } | |
| 1083 | + } | |
| 1084 | + // 有父目标但父不在当前筛选结果里的孤儿目标 | |
| 1085 | + val orphans = allGoals.filter { it.parentGoalId != null && rootGoals.none { r -> r.id == it.parentGoalId } && !rootGoals.any { r -> childMap[r.id]?.any { c -> c.id == it.parentGoalId } == true } } | |
| 1086 | + if (orphans.isNotEmpty()) { | |
| 1087 | + appendLine("\n(其他子目标)") | |
| 1088 | + orphans.forEach { append(goalBlock(it, " ")) } | |
| 1089 | + } | |
| 1090 | + } | |
| 1091 | + } | |
| 1092 | + | |
| 1093 | + private suspend fun linkFollowupToGoal(repo: Repository, args: JSONObject): String { | |
| 1094 | + val goalId = args.optLong("goal_id", 0).takeIf { it > 0 } | |
| 1095 | + ?: return "请提供 goal_id(从 get_goals 获取)" | |
| 1096 | + val goal = repo.goals.get(goalId) ?: return "找不到目标 id=$goalId" | |
| 1097 | + | |
| 1098 | + val followupId = args.optLong("followup_id", 0).takeIf { it > 0 } | |
| 1099 | + val followupText = args.optString("followup_text", "").ifBlank { null } | |
| 1100 | + | |
| 1101 | + if (followupId == null && followupText == null) | |
| 1102 | + return "请提供 followup_id 或 followup_text" | |
| 1103 | + | |
| 1104 | + val allFollowups = repo.followups.all() | |
| 1105 | + val targets: List<com.lemon.lifelog.data.FollowupRow> = when { | |
| 1106 | + followupId != null -> allFollowups.filter { it.id == followupId } | |
| 1107 | + else -> allFollowups.filter { it.text.contains(followupText!!, ignoreCase = true) }.take(5) | |
| 1108 | + } | |
| 1109 | + if (targets.isEmpty()) return "找不到匹配的待跟进事项(关键词:「${followupText ?: followupId}」)" | |
| 1110 | + | |
| 1111 | + targets.forEach { repo.followups.updateGoal(it.id, goalId) } | |
| 1112 | + return buildString { | |
| 1113 | + appendLine("✅ 已关联 ${targets.size} 条待跟进事项 → 目标「${goal.text}」:") | |
| 1114 | + targets.forEach { appendLine(" - ${it.text}") } | |
| 1115 | + } | |
| 1116 | + } | |
| 1117 | + | |
| 1118 | + private suspend fun updateGoalStatus(repo: Repository, args: JSONObject): String { | |
| 1119 | + val goalId = args.optLong("goal_id", 0).takeIf { it > 0 } | |
| 1120 | + ?: return "请提供 goal_id" | |
| 1121 | + val status = args.optString("status", "").ifBlank { return "请提供状态:active / paused / done / cancelled" } | |
| 1122 | + val valid = setOf("active", "paused", "done", "cancelled") | |
| 1123 | + if (status !in valid) return "无效状态「$status」,可选:${valid.joinToString(" / ")}" | |
| 1124 | + val goal = repo.goals.get(goalId) ?: return "找不到目标 id=$goalId" | |
| 1125 | + repo.goals.updateStatus(goalId, status, System.currentTimeMillis()) | |
| 1126 | + val label = mapOf("active" to "重新激活", "paused" to "暂停", "done" to "标为完成 ✅", "cancelled" to "放弃") | |
| 1127 | + return "目标「${goal.text}」已${label[status]}。" | |
| 1128 | + } | |
| 1129 | + | |
| 961 | 1130 | // ── 人物时间线 ─────────────────────────────────────────────────────────── |
| 962 | 1131 | |
| 963 | 1132 | private suspend fun getPersonTimeline(repo: Repository, args: JSONObject): String { | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt
| ... | ... | @@ -29,8 +29,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase |
| 29 | 29 | TopicMention::class, |
| 30 | 30 | Organization::class, |
| 31 | 31 | PersonOrg::class, |
| 32 | + Goal::class, | |
| 32 | 33 | ], |
| 33 | - version = 23, | |
| 34 | + version = 24, | |
| 34 | 35 | exportSchema = false, |
| 35 | 36 | ) |
| 36 | 37 | abstract class AppDatabase : RoomDatabase() { |
| ... | ... | @@ -50,6 +51,7 @@ abstract class AppDatabase : RoomDatabase() { |
| 50 | 51 | abstract fun topicDao(): TopicDao |
| 51 | 52 | abstract fun organizationDao(): OrganizationDao |
| 52 | 53 | abstract fun personOrgDao(): PersonOrgDao |
| 54 | + abstract fun goalDao(): GoalDao | |
| 53 | 55 | |
| 54 | 56 | companion object { |
| 55 | 57 | @Volatile |
| ... | ... | @@ -252,6 +254,26 @@ abstract class AppDatabase : RoomDatabase() { |
| 252 | 254 | } |
| 253 | 255 | } |
| 254 | 256 | |
| 257 | + /** P2 #6/#7:goals 新表 + followups.goalId 挂载字段。 */ | |
| 258 | + private val MIGRATION_23_24 = object : Migration(23, 24) { | |
| 259 | + override fun migrate(db: SupportSQLiteDatabase) { | |
| 260 | + db.execSQL( | |
| 261 | + "CREATE TABLE IF NOT EXISTS `goals` (" + | |
| 262 | + "`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + | |
| 263 | + "`text` TEXT NOT NULL, " + | |
| 264 | + "`status` TEXT NOT NULL DEFAULT 'active', " + | |
| 265 | + "`deadline` TEXT, " + | |
| 266 | + "`parentGoalId` INTEGER, " + | |
| 267 | + "`personId` INTEGER, " + | |
| 268 | + "`notes` TEXT, " + | |
| 269 | + "`createdAt` INTEGER NOT NULL, " + | |
| 270 | + "`updatedAt` INTEGER NOT NULL)" | |
| 271 | + ) | |
| 272 | + db.execSQL("CREATE INDEX IF NOT EXISTS `index_goals_status` ON `goals` (`status`)") | |
| 273 | + db.execSQL("ALTER TABLE `followups` ADD COLUMN `goalId` INTEGER") | |
| 274 | + } | |
| 275 | + } | |
| 276 | + | |
| 255 | 277 | fun get(ctx: Context): AppDatabase = |
| 256 | 278 | instance ?: synchronized(this) { |
| 257 | 279 | instance ?: Room.databaseBuilder( |
| ... | ... | @@ -259,7 +281,7 @@ abstract class AppDatabase : RoomDatabase() { |
| 259 | 281 | AppDatabase::class.java, |
| 260 | 282 | "lifelog.db", |
| 261 | 283 | ) |
| 262 | - .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20, MIGRATION_20_21, MIGRATION_21_22, MIGRATION_22_23) | |
| 284 | + .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16, MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20, MIGRATION_20_21, MIGRATION_21_22, MIGRATION_22_23, MIGRATION_23_24) | |
| 263 | 285 | // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration, |
| 264 | 286 | // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。 |
| 265 | 287 | .build() | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
| ... | ... | @@ -399,6 +399,7 @@ data class FollowupRow( |
| 399 | 399 | val personName: String?, |
| 400 | 400 | val sourceTs: Long, |
| 401 | 401 | val done: Boolean, |
| 402 | + val goalId: Long? = null, | |
| 402 | 403 | ) |
| 403 | 404 | |
| 404 | 405 | @Dao |
| ... | ... | @@ -409,6 +410,9 @@ interface FollowupDao { |
| 409 | 410 | @Query("UPDATE followups SET done = :done WHERE id = :id") |
| 410 | 411 | suspend fun setDone(id: Long, done: Boolean) |
| 411 | 412 | |
| 413 | + @Query("UPDATE followups SET goalId = :goalId WHERE id = :id") | |
| 414 | + suspend fun updateGoal(id: Long, goalId: Long?) | |
| 415 | + | |
| 412 | 416 | @Query("DELETE FROM followups WHERE id = :id") |
| 413 | 417 | suspend fun delete(id: Long) |
| 414 | 418 | |
| ... | ... | @@ -418,14 +422,66 @@ interface FollowupDao { |
| 418 | 422 | /** 未完成在前、按来源时间倒序。 */ |
| 419 | 423 | @Query( |
| 420 | 424 | """ |
| 421 | - SELECT f.id AS id, f.text AS text, f.personId AS personId, p.name AS personName, | |
| 422 | - f.sourceTs AS sourceTs, f.done AS done | |
| 425 | + SELECT f.id, f.text, f.personId, p.name AS personName, | |
| 426 | + f.sourceTs, f.done, f.goalId | |
| 423 | 427 | FROM followups f |
| 424 | 428 | LEFT JOIN persons p ON p.id = f.personId |
| 425 | 429 | ORDER BY f.done ASC, f.sourceTs DESC |
| 426 | 430 | """, |
| 427 | 431 | ) |
| 428 | 432 | fun observeAll(): Flow<List<FollowupRow>> |
| 433 | + | |
| 434 | + /** 挂在某个目标下的待跟进事项。 */ | |
| 435 | + @Query( | |
| 436 | + """ | |
| 437 | + SELECT f.id, f.text, f.personId, p.name AS personName, | |
| 438 | + f.sourceTs, f.done, f.goalId | |
| 439 | + FROM followups f | |
| 440 | + LEFT JOIN persons p ON p.id = f.personId | |
| 441 | + WHERE f.goalId = :goalId | |
| 442 | + ORDER BY f.done ASC, f.sourceTs DESC | |
| 443 | + """, | |
| 444 | + ) | |
| 445 | + fun observeByGoal(goalId: Long): Flow<List<FollowupRow>> | |
| 446 | + | |
| 447 | + /** 一次性取,供 LLM 工具匹配文本。 */ | |
| 448 | + @Query("SELECT f.id, f.text, f.personId, p.name AS personName, f.sourceTs, f.done, f.goalId FROM followups f LEFT JOIN persons p ON p.id = f.personId ORDER BY f.done ASC, f.sourceTs DESC") | |
| 449 | + suspend fun all(): List<FollowupRow> | |
| 450 | +} | |
| 451 | + | |
| 452 | +// ── Goal OS ─────────────────────────────────────────────────────────────────── | |
| 453 | + | |
| 454 | +@Dao | |
| 455 | +interface GoalDao { | |
| 456 | + @Insert(onConflict = OnConflictStrategy.REPLACE) | |
| 457 | + suspend fun insert(g: Goal): Long | |
| 458 | + | |
| 459 | + @Query("SELECT * FROM goals ORDER BY status ASC, createdAt DESC") | |
| 460 | + fun observeAll(): Flow<List<Goal>> | |
| 461 | + | |
| 462 | + @Query("SELECT * FROM goals WHERE status = :status ORDER BY createdAt DESC") | |
| 463 | + fun observeByStatus(status: String): Flow<List<Goal>> | |
| 464 | + | |
| 465 | + @Query("SELECT * FROM goals WHERE id = :id") | |
| 466 | + suspend fun get(id: Long): Goal? | |
| 467 | + | |
| 468 | + @Query("SELECT * FROM goals WHERE parentGoalId IS NULL ORDER BY status ASC, createdAt DESC") | |
| 469 | + suspend fun roots(): List<Goal> | |
| 470 | + | |
| 471 | + @Query("SELECT * FROM goals WHERE parentGoalId = :parentId ORDER BY createdAt DESC") | |
| 472 | + suspend fun children(parentId: Long): List<Goal> | |
| 473 | + | |
| 474 | + @Query("UPDATE goals SET status = :status, updatedAt = :ts WHERE id = :id") | |
| 475 | + suspend fun updateStatus(id: Long, status: String, ts: Long) | |
| 476 | + | |
| 477 | + @Query("UPDATE goals SET notes = :notes, updatedAt = :ts WHERE id = :id") | |
| 478 | + suspend fun updateNotes(id: Long, notes: String?, ts: Long) | |
| 479 | + | |
| 480 | + @Query("SELECT * FROM goals ORDER BY status ASC, createdAt DESC") | |
| 481 | + suspend fun all(): List<Goal> | |
| 482 | + | |
| 483 | + @Query("DELETE FROM goals WHERE id = :id") | |
| 484 | + suspend fun delete(id: Long) | |
| 429 | 485 | } |
| 430 | 486 | |
| 431 | 487 | /** 话题行(含被提到次数 + 最近时间)。 */ | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt
| ... | ... | @@ -230,6 +230,8 @@ data class Followup( |
| 230 | 230 | val text: String, |
| 231 | 231 | /** 关联人物(可空,从文本匹配 Person) */ |
| 232 | 232 | val personId: Long? = null, |
| 233 | + /** 挂载到的目标(可空,由 AI 或用户手动关联) */ | |
| 234 | + val goalId: Long? = null, | |
| 233 | 235 | /** 来源录音时间(窗口内最新段的墙钟) */ |
| 234 | 236 | val sourceTs: Long, |
| 235 | 237 | val done: Boolean = false, |
| ... | ... | @@ -237,6 +239,28 @@ data class Followup( |
| 237 | 239 | ) |
| 238 | 240 | |
| 239 | 241 | /** |
| 242 | + * Goal OS 目标实体。支持树形分解([parentGoalId] 非空 = 子目标)。 | |
| 243 | + * [status]:active / paused / done / cancelled。 | |
| 244 | + */ | |
| 245 | +@Entity(tableName = "goals") | |
| 246 | +data class Goal( | |
| 247 | + @PrimaryKey(autoGenerate = true) val id: Long = 0, | |
| 248 | + val text: String, | |
| 249 | + /** active / paused / done / cancelled */ | |
| 250 | + val status: String = "active", | |
| 251 | + /** 截止日期,ISO 日期字符串如 "2026-09-01",可空 */ | |
| 252 | + val deadline: String? = null, | |
| 253 | + /** 父目标 id(非空 = 子目标,支持树形分解) */ | |
| 254 | + val parentGoalId: Long? = null, | |
| 255 | + /** 与哪个人相关(可空) */ | |
| 256 | + val personId: Long? = null, | |
| 257 | + /** 备注(AI 或用户填写) */ | |
| 258 | + val notes: String? = null, | |
| 259 | + val createdAt: Long = System.currentTimeMillis(), | |
| 260 | + val updatedAt: Long = System.currentTimeMillis(), | |
| 261 | +) | |
| 262 | + | |
| 263 | +/** | |
| 240 | 264 | * 人↔人「共现」无向边:同一会话(5min 静音切)里同时**在场**(声纹匹配)或**被提到**(mentions) |
| 241 | 265 | * 的两个人 → 一条边,[weight] = 共现过的会话数。规范化存 [personA] < [personB],(A,B) 唯一。 |
| 242 | 266 | */ | ... | ... |
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt