新增: 知识库检索分词化与晋升配置化及对话工具

整句 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:
lxy
2026-10-01 02:40:36 +08:00
parent bd2e94b54f
commit 63779e8d69
7 changed files with 1005 additions and 50 deletions
@@ -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": "已转正为已发布,进入检索面"
}))
}
);
}