大数据

数据治理pipeline-demo

文件结构

core/base_operator.py

"""
操作符基类 - 包含核心自动映射机制
"""
from typing import Any, Dict, TypeVar, Generic, Optional
from core.context import ExecutionState, ResourceBundle
from core.modeling import (
    OperatorInputModel,
    OperatorOutputModel,
    resource_fields_for_model, OperatorConfigModel,
)

# 泛型定义
InputT = TypeVar("InputT", bound=OperatorInputModel)
ConfigT = TypeVar("ConfigT", bound=OperatorConfigModel)
OutputT = TypeVar("OutputT", bound=OperatorOutputModel)


class OperatorBase(Generic[InputT, ConfigT, OutputT]):
    """
    所有具体算子的基类
    核心机制(使用排除法识别资源字段):
        1. 调用resource_fields_for_model(Input)获取所有资源字段
        2. 自动将这些字段从Input映射到State
        3,处理完成后,从State自动映射会Output
    数据流向:
        Input(含RuntimeConfig)-> _state_from_input -> ExecutionState
        -> 算子处理 -> _output_from_state -> Output(含RuntimeConfig)
    """

    # ==================== 类变量(子类必须覆盖) ====================
    operator_id: str = "base_operator"
    name: str = "BaseOperator"
    description: str = "Base Operator for all pipeline operators"
    version: str = "1.0.0"
    operator_stage: str = ""
    supports_ray: bool = False

    # 子类必须定义这三个变量
    Input: Optional[type[InputT]] = None
    Config: Optional[type[ConfigT]] = None
    Output: Optional[type[OutputT]] = None

    # ==================== 初始化 ====================
    def __init__(self, config: Optional[ConfigT] = None):
        """初始化算子,保存配置"""
        self.config = config or {}
        self.params = self.config

    # ==================== 核心抽象方法 ====================
    def run(self, input_data: InputT) -> OutputT:
        """算子执行入口(子类必须实现)"""
        raise NotImplementedError(
            f"{self.__class__.__name__} must implement run(input) -> output"
        )

    # ==================== 自动映射机制(核心!) ====================
    def _state_from_input(self, input_model: InputT) -> "ExecutionState":
        """
        从输入模型中创建工作台,并自动注入资源数据
        【关键步骤】
            1. 从input_model.runtime获取RuntimeConfig
            2. 调用ExecutionState.from_runtime创建动态工作台
            3. 调用 resource_fields_for_model 获取所有资源字段
            4. 将每个资源字段的数据从Input映射到State
        """
        # 1. 从RuntimeConfig创建ExecutionState
        state = ExecutionState.from_runtime(input_model.runtime)
        # 2. 继承sample_reports(原始项目逻辑)
        state.sample_reports = dict(input_model.sample_reports or {})
        # 3. 处理resource_bundles(如果有传入)
        for name, payload in (input_model.resource_bundles or {}).items():
            bundle = ResourceBundle(
                name=name,
                layer=payload.get("layer", "unknown"),
                records=payload.get("records", []),
                dir=payload.get("dir", ""),
                artifacts=payload.get("artifacts", {}),
                meta=payload.get("meta", {}),
                schema_name=payload.get("schema_name", name),
                schema_version=payload.get("schema_version", "v1"),
            )
            state.resources[name] = bundle
        # 4. 自动映射:使用排除法识别所有资源字段
        for field_name in resource_fields_for_model(self.Input):
            field_value = getattr(input_model, field_name, None)
            if field_value is not None:
                records = field_value if isinstance(field_value, list) else [field_value]
                # 检查是否存在资源包,如果存在则合并
                existing = state.resources.get(field_name)
                if existing is not None:
                    # 更新已存在的资源包
                    state.set_resource_bundle(
                        name=field_name,
                        layer=existing.layer,
                        records=records,
                        dir=existing.dir,
                        artifacts=existing.artifacts,
                        meta=existing.meta,
                        schema_name=existing.schema_name,
                        schema_version=existing.schema_version,
                    )
                else:
                    # 创建新的资源记录
                    state.set_records(field_name, records)
        return state

    def _output_from_state(self, state: ExecutionState) -> OutputT:
        """
        从工作台中提取数据,打包成输出模型
        【关键步骤】
            1. 调用state.to_runtime() 将ExecutionState转会RuntimeConfig
            2. 构建输出字典的基础骨架
            3. 使用排除法获取Output的所有资源字段
            4. 自动从State中取出数据填入Output
        """
        # 1. 构建基础骨架
        payload = {
            "runtime": state.to_runtime(),
            "resource_bundles": {
                name: {
                    "name": bundle.name,
                    "layer": bundle.layer,
                    "records": bundle.records,
                    "dir": bundle.dir,
                    "artifacts": bundle.artifacts,
                    "meta": bundle.meta,
                    "schema_name": bundle.schema_name,
                    "schema_version": bundle.schema_version,
                }
                for name, bundle in state.resources.items()
            },
            "sample_reports": state.get_sample_report_store()
        }

        # 2. 自动映射:使用排除法获取Output的所有资源字段
        if self.Output is not None:
            for field_name in resource_fields_for_model(self.Output):
                payload[field_name] = state.get_records(field_name)
        else:
            # 容错:如果未定义Output,至少把最常用的source_files带上
            payload["source_files"] = state.get_records("source_files")

        # 3. 使用pydantic验证并返回
        if self.Output is not None:
            return self.Output.model_validate(payload)
        else:
            return payload

    # ==================== 辅助方法 ====================
    def input_resource_names(self) -> list[str]:
        """获取输入模型中的所有资源字段名称"""
        if self.Input is not None:
            return []
        return resource_fields_for_model(self.Input)

    def output_resource_names(self) -> list[str]:
        """获取输出模型中的所有资源字段名称"""
        if self.Output is not None:
            return []
        return resource_fields_for_model(self.Output)

core/context.py

"""
执行状态管理 - 动态工作台
"""
import os
from typing import Any, Dict, List, Optional
from core.modeling import RuntimeConfig


class ResourceBundle:
    """
    资源包(桶)- 存储一批数据及其元数据

    字段说明:
        name:         资源名称(如 "source_files")
        layer:        资源层级("input" / "corpus" / "samples")
        records:      具体数据列表
        dir:          数据在磁盘上的存储目录
        artifacts:    额外产物(如 manifest 文件路径)
        meta:         元数据(如记录数量)
        schema_name:  数据模型名称(用于序列化/反序列化)
        schema_version: Schema 版本
    """

    def __init__(
            self,
            name: str,
            layer: str = "unknown",
            records: Optional[List[Any]] = None,
            dir: str = "",
            artifacts: Optional[Dict[str, Any]] = None,
            meta: Optional[Dict[str, Any]] = None,
            schema_name: str = "",
            schema_version: str = "v1",
    ):
        self.name = name
        self.layer = layer
        self.records = list(records or [])
        self.dir = dir
        self.artifacts = dict(artifacts or {})
        self.meta = dict(meta or {})
        self.schema_name = schema_name or name
        self.schema_version = schema_version


class ExecutionState:
    """
    执行状态(动态工作台)
    核心职责:
        1. 从RuntimeConfig构建(通过 from_runtime 工厂方法)
        2. 管理所有算子产生的数据(通过 resources 字典)
        3. 提供统一的目录访问接口
        4. 可以回转RuntimeConfig(通过 to_runtime 方法)
    数据流向:
        RuntimeConfig -> ExecutionState(算子内部使用) -> RuntimeConfig(输出打包)
    """

    def __init__(
            self,
            output_root: str,
            corpus_version: str = "1.0",
            metadata: Optional[Dict[str, Any]] = None,
    ):
        self.output_root = output_root
        self.corpus_version = corpus_version
        self.metadata = dict(metadata or {})
        # 大仓库:key是资源名,value是ResourceBundle对象
        self.resources: Dict[str, ResourceBundle] = {}
        self.sample_reports: Dict[str, Any] = {}

    @classmethod
    def from_runtime(cls, runtime: RuntimeConfig) -> "ExecutionState":
        """
        从RuntimeConfig创建ExecutionState
        核心工厂方法:
            1. 提取 output_root、corpus_version、metadata
            2. 生成一个全新的 ExecutionState 实例
        """
        return cls(
            output_root=runtime.output_root,
            corpus_version=runtime.corpus_version,
            metadata=runtime.metadata,
        )

    def to_runtime(self) -> RuntimeConfig:
        """
        将ExecutionState转回RuntimeConfig
        用于输出打包:将动态状态中的静态配置部分提取出来
        """
        return RuntimeConfig(
            output_root=self.output_root,
            corpus_version=self.corpus_version,
            metadata=dict(self.metadata),
        )

    # ==================== 目录管理 ====================
    def corpus_root(self) -> str:
        """语料根目录:{output_root}/corpus"""
        # 支持子目录
        subdir = str(self.metadata.get("corpus_subdir", "") or "").strip().strip("/\\")
        if subdir:
            return os.path.join(self.output_root, "corpus", subdir)
        return os.path.join(self.output_root, "corpus")

    def corpus_path(self, *parts: str) -> str:
        """获取语料子路径"""
        return os.path.join(self.corpus_root(), *parts)

    def stage_root(self) -> str:
        """暂存区目录:{output_root}/corpus/stage"""
        return os.path.join(self.corpus_root(), "stage")

    def final_root(self) -> str:
        """最终输出目录:{output_root}/corpus/final"""
        return os.path.join(self.corpus_root(), "final")

    def cache_root(self) -> str:
        """缓存根目录:{output_root}/corpus/.cache"""
        return os.path.join(self.corpus_root(), ".cache")

    def cache_path(self, *parts: str) -> str:
        """获取缓存子路径"""
        return os.path.join(self.cache_root(), *parts)

    def corpus_shared_root(self) -> str:
        """共享语料根目录"""
        return os.path.join(self.output_root, "corpus")

    def corpus_shared_path(self, *parts: str) -> str:
        """获取共享语料子路径"""
        return os.path.join(self.corpus_shared_root(), *parts)

    # ==================== 资源管理(核心) ====================
    def get_resource_bundle(self, name: str) -> ResourceBundle:
        """
        从仓库中取出一个“桶”
        如果不存在,返回一个空桶(而不是抛出异常)
        """
        if name not in self.resources:
            return ResourceBundle(
                name=name,
                layer=self._infer_resource_layer(name),
                schema_name=name
            )
        return self.resources[name]

    def set_resource_bundle(
            self,
            name: str,
            *,
            layer: str,
            records: List[Any],
            dir: str = "",
            artifacts: Optional[Dict[str, Any]] = None,
            meta: Optional[Dict[str, Any]] = None,
            schema_name: Optional[str] = None,
            schema_version: str = "v1",
    ) -> None:
        """
        把一个“桶”放进仓库
        这是最底层的存储方法,通常不直接调用
        """
        self.resources[name] = ResourceBundle(
            name=name,
            layer=layer,
            records=records,
            dir=dir or self._infer_resource_dir(name),
            artifacts=artifacts,
            meta=meta,
            schema_name=schema_name,
            schema_version=schema_version,
        )

    def set_records(
            self,
            name: str,
            records: List[Any],
            *,
            layer: Optional[str] = None,
            dir: str = "",
            artifacts: Optional[Dict[str, Any]] = None,
            meta: Optional[Dict[str, Any]] = None,
            schema_name: Optional[str] = None,
            schema_version: str = "v1",
    ) -> None:
        """
        将数据存进仓库(上层算子调用的主入口)
        这是“放数据”的核心方法
            1. 自动根据name推断layer(输入层/语料层)
            2. 自动推断物理存储目录
            3. 打包成ResourceBundle存入self.resources
        """
        self.set_resource_bundle(
            name=name,
            layer=layer or self._infer_resource_layer(name),
            records=records,
            dir=dir or self._infer_resource_dir(name),
            artifacts=artifacts,
            meta=meta or {"record_count": len(records)},
            schema_name=schema_name or name,
            schema_version=schema_version,
        )

    def get_records(self, name: str) -> List[Any]:
        """
        从仓库中取出数据(下游算子调用的主入口)
        这是“取数据”的核心方法
            1. 从self.resources中取出ResourceBundle
            2. 返回其中的 records 列表
            3. 如果不存在,返回空列表(优雅降级)
        """
        bundle = self.get_resource_bundle(name)
        return list(bundle.records or [])

    def resource_names(self):
        """获取所有资源名称"""
        return sorted(self.resources.keys())

    # ==================== 辅助方法 ====================
    def _infer_resource_layer(self, name: str) -> str:
        """推断资源层级"""
        layer_map = {
            "source_files": "input",
            "processed_files": "corpus",
            "final_files": "corpus",
        }
        return layer_map.get(name, "unknown")

    def _infer_resource_dir(self, name: str) -> str:
        """
        自动推到数据应该放在哪个物理目录
        避免每个算子都手动传dir参数
        """
        mapping = {
            "source_files": os.path.join(self.corpus_root(), "source_files"),
            "processed_files": os.path.join(self.corpus_root(), "processed_files"),
            "final_files": self.final_root(),
        }
        return mapping.get(name, self.corpus_root())

    def get_sample_report_store(self) -> Dict[str, Any]:
        """获取样本报告存储"""
        return self.sample_reports

    def set_sample_report(self, group_name: str, report_name: str, payload: Dict[str, Any]) -> None:
        """设置样本报告"""
        report = self.sample_reports.get(group_name)
        if not isinstance(report, dict):
            report = {}
            self.sample_reports[group_name] = report
        report[report_name] = dict(payload or {})

core/modeling.py

"""
模型基类 - 定义RuntimeConfig和算子输入/输出积累
"""
import os
import tempfile
from typing import Any, Dict
from pydantic import BaseModel, Field, model_validator, ConfigDict


class SchemaModel(BaseModel):
    """
    所有模型的基类(支持宽松模式)
    """

    model_config = ConfigDict(
        arbitrary_types_allowed=True,  # 允许任意类型
        extra="allow",  # 允许额外字段
        populate_by_name=True,  # 按名称填充
        validate_assignment=True,  # 验证赋值
    )

    schema_version: str = "v1"


class RuntimeConfig(SchemaModel):
    """
    运行时配置(静态配置载体)
    职责:
        1. 存储调用方传入的配置参数
        2. 测试时自动重定向输出目录
        3. 作为Input的一部分传入算子
    注意:RuntimeConfig 是只读的,算子不应该修改它
    """
    output_root: str = "./output"
    corpus_version: str = "v1.0"
    metadata: Dict[str, Any] = Field(default_factory=dict)

    @model_validator(mode="after")
    def _redirect_test_default_output(self) -> "RuntimeConfig":
        """测试时自动重定向到临时目录("""
        if (
                self.output_root == "./outputs"
                and os.getenv("PYTEST_CURRENT_TEST")
        ):
            object.__setattr__(
                self,
                "output_root",
                tempfile.mkdtemp(prefix="pipeline-test-"),
            )
        return self


class OperatorInputModel(SchemaModel):
    """
    算子的输入模型基类
    关键设计:
        runtime: 类型是RuntimeConfig(静态配置)
        resource_bundles: 存储的是“桶”的序列化表示
        sample_reports: 用于存放调试/系统信息
        其他字段(如 source_files)自动被识别为资源字段
    """
    runtime: RuntimeConfig = Field(default_factory=RuntimeConfig)
    resource_bundles: Dict[str, Any] = Field(default_factory=dict)
    sample_reports: Dict[str, Any] = Field(default_factory=dict)


class OperatorOutputModel(SchemaModel):
    """
    算子的输出模型基类
    同样包含runtime(RuntimeConfig 类型)
    但实际运行时,BaseOperator 会从ExecutionState中转回RuntimeConfig
    """
    runtime: RuntimeConfig = Field(default_factory=RuntimeConfig)
    resource_bundles: Dict[str, Any] = Field(default_factory=dict)
    sample_reports: Dict[str, Any] = Field(default_factory=dict)


class OperatorConfigModel(SchemaModel):
    """
    算子的配置模型基类

    【重要】所有控制算子行为的参数(如 input_dir、max_retry、threshold 等)
    都必须定义在 Config 模型中,而不是 Input 模型中!
    """
    model_config = ConfigDict(
        arbitrary_types_allowed=True,  # 允许任意类型
        extra="allow",  # 允许额外字段
        populate_by_name=True,  # 按名称填充
        validate_assignment=True,  # 验证赋值
    )


# 核心设计:状态保留字段(这些字段不被视为资源)
# 所有不在这个集合中的字段,都被自动识别为资源字段
_STATE_RESERVED_FIELDS = {
    "runtime",
    "resource_bundles",
    "sample_reports",
    "schema_version",
}


def resource_fields_for_model(model_cls: type[OperatorInputModel] | type[OperatorOutputModel]) -> list[str]:
    """
    获取模型中的资源字段名称
    核心逻辑(排除法):
        遍历模型的所有字段,跳过保留字段(runtime、resource_bundles、sample_reports、schema_version)
        其余字段全部视为资源字段
    """
    fields = []
    for field_name in model_cls.model_fields:
        # 跳过保留字段
        if field_name in _STATE_RESERVED_FIELDS:
            continue
        # 添加资源字段
        fields.append(field_name)
    return fields

operators/finalize_operator.py

"""
算子3:最终清洗
"""

import os
from typing import Any, Dict, List
from core.base_operator import OperatorBase
from core.modeling import OperatorInputModel, OperatorOutputModel, OperatorConfigModel
from schemas.data_objects import SourceFile


# Config:配置参数
class FinalizeConfig(OperatorConfigModel):
    report_filename: str = "summary_report.txt"
    output_subdir: str = "final"


class FinalizeInput(OperatorInputModel):
    """清洗算子的输入"""
    source_files: List[SourceFile] = []


class FinalizeOutput(OperatorOutputModel):
    """清洗算子的输出"""
    source_files: List[SourceFile] = []


class FinalizeOperator(OperatorBase[FinalizeInput, FinalizeConfig, FinalizeOutput]):
    """最终清洗算子"""
    operator_id = "finalize_operator"
    name = "Finalize"
    description = "Generate summary report from processed files."
    version = "1.0.0"

    Input = FinalizeInput
    Config = FinalizeConfig
    Output = FinalizeOutput

    def run(self, input_data: FinalizeInput) -> FinalizeOutput:
        state = self._state_from_input(input_data)

        files = state.get_records("source_files")
        if not files:
            print("[FinalizeOperator] 没有收到数据,跳过")
            return self._output_from_state(state)
        print(f"[FinalizeOperator] 收到 {len(files)} 个文件")
        final_dir = state.final_root()
        os.makedirs(final_dir, exist_ok=True)
        total_lines = 0
        summary_lines: List[str] = []
        for src in files:
            try:
                with open(src.file_path, "r", encoding="utf-8") as f:
                    line_count = len(f.readlines())
                total_lines += line_count
                summary_lines.append(f"  {os.path.basename(src.file_path)}: {line_count} 行")
            except Exception as e:
                summary_lines.append(f"  {os.path.basename(src.file_path)}: 读取失败 ({e})")
        report_path = os.path.join(final_dir, self.config.report_filename)
        with open(report_path, "w", encoding="utf-8") as f:
            f.write("=" * 60 + "\n")
            f.write("           数据流水线处理报告\n")
            f.write("=" * 60 + "\n\n")
            f.write(f"处理文件总数: {len(files)}\n")
            f.write(f"总行数: {total_lines}\n")
            f.write("\n--- 文件明细 ---\n")
            f.write("\n".join(summary_lines))
            f.write("\n" + "=" * 60 + "\n")
        print(f"[FinalizeOperator] 报告已生成: {report_path}")
        final_files = [
            SourceFile(
                document_id="summary_report",
                file_path=report_path,
                file_type=".txt",
                source_type="final",
                file_hash="",
                metadata={
                    "file_count": len(files),
                    "total_lines": total_lines,
                }
            )
        ]
        state.set_records("source_files", final_files)
        state.metadata["summary_report"] = report_path
        state.metadata["total_lines"] = total_lines
        state.metadata["file_count"] = len(files)
        return self._output_from_state(state)

operators/ingest_operator.py

"""
算子1:源文件接入
"""

import os
import uuid
from typing import Any, List, Dict
from core.base_operator import OperatorBase
from core.modeling import OperatorInputModel, OperatorOutputModel, OperatorConfigModel
from schemas.data_objects import SourceFile


# Config:所有配置参数放在这里!
class IngestConfig(OperatorConfigModel):
    """
    接入算子的配置
    【重要】input_dir 是配置参数,放在 Config 中,
    不会被 _state_from_input 误认为资源字段!
    """
    input_dir: str = "./inputs"  # 要扫描的目录
    file_extension: str = ".txt"  # 要扫描的文件扩展名


class IngestInput(OperatorInputModel):
    """
    接入算子的输入
    注意:这里没有定义任何资源字段(因为本算子自己就是数据源)
    所有的配置参数都在 IngestConfig 中
    """
    pass


class IngestOutput(OperatorOutputModel):
    """
    接入算子的输出
    关键:定义了source_files字段
    这个字段会被resource_fields_for_model自动识别为资源字段
    """
    source_files: List[SourceFile] = []


class IngestOperator(OperatorBase[IngestInput, IngestConfig, IngestOutput]):
    """源文件接入算子"""
    operator_id = "ingest_operator"
    name = "SourceIngest"
    description = "Scan local directory and create SourceFile records."
    version = "1.0.0"

    Input = IngestInput
    Config = IngestConfig
    Output = IngestOutput

    def run(self, input_data: IngestInput) -> IngestOutput:
        # 1. 创建工作台(自动识别资源字段)
        state = self._state_from_input(input_data)
        # 从self.config中读取配置参数
        input_dir = self.config.input_dir
        file_extension = self.config.file_extension
        if not os.path.isdir(input_dir):
            raise FileNotFoundError(f"Input directory not found: {input_dir}")

        # 2. 扫描文件夹
        source_files: List[SourceFile] = []
        for filename in os.listdir(input_dir):
            if not filename.endswith(file_extension):
                continue

            file_path = os.path.join(input_dir, filename)
            if not os.path.isfile(file_path):
                continue

            doc_id = str(uuid.uuid4())
            source_files.append(SourceFile(
                document_id=doc_id,
                file_path=file_path,
                file_type=file_extension,
                source_type="ingest",
                file_hash="",
                metadata={"original_filename": filename},
            ))
        print(f"[IngestOperator] 发现 {len(source_files)} 个文件")
        # 存入state
        state.set_records("source_files", source_files)
        # 返回输出(自动从state提取资源字段)
        return self._output_from_state(state)

operators/process_operator.py

"""
算子2:业务处理
"""

import os
from typing import Any, Dict, List

from core.base_operator import OperatorBase
from core.modeling import OperatorInputModel, OperatorOutputModel, OperatorConfigModel
from schemas.data_objects import SourceFile


# Config:配置参数放这里
class ProcessConfig(OperatorConfigModel):
    filter_keyword: str = "DEBUG"  # 要过滤的关键词
    cache_subdir: str = "processed"  # 缓存子目录


class ProcessInput(OperatorInputModel):
    """
    处理算子的输入
    定义了source_files字段,表示接收上游数据
    这个字段会被自动识别为资源字段
    """
    source_files: List[SourceFile] = [] # 接收上游数据


class ProcessOutput(OperatorOutputModel):
    """
    处理算子的输出
    同样定义了source_files字段
    """
    source_files: List[SourceFile] = [] # 输出处理后的数据


class ProcessOperator(OperatorBase[ProcessInput, ProcessConfig, ProcessOutput]):
    """业务处理算子"""
    operator_id = "process_operator"
    name = "LineFilter"
    description = "Filter out DEBUG lines from source files."
    version = "1.0.0"

    Input = ProcessInput
    Config = ProcessConfig
    Output = ProcessOutput

    def run(self, input_data: ProcessInput) -> ProcessOutput:
        # 1. 创建工作台(自动将input_dta.source_files存入state)
        state = self._state_from_input(input_data)
        # 从self.config中读取配置
        keyword = self.config.filter_keyword
        cache_dir = state.cache_path(self.config.cache_subdir)

        # 2. 从state取出数据
        raw_files = state.get_records("source_files")
        if not raw_files:
            print("[ProcessOperator] 没有收到上游数据,跳过")
            return self._output_from_state(state)
        print(f"[ProcessOperator] 收到 {len(raw_files)} 个待处理文件")
        # 3. 创建缓存目录
        os.makedirs(cache_dir, exist_ok=True)
        # 4. 处理每个文件
        processed_files: List[SourceFile] = []
        for src in raw_files:
            try:
                with open(src.file_path, "r", encoding="utf-8") as f:
                    lines = f.readlines()
            except Exception as e:
                print(f"[ProcessOperator] 读取文件失败 {src.file_path}: {e}")
                continue
            # 过滤 DEBUG 行
            filtered_lines = [line for line in lines if keyword not in line]
            # 写入缓存
            new_filename = f"{src.document_id[:8]}.txt"
            new_path = os.path.join(cache_dir, new_filename)
            with open(new_path, "w", encoding="utf-8") as f:
                f.writelines(filtered_lines)
            processed_files.append(
                SourceFile(
                    document_id=src.document_id,
                    file_path=new_path,
                    file_type=".txt",
                    source_type="processed",
                    file_hash="",
                    metadata={
                        "original_path": src.file_path,
                        "original_lines": len(lines),
                        "filtered_lines": len(filtered_lines),
                        "removed_lines": len(lines) - len(filtered_lines),
                    }
                )
            )
        print(f"[ProcessOperator] 处理完成,生成 {len(processed_files)} 个文件")
        # 5. 存入state(覆盖旧的source_files)
        state.set_records("source_files", processed_files)
        # 6. 返回输出
        return self._output_from_state(state)

schemas/data_objects.py

"""
数据模型层 - 定义流转的最小单元
"""

from pydantic import BaseModel, Field
from typing import Any, Dict


class SourceFile(BaseModel):
    """
    源文件元数据 - 整个流水线的"血液"

    字段说明:
        document_id: 唯一标识符
        file_path:   当前文件在磁盘上的绝对路径
        file_type:   文件扩展名(如 .txt, .md, .pdf)
        source_type: 数据来源标记(ingest / processed / final)
        file_hash:   文件哈希值(用于去重和缓存验证)
        metadata:    灵活字典,存储各算子附加的临时信息
    """
    document_id: str
    file_path: str
    file_type: str
    source_type: str
    file_hash: str = ""
    metadata: Dict[str, Any] = Field(default_factory=dict)

run_pipeline.py

"""
主入口编排器
"""

import argparse
import sys
import time
from pathlib import Path
from core.modeling import RuntimeConfig
from operators.ingest_operator import IngestOperator, IngestInput, IngestConfig
from operators.process_operator import ProcessOperator, ProcessInput, ProcessConfig
from operators.finalize_operator import FinalizeOperator, FinalizeInput, FinalizeConfig


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description="数据流水线 Demo:输入 TXT 文件,过滤 DEBUG 行,生成统计报告"
    )
    parser.add_argument("--input-dir", required=True, help="输入文件夹路径")
    parser.add_argument("--output-root", required=True, help="输出根目录")
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    start_time = time.perf_counter()
    input_dir = Path(args.input_dir).resolve()
    if not input_dir.is_dir():
        print(f"❌ 错误: 输入目录不存在: {input_dir}")
        return 1
    # ==================== 创建 RuntimeConfig ====================
    runtime = RuntimeConfig(
        output_root=str(Path(args.output_root).resolve()),
        corpus_version="v1.0",
        metadata={
            "pipline_name": "txt_filter_pipeline",
            "input_dir": str(input_dir),
        }
    )
    print(f"📂 RuntimeConfig 已创建")
    print(f"   output_root: {runtime.output_root}")
    print("=" * 60)

    # ==================== 阶段 1:接入 ====================
    print("\n[阶段 1/3] 接入源文件...")
    # 创建Config
    ingest_config = IngestConfig(input_dir=str(input_dir))
    ingest_input = IngestInput(runtime=runtime)
    ingest_op = IngestOperator(config=ingest_config)
    ingest_output = ingest_op.run(ingest_input)
    raw_files = ingest_output.source_files
    print(f"   ✅ 接入完成: 发现 {len(raw_files)} 个文件")
    if not raw_files:
        print("   ⚠️  没有找到 .txt 文件,流水线终止")
        return 0

    # ==================== 阶段 2:处理 ====================
    print("\n[阶段 2/3] 过滤 DEBUG 行...")
    process_config = ProcessConfig(filter_keyword="DEBUG")
    process_input = ProcessInput(runtime=runtime, source_files=raw_files)
    process_op = ProcessOperator(config=process_config)
    process_output = process_op.run(process_input)
    processed_files = process_output.source_files
    print(f"   ✅ 处理完成: 生成 {len(processed_files)} 个缓存文件")
    for src in processed_files:
        removed = src.metadata.get("removed_lines", 0)
        if removed > 0:
            print(f"      - {Path(src.file_path).name}: 移除了 {removed} 行 DEBUG 日志")

    # ==================== 阶段 3:最终清洗 ====================
    print("\n[阶段 3/3] 生成最终报告...")
    finalize_config = FinalizeConfig(report_filename="summary_report.txt")
    finalize_input = FinalizeInput(runtime=runtime, source_files=processed_files)
    finalize_op = FinalizeOperator(config=finalize_config)
    finalize_output = finalize_op.run(finalize_input)
    report_path = finalize_output.runtime.metadata.get("summary_report")
    print(f"   ✅ 报告已生成: {report_path}")

    # ==================== 总结 ====================
    elapsed = time.perf_counter() - start_time
    print("\n" + "=" * 60)
    print("🎉 流水线执行完毕!")
    print(f"   耗时: {elapsed:.2f} 秒")
    print(f"   最终输出目录: {runtime.output_root}/corpus/final/")
    print("=" * 60)

    return 0


if __name__ == '__main__':
    sys.exit(main())