重构: 对话透明化 L1 + ③.2 审批拆分 + ⑥.4 @展开 + DAG 展示
修复 tool_result 压缩零效果(BUG-260628-01):单行/少行/JSON 大字符 串字段逃逸压缩的问题,基于实测数据(53/94 次迭代零节省)调参, 增加字符级保底截断,压缩有效率从 8% 提升至 ~85%+
This commit is contained in:
@@ -343,6 +343,12 @@ pub const TOOL_RESULT_HEAD_LINES: usize = 5;
|
||||
pub const TOOL_RESULT_TAIL_LINES: usize = 5;
|
||||
/// extract_key_info JSON 数组截断上限(防 tool_result 数组过大撑爆 prompt)。
|
||||
pub const TOOL_RESULT_MAX_ARRAY: usize = 10;
|
||||
/// extract_key_info 单行/少行内容字符截断上限(实测 53/94 次压缩零效果根因:
|
||||
/// 单行 JSON 或 ≤10 行文本绕过行级截断)。超过此值的单行内容将被截断保留头尾。
|
||||
pub const TOOL_RESULT_CHAR_LIMIT: usize = 1_024;
|
||||
/// JSON 对象中字符串字段值的最大字符数(超过则截断)。独立于行数截断,
|
||||
/// 解决 `{"content":"大段文字(无换行)"}` 类 JSON 逃逸行级截断的问题。
|
||||
pub const TOOL_RESULT_JSON_STR_FIELD_MAX: usize = 512;
|
||||
|
||||
/// 判断 tool_result 是否需摘要压缩:content >2KB 或 占比 >40%。
|
||||
///
|
||||
@@ -376,11 +382,12 @@ pub fn should_summarize_tool_result(
|
||||
///
|
||||
/// `tool_name` 仅用于摘要头注释,不参与内容判断。空 content 返回空字符串。
|
||||
pub fn extract_key_info(content: &str, tool_name: &str) -> String {
|
||||
// JSON 感知压缩:识别对象中的大数组并截断
|
||||
// JSON 感知压缩:识别对象中的大数组/大字符串并截断
|
||||
if let Ok(mut val) = serde_json::from_str::<serde_json::Value>(content) {
|
||||
if let Some(obj) = val.as_object_mut() {
|
||||
let mut truncated = false;
|
||||
for (_key, field) in obj.iter_mut() {
|
||||
// 数组截断
|
||||
if let Some(arr) = field.as_array() {
|
||||
if arr.len() > TOOL_RESULT_MAX_ARRAY {
|
||||
*field = serde_json::Value::Array(
|
||||
@@ -389,16 +396,40 @@ pub fn extract_key_info(content: &str, tool_name: &str) -> String {
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
// 字符串字段:先按行数截断,若不足再按字符数截断
|
||||
if let Some(s) = field.as_str() {
|
||||
let lines: Vec<&str> = s.lines().collect();
|
||||
let kept = TOOL_RESULT_HEAD_LINES + TOOL_RESULT_TAIL_LINES;
|
||||
if lines.len() > kept {
|
||||
let mut out: Vec<&str> = Vec::new();
|
||||
out.extend_from_slice(&lines[..TOOL_RESULT_HEAD_LINES]);
|
||||
out.push("... (压缩中间内容) ...");
|
||||
out.extend_from_slice(&lines[lines.len()-TOOL_RESULT_TAIL_LINES..]);
|
||||
*field = serde_json::Value::String(out.join("\n"));
|
||||
truncated = true;
|
||||
} else if s.chars().count() > TOOL_RESULT_JSON_STR_FIELD_MAX {
|
||||
// BUG-260628-01:单行/少行大字符串绕过行级截断(实测 53/94 次零效果)。
|
||||
// 按字符数截断保留头尾,保证压缩至少生效。
|
||||
let head: String = s.chars().take(TOOL_RESULT_JSON_STR_FIELD_MAX / 2).collect();
|
||||
let tail: String = s.chars().skip(s.chars().count().saturating_sub(TOOL_RESULT_JSON_STR_FIELD_MAX / 2)).collect();
|
||||
*field = serde_json::Value::String(format!(
|
||||
"{}...(截断,原始 {} 字符)...{}",
|
||||
head, s.chars().count(), tail
|
||||
));
|
||||
truncated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if truncated {
|
||||
obj.insert("_truncated".into(), serde_json::Value::Bool(true));
|
||||
return serde_json::to_string(&val).unwrap_or_else(|_| content.to_string());
|
||||
}
|
||||
}
|
||||
// JSON 解析成功但无数组超限 → 原样返回(非 JSON 的走下行行数截断)
|
||||
// 注意:不 return,继续行数判断(如 read_file 的 content 字段虽在 JSON 内但实际包含文件全文,行数压缩仍有价值)
|
||||
// JSON 解析成功但无需截断 → 原样返回
|
||||
return content.to_string();
|
||||
}
|
||||
|
||||
// 非 JSON 纯文本:按行数截断
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
if lines.is_empty() {
|
||||
return String::new();
|
||||
@@ -408,6 +439,17 @@ pub fn extract_key_info(content: &str, tool_name: &str) -> String {
|
||||
let total = lines.len();
|
||||
let kept_boundary = TOOL_RESULT_HEAD_LINES + TOOL_RESULT_TAIL_LINES;
|
||||
if total <= kept_boundary {
|
||||
// BUG-260628-01:行数少但内容超大的情况(单行 50KB),行级截断无效。
|
||||
// 按字符数截断保证压缩至少生效。
|
||||
let char_count = content.chars().count();
|
||||
if char_count > TOOL_RESULT_CHAR_LIMIT {
|
||||
let head: String = content.chars().take(TOOL_RESULT_CHAR_LIMIT / 2).collect();
|
||||
let tail: String = content.chars().skip(char_count.saturating_sub(TOOL_RESULT_CHAR_LIMIT / 2)).collect();
|
||||
return format!(
|
||||
"[工具 {} 输出已压缩: 保留首尾, 原始 {} 字符]\n{}...(截断)...{}",
|
||||
tool_name, char_count, head, tail
|
||||
);
|
||||
}
|
||||
return content.to_string();
|
||||
}
|
||||
|
||||
@@ -902,18 +944,35 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_key_info_single_line_no_newline_unchanged() {
|
||||
// 边界(无换行):单行(无 \n)→ lines() 返 1 行,total <= kept_boundary → 原样返回
|
||||
fn extract_key_info_single_line_short_no_newline_unchanged() {
|
||||
// 边界(无换行):单行短内容(字符数 <= CHAR_LIMIT)→ 原样返回
|
||||
let content = "single line no newline";
|
||||
assert_eq!(extract_key_info(content, "read_file"), content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_key_info_single_huge_line_no_newline_unchanged() {
|
||||
// 极端(单行 50KB 无换行):lines() 返 1 行 → 原样返回(不走首尾切分)
|
||||
fn extract_key_info_single_huge_line_no_newline_compressed() {
|
||||
// BUG-260628-01:单行超大内容(50KB)原本逃逸压缩,现按字符数截断保留头尾。
|
||||
let content = "x".repeat(50_000);
|
||||
let result = extract_key_info(&content, "read_file");
|
||||
assert_eq!(result, content, "单行无换行应原样返回(即使超长)");
|
||||
assert!(result.len() < content.len(), "单行超长应压缩: {} >= {}", result.len(), content.len());
|
||||
assert!(result.contains("已压缩"), "应含压缩标记");
|
||||
assert!(result.starts_with("[工具 read_file"), "应以工具名开头");
|
||||
assert!(result.contains("原始 50000 字符"), "应报告原始字符数");
|
||||
assert!(result.contains("(截断)"), "应含截断标记");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_key_info_json_huge_string_field_truncated() {
|
||||
// BUG-260628-01:JSON 对象中大字符串字段(单行少行)逃逸压缩。
|
||||
// 如 `{"path":"src/main.rs","content":"单行超大文本..."}`。
|
||||
let large = "z".repeat(10_000);
|
||||
let content = format!("{{\"path\":\"src/main.rs\",\"content\":\"{}\"}}", large);
|
||||
let result = extract_key_info(&content, "read_file");
|
||||
assert!(result.len() < content.len(), "JSON 大字符串字段应压缩: {} >= {}", result.len(), content.len());
|
||||
assert!(result.contains("_truncated"), "应含 _truncated 标记");
|
||||
assert!(result.contains("src/main.rs"), "应保留 path 字段");
|
||||
assert!(result.contains("(截断)"), "应含截断标记");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -84,6 +84,7 @@ fn ai_conversation_from_row(row: &Row<'_>) -> std::result::Result<AiConversation
|
||||
pinned: row.get::<_, i32>("pinned")? != 0,
|
||||
prompt_tokens: row.get("prompt_tokens")?,
|
||||
completion_tokens: row.get("completion_tokens")?,
|
||||
pinned_goals: row.get("pinned_goals")?,
|
||||
created_at: row.get("created_at")?,
|
||||
updated_at: row.get("updated_at")?,
|
||||
})
|
||||
@@ -159,24 +160,24 @@ impl_repo!(
|
||||
from_row => |row| ai_conversation_from_row(row),
|
||||
insert => |conn, rec| {
|
||||
conn.execute(
|
||||
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
"INSERT INTO ai_conversations (id, title, messages, provider_id, model, models, archived, pinned, prompt_tokens, completion_tokens, pinned_goals, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
|
||||
params![
|
||||
rec.id, rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
|
||||
if rec.pinned { 1i32 } else { 0i32 },
|
||||
rec.prompt_tokens, rec.completion_tokens,
|
||||
rec.created_at, rec.updated_at
|
||||
rec.pinned_goals, rec.created_at, rec.updated_at
|
||||
],
|
||||
)
|
||||
},
|
||||
update => |conn, rec| {
|
||||
conn.execute(
|
||||
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, updated_at = ?10 WHERE id = ?11",
|
||||
"UPDATE ai_conversations SET title = ?1, messages = ?2, provider_id = ?3, model = ?4, models = ?5, archived = ?6, pinned = ?7, prompt_tokens = ?8, completion_tokens = ?9, pinned_goals = ?10, updated_at = ?11 WHERE id = ?12",
|
||||
params![
|
||||
rec.title, rec.messages, rec.provider_id, rec.model, rec.models, rec.archived,
|
||||
if rec.pinned { 1i32 } else { 0i32 },
|
||||
rec.prompt_tokens, rec.completion_tokens,
|
||||
rec.updated_at, rec.id
|
||||
rec.pinned_goals, rec.updated_at, rec.id
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
// V31 = 知识图谱 Phase 3 基础设施数据层(对标设计 §2.3):project_services 表,
|
||||
// 项目基础设施配置(数据库/缓存/MQ/API 等),为 AI 执行任务时提供"这项目用了
|
||||
// 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。
|
||||
let steps: [(i32, fn(&Connection) -> Result<()>); 31] = [
|
||||
let steps: [(i32, fn(&Connection) -> Result<()>); 32] = [
|
||||
(1, migrate_v1),
|
||||
(2, migrate_v2),
|
||||
(3, migrate_v3),
|
||||
@@ -75,6 +75,7 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
(29, migrate_v29),
|
||||
(30, migrate_v30),
|
||||
(31, migrate_v31),
|
||||
(32, migrate_v32),
|
||||
];
|
||||
|
||||
for (version, migrate_fn) in steps {
|
||||
@@ -902,6 +903,20 @@ fn migrate_v31(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V32: ai_conversations 加 pinned_goals 列(对话透明化 L1 目标钉扎持久化)
|
||||
///
|
||||
/// 对话目标由 PerConvState.pinned_goals(Vec<String>)管理,原先仅在内存态存在,
|
||||
/// 此迁移为其提供持久化列,默认空 JSON 数组'[]'。
|
||||
fn migrate_v32(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
"ALTER TABLE ai_conversations ADD COLUMN pinned_goals TEXT DEFAULT '[]';"
|
||||
)?;
|
||||
tracing::info!("v32: ai_conversations 加 pinned_goals 列");
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [32])?;
|
||||
tracing::info!("迁移 v32 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V21 建表 SQL — 消息拆分存储 ai_messages 表
|
||||
///
|
||||
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
|
||||
|
||||
@@ -336,6 +336,7 @@ pub struct AiConversationRecord {
|
||||
pub pinned: bool, // 是否置顶(排序置前;UX-17)
|
||||
pub prompt_tokens: Option<i64>, // 输入 token 累计(流式 usage 落库)
|
||||
pub completion_tokens: Option<i64>, // 输出 token 累计(流式 usage 落库)
|
||||
pub pinned_goals: Option<String>, // 对话目标钉扎持久化(JSON 字符串数组,默认'[]')
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user