新增: Phase2 阶段收尾(Sprint 1-20)
重构:删 5 零引用 crate(df-evolve/plugin/stages/task/traceability)+ 清死模块、ai.rs 拆 11 子 module、ai.ts 拆 6 composable、i18n 拆目录 功能:知识库全栈(df-project/scan + CRUD + 时间线 + 前端)、Settings 拆分、appSettings KV 迁移、模型池、LLM 并发 Semaphore 修复:审批持久化根治、ConditionEngine 默认拒绝、NodeRegistry unimplemented 清除、promote 补偿删除、工具结果截断 50KB、路径校验防 symlink 逃逸 文档:B-03 人工审批设计、决策记录三分档、规格契约自检、经验记录、todo 看板、PROGRESS 更新 详见 PROGRESS.md。src-tauri/儿童每日打卡应用/ 与本项目无关,已排除。
This commit is contained in:
@@ -96,6 +96,19 @@ impl AiToolRegistry {
|
||||
self.tools.get(name)
|
||||
}
|
||||
|
||||
/// 执行指定工具 — handler 是唯一执行路径(schema+risk+实现同源,消除双轨)
|
||||
pub async fn execute(
|
||||
&self,
|
||||
name: &str,
|
||||
args: serde_json::Value,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let tool = self
|
||||
.tools
|
||||
.get(name)
|
||||
.ok_or_else(|| anyhow::anyhow!("未知工具: {}", name))?;
|
||||
(tool.handler)(args).await
|
||||
}
|
||||
|
||||
/// 获取所有已注册工具名称
|
||||
pub fn tool_names(&self) -> Vec<String> {
|
||||
self.tools.keys().cloned().collect()
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
use async_trait::async_trait;
|
||||
use eventsource_stream::Eventsource;
|
||||
use futures::{Stream, StreamExt};
|
||||
use futures::StreamExt;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, error, warn};
|
||||
@@ -79,6 +79,126 @@ struct AnthropicUsage {
|
||||
output_tokens: u32,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SSE 解析纯函数(与 HTTP 解耦,便于单测)
|
||||
// ============================================================
|
||||
|
||||
/// 将一条 Anthropic Messages SSE 事件 data 解析为 StreamChunk,并按需更新 usage 累加器。
|
||||
///
|
||||
/// 按 `type` 字段分发:
|
||||
/// - `message_start` → 用 `message.usage.input_tokens` 初始化累加器(output 置 0)。
|
||||
/// - `message_delta` → **output_tokens 是累计值(非增量)**,直接覆盖 `completion_tokens` 并重算 `total`。
|
||||
/// - `content_block_delta` (text_delta/input_json_delta) → 文本/工具入参增量。
|
||||
/// - `content_block_start` (tool_use) → 工具块开始,带 id+name。
|
||||
/// - `message_stop` → 返回 `finished=true` 终态 chunk,`usage` 取自累加器(`take()`)。
|
||||
/// - `error` → 返回 `finished=true` 终态空 chunk。
|
||||
/// - 其它(content_block_stop / ping 等)→ 空 chunk。
|
||||
///
|
||||
/// 等价性:content_block / message_stop / error 等事件分支与原 stream() 闭包逐字一致;
|
||||
/// usage 透传(message_stop 终态 take() 带出、message_delta 的 output_tokens 按累计值覆盖)
|
||||
/// 为本次新增能力,对应 StreamChunk 新增的 usage 字段。
|
||||
pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option<TokenUsage>) -> StreamChunk {
|
||||
// 解析 data 中的 JSON,按 type 字段决定如何转 StreamChunk
|
||||
let v: serde_json::Value = match serde_json::from_str(data) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
return StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None }
|
||||
}
|
||||
};
|
||||
let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
match ty {
|
||||
// 消息开始:取 input_tokens 初始化累积器(output 此时未知,置 0)
|
||||
"message_start" => {
|
||||
if let Some(inp) = v
|
||||
.get("message")
|
||||
.and_then(|m| m.get("usage"))
|
||||
.and_then(|u| u.get("input_tokens"))
|
||||
.and_then(|t| t.as_u64())
|
||||
{
|
||||
*usage_accum = Some(TokenUsage {
|
||||
prompt_tokens: inp as u32,
|
||||
completion_tokens: 0,
|
||||
total_tokens: inp as u32,
|
||||
});
|
||||
}
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None }
|
||||
}
|
||||
// 消息增量:output_tokens 是累计值(非增量),直接覆盖 completion + 重算 total
|
||||
"message_delta" => {
|
||||
if let Some(out) = v.get("usage").and_then(|u| u.get("output_tokens")).and_then(|t| t.as_u64()) {
|
||||
let acc = usage_accum
|
||||
.get_or_insert(TokenUsage { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 });
|
||||
acc.completion_tokens = out as u32;
|
||||
acc.total_tokens = acc.prompt_tokens + acc.completion_tokens;
|
||||
}
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None }
|
||||
}
|
||||
// 文本增量
|
||||
"content_block_delta" => {
|
||||
if let Some(delta) = v.get("delta") {
|
||||
if delta.get("type").and_then(|t| t.as_str()) == Some("text_delta") {
|
||||
let text = delta.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string();
|
||||
return StreamChunk { delta: text, finished: false, tool_calls: None, usage: None };
|
||||
}
|
||||
// 工具入参增量
|
||||
if delta.get("type").and_then(|t| t.as_str()) == Some("input_json_delta") {
|
||||
let partial = delta.get("partial_json").and_then(|t| t.as_str()).unwrap_or("").to_string();
|
||||
let idx = v.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as u32;
|
||||
return StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: false,
|
||||
tool_calls: Some(vec![ToolCallDelta {
|
||||
index: idx,
|
||||
id: None,
|
||||
function_name: None,
|
||||
function_arguments: Some(partial),
|
||||
}]),
|
||||
usage: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None }
|
||||
}
|
||||
// 工具块开始:带 id + name
|
||||
"content_block_start" => {
|
||||
if let Some(cb) = v.get("content_block") {
|
||||
if cb.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
|
||||
let idx = v.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as u32;
|
||||
let id = cb.get("id").and_then(|t| t.as_str()).map(|s| s.to_string());
|
||||
let name = cb.get("name").and_then(|t| t.as_str()).map(|s| s.to_string());
|
||||
return StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: false,
|
||||
tool_calls: Some(vec![ToolCallDelta {
|
||||
index: idx,
|
||||
id,
|
||||
function_name: name,
|
||||
function_arguments: None,
|
||||
}]),
|
||||
usage: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None }
|
||||
}
|
||||
// 消息结束:带出累积 usage
|
||||
"message_stop" => StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: true,
|
||||
tool_calls: None,
|
||||
usage: usage_accum.take(),
|
||||
},
|
||||
// 错误事件
|
||||
"error" => {
|
||||
let msg = v.get("error").and_then(|e| e.get("message")).and_then(|m| m.as_str()).unwrap_or("stream error");
|
||||
error!(%msg, "Anthropic 流式错误事件");
|
||||
StreamChunk { delta: String::new(), finished: true, tool_calls: None, usage: None }
|
||||
}
|
||||
// content_block_stop / ping 等不产出 chunk
|
||||
_ => StreamChunk { delta: String::new(), finished: false, tool_calls: None, usage: None },
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Provider 实现
|
||||
// ============================================================
|
||||
@@ -332,77 +452,15 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
anyhow::bail!("Anthropic 流式 API 错误 {}: {}", status, text);
|
||||
}
|
||||
|
||||
// 流式解析:eventsource 逐事件处理,按 type 字段分发转 StreamChunk
|
||||
// 流式解析:eventsource 逐事件处理,按 type 字段分发转 StreamChunk。
|
||||
// 事件解析/usage 累积逻辑抽到 apply_anthropic_event 纯函数,便于单测;此处闭包只负责传 data。
|
||||
// usage 累积:message_start 给 input_tokens,message_delta 给累计 output_tokens(非增量),message_stop 带出。
|
||||
let mut usage_accum: Option<TokenUsage> = None;
|
||||
let stream = resp
|
||||
.bytes_stream()
|
||||
.eventsource()
|
||||
.map(move |event| match event {
|
||||
Ok(ev) => {
|
||||
// 解析 data 中的 JSON,按 type 字段决定如何转 StreamChunk
|
||||
let v: serde_json::Value = match serde_json::from_str(&ev.data) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return Ok(StreamChunk { delta: String::new(), finished: false, tool_calls: None }),
|
||||
};
|
||||
let ty = v.get("type").and_then(|t| t.as_str()).unwrap_or("");
|
||||
match ty {
|
||||
// 文本增量
|
||||
"content_block_delta" => {
|
||||
if let Some(delta) = v.get("delta") {
|
||||
if delta.get("type").and_then(|t| t.as_str()) == Some("text_delta") {
|
||||
let text = delta.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string();
|
||||
return Ok(StreamChunk { delta: text, finished: false, tool_calls: None });
|
||||
}
|
||||
// 工具入参增量
|
||||
if delta.get("type").and_then(|t| t.as_str()) == Some("input_json_delta") {
|
||||
let partial = delta.get("partial_json").and_then(|t| t.as_str()).unwrap_or("").to_string();
|
||||
let idx = v.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as u32;
|
||||
return Ok(StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: false,
|
||||
tool_calls: Some(vec![ToolCallDelta {
|
||||
index: idx,
|
||||
id: None,
|
||||
function_name: None,
|
||||
function_arguments: Some(partial),
|
||||
}]),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(StreamChunk { delta: String::new(), finished: false, tool_calls: None })
|
||||
}
|
||||
// 工具块开始:带 id + name
|
||||
"content_block_start" => {
|
||||
if let Some(cb) = v.get("content_block") {
|
||||
if cb.get("type").and_then(|t| t.as_str()) == Some("tool_use") {
|
||||
let idx = v.get("index").and_then(|i| i.as_u64()).unwrap_or(0) as u32;
|
||||
let id = cb.get("id").and_then(|t| t.as_str()).map(|s| s.to_string());
|
||||
let name = cb.get("name").and_then(|t| t.as_str()).map(|s| s.to_string());
|
||||
return Ok(StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: false,
|
||||
tool_calls: Some(vec![ToolCallDelta {
|
||||
index: idx,
|
||||
id,
|
||||
function_name: name,
|
||||
function_arguments: None,
|
||||
}]),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(StreamChunk { delta: String::new(), finished: false, tool_calls: None })
|
||||
}
|
||||
// 消息结束
|
||||
"message_stop" => Ok(StreamChunk { delta: String::new(), finished: true, tool_calls: None }),
|
||||
// 错误事件
|
||||
"error" => {
|
||||
let msg = v.get("error").and_then(|e| e.get("message")).and_then(|m| m.as_str()).unwrap_or("stream error");
|
||||
error!(%msg, "Anthropic 流式错误事件");
|
||||
Ok(StreamChunk { delta: String::new(), finished: true, tool_calls: None })
|
||||
}
|
||||
// message_start / content_block_stop / message_delta 等不产出 chunk
|
||||
_ => Ok(StreamChunk { delta: String::new(), finished: false, tool_calls: None }),
|
||||
}
|
||||
}
|
||||
Ok(ev) => Ok(apply_anthropic_event(&ev.data, &mut usage_accum)),
|
||||
Err(e) => {
|
||||
error!(error = %e, "Anthropic SSE 事件流错误");
|
||||
Err(anyhow::anyhow!("Anthropic SSE 错误: {}", e))
|
||||
@@ -424,3 +482,184 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单测(不发真实 HTTP,喂构造的 SSE data 字符串序列)
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 辅助:构造 message_start 事件
|
||||
fn message_start(input_tokens: u32) -> String {
|
||||
format!(
|
||||
r#"{{"type":"message_start","message":{{"usage":{{"input_tokens":{},"output_tokens":0}}}}}}"#,
|
||||
input_tokens
|
||||
)
|
||||
}
|
||||
|
||||
/// 辅助:构造 message_delta 事件(output_tokens 为累计值)
|
||||
fn message_delta(output_tokens: u32) -> String {
|
||||
format!(
|
||||
r#"{{"type":"message_delta","delta":{{"stop_reason":"end_turn"}},"usage":{{"output_tokens":{}}}}}"#,
|
||||
output_tokens
|
||||
)
|
||||
}
|
||||
|
||||
/// 辅助:构造文本增量 content_block_delta
|
||||
fn text_delta(text: &str) -> String {
|
||||
format!(
|
||||
r#"{{"type":"content_block_delta","index":0,"delta":{{"type":"text_delta","text":"{}"}}}}"#,
|
||||
text
|
||||
)
|
||||
}
|
||||
|
||||
/// 辅助:构造 message_stop 事件
|
||||
fn message_stop() -> &'static str {
|
||||
r#"{"type":"message_stop"}"#
|
||||
}
|
||||
|
||||
/// 完整流:message_start 初始化 input + 多次 message_delta 累计覆盖 output + message_stop 带出
|
||||
#[test]
|
||||
fn anthropic_full_stream_accumulates_usage() {
|
||||
let mut acc: Option<TokenUsage> = None;
|
||||
|
||||
// 1) message_start:input=42,output=0
|
||||
let c = apply_anthropic_event(&message_start(42), &mut acc);
|
||||
assert!(!c.finished);
|
||||
assert!(c.usage.is_none());
|
||||
let a = acc.as_ref().expect("message_start 应初始化累加器");
|
||||
assert_eq!(a.prompt_tokens, 42);
|
||||
assert_eq!(a.completion_tokens, 0);
|
||||
assert_eq!(a.total_tokens, 42);
|
||||
|
||||
// 2) 文本增量不影响 usage
|
||||
let c = apply_anthropic_event(&text_delta("Hello"), &mut acc);
|
||||
assert_eq!(c.delta, "Hello");
|
||||
assert!(!c.finished);
|
||||
assert_eq!(acc.as_ref().unwrap().completion_tokens, 0, "文本增量不应改 output");
|
||||
|
||||
// 3) message_delta:output_tokens=10(累计值,覆盖)
|
||||
let c = apply_anthropic_event(&message_delta(10), &mut acc);
|
||||
assert!(!c.finished);
|
||||
let a = acc.as_ref().unwrap();
|
||||
assert_eq!(a.prompt_tokens, 42, "input 保持");
|
||||
assert_eq!(a.completion_tokens, 10, "output 被覆盖为累计值");
|
||||
assert_eq!(a.total_tokens, 52, "total 重算 = input+output");
|
||||
|
||||
// 4) 再次 message_delta:output_tokens=30(更大累计值,再覆盖)
|
||||
let _ = apply_anthropic_event(&message_delta(30), &mut acc);
|
||||
let a = acc.as_ref().unwrap();
|
||||
assert_eq!(a.completion_tokens, 30, "后续累计值覆盖前值");
|
||||
assert_eq!(a.total_tokens, 72);
|
||||
|
||||
// 5) message_stop:带出累积 usage,finished=true,累加器清空
|
||||
let c = apply_anthropic_event(message_stop(), &mut acc);
|
||||
assert!(c.finished);
|
||||
let u = c.usage.expect("message_stop 应带出累积 usage");
|
||||
assert_eq!(u.prompt_tokens, 42);
|
||||
assert_eq!(u.completion_tokens, 30);
|
||||
assert_eq!(u.total_tokens, 72);
|
||||
assert!(acc.is_none(), "take() 后累加器应清空");
|
||||
}
|
||||
|
||||
/// message_delta 在没有 message_start 时也能补全累加器(get_or_insert 兜底)
|
||||
#[test]
|
||||
fn anthropic_message_delta_without_start_uses_default_input() {
|
||||
let mut acc: Option<TokenUsage> = None;
|
||||
let _ = apply_anthropic_event(&message_delta(15), &mut acc);
|
||||
let a = acc.as_ref().unwrap();
|
||||
assert_eq!(a.prompt_tokens, 0, "无 message_start 时 input 兜底为 0");
|
||||
assert_eq!(a.completion_tokens, 15);
|
||||
assert_eq!(a.total_tokens, 15);
|
||||
}
|
||||
|
||||
/// message_delta 的 output_tokens 必须是累计覆盖(非累加):连续两个 delta 5 和 8,结果应是 8 不是 13
|
||||
#[test]
|
||||
fn anthropic_message_delta_output_is_cumulative_not_incremental() {
|
||||
let mut acc: Option<TokenUsage> = None;
|
||||
apply_anthropic_event(&message_start(100), &mut acc);
|
||||
apply_anthropic_event(&message_delta(5), &mut acc);
|
||||
apply_anthropic_event(&message_delta(8), &mut acc);
|
||||
let c = apply_anthropic_event(message_stop(), &mut acc);
|
||||
let u = c.usage.unwrap();
|
||||
assert_eq!(u.completion_tokens, 8, "output_tokens 是累计值,覆盖而非累加");
|
||||
assert_eq!(u.total_tokens, 108);
|
||||
}
|
||||
|
||||
/// 无 usage 字段的流:message_stop 时 usage 为 None
|
||||
#[test]
|
||||
fn anthropic_message_stop_without_any_usage() {
|
||||
let mut acc: Option<TokenUsage> = None;
|
||||
let _ = apply_anthropic_event(&text_delta("hi"), &mut acc);
|
||||
assert!(acc.is_none(), "文本增量不初始化累加器");
|
||||
let c = apply_anthropic_event(message_stop(), &mut acc);
|
||||
assert!(c.finished);
|
||||
assert!(c.usage.is_none(), "无 usage 时 message_stop usage 为 None");
|
||||
}
|
||||
|
||||
/// content_block_start (tool_use) 带 id+name
|
||||
#[test]
|
||||
fn anthropic_content_block_start_tool_use() {
|
||||
let mut acc: Option<TokenUsage> = None;
|
||||
let data = r#"{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"tool_1","name":"get_weather"}}"#;
|
||||
let c = apply_anthropic_event(data, &mut acc);
|
||||
assert!(acc.is_none(), "content_block_start 不动 usage");
|
||||
let tcs = c.tool_calls.expect("应有 tool_calls");
|
||||
assert_eq!(tcs.len(), 1);
|
||||
assert_eq!(tcs[0].index, 1);
|
||||
assert_eq!(tcs[0].id.as_deref(), Some("tool_1"));
|
||||
assert_eq!(tcs[0].function_name.as_deref(), Some("get_weather"));
|
||||
assert!(tcs[0].function_arguments.is_none());
|
||||
assert!(!c.finished);
|
||||
}
|
||||
|
||||
/// content_block_delta (input_json_delta) → 工具入参增量
|
||||
#[test]
|
||||
fn anthropic_content_block_delta_input_json() {
|
||||
let mut acc: Option<TokenUsage> = None;
|
||||
let data = r#"{"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"{\"q\":"}}"#;
|
||||
let c = apply_anthropic_event(data, &mut acc);
|
||||
let tcs = c.tool_calls.expect("应有 tool_calls 增量");
|
||||
assert_eq!(tcs[0].index, 2);
|
||||
assert_eq!(tcs[0].function_arguments.as_deref(), Some("{\"q\":"));
|
||||
assert!(tcs[0].id.is_none());
|
||||
assert_eq!(c.delta, "");
|
||||
assert!(!c.finished);
|
||||
}
|
||||
|
||||
/// error 事件 → finished=true 终态空 chunk
|
||||
#[test]
|
||||
fn anthropic_error_event_finishes_stream() {
|
||||
let mut acc: Option<TokenUsage> = None;
|
||||
apply_anthropic_event(&message_start(10), &mut acc);
|
||||
let c = apply_anthropic_event(r#"{"type":"error","error":{"message":"overloaded"}}"#, &mut acc);
|
||||
assert!(c.finished, "error 应终止流");
|
||||
assert!(c.usage.is_none(), "error 不带出 usage");
|
||||
assert!(acc.is_some(), "error 不应清空已累积的 usage(与原实现一致)");
|
||||
}
|
||||
|
||||
/// ping / content_block_stop 等事件 → 空且不 finished
|
||||
#[test]
|
||||
fn anthropic_ping_and_block_stop_yield_empty_chunk() {
|
||||
let mut acc: Option<TokenUsage> = None;
|
||||
let c = apply_anthropic_event(r#"{"type":"ping"}"#, &mut acc);
|
||||
assert!(!c.finished);
|
||||
assert_eq!(c.delta, "");
|
||||
assert!(acc.is_none());
|
||||
let c = apply_anthropic_event(r#"{"type":"content_block_stop","index":0}"#, &mut acc);
|
||||
assert!(!c.finished);
|
||||
assert_eq!(c.delta, "");
|
||||
}
|
||||
|
||||
/// 非法 JSON → 空 chunk,不 panic
|
||||
#[test]
|
||||
fn anthropic_malformed_json_yields_empty_chunk() {
|
||||
let mut acc: Option<TokenUsage> = None;
|
||||
let c = apply_anthropic_event("not json", &mut acc);
|
||||
assert!(!c.finished);
|
||||
assert_eq!(c.delta, "");
|
||||
assert!(acc.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,59 +1,511 @@
|
||||
//! 上下文管理器 — 管理对话上下文和 token 预算
|
||||
|
||||
use std::collections::VecDeque;
|
||||
//!
|
||||
//! 职责:
|
||||
//! - 维护消息历史及其 token 计数缓存
|
||||
//! - 提供预算感知的消息裁剪(保护工具调用三元组)
|
||||
//! - 为 run_agentic_loop 提供受控的消息视图
|
||||
//!
|
||||
//! 裁剪策略与模型选择是正交维度:本模块只管「窗口多大、怎么裁」,
|
||||
//! 用哪个 model / 是否启用 reasoning 由调用方在 CompletionRequest 层决定。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::provider::ChatMessage;
|
||||
use crate::provider::{ChatMessage, MessageRole};
|
||||
|
||||
// ============================================================
|
||||
// Token 估算器(零依赖粗估)
|
||||
// ============================================================
|
||||
|
||||
/// Token 粗估器 — 字符级近似计数,无 tokenizer 依赖
|
||||
///
|
||||
/// 用于发送前预算控制,误差 ±15% 完全可接受(保守估计,宁可多算)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TokenEstimator {
|
||||
/// 字符 → token 转换系数(默认 0.35,即 ~2.8 字符/token,中英混合偏保守)
|
||||
pub chars_ratio: f32,
|
||||
/// 每条消息固定开销(role 标记 + 格式)
|
||||
pub per_message_overhead: u32,
|
||||
/// 每个 tool_call 的额外开销(name + arguments JSON 结构)
|
||||
pub per_tool_call_overhead: u32,
|
||||
}
|
||||
|
||||
impl Default for TokenEstimator {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
chars_ratio: 0.35,
|
||||
per_message_overhead: 4,
|
||||
per_tool_call_overhead: 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TokenEstimator {
|
||||
/// 估算单条消息的 token 数(保守估计)
|
||||
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 total = content_tokens + self.per_message_overhead;
|
||||
|
||||
// tool_calls 的 JSON 结构开销(role=Assistant 时可能有)
|
||||
if let Some(ref calls) = msg.tool_calls {
|
||||
for call in calls {
|
||||
total += self.per_tool_call_overhead;
|
||||
total += (call.function.name.chars().count() as f32 * self.chars_ratio).ceil() as u32;
|
||||
total += (call.function.arguments.chars().count() as f32 * self.chars_ratio).ceil() as u32;
|
||||
}
|
||||
}
|
||||
|
||||
// tool_call_id 开销(role=Tool 时有)
|
||||
if msg.tool_call_id.is_some() {
|
||||
total += 3;
|
||||
}
|
||||
|
||||
total
|
||||
}
|
||||
|
||||
/// 估算纯文本字符串的 token 数(用于 system prompt)
|
||||
pub fn estimate_text(&self, text: &str) -> u32 {
|
||||
(text.chars().count() as f32 * self.chars_ratio).ceil() as u32
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 上下文窗口配置
|
||||
// ============================================================
|
||||
|
||||
/// 上下文窗口配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ContextConfig {
|
||||
/// 最大 token 数
|
||||
/// 窗口上限 token 数(默认 128_000)
|
||||
pub max_tokens: u32,
|
||||
/// 保留的系统提示 token 数
|
||||
pub system_reserve: u32,
|
||||
/// 输出预留 token 数(窗口中留给模型生成的部分,默认 8_192)
|
||||
pub output_reserve: u32,
|
||||
/// 安全系数 0.0~1.0(默认 0.85,留 15% 余量)
|
||||
pub safety_ratio: f32,
|
||||
}
|
||||
|
||||
impl Default for ContextConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_tokens: 128_000,
|
||||
system_reserve: 4_000,
|
||||
output_reserve: 8_192,
|
||||
safety_ratio: 0.85,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ContextConfig {
|
||||
/// 预算上限 = (max_tokens - output_reserve) × safety_ratio
|
||||
pub fn budget_limit(&self) -> u32 {
|
||||
(self.max_tokens.saturating_sub(self.output_reserve) as f32 * self.safety_ratio) as u32
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 消息分组(淘汰时保持工具调用三元组原子性)
|
||||
// ============================================================
|
||||
|
||||
/// 消息在逻辑上的分组标签,用于淘汰时保持原子性
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum MessageGroup {
|
||||
/// 普通 User / Assistant 文本消息(可独立淘汰)
|
||||
Standalone,
|
||||
/// Assistant 带 tool_calls,是三元组的头
|
||||
ToolCallHead,
|
||||
/// Tool 结果消息,是三元组的尾
|
||||
ToolResultTail,
|
||||
}
|
||||
|
||||
/// 带有 token 缓存和分组信息的消息条目
|
||||
struct TrackedMessage {
|
||||
message: ChatMessage,
|
||||
token_count: u32,
|
||||
group: MessageGroup,
|
||||
}
|
||||
|
||||
fn classify_group(msg: &ChatMessage) -> MessageGroup {
|
||||
match msg.role {
|
||||
MessageRole::Tool => MessageGroup::ToolResultTail,
|
||||
MessageRole::Assistant => {
|
||||
if msg.tool_calls.as_ref().is_some_and(|c| !c.is_empty()) {
|
||||
MessageGroup::ToolCallHead
|
||||
} else {
|
||||
MessageGroup::Standalone
|
||||
}
|
||||
}
|
||||
_ => MessageGroup::Standalone,
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 上下文管理器
|
||||
// ============================================================
|
||||
|
||||
/// 上下文管理器
|
||||
///
|
||||
/// 唯一的消息真相来源(替代原来的 `Vec<ChatMessage>`)。
|
||||
/// 裁剪仅影响发送视图(`build_for_request`),不影响持久化(`all_messages_clone`)。
|
||||
pub struct ContextManager {
|
||||
/// 消息历史
|
||||
messages: VecDeque<ChatMessage>,
|
||||
/// 配置
|
||||
messages: Vec<TrackedMessage>,
|
||||
/// 当前历史总 token 数(不含 system prompt)
|
||||
history_tokens: u32,
|
||||
config: ContextConfig,
|
||||
estimator: TokenEstimator,
|
||||
}
|
||||
|
||||
/// 保护区大小:最后 N 条消息永不淘汰(≈ 最近 2 个完整用户轮次)
|
||||
const PROTECT_COUNT: usize = 6;
|
||||
|
||||
impl ContextManager {
|
||||
/// 创建上下文管理器
|
||||
pub fn new(config: ContextConfig) -> Self {
|
||||
Self {
|
||||
messages: VecDeque::new(),
|
||||
messages: Vec::new(),
|
||||
history_tokens: 0,
|
||||
config,
|
||||
estimator: TokenEstimator::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加消息
|
||||
/// 追加消息(自动计算 token 并更新缓存)
|
||||
///
|
||||
/// 不在此处淘汰——push 可能发生在 agentic loop 中间(追加 tool_result),
|
||||
/// 此时不应裁剪正在使用的活跃消息。裁剪在 `build_for_request` 时统一处理。
|
||||
pub fn push(&mut self, message: ChatMessage) {
|
||||
self.messages.push_back(message);
|
||||
// TODO: 当超过 token 预算时,淘汰旧消息
|
||||
let tokens = self.estimator.estimate_message(&message);
|
||||
let group = classify_group(&message);
|
||||
self.history_tokens += tokens;
|
||||
self.messages.push(TrackedMessage {
|
||||
message,
|
||||
token_count: tokens,
|
||||
group,
|
||||
});
|
||||
}
|
||||
|
||||
/// 获取当前消息列表
|
||||
pub fn messages(&self) -> &VecDeque<ChatMessage> {
|
||||
&self.messages
|
||||
}
|
||||
|
||||
/// 清空上下文
|
||||
/// 清空所有消息
|
||||
pub fn clear(&mut self) {
|
||||
self.messages.clear();
|
||||
self.history_tokens = 0;
|
||||
}
|
||||
|
||||
/// 消息数量
|
||||
pub fn len(&self) -> usize {
|
||||
self.messages.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.messages.is_empty()
|
||||
}
|
||||
|
||||
/// 当前历史占用的 token 数(不含 system prompt)
|
||||
pub fn history_tokens(&self) -> u32 {
|
||||
self.history_tokens
|
||||
}
|
||||
|
||||
/// 预算上限
|
||||
pub fn budget_limit(&self) -> u32 {
|
||||
self.config.budget_limit()
|
||||
}
|
||||
|
||||
// ── 核心:构建请求消息(受控裁剪版本)──
|
||||
|
||||
/// 构建发送给 LLM 的消息列表
|
||||
///
|
||||
/// `sys_tokens` 为调用方已估算好的 system prompt token 数。
|
||||
/// 超预算时自动裁剪旧消息(保护工具调用三元组 + 最近 PROTECT_COUNT 条)。
|
||||
/// 返回 (消息列表, 是否发生了裁剪)。
|
||||
pub fn build_for_request(&self, sys_tokens: u32) -> (Vec<ChatMessage>, bool) {
|
||||
let budget = self.budget_limit();
|
||||
let available = budget.saturating_sub(sys_tokens);
|
||||
|
||||
// system prompt 自身超预算:裁剪无法缓解(仍返回保护区兜底),warn 便于诊断
|
||||
if sys_tokens > budget {
|
||||
tracing::warn!(
|
||||
"system prompt (~{} tokens) 超过上下文预算 ({}),裁剪无法缓解",
|
||||
sys_tokens, budget
|
||||
);
|
||||
}
|
||||
|
||||
// 未超预算 → 直接返回全量
|
||||
if self.history_tokens <= available {
|
||||
return (self.all_messages_clone(), false);
|
||||
}
|
||||
|
||||
// 超预算 → 视图裁剪(不修改 self.messages,保证 all_messages_clone 仍返回全量)
|
||||
let protect_start = self.messages.len().saturating_sub(PROTECT_COUNT);
|
||||
let units = self.build_eviction_units(protect_start);
|
||||
|
||||
let mut removed: u64 = 0;
|
||||
let mut trim_end = 0;
|
||||
for unit in &units {
|
||||
if self.history_tokens.saturating_sub(removed as u32) <= available {
|
||||
break;
|
||||
}
|
||||
removed += unit.token_sum as u64;
|
||||
trim_end = unit.end;
|
||||
}
|
||||
|
||||
if trim_end == 0 {
|
||||
tracing::warn!(
|
||||
"history (~{} tokens) 超预算 ({}) 但无可淘汰单元(全在保护区 {} 条),发送兜底可能触发 provider 超限",
|
||||
self.history_tokens, available, PROTECT_COUNT
|
||||
);
|
||||
return (self.all_messages_clone(), false);
|
||||
}
|
||||
|
||||
let msgs: Vec<ChatMessage> = self.messages[trim_end..]
|
||||
.iter()
|
||||
.map(|t| t.message.clone())
|
||||
.collect();
|
||||
|
||||
tracing::info!(
|
||||
"context_trimmed: skip {} messages, ~{} tokens (view-only, full history retained)",
|
||||
trim_end, removed
|
||||
);
|
||||
(msgs, true)
|
||||
}
|
||||
|
||||
/// 全量克隆(持久化 save_conversation / build_for_request 未裁剪分支,不受裁剪影响)
|
||||
pub fn all_messages_clone(&self) -> Vec<ChatMessage> {
|
||||
self.messages.iter().map(|t| t.message.clone()).collect()
|
||||
}
|
||||
|
||||
/// 从 Vec 恢复(兼容从 DB 加载)
|
||||
pub fn restore_from_messages(&mut self, messages: Vec<ChatMessage>) {
|
||||
self.clear();
|
||||
for msg in messages {
|
||||
self.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
/// 就地替换某条 tool_result 的内容(兼容审批 replace_tool_result)
|
||||
/// 返回 true 如果找到并替换了
|
||||
pub fn replace_tool_result_content(&mut self, tool_call_id: &str, new_content: &str) -> bool {
|
||||
let pos = self.messages.iter().position(|t| {
|
||||
matches!(t.message.role, MessageRole::Tool)
|
||||
&& t.message.tool_call_id.as_deref() == Some(tool_call_id)
|
||||
});
|
||||
|
||||
let Some(i) = pos else { return false };
|
||||
|
||||
// 先更新 content,再重估 token 并校正总量
|
||||
let old_tokens = self.messages[i].token_count;
|
||||
self.messages[i].message.content = new_content.to_string();
|
||||
let new_tokens = self.estimator.estimate_message(&self.messages[i].message);
|
||||
self.messages[i].token_count = new_tokens;
|
||||
self.history_tokens = self.history_tokens.saturating_sub(old_tokens).saturating_add(new_tokens);
|
||||
true
|
||||
}
|
||||
|
||||
/// 只读迭代(兼容 ensure_conversation_title 的 .iter().filter() 等)
|
||||
pub fn iter(&self) -> impl Iterator<Item = &ChatMessage> {
|
||||
self.messages.iter().map(|t| &t.message)
|
||||
}
|
||||
|
||||
// ── 内部方法 ──
|
||||
|
||||
/// 构建淘汰单元列表
|
||||
///
|
||||
/// 每个单元是连续消息范围 [start, end),保证:
|
||||
/// - 工具调用三元组(ToolCallHead + ToolResultTail* + 紧随的文本 Assistant)在同一单元
|
||||
/// - 保护区内的消息不纳入任何单元
|
||||
fn build_eviction_units(&self, protect_start: usize) -> Vec<EvictionUnit> {
|
||||
let mut units = Vec::new();
|
||||
let mut i = 0usize;
|
||||
|
||||
while i < protect_start {
|
||||
let mut token_sum = 0u32;
|
||||
|
||||
if self.messages[i].group == MessageGroup::ToolCallHead {
|
||||
// 收集完整三元组:Head + 后续所有 ToolResultTail + 紧随的文本 Assistant
|
||||
token_sum += self.messages[i].token_count;
|
||||
i += 1;
|
||||
while i < protect_start && self.messages[i].group == MessageGroup::ToolResultTail {
|
||||
token_sum += self.messages[i].token_count;
|
||||
i += 1;
|
||||
}
|
||||
// 紧随的 Standalone Assistant(工具调用的最终文本回复)
|
||||
if i < protect_start
|
||||
&& self.messages[i].group == MessageGroup::Standalone
|
||||
&& matches!(self.messages[i].message.role, MessageRole::Assistant)
|
||||
{
|
||||
token_sum += self.messages[i].token_count;
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
// Standalone / ToolResultTail(理论上孤立 Tail 不该出现,按单条处理)
|
||||
token_sum += self.messages[i].token_count;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
units.push(EvictionUnit { end: i, token_sum });
|
||||
}
|
||||
|
||||
units
|
||||
}
|
||||
}
|
||||
|
||||
/// 淘汰单元:连续消息范围 [..end) + token 总和
|
||||
struct EvictionUnit {
|
||||
end: usize,
|
||||
token_sum: u32,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::provider::ToolCall;
|
||||
|
||||
fn cfg(max_tokens: u32) -> ContextConfig {
|
||||
ContextConfig {
|
||||
max_tokens,
|
||||
output_reserve: 0,
|
||||
safety_ratio: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_history_no_trim() {
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("你好"));
|
||||
mgr.push(ChatMessage::assistant("你好啊"));
|
||||
let (msgs, trimmed) = mgr.build_for_request(10);
|
||||
assert!(!trimmed);
|
||||
assert_eq!(msgs.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_budget_trims_old() {
|
||||
// 小预算强制裁剪:20 条超预算,触发裁剪且保留保护区
|
||||
let mut mgr = ContextManager::new(cfg(200));
|
||||
for i in 0..20 {
|
||||
mgr.push(ChatMessage::user(&format!("这是第 {} 条较长的消息用于撑爆预算", i)));
|
||||
}
|
||||
let (msgs, trimmed) = mgr.build_for_request(0);
|
||||
assert!(trimmed, "超预算应触发裁剪");
|
||||
assert!(msgs.len() < 20, "应裁掉部分旧消息, 实际 {}", msgs.len());
|
||||
|
||||
// 保护区:最新一条必保留
|
||||
assert_eq!(
|
||||
msgs.last().unwrap().content,
|
||||
"这是第 19 条较长的消息用于撑爆预算",
|
||||
"保护区最新消息被误裁"
|
||||
);
|
||||
|
||||
// 裁剪是视图:内存全量不变
|
||||
assert_eq!(mgr.all_messages_clone().len(), 20, "裁剪污染了内存全量");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_triplet_kept_atomic() {
|
||||
// 三元组不可分离:Head 与 Tail 同进同出,永不从中间切断
|
||||
// 布局:6 旧(淘汰区) + 三元组(裁剪边界) + 6 新(保护区) = 15 条
|
||||
let mut mgr = ContextManager::new(cfg(95));
|
||||
for i in 0..6 {
|
||||
mgr.push(ChatMessage::user(&format!("旧消息 {}", i)));
|
||||
}
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调工具",
|
||||
vec![ToolCall::new("tc1", "read_file", "{}")],
|
||||
));
|
||||
mgr.push(ChatMessage::tool_result("tc1", "文件内容"));
|
||||
mgr.push(ChatMessage::assistant("完成"));
|
||||
for i in 0..6 {
|
||||
mgr.push(ChatMessage::user(&format!("新消息 {}", i)));
|
||||
}
|
||||
|
||||
// 分支一:预算宽松,三元组整体保留 → Head 在则 Tail 在
|
||||
let (msgs_keep, trimmed1) = mgr.build_for_request(0);
|
||||
assert!(trimmed1, "分支一应触发裁剪");
|
||||
assert_eq!(
|
||||
has_head(&msgs_keep),
|
||||
has_tail(&msgs_keep),
|
||||
"分支一三元组被切断: head={} tail={}",
|
||||
has_head(&msgs_keep),
|
||||
has_tail(&msgs_keep)
|
||||
);
|
||||
|
||||
// 分支二:预算紧张,三元组整体丢弃 → Head 不在则 Tail 也不在
|
||||
let (msgs_drop, trimmed2) = mgr.build_for_request(40);
|
||||
assert!(trimmed2, "分支二应触发裁剪");
|
||||
assert_eq!(
|
||||
has_head(&msgs_drop),
|
||||
has_tail(&msgs_drop),
|
||||
"分支二三元组被切断: head={} tail={}",
|
||||
has_head(&msgs_drop),
|
||||
has_tail(&msgs_drop)
|
||||
);
|
||||
|
||||
// 裁剪是视图:两次 build 都不应改变内存全量
|
||||
assert_eq!(
|
||||
mgr.all_messages_clone().len(),
|
||||
15,
|
||||
"裁剪污染了内存全量"
|
||||
);
|
||||
}
|
||||
|
||||
fn has_head(msgs: &[ChatMessage]) -> bool {
|
||||
msgs.iter()
|
||||
.any(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some())
|
||||
}
|
||||
|
||||
fn has_tail(msgs: &[ChatMessage]) -> bool {
|
||||
msgs.iter().any(|m| matches!(m.role, MessageRole::Tool))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_tool_result_updates_tokens() {
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::tool_result("tc1", "短"));
|
||||
let before = mgr.history_tokens();
|
||||
assert!(mgr.replace_tool_result_content("tc1", "这是一个明显更长的替换内容用于验证 token 重估"));
|
||||
let after = mgr.history_tokens();
|
||||
assert!(after > before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_rebuilds_token_cache() {
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
let src = vec![
|
||||
ChatMessage::user("测试消息一"),
|
||||
ChatMessage::assistant("回复一"),
|
||||
ChatMessage::user("测试消息二"),
|
||||
];
|
||||
mgr.restore_from_messages(src);
|
||||
assert!(mgr.history_tokens() > 0);
|
||||
assert_eq!(mgr.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_history_returns_empty() {
|
||||
let mgr = ContextManager::new(cfg(100_000));
|
||||
let (msgs, trimmed) = mgr.build_for_request(10);
|
||||
assert!(!trimmed);
|
||||
assert!(msgs.is_empty(), "空历史应返回空列表");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protect_zone_returns_full_when_untrimmable() {
|
||||
// 消息全在保护区(< PROTECT_COUNT 条)且超预算 → 无可淘汰单元,走 trim_end==0 兜底返回全量
|
||||
let mut mgr = ContextManager::new(cfg(10));
|
||||
mgr.push(ChatMessage::user("撑爆小预算的长消息内容"));
|
||||
mgr.push(ChatMessage::user("第二条撑爆预算的长消息"));
|
||||
let (msgs, trimmed) = mgr.build_for_request(0);
|
||||
assert!(!trimmed, "无可淘汰单元应返回 false(兜底)");
|
||||
assert_eq!(msgs.len(), 2, "兜底返回全部保护区消息");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_over_budget_trims_to_protect_zone() {
|
||||
// system prompt 吃光预算 → history 仍尝试裁剪到保护区,不 panic
|
||||
let mut mgr = ContextManager::new(cfg(200));
|
||||
for i in 0..10 {
|
||||
mgr.push(ChatMessage::user(&format!("消息 {} 撑量", i)));
|
||||
}
|
||||
let (msgs, _trimmed) = mgr.build_for_request(195);
|
||||
assert!(
|
||||
msgs.len() <= PROTECT_COUNT,
|
||||
"system 超预算时裁剪后至多保留保护区 {} 条,实际 {}",
|
||||
PROTECT_COUNT,
|
||||
msgs.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
//! Agent 协调器 — 管理多 Agent 协作
|
||||
//!
|
||||
//! ⚠ B 路线占位:当前单链 ReAct 够用,多 Agent 协作待 B 路线立项。有意保留空壳,勿删。
|
||||
|
||||
/// Agent 协调器
|
||||
///
|
||||
|
||||
@@ -8,3 +8,27 @@ pub mod openai_compat;
|
||||
pub mod provider;
|
||||
pub mod router;
|
||||
pub mod stream;
|
||||
|
||||
use provider::LlmProvider;
|
||||
|
||||
/// 按 provider_type 构建 LLM Provider 实例(统一选择逻辑,消除调用方重复 match)
|
||||
///
|
||||
/// `anthropic` 协议走 AnthropicCompatProvider(GLM 订阅端点 / Claude 官方),
|
||||
/// 其余(openai / glm / deepseek 等 OpenAI 兼容)走 OpenAICompatProvider。
|
||||
/// 调用方(AI Chat 的 run_agentic_loop、df-nodes 的 AiNode)统一引用此工厂,
|
||||
/// 新增 provider 只改这一处。
|
||||
pub fn build_provider(
|
||||
provider_type: &str,
|
||||
base_url: &str,
|
||||
api_key: &str,
|
||||
model: &str,
|
||||
) -> Box<dyn LlmProvider> {
|
||||
match provider_type {
|
||||
"anthropic" => Box::new(anthropic_compat::AnthropicCompatProvider::new(
|
||||
base_url, api_key, model,
|
||||
)),
|
||||
_ => Box::new(openai_compat::OpenAICompatProvider::new(
|
||||
base_url, api_key, model,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,12 @@
|
||||
//! 覆盖: OpenAI / GLM (open.bigmodel.cn) / DeepSeek / Claude OpenAI 兼容模式
|
||||
//! 支持: 同步调用 + SSE 流式 + Function Calling / Tool Use
|
||||
|
||||
use std::pin::Pin;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use eventsource_stream::Eventsource;
|
||||
use futures::{Stream, StreamExt};
|
||||
use futures::StreamExt;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tracing::{debug, error, info, warn};
|
||||
use tracing::{debug, error, warn};
|
||||
|
||||
use crate::provider::{
|
||||
CompletionRequest, CompletionResponse, LlmProvider, ProviderFeatures, StreamChunk, StreamResult,
|
||||
@@ -35,6 +33,9 @@ struct OpenAiRequest {
|
||||
tools: Option<Vec<serde_json::Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
tool_choice: Option<serde_json::Value>,
|
||||
/// 流式时请求末 chunk 携带 usage(OpenAI 官方 + DeepSeek/GLM 兼容)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
stream_options: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// OpenAI 消息格式
|
||||
@@ -93,6 +94,9 @@ struct OpenAiUsage {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OpenAiStreamChunk {
|
||||
choices: Vec<OpenAiStreamChoice>,
|
||||
/// 末 chunk(choices 为空)携带的累计 usage
|
||||
#[serde(default)]
|
||||
usage: Option<OpenAiUsage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -120,6 +124,87 @@ struct OpenAiStreamFunction {
|
||||
arguments: Option<String>,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SSE 解析纯函数(与 HTTP 解耦,便于单测)
|
||||
// ============================================================
|
||||
|
||||
/// 将一条 OpenAI 兼容 SSE 事件 data 解析为 StreamChunk,并按需更新 usage 累加器。
|
||||
///
|
||||
/// - `[DONE]` → 返回 `finished=true` 的终态 chunk,`usage` 取自累加器(`take()`)。
|
||||
/// - 普通文本/工具增量 chunk → 返回对应 `StreamChunk`,usage 字段恒为 None(usage 仅在终态带出)。
|
||||
/// - usage(`stream_options.include_usage` 时末段或 usage-only chunk 携带)→ 覆盖累加器(覆盖语义保对)。
|
||||
/// - 解析失败 → 返回空 chunk(与原内联实现一致)。
|
||||
///
|
||||
/// 等价性:delta / tool_calls / finished / 解析失败等分支与原 stream() 闭包逐字一致;
|
||||
/// usage 透传([DONE] 终态 take() 带出、usage chunk 覆盖累加器)为本次新增能力,
|
||||
/// 对应 StreamChunk 新增的 usage 字段 + 请求体新增 stream_options.include_usage。
|
||||
pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option<TokenUsage>) -> StreamChunk {
|
||||
// OpenAI 发送 "data: [DONE]" 表示流结束,带出累积 usage
|
||||
if data == "[DONE]" {
|
||||
return StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: true,
|
||||
tool_calls: None,
|
||||
usage: usage_accum.take(),
|
||||
};
|
||||
}
|
||||
|
||||
match serde_json::from_str::<OpenAiStreamChunk>(data) {
|
||||
Ok(chunk) => {
|
||||
// 提取 usage(带 include_usage 时末段 chunk 携带,覆盖累积)
|
||||
if let Some(u) = chunk.usage {
|
||||
*usage_accum = Some(TokenUsage {
|
||||
prompt_tokens: u.prompt_tokens,
|
||||
completion_tokens: u.completion_tokens,
|
||||
total_tokens: u.total_tokens,
|
||||
});
|
||||
}
|
||||
if let Some(choice) = chunk.choices.into_iter().next() {
|
||||
let delta_text = choice.delta.content.unwrap_or_default();
|
||||
// "length" = max_tokens 截断,属正常终止(非断连),纳入 finished
|
||||
let finished = choice.finish_reason.as_deref() == Some("stop")
|
||||
|| choice.finish_reason.as_deref() == Some("tool_calls")
|
||||
|| choice.finish_reason.as_deref() == Some("length");
|
||||
|
||||
let tool_calls = choice.delta.tool_calls.map(|tcs| {
|
||||
tcs.into_iter()
|
||||
.map(|tc| ToolCallDelta {
|
||||
index: tc.index,
|
||||
id: tc.id,
|
||||
function_name: tc.function.as_ref().and_then(|f| f.name.clone()),
|
||||
function_arguments: tc.function.and_then(|f| f.arguments),
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
StreamChunk {
|
||||
delta: delta_text,
|
||||
finished,
|
||||
tool_calls,
|
||||
usage: None,
|
||||
}
|
||||
} else {
|
||||
// choices 为空 = usage-only chunk,不输出文本(usage 已累积)
|
||||
StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: false,
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("SSE 数据解析失败: {} — data: {}", e, data);
|
||||
StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: false,
|
||||
tool_calls: None,
|
||||
usage: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// OpenAI Compat Provider
|
||||
// ============================================================
|
||||
@@ -184,6 +269,18 @@ impl OpenAICompatProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建 embeddings API URL(与 chat_url 同套智能拼接规则)
|
||||
fn embed_url(&self) -> String {
|
||||
let base = self.base_url.trim_end_matches('/');
|
||||
if base.ends_with("/embeddings") {
|
||||
return base.to_string();
|
||||
}
|
||||
if Self::ends_with_version(base) {
|
||||
return format!("{}/embeddings", base);
|
||||
}
|
||||
format!("{}/v1/embeddings", base)
|
||||
}
|
||||
|
||||
/// 将通用请求转换为 OpenAI 格式
|
||||
fn convert_request(&self, req: CompletionRequest) -> OpenAiRequest {
|
||||
let model = if req.model.is_empty() {
|
||||
@@ -240,6 +337,12 @@ impl OpenAICompatProvider {
|
||||
stream: req.stream,
|
||||
tools,
|
||||
tool_choice: req.tool_choice,
|
||||
// 流式请求末 chunk 带 usage(同步调用 complete 不需要)
|
||||
stream_options: if req.stream {
|
||||
Some(serde_json::json!({ "include_usage": true }))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,6 +357,34 @@ impl OpenAICompatProvider {
|
||||
|
||||
#[async_trait]
|
||||
impl LlmProvider for OpenAICompatProvider {
|
||||
/// 文本嵌入: POST /v1/embeddings(OpenAI 兼容,智谱/阿里百炼/OpenAI 通用)
|
||||
async fn embed(&self, model: &str, texts: Vec<String>) -> anyhow::Result<Vec<Vec<f32>>> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct EmbedData { embedding: Vec<f32>, index: usize }
|
||||
#[derive(serde::Deserialize)]
|
||||
struct EmbedResponse { data: Vec<EmbedData> }
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
.post(self.embed_url())
|
||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&serde_json::json!({ "model": model, "input": texts }))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Embedding API 错误 {}: {}", status, body);
|
||||
}
|
||||
|
||||
let mut body: EmbedResponse = resp.json().await?;
|
||||
// 按 index 排序保证与输入顺序一致(API 不保证返回顺序)
|
||||
body.data.sort_by_key(|d| d.index);
|
||||
Ok(body.data.into_iter().map(|d| d.embedding).collect())
|
||||
}
|
||||
|
||||
async fn complete(&self, request: CompletionRequest) -> anyhow::Result<CompletionResponse> {
|
||||
let mut req = request;
|
||||
req.stream = false;
|
||||
@@ -328,68 +459,18 @@ impl LlmProvider for OpenAICompatProvider {
|
||||
anyhow::bail!("LLM 流式 API 错误 {}: {}", status, body);
|
||||
}
|
||||
|
||||
// 累积流式 usage:开 include_usage 后,末段正常 chunk(finish_reason)及额外 usage-only chunk(choices=[])都带 usage。
|
||||
// usage 解析/累积逻辑抽到 apply_openai_sse 纯函数,便于单测;此处闭包只负责传 data 与传递 last_usage。
|
||||
let mut last_usage: Option<TokenUsage> = None;
|
||||
|
||||
let stream = resp
|
||||
.bytes_stream()
|
||||
.eventsource()
|
||||
.map(move |event| {
|
||||
match event {
|
||||
Ok(event) => {
|
||||
// OpenAI 发送 "data: [DONE]" 表示流结束
|
||||
if event.data == "[DONE]" {
|
||||
return Ok(StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: true,
|
||||
tool_calls: None,
|
||||
});
|
||||
}
|
||||
|
||||
match serde_json::from_str::<OpenAiStreamChunk>(&event.data) {
|
||||
Ok(chunk) => {
|
||||
if let Some(choice) = chunk.choices.into_iter().next() {
|
||||
let delta_text = choice.delta.content.unwrap_or_default();
|
||||
// "length" = max_tokens 截断,属正常终止(非断连),纳入 finished
|
||||
let finished = choice.finish_reason.as_deref() == Some("stop")
|
||||
|| choice.finish_reason.as_deref() == Some("tool_calls")
|
||||
|| choice.finish_reason.as_deref() == Some("length");
|
||||
|
||||
let tool_calls = choice.delta.tool_calls.map(|tcs| {
|
||||
tcs.into_iter()
|
||||
.map(|tc| ToolCallDelta {
|
||||
index: tc.index,
|
||||
id: tc.id,
|
||||
function_name: tc.function.as_ref().and_then(|f| f.name.clone()),
|
||||
function_arguments: tc.function.and_then(|f| f.arguments),
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
Ok(StreamChunk {
|
||||
delta: delta_text,
|
||||
finished,
|
||||
tool_calls,
|
||||
})
|
||||
} else {
|
||||
Ok(StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: false,
|
||||
tool_calls: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("SSE 数据解析失败: {} — data: {}", e, event.data);
|
||||
Ok(StreamChunk {
|
||||
delta: String::new(),
|
||||
finished: false,
|
||||
tool_calls: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("SSE 流错误: {}", e);
|
||||
Err(anyhow::anyhow!("SSE 流错误: {}", e))
|
||||
}
|
||||
.map(move |event| match event {
|
||||
Ok(event) => Ok(apply_openai_sse(&event.data, &mut last_usage)),
|
||||
Err(e) => {
|
||||
error!("SSE 流错误: {}", e);
|
||||
Err(anyhow::anyhow!("SSE 流错误: {}", e))
|
||||
}
|
||||
});
|
||||
|
||||
@@ -408,3 +489,169 @@ impl LlmProvider for OpenAICompatProvider {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单测(不发真实 HTTP,喂构造的 SSE data 字符串序列)
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 辅助:构造普通文本 delta chunk 的 SSE data
|
||||
fn text_chunk(content: &str, finish_reason: Option<&str>) -> String {
|
||||
let fr = match finish_reason {
|
||||
Some(r) => format!(", \"finish_reason\": \"{}\"", r),
|
||||
None => String::from(", \"finish_reason\": null"),
|
||||
};
|
||||
format!(
|
||||
r#"{{"choices":[{{"delta":{{"content":"{}"}}{}}}]}}"#,
|
||||
content, fr
|
||||
)
|
||||
}
|
||||
|
||||
/// 辅助:构造带 usage 的 chunk(choices 为空 → usage-only 末 chunk,对应 include_usage)
|
||||
fn usage_only_chunk(prompt: u32, completion: u32) -> String {
|
||||
format!(
|
||||
r#"{{"choices":[],"usage":{{"prompt_tokens":{},"completion_tokens":{},"total_tokens":{}}}}}"#,
|
||||
prompt,
|
||||
completion,
|
||||
prompt + completion
|
||||
)
|
||||
}
|
||||
|
||||
/// 辅助:构造既有 content 又带 usage 的末段 chunk(部分兼容端点会把 usage 挂到正常末 chunk 上)
|
||||
fn text_chunk_with_usage(content: &str, finish_reason: &str, prompt: u32, completion: u32) -> String {
|
||||
format!(
|
||||
r#"{{"choices":[{{"delta":{{"content":"{}"}},"finish_reason":"{}"}}],"usage":{{"prompt_tokens":{},"completion_tokens":{},"total_tokens":{}}}}}"#,
|
||||
content,
|
||||
finish_reason,
|
||||
prompt,
|
||||
completion,
|
||||
prompt + completion
|
||||
)
|
||||
}
|
||||
|
||||
/// 多 chunk 文本流后,末 chunk 携带 usage(include_usage 覆盖语义)
|
||||
#[test]
|
||||
fn openai_sse_multi_chunk_with_terminal_usage() {
|
||||
let mut acc: Option<TokenUsage> = None;
|
||||
|
||||
// 1) 首个文本增量,无 usage
|
||||
let c = apply_openai_sse(&text_chunk("Hello", None), &mut acc);
|
||||
assert_eq!(c.delta, "Hello");
|
||||
assert!(!c.finished);
|
||||
assert!(c.usage.is_none());
|
||||
assert!(acc.is_none(), "无 usage 的 chunk 不应改累加器");
|
||||
|
||||
// 2) 第二个文本增量
|
||||
let c = apply_openai_sse(&text_chunk(" world", None), &mut acc);
|
||||
assert_eq!(c.delta, " world");
|
||||
assert!(!c.finished);
|
||||
assert!(acc.is_none());
|
||||
|
||||
// 3) 末段正常 chunk 带 finish_reason=stop(仍是文本 delta,不带 usage)
|
||||
let c = apply_openai_sse(&text_chunk("", Some("stop")), &mut acc);
|
||||
assert!(c.finished);
|
||||
assert_eq!(c.delta, "");
|
||||
assert!(acc.is_none(), "此 chunk 无 usage 字段,累加器仍为 None");
|
||||
|
||||
// 4) usage-only chunk(choices=[])携带累计 usage → 覆盖累加器
|
||||
let c = apply_openai_sse(&usage_only_chunk(12, 34), &mut acc);
|
||||
assert!(!c.finished);
|
||||
assert!(c.usage.is_none(), "非 [DONE] chunk 不带出 usage");
|
||||
let acc = acc.expect("累加器应已被 usage-only chunk 覆盖写入");
|
||||
assert_eq!(acc.prompt_tokens, 12);
|
||||
assert_eq!(acc.completion_tokens, 34);
|
||||
assert_eq!(acc.total_tokens, 46);
|
||||
}
|
||||
|
||||
/// usage 挂在正常末段 chunk(含 finish_reason)上,而非独立 usage-only chunk
|
||||
#[test]
|
||||
fn openai_sse_usage_on_terminal_text_chunk() {
|
||||
let mut acc: Option<TokenUsage> = None;
|
||||
let c = apply_openai_sse(&text_chunk_with_usage("", "stop", 100, 200), &mut acc);
|
||||
assert!(c.finished);
|
||||
assert!(c.usage.is_none(), "非 [DONE] 不带出 usage,仅覆盖累加器");
|
||||
let acc = acc.expect("末段 chunk 的 usage 应已覆盖累加器");
|
||||
assert_eq!(acc.prompt_tokens, 100);
|
||||
assert_eq!(acc.completion_tokens, 200);
|
||||
assert_eq!(acc.total_tokens, 300);
|
||||
}
|
||||
|
||||
/// [DONE] 时 take() 带出累积 usage,且取走后累加器清空
|
||||
#[test]
|
||||
fn openai_sse_done_takes_accumulated_usage() {
|
||||
let mut acc: Option<TokenUsage> = None;
|
||||
apply_openai_sse(&text_chunk("x", None), &mut acc);
|
||||
apply_openai_sse(&usage_only_chunk(5, 7), &mut acc);
|
||||
|
||||
let c = apply_openai_sse("[DONE]", &mut acc);
|
||||
assert!(c.finished);
|
||||
let u = c.usage.expect("[DONE] 应带出累积 usage");
|
||||
assert_eq!(u.prompt_tokens, 5);
|
||||
assert_eq!(u.completion_tokens, 7);
|
||||
assert_eq!(u.total_tokens, 12);
|
||||
assert!(acc.is_none(), "take() 后累加器应清空");
|
||||
}
|
||||
|
||||
/// 无 usage 的流:[DONE] 时 usage 字段为 None
|
||||
#[test]
|
||||
fn openai_sse_done_without_usage() {
|
||||
let mut acc: Option<TokenUsage> = None;
|
||||
apply_openai_sse(&text_chunk("hi", None), &mut acc);
|
||||
let c = apply_openai_sse("[DONE]", &mut acc);
|
||||
assert!(c.finished);
|
||||
assert!(c.usage.is_none(), "全程无 usage 时 [DONE] usage 应为 None");
|
||||
assert!(acc.is_none());
|
||||
}
|
||||
|
||||
/// 后续 usage chunk 覆盖先前 usage(多轮 / 重发场景)
|
||||
#[test]
|
||||
fn openai_sse_later_usage_overrides_earlier() {
|
||||
let mut acc: Option<TokenUsage> = None;
|
||||
apply_openai_sse(&usage_only_chunk(1, 1), &mut acc);
|
||||
apply_openai_sse(&usage_only_chunk(50, 60), &mut acc);
|
||||
let c = apply_openai_sse("[DONE]", &mut acc);
|
||||
let u = c.usage.unwrap();
|
||||
assert_eq!(u.prompt_tokens, 50, "末 usage 应覆盖前值");
|
||||
assert_eq!(u.completion_tokens, 60);
|
||||
assert_eq!(u.total_tokens, 110);
|
||||
}
|
||||
|
||||
/// finish_reason=length(max_tokens 截断)按正常终止处理
|
||||
#[test]
|
||||
fn openai_sse_length_finish_reason_treated_as_finished() {
|
||||
let mut acc: Option<TokenUsage> = None;
|
||||
let c = apply_openai_sse(&text_chunk("...", Some("length")), &mut acc);
|
||||
assert!(c.finished, "length 应视为正常终止");
|
||||
assert!(acc.is_none());
|
||||
}
|
||||
|
||||
/// 非法 JSON data → 返回空 chunk,不 panic、不改累加器
|
||||
#[test]
|
||||
fn openai_sse_malformed_json_yields_empty_chunk() {
|
||||
let mut acc: Option<TokenUsage> = None;
|
||||
let c = apply_openai_sse("not a json", &mut acc);
|
||||
assert_eq!(c.delta, "");
|
||||
assert!(!c.finished);
|
||||
assert!(c.usage.is_none());
|
||||
assert!(acc.is_none());
|
||||
}
|
||||
|
||||
/// tool_calls 增量解析
|
||||
#[test]
|
||||
fn openai_sse_tool_call_delta() {
|
||||
let mut acc: Option<TokenUsage> = None;
|
||||
let data = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","function":{"name":"get_weather","arguments":"{\"q\":"}}]}}]}"#;
|
||||
let c = apply_openai_sse(data, &mut acc);
|
||||
assert!(acc.is_none());
|
||||
let tcs = c.tool_calls.expect("应有 tool_calls 增量");
|
||||
assert_eq!(tcs.len(), 1);
|
||||
assert_eq!(tcs[0].index, 0);
|
||||
assert_eq!(tcs[0].id.as_deref(), Some("call_1"));
|
||||
assert_eq!(tcs[0].function_name.as_deref(), Some("get_weather"));
|
||||
assert_eq!(tcs[0].function_arguments.as_deref(), Some("{\"q\":"));
|
||||
assert!(!c.finished);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,23 +45,26 @@ pub struct ChatMessage {
|
||||
/// AI 发起的工具调用列表(role=Assistant 时可能有)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<ToolCall>>,
|
||||
/// 生成该消息的 model(仅 assistant 消息有,消息级 model 追溯)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
impl ChatMessage {
|
||||
pub fn system(content: impl Into<String>) -> Self {
|
||||
Self { role: MessageRole::System, content: content.into(), tool_call_id: None, tool_calls: None }
|
||||
Self { role: MessageRole::System, content: content.into(), tool_call_id: None, tool_calls: None, model: None }
|
||||
}
|
||||
pub fn user(content: impl Into<String>) -> Self {
|
||||
Self { role: MessageRole::User, content: content.into(), tool_call_id: None, tool_calls: None }
|
||||
Self { role: MessageRole::User, content: content.into(), tool_call_id: None, tool_calls: None, model: None }
|
||||
}
|
||||
pub fn assistant(content: impl Into<String>) -> Self {
|
||||
Self { role: MessageRole::Assistant, content: content.into(), tool_call_id: None, tool_calls: None }
|
||||
Self { role: MessageRole::Assistant, content: content.into(), tool_call_id: None, tool_calls: None, model: None }
|
||||
}
|
||||
pub fn assistant_with_tools(content: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self {
|
||||
Self { role: MessageRole::Assistant, content: content.into(), tool_call_id: None, tool_calls: Some(tool_calls) }
|
||||
Self { role: MessageRole::Assistant, content: content.into(), tool_call_id: None, tool_calls: Some(tool_calls), model: None }
|
||||
}
|
||||
pub fn tool_result(call_id: impl Into<String>, content: impl Into<String>) -> Self {
|
||||
Self { role: MessageRole::Tool, content: content.into(), tool_call_id: Some(call_id.into()), tool_calls: None }
|
||||
Self { role: MessageRole::Tool, content: content.into(), tool_call_id: Some(call_id.into()), tool_calls: None, model: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +144,7 @@ pub struct CompletionResponse {
|
||||
}
|
||||
|
||||
/// Token 用量
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct TokenUsage {
|
||||
pub prompt_tokens: u32,
|
||||
pub completion_tokens: u32,
|
||||
@@ -166,6 +169,9 @@ pub struct StreamChunk {
|
||||
/// 工具调用增量(如有)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<ToolCallDelta>>,
|
||||
/// Token 用量(流末 chunk 携带,由 provider 解析自 SSE usage 事件)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub usage: Option<TokenUsage>,
|
||||
}
|
||||
|
||||
/// 工具调用增量(流式中的片段)
|
||||
@@ -199,6 +205,14 @@ pub trait LlmProvider: Send + Sync {
|
||||
request: CompletionRequest,
|
||||
) -> anyhow::Result<StreamResult>;
|
||||
|
||||
/// 文本嵌入:批量文本 → 语义向量(供知识库向量检索)
|
||||
///
|
||||
/// 默认实现返回 Err(协议不支持)。OpenAI 兼容协议覆盖实现(/v1/embeddings);
|
||||
/// Anthropic 无 embedding API,保持默认。
|
||||
async fn embed(&self, _model: &str, _texts: Vec<String>) -> anyhow::Result<Vec<Vec<f32>>> {
|
||||
anyhow::bail!("该 Provider 不支持 embedding({})", self.name())
|
||||
}
|
||||
|
||||
/// Provider 名称
|
||||
fn name(&self) -> &str;
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
[package]
|
||||
name = "df-evolve"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
df-core = { path = "../df-core" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
@@ -1,58 +0,0 @@
|
||||
//! 进化引擎:知识沉淀的核心闭环
|
||||
//!
|
||||
//! 使用 → 沉淀 → 复用 → 改进 → 再沉淀
|
||||
|
||||
use crate::knowledge::{Knowledge, KnowledgeKind, KnowledgeStore};
|
||||
use crate::pattern::PatternExtractor;
|
||||
|
||||
/// 进化引擎
|
||||
pub struct EvolveEngine {
|
||||
extractor: PatternExtractor,
|
||||
}
|
||||
|
||||
impl EvolveEngine {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
extractor: PatternExtractor,
|
||||
}
|
||||
}
|
||||
|
||||
/// 自动扫描项目事件,提取可沉淀的知识
|
||||
///
|
||||
/// 触发时机:
|
||||
/// - 工作流节点完成后
|
||||
/// - 代码审查完成后
|
||||
/// - Bug 修复完成后
|
||||
/// - 发布完成后
|
||||
pub async fn evolve_from_events(&self, _events: &[serde_json::Value]) -> Vec<Knowledge> {
|
||||
let mut new_knowledge = Vec::new();
|
||||
|
||||
// TODO: 遍历事件,分类处理
|
||||
// 1. 审查事件 → 提取审查规则
|
||||
// 2. Bug 修复事件 → 提取诊断知识
|
||||
// 3. 发布事件 → 提取部署经验
|
||||
// 4. Prompt 事件 → 提取 Prompt 模板
|
||||
|
||||
new_knowledge
|
||||
}
|
||||
|
||||
/// 查询当前任务相关的知识(供 AI 节点使用)
|
||||
///
|
||||
/// AI 在执行任务前可以查询知识库,获取相关经验和规则
|
||||
pub fn query_relevant(
|
||||
&self,
|
||||
_context: &str,
|
||||
_kind: Option<&KnowledgeKind>,
|
||||
) -> Vec<Knowledge> {
|
||||
// TODO: 语义搜索知识库
|
||||
KnowledgeStore::search(_context, _kind, 5)
|
||||
}
|
||||
|
||||
/// 验证知识的有效性(定期执行)
|
||||
///
|
||||
/// 检查知识是否仍然适用(依赖版本是否过时、规则是否仍有意义等)
|
||||
pub async fn validate_knowledge(&self) -> Vec<String> {
|
||||
// TODO: 遍历知识库,标记过时的知识
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
//! 知识条目:经验沉淀的基本单元
|
||||
|
||||
use df_core::types::ProjectId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 知识类型
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum KnowledgeKind {
|
||||
/// 代码审查规则(如"禁止在循环中创建连接")
|
||||
ReviewRule,
|
||||
/// 有效的 Prompt 模板
|
||||
PromptTemplate,
|
||||
/// 踩坑经验
|
||||
Pitfall,
|
||||
/// 架构模式
|
||||
ArchitecturePattern,
|
||||
/// 诊断知识(Bug 根因分析)
|
||||
Diagnosis,
|
||||
/// 部署经验
|
||||
DeploymentNote,
|
||||
/// 工作流优化建议
|
||||
WorkflowOptimization,
|
||||
}
|
||||
|
||||
/// 知识条目
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Knowledge {
|
||||
pub id: String,
|
||||
pub kind: KnowledgeKind,
|
||||
/// 标题
|
||||
pub title: String,
|
||||
/// 内容
|
||||
pub content: String,
|
||||
/// 标签
|
||||
pub tags: Vec<String>,
|
||||
/// 来源项目
|
||||
pub source_project: Option<ProjectId>,
|
||||
/// 来源实体(如某次审查、某个 Bug 修复)
|
||||
pub source_ref: Option<String>,
|
||||
/// 被复用次数
|
||||
pub reuse_count: usize,
|
||||
/// 效果评分 (0-100,由用户或 AI 评估)
|
||||
pub effectiveness: Option<f32>,
|
||||
/// 是否已验证有效
|
||||
pub verified: bool,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
impl Knowledge {
|
||||
pub fn new(kind: KnowledgeKind, title: String, content: String) -> Self {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
Self {
|
||||
id: df_core::types::new_id(),
|
||||
kind,
|
||||
title,
|
||||
content,
|
||||
tags: vec![],
|
||||
source_project: None,
|
||||
source_ref: None,
|
||||
reuse_count: 0,
|
||||
effectiveness: None,
|
||||
verified: false,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// 记录一次复用
|
||||
pub fn record_reuse(&mut self) {
|
||||
self.reuse_count += 1;
|
||||
self.updated_at = chrono::Utc::now().timestamp();
|
||||
}
|
||||
}
|
||||
|
||||
/// 知识库(内存索引,持久化到 SQLite)
|
||||
pub struct KnowledgeStore;
|
||||
|
||||
impl KnowledgeStore {
|
||||
/// 搜索相关知识
|
||||
pub fn search(_query: &str, _kind: Option<&KnowledgeKind>, _limit: usize) -> Vec<Knowledge> {
|
||||
// TODO: SQLite 全文搜索或向量搜索
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// 获取最常用的知识
|
||||
pub fn top_used(_limit: usize) -> Vec<Knowledge> {
|
||||
// TODO: 按 reuse_count 降序
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// 保存知识条目
|
||||
pub fn save(_knowledge: &Knowledge) -> anyhow::Result<()> {
|
||||
// TODO: SQLite INSERT/UPDATE
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
//! 经验进化引擎:从开发过程中自动沉淀知识,持续进化复用
|
||||
//!
|
||||
//! 核心闭环:使用 → 沉淀 → 复用 → 改进 → 再沉淀
|
||||
|
||||
pub mod knowledge;
|
||||
pub mod pattern;
|
||||
pub mod prompt_template;
|
||||
pub mod review_rule;
|
||||
pub mod evolve_engine;
|
||||
|
||||
pub use evolve_engine::EvolveEngine;
|
||||
pub use knowledge::{Knowledge, KnowledgeKind, KnowledgeStore};
|
||||
@@ -1,47 +0,0 @@
|
||||
//! 模式提取器:从开发过程中自动识别可沉淀的模式
|
||||
|
||||
use crate::knowledge::{Knowledge, KnowledgeKind};
|
||||
|
||||
/// 模式提取器
|
||||
///
|
||||
/// 自动从以下场景中识别可沉淀的模式:
|
||||
/// - 代码审查 → 审查规则
|
||||
/// - Bug 修复 → 诊断知识
|
||||
/// - 发布流程 → 部署经验
|
||||
/// - Prompt 调优 → Prompt 模板
|
||||
pub struct PatternExtractor;
|
||||
|
||||
impl PatternExtractor {
|
||||
/// 从代码审查结果中提取审查规则
|
||||
///
|
||||
/// 如果同一类问题在多次审查中重复出现,自动沉淀为规则
|
||||
pub fn extract_review_rule(
|
||||
_findings: &[serde_json::Value],
|
||||
_occurrence_threshold: usize,
|
||||
) -> Option<Knowledge> {
|
||||
// TODO:
|
||||
// 1. 分析 findings 的共性
|
||||
// 2. 如果出现次数 >= threshold,生成规则
|
||||
// 3. 去重(与已有规则比较)
|
||||
None
|
||||
}
|
||||
|
||||
/// 从 Bug 修复过程中提取诊断知识
|
||||
pub fn extract_diagnosis(
|
||||
_bug_description: &str,
|
||||
_root_cause: &str,
|
||||
_fix_description: &str,
|
||||
) -> Option<Knowledge> {
|
||||
// TODO: AI 总结为可复用的诊断知识
|
||||
None
|
||||
}
|
||||
|
||||
/// 从成功的 Prompt 中提取模板
|
||||
pub fn extract_prompt_template(
|
||||
_prompt: &str,
|
||||
_result_quality: f32,
|
||||
) -> Option<Knowledge> {
|
||||
// TODO: 如果 result_quality > 0.8,提取为模板
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
//! Prompt 模板管理:AI 交互经验的沉淀与复用
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Prompt 模板
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PromptTemplate {
|
||||
pub id: String,
|
||||
/// 模板名称
|
||||
pub name: String,
|
||||
/// 模板内容(支持 {variable} 占位符)
|
||||
pub template: String,
|
||||
/// 变量说明
|
||||
pub variables: Vec<TemplateVariable>,
|
||||
/// 适用场景
|
||||
pub applicable_scenarios: Vec<String>,
|
||||
/// 效果评分
|
||||
pub avg_score: f32,
|
||||
/// 使用次数
|
||||
pub use_count: usize,
|
||||
}
|
||||
|
||||
/// 模板变量
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TemplateVariable {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub default_value: Option<String>,
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
impl PromptTemplate {
|
||||
/// 渲染模板(替换变量)
|
||||
pub fn render(&self, vars: &std::collections::HashMap<String, String>) -> String {
|
||||
let mut result = self.template.clone();
|
||||
for (key, value) in vars {
|
||||
result = result.replace(&format!("{{{}}}", key), value);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
//! 审查规则:从历史审查经验中沉淀的代码审查规则
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 审查规则
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReviewRule {
|
||||
pub id: String,
|
||||
/// 规则标题
|
||||
pub title: String,
|
||||
/// 规则描述
|
||||
pub description: String,
|
||||
/// 严重级别
|
||||
pub severity: RuleSeverity,
|
||||
/// 适用的语言/框架
|
||||
pub scope: Vec<String>,
|
||||
/// 检查方式(正则/AST/AI)
|
||||
pub check_method: CheckMethod,
|
||||
/// 发现次数(历史累计)
|
||||
pub found_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuleSeverity {
|
||||
/// 必须修复
|
||||
MustFix,
|
||||
/// 建议改进
|
||||
ShouldFix,
|
||||
/// 可选优化
|
||||
NiceToHave,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CheckMethod {
|
||||
/// 正则匹配
|
||||
Regex,
|
||||
/// AST 分析
|
||||
Ast,
|
||||
/// AI 判断
|
||||
AiAnalysis,
|
||||
/// 人工判断
|
||||
Manual,
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
//! Docker 执行器 — 在容器中运行任务
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Docker 容器执行请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DockerRequest {
|
||||
/// 镜像名称
|
||||
pub image: String,
|
||||
/// 容器内执行的命令
|
||||
pub command: Option<String>,
|
||||
/// 环境变量
|
||||
pub env: std::collections::HashMap<String, String>,
|
||||
/// 挂载卷
|
||||
pub volumes: Vec<VolumeMount>,
|
||||
/// 是否在执行后自动删除容器
|
||||
pub auto_remove: bool,
|
||||
}
|
||||
|
||||
/// 卷挂载
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VolumeMount {
|
||||
pub host_path: String,
|
||||
pub container_path: String,
|
||||
pub read_only: bool,
|
||||
}
|
||||
|
||||
/// Docker 执行结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DockerResult {
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub exit_code: Option<i32>,
|
||||
}
|
||||
|
||||
/// 在 Docker 容器中执行命令
|
||||
///
|
||||
/// TODO: 实现 Docker API 调用或 CLI 包装
|
||||
pub async fn execute(_request: DockerRequest) -> anyhow::Result<DockerResult> {
|
||||
tracing::info!("Docker 执行: TODO");
|
||||
Ok(DockerResult {
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
exit_code: None,
|
||||
})
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
//! Git 操作 — 克隆、提交、推送、合并等
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Git 操作类型
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GitAction {
|
||||
Clone,
|
||||
Commit,
|
||||
Push,
|
||||
Pull,
|
||||
Merge,
|
||||
Checkout,
|
||||
CreateBranch,
|
||||
}
|
||||
|
||||
/// Git 操作请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GitRequest {
|
||||
/// 操作类型
|
||||
pub action: GitAction,
|
||||
/// 仓库路径(本地路径或远程 URL)
|
||||
pub repo: String,
|
||||
/// 分支名
|
||||
pub branch: Option<String>,
|
||||
/// 提交消息
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
/// Git 操作结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GitResult {
|
||||
pub success: bool,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// 执行 Git 操作
|
||||
///
|
||||
/// TODO: 实现完整的 Git 操作(可包装 git CLI 或使用 git2 crate)
|
||||
pub async fn execute(_request: GitRequest) -> anyhow::Result<GitResult> {
|
||||
tracing::info!("Git 操作: TODO");
|
||||
Ok(GitResult {
|
||||
success: true,
|
||||
message: "TODO: 未实现".to_string(),
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
//! df-execute: 执行运行时 — Shell、Docker、SSH、Git 操作
|
||||
//! df-execute: 执行运行时 — Shell
|
||||
|
||||
pub mod docker;
|
||||
pub mod git_ops;
|
||||
pub mod shell;
|
||||
pub mod ssh;
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
//! SSH 执行器 — 远程命令执行
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// SSH 执行请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SshRequest {
|
||||
/// 主机地址
|
||||
pub host: String,
|
||||
/// 端口
|
||||
pub port: u16,
|
||||
/// 用户名
|
||||
pub user: String,
|
||||
/// 要执行的命令
|
||||
pub command: String,
|
||||
/// 超时时间(秒)
|
||||
pub timeout_secs: Option<u64>,
|
||||
}
|
||||
|
||||
/// SSH 执行结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SshResult {
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub exit_code: Option<i32>,
|
||||
}
|
||||
|
||||
/// 通过 SSH 执行远程命令
|
||||
///
|
||||
/// TODO: 实现SSH连接(可用 ssh2 crate 或包装 ssh 命令)
|
||||
pub async fn execute(_request: SshRequest) -> anyhow::Result<SshResult> {
|
||||
tracing::info!("SSH 执行: TODO");
|
||||
Ok(SshResult {
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
exit_code: None,
|
||||
})
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
//! 对抗式评估系统 — 正反方辩论 + AI 分析师
|
||||
//!
|
||||
//! 当前为基于评分与内容信号的启发式实现(稳定、有区分度)。
|
||||
//! TODO: 接入 df-ai LlmProvider 让正反方论点由 LLM 生成,启发式降级为 fallback。
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use df_core::types::IdeaId;
|
||||
use df_core::types::{IdeaId, Priority};
|
||||
use crate::capture::Idea;
|
||||
use crate::scoring::IdeaScores;
|
||||
|
||||
/// 对抗评估结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -18,7 +21,7 @@ pub struct AdversarialEval {
|
||||
pub recommendation: Recommendation,
|
||||
}
|
||||
|
||||
/// 正方论点
|
||||
/// 论点(正方/反方共用同一结构)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Argument {
|
||||
pub thesis: String, // 核心观点
|
||||
@@ -27,15 +30,6 @@ pub struct Argument {
|
||||
pub confidence: f64, // 置信度 0-1
|
||||
}
|
||||
|
||||
/// 反方论点
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CounterArgument {
|
||||
pub thesis: String, // 反对观点
|
||||
pub evidence: Vec<String>, // 反对证据
|
||||
pub reasoning: Vec<String>, // 反驳推理
|
||||
pub confidence: f64, // 置信度 0-1
|
||||
}
|
||||
|
||||
/// AI 分析师综合分析
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AnalystAnalysis {
|
||||
@@ -55,7 +49,6 @@ pub enum AssessmentLevel {
|
||||
Conditional, // 有条件执行
|
||||
Revised, // 需要修改后执行
|
||||
Defer, // 推迟执行
|
||||
Reject, // 不推荐执行
|
||||
}
|
||||
|
||||
/// 最终建议
|
||||
@@ -66,7 +59,6 @@ pub enum Recommendation {
|
||||
WithResources, // 配置资源后行动
|
||||
ResearchMore, // 需要更多研究
|
||||
Monitor, // 持续监控
|
||||
Cancel, // 取消想法
|
||||
}
|
||||
|
||||
/// 对抗评估引擎
|
||||
@@ -74,133 +66,166 @@ pub struct AdversarialEngine;
|
||||
|
||||
impl AdversarialEngine {
|
||||
/// 执行完整的对抗评估
|
||||
#[allow(clippy::unused_async)] // 签名保留 async,待接 LLM 注入异步调用
|
||||
pub async fn evaluate(idea: &Idea) -> Result<AdversarialEval> {
|
||||
// 1. 生成正方观点
|
||||
let positive = Self::generate_positive_argument(idea).await?;
|
||||
// 先做多维评分,作为正反方论点与置信度的依据
|
||||
let scores = crate::scoring::ScoringEngine::compute_default(idea);
|
||||
|
||||
// 2. 生成反方观点
|
||||
let negative = Self::generate_negative_argument(idea, &positive).await?;
|
||||
|
||||
// 3. AI 分析师综合分析
|
||||
let analyst = Self::analyst_analysis(idea, &positive, &negative).await?;
|
||||
|
||||
// 4. 计算最终分数和建议
|
||||
let (final_score, recommendation) = Self::compute_final_assessment(&analyst);
|
||||
let positive = Self::generate_positive_argument(idea, &scores)?;
|
||||
let negative = Self::generate_negative_argument(idea, &scores)?;
|
||||
let analyst = Self::analyst_analysis(idea, &scores)?;
|
||||
let recommendation = Self::recommendation_for(&analyst.final_assessment);
|
||||
|
||||
Ok(AdversarialEval {
|
||||
idea_id: idea.id.clone(),
|
||||
positive,
|
||||
negative,
|
||||
analyst,
|
||||
final_score,
|
||||
final_score: scores.overall,
|
||||
recommendation,
|
||||
})
|
||||
}
|
||||
|
||||
/// 生成正方观点(支持执行)
|
||||
async fn generate_positive_argument(idea: &Idea) -> Result<Argument> {
|
||||
// TODO: 接入 AI 生成正方观点
|
||||
// 当前使用启发式模板
|
||||
/// 生成正方观点(支持执行)— confidence 由可行性 + 影响力驱动
|
||||
/// 注:返回 Result 为后续 LLM 注入失败预留,启发式阶段恒 Ok
|
||||
fn generate_positive_argument(idea: &Idea, scores: &IdeaScores) -> Result<Argument> {
|
||||
let desc = idea.description.trim();
|
||||
let mut evidence = Vec::new();
|
||||
evidence.push(format!("优先级:{}", priority_label(&idea.priority)));
|
||||
if desc.is_empty() {
|
||||
evidence.push("需求待补充(建议补全描述)".to_string());
|
||||
} else {
|
||||
let head: String = desc.chars().take(60).collect();
|
||||
evidence.push(format!("明确需求:{}", head));
|
||||
}
|
||||
if idea.tags.is_empty() {
|
||||
evidence.push("关联领域待界定".to_string());
|
||||
} else {
|
||||
evidence.push(format!("关联领域:{}", idea.tags.join("、")));
|
||||
}
|
||||
if scores.impact >= 7.0 {
|
||||
evidence.push("业务价值显著,影响面较广".to_string());
|
||||
}
|
||||
|
||||
let title = &idea.title;
|
||||
let desc = &idea.description;
|
||||
// 正方置信度:可行性+影响力等权折算到 [0.1, 0.95],满分≈0.95 留质疑余地
|
||||
let confidence =
|
||||
((scores.feasibility * 0.5 + scores.impact * 0.5) / 10.0).clamp(0.1, 0.95);
|
||||
|
||||
let reasoning = vec![
|
||||
format!("可行性评分 {:.1}/10,路径相对清晰", scores.feasibility),
|
||||
format!("影响力评分 {:.1}/10,预期回报可观", scores.impact),
|
||||
"整体风险可控,适合推进".to_string(),
|
||||
];
|
||||
|
||||
Ok(Argument {
|
||||
thesis: format!("{} 具有很高的价值和可行性,应该优先执行", title),
|
||||
evidence: vec![
|
||||
format!("满足业务需求:{}", desc),
|
||||
"投入产出比高".to_string(),
|
||||
"技术实现可行".to_string(),
|
||||
"时间窗口合适".to_string(),
|
||||
],
|
||||
reasoning: vec![
|
||||
"能够解决现有痛点".to_string(),
|
||||
"竞争优势明显".to_string(),
|
||||
"风险可控".to_string(),
|
||||
],
|
||||
confidence: 0.75,
|
||||
thesis: format!("「{}」具备明确价值与可行性,建议优先推进", idea.title),
|
||||
evidence,
|
||||
reasoning,
|
||||
confidence,
|
||||
})
|
||||
}
|
||||
|
||||
/// 生成反方观点(反对或谨慎)
|
||||
async fn generate_negative_argument(idea: &Idea, positive: &Argument) -> Result<CounterArgument> {
|
||||
// TODO: 接入 AI 生成反方观点,考虑正方观点
|
||||
/// 生成反方观点(反对或谨慎)— 论点基于想法实际缺陷,confidence 随风险上升
|
||||
fn generate_negative_argument(idea: &Idea, scores: &IdeaScores) -> Result<Argument> {
|
||||
let desc = idea.description.trim();
|
||||
let mut evidence = Vec::new();
|
||||
if desc.is_empty() {
|
||||
evidence.push("描述过于简略,需求边界不清".to_string());
|
||||
} else if desc.chars().count() < 50 {
|
||||
evidence.push("描述偏短,实现细节尚未论证".to_string());
|
||||
}
|
||||
if idea.tags.is_empty() {
|
||||
evidence.push("缺少标签,影响范围未界定".to_string());
|
||||
}
|
||||
if scores.feasibility < 6.0 {
|
||||
evidence.push(format!("可行性 {:.1}/10 偏低,实现路径存疑", scores.feasibility));
|
||||
}
|
||||
if matches!(idea.priority, Priority::Low) {
|
||||
evidence.push("优先级偏低,可能非当前关键路径".to_string());
|
||||
}
|
||||
if evidence.is_empty() {
|
||||
evidence.push("机会成本需权衡,可能存在更优替代方案".to_string());
|
||||
}
|
||||
|
||||
let title = &idea.title;
|
||||
// 反方强度:feasibility 每降 1 分 +0.04,impact 每降 1 分 +0.03,基线 0.25(满分也保留最低质疑),clamp [0.1, 0.9]
|
||||
let confidence = ((10.0 - scores.feasibility) * 0.04 + (10.0 - scores.impact) * 0.03 + 0.25)
|
||||
.clamp(0.1, 0.9);
|
||||
|
||||
Ok(CounterArgument {
|
||||
thesis: format!("{} 需要谨慎评估,存在一定风险", title),
|
||||
evidence: vec![
|
||||
"资源投入较大".to_string(),
|
||||
"市场不确定性高".to_string(),
|
||||
"技术挑战存在".to_string(),
|
||||
"机会成本高".to_string(),
|
||||
],
|
||||
reasoning: vec![
|
||||
"ROI 需要进一步验证".to_string(),
|
||||
"优先级可能过高".to_string(),
|
||||
"存在更优替代方案".to_string(),
|
||||
],
|
||||
confidence: 0.65,
|
||||
let reasoning = vec![
|
||||
format!("资源投入与当前综合评分 {:.1} 需匹配", scores.overall),
|
||||
"ROI 需进一步验证".to_string(),
|
||||
"需评估是否存在更优解".to_string(),
|
||||
];
|
||||
|
||||
Ok(Argument {
|
||||
thesis: format!("「{}」需谨慎评估,存在风险与机会成本", idea.title),
|
||||
evidence,
|
||||
reasoning,
|
||||
confidence,
|
||||
})
|
||||
}
|
||||
|
||||
/// AI 分析师综合分析
|
||||
async fn analyst_analysis(
|
||||
idea: &Idea,
|
||||
positive: &Argument,
|
||||
negative: &CounterArgument,
|
||||
) -> Result<AnalystAnalysis> {
|
||||
// TODO: 接入 AI 进行深度分析
|
||||
/// AI 分析师综合分析 — 评估等级由综合评分决定,优势/劣势按维度动态生成
|
||||
fn analyst_analysis(idea: &Idea, scores: &IdeaScores) -> Result<AnalystAnalysis> {
|
||||
let final_assessment = match scores.overall {
|
||||
x if x >= 7.5 => AssessmentLevel::StrongGo,
|
||||
x if x >= 6.0 => AssessmentLevel::Recommended,
|
||||
x if x >= 4.5 => AssessmentLevel::Conditional,
|
||||
x if x >= 3.0 => AssessmentLevel::Revised,
|
||||
_ => AssessmentLevel::Defer,
|
||||
};
|
||||
|
||||
let positive_strengths = vec![
|
||||
"方向正确,符合业务战略".to_string(),
|
||||
"技术创新性较强".to_string(),
|
||||
"用户价值明确".to_string(),
|
||||
];
|
||||
let mut strengths = Vec::new();
|
||||
if scores.impact >= 6.0 {
|
||||
strengths.push("业务价值明确".to_string());
|
||||
}
|
||||
if scores.feasibility >= 6.0 {
|
||||
strengths.push("技术路径清晰".to_string());
|
||||
}
|
||||
if scores.urgency >= 7.0 {
|
||||
strengths.push("时间窗口合适".to_string());
|
||||
}
|
||||
if strengths.is_empty() {
|
||||
strengths.push("方向值得探索".to_string());
|
||||
}
|
||||
|
||||
let weaknesses = vec![
|
||||
"资源需求评估不足".to_string(),
|
||||
"风险控制需要加强".to_string(),
|
||||
"时间规划可能过于乐观".to_string(),
|
||||
];
|
||||
let mut weaknesses = Vec::new();
|
||||
if scores.feasibility < 6.0 {
|
||||
weaknesses.push("可行性论证不足".to_string());
|
||||
}
|
||||
if idea.description.trim().is_empty() {
|
||||
weaknesses.push("需求描述缺失".to_string());
|
||||
}
|
||||
if scores.urgency < 4.0 {
|
||||
weaknesses.push("紧急度偏低,易被搁置".to_string());
|
||||
}
|
||||
if weaknesses.is_empty() {
|
||||
weaknesses.push("资源需求待评估".to_string());
|
||||
}
|
||||
|
||||
// 启发式占位:固定风险模板,与具体想法无关,接 LLM 后改动态生成
|
||||
let risks = vec![
|
||||
"技术实现难度超出预期".to_string(),
|
||||
"市场竞争加剧".to_string(),
|
||||
"用户接受度不确定".to_string(),
|
||||
"技术实现难度可能超出预期".to_string(),
|
||||
"优先级与资源争夺".to_string(),
|
||||
"需求范围蔓延".to_string(),
|
||||
];
|
||||
|
||||
let opportunities = vec![
|
||||
"可能形成新的竞争优势".to_string(),
|
||||
"技术积累价值显著".to_string(),
|
||||
"市场机会窗口良好".to_string(),
|
||||
"可能形成可复用能力".to_string(),
|
||||
"积累技术资产".to_string(),
|
||||
];
|
||||
|
||||
// 基于正反方观点的强度计算
|
||||
let positive_strength = positive.confidence;
|
||||
let negative_strength = negative.confidence;
|
||||
let net_positive = (positive_strength - negative_strength + 1.0) / 2.0;
|
||||
|
||||
let final_assessment = if net_positive > 0.7 {
|
||||
AssessmentLevel::StrongGo
|
||||
} else if net_positive > 0.5 {
|
||||
AssessmentLevel::Recommended
|
||||
} else if net_positive > 0.3 {
|
||||
AssessmentLevel::Conditional
|
||||
} else if net_positive > 0.1 {
|
||||
AssessmentLevel::Revised
|
||||
} else {
|
||||
AssessmentLevel::Defer
|
||||
};
|
||||
let summary = format!(
|
||||
"「{}」综合评分 {:.1}/10,{}。建议{}。",
|
||||
idea.title,
|
||||
scores.overall,
|
||||
assessment_desc(&final_assessment),
|
||||
action_hint(&final_assessment)
|
||||
);
|
||||
|
||||
Ok(AnalystAnalysis {
|
||||
summary: format!(
|
||||
"该想法整体价值评估中等偏上,建议在有条件的情况下执行。主要价值在于{},需要关注{}。",
|
||||
idea.title,
|
||||
if net_positive > 0.5 { "风险控制" } else { "价值验证" }
|
||||
),
|
||||
strengths: positive_strengths,
|
||||
summary,
|
||||
strengths,
|
||||
weaknesses,
|
||||
risks,
|
||||
opportunities,
|
||||
@@ -208,101 +233,148 @@ impl AdversarialEngine {
|
||||
})
|
||||
}
|
||||
|
||||
/// 计算最终评估分数和建议
|
||||
fn compute_final_assessment(analyst: &AnalystAnalysis) -> (f64, Recommendation) {
|
||||
// 基于评估等级映射分数
|
||||
let base_score = match analyst.final_assessment {
|
||||
AssessmentLevel::StrongGo => 8.5,
|
||||
AssessmentLevel::Recommended => 7.0,
|
||||
AssessmentLevel::Conditional => 5.5,
|
||||
AssessmentLevel::Revised => 4.0,
|
||||
AssessmentLevel::Defer => 2.5,
|
||||
AssessmentLevel::Reject => 1.0,
|
||||
};
|
||||
|
||||
// 根据优劣势微调分数
|
||||
let strength_count = analyst.strengths.len() as f64;
|
||||
let weakness_count = analyst.weaknesses.len() as f64;
|
||||
let score_adjustment = (strength_count - weakness_count) * 0.3;
|
||||
|
||||
let final_score = (base_score + score_adjustment).clamp(0.0, 10.0);
|
||||
|
||||
let recommendation = match analyst.final_assessment {
|
||||
/// 评估等级 → 最终建议
|
||||
fn recommendation_for(level: &AssessmentLevel) -> Recommendation {
|
||||
match level {
|
||||
AssessmentLevel::StrongGo => Recommendation::ImmediateAction,
|
||||
AssessmentLevel::Recommended => Recommendation::Soon,
|
||||
AssessmentLevel::Conditional => Recommendation::WithResources,
|
||||
AssessmentLevel::Revised => Recommendation::ResearchMore,
|
||||
AssessmentLevel::Defer => Recommendation::Monitor,
|
||||
AssessmentLevel::Reject => Recommendation::Cancel,
|
||||
};
|
||||
|
||||
(final_score, recommendation)
|
||||
}
|
||||
}
|
||||
|
||||
/// 评估结果展示格式
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EvalDisplay {
|
||||
pub idea_title: String,
|
||||
pub positive_strength: f64,
|
||||
pub negative_strength: f64,
|
||||
pub net_sentiment: f64, // -1 到 1,正为正面
|
||||
pub assessment_level: String,
|
||||
pub key_takeaways: Vec<String>,
|
||||
pub action_items: Vec<String>,
|
||||
}
|
||||
|
||||
impl From<AdversarialEval> for EvalDisplay {
|
||||
fn from(eval: AdversarialEval) -> Self {
|
||||
let net_sentiment = (eval.positive.confidence - eval.negative.confidence) as f64;
|
||||
|
||||
let key_takeaways = vec![
|
||||
format!("优势:{}", eval.analyst.strengths.join("、")),
|
||||
format!("风险:{}", eval.analyst.risks.join("、")),
|
||||
format!("建议:{:?}", eval.recommendation),
|
||||
];
|
||||
|
||||
let action_items = match eval.recommendation {
|
||||
Recommendation::ImmediateAction => vec![
|
||||
"立即组建项目团队".to_string(),
|
||||
"制定详细执行计划".to_string(),
|
||||
"分配必要资源".to_string(),
|
||||
],
|
||||
Recommendation::Soon => vec![
|
||||
"下周启动项目".to_string(),
|
||||
"准备资源需求".to_string(),
|
||||
"制定时间表".to_string(),
|
||||
],
|
||||
Recommendation::WithResources => vec![
|
||||
"确认资源预算".to_string(),
|
||||
"评估ROI".to_string(),
|
||||
"制定风险预案".to_string(),
|
||||
],
|
||||
Recommendation::ResearchMore => vec![
|
||||
"进行市场调研".to_string(),
|
||||
"收集用户反馈".to_string(),
|
||||
"验证技术可行性".to_string(),
|
||||
],
|
||||
Recommendation::Monitor => vec![
|
||||
"持续跟踪相关指标".to_string(),
|
||||
"定期评估进展".to_string(),
|
||||
"等待更好的时机".to_string(),
|
||||
],
|
||||
Recommendation::Cancel => vec![
|
||||
"记录归档原因".to_string(),
|
||||
"释放相关资源".to_string(),
|
||||
"提取经验教训".to_string(),
|
||||
],
|
||||
};
|
||||
|
||||
EvalDisplay {
|
||||
idea_title: eval.positive.thesis.split(' ').take(3).collect::<Vec<_>>().join(" "),
|
||||
positive_strength: eval.positive.confidence,
|
||||
negative_strength: eval.negative.confidence,
|
||||
net_sentiment,
|
||||
assessment_level: format!("{:?}", eval.analyst.final_assessment),
|
||||
key_takeaways,
|
||||
action_items,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn priority_label(p: &Priority) -> &'static str {
|
||||
match p {
|
||||
Priority::Critical => "紧急",
|
||||
Priority::High => "高",
|
||||
Priority::Medium => "中",
|
||||
Priority::Low => "低",
|
||||
}
|
||||
}
|
||||
|
||||
fn assessment_desc(level: &AssessmentLevel) -> &'static str {
|
||||
match level {
|
||||
AssessmentLevel::StrongGo => "价值高且可行性强",
|
||||
AssessmentLevel::Recommended => "整体值得推进",
|
||||
AssessmentLevel::Conditional => "有条件地推进",
|
||||
AssessmentLevel::Revised => "需调整后再评估",
|
||||
AssessmentLevel::Defer => "建议暂缓",
|
||||
}
|
||||
}
|
||||
|
||||
fn action_hint(level: &AssessmentLevel) -> &'static str {
|
||||
match level {
|
||||
AssessmentLevel::StrongGo => "立即立项启动",
|
||||
AssessmentLevel::Recommended => "尽快排期",
|
||||
AssessmentLevel::Conditional => "配置资源后启动",
|
||||
AssessmentLevel::Revised => "补充信息后重新评估",
|
||||
AssessmentLevel::Defer => "持续观察时机",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::capture::Idea;
|
||||
use crate::scoring::ScoringEngine;
|
||||
use df_core::types::{IdeaStatus, Priority};
|
||||
|
||||
fn make_idea(title: &str, desc: &str, priority: Priority, tags: Vec<&str>) -> Idea {
|
||||
Idea {
|
||||
id: "test-id".to_string(),
|
||||
title: title.to_string(),
|
||||
description: desc.to_string(),
|
||||
status: IdeaStatus::Draft,
|
||||
priority,
|
||||
scores: None,
|
||||
tags: tags.into_iter().map(String::from).collect(),
|
||||
source: None,
|
||||
related_ids: Vec::new(),
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a1_high_score_immediate_action() {
|
||||
let desc = "面向用户的核心功能,带来显著增长,大幅提升效率。集成成熟方案,复用已有组件。".repeat(3);
|
||||
let idea = make_idea("AI增长引擎", &desc, Priority::Critical, vec!["增长", "核心"]);
|
||||
let scores = ScoringEngine::compute_default(&idea);
|
||||
let eval = AdversarialEngine::evaluate(&idea).await.unwrap();
|
||||
println!("\n[a1] 高分想法 → 期望 ImmediateAction");
|
||||
println!(" scores: feas={:.2} impact={:.2} urg={:.2} overall={:.2}", scores.feasibility, scores.impact, scores.urgency, scores.overall);
|
||||
println!(" eval: final_score={:.2} recommendation={:?}", eval.final_score, eval.recommendation);
|
||||
println!(" 正方 confidence={:.2} 反方 confidence={:.2}", eval.positive.confidence, eval.negative.confidence);
|
||||
assert!(eval.final_score >= 7.5, "final_score 应≥7.5, 实际 {:.2}", eval.final_score);
|
||||
assert_eq!(eval.recommendation, Recommendation::ImmediateAction);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a2_mid_score_soon() {
|
||||
let desc = "面向用户的功能,集成已有方案,提升体验".to_string();
|
||||
let idea = make_idea("体验优化", &desc, Priority::Medium, vec!["体验"]);
|
||||
let scores = ScoringEngine::compute_default(&idea);
|
||||
let eval = AdversarialEngine::evaluate(&idea).await.unwrap();
|
||||
println!("\n[a2] 中分想法 → 期望 Soon");
|
||||
println!(" scores overall={:.2} eval final_score={:.2} recommendation={:?}", scores.overall, eval.final_score, eval.recommendation);
|
||||
assert_eq!(eval.recommendation, Recommendation::Soon);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a3_low_score_monitor() {
|
||||
let desc = "重构迁移大规模分布式重写从零全新架构高并发底层".to_string();
|
||||
let idea = make_idea("过度工程", &desc, Priority::Low, vec![]);
|
||||
let scores = ScoringEngine::compute_default(&idea);
|
||||
let eval = AdversarialEngine::evaluate(&idea).await.unwrap();
|
||||
println!("\n[a3] 低分想法 → 期望 Monitor");
|
||||
println!(" scores overall={:.2} eval final_score={:.2} recommendation={:?}", scores.overall, eval.final_score, eval.recommendation);
|
||||
assert!(eval.final_score < 3.0, "final_score 应<3.0, 实际 {:.2}", eval.final_score);
|
||||
assert_eq!(eval.recommendation, Recommendation::Monitor);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a4_confidence_ranges() {
|
||||
let idea = make_idea("普通想法", "一般描述", Priority::Medium, vec!["标签"]);
|
||||
let eval = AdversarialEngine::evaluate(&idea).await.unwrap();
|
||||
println!("\n[a4] confidence 区间校验");
|
||||
println!(" 正方={:.2} (应∈[0.1, 0.95]) 反方={:.2} (应∈[0.1, 0.9])", eval.positive.confidence, eval.negative.confidence);
|
||||
assert!(eval.positive.confidence >= 0.1 && eval.positive.confidence <= 0.95);
|
||||
assert!(eval.negative.confidence >= 0.1 && eval.negative.confidence <= 0.9);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a5_positive_thesis_contains_title() {
|
||||
let idea = make_idea("独家创意", "描述内容", Priority::High, vec![]);
|
||||
let eval = AdversarialEngine::evaluate(&idea).await.unwrap();
|
||||
println!("\n[a5] 正方论点含标题");
|
||||
println!(" thesis: {}", eval.positive.thesis);
|
||||
assert!(eval.positive.thesis.contains("独家创意"), "正方 thesis 应含标题");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a6_negative_evidence_nonempty() {
|
||||
let idea = make_idea("待质疑想法", "短", Priority::Low, vec![]);
|
||||
let eval = AdversarialEngine::evaluate(&idea).await.unwrap();
|
||||
println!("\n[a6] 反方证据非空 ({} 条)", eval.negative.evidence.len());
|
||||
for (i, e) in eval.negative.evidence.iter().enumerate() {
|
||||
println!(" 证据{}: {}", i + 1, e);
|
||||
}
|
||||
assert!(!eval.negative.evidence.is_empty(), "反方 evidence 不应为空");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a7_final_score_consistency() {
|
||||
let desc = "面向用户的核心功能".to_string();
|
||||
let idea = make_idea("一致性测试", &desc, Priority::High, vec!["核心"]);
|
||||
let scores = ScoringEngine::compute_default(&idea);
|
||||
let eval = AdversarialEngine::evaluate(&idea).await.unwrap();
|
||||
println!("\n[a7] final_score == scores.overall 一致性");
|
||||
println!(" scores.overall={:.2} eval.final_score={:.2}", scores.overall, eval.final_score);
|
||||
println!(" analyst.summary: {}", eval.analyst.summary);
|
||||
assert!((eval.final_score - scores.overall).abs() < 0.001, "final_score 应等于 overall");
|
||||
assert!(eval.analyst.summary.contains("一致性测试"), "summary 应含标题");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
//! 想法评估器 — 对想法进行多维度评估
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use df_core::types::IdeaId;
|
||||
|
||||
use crate::adversarial::{AdversarialEngine, AdversarialEval};
|
||||
use crate::capture::Idea;
|
||||
use crate::scoring::IdeaScores;
|
||||
|
||||
/// 评估维度
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum EvalDimension {
|
||||
/// 可行性
|
||||
Feasibility,
|
||||
/// 影响力
|
||||
Impact,
|
||||
/// 紧急度
|
||||
Urgency,
|
||||
}
|
||||
|
||||
/// 评估结果
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EvalResult {
|
||||
pub idea_id: IdeaId,
|
||||
pub scores: IdeaScores,
|
||||
pub recommendation: Recommendation,
|
||||
pub comments: Vec<String>,
|
||||
}
|
||||
|
||||
/// 评估建议
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Recommendation {
|
||||
/// 强烈推荐立即执行
|
||||
StrongApprove,
|
||||
/// 推荐执行
|
||||
Approve,
|
||||
/// 需要更多信息
|
||||
NeedsInfo,
|
||||
/// 建议推迟
|
||||
Defer,
|
||||
/// 不推荐
|
||||
Reject,
|
||||
}
|
||||
|
||||
/// 想法评估器
|
||||
pub struct IdeaEvaluator;
|
||||
|
||||
impl IdeaEvaluator {
|
||||
/// 评估一个想法 - 使用对抗式评估
|
||||
pub async fn evaluate_adversarial(idea: &Idea) -> Result<AdversarialEval> {
|
||||
AdversarialEngine::evaluate(idea).await
|
||||
}
|
||||
|
||||
/// 评估一个想法 - 保持向后兼容
|
||||
pub fn evaluate(idea: &Idea) -> Result<EvalResult> {
|
||||
// 使用简单评分作为后备
|
||||
let scores = crate::scoring::ScoringEngine::compute_default(idea);
|
||||
|
||||
let recommendation = if scores.overall >= 8.0 {
|
||||
Recommendation::StrongApprove
|
||||
} else if scores.overall >= 6.0 {
|
||||
Recommendation::Approve
|
||||
} else if scores.overall >= 4.0 {
|
||||
Recommendation::NeedsInfo
|
||||
} else if scores.overall >= 2.0 {
|
||||
Recommendation::Defer
|
||||
} else {
|
||||
Recommendation::Reject
|
||||
};
|
||||
|
||||
Ok(EvalResult {
|
||||
idea_id: idea.id.clone(),
|
||||
scores,
|
||||
recommendation,
|
||||
comments: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
//! 想法关联图 — 管理想法之间的关系
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use df_core::types::IdeaId;
|
||||
|
||||
/// 想法之间的关系类型
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RelationKind {
|
||||
/// 相似(语义相近)
|
||||
Similar,
|
||||
/// 依赖(A 依赖 B)
|
||||
DependsOn,
|
||||
/// 衍生(A 衍生自 B)
|
||||
DerivedFrom,
|
||||
/// 互补(A 和 B 可以互补)
|
||||
Complementary,
|
||||
}
|
||||
|
||||
/// 想法关系边
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Relation {
|
||||
pub source_id: IdeaId,
|
||||
pub target_id: IdeaId,
|
||||
pub kind: RelationKind,
|
||||
pub strength: f64, // 0.0 ~ 1.0
|
||||
}
|
||||
|
||||
/// 想法关联图
|
||||
pub struct IdeaGraph {
|
||||
/// 邻接表(idea_id -> 相关关系列表)
|
||||
edges: HashMap<IdeaId, Vec<Relation>>,
|
||||
}
|
||||
|
||||
impl IdeaGraph {
|
||||
/// 创建空图
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
edges: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加关系
|
||||
pub fn add_relation(&mut self, source_id: IdeaId, target_id: IdeaId, kind: RelationKind, strength: f64) {
|
||||
let relation = Relation {
|
||||
source_id: source_id.clone(),
|
||||
target_id: target_id.clone(),
|
||||
kind,
|
||||
strength,
|
||||
};
|
||||
self.edges.entry(source_id).or_default().push(relation.clone());
|
||||
self.edges.entry(target_id).or_default().push(relation);
|
||||
}
|
||||
|
||||
/// 获取与指定想法相关的所有关系
|
||||
pub fn get_relations(&self, idea_id: &IdeaId) -> Vec<&Relation> {
|
||||
self.edges.get(idea_id).map(|r| r.iter().collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 查找相似想法
|
||||
pub fn find_similar(&self, idea_id: &IdeaId) -> Vec<&Relation> {
|
||||
self.get_relations(idea_id)
|
||||
.into_iter()
|
||||
.filter(|r| r.kind == RelationKind::Similar)
|
||||
.collect()
|
||||
}
|
||||
|
||||
// TODO: 基于向量相似度的自动关联发现
|
||||
// TODO: 图遍历、聚类算法
|
||||
}
|
||||
|
||||
impl Default for IdeaGraph {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
//! df-ideas: 想法池 — 捕获、评估、评分、关联图、晋升
|
||||
//! df-ideas: 想法池 — 捕获、评估、评分、晋升
|
||||
|
||||
pub mod adversarial;
|
||||
pub mod capture;
|
||||
pub mod evaluator;
|
||||
pub mod graph;
|
||||
pub mod promotion;
|
||||
pub mod scoring;
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
//! 想法晋升 — 将想法转为项目
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::Serialize;
|
||||
|
||||
use df_core::types::{IdeaId, ProjectId};
|
||||
|
||||
use crate::adversarial::Recommendation;
|
||||
use crate::capture::Idea;
|
||||
use crate::evaluator::Recommendation;
|
||||
|
||||
/// 晋升结果
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PromotionResult {
|
||||
pub idea_id: IdeaId,
|
||||
pub project_id: ProjectId,
|
||||
@@ -44,7 +45,7 @@ impl IdeaPromoter {
|
||||
pub fn try_promote(&self, idea: &Idea, recommendation: &Recommendation) -> Result<PromotionResult> {
|
||||
match self.policy {
|
||||
PromotionPolicy::Auto => {
|
||||
if matches!(recommendation, Recommendation::StrongApprove | Recommendation::Approve) {
|
||||
if matches!(recommendation, Recommendation::ImmediateAction | Recommendation::Soon) {
|
||||
self.do_promote(idea)
|
||||
} else {
|
||||
Ok(PromotionResult {
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
//! 评分引擎 — 多维度加权评分
|
||||
//! 评分引擎 — 基于想法内容的多维度启发式评分
|
||||
//!
|
||||
//! 各维度分数均为 0-10(IPC 层会 *10 缩放为 0-100 以匹配前端)。
|
||||
//! 启发式依据:优先级、描述充实度、标签、关键词信号——保证稳定且有区分度。
|
||||
//! TODO: 接入 AI 做语义级深度评分。
|
||||
|
||||
use crate::capture::Idea;
|
||||
|
||||
/// 想法评分详情(重新导出 capture 模块中的定义)
|
||||
pub use crate::capture::IdeaScores;
|
||||
|
||||
use df_core::types::Priority;
|
||||
|
||||
/// 评分权重配置
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ScoringWeights {
|
||||
@@ -28,17 +34,12 @@ pub struct ScoringEngine;
|
||||
|
||||
impl ScoringEngine {
|
||||
/// 使用默认权重计算评分
|
||||
///
|
||||
/// TODO: 接入 AI 进行深度评分,当前返回基于启发式的分数
|
||||
pub fn compute_default(idea: &Idea) -> IdeaScores {
|
||||
let weights = ScoringWeights::default();
|
||||
Self::compute(idea, &weights)
|
||||
Self::compute(idea, &ScoringWeights::default())
|
||||
}
|
||||
|
||||
/// 使用指定权重计算评分
|
||||
pub fn compute(idea: &Idea, weights: &ScoringWeights) -> IdeaScores {
|
||||
// TODO: 基于想法内容、历史数据、AI 分析等多维度评分
|
||||
// 当前使用基于启发式的占位评分
|
||||
let feasibility = Self::heuristic_feasibility(idea);
|
||||
let impact = Self::heuristic_impact(idea);
|
||||
let urgency = Self::heuristic_urgency(idea);
|
||||
@@ -55,21 +56,181 @@ impl ScoringEngine {
|
||||
}
|
||||
}
|
||||
|
||||
/// 启发式可行性评分
|
||||
fn heuristic_feasibility(_idea: &Idea) -> f64 {
|
||||
// TODO: 基于描述复杂度、资源需求等评估
|
||||
5.0
|
||||
/// 启发式可行性评分(描述充实度 + 技术/资源信号词)
|
||||
fn heuristic_feasibility(idea: &Idea) -> f64 {
|
||||
let mut score = 5.0_f64;
|
||||
let desc = idea.description.trim();
|
||||
if !desc.is_empty() {
|
||||
score += 1.5;
|
||||
}
|
||||
let len = desc.chars().count();
|
||||
if (50..=500).contains(&len) {
|
||||
score += 1.0;
|
||||
} else if len > 500 {
|
||||
// 过长描述通常意味着实现复杂度上升
|
||||
score -= 0.5;
|
||||
}
|
||||
// 可行性正向信号
|
||||
let pos = count_any(desc, &[
|
||||
"复用", "已有", "简单", "集成", "支持", "成熟", "基于", "现成", "脚手架", "模板",
|
||||
]);
|
||||
score += (pos as f64) * 0.5;
|
||||
// 复杂度负向信号
|
||||
let neg = count_any(desc, &[
|
||||
"重构", "迁移", "大规模", "分布式", "重写", "从零", "全新架构", "高并发", "底层",
|
||||
]);
|
||||
score -= (neg as f64) * 0.6;
|
||||
score.clamp(0.0, 10.0)
|
||||
}
|
||||
|
||||
/// 启发式影响力评分
|
||||
fn heuristic_impact(_idea: &Idea) -> f64 {
|
||||
// TODO: 基于业务价值、用户影响等评估
|
||||
5.0
|
||||
/// 启发式影响力评分(优先级 + 价值信号词 + 标签广度)
|
||||
fn heuristic_impact(idea: &Idea) -> f64 {
|
||||
let mut score = match idea.priority {
|
||||
Priority::Critical => 8.0,
|
||||
Priority::High => 6.5,
|
||||
Priority::Medium => 5.0,
|
||||
Priority::Low => 3.5,
|
||||
};
|
||||
if !idea.tags.is_empty() {
|
||||
score += 0.5;
|
||||
// 标签越多影响面越广,上限 +1.0
|
||||
score += (idea.tags.len().min(4) as f64) * 0.25;
|
||||
}
|
||||
let desc = idea.description.trim();
|
||||
let value_hits = count_any(desc, &[
|
||||
"用户", "增长", "收入", "效率", "体验", "核心", "关键", "痛点", "竞品", "留存",
|
||||
]);
|
||||
score += (value_hits as f64) * 0.5;
|
||||
if desc.chars().count() > 100 {
|
||||
score += 0.5;
|
||||
}
|
||||
score.clamp(0.0, 10.0)
|
||||
}
|
||||
|
||||
/// 启发式紧急度评分
|
||||
fn heuristic_urgency(_idea: &Idea) -> f64 {
|
||||
// TODO: 基于优先级、时间窗口等评估
|
||||
5.0
|
||||
/// 启发式紧急度评分(优先级 + 时效信号词)
|
||||
fn heuristic_urgency(idea: &Idea) -> f64 {
|
||||
let mut score = match idea.priority {
|
||||
Priority::Critical => 9.0,
|
||||
Priority::High => 7.0,
|
||||
Priority::Medium => 5.0,
|
||||
Priority::Low => 3.0,
|
||||
};
|
||||
let desc = idea.description.trim();
|
||||
let time_hits = count_any(desc, &[
|
||||
"立即", "马上", "紧急", "尽快", "本周", "上线", "deadline", "截止", "先行", "阻塞",
|
||||
]);
|
||||
score += (time_hits as f64) * 0.5;
|
||||
score.clamp(0.0, 10.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// 统计 text 中命中任一关键词的数量(小写匹配,兼顾中英文)
|
||||
/// 局限:纯子串匹配,不识别"不复用""无用户增长"等否定前缀,接 LLM 后由语义层修正
|
||||
fn count_any(text: &str, keywords: &[&str]) -> usize {
|
||||
let lower = text.to_lowercase();
|
||||
keywords.iter().filter(|kw| lower.contains(*kw)).count()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::capture::Idea;
|
||||
use df_core::types::{IdeaStatus, Priority};
|
||||
|
||||
/// 辅助工厂:构造测试用 Idea(时间/ID 用默认值,不影响评分)
|
||||
fn make_idea(title: &str, desc: &str, priority: Priority, tags: Vec<&str>) -> Idea {
|
||||
Idea {
|
||||
id: "test-id".to_string(),
|
||||
title: title.to_string(),
|
||||
description: desc.to_string(),
|
||||
status: IdeaStatus::Draft,
|
||||
priority,
|
||||
scores: None,
|
||||
tags: tags.into_iter().map(String::from).collect(),
|
||||
source: None,
|
||||
related_ids: Vec::new(),
|
||||
created_at: chrono::Utc::now(),
|
||||
updated_at: chrono::Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s1_empty_idea_baseline() {
|
||||
let idea = make_idea("测试想法", "", Priority::Medium, vec![]);
|
||||
let s = ScoringEngine::compute_default(&idea);
|
||||
println!("\n[s1] 空想法 (Medium / 无描述 / 无标签)");
|
||||
println!(" 可行性={:.2} 影响力={:.2} 紧急度={:.2} 综合={:.2}", s.feasibility, s.impact, s.urgency, s.overall);
|
||||
assert!((s.overall - 5.0).abs() < 0.01, "空想法 overall 应为 5.0, 实际 {:.2}", s.overall);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s2_high_priority_value_desc() {
|
||||
let desc = "面向用户的核心功能,带来显著增长,大幅提升效率。集成成熟方案,复用已有组件,快速交付价值。".repeat(3);
|
||||
let idea = make_idea("增长引擎", &desc, Priority::Critical, vec!["增长", "核心"]);
|
||||
let s = ScoringEngine::compute_default(&idea);
|
||||
println!("\n[s2] 高优先级 + 价值描述 (Critical / ~120字 / 含价值词)");
|
||||
println!(" 可行性={:.2} 影响力={:.2} 紧急度={:.2} 综合={:.2}", s.feasibility, s.impact, s.urgency, s.overall);
|
||||
assert!(s.impact >= 7.0, "impact 应≥7.0, 实际 {:.2}", s.impact);
|
||||
assert!(s.urgency >= 8.0, "urgency 应≥8.0, 实际 {:.2}", s.urgency);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s3_low_priority_short_desc() {
|
||||
let idea = make_idea("小优化", "一句话", Priority::Low, vec![]);
|
||||
let s = ScoringEngine::compute_default(&idea);
|
||||
println!("\n[s3] 低优先级 + 短描述 (Low / 3字)");
|
||||
println!(" 可行性={:.2} 影响力={:.2} 紧急度={:.2} 综合={:.2}", s.feasibility, s.impact, s.urgency, s.overall);
|
||||
assert!((s.overall - 4.6).abs() < 0.01, "overall 应为 4.6, 实际 {:.2}", s.overall);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s4_feasibility_positive_signals() {
|
||||
let idea = make_idea("复用方案", "复用已有组件,简单集成现成脚手架", Priority::Medium, vec![]);
|
||||
let s = ScoringEngine::compute_default(&idea);
|
||||
println!("\n[s4] 可行性正向信号 (含 复用/已有/简单/集成/现成/脚手架)");
|
||||
println!(" 可行性={:.2} (预期 9.5)", s.feasibility);
|
||||
assert!((s.feasibility - 9.5).abs() < 0.01, "正向信号 feasibility 应为 9.5, 实际 {:.2}", s.feasibility);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s5_feasibility_negative_signals() {
|
||||
let idea = make_idea("大重构", "大规模重构迁移,分布式重写从零开始", Priority::Medium, vec![]);
|
||||
let s = ScoringEngine::compute_default(&idea);
|
||||
println!("\n[s5] 可行性负向信号 (含 大规模/重构/迁移/分布式/重写/从零)");
|
||||
println!(" 可行性={:.2} (预期 ≤5.0)", s.feasibility);
|
||||
assert!(s.feasibility <= 5.0, "负向信号 feasibility 应≤5.0, 实际 {:.2}", s.feasibility);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s6_custom_weights() {
|
||||
let idea = make_idea("高可行低影响", "复用已有简单集成现成", Priority::Low, vec![]);
|
||||
let custom = ScoringWeights { feasibility: 0.7, impact: 0.2, urgency: 0.1 };
|
||||
let s_custom = ScoringEngine::compute(&idea, &custom);
|
||||
let s_default = ScoringEngine::compute_default(&idea);
|
||||
let manual = s_custom.feasibility * 0.7 + s_custom.impact * 0.2 + s_custom.urgency * 0.1;
|
||||
println!("\n[s6] 自定义权重 (feas:0.7 / impact:0.2 / urg:0.1)");
|
||||
println!(" 自定义综合={:.2} 默认综合={:.2} 手算加权={:.2}", s_custom.overall, s_default.overall, manual);
|
||||
assert!((s_custom.overall - manual).abs() < 0.01, "overall 应等于手算加权");
|
||||
assert!(s_custom.overall > s_default.overall, "高 feas 配高权重应让综合更高");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s7_clamp_upper_bound() {
|
||||
let desc = "复用已有简单集成现成成熟基于脚手架模板支持".repeat(20);
|
||||
let idea = make_idea("满分想法", &desc, Priority::Critical, vec!["a", "b", "c", "d"]);
|
||||
let s = ScoringEngine::compute_default(&idea);
|
||||
println!("\n[s7] clamp 上限 (堆正向词 + 超长描述 + Critical)");
|
||||
println!(" 可行性={:.2} 影响力={:.2} 紧急度={:.2} 综合={:.2}", s.feasibility, s.impact, s.urgency, s.overall);
|
||||
assert!(s.feasibility <= 10.0 && s.impact <= 10.0 && s.urgency <= 10.0, "所有维度应≤10");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s8_clamp_lower_bound() {
|
||||
let desc = "重构迁移大规模分布式重写从零全新架构高并发底层".repeat(20);
|
||||
let idea = make_idea("灾难想法", &desc, Priority::Low, vec![]);
|
||||
let s = ScoringEngine::compute_default(&idea);
|
||||
println!("\n[s8] clamp 下限 (堆负向词 + Low)");
|
||||
println!(" 可行性={:.2} 影响力={:.2} 紧急度={:.2} 综合={:.2}", s.feasibility, s.impact, s.urgency, s.overall);
|
||||
assert!(s.feasibility >= 0.0 && s.impact >= 0.0 && s.urgency >= 0.0, "所有维度应≥0");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,104 @@
|
||||
//! 与 AI Chat(侧边栏交互对话)的区别:AI Node 由 DAG Executor 自动驱动,
|
||||
//! 适合嵌入自动化链路(如 想法 → AI分析 → 脚本落地 → 人工审批)。
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_ai::anthropic_compat::AnthropicCompatProvider;
|
||||
use df_ai::openai_compat::OpenAICompatProvider;
|
||||
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
|
||||
/// AI 节点解析后的参数(execute 与参数解析解耦,便于单测覆盖取值/默认/校验逻辑)
|
||||
#[derive(Debug)]
|
||||
struct AiNodeParams {
|
||||
base_url: String,
|
||||
api_key: String,
|
||||
prompt: String,
|
||||
system_prompt: Option<String>,
|
||||
model: String,
|
||||
temperature: Option<f32>,
|
||||
max_tokens: Option<u32>,
|
||||
/// 协议类型:openai_compat(默认)/ anthropic(GLM 订阅 / Claude 官方)
|
||||
protocol: String,
|
||||
/// model 为空时的占位,避免 provider 构造 panic
|
||||
default_model: String,
|
||||
}
|
||||
|
||||
/// 从节点 config + 上游输入解析 AI 节点参数
|
||||
///
|
||||
/// prompt 取值优先级:上游 `inputs["prompt"]` > `config.prompt`,两者皆无则报错。
|
||||
/// model 为空时 default_model 兜底为 "gpt-4o-mini"。protocol 默认 openai_compat。
|
||||
fn parse_params(
|
||||
config: &serde_json::Value,
|
||||
inputs: &HashMap<String, NodeOutput>,
|
||||
) -> anyhow::Result<AiNodeParams> {
|
||||
// ── provider 配置(必填)──
|
||||
let base_url = config
|
||||
.get("base_url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("AiNode 缺少必填参数: base_url"))?
|
||||
.to_string();
|
||||
let api_key = config
|
||||
.get("api_key")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("AiNode 缺少必填参数: api_key"))?
|
||||
.to_string();
|
||||
|
||||
// ── prompt(必填):优先取上游节点 "prompt" 输出,回退 config.prompt ──
|
||||
let prompt = inputs
|
||||
.get("prompt")
|
||||
.and_then(|o| o.data.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
config
|
||||
.get("prompt")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("AiNode 缺少必填参数: prompt(config 或上游输入均无)"))?;
|
||||
|
||||
// ── 可选参数 ──
|
||||
let model = config
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let temperature = config
|
||||
.get("temperature")
|
||||
.and_then(|v| v.as_f64())
|
||||
.map(|f| f as f32);
|
||||
let max_tokens = config
|
||||
.get("max_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|n| n as u32);
|
||||
let system_prompt = config
|
||||
.get("system_prompt")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
let protocol = config
|
||||
.get("protocol")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("openai_compat")
|
||||
.to_string();
|
||||
// default_model:留空时给一个占位,避免 provider 构造 panic
|
||||
let default_model = if model.is_empty() {
|
||||
"gpt-4o-mini".to_string()
|
||||
} else {
|
||||
model.clone()
|
||||
};
|
||||
|
||||
Ok(AiNodeParams {
|
||||
base_url,
|
||||
api_key,
|
||||
prompt,
|
||||
system_prompt,
|
||||
model,
|
||||
temperature,
|
||||
max_tokens,
|
||||
protocol,
|
||||
default_model,
|
||||
})
|
||||
}
|
||||
|
||||
/// AI 节点
|
||||
pub struct AiNode;
|
||||
|
||||
@@ -20,90 +112,23 @@ impl Node for AiNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
tracing::info!("AiNode 执行: node_id={}", ctx.node_id);
|
||||
|
||||
// ── provider 配置(必填)──
|
||||
let base_url = ctx
|
||||
.config
|
||||
.get("base_url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("AiNode 缺少必填参数: base_url"))?
|
||||
.to_string();
|
||||
let p = parse_params(&ctx.config, &ctx.inputs)?;
|
||||
|
||||
let api_key = ctx
|
||||
.config
|
||||
.get("api_key")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("AiNode 缺少必填参数: api_key"))?
|
||||
.to_string();
|
||||
|
||||
// ── prompt(必填):优先取上游节点 "prompt" 输出,回退 config.prompt ──
|
||||
let prompt = ctx
|
||||
.inputs
|
||||
.get("prompt")
|
||||
.and_then(|o| o.data.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| {
|
||||
ctx.config
|
||||
.get("prompt")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
})
|
||||
.ok_or_else(|| anyhow::anyhow!("AiNode 缺少必填参数: prompt(config 或上游输入均无)"))?;
|
||||
|
||||
// ── 可选参数 ──
|
||||
let model = ctx
|
||||
.config
|
||||
.get("model")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let temperature = ctx
|
||||
.config
|
||||
.get("temperature")
|
||||
.and_then(|v| v.as_f64())
|
||||
.map(|f| f as f32);
|
||||
let max_tokens = ctx
|
||||
.config
|
||||
.get("max_tokens")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|n| n as u32);
|
||||
let system_prompt = ctx
|
||||
.config
|
||||
.get("system_prompt")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// 协议类型:默认 openai_compat,可设 anthropic(GLM 订阅端点 / Claude 官方)
|
||||
let protocol = ctx
|
||||
.config
|
||||
.get("protocol")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("openai_compat")
|
||||
.to_string();
|
||||
|
||||
// default_model:留空时给一个占位,避免 provider 构造 panic
|
||||
let default_model = if model.is_empty() {
|
||||
"gpt-4o-mini".to_string()
|
||||
} else {
|
||||
model.clone()
|
||||
};
|
||||
|
||||
let provider: Box<dyn LlmProvider> = match protocol.as_str() {
|
||||
"anthropic" => Box::new(AnthropicCompatProvider::new(&base_url, &api_key, &default_model)),
|
||||
_ => Box::new(OpenAICompatProvider::new(&base_url, &api_key, &default_model)),
|
||||
};
|
||||
let provider: Box<dyn LlmProvider> =
|
||||
df_ai::build_provider(&p.protocol, &p.base_url, &p.api_key, &p.default_model);
|
||||
|
||||
// ── 构建消息 ──
|
||||
let mut messages = Vec::with_capacity(2);
|
||||
if let Some(sys) = system_prompt {
|
||||
if let Some(sys) = p.system_prompt {
|
||||
messages.push(ChatMessage::system(sys));
|
||||
}
|
||||
messages.push(ChatMessage::user(prompt));
|
||||
messages.push(ChatMessage::user(p.prompt));
|
||||
|
||||
let request = CompletionRequest {
|
||||
model: model.clone(),
|
||||
model: p.model.clone(),
|
||||
messages,
|
||||
temperature,
|
||||
max_tokens,
|
||||
temperature: p.temperature,
|
||||
max_tokens: p.max_tokens,
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
@@ -111,8 +136,8 @@ impl Node for AiNode {
|
||||
|
||||
tracing::info!(
|
||||
"AiNode 调用 LLM: model={}, base_url={}",
|
||||
default_model,
|
||||
base_url
|
||||
p.default_model,
|
||||
p.base_url
|
||||
);
|
||||
let response = provider.complete(request).await?;
|
||||
|
||||
@@ -148,7 +173,7 @@ impl Node for AiNode {
|
||||
"temperature": { "type": "number", "description": "温度 0.0~2.0(可选)" },
|
||||
"max_tokens": { "type": "integer", "description": "最大生成 token(可选,anthropic 协议无值时默认 4096)" }
|
||||
},
|
||||
"required": ["prompt", "base_url", "api_key"]
|
||||
"required": ["base_url", "api_key"]
|
||||
}),
|
||||
output: serde_json::json!({
|
||||
"type": "object",
|
||||
@@ -165,3 +190,137 @@ impl Node for AiNode {
|
||||
"ai"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// 造带基础三字段(base_url/api_key/prompt)的 config,overrides 覆盖或追加
|
||||
fn config_with(overrides: serde_json::Value) -> serde_json::Value {
|
||||
let mut base = json!({
|
||||
"base_url": "https://api.example.com",
|
||||
"api_key": "sk-test",
|
||||
"prompt": "config-prompt"
|
||||
});
|
||||
if let (serde_json::Value::Object(b), serde_json::Value::Object(o)) = (&mut base, overrides) {
|
||||
for (k, v) in o {
|
||||
b.insert(k, v);
|
||||
}
|
||||
}
|
||||
base
|
||||
}
|
||||
|
||||
fn empty_inputs() -> HashMap<String, NodeOutput> {
|
||||
HashMap::new()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_base_url_errors() {
|
||||
let config = json!({ "api_key": "k", "prompt": "p" });
|
||||
let err = parse_params(&config, &empty_inputs()).unwrap_err().to_string();
|
||||
assert!(err.contains("base_url"), "缺 base_url 应报错, 实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_api_key_errors() {
|
||||
let config = json!({ "base_url": "http://x", "prompt": "p" });
|
||||
let err = parse_params(&config, &empty_inputs()).unwrap_err().to_string();
|
||||
assert!(err.contains("api_key"), "缺 api_key 应报错, 实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_prompt_errors() {
|
||||
let config = json!({ "base_url": "http://x", "api_key": "k" });
|
||||
let err = parse_params(&config, &empty_inputs()).unwrap_err().to_string();
|
||||
assert!(err.contains("prompt"), "缺 prompt 应报错, 实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_from_config_when_no_upstream() {
|
||||
let p = parse_params(&config_with(json!({})), &empty_inputs()).unwrap();
|
||||
assert_eq!(p.prompt, "config-prompt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_prefers_upstream_input_over_config() {
|
||||
let mut inputs = empty_inputs();
|
||||
inputs.insert(
|
||||
"prompt".to_string(),
|
||||
NodeOutput::from_value(json!("upstream-prompt")),
|
||||
);
|
||||
let p = parse_params(&config_with(json!({})), &inputs).unwrap();
|
||||
assert_eq!(p.prompt, "upstream-prompt", "上游输入应优先于 config.prompt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_when_optional_fields_missing() {
|
||||
let p = parse_params(&config_with(json!({})), &empty_inputs()).unwrap();
|
||||
assert_eq!(p.model, "");
|
||||
assert_eq!(p.default_model, "gpt-4o-mini", "model 空时 default_model 兜底");
|
||||
assert_eq!(p.protocol, "openai_compat", "protocol 默认 openai_compat");
|
||||
assert_eq!(p.temperature, None);
|
||||
assert_eq!(p.max_tokens, None);
|
||||
assert_eq!(p.system_prompt, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optional_fields_parsed_when_present() {
|
||||
let p = parse_params(
|
||||
&config_with(json!({
|
||||
"model": "glm-4",
|
||||
"protocol": "anthropic",
|
||||
"temperature": 0.3,
|
||||
"max_tokens": 1024,
|
||||
"system_prompt": "你是助手"
|
||||
})),
|
||||
&empty_inputs(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(p.model, "glm-4");
|
||||
assert_eq!(p.default_model, "glm-4", "model 非空时 default_model = model");
|
||||
assert_eq!(p.protocol, "anthropic");
|
||||
assert_eq!(p.temperature, Some(0.3));
|
||||
assert_eq!(p.max_tokens, Some(1024));
|
||||
assert_eq!(p.system_prompt.as_deref(), Some("你是助手"));
|
||||
}
|
||||
|
||||
/// 真调 GLM 验证 provider 调用层(parse_params 已由上方单测覆盖,此处补 complete 端到端)
|
||||
///
|
||||
/// `#[ignore]`:需真实 GLM 配置(env var),默认不跑。
|
||||
/// 跑法:`GLM_BASE_URL=... GLM_API_KEY=... GLM_MODEL=glm-4-flash \
|
||||
/// cargo test -p df-nodes --lib glm_live_complete -- --ignored --nocapture`
|
||||
/// env var 缺失 → 跳过(非失败)。
|
||||
#[ignore = "需真实 GLM 配置(env var)"]
|
||||
#[tokio::test]
|
||||
async fn glm_live_complete() {
|
||||
let base_url = std::env::var("GLM_BASE_URL").ok().filter(|s| !s.is_empty());
|
||||
let api_key = std::env::var("GLM_API_KEY").ok().filter(|s| !s.is_empty());
|
||||
let (base_url, api_key) = match (base_url, api_key) {
|
||||
(Some(b), Some(k)) => (b, k),
|
||||
_ => {
|
||||
eprintln!("跳过: 未设 GLM_BASE_URL / GLM_API_KEY env var");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let model = std::env::var("GLM_MODEL").unwrap_or_else(|_| "glm-4-flash".to_string());
|
||||
|
||||
let provider: Box<dyn LlmProvider> =
|
||||
df_ai::build_provider("openai_compat", &base_url, &api_key, &model);
|
||||
let request = CompletionRequest {
|
||||
model: model.clone(),
|
||||
messages: vec![ChatMessage::user("只回复两个字:通过")],
|
||||
temperature: Some(0.0),
|
||||
max_tokens: Some(16),
|
||||
stream: false,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
};
|
||||
let response = provider.complete(request).await.expect("GLM 调用失败");
|
||||
assert!(!response.text.is_empty(), "GLM 返回空文本");
|
||||
println!(
|
||||
"GLM 响应: model={}, text={}, usage={:?}",
|
||||
response.model, response.text, response.usage
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
//! Docker 节点 — 在容器中执行任务
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
|
||||
/// Docker 节点
|
||||
pub struct DockerNode;
|
||||
|
||||
#[async_trait]
|
||||
impl Node for DockerNode {
|
||||
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
|
||||
// TODO: 接入 df-execute 的 Docker 执行器
|
||||
tracing::info!("DockerNode 执行: 在容器中运行");
|
||||
Ok(NodeOutput::empty())
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"image": { "type": "string" },
|
||||
"command": { "type": "string" },
|
||||
"env": { "type": "object" }
|
||||
},
|
||||
"required": ["image"]
|
||||
}),
|
||||
output: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"stdout": { "type": "string" },
|
||||
"exit_code": { "type": "integer" }
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"docker"
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
//! Git 节点 — 执行 Git 操作(克隆、提交、推送、合并等)
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
|
||||
/// Git 节点
|
||||
pub struct GitNode;
|
||||
|
||||
#[async_trait]
|
||||
impl Node for GitNode {
|
||||
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
|
||||
// TODO: 接入 df-execute 的 Git 操作
|
||||
tracing::info!("GitNode 执行: Git 操作");
|
||||
Ok(NodeOutput::empty())
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": { "type": "string", "enum": ["clone", "commit", "push", "merge", "checkout"] },
|
||||
"repo": { "type": "string" },
|
||||
"branch": { "type": "string" }
|
||||
},
|
||||
"required": ["action"]
|
||||
}),
|
||||
output: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": { "type": "boolean" },
|
||||
"message": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"git"
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
//! HTTP 节点 — 发起 HTTP 请求
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
|
||||
/// HTTP 节点
|
||||
pub struct HttpNode;
|
||||
|
||||
#[async_trait]
|
||||
impl Node for HttpNode {
|
||||
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
|
||||
// TODO: 实现HTTP请求逻辑
|
||||
tracing::info!("HttpNode 执行: 发送 HTTP 请求");
|
||||
Ok(NodeOutput::empty())
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"method": { "type": "string", "enum": ["GET", "POST", "PUT", "DELETE"] },
|
||||
"url": { "type": "string" },
|
||||
"headers": { "type": "object" },
|
||||
"body": {}
|
||||
},
|
||||
"required": ["method", "url"]
|
||||
}),
|
||||
output: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": { "type": "integer" },
|
||||
"body": {},
|
||||
"headers": { "type": "object" }
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"http"
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,5 @@
|
||||
//! df-nodes: 内置节点集合 — AI、脚本、Docker、Git、人工审批、HTTP、子流程、通知
|
||||
//! df-nodes: 内置节点集合 — AI、脚本、人工审批
|
||||
|
||||
pub mod ai_node;
|
||||
pub mod docker_node;
|
||||
pub mod git_node;
|
||||
pub mod http_node;
|
||||
pub mod human_node;
|
||||
pub mod notify_node;
|
||||
pub mod script_node;
|
||||
pub mod subflow_node;
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
//! 通知节点 — 发送通知(邮件、飞书、钉钉等)
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
|
||||
/// 通知节点
|
||||
pub struct NotifyNode;
|
||||
|
||||
#[async_trait]
|
||||
impl Node for NotifyNode {
|
||||
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
|
||||
// TODO: 实现通知发送逻辑
|
||||
tracing::info!("NotifyNode 执行: 发送通知");
|
||||
Ok(NodeOutput::empty())
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"channel": { "type": "string", "enum": ["email", "feishu", "dingtalk", "webhook"] },
|
||||
"to": { "type": "string" },
|
||||
"title": { "type": "string" },
|
||||
"body": { "type": "string" }
|
||||
},
|
||||
"required": ["channel", "to", "body"]
|
||||
}),
|
||||
output: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": { "type": "boolean" }
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"notify"
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
//! 子流程节点 — 嵌套执行另一个工作流
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
|
||||
/// 子流程节点
|
||||
pub struct SubflowNode;
|
||||
|
||||
#[async_trait]
|
||||
impl Node for SubflowNode {
|
||||
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
|
||||
// TODO: 实现子工作流加载与执行
|
||||
tracing::info!("SubflowNode 执行: 启动子工作流");
|
||||
Ok(NodeOutput::empty())
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workflow_id": { "type": "string" },
|
||||
"inputs": { "type": "object" }
|
||||
},
|
||||
"required": ["workflow_id"]
|
||||
}),
|
||||
output: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"outputs": { "type": "object" }
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"subflow"
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
[package]
|
||||
name = "df-plugin"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
df-core = { path = "../df-core" }
|
||||
df-workflow = { path = "../df-workflow" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
@@ -1,66 +0,0 @@
|
||||
//! 插件宿主环境 — 管理插件生命周期与沙箱
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use df_core::types::PluginId;
|
||||
use df_workflow::node::Node;
|
||||
|
||||
use crate::loader::{LoadedPlugin, PluginLoader, PluginMetadata};
|
||||
|
||||
/// 插件宿主环境
|
||||
pub struct PluginHost {
|
||||
/// 插件加载器
|
||||
loader: PluginLoader,
|
||||
/// 已加载的插件
|
||||
plugins: HashMap<PluginId, LoadedPlugin>,
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// 创建宿主环境
|
||||
pub fn new(loader: PluginLoader) -> Self {
|
||||
Self {
|
||||
loader,
|
||||
plugins: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 初始化:加载所有插件
|
||||
pub async fn initialize(&mut self) -> anyhow::Result<()> {
|
||||
self.plugins = self.loader.load_all()?;
|
||||
tracing::info!("插件宿主环境初始化完成,加载了 {} 个插件", self.plugins.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取所有已注册的节点
|
||||
pub fn all_nodes(&self) -> Vec<(&PluginId, &Box<dyn Node>)> {
|
||||
let mut nodes = Vec::new();
|
||||
for (plugin_id, plugin) in &self.plugins {
|
||||
for node in &plugin.nodes {
|
||||
nodes.push((plugin_id, node));
|
||||
}
|
||||
}
|
||||
nodes
|
||||
}
|
||||
|
||||
/// 获取指定插件的元数据
|
||||
pub fn get_metadata(&self, plugin_id: &PluginId) -> Option<&PluginMetadata> {
|
||||
self.plugins.get(plugin_id).map(|p| &p.metadata)
|
||||
}
|
||||
|
||||
/// 卸载指定插件
|
||||
///
|
||||
/// TODO: 实现插件卸载(清理资源、取消注册节点等)
|
||||
pub fn unload(&mut self, plugin_id: &PluginId) -> anyhow::Result<()> {
|
||||
if let Some(plugin) = self.plugins.remove(plugin_id) {
|
||||
tracing::info!("插件 {} 已卸载", plugin.metadata.name);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 关闭宿主环境,卸载所有插件
|
||||
pub fn shutdown(&mut self) {
|
||||
let count = self.plugins.len();
|
||||
self.plugins.clear();
|
||||
tracing::info!("插件宿主环境关闭,卸载了 {} 个插件", count);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
//! df-plugin: 插件系统 — 插件加载、宿主环境、SDK
|
||||
|
||||
pub mod host;
|
||||
pub mod loader;
|
||||
pub mod sdk;
|
||||
@@ -1,94 +0,0 @@
|
||||
//! 插件加载器 — 发现、加载、注册插件
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use df_core::types::PluginId;
|
||||
use df_workflow::node::Node;
|
||||
|
||||
/// 插件元数据
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PluginMetadata {
|
||||
/// 插件 ID
|
||||
pub id: PluginId,
|
||||
/// 插件名称
|
||||
pub name: String,
|
||||
/// 版本
|
||||
pub version: String,
|
||||
/// 描述
|
||||
pub description: String,
|
||||
/// 作者
|
||||
pub author: Option<String>,
|
||||
/// 入口文件路径
|
||||
pub entry_path: PathBuf,
|
||||
}
|
||||
|
||||
/// 已加载的插件
|
||||
pub struct LoadedPlugin {
|
||||
pub metadata: PluginMetadata,
|
||||
pub nodes: Vec<Box<dyn Node>>,
|
||||
}
|
||||
|
||||
/// 插件加载器
|
||||
pub struct PluginLoader {
|
||||
/// 插件搜索目录
|
||||
search_dirs: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl PluginLoader {
|
||||
/// 创建插件加载器
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
search_dirs: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加插件搜索目录
|
||||
pub fn add_search_dir(&mut self, dir: PathBuf) {
|
||||
self.search_dirs.push(dir);
|
||||
}
|
||||
|
||||
/// 扫描并发现所有可用插件
|
||||
///
|
||||
/// TODO: 实现文件系统扫描、manifest 解析
|
||||
pub fn discover(&self) -> anyhow::Result<Vec<PluginMetadata>> {
|
||||
tracing::info!("扫描插件目录: {:?}", self.search_dirs);
|
||||
// TODO: 实现插件发现逻辑
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
/// 加载指定插件
|
||||
///
|
||||
/// TODO: 实现动态库加载(dlopen/LoadLibrary)或 WASM 加载
|
||||
pub fn load(&self, _metadata: &PluginMetadata) -> anyhow::Result<LoadedPlugin> {
|
||||
// TODO: 实现插件加载
|
||||
tracing::warn!("插件加载尚未实现");
|
||||
Err(anyhow::anyhow!("插件加载尚未实现"))
|
||||
}
|
||||
|
||||
/// 批量加载所有发现的插件
|
||||
pub fn load_all(&self) -> anyhow::Result<HashMap<PluginId, LoadedPlugin>> {
|
||||
let metadata_list = self.discover()?;
|
||||
let mut loaded = HashMap::new();
|
||||
|
||||
for meta in metadata_list {
|
||||
match self.load(&meta) {
|
||||
Ok(plugin) => {
|
||||
tracing::info!("插件 {} v{} 加载成功", meta.name, meta.version);
|
||||
loaded.insert(meta.id.clone(), plugin);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("插件 {} 加载失败: {}", meta.name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(loaded)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PluginLoader {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
//! 插件 SDK — 插件开发者使用的工具与trait
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_workflow::node::Node;
|
||||
|
||||
/// 插件入口 trait — 所有插件必须实现
|
||||
#[async_trait]
|
||||
pub trait Plugin: Send + Sync {
|
||||
/// 插件 ID
|
||||
fn id(&self) -> &str;
|
||||
|
||||
/// 插件名称
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// 插件版本
|
||||
fn version(&self) -> &str;
|
||||
|
||||
/// 注册的所有节点
|
||||
fn nodes(&self) -> Vec<Box<dyn Node>>;
|
||||
|
||||
/// 插件初始化(可选)
|
||||
async fn initialize(&self) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 插件销毁(可选)
|
||||
async fn shutdown(&self) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// 插件构建器 — 简化插件定义
|
||||
pub struct PluginBuilder {
|
||||
id: String,
|
||||
name: String,
|
||||
version: String,
|
||||
nodes: Vec<Box<dyn Node>>,
|
||||
}
|
||||
|
||||
impl PluginBuilder {
|
||||
/// 创建插件构建器
|
||||
pub fn new(id: impl Into<String>, name: impl Into<String>, version: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
version: version.into(),
|
||||
nodes: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 注册节点
|
||||
pub fn node(mut self, node: Box<dyn Node>) -> Self {
|
||||
self.nodes.push(node);
|
||||
self
|
||||
}
|
||||
|
||||
/// 获取插件 ID
|
||||
pub fn id(&self) -> &str {
|
||||
&self.id
|
||||
}
|
||||
|
||||
/// 获取插件名称
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
/// 获取插件版本
|
||||
pub fn version(&self) -> &str {
|
||||
&self.version
|
||||
}
|
||||
|
||||
/// 构建并获取所有节点
|
||||
pub fn build(self) -> Vec<Box<dyn Node>> {
|
||||
self.nodes
|
||||
}
|
||||
}
|
||||
|
||||
/// 声明插件的宏(简化版)
|
||||
///
|
||||
/// TODO: 实现完整的声明宏
|
||||
/// 使用示例:
|
||||
/// ```
|
||||
/// declare_plugin!("my-plugin", "My Plugin", "0.1.0");
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! declare_plugin {
|
||||
($id:expr, $name:expr, $version:expr) => {
|
||||
// TODO: 实现宏
|
||||
};
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
//! 项目上下文 — 项目运行时的环境与配置信息
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use df_core::types::ProjectId;
|
||||
|
||||
/// 项目上下文
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProjectContext {
|
||||
/// 项目 ID
|
||||
pub project_id: ProjectId,
|
||||
/// 项目根目录(本地文件系统路径)
|
||||
pub root_path: Option<String>,
|
||||
/// Git 仓库 URL
|
||||
pub repo_url: Option<String>,
|
||||
/// 当前分支
|
||||
pub current_branch: Option<String>,
|
||||
/// 环境变量
|
||||
pub env_vars: std::collections::HashMap<String, String>,
|
||||
/// 技术栈
|
||||
pub tech_stack: Vec<String>,
|
||||
/// AI 上下文(项目相关的 AI 记忆)
|
||||
pub ai_context: Option<AiContext>,
|
||||
}
|
||||
|
||||
/// AI 上下文信息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AiContext {
|
||||
/// 项目摘要
|
||||
pub summary: String,
|
||||
/// 架构描述
|
||||
pub architecture: Option<String>,
|
||||
/// 关键决策记录
|
||||
pub decisions: Vec<String>,
|
||||
/// 最近修改摘要
|
||||
pub recent_changes: Vec<String>,
|
||||
}
|
||||
|
||||
impl ProjectContext {
|
||||
/// 创建空上下文
|
||||
pub fn new(project_id: ProjectId) -> Self {
|
||||
Self {
|
||||
project_id,
|
||||
root_path: None,
|
||||
repo_url: None,
|
||||
current_branch: None,
|
||||
env_vars: std::collections::HashMap::new(),
|
||||
tech_stack: Vec::new(),
|
||||
ai_context: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
//! df-project: 项目管理 — 项目创建、调度、上下文、时间线
|
||||
//! df-project: 项目管理 — 项目创建、技术栈探测
|
||||
|
||||
pub mod context;
|
||||
pub mod manager;
|
||||
pub mod scheduler;
|
||||
pub mod timeline;
|
||||
pub mod scan;
|
||||
|
||||
303
crates/df-project/src/scan.rs
Normal file
303
crates/df-project/src/scan.rs
Normal file
@@ -0,0 +1,303 @@
|
||||
//! 项目技术栈探测 — 浅读根目录标志文件识别技术栈
|
||||
//!
|
||||
//! 纯函数、零状态、零外部依赖(仅 std + serde_json + anyhow)。
|
||||
//! 只读根目录标志文件,不递归遍历(控制性能与安全)。
|
||||
//!
|
||||
//! 供 commands 层薄封装暴露为 IPC 命令,新建/导入项目时自动填充 `ProjectRecord.stack`;
|
||||
//! 未来 df-ai/df-workflow 亦可复用以感知「项目是什么技术栈」。
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
/// 探测目录的技术栈
|
||||
///
|
||||
/// 返回去重后的技术栈标签数组(如 `["rust","vue","tauri","typescript"]`)。
|
||||
/// 空数组表示未识别出任何已知标志(空目录或非常规项目)。非目录返回 Err。
|
||||
pub fn detect_stack(root: &Path) -> Result<Vec<String>> {
|
||||
if !root.is_dir() {
|
||||
anyhow::bail!("路径不是目录: {}", root.display());
|
||||
}
|
||||
let mut stack: Vec<String> = Vec::new();
|
||||
|
||||
// ── 后端/系统语言 ──
|
||||
if root.join("Cargo.toml").exists() {
|
||||
push_unique(&mut stack, "rust");
|
||||
}
|
||||
if root.join("go.mod").exists() {
|
||||
push_unique(&mut stack, "go");
|
||||
}
|
||||
if root.join("pom.xml").exists()
|
||||
|| root.join("build.gradle").exists()
|
||||
|| root.join("build.gradle.kts").exists()
|
||||
{
|
||||
push_unique(&mut stack, "java");
|
||||
}
|
||||
if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() {
|
||||
push_unique(&mut stack, "python");
|
||||
}
|
||||
// C#: 根目录存在 .csproj 文件(仅一层)
|
||||
if has_file_with_ext(root, "csproj") {
|
||||
push_unique(&mut stack, "csharp");
|
||||
}
|
||||
|
||||
// ── Tauri 桌面应用(通常含 src-tauri 目录) ──
|
||||
if root.join("src-tauri").is_dir() {
|
||||
push_unique(&mut stack, "tauri");
|
||||
}
|
||||
|
||||
// ── 前端/Node:解析 package.json 的依赖推断框架 ──
|
||||
if root.join("package.json").exists() {
|
||||
if let Ok(deps) = read_package_deps(root.join("package.json")) {
|
||||
if deps.iter().any(|d| d == "vue") {
|
||||
push_unique(&mut stack, "vue");
|
||||
}
|
||||
if deps.iter().any(|d| d == "react" || d == "react-dom") {
|
||||
push_unique(&mut stack, "react");
|
||||
}
|
||||
if deps.iter().any(|d| d == "@angular/core") {
|
||||
push_unique(&mut stack, "angular");
|
||||
}
|
||||
if deps.iter().any(|d| d == "svelte") {
|
||||
push_unique(&mut stack, "svelte");
|
||||
}
|
||||
if deps.iter().any(|d| d == "next") {
|
||||
push_unique(&mut stack, "next");
|
||||
}
|
||||
if deps.iter().any(|d| d == "vite") {
|
||||
push_unique(&mut stack, "vite");
|
||||
}
|
||||
if deps.iter().any(|d| d == "typescript") {
|
||||
push_unique(&mut stack, "typescript");
|
||||
}
|
||||
if deps.iter().any(|d| d == "express" || d == "koa" || d == "fastify") {
|
||||
push_unique(&mut stack, "node");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(stack)
|
||||
}
|
||||
|
||||
/// 解析 package.json,合并 dependencies + devDependencies 的包名
|
||||
fn read_package_deps(path: impl AsRef<Path>) -> Result<Vec<String>> {
|
||||
let content = std::fs::read_to_string(path.as_ref())
|
||||
.with_context(|| format!("读取 package.json 失败: {}", path.as_ref().display()))?;
|
||||
let pkg: serde_json::Value = serde_json::from_str(&content).context("解析 package.json 失败")?;
|
||||
let mut names = Vec::new();
|
||||
for key in &["dependencies", "devDependencies"] {
|
||||
if let Some(obj) = pkg.get(key).and_then(|v| v.as_object()) {
|
||||
for k in obj.keys() {
|
||||
names.push(k.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// 目录下是否存在指定扩展名的文件(仅一层)
|
||||
fn has_file_with_ext(dir: &Path, ext: &str) -> bool {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return false;
|
||||
};
|
||||
entries.flatten().any(|e| {
|
||||
e.path()
|
||||
.extension()
|
||||
.and_then(|x| x.to_str())
|
||||
.map(|x| x == ext)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 项目采样(供 LLM 分析基础信息) — 纯 IO,控 token 不读源码
|
||||
// ============================================================
|
||||
|
||||
/// 项目采样结果 — README 首段 + 目录树(2层) + 清单文件片段
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProjectSample {
|
||||
pub readme: Option<String>,
|
||||
pub tree: Vec<String>,
|
||||
/// (文件名, 截断内容)
|
||||
pub manifests: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
const SAMPLE_README_MAX: usize = 2000;
|
||||
const SAMPLE_MANIFEST_MAX: usize = 1500;
|
||||
const SAMPLE_TREE_MAX: usize = 80;
|
||||
/// 目录树过滤的噪音目录(依赖产物/构建/缓存/IDE)
|
||||
const SAMPLE_IGNORED_DIRS: &[&str] = &[
|
||||
"node_modules", "target", ".git", "dist", "build", ".next", "venv", ".venv",
|
||||
"__pycache__", ".idea", ".vscode", ".cache", "out", "coverage", ".svelte-kit",
|
||||
".turbo", ".angular", ".gradle", "vendor",
|
||||
];
|
||||
|
||||
/// 采集项目采样(README + 目录树 + 清单),供 LLM 分析填基础信息
|
||||
pub fn collect_sample(root: &Path) -> Result<ProjectSample> {
|
||||
if !root.is_dir() {
|
||||
anyhow::bail!("路径不是目录: {}", root.display());
|
||||
}
|
||||
Ok(ProjectSample {
|
||||
readme: read_readme(root),
|
||||
tree: collect_tree(root),
|
||||
manifests: collect_manifests(root),
|
||||
})
|
||||
}
|
||||
|
||||
fn read_readme(root: &Path) -> Option<String> {
|
||||
for name in &["README.md", "README.MD", "README", "README.zh.md", "README_zh.md", "README_EN.md", "readme.md"] {
|
||||
let p = root.join(name);
|
||||
if p.is_file() {
|
||||
if let Ok(content) = std::fs::read_to_string(&p) {
|
||||
return Some(truncate_chars(&content, SAMPLE_README_MAX));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 目录树(根 + 一层子目录),过滤噪音目录,控条目数
|
||||
fn collect_tree(root: &Path) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
let mut count = 0usize;
|
||||
collect_tree_level(root, "", &mut lines, &mut count, false);
|
||||
lines
|
||||
}
|
||||
|
||||
fn collect_tree_level(dir: &Path, prefix: &str, lines: &mut Vec<String>, count: &mut usize, is_sub: bool) {
|
||||
if *count >= SAMPLE_TREE_MAX {
|
||||
return;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
let mut items: Vec<_> = entries.flatten().collect();
|
||||
items.sort_by_key(|e| e.file_name());
|
||||
for e in items {
|
||||
if *count >= SAMPLE_TREE_MAX {
|
||||
return;
|
||||
}
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
let is_dir = e.file_type().map(|t| t.is_dir()).unwrap_or(false);
|
||||
if is_dir {
|
||||
if SAMPLE_IGNORED_DIRS.contains(&name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
lines.push(format!("{}{}/", prefix, name));
|
||||
*count += 1;
|
||||
// 仅根目录的子目录展开一层(is_sub=true 不再递归)
|
||||
if !is_sub {
|
||||
collect_tree_level(&e.path(), &format!("{} ", prefix), lines, count, true);
|
||||
}
|
||||
} else {
|
||||
// 跳过隐藏文件(保留 .gitignore 作 git 标识)
|
||||
if name.starts_with('.') && name != ".gitignore" {
|
||||
continue;
|
||||
}
|
||||
lines.push(format!("{}{}", prefix, name));
|
||||
*count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_manifests(root: &Path) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
for name in &["package.json", "Cargo.toml", "go.mod", "pyproject.toml", "pom.xml", "build.gradle", "build.gradle.kts"] {
|
||||
let p = root.join(name);
|
||||
if let Ok(content) = std::fs::read_to_string(&p) {
|
||||
out.push(((*name).to_string(), truncate_chars(&content, SAMPLE_MANIFEST_MAX)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 按字符数截断(避免截断 UTF-8 多字节边界)
|
||||
fn truncate_chars(s: &str, max: usize) -> String {
|
||||
if s.chars().count() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
let truncated: String = s.chars().take(max).collect();
|
||||
format!("{}…(已截断)", truncated)
|
||||
}
|
||||
|
||||
/// 去重 push
|
||||
fn push_unique(stack: &mut Vec<String>, s: &str) {
|
||||
if !stack.iter().any(|x| x == s) {
|
||||
stack.push(s.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 在系统临时目录建唯一子目录(以进程号隔离并发),返回路径
|
||||
fn scratch(name: &str) -> PathBuf {
|
||||
let mut p = std::env::temp_dir();
|
||||
p.push(format!("df-project-scan-{}-{}", name, std::process::id()));
|
||||
let _ = fs::remove_dir_all(&p);
|
||||
fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_dir_errors() {
|
||||
let r = detect_stack(Path::new("definitely-not-exist-xyz-123"));
|
||||
assert!(r.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_rust() {
|
||||
let d = scratch("rust");
|
||||
fs::write(d.join("Cargo.toml"), "").unwrap();
|
||||
let s = detect_stack(&d).unwrap();
|
||||
assert!(s.contains(&"rust".to_string()));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_full_stack() {
|
||||
// 模拟 DevFlow 自身:rust + tauri + vue + vite + typescript
|
||||
let d = scratch("full");
|
||||
fs::write(d.join("Cargo.toml"), "").unwrap();
|
||||
fs::create_dir(d.join("src-tauri")).unwrap();
|
||||
fs::write(
|
||||
d.join("package.json"),
|
||||
r#"{"dependencies":{"vue":"^3.5.0"},"devDependencies":{"vite":"^6.0.0","typescript":"~5.6.0"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let s = detect_stack(&d).unwrap();
|
||||
assert!(s.contains(&"rust".to_string()));
|
||||
assert!(s.contains(&"tauri".to_string()));
|
||||
assert!(s.contains(&"vue".to_string()));
|
||||
assert!(s.contains(&"vite".to_string()));
|
||||
assert!(s.contains(&"typescript".to_string()));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_dir_returns_empty() {
|
||||
let d = scratch("empty");
|
||||
let s = detect_stack(&d).unwrap();
|
||||
assert!(s.is_empty());
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collects_sample() {
|
||||
let d = scratch("sample");
|
||||
fs::write(d.join("README.md"), "# Test\nA test project.\nMore.").unwrap();
|
||||
fs::write(d.join("package.json"), r#"{"name":"x","dependencies":{"vue":"3"}}"#).unwrap();
|
||||
fs::create_dir(d.join("src")).unwrap();
|
||||
fs::write(d.join("src/main.ts"), "x").unwrap();
|
||||
fs::create_dir(d.join("node_modules")).unwrap();
|
||||
fs::write(d.join("node_modules/junk.json"), "x").unwrap();
|
||||
let s = collect_sample(&d).unwrap();
|
||||
assert!(s.readme.as_deref().unwrap_or("").contains("test project"));
|
||||
assert!(s.manifests.iter().any(|(n, _)| n == "package.json"));
|
||||
assert!(s.tree.iter().any(|t| t.contains("src")));
|
||||
// node_modules 应被过滤
|
||||
assert!(s.tree.iter().all(|t| !t.contains("node_modules")));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
//! 项目调度器 — 自动化任务分配与调度
|
||||
|
||||
use df_core::types::{ProjectId, TaskId};
|
||||
|
||||
/// 调度策略
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum SchedulingStrategy {
|
||||
/// FIFO(先进先出)
|
||||
Fifo,
|
||||
/// 按优先级调度
|
||||
Priority,
|
||||
/// 最短任务优先
|
||||
ShortestFirst,
|
||||
/// AI 智能调度
|
||||
AiDriven,
|
||||
}
|
||||
|
||||
/// 调度决策
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SchedulingDecision {
|
||||
pub project_id: ProjectId,
|
||||
pub task_order: Vec<TaskId>,
|
||||
pub strategy: SchedulingStrategy,
|
||||
}
|
||||
|
||||
/// 项目调度器
|
||||
pub struct ProjectScheduler {
|
||||
strategy: SchedulingStrategy,
|
||||
}
|
||||
|
||||
impl ProjectScheduler {
|
||||
/// 创建调度器
|
||||
pub fn new(strategy: SchedulingStrategy) -> Self {
|
||||
Self { strategy }
|
||||
}
|
||||
|
||||
/// 为项目生成调度计划
|
||||
///
|
||||
/// TODO: 实现基于策略的调度算法
|
||||
pub fn schedule(&self, project_id: &ProjectId) -> anyhow::Result<SchedulingDecision> {
|
||||
match self.strategy {
|
||||
SchedulingStrategy::Fifo => {
|
||||
// TODO: 按创建时间排序
|
||||
tracing::info!("FIFO 调度,项目: {}", project_id);
|
||||
}
|
||||
SchedulingStrategy::Priority => {
|
||||
// TODO: 按优先级排序
|
||||
tracing::info!("优先级调度,项目: {}", project_id);
|
||||
}
|
||||
SchedulingStrategy::ShortestFirst => {
|
||||
// TODO: 按预估工作量排序
|
||||
tracing::info!("最短任务优先调度,项目: {}", project_id);
|
||||
}
|
||||
SchedulingStrategy::AiDriven => {
|
||||
// TODO: 接入 AI 进行智能调度
|
||||
tracing::info!("AI 智能调度,项目: {}", project_id);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SchedulingDecision {
|
||||
project_id: project_id.clone(),
|
||||
task_order: Vec::new(),
|
||||
strategy: self.strategy,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
//! 项目时间线 — 里程碑与进度追踪
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use df_core::types::ProjectId;
|
||||
|
||||
/// 里程碑
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Milestone {
|
||||
/// 唯一 ID
|
||||
pub id: String,
|
||||
/// 所属项目 ID
|
||||
pub project_id: ProjectId,
|
||||
/// 里程碑名称
|
||||
pub name: String,
|
||||
/// 描述
|
||||
pub description: String,
|
||||
/// 计划完成时间
|
||||
pub due_date: Option<chrono::DateTime<chrono::Utc>>,
|
||||
/// 实际完成时间
|
||||
pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
/// 进度百分比 (0-100)
|
||||
pub progress: u8,
|
||||
/// 是否已完成
|
||||
pub completed: bool,
|
||||
}
|
||||
|
||||
/// 项目时间线
|
||||
pub struct Timeline {
|
||||
pub project_id: ProjectId,
|
||||
pub milestones: Vec<Milestone>,
|
||||
}
|
||||
|
||||
impl Timeline {
|
||||
/// 创建空时间线
|
||||
pub fn new(project_id: ProjectId) -> Self {
|
||||
Self {
|
||||
project_id,
|
||||
milestones: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加里程碑
|
||||
pub fn add_milestone(&mut self, milestone: Milestone) {
|
||||
self.milestones.push(milestone);
|
||||
}
|
||||
|
||||
/// 计算整体进度
|
||||
pub fn overall_progress(&self) -> f64 {
|
||||
if self.milestones.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let total: f64 = self.milestones.iter().map(|m| m.progress as f64).sum();
|
||||
total / self.milestones.len() as f64
|
||||
}
|
||||
|
||||
/// 获取下一个待完成的里程碑
|
||||
pub fn next_milestone(&self) -> Option<&Milestone> {
|
||||
self.milestones
|
||||
.iter()
|
||||
.filter(|m| !m.completed)
|
||||
.min_by_key(|m| m.due_date)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
[package]
|
||||
name = "df-stages"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
df-core = { path = "../df-core" }
|
||||
df-workflow = { path = "../df-workflow" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
@@ -1,50 +0,0 @@
|
||||
//! 编码阶段 — 代码生成与审查阶段模板
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_workflow::node::{Node, NodeContext, NodeResult, NodeSchema};
|
||||
|
||||
/// 代码生成节点
|
||||
pub struct CodeGenNode;
|
||||
|
||||
#[async_trait]
|
||||
impl Node for CodeGenNode {
|
||||
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
|
||||
// TODO: 调用 AI 生成代码
|
||||
tracing::info!("代码生成节点执行");
|
||||
Ok(df_workflow::node::NodeOutput::empty())
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({"type": "object"}),
|
||||
output: serde_json::json!({"type": "object"}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"stage.coding.codegen"
|
||||
}
|
||||
}
|
||||
|
||||
/// 代码审查节点
|
||||
pub struct CodeReviewNode;
|
||||
|
||||
#[async_trait]
|
||||
impl Node for CodeReviewNode {
|
||||
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
|
||||
// TODO: 调用 AI 审查代码
|
||||
tracing::info!("代码审查节点执行");
|
||||
Ok(df_workflow::node::NodeOutput::empty())
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({"type": "object"}),
|
||||
output: serde_json::json!({"type": "object"}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"stage.coding.review"
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
//! 想法阶段 — 想法捕获与评估阶段模板
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_workflow::node::{Node, NodeContext, NodeResult, NodeSchema};
|
||||
|
||||
/// 想法捕获节点
|
||||
pub struct IdeaCaptureNode;
|
||||
|
||||
#[async_trait]
|
||||
impl Node for IdeaCaptureNode {
|
||||
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
|
||||
// TODO: 调用 df-ideas 的捕获功能
|
||||
tracing::info!("想法捕获节点执行");
|
||||
Ok(df_workflow::node::NodeOutput::empty())
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": { "type": "string" },
|
||||
"description": { "type": "string" }
|
||||
},
|
||||
"required": ["title"]
|
||||
}),
|
||||
output: serde_json::json!({"type": "object"}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"stage.idea.capture"
|
||||
}
|
||||
}
|
||||
|
||||
/// 想法评估节点
|
||||
pub struct IdeaEvalNode;
|
||||
|
||||
#[async_trait]
|
||||
impl Node for IdeaEvalNode {
|
||||
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
|
||||
// TODO: 调用 df-ideas 的评估功能
|
||||
tracing::info!("想法评估节点执行");
|
||||
Ok(df_workflow::node::NodeOutput::empty())
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({"type": "object"}),
|
||||
output: serde_json::json!({"type": "object"}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"stage.idea.eval"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
//! df-stages: 阶段插件 — 开发流程各阶段的节点模板注册
|
||||
|
||||
pub mod coding;
|
||||
pub mod idea;
|
||||
pub mod release;
|
||||
pub mod requirement;
|
||||
pub mod testing;
|
||||
@@ -1,73 +0,0 @@
|
||||
//! 发布阶段 — 构建、部署与发布阶段模板
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_workflow::node::{Node, NodeContext, NodeResult, NodeSchema};
|
||||
|
||||
/// 构建节点
|
||||
pub struct BuildNode;
|
||||
|
||||
#[async_trait]
|
||||
impl Node for BuildNode {
|
||||
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
|
||||
// TODO: 调用 df-execute 执行构建命令
|
||||
tracing::info!("构建节点执行");
|
||||
Ok(df_workflow::node::NodeOutput::empty())
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({"type": "object"}),
|
||||
output: serde_json::json!({"type": "object"}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"stage.release.build"
|
||||
}
|
||||
}
|
||||
|
||||
/// 部署节点
|
||||
pub struct DeployNode;
|
||||
|
||||
#[async_trait]
|
||||
impl Node for DeployNode {
|
||||
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
|
||||
// TODO: 调用 df-execute 执行部署命令
|
||||
tracing::info!("部署节点执行");
|
||||
Ok(df_workflow::node::NodeOutput::empty())
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({"type": "object"}),
|
||||
output: serde_json::json!({"type": "object"}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"stage.release.deploy"
|
||||
}
|
||||
}
|
||||
|
||||
/// 发布公告节点
|
||||
pub struct ReleaseNotesNode;
|
||||
|
||||
#[async_trait]
|
||||
impl Node for ReleaseNotesNode {
|
||||
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
|
||||
// TODO: 调用 AI 生成发布公告
|
||||
tracing::info!("发布公告节点执行");
|
||||
Ok(df_workflow::node::NodeOutput::empty())
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({"type": "object"}),
|
||||
output: serde_json::json!({"type": "object"}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"stage.release.notes"
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
//! 需求阶段 — 需求分析与文档生成阶段模板
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_workflow::node::{Node, NodeContext, NodeResult, NodeSchema};
|
||||
|
||||
/// 需求分析节点
|
||||
pub struct RequirementAnalysisNode;
|
||||
|
||||
#[async_trait]
|
||||
impl Node for RequirementAnalysisNode {
|
||||
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
|
||||
// TODO: 调用 AI 分析需求
|
||||
tracing::info!("需求分析节点执行");
|
||||
Ok(df_workflow::node::NodeOutput::empty())
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({"type": "object"}),
|
||||
output: serde_json::json!({"type": "object"}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"stage.requirement.analysis"
|
||||
}
|
||||
}
|
||||
|
||||
/// 需求文档生成节点
|
||||
pub struct RequirementDocNode;
|
||||
|
||||
#[async_trait]
|
||||
impl Node for RequirementDocNode {
|
||||
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
|
||||
// TODO: 调用 AI 生成需求文档
|
||||
tracing::info!("需求文档生成节点执行");
|
||||
Ok(df_workflow::node::NodeOutput::empty())
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({"type": "object"}),
|
||||
output: serde_json::json!({"type": "object"}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"stage.requirement.doc"
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
//! 测试阶段 — 测试生成与执行阶段模板
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_workflow::node::{Node, NodeContext, NodeResult, NodeSchema};
|
||||
|
||||
/// 测试用例生成节点
|
||||
pub struct TestGenNode;
|
||||
|
||||
#[async_trait]
|
||||
impl Node for TestGenNode {
|
||||
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
|
||||
// TODO: 调用 AI 生成测试用例
|
||||
tracing::info!("测试用例生成节点执行");
|
||||
Ok(df_workflow::node::NodeOutput::empty())
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({"type": "object"}),
|
||||
output: serde_json::json!({"type": "object"}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"stage.testing.testgen"
|
||||
}
|
||||
}
|
||||
|
||||
/// 测试执行节点
|
||||
pub struct TestRunNode;
|
||||
|
||||
#[async_trait]
|
||||
impl Node for TestRunNode {
|
||||
async fn execute(&self, _ctx: NodeContext) -> NodeResult {
|
||||
// TODO: 调用 df-execute 执行测试命令
|
||||
tracing::info!("测试执行节点执行");
|
||||
Ok(df_workflow::node::NodeOutput::empty())
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({"type": "object"}),
|
||||
output: serde_json::json!({"type": "object"}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"stage.testing.run"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,19 @@ use anyhow::Result;
|
||||
use rusqlite::Connection;
|
||||
|
||||
/// 当前迁移版本
|
||||
const MIGRATION_VERSION: i32 = 2;
|
||||
const MIGRATION_VERSION: i32 = 12;
|
||||
|
||||
/// 检测表是否存在指定列(幂等迁移通用辅助)
|
||||
fn column_exists(conn: &Connection, table: &str, col: &str) -> bool {
|
||||
let Ok(mut stmt) = conn.prepare(&format!("PRAGMA table_info({table})")) else { return false };
|
||||
let Ok(rows) = stmt.query_map([], |r| r.get::<_, String>(1)) else { return false };
|
||||
for r in rows {
|
||||
if let Ok(name) = r {
|
||||
if name == col { return true; }
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 执行所有迁移
|
||||
pub fn run(conn: &Connection) -> Result<()> {
|
||||
@@ -31,8 +43,49 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
migrate_v2(conn)?;
|
||||
}
|
||||
|
||||
// 未来迁移在此扩展:
|
||||
// if current_version < 3 { migrate_v3(conn)?; }
|
||||
if current_version < 3 {
|
||||
migrate_v3(conn)?;
|
||||
}
|
||||
|
||||
if current_version < 4 {
|
||||
migrate_v4(conn)?;
|
||||
}
|
||||
|
||||
if current_version < 5 {
|
||||
migrate_v5(conn)?;
|
||||
}
|
||||
|
||||
if current_version < 6 {
|
||||
migrate_v6(conn)?;
|
||||
}
|
||||
|
||||
if current_version < 7 {
|
||||
migrate_v7(conn)?;
|
||||
}
|
||||
|
||||
if current_version < 8 {
|
||||
migrate_v8(conn)?;
|
||||
}
|
||||
|
||||
if current_version < 9 {
|
||||
migrate_v9(conn)?;
|
||||
}
|
||||
|
||||
if current_version < 10 {
|
||||
migrate_v10(conn)?;
|
||||
}
|
||||
|
||||
if current_version < 11 {
|
||||
migrate_v11(conn)?;
|
||||
}
|
||||
|
||||
if current_version < 12 {
|
||||
migrate_v12(conn)?;
|
||||
}
|
||||
|
||||
if current_version < 13 {
|
||||
migrate_v13(conn)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -53,6 +106,170 @@ fn migrate_v2(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V3: AI 对话表补建(新库) + 归档标记列(新老库统一)
|
||||
fn migrate_v3(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(V3_SQL)?;
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [3])?;
|
||||
tracing::info!("迁移 v3 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V4: 幂等补 ai_conversations.archived 列
|
||||
///
|
||||
/// 修复历史缺陷:早期 v3 迁移仅写入版本号 3,ALTER ADD COLUMN archived 未实际生效,
|
||||
/// 导致 schema_version=3 但 ai_conversations 缺列,from_row 读 archived 报错,
|
||||
/// list_all 失败 → 前端历史会话不显示 + 新对话 insert 失败。
|
||||
/// 因 run() 按 `current_version < 3` 跳过 v3,该列无法靠 v3 自补。
|
||||
/// 此处用 PRAGMA 探测列存在性,缺失才 ALTER,对新库/老库/坏库均安全。
|
||||
fn migrate_v4(conn: &Connection) -> Result<()> {
|
||||
let has_archived = column_exists(conn, "ai_conversations", "archived");
|
||||
if !has_archived {
|
||||
conn.execute(
|
||||
"ALTER TABLE ai_conversations ADD COLUMN archived INTEGER NOT NULL DEFAULT 0",
|
||||
[],
|
||||
)?;
|
||||
tracing::info!("v4: 补建 ai_conversations.archived 列");
|
||||
}
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [4])?;
|
||||
tracing::info!("迁移 v4 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V5: 幂等补 ai_conversations.prompt_tokens / completion_tokens 列
|
||||
///
|
||||
/// 流式 token 用量记录:对话级累计 input/output token(由 save_conversation 写入)。
|
||||
/// 用 PRAGMA 探测列存在性,缺失才 ALTER,对新库/老库/坏库均安全(同 v4 模式)。
|
||||
fn migrate_v5(conn: &Connection) -> Result<()> {
|
||||
if !column_exists(conn, "ai_conversations", "prompt_tokens") {
|
||||
conn.execute("ALTER TABLE ai_conversations ADD COLUMN prompt_tokens INTEGER", [])?;
|
||||
tracing::info!("v5: 补建 ai_conversations.prompt_tokens 列");
|
||||
}
|
||||
if !column_exists(conn, "ai_conversations", "completion_tokens") {
|
||||
conn.execute("ALTER TABLE ai_conversations ADD COLUMN completion_tokens INTEGER", [])?;
|
||||
tracing::info!("v5: 补建 ai_conversations.completion_tokens 列");
|
||||
}
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [5])?;
|
||||
tracing::info!("迁移 v5 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V6: 幂等补 ai_conversations.models 列
|
||||
///
|
||||
/// 对话级多 model 记录:JSON 数组字符串(去重存对话用过的所有 model)。
|
||||
/// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5 模式)。
|
||||
fn migrate_v6(conn: &Connection) -> Result<()> {
|
||||
let has_models = column_exists(conn, "ai_conversations", "models");
|
||||
if !has_models {
|
||||
conn.execute("ALTER TABLE ai_conversations ADD COLUMN models TEXT", [])?;
|
||||
tracing::info!("v6: 补建 ai_conversations.models 列");
|
||||
}
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [6])?;
|
||||
tracing::info!("迁移 v6 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V7: 知识库表 — 经验沉淀的基本单元(共享记忆层)
|
||||
///
|
||||
/// 状态机: candidate → pending_review → published → archived
|
||||
/// AI 只产 candidate,人工门控发布;reuse_count 是唯一客观排序信号。
|
||||
/// effectiveness 列不建(决策撤销人工评分)。时间字段用毫秒字符串(同既有 model 约定)。
|
||||
fn migrate_v7(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(V7_SQL)?;
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [7])?;
|
||||
tracing::info!("迁移 v7 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V8: 幂等补 knowledges.embedding 列(向量检索)
|
||||
///
|
||||
/// 存 Vec<f32> 的小端字节序列化 BLOB。NULL = 未嵌入(走 LIKE 降级)。
|
||||
/// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6 模式)。
|
||||
fn migrate_v8(conn: &Connection) -> Result<()> {
|
||||
let has_embedding = column_exists(conn, "knowledges", "embedding");
|
||||
if !has_embedding {
|
||||
conn.execute("ALTER TABLE knowledges ADD COLUMN embedding BLOB", [])?;
|
||||
tracing::info!("v8: 补建 knowledges.embedding 列");
|
||||
}
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [8])?;
|
||||
tracing::info!("迁移 v8 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V9: 幂等补建 ai_providers + ai_tool_executions 表
|
||||
///
|
||||
/// 历史遗漏:这两张表从未写入迁移文件(V1-V8 均未包含),
|
||||
/// 旧库可能通过其他方式已建,新库缺失导致 save_provider 等操作报 SQL 错误。
|
||||
/// 用 CREATE TABLE IF NOT EXISTS 幂等,已有表不受影响。
|
||||
fn migrate_v9(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(V9_SQL)?;
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [9])?;
|
||||
tracing::info!("迁移 v9 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V10: 知识生命线 — 补 knowledges.reasoning 列 + 新建 knowledge_events 事件表
|
||||
///
|
||||
/// - reasoning: AI 提炼时给出的"为何值得沉淀"判断依据(此前 prompt 要求但写库丢弃,
|
||||
/// 此处补列修复;老库行默认 NULL,前端降级展示"手动录入/无依据")。幂等(PRAGMA 探测)。
|
||||
/// - knowledge_events: 追加型审计表,记录产生/审核/引用/归档四类事件,支撑生命线视图。
|
||||
/// 独立表(非 JSON 嵌主表): 一条知识可被引用数百次,JSON 嵌入致行膨胀+更新竞争。
|
||||
fn migrate_v10(conn: &Connection) -> Result<()> {
|
||||
if !column_exists(conn, "knowledges", "reasoning") {
|
||||
conn.execute("ALTER TABLE knowledges ADD COLUMN reasoning TEXT", [])?;
|
||||
tracing::info!("v10: 补建 knowledges.reasoning 列");
|
||||
}
|
||||
conn.execute_batch(V10_SQL)?;
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [10])?;
|
||||
tracing::info!("迁移 v10 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V11: 幂等补 projects.deleted_at 列(软删回收站)
|
||||
///
|
||||
/// 删除项目改为软删:deleted_at NULL=正常,非空=已进回收站(可恢复)。
|
||||
/// ProjectRecord 不带该字段,纯靠 SQL WHERE deleted_at IS NULL 过滤;
|
||||
/// 子表(tasks/releases/branches)不动,FK 仍满足,项目数据完整保留待恢复。
|
||||
/// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6/v8/v10 模式)。
|
||||
fn migrate_v11(conn: &Connection) -> Result<()> {
|
||||
if !column_exists(conn, "projects", "deleted_at") {
|
||||
conn.execute("ALTER TABLE projects ADD COLUMN deleted_at TEXT", [])?;
|
||||
tracing::info!("v11: 补建 projects.deleted_at 列(软删回收站)");
|
||||
}
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [11])?;
|
||||
tracing::info!("迁移 v11 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V12: 幂等补 projects.path / projects.stack 列(项目绑定真实代码目录)
|
||||
///
|
||||
/// 项目与磁盘代码库脱钩是项目管理核心缺失:此版补 path(绑定目录绝对路径) +
|
||||
/// stack(技术栈 JSON 数组字符串),为「绑定目录 + 探测技术栈」打地基,
|
||||
/// 第二步「导入历史项目」直接复用。两列均 nullable,老项目 path/stack=NULL 天然兼容。
|
||||
/// 用 PRAGMA 探测列存在性,缺失才 ALTER(同 v4/v5/v6/v8/v10/v11 模式)。
|
||||
fn migrate_v12(conn: &Connection) -> Result<()> {
|
||||
if !column_exists(conn, "projects", "path") {
|
||||
conn.execute("ALTER TABLE projects ADD COLUMN path TEXT", [])?;
|
||||
tracing::info!("v12: 补建 projects.path 列(绑定代码目录)");
|
||||
}
|
||||
if !column_exists(conn, "projects", "stack") {
|
||||
conn.execute("ALTER TABLE projects ADD COLUMN stack TEXT", [])?;
|
||||
tracing::info!("v12: 补建 projects.stack 列(技术栈)");
|
||||
}
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [12])?;
|
||||
tracing::info!("迁移 v12 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V13: 通用应用设置 KV 表(前端 localStorage 迁移目标)
|
||||
///
|
||||
/// 存主题/语言/AI 偏好/连接配置等,`value` 为 JSON 字符串。CREATE TABLE IF NOT EXISTS 幂等。
|
||||
fn migrate_v13(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(V13_SQL)?;
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [13])?;
|
||||
tracing::info!("迁移 v13 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V1 建表 SQL
|
||||
const V1_SQL: &str = "
|
||||
-- 想法表
|
||||
@@ -172,3 +389,116 @@ CREATE TABLE IF NOT EXISTS branches (
|
||||
CREATE INDEX IF NOT EXISTS idx_branches_project_id ON branches(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_branches_task_id ON branches(task_id);
|
||||
";
|
||||
|
||||
/// V3 迁移 SQL — AI 对话表补建(新库首次创建;老库 IF NOT EXISTS 跳过)
|
||||
///
|
||||
/// 注:archived 列不在此处 ALTER —— 由 v4 迁移幂等补建。
|
||||
/// (历史 v3 曾写入版本号但 ALTER 未生效,统一交 v4 用 PRAGMA 探测修复)
|
||||
const V3_SQL: &str = "
|
||||
CREATE TABLE IF NOT EXISTS ai_conversations (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
messages TEXT NOT NULL DEFAULT '[]',
|
||||
provider_id TEXT,
|
||||
model TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
";
|
||||
|
||||
/// V7 建表 SQL — 知识库表
|
||||
///
|
||||
/// kind: 7 种 KnowledgeKind snake_case(review_rule/prompt_template/pitfall/
|
||||
/// architecture_pattern/diagnosis/deployment_note/workflow_optimization)
|
||||
/// status: candidate|pending_review|published|archived
|
||||
/// confidence: high|medium|low(AI 提炼自评,可空)
|
||||
/// verified: 发布审核时一次性人工标(INTEGER 0/1)
|
||||
/// reuse_count: 检索命中自动 +1(唯一客观排序信号)
|
||||
/// source_project/source_ref: 来源溯源(不过滤,仅展示)
|
||||
const V7_SQL: &str = "
|
||||
CREATE TABLE IF NOT EXISTS knowledges (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL DEFAULT 'pitfall',
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
tags TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'candidate',
|
||||
confidence TEXT,
|
||||
reuse_count INTEGER NOT NULL DEFAULT 0,
|
||||
verified INTEGER NOT NULL DEFAULT 0,
|
||||
source_project TEXT,
|
||||
source_ref TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledges_status ON knowledges(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledges_kind ON knowledges(kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledges_reuse_count ON knowledges(reuse_count DESC);
|
||||
";
|
||||
|
||||
/// V9 建表 SQL — AI Provider 配置 + 工具执行审计
|
||||
///
|
||||
/// 历史遗漏补建:ai_providers(AI 提供商配置) + ai_tool_executions(工具调用审计记录)。
|
||||
/// CREATE TABLE IF NOT EXISTS 保证老库(已有表)和新库(缺表)均安全。
|
||||
const V9_SQL: &str = "
|
||||
CREATE TABLE IF NOT EXISTS ai_providers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
provider_type TEXT NOT NULL DEFAULT 'openai_compat',
|
||||
api_key TEXT NOT NULL,
|
||||
base_url TEXT NOT NULL,
|
||||
default_model TEXT NOT NULL,
|
||||
models TEXT,
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
config TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ai_tool_executions (
|
||||
id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT,
|
||||
tool_call_id TEXT NOT NULL,
|
||||
tool_name TEXT NOT NULL,
|
||||
arguments TEXT NOT NULL,
|
||||
result TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
risk_level TEXT NOT NULL DEFAULT 'medium',
|
||||
requested_at TEXT NOT NULL,
|
||||
executed_at TEXT,
|
||||
decided_by TEXT
|
||||
);
|
||||
";
|
||||
|
||||
/// V10 建表 SQL — 知识生命线事件表
|
||||
///
|
||||
/// 追加型审计表(只增不改),记录知识产生/审核/引用/归档四类事件,支撑生命线视图。
|
||||
/// event_type: created | extracted | status_changed | referenced | archived
|
||||
/// context_json: 因 event_type 而异的上下文(如引用事件的 conv_id+query)。
|
||||
const V10_SQL: &str = "
|
||||
CREATE TABLE IF NOT EXISTS knowledge_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
knowledge_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
source_ref TEXT,
|
||||
context_json TEXT,
|
||||
timestamp TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_events_kid ON knowledge_events(knowledge_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_events_type ON knowledge_events(event_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_events_kid_type ON knowledge_events(knowledge_id, event_type);
|
||||
";
|
||||
|
||||
/// V13 建表 SQL — 通用应用设置 KV 表
|
||||
///
|
||||
/// 前端 localStorage 迁移目标:key/value(JSON 字符串)+ updated_at。
|
||||
/// CREATE TABLE IF NOT EXISTS 幂等(新库建、老库已有则跳过)。
|
||||
const V13_SQL: &str = "
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
";
|
||||
|
||||
@@ -36,6 +36,10 @@ pub struct ProjectRecord {
|
||||
pub description: String,
|
||||
pub status: String,
|
||||
pub idea_id: Option<String>,
|
||||
/// 绑定的本地代码目录(绝对路径,可空=未绑定,第二步导入历史项目时复用)
|
||||
pub path: Option<String>,
|
||||
/// 技术栈 JSON 数组字符串(如 ["rust","vue","tauri"],由探测填充,可空)
|
||||
pub stack: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
@@ -149,6 +153,10 @@ pub struct AiConversationRecord {
|
||||
pub messages: String, // JSON array of ChatMessage
|
||||
pub provider_id: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub models: Option<String>, // 用过的所有 model(JSON 数组字符串,去重)
|
||||
pub archived: bool, // 是否归档(侧栏折叠展示)
|
||||
pub prompt_tokens: Option<i64>, // 输入 token 累计(流式 usage 落库)
|
||||
pub completion_tokens: Option<i64>, // 输出 token 累计(流式 usage 落库)
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
@@ -168,3 +176,37 @@ pub struct AiToolExecutionRecord {
|
||||
pub executed_at: Option<String>,
|
||||
pub decided_by: Option<String>, // human/auto
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 知识库模型 (V7)
|
||||
// ============================================================
|
||||
|
||||
/// 知识条目记录(经验沉淀基本单元,共享记忆层)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KnowledgeRecord {
|
||||
pub id: String,
|
||||
pub kind: String, // KnowledgeKind snake_case(7 种)
|
||||
pub title: String,
|
||||
pub content: String,
|
||||
pub tags: Option<String>, // JSON 数组字符串
|
||||
pub status: String, // candidate|pending_review|published|archived
|
||||
pub confidence: Option<String>, // high|medium|low(AI 提炼自评,可空)
|
||||
pub reuse_count: i32, // 唯一客观排序信号
|
||||
pub verified: bool, // 发布审核时一次性人工标
|
||||
pub source_project: Option<String>, // 来源项目(仅溯源不过滤)
|
||||
pub source_ref: Option<String>, // 来源实体引用(如 conv:{id})
|
||||
pub reasoning: Option<String>, // AI 提炼判断依据("为何值得沉淀"),手动录入为 None
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// 知识生命线事件记录(追加型审计:产生/审核/引用/归档)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct KnowledgeEventRecord {
|
||||
pub id: String,
|
||||
pub knowledge_id: String,
|
||||
pub event_type: String, // created | extracted | status_changed | referenced | archived
|
||||
pub source_ref: Option<String>, // 触发来源: conv:{id} / manual / system
|
||||
pub context_json: Option<String>, // JSON: 因 event_type 而异
|
||||
pub timestamp: String,
|
||||
}
|
||||
|
||||
271
crates/df-storage/tests/project_soft_delete.rs
Normal file
271
crates/df-storage/tests/project_soft_delete.rs
Normal file
@@ -0,0 +1,271 @@
|
||||
//! ProjectRepo 软删回收站 + 级联清理 的集成测试。
|
||||
//!
|
||||
//! 覆盖三类行为:
|
||||
//! 1. purge_with_descendants — 事务级联删 branches→releases→tasks→projects。
|
||||
//! 2. soft_delete / list_active / list_deleted / restore — 回收站生命周期。
|
||||
//! 3. allowed_columns_for 按表隔离 — update_field 误传跨表列名在白名单阶段被拒。
|
||||
|
||||
use df_storage::crud::{BranchRepo, ProjectRepo, ReleaseRepo, TaskRepo};
|
||||
use df_storage::db::Database;
|
||||
use df_storage::models::{BranchRecord, ProjectRecord, ReleaseRecord, TaskRecord};
|
||||
|
||||
// ---------- fixtures ----------
|
||||
|
||||
fn now_ts() -> String {
|
||||
"1700000000000".to_string()
|
||||
}
|
||||
|
||||
fn project(id: &str) -> ProjectRecord {
|
||||
ProjectRecord {
|
||||
id: id.to_string(),
|
||||
name: format!("proj-{id}"),
|
||||
description: "desc".to_string(),
|
||||
status: "active".to_string(),
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
created_at: now_ts(),
|
||||
updated_at: now_ts(),
|
||||
}
|
||||
}
|
||||
|
||||
fn task(id: &str, project_id: &str) -> TaskRecord {
|
||||
TaskRecord {
|
||||
id: id.to_string(),
|
||||
project_id: project_id.to_string(),
|
||||
title: format!("task-{id}"),
|
||||
description: "desc".to_string(),
|
||||
status: "todo".to_string(),
|
||||
priority: 1,
|
||||
branch_name: None,
|
||||
assignee: None,
|
||||
workflow_def_id: None,
|
||||
base_branch: None,
|
||||
created_at: now_ts(),
|
||||
updated_at: now_ts(),
|
||||
}
|
||||
}
|
||||
|
||||
fn release(id: &str, project_id: &str) -> ReleaseRecord {
|
||||
ReleaseRecord {
|
||||
id: id.to_string(),
|
||||
project_id: project_id.to_string(),
|
||||
version: "0.1.0".to_string(),
|
||||
status: "draft".to_string(),
|
||||
task_ids: "[]".to_string(),
|
||||
changelog: None,
|
||||
created_at: now_ts(),
|
||||
released_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn branch(id: &str, project_id: &str) -> BranchRecord {
|
||||
BranchRecord {
|
||||
id: id.to_string(),
|
||||
project_id: project_id.to_string(),
|
||||
task_id: None,
|
||||
name: format!("br-{id}"),
|
||||
base: "main".to_string(),
|
||||
status: "open".to_string(),
|
||||
created_at: now_ts(),
|
||||
updated_at: now_ts(),
|
||||
merged_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn setup() -> (ProjectRepo, TaskRepo, ReleaseRepo, BranchRepo) {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
(
|
||||
ProjectRepo::new(&db),
|
||||
TaskRepo::new(&db),
|
||||
ReleaseRepo::new(&db),
|
||||
BranchRepo::new(&db),
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 1. purge_with_descendants — 级联删除
|
||||
// ============================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn purge_with_descendants_removes_project_and_all_children() {
|
||||
let (projects, tasks, releases, branches) = setup().await;
|
||||
|
||||
projects.insert(project("p1")).await.unwrap();
|
||||
tasks.insert(task("t1", "p1")).await.unwrap();
|
||||
tasks.insert(task("t2", "p1")).await.unwrap();
|
||||
releases.insert(release("r1", "p1")).await.unwrap();
|
||||
branches.insert(branch("b1", "p1")).await.unwrap();
|
||||
branches.insert(branch("b2", "p1")).await.unwrap();
|
||||
|
||||
// 另一个项目的子记录不应被波及(只删 p1 的)
|
||||
projects.insert(project("p2")).await.unwrap();
|
||||
tasks.insert(task("t3", "p2")).await.unwrap();
|
||||
|
||||
let affected = projects.purge_with_descendants("p1").await.unwrap();
|
||||
assert!(affected, "应命中 p1");
|
||||
|
||||
// 四表对应记录全消失
|
||||
assert!(projects.get_by_id("p1").await.unwrap().is_none());
|
||||
assert!(tasks.get_by_id("t1").await.unwrap().is_none());
|
||||
assert!(tasks.get_by_id("t2").await.unwrap().is_none());
|
||||
assert!(releases.get_by_id("r1").await.unwrap().is_none());
|
||||
assert!(branches.get_by_id("b1").await.unwrap().is_none());
|
||||
assert!(branches.get_by_id("b2").await.unwrap().is_none());
|
||||
|
||||
// 邻居项目 p2 完好
|
||||
assert!(projects.get_by_id("p2").await.unwrap().is_some());
|
||||
assert!(tasks.get_by_id("t3").await.unwrap().is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn purge_with_descendants_unknown_id_returns_false() {
|
||||
let (projects, _t, _r, _b) = setup().await;
|
||||
let affected = projects.purge_with_descendants("ghost").await.unwrap();
|
||||
assert!(!affected, "不存在的 id 不命中");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn purge_with_descendants_is_atomic_on_missing_child_table_row() {
|
||||
// 事务性验证:即使 project 存在但被删语句之间无冲突,purge 应整体成功或整体不变。
|
||||
// 这里验证 commit 成功路径下子表清空、父表删除;反向证明未部分提交(若部分提交,
|
||||
// projects 会在事务中途被删但 branches 残留 —— 通过重新 purge 验证幂等空操作)。
|
||||
let (projects, tasks, _releases, branches) = setup().await;
|
||||
projects.insert(project("p1")).await.unwrap();
|
||||
tasks.insert(task("t1", "p1")).await.unwrap();
|
||||
branches.insert(branch("b1", "p1")).await.unwrap();
|
||||
|
||||
assert!(projects.purge_with_descendants("p1").await.unwrap());
|
||||
|
||||
// 事务已提交:父表与全部子表一致清空(不会出现父删子留)
|
||||
assert!(projects.get_by_id("p1").await.unwrap().is_none());
|
||||
assert!(tasks.get_by_id("t1").await.unwrap().is_none());
|
||||
assert!(branches.get_by_id("b1").await.unwrap().is_none());
|
||||
|
||||
// 再次 purge 已不存在的 id → false,且无副作用
|
||||
assert!(!projects.purge_with_descendants("p1").await.unwrap());
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 2. soft_delete / list_active / list_deleted / restore
|
||||
// ============================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn soft_delete_moves_project_out_of_active_into_deleted() {
|
||||
let (projects, _t, _r, _b) = setup().await;
|
||||
projects.insert(project("p1")).await.unwrap();
|
||||
projects.insert(project("p2")).await.unwrap();
|
||||
|
||||
let ok = projects.soft_delete("p1").await.unwrap();
|
||||
assert!(ok, "软删 p1 应命中");
|
||||
|
||||
let active = projects.list_active().await.unwrap();
|
||||
let active_ids: Vec<_> = active.iter().map(|p| p.id.as_str()).collect();
|
||||
assert!(!active_ids.contains(&"p1"), "p1 不应在 active");
|
||||
assert!(active_ids.contains(&"p2"), "p2 仍在 active");
|
||||
|
||||
let deleted = projects.list_deleted().await.unwrap();
|
||||
let deleted_ids: Vec<_> = deleted.iter().map(|p| p.id.as_str()).collect();
|
||||
assert!(deleted_ids.contains(&"p1"), "p1 应在 deleted");
|
||||
assert!(!deleted_ids.contains(&"p2"), "p2 不在 deleted");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_moves_project_back_into_active() {
|
||||
let (projects, _t, _r, _b) = setup().await;
|
||||
projects.insert(project("p1")).await.unwrap();
|
||||
projects.soft_delete("p1").await.unwrap();
|
||||
|
||||
let ok = projects.restore("p1").await.unwrap();
|
||||
assert!(ok, "恢复 p1 应命中");
|
||||
|
||||
let active_ids: Vec<_> = projects
|
||||
.list_active()
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|p| p.id)
|
||||
.collect();
|
||||
assert!(active_ids.contains(&"p1".to_string()), "p1 恢复后回到 active");
|
||||
|
||||
let deleted_ids: Vec<_> = projects
|
||||
.list_deleted()
|
||||
.await
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|p| p.id)
|
||||
.collect();
|
||||
assert!(!deleted_ids.contains(&"p1".to_string()), "p1 恢复后离开 deleted");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn soft_delete_on_already_deleted_returns_false() {
|
||||
// WHERE deleted_at IS NULL 守卫:已删项二次软删不命中
|
||||
let (projects, _t, _r, _b) = setup().await;
|
||||
projects.insert(project("p1")).await.unwrap();
|
||||
|
||||
assert!(projects.soft_delete("p1").await.unwrap());
|
||||
// 第二次对已删项 → false(守卫生效)
|
||||
assert!(
|
||||
!projects.soft_delete("p1").await.unwrap(),
|
||||
"已删项二次软删应返回 false"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn soft_delete_unknown_id_returns_false() {
|
||||
let (projects, _t, _r, _b) = setup().await;
|
||||
assert!(!projects.soft_delete("ghost").await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_unknown_id_returns_false() {
|
||||
let (projects, _t, _r, _b) = setup().await;
|
||||
assert!(!projects.restore("ghost").await.unwrap());
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 3. allowed_columns_for — 按表隔离
|
||||
// ============================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_field_rejects_cross_table_column_tasks_name() {
|
||||
// tasks 表白名单无 "name"(那是 projects/branches 的列)→ Err
|
||||
let (projects, tasks, _r, _b) = setup().await;
|
||||
projects.insert(project("p1")).await.unwrap();
|
||||
tasks.insert(task("t1", "p1")).await.unwrap();
|
||||
|
||||
let res = tasks.update_field("t1", "name", "x").await;
|
||||
assert!(res.is_err(), "tasks 表无 name 列,应被白名单拒绝");
|
||||
|
||||
// 对照:tasks 的合法列 title 通过
|
||||
let ok = tasks.update_field("t1", "title", "renamed").await.unwrap();
|
||||
assert!(ok);
|
||||
let rec = tasks.get_by_id("t1").await.unwrap().unwrap();
|
||||
assert_eq!(rec.title, "renamed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_field_allows_tasks_status() {
|
||||
let (projects, tasks, _r, _b) = setup().await;
|
||||
projects.insert(project("p1")).await.unwrap();
|
||||
tasks.insert(task("t1", "p1")).await.unwrap();
|
||||
|
||||
let ok = tasks.update_field("t1", "status", "done").await.unwrap();
|
||||
assert!(ok);
|
||||
let rec = tasks.get_by_id("t1").await.unwrap().unwrap();
|
||||
assert_eq!(rec.status, "done");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_field_rejects_projects_deleted_at() {
|
||||
// deleted_at 是专用路径列(soft_delete/restore),不进通用白名单 → 拒绝
|
||||
let (projects, _t, _r, _b) = setup().await;
|
||||
projects.insert(project("p1")).await.unwrap();
|
||||
|
||||
let res = projects.update_field("p1", "deleted_at", "123").await;
|
||||
assert!(
|
||||
res.is_err(),
|
||||
"deleted_at 不在 projects 白名单,应被拒(专用路径才能写)"
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
[package]
|
||||
name = "df-task"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
df-core = { path = "../df-core" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
@@ -1,74 +0,0 @@
|
||||
//! 分支管理 — Git 分支的创建、跟踪与生命周期
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use df_core::types::{BranchId, ProjectId, TaskId};
|
||||
|
||||
/// 分支状态
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BranchStatus {
|
||||
/// 活跃开发中
|
||||
Active,
|
||||
/// 待合并
|
||||
ReadyToMerge,
|
||||
/// 已合并
|
||||
Merged,
|
||||
/// 已删除
|
||||
Deleted,
|
||||
}
|
||||
|
||||
/// 分支实体
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Branch {
|
||||
/// 唯一 ID
|
||||
pub id: BranchId,
|
||||
/// 分支名称
|
||||
pub name: String,
|
||||
/// 所属项目 ID
|
||||
pub project_id: ProjectId,
|
||||
/// 关联任务 ID
|
||||
pub task_id: Option<TaskId>,
|
||||
/// 基于的分支(通常是 main)
|
||||
pub base_branch: String,
|
||||
/// 当前状态
|
||||
pub status: BranchStatus,
|
||||
/// 创建时间
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
/// 分支管理器
|
||||
pub struct BranchManager;
|
||||
|
||||
impl BranchManager {
|
||||
/// 为任务创建分支
|
||||
///
|
||||
/// TODO: 接入 df-execute 的 git_ops 执行实际的 Git 操作
|
||||
pub fn create_for_task(
|
||||
task_id: &TaskId,
|
||||
project_id: &ProjectId,
|
||||
base_branch: &str,
|
||||
) -> Branch {
|
||||
// 生成分支名称:task/{task_id 前缀}
|
||||
let short_id = &task_id[..8.min(task_id.len())];
|
||||
let branch_name = format!("task/{}", short_id);
|
||||
|
||||
Branch {
|
||||
id: df_core::types::new_id(),
|
||||
name: branch_name,
|
||||
project_id: project_id.clone(),
|
||||
task_id: Some(task_id.clone()),
|
||||
base_branch: base_branch.to_string(),
|
||||
status: BranchStatus::Active,
|
||||
created_at: chrono::Utc::now(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 列出项目的所有活跃分支
|
||||
///
|
||||
/// TODO: 接入 df-execute 的 git_ops
|
||||
pub fn list_active(_project_id: &ProjectId) -> Vec<Branch> {
|
||||
// TODO: 实现
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
//! df-task: 任务/分支管理 — 任务 CRUD、分支管理、合并协调、发布计划
|
||||
|
||||
pub mod branch;
|
||||
pub mod merge;
|
||||
pub mod release;
|
||||
pub mod task;
|
||||
@@ -1,91 +0,0 @@
|
||||
//! 合并协调器 — 分支合并、冲突检测、AI 辅助解决
|
||||
|
||||
use df_core::types::{BranchId, TaskId};
|
||||
|
||||
/// 合并状态
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum MergeStatus {
|
||||
/// 待合并
|
||||
Pending,
|
||||
/// 自动合并中
|
||||
AutoMerging,
|
||||
/// 存在冲突,等待解决
|
||||
Conflicted,
|
||||
/// 已解决冲突
|
||||
Resolved,
|
||||
/// 合并完成
|
||||
Completed,
|
||||
/// 合并失败
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// 合并请求
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MergeRequest {
|
||||
pub id: String,
|
||||
pub source_branch: BranchId,
|
||||
pub target_branch: String,
|
||||
pub task_id: Option<TaskId>,
|
||||
pub status: MergeStatus,
|
||||
pub conflicts: Vec<Conflict>,
|
||||
}
|
||||
|
||||
/// 冲突信息
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Conflict {
|
||||
pub file_path: String,
|
||||
pub conflict_type: ConflictType,
|
||||
pub description: String,
|
||||
pub auto_resolvable: bool,
|
||||
}
|
||||
|
||||
/// 冲突类型
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ConflictType {
|
||||
/// 文件级别冲突(双方修改了同一文件)
|
||||
FileModified,
|
||||
/// 删除/修改冲突
|
||||
DeleteModify,
|
||||
/// 二进制文件冲突
|
||||
Binary,
|
||||
}
|
||||
|
||||
/// 合并协调器
|
||||
pub struct MergeCoordinator;
|
||||
|
||||
impl MergeCoordinator {
|
||||
/// 创建合并请求
|
||||
///
|
||||
/// TODO: 接入 Git 操作执行实际合并
|
||||
pub fn create_request(
|
||||
source_branch: BranchId,
|
||||
target_branch: String,
|
||||
task_id: Option<TaskId>,
|
||||
) -> MergeRequest {
|
||||
MergeRequest {
|
||||
id: df_core::types::new_id(),
|
||||
source_branch,
|
||||
target_branch,
|
||||
task_id,
|
||||
status: MergeStatus::Pending,
|
||||
conflicts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 检测冲突
|
||||
///
|
||||
/// TODO: 调用 git merge --no-commit --no-ff 检测
|
||||
pub fn detect_conflicts(_merge_request: &MergeRequest) -> Vec<Conflict> {
|
||||
// TODO: 实现冲突检测
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// AI 辅助解决冲突
|
||||
///
|
||||
/// TODO: 调用 df-ai 的 LLM 分析冲突并提供解决方案
|
||||
pub fn ai_resolve_conflict(_conflict: &Conflict) -> anyhow::Result<String> {
|
||||
// TODO: 实现 AI 辅助冲突解决
|
||||
tracing::warn!("AI 冲突解决尚未实现");
|
||||
Ok(String::new())
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
//! 发布计划 — 多任务合并、版本号管理、发布流程
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use df_core::types::{ProjectId, ReleaseId, TaskId};
|
||||
|
||||
/// 发布状态
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ReleaseStatus {
|
||||
/// 计划中
|
||||
Planned,
|
||||
/// 准备中(收集已完成的任务)
|
||||
Preparing,
|
||||
/// 构建中
|
||||
Building,
|
||||
/// 测试中
|
||||
Testing,
|
||||
/// 待发布
|
||||
Ready,
|
||||
/// 发布中
|
||||
Releasing,
|
||||
/// 已发布
|
||||
Released,
|
||||
/// 已回滚
|
||||
RolledBack,
|
||||
/// 已取消
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// 版本号
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SemanticVersion {
|
||||
pub major: u32,
|
||||
pub minor: u32,
|
||||
pub patch: u32,
|
||||
pub pre_release: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SemanticVersion {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match &self.pre_release {
|
||||
Some(pre) => write!(f, "{}.{}.{}-{}", self.major, self.minor, self.patch, pre),
|
||||
None => write!(f, "{}.{}.{}", self.major, self.minor, self.patch),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 发布计划
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Release {
|
||||
/// 唯一 ID
|
||||
pub id: ReleaseId,
|
||||
/// 所属项目 ID
|
||||
pub project_id: ProjectId,
|
||||
/// 版本号
|
||||
pub version: SemanticVersion,
|
||||
/// 当前状态
|
||||
pub status: ReleaseStatus,
|
||||
/// 包含的任务 ID 列表
|
||||
pub task_ids: Vec<TaskId>,
|
||||
/// 变更日志
|
||||
pub changelog: Option<String>,
|
||||
/// 创建时间
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
/// 发布时间
|
||||
pub released_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
/// 版本号递增类型
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum VersionBump {
|
||||
Major,
|
||||
Minor,
|
||||
Patch,
|
||||
}
|
||||
|
||||
/// 发布管理器
|
||||
pub struct ReleaseManager;
|
||||
|
||||
impl ReleaseManager {
|
||||
/// 创建发布计划
|
||||
pub fn create(
|
||||
project_id: &ProjectId,
|
||||
version: SemanticVersion,
|
||||
task_ids: Vec<TaskId>,
|
||||
) -> Release {
|
||||
Release {
|
||||
id: df_core::types::new_id(),
|
||||
project_id: project_id.clone(),
|
||||
version,
|
||||
status: ReleaseStatus::Planned,
|
||||
task_ids,
|
||||
changelog: None,
|
||||
created_at: chrono::Utc::now(),
|
||||
released_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 自动生成变更日志
|
||||
///
|
||||
/// TODO: 从 Git 提交历史、任务描述中生成
|
||||
pub fn generate_changelog(_release: &mut Release) -> anyhow::Result<()> {
|
||||
// TODO: 实现
|
||||
tracing::warn!("自动变更日志生成尚未实现");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 自动递增版本号
|
||||
pub fn bump_version(current: &SemanticVersion, bump: VersionBump) -> SemanticVersion {
|
||||
match bump {
|
||||
VersionBump::Major => SemanticVersion {
|
||||
major: current.major + 1,
|
||||
minor: 0,
|
||||
patch: 0,
|
||||
pre_release: None,
|
||||
},
|
||||
VersionBump::Minor => SemanticVersion {
|
||||
major: current.major,
|
||||
minor: current.minor + 1,
|
||||
patch: 0,
|
||||
pre_release: None,
|
||||
},
|
||||
VersionBump::Patch => SemanticVersion {
|
||||
major: current.major,
|
||||
minor: current.minor,
|
||||
patch: current.patch + 1,
|
||||
pre_release: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
//! 任务管理 — 任务实体与 CRUD
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use df_core::types::{BranchId, Priority, ProjectId, TaskId, TaskStatus};
|
||||
|
||||
/// 任务实体
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Task {
|
||||
/// 唯一 ID
|
||||
pub id: TaskId,
|
||||
/// 所属项目 ID
|
||||
pub project_id: ProjectId,
|
||||
/// 任务标题
|
||||
pub title: String,
|
||||
/// 任务描述
|
||||
pub description: String,
|
||||
/// 当前状态
|
||||
pub status: TaskStatus,
|
||||
/// 优先级
|
||||
pub priority: Priority,
|
||||
/// 关联分支名称
|
||||
pub branch_name: Option<String>,
|
||||
/// 关联分支 ID
|
||||
pub branch_id: Option<BranchId>,
|
||||
/// 指派人
|
||||
pub assignee: Option<String>,
|
||||
/// 标签
|
||||
pub tags: Vec<String>,
|
||||
/// 预估工时(小时)
|
||||
pub estimate_hours: Option<f64>,
|
||||
/// 实际工时(小时)
|
||||
pub actual_hours: Option<f64>,
|
||||
/// 创建时间
|
||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||
/// 更新时间
|
||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
||||
}
|
||||
|
||||
/// 创建任务的输入
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CreateTaskInput {
|
||||
pub project_id: ProjectId,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub priority: Priority,
|
||||
pub assignee: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub estimate_hours: Option<f64>,
|
||||
}
|
||||
|
||||
/// 任务管理器
|
||||
pub struct TaskManager;
|
||||
|
||||
impl TaskManager {
|
||||
/// 创建新任务
|
||||
///
|
||||
/// TODO: 接入存储层持久化
|
||||
pub fn create(input: CreateTaskInput) -> Task {
|
||||
let now = chrono::Utc::now();
|
||||
Task {
|
||||
id: df_core::types::new_id(),
|
||||
project_id: input.project_id,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
status: TaskStatus::Todo,
|
||||
priority: input.priority,
|
||||
branch_name: None,
|
||||
branch_id: None,
|
||||
assignee: input.assignee,
|
||||
tags: input.tags,
|
||||
estimate_hours: input.estimate_hours,
|
||||
actual_hours: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// 更新任务状态
|
||||
pub fn transition_status(task: &mut Task, new_status: TaskStatus) -> anyhow::Result<()> {
|
||||
// TODO: 校验状态转换合法性
|
||||
task.status = new_status;
|
||||
task.updated_at = chrono::Utc::now();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
[package]
|
||||
name = "df-traceability"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
df-core = { path = "../df-core" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
@@ -1,130 +0,0 @@
|
||||
//! 标注系统:FIXME/TODO/QUESTION 等标记,批量收集后交给 AI 处理
|
||||
|
||||
use df_core::types::ProjectId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 标注标记类型
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AnnotationMarker {
|
||||
/// 需要修复
|
||||
Fixme,
|
||||
/// 待办事项
|
||||
Todo,
|
||||
/// 疑问待确认
|
||||
Question,
|
||||
/// 风险标记
|
||||
Risk,
|
||||
/// 决策标记
|
||||
Decision,
|
||||
/// 优化建议
|
||||
Optimize,
|
||||
}
|
||||
|
||||
/// 标注状态
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AnnotationStatus {
|
||||
/// 未处理
|
||||
Open,
|
||||
/// AI 处理中
|
||||
AiProcessing,
|
||||
/// 已解决
|
||||
Resolved,
|
||||
/// 不处理
|
||||
WontFix,
|
||||
}
|
||||
|
||||
/// 标注可附加的实体类型
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EntityType {
|
||||
Requirement,
|
||||
Feature,
|
||||
Code,
|
||||
TestCase,
|
||||
TestReport,
|
||||
DesignDoc,
|
||||
}
|
||||
|
||||
/// 标注
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Annotation {
|
||||
pub id: String,
|
||||
pub project_id: ProjectId,
|
||||
/// 附加的实体类型
|
||||
pub entity_type: EntityType,
|
||||
/// 附加的实体 ID
|
||||
pub entity_id: String,
|
||||
/// 标记类型
|
||||
pub marker: AnnotationMarker,
|
||||
/// 标注内容
|
||||
pub content: String,
|
||||
/// 位置(文件路径+行号 / 文档段落)
|
||||
pub location: Option<String>,
|
||||
/// 状态
|
||||
pub status: AnnotationStatus,
|
||||
/// 处理者
|
||||
pub resolved_by: Option<String>,
|
||||
/// 处理结果
|
||||
pub resolution: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub resolved_at: Option<i64>,
|
||||
}
|
||||
|
||||
impl Annotation {
|
||||
pub fn new(
|
||||
project_id: ProjectId,
|
||||
entity_type: EntityType,
|
||||
entity_id: String,
|
||||
marker: AnnotationMarker,
|
||||
content: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: df_core::types::new_id(),
|
||||
project_id,
|
||||
entity_type,
|
||||
entity_id,
|
||||
marker,
|
||||
content,
|
||||
location: None,
|
||||
status: AnnotationStatus::Open,
|
||||
resolved_by: None,
|
||||
resolution: None,
|
||||
created_at: chrono::Utc::now().timestamp(),
|
||||
resolved_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 标注批量处理器
|
||||
pub struct AnnotationBatchProcessor;
|
||||
|
||||
impl AnnotationBatchProcessor {
|
||||
/// 收集项目中所有未处理的标注
|
||||
pub fn collect_open_annotations(_project_id: &ProjectId) -> Vec<Annotation> {
|
||||
// TODO: 从 SQLite 查询所有 status = Open 的标注
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// 按类型分组
|
||||
pub fn group_by_marker(annotations: &[Annotation]) -> std::collections::HashMap<AnnotationMarker, Vec<&Annotation>> {
|
||||
let mut groups: std::collections::HashMap<AnnotationMarker, Vec<&Annotation>> = std::collections::HashMap::new();
|
||||
for ann in annotations {
|
||||
groups.entry(ann.marker.clone()).or_default().push(ann);
|
||||
}
|
||||
groups
|
||||
}
|
||||
|
||||
/// 将分组后的标注交给 AI 批量处理
|
||||
///
|
||||
/// AI 会逐条处理每个标注,更新内容和状态
|
||||
pub async fn batch_process(_annotations: &[Annotation]) -> anyhow::Result<Vec<Annotation>> {
|
||||
// TODO: 调用 df-ai 编排器,构建批量处理 prompt
|
||||
// 1. 按类型分组
|
||||
// 2. 每组构建一个 AI 请求
|
||||
// 3. AI 返回处理结果
|
||||
// 4. 更新标注状态和 resolution
|
||||
Ok(vec![])
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
//! 决策留痕:所有关键决策的记录、检索和审计
|
||||
|
||||
use df_core::types::{DecisionId, ProjectId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 决策记录
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Decision {
|
||||
pub id: DecisionId,
|
||||
pub project_id: ProjectId,
|
||||
/// 决策背景
|
||||
pub context: String,
|
||||
/// 需要决定的问题
|
||||
pub question: String,
|
||||
/// 考虑过的方案
|
||||
pub alternatives: Vec<String>,
|
||||
/// 最终决定
|
||||
pub decision: String,
|
||||
/// 决策原因
|
||||
pub reason: String,
|
||||
/// 决策者
|
||||
pub decided_by: DecidedBy,
|
||||
/// 关联实体类型
|
||||
pub entity_type: Option<String>,
|
||||
/// 关联实体 ID
|
||||
pub entity_id: Option<String>,
|
||||
/// 影响范围
|
||||
pub impact: Option<String>,
|
||||
/// 所处阶段
|
||||
pub stage: Option<String>,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
/// 决策者
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum DecidedBy {
|
||||
/// AI 自动决策
|
||||
Ai,
|
||||
/// 人工决策
|
||||
Human,
|
||||
/// AI 建议后人工确认
|
||||
AiSuggested,
|
||||
}
|
||||
|
||||
/// 决策日志
|
||||
pub struct DecisionJournal;
|
||||
|
||||
impl DecisionJournal {
|
||||
/// 记录新决策
|
||||
pub fn record(
|
||||
project_id: ProjectId,
|
||||
context: String,
|
||||
question: String,
|
||||
alternatives: Vec<String>,
|
||||
decision: String,
|
||||
reason: String,
|
||||
decided_by: DecidedBy,
|
||||
) -> Decision {
|
||||
Decision {
|
||||
id: df_core::types::new_id(),
|
||||
project_id,
|
||||
context,
|
||||
question,
|
||||
alternatives,
|
||||
decision,
|
||||
reason,
|
||||
decided_by,
|
||||
entity_type: None,
|
||||
entity_id: None,
|
||||
impact: None,
|
||||
stage: None,
|
||||
created_at: chrono::Utc::now().timestamp(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 查询项目的决策历史
|
||||
pub fn query_by_project(_project_id: &ProjectId) -> Vec<Decision> {
|
||||
// TODO: SQLite 查询
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// 按阶段筛选
|
||||
pub fn filter_by_stage<'a>(decisions: &'a [Decision], stage: &str) -> Vec<&'a Decision> {
|
||||
decisions.iter().filter(|d| d.stage.as_deref() == Some(stage)).collect()
|
||||
}
|
||||
|
||||
/// 生成决策时间线
|
||||
///
|
||||
/// 按时间顺序展示项目的所有决策,用于复盘和审计
|
||||
pub fn timeline(_project_id: &ProjectId) -> Vec<Decision> {
|
||||
// TODO: 查询并按 created_at 排序
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
//! 可追溯性引擎:标注系统、决策留痕、需求-测试映射
|
||||
|
||||
pub mod annotation;
|
||||
pub mod decision;
|
||||
pub mod traceability;
|
||||
|
||||
pub use annotation::{Annotation, AnnotationMarker, AnnotationStatus};
|
||||
pub use decision::{Decision, DecisionJournal};
|
||||
pub use traceability::TraceabilityMatrix;
|
||||
@@ -1,78 +0,0 @@
|
||||
//! 需求-功能-测试可追溯矩阵
|
||||
//!
|
||||
//! 建立需求 → 功能 → 测试用例 → 测试报告 的双向追溯关系
|
||||
|
||||
use df_core::types::ProjectId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 追溯关系
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TraceLink {
|
||||
/// 上游实体类型 (Requirement / Feature)
|
||||
pub source_type: String,
|
||||
pub source_id: String,
|
||||
/// 下游实体类型 (Feature / TestCase / TestRun)
|
||||
pub target_type: String,
|
||||
pub target_id: String,
|
||||
}
|
||||
|
||||
/// 覆盖率统计
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CoverageReport {
|
||||
/// 功能总数
|
||||
pub total_features: usize,
|
||||
/// 已选中功能数
|
||||
pub selected_features: usize,
|
||||
/// 有测试用例的功能数
|
||||
pub features_with_tests: usize,
|
||||
/// 测试覆盖率百分比
|
||||
pub coverage_percent: f32,
|
||||
/// 测试通过率
|
||||
pub pass_rate: f32,
|
||||
}
|
||||
|
||||
/// 可追溯矩阵
|
||||
pub struct TraceabilityMatrix;
|
||||
|
||||
impl TraceabilityMatrix {
|
||||
/// 创建追溯关系
|
||||
pub fn link(
|
||||
source_type: &str,
|
||||
source_id: &str,
|
||||
target_type: &str,
|
||||
target_id: &str,
|
||||
) -> TraceLink {
|
||||
TraceLink {
|
||||
source_type: source_type.to_string(),
|
||||
source_id: source_id.to_string(),
|
||||
target_type: target_type.to_string(),
|
||||
target_id: target_id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 查询功能的完整追溯链
|
||||
///
|
||||
/// Feature → [TestCases] → [TestRuns] → [TestReports]
|
||||
pub fn trace_feature(_feature_id: &str) -> Vec<TraceLink> {
|
||||
// TODO: SQLite 查询完整链路
|
||||
vec![]
|
||||
}
|
||||
|
||||
/// 生成覆盖率报告
|
||||
pub fn coverage_report(_project_id: &ProjectId) -> CoverageReport {
|
||||
// TODO: 统计功能-测试用例覆盖情况
|
||||
CoverageReport {
|
||||
total_features: 0,
|
||||
selected_features: 0,
|
||||
features_with_tests: 0,
|
||||
coverage_percent: 0.0,
|
||||
pass_rate: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// 查找未覆盖的功能(有功能但无测试用例)
|
||||
pub fn uncovered_features(_project_id: &ProjectId) -> Vec<String> {
|
||||
// TODO: 找出没有关联测试用例的功能
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,10 @@ pub struct ConditionEngine;
|
||||
impl ConditionEngine {
|
||||
/// 求值条件表达式
|
||||
///
|
||||
/// TODO: 实现完整的表达式解析,当前仅支持简单的 JSON Path 比较
|
||||
/// 当前仅支持 "true"/"false" 字面量。未识别的表达式 **默认 false**(保守拒绝),
|
||||
/// 而非默认 true——条件分支写错或引擎未实现时不应该静默放行。
|
||||
/// TODO: 实现完整的表达式解析(JSON Path / 数值比较 / contains / 逻辑组合)。
|
||||
pub fn evaluate(expr: &str, _context: &Value) -> anyhow::Result<bool> {
|
||||
// 骨架:简单的 true/false 字面量
|
||||
let trimmed = expr.trim();
|
||||
if trimmed == "true" {
|
||||
return Ok(true);
|
||||
@@ -27,7 +28,70 @@ impl ConditionEngine {
|
||||
// - "$.tags contains 'ai'" — 包含检查
|
||||
// - "and/or/not" — 逻辑组合
|
||||
|
||||
tracing::warn!("条件表达式引擎尚未完整实现,表达式: {}", expr);
|
||||
Ok(true)
|
||||
tracing::warn!("条件表达式引擎尚未完整实现,表达式未识别默认 false: {}", expr);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn ctx() -> Value {
|
||||
// 当前实现未使用 context,但保持传入以对齐签名
|
||||
json!({})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_true_literal_returns_true() {
|
||||
assert_eq!(ConditionEngine::evaluate("true", &ctx()).unwrap(), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_false_literal_returns_false() {
|
||||
assert_eq!(ConditionEngine::evaluate("false", &ctx()).unwrap(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unsupported_expression_defaults_to_false() {
|
||||
// 尚未实现的语法:表达式非 true/false 字面量时默认 false(保守拒绝,不静默放行)
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("$.status == 'completed'", &ctx()).unwrap(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_whitespace_is_trimmed() {
|
||||
// trimmed() 去除首尾空白后再与字面量比较
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate(" true ", &ctx()).unwrap(),
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
ConditionEngine::evaluate("\tfalse\n", &ctx()).unwrap(),
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_string_defaults_to_false() {
|
||||
assert_eq!(ConditionEngine::evaluate("", &ctx()).unwrap(), false);
|
||||
assert_eq!(ConditionEngine::evaluate(" ", &ctx()).unwrap(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_case_sensitive_not_matched() {
|
||||
// 字面量比较区分大小写:True/False 既非 "true" 也非 "false",默认 false
|
||||
assert_eq!(ConditionEngine::evaluate("True", &ctx()).unwrap(), false);
|
||||
assert_eq!(ConditionEngine::evaluate("FALSE", &ctx()).unwrap(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_arbitrary_string_defaults_to_false() {
|
||||
assert_eq!(ConditionEngine::evaluate("yes", &ctx()).unwrap(), false);
|
||||
assert_eq!(ConditionEngine::evaluate("1", &ctx()).unwrap(), false);
|
||||
assert_eq!(ConditionEngine::evaluate("completed", &ctx()).unwrap(), false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,14 +74,6 @@ impl NodeRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NodeRegistry {
|
||||
fn default() -> Self {
|
||||
let mut registry = Self::new();
|
||||
registry.register("script", |_config| {
|
||||
// ScriptNode 的工厂 — 需要 df-nodes 依赖后才可用
|
||||
// 这里返回一个占位实现,实际项目中由 df-nodes crate 注册
|
||||
unimplemented!("ScriptNode 需要通过 df-nodes 注册")
|
||||
});
|
||||
registry
|
||||
}
|
||||
}
|
||||
// 不实现 Default:原 Default 注册了一个会 panic 的 script 工厂(unimplemented!),
|
||||
// 违反项目铁律「无 panic——所有占位代码返回空/默认值」。所有调用方应显式 new() +
|
||||
// 手动 register 真实节点(如 state.rs::build_registry 的做法)。
|
||||
|
||||
Reference in New Issue
Block a user