重构: df-ai-core trait下沉拆crate+导入历史项目批量扫描
This commit is contained in:
11
crates/df-ai-core/Cargo.toml
Normal file
11
crates/df-ai-core/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "df-ai-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
futures = "0.3"
|
||||
10
crates/df-ai-core/src/lib.rs
Normal file
10
crates/df-ai-core/src/lib.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
//! df-ai-core: LLM Provider trait + AI 数据结构(轻量 crate,零 HTTP 依赖)
|
||||
//!
|
||||
//! 从 df-ai 下沉的全局 AI 接入标准。df-ai 保留 HTTP impl 与业务逻辑
|
||||
//! (ContextManager / AiToolRegistry / build_provider 等),通过 re-export
|
||||
//! 保持 `df_ai::provider::*` 路径不变。df-ideas 等轻消费方直接依赖本 crate
|
||||
//! 的 trait 即可接 LLM,不引入 reqwest / eventsource-stream 等重依赖。
|
||||
|
||||
pub mod provider;
|
||||
|
||||
pub use provider::*;
|
||||
235
crates/df-ai-core/src/provider.rs
Normal file
235
crates/df-ai-core/src/provider.rs
Normal file
@@ -0,0 +1,235 @@
|
||||
//! LLM Provider trait — 统一的 LLM 调用抽象
|
||||
//!
|
||||
//! 支持 OpenAI 兼容 API(覆盖 OpenAI / GLM / DeepSeek / Claude 兼容模式),
|
||||
//! 含 function calling / tool use 能力。
|
||||
//!
|
||||
//! 本文件仅含 trait + 数据结构定义(零 IO)。HTTP impl(OpenAICompatProvider /
|
||||
//! AnthropicCompatProvider)+ 业务逻辑(ContextManager / AiToolRegistry /
|
||||
//! build_provider 工厂)留在 df-ai crate。
|
||||
|
||||
use std::pin::Pin;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ============================================================
|
||||
// 核心数据结构
|
||||
// ============================================================
|
||||
|
||||
/// LLM 调用请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CompletionRequest {
|
||||
/// 模型名称
|
||||
pub model: String,
|
||||
/// 提示消息列表
|
||||
pub messages: Vec<ChatMessage>,
|
||||
/// 温度(0.0 ~ 2.0)
|
||||
pub temperature: Option<f32>,
|
||||
/// 最大生成 token 数
|
||||
pub max_tokens: Option<u32>,
|
||||
/// 是否流式输出
|
||||
pub stream: bool,
|
||||
/// 可调用的工具定义
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Vec<ToolDefinition>>,
|
||||
/// 工具调用策略: "auto" | "none" | {"type":"function","name":"xxx"}
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// 聊天消息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
pub role: MessageRole,
|
||||
pub content: String,
|
||||
/// 工具调用 ID(role=Tool 时必填)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
/// 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>,
|
||||
/// 消息状态(UX-09 编辑重生成):None/"active" 正常可见;
|
||||
/// "truncated" 软删(编辑某条 user 消息后其后续消息标记,保留 DB 可追溯但不进 LLM 上下文、前端视图过滤)
|
||||
/// 默认 None(向前兼容老 JSON 反序列化)。落库随 messages JSON 序列化,无需独立列。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub status: 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, model: None, status: None }
|
||||
}
|
||||
pub fn user(content: impl Into<String>) -> Self {
|
||||
Self { role: MessageRole::User, content: content.into(), tool_call_id: None, tool_calls: None, model: None, status: None }
|
||||
}
|
||||
pub fn assistant(content: impl Into<String>) -> Self {
|
||||
Self { role: MessageRole::Assistant, content: content.into(), tool_call_id: None, tool_calls: None, model: None, status: 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), model: None, status: 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, model: None, status: None }
|
||||
}
|
||||
|
||||
/// 是否处于 active 态(status 为 None 或 "active")。truncated 返回 false。
|
||||
pub fn is_active(&self) -> bool {
|
||||
!matches!(self.status.as_deref(), Some("truncated"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 消息角色
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MessageRole {
|
||||
System,
|
||||
User,
|
||||
Assistant,
|
||||
Tool,
|
||||
}
|
||||
|
||||
/// 工具定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolDefinition {
|
||||
#[serde(rename = "type")]
|
||||
pub tool_type: String,
|
||||
pub function: ToolFunction,
|
||||
}
|
||||
|
||||
impl ToolDefinition {
|
||||
pub fn function(name: impl Into<String>, description: impl Into<String>, parameters: serde_json::Value) -> Self {
|
||||
Self {
|
||||
tool_type: "function".into(),
|
||||
function: ToolFunction { name: name.into(), description: description.into(), parameters },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 函数定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolFunction {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub parameters: serde_json::Value,
|
||||
}
|
||||
|
||||
/// 工具调用(AI 发起)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCall {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub call_type: String,
|
||||
pub function: ToolCallFunction,
|
||||
}
|
||||
|
||||
impl ToolCall {
|
||||
pub fn new(id: impl Into<String>, name: impl Into<String>, arguments: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
call_type: "function".into(),
|
||||
function: ToolCallFunction { name: name.into(), arguments: arguments.into() },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 工具调用函数部分
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCallFunction {
|
||||
pub name: String,
|
||||
pub arguments: String,
|
||||
}
|
||||
|
||||
/// LLM 调用响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CompletionResponse {
|
||||
/// 生成的文本
|
||||
pub text: String,
|
||||
/// 使用的模型
|
||||
pub model: String,
|
||||
/// 消耗的 token 数
|
||||
pub usage: TokenUsage,
|
||||
/// AI 发起的工具调用(如有)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<ToolCall>>,
|
||||
}
|
||||
|
||||
/// Token 用量
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct TokenUsage {
|
||||
pub prompt_tokens: u32,
|
||||
pub completion_tokens: u32,
|
||||
pub total_tokens: u32,
|
||||
}
|
||||
|
||||
/// 流式输出的 chunk
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StreamChunk {
|
||||
/// 增量文本
|
||||
pub delta: String,
|
||||
/// 是否结束
|
||||
pub finished: bool,
|
||||
/// 工具调用增量(如有)
|
||||
#[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>,
|
||||
/// provider 流式错误事件(如 Anthropic SSE `type=="error"`)。
|
||||
/// 非空表示流中途出错,不应视为正常完成(finished 路径),由 stream_llm 转 AiError。
|
||||
#[serde(skip)]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// 工具调用增量(流式中的片段)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCallDelta {
|
||||
/// 索引
|
||||
pub index: u32,
|
||||
/// 工具调用 ID(仅第一个 chunk 有)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
/// 函数名片段
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub function_name: Option<String>,
|
||||
/// 函数参数片段
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub function_arguments: Option<String>,
|
||||
}
|
||||
|
||||
/// 异步流类型别名
|
||||
pub type StreamResult = Pin<Box<dyn Stream<Item = anyhow::Result<StreamChunk>> + Send>>;
|
||||
|
||||
/// LLM Provider trait
|
||||
#[async_trait]
|
||||
pub trait LlmProvider: Send + Sync {
|
||||
/// 同步调用
|
||||
async fn complete(&self, request: CompletionRequest) -> anyhow::Result<CompletionResponse>;
|
||||
|
||||
/// 流式调用(返回异步流)
|
||||
async fn stream(
|
||||
&self,
|
||||
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;
|
||||
|
||||
/// 实际请求端点(含 base_url + 关键路径,如 chat completions / messages)。
|
||||
/// 默认回落 `name()`,provider 实现覆盖返真实 URL,供 401/网络错误诊断打印
|
||||
/// —— 旧路径只能近似打印 provider_type,看不到实际请求端点。
|
||||
fn endpoint(&self) -> String {
|
||||
self.name().to_string()
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
df-core = { path = "../df-core" }
|
||||
df-ai-core = { path = "../df-ai-core" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["sync", "time"] }
|
||||
|
||||
@@ -13,6 +13,9 @@ pub mod retry;
|
||||
|
||||
use provider::LlmProvider;
|
||||
|
||||
// df-ai-core 直接暴露,供需要直接引用 trait crate 的下游(可选)。
|
||||
pub use df_ai_core;
|
||||
|
||||
/// 按 provider_type 构建 LLM Provider 实例(统一选择逻辑,消除调用方重复 match)
|
||||
///
|
||||
/// `anthropic` 协议走 AnthropicCompatProvider(GLM 订阅端点 / Claude 官方),
|
||||
|
||||
@@ -1,231 +1,15 @@
|
||||
//! LLM Provider trait — 统一的 LLM 调用抽象
|
||||
//! LLM Provider trait + 数据结构 re-export
|
||||
//!
|
||||
//! 支持 OpenAI 兼容 API(覆盖 OpenAI / GLM / DeepSeek / Claude 兼容模式),
|
||||
//! 含 function calling / tool use 能力。
|
||||
//! trait 与数据结构定义已下沉到 df-ai-core crate(零 HTTP 依赖,轻消费方
|
||||
//! 如 df-ideas 可直接依赖 trait 不引入 reqwest/eventsource-stream)。
|
||||
//! 本文件保留为 df-ai 的 re-export 入口,使 `df_ai::provider::*` 路径不变
|
||||
//! —— df-ai 内部模块(openai_compat / anthropic_compat / context / ai_tools)
|
||||
//! 与外部消费方(df-nodes / src-tauri)的 `use df_ai::provider::LlmProvider`
|
||||
//! 等引用全部透明继续可用(编译期验证)。
|
||||
//!
|
||||
//! 留在 df-ai 的部分:
|
||||
//! - `OpenAICompatProvider` / `AnthropicCompatProvider`(HTTP impl,见 openai_compat.rs / anthropic_compat.rs)
|
||||
//! - `build_provider()` 工厂(见 lib.rs,按协议选 impl)
|
||||
//! - `ContextManager` / `AiToolRegistry` / `StreamCollector`(业务逻辑)
|
||||
|
||||
use std::pin::Pin;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ============================================================
|
||||
// 核心数据结构
|
||||
// ============================================================
|
||||
|
||||
/// LLM 调用请求
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CompletionRequest {
|
||||
/// 模型名称
|
||||
pub model: String,
|
||||
/// 提示消息列表
|
||||
pub messages: Vec<ChatMessage>,
|
||||
/// 温度(0.0 ~ 2.0)
|
||||
pub temperature: Option<f32>,
|
||||
/// 最大生成 token 数
|
||||
pub max_tokens: Option<u32>,
|
||||
/// 是否流式输出
|
||||
pub stream: bool,
|
||||
/// 可调用的工具定义
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Vec<ToolDefinition>>,
|
||||
/// 工具调用策略: "auto" | "none" | {"type":"function","name":"xxx"}
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// 聊天消息
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
pub role: MessageRole,
|
||||
pub content: String,
|
||||
/// 工具调用 ID(role=Tool 时必填)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
/// 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>,
|
||||
/// 消息状态(UX-09 编辑重生成):None/"active" 正常可见;
|
||||
/// "truncated" 软删(编辑某条 user 消息后其后续消息标记,保留 DB 可追溯但不进 LLM 上下文、前端视图过滤)
|
||||
/// 默认 None(向前兼容老 JSON 反序列化)。落库随 messages JSON 序列化,无需独立列。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub status: 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, model: None, status: None }
|
||||
}
|
||||
pub fn user(content: impl Into<String>) -> Self {
|
||||
Self { role: MessageRole::User, content: content.into(), tool_call_id: None, tool_calls: None, model: None, status: None }
|
||||
}
|
||||
pub fn assistant(content: impl Into<String>) -> Self {
|
||||
Self { role: MessageRole::Assistant, content: content.into(), tool_call_id: None, tool_calls: None, model: None, status: 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), model: None, status: 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, model: None, status: None }
|
||||
}
|
||||
|
||||
/// 是否处于 active 态(status 为 None 或 "active")。truncated 返回 false。
|
||||
pub fn is_active(&self) -> bool {
|
||||
!matches!(self.status.as_deref(), Some("truncated"))
|
||||
}
|
||||
}
|
||||
|
||||
/// 消息角色
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MessageRole {
|
||||
System,
|
||||
User,
|
||||
Assistant,
|
||||
Tool,
|
||||
}
|
||||
|
||||
/// 工具定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolDefinition {
|
||||
#[serde(rename = "type")]
|
||||
pub tool_type: String,
|
||||
pub function: ToolFunction,
|
||||
}
|
||||
|
||||
impl ToolDefinition {
|
||||
pub fn function(name: impl Into<String>, description: impl Into<String>, parameters: serde_json::Value) -> Self {
|
||||
Self {
|
||||
tool_type: "function".into(),
|
||||
function: ToolFunction { name: name.into(), description: description.into(), parameters },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 函数定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolFunction {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub parameters: serde_json::Value,
|
||||
}
|
||||
|
||||
/// 工具调用(AI 发起)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCall {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub call_type: String,
|
||||
pub function: ToolCallFunction,
|
||||
}
|
||||
|
||||
impl ToolCall {
|
||||
pub fn new(id: impl Into<String>, name: impl Into<String>, arguments: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
call_type: "function".into(),
|
||||
function: ToolCallFunction { name: name.into(), arguments: arguments.into() },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 工具调用函数部分
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCallFunction {
|
||||
pub name: String,
|
||||
pub arguments: String,
|
||||
}
|
||||
|
||||
/// LLM 调用响应
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CompletionResponse {
|
||||
/// 生成的文本
|
||||
pub text: String,
|
||||
/// 使用的模型
|
||||
pub model: String,
|
||||
/// 消耗的 token 数
|
||||
pub usage: TokenUsage,
|
||||
/// AI 发起的工具调用(如有)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<ToolCall>>,
|
||||
}
|
||||
|
||||
/// Token 用量
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct TokenUsage {
|
||||
pub prompt_tokens: u32,
|
||||
pub completion_tokens: u32,
|
||||
pub total_tokens: u32,
|
||||
}
|
||||
|
||||
/// 流式输出的 chunk
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StreamChunk {
|
||||
/// 增量文本
|
||||
pub delta: String,
|
||||
/// 是否结束
|
||||
pub finished: bool,
|
||||
/// 工具调用增量(如有)
|
||||
#[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>,
|
||||
/// provider 流式错误事件(如 Anthropic SSE `type=="error"`)。
|
||||
/// 非空表示流中途出错,不应视为正常完成(finished 路径),由 stream_llm 转 AiError。
|
||||
#[serde(skip)]
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// 工具调用增量(流式中的片段)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCallDelta {
|
||||
/// 索引
|
||||
pub index: u32,
|
||||
/// 工具调用 ID(仅第一个 chunk 有)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
/// 函数名片段
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub function_name: Option<String>,
|
||||
/// 函数参数片段
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub function_arguments: Option<String>,
|
||||
}
|
||||
|
||||
/// 异步流类型别名
|
||||
pub type StreamResult = Pin<Box<dyn Stream<Item = anyhow::Result<StreamChunk>> + Send>>;
|
||||
|
||||
/// LLM Provider trait
|
||||
#[async_trait]
|
||||
pub trait LlmProvider: Send + Sync {
|
||||
/// 同步调用
|
||||
async fn complete(&self, request: CompletionRequest) -> anyhow::Result<CompletionResponse>;
|
||||
|
||||
/// 流式调用(返回异步流)
|
||||
async fn stream(
|
||||
&self,
|
||||
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;
|
||||
|
||||
/// 实际请求端点(含 base_url + 关键路径,如 chat completions / messages)。
|
||||
/// 默认回落 `name()`,provider 实现覆盖返真实 URL,供 401/网络错误诊断打印
|
||||
/// —— 旧路径只能近似打印 provider_type,看不到实际请求端点。
|
||||
fn endpoint(&self) -> String {
|
||||
self.name().to_string()
|
||||
}
|
||||
}
|
||||
pub use df_ai_core::provider::*;
|
||||
|
||||
@@ -5,9 +5,11 @@ edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
df-core = { path = "../df-core" }
|
||||
df-ai-core = { path = "../df-ai-core" }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
@@ -1,15 +1,40 @@
|
||||
//! 对抗式评估系统 — 正反方辩论 + AI 分析师
|
||||
//!
|
||||
//! 当前为基于评分与内容信号的启发式实现(稳定、有区分度)。
|
||||
//! TODO: 接入 df-ai LlmProvider 让正反方论点由 LLM 生成,启发式降级为 fallback。
|
||||
//! 双轨实现:
|
||||
//! - **启发式**(默认/降级):基于评分与内容信号生成正反方论点,稳定有区分度。
|
||||
//! - **LLM**(注入 provider 后):调一次 `complete()` 让论点由 LLM 生成,失败自动降级启发式。
|
||||
//!
|
||||
//! 评估来源由 [`EvaluatedBy`] 三态标记:`Llm`(LLM 深度评估)/ `Heuristic`(主动选启发式,
|
||||
//! 无 provider)/ `HeuristicFallback`(LLM 调用失败降级)。前端可据此显示评估深度标签。
|
||||
//!
|
||||
//! LLM prompt 构造与 JSON 解析在 F-260614-03(已由本任务解锁)接入,当前 `evaluate_with_llm`
|
||||
//! 返回 Err 触发降级路径——机制完整,仅缺 prompt/解析实现。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use df_ai_core::provider::LlmProvider;
|
||||
use df_core::types::{IdeaId, Priority};
|
||||
use crate::capture::Idea;
|
||||
use crate::scoring::IdeaScores;
|
||||
|
||||
/// 评估来源标记
|
||||
///
|
||||
/// `Default = Heuristic`:老数据(F-07 之前)序列化时无 evaluated_by 字段,
|
||||
/// 反序列化回落启发式(与 F-07 之前行为一致)。
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum EvaluatedBy {
|
||||
/// LLM 深度评估
|
||||
Llm,
|
||||
/// 启发式评估(无 LLM 配置时的默认模式,也是老数据反序列化默认值)
|
||||
#[default]
|
||||
Heuristic,
|
||||
/// 启发式降级(LLM 调用失败后 fallback)
|
||||
HeuristicFallback,
|
||||
}
|
||||
|
||||
/// 对抗评估结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AdversarialEval {
|
||||
@@ -19,6 +44,9 @@ pub struct AdversarialEval {
|
||||
pub analyst: AnalystAnalysis,
|
||||
pub final_score: f64,
|
||||
pub recommendation: Recommendation,
|
||||
/// 评估来源(Llm / Heuristic / HeuristicFallback),前端据此显示评估深度标签
|
||||
#[serde(default)]
|
||||
pub evaluated_by: EvaluatedBy,
|
||||
}
|
||||
|
||||
/// 论点(正方/反方共用同一结构)
|
||||
@@ -62,19 +90,64 @@ pub enum Recommendation {
|
||||
}
|
||||
|
||||
/// 对抗评估引擎
|
||||
pub struct AdversarialEngine;
|
||||
pub struct AdversarialEngine {
|
||||
/// 可选 LLM provider。Some → 优先 LLM 评估(失败降级启发式);None → 纯启发式。
|
||||
/// 构造注入(与 IdeaPromoter::new(policy) 同一模式),批量评估复用同一 provider。
|
||||
provider: Option<Arc<dyn LlmProvider>>,
|
||||
}
|
||||
|
||||
impl AdversarialEngine {
|
||||
/// 执行完整的对抗评估
|
||||
#[allow(clippy::unused_async)] // 签名保留 async,待接 LLM 注入异步调用
|
||||
pub async fn evaluate(idea: &Idea) -> Result<AdversarialEval> {
|
||||
/// 注入 LLM provider 构造(provider Some 时走 LLM,调用失败自动降级启发式)
|
||||
pub fn new(provider: Arc<dyn LlmProvider>) -> Self {
|
||||
Self { provider: Some(provider) }
|
||||
}
|
||||
|
||||
/// 纯启发式构造(无 LLM 配置时的默认模式)
|
||||
pub fn heuristic() -> Self {
|
||||
Self { provider: None }
|
||||
}
|
||||
|
||||
/// 执行完整的对抗评估(内部按 provider 有无调度 LLM / 启发式,失败降级)
|
||||
pub async fn evaluate(&self, idea: &Idea) -> Result<AdversarialEval> {
|
||||
match &self.provider {
|
||||
Some(p) => match self.evaluate_with_llm(idea, p).await {
|
||||
Ok(mut eval) => {
|
||||
eval.evaluated_by = EvaluatedBy::Llm;
|
||||
Ok(eval)
|
||||
}
|
||||
Err(e) => {
|
||||
// LLM 调用失败/超时/格式异常 → 自动降级启发式,保证前端结构完整返回
|
||||
tracing::warn!("LLM 对抗评估失败, 降级到启发式: {e}");
|
||||
let mut eval = self.evaluate_heuristic(idea)?;
|
||||
eval.evaluated_by = EvaluatedBy::HeuristicFallback;
|
||||
Ok(eval)
|
||||
}
|
||||
},
|
||||
None => {
|
||||
let mut eval = self.evaluate_heuristic(idea)?;
|
||||
eval.evaluated_by = EvaluatedBy::Heuristic;
|
||||
Ok(eval)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// LLM 对抗评估(注入 provider 后走此路)。
|
||||
///
|
||||
/// prompt 构造 + JSON 解析在 F-260614-03(已由本任务解锁)接入。当前返回 Err
|
||||
/// 触发降级路径——降级机制与启发式评估路径完整,仅缺 LLM 调用实现。
|
||||
async fn evaluate_with_llm(&self, _idea: &Idea, _provider: &Arc<dyn LlmProvider>) -> Result<AdversarialEval> {
|
||||
anyhow::bail!("LLM 对抗评估尚未实现(F-260614-03)")
|
||||
}
|
||||
|
||||
/// 启发式评估(基于评分与内容信号,稳定有区分度)
|
||||
fn evaluate_heuristic(&self, idea: &Idea) -> Result<AdversarialEval> {
|
||||
// 先做多维评分,作为正反方论点与置信度的依据
|
||||
let scores = crate::scoring::ScoringEngine::compute_default(idea);
|
||||
|
||||
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);
|
||||
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(),
|
||||
@@ -83,12 +156,14 @@ impl AdversarialEngine {
|
||||
analyst,
|
||||
final_score: scores.overall,
|
||||
recommendation,
|
||||
// 由 evaluate() 调用方按调度路径覆盖(Heuristic / HeuristicFallback)
|
||||
evaluated_by: EvaluatedBy::Heuristic,
|
||||
})
|
||||
}
|
||||
|
||||
/// 生成正方观点(支持执行)— confidence 由可行性 + 影响力驱动
|
||||
/// 注:返回 Result 为后续 LLM 注入失败预留,启发式阶段恒 Ok
|
||||
fn generate_positive_argument(idea: &Idea, scores: &IdeaScores) -> Result<Argument> {
|
||||
fn generate_positive_argument(&self, idea: &Idea, scores: &IdeaScores) -> Result<Argument> {
|
||||
let desc = idea.description.trim();
|
||||
let mut evidence = Vec::new();
|
||||
evidence.push(format!("优先级:{}", priority_label(&idea.priority)));
|
||||
@@ -126,7 +201,7 @@ impl AdversarialEngine {
|
||||
}
|
||||
|
||||
/// 生成反方观点(反对或谨慎)— 论点基于想法实际缺陷,confidence 随风险上升
|
||||
fn generate_negative_argument(idea: &Idea, scores: &IdeaScores) -> Result<Argument> {
|
||||
fn generate_negative_argument(&self, idea: &Idea, scores: &IdeaScores) -> Result<Argument> {
|
||||
let desc = idea.description.trim();
|
||||
let mut evidence = Vec::new();
|
||||
if desc.is_empty() {
|
||||
@@ -166,7 +241,7 @@ impl AdversarialEngine {
|
||||
}
|
||||
|
||||
/// AI 分析师综合分析 — 评估等级由综合评分决定,优势/劣势按维度动态生成
|
||||
fn analyst_analysis(idea: &Idea, scores: &IdeaScores) -> Result<AnalystAnalysis> {
|
||||
fn analyst_analysis(&self, 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,
|
||||
@@ -234,7 +309,7 @@ impl AdversarialEngine {
|
||||
}
|
||||
|
||||
/// 评估等级 → 最终建议
|
||||
fn recommendation_for(level: &AssessmentLevel) -> Recommendation {
|
||||
fn recommendation_for(&self, level: &AssessmentLevel) -> Recommendation {
|
||||
match level {
|
||||
AssessmentLevel::StrongGo => Recommendation::ImmediateAction,
|
||||
AssessmentLevel::Recommended => Recommendation::Soon,
|
||||
@@ -302,7 +377,7 @@ mod tests {
|
||||
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();
|
||||
let eval = AdversarialEngine::heuristic().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);
|
||||
@@ -316,7 +391,7 @@ mod tests {
|
||||
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();
|
||||
let eval = AdversarialEngine::heuristic().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);
|
||||
@@ -327,7 +402,7 @@ mod tests {
|
||||
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();
|
||||
let eval = AdversarialEngine::heuristic().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);
|
||||
@@ -337,7 +412,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn a4_confidence_ranges() {
|
||||
let idea = make_idea("普通想法", "一般描述", Priority::Medium, vec!["标签"]);
|
||||
let eval = AdversarialEngine::evaluate(&idea).await.unwrap();
|
||||
let eval = AdversarialEngine::heuristic().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);
|
||||
@@ -347,7 +422,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn a5_positive_thesis_contains_title() {
|
||||
let idea = make_idea("独家创意", "描述内容", Priority::High, vec![]);
|
||||
let eval = AdversarialEngine::evaluate(&idea).await.unwrap();
|
||||
let eval = AdversarialEngine::heuristic().evaluate(&idea).await.unwrap();
|
||||
println!("\n[a5] 正方论点含标题");
|
||||
println!(" thesis: {}", eval.positive.thesis);
|
||||
assert!(eval.positive.thesis.contains("独家创意"), "正方 thesis 应含标题");
|
||||
@@ -356,7 +431,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn a6_negative_evidence_nonempty() {
|
||||
let idea = make_idea("待质疑想法", "短", Priority::Low, vec![]);
|
||||
let eval = AdversarialEngine::evaluate(&idea).await.unwrap();
|
||||
let eval = AdversarialEngine::heuristic().evaluate(&idea).await.unwrap();
|
||||
println!("\n[a6] 反方证据非空 ({} 条)", eval.negative.evidence.len());
|
||||
for (i, e) in eval.negative.evidence.iter().enumerate() {
|
||||
println!(" 证据{}: {}", i + 1, e);
|
||||
@@ -369,7 +444,7 @@ mod tests {
|
||||
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();
|
||||
let eval = AdversarialEngine::heuristic().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);
|
||||
|
||||
@@ -92,6 +92,157 @@ pub fn detect_stack(root: &Path) -> Result<Vec<String>> {
|
||||
Ok(stack)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 历史项目发现 — monorepo 识别 + 子项目展开(纯规则,不跑 LLM)
|
||||
// ============================================================
|
||||
|
||||
/// monorepo 工作区配置文件名(JS 生态主流:pnpm/lerna/turbo/nx)
|
||||
const MONOREPO_MARKERS: &[&str] = &[
|
||||
"pnpm-workspace.yaml",
|
||||
"lerna.json",
|
||||
"turbo.json",
|
||||
"nx.json",
|
||||
];
|
||||
|
||||
/// 判定目录是否为 monorepo 根(JS 生态主流工作区管理器)。
|
||||
///
|
||||
/// 命中任一即视为 monorepo:
|
||||
/// - pnpm-workspace.yaml / lerna.json / turbo.json / nx.json 存在
|
||||
/// - package.json 含 `workspaces` 字段(npm/yarn workspaces)
|
||||
pub fn is_monorepo(root: &Path) -> bool {
|
||||
if !root.is_dir() {
|
||||
return false;
|
||||
}
|
||||
for marker in MONOREPO_MARKERS {
|
||||
if root.join(marker).is_file() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// package.json workspaces 字段(npm/yarn)
|
||||
let pkg_path = root.join("package.json");
|
||||
if pkg_path.is_file() {
|
||||
if let Ok(content) = std::fs::read_to_string(&pkg_path) {
|
||||
if let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
if pkg.get("workspaces").is_some() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 单个发现的候选项目(monorepo 子项目或独立项目)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiscoveredProject {
|
||||
/// 项目根目录绝对路径
|
||||
pub path: String,
|
||||
/// 推断的项目名(目录名)
|
||||
pub name: String,
|
||||
/// 规则探测的技术栈(空=未识别)
|
||||
pub stack: Vec<String>,
|
||||
/// 是否为 monorepo 根(便于前端标记)
|
||||
pub is_monorepo: bool,
|
||||
}
|
||||
|
||||
/// 在指定根目录下发现候选项目。
|
||||
///
|
||||
/// 策略(只展开一层,不做深递归):
|
||||
/// 1. 根目录本身有项目标志(Cargo.toml/package.json/go.mod 等)→ 根为独立项目
|
||||
/// 2. 根目录是 monorepo → 展开 packages/\*/apps/\* 直接子目录(各子目录跑 detect_stack 过滤空)
|
||||
/// 3. 否则:扫根的直接子目录,凡 detect_stack 非空的视为候选项目
|
||||
///
|
||||
/// 不跑 LLM(快),不读源码。空 stack 的目录在 monorepo 展开/子目录扫描时被过滤。
|
||||
pub fn discover_projects(root: &Path) -> Result<Vec<DiscoveredProject>> {
|
||||
if !root.is_dir() {
|
||||
anyhow::bail!("路径不是目录: {}", root.display());
|
||||
}
|
||||
|
||||
let mut out: Vec<DiscoveredProject> = Vec::new();
|
||||
let mono = is_monorepo(root);
|
||||
|
||||
// 1. 根目录自身是项目(有 manifest 标志)
|
||||
if has_project_manifest(root) {
|
||||
let stack = detect_stack(root).unwrap_or_default();
|
||||
out.push(DiscoveredProject {
|
||||
path: root.to_string_lossy().to_string(),
|
||||
name: root
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| root.to_string_lossy().to_string()),
|
||||
stack,
|
||||
is_monorepo: mono,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. monorepo → 展开 packages/* apps/* 直接子目录
|
||||
// 3. 普通目录 → 扫直接子目录,凡 detect_stack 非空的入选
|
||||
let scan_globs: &[&str] = if mono {
|
||||
&["packages", "apps"]
|
||||
} else {
|
||||
&[""]
|
||||
};
|
||||
|
||||
for glob in scan_globs {
|
||||
let target = if glob.is_empty() {
|
||||
root.to_path_buf()
|
||||
} else {
|
||||
root.join(glob)
|
||||
};
|
||||
if !target.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(&target) else {
|
||||
continue;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if !p.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
if SAMPLE_IGNORED_DIRS.contains(&name.as_str()) || name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
// 必须有项目标志 + detect_stack 非空
|
||||
if !has_project_manifest(&p) {
|
||||
continue;
|
||||
}
|
||||
let stack = match detect_stack(&p) {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if stack.is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.push(DiscoveredProject {
|
||||
path: p.to_string_lossy().to_string(),
|
||||
name,
|
||||
stack,
|
||||
is_monorepo: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// 目录是否含任一项目清单标志文件
|
||||
fn has_project_manifest(dir: &Path) -> bool {
|
||||
const MARKS: &[&str] = &[
|
||||
"Cargo.toml",
|
||||
"package.json",
|
||||
"go.mod",
|
||||
"pyproject.toml",
|
||||
"requirements.txt",
|
||||
"pom.xml",
|
||||
"build.gradle",
|
||||
"build.gradle.kts",
|
||||
];
|
||||
MARKS.iter().any(|m| dir.join(m).is_file()) || has_file_with_ext(dir, "csproj")
|
||||
}
|
||||
|
||||
/// 解析 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())
|
||||
@@ -132,8 +283,8 @@ fn has_file_with_ext(dir: &Path, ext: &str) -> bool {
|
||||
/// 首段定义:跳过开头标题行(# / ## …)、空行、HTML 注释与 badge 图片/HTML 行等噪声,
|
||||
/// 取首个含实质文本的段落(连续多行直到空行);按字符截断至 200 字避免超长。
|
||||
pub fn extract_description(root: &Path) -> Option<String> {
|
||||
// 复用 read_readme 的查找逻辑(支持 README.md / README.zh.md 等变体)
|
||||
let content = read_readme(root)?;
|
||||
// 复用 read_readme_raw 的查找逻辑(支持 README.md / README.zh.md 等变体)
|
||||
let content = read_readme_raw(root)?;
|
||||
let mut text = String::new();
|
||||
let mut started = false;
|
||||
for raw_line in content.lines() {
|
||||
@@ -171,16 +322,28 @@ pub fn extract_description(root: &Path) -> Option<String> {
|
||||
/// extract_description 最大字符数
|
||||
const EXTRACT_DESC_MAX: usize = 200;
|
||||
|
||||
/// 项目采样结果 — README 首段 + 目录树(2层) + 清单文件片段
|
||||
/// 内容图引用(README 内的架构图/截图等,喂 vision 用)。
|
||||
/// 采样层只收集 alt+src,Phase 2 上线后由 commands 层读 base64 喂 vision。
|
||||
/// 当前 ChatMessage.content:String(F-260614-05 未做)走纯文本降级,
|
||||
/// 此结构仅为采样层留接口,不读 base64。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ImageRef {
|
||||
pub alt: String,
|
||||
pub src: String,
|
||||
}
|
||||
|
||||
/// 项目采样结果 — README(剥噪音后) + 目录树(2层) + 清单文件片段 + 内容图引用
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProjectSample {
|
||||
pub readme: Option<String>,
|
||||
pub tree: Vec<String>,
|
||||
/// (文件名, 截断内容)
|
||||
pub manifests: Vec<(String, String)>,
|
||||
/// README 内的内容图引用(架构图/截图等,跳徽章)
|
||||
pub images: Vec<ImageRef>,
|
||||
}
|
||||
|
||||
const SAMPLE_README_MAX: usize = 2000;
|
||||
const SAMPLE_README_MAX: usize = 8000;
|
||||
const SAMPLE_MANIFEST_MAX: usize = 1500;
|
||||
const SAMPLE_TREE_MAX: usize = 80;
|
||||
/// 目录树过滤的噪音目录(依赖产物/构建/缓存/IDE)
|
||||
@@ -190,30 +353,260 @@ const SAMPLE_IGNORED_DIRS: &[&str] = &[
|
||||
".turbo", ".angular", ".gradle", "vendor",
|
||||
];
|
||||
|
||||
/// 采集项目采样(README + 目录树 + 清单),供 LLM 分析填基础信息
|
||||
/// 采集项目采样(README + 目录树 + 清单 + 内容图),供 LLM 分析填基础信息
|
||||
pub fn collect_sample(root: &Path) -> Result<ProjectSample> {
|
||||
if !root.is_dir() {
|
||||
anyhow::bail!("路径不是目录: {}", root.display());
|
||||
}
|
||||
let raw = read_readme_raw(root);
|
||||
let (readme, images) = match raw {
|
||||
Some(text) => {
|
||||
let images = collect_images(&text);
|
||||
let cleaned = strip_readme_noise(&text);
|
||||
if cleaned.trim().is_empty() {
|
||||
(None, images)
|
||||
} else {
|
||||
(Some(truncate_chars(&cleaned, SAMPLE_README_MAX)), images)
|
||||
}
|
||||
}
|
||||
None => (None, Vec::new()),
|
||||
};
|
||||
Ok(ProjectSample {
|
||||
readme: read_readme(root),
|
||||
readme,
|
||||
tree: collect_tree(root),
|
||||
manifests: collect_manifests(root),
|
||||
images,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_readme(root: &Path) -> Option<String> {
|
||||
/// 读 README 原始内容(不做处理)
|
||||
fn read_readme_raw(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));
|
||||
return Some(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 徽章图域名(纯徽章图,跳过不喂 LLM)
|
||||
const BADGE_HOSTS: &[&str] = &[
|
||||
"img.shields.io",
|
||||
"shields.io",
|
||||
"badge.fury.io",
|
||||
"badgen.net",
|
||||
"badges.frapsoft.com",
|
||||
"github.com/workflows", // GitHub Actions 工作流状态徽章
|
||||
"travis-ci.org",
|
||||
"coveralls.io",
|
||||
"codecov.io",
|
||||
"app.codacy.com",
|
||||
"david-dm.org",
|
||||
"circleci.com",
|
||||
"ci.appveyor.com",
|
||||
];
|
||||
|
||||
/// 徽章关键词(图 alt/src 含这些视为纯徽章图)
|
||||
const BADGE_KEYWORDS: &[&str] = &[
|
||||
"build",
|
||||
"version",
|
||||
"license",
|
||||
"coverage",
|
||||
"downloads",
|
||||
"download",
|
||||
"npm",
|
||||
"pypi",
|
||||
"crates.io",
|
||||
"codeclimate",
|
||||
"maintainability",
|
||||
"stars",
|
||||
"forks",
|
||||
"issues",
|
||||
"contributors",
|
||||
"dependencies",
|
||||
"devdependencies",
|
||||
"circleci",
|
||||
"travis",
|
||||
"appveyor",
|
||||
"coveralls",
|
||||
"codecov",
|
||||
];
|
||||
|
||||
/// 判定 markdown 图片 `` 是否为纯徽章(架构图/截图等保留)
|
||||
fn is_badge_image(alt: &str, src: &str) -> bool {
|
||||
// 域名命中(shields.io / badge.fury / GitHub Actions 等)
|
||||
let src_lower = src.to_lowercase();
|
||||
if BADGE_HOSTS.iter().any(|h| src_lower.contains(h)) {
|
||||
return true;
|
||||
}
|
||||
// 关键词命中(alt 或 src 含 build/version/license/coverage 等)
|
||||
let hay = format!("{alt} {src}").to_lowercase();
|
||||
if BADGE_KEYWORDS.iter().any(|k| hay.contains(k)) {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 从 README 收集内容图(跳徽章),保留 markdown 原样引用
|
||||
fn collect_images(content: &str) -> Vec<ImageRef> {
|
||||
let mut out = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
// 匹配  —— 简单行级扫描,够用且无 regex 依赖
|
||||
for line in content.lines() {
|
||||
let mut rest = line;
|
||||
while let Some(start) = rest.find("![") {
|
||||
let after_bracket = &rest[start + 2..];
|
||||
let Some(alt_end) = after_bracket.find("](") else { break };
|
||||
let alt = after_bracket[..alt_end].to_string();
|
||||
let after_paren = &after_bracket[alt_end + 2..];
|
||||
let Some(paren_end) = after_paren.find(')') else { break };
|
||||
let src = after_paren[..paren_end].to_string();
|
||||
rest = &after_paren[paren_end + 1..];
|
||||
if is_badge_image(&alt, &src) {
|
||||
continue;
|
||||
}
|
||||
// 同 src 去重
|
||||
if seen.insert(src.clone()) {
|
||||
out.push(ImageRef { alt, src });
|
||||
}
|
||||
if out.len() >= 20 {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// 剥 README 噪音:frontmatter(YAML/TOML 块) / HTML 注释 / TOC(纯链接目录行) /
|
||||
/// 纯徽章图片行 / 纯徽章 HTML 行。保留标题、正文、内容图 markdown 原样。
|
||||
fn strip_readme_noise(content: &str) -> String {
|
||||
let mut out = Vec::new();
|
||||
let mut lines = content.lines().peekable();
|
||||
while let Some(line) = lines.next() {
|
||||
let trimmed = line.trim();
|
||||
// ── frontmatter 块(YAML `---` / TOML `+++`)──
|
||||
if trimmed == "---" || trimmed == "+++" {
|
||||
// 开头处的 frontmatter:跳到下一个匹配分隔符
|
||||
let delim = trimmed;
|
||||
let mut consumed_any = false;
|
||||
for next in lines.by_ref() {
|
||||
consumed_any = true;
|
||||
if next.trim() == delim {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = consumed_any;
|
||||
continue;
|
||||
}
|
||||
// ── HTML 注释块(<!-- ... -->,可能跨行)──
|
||||
if trimmed.starts_with("<!--") {
|
||||
if trimmed.contains("-->") {
|
||||
// 单行注释,整行跳过
|
||||
continue;
|
||||
}
|
||||
// 多行:吃到含 --> 的行
|
||||
for next in lines.by_ref() {
|
||||
if next.contains("-->") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// ── 纯徽章行:整行只剩图片/HTML 徽章(可能多个  连排)──
|
||||
if is_pure_badge_line(trimmed) {
|
||||
continue;
|
||||
}
|
||||
// ── TOC:纯目录行(整行只有 [..](#anchor) 锚点链接,或 markdown 列表项仅锚点)──
|
||||
if is_toc_line(trimmed) {
|
||||
continue;
|
||||
}
|
||||
out.push(line.to_string());
|
||||
}
|
||||
out.join("\n")
|
||||
}
|
||||
|
||||
/// 判定整行是否为纯徽章行(仅含图片/HTML badge,无其它实质文本)。
|
||||
/// 如 `  <a href="..."><img.../></a>`
|
||||
fn is_pure_badge_line(line: &str) -> bool {
|
||||
if line.is_empty() {
|
||||
return false;
|
||||
}
|
||||
// 仅含图片语法/HTML 标签/空白/[alt] 片段,且至少有一个图片或 HTML img 标签
|
||||
let mut content_found = false;
|
||||
let mut text_only: String = String::new();
|
||||
let mut rest = line;
|
||||
loop {
|
||||
// 找下一个 ![ 或 <img 或 <a
|
||||
let img_md = rest.find("![");
|
||||
let img_html = rest.to_lowercase().find("<img");
|
||||
let anchor_html = rest.to_lowercase().find("<a ");
|
||||
let earliest = [img_md, img_html, anchor_html]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.min();
|
||||
let Some(pos) = earliest else {
|
||||
// 剩余纯文本
|
||||
text_only.push_str(rest);
|
||||
break;
|
||||
};
|
||||
// pos 之前的文本进 text_only
|
||||
text_only.push_str(&rest[..pos]);
|
||||
let suffix = &rest[pos..];
|
||||
let consumed = if suffix.starts_with(".unwrap_or(end)];
|
||||
let src = &suffix[suffix.find("](").map(|i| i + 2).unwrap_or(end)..end];
|
||||
if !is_badge_image(alt, src) {
|
||||
// 内容图混在行里 → 非纯徽章行
|
||||
return false;
|
||||
}
|
||||
content_found = true;
|
||||
end + 1
|
||||
} else {
|
||||
// 不闭合,当普通文本
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// HTML <img 或 <a 标签:视为徽章载体(HTML 行常见于此)
|
||||
// 找 > 闭合
|
||||
if let Some(end) = suffix.find('>') {
|
||||
content_found = true;
|
||||
end + 1
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
rest = &suffix[consumed..];
|
||||
}
|
||||
// 剩余 text_only 必须为空或纯空白/标点
|
||||
let residual = text_only
|
||||
.chars()
|
||||
.filter(|c| !c.is_whitespace() && *c != '|' && *c != '-')
|
||||
.collect::<String>();
|
||||
content_found && residual.is_empty()
|
||||
}
|
||||
|
||||
/// 判定 TOC 行:markdown 列表项仅含锚点链接 `- [..](#..)` 或整行仅锚点链接
|
||||
fn is_toc_line(line: &str) -> bool {
|
||||
if line.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let t = line.trim_start_matches(['-', '*', '+', ' ']);
|
||||
// 形如 [text](#anchor) 或 [text](#anchor "title")
|
||||
if !(t.starts_with('[') && t.contains("](")) {
|
||||
return false;
|
||||
}
|
||||
// 取 ]( 后到行尾或空格,须以 # 开头
|
||||
let Some(close) = t.find("](") else { return false };
|
||||
let after = &t[close + 2..];
|
||||
let target = after.trim_end_matches(')').split_whitespace().next().unwrap_or("");
|
||||
target.starts_with('#')
|
||||
}
|
||||
|
||||
/// 目录树(根 + 一层子目录),过滤噪音目录,控条目数
|
||||
fn collect_tree(root: &Path) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
@@ -344,14 +737,25 @@ mod tests {
|
||||
#[test]
|
||||
fn collects_sample() {
|
||||
let d = scratch("sample");
|
||||
fs::write(d.join("README.md"), "# Test\nA test project.\nMore.").unwrap();
|
||||
fs::write(
|
||||
d.join("README.md"),
|
||||
"# Test\n\n\n\n\n\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"));
|
||||
// 徽章行剥,内容图 + 正文保留
|
||||
let readme = s.readme.as_deref().unwrap_or("");
|
||||
assert!(readme.contains("test project"), "readme={readme}");
|
||||
assert!(!readme.contains("shields.io"), "徽章未剥: {readme}");
|
||||
assert!(readme.contains("docs/arch.png"), "内容图丢失: {readme}");
|
||||
// 内容图收集(badge 跳,arch 留)
|
||||
assert_eq!(s.images.len(), 1);
|
||||
assert_eq!(s.images[0].src, "./docs/arch.png");
|
||||
assert!(s.manifests.iter().any(|(n, _)| n == "package.json"));
|
||||
assert!(s.tree.iter().any(|t| t.contains("src")));
|
||||
// node_modules 应被过滤
|
||||
@@ -359,6 +763,103 @@ mod tests {
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_frontmatter_and_toc_and_badges() {
|
||||
let d = scratch("noise");
|
||||
let readme = "---\ntitle: Foo\n---\n\n# Foo\n\n<!-- hidden comment -->\n\n[Install](#install)\n\n- [Usage](#usage)\n\n\n\nThis is the real intro.\n";
|
||||
fs::write(d.join("README.md"), readme).unwrap();
|
||||
let s = collect_sample(&d).unwrap();
|
||||
let r = s.readme.as_deref().unwrap_or("");
|
||||
assert!(r.contains("# Foo"), "标题应保留: {r}");
|
||||
assert!(r.contains("real intro"), "正文应保留: {r}");
|
||||
assert!(!r.contains("hidden comment"), "HTML 注释未剥: {r}");
|
||||
assert!(!r.contains("shields.io"), "徽章未剥: {r}");
|
||||
assert!(!r.contains("[Install](#install)"), "TOC 锚点未剥: {r}");
|
||||
assert!(!r.contains("[Usage](#usage)"), "TOC 列表项未剥: {r}");
|
||||
assert!(!r.contains("title: Foo"), "frontmatter 未剥: {r}");
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_collection_skips_badges() {
|
||||
let content = "\n\n\n\n";
|
||||
let imgs = collect_images(content);
|
||||
// 只有 arch + screenshot,build/version 跳
|
||||
assert_eq!(imgs.len(), 2);
|
||||
assert!(imgs.iter().any(|i| i.src == "./a.png"));
|
||||
assert!(imgs.iter().any(|i| i.src == "screens/b.png"));
|
||||
assert!(imgs.iter().all(|i| !i.src.contains("shields.io")));
|
||||
assert!(imgs.iter().all(|i| !i.src.contains("badge.fury")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_monorepo_pnpm() {
|
||||
let d = scratch("mono-pnpm");
|
||||
fs::write(d.join("pnpm-workspace.yaml"), "packages:\n - packages/*\n").unwrap();
|
||||
assert!(is_monorepo(&d));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_monorepo_npm_workspaces() {
|
||||
let d = scratch("mono-npm");
|
||||
fs::write(
|
||||
d.join("package.json"),
|
||||
r#"{"name":"root","workspaces":["packages/*"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(is_monorepo(&d));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_non_monorepo() {
|
||||
let d = scratch("nonmono");
|
||||
fs::write(d.join("package.json"), r#"{"name":"x"}"#).unwrap();
|
||||
assert!(!is_monorepo(&d));
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_monorepo_children() {
|
||||
let d = scratch("discover-mono");
|
||||
fs::write(d.join("pnpm-workspace.yaml"), "packages:\n - packages/*\n").unwrap();
|
||||
// 子项目:packages/web(有 package.json + vue)、packages/cli(有 Cargo.toml)
|
||||
fs::create_dir_all(d.join("packages/web")).unwrap();
|
||||
fs::write(
|
||||
d.join("packages/web/package.json"),
|
||||
r#"{"name":"web","dependencies":{"vue":"3"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir_all(d.join("packages/cli")).unwrap();
|
||||
fs::write(d.join("packages/cli/Cargo.toml"), "[package]\nname=\"cli\"\n").unwrap();
|
||||
// 空 stack 子目录应过滤
|
||||
fs::create_dir_all(d.join("packages/empty")).unwrap();
|
||||
fs::write(d.join("packages/empty/x.txt"), "x").unwrap();
|
||||
let found = discover_projects(&d).unwrap();
|
||||
// 根自身无 manifest 不入选;packages/web + packages/cli 入选;empty 过滤
|
||||
let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect();
|
||||
assert!(names.contains(&"web"), "names={names:?}");
|
||||
assert!(names.contains(&"cli"), "names={names:?}");
|
||||
assert!(!names.contains(&"empty"), "空 stack 未过滤: {names:?}");
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discover_flat_children() {
|
||||
// 非 monorepo:扫根直接子目录中 detect_stack 非空的
|
||||
let d = scratch("discover-flat");
|
||||
fs::create_dir_all(d.join("proj-a")).unwrap();
|
||||
fs::write(d.join("proj-a/Cargo.toml"), "").unwrap();
|
||||
fs::create_dir_all(d.join("not-a-project")).unwrap();
|
||||
fs::write(d.join("not-a-project/readme.txt"), "x").unwrap();
|
||||
let found = discover_projects(&d).unwrap();
|
||||
let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect();
|
||||
assert!(names.contains(&"proj-a"), "names={names:?}");
|
||||
assert!(!names.contains(&"not-a-project"), "空 stack 未过滤: {names:?}");
|
||||
fs::remove_dir_all(&d).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_desc_skips_title_badge() {
|
||||
let d = scratch("desc");
|
||||
|
||||
Reference in New Issue
Block a user