codebook / potato /server_utils /schemas /multiselect.py
davidjurgens's picture
Deploy: Potato — Codebook Annotation
aceb1b2 verified
Raw
History Blame Contribute Delete
11.3 kB
"""
Multiselect Layout
Generates a form interface that allows users to select multiple options from a list
of choices. Features include:
- Multiple column layout support
- Keyboard shortcuts
- Required/optional validation
- Individual label requirements
- Tooltip support
- Video label support
- Free response option
"""
import logging
from collections.abc import Mapping
from potato.ai.ai_help_wrapper import get_ai_wrapper, get_dynamic_ai_help
from .identifier_utils import (
safe_generate_layout,
generate_element_identifier,
generate_validation_attribute,
escape_html_content,
generate_layout_attributes,
display_label_text,
)
logger = logging.getLogger(__name__)
def generate_multiselect_layout(annotation_scheme):
"""
Generate HTML for a multiple-choice selection interface.
Args:
annotation_scheme (dict): Configuration including:
- name: Schema identifier
- description: Display description
- labels: List of label configurations, each either:
- str: Simple label text
- dict: Complex label with:
- name: Label identifier
- tooltip: Hover text description
- tooltip_file: Path to file containing tooltip text
- key_value: Keyboard shortcut key
- videopath: Path to video file (if video_as_label=True)
- display_config (dict): Optional display settings
- num_columns: Number of columns to arrange options (default: 1)
- label_requirement (dict): Optional validation settings
- required (bool): Whether any selection is mandatory
- required_label (str|list): Specific labels that must be selected
- sequential_key_binding (bool): Enable numeric key shortcuts
- video_as_label (bool): Use videos instead of text for labels
- has_free_response (dict): Optional free text input configuration
- instruction: Label for free response field
Returns:
tuple: (html_string, key_bindings)
html_string: Complete HTML for the multiselect interface
key_bindings: List of (key, description) tuples for keyboard shortcuts
"""
return safe_generate_layout(annotation_scheme, _generate_multiselect_layout_internal)
def _generate_multiselect_layout_internal(annotation_scheme):
"""
Internal function to generate multiselect layout after validation.
"""
logger.debug(f"Generating multiselect layout for schema: {annotation_scheme['name']}")
# Get layout attributes for grid positioning
layout_attrs = generate_layout_attributes(annotation_scheme)
# Initialize form wrapper
schematic = f"""
<form id="{escape_html_content(annotation_scheme['name'])}" class="annotation-form multiselect shadcn-multiselect-container" action="javascript:void(0)" data-annotation-id="{escape_html_content(str(annotation_scheme.get("annotation_id", "")))}" data-annotation-type="multiselect" data-schema-name="{escape_html_content(annotation_scheme['name'])}" {layout_attrs}>
{get_ai_wrapper()}
<fieldset schema="{escape_html_content(annotation_scheme['name'])}">
<legend class="shadcn-multiselect-title">{escape_html_content(annotation_scheme['description'])}</legend>
"""
# Initialize keyboard shortcut mappings
key2label = {}
label2key = {}
key_bindings = []
# Get display configuration
display_info = annotation_scheme.get("display_config", {})
n_columns = display_info.get("num_columns", 1)
logger.debug(f"Using {n_columns} column layout")
# Add grid with appropriate columns
schematic += f'<div class="shadcn-multiselect-grid" style="grid-template-columns: repeat({n_columns}, 1fr);">'
# Check for pre-allocated keys from the centralized allocator
allocated_keys = annotation_scheme.get("_allocated_keys", None)
allocated_map = {}
if allocated_keys:
for entry in allocated_keys:
if entry.get("key"):
allocated_map[entry["label"]] = entry["key"]
# Generate checkbox inputs for each label
for i, label_data in enumerate(annotation_scheme["labels"], 1):
# Extract label information
label = label_data if isinstance(label_data, str) else label_data["name"]
# Generate consistent identifiers
identifiers = generate_element_identifier(annotation_scheme["name"], label, "checkbox")
validation = generate_validation_attribute(annotation_scheme, label)
# The value attribute stores the label name for annotation purposes
label_value = label
# Handle tooltips
tooltip = ""
if isinstance(label_data, Mapping):
tooltip = _generate_tooltip(label_data)
# Handle explicit per-label keyboard shortcuts
if "key_value" in label_data:
shortcut_key = str(label_data["key_value"])
if shortcut_key in key2label:
logger.warning(f"Keyboard input conflict: {shortcut_key}")
continue
key2label[shortcut_key] = label
label2key[label] = shortcut_key
key_bindings.append((shortcut_key, f"{identifiers['schema']}: {label}"))
logger.debug(f"Added key binding '{shortcut_key}' for label '{label}'")
# Use pre-allocated keys if available
if label in allocated_map and label not in label2key:
shortcut_key = allocated_map[label]
key2label[shortcut_key] = label
label2key[label] = shortcut_key
key_bindings.append((shortcut_key, f"{identifiers['schema']}: {label}"))
logger.debug(f"Added allocated key binding '{shortcut_key}' for label '{label}'")
elif not allocated_keys:
# Fallback: sequential key bindings when no allocator was used
if (annotation_scheme.get("sequential_key_binding")
and len(annotation_scheme["labels"]) <= 10
and label not in label2key):
shortcut_key = str(i % 10)
key2label[shortcut_key] = label
label2key[label] = shortcut_key
key_bindings.append((shortcut_key, f"{identifiers['schema']}: {label}"))
logger.debug(f"Added sequential key binding '{shortcut_key}' for label '{label}'")
# Format label content
label_content = _format_label_content(label_data, annotation_scheme)
# Display keyboard shortcut if available
key_display = f'<span class="keybinding-badge shadcn-multiselect-key">{label2key[label].upper()}</span>' if label in label2key else ''
# Build data-key attribute for JS keybinding matching
data_key_attr = f' data-key="{label2key[label]}"' if label in label2key else ''
# Generate checkbox input
schematic += f"""
<div class="shadcn-multiselect-item">
<input class="{identifiers['schema']} shadcn-multiselect-checkbox annotation-input"
type="checkbox"
id="{identifiers['id']}"
name="{identifiers['name']}"
value="{escape_html_content(label_value)}"
label_name="{identifiers['label_name']}"
schema="{identifiers['schema']}"
onclick="whetherNone(this);registerAnnotation(this)"
validation="{validation}"{data_key_attr}>
<label for="{identifiers['id']}" {tooltip} schema="{identifiers['schema']}" class="shadcn-multiselect-label">
{label_content} {key_display}
</label>
</div>
"""
schematic += "</div>"
# Add optional free response field
if annotation_scheme.get("has_free_response"):
schematic += _generate_free_response(annotation_scheme, n_columns)
schematic += "</fieldset></form>"
logger.info(f"Successfully generated multiselect layout for {annotation_scheme['name']} "
f"with {len(annotation_scheme['labels'])} options")
return schematic, key_bindings
def _generate_tooltip(label_data):
"""
Generate tooltip HTML attribute from label data.
Args:
label_data (dict): Label configuration containing tooltip information
Returns:
str: Tooltip HTML attribute or empty string if no tooltip
"""
tooltip_text = ""
if "tooltip" in label_data:
tooltip_text = label_data["tooltip"]
elif "tooltip_file" in label_data:
try:
with open(label_data["tooltip_file"], "rt", encoding="utf-8") as f:
tooltip_text = "".join(f.readlines())
except Exception as e:
logger.error(f"Failed to read tooltip file: {e}")
return ""
if tooltip_text:
escaped_tooltip = escape_html_content(tooltip_text)
return f'data-toggle="tooltip" data-html="true" data-placement="top" title="{escaped_tooltip}"'
return ""
def _format_label_content(label_data, annotation_scheme):
"""
Format the label content, handling both text and video labels.
Args:
label_data: Label configuration
annotation_scheme: Full annotation scheme configuration
Returns:
str: Formatted label content (text or video HTML)
"""
if annotation_scheme.get("video_as_label") and isinstance(label_data, dict) and "videopath" in label_data:
# Video label
video_path = label_data["videopath"]
return f'<video src="{escape_html_content(video_path)}" controls style="max-width: 200px; max-height: 150px;"></video>'
else:
# Text label -- visible text is humanized (or explicit
# displayed_label); stored value remains the raw label name.
return escape_html_content(
display_label_text(label_data, annotation_scheme)
)
def _generate_free_response(annotation_scheme, n_columns):
"""
Generate free response field for multiselect.
Args:
annotation_scheme: Schema configuration
n_columns: Number of columns in the grid
Returns:
str: HTML for free response field
"""
free_response_identifiers = generate_element_identifier(annotation_scheme["name"], "free_response", "text")
free_response_config = annotation_scheme["has_free_response"]
instruction = free_response_config.get("instruction", "Other (please specify)") if isinstance(free_response_config, dict) else "Other (please specify)"
return f"""
<div class="shadcn-multiselect-free-response" style="grid-column: 1 / -1;">
<span class="shadcn-multiselect-label">{escape_html_content(instruction)}</span>
<input class="{free_response_identifiers['schema']} shadcn-multiselect-free-input annotation-input"
type="text"
id="{free_response_identifiers['id']}"
name="{free_response_identifiers['name']}"
schema="{free_response_identifiers['schema']}"
label_name="{free_response_identifiers['label_name']}">
</div>
"""