codebook / potato /server_utils /schemas /multirate.py
davidjurgens's picture
Deploy: Potato — Codebook Annotation
aceb1b2 verified
Raw
History Blame Contribute Delete
17.9 kB
"""
Multirate Layout
Generates a matrix-style interface for rating multiple items on the same scale.
Features include:
- Multiple column layout support
- Configurable rating options
- Vertical/horizontal arrangement options
- Tooltip support
- Required/optional validation
"""
import logging
import os
from collections.abc import Mapping
from jinja2 import Template
from .identifier_utils import (
safe_generate_layout,
generate_element_identifier,
generate_validation_attribute,
escape_html_content,
generate_layout_attributes
)
logger = logging.getLogger(__name__)
# HTML template using Jinja2 with comprehensive styling that preserves horizontal layout
MULTIRATE_TEMPLATE = """
<form id="{{ schema_name }}" class="annotation-form multirate shadcn-multirate-container" action="javascript:void(0)" data-annotation-id="{{ annotation_id }}" {{ layout_attrs }}>
<fieldset schema="{{ schema_name }}">
<legend class="shadcn-multirate-title">{{ description }}</legend>
<table class="shadcn-multirate-table">
<thead>
<tr>
{% for col in range(num_headers) %}
<th>&nbsp;</th>
{% for rating in ratings %}
<th>{{ rating }}</th>
{% endfor %}
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr schema="multirate">
{% for item in row %}
{% if item %}
<td {{ item.tooltip|safe }}>{{ item.label }}</td>
{% for rating in ratings %}
<td class="shadcn-radio-cell">
<input name="{{ item.name }}"
type="radio"
id="{{ item.id }}.{{ rating }}"
value="{{ rating }}"
onclick="this.blur();"
validation="{{ validation }}"
class="shadcn-multirate-radio annotation-input"
schema="{{ schema_name }}"
label_name="{{ item.label_name }}"
aria-label="{{ item.label }}: {{ rating }}" />
</td>
{% endfor %}
{% else %}
<td></td>
{% for rating in ratings %}
<td></td>
{% endfor %}
{% endif %}
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</fieldset>
</form>
"""
def generate_multirate_layout(annotation_scheme):
"""
Generate HTML for a multi-item rating interface.
Args:
annotation_scheme (dict): Configuration including:
- name: Schema identifier
- description: Display description
- options: List of items to be rated
- labels: List of rating options to choose from
- display_config (dict): Optional display settings
- arrangement (str): Layout direction ('vertical' or 'horizontal')
- label_requirement (dict): Optional validation settings
Returns:
tuple: (html_string, key_bindings)
html_string: Complete HTML for the multirate interface
key_bindings: List of (key, description) tuples for keyboard shortcuts
"""
return safe_generate_layout(annotation_scheme, _generate_multirate_layout_internal)
def _generate_multirate_layout_internal(annotation_scheme):
"""
Internal function to generate multirate layout after validation.
"""
logger.debug(f"Generating multirate layout for schema: {annotation_scheme['name']}")
# Check for options_from_data (dynamic multirate)
options_from_data = annotation_scheme.get('options_from_data')
if options_from_data and 'options' not in annotation_scheme:
return _generate_dynamic_multirate(annotation_scheme, options_from_data)
# Extract configuration
schema_name = annotation_scheme['name']
description = annotation_scheme['description']
options = annotation_scheme['options']
ratings = annotation_scheme['labels']
# Get display configuration
display_config = annotation_scheme.get('display_config', {})
num_columns = display_config.get('num_columns', 1)
# Set validation
validation = generate_validation_attribute(annotation_scheme)
# Get layout attributes for grid positioning
layout_attrs = generate_layout_attributes(annotation_scheme)
# Preprocess items for template
processed_items = []
for option in options:
if isinstance(option, str):
identifiers = generate_element_identifier(schema_name, option, "multirate")
processed_items.append({
'label': escape_html_content(option),
'name': identifiers['name'],
'id': identifiers['id'],
'label_name': identifiers['label_name'],
'tooltip': ""
})
else:
identifiers = generate_element_identifier(schema_name, option['name'], "multirate")
processed_items.append({
'label': escape_html_content(option['label']),
'name': identifiers['name'],
'id': identifiers['id'],
'label_name': identifiers['label_name'],
'tooltip': _generate_tooltip(option)
})
# Arrange items according to specified layout
if annotation_scheme.get('arrangement') == 'vertical':
arranged_items = _arrange_items_vertically(processed_items, num_columns)
else:
arranged_items = _arrange_items_horizontally(processed_items, num_columns)
# Format template data
template_data = {
'schema_name': escape_html_content(schema_name),
'description': escape_html_content(description),
'ratings': [escape_html_content(rating) for rating in ratings],
'num_headers': min(len(options), num_columns),
'rows': arranged_items,
'validation': validation,
'annotation_id': annotation_scheme.get('annotation_id', ''),
'layout_attrs': layout_attrs
}
# Render template
template = Template(MULTIRATE_TEMPLATE)
html = template.render(**template_data)
logger.info(f"Successfully generated multirate layout for {schema_name} "
f"with {len(options)} items and {len(ratings)} rating options")
return html, [] # No key bindings implemented
def _arrange_items_horizontally(items, num_columns):
"""
Arrange items in a horizontal layout with specified number of columns.
Args:
items (list): List of processed item dictionaries
num_columns (int): Number of columns
Returns:
list: List of rows, where each row is a list of items
"""
rows = []
for i in range(0, len(items), num_columns):
row = items[i:i+num_columns]
# Pad the row if it's not full
while len(row) < num_columns:
row.append(None)
rows.append(row)
return rows
def _arrange_items_vertically(items, num_columns):
"""
Rearrange items for vertical column layout.
Args:
items (list): List of processed item dictionaries
num_columns (int): Number of columns
Returns:
list: List of rows, where each row is a list of items arranged vertically
"""
logger.debug(f"Rearranging {len(items)} items into {num_columns} vertical columns")
# Calculate rows needed
num_rows = (len(items) + num_columns - 1) // num_columns # Ceiling division
# Distribute items into columns
columns = [[] for _ in range(num_columns)]
for i, item in enumerate(items):
col_idx = i // num_rows
if col_idx < num_columns:
columns[col_idx].append(item)
# Create rows from columns
rows = []
for row_idx in range(num_rows):
row = []
for col in columns:
row.append(col[row_idx] if row_idx < len(col) else None)
rows.append(row)
return rows
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 ""
# JavaScript template for dynamic multirate (options_from_data)
DYNAMIC_MULTIRATE_JS = """
<script>
(function() {
var container = document.getElementById('dynamic-multirate-{{ schema_name }}');
if (!container) return;
var dataKey = container.getAttribute('data-options-from-data');
var ratings = JSON.parse(container.getAttribute('data-ratings'));
var schemaName = {{ schema_name_js }};
var validation = {{ validation_js }};
// Try to find instance data - look in various places
function getInstanceData() {
// Check for display field data attributes
var fields = document.querySelectorAll('[data-field-key="' + dataKey + '"]');
if (fields.length > 0) {
var text = fields[0].textContent;
try { return JSON.parse(text); } catch(e) {}
}
// Check for raw instance data in script tag
var instanceScript = document.getElementById('instance');
if (instanceScript) {
try {
var data = JSON.parse(instanceScript.textContent);
if (data.text && typeof data.text === 'object') return data.text[dataKey];
} catch(e) {}
}
return null;
}
function escapeHtml(str) {
var div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
function escapeAttr(str) {
return str.replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/'/g,'&#39;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function buildTable(options) {
if (!options || !Array.isArray(options) || options.length === 0) {
container.innerHTML = '<p style="color:#666;font-style:italic">No dynamic options available for this instance.</p>';
return;
}
var html = '<table class="shadcn-multirate-table"><thead><tr><th>&nbsp;</th>';
for (var r = 0; r < ratings.length; r++) {
html += '<th>' + escapeHtml(ratings[r]) + '</th>';
}
html += '</tr></thead><tbody>';
for (var i = 0; i < options.length; i++) {
var label = typeof options[i] === 'string' ? options[i] : (options[i].label || options[i].name || String(i));
var safeName = schemaName + ':::' + label.replace(/[^a-zA-Z0-9_]/g, '_');
html += '<tr schema="multirate"><td>' + escapeHtml(label) + '</td>';
for (var r = 0; r < ratings.length; r++) {
html += '<td class="shadcn-radio-cell"><input name="' + escapeAttr(safeName) +
'" type="radio" id="' + escapeAttr(safeName) + '.' + escapeAttr(ratings[r]) +
'" value="' + escapeAttr(ratings[r]) +
'" onclick="this.blur();" validation="' + escapeAttr(validation) +
'" class="shadcn-multirate-radio annotation-input" schema="' +
escapeAttr(schemaName) + '" label_name="' + escapeAttr(label) +
'" aria-label="' + escapeAttr(label + ': ' + ratings[r]) + '" /></td>';
}
html += '</tr>';
}
html += '</tbody></table>';
container.querySelector('.dynamic-multirate-body').innerHTML = html;
}
// Build table from data attribute (server-injected)
var preloadedData = container.getAttribute('data-options-values');
if (preloadedData) {
try { buildTable(JSON.parse(preloadedData)); } catch(e) { console.error('Dynamic multirate parse error:', e); }
} else {
var data = getInstanceData();
if (data) buildTable(data);
}
})();
</script>
"""
DYNAMIC_MULTIRATE_TEMPLATE = """
<form id="{{ schema_name }}" class="annotation-form multirate shadcn-multirate-container"
action="javascript:void(0)" data-annotation-id="{{ annotation_id }}" {{ layout_attrs }}>
<fieldset schema="{{ schema_name }}">
<legend class="shadcn-multirate-title">{{ description }}</legend>
<div id="dynamic-multirate-{{ schema_name }}"
class="dynamic-multirate-container"
data-options-from-data="{{ data_key }}"
data-ratings='{{ ratings_json }}'
data-options-values='{{ options_values_json }}'>
<div class="dynamic-multirate-body">
<p style="color:#666;font-style:italic">Loading options from instance data...</p>
</div>
</div>
</fieldset>
</form>
"""
def _generate_dynamic_multirate(annotation_scheme, options_from_data):
"""
Generate a dynamic multirate layout that reads options from instance data.
The generated HTML includes a JavaScript snippet that populates the multirate
table at page load time using data from the instance's specified field.
Args:
annotation_scheme: Schema configuration dict
options_from_data: Name of the instance data field containing options
Returns:
tuple: (html_string, key_bindings)
"""
import json
import html as html_module
schema_name = annotation_scheme['name']
description = annotation_scheme.get('description', '')
ratings = annotation_scheme.get('labels', [])
validation = generate_validation_attribute(annotation_scheme)
layout_attrs = generate_layout_attributes(annotation_scheme)
ratings_json = json.dumps(ratings)
template_data = {
'schema_name': escape_html_content(schema_name),
'description': escape_html_content(description),
'data_key': escape_html_content(options_from_data),
'ratings_json': html_module.escape(ratings_json),
'options_values_json': '[]', # Will be filled by server at render time
'annotation_id': annotation_scheme.get('annotation_id', ''),
'layout_attrs': layout_attrs,
'validation': validation,
# JS-safe values using json.dumps to produce quoted strings
'schema_name_js': json.dumps(schema_name),
'validation_js': json.dumps(validation),
}
template = Template(DYNAMIC_MULTIRATE_TEMPLATE + DYNAMIC_MULTIRATE_JS)
html = template.render(**template_data)
logger.info(f"Generated dynamic multirate for {schema_name} "
f"reading options from '{options_from_data}'")
return html, []
def populate_dynamic_multirate(html_str, instance_data):
"""
Post-process rendered HTML to inject instance-specific options into
dynamic multirate schemas.
Called from render_page_with_annotations() after template rendering.
Args:
html_str: The rendered HTML string
instance_data: The instance data dictionary
Returns:
Modified HTML string with dynamic multirate options populated
"""
import json
import re
import html as html_module
# Find all dynamic multirate containers
pattern = r'data-options-from-data="([^"]+)"'
matches = list(re.finditer(pattern, html_str))
# Process in reverse order so earlier match offsets remain valid
for match in reversed(matches):
data_key = html_module.unescape(match.group(1))
options = instance_data.get(data_key, [])
if options and isinstance(options, list):
# Use html.escape on the JSON to safely embed in a single-quoted attribute
options_attr = html_module.escape(json.dumps(options))
# Search for the placeholder attribute near this match
search_start = max(0, match.start() - 200)
search_end = min(len(html_str), match.end() + 500)
local_html = html_str[search_start:search_end]
# Handle both single-quoted and double-quoted attributes
# (BeautifulSoup may convert single quotes to double quotes)
old_attr_single = "data-options-values='[]'"
old_attr_double = 'data-options-values="[]"'
if old_attr_single in local_html:
html_str = html_str[:search_start] + local_html.replace(
old_attr_single,
f"data-options-values='{options_attr}'",
1,
) + html_str[search_end:]
elif old_attr_double in local_html:
html_str = html_str[:search_start] + local_html.replace(
old_attr_double,
f'data-options-values="{options_attr}"',
1,
) + html_str[search_end:]
return html_str