优化: 上下文管理与提示词链路批

- T1 usage 口径: anthropic usage 两层容错+cache 三口径归一(prompt=input+read+creation)
- T2 压缩水位判据: compress_should_trigger 双模式+ratio 钳位,开关关逐字节等价
- T3 跨会话检索: history_search 工具(两跳式)+message_repo 关键词检索+prompt 指引
- T4 代语义闭环: status 终局补写+飞书停靠/换代推卡+交付引导+watcher 兜底补漏
- 段序缓存重排: build_runtime_prompt 恒静段上移/工作上下文压尾(前缀缓存失效面收窄)
- 智谱缓存解析: anthropic 臂补 prompt_tokens_details.cached_tokens 嵌套回退
- 工时禁令: 提示词双语加「禁止预估工时,AI Coding 工时无意义」
- 拖拽文件落盘: 非图片附件存引用行,防整段文本灌入输入框
- 事实修正: 提示词「40+ 工具」→「80+」(基线 81);tool_failure_memory 测试串行锁除竞态
- UI 拆分: TopBar/ToolResultBody 壳层化(top-bar/tool-result-body 子件目录)
This commit is contained in:
lxy
2026-10-26 01:36:00 +08:00
parent 23e505bc40
commit a30f599fe2
59 changed files with 2651 additions and 2541 deletions
+236 -6
View File
@@ -224,6 +224,10 @@ pub fn start_if_enabled(app: tauri::AppHandle) {
// 24 审批可见性:渠道生命周期订阅任务,审批挂起事件推卡到绑定 chat(跨重连常驻,
// 与 run_loop 的按连接重建无关)
tauri::async_runtime::spawn(run_approval_notifier(ctx.clone()));
// 换代通知(P1-B 方案甲,2026-10-21):停靠/压缩换代事件推绑定 chat,同款生命周期
// 订阅任务。此前两事件已广播(emit_ai_chat_event 双写底座)但本文件零消费,
// 停靠/换代对飞书侧完全无感知
tauri::async_runtime::spawn(run_generation_notifier(ctx.clone()));
// 断线自动重连:网络波动/服务端断开导致 start 退出时 backoff 重试,防 bot 静默死
// 2026-10-08 重连用户通知:此前重连只落日志,渠道静默死数分钟用户无感知。
// 判据:run_loop 运行时长——>30s 视为「曾成功连接过」(连接建立后才有长驻),
@@ -1168,7 +1172,7 @@ async fn handle_message(
false, // 不劫持用户活跃面板(编排节点同款语义)
// #958 溯源:飞书原始消息定位随 user 消息落库,可跳原消息/区分多端来源
Some(df_ai::provider::MessageSource {
channel: "feishu".into(),
channel: SOURCE_CHANNEL_FEISHU.into(),
message_id: message_id.clone(),
chat_id: Some(chat_id.clone()),
sender: if open_id.is_empty() { None } else { Some(open_id.clone()) },
@@ -1294,6 +1298,18 @@ fn delivery_blocked(path: &str) -> Option<&'static str> {
.map(|_| "敏感文件(凭据/密钥特征)")
}
/// 入站消息渠道标识(source.channel 值,#958 溯源同参;spawn 链渠道段判定用)
pub(crate) const SOURCE_CHANNEL_FEISHU: &str = "feishu";
/// 交付引导(#958 生产端配套,2026-10-21 修「devflow 不会用飞书发文件」):出站回传消费端
/// (extract_delivery_paths/reply_deliveries/delivery_blocked)早已在位,但全仓 prompt 层
/// 零处教 AI 写 `[交付]` 标记行——AI 永远不会自发写标记,链路空转。本段经 spawn 链渠道
/// 标识透传(source.channel==feishu)注入飞书渠道会话的 AI 语境(落点 chat.rs
/// spawn_agentic_session 稳定段之后,与 aug_seg 同构分流)。文案与解析器是同一契约,
/// 示例格式必须能被 extract_delivery_paths 原样提取(单测 delivery_guidance 锁死);
/// 敏感拦截只一句交底不重复造(拦截器本尊在 delivery_blocked)。
pub(crate) const DELIVERY_GUIDANCE_PROMPT: &str = "(渠道提示)如需给用户发送本机文件:在报告正文写 `[交付] <文件的本机绝对路径>` 标记行,每个文件单独一行;渠道会自动把这些文件上传为飞书原生附件发给用户,无需其他操作。名字含凭据/密钥特征(如 .env、token、secret、password)的文件会被拦截拒发,不要尝试交付敏感文件。";
/// 扩展名 → 飞书 file_type(上传接口必填;对齐飞书开放平台枚举)
fn feishu_file_type(ext: &str) -> &'static str {
match ext {
@@ -1441,6 +1457,10 @@ async fn run_completion_watcher(
if let Some(t) = last_assistant_reply(&state.db, &conv_id, Some(accepted_ms)).await {
if !t.trim().is_empty() {
feishu_log(&format!("watcher 快任务兜底 conv={conv_id} len={}", t.len()));
// #958 出站回传补口(2026-10-21):快任务兜底此前早退直投终版,漏过主循环
// 统一出口的 reply_deliveries——秒级任务报告里的 [交付] 标记行全部落空。
// 迟到终版(35min 降级 continue 后到达)走 rx.recv() 臂汇入主出口,无缺口
reply_deliveries(&ctx.client, &chat_id, &t).await;
let _ = frame_tx.send(CardFrame { text: t, terminal: true }).await;
let _ = stream_task.await;
return;
@@ -1609,6 +1629,148 @@ async fn run_approval_notifier(ctx: Arc<Ctx>) {
}
}
/// 换代通知聚合窗(P1-B 方案甲 §1.4 节流从严):首条换代事件开 30s 窗,窗尾同 chat 合并
/// 一卡——多会话并行时同窗多代,逐条直推刷屏
const COMPRESS_NOTIFY_WINDOW: std::time::Duration = std::time::Duration::from_secs(30);
/// 停靠原因类别中文映射(stop_kind 词表 7 值,ai/mod.rs tests_task_parked 词表锁定;
/// 未知/缺省值走兜底,新增词表值须同步此处)
fn stop_kind_zh(kind: &str) -> &'static str {
match kind {
"approval_timeout" => "审批超时",
"cancelled" => "已取消",
"circuit_breaker" => "连续失败熔断",
"stall_breaker" => "进展停滞熔断",
"max_rounds" => "轮次预算用尽",
"fatal" => "致命错误",
"midstream" => "中途断流",
_ => "未知原因",
}
}
/// 停靠通知卡文案(纯函数,单测锁死三要素:类别+原因+恢复指引)。恢复指引只交底真实
/// 出路:桌面停靠卡「继续」或直接发新指令接力(飞书侧无 /continue 命令,文案不承诺)
fn park_notify_text(stop_kind: &str, reason: &str) -> String {
format!(
"⏸ 任务已停靠({}):{}\n桌面端会话可点「继续」恢复执行,也可直接在此发新指令接力。",
stop_kind_zh(stop_kind),
truncate(reason, 200),
)
}
/// 换代通知单行文案(纯函数):代号+摘要首行(压缩换代对用户的可感知面就一句;
/// 空摘要退化为纯换代告知,不出悬空冒号)
fn compress_notify_line(seq: u32, summary: &str) -> String {
let first = summary.lines().next().unwrap_or("").trim();
if first.is_empty() {
format!("🔄 会话上下文已自动压缩换代(第 {seq} 代)。")
} else {
format!(
"🔄 会话上下文已自动压缩换代(第 {seq} 代):{}",
truncate(first, 60)
)
}
}
/// 聚合窗合并卡文案(纯函数):单会话单行直出;同 chat 多会话合并为清单
fn merge_compress_notify(lines: &[String]) -> String {
match lines {
[one] => one.clone(),
many => format!(
"🔄 {} 个会话上下文已自动换代:\n{}",
many.len(),
many.iter().map(|l| format!("- {l}")).collect::<Vec<_>>().join("\n")
),
}
}
/// 换代通知(分析 P1-B §1.4,2026-10-21):渠道生命周期订阅任务(与 run_approval_notifier
/// 同款骨架,跨重连常驻)。消费 ai_event_bus 的 AiTaskParked/AiAutoCompressed,反查 conv
/// 绑定飞书 chat 推文本卡;无绑定(纯桌面会话)不推,零打扰。此前两事件已广播(双写底座
/// emit_ai_chat_event)但本文件零消费——停靠/换代对飞书侧完全无感知。
/// AiTaskParked 同因去重(重放/多重终局汇聚不重推,恢复后再停靠因内容不同自然放行);
/// AiAutoCompressed 走 30s 聚合窗(同 chat 合并一卡);换代代号=进程内递增计数(方案甲
/// 零 DDL,持久化代号建模留 P1 检索批),重启归 1。
async fn run_generation_notifier(ctx: Arc<Ctx>) {
let state = ctx.app.state::<crate::state::AppState>();
let mut rx = state.ai_event_bus.subscribe();
// 停靠去重记忆:conv → 最近一次已推 (reason, stop_kind)
let mut pushed_parks: HashMap<String, (String, String)> = HashMap::new();
// 换代号:conv → 进程内压缩换代计数
let mut gen_seq: HashMap<String, u32> = HashMap::new();
// 聚合窗:chat → 待推行;flush_at=None=无窗,Some=窗尾时刻
let mut pending_compress: HashMap<String, Vec<String>> = HashMap::new();
let mut flush_at: Option<tokio::time::Instant> = None;
loop {
let item = if let Some(deadline) = flush_at {
match tokio::time::timeout_at(deadline, rx.recv()).await {
// 窗到:同 chat 合并推送后重开等事件(无窗期零 select 开销)
Err(_) => {
flush_at = None;
let chats = pending_compress.len();
for (chat_id, lines) in pending_compress.drain() {
let body = merge_compress_notify(&lines);
let _ = send_markdown_card(&ctx.client, "", &chat_id, &body).await;
}
feishu_log(&format!("换代通知聚合窗推送 chats={chats}"));
continue;
}
Ok(item) => item,
}
} else {
rx.recv().await
};
match item {
Ok(v) => match v.get("type").and_then(|t| t.as_str()) {
Some("AiTaskParked") => {
let Some(cid) = v.get("conversation_id").and_then(|c| c.as_str()) else {
continue;
};
let reason = v.get("reason").and_then(|r| r.as_str()).unwrap_or("").to_string();
let kind = v.get("stop_kind").and_then(|k| k.as_str()).unwrap_or("").to_string();
if pushed_parks.get(cid) == Some(&(reason.clone(), kind.clone())) {
continue;
}
let Some(chat_id) = chat_of_conv(&ctx, cid).await else {
continue;
};
// 去重记忆防无界(低频事件,超限全清只失去去重,无正确性影响)
if pushed_parks.len() > 256 {
pushed_parks.clear();
}
pushed_parks.insert(cid.to_string(), (reason.clone(), kind.clone()));
let text = park_notify_text(&kind, &reason);
let _ = send_markdown_card(&ctx.client, "", &chat_id, &text).await;
feishu_log(&format!("停靠通知推送 chat={chat_id} conv={cid} kind={kind}"));
}
Some("AiAutoCompressed") => {
let Some(cid) = v.get("conversation_id").and_then(|c| c.as_str()) else {
continue;
};
let Some(chat_id) = chat_of_conv(&ctx, cid).await else {
continue;
};
let seq = gen_seq
.entry(cid.to_string())
.and_modify(|n| *n += 1)
.or_insert(1);
let line = compress_notify_line(
*seq,
v.get("summary").and_then(|s| s.as_str()).unwrap_or(""),
);
pending_compress.entry(chat_id).or_default().push(line);
if flush_at.is_none() {
flush_at = Some(tokio::time::Instant::now() + COMPRESS_NOTIFY_WINDOW);
}
}
_ => {}
},
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
}
}
}
/// 卡片按钮回调(2026-10-08 审批卡按钮化):解析按钮 value 的 {approval_id, approved},
/// 经 ai_approve 原语决议。与文本「批准/拒绝」通路同原语,双通道并存(按钮优先,
/// 文本作降级)。回调在独立 spawn 内执行,不阻塞事件泵。
@@ -2679,11 +2841,13 @@ async fn save_chat_map(db: &df_storage::db::Database, map: &HashMap<String, Stri
#[cfg(test)]
mod tests {
use super::{
ContentPart, DebounceEntry, delivery_blocked, extract_delivery_paths, feishu_file_type,
file_display_name, file_inbound_prompt, file_size_over_limit_error, heartbeat_due,
is_terminal_push, md_to_lark_friendly, merge_debounce_parts, merge_debounce_text,
msg_after_acceptance, parse_approval_verdict, parse_command, sniff_image_mime,
strip_mention_placeholders, stream_preview, within_resolved_window,
compress_notify_line, ContentPart, DebounceEntry, DELIVERY_GUIDANCE_PROMPT,
delivery_blocked, extract_delivery_paths, feishu_file_type, file_display_name,
file_inbound_prompt, file_size_over_limit_error, heartbeat_due, is_terminal_push,
md_to_lark_friendly, merge_compress_notify, merge_debounce_parts, merge_debounce_text,
msg_after_acceptance, park_notify_text, parse_approval_verdict, parse_command,
sniff_image_mime, stop_kind_zh, stream_preview, strip_mention_placeholders,
within_resolved_window,
};
#[test]
@@ -2706,6 +2870,72 @@ mod tests {
assert_eq!(delivery_blocked("E:\\out\\report.pdf"), None);
}
#[test]
fn delivery_guidance_teaches_extractable_format() {
// 件三(2026-10-21):引导文案与解析器是同一契约——文案教的标记行格式必须能被
// extract_delivery_paths 原样提取,教错格式=引导写出的标记全部落空(链路二次空转)
assert!(DELIVERY_GUIDANCE_PROMPT.contains("[交付]"), "缺标记格式: {DELIVERY_GUIDANCE_PROMPT}");
let marked = "[交付] E:\\out\\report.md\n其余为叙述文本";
assert_eq!(
extract_delivery_paths(marked),
vec!["E:\\out\\report.md".to_string()],
"引导教的标记行格式必须可被消费端解析"
);
// 三要素交底:自动上传机制 / 敏感拦截 / 绝对路径口径
assert!(DELIVERY_GUIDANCE_PROMPT.contains("附件"), "缺自动上传机制交底");
assert!(DELIVERY_GUIDANCE_PROMPT.contains("拦截"), "缺敏感拦截交底");
assert!(DELIVERY_GUIDANCE_PROMPT.contains("绝对路径"), "缺路径口径交底");
}
#[test]
fn stop_kind_zh_maps_full_vocabulary() {
// 停靠词表 7 值全映射(与 ai/mod.rs tests_task_parked 词表对齐),词表值不得落兜底
for k in [
"approval_timeout",
"cancelled",
"circuit_breaker",
"stall_breaker",
"max_rounds",
"fatal",
"midstream",
] {
assert_ne!(stop_kind_zh(k), "未知原因", "词表值 {k} 不应落兜底");
}
assert_eq!(stop_kind_zh("__unknown__"), "未知原因");
assert_eq!(stop_kind_zh(""), "未知原因", "stop_kind 缺省走兜底");
}
#[test]
fn park_and_compress_notify_texts_carry_essentials() {
// 停靠卡三要素:类别+原因+恢复指引(指引不承诺飞书侧不存在的 /continue 命令)
let park = park_notify_text("approval_timeout", "审批超时(15分钟)自动取消,任务已安全停靠");
assert!(park.contains("审批超时"), "缺类别: {park}");
assert!(park.contains("审批超时(15分钟)自动取消"), "缺原因: {park}");
assert!(park.contains("「继续」"), "缺恢复指引: {park}");
assert!(!park.contains("/continue"), "不得承诺不存在的命令: {park}");
// 换代行:代号+摘要首行,不带出后续行;空摘要退化纯告知(无悬空冒号)
let line = compress_notify_line(3, "第一行摘要\n第二行细节");
assert!(line.contains("第 3 代"), "缺代号: {line}");
assert!(line.contains("第一行摘要"), "缺摘要首行: {line}");
assert!(!line.contains("第二行"), "不得带出后续行: {line}");
let bare = compress_notify_line(1, "");
assert!(
bare.contains("第 1 代") && !bare.contains(':') && !bare.contains(':'),
"空摘要应退化纯告知: {bare}"
);
}
#[test]
fn compress_aggregate_window_merges_by_chat() {
// 聚合窗合并:同 chat 单会话单行直出;多会话合并为计数+清单
let one = vec!["🔄 会话上下文已自动压缩换代(第 1 代)。".to_string()];
assert_eq!(merge_compress_notify(&one), one[0], "单行直出不加壳");
let many = vec!["换代A".to_string(), "换代B".to_string()];
let merged = merge_compress_notify(&many);
assert!(merged.contains("2 个会话"), "缺计数: {merged}");
assert!(merged.contains("- 换代A") && merged.contains("- 换代B"), "缺清单项: {merged}");
}
#[test]
fn feishu_file_type_maps_common_exts() {
assert_eq!(feishu_file_type("pdf"), "pdf");