新增: 知识库检索分词化与晋升配置化及对话工具
整句 LIKE 命中率趋零,注入检索改 CJK 2-gram+ASCII 词分词 OR 匹配,标题命中计分排序 停用词剥离前置到切分前,滤掉跨词组合噪音 gram;ASCII 等频精确词优先 candidate 纳入检索面(include_candidates 默认开,配开关可回仅 published) 复用即信任自动晋升配置化:默认关+阈值 5,等晋升把关策略拍板后再开,防未审核产物经误命中固化污染 晋升留痕进时间线(auto/agent 来源区分);补 candidate 复用达标自动转正机制 agent 对话侧新增 knowledge_search/insert/promote 三工具,insert 固定 candidate,转正仅限 candidate 单向 知识配置结构新增两字段带 serde 默认,旧配置 JSON 缺字段不破坏启动恢复
This commit is contained in:
@@ -85,6 +85,14 @@ fn parse_json_id_array(raw: &Option<String>) -> Vec<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// LIKE 通配符转义(与 SQL 侧 `ESCAPE '|'` 配套)。
|
||||||
|
///
|
||||||
|
/// 检索词里的 `%`/`_`/`|` 会被当成通配/转义符,用户输入与分词产物都可能含这些字符,
|
||||||
|
/// 统一在此转义,避免关键词意外扩大命中面。
|
||||||
|
fn escape_like(s: &str) -> String {
|
||||||
|
s.replace('|', "||").replace('%', "|%").replace('_', "|_")
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// from_row 辅助函数
|
// from_row 辅助函数
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -634,13 +642,13 @@ impl IdeaRepo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl KnowledgeRepo {
|
impl KnowledgeRepo {
|
||||||
/// 检索知识: title/content LIKE 匹配,可选 kind 过滤,按 reuse_count 降序,top-N
|
/// 检索知识(整句 LIKE,兼容路径): title/content LIKE 匹配,可选 kind 过滤,按 reuse_count 降序,top-N
|
||||||
///
|
///
|
||||||
/// 克制检索: top-N≤3(由调用方 limit 控制),精确匹配优先(语义模糊后做)。
|
/// 保留整句匹配语义,服务 UI 知识面板搜索与 df-mcp `search_knowledge`(调用方传的就是
|
||||||
|
/// 用户显式输入的搜索串,整句即意图)。AI 注入路径的分词检索走 [`KnowledgeRepo::search_keywords`]。
|
||||||
pub async fn search(&self, query: &str, kind: Option<&str>, limit: usize) -> Result<Vec<KnowledgeRecord>> {
|
pub async fn search(&self, query: &str, kind: Option<&str>, limit: usize) -> Result<Vec<KnowledgeRecord>> {
|
||||||
let conn = self.conn.clone();
|
let conn = self.conn.clone();
|
||||||
let escaped = query.replace('|', "||").replace('%', "|%").replace('_', "|_");
|
let pattern = format!("%{}%", escape_like(query));
|
||||||
let pattern = format!("%{escaped}%");
|
|
||||||
let kind = kind.map(|s| s.to_owned());
|
let kind = kind.map(|s| s.to_owned());
|
||||||
let limit_i = limit as i64;
|
let limit_i = limit as i64;
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
@@ -673,6 +681,77 @@ impl KnowledgeRepo {
|
|||||||
.map_err(storage_err)?
|
.map_err(storage_err)?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 分词关键词检索(AI 注入路径专用): 多关键词 OR 命中 title/content
|
||||||
|
///
|
||||||
|
/// 排序:标题命中词数 > 内容命中 > reuse_count 降序(SQL 内 CASE 计分,同分再看复用次数)。
|
||||||
|
///
|
||||||
|
/// 背景(知识库走查 2026-09-29):注入路径原复用 [`KnowledgeRepo::search`] 把**整句用户消息**
|
||||||
|
/// 当 LIKE pattern,`%怎么修复知识库检索命中率%` 这类长串几乎不可能作为连续子串出现在
|
||||||
|
/// 任何知识正文里,命中率≈0。本方法由 caller 分词后逐词匹配,任一词命中即入候选,
|
||||||
|
/// 多词同时命中者排前。关键词条数为 WHERE 的 OR 分支数,通常 ≤6,全表扫描量级可控。
|
||||||
|
///
|
||||||
|
/// `include_candidates`:是否把 status='candidate'(AI 自动提炼产物)纳入检索面。
|
||||||
|
/// 提炼产物曾长期全部滞留 candidate 而本路径只认 published,存量知识近乎零生效;
|
||||||
|
/// 默认纳入后靠「复用即信任」([`KnowledgeRepo::auto_publish_if_reused`])渐进晋升,
|
||||||
|
/// 人工审核降级为可选修正路径。
|
||||||
|
///
|
||||||
|
/// 关键词为空 → 返回空(不回退整句 LIKE,避免退化回原缺陷)。
|
||||||
|
pub async fn search_keywords(
|
||||||
|
&self,
|
||||||
|
keywords: &[String],
|
||||||
|
include_candidates: bool,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<KnowledgeRecord>> {
|
||||||
|
if keywords.is_empty() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
// 每个关键词占一个编号占位符;WHERE 与 ORDER BY 的 CASE 复用同号(SQLite 允许 ?N 复用),
|
||||||
|
// 参数列表只装一份 pattern,末尾追加 LIMIT 占位符。
|
||||||
|
let mut patterns: Vec<String> = Vec::with_capacity(keywords.len());
|
||||||
|
let mut where_parts: Vec<String> = Vec::with_capacity(keywords.len());
|
||||||
|
let mut title_score_terms: Vec<String> = Vec::with_capacity(keywords.len());
|
||||||
|
for (i, kw) in keywords.iter().enumerate() {
|
||||||
|
let n = i + 1;
|
||||||
|
where_parts.push(format!(
|
||||||
|
"(title LIKE ?{n} ESCAPE '|' OR content LIKE ?{n} ESCAPE '|')"
|
||||||
|
));
|
||||||
|
title_score_terms.push(format!("CASE WHEN title LIKE ?{n} ESCAPE '|' THEN 1 ELSE 0 END"));
|
||||||
|
patterns.push(format!("%{}%", escape_like(kw)));
|
||||||
|
}
|
||||||
|
let status_clause = if include_candidates {
|
||||||
|
"status IN ('published','candidate')"
|
||||||
|
} else {
|
||||||
|
"status = 'published'"
|
||||||
|
};
|
||||||
|
let limit_i = limit as i64;
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT {KNOWLEDGE_COLS} FROM knowledges \
|
||||||
|
WHERE {status_clause} AND ({}) \
|
||||||
|
ORDER BY ({}) DESC, reuse_count DESC, created_at DESC LIMIT ?{}",
|
||||||
|
where_parts.join(" OR "),
|
||||||
|
title_score_terms.join(" + "),
|
||||||
|
patterns.len() + 1,
|
||||||
|
);
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let mut param_refs: Vec<&dyn rusqlite::ToSql> =
|
||||||
|
patterns.iter().map(|p| p as &dyn rusqlite::ToSql).collect();
|
||||||
|
param_refs.push(&limit_i);
|
||||||
|
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(param_refs.as_slice(), |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
|
/// 按状态列出(审核收件箱用): 按 confidence 语义排序(high>medium>low),次按 created_at
|
||||||
///
|
///
|
||||||
/// 用 CASE WHEN 替代纯 TEXT 排序(字典序 high>low>medium 非预期语义)。
|
/// 用 CASE WHEN 替代纯 TEXT 排序(字典序 high>low>medium 非预期语义)。
|
||||||
@@ -724,6 +803,59 @@ impl KnowledgeRepo {
|
|||||||
.map_err(storage_err)?
|
.map_err(storage_err)?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 复用即信任:candidate 的 reuse_count 达到阈值后自动晋升 published
|
||||||
|
///
|
||||||
|
/// 背景(知识库走查 2026-09-29):AI 提炼产物长期滞留 candidate,人工审核入口堆积无人处理,
|
||||||
|
/// 存量知识近乎零生效。「被真实注入命中 N 次」比「被人工翻到」信号更强,故以复用达标为
|
||||||
|
/// 主晋升路径;人工审核保留为可选修正(仍可在面板驳回/归档)。
|
||||||
|
///
|
||||||
|
/// SQL 原子判定(`status='candidate' AND reuse_count >= threshold`):published/archived
|
||||||
|
/// 条目不受影响,幂等可重复调用。返回 true = 本次发生了晋升(调用方据此打留痕日志)。
|
||||||
|
pub async fn auto_publish_if_reused(&self, id: &str, threshold: i64) -> Result<bool> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let id = id.to_owned();
|
||||||
|
let now = now_millis_str();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let affected = guard
|
||||||
|
.execute(
|
||||||
|
"UPDATE knowledges SET status = 'published', updated_at = ?1 \
|
||||||
|
WHERE id = ?2 AND status = 'candidate' AND reuse_count >= ?3",
|
||||||
|
params![now, id, threshold],
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(affected > 0)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 显式转正:candidate 单向晋升 published(对话内 knowledge_promote 工具 / 人工放行通道)
|
||||||
|
///
|
||||||
|
/// 与 [`KnowledgeRepo::auto_publish_if_reused`] 同款 SQL 原子条件写,区别在晋升条件:
|
||||||
|
/// 本方法不做复用计数判定,仅 `status='candidate'` 一个门槛——显式转正是「人/agent
|
||||||
|
/// 拍板」语义,不复用即信任的阈值逻辑。SQL WHERE 收口边界:published/archived 条目
|
||||||
|
/// 不命中(affected=0 → false),调用方据此回「仅 candidate 可转正」,天然幂等且
|
||||||
|
/// 禁止降级/归档/改内容(只写 status + updated_at 两列)。
|
||||||
|
pub async fn promote(&self, id: &str) -> Result<bool> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let id = id.to_owned();
|
||||||
|
let now = now_millis_str();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let affected = guard
|
||||||
|
.execute(
|
||||||
|
"UPDATE knowledges SET status = 'published', updated_at = ?1 \
|
||||||
|
WHERE id = ?2 AND status = 'candidate'",
|
||||||
|
params![now, id],
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(affected > 0)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
/// 写入向量嵌入(BLOB = Vec<f32> 小端字节序列化)
|
/// 写入向量嵌入(BLOB = Vec<f32> 小端字节序列化)
|
||||||
///
|
///
|
||||||
/// embedding 列不进 KnowledgeRecord(IPC 不需要传向量给前端),专用方法读写。
|
/// embedding 列不进 KnowledgeRecord(IPC 不需要传向量给前端),专用方法读写。
|
||||||
@@ -1261,6 +1393,205 @@ mod tests {
|
|||||||
assert_eq!(hits[1].id, "k3");
|
assert_eq!(hits[1].id, "k3");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 分词关键词检索(search_keywords,知识注入路径) ----------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn search_keywords_empty_input_returns_empty() {
|
||||||
|
let repo = setup_repo().await;
|
||||||
|
repo.insert(krec("k1", "Rust 异步", "tokio", "lesson", "published", None, 1))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// 关键词为空 → 直接空结果,不回退整句 LIKE(避免退化回整句匹配缺陷)
|
||||||
|
let hits = repo.search_keywords(&[], true, 10).await.unwrap();
|
||||||
|
assert!(hits.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn search_keywords_or_matches_any_keyword() {
|
||||||
|
let repo = setup_repo().await;
|
||||||
|
// k1 命中 title 的「异步」;k2 命中 content 的「tokio」——OR 语义两路都收
|
||||||
|
repo.insert(krec("k1", "Rust 异步并发", "无关内容", "lesson", "published", None, 1))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
repo.insert(krec("k2", "运行时", "tokio 深入", "lesson", "published", None, 1))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let kws = vec!["异步".to_string(), "tokio".to_string()];
|
||||||
|
let hits = repo.search_keywords(&kws, false, 10).await.unwrap();
|
||||||
|
let ids: Vec<_> = hits.iter().map(|h| h.id.as_str()).collect();
|
||||||
|
assert_eq!(ids.len(), 2);
|
||||||
|
assert!(ids.contains(&"k1"));
|
||||||
|
assert!(ids.contains(&"k2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn search_keywords_title_hit_ranks_above_content_hit() {
|
||||||
|
let repo = setup_repo().await;
|
||||||
|
// k2 内容命中且复用次数极高,k1 仅标题命中 → 标题权重压过 reuse_count
|
||||||
|
repo.insert(krec("k1", "Rust 所有权", "无关内容", "lesson", "published", None, 0))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
repo.insert(krec("k2", "无关标题", "聊聊 Rust 所有权与借用", "lesson", "published", None, 99))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let kws = vec!["所有权".to_string()];
|
||||||
|
let hits = repo.search_keywords(&kws, false, 10).await.unwrap();
|
||||||
|
assert_eq!(hits.len(), 2);
|
||||||
|
assert_eq!(hits[0].id, "k1", "标题命中应排在内容命中之前");
|
||||||
|
assert_eq!(hits[1].id, "k2");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn search_keywords_more_title_hits_rank_first() {
|
||||||
|
let repo = setup_repo().await;
|
||||||
|
// 两条都是标题命中:k1 命中 2 词,k2 命中 1 词 → k1 前
|
||||||
|
repo.insert(krec("k1", "Rust 异步模型", "...", "lesson", "published", None, 1))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
repo.insert(krec("k2", "异步进阶", "...", "lesson", "published", None, 50))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let kws = vec!["Rust".to_string(), "异步".to_string()];
|
||||||
|
let hits = repo.search_keywords(&kws, false, 10).await.unwrap();
|
||||||
|
assert_eq!(hits.len(), 2);
|
||||||
|
assert_eq!(hits[0].id, "k1", "标题命中词数多者应排前(同档才看 reuse_count)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn search_keywords_include_candidates_toggle() {
|
||||||
|
let repo = setup_repo().await;
|
||||||
|
repo.insert(krec("pub1", "Rust 规范", "...", "lesson", "published", None, 1))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
repo.insert(krec("cand1", "Rust 提炼稿", "...", "lesson", "candidate", None, 1))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let kws = vec!["Rust".to_string()];
|
||||||
|
|
||||||
|
// 默认(纳入 candidate):published + candidate 都出
|
||||||
|
let hits = repo.search_keywords(&kws, true, 10).await.unwrap();
|
||||||
|
assert_eq!(hits.len(), 2, "include_candidates=true 应纳入 candidate");
|
||||||
|
|
||||||
|
// 关闭:回到仅 published(兼容旧行为)
|
||||||
|
let hits = repo.search_keywords(&kws, false, 10).await.unwrap();
|
||||||
|
assert_eq!(hits.len(), 1);
|
||||||
|
assert_eq!(hits[0].id, "pub1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn search_keywords_like_wildcards_escaped() {
|
||||||
|
let repo = setup_repo().await;
|
||||||
|
// 关键词含 % 时不应充当通配符("100%" 不该命中任意串)
|
||||||
|
repo.insert(krec("k1", "进度 100% 完成", "普通内容", "lesson", "published", None, 1))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
repo.insert(krec("k2", "完全不同", "...", "lesson", "published", None, 1))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let kws = vec!["100%".to_string()];
|
||||||
|
let hits = repo.search_keywords(&kws, false, 10).await.unwrap();
|
||||||
|
assert_eq!(hits.len(), 1);
|
||||||
|
assert_eq!(hits[0].id, "k1");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 复用即信任(auto_publish_if_reused) ----------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn auto_publish_promotes_candidate_at_threshold() {
|
||||||
|
let repo = setup_repo().await;
|
||||||
|
repo.insert(krec("c1", "提炼知识", "...", "lesson", "candidate", None, 0))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// 复用 1 次(< 阈值 2)→ 不晋升
|
||||||
|
repo.increment_reuse_count("c1").await.unwrap();
|
||||||
|
assert!(!repo.auto_publish_if_reused("c1", 2).await.unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
repo.get_by_id("c1").await.unwrap().unwrap().status.as_str(),
|
||||||
|
"candidate"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 复用第 2 次(达标)→ 晋升 published
|
||||||
|
repo.increment_reuse_count("c1").await.unwrap();
|
||||||
|
assert!(repo.auto_publish_if_reused("c1", 2).await.unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
repo.get_by_id("c1").await.unwrap().unwrap().status.as_str(),
|
||||||
|
"published"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 已 published 再调:幂等 false(不再重复晋升)
|
||||||
|
assert!(!repo.auto_publish_if_reused("c1", 2).await.unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn auto_publish_ignores_published_and_missing() {
|
||||||
|
let repo = setup_repo().await;
|
||||||
|
repo.insert(krec("p1", "已发布", "...", "lesson", "published", None, 10))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
// published 即使 reuse_count 远超阈值也不动(晋升仅面向 candidate)
|
||||||
|
assert!(!repo.auto_publish_if_reused("p1", 2).await.unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
repo.get_by_id("p1").await.unwrap().unwrap().status.as_str(),
|
||||||
|
"published"
|
||||||
|
);
|
||||||
|
// 不存在的 id → false
|
||||||
|
assert!(!repo.auto_publish_if_reused("ghost", 2).await.unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 显式转正(promote,对话内 knowledge_promote 工具通道) ----------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn promote_candidates_to_published() {
|
||||||
|
let repo = setup_repo().await;
|
||||||
|
repo.insert(krec("c1", "候选知识", "...", "lesson", "candidate", None, 0))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// candidate → published: true,状态落库
|
||||||
|
assert!(repo.promote("c1").await.unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
repo.get_by_id("c1").await.unwrap().unwrap().status.as_str(),
|
||||||
|
"published"
|
||||||
|
);
|
||||||
|
|
||||||
|
// 已 published 再调:幂等 false(晋升仅面向 candidate,不降级不重复)
|
||||||
|
assert!(!repo.promote("c1").await.unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
repo.get_by_id("c1").await.unwrap().unwrap().status.as_str(),
|
||||||
|
"published"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn promote_ignores_published_archived_and_missing() {
|
||||||
|
let repo = setup_repo().await;
|
||||||
|
repo.insert(krec("p1", "已发布", "...", "lesson", "published", None, 3))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
repo.insert(krec("a1", "已归档", "...", "lesson", "archived", None, 1))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// published / archived 均 WHERE 不命中 → false 且状态不变
|
||||||
|
assert!(!repo.promote("p1").await.unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
repo.get_by_id("p1").await.unwrap().unwrap().status.as_str(),
|
||||||
|
"published"
|
||||||
|
);
|
||||||
|
assert!(!repo.promote("a1").await.unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
repo.get_by_id("a1").await.unwrap().unwrap().status.as_str(),
|
||||||
|
"archived"
|
||||||
|
);
|
||||||
|
// 不存在的 id → false
|
||||||
|
assert!(!repo.promote("ghost").await.unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn list_by_status_orders_by_confidence_semantics() {
|
async fn list_by_status_orders_by_confidence_semantics() {
|
||||||
// confidence 字典序 high>low>medium 非预期,实现用 CASE 强制 high>medium>low
|
// confidence 字典序 high>low>medium 非预期,实现用 CASE 强制 high>medium>low
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! 知识库集成 — 注入 + 提炼(嵌入生成 / 混合检索 / 上下文构建 / 对话提炼)
|
//! 知识库集成 — 注入 + 提炼(嵌入生成 / 混合检索 / 上下文构建 / 对话提炼)
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, LazyLock, Mutex as StdMutex};
|
||||||
|
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
@@ -24,6 +25,168 @@ use crate::commands::err_str;
|
|||||||
|
|
||||||
use super::{AiSession};
|
use super::{AiSession};
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 知识注入/提炼参数(集中收口,不散魔法数)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// 注入检索 query 最多保留的关键词个数(按出现频次取前 N)
|
||||||
|
///
|
||||||
|
/// 多词会放大 SQL 命中面并稀释「标题命中 > 内容命中」的排序精度,6 个足以覆盖
|
||||||
|
/// 一句提问的信息量(ASCII 专有名词 + 核心汉字 2-gram)。
|
||||||
|
const KNOWLEDGE_QUERY_MAX_KEYWORDS: usize = 6;
|
||||||
|
/// ASCII 词最短长度(单字符词如 a/x/i 全表乱命中,过滤)
|
||||||
|
const KNOWLEDGE_QUERY_MIN_ASCII_LEN: usize = 2;
|
||||||
|
/// 自动提炼窗口:取最近 N 条 user/assistant 消息送 LLM 提炼
|
||||||
|
///
|
||||||
|
/// 原 6 条窗口过窄,长会话后半程的经验不在提炼视野内;40 条覆盖近期完整上下文,
|
||||||
|
/// 同时不撑爆提炼调用的 token 预算(max_tokens 2048 的输出侧不受影响,输入侧由
|
||||||
|
/// 提炼模型上下文窗口兜底)。
|
||||||
|
const KNOWLEDGE_EXTRACT_WINDOW: usize = 40;
|
||||||
|
/// 自动提炼频次:距上次提炼至少新增 N 条用户消息才再提炼
|
||||||
|
///
|
||||||
|
/// 取代原「每会话仅提炼一次」的 bool 硬去重——长会话后半程的新经验此前永不提炼。
|
||||||
|
const KNOWLEDGE_EXTRACT_MIN_NEW_USER_MSGS: usize = 10;
|
||||||
|
|
||||||
|
/// 自动提炼频次水位(conv_id → 上次提炼进度),见 [`EXTRACT_WATERMARKS`]。
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
enum ExtractWatermark {
|
||||||
|
/// 提炼进行中(spawn 前置位,完成/失败后改写,防并发两路同时触发)
|
||||||
|
InFlight,
|
||||||
|
/// 上次成功提炼完成时的用户消息累计条数(含已压缩消息,保证单调递增)
|
||||||
|
Done(usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 提炼频次水位表(进程内,不落库)。
|
||||||
|
///
|
||||||
|
/// 原「每会话仅提炼一次」的 `knowledge_extracted` bool 去重使长会话后半程的新经验
|
||||||
|
/// 永不提炼(知识库走查 2026-09-29);改为水位制:距上次提炼累计新增
|
||||||
|
/// [`KNOWLEDGE_EXTRACT_MIN_NEW_USER_MSGS`] 条用户消息可再提炼。
|
||||||
|
/// 放本模块静态而非 PerConvState:后者归属会话状态层(本次改动边界外),且水位随进程
|
||||||
|
/// 生命周期存续即可——重启丢失后最多多提炼一轮,无正确性风险。
|
||||||
|
static EXTRACT_WATERMARKS: LazyLock<StdMutex<HashMap<String, ExtractWatermark>>> =
|
||||||
|
LazyLock::new(|| StdMutex::new(HashMap::new()));
|
||||||
|
|
||||||
|
/// 统计会话的用户消息累计条数(提炼频次水位的计量单位)
|
||||||
|
fn count_user_rounds<'a>(msgs: impl Iterator<Item = &'a ChatMessage>) -> usize {
|
||||||
|
msgs.filter(|m| matches!(m.role, MessageRole::User)).count()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 检索 query 分词(整句 LIKE 命中率≈0 的根治,见 search_keywords)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// 中文判断(CJK 统一表意文字区,覆盖日常中文)
|
||||||
|
fn is_cjk_char(c: char) -> bool {
|
||||||
|
matches!(c, '\u{4E00}'..='\u{9FFF}')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ASCII 词字符(字母数字下划线)
|
||||||
|
fn is_ascii_word_char(c: char) -> bool {
|
||||||
|
c.is_ascii_alphanumeric() || c == '_'
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 高频虚词停用表(2-gram 粒度):中文疑问/指代/客套组合 + 英文虚词。
|
||||||
|
/// ASCII 词抽取时已统一小写,英文停用词按小写收录;命中即弃,不参与检索。
|
||||||
|
const QUERY_STOPWORDS: &[&str] = &[
|
||||||
|
"怎么", "如何", "什么", "为何", "帮我", "看看", "这个", "那个", "一下", "可以",
|
||||||
|
"应该", "需要", "是否", "如果", "但是", "所以", "因为", "还是", "就是", "没有",
|
||||||
|
"一个", "这些", "那些", "然后", "现在", "已经", "我们", "你们", "他们", "自己",
|
||||||
|
"时候", "我看", "请把", "觉得", "感觉", "好像", "对于", "关于",
|
||||||
|
"the", "and", "for", "with", "this", "that", "from", "have", "what", "how",
|
||||||
|
"can", "you", "are", "was", "not", "but", "about", "into", "use", "please",
|
||||||
|
"help", "want", "need", "should", "would", "could", "there", "here", "when",
|
||||||
|
"where", "which", "your",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 从用户消息抽取检索关键词:ASCII 词(≥2 字符,统一小写)+ CJK 2-gram,按频次取前 N
|
||||||
|
///
|
||||||
|
/// 背景(知识库走查 2026-09-29):原把整条用户消息当 LIKE pattern,中文长句几乎不可能
|
||||||
|
/// 作为连续子串命中知识正文,注入检索近乎失效。不上分词器:提问多为短句,
|
||||||
|
/// 2-gram + 停用词已够;不依赖 df-ai 的同思路实现(df-storage 在依赖链下层,反向依赖会成环),
|
||||||
|
/// 故此处自实现最小分词。停用词过滤同时压掉客套话与指代词的噪音 2-gram。
|
||||||
|
///
|
||||||
|
/// 返回按出现频次降序的前 [`KNOWLEDGE_QUERY_MAX_KEYWORDS`] 个,同频次 ASCII 词优先于
|
||||||
|
/// CJK 2-gram(2-gram 是无词典近似噪音多,等频抢占容量时精确词让位),组内按首次
|
||||||
|
/// 出现顺序稳定排序(检索 query 确定,行为可测)。
|
||||||
|
pub(crate) fn extract_query_keywords(text: &str) -> Vec<String> {
|
||||||
|
let mut tokens: Vec<String> = Vec::new();
|
||||||
|
let mut ascii_buf = String::new();
|
||||||
|
let mut cjk_buf: Vec<char> = Vec::new();
|
||||||
|
|
||||||
|
for c in text.chars() {
|
||||||
|
if is_ascii_word_char(c) {
|
||||||
|
// ASCII 词打断了汉字连续段:先结算汉字 2-gram 再收集 ASCII
|
||||||
|
flush_cjk_bigrams(&mut cjk_buf, &mut tokens);
|
||||||
|
ascii_buf.push(c.to_ascii_lowercase());
|
||||||
|
} else {
|
||||||
|
if !ascii_buf.is_empty() {
|
||||||
|
tokens.push(std::mem::take(&mut ascii_buf));
|
||||||
|
}
|
||||||
|
if is_cjk_char(c) {
|
||||||
|
cjk_buf.push(c);
|
||||||
|
} else {
|
||||||
|
// 空白/标点等分隔符:结算当前汉字段
|
||||||
|
flush_cjk_bigrams(&mut cjk_buf, &mut tokens);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ascii_buf.is_empty() {
|
||||||
|
tokens.push(ascii_buf);
|
||||||
|
}
|
||||||
|
flush_cjk_bigrams(&mut cjk_buf, &mut tokens);
|
||||||
|
|
||||||
|
// 过滤(过短/停用词)后按频次降序取前 N;同频次 ASCII 词优先于 CJK 2-gram,
|
||||||
|
// 组内按首现顺序稳定排序
|
||||||
|
let ascii_word = |t: &str| t.starts_with(is_ascii_word_char);
|
||||||
|
let mut freq: Vec<(String, usize, usize)> = Vec::new();
|
||||||
|
for (idx, t) in tokens.into_iter().enumerate() {
|
||||||
|
if t.chars().count() < KNOWLEDGE_QUERY_MIN_ASCII_LEN
|
||||||
|
|| QUERY_STOPWORDS.contains(&t.as_str())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(entry) = freq.iter_mut().find(|(tok, _, _)| tok == &t) {
|
||||||
|
entry.1 += 1;
|
||||||
|
} else {
|
||||||
|
freq.push((t, 1, idx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
freq.sort_by(|a, b| {
|
||||||
|
b.1.cmp(&a.1)
|
||||||
|
.then_with(|| ascii_word(&b.0).cmp(&ascii_word(&a.0)))
|
||||||
|
.then_with(|| a.2.cmp(&b.2))
|
||||||
|
});
|
||||||
|
freq
|
||||||
|
.into_iter()
|
||||||
|
.take(KNOWLEDGE_QUERY_MAX_KEYWORDS)
|
||||||
|
.map(|(t, _, _)| t)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 结算 CJK 连续段:剥离段内停用词后两两切 2-gram。
|
||||||
|
///
|
||||||
|
/// 中文无空格分词,2-gram 是无词典近似的最小可靠粒度;孤单汉字不产出
|
||||||
|
/// (单字命中率低噪音高)。段长 <2 时 windows(2) 自然产出空,无需特判。
|
||||||
|
///
|
||||||
|
/// 停用词须在切 gram 前替换为分隔符打断连续段:切完再整词过滤滤不掉
|
||||||
|
/// 停用词与邻字的跨词组合 gram("帮我看看这个怎么修复"会产生"看这/个怎"噪音)。
|
||||||
|
/// 表内 CJK 停用词均为 2 字词,ASCII 停用词不会命中纯汉字段,统一替换无副作用;
|
||||||
|
/// 剥离后单字残留不成 gram 自然丢弃,与孤单汉字语义一致。
|
||||||
|
fn flush_cjk_bigrams(buf: &mut Vec<char>, out: &mut Vec<String>) {
|
||||||
|
let mut segment: String = buf.iter().collect();
|
||||||
|
for w in QUERY_STOPWORDS {
|
||||||
|
if segment.contains(w) {
|
||||||
|
segment = segment.replace(w, "\u{0}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for part in segment.split('\u{0}') {
|
||||||
|
for w in part.chars().collect::<Vec<_>>().windows(2) {
|
||||||
|
out.push(w.iter().collect());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf.clear();
|
||||||
|
}
|
||||||
|
|
||||||
/// 按配置构建 embedding provider + model。None = 配置缺失/provider 不存在。
|
/// 按配置构建 embedding provider + model。None = 配置缺失/provider 不存在。
|
||||||
///
|
///
|
||||||
/// F-01 阶段5: model 选择路由优先 — 在 embedding provider 的 model_configs 池中按
|
/// F-01 阶段5: model 选择路由优先 — 在 embedding provider 的 model_configs 池中按
|
||||||
@@ -232,13 +395,20 @@ pub async fn retry_failed_embeddings(state: &AppState) -> anyhow::Result<usize>
|
|||||||
///
|
///
|
||||||
/// 双信号(两路都命中)排最前,LIKE 单信号次之,向量单信号第三。
|
/// 双信号(两路都命中)排最前,LIKE 单信号次之,向量单信号第三。
|
||||||
/// vector_enabled 关闭或 embed 失败时纯 LIKE(零外部依赖降级)。
|
/// vector_enabled 关闭或 embed 失败时纯 LIKE(零外部依赖降级)。
|
||||||
|
/// LIKE 路 query 先经 [`extract_query_keywords`] 分词(整句 LIKE 命中率≈0);
|
||||||
|
/// 向量路 query 同步改用关键词拼接(虚词稀释 embedding 语义),分词为空时回退原句。
|
||||||
async fn hybrid_search(
|
async fn hybrid_search(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
query: &str,
|
query: &str,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
config: &crate::state::KnowledgeConfig,
|
config: &crate::state::KnowledgeConfig,
|
||||||
) -> Vec<df_storage::models::KnowledgeRecord> {
|
) -> Vec<df_storage::models::KnowledgeRecord> {
|
||||||
let keyword_results = match state.knowledge.search(query, None, limit).await {
|
let keywords = extract_query_keywords(query);
|
||||||
|
let keyword_results = match state
|
||||||
|
.knowledge
|
||||||
|
.search_keywords(&keywords, config.include_candidates, limit)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(error = %e, "[ai] 知识关键词检索失败,降级空结果");
|
tracing::warn!(error = %e, "[ai] 知识关键词检索失败,降级空结果");
|
||||||
@@ -249,7 +419,12 @@ async fn hybrid_search(
|
|||||||
if !config.vector_enabled {
|
if !config.vector_enabled {
|
||||||
return keyword_results;
|
return keyword_results;
|
||||||
}
|
}
|
||||||
let query_vec = match generate_embedding(state, query, config).await {
|
let embed_query = if keywords.is_empty() {
|
||||||
|
query.to_string()
|
||||||
|
} else {
|
||||||
|
keywords.join("、")
|
||||||
|
};
|
||||||
|
let query_vec = match generate_embedding(state, &embed_query, config).await {
|
||||||
Some(v) => v,
|
Some(v) => v,
|
||||||
None => return keyword_results, // embed 失败降级
|
None => return keyword_results, // embed 失败降级
|
||||||
};
|
};
|
||||||
@@ -322,19 +497,40 @@ pub(crate) async fn build_knowledge_context(
|
|||||||
if results.is_empty() {
|
if results.is_empty() {
|
||||||
return String::new();
|
return String::new();
|
||||||
}
|
}
|
||||||
// 命中条目:复用计数 +1 + 记录引用事件(fire-and-forget,单个 spawn 任务批量处理)
|
// 命中条目:复用计数 +1 + candidate 复用达标自动晋升 + 记录引用事件(fire-and-forget,单个 spawn 任务批量处理)
|
||||||
let db = state.db.clone();
|
let db = state.db.clone();
|
||||||
let ids: Vec<String> = results.iter().map(|r| r.id.clone()).collect();
|
// (id, 是否 candidate):晋升检查只对 candidate 发起,published 免一次注定空跑的 UPDATE
|
||||||
|
let hits: Vec<(String, bool)> = results
|
||||||
|
.iter()
|
||||||
|
.map(|r| (r.id.clone(), r.status == "candidate"))
|
||||||
|
.collect();
|
||||||
let conv_id = conv_id.to_string();
|
let conv_id = conv_id.to_string();
|
||||||
let query_clone = query.to_string();
|
let query_clone = query.to_string();
|
||||||
let user_message_id = user_message_id.map(|s| s.to_string());
|
let user_message_id = user_message_id.map(|s| s.to_string());
|
||||||
|
// 晋升开关与阈值取配置快照值拷贝进闭包(决策单未拍板前默认保守,开关默认关)
|
||||||
|
let auto_publish_enabled = config.auto_publish_enabled;
|
||||||
|
let auto_publish_threshold = config.auto_publish_reuse_threshold;
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
let repo = KnowledgeRepo::new(&db);
|
let repo = KnowledgeRepo::new(&db);
|
||||||
let timeline = crate::commands::knowledge_timeline::KnowledgeTimeline::new(&db);
|
let timeline = crate::commands::knowledge_timeline::KnowledgeTimeline::new(&db);
|
||||||
for id in &ids {
|
for (id, is_candidate) in &hits {
|
||||||
if let Err(e) = repo.increment_reuse_count(id).await {
|
if let Err(e) = repo.increment_reuse_count(id).await {
|
||||||
tracing::warn!("reuse_count +1 失败(非阻断): {}", e);
|
tracing::warn!("reuse_count +1 失败(非阻断): {}", e);
|
||||||
}
|
}
|
||||||
|
// 复用即信任:开关开启时 candidate 达到阈值自动晋升 published(SQL 侧原子判定,
|
||||||
|
// 重复调用幂等)。默认关(决策卡 C3 未拍板前保守),开启后阈值取配置。
|
||||||
|
if *is_candidate && auto_publish_enabled {
|
||||||
|
match repo.auto_publish_if_reused(id, auto_publish_threshold).await {
|
||||||
|
Ok(true) => {
|
||||||
|
tracing::info!("[knowledge] candidate 复用达标({auto_publish_threshold} 次),自动晋升 published: {id}");
|
||||||
|
// 晋升留痕:生命线记 status_changed(source_ref=auto_publish),
|
||||||
|
// fire-and-forget 与上方 record_referenced 同风格,失败仅 warn 非阻断。
|
||||||
|
timeline.record_auto_publish(id, auto_publish_threshold).await;
|
||||||
|
}
|
||||||
|
Ok(false) => {}
|
||||||
|
Err(e) => tracing::warn!("candidate 自动晋升检查失败(非阻断): {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
timeline
|
timeline
|
||||||
.record_referenced(id, &conv_id, &query_clone, user_message_id.as_deref())
|
.record_referenced(id, &conv_id, &query_clone, user_message_id.as_deref())
|
||||||
.await;
|
.await;
|
||||||
@@ -423,13 +619,15 @@ pub(crate) async fn inject_knowledge_into_prompt(
|
|||||||
/// 判断是否应触发提炼,满足则后台 spawn 提炼 task(非阻断)
|
/// 判断是否应触发提炼,满足则后台 spawn 提炼 task(非阻断)
|
||||||
///
|
///
|
||||||
/// 守卫: auto_extract 开 + trigger_mode == OnComplete + 消息数 ≥ min_messages
|
/// 守卫: auto_extract 开 + trigger_mode == OnComplete + 消息数 ≥ min_messages
|
||||||
/// + 去重标志(P1 修复:本会话已成功提炼过则跳过,防重复触发刷 candidate)
|
/// + 频次水位(提炼中 InFlight,或距上次提炼新增用户消息
|
||||||
|
/// < [`KNOWLEDGE_EXTRACT_MIN_NEW_USER_MSGS`] 则跳过)。
|
||||||
|
/// 原「每会话仅提炼一次」的 bool 硬去重已由水位制取代——长会话后半程的新经验
|
||||||
|
/// 此前永不提炼(知识库走查 2026-09-29)。
|
||||||
///
|
///
|
||||||
/// TOCTOU 修复(🟡D):老实现 read(knowledge_extracted) → release lock → spawn,
|
/// TOCTOU 防护保持:守卫判定 + **预置位**(InFlight 水位 + knowledge_extracted 哨兵)
|
||||||
/// 并发窗口内另一路 maybe_spawn_extraction 也能读到 false 各自 spawn,致重复提炼刷 candidate。
|
/// 在**同一个锁临界区**内原子完成,并发窗口内的后来者读到占用即跳过,防重复提炼刷
|
||||||
/// 现在判重 + 消息数 + **预置位**三步在**同一个锁临界区**内原子完成:置位即占用提炼槽位,
|
/// candidate。spawn 后按结果修正:0 条 / Err 清位回退水位(允许下次重试),
|
||||||
/// 并发的后来者读到 true 直接跳过。spawn 后按结果修正:0 条 / Err 清位(允许下次重试),
|
/// ≥1 条记 Done 水位(此后须再攒够阈值条用户消息才再提炼)。
|
||||||
/// ≥1 条保持 true(已提炼,后续跳过)。
|
|
||||||
pub(crate) async fn maybe_spawn_extraction(
|
pub(crate) async fn maybe_spawn_extraction(
|
||||||
session_arc: &Arc<Mutex<AiSession>>,
|
session_arc: &Arc<Mutex<AiSession>>,
|
||||||
db: &Arc<Database>,
|
db: &Arc<Database>,
|
||||||
@@ -444,46 +642,60 @@ pub(crate) async fn maybe_spawn_extraction(
|
|||||||
if config.trigger_mode != ExtractTrigger::OnComplete {
|
if config.trigger_mode != ExtractTrigger::OnComplete {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
// P1 修复(提炼重复触发)+ 🟡D TOCTOU 修复:判重 + 消息数守卫 + 预置位同一锁内原子完成。
|
// 频次水位守卫 + 消息数守卫 + 预置位在同一锁临界区内原子完成(TOCTOU 防护与原 P1 修复同思路:
|
||||||
// 背景:审批续跑 try_continue → 重 spawn run_agentic_loop → 正常完成块 →
|
// 审批续跑 try_continue → 重 spawn run_agentic_loop → 正常完成块会二次触发本函数,
|
||||||
// maybe_spawn_extraction 二次触发,无去重致同知识点重复 candidate 刷屏。
|
// 无占位致并发重复提炼刷 candidate)。
|
||||||
// 预置位语义:knowledge_extracted 在 spawn 前先置 true 占提炼槽位,并发后来者读到
|
// 清位/回退:trigger_extraction_now(手动按钮)强制清哨兵 + 水位,允许用户立即手动重提炼。
|
||||||
// true 即跳过(等价「提炼中」哨兵);spawn 后按 inserted 结果修正(见下)。
|
|
||||||
// 清位:trigger_extraction_now(手动按钮)强制清位,允许用户手动重提炼。
|
|
||||||
{
|
{
|
||||||
let __lock_t = std::time::Instant::now();
|
let __lock_t = std::time::Instant::now();
|
||||||
let mut session = session_arc.lock().await;
|
let mut session = session_arc.lock().await;
|
||||||
// 一次 conv_read 读两个字段(knowledge_extracted + messages.len()),map 后借用即结束,
|
// 一次 conv_read 读两个字段(消息总数守卫 + 用户消息水位计量),map 后借用即结束,
|
||||||
// 后续 session.conv()(&mut self)不再冲突。
|
// 后续 session.conv()(&mut self)不再冲突。
|
||||||
let (already, count) = session
|
let (msg_count, user_rounds) = session
|
||||||
.conv_read(conv_id)
|
.conv_read(conv_id)
|
||||||
.map(|c| (c.knowledge_extracted, c.messages.len()))
|
.map(|c| (c.messages.len(), count_user_rounds(c.messages.iter())))
|
||||||
.unwrap_or((false, 0));
|
.unwrap_or((0, 0));
|
||||||
// 守卫 1:已提炼过(或提炼中) → 跳过 + warn。
|
// 守卫 1:频次水位(InFlight=提炼中防并发;Done=新增用户消息不足阈值)。
|
||||||
if already {
|
// 替代原 bool 硬去重;跳过走 debug 留痕(改水位制后此路径高频,warn 会刷屏)。
|
||||||
|
let blocked = {
|
||||||
|
let wms = EXTRACT_WATERMARKS.lock().expect("提炼水位互斥锁中毒");
|
||||||
|
match wms.get(conv_id) {
|
||||||
|
Some(ExtractWatermark::InFlight) => true,
|
||||||
|
Some(ExtractWatermark::Done(last)) => {
|
||||||
|
user_rounds.saturating_sub(*last) < KNOWLEDGE_EXTRACT_MIN_NEW_USER_MSGS
|
||||||
|
}
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if blocked {
|
||||||
let __hold = __lock_t.elapsed();
|
let __hold = __lock_t.elapsed();
|
||||||
if __hold > std::time::Duration::from_millis(30) {
|
if __hold > std::time::Duration::from_millis(30) {
|
||||||
eprintln!("[LOCK-SLOW] maybe_spawn_extraction:437 持锁 {:?} (含 lock 等待)", __hold);
|
eprintln!("[LOCK-SLOW] maybe_spawn_extraction 持锁 {:?} (含 lock 等待)", __hold);
|
||||||
}
|
}
|
||||||
tracing::warn!(
|
tracing::debug!(
|
||||||
conv_id = %conv_id,
|
conv_id = %conv_id,
|
||||||
"[knowledge] 跳过自动提炼:本会话已提炼过/提炼中(去重标志置位,防重复刷 candidate);如需重提炼用手动按钮"
|
"[knowledge] 跳过自动提炼:提炼中或距上次提炼新增用户消息不足 {} 条(如需立即重提炼用手动按钮)",
|
||||||
|
KNOWLEDGE_EXTRACT_MIN_NEW_USER_MSGS
|
||||||
);
|
);
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
// 守卫 2:消息数。conv_read 未建返 0(< min_messages 自然跳过,语义=空对话不注入知识)。
|
// 守卫 2:消息数。conv_read 未建返 0(< min_messages 自然跳过,语义=空对话不注入知识)。
|
||||||
if (count as u32) < config.min_messages {
|
if msg_count < config.min_messages as usize {
|
||||||
let __hold = __lock_t.elapsed();
|
let __hold = __lock_t.elapsed();
|
||||||
if __hold > std::time::Duration::from_millis(30) {
|
if __hold > std::time::Duration::from_millis(30) {
|
||||||
eprintln!("[LOCK-SLOW] maybe_spawn_extraction:437 持锁 {:?} (含 lock 等待)", __hold);
|
eprintln!("[LOCK-SLOW] maybe_spawn_extraction 持锁 {:?} (含 lock 等待)", __hold);
|
||||||
}
|
}
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
// 原子预置位(TOCTOU 核心):释放锁前先占提炼槽位,杜绝并发窗口内重复 spawn。
|
// 原子预置位(TOCTOU 核心):释放锁前先占提炼槽位,杜绝并发窗口内重复 spawn。
|
||||||
session.conv(conv_id).knowledge_extracted = true;
|
session.conv(conv_id).knowledge_extracted = true;
|
||||||
|
EXTRACT_WATERMARKS
|
||||||
|
.lock()
|
||||||
|
.expect("提炼水位互斥锁中毒")
|
||||||
|
.insert(conv_id.to_string(), ExtractWatermark::InFlight);
|
||||||
let __hold = __lock_t.elapsed();
|
let __hold = __lock_t.elapsed();
|
||||||
if __hold > std::time::Duration::from_millis(30) {
|
if __hold > std::time::Duration::from_millis(30) {
|
||||||
eprintln!("[LOCK-SLOW] maybe_spawn_extraction:437 持锁 {:?} (含 lock 等待)", __hold);
|
eprintln!("[LOCK-SLOW] maybe_spawn_extraction 持锁 {:?} (含 lock 等待)", __hold);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,26 +708,44 @@ pub(crate) async fn maybe_spawn_extraction(
|
|||||||
match extract_knowledge_from_conversation(&db, &conv_id, &provider_config, &llm_concurrency).await {
|
match extract_knowledge_from_conversation(&db, &conv_id, &provider_config, &llm_concurrency).await {
|
||||||
Ok(inserted) => {
|
Ok(inserted) => {
|
||||||
if inserted > 0 {
|
if inserted > 0 {
|
||||||
// ≥1 条:保持预置的 true(已占用槽位即最终状态),仅 log。
|
// ≥1 条:哨兵保持置位,水位记为当前用户消息进度
|
||||||
|
// (此后须再新增 ≥ 阈值条用户消息才会再提炼)。
|
||||||
|
let session = session_arc.lock().await;
|
||||||
|
let rounds = session
|
||||||
|
.conv_read(&conv_id)
|
||||||
|
.map(|c| count_user_rounds(c.messages.iter()))
|
||||||
|
.unwrap_or(0);
|
||||||
|
EXTRACT_WATERMARKS
|
||||||
|
.lock()
|
||||||
|
.expect("提炼水位互斥锁中毒")
|
||||||
|
.insert(conv_id.clone(), ExtractWatermark::Done(rounds));
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
conv_id = %conv_id, inserted,
|
conv_id = %conv_id, inserted, rounds,
|
||||||
"[knowledge] 提炼成功,去重标志保持置位,后续自动触发将被跳过"
|
"[knowledge] 提炼成功,水位记为当前进度,新增用户消息达阈值后才再提炼"
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// 0 条:清位回 false,允许下次自动触发重试(对话可能后续补充了有价值内容)。
|
// 0 条:清哨兵 + 回退水位,允许下次自动触发重试(对话可能后续补充了有价值内容)。
|
||||||
let mut session = session_arc.lock().await;
|
let mut session = session_arc.lock().await;
|
||||||
session.conv(&conv_id).knowledge_extracted = false;
|
session.conv(&conv_id).knowledge_extracted = false;
|
||||||
|
EXTRACT_WATERMARKS
|
||||||
|
.lock()
|
||||||
|
.expect("提炼水位互斥锁中毒")
|
||||||
|
.remove(&conv_id);
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
conv_id = %conv_id,
|
conv_id = %conv_id,
|
||||||
"[knowledge] 提炼 0 条(无可提炼内容),清去重标志允许下次自动重试"
|
"[knowledge] 提炼 0 条(无可提炼内容),清哨兵与水位允许下次自动重试"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// 失败:清位回 false,允许下次触发重试(预置位已在,须回滚否则会话被锁死)。
|
// 失败:清哨兵 + 回退水位,允许下次触发重试(预置位已在,须回滚否则会话被锁死)。
|
||||||
tracing::warn!("知识提取失败(非阻断): {}", e);
|
tracing::warn!("知识提取失败(非阻断): {}", e);
|
||||||
let mut session = session_arc.lock().await;
|
let mut session = session_arc.lock().await;
|
||||||
session.conv(&conv_id).knowledge_extracted = false;
|
session.conv(&conv_id).knowledge_extracted = false;
|
||||||
|
EXTRACT_WATERMARKS
|
||||||
|
.lock()
|
||||||
|
.expect("提炼水位互斥锁中毒")
|
||||||
|
.remove(&conv_id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -532,9 +762,13 @@ pub async fn trigger_extraction_now(state: &AppState) -> Result<bool, String> {
|
|||||||
let conv_id = {
|
let conv_id = {
|
||||||
let mut session = state.ai_session.lock().await;
|
let mut session = state.ai_session.lock().await;
|
||||||
let conv_id = session.active_conversation_id.clone();
|
let conv_id = session.active_conversation_id.clone();
|
||||||
// 强制清去重标志:手动提炼是用户显式动作,允许对已提炼过的会话重提炼。
|
// 强制清哨兵 + 频次水位:手动提炼是用户显式动作,绕过自动路径的间隔限制(语义=手动覆盖)。
|
||||||
if let Some(cid) = conv_id.as_deref() {
|
if let Some(cid) = conv_id.as_deref() {
|
||||||
session.conv(cid).knowledge_extracted = false;
|
session.conv(cid).knowledge_extracted = false;
|
||||||
|
EXTRACT_WATERMARKS
|
||||||
|
.lock()
|
||||||
|
.expect("提炼水位互斥锁中毒")
|
||||||
|
.remove(cid);
|
||||||
}
|
}
|
||||||
conv_id
|
conv_id
|
||||||
};
|
};
|
||||||
@@ -546,11 +780,19 @@ pub async fn trigger_extraction_now(state: &AppState) -> Result<bool, String> {
|
|||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
match extract_knowledge_from_conversation(&db, &conv_id, &provider_config, &llm_concurrency).await {
|
match extract_knowledge_from_conversation(&db, &conv_id, &provider_config, &llm_concurrency).await {
|
||||||
Ok(inserted) => {
|
Ok(inserted) => {
|
||||||
// 手动提炼成功(≥1 条)后也置去重标志,保持与自动路径一致:
|
// 手动提炼成功(≥1 条)后与自动路径同语义收口:置哨兵 + 记水位,
|
||||||
// 避免手动提炼后再触发自动提炼重复。用户再次手动按按钮会再次清位,行为自洽。
|
// 避免手动提炼后再触发自动提炼重复。用户再次手动按按钮会再次清位,行为自洽。
|
||||||
if inserted > 0 {
|
if inserted > 0 {
|
||||||
let mut session = session_arc.lock().await;
|
let mut session = session_arc.lock().await;
|
||||||
session.conv(&conv_id).knowledge_extracted = true;
|
session.conv(&conv_id).knowledge_extracted = true;
|
||||||
|
let rounds = session
|
||||||
|
.conv_read(&conv_id)
|
||||||
|
.map(|c| count_user_rounds(c.messages.iter()))
|
||||||
|
.unwrap_or(0);
|
||||||
|
EXTRACT_WATERMARKS
|
||||||
|
.lock()
|
||||||
|
.expect("提炼水位互斥锁中毒")
|
||||||
|
.insert(conv_id.clone(), ExtractWatermark::Done(rounds));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => tracing::warn!("手动提炼失败(非阻断): {}", e),
|
Err(e) => tracing::warn!("手动提炼失败(非阻断): {}", e),
|
||||||
@@ -574,7 +816,8 @@ const EXTRACTION_SYSTEM_PROMPT: &str = "你是知识提炼引擎,从 AI 对话
|
|||||||
|
|
||||||
/// 从对话中提炼知识,产出 candidate 写入知识库
|
/// 从对话中提炼知识,产出 candidate 写入知识库
|
||||||
///
|
///
|
||||||
/// 流程: 读对话消息 → 过滤 user/assistant 取最后 6 条 → LLM 提炼(强制 JSON) → 解析 → 批量插入 candidate
|
/// 流程: 读对话消息 → 过滤 user/assistant 取最近 [`KNOWLEDGE_EXTRACT_WINDOW`] 条 →
|
||||||
|
/// LLM 提炼(强制 JSON) → 解析 → 批量插入 candidate
|
||||||
///
|
///
|
||||||
/// 返回值: 成功插入的 candidate 条数(inserted)。调用方(maybe_spawn_extraction)据此置
|
/// 返回值: 成功插入的 candidate 条数(inserted)。调用方(maybe_spawn_extraction)据此置
|
||||||
/// `knowledge_extracted` 去重标志(≥1 才置位,0 条不置位—允许下次自动触发重试)。
|
/// `knowledge_extracted` 去重标志(≥1 才置位,0 条不置位—允许下次自动触发重试)。
|
||||||
@@ -636,12 +879,13 @@ async fn extract_knowledge_from_conversation(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// 过滤 user/assistant,取最后 6 条
|
// 过滤 user/assistant,取最近 KNOWLEDGE_EXTRACT_WINDOW 条
|
||||||
|
// (原 6 条窗口过窄:长会话后半程的经验不在提炼视野内;放宽后单次提炼可覆盖近期完整上下文)
|
||||||
let recent: Vec<&ChatMessage> = messages
|
let recent: Vec<&ChatMessage> = messages
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|m| matches!(m.role, MessageRole::User | MessageRole::Assistant))
|
.filter(|m| matches!(m.role, MessageRole::User | MessageRole::Assistant))
|
||||||
.rev()
|
.rev()
|
||||||
.take(6)
|
.take(KNOWLEDGE_EXTRACT_WINDOW)
|
||||||
.collect();
|
.collect();
|
||||||
if recent.len() < 4 {
|
if recent.len() < 4 {
|
||||||
return Ok(0); // 太短,不值得提炼(0 条,不置去重标志)
|
return Ok(0); // 太短,不值得提炼(0 条,不置去重标志)
|
||||||
@@ -947,4 +1191,74 @@ mod tests {
|
|||||||
assert_eq!(out.len(), 1);
|
assert_eq!(out.len(), 1);
|
||||||
assert_eq!(out[0].id, "kw1");
|
assert_eq!(out[0].id, "kw1");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 检索 query 分词(extract_query_keywords) ----------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keywords_ascii_words_lowercased() {
|
||||||
|
let out = extract_query_keywords("修复 KnowledgeRepo 的 Search_Bug");
|
||||||
|
assert!(out.contains(&"knowledgerepo".to_string()), "ASCII 词应小写: {:?}", out);
|
||||||
|
assert!(out.contains(&"search_bug".to_string()), "下划线应保留在词内: {:?}", out);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keywords_cjk_bigrams_extracted() {
|
||||||
|
let out = extract_query_keywords("修复知识库检索");
|
||||||
|
// 连续汉字两两切:修复/复知/知识/识库/检索
|
||||||
|
assert!(out.contains(&"修复".to_string()));
|
||||||
|
assert!(out.contains(&"知识".to_string()));
|
||||||
|
assert!(out.contains(&"检索".to_string()));
|
||||||
|
assert!(out.contains(&"复知".to_string()), "跨词 2-gram 允许存在(无词典近似的已知代价)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keywords_stopwords_filtered() {
|
||||||
|
let out = extract_query_keywords("帮我看看这个怎么修复");
|
||||||
|
// 帮我/看看/这个/怎么全为停用词,只剩修复
|
||||||
|
assert_eq!(out, vec!["修复".to_string()], "停用词应被过滤: {:?}", out);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keywords_short_ascii_dropped() {
|
||||||
|
let out = extract_query_keywords("use a x axis_config");
|
||||||
|
// 单字符 a/x 被过滤(use 为英文停用词),保留 axis_config
|
||||||
|
assert_eq!(out, vec!["axis_config".to_string()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keywords_single_cjk_char_dropped() {
|
||||||
|
// 孤单汉字不成 2-gram,不产出
|
||||||
|
let out = extract_query_keywords("好 的");
|
||||||
|
assert!(out.is_empty(), "单字与停用词应全过滤: {:?}", out);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keywords_frequency_ranking_and_cap() {
|
||||||
|
let text = "tokio 出现多次 tokio 然后 tokio 还有 rust 与 rust 以及 postgres";
|
||||||
|
let out = extract_query_keywords(text);
|
||||||
|
// 频次降序:tokio(3) > rust(2) > postgres(1);「然后」为停用词
|
||||||
|
assert_eq!(out[0], "tokio");
|
||||||
|
assert_eq!(out[1], "rust");
|
||||||
|
assert!(out.contains(&"postgres".to_string()));
|
||||||
|
// 同频次按首现顺序稳定:构造 8 个互异词验证上限 6
|
||||||
|
let many = extract_query_keywords("alpha beta gamma delta epsilon zeta eta theta");
|
||||||
|
assert_eq!(many.len(), 6, "应截断到前 6 个: {:?}", many);
|
||||||
|
assert_eq!(many[0], "alpha", "同频次按首现顺序");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keywords_empty_or_noise_returns_empty() {
|
||||||
|
assert!(extract_query_keywords("").is_empty());
|
||||||
|
assert!(extract_query_keywords(" ,。!?").is_empty(), "纯标点应产出空");
|
||||||
|
assert!(extract_query_keywords("帮我看看一下这个").is_empty(), "全停用词应产出空");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keywords_mixed_ascii_cjk_segmented() {
|
||||||
|
let out = extract_query_keywords("用cargo编译失败error E0499");
|
||||||
|
assert!(out.contains(&"cargo".to_string()));
|
||||||
|
assert!(out.contains(&"e0499".to_string()), "字母数字混合词应整体保留: {:?}", out);
|
||||||
|
assert!(out.contains(&"编译".to_string()));
|
||||||
|
assert!(out.contains(&"失败".to_string()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -664,6 +664,7 @@ fn register_data_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
|||||||
register_task_graph_tools(registry, db);
|
register_task_graph_tools(registry, db);
|
||||||
register_workflow_tools(registry);
|
register_workflow_tools(registry);
|
||||||
register_idea_tools(registry, db);
|
register_idea_tools(registry, db);
|
||||||
|
register_knowledge_tools(registry, db);
|
||||||
register_trash_tools(registry, db);
|
register_trash_tools(registry, db);
|
||||||
register_git_tools(registry, db);
|
register_git_tools(registry, db);
|
||||||
}
|
}
|
||||||
@@ -749,6 +750,21 @@ fn register_idea_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
|||||||
super::tools::idea::register(registry, db);
|
super::tools::idea::register(registry, db);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 知识库类 AI 工具注册(3 个:knowledge_search 检索 + knowledge_insert 沉淀 +
|
||||||
|
/// knowledge_promote 显式转正)——持 db:Arc<Database>。
|
||||||
|
///
|
||||||
|
/// 打通 aichat 对话侧与知识库:此前 KnowledgeRepo 的检索/写入仅有 IPC 命令与 MCP 入口,
|
||||||
|
/// LLM 工具注册表无对应工具,agent 对话中既查不了过往经验也无法沉淀要点(知识库与记忆
|
||||||
|
/// 走查 2026-09-29 缺口 4)。insert 固定写 status='candidate'(LLM 写入不自动发布,
|
||||||
|
/// 与自动提炼同信任级),promote 补齐对话内显式转正通道(candidate→published 单向,
|
||||||
|
/// 与默认关闭的自动晋升互不影响),handler 细节见 tools/knowledge.rs。
|
||||||
|
///
|
||||||
|
/// 风险:search/insert 为 Low(检索只读;插入为本地库追加写,无覆盖无删除,落候选不进
|
||||||
|
/// 检索面);promote 为 Medium(改既有条目状态进检索面,单向候选转正可 UI 归档修正)。
|
||||||
|
fn register_knowledge_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||||
|
super::tools::knowledge::register(registry, db);
|
||||||
|
}
|
||||||
|
|
||||||
/// 回收站类 AI 工具注册(1 个:list_trash)——持 db:Arc<Database>。
|
/// 回收站类 AI 工具注册(1 个:list_trash)——持 db:Arc<Database>。
|
||||||
/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。
|
/// 抽自 register_data_tools(SMELL-P0-2 续拆),【原样移入】,零行为变更。
|
||||||
///
|
///
|
||||||
@@ -1303,7 +1319,7 @@ mod tests {
|
|||||||
// 任一层漏移 register 调用,此测试立即红。工具名集合也断言,防 rename 致 LLM tool 突变。
|
// 任一层漏移 register 调用,此测试立即红。工具名集合也断言,防 rename 致 LLM tool 突变。
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
/// build_ai_tool_registry 应注册恰好 66 个工具(36 data + 14 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy),且工具名集合稳定。
|
/// build_ai_tool_registry 应注册恰好 76 个工具(45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy),且工具名集合稳定。
|
||||||
///
|
///
|
||||||
/// 用 in-memory SQLite(Database::open_in_memory 自跑迁移),构造零外部依赖的 db,
|
/// 用 in-memory SQLite(Database::open_in_memory 自跑迁移),构造零外部依赖的 db,
|
||||||
// 不实际执行任何 handler——仅断言注册阶段的定义完整性,故无需真实数据。
|
// 不实际执行任何 handler——仅断言注册阶段的定义完整性,故无需真实数据。
|
||||||
@@ -1368,18 +1384,24 @@ mod tests {
|
|||||||
// diff_files(2026-08-08): file 层 14→15(新增任意两文件内容对比,只读 Low,
|
// diff_files(2026-08-08): file 层 14→15(新增任意两文件内容对比,只读 Low,
|
||||||
// 补 git_diff 仅仓库内缺口;复用 generate_diff LCS unified diff;namespace ALWAYS_LARGE_TOOLS 既有引用生效)。
|
// 补 git_diff 仅仓库内缺口;复用 generate_diff LCS unified diff;namespace ALWAYS_LARGE_TOOLS 既有引用生效)。
|
||||||
// 73 = 42 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy。
|
// 73 = 42 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy。
|
||||||
|
// 知识库工具(2026-09-29): data 层 42→44(新增 knowledge_search/knowledge_insert,
|
||||||
|
// 打通对话侧与知识库双向检索/沉淀;insert 落 candidate 不自动发布,均为 Low)。
|
||||||
|
// 75 = 44 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy。
|
||||||
|
// knowledge_promote(2026-10-01): data 层 44→45(新增候选知识显式转正工具,
|
||||||
|
// candidate→published 单向,补齐自动晋升默认关后的对话内审核通道,Medium)。
|
||||||
|
// 76 = 45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy。
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
registry.len(),
|
registry.len(),
|
||||||
73,
|
76,
|
||||||
"工具总数应为 73(42 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy),实际 {}", registry.len()
|
"工具总数应为 76(45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy),实际 {}", registry.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
// 工具名集合基线:防 rename / 漏注册 / 误删除。
|
// 工具名集合基线:防 rename / 漏注册 / 误删除。
|
||||||
// data 层 42 个(持 db):CRUD/状态机/工作流/知识图谱任务关联 + 项目事件流 + 基础设施配置 + git 工具 + 工程模块写工具
|
// data 层 45 个(持 db):CRUD/状态机/工作流/知识图谱任务关联 + 项目事件流 + 基础设施配置 + git 工具 + 工程模块写工具 + 知识库检索/沉淀/转正
|
||||||
// file 层 15 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜/grep/环境探测/符号解析/下载/对比
|
// file 层 15 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜/grep/环境探测/符号解析/下载/对比
|
||||||
// http 层 1 个(不持 db):http_request
|
// http 层 1 个(不持 db):http_request
|
||||||
let mut expected: Vec<&str> = vec![
|
let mut expected: Vec<&str> = vec![
|
||||||
// ── data 层 (42) ──
|
// ── data 层 (44) ──
|
||||||
"list_projects", "list_tasks", "list_ideas",
|
"list_projects", "list_tasks", "list_ideas",
|
||||||
"update_project", "create_project", "bind_directory",
|
"update_project", "create_project", "bind_directory",
|
||||||
"create_task", "update_task", "advance_task",
|
"create_task", "update_task", "advance_task",
|
||||||
@@ -1403,6 +1425,10 @@ mod tests {
|
|||||||
"git_status", "git_diff", "git_log",
|
"git_status", "git_diff", "git_log",
|
||||||
// Git 写工具
|
// Git 写工具
|
||||||
"git_commit", "git_branch", "git_merge",
|
"git_commit", "git_branch", "git_merge",
|
||||||
|
// 知识库工具(2026-09-29):检索已发布知识 + 沉淀候选知识,打通对话侧与知识库
|
||||||
|
"knowledge_search", "knowledge_insert",
|
||||||
|
// 候选知识显式转正(2026-10-01):candidate→published 单向,对话内审核通道
|
||||||
|
"knowledge_promote",
|
||||||
// ── file 层 (15) ──(run_command 注册顺序已移至末位降低 LLM 偏好,
|
// ── file 层 (15) ──(run_command 注册顺序已移至末位降低 LLM 偏好,
|
||||||
// 集合断言经 sort 后与顺序无关,仅守护工具名不漂移。grep 新增 F-260621;
|
// 集合断言经 sort 后与顺序无关,仅守护工具名不漂移。grep 新增 F-260621;
|
||||||
// detect_environment 新增 L1 环境感知 设计 §2.1;read_symbol 新增 AST 代码智能;
|
// detect_environment 新增 L1 环境感知 设计 §2.1;read_symbol 新增 AST 代码智能;
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
//! 知识库类 AI 工具声明式注册(knowledge_search + knowledge_insert + knowledge_promote 共 3 个)。
|
||||||
|
//!
|
||||||
|
//! 背景:aichat 知识库与记忆走查(2026-09-29)缺口 4——知识库已有检索/写入的 Repo 与
|
||||||
|
//! IPC 命令,但 LLM 工具注册表无对应工具,agent 对话中既查不了过往经验也无法沉淀要点,
|
||||||
|
//! 知识库对对话侧是「只进不出的孤岛」。本文件把 KnowledgeRepo 的检索/插入暴露为工具,
|
||||||
|
//! 让 agent 按需双向打通。
|
||||||
|
//!
|
||||||
|
//! 信任级设计:knowledge_insert 固定写 status='candidate'(与自动提炼同信任级),
|
||||||
|
//! 不自动发布——LLM 写入未经人确认,直接 published 会污染检索面(检索只看 published);
|
||||||
|
//! 转正通道有二:①既有复用晋升(默认关,决策卡 C3 未拍板)与人工审核/UI 收件箱;
|
||||||
|
//! ②knowledge_promote 显式转正(对话内人工/agent 拍板,仅 candidate 可转,与自动晋升
|
||||||
|
//! 互不影响)。insert 只负责落候选,转正须显式调用 promote,不存在隐式发布。
|
||||||
|
//!
|
||||||
|
//! 溯源说明:工具 handler 只有 args 入参,无会话上下文注入机制,拿不到 conv_id,
|
||||||
|
//! 故 source_ref 记渠道标记 "ai-chat" 兜底(标识写入来源是 AI 对话,精确到会话待
|
||||||
|
//! 上下文注入机制具备后补全)。
|
||||||
|
//!
|
||||||
|
//! kind 默认值:任务草案默认 "fact",但 knowledges 表合法 kind 是 7 种 KnowledgeKind
|
||||||
|
//! snake_case(review_rule/prompt_template/pitfall/architecture_pattern/diagnosis/
|
||||||
|
//! deployment_note/workflow_optimization,见 migrations.rs V7 注释),无 "fact";
|
||||||
|
//! 表 DEFAULT 是 'pitfall',故跟随表语义默认 'pitfall'。knowledge_inject 有写
|
||||||
|
//! "snippet" 的先例(表无 CHECK 约束),故不做硬白名单校验,非空即透传——过严校验
|
||||||
|
//! 会把生态已在用的合法值拒成假失败。
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
|
||||||
|
use df_ai::declare_tool;
|
||||||
|
use df_storage::db::Database;
|
||||||
|
use df_storage::models::KnowledgeRecord;
|
||||||
|
use df_types::types::new_id;
|
||||||
|
|
||||||
|
use crate::commands::knowledge_timeline::KnowledgeTimeline;
|
||||||
|
use crate::commands::now_millis;
|
||||||
|
|
||||||
|
/// search 结果单条 content 的截断长度:知识全文可能很长,原样返回会回灌 prompt
|
||||||
|
/// 挤占上下文;截 500 字给 LLM 足够判断「这条是否相关」,需全文时走 IPC/前端看详情。
|
||||||
|
const SEARCH_CONTENT_SNIPPET_LEN: usize = 500;
|
||||||
|
|
||||||
|
/// 注册 3 个知识库工具(search/insert/promote)到 `$registry`。
|
||||||
|
///
|
||||||
|
/// 风险:search/insert 为 Low——search 只读;insert 是本地库追加写,无覆盖无删除,且落
|
||||||
|
/// candidate 不进检索面,误写代价低(人工审核时丢弃即可),无需审批拦截。promote 为
|
||||||
|
/// Medium——改既有条目状态(进检索面),但单向 candidate→published、SQL WHERE 收口
|
||||||
|
/// 仅命中候选,禁降级/归档/改内容,误转正可在 UI 收件箱归档修正。
|
||||||
|
pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||||
|
// 检索工具:回答前查过往经验/约定(用户问「之前怎么处理的/有没有记过」时先 search)
|
||||||
|
declare_tool!(
|
||||||
|
registry,
|
||||||
|
db: Arc<Database>,
|
||||||
|
"knowledge_search",
|
||||||
|
"检索本地知识库(仅已发布知识,按关键词 LIKE 匹配,按复用次数降序)。使用时机:回答前需要过往经验/项目约定/踩坑记录时调用,如用户问「之前怎么解决的」「有没有相关约定」。用户说「记住/记一下/以后注意」时不要用本工具,改用 knowledge_insert。参数:query=知识主题关键词(必填),limit=返回条数(可选,默认 5,上限 10)",
|
||||||
|
RiskLevel::Low,
|
||||||
|
schema: object_schema(vec![("query", "string", true), ("limit", "integer", false)]),
|
||||||
|
args => {
|
||||||
|
let query = args["query"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 query"))?;
|
||||||
|
if query.trim().is_empty() {
|
||||||
|
anyhow::bail!("query 不能为空");
|
||||||
|
}
|
||||||
|
let limit = args["limit"].as_u64().unwrap_or(5).clamp(1, 10) as usize;
|
||||||
|
let repo = df_storage::crud::KnowledgeRepo::new(&db);
|
||||||
|
let hits = repo.search(query, None, limit).await?;
|
||||||
|
// 空结果返回可执行的下一步指引而非裸空数组:弱模型拿到 [] 常直接回「没找到」
|
||||||
|
// 结束,带上「让用户口述 + insert 沉淀」能把检索失败转化为知识积累入口。
|
||||||
|
if hits.is_empty() {
|
||||||
|
return Ok(serde_json::json!({
|
||||||
|
"results": [],
|
||||||
|
"hint": "知识库中无相关知识。可提示用户直接口述要点,然后用 knowledge_insert 沉淀。"
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
let results: Vec<serde_json::Value> = hits
|
||||||
|
.iter()
|
||||||
|
.map(|k| {
|
||||||
|
// 按 chars 截断而非字节切片:中文内容按字节切会落在 UTF-8 边界上 panic
|
||||||
|
let snippet = if k.content.chars().count() > SEARCH_CONTENT_SNIPPET_LEN {
|
||||||
|
let truncated: String = k
|
||||||
|
.content
|
||||||
|
.chars()
|
||||||
|
.take(SEARCH_CONTENT_SNIPPET_LEN)
|
||||||
|
.collect();
|
||||||
|
format!("{truncated}…")
|
||||||
|
} else {
|
||||||
|
k.content.clone()
|
||||||
|
};
|
||||||
|
serde_json::json!({
|
||||||
|
"title": k.title,
|
||||||
|
"kind": k.kind,
|
||||||
|
"content": snippet,
|
||||||
|
"status": k.status,
|
||||||
|
"reuse_count": k.reuse_count,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(serde_json::json!({ "results": results }))
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 沉淀工具:用户说「记住/记一下/以后注意」时把要点落为候选知识
|
||||||
|
declare_tool!(
|
||||||
|
registry,
|
||||||
|
db: Arc<Database>,
|
||||||
|
"knowledge_insert",
|
||||||
|
"把一条经验/约定/踩坑要点沉淀进本地知识库(写入为候选知识,经复用或人工审核后转正,不会立即进入检索)。使用时机:用户说「记住/记一下/以后注意/这是我踩过的坑」时调用,先提炼要点再写入。参数:title=一句话概括(必填),content=要点详情(必填),kind=知识类型(可选,常用:pitfall 踩坑/architecture_pattern 架构模式/diagnosis 诊断/review_rule 评审规则/prompt_template 提示词模板/deployment_note 部署要点/workflow_optimization 流程优化,默认 pitfall),tags=标签数组(可选)。回答前查已有知识请用 knowledge_search",
|
||||||
|
RiskLevel::Low,
|
||||||
|
schema: object_schema(vec![
|
||||||
|
("title", "string", true),
|
||||||
|
("content", "string", true),
|
||||||
|
("kind", "string", false),
|
||||||
|
("tags", "array", false),
|
||||||
|
]),
|
||||||
|
args => {
|
||||||
|
let title = args["title"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 title"))?;
|
||||||
|
let content = args["content"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 content"))?;
|
||||||
|
if title.trim().is_empty() || content.trim().is_empty() {
|
||||||
|
anyhow::bail!("title 和 content 不能为空");
|
||||||
|
}
|
||||||
|
// 非空即透传不做硬白名单(见模块注释:snippet 先例,过严校验制造假失败)
|
||||||
|
let kind = match args["kind"].as_str() {
|
||||||
|
Some(k) if !k.trim().is_empty() => k.trim().to_string(),
|
||||||
|
_ => "pitfall".to_string(),
|
||||||
|
};
|
||||||
|
// tags 统一序列化为 JSON 数组字符串落库(与表 tags 列约定一致, KnowledgeRecord.tags=JSON 数组字符串)
|
||||||
|
let tags = args["tags"].as_array().map(|arr| {
|
||||||
|
let items: Vec<String> = arr
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||||
|
.collect();
|
||||||
|
serde_json::to_string(&items).unwrap_or_else(|_| "[]".to_string())
|
||||||
|
});
|
||||||
|
let now = now_millis();
|
||||||
|
let record = KnowledgeRecord {
|
||||||
|
id: new_id(),
|
||||||
|
kind,
|
||||||
|
title: title.trim().to_string(),
|
||||||
|
content: content.trim().to_string(),
|
||||||
|
tags,
|
||||||
|
// LLM 写入不自动发布:与自动提炼同信任级,经复用晋升或人工审核转正
|
||||||
|
status: "candidate".to_string(),
|
||||||
|
confidence: None,
|
||||||
|
reuse_count: 0,
|
||||||
|
verified: false,
|
||||||
|
source_project: None,
|
||||||
|
// handler 无会话上下文,拿不到 conv_id,记渠道标记兜底溯源
|
||||||
|
source_ref: Some("ai-chat".to_string()),
|
||||||
|
reasoning: None,
|
||||||
|
embedding_status: None,
|
||||||
|
created_at: now.clone(),
|
||||||
|
updated_at: now,
|
||||||
|
};
|
||||||
|
let id = record.id.clone();
|
||||||
|
let kind_out = record.kind.clone();
|
||||||
|
let repo = df_storage::crud::KnowledgeRepo::new(&db);
|
||||||
|
repo.insert(record).await?;
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"id": id,
|
||||||
|
"title": title,
|
||||||
|
"kind": kind_out,
|
||||||
|
"status": "candidate",
|
||||||
|
"note": "已记录为候选知识,复用或审核后转正"
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 显式转正工具:对话内人工/agent 拍板 candidate → published,补齐「检索/沉淀之外
|
||||||
|
// 缺显式转正路径」的通道缺口。自动晋升(auto_publish,默认关)与本工具互不影响:
|
||||||
|
// 前者靠复用计数达标,本工具靠显式调用,SQL WHERE 均只命中 candidate,天然幂等。
|
||||||
|
// 转正后经 KnowledgeTimeline 记 status_changed(source_ref="agent")留痕供审计,
|
||||||
|
// fire-and-forget 与 knowledge_inject 晋升留痕同风格(fire 内部吞错误只 warn)。
|
||||||
|
declare_tool!(
|
||||||
|
registry,
|
||||||
|
db: Arc<Database>,
|
||||||
|
"knowledge_promote",
|
||||||
|
"把一条候选知识显式转正为已发布(candidate → published 单向,转正后进入检索面)。使用时机:用户明确认可某条候选知识时调用,如「这条可以转正/发布/采纳」,先用 knowledge_search 确认、或从 knowledge_insert 的返回拿到知识 ID。边界:仅 status=candidate 的知识可转正,已发布/已归档会返回明确错误;本工具不降级、不归档、不改内容。与自动晋升(默认关闭)互不影响,属于显式人工/agent 审核通道。参数:id=知识 ID(必填)",
|
||||||
|
RiskLevel::Medium,
|
||||||
|
schema: object_schema(vec![("id", "string", true)]),
|
||||||
|
args => {
|
||||||
|
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
|
||||||
|
if id.trim().is_empty() {
|
||||||
|
anyhow::bail!("id 不能为空");
|
||||||
|
}
|
||||||
|
let repo = df_storage::crud::KnowledgeRepo::new(&db);
|
||||||
|
if !repo.promote(id).await? {
|
||||||
|
// false = id 不存在 或 非 candidate,查一次回明确文案防 LLM 盲重试
|
||||||
|
let current = repo.get_by_id(id).await?;
|
||||||
|
match current {
|
||||||
|
None => anyhow::bail!("知识不存在: {id}"),
|
||||||
|
Some(rec) => anyhow::bail!(
|
||||||
|
"仅 candidate 可转正(当前状态: {},id: {id}),已发布/已归档知识不做变更",
|
||||||
|
rec.status
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 留痕:生命线记 status_changed(source_ref="agent"),fire-and-forget 非阻断
|
||||||
|
KnowledgeTimeline::new(&db)
|
||||||
|
.record_manual_publish(id, "agent")
|
||||||
|
.await;
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"id": id,
|
||||||
|
"status": "published",
|
||||||
|
"note": "已转正为已发布,进入检索面"
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -153,6 +153,46 @@ impl KnowledgeTimeline {
|
|||||||
self.fire(knowledge_id, EVENT_STATUS_CHANGED, None, Some(ctx)).await;
|
self.fire(knowledge_id, EVENT_STATUS_CHANGED, None, Some(ctx)).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 复用达标自动晋升(candidate 转 published;context: from / to / threshold)
|
||||||
|
///
|
||||||
|
/// 复用即信任路径的晋升留痕,与人工审核的 status_changed 同事件类型,
|
||||||
|
/// source_ref=auto_publish 区分晋升来源(供审计区分未审核固化与人工放行)。
|
||||||
|
pub async fn record_auto_publish(&self, knowledge_id: &str, threshold: i64) {
|
||||||
|
let ctx = serde_json::json!({
|
||||||
|
"from": "candidate",
|
||||||
|
"to": "published",
|
||||||
|
"threshold": threshold,
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
self.fire(
|
||||||
|
knowledge_id,
|
||||||
|
EVENT_STATUS_CHANGED,
|
||||||
|
Some("auto_publish".into()),
|
||||||
|
Some(ctx),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 显式转正(candidate 转 published;context: from / to;source_ref=晋升来源)
|
||||||
|
///
|
||||||
|
/// 人工/agent 拍板路径的晋升留痕,与自动晋升 record_auto_publish 同事件类型,
|
||||||
|
/// source_ref 由调用方传入(如 "agent"=对话内 knowledge_promote 工具)区分晋升来源,
|
||||||
|
/// 供审计区分未审核固化、复用达标固化与显式放行。
|
||||||
|
pub async fn record_manual_publish(&self, knowledge_id: &str, source: &str) {
|
||||||
|
let ctx = serde_json::json!({
|
||||||
|
"from": "candidate",
|
||||||
|
"to": "published",
|
||||||
|
})
|
||||||
|
.to_string();
|
||||||
|
self.fire(
|
||||||
|
knowledge_id,
|
||||||
|
EVENT_STATUS_CHANGED,
|
||||||
|
Some(source.to_string()),
|
||||||
|
Some(ctx),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
/// 内部:写入并吞错误(fire-and-forget,失败只 warn 不阻断主流程)
|
/// 内部:写入并吞错误(fire-and-forget,失败只 warn 不阻断主流程)
|
||||||
async fn fire(
|
async fn fire(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -46,6 +46,38 @@ pub struct KnowledgeConfig {
|
|||||||
/// embedding 模型名(如 embedding-3 / text-embedding-3-small)
|
/// embedding 模型名(如 embedding-3 / text-embedding-3-small)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub embedding_model: Option<String>,
|
pub embedding_model: Option<String>,
|
||||||
|
/// 检索纳入 candidate(AI 自动提炼产物)开关,默认 true
|
||||||
|
///
|
||||||
|
/// 背景(知识库走查 2026-09-29):自动提炼产物全部滞留 candidate 而检索只认 published,
|
||||||
|
/// 存量知识近乎零生效。纳入后配合「复用即信任」(reuse_count 达标自动晋升 published),
|
||||||
|
/// 人工审核降级为可选修正路径。关=回到仅 published(兼容旧行为)。
|
||||||
|
/// serde default=true:已持久化的旧配置 JSON 缺该字段时按新默认生效,不破坏启动恢复。
|
||||||
|
#[serde(default = "default_include_candidates")]
|
||||||
|
pub include_candidates: bool,
|
||||||
|
/// 复用即信任自动晋升开关,默认 false
|
||||||
|
///
|
||||||
|
/// 背景(决策单未拍板前默认保守):candidate 命中达标自动转正 published 是单向固化
|
||||||
|
/// (无回退路径),且 2-gram 分词的误命中同样计入 reuse_count,未审核的 LLM 提炼产物
|
||||||
|
/// 可能经误命中固化污染知识库。晋升固化路径默认关,待决策卡 C3 拍板后由用户显式开启。
|
||||||
|
/// serde default=false:已持久化的旧配置 JSON 缺该字段时按保守默认生效,不破坏启动恢复。
|
||||||
|
#[serde(default)]
|
||||||
|
pub auto_publish_enabled: bool,
|
||||||
|
/// 自动晋升的复用命中阈值,默认 5
|
||||||
|
///
|
||||||
|
/// 原硬编码 2 过激进:2-gram 分词的误命中两次即可凑满转正;5 次真实复用才够格晋升。
|
||||||
|
/// serde default=5:旧配置 JSON 缺该字段时按新默认生效。
|
||||||
|
#[serde(default = "default_auto_publish_reuse_threshold")]
|
||||||
|
pub auto_publish_reuse_threshold: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// include_candidates 的 serde 默认值(true 语义名收口,与 Default impl 保持一处语义)
|
||||||
|
fn default_include_candidates() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// auto_publish_reuse_threshold 的 serde 默认值(5 语义名收口,与 Default impl 保持一处语义)
|
||||||
|
fn default_auto_publish_reuse_threshold() -> i64 {
|
||||||
|
5
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for KnowledgeConfig {
|
impl Default for KnowledgeConfig {
|
||||||
@@ -58,6 +90,9 @@ impl Default for KnowledgeConfig {
|
|||||||
vector_enabled: false,
|
vector_enabled: false,
|
||||||
embedding_provider_id: None,
|
embedding_provider_id: None,
|
||||||
embedding_model: None,
|
embedding_model: None,
|
||||||
|
include_candidates: true,
|
||||||
|
auto_publish_enabled: false,
|
||||||
|
auto_publish_reuse_threshold: 5,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -696,6 +696,11 @@ export interface AiToolCallInfo {
|
|||||||
/** path_auth 审批链阶段3b:'path' 类挂起携带的原始路径(工具调用参数中的 path)。
|
/** path_auth 审批链阶段3b:'path' 类挂起携带的原始路径(工具调用参数中的 path)。
|
||||||
* ToolCard 审批提示文案展示用(LLM 想访问 path,父目录为 dir)。'risk' 类为 undefined。 */
|
* ToolCard 审批提示文案展示用(LLM 想访问 path,父目录为 dir)。'risk' 类为 undefined。 */
|
||||||
path?: string
|
path?: string
|
||||||
|
/** 工具开始执行时间(前端在 AiToolCallStarted 时记录,毫秒)。历史消息从 DB 回载无此字段,
|
||||||
|
* 后端事件协议不含耗时,卡片耗时/轨迹总耗时由前端时间差计算,缺字段时隐藏不显 0。 */
|
||||||
|
startedAt?: number
|
||||||
|
/** 工具执行完成时间(前端在 AiToolCallCompleted 时记录,毫秒)。语义同 startedAt。 */
|
||||||
|
completedAt?: number
|
||||||
/**
|
/**
|
||||||
* A2-B10 conv-scoped 审批收尾:挂起审批所属的会话 id(前端判定用,仅挂起项设置)。
|
* A2-B10 conv-scoped 审批收尾:挂起审批所属的会话 id(前端判定用,仅挂起项设置)。
|
||||||
* 来源:AiApprovalRequired / AiDirAuthRequired(统一审批归一)事件透传 conversation_id
|
* 来源:AiApprovalRequired / AiDirAuthRequired(统一审批归一)事件透传 conversation_id
|
||||||
|
|||||||
Reference in New Issue
Block a user