"""
Base Display Class
Provides a base class for custom display types that can be registered
as plugins. Third-party developers can extend this class to create
custom content renderers.
Usage:
from potato.server_utils.displays.base import BaseDisplay
class MyCustomDisplay(BaseDisplay):
name = "my_custom"
required_fields = ["key"]
optional_fields = {"my_option": "default_value"}
def render(self, field_config, data):
return f'
{data}
'
Span Target Contract:
If a display declares ``supports_span_target = True``, its ``render()``
output MUST contain a ``.text-content`` wrapper when the field has
``span_target: true``. Use the ``render_span_wrapper()`` helper:
if field_config.get("span_target"):
inner_html = self.render_span_wrapper(field_key, inner_html, plain_text)
SpanManager (span-core.js) discovers span-target fields via:
document.querySelectorAll('.display-field[data-span-target="true"]')
then looks inside each for:
field.querySelector('.text-content')
If ``.text-content`` is missing, span annotation silently fails.
See displays/ARCHITECTURE.md for the full contract.
"""
import html as html_module
from abc import ABC, abstractmethod
from typing import Dict, Any, List, Optional
class BaseDisplay(ABC):
"""
Abstract base class for display type implementations.
Subclasses must implement the `render` method and define
class attributes for registration.
Class Attributes:
name: Unique identifier for this display type (e.g., "text", "image")
required_fields: List of required configuration field names
optional_fields: Dictionary of optional fields with their default values
description: Human-readable description of this display type
supports_span_target: Whether this type can be a span annotation target.
If True, render() MUST produce a .text-content wrapper when
field_config["span_target"] is True. Use render_span_wrapper().
lazy_populated: Whether this display's data field is populated after
initial page load (e.g. ``interactive_chat`` writes its
conversation only when the user finishes chatting with the
agent_proxy). Set True to tell the ``instance_display`` validator
that a missing data key for this field is an expected transient
state, not a configuration error.
"""
name: str = ""
required_fields: List[str] = []
optional_fields: Dict[str, Any] = {}
description: str = ""
supports_span_target: bool = False
lazy_populated: bool = False
@abstractmethod
def render(self, field_config: Dict[str, Any], data: Any) -> str:
"""
Render the content as HTML.
Args:
field_config: The field configuration from instance_display.fields
data: The actual data value from the instance
Returns:
HTML string for rendering the content
"""
pass
def render_span_wrapper(self, field_key: str, inner_html: str, plain_text: str) -> str:
"""
Wrap content in the standard .text-content div required by SpanManager.
Call this from render() when field_config.get("span_target") is True.
This ensures the HTML output satisfies the span annotation contract:
- ``class="text-content"``
- ``id="text-content-{field_key}"``
- ``data-original-text="{escaped plain text}"``
- ``padding-top: 24px`` (via style) for span label positioning
Args:
field_key: The field key (e.g., "conversation", "premise")
inner_html: The HTML content to wrap
plain_text: The plain text for offset-based span positioning.
Must match the text extraction format in routes.py for this
data type, or span offsets will misalign on reload.
Returns:
HTML string with the .text-content wrapper
"""
escaped_text = html_module.escape(plain_text, quote=True)
return (
f''
f'{inner_html}'
f'
'
)
def has_inline_label(self, field_config: Dict[str, Any]) -> bool:
"""
Check if this display handles its own label rendering.
If True, the registry will NOT add a label wrapper around the
display container (avoiding duplicate labels).
Override in subclasses where the display renders its own label
(e.g., collapsible text with label in ).
Args:
field_config: The field configuration
Returns:
True if the display renders its own label
"""
return False
def get_css_classes(self, field_config: Dict[str, Any]) -> List[str]:
"""
Get CSS classes to apply to the display container.
Override in subclasses to add custom classes.
Args:
field_config: The field configuration
Returns:
List of CSS class names
"""
return [f"display-field", f"display-type-{self.name}"]
def get_data_attributes(self, field_config: Dict[str, Any], data: Any) -> Dict[str, str]:
"""
Get data attributes to add to the display container.
These are used for JavaScript interactions and linking
annotation schemas to display fields.
Args:
field_config: The field configuration
data: The actual data value
Returns:
Dictionary of data attribute names (without 'data-' prefix) to values
"""
attrs = {
"field-key": field_config.get("key", ""),
"field-type": self.name,
}
if field_config.get("span_target"):
attrs["span-target"] = "true"
return attrs
def get_js_init(self) -> Optional[str]:
"""
Get JavaScript initialization code for this display type.
Override in subclasses that need client-side initialization.
Returns:
JavaScript code string or None if not needed
"""
return None
def validate_config(self, field_config: Dict[str, Any]) -> List[str]:
"""
Validate the field configuration.
Args:
field_config: The field configuration to validate
Returns:
List of error messages (empty if valid)
"""
errors = []
# Check required fields
for field in self.required_fields:
if field not in field_config:
errors.append(f"Missing required field '{field}' for display type '{self.name}'")
# Warn if span_target is set but display doesn't support it
if field_config.get("span_target") and not self.supports_span_target:
errors.append(
f"Display type '{self.name}' does not support span_target. "
f"Span annotation will not work on this field."
)
return errors
def get_display_options(self, field_config: Dict[str, Any]) -> Dict[str, Any]:
"""
Get display options with defaults applied.
Args:
field_config: The field configuration
Returns:
Dictionary of display options with defaults filled in
"""
options = field_config.get("display_options", {})
result = dict(self.optional_fields) # Start with defaults
result.update(options) # Override with user-specified options
return result
def concatenate_dialogue_text(data: Any, speaker_key: str = "speaker", text_key: str = "text") -> str:
"""
Concatenate dialogue data into a single plain text string.
Format: "Speaker: text\\nSpeaker: text\\n..."
Turns without a speaker omit the "Speaker: " prefix.
Note: For span offset matching with dialogue displays, use
``reconstruct_dialogue_dom_text()`` instead — it accounts for
turn numbers and DOM whitespace normalization.
Args:
data: Dialogue data — list of dicts, list of strings, or a string
speaker_key: Key for speaker in dict format (default "speaker")
text_key: Key for text in dict format (default "text")
Returns:
Concatenated plain text string
"""
if isinstance(data, str):
return data
if not isinstance(data, list):
return str(data)
parts = []
for item in data:
if isinstance(item, dict):
speaker = item.get(speaker_key, '')
text = item.get(text_key, '')
parts.append(f"{speaker}: {text}" if speaker else text)
else:
parts.append(str(item))
return "\n".join(parts)
def reconstruct_dialogue_dom_text(
data: Any,
speaker_key: str = "speaker",
text_key: str = "text",
show_turn_numbers: bool = False,
) -> str:
"""
Reconstruct the whitespace-normalized DOM textContent of a dialogue display.
When DialogueDisplay renders HTML, the browser's ``textContent`` includes
turn numbers, speaker prefixes, and the text of each turn — all separated
by whitespace that ``normalizeText()`` collapses to single spaces.
This function reproduces that collapsed form so that span offsets produced
by the client (DOM-based) can be used server-side to extract the correct
substring.
Args:
data: Dialogue data — list of dicts, list of strings, or a string
speaker_key: Key for speaker in dict format
text_key: Key for text in dict format
show_turn_numbers: Whether turn numbers like ``[1]`` are shown
Returns:
Single-line text matching the browser's normalized textContent
"""
if isinstance(data, str):
return data.strip()
if not isinstance(data, list):
return str(data).strip()
parts = []
for i, item in enumerate(data):
turn_parts = []
if show_turn_numbers:
turn_parts.append(f"[{i + 1}]")
if isinstance(item, dict):
speaker = item.get(speaker_key, "")
text = item.get(text_key, "")
if speaker:
turn_parts.append(f"{speaker}:")
turn_parts.append(str(text))
else:
turn_parts.append(str(item))
parts.append(" ".join(turn_parts))
# Join turns with single space (browser normalizes inter-turn whitespace)
import re as _re
joined = " ".join(parts)
# Final normalization: collapse any remaining multi-space to single space
return _re.sub(r"\s+", " ", joined).strip()
def render_display_container(
inner_html: str,
css_classes: List[str],
data_attrs: Dict[str, str],
label: Optional[str] = None
) -> str:
"""
Render a display container with the given content.
This helper function wraps content in a standard display container
with proper classes and data attributes.
Args:
inner_html: The inner HTML content
css_classes: CSS classes to apply
data_attrs: Data attributes to add
label: Optional label/header for the display
Returns:
Complete HTML for the display container
"""
class_str = " ".join(css_classes)
attr_str = " ".join(f'data-{k}="{v}"' for k, v in data_attrs.items())
parts = []
parts.append(f'')
if label:
parts.append(f'
{label}
')
parts.append(f'
')
parts.append(f' {inner_html}')
parts.append(f'
')
parts.append(f'
')
return "\n".join(parts)