wuenlp_tools.models.sentiment.SentimentPromptingLLM

  1from dataclasses import dataclass, field
  2import json
  3from typing import Type
  4
  5from loguru import logger
  6from pydantic import BaseModel
  7from tqdm.auto import tqdm
  8
  9from wuenlp_tools.keys import OPENAI_API_KEY
 10from wuenlp_tools.pipeline import PipelineStep, PipelineProcessor
 11from wuenlp_tools.utils.prompting import LLMArchitecture, default_llm, LLM
 12from wuenlp.impl.UIMANLPStructs import UIMADocument, UIMAAnnotation, UIMASpan, UIMASystemScene
 13
 14
 15class FloatSentimentOutput(BaseModel):
 16    sentiment: float
 17
 18
 19DEFAULT_BASE_LLM_SYSTEM_PROMPT = (
 20    "You are given a piece of text from a novel. You should determine the sentiment of the text on a scale "
 21    "from -1 to 1, depending on how well the overall situation described in the text is. Return a valid json "
 22    "object of the required format."
 23)
 24
 25
 26@dataclass(frozen=True)
 27class BaseLLMSentimentConfig:
 28    model: LLMArchitecture = field(default_factory=lambda: default_llm)
 29    system_prompt: str = DEFAULT_BASE_LLM_SYSTEM_PROMPT
 30    base_url: str = "https://ollama.professor-x.de/v1/"
 31    feature_key: str = "llama_sentiment"
 32
 33
 34class BaseLLMSentimentModel(object):
 35    model: LLM
 36
 37    def __init__(self, config: BaseLLMSentimentConfig | None = None, output_format: type[BaseModel] = FloatSentimentOutput):
 38        cfg = config or BaseLLMSentimentConfig()
 39        self.model = LLM(
 40            model=cfg.model,
 41            system_prompt=cfg.system_prompt,
 42            output_format=output_format,
 43            base_url=cfg.base_url,
 44        )
 45
 46    @staticmethod
 47    def normalize_sentiment(sentiment):
 48        return min(1, max(-1, sentiment))
 49
 50    def __call__(self, text, output_type: Type = float):
 51        assert output_type in (float,), "Only float output_type is supported for GPTSentimentModel"
 52        response = self.model(text)
 53        if hasattr(response, "sentiment"):
 54            return self.normalize_sentiment(response.sentiment)
 55
 56        if isinstance(response, str):
 57            try:
 58                parsed = json.loads(response)
 59                if isinstance(parsed, dict) and "sentiment" in parsed:
 60                    return self.normalize_sentiment(float(parsed["sentiment"]))
 61            except Exception:
 62                try:
 63                    return self.normalize_sentiment(float(response))
 64                except Exception:
 65                    pass
 66
 67        raise ValueError(f"Unexpected sentiment response type: {type(response).__name__}")
 68
 69
 70def base_llm_sentiment(
 71        doc: UIMADocument,
 72        unit_type: Type[UIMASpan],
 73        overwrite: bool = False,
 74        config: BaseLLMSentimentConfig | None = None,
 75) -> UIMADocument:
 76    cfg = config or BaseLLMSentimentConfig()
 77    if not overwrite and any(
 78            cfg.feature_key in segment.additional_features for segment in doc._get_annos_of_type(unit_type)):
 79        logger.warning(
 80            f"Skipping {unit_type} sentiment prediction because some segments already have a sentiment prediction")
 81        return doc
 82    model = BaseLLMSentimentModel(config=cfg)
 83    segments = doc._get_annos_of_type(unit_type)
 84    progress_desc = f"LLM sentiment ({unit_type.__name__})"
 85    for segment in tqdm(segments, desc=progress_desc, unit="segment"):
 86        try:
 87            segment.additional_features[cfg.feature_key] = model(segment.text)
 88        except Exception as exc:
 89            logger.warning(f"Falling back to neutral sentiment due to model error: {exc}")
 90            segment.additional_features[cfg.feature_key] = 0.0
 91    return doc
 92
 93
 94class BaseLLMSentimentProcessor(PipelineProcessor):
 95    def __init__(self, config: BaseLLMSentimentConfig | None = None):
 96        self.config = config or BaseLLMSentimentConfig()
 97
 98    def __call__(self, doc: UIMADocument, unit_type: Type[UIMASpan], overwrite: bool = False, **kwargs) -> UIMADocument:
 99        return base_llm_sentiment(doc, unit_type, overwrite=overwrite, config=self.config)
100
101
102def BaseLLMSentimentStep(
103        config: BaseLLMSentimentConfig | None = None,
104        unit_type: Type[UIMAAnnotation] = UIMASystemScene,
105        name: str = "LLM Sentiment",
106) -> PipelineStep[BaseLLMSentimentProcessor]:
107    cfg = config or BaseLLMSentimentConfig()
108    return PipelineStep[BaseLLMSentimentProcessor](
109        name,
110        BaseLLMSentimentProcessor(config=cfg),
111        unit_type=unit_type,
112        added_additional_features=[cfg.feature_key],
113        requires_api_key=OPENAI_API_KEY,
114        requires_paid_api_requests=True,
115    )
116
117
118BaseLLMSentiment = BaseLLMSentimentStep()
class FloatSentimentOutput(pydantic.main.BaseModel):
16class FloatSentimentOutput(BaseModel):
17    sentiment: float

!!! abstract "Usage Documentation" Models

A base class for creating Pydantic models.

Attributes: __class_vars__: The names of the class variables defined on the model. __private_attributes__: Metadata about the private attributes of the model. __signature__: The synthesized __init__ [Signature][inspect.Signature] of the model.

__pydantic_complete__: Whether model building is completed, or if there are still undefined fields.
__pydantic_core_schema__: The core schema of the model.
__pydantic_custom_init__: Whether the model has a custom `__init__` function.
__pydantic_decorators__: Metadata containing the decorators defined on the model.
    This replaces `Model.__validators__` and `Model.__root_validators__` from Pydantic V1.
__pydantic_generic_metadata__: A dictionary containing metadata about generic Pydantic models.
    The `origin` and `args` items map to the [`__origin__`][genericalias.__origin__]
    and [`__args__`][genericalias.__args__] attributes of [generic aliases][types-genericalias],
    and the `parameter` item maps to the `__parameter__` attribute of generic classes.
__pydantic_parent_namespace__: Parent namespace of the model, used for automatic rebuilding of models.
__pydantic_post_init__: The name of the post-init method for the model, if defined.
__pydantic_root_model__: Whether the model is a [`RootModel`][pydantic.root_model.RootModel].
__pydantic_serializer__: The `pydantic-core` `SchemaSerializer` used to dump instances of the model.
__pydantic_validator__: The `pydantic-core` `SchemaValidator` used to validate instances of the model.

__pydantic_fields__: A dictionary of field names and their corresponding [`FieldInfo`][pydantic.fields.FieldInfo] objects.
__pydantic_computed_fields__: A dictionary of computed field names and their corresponding [`ComputedFieldInfo`][pydantic.fields.ComputedFieldInfo] objects.

__pydantic_extra__: A dictionary containing extra values, if [`extra`][pydantic.config.ConfigDict.extra]
    is set to `'allow'`.
__pydantic_fields_set__: The names of fields explicitly set during instantiation.
__pydantic_private__: Values of private attributes set on the model instance.
sentiment: float = PydanticUndefined
DEFAULT_BASE_LLM_SYSTEM_PROMPT = 'You are given a piece of text from a novel. You should determine the sentiment of the text on a scale from -1 to 1, depending on how well the overall situation described in the text is. Return a valid json object of the required format.'

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.__str__() (if defined) or repr(object). encoding defaults to sys.getdefaultencoding(). errors defaults to 'strict'.

@dataclass(frozen=True)
class BaseLLMSentimentConfig:
27@dataclass(frozen=True)
28class BaseLLMSentimentConfig:
29    model: LLMArchitecture = field(default_factory=lambda: default_llm)
30    system_prompt: str = DEFAULT_BASE_LLM_SYSTEM_PROMPT
31    base_url: str = "https://ollama.professor-x.de/v1/"
32    feature_key: str = "llama_sentiment"
BaseLLMSentimentConfig( model: wuenlp_tools.utils.prompting.LLMArchitecture = <factory>, system_prompt: str = 'You are given a piece of text from a novel. You should determine the sentiment of the text on a scale from -1 to 1, depending on how well the overall situation described in the text is. Return a valid json object of the required format.', base_url: str = 'https://ollama.professor-x.de/v1/', feature_key: str = 'llama_sentiment')
system_prompt: str = 'You are given a piece of text from a novel. You should determine the sentiment of the text on a scale from -1 to 1, depending on how well the overall situation described in the text is. Return a valid json object of the required format.'

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.__str__() (if defined) or repr(object). encoding defaults to sys.getdefaultencoding(). errors defaults to 'strict'.

base_url: str = 'https://ollama.professor-x.de/v1/'

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.__str__() (if defined) or repr(object). encoding defaults to sys.getdefaultencoding(). errors defaults to 'strict'.

feature_key: str = 'llama_sentiment'

str(object='') -> str str(bytes_or_buffer[, encoding[, errors]]) -> str

Create a new string object from the given object. If encoding or errors is specified, then the object must expose a data buffer that will be decoded using the given encoding and error handler. Otherwise, returns the result of object.__str__() (if defined) or repr(object). encoding defaults to sys.getdefaultencoding(). errors defaults to 'strict'.

class BaseLLMSentimentModel:
35class BaseLLMSentimentModel(object):
36    model: LLM
37
38    def __init__(self, config: BaseLLMSentimentConfig | None = None, output_format: type[BaseModel] = FloatSentimentOutput):
39        cfg = config or BaseLLMSentimentConfig()
40        self.model = LLM(
41            model=cfg.model,
42            system_prompt=cfg.system_prompt,
43            output_format=output_format,
44            base_url=cfg.base_url,
45        )
46
47    @staticmethod
48    def normalize_sentiment(sentiment):
49        return min(1, max(-1, sentiment))
50
51    def __call__(self, text, output_type: Type = float):
52        assert output_type in (float,), "Only float output_type is supported for GPTSentimentModel"
53        response = self.model(text)
54        if hasattr(response, "sentiment"):
55            return self.normalize_sentiment(response.sentiment)
56
57        if isinstance(response, str):
58            try:
59                parsed = json.loads(response)
60                if isinstance(parsed, dict) and "sentiment" in parsed:
61                    return self.normalize_sentiment(float(parsed["sentiment"]))
62            except Exception:
63                try:
64                    return self.normalize_sentiment(float(response))
65                except Exception:
66                    pass
67
68        raise ValueError(f"Unexpected sentiment response type: {type(response).__name__}")
BaseLLMSentimentModel( config: BaseLLMSentimentConfig | None = None, output_format: type[pydantic.main.BaseModel] = <class 'FloatSentimentOutput'>)
38    def __init__(self, config: BaseLLMSentimentConfig | None = None, output_format: type[BaseModel] = FloatSentimentOutput):
39        cfg = config or BaseLLMSentimentConfig()
40        self.model = LLM(
41            model=cfg.model,
42            system_prompt=cfg.system_prompt,
43            output_format=output_format,
44            base_url=cfg.base_url,
45        )
@staticmethod
def normalize_sentiment(sentiment):
47    @staticmethod
48    def normalize_sentiment(sentiment):
49        return min(1, max(-1, sentiment))
def base_llm_sentiment( doc: wuenlp.impl.uima.UIMANLPStructs.UIMADocument, unit_type: Type[wuenlp.impl.uima.UIMANLPStructs.UIMASpan], overwrite: bool = False, config: BaseLLMSentimentConfig | None = None) -> wuenlp.impl.uima.UIMANLPStructs.UIMADocument:
71def base_llm_sentiment(
72        doc: UIMADocument,
73        unit_type: Type[UIMASpan],
74        overwrite: bool = False,
75        config: BaseLLMSentimentConfig | None = None,
76) -> UIMADocument:
77    cfg = config or BaseLLMSentimentConfig()
78    if not overwrite and any(
79            cfg.feature_key in segment.additional_features for segment in doc._get_annos_of_type(unit_type)):
80        logger.warning(
81            f"Skipping {unit_type} sentiment prediction because some segments already have a sentiment prediction")
82        return doc
83    model = BaseLLMSentimentModel(config=cfg)
84    segments = doc._get_annos_of_type(unit_type)
85    progress_desc = f"LLM sentiment ({unit_type.__name__})"
86    for segment in tqdm(segments, desc=progress_desc, unit="segment"):
87        try:
88            segment.additional_features[cfg.feature_key] = model(segment.text)
89        except Exception as exc:
90            logger.warning(f"Falling back to neutral sentiment due to model error: {exc}")
91            segment.additional_features[cfg.feature_key] = 0.0
92    return doc
 95class BaseLLMSentimentProcessor(PipelineProcessor):
 96    def __init__(self, config: BaseLLMSentimentConfig | None = None):
 97        self.config = config or BaseLLMSentimentConfig()
 98
 99    def __call__(self, doc: UIMADocument, unit_type: Type[UIMASpan], overwrite: bool = False, **kwargs) -> UIMADocument:
100        return base_llm_sentiment(doc, unit_type, overwrite=overwrite, config=self.config)

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:
        ...
BaseLLMSentimentProcessor( config: BaseLLMSentimentConfig | None = None)
96    def __init__(self, config: BaseLLMSentimentConfig | None = None):
97        self.config = config or BaseLLMSentimentConfig()
config
def BaseLLMSentimentStep( config: BaseLLMSentimentConfig | None = None, unit_type: Type[wuenlp.impl.uima.UIMANLPStructs.UIMAAnnotation] = <class 'wuenlp.impl.uima.UIMANLPStructs.UIMASystemScene'>, name: str = 'LLM Sentiment') -> wuenlp_tools.pipeline.PipelineStep[BaseLLMSentimentProcessor]:
103def BaseLLMSentimentStep(
104        config: BaseLLMSentimentConfig | None = None,
105        unit_type: Type[UIMAAnnotation] = UIMASystemScene,
106        name: str = "LLM Sentiment",
107) -> PipelineStep[BaseLLMSentimentProcessor]:
108    cfg = config or BaseLLMSentimentConfig()
109    return PipelineStep[BaseLLMSentimentProcessor](
110        name,
111        BaseLLMSentimentProcessor(config=cfg),
112        unit_type=unit_type,
113        added_additional_features=[cfg.feature_key],
114        requires_api_key=OPENAI_API_KEY,
115        requires_paid_api_requests=True,
116    )
BaseLLMSentiment = PipelineStep('LLM Sentiment', processor=BaseLLMSentimentProcessor)

Pipeline step LLM Sentiment (BaseLLMSentimentProcessor).

unit type UIMASystemScene; additional features llama_sentiment.