diff --git a/apps/df-miniapp/src/composables/useAiChat.ts b/apps/df-miniapp/src/composables/useAiChat.ts index e26b66f..bc34903 100644 --- a/apps/df-miniapp/src/composables/useAiChat.ts +++ b/apps/df-miniapp/src/composables/useAiChat.ts @@ -29,7 +29,6 @@ * - listSkills(): 拉技能列表(list_skills Command → AiSkillList 事件回流 skills) * - listEntities(): 拉实体列表(list_entities Command → AiEntityList 事件回流 entities) * - approve(toolCallId, approved): 审批工具调用(approve Command → device invoke ai_approve) - * - authorizeDir(toolCallId, decision): 路径授权(authorize_dir Command → device invoke ai_authorize_dir) * - continueLoop(convId): 继续循环(continue_loop Command → device invoke ai_continue_loop) * - stopLoop(convId): 停止循环(stop_loop Command → device invoke ai_stop_loop) */ @@ -235,7 +234,7 @@ const deviceOnline = ref(false) * 现机制(130s 持续 watchdog,非首事件清除): * - send 后 resetWatchdog 启动 * - 活跃事件(AiTextDelta/AiAgentRound/AiToolCallStarted/Heartbeat/Retry)resetWatchdog 重置 130s - * - 终态(AiCompleted/AiError)/挂起(Approval/DirAuth/MaxRounds/Help)/断连 clearWatchdog 清除 + * - 终态(AiCompleted/AiError)/挂起(Approval/MaxRounds/Help)/断连 clearWatchdog 清除 * - 超时(130s 无活跃事件)= 中途断 → flush + 错误气泡 + reset generating,用户可重发 */ let streamTimer: ReturnType | null = null @@ -312,7 +311,7 @@ function resetWatchdog(): void { const APPROVAL_TIMEOUT_MS = 300_000 // 5 分钟 const _approvalTimers = new Map>() -/** 启动审批超时计时器(AiApprovalRequired/AiDirAuthRequired 触发)。 */ +/** 启动审批超时计时器(AiApprovalRequired 触发)。 */ function startApprovalTimer(tcId: string, toolName: string): void { if (_approvalTimers.has(tcId)) return // 幂等 const timer = setTimeout(() => { @@ -768,37 +767,6 @@ export function handleEvent(event: AiChatEvent): void { break } - case 'AiDirAuthRequired': { - clearWatchdog() // 挂起等授权,停看门狗 - startApprovalTimer(event.id, event.tool) // 5min 超时自动拒绝 - // F-#95 P0-3:路径授权进 pendingApprovals(kind=path),chat 页渲染 once/always/deny 按钮 - // (替代旧 system 文案降级「请在桌面端处理」,miniapp 成轻量审批终端,治 loop 卡死)。 - const authInfo: AiToolCallInfo = { - id: event.id, - name: event.tool, - args: {}, - status: 'pending_approval', - kind: 'path', - dir: event.dir, - path: event.path, - } - // req3 去重:同 AiApprovalRequired(sync_pending 重发 + 实时事件竞态防重复) - if (!pendingApprovals.value.some((p) => p.id === authInfo.id)) { - pendingApprovals.value.push(authInfo) - } - // 对齐 AiApprovalRequired:同步更新消息内工具卡片为 pending_approval + path 授权信息, - // 使 chat 页消息内联渲染 once/always/deny 按钮(单一渲染源,避免消息 tc 卡 running 态)。 - const authTc = findToolCall(event.id) - if (authTc) { - authTc.status = 'pending_approval' - authTc.kind = 'path' - authTc.dir = event.dir - authTc.path = event.path - triggerRef(messages) // P1-5:路径授权态嵌套变更,刷新工具卡 - } - break - } - case 'AiCompleted': { stopping = false // F9:终态清停止标志 flushCurrentText() @@ -1158,26 +1126,6 @@ function approve(toolCallId: string, approved: boolean): void { } /** - * 路径授权(authorize_dir Command → relay → device invoke ai_authorize_dir) - * - * Phase3 §2.2 路由表:{cmd:"authorize_dir", args:{tool_call_id, decision}} - * 对应事件:AiDirAuthRequired(决策:'once'=本次 / 'always'=永久 / 'deny'=拒绝)。 - */ -function authorizeDir(toolCallId: string, decision: 'once' | 'always' | 'deny'): void { - if (!toolCallId) return - const cmd: MiniCommand = { - cmd: 'authorize_dir', - args: { - tool_call_id: toolCallId, - decision, - }, - } - if (!wsClient.send(cmd)) { - // 发送失败:wsClient.send 返 false(未连接),提示用户操作未发出 - uni.showToast({ title: '未连接,操作未发出', icon: 'none' }) - } -} - /** * 继续循环(continue_loop Command → relay → device invoke ai_continue_loop) * @@ -1475,7 +1423,6 @@ export function useAiChat() { setActiveModel, newConversation, approve, - authorizeDir, continueLoop, stopLoop, handleEvent, diff --git a/apps/df-miniapp/src/pages/chat/index.vue b/apps/df-miniapp/src/pages/chat/index.vue index 4a3aafa..c5a579d 100644 --- a/apps/df-miniapp/src/pages/chat/index.vue +++ b/apps/df-miniapp/src/pages/chat/index.vue @@ -543,7 +543,7 @@ function handleSend(): void { } // —— 工具卡片 + 审批 UI(对齐桌面端 ToolCard,适配 mp-weixin 深色风格) —— -// 后端推 AiToolCallStarted/AiApprovalRequired/AiDirAuthRequired → useAiChat 收进 +// 后端推 AiToolCallStarted/AiApprovalRequired → useAiChat 收进 // 消息 m.toolCalls(状态机)+ pendingApprovals。消息内联渲染工具状态 + 待审批按钮, // miniapp 成轻量审批终端(单一渲染源,对齐桌面 ToolCard 嵌消息气泡)。 @@ -666,11 +666,6 @@ function onReject(tc: AiToolCallInfo): void { ai.approve(tc.id, false) } -/** 路径授权(path 类:once=本次 / always=永久 / deny=拒绝) */ -function onAuthorize(tc: AiToolCallInfo, decision: 'once' | 'always' | 'deny'): void { - ai.authorizeDir(tc.id, decision) -} - /** 连接状态中文文案(F21:裸枚举字符串 disconnected/connecting 不可读) */ const STATUS_TEXT: Record = { connecting: '连接中…', @@ -782,23 +777,14 @@ function tokenInOf(t: TokenUsage): number { {{ a.val }} - - 📁 {{ p.dir }} - ⚠ {{ p.reason }} - - + + - - - - - - @@ -859,10 +845,6 @@ function tokenInOf(t: TokenUsage): number { {{ a.val }} - - - 📁 {{ tc.dir }} - ⚠ {{ tc.reason }} @@ -1494,13 +1476,6 @@ function tokenInOf(t: TokenUsage): number { flex: 1; word-break: break-all; /* 工具参数值(路径/长字符串)断行防撑开 */ } -.tool-dir { - margin-top: 4px; -} -.tool-dir text { - color: #6bd99a; - font-size: 12px; -} .tool-reason { margin-top: 6px; padding: 4px 6px; diff --git a/apps/df-miniapp/src/types/events.ts b/apps/df-miniapp/src/types/events.ts index 22dd7c5..67e06ed 100644 --- a/apps/df-miniapp/src/types/events.ts +++ b/apps/df-miniapp/src/types/events.ts @@ -64,14 +64,6 @@ export type AiChatEvent = | { type: 'AiHeartbeat'; conversation_id?: string | null } | { type: 'AiMaxRoundsReached'; conversation_id?: string | null } | { type: 'AiStreamRetry'; attempt: number; max_attempts: number; conversation_id?: string | null } - | { - type: 'AiDirAuthRequired' - id: string - tool: string - path: string - dir: string - conversation_id?: string | null - } | { type: 'AiContextCleared'; conversation_id?: string | null } | { type: 'AiCompressing'; conversation_id?: string | null } // 治 Task#1:拆分手动/自动两变体(字段相同仅 type 字面量不同),桌面按 type 分流, @@ -154,10 +146,6 @@ export interface AiToolCallInfo { status: 'running' | 'completed' | 'failed' | 'pending_approval' | 'rejected' reason?: string diff?: string - /** 审批类型(risk 普通 / path 路径授权,对齐后端 ApprovalKind) */ - kind?: 'risk' | 'path' - dir?: string - path?: string } /** 挂起审批项(复用 AiToolCallInfo 结构,miniapp 不实现审批 UI 仅展示) */ diff --git a/apps/df-miniapp/src/types/relay.ts b/apps/df-miniapp/src/types/relay.ts index 9fe954a..c800b01 100644 --- a/apps/df-miniapp/src/types/relay.ts +++ b/apps/df-miniapp/src/types/relay.ts @@ -86,9 +86,9 @@ export type ControlMessage = * - cmd:命令名(对齐 src-tauri command 函数名) * - args:命令参数(对齐各 command 的参数) * - * 路由表全覆盖(14 条): + * 路由表全覆盖: * - send_message / stop / switch_conversation / regenerate(MVP 已发) - * - approve / authorize_dir / continue_loop / stop_loop(阶段3 补齐,见 useAiChat.ts) + * - approve / continue_loop / stop_loop(见 useAiChat.ts;authorize_dir 已随 path_auth 退役) * - list_conversations / load_messages(F-#95 扩展,会话列表/历史消息读取) * - list_skills / list_entities(F-#95 联想扩展,技能/项目·任务·灵感列表,见 useAiChat.ts) * - rename_conversation(会话重命名,2026-06-23)/ sync_pending(重连审批恢复,2026-06-23) @@ -207,21 +207,6 @@ export interface ApproveArgs { approved: boolean } -/** - * authorize_dir 命令参数(对齐 src-tauri ai_authorize_dir,Phase3 §2.2 路由表) - * - * decision 取值:对齐 src-tauri ai_authorize_dir 的 decision 参数 - * - 'once':本次授权(下次同路径仍需问) - * - 'always':永久授权(写入会话级信任目录) - * - 'deny':拒绝(本次不授权) - */ -export interface AuthorizeDirArgs { - /** 待授权的工具调用 id(对应 AiDirAuthRequired 事件的 id) */ - tool_call_id: string - /** 授权决策('once' / 'always' / 'deny') */ - decision: 'once' | 'always' | 'deny' -} - /** continue_loop 命令参数(对齐 src-tauri ai_continue_loop,Phase3 §2.2 路由表) */ export interface ContinueLoopArgs { /** 需继续运行的会话 id */ diff --git a/crates/df-storage/src/crud/idea_repo.rs b/crates/df-storage/src/crud/idea_repo.rs index d671440..a3e4def 100644 --- a/crates/df-storage/src/crud/idea_repo.rs +++ b/crates/df-storage/src/crud/idea_repo.rs @@ -8,7 +8,7 @@ use tokio::sync::Mutex; use df_types::error::Result; use crate::db::Database; -use crate::models::{IdeaRecord, KnowledgeEventRecord, KnowledgeRecord}; +use crate::models::{IdeaRecord, KnowledgeEdgeRecord, KnowledgeEventRecord, KnowledgeRecord, RelatedKnowledge}; use df_types::types::IdeaStatus; use super::impl_repo; @@ -90,7 +90,8 @@ fn parse_json_id_array(raw: &Option) -> Vec { /// /// 检索词里的 `%`/`_`/`|` 会被当成通配/转义符,用户输入与分词产物都可能含这些字符, /// 统一在此转义,避免关键词意外扩大命中面。 -fn escape_like(s: &str) -> String { +/// pub(crate):message_repo 关键词检索(P1-A)同规则复用,单一实现。 +pub(crate) fn escape_like(s: &str) -> String { s.replace('|', "||").replace('%', "|%").replace('_', "|_") } @@ -796,10 +797,14 @@ impl KnowledgeRepo { "status = 'published'" }; let limit_i = limit as i64; + // confidence 同档决胜键(P0 淘汰机制,2026-10-23):title 分打平时置信先验优先, + // 与 search_keywords_scoped 同序(后者第一档位键为 project=None 特例不参与)。 let sql = format!( "SELECT {KNOWLEDGE_COLS} FROM knowledges \ WHERE {status_clause} AND ({}) \ - ORDER BY ({}) DESC, reuse_count DESC, created_at DESC LIMIT ?{}", + ORDER BY ({}) DESC, \ + (CASE confidence WHEN 'high' THEN 2 WHEN 'medium' THEN 1 ELSE 0 END) DESC, \ + reuse_count DESC, created_at DESC LIMIT ?{}", where_parts.join(" OR "), title_score_terms.join(" + "), patterns.len() + 1, @@ -862,6 +867,8 @@ impl KnowledgeRepo { // 项目档位表达式复用 ?(n+1):0=同项目 1=全局(NULL) 2=异项目 // 档位是**第一排序键**(ASC:0 最前),title 命分是同档内第二键(DESC)—— // 若档位与 title 分相加,档位会被 title 分稀释(异项目 title 高分压过同项目)。 + // confidence 是同档内第三键(P0 淘汰机制,2026-10-23):低置信噪音条与高置信 + // 经验条 title 分打平时,置信先验决胜,减少 low 条目挤占检索带宽。 let pid_n = keywords.len() + 1; let project_tier = format!("CASE WHEN source_project = ?{pid_n} THEN 0 WHEN source_project IS NULL THEN 1 ELSE 2 END"); @@ -875,7 +882,9 @@ impl KnowledgeRepo { let sql = format!( "SELECT {KNOWLEDGE_COLS} FROM knowledges \ WHERE {status_clause} AND ({}) \ - ORDER BY ({project_tier}) ASC, ({title_terms}) DESC, reuse_count DESC, created_at DESC LIMIT ?{}", + ORDER BY ({project_tier}) ASC, ({title_terms}) DESC, \ + (CASE confidence WHEN 'high' THEN 2 WHEN 'medium' THEN 1 ELSE 0 END) DESC, \ + reuse_count DESC, created_at DESC LIMIT ?{}", where_parts.join(" OR "), pid_n + 1, ); @@ -899,6 +908,39 @@ impl KnowledgeRepo { .map_err(storage_err)? } + /// 按状态+kind 列出(蒸馏合并分组用,2026-10-24):created_at 升序(最老优先—— + /// 早期无防重时期的重复重灾区),limit 封顶单批蒸馏规模 + pub async fn list_by_status_kind( + &self, + status: &str, + kind: &str, + limit: usize, + ) -> Result> { + let conn = self.conn.clone(); + let status = status.to_owned(); + let kind = kind.to_owned(); + let limit_i = limit as i64; + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + let mut stmt = guard + .prepare(&format!( + "SELECT {KNOWLEDGE_COLS} FROM knowledges \ + WHERE status = ?1 AND kind = ?2 ORDER BY created_at ASC LIMIT ?3", + )) + .map_err(storage_err)?; + let rows = stmt + .query_map(params![status, kind, limit_i], |row| knowledge_from_row(row)) + .map_err(storage_err)?; + let mut results = Vec::new(); + for r in rows { + results.push(r.map_err(storage_err)?); + } + Ok(results) + }) + .await + .map_err(storage_err)? + } + /// 按状态列出(审核收件箱用): 按 confidence 语义排序(high>medium>low),次按 created_at /// /// 用 CASE WHEN 替代纯 TEXT 排序(字典序 high>low>medium 非预期语义)。 @@ -1003,6 +1045,265 @@ impl KnowledgeRepo { .map_err(storage_err)? } + /// 提炼查重融合更新:覆盖 content + reasoning 追加演进轨迹(P0 写入防重,2026-10-23) + /// + /// 背景(知识库治理审查 2026-10-23):提炼零查重直接落新条,同坑换会话再存一条, + /// 语义重复线性累积(10 月 695 条)。此方法是「重复经验并入已有条目」的落库通道: + /// LLM 已看过旧 content 后给出的融合版直接覆盖(单一真值=最新版),reasoning 尾部 + /// 追加一行更新轨迹(旧轨迹保留,演进可溯)。status/复用计数不动——update 不构成 + /// 复用证据,不参与「复用即信任」晋升。 + /// + /// UPDATE 幂等可重复调用;id 不存在返回 false(调用方降级走新建)。 + pub async fn update_content_merged( + &self, + id: &str, + content: &str, + reasoning_append: &str, + ) -> Result { + let conn = self.conn.clone(); + let id = id.to_owned(); + let content = content.to_owned(); + let reasoning_append = reasoning_append.to_owned(); + let now = now_millis_str(); + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + let affected = guard + .execute( + "UPDATE knowledges SET content = ?1, updated_at = ?2, \ + reasoning = CASE WHEN reasoning IS NULL OR reasoning = '' \ + THEN ?3 ELSE reasoning || char(10) || ?3 END \ + WHERE id = ?4", + params![content, now, reasoning_append, id], + ) + .map_err(storage_err)?; + Ok(affected > 0) + }) + .await + .map_err(storage_err)? + } + + /// 淘汰批:零复用 + 非 high 置信 + 超龄条目批量转 archived(P0 淘汰机制,2026-10-23) + /// + /// 背景:71% 条目零复用长尾不是中性的——每次检索都参与 LIKE 匹配和排序竞争, + /// 稀释 head 知识召回。检索面是 status 白名单(`IN ('published','candidate')`), + /// archived 天然被排除,故本方法只需改 status、无需动任何检索 SQL。 + /// + /// 判据校准(2026-10-23 生产实测):LLM 提炼 confidence 系统性偏高 + /// (high 1416/medium 233/low 仅 1),原 `confidence='low'` 判据命中 0 条形同虚设, + /// 校准为「非 high」(low/medium/NULL 同档)——高置信零复用长尾(632 条)不动, + /// 需单独拍板。 + /// + /// UPDATE...RETURNING 一次拿回归档 id 列表(SQLite 3.35+,bundled 3.45 支持), + /// 调用方逐条打时间线留痕。幂等:条件含 status 白名单,已 archived 不再命中。 + /// 返回归档的 id 列表(0 条=无可淘汰)。 + pub async fn archive_stale(&self, older_than_days: i64, limit: i64) -> Result> { + let conn = self.conn.clone(); + let cutoff = (now_millis_str().parse::().unwrap_or(0)) - older_than_days * 86_400_000; + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + let mut stmt = guard + .prepare( + "UPDATE knowledges SET status = 'archived', updated_at = ?1 \ + WHERE id IN (SELECT id FROM knowledges \ + WHERE status IN ('published','candidate') AND reuse_count = 0 \ + AND (confidence IS NULL OR confidence != 'high') AND created_at < ?2 \ + ORDER BY created_at LIMIT ?3) \ + RETURNING id", + ) + .map_err(storage_err)?; + let ids = stmt + .query_map(params![now_millis_str(), cutoff, limit], |row| row.get::<_, String>(0)) + .map_err(storage_err)? + .collect::, _>>() + .map_err(storage_err)?; + Ok(ids) + }) + .await + .map_err(storage_err)? + } + + /// 知识关联边:幂等插入(双向去重)+ 触碰验证时间(知识中枢阶段1,V54) + /// + /// 幂等语义:同两节点任意方向已有边 → 不重复建,只 touch last_verified_at(复验信号) + /// 并返回已有边 id。调用方(id 不存在的外键由应用层保证,表无 FK 约束与 knowledges 一致)。 + pub async fn insert_edge( + &self, + src_id: &str, + dst_id: &str, + kind: &str, + origin: &str, + ) -> Result { + let conn = self.conn.clone(); + let src_id = src_id.to_owned(); + let dst_id = dst_id.to_owned(); + let kind = kind.to_owned(); + let origin = origin.to_owned(); + let now = now_millis_str(); + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + // 双向去重(关联语义无方向):同对节点任一方向的既有边即复用 + let existing: Option = guard + .query_row( + "SELECT id FROM knowledge_edges \ + WHERE (src_id = ?1 AND dst_id = ?2) OR (src_id = ?2 AND dst_id = ?1) \ + LIMIT 1", + params![src_id, dst_id], + |row| row.get(0), + ) + .optional() + .map_err(storage_err)?; + if let Some(id) = existing { + guard + .execute( + "UPDATE knowledge_edges SET last_verified_at = ?1 WHERE id = ?2", + params![now, id], + ) + .map_err(storage_err)?; + return Ok(id); + } + let id = df_types::types::new_id(); + guard + .execute( + "INSERT INTO knowledge_edges (id, src_id, dst_id, kind, weight, origin, created_at, last_verified_at) \ + VALUES (?1, ?2, ?3, ?4, 0.5, ?5, ?6, ?6)", + params![id, src_id, dst_id, kind, origin, now], + ) + .map_err(storage_err)?; + Ok(id) + }) + .await + .map_err(storage_err)? + } + + /// 关联知识列表(双向读 + 对端条目展开,详情页「关联知识」区块消费) + /// + /// direction: out=边从当前条目指出,in=边指入当前条目。对端 archived 不展示 + /// (与检索面口径一致:归档知识退出活跃视图)。 + pub async fn list_related( + &self, + knowledge_id: &str, + limit: usize, + ) -> Result> { + let conn = self.conn.clone(); + let knowledge_id = knowledge_id.to_owned(); + let limit_i = limit as i64; + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + // 边表与知识表列名重叠(kind/created_at 等),KNOWLEDGE_COLS 裸名在 JOIN 下歧义, + // 统一加 k. 前缀(split-join 保单一列清单来源) + let k_cols = KNOWLEDGE_COLS + .split(',') + .map(|c| format!("k.{}", c.trim())) + .collect::>() + .join(", "); + let mut stmt = guard + .prepare(&format!( + "SELECT {k_cols}, e.id, e.src_id, e.dst_id, e.kind, e.weight, e.origin, e.created_at, e.last_verified_at, \ + CASE WHEN e.src_id = ?1 THEN 'out' ELSE 'in' END \ + FROM knowledge_edges e \ + JOIN knowledges k ON k.id = (CASE WHEN e.src_id = ?1 THEN e.dst_id ELSE e.src_id END) \ + WHERE (e.src_id = ?1 OR e.dst_id = ?1) AND k.status != 'archived' \ + ORDER BY e.weight DESC, e.last_verified_at DESC LIMIT ?2" + )) + .map_err(storage_err)?; + let mut rows = stmt + .query_map(params![knowledge_id, limit_i], |row| { + let edge = KnowledgeEdgeRecord { + id: row.get::<_, String>(KNOWLEDGE_COL_COUNT)?, + src_id: row.get::<_, String>(KNOWLEDGE_COL_COUNT + 1)?, + dst_id: row.get::<_, String>(KNOWLEDGE_COL_COUNT + 2)?, + kind: row.get::<_, String>(KNOWLEDGE_COL_COUNT + 3)?, + weight: row.get::<_, f64>(KNOWLEDGE_COL_COUNT + 4)?, + origin: row.get::<_, String>(KNOWLEDGE_COL_COUNT + 5)?, + created_at: row.get::<_, String>(KNOWLEDGE_COL_COUNT + 6)?, + last_verified_at: row.get::<_, String>(KNOWLEDGE_COL_COUNT + 7)?, + }; + Ok(RelatedKnowledge { + knowledge: knowledge_from_row(row)?, + edge, + direction: row.get::<_, String>(KNOWLEDGE_COL_COUNT + 8)?, + }) + }) + .map_err(storage_err)?; + let mut results = Vec::new(); + while let Some(r) = rows.next() { + results.push(r.map_err(storage_err)?); + } + Ok(results) + }) + .await + .map_err(storage_err)? + } + + /// 图召回·激活扩散(多种子 1-hop):命中集种子沿边扩一圈,排除种子自身与 archived + /// 对端,按边权重降序限条。knowledge_search 命中后调用,把「连着的知识」带出检索面 + /// (阶段2 图召回,2026-10-26);单种子场景 = list_related 去种子子集。 + pub async fn list_neighbors_for_seeds( + &self, + seed_ids: &[String], + limit: usize, + ) -> Result> { + if seed_ids.is_empty() || limit == 0 { + return Ok(Vec::new()); + } + let conn = self.conn.clone(); + let seeds = seed_ids.to_vec(); + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + // 种子 id 全参数化(动态 IN 占位,与既有检索同惯例) + let seed_set = (1..=seeds.len()) + .map(|i| format!("?{i}")) + .collect::>() + .join(", "); + let k_cols = KNOWLEDGE_COLS + .split(',') + .map(|c| format!("k.{}", c.trim())) + .collect::>() + .join(", "); + let sql = format!( + "SELECT DISTINCT {k_cols}, e.id, e.src_id, e.dst_id, e.kind, e.weight, e.origin, e.created_at, e.last_verified_at, \ + CASE WHEN e.src_id IN ({seed_set}) THEN 'out' ELSE 'in' END \ + FROM knowledge_edges e \ + JOIN knowledges k ON k.id = (CASE WHEN e.src_id IN ({seed_set}) THEN e.dst_id ELSE e.src_id END) \ + WHERE (e.src_id IN ({seed_set}) OR e.dst_id IN ({seed_set})) \ + AND k.id NOT IN ({seed_set}) AND k.status != 'archived' \ + ORDER BY e.weight DESC, e.last_verified_at DESC LIMIT ?{}", + seeds.len() + 1 + ); + let mut stmt = guard.prepare(&sql).map_err(storage_err)?; + let limit_i = limit as i64; + let mut bind: Vec<&dyn rusqlite::ToSql> = + seeds.iter().map(|s| s as &dyn rusqlite::ToSql).collect(); + bind.push(&limit_i); + let mut rows = stmt + .query_map(bind.as_slice(), |row| { + let edge = KnowledgeEdgeRecord { + id: row.get::<_, String>(KNOWLEDGE_COL_COUNT)?, + src_id: row.get::<_, String>(KNOWLEDGE_COL_COUNT + 1)?, + dst_id: row.get::<_, String>(KNOWLEDGE_COL_COUNT + 2)?, + kind: row.get::<_, String>(KNOWLEDGE_COL_COUNT + 3)?, + weight: row.get::<_, f64>(KNOWLEDGE_COL_COUNT + 4)?, + origin: row.get::<_, String>(KNOWLEDGE_COL_COUNT + 5)?, + created_at: row.get::<_, String>(KNOWLEDGE_COL_COUNT + 6)?, + last_verified_at: row.get::<_, String>(KNOWLEDGE_COL_COUNT + 7)?, + }; + Ok(RelatedKnowledge { + knowledge: knowledge_from_row(row)?, + edge, + direction: row.get::<_, String>(KNOWLEDGE_COL_COUNT + 8)?, + }) + }) + .map_err(storage_err)?; + let mut results = Vec::new(); + while let Some(r) = rows.next() { + results.push(r.map_err(storage_err)?); + } + Ok(results) + }) + .await + .map_err(storage_err)? + } + /// 写入向量嵌入(BLOB = Vec 小端字节序列化) /// /// embedding 列不进 KnowledgeRecord(IPC 不需要传向量给前端),专用方法读写。 @@ -2442,4 +2743,161 @@ mod tests { assert_eq!(after.title, "他人已改", "CAS 冲突不得覆盖他人修改"); assert_eq!(after.updated_at, "1800000000000"); } + + // ---------- P0 写入防重/淘汰机制(2026-10-23) ---------- + + #[tokio::test] + async fn update_content_merged_overrides_and_appends_reasoning() { + let repo = setup_repo().await; + repo.insert(krec("k1", "路由模型选择", "旧内容", "pitfall", "published", Some("high"), 2)) + .await + .unwrap(); + // 首次融合更新:content 覆盖 + reasoning 从 None 直接成为追加行 + assert!(repo + .update_content_merged("k1", "融合后的新版内容", "update: 并入会话B同坑经验") + .await + .unwrap()); + let rec = repo.get_by_id("k1").await.unwrap().unwrap(); + assert_eq!(rec.content, "融合后的新版内容"); + assert_eq!(rec.reasoning.as_deref(), Some("update: 并入会话B同坑经验")); + // 二次更新:reasoning 尾部追加,旧轨迹保留(演进可溯) + assert!(repo + .update_content_merged("k1", "二轮融合版", "update: 并入会话C补充") + .await + .unwrap()); + let rec = repo.get_by_id("k1").await.unwrap().unwrap(); + assert_eq!(rec.content, "二轮融合版"); + assert_eq!( + rec.reasoning.as_deref(), + Some("update: 并入会话B同坑经验\nupdate: 并入会话C补充") + ); + // id 不存在 → false(调用方降级新建) + assert!(!repo.update_content_merged("nope", "x", "y").await.unwrap()); + } + + #[tokio::test] + async fn list_neighbors_for_seeds_expands_one_hop_excluding_seeds_and_archived() { + let repo = setup_repo().await; + for (id, status) in [ + ("s1", "published"), + ("s2", "published"), + ("n1", "published"), + ("n2", "published"), + ("arch", "archived"), + ] { + repo.insert(krec(id, &format!("标题{id}"), "内容", "pitfall", status, Some("high"), 0)).await.unwrap(); + } + // s1-n1 / s2-n2 正常展开;s1-arch 对端 archived 排除 + repo.insert_edge("s1", "n1", "similar", "llm-tag").await.unwrap(); + repo.insert_edge("s2", "n2", "similar", "manual").await.unwrap(); + repo.insert_edge("s1", "arch", "similar", "manual").await.unwrap(); + let nb = repo + .list_neighbors_for_seeds(&["s1".into(), "s2".into()], 10) + .await + .unwrap(); + let mut ids: Vec<&str> = nb.iter().map(|r| r.knowledge.id.as_str()).collect(); + ids.sort(); + assert_eq!(ids, vec!["n1", "n2"], "1-hop 展开:排除种子与 archived 对端"); + let one = repo.list_neighbors_for_seeds(&["s1".into()], 10).await.unwrap(); + assert_eq!(one.len(), 1, "单种子 s1 仅 n1(archived 对端排除)"); + assert!(repo.list_neighbors_for_seeds(&[], 5).await.unwrap().is_empty(), "空种子返空"); + let lim = repo + .list_neighbors_for_seeds(&["s1".into(), "s2".into()], 1) + .await + .unwrap(); + assert_eq!(lim.len(), 1, "limit 封顶"); + } + + #[tokio::test] + async fn archive_stale_moves_only_non_high_unused_aged() { + let repo = setup_repo().await; + // a 低置信零复用超龄 → 淘汰;b 有复用/c 高置信 → 保留; + // d candidate 也淘汰;e 已 archived 幂等不重复命中; + // f medium/n 无置信 → 淘汰(2026-10-23 判据校准:low 实测仅 1/1688 形同虚设,非 high 同档) + repo.insert(krec("a", "过期低值1", "x", "pitfall", "published", Some("low"), 0)).await.unwrap(); + repo.insert(krec("b", "过期低值但复用过", "x", "pitfall", "published", Some("low"), 3)).await.unwrap(); + repo.insert(krec("c", "过期高置信", "x", "pitfall", "published", Some("high"), 0)).await.unwrap(); + repo.insert(krec("d", "候选期低值", "x", "pitfall", "candidate", Some("low"), 0)).await.unwrap(); + repo.insert(krec("e", "已归档", "x", "pitfall", "archived", Some("low"), 0)).await.unwrap(); + repo.insert(krec("f", "过期中置信", "x", "pitfall", "published", Some("medium"), 0)).await.unwrap(); + let mut nrec = krec("n", "过期无置信", "x", "pitfall", "published", None, 0); + nrec.confidence = None; + repo.insert(nrec).await.unwrap(); + // krec created_at=1700000000000(2023-11),older_than_days=1 → 全部超龄,仅三条件筛 + let mut ids = repo.archive_stale(1, 100).await.unwrap(); + ids.sort(); + assert_eq!( + ids, + vec!["a".to_string(), "d".to_string(), "f".to_string(), "n".to_string()], + "非high置信+零复用+超龄(published/candidate)淘汰" + ); + for (id, want) in [("a", "archived"), ("b", "published"), ("c", "published"), ("d", "archived"), ("e", "archived"), ("f", "archived"), ("n", "archived")] { + assert_eq!(repo.get_by_id(id).await.unwrap().unwrap().status, want, "条目 {id} 终态"); + } + // limit 生效:再插 3 条同类,limit=2 只归档 2 条 + for i in 0..3 { + repo.insert(krec(&format!("g{i}"), "批量", "x", "pitfall", "published", Some("low"), 0)).await.unwrap(); + } + assert_eq!(repo.archive_stale(1, 2).await.unwrap().len(), 2); + // 幂等复跑:剩余 1 条 + assert_eq!(repo.archive_stale(1, 100).await.unwrap().len(), 1); + } + + #[tokio::test] + async fn search_keywords_tiebreaks_by_confidence() { + let repo = setup_repo().await; + // title 分打平(都含关键词一次)、reuse 打平(0) → confidence high 排 low 前 + repo.insert(krec("low-1", "签名配置", "低置信噪音", "pitfall", "published", Some("low"), 0)).await.unwrap(); + repo.insert(krec("high-1", "签名配置经验", "高置信经验", "pitfall", "published", Some("high"), 0)).await.unwrap(); + let hits = repo + .search_keywords(&["签名".to_string()], true, 10) + .await + .unwrap(); + let ids: Vec<&str> = hits.iter().map(|r| r.id.as_str()).collect(); + assert_eq!(ids, vec!["high-1", "low-1"], "title/reuse 打平时 confidence 先验决胜"); + } + + // ---------- 知识关联边(阶段1 连边,V54) ---------- + + #[tokio::test] + async fn insert_edge_dedups_bidirectionally_and_lists_related() { + let repo = setup_repo().await; + for (id, status) in [("k1", "published"), ("k2", "published"), ("k3", "published"), ("k4", "archived")] { + repo.insert(krec(id, &format!("标题{id}"), "内容", "pitfall", status, Some("high"), 0)).await.unwrap(); + } + // 首建 + 反向重复:双向去重返回同一边 id + let e1 = repo.insert_edge("k1", "k2", "similar", "llm-tag").await.unwrap(); + let e1_rev = repo.insert_edge("k2", "k1", "similar", "llm-tag").await.unwrap(); + assert_eq!(e1, e1_rev, "同对节点反向建边应复用既有边"); + // k3 → k4:对端 archived,list_related 不展示 + repo.insert_edge("k1", "k3", "similar", "manual").await.unwrap(); + repo.insert_edge("k1", "k4", "similar", "manual").await.unwrap(); + let rel = repo.list_related("k1", 10).await.unwrap(); + let mut peer_ids: Vec<&str> = rel.iter().map(|r| r.knowledge.id.as_str()).collect(); + peer_ids.sort(); + assert_eq!(peer_ids, vec!["k2", "k3"], "archived 对端不展示(k4)"); + // 方向标注(k1 视角):e1 src=k1→dst=k2 对端 k2 是 out;k1→k3 对端 k3 也是 out; + // 反向边视角:另查 k2,其对端 k1 是 in(边指进来) + let dir_of_k2 = repo.list_related("k2", 10).await.unwrap(); + assert_eq!(dir_of_k2.iter().find(|r| r.knowledge.id == "k1").unwrap().direction, "in"); + let dir_of = |id: &str| rel.iter().find(|r| r.knowledge.id == id).unwrap().direction.clone(); + assert_eq!(dir_of("k3"), "out"); + assert_eq!(dir_of("k2"), "out"); + // 无关联条目 → 空表 + assert!(repo.list_related("nobody", 10).await.unwrap().is_empty()); + } + + #[tokio::test] + async fn list_by_status_kind_filters_and_orders_oldest_first() { + let repo = setup_repo().await; + repo.insert(krec("a", "老pitfall", "x", "pitfall", "archived", Some("low"), 0)).await.unwrap(); + repo.insert(krec("b", "新pitfall", "x", "pitfall", "archived", Some("low"), 0)).await.unwrap(); + repo.insert(krec("c", "未归档pitfall", "x", "pitfall", "published", Some("low"), 0)).await.unwrap(); + repo.insert(krec("d", "其他kind", "x", "diagnosis", "archived", Some("low"), 0)).await.unwrap(); + let hits = repo.list_by_status_kind("archived", "pitfall", 10).await.unwrap(); + let ids: Vec<&str> = hits.iter().map(|r| r.id.as_str()).collect(); + assert_eq!(ids, vec!["a", "b"], "仅命中 status+kind,created_at 升序(最老优先)"); + // limit 封顶 + assert_eq!(repo.list_by_status_kind("archived", "pitfall", 1).await.unwrap().len(), 1); + } } diff --git a/crates/df-storage/src/migrations.rs b/crates/df-storage/src/migrations.rs index fb7fe43..da7f701 100644 --- a/crates/df-storage/src/migrations.rs +++ b/crates/df-storage/src/migrations.rs @@ -67,7 +67,7 @@ pub fn run(conn: &Connection) -> Result<()> { // V52 = ai_messages.source 列(#958 消息溯源:外部渠道原始消息 ID JSON,NULL=桌面本端)。 // V53 = WorkingContext 持久化(ai_conversations.working_context_json TEXT,跨会话召回 L4 // 重启不失忆:save 随行写,会话载入与 parked 恢复读还原,NULL=无上下文行为同旧)。 - let steps: [(i32, fn(&Connection) -> Result<()>); 53] = [ + let steps: [(i32, fn(&Connection) -> Result<()>); 54] = [ (1, migrate_v1), (2, migrate_v2), (3, migrate_v3), @@ -129,6 +129,8 @@ pub fn run(conn: &Connection) -> Result<()> { (52, migrate_v52), // V53 = WorkingContext 持久化(ai_conversations.working_context_json,跨会话召回 L4)。 (53, migrate_v53), + // V54 = knowledge_edges 边表(知识中枢阶段1:节点连边,提炼 related_ids 标注落边)。 + (54, migrate_v54), ]; for (version, migrate_fn) in steps { @@ -1536,7 +1538,7 @@ fn migrate_v50(conn: &Connection) -> Result<()> { /// 调度器启动时 reset_stale_sending 复位为 pending。 /// 迁移链顶点版本(单一真值来源):**新增迁移时必须同步 +1**,否则下方迁移测试会失败 /// (测试用它断言「全量迁移后 schema_version == 顶点」「重跑仍到顶点」——既防链中断,也免多处写死)。 -pub const LATEST_SCHEMA_VERSION: i32 = 53; +pub const LATEST_SCHEMA_VERSION: i32 = 54; fn migrate_v51(conn: &Connection) -> Result<()> { if !table_exists(conn, "ai_outbox") { @@ -1609,6 +1611,38 @@ fn migrate_v53(conn: &Connection) -> Result<()> { Ok(()) } +/// V54: knowledge_edges 边表(知识中枢阶段1「连边」,2026-10-23) +/// +/// 终局设计(temp/知识中枢终局设计-20261023.md):知识从「清单」变「图」,节点=knowledges +/// 零改动,边独立成表——related 视图走边表,不在 knowledges 上加列(防双源)。 +/// - kind: similar(强相关)|evolves(演进)|causal(因果)|merges(合并)|anchors(锚点),首两版 +/// 只产 similar(提炼 related_ids)/evolves(预留),其余为阶段 3 自动建边预留; +/// - origin: llm-tag(LLM 标注)|embed-sim(向量相似)|co-cite(共引)|manual(人工)|tag-co-occur +/// (标签共现)——每条边可溯源,错的边可按 origin 批量撤销; +/// - 双列索引:激活扩散(阶段2)从任一端遍历,百万级实测双索引是毫秒级前提; +/// - weight/last_verified_at: 边随复用强化、久未验证降权(阶段 3 演化循环消费)。 +/// - **幂等**:table_exists 守卫(`CREATE TABLE IF NOT EXISTS` 同效); +/// **可逆**:`DROP TABLE knowledge_edges`。 +fn migrate_v54(conn: &Connection) -> Result<()> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS knowledge_edges (\ + id TEXT PRIMARY KEY,\ + src_id TEXT NOT NULL,\ + dst_id TEXT NOT NULL,\ + kind TEXT NOT NULL DEFAULT 'similar',\ + weight REAL NOT NULL DEFAULT 0.5,\ + origin TEXT NOT NULL DEFAULT 'llm-tag',\ + created_at TEXT NOT NULL,\ + last_verified_at TEXT NOT NULL\ + );\ + CREATE INDEX IF NOT EXISTS ke_src ON knowledge_edges(src_id);\ + CREATE INDEX IF NOT EXISTS ke_dst ON knowledge_edges(dst_id);", + )?; + conn.execute("INSERT OR IGNORE INTO schema_version (version) VALUES (?)", [54])?; + tracing::info!("迁移 v54 完成: knowledge_edges 边表(知识中枢阶段1)"); + Ok(()) +} + /// 回填 conversation_checkpoints.token_total:按 snapshot JSON 内逐条消息的 /// prompt_tokens+completion_tokens 求真实总量。 /// diff --git a/crates/df-storage/src/models.rs b/crates/df-storage/src/models.rs index df2195e..6b3bf7d 100644 --- a/crates/df-storage/src/models.rs +++ b/crates/df-storage/src/models.rs @@ -559,6 +559,31 @@ pub struct KnowledgeEventRecord { pub timestamp: String, } +/// 知识关联边(知识中枢阶段1:节点连边,V54) +/// +/// 有向 src→dst,查询侧双向读(关联语义无方向)。kind/origin 枚举见 migrations V54 注释; +/// 首两版只产 similar(提炼 related_ids)与 manual(人工),embed-sim/co-cite 归阶段 3 自动建边。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct KnowledgeEdgeRecord { + pub id: String, + pub src_id: String, + pub dst_id: String, + pub kind: String, + pub weight: f64, + pub origin: String, + pub created_at: String, + pub last_verified_at: String, +} + +/// 关联知识条目(边 + 对端知识展开,详情页「关联知识」区块直接消费) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RelatedKnowledge { + pub edge: KnowledgeEdgeRecord, + pub knowledge: KnowledgeRecord, + /// 对端相对当前条目的方向:out=当前是 src(边指向对方),in=当前是 dst(对方指进来) + pub direction: String, +} + /// 工程记录(project_modules 表,项目多工程,每个工程独立代码仓库) /// /// 一个项目可含多个工程(Monorepo 多仓库 / 微服务 / 前后端分离)。 diff --git a/src-tauri/src/commands/ai/knowledge_distill.rs b/src-tauri/src/commands/ai/knowledge_distill.rs new file mode 100644 index 0000000..e4a369a --- /dev/null +++ b/src-tauri/src/commands/ai/knowledge_distill.rs @@ -0,0 +1,133 @@ +//! 存量知识蒸馏合并(知识库治理 P0 深化,2026-10-24 用户指令「历史知识内容蒸馏压缩合并」) +//! +//! 与写入防重(P0,治增量)互补:本模块治存量——已归档长尾(淘汰批 632 条)按 kind 分组, +//! LLM 把一组同主题旧知识蒸馏成一条「合订本」(candidate 入库,原条目保持 archived 不删), +//! 建 merges 边(合订本→组内每条,origin=distill)保留可溯关系。 +//! +//! 蒸馏语义:合订本是「组内知识的浓缩替代视图」,不是权威合并——原条目仍在库(archived) +//! 可随时人工复核;合订本走正常 candidate→复用晋升通道,被人用过才算立住。 + +use std::sync::Arc; + +use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider}; +use df_ai::router::{select_model_id, Modality, TaskRequirements}; +use df_storage::crud::KnowledgeRepo; +use df_storage::db::Database; +use df_storage::models::AiProviderRecord; + +use crate::commands::ai::knowledge_inject::{candidate_record_from_item, strip_code_fence}; +use crate::state::LlmConcurrency; + +use super::secret::build_provider_for; + +/// 单批蒸馏条数上限:每组送 LLM 的条目数。20 条 × 均 218 字 ≈ 4.4K 字符输入, +/// 输出单条合订本 2K token 内;更多条分组蒸馏质量劣化(模型记不住开头),宁多批。 +const DISTILL_GROUP_LIMIT: usize = 20; + +/// 合订本 content 里单条原知识的摘要截断(蒸馏输入侧) +const DISTILL_ITEM_SNIPPET: usize = 300; + +const DISTILL_SYSTEM_PROMPT: &str = "你是知识蒸馏引擎。下面是知识库中同一主题下的一组已归档旧知识条目,\ +它们多为多次对话对同一经验的重复沉淀。请把它们蒸馏成**一条**合并后的知识:\n\ +- content 融合全组信息,去重、合并同类点、保留每条的独有细节,输出完整可复用的浓缩版\n\ +- title 概括整组共同主题\n\ +- tags 取全组标签的并集(最多 5 个)\n\ +- reasoning 说明蒸馏取舍(丢了什么、为何可丢)\n\n\ +输出严格的 JSON 对象(不要 markdown 包裹):\n\ +{\"kind\": \"<沿用组内 kind>\", \"title\": \"...\", \"content\": \"...\", \"tags\": [\"...\"], \"confidence\": \"medium\", \"reasoning\": \"...\"}\n\n\ +无可蒸馏内容(条目互相无重叠且各自完整)时输出 {\"skip\": true}。只输出 JSON。"; + +/// 蒸馏一个已归档分组:LLM 合订本 → candidate 落库 → merges 边连组内全部条目。 +/// +/// 返回合订本 id;组内不足 2 条(无可合并)或模型输出 skip 时返回 Ok(None)。 +/// 节奏控制:重复调用同 kind 会再取同批条目(蒸馏批不做已处理标记,P0 由前端按钮 +/// 人工控频;merges 边幂等,重复产出合订本可人工在收件箱驳回)。 +pub async fn distill_archived_group( + db: &Arc, + provider_config: &AiProviderRecord, + llm_concurrency: &LlmConcurrency, + kind: &str, + limit: Option, +) -> anyhow::Result> { + let repo = KnowledgeRepo::new(db); + let group = repo + .list_by_status_kind("archived", kind, limit.unwrap_or(DISTILL_GROUP_LIMIT).min(50)) + .await?; + if group.len() < 2 { + return Ok(None); + } + // 组装蒸馏输入:编号列表(id 供溯源,正文截断) + let mut conv_text = format!("主题分组: {kind}\n\n"); + for (i, k) in group.iter().enumerate() { + let truncated: String; + let content = if k.content.chars().count() > DISTILL_ITEM_SNIPPET { + truncated = k.content.chars().take(DISTILL_ITEM_SNIPPET).collect(); + truncated.as_str() + } else { + k.content.as_str() + }; + conv_text.push_str(&format!("[{}] id={} | {}\n{}\n\n", i + 1, k.id, k.title, content)); + } + + let messages = vec![ + ChatMessage::system(DISTILL_SYSTEM_PROMPT), + ChatMessage::user(&conv_text), + ]; + let req = TaskRequirements { + modalities: vec![Modality::Text], + needs_tool_use: false, + estimated_context: 0, + tier: None, + }; + let model = select_model_id(&req, &provider_config.model_configs) + .unwrap_or_else(|| provider_config.default_model.clone()); + let request = CompletionRequest { + model, + messages, + temperature: Some(0.2), + max_tokens: Some(2048), + stream: false, + tools: None, + tool_choice: None, + reasoning_content: None, + }; + let provider: Box = build_provider_for(provider_config) + .map_err(|e| anyhow::anyhow!("provider 密钥不可用: {e}"))?; + let _global = llm_concurrency.acquire_global().await; + let resp = provider.complete(request).await?; + let json_str = strip_code_fence(resp.text.trim()); + let item: serde_json::Value = serde_json::from_str(json_str) + .map_err(|e| anyhow::anyhow!("蒸馏输出 JSON 解析失败: {e} | 原始: {}", resp.text))?; + if item.get("skip").and_then(|v| v.as_bool()) == Some(true) { + return Ok(None); + } + + // 合订本落库:candidate 信任级(source_ref=distill:{kind}),溯源与自动提炼同风格 + let batch_ref = format!("distill:{}", kind); + let record = candidate_record_from_item(&item, None, Some(batch_ref.clone())) + .ok_or_else(|| anyhow::anyhow!("蒸馏产出字段残缺(kind/title/content 缺失)"))?; + repo.insert(record.clone()).await?; + let timeline = crate::commands::knowledge_timeline::KnowledgeTimeline::new(db); + timeline + .record_extracted( + &record.id, + "distill-batch", + &format!("蒸馏合订本({kind})"), + record.reasoning.as_deref().unwrap_or(""), + None, + ) + .await; + // merges 边:合订本 → 组内每条(insert_edge 双向去重幂等,单边失败非阻断) + let mut linked = 0usize; + for k in &group { + if repo.insert_edge(&record.id, &k.id, "merges", "distill").await.is_ok() { + linked += 1; + } + } + tracing::info!( + "[knowledge-distill] {kind} 组蒸馏完成: 合订本 {} ← {linked} 条边(group={})", + record.id, + group.len() + ); + Ok(Some(record.id)) +} diff --git a/src-tauri/src/commands/ai/knowledge_inject.rs b/src-tauri/src/commands/ai/knowledge_inject.rs index 8234bd6..12433c7 100644 --- a/src-tauri/src/commands/ai/knowledge_inject.rs +++ b/src-tauri/src/commands/ai/knowledge_inject.rs @@ -50,6 +50,12 @@ const KNOWLEDGE_EXTRACT_WINDOW: usize = 40; /// /// 取代原「每会话仅提炼一次」的 bool 硬去重——长会话后半程的新经验此前永不提炼。 const KNOWLEDGE_EXTRACT_MIN_NEW_USER_MSGS: usize = 10; +/// 提炼查重候选数:随 prompt 附给 LLM 的已有知识条数(P0 写入防重,2026-10-23) +/// +/// 5 条 × 500 字截断 ≈ 2.5K 字符,足以覆盖本轮对话主题的已有知识面;更多会稀释 +/// 提炼输入预算与 LLM 对单条的注意力。与注入 limit=3 同量级但不等值(注入防膨胀, +/// 查重要盖住高频重复区)。 +const KNOWLEDGE_DEDUP_CANDIDATES: usize = 5; /// 自动提炼频次水位(conv_id → 上次提炼进度),见 [`EXTRACT_WATERMARKS`]。 #[derive(Clone, Copy)] @@ -235,6 +241,11 @@ async fn resolve_embed_provider( /// 生成文本嵌入(向量检索用) /// /// 用配置指定的 embedding provider(必须 openai_compat 类型),失败返回 None(降级 LIKE)。 +/// +/// ⚠️ P0 定位变更(2026-10-23 知识库治理审查拍板):向量路径的「检索用途」明确弃用—— +/// 生产实证 0/1688 条有向量(provider 从未配成,本函数返回 None 即天然跳过,降级 LIKE +/// 已是唯一实际路径)。检索独走分词 LIKE;本链路保留不删,阶段 3(知识中枢设计 2026-10-23) +/// 改职复活为「自动建边引擎」(相似度>阈值=similar 边候选),届时不再做检索排序。 async fn generate_embedding( state: &AppState, text: &str, @@ -894,13 +905,20 @@ const EXTRACTION_SYSTEM_PROMPT: &str = "你是知识提炼引擎,从 AI 对话 输出严格的 JSON 数组(不要 markdown 代码块包裹),每个元素 schema:\n\ {\"kind\": \"review_rule|prompt_template|pitfall|architecture_pattern|diagnosis|deployment_note|workflow_optimization\", \ \"title\": \"简短标题\", \"content\": \"完整可复用内容\", \ -\"tags\": [\"标签\"], \"confidence\": \"high|medium|low\", \"reasoning\": \"为何值得沉淀\"}\n\n\ +\"tags\": [\"标签\"], \"confidence\": \"high|medium|low\", \"reasoning\": \"为何值得沉淀\", \ +\"action\": \"new|update\", \"target_id\": \"已有条目id(仅 update 时)\", \ +\"related_ids\": [\"强相关的已有条目id\"](可选,最多3个,仅 action=new 时)}\n\n\ 规则:\n\ 1. 如果适用范围有限制(如仅适用特定语言/框架/场景),必须在 content 或 tags 中明确标注\n\ 2. confidence: high=对话中可直接观察的明确模式, medium=合理推断, low=推测性弱信号\n\ 3. 无可提炼内容时返回空数组 []\n\ 4. 输出纯 JSON,无任何额外文字\n\ -5. 最多提取 5 条,按复用价值降序排列(只保留高价值条目,宁缺毋滥)"; +5. 最多提取 5 条,按复用价值降序排列(只保留高价值条目,宁缺毋滥)\n\ +6. 查重融合:若本次经验与「已有知识库条目」中某条重复或强相关,必须输出 action=\"update\" \ +且 target_id=该条 id,content 给出融合两方后的完整新版(不要重复旧文已有信息),\ +禁止同义新增造成知识重复;确属新经验才 action=\"new\"(可省略 action 字段)\n\ +7. 关联标注:action=\"new\" 时,若与已有条目存在强相关(同一主题的互补/前提/对照),\ +在 related_ids 数组里列出最多 3 个相关条目 id,供知识关联网络建边;无强相关则省略该字段"; /// 终态提炼提示词 — 在常规提示词上加终态边界(设计 §3.1):产出限跨会话可复用经验, /// 未完成事项/待办是会话任务态不是知识,归 L4 目标快照与停靠说明承接,不进知识表。 @@ -1356,7 +1374,7 @@ fn conv_title_or_placeholder(title: Option) -> String { /// LLM 产出单条 → candidate 知识记录(常规/终态提炼共用的落库统一口径)。 /// kind 缺失或标题/正文为空返回 None(调用方跳过该条);status 恒 candidate(LLM 写入 /// 不自动发布,与工具沉淀同信任级);归属与溯源 source_ref 由调用方按统一口径给定。 -fn candidate_record_from_item( +pub(crate) fn candidate_record_from_item( item: &serde_json::Value, source_project: Option<&str>, source_ref: Option, @@ -1417,8 +1435,19 @@ async fn run_extraction_core( Some(mid) => format!("conv_msg:{}", mid), None => format!("conv:{}", conv_id), }); + // P0 写入防重(2026-10-23):提炼前按窗口关键词查已有知识 top5 随 prompt 附给 LLM, + // 重复经验走 update 融合而非同义新增——治理审查实测零查重是 10 月 695 条膨胀的主因。 + // 全局检索(project=None):知识重复不分项目。检索失败非阻断(降级为无候选,行为回旧)。 + let existing = match extract_existing_candidates(db, conv_text).await { + Ok(v) => v, + Err(e) => { + tracing::warn!("[knowledge] 查重候选检索失败,降级无查重提炼(非阻断): {e}"); + Vec::new() + } + }; + let system_full = append_existing_block(system_prompt, &existing); let extract_messages = vec![ - ChatMessage::system(system_prompt), + ChatMessage::system(&system_full), ChatMessage::user(&format!("请从以下对话中提炼可复用知识:\n\n{}", conv_text)), ]; // F-01 阶段5: 知识提炼路由 — TaskRequirements(Standard,无工具)。 @@ -1498,7 +1527,45 @@ async fn run_extraction_core( tracing::info!(conv_id, project_id = %pid, "[KNOWLEDGE-EXTRACT] 本轮产出知识归属项目"); } let mut inserted = 0; + let mut updated = 0; + // update 分派的合法性锚:target_id 必须命中本轮附给 LLM 的候选集(防幻觉 id 误改他条) + let existing_ids: std::collections::HashSet<&str> = + existing.iter().map(|r| r.id.as_str()).collect(); for item in &items { + // P0 查重融合路径:action=update 且 target_id 命中候选集 → 覆盖已有条目而非新建。 + // content 缺失/空白与 target 未命中都降级走新建(内容不丢,与旧行为一致)。 + let update_target = (item.get("action").and_then(|v| v.as_str()) == Some("update")) + .then(|| item.get("target_id").and_then(|v| v.as_str())) + .flatten() + .filter(|tid| existing_ids.contains(*tid)); + if let Some(tid) = update_target { + let Some(new_content) = item + .get("content") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + else { + continue; + }; + let reasoning_line = format!( + "update@{}: {}", + chrono::Utc::now().format("%Y-%m-%d"), + item.get("reasoning").and_then(|v| v.as_str()).unwrap_or("融合重复经验") + ); + match knowledge_repo + .update_content_merged(tid, new_content, &reasoning_line) + .await + { + Ok(true) => { + updated += 1; + tracing::info!("AI 提炼知识融合更新: {tid}(重复经验并入已有条目)"); + timeline.record_updated(tid, &["content"], source_ref.as_deref().unwrap_or("extract")).await; + } + Ok(false) => tracing::warn!("知识融合更新未命中(id 已消失?),降级新建: {tid}"), + Err(e) => tracing::warn!("知识融合更新失败(非阻断,降级新建): {e}"), + } + continue; + } let Some(record) = candidate_record_from_item(item, source_project.as_deref(), source_ref.clone()) else { continue; }; @@ -1520,18 +1587,78 @@ async fn run_extraction_core( last_assistant_msg_id, ) .await; + // 阶段1 连边:related_ids ∩ 候选集 → similar 边(方向=新条目→相关已有条目)。 + // 幂等(insert_edge 双向去重),单边失败非阻断只 warn。 + let related = item + .get("related_ids") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().filter_map(|x| x.as_str()).collect::>()) + .unwrap_or_default(); + for rid in related.into_iter().take(3) { + if !existing_ids.contains(rid) || rid == record.id { + continue; + } + if let Err(e) = knowledge_repo.insert_edge(&record.id, rid, "similar", "llm-tag").await { + tracing::warn!("知识关联边建立失败(非阻断): {}→{}: {e}", record.id, rid); + } + } } Err(e) => tracing::warn!("知识候选插入失败(非阻断): {}", e), } } - if inserted > 0 { - tracing::info!("知识提炼完成: 对话 {} 产出 {} 条 candidate", conv_id, inserted); + // 水位语义「有产出才推进」:update 与 new 同为产出(0 new + N update 不再误判无产出) + if inserted + updated > 0 { + tracing::info!( + "知识提炼完成: 对话 {conv_id} 新建 {inserted} 条 / 融合更新 {updated} 条" + ); } - Ok(inserted) + Ok(inserted + updated) +} + +/// 提炼查重候选检索:按窗口关键词查已有知识 top5(P0 写入防重,2026-10-23) +/// +/// 走与注入同源的 [`extract_query_keywords`] 分词 + [`KnowledgeRepo::search_keywords`] +/// (include_candidates=true 与检索面同口径)。降级链:分词空词/0 命中 → 空表(LLM 收不到 +/// 已有块,全部走 new,行为回旧)。 +async fn extract_existing_candidates( + db: &Arc, + conv_text: &str, +) -> anyhow::Result> { + let keywords = extract_query_keywords(conv_text); + if keywords.is_empty() { + return Ok(Vec::new()); + } + let repo = KnowledgeRepo::new(db); + Ok(repo.search_keywords(&keywords, true, KNOWLEDGE_DEDUP_CANDIDATES).await?) +} + +/// 把已有知识候选块追加到提炼 system prompt(action=update 协议的上下文载体) +/// +/// 每条带 id/title/content 截断(500 字,LLM 判断重复足够,不撑提炼输入预算); +/// 候选为空返回原 prompt(零开销)。 +fn append_existing_block(system_prompt: &str, existing: &[KnowledgeRecord]) -> String { + if existing.is_empty() { + return system_prompt.to_owned(); + } + let mut block = String::from("\n\n已有知识库条目(判断查重用):\n"); + for r in existing { + let truncated: String; + let content = if r.content.chars().count() > 500 { + truncated = r.content.chars().take(500).collect(); + truncated.as_str() + } else { + r.content.as_str() + }; + block.push_str(&format!( + "- id={} | {} | {}\n", + r.id, r.title, content + )); + } + format!("{system_prompt}{block}") } /// 剥离 LLM 输出可能的 ```json ... ``` 代码块包裹 -fn strip_code_fence(s: &str) -> &str { +pub(crate) fn strip_code_fence(s: &str) -> &str { let s = s.trim(); if let Some(rest) = s.strip_prefix("```json") { return rest.trim().trim_end_matches("```").trim(); diff --git a/src-tauri/src/commands/ai/tools/knowledge.rs b/src-tauri/src/commands/ai/tools/knowledge.rs index bab07b4..58154d8 100644 --- a/src-tauri/src/commands/ai/tools/knowledge.rs +++ b/src-tauri/src/commands/ai/tools/knowledge.rs @@ -92,6 +92,7 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc) { .iter() .map(|k| { serde_json::json!({ + "id": k.id, "title": k.title, "kind": k.kind, "content": truncate_chars_ellipsis(&k.content, SEARCH_CONTENT_SNIPPET_LEN), @@ -100,22 +101,46 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc) { }) }) .collect(); - Ok(serde_json::json!({ "results": results })) + // 图召回·激活扩散(阶段2,2026-10-26):命中集沿边扩一跳,把「连着的知识」带出 + // 检索面——排除已命中与 archived,按边权重降序限 5 条;related 不占 limit 配额, + // 空数组=无关联(语义清晰,LLM 可忽略)。 + let seed_ids: Vec = hits.iter().map(|k| k.id.clone()).collect(); + let related: Vec = repo + .list_neighbors_for_seeds(&seed_ids, 5) + .await + .unwrap_or_default() + .iter() + .map(|n| { + serde_json::json!({ + "id": n.knowledge.id, + "title": n.knowledge.title, + "kind": n.knowledge.kind, + "edge_kind": n.edge.kind, + "direction": n.direction, + }) + }) + .collect(); + Ok(serde_json::json!({ "results": results, "related": related })) } ); - // 沉淀工具:用户说「记住/记一下/以后注意」时把要点落为候选知识 + // 沉淀工具:用户说「记住/记一下/以后注意」时把要点落为候选知识。 + // P0 写入防重(2026-10-23):工具无「再问一次 LLM」的查重机会,防重靠描述引导 + // (先 search 后写) + update_target_id 融合口径——agent 检索发现重复时传已有条目 + // id 走融合更新,禁同义新增。 declare_tool!( registry, db: Arc, "knowledge_insert", - "把一条经验/约定/踩坑要点沉淀进本地知识库(写入为候选知识,经复用或人工审核后转正,不会立即进入检索)。使用时机:用户说「记住/记一下/以后注意/这是我踩过的坑」时调用,先提炼要点再写入。参数:title=一句话概括(必填),content=要点详情(必填),kind=知识类型(可选,常用:pitfall 踩坑/architecture_pattern 架构模式/diagnosis 诊断/review_rule 评审规则/prompt_template 提示词模板/deployment_note 部署要点/workflow_optimization 流程优化,默认 pitfall),tags=标签数组(可选)。回答前查已有知识请用 knowledge_search", + "把一条经验/约定/踩坑要点沉淀进本地知识库(写入为候选知识,经复用或人工审核后转正,不会立即进入检索)。使用时机:用户说「记住/记一下/以后注意/这是我踩过的坑」时调用,先提炼要点再写入。写入前必须先 knowledge_search 查重:与已有条目重复或强相关时不要新增,改传 update_target_id=该条 ID 做融合更新(此时 content 给融合后的完整内容,title 忽略),禁止同义新增造成知识重复。参数:title=一句话概括(新增时必填),content=要点详情(必填),kind=知识类型(可选,常用:pitfall 踩坑/architecture_pattern 架构模式/diagnosis 诊断/review_rule 评审规则/prompt_template 提示词模板/deployment_note 部署要点/workflow_optimization 流程优化,默认 pitfall),tags=标签数组(可选),update_target_id=已有知识ID(可选,融合更新而非新增)", RiskLevel::Low, schema: object_schema(vec![ ("title", "string", true), ("content", "string", true), ("kind", "string", false), ("tags", "array", false), + ("update_target_id", "string", false), + ("related_ids", "array", false), ]), args => { let title = args["title"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 title"))?; @@ -123,6 +148,28 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc) { if title.trim().is_empty() || content.trim().is_empty() { anyhow::bail!("title 和 content 不能为空"); } + let repo = df_storage::crud::KnowledgeRepo::new(&db); + // P0 融合更新路径:update_target_id 命中已有条目 → 覆盖 content 并留轨迹; + // 未命中(false=条目已消失)向 agent 明确报错,由其降级重新调用(不带该参数) + if let Some(tid) = args["update_target_id"].as_str().map(str::trim).filter(|s| !s.is_empty()) { + let reasoning_line = format!( + "update@{}: agent 对话沉淀融合(来源 {})", + chrono::Utc::now().format("%Y-%m-%d"), + caller_conv().map(|c| format!("conv:{c}")).unwrap_or_else(|| "ai-chat".into()) + ); + let merged = repo + .update_content_merged(tid, content.trim(), &reasoning_line) + .await?; + if !merged { + anyhow::bail!("update_target_id={tid} 的知识不存在(可能已删除),请不带该参数重新调用以新增"); + } + return Ok(serde_json::json!({ + "id": tid, + "content": content, + "action": "update", + "note": "已融合更新已有知识,未新增条目" + })); + } // 非空即透传不做硬白名单(见模块注释:snippet 先例,过严校验制造假失败) let kind = match args["kind"].as_str() { Some(k) if !k.trim().is_empty() => k.trim().to_string(), @@ -169,11 +216,29 @@ pub fn register(registry: &mut AiToolRegistry, db: &Arc) { let kind_out = record.kind.clone(); let repo = df_storage::crud::KnowledgeRepo::new(&db); repo.insert(record).await?; + // 阶段1 连边:related_ids(知识检索结果里的已有条目)逐个校验存在后建 similar 边, + // 幂等且非阻断(单边失败只 warn,不回滚主写入) + let mut linked = 0usize; + if let Some(arr) = args["related_ids"].as_array() { + for rid in arr.iter().filter_map(|v| v.as_str()).take(3) { + if rid == id { + continue; + } + if repo.get_by_id(rid).await.unwrap_or(None).is_none() { + tracing::warn!("related_id 不存在,跳过建边: {rid}"); + continue; + } + if repo.insert_edge(&id, rid, "similar", "llm-tag").await.is_ok() { + linked += 1; + } + } + } Ok(serde_json::json!({ "id": id, "title": title, "kind": kind_out, "status": "candidate", + "linked": linked, "note": "已记录为候选知识,复用或审核后转正" })) } diff --git a/src-tauri/src/commands/knowledge.rs b/src-tauri/src/commands/knowledge.rs index 20436bd..013be19 100644 --- a/src-tauri/src/commands/knowledge.rs +++ b/src-tauri/src/commands/knowledge.rs @@ -7,7 +7,7 @@ use serde::Deserialize; use tauri::State; use df_types::types::new_id; -use df_storage::models::{KnowledgeEventRecord, KnowledgeRecord}; +use df_storage::models::{KnowledgeEventRecord, KnowledgeRecord, RelatedKnowledge}; use crate::state::{AppState, KnowledgeConfig}; @@ -303,6 +303,80 @@ pub async fn knowledge_retry_embedding( Ok(count) } +/// 淘汰批:零复用 + low 置信 + 超龄条目批量归档(P0 淘汰机制,2026-10-23) +/// +/// 背景(知识库治理审查 2026-10-23):71% 条目零复用长尾稀释检索排序,检索面是 +/// status 白名单,archived 天然出池。逐条留痕(status_changed,source_ref=archive_stale) +/// 可审计可回滚。默认阈值 90 天/批 500——从生产画像看 low 置信存量极少(实测 1 条), +/// 阈值偏保守,宁可分批多跑不误伤。 +/// +/// 归档不可逆性:archived 条目退出检索面,可通过 knowledge_update_status 手动转回 +/// published(状态机合法迁移),留痕链完整。 +#[tauri::command] +pub async fn knowledge_archive_stale( + state: State<'_, AppState>, + older_than_days: Option, + limit: Option, +) -> Result, String> { + let days = older_than_days.unwrap_or(90).clamp(1, 3650); + let limit = limit.unwrap_or(500).clamp(1, 5000); + let ids = state + .knowledge + .archive_stale(days, limit) + .await + .map_err(err_str)?; + // 逐条留痕 fire-and-forget(与晋升留痕同风格,吞错只 warn)。 + // from 记 "stale" 而非逐条旧值(批量 UPDATE 已合并原状态,淘汰批标记便于审计回滚) + let timeline = KnowledgeTimeline::new(&state.db); + for id in &ids { + timeline.record_status_change(id, "stale", "archived").await; + } + if !ids.is_empty() { + tracing::info!("[knowledge] 淘汰批: {} 条零复用低置信超龄条目归档(>{days}天)", ids.len()); + } + Ok(ids) +} + +/// 关联知识列表(知识中枢阶段1:详情页「关联知识」区块数据源) +/// +/// 双向读边表 + 对端条目展开,archived 对端不展示(与检索面口径一致)。 +#[tauri::command] +pub async fn knowledge_list_related( + state: State<'_, AppState>, + id: String, + limit: Option, +) -> Result, String> { + let limit = limit.unwrap_or(10).clamp(1, 50) as usize; + state + .knowledge + .list_related(&id, limit) + .await + .map_err(err_str) +} + +/// 存量蒸馏合并(治理 P0 深化,2026-10-24):对指定 kind 的已归档分组跑一次 LLM 蒸馏, +/// 产出单条合订本(candidate)并建 merges 边连组内条目。返回合订本 id(组内不足 2 条 +/// 或模型判 skip 返回 None)。 +#[tauri::command] +pub async fn knowledge_distill_group( + state: State<'_, AppState>, + kind: String, + limit: Option, +) -> Result, String> { + let provider_config = crate::commands::ai::prompt::get_active_provider(&state) + .await + .map_err(err_str)?; + crate::commands::ai::knowledge_distill::distill_archived_group( + &state.db, + &provider_config, + &state.llm_concurrency, + &kind, + limit.map(|n| n as usize), + ) + .await + .map_err(err_str) +} + // ============================================================ // 配置 + 手动提炼 // ============================================================ @@ -367,14 +441,17 @@ pub struct UpdateKnowledgeInput { pub reasoning: Option, } -/// 知识详情聚合负载(基本信息 + 全部生命线事件) +/// 知识详情聚合负载(基本信息 + 全部生命线事件 + 关联知识) #[derive(Debug, Serialize)] pub struct KnowledgeDetailPayload { pub knowledge: KnowledgeRecord, pub events: Vec, + /// 关联知识(阶段1 连边,双向读+对端展开);老前端不消费此字段无影响 + #[serde(default)] + pub related: Vec, } -/// 知识详情(基本信息 + 生命线事件) — 详情页一次拉全 +/// 知识详情(基本信息 + 生命线事件 + 关联知识) — 详情页一次拉全 #[tauri::command] pub async fn knowledge_get_detail( state: State<'_, AppState>, @@ -391,7 +468,9 @@ pub async fn knowledge_get_detail( .list_by_knowledge(&id) .await .map_err(err_str)?; - Ok(KnowledgeDetailPayload { knowledge, events }) + // 关联边读取失败非阻断(边表为空/极老库未到 V54 时降级空列表) + let related = state.knowledge.list_related(&id, 10).await.unwrap_or_default(); + Ok(KnowledgeDetailPayload { knowledge, events, related }) } /// 编辑知识(部分更新 title/content/tags/confidence/reasoning) diff --git a/src/i18n/en/ideas.ts b/src/i18n/en/ideas.ts index bc474fe..2f8d743 100644 --- a/src/i18n/en/ideas.ts +++ b/src/i18n/en/ideas.ts @@ -149,7 +149,7 @@ export default { confirmStatus: 'Change idea "{title}" status from {from} to {to}?', promoteFailed: 'Promotion failed', // Archive P1: idea archive box - filterArchived: '📦 Archive', + filterArchived: 'Archive', archiveBoxEmpty: 'No archived ideas', quickArchive: '📦 Archive', quickUnarchive: '📤 Unarchive', diff --git a/src/i18n/en/knowledge.ts b/src/i18n/en/knowledge.ts index 23a0756..4812aaf 100644 --- a/src/i18n/en/knowledge.ts +++ b/src/i18n/en/knowledge.ts @@ -46,6 +46,7 @@ export default { originUnknown: '—', reasoningLabel: '🤖 AI Reasoning', refTitle: '🔄 References ({n})', + relatedTitle: '🔗 Related ({n})', refEmpty: 'Not referenced yet', loadMore: 'Load more ({n} left)', lifecycleTitle: '📅 Lifecycle', diff --git a/src/i18n/zh-CN/ideas.ts b/src/i18n/zh-CN/ideas.ts index f90ddea..b71980e 100644 --- a/src/i18n/zh-CN/ideas.ts +++ b/src/i18n/zh-CN/ideas.ts @@ -149,7 +149,7 @@ export default { confirmStatus: '将灵感「{title}」状态从 {from} 改为 {to}?', promoteFailed: '立项失败', // 归档功能 P1(灵感归档箱) - filterArchived: '📦 归档箱', + filterArchived: '归档箱', archiveBoxEmpty: '暂无归档灵感', quickArchive: '📦 归档到归档箱', quickUnarchive: '📤 取消归档', diff --git a/src/i18n/zh-CN/knowledge.ts b/src/i18n/zh-CN/knowledge.ts index 65689e5..07d30a0 100644 --- a/src/i18n/zh-CN/knowledge.ts +++ b/src/i18n/zh-CN/knowledge.ts @@ -55,6 +55,7 @@ export default { reasoningLabel: '🤖 AI 判断依据', // 引用 refTitle: '🔄 引用记录({n} 次)', + relatedTitle: '🔗 关联知识({n} 条)', refEmpty: '尚未被引用', loadMore: '加载更多(剩 {n} 条)', // 生命周期