Files
zclaw_openfang/crates/zclaw-pipeline/src/parser_v2.rs
iven b7f3d94950
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
fix(presentation): 修复 presentation 模块类型错误和语法问题
- 创建 types.ts 定义完整的类型系统
- 重写 DocumentRenderer.tsx 修复语法错误
- 重写 QuizRenderer.tsx 修复语法错误
- 重写 PresentationContainer.tsx 添加类型守卫
- 重写 TypeSwitcher.tsx 修复类型引用
- 更新 index.ts 移除不存在的 ChartRenderer 导出

审计结果:
- 类型检查: 通过
- 单元测试: 222 passed
- 构建: 成功
2026-03-26 17:19:28 +08:00

443 lines
11 KiB
Rust

//! Pipeline v2 Parser
//!
//! Parses YAML pipeline definitions into PipelineV2 structs.
//!
//! # Example
//!
//! ```yaml
//! apiVersion: zclaw/v2
//! kind: Pipeline
//! metadata:
//! name: course-generator
//! displayName: 课程生成器
//! trigger:
//! keywords: [课程, 教程]
//! patterns:
//! - "帮我做*课程"
//! params:
//! - name: topic
//! type: string
//! required: true
//! stages:
//! - id: outline
//! type: llm
//! prompt: "为{params.topic}创建课程大纲"
//! ```
use std::collections::HashSet;
use std::path::Path;
use thiserror::Error;
use crate::types_v2::{PipelineV2, API_VERSION_V2, Stage};
/// Parser errors
#[derive(Debug, Error)]
pub enum ParseErrorV2 {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("YAML parse error: {0}")]
Yaml(#[from] serde_yaml::Error),
#[error("Invalid API version: expected '{expected}', got '{actual}'")]
InvalidVersion { expected: String, actual: String },
#[error("Invalid kind: expected 'Pipeline', got '{0}'")]
InvalidKind(String),
#[error("Missing required field: {0}")]
MissingField(String),
#[error("Validation error: {0}")]
Validation(String),
}
/// Pipeline v2 parser
pub struct PipelineParserV2;
impl PipelineParserV2 {
/// Parse a pipeline from YAML string
pub fn parse(yaml: &str) -> Result<PipelineV2, ParseErrorV2> {
let pipeline: PipelineV2 = serde_yaml::from_str(yaml)?;
// Validate API version
if pipeline.api_version != API_VERSION_V2 {
return Err(ParseErrorV2::InvalidVersion {
expected: API_VERSION_V2.to_string(),
actual: pipeline.api_version.clone(),
});
}
// Validate kind
if pipeline.kind != "Pipeline" {
return Err(ParseErrorV2::InvalidKind(pipeline.kind.clone()));
}
// Validate required fields
if pipeline.metadata.name.is_empty() {
return Err(ParseErrorV2::MissingField("metadata.name".to_string()));
}
// Validate stages
if pipeline.stages.is_empty() {
return Err(ParseErrorV2::Validation(
"Pipeline must have at least one stage".to_string(),
));
}
// Validate stage IDs are unique
let mut seen_ids = HashSet::new();
validate_stage_ids(&pipeline.stages, &mut seen_ids)?;
// Validate parameter names are unique
let mut seen_params = HashSet::new();
for param in &pipeline.params {
if !seen_params.insert(&param.name) {
return Err(ParseErrorV2::Validation(format!(
"Duplicate parameter name: {}",
param.name
)));
}
}
Ok(pipeline)
}
/// Parse a pipeline from file
pub fn parse_file(path: &Path) -> Result<PipelineV2, ParseErrorV2> {
let content = std::fs::read_to_string(path)?;
Self::parse(&content)
}
/// Parse all v2 pipelines in a directory
pub fn parse_directory(dir: &Path) -> Result<Vec<(String, PipelineV2)>, ParseErrorV2> {
let mut pipelines = Vec::new();
if !dir.exists() {
return Ok(pipelines);
}
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().map(|e| e == "yaml" || e == "yml").unwrap_or(false) {
match Self::parse_file(&path) {
Ok(pipeline) => {
let filename = path
.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
pipelines.push((filename, pipeline));
}
Err(e) => {
tracing::warn!("Failed to parse pipeline {:?}: {}", path, e);
}
}
}
}
Ok(pipelines)
}
/// Try to parse as v2, return None if not v2 format
pub fn try_parse(yaml: &str) -> Option<Result<PipelineV2, ParseErrorV2>> {
// Quick check for v2 version marker
if !yaml.contains("apiVersion: zclaw/v2") && !yaml.contains("apiVersion: 'zclaw/v2'") {
return None;
}
Some(Self::parse(yaml))
}
}
/// Recursively validate stage IDs are unique
fn validate_stage_ids(stages: &[Stage], seen_ids: &mut HashSet<String>) -> Result<(), ParseErrorV2> {
for stage in stages {
let id = stage.id().to_string();
if !seen_ids.insert(id.clone()) {
return Err(ParseErrorV2::Validation(format!("Duplicate stage ID: {}", id)));
}
// Recursively validate nested stages
match stage {
Stage::Parallel { stage, .. } => {
validate_stage_ids(std::slice::from_ref(stage), seen_ids)?;
}
Stage::Sequential { stages: sub_stages, .. } => {
validate_stage_ids(sub_stages, seen_ids)?;
}
Stage::Conditional { branches, default, .. } => {
for branch in branches {
validate_stage_ids(std::slice::from_ref(&branch.then), seen_ids)?;
}
if let Some(default_stage) = default {
validate_stage_ids(std::slice::from_ref(default_stage), seen_ids)?;
}
}
_ => {}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_valid_pipeline_v2() {
let yaml = r#"
apiVersion: zclaw/v2
kind: Pipeline
metadata:
name: test-pipeline
displayName: 测试流水线
trigger:
keywords: [测试, pipeline]
patterns:
- "测试*流水线"
params:
- name: topic
type: string
required: true
label: 主题
stages:
- id: step1
type: llm
prompt: "test"
"#;
let pipeline = PipelineParserV2::parse(yaml).unwrap();
assert_eq!(pipeline.metadata.name, "test-pipeline");
assert_eq!(pipeline.metadata.display_name, Some("测试流水线".to_string()));
assert_eq!(pipeline.stages.len(), 1);
}
#[test]
fn test_parse_invalid_version() {
let yaml = r#"
apiVersion: zclaw/v1
kind: Pipeline
metadata:
name: test
stages:
- id: step1
type: llm
prompt: "test"
"#;
let result = PipelineParserV2::parse(yaml);
assert!(matches!(result, Err(ParseErrorV2::InvalidVersion { .. })));
}
#[test]
fn test_parse_invalid_kind() {
let yaml = r#"
apiVersion: zclaw/v2
kind: NotPipeline
metadata:
name: test
stages:
- id: step1
type: llm
prompt: "test"
"#;
let result = PipelineParserV2::parse(yaml);
assert!(matches!(result, Err(ParseErrorV2::InvalidKind(_))));
}
#[test]
fn test_parse_empty_stages() {
let yaml = r#"
apiVersion: zclaw/v2
kind: Pipeline
metadata:
name: test
stages: []
"#;
let result = PipelineParserV2::parse(yaml);
assert!(matches!(result, Err(ParseErrorV2::Validation(_))));
}
#[test]
fn test_parse_duplicate_stage_ids() {
let yaml = r#"
apiVersion: zclaw/v2
kind: Pipeline
metadata:
name: test
stages:
- id: step1
type: llm
prompt: "test"
- id: step1
type: llm
prompt: "test2"
"#;
let result = PipelineParserV2::parse(yaml);
assert!(matches!(result, Err(ParseErrorV2::Validation(_))));
}
#[test]
fn test_parse_parallel_stage() {
let yaml = r#"
apiVersion: zclaw/v2
kind: Pipeline
metadata:
name: test
stages:
- id: parallel1
type: parallel
each: "${params.items}"
stage:
id: inner
type: llm
prompt: "process ${item}"
"#;
let pipeline = PipelineParserV2::parse(yaml).unwrap();
assert_eq!(pipeline.metadata.name, "test");
assert_eq!(pipeline.stages.len(), 1);
}
#[test]
fn test_parse_conditional_stage() {
let yaml = r#"
apiVersion: zclaw/v2
kind: Pipeline
metadata:
name: test
stages:
- id: cond1
type: conditional
condition: "${params.level} == 'advanced'"
branches:
- when: "${params.level} == 'advanced'"
then:
id: advanced
type: llm
prompt: "advanced content"
default:
id: basic
type: llm
prompt: "basic content"
"#;
let pipeline = PipelineParserV2::parse(yaml).unwrap();
assert_eq!(pipeline.metadata.name, "test");
}
#[test]
fn test_parse_sequential_stage() {
let yaml = r#"
apiVersion: zclaw/v2
kind: Pipeline
metadata:
name: test
stages:
- id: seq1
type: sequential
stages:
- id: sub1
type: llm
prompt: "step 1"
- id: sub2
type: llm
prompt: "step 2"
"#;
let pipeline = PipelineParserV2::parse(yaml).unwrap();
assert_eq!(pipeline.metadata.name, "test");
}
#[test]
fn test_parse_all_stage_types() {
let yaml = r#"
apiVersion: zclaw/v2
kind: Pipeline
metadata:
name: test-all-types
stages:
- id: llm1
type: llm
prompt: "llm prompt"
model: "gpt-4"
temperature: 0.7
max_tokens: 1000
- id: compose1
type: compose
template: '{"result": "${stages.llm1}"}'
- id: skill1
type: skill
skill_id: "research-skill"
input:
query: "${params.topic}"
- id: hand1
type: hand
hand_id: "browser"
action: "navigate"
params:
url: "https://example.com"
- id: http1
type: http
url: "https://api.example.com/data"
method: "POST"
headers:
Content-Type: "application/json"
body: '{"query": "${params.query}"}'
- id: setvar1
type: set_var
name: "customVar"
value: "${stages.http1.result}"
"#;
let pipeline = PipelineParserV2::parse(yaml).unwrap();
assert_eq!(pipeline.metadata.name, "test-all-types");
assert_eq!(pipeline.stages.len(), 6);
}
#[test]
fn test_try_parse_v2() {
// v2 format - should return Some
let yaml_v2 = r#"
apiVersion: zclaw/v2
kind: Pipeline
metadata:
name: test
stages:
- id: s1
type: llm
prompt: "test"
"#;
assert!(PipelineParserV2::try_parse(yaml_v2).is_some());
// v1 format - should return None
let yaml_v1 = r#"
apiVersion: zclaw/v1
kind: Pipeline
metadata:
name: test
spec:
steps: []
"#;
assert!(PipelineParserV2::try_parse(yaml_v1).is_none());
}
#[test]
fn test_parse_output_config() {
let yaml = r#"
apiVersion: zclaw/v2
kind: Pipeline
metadata:
name: test
stages:
- id: s1
type: llm
prompt: "test"
output:
type: dynamic
allowSwitch: true
supportedTypes: [slideshow, quiz, document]
defaultType: slideshow
"#;
let pipeline = PipelineParserV2::parse(yaml).unwrap();
assert!(pipeline.output.allow_switch);
assert_eq!(pipeline.output.supported_types.len(), 3);
}
}