""" 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)