codebook / potato /server_utils /displays /dialogue_display.py
davidjurgens's picture
Deploy: Potato — Codebook Annotation
aceb1b2 verified
Raw
History Blame Contribute Delete
13.8 kB
"""
Dialogue Display Type
Renders conversation/dialogue content for display in the annotation interface.
Supports multiple conversation turns with speaker identification and styling.
"""
import html
from typing import Dict, Any, List, Union
from .base import BaseDisplay
class DialogueDisplay(BaseDisplay):
"""
Display type for dialogue/conversation content.
Displays conversations with alternating speaker turns and
visual styling to distinguish between speakers.
Can be used as a target for span annotations.
"""
name = "dialogue"
required_fields = ["key"]
optional_fields = {
"alternating_shading": True,
"speaker_extraction": True,
"speaker_key": "speaker",
"text_key": "text",
"show_turn_numbers": False,
"per_turn_ratings": None,
}
description = "Dialogue/conversation turns display"
supports_span_target = True
def render(self, field_config: Dict[str, Any], data: Any) -> str:
"""
Render dialogue content as HTML.
Args:
field_config: The field configuration
data: The dialogue data - can be:
- List of strings (each string is a turn)
- List of dicts with speaker/text keys
- String with turns separated by newlines
Returns:
HTML string for the dialogue display
"""
if not data:
return '<div class="dialogue-placeholder">No dialogue provided</div>'
# Get display options
options = self.get_display_options(field_config)
alternating_shading = options.get("alternating_shading", True)
speaker_extraction = options.get("speaker_extraction", True)
speaker_key = options.get("speaker_key", "speaker")
text_key = options.get("text_key", "text")
show_turn_numbers = options.get("show_turn_numbers", False)
per_turn_ratings = options.get("per_turn_ratings")
# Normalize the dialogue data to a list of turns
turns = self._normalize_dialogue(data, speaker_key, text_key, speaker_extraction)
if not turns:
return '<div class="dialogue-placeholder">No dialogue turns found</div>'
field_key = html.escape(field_config.get("key", ""), quote=True)
is_span_target = field_config.get("span_target", False)
# Determine which speakers get per-turn ratings
rated_speakers = set()
rating_schemes = []
if per_turn_ratings:
rated_speakers = set(per_turn_ratings.get("speakers", []))
# Support both single-scheme and multi-scheme formats
if "schemes" in per_turn_ratings:
# New multi-dimension format
rating_schemes = per_turn_ratings["schemes"]
elif "scheme" in per_turn_ratings:
# Legacy single-scheme format: wrap in list
rating_schemes = [{
"schema_name": per_turn_ratings.get("schema_name", "per_turn_ratings"),
"scheme": per_turn_ratings["scheme"],
}]
# Build HTML for each turn
turn_html_list = []
for i, turn in enumerate(turns):
speaker = turn.get("speaker", "")
text = turn.get("text", "")
# Determine styling
turn_classes = ["dialogue-turn"]
if alternating_shading:
turn_classes.append(f"turn-{'even' if i % 2 == 0 else 'odd'}")
# Speaker-based styling
speaker_index = self._get_speaker_index(speaker, turns)
turn_classes.append(f"speaker-{speaker_index}")
# Build turn HTML
speaker_html = ""
if speaker:
escaped_speaker = html.escape(str(speaker))
speaker_html = f'<span class="dialogue-speaker" data-speaker="{escaped_speaker}">{escaped_speaker}:</span>'
turn_number_html = ""
if show_turn_numbers:
turn_number_html = f'<span class="turn-number">[{i + 1}]</span>'
escaped_text = html.escape(str(text))
# For span target, add data attributes
span_attrs = ""
text_id = ""
if is_span_target:
text_id = f'id="turn-text-{field_key}-{i}"'
span_attrs = f'data-original-text="{escaped_text}" data-turn-index="{i}"'
# Per-turn rating widgets (one or more per rated turn)
rating_html = ""
if per_turn_ratings and speaker in rated_speakers and rating_schemes:
if len(rating_schemes) == 1:
rating_html = self._render_turn_rating(
field_key, i, rating_schemes[0].get("scheme", {}),
rating_schemes[0].get("schema_name", "per_turn_ratings")
)
else:
# Multi-dimension: wrap multiple ratings in a group
parts = []
for scheme_entry in rating_schemes:
parts.append(self._render_turn_rating(
field_key, i, scheme_entry.get("scheme", {}),
scheme_entry.get("schema_name", "")
))
rating_html = f'<div class="per-turn-rating-group">{"".join(parts)}</div>'
turn_html = f'''
<div class="{' '.join(turn_classes)}" data-speaker-index="{speaker_index}">
{turn_number_html}
{speaker_html}
<span class="dialogue-text" {text_id} {span_attrs}>{escaped_text}</span>
{rating_html}
</div>
'''
turn_html_list.append(turn_html)
# Combine all turns
all_turns_html = "\n".join(turn_html_list)
# For span annotation, wrap in .text-content WITHOUT data-original-text.
# Dialogue DOM textContent (with turn numbers, speaker prefixes, whitespace)
# differs from concatenate_dialogue_text() output. By omitting the attribute,
# getCanonicalText() falls back to container.textContent, so offsets from
# selection and from canonicalText always agree.
if is_span_target:
escaped_key = html.escape(field_key, quote=True)
all_turns_html = (
f'<div class="text-content" id="text-content-{escaped_key}"'
f' style="position: relative; padding-top: 24px;">'
f'{all_turns_html}'
f'</div>'
)
# Hidden inputs for storing per-turn rating data (one per scheme)
hidden_input_html = ""
if per_turn_ratings and rating_schemes:
hidden_parts = []
for scheme_entry in rating_schemes:
schema_name = html.escape(
scheme_entry.get("schema_name", "per_turn_ratings"), quote=True
)
hidden_parts.append(
f'<input type="hidden" class="annotation-data-input per-turn-hidden"'
f' name="{schema_name}"'
f' id="per-turn-ratings-{field_key}-{schema_name}"'
f' data-schema-name="{schema_name}"'
f' value="" />'
)
hidden_input_html = "\n".join(hidden_parts)
# Wrap in container
container_classes = ["dialogue-display-content"]
if is_span_target:
container_classes.append("span-target-dialogue")
if per_turn_ratings:
container_classes.append("has-per-turn-ratings")
return f'''
<div class="{' '.join(container_classes)}" data-field-key="{field_key}">
{all_turns_html}
{hidden_input_html}
</div>
'''
def _render_turn_rating(self, field_key: str, turn_index: int,
rating_config: Dict[str, Any],
schema_name: str = "") -> str:
"""
Render an inline rating widget for a dialogue turn.
Args:
field_key: The field key for the dialogue
turn_index: The index of the turn
rating_config: Configuration for the rating widget
schema_name: Schema name for multi-dimension support
Returns:
HTML string for the rating widget
"""
size = rating_config.get("size", 5)
labels = rating_config.get("labels", [])
min_label = labels[0] if len(labels) > 0 else ""
max_label = labels[1] if len(labels) > 1 else ""
escaped_min = html.escape(str(min_label))
escaped_max = html.escape(str(max_label))
escaped_schema = html.escape(str(schema_name), quote=True)
# Build rating circles/stars
rating_items = []
for v in range(1, size + 1):
rating_items.append(
f'<span class="ptr-value" data-field="{field_key}" '
f'data-turn="{turn_index}" data-value="{v}" '
f'data-schema="{escaped_schema}" '
f'title="{v}">{v}</span>'
)
items_html = "\n".join(rating_items)
min_html = f'<span class="ptr-label ptr-min">{escaped_min}</span>' if min_label else ""
max_html = f'<span class="ptr-label ptr-max">{escaped_max}</span>' if max_label else ""
# Schema label for multi-dimension mode
schema_label_html = ""
if schema_name:
readable_name = schema_name.replace("_", " ").title()
escaped_readable = html.escape(readable_name)
schema_label_html = f'<span class="ptr-schema-label">{escaped_readable}:</span>'
return f'''
<div class="per-turn-rating" data-field="{field_key}" data-turn="{turn_index}" data-schema="{escaped_schema}">
{schema_label_html}
{min_html}
<div class="ptr-values">{items_html}</div>
{max_html}
</div>
'''
def _normalize_dialogue(
self,
data: Any,
speaker_key: str,
text_key: str,
speaker_extraction: bool
) -> List[Dict[str, str]]:
"""
Normalize dialogue data to a list of {speaker, text} dicts.
Args:
data: Raw dialogue data
speaker_key: Key for speaker in dict format
text_key: Key for text in dict format
speaker_extraction: Whether to extract speaker from text
Returns:
List of turn dictionaries
"""
turns = []
# Handle string input (newline-separated turns)
if isinstance(data, str):
lines = data.strip().split('\n')
for line in lines:
line = line.strip()
if not line:
continue
speaker, text = self._extract_speaker(line) if speaker_extraction else ("", line)
turns.append({"speaker": speaker, "text": text})
# Handle list input
elif isinstance(data, list):
for item in data:
if isinstance(item, str):
speaker, text = self._extract_speaker(item) if speaker_extraction else ("", item)
turns.append({"speaker": speaker, "text": text})
elif isinstance(item, dict):
speaker = item.get(speaker_key, "")
text = item.get(text_key, str(item))
turns.append({"speaker": speaker, "text": text})
else:
turns.append({"speaker": "", "text": str(item)})
# Handle single dict (unlikely but possible)
elif isinstance(data, dict):
speaker = data.get(speaker_key, "")
text = data.get(text_key, str(data))
turns.append({"speaker": speaker, "text": text})
return turns
def _extract_speaker(self, text: str) -> tuple:
"""
Extract speaker from text if it starts with "Speaker:" pattern.
Args:
text: The text that may contain a speaker prefix
Returns:
Tuple of (speaker, remaining_text)
"""
import re
# Match patterns like "Speaker:" or "Speaker 1:" or "User:" at the start
match = re.match(r'^([A-Za-z0-9_\s]+):\s*(.*)$', text)
if match:
return match.group(1).strip(), match.group(2).strip()
return "", text
def _get_speaker_index(self, speaker: str, turns: List[Dict[str, str]]) -> int:
"""
Get a consistent index for a speaker for styling purposes.
Args:
speaker: The speaker name
turns: All turns in the dialogue
Returns:
Integer index for the speaker (0, 1, 2, ...)
"""
if not speaker:
return 0
# Get unique speakers in order of first appearance
seen_speakers = []
for turn in turns:
s = turn.get("speaker", "")
if s and s not in seen_speakers:
seen_speakers.append(s)
try:
return seen_speakers.index(speaker)
except ValueError:
return 0
def get_css_classes(self, field_config: Dict[str, Any]) -> List[str]:
"""Get CSS classes for the container."""
classes = super().get_css_classes(field_config)
if field_config.get("span_target"):
classes.append("span-target-field")
return classes
def get_data_attributes(self, field_config: Dict[str, Any], data: Any) -> Dict[str, str]:
"""Get data attributes for the container."""
attrs = super().get_data_attributes(field_config, data)
if field_config.get("span_target"):
attrs["span-target"] = "true"
return attrs