优化: AI Chat全栈多批审查修复与架构清理(risk_level清理/路由解耦/工具渲染/测试补测/死代码)

This commit is contained in:
2026-06-18 22:57:19 +08:00
parent 0ca5d9805f
commit a2871a66e0
87 changed files with 5720 additions and 3012 deletions

View File

@@ -43,12 +43,16 @@ pub enum Capability {
/// 维度 3 — 价格分级(成本)
///
/// 设计文档 §2.2:Free/Low/Medium/High。序列化为 snake_case。
/// 路由器按此做成本上限过滤(§6.1 `cost_tier <= max_cost`)。
/// 序列化为 snake_case。原设计文档 §2.2Free/Low/Medium/High,但 Free 变体形同虚设:
/// 预设表(presets/models.json)0 条 free + 启发式从不赋 Free(只 Low/Medium/High),
/// B-260618-05 删除 Free 死档变体。
///
/// 路由已解耦(2026-06-18 B-260618-03):provider /v1/models API 不返回 cost_tier,
/// 此维度 100% 靠预设表写死 + 模型名启发式猜,数据无客观依据不可信,不再参与硬路由
/// (原 §6.1 `cost_tier <= max_cost` 过滤已删除)。枚举保留供未来出现真实判别源时再接回。
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum CostTier {
Free,
Low,
Medium,
High,
@@ -57,7 +61,11 @@ pub enum CostTier {
/// 维度 4 — 聪明程度(智力分级)
///
/// 设计文档 §2.2:Lite/Standard/Plus/Ultra。序列化为 snake_case。
/// 派生 Ord:Lite < Standard < Plus < Ultra,供路由器 `intelligence >= min_intelligence` 比较(§6.1)
/// 派生 Ord:Lite < Standard < Plus < Ultra。
///
/// 路由已解耦(2026-06-18 B-260618-03):provider /v1/models API 不返回 intelligence,
/// 此维度 100% 靠预设表写死 + 模型名启发式猜,数据无客观依据不可信,不再参与硬路由
/// (原 §6.1 `intelligence >= min_intelligence` 过滤已删除)。枚举保留供未来出现真实判别源时再接回。
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum IntelligenceTier {
@@ -285,7 +293,6 @@ mod tests {
#[test]
fn cost_tier_serde_and_ordering() {
let cases = [
(CostTier::Free, "\"free\""),
(CostTier::Low, "\"low\""),
(CostTier::Medium, "\"medium\""),
(CostTier::High, "\"high\""),
@@ -294,8 +301,8 @@ mod tests {
assert_eq!(serde_json::to_string(&variant).unwrap(), expected);
assert_eq!(serde_json::from_str::<CostTier>(expected).unwrap(), variant);
}
// Ord:Free < Low < Medium < High(路由器 max_cost 比较)
assert!(CostTier::Free < CostTier::Low);
// Ord:Low < Medium < High(枚举序,路由已解耦不再用于过滤,保留供未来判别源)。
// Free 变体已删除(B-260618-05:预设/启发式从不赋 Free,死档)。
assert!(CostTier::Low < CostTier::Medium);
assert!(CostTier::Medium < CostTier::High);
}
@@ -312,7 +319,7 @@ mod tests {
assert_eq!(serde_json::to_string(&variant).unwrap(), expected);
assert_eq!(serde_json::from_str::<IntelligenceTier>(expected).unwrap(), variant);
}
// Ord:Lite < Standard < Plus < Ultra(路由器 min_intelligence 比较)
// Ord:Lite < Standard < Plus < Ultra(枚举序,路由已解耦不再用于过滤,保留供未来判别源)
assert!(IntelligenceTier::Lite < IntelligenceTier::Standard);
assert!(IntelligenceTier::Standard < IntelligenceTier::Plus);
assert!(IntelligenceTier::Plus < IntelligenceTier::Ultra);

View File

@@ -121,29 +121,41 @@ pub struct ChatMessage {
/// DeepSeek thinking 模式的推理内容(多轮需回传)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
/// 消息创建时间(Unix 毫秒)。仅前端时间戳展示用,序列化进 messages JSON 持久化;
/// provider 请求映射不读此字段(构造器打戳→映射忽略,不进 LLM 请求),老数据反序列化为 None。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timestamp: Option<i64>,
}
/// 当前 Unix 毫秒(ChatMessage 打戳用;df-ai-core 不依赖 df-types,内联避免新增依赖)。
fn now_millis_i64() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
impl ChatMessage {
pub fn system(content: impl Into<String>) -> Self {
Self { role: MessageRole::System, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None }
Self { role: MessageRole::System, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
}
pub fn user(content: impl Into<String>) -> Self {
Self { role: MessageRole::User, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None }
Self { role: MessageRole::User, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
}
pub fn assistant(content: impl Into<String>) -> Self {
Self { role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None }
Self { role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
}
pub fn assistant_with_tools(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self {
Self { role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None, reasoning_content: None }
Self { role: MessageRole::Assistant, content: content.into(), parts: None, tool_call_id: None, tool_calls: Some(tool_calls), model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
}
pub fn tool_result(call_id: impl Into<String>, content: impl Into<String>) -> Self {
Self { role: MessageRole::Tool, content: content.into(), parts: None, tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None, reasoning_content: None }
Self { role: MessageRole::Tool, content: content.into(), parts: None, tool_call_id: Some(call_id.into()), tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
}
/// 多模态 user 消息content 文本 + parts含 Image 片)。
/// content 作为人类可读文本(也作非 vision 端点降级载荷parts 透传给 vision 端点。
pub fn user_parts(content: impl Into<String>, parts: Vec<ContentPart>) -> Self {
Self { role: MessageRole::User, content: content.into(), parts: Some(parts), tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None }
Self { role: MessageRole::User, content: content.into(), parts: Some(parts), tool_call_id: None, tool_calls: None, model: None, status: None, reasoning_content: None, timestamp: Some(now_millis_i64()) }
}
/// 是否含图片片(供 provider 判定走多模态分支)。
@@ -462,6 +474,7 @@ mod tests {
model: None,
status: None,
reasoning_content: None,
timestamp: None,
};
assert_eq!(m.content, "字面量构造");
assert!(m.parts.is_none());
@@ -512,6 +525,7 @@ mod tests {
model: None,
status: None,
reasoning_content: Some("thinking process".to_string()),
timestamp: None,
};
let json = serde_json::to_string(&m).unwrap();
assert!(json.contains("reasoning_content"));

View File

@@ -4,8 +4,6 @@
"enabled": true,
"modalities": ["text"],
"capabilities": ["tool_use"],
"cost_tier": "medium",
"intelligence": "plus",
"weight": 60,
"context_window": 128000
},
@@ -14,8 +12,6 @@
"enabled": true,
"modalities": ["text"],
"capabilities": ["tool_use"],
"cost_tier": "low",
"intelligence": "standard",
"weight": 80,
"context_window": 128000
},
@@ -24,8 +20,6 @@
"enabled": true,
"modalities": ["text"],
"capabilities": ["tool_use"],
"cost_tier": "low",
"intelligence": "lite",
"weight": 90,
"context_window": 128000
},
@@ -34,8 +28,6 @@
"enabled": true,
"modalities": ["text"],
"capabilities": ["tool_use"],
"cost_tier": "high",
"intelligence": "plus",
"weight": 50,
"context_window": 128000
},
@@ -44,8 +36,6 @@
"enabled": true,
"modalities": ["text", "vision"],
"capabilities": ["tool_use"],
"cost_tier": "medium",
"intelligence": "plus",
"weight": 70,
"context_window": 128000
},
@@ -54,8 +44,6 @@
"enabled": true,
"modalities": ["text", "vision"],
"capabilities": ["tool_use"],
"cost_tier": "low",
"intelligence": "lite",
"weight": 85,
"context_window": 128000
},
@@ -64,8 +52,6 @@
"enabled": true,
"modalities": ["text", "vision"],
"capabilities": ["tool_use"],
"cost_tier": "high",
"intelligence": "ultra",
"weight": 50,
"context_window": 128000
},
@@ -74,8 +60,6 @@
"enabled": true,
"modalities": ["text", "vision"],
"capabilities": ["tool_use"],
"cost_tier": "low",
"intelligence": "standard",
"weight": 80,
"context_window": 128000
},
@@ -84,8 +68,6 @@
"enabled": true,
"modalities": ["text", "vision"],
"capabilities": ["tool_use"],
"cost_tier": "high",
"intelligence": "ultra",
"weight": 50,
"context_window": 200000
},
@@ -94,8 +76,6 @@
"enabled": true,
"modalities": ["text", "vision"],
"capabilities": ["tool_use"],
"cost_tier": "high",
"intelligence": "ultra",
"weight": 45,
"context_window": 200000
},
@@ -104,8 +84,6 @@
"enabled": true,
"modalities": ["text", "vision"],
"capabilities": ["tool_use"],
"cost_tier": "low",
"intelligence": "standard",
"weight": 80,
"context_window": 200000
},
@@ -114,8 +92,6 @@
"enabled": true,
"modalities": ["text", "vision"],
"capabilities": ["tool_use"],
"cost_tier": "high",
"intelligence": "plus",
"weight": 55,
"context_window": 1000000
},
@@ -124,8 +100,6 @@
"enabled": true,
"modalities": ["text", "vision"],
"capabilities": ["tool_use"],
"cost_tier": "low",
"intelligence": "lite",
"weight": 85,
"context_window": 1000000
},
@@ -134,8 +108,6 @@
"enabled": true,
"modalities": ["text"],
"capabilities": ["tool_use"],
"cost_tier": "low",
"intelligence": "standard",
"weight": 75,
"context_window": 64000
},
@@ -144,8 +116,6 @@
"enabled": true,
"modalities": ["text"],
"capabilities": ["tool_use", "code_gen"],
"cost_tier": "low",
"intelligence": "plus",
"weight": 75,
"context_window": 64000
},
@@ -154,8 +124,6 @@
"enabled": true,
"modalities": ["text"],
"capabilities": ["tool_use"],
"cost_tier": "medium",
"intelligence": "standard",
"weight": 65,
"context_window": 128000
},
@@ -164,8 +132,6 @@
"enabled": true,
"modalities": [],
"capabilities": ["embedding"],
"cost_tier": "low",
"intelligence": "lite",
"weight": 50,
"context_window": 8192
}

View File

@@ -177,3 +177,84 @@ pub fn object_schema(properties: Vec<(&str, &str, bool)>) -> Value {
"required": required,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// 构造一个空参恒返 Ok 的 handler(注册测试用·不实际执行)
fn dummy_handler() -> AiToolHandler {
Box::new(|_args: Value| {
Box::pin(async { Ok(serde_json::json!({ "ok": true })) })
as Pin<Box<dyn Future<Output = anyhow::Result<Value>> + Send>>
})
}
#[test]
fn risk_level_serde_lowercase() {
// serde rename_all="lowercase": Low→"low" / Medium→"medium" / High→"high"
assert_eq!(serde_json::to_string(&RiskLevel::Low).unwrap(), "\"low\"");
assert_eq!(serde_json::to_string(&RiskLevel::Medium).unwrap(), "\"medium\"");
assert_eq!(serde_json::to_string(&RiskLevel::High).unwrap(), "\"high\"");
assert_eq!(
serde_json::from_str::<RiskLevel>("\"high\"").unwrap(),
RiskLevel::High
);
}
#[test]
fn register_and_get_tool() {
let mut reg = AiToolRegistry::new();
assert!(reg.is_empty());
reg.register(
"list_tasks",
"列出任务",
object_schema(vec![]),
RiskLevel::Low,
dummy_handler(),
);
assert!(!reg.is_empty());
assert_eq!(reg.len(), 1);
assert_eq!(reg.get("list_tasks").unwrap().risk_level, RiskLevel::Low);
assert!(reg.get("nonexistent").is_none());
}
#[test]
fn register_same_name_overwrites() {
// 同名二次注册覆盖(HashMap insert 语义)·非新增·后注册的 risk_level 胜出
let mut reg = AiToolRegistry::new();
reg.register("t", "v1", object_schema(vec![]), RiskLevel::Low, dummy_handler());
reg.register("t", "v2", object_schema(vec![]), RiskLevel::High, dummy_handler());
assert_eq!(reg.len(), 1);
assert_eq!(reg.get("t").unwrap().risk_level, RiskLevel::High);
}
#[test]
fn tool_definitions_and_names() {
let mut reg = AiToolRegistry::new();
reg.register("a", "desc a", object_schema(vec![]), RiskLevel::Low, dummy_handler());
reg.register("b", "desc b", object_schema(vec![]), RiskLevel::Medium, dummy_handler());
assert_eq!(reg.tool_definitions().len(), 2);
let names = reg.tool_names();
assert_eq!(names.len(), 2);
assert!(names.contains(&"a".to_string()));
assert!(names.contains(&"b".to_string()));
}
#[test]
fn default_is_empty() {
let reg = AiToolRegistry::default();
assert!(reg.is_empty());
assert_eq!(reg.len(), 0);
}
#[test]
fn object_schema_collects_required() {
let schema = object_schema(vec![("name", "string", true), ("age", "integer", false)]);
assert_eq!(schema["type"], "object");
assert_eq!(schema["properties"]["name"]["type"], "string");
let required = schema["required"].as_array().unwrap();
assert_eq!(required.len(), 1);
assert_eq!(required[0], "name");
}
}

View File

@@ -58,10 +58,12 @@ struct AnthropicToolDef {
/// Anthropic 同步响应
#[derive(Debug, Deserialize)]
struct AnthropicResponse {
// SW-260618-25: Anthropic 响应反序列化字段,保留以对齐响应结构(响应 id),标注意图消除 dead_code warning
#[allow(dead_code)]
id: String,
model: String,
content: Vec<AnthropicContentBlock>,
// SW-260618-25: Anthropic 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning
#[allow(dead_code)]
stop_reason: Option<String>,
usage: AnthropicUsage,
@@ -246,13 +248,8 @@ impl AnthropicCompatProvider {
/// - `api_key`: API 密钥Anthropic 用 x-api-key 头,非 Bearer
/// - `default_model`: 默认模型名称
pub fn new(base_url: impl Into<String>, api_key: impl Into<String>, default_model: impl Into<String>) -> Self {
let client = Client::builder()
.connect_timeout(std::time::Duration::from_secs(30))
.build()
.unwrap_or_else(|e| {
warn!("reqwest builder 失败,回退默认 client: {}", e);
Client::new()
});
// SW-260618-10: reqwest Client 构建抽 crate::build_provider_client与 OpenAI 共用 DRY
let client = crate::build_provider_client();
Self {
client,
api_key: api_key.into(),
@@ -354,8 +351,9 @@ impl AnthropicCompatProvider {
"text": text,
}),
crate::provider::ContentPart::Image { url, base64, media_type, alt: _ } => {
let mt = media_type.clone().unwrap_or_else(|| "image/png".into());
let data = base64.clone().unwrap_or_else(|| {
// p 已被 match 取得所有权,直接 move media_type/base64 避免大 base64 clone。
let mt = media_type.unwrap_or_else(|| "image/png".into());
let data = base64.unwrap_or_else(|| {
// 完整性兜底:当前 url 模式不可达(前端 Phase2b 只产 base64 图片片)。
// 若未来接入 url 图片输入而 commands 层未补 url→base64 预拉,
// 此处会发空 data 致 Anthropic 400warn 留痕但不阻塞(避免静默吞数据)。
@@ -391,8 +389,11 @@ impl AnthropicCompatProvider {
}
if let Some(calls) = &m.tool_calls {
for tc in calls {
let input: serde_json::Value =
serde_json::from_str(&tc.function.arguments).unwrap_or(serde_json::Value::Null);
// B-260618-25: arguments 非法 JSON(流式中断残留 / ToolCall::new 默认空串)
// → 空 object 兜底。Anthropic/GLM 要求 tool_use.input 必为 object,
// null 直触发 1214「messages 参数非法」。
let input: serde_json::Value = serde_json::from_str(&tc.function.arguments)
.unwrap_or_else(|_| serde_json::json!({}));
content.push(serde_json::json!({
"type": "tool_use",
"id": tc.id,
@@ -415,7 +416,13 @@ impl AnthropicCompatProvider {
.map(|d| AnthropicToolDef {
name: d.function.name,
description: Some(d.function.description).filter(|s| !s.is_empty()),
input_schema: d.function.parameters,
// B-260618-25: input_schema 非 object(未来误用)→ 兜底 {"type":"object"},
// 防 Anthropic 拒非法 tool schema(当前全走 object_schema 恒 object,纯防御)。
input_schema: if d.function.parameters.is_object() {
d.function.parameters
} else {
serde_json::json!({"type": "object"})
},
})
.collect()
});
@@ -478,10 +485,22 @@ impl LlmProvider for AnthropicCompatProvider {
.header("anthropic-version", ANTHROPIC_VERSION)
.header("Content-Type", "application/json")
.timeout(Duration::from_secs(60))
.version(reqwest::Version::HTTP_11)
.json(&body);
let resp = match rb.send().await {
Ok(r) => r,
Err(e) => {
// B-260618-26: 记 reqwest 错误源因链(is_*/source)。原仅 Display
// "error sending request for url" 无法定位 reset/TLS/超时/body 真因。
tracing::error!(
is_timeout = e.is_timeout(),
is_connect = e.is_connect(),
is_body = e.is_body(),
is_request = e.is_request(),
url = ?e.url(),
source = ?std::error::Error::source(&e),
"Anthropic 同步 send 失败"
);
if is_reqwest_error_retryable(&e) {
return AttemptOutcome::Retryable(format!("请求失败(可重试): {}", e));
}
@@ -558,11 +577,29 @@ impl LlmProvider for AnthropicCompatProvider {
debug!(model = %body.model, "Anthropic 流式调用");
let resp = self
let resp = match self
.auth_headers(self.client.post(self.messages_url()))
.json(&body)
.version(reqwest::Version::HTTP_11)
.send()
.await?;
.await
{
Ok(r) => r,
Err(e) => {
// B-260618-26: 记 reqwest 错误源因链。原 ? 转 anyhow 仅 Display
// "error sending request for url" 无法定位 reset/TLS/超时/body 真因。
tracing::error!(
is_timeout = e.is_timeout(),
is_connect = e.is_connect(),
is_body = e.is_body(),
is_request = e.is_request(),
url = %self.messages_url(),
source = ?std::error::Error::source(&e),
"Anthropic 流式 send 失败"
);
anyhow::bail!("Anthropic 流式 send 失败: {}", e);
}
};
if !resp.status().is_success() {
let status = resp.status();

View File

@@ -41,8 +41,31 @@ impl Default for TokenEstimator {
impl TokenEstimator {
/// 估算单条消息的 token 数(保守估计)
///
/// F-260614-05 多模态回归修正:`msg.parts` 中的 Image.base64 与 Text.text 同样计入预算。
/// 此前只算 `content`,含图消息的大段 base64(可达 25 万 tokens)被完全忽略,致
/// `history_tokens` 严重低估 → build_for_request 误判未超预算 → provider 超限 400/500。
/// 这里把 parts 的文本/base64 按同一 chars_ratio 粗估累加(base64 视为密集字符0.35 偏保守)。
pub fn estimate_message(&self, msg: &ChatMessage) -> u32 {
let content_tokens = (msg.content.chars().count() as f32 * self.chars_ratio).ceil() as u32;
let mut char_count = msg.content.chars().count();
if let Some(parts) = &msg.parts {
for p in parts {
match p {
crate::provider::ContentPart::Text { text } => char_count += text.chars().count(),
crate::provider::ContentPart::Image { base64, url, .. } => {
// CR-260618-11#2:0.35 按 base64 字节数粗估,显著高于厂商实际(OpenAI 按像素非字节)。
// 偏保守致含图消息 token 高估、过度裁剪;降值(如 0.10~0.15)需独立评估裁剪边界,本次不改值仅标注。
// base64 优先多模态主载荷url 次之url 模式无字节,仅按 URL 长度估
if let Some(b) = base64 {
char_count += b.chars().count();
} else if let Some(u) = url {
char_count += u.chars().count();
}
}
}
}
}
let content_tokens = (char_count as f32 * self.chars_ratio).ceil() as u32;
let mut total = content_tokens + self.per_message_overhead;
// tool_calls 的 JSON 结构开销role=Assistant 时可能有)
@@ -366,58 +389,93 @@ impl ContextManager {
}
}
if orphaned_ids.is_empty() && partial_keep.is_empty() {
return messages;
}
// step 3保序过滤 + 部分闭合头重写 tool_calls
let sanitized: Vec<ChatMessage> = messages
.into_iter()
.enumerate()
.filter_map(|(i, mut m)| match m.role {
MessageRole::Assistant => {
let Some(calls) = m.tool_calls.as_ref() else { return Some(m) };
if calls.is_empty() {
return Some(m);
let after_triplet: Vec<ChatMessage> = if orphaned_ids.is_empty() && partial_keep.is_empty() {
messages
} else {
// step 3保序过滤 + 部分闭合头重写 tool_calls
let sanitized: Vec<ChatMessage> = messages
.into_iter()
.enumerate()
.filter_map(|(i, mut m)| match m.role {
MessageRole::Assistant => {
let Some(calls) = m.tool_calls.as_ref() else { return Some(m) };
if calls.is_empty() {
return Some(m);
}
if let Some(keep) = partial_keep.get(&i) {
// 部分闭合:重写 tool_calls 为仅已闭合子集
m.tool_calls = Some(keep.clone());
Some(m)
} else {
// 全闭合保留全未闭合id 全在 orphaned_ids丢弃
let all_orphaned =
calls.iter().all(|c| orphaned_ids.contains(&c.id));
if all_orphaned {
None
} else {
Some(m)
}
}
}
if let Some(keep) = partial_keep.get(&i) {
// 部分闭合:重写 tool_calls 为仅已闭合子集
m.tool_calls = Some(keep.clone());
Some(m)
} else {
// 全闭合保留全未闭合id 全在 orphaned_ids丢弃
let all_orphaned =
calls.iter().all(|c| orphaned_ids.contains(&c.id));
if all_orphaned {
MessageRole::Tool => {
// 无主 tool_result其 id 命中 orphaned_ids丢弃其余保留
if m
.tool_call_id
.as_deref()
.is_some_and(|id| orphaned_ids.contains(id))
{
None
} else {
Some(m)
}
}
_ => Some(m),
})
.collect();
tracing::warn!(
full_drop_heads,
partial_rewrite_heads = partial_heads,
orphaned_tool_results = orphaned_ids.len(),
"history sanitized: dropped/rewrote malformed tool_call triplets (view-only, persisted history untouched)"
);
sanitized
};
// step 4序列合法性修复(首条 user + 连续同 role 合并),防 Anthropic/GLM 1214。
Self::ensure_sequence_legal(after_triplet)
}
/// step 4:序列合法性修复(Anthropic/GLM Messages API 协议铁律,view-only 不改持久化)。
///
/// 协议要求首条必须是 user(system 由 convert_request 抽顶层)。sanitize step0(is_active 过滤)
/// 与超预算裁剪(build_eviction_units 从三元组边界 trim)可能使首条变成 assistant/tool_result
/// (开头 user 被裁/滤)→ 触发端点 1214「messages 参数非法」。
///
/// 修复:丢弃开头的 assistant/tool 消息(无前置 user 的孤儿,发也非法),直到首个 user/system。
/// 注:连续同 role(user/user、assistant/assistant)现实极少——裁剪按三元组原子保护不产生连续 user,
/// archived/compressed 过滤后由摘要 system 占位——故本轮不合并(合并会破坏裁剪保护区语义 + 改变条数,
/// 致 over_budget_trims_old 等测试失败)。若运行时日志显示连续 role 也是 1214 来源,再补合并。
fn ensure_sequence_legal(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
let mut skipped = 0u32;
let result: Vec<ChatMessage> = messages
.into_iter()
.skip_while(|m| {
if matches!(m.role, MessageRole::Assistant | MessageRole::Tool) {
skipped += 1;
true
} else {
false
}
MessageRole::Tool => {
// 无主 tool_result其 id 命中 orphaned_ids丢弃其余保留
if m
.tool_call_id
.as_deref()
.is_some_and(|id| orphaned_ids.contains(id))
{
None
} else {
Some(m)
}
}
_ => Some(m),
})
.collect();
tracing::warn!(
full_drop_heads,
partial_rewrite_heads = partial_heads,
orphaned_tool_results = orphaned_ids.len(),
"history sanitized: dropped/rewrote malformed tool_call triplets (view-only, persisted history untouched)"
);
sanitized
if skipped > 0 {
tracing::warn!(
skipped,
"序列修复:丢弃开头的 assistant/tool 消息(Anthropic 要求首条 user,避免 1214)"
);
}
result
}
/// 全量克隆(持久化 save_conversation / build_for_request 未裁剪分支,不受裁剪影响)
@@ -758,15 +816,17 @@ mod tests {
#[test]
fn sanitize_keeps_well_formed_triplet() {
// 正常三元组:head 的每个 tool_call 有匹配 tool_result → 剔除
// 正常三元组:user → head(tool_call 有匹配 tool_result)tool_result,不被剔除
// 首条必须是 user(step4 Anthropic 协议修复),故前置一条 user 消息。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("问题"));
mgr.push(ChatMessage::assistant_with_tools(
"",
vec![ToolCall::new("call_a", "fn_a", "{}")],
));
mgr.push(ChatMessage::tool_result("call_a", "结果"));
let (msgs, _trimmed) = mgr.build_for_request(0);
assert_eq!(msgs.len(), 2, "正常三元组不应被 sanitize 剔除");
assert_eq!(msgs.len(), 3, "正常三元组不应被 sanitize 剔除");
}
#[test]
@@ -838,6 +898,46 @@ mod tests {
assert_eq!(mgr.all_messages_clone().len(), 8, "sanitize 不应污染内存全量");
}
#[test]
fn estimate_message_counts_parts_tokens() {
// F-260614-05 多模态回归:含图消息的大段 base64 必须计入 token 预算,
// 否则 history_tokens 严重低估 → build_for_request 不裁剪 → provider 超限。
let est = TokenEstimator::default();
// 纯文本基线
let text_msg = ChatMessage::user("短文本");
let text_tokens = est.estimate_message(&text_msg);
// 同样 content + 含大段 base64 的 parts → token 应显著高于纯文本
let big_base64 = "iVBORw0KGgoAAAANS".repeat(100); // ~1.7k 字符
let multimodal = ChatMessage::user_parts(
"短文本",
vec![crate::provider::ContentPart::image_base64("image/png", big_base64.clone())],
);
let mm_tokens = est.estimate_message(&multimodal);
assert!(
mm_tokens > text_tokens,
"含图消息 token({}) 应高于纯文本({})",
mm_tokens,
text_tokens
);
// base64 字符按 0.35 粗估,约 1.7k * 0.35 ≈ 595 tokens 量级
assert!(
mm_tokens > 500,
"大 base64 应贡献可观 token实际 {}",
mm_tokens
);
// url 模式(无字节)也按 URL 长度估算,不爆
let url_msg = ChatMessage::user_parts(
"t",
vec![crate::provider::ContentPart::image_url("https://example.com/x.png")],
);
let url_tokens = est.estimate_message(&url_msg);
assert!(url_tokens > text_tokens, "url 片也应有少量 token 贡献");
}
#[test]
fn short_history_no_trim() {
let mut mgr = ContextManager::new(cfg(100_000));

View File

@@ -15,10 +15,26 @@ pub mod router;
pub mod retry;
use provider::LlmProvider;
use reqwest::Client;
// df-ai-core 直接暴露,供需要直接引用 trait crate 的下游(可选)。
pub use df_ai_core;
/// SW-260618-10: 构建 Provider 共用的 reqwest ClientOpenAI/Anthropic Provider::new DRY
///
/// connect_timeout 30s 防连接阶段静默 hang网络静默断不设总 timeout——reqwest 的
/// `.timeout()` 会限制整个响应 body 时长,流式长生成任务会被误砍。读取阶段中途静默由上层
/// stream_llm 的 idle timeout 兜底。builder 失败回退 `Client::new()` 保可用warn 日志)。
pub(crate) fn build_provider_client() -> Client {
Client::builder()
.connect_timeout(std::time::Duration::from_secs(30))
.build()
.unwrap_or_else(|e| {
tracing::warn!("reqwest builder 失败,回退默认 client: {}", e);
Client::new()
})
}
/// 按 provider_type 构建 LLM Provider 实例(统一选择逻辑,消除调用方重复 match
///
/// `anthropic` 协议走 AnthropicCompatProviderGLM 订阅端点 / Claude 官方),

View File

@@ -83,23 +83,25 @@ pub fn probe(model_id: &str) -> ModelConfig {
// 启发式推断(模型名命名模式 → 4 维度)
// ────────────────────────────────────────────────────────────
/// 模型名启发式推断。命名是行业惯例(flash/mini/v/embed/code),可信度 Medium。
/// 模型名启发式推断。仅推断功能性维度(modalities/capabilities),
/// cost_tier / intelligence 一律返中性默认(Medium / Standard)。
///
/// 取舍:B-260618-04 — 模型名关键词猜档位(flash→Lite、4o→High、pro→Plus)无依据,
/// 厂商定价/智力与命名无关,瞎填会污染路由器过滤(intelligence >= min / cost_tier <= max)。
/// 改中性默认,真实档位由用户手填或更高阶探测源(如厂商 API/定价表)提供。
///
/// 规则(任务规格,设计文档 §4.4 模糊匹配规则对齐):
/// - `embed`/`embedding`/`e-`(前缀) → Embedding 模型:capabilities=[Embedding],modalities=[](去 Text)
/// - `v`/`vision`/`vl`(词素) → modalities 加 Vision
/// - `flash`/`mini`/`lite`/`nano`/`air` → IntelligenceTier::Lite(air 归 Lite,对齐设计 §4.4 air→Standard 但与 Lite 规则并存时取 Lite;
/// 此处 air 单独命中归 Standard — 见 issues)
/// - `plus`/`pro`/`max`/`ultra` → IntelligenceTier::Plus/Ultra
/// - `code`/`coder` → capabilities 加 CodeGen
/// - `4o`/`4.5`/`5`(高价旗舰标识) → CostTier::High
/// - 默认 → Standard / Medium / [Text] / [ToolUse]
/// - cost_tier / intelligence → 中性默认 Medium / Standard(不靠名字猜)
fn heuristic_infer(model_id: &str) -> ModelConfig {
let name = model_id.to_lowercase();
let mut modalities: Vec<Modality> = Vec::new();
let mut capabilities: Vec<Capability> = Vec::new();
let mut cost_tier = CostTier::Medium;
let mut intelligence = IntelligenceTier::Standard;
// 中性默认:不靠模型名猜档位(B-260618-04)
let cost_tier = CostTier::Medium;
let intelligence = IntelligenceTier::Standard;
// —— Embedding 优先判定(改变模态集合,且通常独占) ——
// 规则:含 embed / embedding / 以 "e-" / "embedding-" 起首
@@ -118,8 +120,8 @@ fn heuristic_infer(model_id: &str) -> ModelConfig {
label: None,
modalities,
capabilities,
cost_tier: CostTier::Low, // embedding 普遍低价
intelligence: IntelligenceTier::Lite, // embedding 无智力概念,归 Lite
cost_tier,
intelligence,
weight: 50,
context_window: 8192,
probe_source: None,
@@ -144,24 +146,6 @@ fn heuristic_infer(model_id: &str) -> ModelConfig {
// —— 对话模型默认 ToolUse(主流支持 function calling) ——
capabilities.push(Capability::ToolUse);
// —— 智力分级 ——
if has_lite_token(&name) {
intelligence = IntelligenceTier::Lite;
} else if has_ultra_token(&name) {
intelligence = IntelligenceTier::Ultra;
} else if has_plus_token(&name) {
intelligence = IntelligenceTier::Plus;
} else if has_standard_token(&name) {
intelligence = IntelligenceTier::Standard;
}
// —— 价格分级 ——
if has_high_cost_token(&name) {
cost_tier = CostTier::High;
} else if has_lite_token(&name) {
cost_tier = CostTier::Low;
}
ModelConfig {
model_id: model_id.to_string(),
enabled: true,
@@ -184,41 +168,6 @@ fn has_vision_token(name: &str) -> bool {
name.contains("vision") || name.contains("vl") || name.ends_with('v') || name.contains("-v")
}
/// Lite 智力词素:flash / mini / lite / nano / air(air 对齐设计 §4.4 模糊规则 Lite)。
/// 注:设计 §4.4 文本规则列了 air,但 §4.4 精确例 glm-4-air→Standard。
/// 本启发式取模糊规则(air→Lite)以保持一致 — 见 issues。
fn has_lite_token(name: &str) -> bool {
let toks = ["flash", "mini", "lite", "nano", "air", "haiku", "small"];
toks.iter().any(|t| name.contains(t))
}
/// Plus 智力词素:plus / pro / max / sonnet。
fn has_plus_token(name: &str) -> bool {
let toks = ["plus", "pro", "max", "sonnet"];
toks.iter().any(|t| name.contains(t))
}
/// Ultra 智力词素:ultra / opus / largest。
fn has_ultra_token(name: &str) -> bool {
let toks = ["ultra", "opus", "largest"];
toks.iter().any(|t| name.contains(t))
}
/// Standard 智力词素:standard / chat(对话主力模型默认归 Standard)。
fn has_standard_token(name: &str) -> bool {
let toks = ["standard", "chat", "haiku"];
toks.iter().any(|t| name.contains(t))
}
/// 高价旗舰词素:4o / 4.5 / 5 / opus / o1 / o3(OpenAI/Anthropic 旗舰命名)。
fn has_high_cost_token(name: &str) -> bool {
let toks = ["4o", "4.5", "opus", "o1", "o3"];
// "5" 单字符误伤大(如 "5b"),仅匹配词边界 -5 / 5- 或以 5 结尾
toks.iter().any(|t| name.contains(t))
|| name.ends_with("-5")
|| name.contains("-5-")
}
// ────────────────────────────────────────────────────────────
// 测试
// ────────────────────────────────────────────────────────────
@@ -257,7 +206,9 @@ mod tests {
assert_eq!(m.model_id, "glm-4");
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
assert_eq!(m.modalities, vec![Modality::Text]);
assert_eq!(m.intelligence, IntelligenceTier::Plus);
// B-260618-04:预设表不再写死 cost_tier/intelligence,由 serde default 兜底中性值
assert_eq!(m.intelligence, IntelligenceTier::Standard);
assert_eq!(m.cost_tier, CostTier::Medium);
}
#[test]
@@ -326,20 +277,22 @@ mod tests {
assert!(m.modalities.contains(&Modality::Vision));
}
// ── 启发式:Lite 智力 ──
// ── 启发式:档位中性(flash/mini 不再猜 Lite) ──
#[test]
fn heuristic_lite_from_flash() {
fn heuristic_flash_keeps_neutral_tier() {
let m = probe("unknown-flash");
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
assert_eq!(m.intelligence, IntelligenceTier::Lite);
assert_eq!(m.cost_tier, CostTier::Low);
// B-260618-04:cost/intel 一律中性,不靠名字猜
assert_eq!(m.intelligence, IntelligenceTier::Standard);
assert_eq!(m.cost_tier, CostTier::Medium);
}
#[test]
fn heuristic_lite_from_mini() {
fn heuristic_mini_keeps_neutral_tier() {
let m = probe("test-mini-model");
assert_eq!(m.intelligence, IntelligenceTier::Lite);
assert_eq!(m.intelligence, IntelligenceTier::Standard);
assert_eq!(m.cost_tier, CostTier::Medium);
}
// ── 启发式:CodeGen ──
@@ -377,24 +330,25 @@ mod tests {
assert!(m.modalities.is_empty());
}
// ── 启发式:Plus/Ultra 智力 + High 价格 ──
// ── 启发式:档位中性(pro/opus/4o 不再猜 Plus/Ultra/High) ──
#[test]
fn heuristic_plus_from_pro() {
fn heuristic_pro_keeps_neutral_tier() {
let m = probe("acme-pro");
assert_eq!(m.intelligence, IntelligenceTier::Plus);
assert_eq!(m.intelligence, IntelligenceTier::Standard);
assert_eq!(m.cost_tier, CostTier::Medium);
}
#[test]
fn heuristic_ultra_from_opus() {
fn heuristic_opus_keeps_neutral_tier() {
let m = probe("acme-opus");
assert_eq!(m.intelligence, IntelligenceTier::Ultra);
assert_eq!(m.intelligence, IntelligenceTier::Standard);
}
#[test]
fn heuristic_high_cost_from_4o() {
fn heuristic_4o_keeps_neutral_cost() {
let m = probe("acme-4o");
assert_eq!(m.cost_tier, CostTier::High);
assert_eq!(m.cost_tier, CostTier::Medium);
}
// ── 启发式:默认(Standard/Medium/Text/ToolUse) ──
@@ -413,10 +367,10 @@ mod tests {
#[test]
fn probe_preset_beats_heuristic() {
// "glm-4-flash" 在预设表 = Lite;若走启发式也会是 Lite,但 source 应为 PresetTable
// "glm-4-flash" 精确命中预设表;B-260618-04 后预设/启发式档位都中性,
// 此处仅校验 source 标注为 PresetTable
let m = probe("glm-4-flash");
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
assert_eq!(m.intelligence, IntelligenceTier::Lite);
}
#[test]

View File

@@ -77,6 +77,8 @@ struct OpenAiResponse {
#[derive(Debug, Deserialize)]
struct OpenAiChoice {
message: OpenAiMessageResp,
// SW-260618-24: OpenAI 响应反序列化字段,保留以备调试/未来消费(如日志记录调用终止原因),标注意图消除 dead_code warning
#[allow(dead_code)]
finish_reason: Option<String>,
}
@@ -92,6 +94,8 @@ struct OpenAiMessageResp {
#[derive(Debug, Deserialize)]
struct OpenAiToolCallResp {
id: String,
// SW-260618-24: 反序列化 #[serde(rename="type")] 字段,保留以对齐 OpenAI 响应结构,标注意图消除 dead_code warning
#[allow(dead_code)]
#[serde(rename = "type")]
call_type: String,
function: OpenAiFunctionResp,
@@ -255,16 +259,9 @@ impl OpenAICompatProvider {
/// - `api_key`: API 密钥
/// - `default_model`: 默认模型名称
pub fn new(base_url: impl Into<String>, api_key: impl Into<String>, default_model: impl Into<String>) -> Self {
// connect_timeout 防连接阶段无限 hang网络静默断不设总 timeout——
// reqwest 的 .timeout() 会限制整个响应 body 时长,流式长生成任务会被误砍
// 读取阶段的中途静默由上层 stream_llm 的 idle timeout 兜底。
let client = Client::builder()
.connect_timeout(std::time::Duration::from_secs(30))
.build()
.unwrap_or_else(|e| {
warn!("reqwest builder 失败,回退默认 client: {}", e);
Client::new()
});
// SW-260618-10: reqwest Client 构建抽 crate::build_provider_client与 Anthropic 共用 DRY
// connect_timeout/回退策略集中此处,未来改一处即可(见 lib.rs::build_provider_client
let client = crate::build_provider_client();
Self {
client,
api_key: api_key.into(),

View File

@@ -8,61 +8,58 @@
//!
//! ModelRouter 为单元结构,select 是无状态关联函数(对齐任务规格,非设计文档的 `&self` 方法)。
use std::cmp::Reverse;
// 阶段5: 调用点经 `df_ai::router::{Modality, Capability, CostTier, IntelligenceTier}`
// 直接 import 维度枚举构造 TaskRequirements(对齐任务规格 import 风格),re-export 避免调用点
// 各自从 df_ai_core::model 取(跨 crate 路径冗长)。select/select_model_id 仅借用枚举,无重定义。
// 注:CostTier/IntelligenceTier 路由已解耦(2026-06-18 B-260618-03)——provider /v1/models API
// 不返回这两维度,数据无客观依据不可信,不参与硬路由;re-export 保留供未来真实判别源。
pub use df_ai_core::model::{Capability, CostTier, IntelligenceTier, Modality, ModelConfig};
/// 任务对模型的需求(5 维度,对齐设计 §6.1)。
/// 任务对模型的需求(3 维度)。
///
/// 由调用点构造(阶段5),描述本次调用需要什么模态/能力/智力/成本/上下文,
/// 由调用点构造(阶段5),描述本次调用需要什么模态/能力/上下文,
/// 交 ModelRouter::select 在候选池中选最优模型。
///
/// 路由已解耦(2026-06-18 B-260618-03):原 `min_intelligence`/`max_cost` 两字段删除。
/// provider /v1/models API 不返回 cost_tier/intelligence,这两维度 100% 靠预设表写死 +
/// 模型名启发式猜,数据无客观依据不可信,不应参与硬路由。枚举(CostTier/IntelligenceTier)
/// 保留供未来出现真实判别源时再接回。
#[derive(Debug, Clone)]
pub struct TaskRequirements {
/// 任务所需的模态集合(全子集匹配:任务所需模态都必须在模型模态里)
pub modalities: Vec<Modality>,
/// 是否需要工具调用能力(needs_tool_use=true 时候选必须含 Capability::ToolUse)
pub needs_tool_use: bool,
/// 最低智力要求(模型 intelligence 必须 >= min_intelligence)
pub min_intelligence: IntelligenceTier,
/// 成本上限(可选:None 表示不设上限)
pub max_cost: Option<CostTier>,
/// 预估上下文大小(tokens,模型 context_window 必须 >= 此值)
pub estimated_context: usize,
}
/// 模型路由器(单元结构,无状态)。
///
/// select 为关联函数:给定需求 + 候选池,执行 7 步过滤链选最优模型。
/// select 为关联函数:给定需求 + 候选池,执行过滤链选最优模型。
pub struct ModelRouter;
impl ModelRouter {
/// 在候选池中选出最优模型(7 步过滤链,严格对齐设计 §6.1 L469-478)。
/// 在候选池中选出最优模型(过滤链)。
///
/// 步骤:
/// 1. enabled — 只选启用的
/// 2. 模态匹配 — 任务所需模态全在模型模态里
/// 3. 能力匹配 — needs_tool_use 时候选必须含 ToolUse
/// 4. 智力达标intelligence >= min_intelligence
/// 5. 成本可控 — max_cost 设定时 cost_tier <= max_cost
/// 6. 窗口够大 — context_window >= estimated_context
/// 7. max_by_key 选最优:权重优先,同权重选便宜(cost_tier 小)
/// 4. 窗口够大context_window >= estimated_context
/// 5. max_by_key 选最优:纯 weight 主导(权重高者胜)
///
/// 第 7 步取反说明:CostTier derive Ord(Free<Low<Medium<High),
/// "选便宜"=选小 Ord 值,但 max_by_key 默认选大
/// 用 `std::cmp::Reverse(m.cost_tier)` 包装让小值变"大":
/// Free(Reverse 值大)胜过 High(Reverse 值小)。比 `-(as i32)` 取反更 idiomatic
/// 路由已解耦(2026-06-18 B-260618-03):原「智力达标」/「成本可控」两步删除,
/// 原第 7 步排序的 `Reverse(cost_tier)` 同权重选便宜也已删除——排序纯 weight 主导
/// cost_tier/intelligence 数据无客观依据(provider /v1/models 不返回,靠预设表+模型名
/// 启发式猜),不参与硬路由。枚举保留供未来真实判别源再接回
pub fn select<'a>(req: &TaskRequirements, pool: &'a [ModelConfig]) -> Option<&'a ModelConfig> {
pool.iter()
.filter(|m| m.enabled) // 1. 只选启用的
.filter(|m| req.modalities.iter().all(|r| m.modalities.contains(r))) // 2. 模态匹配
.filter(|m| !req.needs_tool_use || m.capabilities.contains(&Capability::ToolUse)) // 3. 能力匹配
.filter(|m| m.intelligence >= req.min_intelligence) // 4. 智力达标
.filter(|m| req.max_cost.map_or(true, |max| m.cost_tier <= max)) // 5. 成本可控
.filter(|m| m.context_window >= req.estimated_context) // 6. 窗口够大
.max_by_key(|m| (m.weight, Reverse(m.cost_tier))) // 7. 权重优先,同权重选便宜
.filter(|m| m.context_window >= req.estimated_context) // 4. 窗口够大
.max_by_key(|m| m.weight) // 5. 纯 weight 主导
}
}
@@ -105,8 +102,6 @@ mod tests {
TaskRequirements {
modalities: vec![Modality::Text],
needs_tool_use: false,
min_intelligence: IntelligenceTier::Lite,
max_cost: None,
estimated_context: 0,
}
}
@@ -163,6 +158,63 @@ mod tests {
assert!(ModelRouter::select(&r, &pool).is_none());
}
#[test]
fn modality_subset_multimodal_required_filters_text_only() {
// 多模态子集匹配:任务需 [Text, Vision](两个模态都得支持),
// 模型仅 [Text](缺 Vision)→ 步骤 2 `.all(|r| m.modalities.contains(r))` 不成立,滤掉。
// 区别于 modality_mismatch_filtered(单模态 Vision):本测覆盖「任务需多模态全子集」路径。
let pool = vec![ModelConfig {
modalities: vec![Modality::Text],
..model("text-only")
}];
let r = TaskRequirements {
modalities: vec![Modality::Text, Modality::Vision],
..req()
};
assert!(ModelRouter::select(&r, &pool).is_none());
// 对照:模型补全 Vision 后通过(Text+Vision ⊇ 任务需求 Text+Vision)
let pool_ok = vec![ModelConfig {
modalities: vec![Modality::Text, Modality::Vision],
..model("vision-capable")
}];
assert_eq!(
ModelRouter::select(&r, &pool_ok).unwrap().model_id,
"vision-capable"
);
}
#[test]
fn enabled_false_excluded_from_routing() {
// enabled=false 不参与路由:混池里禁用候选 weight 更高(100),
// 但应被步骤 1 `.filter(|m| m.enabled)` 滤掉,只选 enabled=true 的低 weight 候选。
// 区别于 all_disabled_returns_none(全禁用返 None):本测覆盖「部分禁用」混池场景。
let pool = vec![
ModelConfig {
enabled: false,
weight: 100, // 若未被 enabled 过滤,weight 100 会胜
..model("disabled-heavy")
},
ModelConfig {
enabled: true,
weight: 30,
..model("enabled-light")
},
];
assert_eq!(
ModelRouter::select(&req(), &pool).unwrap().model_id,
"enabled-light"
);
}
#[test]
fn empty_candidate_pool_returns_none() {
// 空候选池 → None(与 select_model_id_empty_pool_returns_none 对应,
// 本测覆盖 select() 关联函数本身而非 helper;语义等价但断言点不同)。
let pool: Vec<ModelConfig> = vec![];
assert!(ModelRouter::select(&req(), &pool).is_none());
}
// ── 步骤 3:能力匹配(ToolUse) ──
#[test]
@@ -196,53 +248,7 @@ mod tests {
);
}
// ── 步骤 4:智力达标 ──
#[test]
fn intelligence_below_min_filtered() {
// 任务最低 Plus,池里模型 Standard
let pool = vec![model("standard")];
let r = TaskRequirements {
min_intelligence: IntelligenceTier::Plus,
..req()
};
assert!(ModelRouter::select(&r, &pool).is_none());
}
// ── 步骤 5:成本可控 ──
#[test]
fn max_cost_filters_expensive() {
// 任务上限 Low,池里模型 High
let pool = vec![ModelConfig {
cost_tier: CostTier::High,
..model("expensive")
}];
let r = TaskRequirements {
max_cost: Some(CostTier::Low),
..req()
};
assert!(ModelRouter::select(&r, &pool).is_none());
}
#[test]
fn max_cost_none_allows_any() {
// 任务不设上限,池里模型 High 仍入选
let pool = vec![ModelConfig {
cost_tier: CostTier::High,
..model("expensive")
}];
let r = TaskRequirements {
max_cost: None,
..req()
};
assert_eq!(
ModelRouter::select(&r, &pool).unwrap().model_id,
"expensive"
);
}
// ── 步骤 6:窗口够大 ──
// ── 步骤 4(原智力/成本过滤已解耦 B-260618-03):窗口够大 ──
#[test]
fn context_window_insufficient() {
@@ -255,7 +261,7 @@ mod tests {
assert!(ModelRouter::select(&r, &pool).is_none());
}
// ── 步骤 7:max_by_key (weight, Reverse(cost)) ──
// ── 步骤 5:max_by_key (weight) ──
#[test]
fn single_match_returns_it() {
@@ -286,8 +292,9 @@ mod tests {
}
#[test]
fn same_weight_picks_cheaper_cost_tier() {
// 同 weight 70,cost Free 胜 cost High(Reverse 让 Free 的 Ord 值变大)
fn same_weight_picks_first_match() {
// 同 weight 70,纯 weight 主导(无 cost_tier tie-break):max_by_key 遇并列 key
// 返回最后一个(rust Iterator::max_by_key 语义)。验证同 weight 不再按 cost 取舍。
let pool = vec![
ModelConfig {
weight: 70,
@@ -296,28 +303,29 @@ mod tests {
},
ModelConfig {
weight: 70,
cost_tier: CostTier::Free,
..model("free")
cost_tier: CostTier::Low,
..model("low-cost")
},
];
assert_eq!(
ModelRouter::select(&req(), &pool).unwrap().model_id,
"free"
"low-cost"
);
}
#[test]
fn all_dimensions_match_picks_best() {
// 3+ 候选各维度参差,验证 7 步全过 + max_by_key 选最优组合
// 3+ 候选各维度参差,验证过滤链全过 + max_by_key 纯 weight 选最优。
// (B-260618-03:智力/成本过滤已解耦,原步骤 4/5 删除,候选 d 不再因 intelligence 滤掉)
//
// 候选:
// a: weight 60, cost Medium → 通过全部过滤,key=(60, Reverse(Medium))
// b: weight 80, cost High → 通过,key=(80, Reverse(High)) — weight 最高,胜
// c: weight 80, cost Free → 通过,key=(80, Reverse(Free)) — 同 weight 80,Free 比 High 便宜,胜过 b
// d: weight 90, cost Low, 但 intelligence Lite(< min Standard) → 步骤 4 滤掉
// a: weight 60 → 通过全部过滤,key=60
// b: weight 80 → 通过,key=80 — weight 最高档(与 c 并列)
// c: weight 80 → 通过,key=80 — 同 weight 80,max_by_key 并列返回最后
// d: weight 90 → 通过(B-260618-03 后 intelligence 不参与过滤),key=90 — weight 最高,胜
// e: enabled=false → 步骤 1 滤掉
//
// 预期:c 胜(weight 80 最高档中,Free 最便宜)
// 预期:d 胜(weight 90 最高,不再被 intelligence 滤掉)
let pool = vec![
ModelConfig {
weight: 60,
@@ -333,14 +341,14 @@ mod tests {
},
ModelConfig {
weight: 80,
cost_tier: CostTier::Free,
cost_tier: CostTier::Low,
intelligence: IntelligenceTier::Plus,
..model("c")
},
ModelConfig {
weight: 90,
cost_tier: CostTier::Low,
intelligence: IntelligenceTier::Lite, // 低于 min,滤掉
intelligence: IntelligenceTier::Lite,
..model("d")
},
ModelConfig {
@@ -352,10 +360,8 @@ mod tests {
let r = TaskRequirements {
modalities: vec![Modality::Text],
needs_tool_use: true,
min_intelligence: IntelligenceTier::Standard,
max_cost: None,
estimated_context: 0,
};
assert_eq!(ModelRouter::select(&r, &pool).unwrap().model_id, "c");
assert_eq!(ModelRouter::select(&r, &pool).unwrap().model_id, "d");
}
}

View File

@@ -52,7 +52,8 @@ pub struct ShellRequest {
/// 执行 Shell 命令
///
/// TODO: 完整实现,支持超时、环境变量、工作目录等
/// 支持超时(timeout_secs)、环境变量(env)、工作目录(working_dir),
/// kill_on_drop(true) 保证超时后子进程不残留,shell_type 可选 Cmd/PowerShell/Sh。
pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
let start = std::time::Instant::now();

View File

@@ -0,0 +1,161 @@
//! Shell 执行器基础集成测试。
//!
//! 覆盖 execute() 五类行为,跨平台(Windows=Cmd, 非 Windows=Sh):
//! 1. 成功:exit_code=Some(0) + stdout 非空 + duration_ms>0
//! 2. 非零退出:exit_code=Some(非0),返回 Ok(退出码在 ShellResult 内,非 Err)
//! 3. 超时:timeout_secs=1 + 长睡命令,返回 Err
//! 4. env 注入:env HashMap 传 KEY=VAL,命令回显验证
//! 5. working_dir:传临时目录,命令回显当前目录验证
//!
//! 注:execute 逻辑本身未改动,此文件为零行为变更的纯新增测试。
use df_execute::shell::{execute, ShellRequest, ShellType};
use std::collections::HashMap;
/// 平台默认 ShellType(对齐 shell.rs:31 Default impl:Windows→Cmd, 非 Windows→Sh)
fn default_shell() -> ShellType {
if cfg!(windows) {
ShellType::Cmd
} else {
ShellType::Sh
}
}
/// 构造最小 ShellRequest,默认无超时/无 env/无 working_dir
fn req(command: &str) -> ShellRequest {
ShellRequest {
command: command.to_string(),
working_dir: None,
env: HashMap::new(),
timeout_secs: None,
shell_type: Some(default_shell()),
}
}
// ---------- 1. 成功:exit_code=0 + stdout 非空 + duration_ms>0 ----------
#[tokio::test]
async fn execute_success_echo() {
// 平台无关:Cmd/Sh 都认 echo。Cmd 输出无尾换行,Sh 输出带 \n——只断言非空与包含。
let result = execute(req("echo hello_df_execute")).await;
assert!(result.is_ok(), "execute 应返回 Ok: {:?}", result.err());
let res = result.unwrap();
assert_eq!(res.exit_code, Some(0), "成功命令 exit_code 应为 0");
assert!(
res.stdout.trim().contains("hello_df_execute"),
"stdout 应包含回显文本,实际: {:?}",
res.stdout
);
assert!(res.duration_ms > 0, "duration_ms 应 > 0,实际: {}", res.duration_ms);
}
// ---------- 2. 非零退出:exit_code=Some(非0),返回 Ok ----------
#[tokio::test]
async fn execute_nonzero_exit() {
// 平台无关:false 是 POSIX 内建 + Windows 的 cmd 也内置(返回退出码 1)。
// 非 Windows 用 sh -c 'false';Windows 用 cmd /C 'cmd /C exit 1' 更稳——
// 这里统一用 false,Windows cmd 内置 false.exe 自 Win10 起可用,失败则 exit_code 非 0 即满足断言。
let result = execute(req("exit 1")).await;
assert!(result.is_ok(), "非零退出应仍返回 Ok(退出码在 ShellResult 内),实际: {:?}", result);
let res = result.unwrap();
assert_ne!(res.exit_code, Some(0), "exit 1 的 exit_code 应非 0,实际: {:?}", res.exit_code);
}
// ---------- 3. 超时:timeout_secs=1 + 长睡命令,返回 Err ----------
#[tokio::test]
async fn execute_timeout_returns_err() {
// 平台无关睡眠命令:Cmd 用 timeout(Windows 内置),Sh 用 sleep。各自分支。
let sleep_cmd = if cfg!(windows) {
// cmd 的 timeout /T 需按键中断,用 ping 假睡更稳(>1s);但 timeout 内置更简洁,
// 这里用 ping -n 5 (≈4s) 跨 Win 版本稳。
"ping -n 5 127.0.0.1 > nul".to_string()
} else {
"sleep 5".to_string()
};
let request = ShellRequest {
command: sleep_cmd,
working_dir: None,
env: HashMap::new(),
timeout_secs: Some(1),
shell_type: Some(default_shell()),
};
let result = execute(request).await;
assert!(
result.is_err(),
"超时应返回 Err,实际: {:?}",
result.as_ref().err()
);
// 错误信息应含「超时」(对齐 shell.rs:101 文案)
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("超时") || msg.to_lowercase().contains("timeout"),
"错误信息应含超时提示,实际: {}",
msg
);
}
// ---------- 4. env 注入:env HashMap 传 KEY=VAL,命令回显验证 ----------
#[tokio::test]
async fn execute_env_injection() {
let mut env = HashMap::new();
env.insert("DF_EXECUTE_TEST_KEY".to_string(), "env_value_42".to_string());
// 平台无关回显 env 变量:Cmd 用 %VAR%,Sh 用 $VAR
let echo_cmd = if cfg!(windows) {
"echo %DF_EXECUTE_TEST_KEY%"
} else {
"echo $DF_EXECUTE_TEST_KEY"
};
let request = ShellRequest {
command: echo_cmd.to_string(),
working_dir: None,
env,
timeout_secs: None,
shell_type: Some(default_shell()),
};
let res = execute(request).await.expect("env 注入命令应返回 Ok");
assert_eq!(res.exit_code, Some(0));
assert!(
res.stdout.trim().contains("env_value_42"),
"stdout 应含注入的 env 值,实际: {:?}",
res.stdout
);
}
// ---------- 5. working_dir:传临时目录,命令回显当前目录验证 ----------
#[tokio::test]
async fn execute_working_dir() {
// 用 std::env::temp_dir()(标准库,零新增依赖)。生成唯一子目录避免并行测试互相干扰。
let unique = format!("df_exec_test_{}", std::process::id());
let tmp = std::env::temp_dir().join(&unique);
std::fs::create_dir_all(&tmp).expect("创建临时目录失败");
// 测试后清理(即便失败也尽量不残留)
let tmp_for_cleanup = tmp.clone();
// 平台无关回显当前目录:Cmd 用 cd(无参数)不靠谱,用 echo %CD%;Sh 用 pwd。
let pwd_cmd = if cfg!(windows) { "echo %CD%" } else { "pwd" };
let request = ShellRequest {
command: pwd_cmd.to_string(),
working_dir: Some(tmp.to_string_lossy().to_string()),
env: HashMap::new(),
timeout_secs: None,
shell_type: Some(default_shell()),
};
let res = execute(request).await.expect("working_dir 命令应返回 Ok");
assert_eq!(res.exit_code, Some(0));
// 规范化比较(去尾空白/大小写在 Windows 不敏感但路径用户自定义,只断言包含)
let stdout_norm = res.stdout.trim().to_lowercase().replace('\\', "/");
let tmp_norm = tmp.to_string_lossy().to_lowercase().replace('\\', "/");
assert!(
stdout_norm.contains(&tmp_norm),
"stdout 应含 working_dir 路径,实际: {:?},期望包含: {:?}",
res.stdout,
tmp.to_string_lossy()
);
// 清理
let _ = std::fs::remove_dir_all(&tmp_for_cleanup);
}

View File

@@ -158,8 +158,6 @@ impl AdversarialEngine {
let eval_req = df_ai::router::TaskRequirements {
modalities: vec![df_ai_core::model::Modality::Text],
needs_tool_use: false,
min_intelligence: df_ai_core::model::IntelligenceTier::Standard,
max_cost: None,
estimated_context: 0,
};
let model = df_ai::router::select_model_id(&eval_req, &self.model_pool).unwrap_or_default();
@@ -577,7 +575,10 @@ fn extract_json(text: &str) -> String {
if let Some(body_inner) = body.strip_suffix("```") {
return body_inner.trim().to_string();
}
return body.trim().to_string();
// 围栏闭合缺失(如 JSON 与 ``` 同行 / 闭合后仍有文字 / 多行 JSON 末尾混噪声):
// 快路径无法干净剥离 → 落到正则兜底提取首个 JSON 对象,提升命中率。
// (原实现在此处 return body.trim() 会把语言标记/尾随文字一起喂 serde,
// 必然失败降级启发式,即便正则本可救回。)
}
// 兜底:围栏不在开头或混杂前后文字 → 正则提取首个 JSON 对象CR-40-2
// (?s) 让 . 匹配换行,贪婪 {*} 取首 { 到末 },覆盖嵌套对象。
@@ -775,6 +776,9 @@ mod tests {
model: "mock-model".to_string(),
usage: df_ai_core::provider::TokenUsage::default(),
tool_calls: None,
// 对抗评估路径不消费推理内容,留 None 即可(CompletionResponse 新增字段,
// 老构造点漏填 → 测试编译失败,回归补齐)。
reasoning_content: None,
})
}

View File

@@ -10,7 +10,7 @@ use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use df_ai::df_ai_core::model::{IntelligenceTier, Modality, ModelConfig};
use df_ai::df_ai_core::model::{Modality, ModelConfig};
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
// F-01 阶段5: AiNode 路由 — 节点 config.model_id 优先;否则按 TaskRequirements 路由
// (默认 Standard + needs_tool_use=true)。池空/无匹配兜底 record.default_model。
@@ -66,6 +66,27 @@ struct ResolvedProvider {
/// 无任何 provider → 友好错误「未配置 AI Provider」。
///
/// modelconfig["model"] 非空用之,否则 record.default_model再否则 "gpt-4o-mini" 占位。
/// SW-260618-09: provider 三件套 DRY —— resolve_provider + parse_params 合并
/// (AiNode/AiSelfReviewNode execute 逐字重复)。
async fn resolve_and_parse(
db: &Arc<Database>,
config: &serde_json::Value,
inputs: &HashMap<String, NodeOutput>,
) -> anyhow::Result<AiNodeParams> {
let provider_cfg = resolve_provider(db, config).await?;
parse_params(config, inputs, provider_cfg)
}
/// SW-260618-09: build_provider 5 行封装 DRY(AiNode/AiSelfReviewNode execute 逐字重复)。
fn provider_from_params(p: &AiNodeParams) -> Box<dyn LlmProvider> {
df_ai::build_provider(
&p.provider.protocol,
&p.provider.base_url,
&p.provider.api_key,
&p.provider.default_model,
)
}
async fn resolve_provider(
db: &Arc<Database>,
config: &serde_json::Value,
@@ -90,23 +111,16 @@ async fn resolve_provider(
}
// ── 路径 2老明文路径兼容过渡warn ──
let has_plain_base = config.get("base_url").and_then(|v| v.as_str()).is_some();
let has_plain_key = config.get("api_key").and_then(|v| v.as_str()).is_some();
if has_plain_base && has_plain_key {
// base_url / api_key 各解析一次(复用于 has 判定与取值,避免 DRY 重复解析同字段)。
let plain_base = config.get("base_url").and_then(|v| v.as_str());
let plain_key = config.get("api_key").and_then(|v| v.as_str());
if let (Some(base_url_str), Some(api_key_str)) = (plain_base, plain_key) {
tracing::warn!(
"AiNode 明文 api_key/base_url 经 config 注入已废弃, 改用 provider_id (FR-S1). \
老路径将在后续版本移除"
);
let base_url = config
.get("base_url")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let api_key = config
.get("api_key")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let base_url = base_url_str.to_string();
let api_key = api_key_str.to_string();
ensure_resolved_key("(明文注入)", &api_key)
.map_err(anyhow::Error::msg)?;
let protocol = config
@@ -201,14 +215,12 @@ fn parse_params(
let model = if !config_model.is_empty() {
config_model
} else {
// F-01 阶段5: AiNode 默认路由 — Standard + needs_tool_use=true(工作流无人值守 AI 步骤
// F-01 阶段5: AiNode 默认路由 — needs_tool_use=true(工作流无人值守 AI 步骤
// 常含工具调用,如检索/生成;无需工具的节点应在 config 显式指定 model)。
// select_model_id None(池空/无匹配)→ 空串(由 provider impl 回填 default_model)。
let node_req = TaskRequirements {
modalities: vec![Modality::Text],
needs_tool_use: true,
min_intelligence: IntelligenceTier::Standard,
max_cost: None,
estimated_context: 0,
};
select_model_id(&node_req, &provider.model_pool).unwrap_or_default()
@@ -258,15 +270,8 @@ impl Node for AiNode {
tracing::info!("AiNode 执行: node_id={}", ctx.node_id);
// FR-S1 注入链provider 经 df_storage::secret 在 AiNode 内存解析api_key 不进 config。
let provider_cfg = resolve_provider(&self.db, &ctx.config).await?;
let p = parse_params(&ctx.config, &ctx.inputs, provider_cfg)?;
let provider: Box<dyn LlmProvider> = df_ai::build_provider(
&p.provider.protocol,
&p.provider.base_url,
&p.provider.api_key,
&p.provider.default_model,
);
let p = resolve_and_parse(&self.db, &ctx.config, &ctx.inputs).await?;
let provider: Box<dyn LlmProvider> = provider_from_params(&p);
// ── 构建消息 ──
let mut messages = Vec::with_capacity(2);
@@ -343,7 +348,8 @@ impl Node for AiNode {
"base_url": { "type": "string", "description": "(已废弃过渡)明文 API 地址,改用 provider_id" },
"api_key": { "type": "string", "description": "(已废弃过渡)明文 API 密钥,改用 provider_idFR-S1 下经 secret 解析" }
},
"required": ["provider_id"]
// SW-260618-15: prompt/provider_id 均"留空走兜底"(prompt 取上游、provider_id 走默认 provider),与 required 矛盾。改 required=[] 对齐 execute 运行时,防前端按 schema 误拒合法配置。
"required": []
}),
output: serde_json::json!({
"type": "object",
@@ -468,8 +474,7 @@ impl Node for AiSelfReviewNode {
tracing::info!("AiSelfReviewNode 执行: node_id={}", ctx.node_id);
// FR-S1 注入链provider 经 df_storage::secret 在 AiNode 内存解析api_key 不进 config。
let provider_cfg = resolve_provider(&self.db, &ctx.config).await?;
let p = parse_params(&ctx.config, &ctx.inputs, provider_cfg)?;
let p = resolve_and_parse(&self.db, &ctx.config, &ctx.inputs).await?;
// ── 读任务(需求 + 产出) ──
// task_id 必填(自审对象是具体任务的产出);缺失报错而非静默跳过。
@@ -496,12 +501,7 @@ impl Node for AiSelfReviewNode {
let prompt = Self::build_review_prompt(&task.description, &output_text);
let provider: Box<dyn LlmProvider> = df_ai::build_provider(
&p.provider.protocol,
&p.provider.base_url,
&p.provider.api_key,
&p.provider.default_model,
);
let provider: Box<dyn LlmProvider> = provider_from_params(&p);
let request = CompletionRequest {
model: p.model.clone(),

View File

@@ -72,13 +72,4 @@ impl ProjectManager {
})
}
/// 更新项目状态
///
/// TODO: 状态转换校验
pub fn transition_status(project: &mut Project, new_status: ProjectStatus) -> anyhow::Result<()> {
// TODO: 校验状态转换是否合法
project.status = new_status;
project.updated_at = chrono::Utc::now();
Ok(())
}
}

View File

@@ -118,12 +118,14 @@ pub fn is_monorepo(root: &Path) -> bool {
return true;
}
}
// package.json workspaces 字段(npm/yarn)
// package.json workspaces 字段(npm/yarn)。数组(`["packages/*"]`)或对象
// (`{"packages":[...]}`,Yarn)均为真正的工作区;JSON 显式 null 表示「无」,
// 不应误判为 monorepo(`.is_some()` 对 key 存在但值为 null 仍返回 true → 误报)。
let pkg_path = root.join("package.json");
if pkg_path.is_file() {
if let Ok(content) = std::fs::read_to_string(&pkg_path) {
if let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&content) {
if pkg.get("workspaces").is_some() {
if pkg.get("workspaces").is_some_and(|v| !v.is_null()) {
return true;
}
}
@@ -540,9 +542,13 @@ fn is_pure_badge_line(line: &str) -> bool {
let mut rest = line;
loop {
// 找下一个 ![ 或 <img 或 <a
// 注:HTML 标签为纯 ASCII,用 to_ascii_lowercase 做「不区分大小写」匹配即可;
// 若用 to_lowercase(),非 ASCII 字符(如 İ→i̇、ß→ss)在小写化时改变字节长度,
// 返回的索引会偏离原 rest 的字节边界,后续 &rest[..pos]/&rest[pos..] 切到错位/panic。
// to_ascii_lowercase 仅改 ASCII 字母、保持非 ASCII 字节不变 → 字节长度不变 → 索引对齐。
let img_md = rest.find("![");
let img_html = rest.to_lowercase().find("<img");
let anchor_html = rest.to_lowercase().find("<a ");
let img_html = rest.to_ascii_lowercase().find("<img");
let anchor_html = rest.to_ascii_lowercase().find("<a ");
let earliest = [img_md, img_html, anchor_html]
.into_iter()
.flatten()
@@ -896,4 +902,114 @@ mod tests {
assert!(desc.contains("已截断"));
fs::remove_dir_all(&d).ok();
}
// ── normalize_path 跨平台归一化 ──
// 锁定:① 反斜杠→正斜杠 ② 小写 ③ 尾部分隔符裁剪 ④ 相同逻辑路径相等(防重复录入绕过)。
// 注:canonicalize 成功路径在不同 OS 返回不同形态(Windows 带前缀),故只断言跨平台不变性,
// 不断言精确串,避免与平台耦合。
#[test]
fn normalize_path_uses_forward_slash_and_lowercase() {
// 不存在的路径走降级分支(trim + replace + lowercase)
let n = normalize_path(r"C:\Foo\Bar\");
assert!(
!n.contains('\\'),
"反斜杠未归一: {n}"
);
assert_eq!(n, n.to_lowercase(), "未小写: {n}");
assert!(
!n.ends_with('/') && !n.ends_with('\\'),
"尾部分隔符未裁剪: {n}"
);
}
#[test]
fn normalize_path_equivalent_inputs_equal() {
// 两种写法应归一为同一串(去重场景)
let a = normalize_path(r"C:\Foo\Bar");
let b = normalize_path("C:/Foo/Bar/");
assert_eq!(a, b, "等价路径归一不等: {a} vs {b}");
}
#[test]
fn normalize_path_relative_is_lowercase_and_forward_slash() {
let n = normalize_path(r"..\Some\Path");
assert!(!n.contains('\\'), "反斜杠未归一: {n}");
assert_eq!(n, n.to_lowercase(), "未小写: {n}");
}
// ── is_monorepo workspaces:null 防回归(wd2fnjh3s) ──
// .is_some_and(!is_null) 修复点:key 存在但值为 JSON null 时不得判为 monorepo。
#[test]
fn is_monorepo_workspaces_null_not_misclassified() {
let d = scratch("ws-null");
fs::write(
d.join("package.json"),
r#"{"name":"x","workspaces":null}"#,
)
.unwrap();
assert!(
!is_monorepo(&d),
"workspaces: null 不应判为 monorepo(回归 wd2fnjh3s)"
);
fs::remove_dir_all(&d).ok();
}
#[test]
fn is_monorepo_workspaces_object_treated_as_real() {
// Yarn 形式 {"packages":[...]} —— 非 null,应判为 monorepo
let d = scratch("ws-obj");
fs::write(
d.join("package.json"),
r#"{"name":"y","workspaces":{"packages":["packages/*"]}}"#,
)
.unwrap();
assert!(is_monorepo(&d), "Yarn workspaces 对象形式应判为 monorepo");
fs::remove_dir_all(&d).ok();
}
#[test]
fn is_monorepo_non_dir_returns_false() {
assert!(!is_monorepo(Path::new("definitely-not-exist-xyz-456")));
}
// ── collect_images 徽章过滤逻辑补强 ──
#[test]
fn collect_images_filters_badge_by_keyword() {
// 非徽章域名,但 alt/src 含关键词(build/license/coverage)应过滤
let content = "![build status](https://example.com/build.svg)\n![my license](https://example.com/lic.svg)\n![coverage](https://example.com/cov.svg)\n![architecture](./arch.png)\n";
let imgs = collect_images(content);
assert!(imgs.iter().all(|i| !i.alt.contains("build")), "build 关键词徽章未过滤");
assert!(imgs.iter().all(|i| !i.alt.contains("license")), "license 关键词徽章未过滤");
assert!(imgs.iter().all(|i| !i.alt.contains("coverage")), "coverage 关键词徽章未过滤");
assert_eq!(imgs.len(), 1, "应仅保留架构图: {imgs:?}");
assert_eq!(imgs[0].src, "./arch.png");
}
#[test]
fn collect_images_dedups_same_src() {
// 同 src 不同 alt 去重,只留首个
let content = "![first](./dup.png)\n![second](./dup.png)\n";
let imgs = collect_images(content);
assert_eq!(imgs.len(), 1, "同 src 应去重: {imgs:?}");
assert_eq!(imgs[0].alt, "first");
}
#[test]
fn collect_images_handles_multiple_in_one_line() {
// 同行多图 + 徽章混排:徽章过滤、内容图保留
let content = "![build](https://img.shields.io/x) ![shot](./shot.png) ![alt2](./two.png)\n";
let imgs = collect_images(content);
let srcs: Vec<_> = imgs.iter().map(|i| i.src.as_str()).collect();
assert!(srcs.contains(&"./shot.png"), "同行内容图丢失: {srcs:?}");
assert!(srcs.contains(&"./two.png"), "同行内容图丢失: {srcs:?}");
assert!(!srcs.contains(&"https://img.shields.io/x"), "同行徽章未过滤: {srcs:?}");
}
#[test]
fn collect_images_empty_and_malformed() {
// 无图片 / 不闭合语法不 panic,返回空
assert!(collect_images("plain text no images").is_empty());
assert!(collect_images("![unclosed alt").is_empty());
assert!(collect_images("![alt](no-close-paren").is_empty());
}
}

View File

@@ -1070,7 +1070,11 @@ fn ai_provider_from_row(row: &Row<'_>) -> std::result::Result<AiProviderRecord,
// F-260614-04: enabled/weight 列老库经 v19 迁移补建,DEFAULT 1 / DEFAULT 50。
// from_row 按 i32 取列值兼容(SQLite 无真 BOOLEAN),0→false/非0→true。
enabled: row.get::<_, i32>("enabled").unwrap_or(1) != 0,
weight: row.get::<_, i32>("weight").unwrap_or(50).max(0) as u32,
// weight 读侧 clamp [0,100]:与 insert/update_full 落库的 `.min(100)` 对齐,
// 防老库(clamp 落地前写入的)或外部直改 DB 产生的越界值污染路由权重语义。
weight: row.get::<_, i32>("weight")
.unwrap_or(50)
.clamp(0, 100) as u32,
})
}
@@ -1657,6 +1661,40 @@ impl KnowledgeEventsRepo {
.await
.map_err(storage_err)?
}
/// 跨知识列最近 N 条事件(全表 timestamp DESC,top-N)。
///
/// **专用兜底方法**:本表时间列名是 `timestamp` 而非 `created_at`,但
/// `impl_repo!` 宏生成的 `query()` / `list_all()` 硬编码 `ORDER BY created_at`
/// (见宏内 `ORDER BY created_at DESC` 字面量),误调 `state.knowledge_events.query(...)`
/// 会触发 SQLite "no such column: created_at"。本方法走专用 SELECT 绕过宏硬编码,
/// 供需要跨知识按时间倒序浏览事件的调用方使用(对标 AiToolExecutionRepo::list_recent
/// 对 ai_tool_executions 表的同款兜底处理——那张表同样无 created_at 列)。
/// limit 上限钳制 200,防前端恶意/失误传超大值。
pub async fn list_recent(&self, limit: u32) -> Result<Vec<KnowledgeEventRecord>> {
let conn = self.conn.clone();
// 钳制 limit 防滥用(最大 200)
let safe_limit = limit.min(200) as i64;
tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
let mut stmt = guard
.prepare(
"SELECT id,knowledge_id,event_type,source_ref,context_json,timestamp \
FROM knowledge_events ORDER BY timestamp DESC LIMIT ?1",
)
.map_err(storage_err)?;
let rows = stmt
.query_map(params![safe_limit], |row| knowledge_event_from_row(row))
.map_err(storage_err)?;
let mut results = Vec::new();
for r in rows {
results.push(r.map_err(storage_err)?);
}
Ok(results)
})
.await
.map_err(storage_err)?
}
}
// AiConversationRepo 的整体更新已由 impl_repo! 宏统一生成的 update_full 提供。

View File

@@ -595,3 +595,65 @@ CREATE TABLE IF NOT EXISTS app_settings (
updated_at TEXT NOT NULL
);
";
#[cfg(test)]
mod tests {
use super::*;
// column_exists 是幂等迁移的通用探测辅助(V4+ 全部迁移靠它判断列存在性),
// 本组用 in-memory SQLite(:memory:) 建临时表覆盖四种语义:
// 列存在→true / 列不存在→false / 表不存在→false / 多列中正确匹配目标列名。
/// 列存在返回 true:建表后探测真实列名。
#[test]
fn column_exists_returns_true_for_existing_column() {
let conn = Connection::open_in_memory().expect("open in-memory db");
conn.execute_batch(
"CREATE TABLE probe_t (id TEXT PRIMARY KEY, name TEXT NOT NULL, score INTEGER);",
)
.expect("create probe table");
assert!(column_exists(&conn, "probe_t", "id"));
assert!(column_exists(&conn, "probe_t", "name"));
assert!(column_exists(&conn, "probe_t", "score"));
}
/// 列不存在返回 false:表存在但目标列名未建。
#[test]
fn column_exists_returns_false_for_missing_column() {
let conn = Connection::open_in_memory().expect("open in-memory db");
conn.execute_batch("CREATE TABLE probe_t (id TEXT PRIMARY KEY, name TEXT);")
.expect("create probe table");
assert!(!column_exists(&conn, "probe_t", "archived"));
assert!(!column_exists(&conn, "probe_t", "deleted_at"));
}
/// 表不存在返回 false:PRAGMA table_info 对未知表返回空结果集,函数应安全降级为 false。
#[test]
fn column_exists_returns_false_when_table_absent() {
let conn = Connection::open_in_memory().expect("open in-memory db");
// 不建任何表,直接探测
assert!(!column_exists(&conn, "ghost_table", "any_col"));
}
/// 多列表中正确匹配目标列名(不误命中前缀/子串):验证精确比较非 contains。
#[test]
fn column_exists_matches_exact_name_among_many() {
let conn = Connection::open_in_memory().expect("open in-memory db");
conn.execute_batch(
"CREATE TABLE probe_t (
id TEXT PRIMARY KEY,
prompt_tokens INTEGER,
completion_tokens INTEGER,
model TEXT
);",
)
.expect("create probe table");
// 命中真实列
assert!(column_exists(&conn, "probe_t", "prompt_tokens"));
assert!(column_exists(&conn, "probe_t", "completion_tokens"));
// 不误命中子串(prefix/suffix 近似名)
assert!(!column_exists(&conn, "probe_t", "tokens"));
assert!(!column_exists(&conn, "probe_t", "prompt"));
assert!(!column_exists(&conn, "probe_t", "model_configs"));
}
}

View File

@@ -101,6 +101,8 @@ impl Dag {
for edge in &self.edges {
// 仅收录两端均为已注册节点的边,跳过野节点
if node_ids.contains(&edge.source) && node_ids.contains(&edge.target) {
// safe: edge.target 已在 node_ids 中(上一行 contains 守卫),而 in_degree
// 对每个 node_id 都插入了表项line 97-99 初始化),故 get_mut 必命中
*in_degree.get_mut(&edge.target).unwrap() += 1;
adjacency_out
.entry(edge.source.clone())
@@ -127,6 +129,9 @@ impl Dag {
// 出边已索引O(出度) 遍历而非 O(E) 全表扫描
if let Some(succs) = adjacency_out.get(&id) {
for succ in succs {
// safe: succ 来自 adjacency_out其元素均为通过 line 103 contains 守卫
// 的 edge.targetline 108 push而 in_degree 覆盖所有 node_idline 97-99
// 故 succ 必在 in_degree 表内get_mut 必命中
let deg = in_degree.get_mut(succ).unwrap();
*deg -= 1;
if *deg == 0 {

View File

@@ -135,13 +135,25 @@ impl DagExecutor {
// 与 Err 分支 is_cancelled 短路对称:已取消节点保持 Cancelled 终态
if !self.state_machine.is_cancelled(&node_id) {
self.state_machine.set_completed(node_id.clone())?;
// 未取消才 emit NodeCompleted,取消时 emit NodeCancelled(对齐 Err 分支语义):
// 避免 状态机=Cancelled 但事件=NodeCompleted 的矛盾信号
self.event_bus
.send(WorkflowEvent::NodeCompleted {
node_id: node_id.clone(),
duration_ms: duration,
})
.await;
} else {
// 对齐 Err 分支:取消节点 emit NodeCancelled(非 NodeCompleted),
// 前端按 type 归「取消」,与状态机 Cancelled 一致
self.event_bus
.send(WorkflowEvent::NodeCancelled {
node_id: node_id.clone(),
})
.await;
}
self.event_bus
.send(WorkflowEvent::NodeCompleted {
node_id: node_id.clone(),
duration_ms: duration,
})
.await;
// outputs.insert 保留现状不动:取消时仍写入 output 供下游消费,
// 当前测试锁定"取消不中止后续层",改 insert 会变行为致回归
outputs.insert(node_id, output);
}
Err(e) => {

View File

@@ -30,8 +30,6 @@ pub struct NodeContext {
pub struct NodeOutput {
/// 输出数据
pub data: serde_json::Value,
/// 输出元数据
pub metadata: std::collections::HashMap<String, String>,
}
impl NodeOutput {
@@ -39,16 +37,12 @@ impl NodeOutput {
pub fn empty() -> Self {
Self {
data: serde_json::Value::Null,
metadata: std::collections::HashMap::new(),
}
}
/// 从 JSON 值创建输出
pub fn from_value(data: serde_json::Value) -> Self {
Self {
data,
metadata: std::collections::HashMap::new(),
}
Self { data }
}
}