Coverage for collectra/tasks/llms.py: 78.23%
124 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-16 09:56 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-16 09:56 +0000
1"""Large Language Model (LLM) integration tasks for Collectra workflows.
3This module provides integration with various Large Language Models through
4the LangChain framework. It supports both text and multimodal (text+image)
5processing tasks with configurable prompts and model parameters.
7The module includes:
8 - LLM task class with configurable model backends
9 - Support for text and image inputs
10 - Template-based prompt formatting
11 - Integration with various LLM providers via llmloader
13Classes:
14 LLM: Task for Large Language Model inference operations
15"""
17__all__ = ["LLM"]
19import os
20import re
21from functools import cached_property
22from pathlib import Path
24import yaml
25from dotenv import load_dotenv
26from langchain_core.messages import HumanMessage, SystemMessage
27from langchain_core.output_parsers import StrOutputParser
29from collectra.tasks.base import Task
30from collectra.types.images import Image
31from collectra.types.texts import Text
33from ..logger import get_logger
36def get_llmloader():
37 try:
38 import llmloader
39 except ModuleNotFoundError as error:
40 if error.name != "llmloader":
41 raise
42 raise ImportError(
43 'LLM execution requires optional dependencies. Install with: pip install "collectra[ml]"'
44 ) from error
45 return llmloader
47load_dotenv()
49logger = get_logger(__name__)
52class LLM(Task):
53 """Task for Large Language Model inference operations.
55 Integrates with various LLM providers to perform text generation and
56 multimodal processing tasks. Supports configurable prompts, temperature,
57 and token limits with template-based input formatting.
59 Attributes:
60 name (str): Name identifier for the LLM task.
61 model (str): Model identifier or path for the LLM.
62 template (str | Path): Prompt template string or path to template file.
63 temperature (float): Sampling temperature for generation (0.0 to 1.0).
64 max_tokens (int): Maximum number of tokens to generate.
65 variables (dict): Additional variables for template substitution.
66 """
68 def __init__(self, name: str, model: str, **kwargs):
69 super().__init__(name, **kwargs)
70 self.template: str = kwargs.get("template", "")
71 self.preamble: str = kwargs.get("preamble", "")
72 self.temperature = kwargs.get("temperature", 0.8)
73 self.max_tokens = kwargs.get("max_tokens", None)
74 self.model = model
75 self.parser = StrOutputParser()
77 init_messages = (
78 SystemMessage(content=kwargs.get("system", ""))
79 if kwargs.get("system", "")
80 else SystemMessage(content="You are a helpful assistant.")
81 )
82 self.messages: list[SystemMessage | HumanMessage] = [init_messages]
84 @cached_property
85 def llm(self):
86 """Load the LLM lazily so that it is only instantiated when first needed."""
87 return get_llmloader().load(
88 self.model, temperature=self.temperature, max_tokens=self.max_tokens
89 )
91 @property
92 def chain(self):
93 return self.llm
95 def get_pattern_matches(self) -> tuple:
96 prompt = f"{self.preamble}\n\n{self.template}".strip()
97 pattern = r"\{(.*?)\}"
98 return prompt, pattern
100 def image_content(self, image: Image):
101 return get_llmloader().LLMWrapper.format(
102 self.llm,
103 data_type="image",
104 data={
105 "data": image.get_encoding(),
106 "mime_type": image.mime(),
107 },
108 )
110 def _add_content(self, value: Image | Text) -> dict:
111 if isinstance(value, Image):
112 return self.image_content(value)
113 return self._add_text(value())
115 def _add_text(self, text: str) -> dict:
116 text = (
117 "''" if len(text.strip()) == 0 else text.strip()
118 ) # Avoid empty string issues in some LLMs
119 return {"type": "text", "text": text}
121 def pre_run(self) -> tuple:
122 self.messages = self.messages[:1] # Reset to initial system message
123 prompt, pattern = self.get_pattern_matches()
124 return prompt, pattern
126 def check_inputs(self, *args: Text | Image) -> None:
127 """Validate that the messages are not longer than the expected number of inputs."""
128 residual_inputs = len(args)
129 if len(self.messages) != 2:
130 raise ValueError("Expected one system message and one human message.")
131 human_message = self.messages[1]
132 for content in human_message.content:
133 if isinstance(content, dict) and (
134 content.get("type") == "image" or content.get("type") == "text"
135 ):
136 residual_inputs -= 1
137 if residual_inputs > 0:
138 raise ValueError("Number of inputs does not match the expected count")
140 def replace_inputs(
141 self, pattern: str, prompt: str, *args: Text | Image, **kwargs
142 ) -> list[str | dict]:
143 messages: list[str | dict] = list()
144 while re.search(pattern, prompt):
145 match = next(re.finditer(pattern, prompt))
146 start, end = match.span()
147 item = match[1].strip()
148 replaced = False
149 if prompt[:start]:
150 messages.append(self._add_text(prompt[:start]))
151 for arg in args:
152 key = arg.name
153 if key == item:
154 messages.append(self._add_content(arg))
155 replaced = True
156 break
157 if item == "entities" and kwargs.get("entities", None):
158 replaced = True
159 messages.append(self._add_text(kwargs["entities"]))
160 if not replaced:
161 messages.append(
162 self._add_text(f"No content provided for {item}. Ignore this part.")
163 )
164 prompt = prompt[end:].strip()
166 if prompt:
167 messages.append(self._add_text(prompt))
169 return messages
171 def invoke(self) -> str:
172 response = self.chain.invoke(self.messages)
174 # Use context for usage tracking instead of environment variable
175 if self.context.usage_file:
176 usage = get_llmloader().LLMWrapper.get_token_count(response)
177 usage_file = self.context.usage_file
178 data = {f"{self.name}": usage}
179 old_data = {}
180 if usage_file.exists():
181 with open(usage_file, "r") as f:
182 old_data = yaml.safe_load(f) or dict()
183 for key, value in old_data.items():
184 if key not in data:
185 data[key] = value
186 else:
187 for item, item_value in value.items():
188 data[key][item] = data.get(key, {}).get(item, 0) + item_value
189 usage_file.parent.mkdir(parents=True, exist_ok=True)
190 with open(usage_file, "w") as f:
191 yaml.dump(data, f)
193 response = self.parser.invoke(response)
195 return "" if response in ('""', "''") else response.strip()
197 def run(self, *args: Text | Image) -> Text | None:
198 """Execute LLM inference on the provided inputs with template-based prompt generation.
200 For list inputs, each item is processed individually and results are collected
201 in a list. The method supports multimodal inputs (text + images) by creating
202 appropriate message structures for the LangChain conversation format.
204 Args:
205 **kwargs: Input data for LLM processing. Keys should match template
206 placeholders. Values can be text strings, file paths (Path objects),
207 Image objects, or lists of these types for batch processing.
209 Side Effects:
210 Updates self.output dictionary with generated text responses. Output keys
211 that contain the input key as a substring will be populated with LLM results.
212 Prints a success message when inference completes.
213 """
214 try:
215 prompt, pattern = self.pre_run()
217 messages = self.replace_inputs(pattern, prompt, *args)
219 self.messages.append(HumanMessage(content=messages))
221 self.check_inputs(*args)
223 response = self.invoke()
225 name = self.get_output_name()
226 output = Text(name=name, data=response)
227 return output
228 except Exception as e:
229 logger.error("LLM task '%s' failed: %s", self.name, e, exc_info=True)
230 return None