新增: 知识库检索分词化与晋升配置化及对话工具
整句 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:
@@ -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": "已转正为已发布,进入检索面"
|
||||
}))
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user