mirror of
https://github.com/imartinez/privateGPT.git
synced 2026-07-17 01:48:03 +00:00
657 lines
22 KiB
Python
657 lines
22 KiB
Python
import asyncio
|
|
import logging
|
|
from typing import TYPE_CHECKING, Any, Literal, cast
|
|
|
|
from llama_index.core.base.llms.types import ImageBlock, TextBlock
|
|
from llama_index.core.callbacks import CallbackManager
|
|
from llama_index.core.llms import LLM, ChatMessage, MessageRole
|
|
from llama_index.core.workflow import Event, StartEvent, StopEvent, Workflow, step
|
|
from pydantic import BaseModel, Field
|
|
from workflows.resource import ResourceManager
|
|
|
|
from private_gpt.artifact_index.artifact_exception import ModelNotAvailableError
|
|
from private_gpt.components.llm.models import MODEL_NOT_AVAILABLE_EXCEPTION_TYPES
|
|
from private_gpt.components.prompts.prompt_builder import PromptBuilderService
|
|
from private_gpt.components.readers.nodes.image_node import IMAGE_NOT_PROCESSABLE
|
|
from private_gpt.components.workflows.types import AnyContext
|
|
from private_gpt.di import get_global_injector
|
|
from private_gpt.events.event_errors import Errors
|
|
from private_gpt.utils.dependencies import format_missing_dependency_message
|
|
from private_gpt.utils.retry import retry_context
|
|
|
|
if TYPE_CHECKING:
|
|
from llama_index.core.base.llms.types import ContentBlock
|
|
from llama_index.core.program.utils import FlexibleModel
|
|
from workflows.handler import WorkflowHandler
|
|
|
|
from private_gpt.components.concurrency.semaphore_manager import SemaphoreManager
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_DEFAULT_RETRY_NUMBER = 3
|
|
_JITTER = (15.0, 30.0)
|
|
|
|
|
|
SKIPPABLE_TYPES = ["icon", "blank", "corrupted"]
|
|
|
|
|
|
class ExtractionStrategy(BaseModel):
|
|
type: Literal[
|
|
"picture",
|
|
"text",
|
|
"icon",
|
|
"table",
|
|
"form",
|
|
"diagram",
|
|
"chart",
|
|
"mixed",
|
|
"blank",
|
|
"corrupted",
|
|
] = Field(description="Primary content type detected in the image")
|
|
confidence: float = Field(
|
|
default=0.0, ge=0.0, le=1.0, description="Confidence in the detection (0-1)"
|
|
)
|
|
language: str = Field(default="en", description="Detected language code")
|
|
has_structure: bool = Field(
|
|
default=False, description="Whether image contains structured elements"
|
|
)
|
|
increase_contrast: bool | None = Field(
|
|
default=None, description="Whether to enhance contrast"
|
|
)
|
|
|
|
|
|
class ExtractionContent(BaseModel):
|
|
markdown: str = Field(description="Extracted content in Markdown format")
|
|
is_complete: bool | None = Field(description="True if extraction is finished")
|
|
|
|
|
|
class ExtractionEvaluation(BaseModel):
|
|
score: float = Field(description="Overall quality score")
|
|
issues_found: list[str] = Field(
|
|
default_factory=list, description="Issues identified"
|
|
)
|
|
|
|
|
|
class ImageProcessingInputEvent(StartEvent):
|
|
image_blocks: list[ImageBlock]
|
|
user_query: str | None = None
|
|
max_iterations: int = 3
|
|
enable_preprocessing: bool = True
|
|
enable_evaluation: bool = True
|
|
skip_strategy_inference: bool = False
|
|
kwargs: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
class WorkflowInitializedEvent(Event):
|
|
image_blocks: list[ImageBlock]
|
|
|
|
|
|
class StrategyInferredEvent(Event):
|
|
strategy: ExtractionStrategy | None
|
|
|
|
|
|
class ImagesPreprocessedEvent(Event):
|
|
processed_blocks: list[ImageBlock]
|
|
|
|
|
|
class ContentExtractedEvent(Event):
|
|
content: str
|
|
is_complete: bool
|
|
iteration: int
|
|
all_results: list[str]
|
|
|
|
|
|
class ContentEvaluatedEvent(Event):
|
|
final_content: str
|
|
evaluation: ExtractionEvaluation | None = None
|
|
|
|
|
|
class FinalizeContentEvent(Event):
|
|
final_content: str
|
|
evaluation: ExtractionEvaluation | None = None
|
|
|
|
|
|
class ImageProcessingResultEvent(StopEvent):
|
|
description: str
|
|
evaluation: ExtractionEvaluation | None = None
|
|
|
|
|
|
class ImageProcessingWorkflow(Workflow):
|
|
def __init__(
|
|
self,
|
|
image_multimodal_llm: LLM,
|
|
prompt_builder: PromptBuilderService | None = None,
|
|
callback_manager: CallbackManager | None = None,
|
|
timeout: float | None = 360000.0,
|
|
disable_validation: bool = False,
|
|
verbose: bool = False,
|
|
resource_manager: ResourceManager | None = None,
|
|
num_concurrent_runs: int | None = None,
|
|
num_max_retries: int = _DEFAULT_RETRY_NUMBER,
|
|
retry_jitter: tuple[float, float] = _JITTER,
|
|
**kwargs: Any,
|
|
) -> None:
|
|
super().__init__(
|
|
timeout=timeout,
|
|
disable_validation=disable_validation,
|
|
verbose=verbose,
|
|
resource_manager=resource_manager,
|
|
num_concurrent_runs=num_concurrent_runs,
|
|
)
|
|
self._llm = image_multimodal_llm
|
|
self._prompt_builder = prompt_builder or get_global_injector().get(
|
|
PromptBuilderService
|
|
)
|
|
self._kwargs = kwargs
|
|
|
|
if callback_manager:
|
|
self._llm.callback_manager = callback_manager
|
|
|
|
self._num_max_retries = num_max_retries
|
|
self._retry_jitter = retry_jitter
|
|
|
|
@step
|
|
async def init_workflow(
|
|
self, ctx: AnyContext, ev: ImageProcessingInputEvent
|
|
) -> WorkflowInitializedEvent:
|
|
await ctx.store.set("image_blocks", ev.image_blocks)
|
|
await ctx.store.set("user_query", ev.user_query)
|
|
await ctx.store.set("max_iterations", ev.max_iterations)
|
|
await ctx.store.set("enable_preprocessing", ev.enable_preprocessing)
|
|
await ctx.store.set("enable_evaluation", ev.enable_evaluation)
|
|
await ctx.store.set("skip_strategy_inference", ev.skip_strategy_inference)
|
|
await ctx.store.set("kwargs", ev.kwargs)
|
|
await ctx.store.set("results", [])
|
|
await ctx.store.set("iteration", 1)
|
|
|
|
return WorkflowInitializedEvent(image_blocks=ev.image_blocks)
|
|
|
|
@step
|
|
async def infer_strategy(
|
|
self, ctx: AnyContext, ev: WorkflowInitializedEvent
|
|
) -> StrategyInferredEvent | ImagesPreprocessedEvent:
|
|
enable_preprocessing = await ctx.store.get("enable_preprocessing")
|
|
skip_strategy_inference = await ctx.store.get("skip_strategy_inference")
|
|
kwargs = await ctx.store.get("kwargs")
|
|
|
|
if skip_strategy_inference:
|
|
strategy = None
|
|
else:
|
|
iteration = await ctx.store.get("iteration", 1)
|
|
strategy = await self._infer_strategy(
|
|
ev.image_blocks, seed=iteration, **kwargs
|
|
)
|
|
|
|
await ctx.store.set("strategy", strategy)
|
|
|
|
return (
|
|
StrategyInferredEvent(strategy=strategy)
|
|
if enable_preprocessing
|
|
else ImagesPreprocessedEvent(processed_blocks=ev.image_blocks)
|
|
)
|
|
|
|
@step
|
|
async def preprocess_images(
|
|
self, ctx: AnyContext, ev: StrategyInferredEvent
|
|
) -> ImagesPreprocessedEvent:
|
|
image_blocks = await ctx.store.get("image_blocks")
|
|
enable_preprocessing = await ctx.store.get("enable_preprocessing")
|
|
|
|
processed_blocks = image_blocks
|
|
if enable_preprocessing and ev.strategy and ev.strategy.increase_contrast:
|
|
processed_blocks = [
|
|
self._preprocess_image(block, ev.strategy) for block in image_blocks
|
|
]
|
|
|
|
await ctx.store.set("strategy", ev.strategy)
|
|
return ImagesPreprocessedEvent(processed_blocks=processed_blocks)
|
|
|
|
@step
|
|
async def extract_content(
|
|
self, ctx: AnyContext, ev: ImagesPreprocessedEvent
|
|
) -> ContentEvaluatedEvent | ImagesPreprocessedEvent:
|
|
user_query = await ctx.store.get("user_query")
|
|
kwargs = await ctx.store.get("kwargs")
|
|
strategy = await ctx.store.get("strategy")
|
|
max_iterations = await ctx.store.get("max_iterations")
|
|
|
|
results = await ctx.store.get("results", [])
|
|
iteration = await ctx.store.get("iteration", 1)
|
|
issues_found = await ctx.store.get("issues_found", [])
|
|
|
|
skippable = (
|
|
strategy is not None
|
|
and strategy.type in SKIPPABLE_TYPES
|
|
and strategy.confidence > 0.6
|
|
)
|
|
if skippable:
|
|
await ctx.store.set("iteration", max_iterations + 1)
|
|
return ContentEvaluatedEvent(final_content=IMAGE_NOT_PROCESSABLE)
|
|
|
|
if iteration > max_iterations:
|
|
final_content = " ".join(results) if results else ""
|
|
return ContentEvaluatedEvent(final_content=final_content)
|
|
|
|
continue_content = " ".join(results) if results else None
|
|
template = self._prompt_builder.create_image_interpretation_prompt(
|
|
user_query=user_query,
|
|
last_content=continue_content,
|
|
extraction_type=strategy.type if strategy else None,
|
|
confidence=strategy.confidence if strategy else None,
|
|
language=strategy.language if strategy else None,
|
|
has_structure=strategy.has_structure if strategy else None,
|
|
errors=issues_found or None,
|
|
)
|
|
|
|
messages = [
|
|
ChatMessage(
|
|
role=MessageRole.SYSTEM, blocks=[TextBlock(text=template.format())]
|
|
),
|
|
ChatMessage(
|
|
role=MessageRole.USER,
|
|
blocks=[
|
|
TextBlock(text="Extract content from this image:"),
|
|
*ev.processed_blocks,
|
|
],
|
|
),
|
|
]
|
|
|
|
response = await self._astructured_chat(
|
|
ExtractionContent,
|
|
messages,
|
|
seed=iteration,
|
|
allow_flexible=True,
|
|
**kwargs,
|
|
)
|
|
|
|
content = response.markdown if hasattr(response, "markdown") else ""
|
|
replace_content = bool(issues_found)
|
|
iteration = iteration + 1
|
|
is_complete = (
|
|
getattr(response, "is_complete", None) or iteration > max_iterations
|
|
)
|
|
|
|
results = [] if replace_content else results
|
|
results.append(content)
|
|
|
|
await ctx.store.set("results", results)
|
|
await ctx.store.set("iteration", iteration)
|
|
await ctx.store.set("issues_found", [])
|
|
|
|
if is_complete:
|
|
final_content = " ".join(results) if results else ""
|
|
return ContentEvaluatedEvent(final_content=final_content)
|
|
|
|
return ImagesPreprocessedEvent(
|
|
processed_blocks=await ctx.store.get("image_blocks")
|
|
)
|
|
|
|
@step
|
|
async def evaluate(
|
|
self, ctx: AnyContext, ev: ContentEvaluatedEvent
|
|
) -> ImagesPreprocessedEvent | FinalizeContentEvent:
|
|
image_blocks = await ctx.store.get("image_blocks")
|
|
enable_evaluation: bool = await ctx.store.get("enable_evaluation")
|
|
max_iterations: int = await ctx.store.get("max_iterations")
|
|
iteration: int = await ctx.store.get("iteration", 1)
|
|
kwargs = await ctx.store.get("kwargs")
|
|
|
|
if not enable_evaluation or iteration > max_iterations:
|
|
return FinalizeContentEvent(
|
|
final_content=ev.final_content, evaluation=ev.evaluation
|
|
)
|
|
|
|
final_content = ev.final_content
|
|
evaluation = await self._evaluate_content(
|
|
final_content, image_blocks, seed=iteration, **kwargs
|
|
)
|
|
|
|
if evaluation and (evaluation.score < 0.7 or evaluation.issues_found):
|
|
await ctx.store.set("issues_found", evaluation.issues_found)
|
|
|
|
return ImagesPreprocessedEvent(
|
|
processed_blocks=await ctx.store.get("image_blocks")
|
|
)
|
|
|
|
return FinalizeContentEvent(final_content=final_content, evaluation=evaluation)
|
|
|
|
@step
|
|
async def finalize(
|
|
self, ctx: AnyContext, ev: FinalizeContentEvent
|
|
) -> ImageProcessingResultEvent:
|
|
user_query = await ctx.store.get("user_query")
|
|
final_content = ev.final_content
|
|
evaluation = ev.evaluation
|
|
|
|
response = (
|
|
"\n\n"
|
|
+ self._prompt_builder.create_image_interpretation_response(
|
|
user_query=user_query, content=final_content
|
|
)
|
|
.format()
|
|
.strip()
|
|
+ "\n\n"
|
|
)
|
|
if not response.strip():
|
|
response = IMAGE_NOT_PROCESSABLE
|
|
|
|
return ImageProcessingResultEvent(description=response, evaluation=evaluation)
|
|
|
|
async def _astructured_chat(
|
|
self,
|
|
response_model: type[BaseModel],
|
|
messages: list[ChatMessage],
|
|
**kwargs: Any,
|
|
) -> Any:
|
|
try:
|
|
async with retry_context(
|
|
tries=self._num_max_retries,
|
|
jitter=self._retry_jitter,
|
|
logger=logger,
|
|
) as retry:
|
|
seed = kwargs.pop("seed", None) or 0
|
|
semaphore_manager: SemaphoreManager | None = kwargs.pop(
|
|
"semaphore_manager", None
|
|
)
|
|
count = 0
|
|
max_iterations = kwargs.pop("max_iterations", 3)
|
|
|
|
async def _call() -> Any:
|
|
nonlocal count
|
|
count += 1
|
|
|
|
structured_chat = getattr(self._llm, "astructured_chat", None)
|
|
if not callable(structured_chat):
|
|
raise NotImplementedError(
|
|
"LLM does not support structured chat."
|
|
)
|
|
|
|
new_kwargs = kwargs.copy()
|
|
new_kwargs["seed"] = str(seed) + str(count)
|
|
|
|
try:
|
|
current_messages = messages
|
|
if count > 1:
|
|
current_messages = self._reduce_images_in_messages(
|
|
messages, count - 1
|
|
)
|
|
logger.info(
|
|
f"Retry {count}: Reduced image quality (iteration {count - 1}/{max_iterations})"
|
|
)
|
|
|
|
return await structured_chat(
|
|
response_model, current_messages, **new_kwargs
|
|
)
|
|
except Errors.RequestTooLarge as e:
|
|
logger.warning(
|
|
f"Request too large on attempt {count}, will retry with reduced quality"
|
|
)
|
|
if count >= self._num_max_retries:
|
|
return e
|
|
raise
|
|
|
|
async def _call_with_semaphore() -> Any:
|
|
if semaphore_manager:
|
|
return await semaphore_manager.execute(
|
|
task_func=_call, priority=0
|
|
)
|
|
return await _call()
|
|
|
|
result = await retry(_call_with_semaphore)
|
|
if isinstance(result, Exception):
|
|
raise result
|
|
return result
|
|
except MODEL_NOT_AVAILABLE_EXCEPTION_TYPES as e:
|
|
raise ModelNotAvailableError(
|
|
"Model server is not available or request failed."
|
|
) from e
|
|
|
|
async def _infer_strategy(
|
|
self, image_blocks: list[ImageBlock], **kwargs: Any
|
|
) -> ExtractionStrategy:
|
|
strategy_prompt = self._prompt_builder.create_image_strategy_prompt()
|
|
|
|
messages = [
|
|
ChatMessage(
|
|
role=MessageRole.SYSTEM,
|
|
blocks=[TextBlock(text=strategy_prompt.format())],
|
|
),
|
|
ChatMessage(
|
|
role=MessageRole.USER,
|
|
blocks=[
|
|
TextBlock(text="Analyze the following image:"),
|
|
*image_blocks,
|
|
],
|
|
),
|
|
]
|
|
|
|
strategy: ExtractionStrategy | FlexibleModel = await self._astructured_chat(
|
|
ExtractionStrategy, messages, **kwargs
|
|
)
|
|
if not isinstance(strategy, ExtractionStrategy):
|
|
strategy = ExtractionStrategy(**strategy.model_dump())
|
|
|
|
return strategy
|
|
|
|
def _preprocess_image(
|
|
self, image_block: ImageBlock, strategy: ExtractionStrategy
|
|
) -> ImageBlock:
|
|
if not strategy.increase_contrast:
|
|
return image_block
|
|
|
|
try:
|
|
import cv2 # ty:ignore[unresolved-import]
|
|
import numpy as np
|
|
|
|
image_b = image_block.resolve_image()
|
|
image_b.seek(0)
|
|
image_bytes = image_b.read()
|
|
|
|
nparr = np.frombuffer(image_bytes, np.uint8)
|
|
image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
|
|
|
if image is None:
|
|
return image_block
|
|
|
|
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
|
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
|
|
enhanced = clahe.apply(gray)
|
|
denoised = cv2.fastNlMeansDenoising(enhanced)
|
|
|
|
if strategy.language.lower() in ["jp", "cn", "zh", "kr"]:
|
|
thresh = cv2.adaptiveThreshold(
|
|
denoised,
|
|
255,
|
|
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
|
cv2.THRESH_BINARY,
|
|
11,
|
|
2,
|
|
)
|
|
else:
|
|
_, thresh = cv2.threshold(
|
|
denoised, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU
|
|
)
|
|
|
|
_, buffer = cv2.imencode(".png", thresh)
|
|
return ImageBlock(image=buffer.tobytes(), image_mimetype="image/png")
|
|
|
|
except ImportError:
|
|
logger.warning(
|
|
format_missing_dependency_message(
|
|
"Image preprocessing",
|
|
extras="media",
|
|
)
|
|
)
|
|
return image_block
|
|
except Exception as e:
|
|
logger.error(f"Image preprocessing failed: {e}")
|
|
return image_block
|
|
|
|
def _reduce_images_in_messages(
|
|
self,
|
|
messages: list[ChatMessage],
|
|
iteration: int,
|
|
) -> list[ChatMessage]:
|
|
reduced_messages = []
|
|
for message in messages:
|
|
reduced_blocks: list[ContentBlock] = []
|
|
for block in message.blocks:
|
|
if isinstance(block, ImageBlock):
|
|
reduced_block = self._reduce_image_quality(block, iteration)
|
|
reduced_blocks.append(reduced_block)
|
|
else:
|
|
reduced_blocks.append(block)
|
|
|
|
reduced_message = ChatMessage(
|
|
role=message.role,
|
|
blocks=reduced_blocks,
|
|
additional_kwargs=message.additional_kwargs,
|
|
)
|
|
reduced_messages.append(reduced_message)
|
|
|
|
return reduced_messages
|
|
|
|
def _reduce_image_quality(
|
|
self,
|
|
image_block: ImageBlock,
|
|
iteration: int,
|
|
) -> ImageBlock:
|
|
try:
|
|
import base64
|
|
from io import BytesIO
|
|
|
|
from PIL import Image
|
|
|
|
if TYPE_CHECKING:
|
|
from PIL.Image import Image as PILImage
|
|
|
|
bytesio = image_block.resolve_image(as_base64=True)
|
|
image_data = base64.b64decode(bytesio.read().decode("utf-8"))
|
|
|
|
image: PILImage = Image.open(BytesIO(image_data))
|
|
if image.mode in ("RGBA", "LA", "P"):
|
|
image = image.convert("RGB")
|
|
|
|
reduction_per_iteration = 0.15
|
|
scale_factor = 1.0 - (iteration * reduction_per_iteration)
|
|
scale_factor = max(0.6, scale_factor) # Never go below 60% of original
|
|
|
|
# Quality reduction: start at 100, reduce by 15 points per iteration
|
|
quality = max(60, int(100 - (iteration * 15)))
|
|
|
|
new_width = int(image.width * scale_factor)
|
|
new_height = int(image.height * scale_factor)
|
|
|
|
if new_width < image.width or new_height < image.height:
|
|
image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
|
|
|
output = BytesIO()
|
|
image.save(output, format="JPEG", quality=quality, optimize=True)
|
|
output.seek(0)
|
|
|
|
return ImageBlock(image=output.getvalue(), image_mimetype="image/jpeg")
|
|
|
|
except ImportError:
|
|
logger.warning(
|
|
"%s Skipping image quality reduction.",
|
|
format_missing_dependency_message(
|
|
"Image quality reduction",
|
|
extras="media",
|
|
),
|
|
)
|
|
return image_block
|
|
except Exception as e:
|
|
logger.error(f"Image quality reduction failed: {e}")
|
|
return image_block
|
|
|
|
async def _evaluate_content(
|
|
self, content: str, image_blocks: ImageBlock, **kwargs: Any
|
|
) -> ExtractionEvaluation | None:
|
|
if not content:
|
|
return None
|
|
|
|
eval_template = self._prompt_builder.create_image_evaluation_prompt()
|
|
messages = [
|
|
ChatMessage(
|
|
role=MessageRole.SYSTEM, blocks=[TextBlock(text=eval_template.format())]
|
|
),
|
|
ChatMessage(
|
|
role=MessageRole.USER,
|
|
blocks=[
|
|
TextBlock(
|
|
text=content,
|
|
),
|
|
*image_blocks,
|
|
],
|
|
),
|
|
]
|
|
|
|
if not hasattr(self._llm, "astructured_chat"):
|
|
raise NotImplementedError("LLM does not support structured chat.")
|
|
|
|
result: ExtractionEvaluation | FlexibleModel = await self._astructured_chat(
|
|
ExtractionEvaluation, messages, **kwargs
|
|
)
|
|
if not isinstance(result, ExtractionEvaluation):
|
|
result = ExtractionEvaluation(**result.model_dump())
|
|
|
|
return result
|
|
|
|
|
|
async def describe_image(
|
|
image_multimodal_llm: LLM,
|
|
image_blocks: list[ImageBlock] | None = None,
|
|
user_query: str | None = None,
|
|
max_iterations: int = 2,
|
|
enable_preprocessing: bool = True,
|
|
enable_evaluation: bool = True,
|
|
skip_strategy_inference: bool = False,
|
|
**kwargs: Any,
|
|
) -> str | None:
|
|
if not image_blocks:
|
|
return None
|
|
|
|
workflow = ImageProcessingWorkflow(image_multimodal_llm, **kwargs)
|
|
handler: WorkflowHandler | None = None
|
|
|
|
try:
|
|
result = cast(
|
|
ImageProcessingResultEvent,
|
|
await workflow.run(
|
|
image_blocks=image_blocks,
|
|
user_query=user_query,
|
|
max_iterations=max_iterations,
|
|
enable_preprocessing=enable_preprocessing,
|
|
enable_evaluation=enable_evaluation,
|
|
kwargs=kwargs,
|
|
),
|
|
)
|
|
|
|
return result.description if result else None
|
|
except asyncio.CancelledError as e:
|
|
if handler:
|
|
await handler.cancel_run()
|
|
raise e
|
|
|
|
|
|
async def process_images_in_message(
|
|
image_multimodal_llm: LLM,
|
|
message: ChatMessage,
|
|
user_query: str | None = None,
|
|
enable_preprocessing: bool = True,
|
|
enable_evaluation: bool = True,
|
|
**kwargs: Any,
|
|
) -> str | None:
|
|
image_blocks = [block for block in message.blocks if isinstance(block, ImageBlock)]
|
|
if not image_blocks:
|
|
return message.content or ""
|
|
|
|
return await describe_image(
|
|
image_multimodal_llm,
|
|
image_blocks,
|
|
user_query,
|
|
enable_preprocessing=enable_preprocessing,
|
|
enable_evaluation=enable_evaluation,
|
|
**kwargs,
|
|
)
|