新增: 知识库图召回激活扩散与治理续批
- 阶段2 图召回: list_neighbors_for_seeds(多种子 1-hop 展开,排除种子与 archived)+knowledge_search 结果附 related 段(权重降序限 5) - 蒸馏合并: knowledge_distill.rs(archived 按 kind 分组 LLM 合订本+merges 边连组) - V54 knowledge_edges 边表与渐进披露批配套迁移/模型字段 - 提炼链 related_ids 协议+检索补 id 字段
This commit is contained in:
@@ -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<String>) -> Vec<String> {
|
||||
///
|
||||
/// 检索词里的 `%`/`_`/`|` 会被当成通配/转义符,用户输入与分词产物都可能含这些字符,
|
||||
/// 统一在此转义,避免关键词意外扩大命中面。
|
||||
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<Vec<KnowledgeRecord>> {
|
||||
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<bool> {
|
||||
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<Vec<String>> {
|
||||
let conn = self.conn.clone();
|
||||
let cutoff = (now_millis_str().parse::<i64>().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::<std::result::Result<Vec<_>, _>>()
|
||||
.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<String> {
|
||||
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<String> = 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<Vec<RelatedKnowledge>> {
|
||||
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::<Vec<_>>()
|
||||
.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<Vec<RelatedKnowledge>> {
|
||||
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::<Vec<_>>()
|
||||
.join(", ");
|
||||
let k_cols = KNOWLEDGE_COLS
|
||||
.split(',')
|
||||
.map(|c| format!("k.{}", c.trim()))
|
||||
.collect::<Vec<_>>()
|
||||
.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<f32> 小端字节序列化)
|
||||
///
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 求真实总量。
|
||||
///
|
||||
|
||||
@@ -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 多仓库 / 微服务 / 前后端分离)。
|
||||
|
||||
Reference in New Issue
Block a user