新增: 跨会话召回四层架构与脉络卡
- 会话终态提炼: 删除前+停靠双挂点,快照与清水位同临界区 - 目标快照: 恢复引导按最终有效意图生成,旧目标不复活 - 知识检索项目维度软排序,WorkingContext 持久化迁移 - 脉络卡: published 知识按项目聚合纯读侧,带入上下文经输入框显式注入 - 压缩与缓存能力在主循环/子代理/提示词三面接线 - 两份专项设计文档与看板销账
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
//! 脉络卡数据面(跨会话召回四层架构 L3 用户侧辅助通道):
|
||||
//! 按 source_project 聚合 published 知识,给前端新会话入口展示「这个项目之前定了什么」。
|
||||
//!
|
||||
//! 铁律红线:本通道只喂「用户看的卡」,不进 system、不进消息流——用户点「带入上下文」
|
||||
//! 才经正常发送路径显式注入(用户决策,非 LLM 自动召回)。
|
||||
|
||||
use tauri::State;
|
||||
|
||||
/// 单条脉络摘要(字段前端契约,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,
|
||||
}
|
||||
|
||||
/// 项目脉络分组
|
||||
#[derive(serde::Serialize, Clone, Debug)]
|
||||
pub struct ProjectBriefs {
|
||||
pub source_project: String,
|
||||
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
|
||||
}
|
||||
|
||||
/// 聚合纯函数:published 记录 → 按项目分组脉络。无归属项目(source_project None)的知识
|
||||
/// 不入脉络卡(无分组锚点,注入侧 top-3 已覆盖全局知识);每组按 reuse_count 降序(唯一
|
||||
/// 客观信号),组间按组内最高复用数降序(活跃项目排前)。
|
||||
fn aggregate_briefs(
|
||||
records: Vec<df_storage::models::KnowledgeRecord>,
|
||||
per_project: usize,
|
||||
) -> 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,
|
||||
};
|
||||
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);
|
||||
ProjectBriefs { source_project, 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))
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// 脉络卡数据:published 知识按 source_project 聚合(每项目取复用最高的 `per_project` 条)。
|
||||
/// 纯读侧,无状态无副作用;knowledge 表空/无归属项目时返空(前端不渲染卡)。
|
||||
#[tauri::command]
|
||||
pub async fn ai_context_briefs(
|
||||
state: State<'_, crate::AppState>,
|
||||
per_project: Option<usize>,
|
||||
) -> Result<Vec<ProjectBriefs>, String> {
|
||||
let records = state
|
||||
.knowledge
|
||||
.list_by_status("published")
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(aggregate_briefs(records, per_project.unwrap_or(5)))
|
||||
}
|
||||
|
||||
#[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);
|
||||
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.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);
|
||||
assert_eq!(out[0].briefs.len(), 3, "每项目截断到 per_project");
|
||||
assert!(out[0].briefs[0].summary.chars().count() <= BRIEF_SUMMARY_MAX_CHARS + 1);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user