wuenlp_tools.pipeline

  1from __future__ import annotations
  2
  3import inspect
  4from enum import Enum
  5from abc import ABC, abstractmethod
  6from collections import defaultdict
  7from hashlib import md5
  8from pathlib import Path
  9from typing import Callable, List, Type, Optional, Protocol, TypeVar, Dict, Tuple, Generic, Iterable, Union
 10
 11from wuenlp.impl.UIMANLPStructs import (
 12    UIMAAnnotation,
 13    UIMADocument,
 14    UIMASpan,
 15    UIMAEntity,
 16    UIMAEntityReference,
 17    UIMASystemScene,
 18)
 19from loguru import logger
 20from wuenlp.impl.uima import UIMACharacter
 21from wuenlp.impl.uima.extensions import ExtraTypesMixin, enrich_with_mixins
 22
 23from wuenlp_tools.keys import LazyKey
 24
 25InDocT = TypeVar("InDocT", UIMADocument, str, contravariant=True)
 26
 27UnitFilter = Callable[[UIMASpan], bool]
 28
 29
 30def get_units(
 31    doc: UIMADocument,
 32    unit_type: Type[UIMASpan] | None,
 33    unit_filter: UnitFilter | None = None,
 34) -> list[UIMASpan]:
 35    """Return units of ``unit_type``, optionally filtered, in CAS annotation-index order."""
 36    if unit_type is None:
 37        return []
 38    units = list(doc.get_annos_of_type(unit_type))
 39    if unit_filter is not None:
 40        units = [unit for unit in units if unit_filter(unit)]
 41    return units
 42
 43class PipelineCapability(str, Enum):
 44    """
 45    Declarative capabilities produced/required by pipeline steps.
 46
 47    These are used to auto-order steps and (when possible) auto-inject missing
 48    providers.
 49    """
 50
 51    def __new__(
 52        cls,
 53        value: str,
 54        description: str,
 55        affected_types: tuple[type, ...],
 56    ):
 57        obj = str.__new__(cls, value)
 58        obj._value_ = value
 59        obj._description = description
 60        obj._affected_types = affected_types
 61        return obj
 62
 63    PREPROCESS = (
 64        "preprocess",
 65        "Provides a fully initialized `UIMADocument` after preprocessing (tokenization, NER, rule-based setup).",
 66        (UIMADocument,),
 67    )
 68    COREF = (
 69        "coref",
 70        "Provides coreference annotations (`UIMAEntity`, `UIMAEntityReference`) needed by character-related steps.",
 71        (UIMAEntity, UIMAEntityReference),
 72    )
 73    CHARACTERS = (
 74        "characters",
 75        "Provides `UIMACharacter` annotations derived from entity/coreference information.",
 76        (UIMACharacter,),
 77    )
 78    MAIN_CHARACTERS = (
 79        "main_characters",
 80        "Provides main and important characters on document annotation (`main_character`, `important_characters`).",
 81        (UIMACharacter,),
 82    )
 83    SCENES = (
 84        "scenes",
 85        "Provides scene segmentation as `UIMASystemScene` annotations in `doc.system_scenes`.",
 86        (UIMASystemScene,),
 87    )
 88    SCENE_SUMMARIES = (
 89        "scene_summaries",
 90        "Provides per-scene summaries/keywords in additional features (e.g. `llama_summary`, `llama_keywords`).",
 91        (UIMASystemScene,),
 92    )
 93    SCENE_EMBEDDINGS = (
 94        "scene_embeddings",
 95        "Provides per-scene embeddings in additional features (e.g. `text_embedding`).",
 96        (UIMASystemScene,),
 97    )
 98    EMBEDDING = (
 99        "embedding",
100        "Provides document-level Moment embedding in `doc.document_annotation.additional_features['embedding']`.",
101        (UIMADocument,),
102    )
103
104    @property
105    def description(self) -> str:
106        return self._description
107
108    @property
109    def affected_types(self) -> tuple[type, ...]:
110        return self._affected_types
111
112
113_CapabilityLike = Union[PipelineCapability, str]
114
115
116def _normalize_capabilities(values: Optional[Iterable[_CapabilityLike]]) -> list[PipelineCapability]:
117    if not values:
118        return []
119    normalized: list[PipelineCapability] = []
120    for v in values:
121        if isinstance(v, PipelineCapability):
122            normalized.append(v)
123        else:
124            normalized.append(PipelineCapability(v))
125    return normalized
126
127
128class AbstractPipelineProcessor(Protocol[InDocT]):
129    @abstractmethod
130    def __call__(self, doc: InDocT, unit_type: Optional[Type[UIMASpan]], overwrite: bool = False,
131                 **kwargs) -> UIMADocument:
132        ...
133
134
135class PipelineProcessor(AbstractPipelineProcessor[UIMADocument]):
136    @abstractmethod
137    def __call__(self, doc: UIMADocument, unit_type: Type[UIMASpan] | None, overwrite: bool = False,
138                 **kwargs) -> UIMADocument:
139        ...
140
141
142class PipelinePreprocessor(AbstractPipelineProcessor[InDocT]):
143    @abstractmethod
144    def __call__(self, doc: UIMADocument | str, unit_type: Type[UIMASpan] | None, overwrite: bool = False,
145                 **kwargs) -> UIMADocument:
146        ...
147
148
149AbstractPipelineProcessorType = TypeVar('AbstractPipelineProcessorType', bound=AbstractPipelineProcessor)
150
151
152class PipelineStep(Generic[AbstractPipelineProcessorType]):
153    """A named processing unit that can be composed into a `Pipeline`.
154
155    Pass step instances into `Pipeline(steps=[...])`. Each step declares what it
156    `provides` and `requires` (`PipelineCapability`), which span type it operates
157    on, and which annotation features it writes.
158    """
159
160    name: Optional[str] = None
161    processor: AbstractPipelineProcessorType
162    unit_type: Optional[Type[UIMASpan]] = None
163    unit_filter: UnitFilter | None = None
164    overwrite: Optional[bool] = None
165    added_mixins: Optional[List[Type[ExtraTypesMixin]]] = None
166    added_features: Optional[List[str]] = None
167    added_additional_features: Optional[List[str]] = None
168
169    requires_api_key: Optional[LazyKey] = None
170    requires_paid_api_requests: Optional[bool] = None
171    provides: List[PipelineCapability]
172    requires: List[PipelineCapability]
173
174    def __init__(self,
175                 name: str,
176                 processor: AbstractPipelineProcessorType,
177                 unit_type: Optional[Type[UIMASpan]],
178                 unit_filter: UnitFilter | None = None,
179                 overwrite: Optional[bool] = None,
180                 added_mixins: Optional[List[Type[ExtraTypesMixin]] | Type[ExtraTypesMixin]] = None,
181                 added_features: Optional[List[str]] = None,
182                 added_additional_features: Optional[List[str]] = None,
183                 modified_types: Optional[List[Type[UIMAAnnotation]]] = None,
184                 needs_manual_merge: bool = False,
185                 requires_api_key: Optional[LazyKey] = None,
186                 requires_paid_api_requests: Optional[bool] = None,
187                 provides: Optional[List[_CapabilityLike]] = None,
188                 requires: Optional[List[_CapabilityLike]] = None,
189                 ):
190        self.name = name
191        self.processor = processor
192        self.unit_type = unit_type
193        self.unit_filter = unit_filter
194        self.overwrite = overwrite
195        self.added_mixins = [added_mixins] if isinstance(added_mixins, type) else (added_mixins or [])
196        self.added_features = added_features if added_features is not None else []
197        self.added_additional_features = added_additional_features if added_additional_features is not None else []
198        self.modified_types = modified_types if modified_types is not None else []
199        self.needs_manual_merge = needs_manual_merge
200        self.requires_api_key = requires_api_key
201        self.requires_paid_api_requests = requires_paid_api_requests
202        self.provides = _normalize_capabilities(provides)
203        self.requires = _normalize_capabilities(requires)
204        self.__doc__ = self._format_doc()
205
206    def __call__(self, doc: UIMADocument | str | Path, overwrite: Optional[bool] = None,
207                 DocType: Type[UIMADocument] = UIMADocument, **kwargs):
208        logger.info(f"Starting {self.name} pipeline step")
209
210        final_overwrite: bool
211
212        if overwrite is True:
213            final_overwrite = True
214        elif overwrite is None:
215            final_overwrite = self.overwrite if self.overwrite is not None else False
216        else:
217            # overwrite is False
218            final_overwrite = self.overwrite if self.overwrite is not None else False
219
220        if isinstance(doc, Path):
221            doc = DocType.from_xmi(doc)
222
223        unit_filter = kwargs.pop("unit_filter", self.unit_filter)
224        return self.processor(
225            doc,
226            unit_type=self.unit_type,
227            unit_filter=unit_filter,
228            overwrite=final_overwrite,
229            **kwargs,
230        )
231
232    def set_overwrite(self, overwrite: bool) -> "PipelineStep":
233        self.overwrite = overwrite
234        return self
235
236    def _format_doc(self) -> str:
237        lines = [f"Pipeline step **{self.name}** (`{type(self.processor).__name__}`)."]
238        proc_doc = type(self.processor).__dict__.get("__doc__")
239        if isinstance(proc_doc, str) and proc_doc.strip():
240            lines.append("")
241            lines.append(inspect.cleandoc(proc_doc).strip())
242        if self.provides:
243            lines.append("")
244            lines.append("**Provides:** " + ", ".join(f"`{c.value}`" for c in self.provides))
245            for cap in self.provides:
246                lines.append(f"- `{cap.value}`: {cap.description}")
247        if self.requires:
248            lines.append("")
249            lines.append("**Requires:** " + ", ".join(f"`{c.value}`" for c in self.requires))
250        details: list[str] = []
251        if self.unit_type is not None:
252            details.append(f"unit type `{self.unit_type.__name__}`")
253        if self.modified_types:
254            details.append(
255                "modifies " + ", ".join(f"`{t.__name__}`" for t in self.modified_types)
256            )
257        if self.added_additional_features:
258            details.append(
259                "additional features "
260                + ", ".join(f"`{f}`" for f in self.added_additional_features)
261            )
262        if self.added_features:
263            details.append("features " + ", ".join(f"`{f}`" for f in self.added_features))
264        if details:
265            lines.append("")
266            lines.append("; ".join(details) + ".")
267        return "\n".join(lines)
268
269    def __repr__(self) -> str:
270        parts = [repr(self.name), f"processor={type(self.processor).__name__}"]
271        if self.provides:
272            parts.append(f"provides={[c.value for c in self.provides]}")
273        if self.requires:
274            parts.append(f"requires={[c.value for c in self.requires]}")
275        return f"{type(self).__name__}({', '.join(parts)})"
276
277
278class Pipeline:
279    def __init__(self, steps: List[PipelineStep], intermediate_file_dir: Optional[Path] = None,
280                 overwrite: bool = False):
281        self.steps = steps
282        self.intermediate_file_dir = intermediate_file_dir
283        self.overwrite = overwrite
284
285        if intermediate_file_dir:
286            intermediate_file_dir.mkdir(exist_ok=True, parents=True)
287
288        force_overwrite = False
289
290        for step in self.steps:
291            if step.overwrite:
292                force_overwrite = True
293                logger.info(
294                    f"Step {step.name} is set to overwrite, setting all following steps (if any) to overwrite as well.")
295            else:
296                if force_overwrite:
297                    step.overwrite = force_overwrite
298                    logger.info(f"Setting step {step.name} to overwrite as a previous step is overwriting.")
299
300    def __call__(self, doc: UIMADocument | str | Path, DocType: Type = UIMADocument) -> UIMADocument | Path:
301        if isinstance(doc, Path):
302            logger.info(f"Processing {doc}")
303            current_class = DocType
304        elif isinstance(doc, UIMADocument):
305            logger.info(f"Processing {doc.path}")
306            current_class = type(doc)
307        else:
308            current_class = DocType
309        orig_overwrite = self.overwrite
310        result = None
311        for step in self.steps:
312            if step.added_mixins:
313                missing_mixins = [
314                    mixin for mixin in step.added_mixins if mixin not in current_class.__mro__
315                ]
316                if missing_mixins:
317                    current_class = enrich_with_mixins(current_class, missing_mixins)
318            if self.intermediate_file_dir:
319                text = doc.text if isinstance(doc, UIMADocument) else str(doc)
320                step_file = self.intermediate_file_dir / f"{md5(text.encode()).hexdigest()}_{step.name}.xmi.zip"
321            else:
322                step_file = None
323            if step_file and step_file.exists() and not self.overwrite and not step.overwrite:
324                logger.info(f"Using intermediate file for {step.name} from {self.intermediate_file_dir}")
325                result = step_file  # current_class.from_xmi(step_file)
326                continue
327            elif step_file and not step_file.exists():
328                self.overwrite = True
329                logger.info(
330                    f"Intermediate file for {step.name} does not exist, setting overwrite to True for all remaining steps.")
331            # TODO: This is highly experimental and needs to be tested/fixed
332            result = doc if result is None else result
333            step_output = step(result, overwrite=self.overwrite, DocType=current_class)
334            if not step.needs_manual_merge:
335                result = step_output
336            else:
337                if result is step_output:
338                    logger.warning("Document was unchanged by this step. Skipping manual merge.")
339                else:
340                    if isinstance(result, Path):
341                        result = current_class.from_xmi(result)
342                    for t in step.modified_types:
343                        for anno in result.get_annos_of_type(t):
344                            result.remove_annotation(anno)
345                        for anno in step_output.get_annos_of_type(t):
346                            result.add_anno(anno)
347            if self.intermediate_file_dir and isinstance(result, UIMADocument):
348                logger.info(f"Serializing intermediate file for {step.name} to {self.intermediate_file_dir}")
349                step_file.parent.mkdir(parents=True, exist_ok=True)
350                result.serialize(step_file)
351
352        self.overwrite = orig_overwrite
353
354        if result is not None:
355            if isinstance(result, UIMADocument):
356                return result
357            else:
358                return current_class.from_xmi(result)
359        else:
360            raise RuntimeError(f"No result produced")
361
362    @property
363    def added_features(self) -> Dict[Type[UIMAAnnotation], List]:
364        added_features: defaultdict = defaultdict(set)
365
366        for step in self.steps:
367            added_features[step.unit_type].update(step.added_features)
368
369        return dict(added_features)
370
371    @property
372    def added_additional_features(self) -> Dict[Type[UIMAAnnotation], List]:
373        added_additional_features: defaultdict = defaultdict(set)
374
375        for step in self.steps:
376            added_additional_features[step.unit_type].update(step.added_additional_features)
377
378        return dict(added_additional_features)
379
380    def __str__(self):
381        return " → ".join(step.name or type(step.processor).__name__ for step in self.steps)
382
383    def __repr__(self):
384        names = [step.name or type(step.processor).__name__ for step in self.steps]
385        return f"Pipeline({names})"
386
387    def __len__(self):
388        return len(self.steps)
389
390    steps: List[PipelineStep] = []
UnitFilter = typing.Callable[[wuenlp.impl.uima.UIMANLPStructs.UIMASpan], bool]
def get_units( doc: wuenlp.impl.uima.UIMANLPStructs.UIMADocument, unit_type: Optional[Type[wuenlp.impl.uima.UIMANLPStructs.UIMASpan]], unit_filter: Optional[Callable[[wuenlp.impl.uima.UIMANLPStructs.UIMASpan], bool]] = None) -> list[wuenlp.impl.uima.UIMANLPStructs.UIMASpan]:
31def get_units(
32    doc: UIMADocument,
33    unit_type: Type[UIMASpan] | None,
34    unit_filter: UnitFilter | None = None,
35) -> list[UIMASpan]:
36    """Return units of ``unit_type``, optionally filtered, in CAS annotation-index order."""
37    if unit_type is None:
38        return []
39    units = list(doc.get_annos_of_type(unit_type))
40    if unit_filter is not None:
41        units = [unit for unit in units if unit_filter(unit)]
42    return units

Return units of unit_type, optionally filtered, in CAS annotation-index order.

class PipelineCapability(builtins.str, enum.Enum):
 44class PipelineCapability(str, Enum):
 45    """
 46    Declarative capabilities produced/required by pipeline steps.
 47
 48    These are used to auto-order steps and (when possible) auto-inject missing
 49    providers.
 50    """
 51
 52    def __new__(
 53        cls,
 54        value: str,
 55        description: str,
 56        affected_types: tuple[type, ...],
 57    ):
 58        obj = str.__new__(cls, value)
 59        obj._value_ = value
 60        obj._description = description
 61        obj._affected_types = affected_types
 62        return obj
 63
 64    PREPROCESS = (
 65        "preprocess",
 66        "Provides a fully initialized `UIMADocument` after preprocessing (tokenization, NER, rule-based setup).",
 67        (UIMADocument,),
 68    )
 69    COREF = (
 70        "coref",
 71        "Provides coreference annotations (`UIMAEntity`, `UIMAEntityReference`) needed by character-related steps.",
 72        (UIMAEntity, UIMAEntityReference),
 73    )
 74    CHARACTERS = (
 75        "characters",
 76        "Provides `UIMACharacter` annotations derived from entity/coreference information.",
 77        (UIMACharacter,),
 78    )
 79    MAIN_CHARACTERS = (
 80        "main_characters",
 81        "Provides main and important characters on document annotation (`main_character`, `important_characters`).",
 82        (UIMACharacter,),
 83    )
 84    SCENES = (
 85        "scenes",
 86        "Provides scene segmentation as `UIMASystemScene` annotations in `doc.system_scenes`.",
 87        (UIMASystemScene,),
 88    )
 89    SCENE_SUMMARIES = (
 90        "scene_summaries",
 91        "Provides per-scene summaries/keywords in additional features (e.g. `llama_summary`, `llama_keywords`).",
 92        (UIMASystemScene,),
 93    )
 94    SCENE_EMBEDDINGS = (
 95        "scene_embeddings",
 96        "Provides per-scene embeddings in additional features (e.g. `text_embedding`).",
 97        (UIMASystemScene,),
 98    )
 99    EMBEDDING = (
100        "embedding",
101        "Provides document-level Moment embedding in `doc.document_annotation.additional_features['embedding']`.",
102        (UIMADocument,),
103    )
104
105    @property
106    def description(self) -> str:
107        return self._description
108
109    @property
110    def affected_types(self) -> tuple[type, ...]:
111        return self._affected_types

Declarative capabilities produced/required by pipeline steps.

These are used to auto-order steps and (when possible) auto-inject missing providers.

PREPROCESS = <PipelineCapability.PREPROCESS: 'preprocess'>
Declarative capabilities produced/required by pipeline steps.

These are used to auto-order steps and (when possible) auto-inject missing
providers.
COREF = <PipelineCapability.COREF: 'coref'>
Declarative capabilities produced/required by pipeline steps.

These are used to auto-order steps and (when possible) auto-inject missing
providers.
CHARACTERS = <PipelineCapability.CHARACTERS: 'characters'>
Declarative capabilities produced/required by pipeline steps.

These are used to auto-order steps and (when possible) auto-inject missing
providers.
MAIN_CHARACTERS = <PipelineCapability.MAIN_CHARACTERS: 'main_characters'>
Declarative capabilities produced/required by pipeline steps.

These are used to auto-order steps and (when possible) auto-inject missing
providers.
SCENES = <PipelineCapability.SCENES: 'scenes'>
Declarative capabilities produced/required by pipeline steps.

These are used to auto-order steps and (when possible) auto-inject missing
providers.
SCENE_SUMMARIES = <PipelineCapability.SCENE_SUMMARIES: 'scene_summaries'>
Declarative capabilities produced/required by pipeline steps.

These are used to auto-order steps and (when possible) auto-inject missing
providers.
SCENE_EMBEDDINGS = <PipelineCapability.SCENE_EMBEDDINGS: 'scene_embeddings'>
Declarative capabilities produced/required by pipeline steps.

These are used to auto-order steps and (when possible) auto-inject missing
providers.
EMBEDDING = <PipelineCapability.EMBEDDING: 'embedding'>
Declarative capabilities produced/required by pipeline steps.

These are used to auto-order steps and (when possible) auto-inject missing
providers.
description: str
105    @property
106    def description(self) -> str:
107        return self._description
affected_types: tuple[type, ...]
109    @property
110    def affected_types(self) -> tuple[type, ...]:
111        return self._affected_types
class AbstractPipelineProcessor(typing.Protocol[-InDocT]):
129class AbstractPipelineProcessor(Protocol[InDocT]):
130    @abstractmethod
131    def __call__(self, doc: InDocT, unit_type: Optional[Type[UIMASpan]], overwrite: bool = False,
132                 **kwargs) -> UIMADocument:
133        ...

Base class for protocol classes.

Protocol classes are defined as::

class Proto(Protocol):
    def meth(self) -> int:
        ...

Such classes are primarily used with static type checkers that recognize structural subtyping (static duck-typing).

For example::

class C:
    def meth(self) -> int:
        return 0

def func(x: Proto) -> int:
    return x.meth()

func(C())  # Passes static type check

See PEP 544 for details. Protocol classes decorated with @typing.runtime_checkable act as simple-minded runtime protocols that check only the presence of given attributes, ignoring their type signatures. Protocol classes can be generic, they are defined as::

class GenProto[T](Protocol):
    def meth(self) -> T:
        ...
136class PipelineProcessor(AbstractPipelineProcessor[UIMADocument]):
137    @abstractmethod
138    def __call__(self, doc: UIMADocument, unit_type: Type[UIMASpan] | None, overwrite: bool = False,
139                 **kwargs) -> UIMADocument:
140        ...

Base class for protocol classes.

Protocol classes are defined as::

class Proto(Protocol):
    def meth(self) -> int:
        ...

Such classes are primarily used with static type checkers that recognize structural subtyping (static duck-typing).

For example::

class C:
    def meth(self) -> int:
        return 0

def func(x: Proto) -> int:
    return x.meth()

func(C())  # Passes static type check

See PEP 544 for details. Protocol classes decorated with @typing.runtime_checkable act as simple-minded runtime protocols that check only the presence of given attributes, ignoring their type signatures. Protocol classes can be generic, they are defined as::

class GenProto[T](Protocol):
    def meth(self) -> T:
        ...
class PipelinePreprocessor(wuenlp_tools.pipeline.AbstractPipelineProcessor[-InDocT]):
143class PipelinePreprocessor(AbstractPipelineProcessor[InDocT]):
144    @abstractmethod
145    def __call__(self, doc: UIMADocument | str, unit_type: Type[UIMASpan] | None, overwrite: bool = False,
146                 **kwargs) -> UIMADocument:
147        ...

Base class for protocol classes.

Protocol classes are defined as::

class Proto(Protocol):
    def meth(self) -> int:
        ...

Such classes are primarily used with static type checkers that recognize structural subtyping (static duck-typing).

For example::

class C:
    def meth(self) -> int:
        return 0

def func(x: Proto) -> int:
    return x.meth()

func(C())  # Passes static type check

See PEP 544 for details. Protocol classes decorated with @typing.runtime_checkable act as simple-minded runtime protocols that check only the presence of given attributes, ignoring their type signatures. Protocol classes can be generic, they are defined as::

class GenProto[T](Protocol):
    def meth(self) -> T:
        ...
class PipelineStep(typing.Generic[~AbstractPipelineProcessorType]):
153class PipelineStep(Generic[AbstractPipelineProcessorType]):
154    """A named processing unit that can be composed into a `Pipeline`.
155
156    Pass step instances into `Pipeline(steps=[...])`. Each step declares what it
157    `provides` and `requires` (`PipelineCapability`), which span type it operates
158    on, and which annotation features it writes.
159    """
160
161    name: Optional[str] = None
162    processor: AbstractPipelineProcessorType
163    unit_type: Optional[Type[UIMASpan]] = None
164    unit_filter: UnitFilter | None = None
165    overwrite: Optional[bool] = None
166    added_mixins: Optional[List[Type[ExtraTypesMixin]]] = None
167    added_features: Optional[List[str]] = None
168    added_additional_features: Optional[List[str]] = None
169
170    requires_api_key: Optional[LazyKey] = None
171    requires_paid_api_requests: Optional[bool] = None
172    provides: List[PipelineCapability]
173    requires: List[PipelineCapability]
174
175    def __init__(self,
176                 name: str,
177                 processor: AbstractPipelineProcessorType,
178                 unit_type: Optional[Type[UIMASpan]],
179                 unit_filter: UnitFilter | None = None,
180                 overwrite: Optional[bool] = None,
181                 added_mixins: Optional[List[Type[ExtraTypesMixin]] | Type[ExtraTypesMixin]] = None,
182                 added_features: Optional[List[str]] = None,
183                 added_additional_features: Optional[List[str]] = None,
184                 modified_types: Optional[List[Type[UIMAAnnotation]]] = None,
185                 needs_manual_merge: bool = False,
186                 requires_api_key: Optional[LazyKey] = None,
187                 requires_paid_api_requests: Optional[bool] = None,
188                 provides: Optional[List[_CapabilityLike]] = None,
189                 requires: Optional[List[_CapabilityLike]] = None,
190                 ):
191        self.name = name
192        self.processor = processor
193        self.unit_type = unit_type
194        self.unit_filter = unit_filter
195        self.overwrite = overwrite
196        self.added_mixins = [added_mixins] if isinstance(added_mixins, type) else (added_mixins or [])
197        self.added_features = added_features if added_features is not None else []
198        self.added_additional_features = added_additional_features if added_additional_features is not None else []
199        self.modified_types = modified_types if modified_types is not None else []
200        self.needs_manual_merge = needs_manual_merge
201        self.requires_api_key = requires_api_key
202        self.requires_paid_api_requests = requires_paid_api_requests
203        self.provides = _normalize_capabilities(provides)
204        self.requires = _normalize_capabilities(requires)
205        self.__doc__ = self._format_doc()
206
207    def __call__(self, doc: UIMADocument | str | Path, overwrite: Optional[bool] = None,
208                 DocType: Type[UIMADocument] = UIMADocument, **kwargs):
209        logger.info(f"Starting {self.name} pipeline step")
210
211        final_overwrite: bool
212
213        if overwrite is True:
214            final_overwrite = True
215        elif overwrite is None:
216            final_overwrite = self.overwrite if self.overwrite is not None else False
217        else:
218            # overwrite is False
219            final_overwrite = self.overwrite if self.overwrite is not None else False
220
221        if isinstance(doc, Path):
222            doc = DocType.from_xmi(doc)
223
224        unit_filter = kwargs.pop("unit_filter", self.unit_filter)
225        return self.processor(
226            doc,
227            unit_type=self.unit_type,
228            unit_filter=unit_filter,
229            overwrite=final_overwrite,
230            **kwargs,
231        )
232
233    def set_overwrite(self, overwrite: bool) -> "PipelineStep":
234        self.overwrite = overwrite
235        return self
236
237    def _format_doc(self) -> str:
238        lines = [f"Pipeline step **{self.name}** (`{type(self.processor).__name__}`)."]
239        proc_doc = type(self.processor).__dict__.get("__doc__")
240        if isinstance(proc_doc, str) and proc_doc.strip():
241            lines.append("")
242            lines.append(inspect.cleandoc(proc_doc).strip())
243        if self.provides:
244            lines.append("")
245            lines.append("**Provides:** " + ", ".join(f"`{c.value}`" for c in self.provides))
246            for cap in self.provides:
247                lines.append(f"- `{cap.value}`: {cap.description}")
248        if self.requires:
249            lines.append("")
250            lines.append("**Requires:** " + ", ".join(f"`{c.value}`" for c in self.requires))
251        details: list[str] = []
252        if self.unit_type is not None:
253            details.append(f"unit type `{self.unit_type.__name__}`")
254        if self.modified_types:
255            details.append(
256                "modifies " + ", ".join(f"`{t.__name__}`" for t in self.modified_types)
257            )
258        if self.added_additional_features:
259            details.append(
260                "additional features "
261                + ", ".join(f"`{f}`" for f in self.added_additional_features)
262            )
263        if self.added_features:
264            details.append("features " + ", ".join(f"`{f}`" for f in self.added_features))
265        if details:
266            lines.append("")
267            lines.append("; ".join(details) + ".")
268        return "\n".join(lines)
269
270    def __repr__(self) -> str:
271        parts = [repr(self.name), f"processor={type(self.processor).__name__}"]
272        if self.provides:
273            parts.append(f"provides={[c.value for c in self.provides]}")
274        if self.requires:
275            parts.append(f"requires={[c.value for c in self.requires]}")
276        return f"{type(self).__name__}({', '.join(parts)})"

A named processing unit that can be composed into a Pipeline.

Pass step instances into Pipeline(steps=[...]). Each step declares what it provides and requires (PipelineCapability), which span type it operates on, and which annotation features it writes.

PipelineStep( name: str, processor: ~AbstractPipelineProcessorType, unit_type: Optional[Type[wuenlp.impl.uima.UIMANLPStructs.UIMASpan]], unit_filter: Optional[Callable[[wuenlp.impl.uima.UIMANLPStructs.UIMASpan], bool]] = None, overwrite: Optional[bool] = None, added_mixins: Union[List[Type[wuenlp.impl.uima.extensions.ExtraTypesMixin]], Type[wuenlp.impl.uima.extensions.ExtraTypesMixin], NoneType] = None, added_features: Optional[List[str]] = None, added_additional_features: Optional[List[str]] = None, modified_types: Optional[List[Type[wuenlp.impl.uima.UIMANLPStructs.UIMAAnnotation]]] = None, needs_manual_merge: bool = False, requires_api_key: Optional[wuenlp_tools.keys.lazykeys.LazyKey] = None, requires_paid_api_requests: Optional[bool] = None, provides: Optional[List[Union[PipelineCapability, str]]] = None, requires: Optional[List[Union[PipelineCapability, str]]] = None)
175    def __init__(self,
176                 name: str,
177                 processor: AbstractPipelineProcessorType,
178                 unit_type: Optional[Type[UIMASpan]],
179                 unit_filter: UnitFilter | None = None,
180                 overwrite: Optional[bool] = None,
181                 added_mixins: Optional[List[Type[ExtraTypesMixin]] | Type[ExtraTypesMixin]] = None,
182                 added_features: Optional[List[str]] = None,
183                 added_additional_features: Optional[List[str]] = None,
184                 modified_types: Optional[List[Type[UIMAAnnotation]]] = None,
185                 needs_manual_merge: bool = False,
186                 requires_api_key: Optional[LazyKey] = None,
187                 requires_paid_api_requests: Optional[bool] = None,
188                 provides: Optional[List[_CapabilityLike]] = None,
189                 requires: Optional[List[_CapabilityLike]] = None,
190                 ):
191        self.name = name
192        self.processor = processor
193        self.unit_type = unit_type
194        self.unit_filter = unit_filter
195        self.overwrite = overwrite
196        self.added_mixins = [added_mixins] if isinstance(added_mixins, type) else (added_mixins or [])
197        self.added_features = added_features if added_features is not None else []
198        self.added_additional_features = added_additional_features if added_additional_features is not None else []
199        self.modified_types = modified_types if modified_types is not None else []
200        self.needs_manual_merge = needs_manual_merge
201        self.requires_api_key = requires_api_key
202        self.requires_paid_api_requests = requires_paid_api_requests
203        self.provides = _normalize_capabilities(provides)
204        self.requires = _normalize_capabilities(requires)
205        self.__doc__ = self._format_doc()
name: Optional[str] = None
processor: ~AbstractPipelineProcessorType
unit_type: Optional[Type[wuenlp.impl.uima.UIMANLPStructs.UIMASpan]] = None
unit_filter: Optional[Callable[[wuenlp.impl.uima.UIMANLPStructs.UIMASpan], bool]] = None
overwrite: Optional[bool] = None
added_mixins: Optional[List[Type[wuenlp.impl.uima.extensions.ExtraTypesMixin]]] = None
added_features: Optional[List[str]] = None
added_additional_features: Optional[List[str]] = None
requires_api_key: Optional[wuenlp_tools.keys.lazykeys.LazyKey] = None
requires_paid_api_requests: Optional[bool] = None
provides: List[PipelineCapability]
requires: List[PipelineCapability]
modified_types
needs_manual_merge
def set_overwrite(self, overwrite: bool) -> PipelineStep:
233    def set_overwrite(self, overwrite: bool) -> "PipelineStep":
234        self.overwrite = overwrite
235        return self
class Pipeline:
279class Pipeline:
280    def __init__(self, steps: List[PipelineStep], intermediate_file_dir: Optional[Path] = None,
281                 overwrite: bool = False):
282        self.steps = steps
283        self.intermediate_file_dir = intermediate_file_dir
284        self.overwrite = overwrite
285
286        if intermediate_file_dir:
287            intermediate_file_dir.mkdir(exist_ok=True, parents=True)
288
289        force_overwrite = False
290
291        for step in self.steps:
292            if step.overwrite:
293                force_overwrite = True
294                logger.info(
295                    f"Step {step.name} is set to overwrite, setting all following steps (if any) to overwrite as well.")
296            else:
297                if force_overwrite:
298                    step.overwrite = force_overwrite
299                    logger.info(f"Setting step {step.name} to overwrite as a previous step is overwriting.")
300
301    def __call__(self, doc: UIMADocument | str | Path, DocType: Type = UIMADocument) -> UIMADocument | Path:
302        if isinstance(doc, Path):
303            logger.info(f"Processing {doc}")
304            current_class = DocType
305        elif isinstance(doc, UIMADocument):
306            logger.info(f"Processing {doc.path}")
307            current_class = type(doc)
308        else:
309            current_class = DocType
310        orig_overwrite = self.overwrite
311        result = None
312        for step in self.steps:
313            if step.added_mixins:
314                missing_mixins = [
315                    mixin for mixin in step.added_mixins if mixin not in current_class.__mro__
316                ]
317                if missing_mixins:
318                    current_class = enrich_with_mixins(current_class, missing_mixins)
319            if self.intermediate_file_dir:
320                text = doc.text if isinstance(doc, UIMADocument) else str(doc)
321                step_file = self.intermediate_file_dir / f"{md5(text.encode()).hexdigest()}_{step.name}.xmi.zip"
322            else:
323                step_file = None
324            if step_file and step_file.exists() and not self.overwrite and not step.overwrite:
325                logger.info(f"Using intermediate file for {step.name} from {self.intermediate_file_dir}")
326                result = step_file  # current_class.from_xmi(step_file)
327                continue
328            elif step_file and not step_file.exists():
329                self.overwrite = True
330                logger.info(
331                    f"Intermediate file for {step.name} does not exist, setting overwrite to True for all remaining steps.")
332            # TODO: This is highly experimental and needs to be tested/fixed
333            result = doc if result is None else result
334            step_output = step(result, overwrite=self.overwrite, DocType=current_class)
335            if not step.needs_manual_merge:
336                result = step_output
337            else:
338                if result is step_output:
339                    logger.warning("Document was unchanged by this step. Skipping manual merge.")
340                else:
341                    if isinstance(result, Path):
342                        result = current_class.from_xmi(result)
343                    for t in step.modified_types:
344                        for anno in result.get_annos_of_type(t):
345                            result.remove_annotation(anno)
346                        for anno in step_output.get_annos_of_type(t):
347                            result.add_anno(anno)
348            if self.intermediate_file_dir and isinstance(result, UIMADocument):
349                logger.info(f"Serializing intermediate file for {step.name} to {self.intermediate_file_dir}")
350                step_file.parent.mkdir(parents=True, exist_ok=True)
351                result.serialize(step_file)
352
353        self.overwrite = orig_overwrite
354
355        if result is not None:
356            if isinstance(result, UIMADocument):
357                return result
358            else:
359                return current_class.from_xmi(result)
360        else:
361            raise RuntimeError(f"No result produced")
362
363    @property
364    def added_features(self) -> Dict[Type[UIMAAnnotation], List]:
365        added_features: defaultdict = defaultdict(set)
366
367        for step in self.steps:
368            added_features[step.unit_type].update(step.added_features)
369
370        return dict(added_features)
371
372    @property
373    def added_additional_features(self) -> Dict[Type[UIMAAnnotation], List]:
374        added_additional_features: defaultdict = defaultdict(set)
375
376        for step in self.steps:
377            added_additional_features[step.unit_type].update(step.added_additional_features)
378
379        return dict(added_additional_features)
380
381    def __str__(self):
382        return " → ".join(step.name or type(step.processor).__name__ for step in self.steps)
383
384    def __repr__(self):
385        names = [step.name or type(step.processor).__name__ for step in self.steps]
386        return f"Pipeline({names})"
387
388    def __len__(self):
389        return len(self.steps)
390
391    steps: List[PipelineStep] = []
Pipeline( steps: List[PipelineStep], intermediate_file_dir: Optional[pathlib.Path] = None, overwrite: bool = False)
280    def __init__(self, steps: List[PipelineStep], intermediate_file_dir: Optional[Path] = None,
281                 overwrite: bool = False):
282        self.steps = steps
283        self.intermediate_file_dir = intermediate_file_dir
284        self.overwrite = overwrite
285
286        if intermediate_file_dir:
287            intermediate_file_dir.mkdir(exist_ok=True, parents=True)
288
289        force_overwrite = False
290
291        for step in self.steps:
292            if step.overwrite:
293                force_overwrite = True
294                logger.info(
295                    f"Step {step.name} is set to overwrite, setting all following steps (if any) to overwrite as well.")
296            else:
297                if force_overwrite:
298                    step.overwrite = force_overwrite
299                    logger.info(f"Setting step {step.name} to overwrite as a previous step is overwriting.")
steps: List[PipelineStep] = []

Built-in mutable sequence.

If no argument is given, the constructor creates a new empty list. The argument must be an iterable if specified.

intermediate_file_dir
overwrite
added_features: Dict[Type[wuenlp.impl.uima.UIMANLPStructs.UIMAAnnotation], List]
363    @property
364    def added_features(self) -> Dict[Type[UIMAAnnotation], List]:
365        added_features: defaultdict = defaultdict(set)
366
367        for step in self.steps:
368            added_features[step.unit_type].update(step.added_features)
369
370        return dict(added_features)
added_additional_features: Dict[Type[wuenlp.impl.uima.UIMANLPStructs.UIMAAnnotation], List]
372    @property
373    def added_additional_features(self) -> Dict[Type[UIMAAnnotation], List]:
374        added_additional_features: defaultdict = defaultdict(set)
375
376        for step in self.steps:
377            added_additional_features[step.unit_type].update(step.added_additional_features)
378
379        return dict(added_additional_features)