优化: 上下文管理与提示词链路批
- 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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user