- 当前会话归属项目组置顶,focused 标记高亮 - source_ref 解析出可跳转会话 id,消息级引用反查补全 - 条目相对时间/空态引导/标题点击跳源,失败静默
359 lines
14 KiB
Rust
359 lines
14 KiB
Rust
//! 脉络卡数据面(跨会话召回四层架构 L3 用户侧辅助通道):
|
|
//! 按 source_project 聚合 published 知识,给前端新会话入口展示「这个项目之前定了什么」。
|
|
//!
|
|
//! 铁律红线:本通道只喂「用户看的卡」,不进 system、不进消息流——用户点「带入上下文」
|
|
//! 才经正常发送路径显式注入(用户决策,非 LLM 自动召回)。
|
|
|
|
use tauri::State;
|
|
|
|
use crate::commands::ai::knowledge_inject::resolve_source_project;
|
|
|
|
/// 单条脉络摘要(字段前端契约,snake_case)
|
|
#[derive(serde::Serialize, Clone, Debug)]
|
|
pub struct ContextBrief {
|
|
pub title: String,
|
|
pub summary: String,
|
|
pub source_ref: Option<String>,
|
|
pub source_project: Option<String>,
|
|
pub reuse_count: i32,
|
|
/// 知识条目最近更新时间,原样透供前端展示「这条什么时候定的」
|
|
pub updated_at: String,
|
|
/// 可跳转的来源会话 id(自 source_ref 解析,消息级引用经 ai_messages 反查);
|
|
/// 解析不出(无源/引用失效)为 None,前端降级纯文本标题
|
|
pub source_conv: Option<String>,
|
|
}
|
|
|
|
/// 项目脉络分组
|
|
#[derive(serde::Serialize, Clone, Debug)]
|
|
pub struct ProjectBriefs {
|
|
pub source_project: String,
|
|
/// 是否为当前会话归属项目(仅传 focus_conv 且解析命中时为 true,前端做置顶高亮)
|
|
pub focused: bool,
|
|
pub briefs: Vec<ContextBrief>,
|
|
}
|
|
|
|
/// 单条摘要截断上限(字符):脉络卡是扫一眼的导航,不是全文阅读,超长压到一行半
|
|
const BRIEF_SUMMARY_MAX_CHARS: usize = 120;
|
|
|
|
fn truncate_chars(s: &str, max: usize) -> String {
|
|
if s.chars().count() <= max {
|
|
return s.to_string();
|
|
}
|
|
let mut out: String = s.chars().take(max).collect();
|
|
out.push('…');
|
|
out
|
|
}
|
|
|
|
/// source_ref 溯源引用的三种形态:对话级 `conv:{id}` 直接可跳;
|
|
/// 消息级 `conv_msg:{msg_id}` 须查 ai_messages 反查会话;其余(空/异形)不可跳。
|
|
enum SourceConvRef {
|
|
Conv(String),
|
|
Msg(String),
|
|
None,
|
|
}
|
|
|
|
fn parse_source_conv(source_ref: Option<&str>) -> SourceConvRef {
|
|
let Some(r) = source_ref else {
|
|
return SourceConvRef::None;
|
|
};
|
|
if let Some(id) = r.strip_prefix("conv:") {
|
|
if id.is_empty() {
|
|
return SourceConvRef::None;
|
|
}
|
|
return SourceConvRef::Conv(id.to_string());
|
|
}
|
|
if let Some(id) = r.strip_prefix("conv_msg:") {
|
|
if id.is_empty() {
|
|
return SourceConvRef::None;
|
|
}
|
|
return SourceConvRef::Msg(id.to_string());
|
|
}
|
|
SourceConvRef::None
|
|
}
|
|
|
|
/// 回填 source_conv:对话级引用直接取 id,消息级引用批量反查 ai_messages(一条 IN 查询,
|
|
/// 引用失效/表查不到保持 None,前端降级纯文本)。
|
|
async fn fill_source_convs(db: &df_storage::db::Database, groups: &mut [ProjectBriefs]) {
|
|
for g in groups.iter_mut() {
|
|
for b in g.briefs.iter_mut() {
|
|
if let SourceConvRef::Conv(id) = parse_source_conv(b.source_ref.as_deref()) {
|
|
b.source_conv = Some(id);
|
|
}
|
|
}
|
|
}
|
|
let msg_ids: Vec<String> = groups
|
|
.iter()
|
|
.flat_map(|g| g.briefs.iter())
|
|
.filter_map(|b| match parse_source_conv(b.source_ref.as_deref()) {
|
|
SourceConvRef::Msg(id) => Some(id),
|
|
_ => None,
|
|
})
|
|
.collect::<std::collections::HashSet<_>>()
|
|
.into_iter()
|
|
.collect();
|
|
if msg_ids.is_empty() {
|
|
return;
|
|
}
|
|
let conn = db.conn();
|
|
let guard = conn.lock().await;
|
|
let Ok(mut stmt) = guard.prepare("SELECT conversation_id FROM ai_messages WHERE id = ?1")
|
|
else {
|
|
return;
|
|
};
|
|
let mut found: std::collections::HashMap<String, String> = std::collections::HashMap::new();
|
|
for id in &msg_ids {
|
|
if let Ok(rows) = stmt.query_map([id], |row| row.get::<_, String>(0)) {
|
|
for conv_id in rows.flatten() {
|
|
found.insert(id.clone(), conv_id);
|
|
}
|
|
}
|
|
}
|
|
drop(stmt);
|
|
for g in groups.iter_mut() {
|
|
for b in g.briefs.iter_mut() {
|
|
if b.source_conv.is_some() {
|
|
continue;
|
|
}
|
|
if let SourceConvRef::Msg(id) = parse_source_conv(b.source_ref.as_deref()) {
|
|
b.source_conv = found.get(&id).cloned();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 聚合纯函数:published 记录 → 按项目分组脉络。无归属项目(source_project None)的知识
|
|
/// 不入脉络卡(无分组锚点,注入侧 top-3 已覆盖全局知识);每组按 reuse_count 降序(唯一
|
|
/// 客观信号),组间按组内最高复用数降序(活跃项目排前);`focus_project` 命中的组置顶
|
|
/// (当前会话归属项目优先展示),未命中/不传时顺序与置顶逻辑不存在时完全一致。
|
|
fn aggregate_briefs(
|
|
records: Vec<df_storage::models::KnowledgeRecord>,
|
|
per_project: usize,
|
|
focus_project: Option<&str>,
|
|
) -> Vec<ProjectBriefs> {
|
|
use std::collections::HashMap;
|
|
let mut groups: HashMap<String, Vec<ContextBrief>> = HashMap::new();
|
|
for r in records {
|
|
let Some(proj) = r.source_project.clone() else {
|
|
continue;
|
|
};
|
|
let brief = ContextBrief {
|
|
title: truncate_chars(&r.title, 60),
|
|
summary: truncate_chars(&r.content, BRIEF_SUMMARY_MAX_CHARS),
|
|
source_ref: r.source_ref,
|
|
source_project: Some(proj.clone()),
|
|
reuse_count: r.reuse_count,
|
|
updated_at: r.updated_at,
|
|
source_conv: None,
|
|
};
|
|
groups.entry(proj).or_default().push(brief);
|
|
}
|
|
let mut out: Vec<ProjectBriefs> = groups
|
|
.into_iter()
|
|
.map(|(source_project, mut briefs)| {
|
|
briefs.sort_by(|a, b| b.reuse_count.cmp(&a.reuse_count));
|
|
briefs.truncate(per_project);
|
|
let focused = focus_project == Some(source_project.as_str());
|
|
ProjectBriefs { source_project, focused, briefs }
|
|
})
|
|
.collect();
|
|
out.sort_by(|a, b| {
|
|
let top = |g: &ProjectBriefs| g.briefs.first().map(|b| b.reuse_count).unwrap_or(0);
|
|
top(b).cmp(&top(a))
|
|
});
|
|
if let Some(focus) = focus_project {
|
|
if let Some(pos) = out.iter().position(|g| g.source_project == focus) {
|
|
let focused_group = out.remove(pos);
|
|
out.insert(0, focused_group);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// 脉络卡数据:published 知识按 source_project 聚合(每项目取复用最高的 `per_project` 条)。
|
|
/// `focus_conv` 传当前会话 id 时解析其归属项目,命中组置顶并带 `focused` 标记;解析失败/
|
|
/// 无归属时与不传该参数行为完全一致(软排序只调顺序,不报错不缩面)。
|
|
/// 纯读侧,无状态无副作用;knowledge 表空/无归属项目时返空(前端不渲染卡)。
|
|
#[tauri::command]
|
|
pub async fn ai_context_briefs(
|
|
state: State<'_, crate::AppState>,
|
|
per_project: Option<usize>,
|
|
focus_conv: Option<String>,
|
|
) -> Result<Vec<ProjectBriefs>, String> {
|
|
let records = state
|
|
.knowledge
|
|
.list_by_status("published")
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
let focus_project = match focus_conv.as_deref() {
|
|
Some(conv_id) => resolve_source_project(&state.db, conv_id, &[]).await,
|
|
None => None,
|
|
};
|
|
let mut out = aggregate_briefs(records, per_project.unwrap_or(5), focus_project.as_deref());
|
|
fill_source_convs(&state.db, &mut out).await;
|
|
Ok(out)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn rec(title: &str, content: &str, project: Option<&str>, reuse: i32) -> df_storage::models::KnowledgeRecord {
|
|
df_storage::models::KnowledgeRecord {
|
|
id: format!("k-{title}"),
|
|
kind: "pitfall".into(),
|
|
title: title.into(),
|
|
content: content.into(),
|
|
tags: None,
|
|
status: "published".into(),
|
|
confidence: Some("high".into()),
|
|
reuse_count: reuse,
|
|
verified: true,
|
|
source_project: project.map(Into::into),
|
|
source_ref: Some("conv:test".into()),
|
|
reasoning: None,
|
|
embedding_status: None,
|
|
created_at: "1700000000".into(),
|
|
updated_at: "1700000000".into(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn briefs_group_by_project_and_sort_by_reuse() {
|
|
let recs = vec![
|
|
rec("甲项目低复用", "c1", Some("proj-a"), 1),
|
|
rec("甲项目高复用", "c2", Some("proj-a"), 9),
|
|
rec("乙项目", "c3", Some("proj-b"), 3),
|
|
rec("无归属不入卡", "c4", None, 99),
|
|
];
|
|
let out = aggregate_briefs(recs, 5, None);
|
|
assert_eq!(out.len(), 2, "两项目分组,无归属剔除: {out:?}");
|
|
assert_eq!(out[0].source_project, "proj-a", "组间按最高复用降序");
|
|
assert_eq!(out[0].briefs[0].title, "甲项目高复用", "组内按复用降序");
|
|
assert!(out.iter().all(|g| !g.focused), "无 focus 时任何组不带置顶标记");
|
|
assert!(out.iter().all(|g| g.briefs.iter().all(|b| b.source_project.is_some())));
|
|
}
|
|
|
|
#[test]
|
|
fn briefs_truncate_per_project_and_summary() {
|
|
let recs: Vec<_> = (0..8)
|
|
.map(|i| rec(&format!("条{i}"), &"长".repeat(200), Some("proj"), i))
|
|
.collect();
|
|
let out = aggregate_briefs(recs, 3, None);
|
|
assert_eq!(out[0].briefs.len(), 3, "每项目截断到 per_project");
|
|
assert!(out[0].briefs[0].summary.chars().count() <= BRIEF_SUMMARY_MAX_CHARS + 1);
|
|
}
|
|
|
|
#[test]
|
|
fn focus_project_group_moves_to_front() {
|
|
let recs = vec![
|
|
rec("乙高复用", "c1", Some("proj-b"), 9),
|
|
rec("甲低复用", "c2", Some("proj-a"), 1),
|
|
];
|
|
let out = aggregate_briefs(recs, 5, Some("proj-a"));
|
|
assert_eq!(out[0].source_project, "proj-a", "会话归属项目组置顶(复用更低也排前): {out:?}");
|
|
assert!(out[0].focused, "命中组带 focused 标记");
|
|
assert!(!out[1].focused, "非命中组保持 focused=false");
|
|
}
|
|
|
|
fn group_order(groups: &[ProjectBriefs]) -> Vec<&str> {
|
|
groups.iter().map(|g| g.source_project.as_str()).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn focus_unmatched_project_keeps_order() {
|
|
let recs = vec![
|
|
rec("乙高复用", "c1", Some("proj-b"), 9),
|
|
rec("甲低复用", "c2", Some("proj-a"), 1),
|
|
];
|
|
let plain = aggregate_briefs(recs.clone(), 5, None);
|
|
let unmatched = aggregate_briefs(recs, 5, Some("proj-x"));
|
|
assert_eq!(group_order(&plain), group_order(&unmatched), "解析无归属/未知项目时顺序不变");
|
|
assert!(unmatched.iter().all(|g| !g.focused), "无命中组不误标记");
|
|
}
|
|
|
|
#[test]
|
|
fn brief_carries_updated_at() {
|
|
let mut r = rec("条目", "c", Some("proj"), 1);
|
|
r.updated_at = "1777777777".into();
|
|
let out = aggregate_briefs(vec![r], 5, None);
|
|
assert_eq!(out[0].briefs[0].updated_at, "1777777777", "updated_at 原样透传");
|
|
}
|
|
|
|
#[test]
|
|
fn source_ref_parses_to_conv_msg_or_none() {
|
|
assert!(matches!(
|
|
parse_source_conv(Some("conv:abc-1")),
|
|
SourceConvRef::Conv(id) if id == "abc-1"
|
|
));
|
|
assert!(matches!(
|
|
parse_source_conv(Some("conv_msg:msg_123_4")),
|
|
SourceConvRef::Msg(id) if id == "msg_123_4"
|
|
));
|
|
assert!(matches!(parse_source_conv(Some("conv:")), SourceConvRef::None));
|
|
assert!(matches!(parse_source_conv(Some("weird:x")), SourceConvRef::None));
|
|
assert!(matches!(parse_source_conv(None), SourceConvRef::None));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn fill_source_convs_resolves_both_ref_kinds() {
|
|
let db = df_storage::db::Database::open_in_memory()
|
|
.await
|
|
.expect("内存库");
|
|
{
|
|
let conn = db.conn();
|
|
let guard = conn.lock().await;
|
|
guard
|
|
.execute(
|
|
"CREATE TABLE IF NOT EXISTS ai_messages (id TEXT PRIMARY KEY, conversation_id TEXT NOT NULL)",
|
|
[],
|
|
)
|
|
.expect("建表");
|
|
guard
|
|
.execute(
|
|
"INSERT INTO ai_messages (id, conversation_id, seq, role, created_at) VALUES \
|
|
('msg_1_0', 'conv-a', 1, 'assistant', '0'), ('msg_2_0', 'conv-b', 2, 'assistant', '0')",
|
|
[],
|
|
)
|
|
.expect("造数");
|
|
}
|
|
let mut groups = vec![ProjectBriefs {
|
|
source_project: "proj".into(),
|
|
focused: false,
|
|
briefs: vec![
|
|
ContextBrief {
|
|
title: "对话级".into(),
|
|
summary: "s".into(),
|
|
source_ref: Some("conv:conv-a".into()),
|
|
source_project: Some("proj".into()),
|
|
reuse_count: 2,
|
|
updated_at: "1".into(),
|
|
source_conv: None,
|
|
},
|
|
ContextBrief {
|
|
title: "消息级".into(),
|
|
summary: "s".into(),
|
|
source_ref: Some("conv_msg:msg_2_0".into()),
|
|
source_project: Some("proj".into()),
|
|
reuse_count: 1,
|
|
updated_at: "1".into(),
|
|
source_conv: None,
|
|
},
|
|
ContextBrief {
|
|
title: "失效引用".into(),
|
|
summary: "s".into(),
|
|
source_ref: Some("conv_msg:msg_gone".into()),
|
|
source_project: Some("proj".into()),
|
|
reuse_count: 0,
|
|
updated_at: "1".into(),
|
|
source_conv: None,
|
|
},
|
|
],
|
|
}];
|
|
fill_source_convs(&db, &mut groups).await;
|
|
let briefs = &groups[0].briefs;
|
|
assert_eq!(briefs[0].source_conv.as_deref(), Some("conv-a"), "对话级直接取 id");
|
|
assert_eq!(briefs[1].source_conv.as_deref(), Some("conv-b"), "消息级反查回填");
|
|
assert_eq!(briefs[2].source_conv, None, "失效引用降级 None");
|
|
}
|
|
}
|