新增: 待发箱/唤醒原语/预算随窗口 + 治理门禁与场景护栏
- 待发箱地基: V51 ai_outbox 表 + 手写 OutboxRepo(claim CAS/崩溃残留复位/瞬时失败保留) + outbox.rs 调度器(KV 默认关/Notify 唤醒/会话忙跳过不丢)
- Wake 异步唤醒原语: 三条件(delay/file-appear/process-exit)+纯判定+注入走正典 spawn_agentic_session,默认关
- 上下文预算随模型窗口: model_context.rs 三级优先(池显式>预设表>回退 128K);上界取 1M 而非 128K——max_tokens 仅裁剪预算,卡 128K 等于不治根因
- 文件租约补纯函数与单测(未另造双轨:LEASE 早随 cb260ab 落地) / 跨会话召回补纯函数单测(主体早已落地)
- 子代理: 完整卡已消化 + 子会话压缩层 + P3 批量
- 隧道/中继: 指令离线不丢 + Hello proto 版本 + 心跳与 runbook
- df-mcp: 写工具后落 KV 变更标记(供 GUI 轮询兜底,补独立 stdio 进程无感知)
- 迁移链顶点改单一真值常量 LATEST_SCHEMA_VERSION(修 2 处因 V51 失效的断言)
- 治理: i18n 特殊字符提交门禁(含反引号覆盖 + 10 自测) / 根目录卫生门禁 / 看板归档器 / 单一 lockfile(bun) / swap-install 落 temp/ 且兼容无 Get-FileHash
- 场景护栏: 流式正文可见性 e2e(playwright 免真机事件回放) + 列表续写/外置句柄 node 测试
This commit is contained in:
@@ -902,6 +902,38 @@ const EXTRACTION_SYSTEM_PROMPT: &str = "你是知识提炼引擎,从 AI 对话
|
||||
4. 输出纯 JSON,无任何额外文字\n\
|
||||
5. 最多提取 5 条,按复用价值降序排列(只保留高价值条目,宁缺毋滥)";
|
||||
|
||||
/// 弱推断会话唯一提及的项目名(级 b,**纯函数**:仅解析消息文本,不碰 DB)。
|
||||
///
|
||||
/// 反向扫最近 20 条 user 消息,收集全部 `[项目:名称]` 标记(chat.rs `extract_pinned_goal`
|
||||
/// 与 df-ai `strip_mention_tags` 同款字面量);**去重后恰好唯一**才返回该名,零命中或
|
||||
/// 多项目返回 None(B1 拍板:多项目 mention 不猜,防张冠李戴)。
|
||||
///
|
||||
/// `dedup(); len()==1` 等价于「所有提及名相同」:任意两处不同名必留下相邻不重复元素
|
||||
/// 使 len≥2,故 A/B/A 这类非相邻重复也被正确判为两个项目(→ None)。
|
||||
fn unique_mentioned_project(messages: &[ChatMessage]) -> Option<&str> {
|
||||
let mut mentioned: Vec<&str> = Vec::new();
|
||||
for m in messages.iter().rev().take(20) {
|
||||
if !matches!(m.role, MessageRole::User) {
|
||||
continue;
|
||||
}
|
||||
let mut rest = m.content.as_str();
|
||||
while let Some(start) = rest.find("[项目:") {
|
||||
let after = &rest[start + "[项目:".len()..];
|
||||
if let Some(end) = after.find(']') {
|
||||
let name = after[..end].trim();
|
||||
if !name.is_empty() {
|
||||
mentioned.push(name);
|
||||
}
|
||||
rest = &after[end + 1..];
|
||||
} else {
|
||||
break; // 未闭合(可能截断),停扫本条
|
||||
}
|
||||
}
|
||||
}
|
||||
mentioned.dedup();
|
||||
(mentioned.len() == 1).then(|| mentioned[0])
|
||||
}
|
||||
|
||||
/// 项目归属弱推断(跨会话召回最小闭环,设计 D1:a→b 两级,皆无 → None=全局知识)。
|
||||
///
|
||||
/// 级 a(高置信):读 `ai_conversations.boundary_json`(V45 无人值守边界)的 `dir_allowlist`
|
||||
@@ -947,31 +979,9 @@ async fn resolve_source_project(db: &Arc<Database>, conv_id: &str, messages: &[C
|
||||
}
|
||||
}
|
||||
|
||||
// 级 b(B1:mention 唯一才启用):最近消息扫 `[项目:名称]` 标记,唯一命中才归属
|
||||
let mut mentioned: Vec<&str> = Vec::new();
|
||||
for m in messages.iter().rev().take(20) {
|
||||
if !matches!(m.role, MessageRole::User) {
|
||||
continue;
|
||||
}
|
||||
let mut rest = m.content.as_str();
|
||||
// 找全部 `[项目:xxx]` / `[项目:xxx]` 标记(半/全角冒号),收名称
|
||||
while let Some(start) = rest.find("[项目:") {
|
||||
let after = &rest[start + "[项目:".len()..];
|
||||
if let Some(end) = after.find(']') {
|
||||
let name = after[..end].trim();
|
||||
if !name.is_empty() {
|
||||
mentioned.push(name);
|
||||
}
|
||||
rest = &after[end + 1..];
|
||||
} else {
|
||||
break; // 未闭合(可能截断),停扫本条
|
||||
}
|
||||
}
|
||||
}
|
||||
// 去重后恰好唯一才猜(B1 拍板:多项目 mention 不猜)
|
||||
mentioned.dedup();
|
||||
if mentioned.len() == 1 {
|
||||
let name = mentioned[0];
|
||||
// 级 b(B1:mention 唯一才启用):唯一命中才归属,多项目/零 mention 不猜
|
||||
// (判定收口至纯函数 unique_mentioned_project,便于单测覆盖命中/未命中/去重)
|
||||
if let Some(name) = unique_mentioned_project(messages) {
|
||||
if let Ok(projects) = project_repo.list_active().await {
|
||||
if let Some(p) = projects.iter().find(|p| p.name == name) {
|
||||
return Some(p.id.clone());
|
||||
@@ -1605,6 +1615,7 @@ mod salvage_tests {
|
||||
origin: None,
|
||||
boundary_json: boundary.map(|b| serde_json::to_string(b).unwrap()),
|
||||
loop_state: None,
|
||||
status: "active".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
@@ -1652,4 +1663,41 @@ mod salvage_tests {
|
||||
// 零 mention → None
|
||||
assert_eq!(resolve_source_project(&db, "conv-ghost", &[um("普通消息")]).await, None);
|
||||
}
|
||||
|
||||
// ---------- 唯一提及项目判定(纯函数 unique_mentioned_project:命中/未命中/去重) ----------
|
||||
|
||||
/// 命中:单条唯一提及 → 去空白后返回名称
|
||||
#[test]
|
||||
fn unique_mention_hit_and_trim() {
|
||||
let msgs = vec![um("修一下 [项目: 项目甲] 的列表页")];
|
||||
assert_eq!(unique_mentioned_project(&msgs), Some("项目甲"));
|
||||
}
|
||||
|
||||
/// 去重:同名跨消息重复 → 去重后唯一 → 仍命中
|
||||
#[test]
|
||||
fn unique_mention_dedup_same_name_repeated() {
|
||||
let msgs = vec![
|
||||
um("[项目:项目甲] 先看这个"),
|
||||
um("还是 [项目:项目甲] 的老问题"),
|
||||
];
|
||||
assert_eq!(unique_mentioned_project(&msgs), Some("项目甲"));
|
||||
}
|
||||
|
||||
/// 未命中:零提及 / 多项目 / 非相邻重复 / 非 user 消息
|
||||
#[test]
|
||||
fn unique_mention_miss_cases() {
|
||||
let none_mention = vec![um("普通消息,无标记")];
|
||||
assert_eq!(unique_mentioned_project(&none_mention), None);
|
||||
|
||||
let multi = vec![um("[项目:项目甲] 和 [项目:项目乙] 谁对")];
|
||||
assert_eq!(unique_mentioned_project(&multi), None);
|
||||
|
||||
// 非相邻重复(A/B/A)视为两个项目 → 不猜(去重判据的关键边界)
|
||||
let interleaved = vec![um("[项目:项目甲] [项目:项目乙] [项目:项目甲]")];
|
||||
assert_eq!(unique_mentioned_project(&interleaved), None);
|
||||
|
||||
// 非 user 消息里的标记不参与(仅扫 user)
|
||||
let assistant_only = vec![ChatMessage::assistant("[项目:项目甲]")];
|
||||
assert_eq!(unique_mentioned_project(&assistant_only), None);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user