'
+ )
+
+ # Assign annotation_id to each scheme if not already set.
+ # The main annotation path (generate_annotation_html_template) does this for
+ # config["annotation_schemes"], but phase schemas loaded from JSON files
+ # (consent, prestudy, etc.) arrive here without annotation_id set.
+ for idx, annotation_scheme in enumerate(annotation_schemas):
+ if "annotation_id" not in annotation_scheme:
+ annotation_scheme["annotation_id"] = idx
+
+ # Pre-allocate non-conflicting keybindings across all schemas
+ allocation = allocate_keybindings(annotation_schemas)
+ for annotation_scheme in annotation_schemas:
+ name = annotation_scheme.get("name", "")
+ if name in allocation:
+ annotation_scheme["_allocated_keys"] = allocation[name]
+
+ # Handle annotation layout generation for surveyflow phases.
+ # Only fall back to the global task_layout for the main annotation page
+ # (phase_name is None). Phase pages should not inherit the global
+ # annotation layout since it may expect annotation-only context.
+ if not task_layout_file and not phase_name:
+ task_layout_file = config.get("task_layout")
+
+ if task_layout_file:
+ # User provided a custom task layout file
+ logger.info(f"Using custom task layout file: {task_layout_file}")
+
+ # Resolve the path relative to the config file
+ task_layout_file = resolve_project_asset_path(config, task_layout_file)
+
+ # Read the custom task layout
+ with open(task_layout_file, "rt", encoding="utf-8") as f:
+ task_html_layout = "".join(f.readlines())
+
+ else:
+ # Use the dedicated annotation layout file system (auto-generated)
+ try:
+ layout_file_path = get_or_generate_annotation_layout(config, annotation_schemas, layout_name=phase_name)
+
+ # Read the generated layout file
+ with open(layout_file_path, "rt", encoding="utf-8") as f:
+ task_html_layout = "".join(f.readlines())
+
+ except Exception as e:
+ logger.warning(f"Failed to use dedicated layout file: {e}. Falling back to inline generation.")
+
+ # Fallback to inline generation
+ # Generate inline layout
+ schema_layouts = ""
+ for annotation_scheme in annotation_schemas:
+ schema_layout, keybindings = generate_schematic(annotation_scheme)
+ schema_layouts += schema_layout + "\n"
+
+ task_html_layout = f'
{schema_layouts}
'
+
+ cur_html_template = html_template.replace("{{ TASK_LAYOUT }}", task_html_layout)
+
+ # Add in a codebook link if the admin specified one
+ codebook_html = ""
+ if len(config.get("annotation_codebook_url", "")) > 0:
+ annotation_codebook = config["annotation_codebook_url"]
+ codebook_html = '
Annotation Codebook'
+ codebook_html = codebook_html.replace("{{annotation_codebook_url}}", annotation_codebook)
+
+ html_template = html_template.replace("{{annotation_codebook}}", codebook_html)
+
+ html_template = html_template.replace(
+ "{{annotation_task_name}}", config["annotation_task_name"]
+ )
+
+ _ = generate_statistics_sidebar(STATS_KEYS)
+ html_template = html_template.replace("{{statistics_nav}}", " ")
+
+ #
+ # Step 3, Fill in the annotation-specific pieces in the layout and save the page
+ #
+
+ logger.debug("Saw %d annotation scheme(s)" % len(annotation_schemas))
+
+ # Keep track of all the keybindings we have
+ all_keybindings = [("←", "Move backward"), ("→", "Move forward")]
+
+ # Do not display keybindings for the first and last page
+ if False:
+ if i == 0:
+ keybindings_desc = generate_keybindings_sidebar(config, all_keybindings[1:])
+ cur_html_template = cur_html_template.replace(
+ '
Move backward',
+ '
Move backward',
+ )
+ elif i == len(annotation_schemas) - 1 or re.search("prestudy_fail", page):
+ keybindings_desc = generate_keybindings_sidebar(config, all_keybindings[:-1])
+ cur_html_template = cur_html_template.replace(
+ '
Move forward',
+ '
Move forward',
+ )
+ else:
+ keybindings_desc = generate_keybindings_sidebar(config, all_keybindings)
+
+ cur_html_template = cur_html_template.replace("{{keybindings}}", keybindings_desc)
+
+ # Cache the html as a template for use in flask server
+ site_name = (
+ "_".join(config["annotation_task_name"].split(" "))
+ + "-"
+ + "%s.html" % phase_name
+ )
+
+ # Create generated subdirectory within the templates directory
+ generated_dir = os.path.join(config["site_dir"], "generated")
+ if not os.path.exists(generated_dir):
+ os.makedirs(generated_dir)
+ logger.info(f"Created generated templates directory: {generated_dir}")
+
+ output_html_fname = os.path.join(generated_dir, site_name)
+
+ # Write the file
+ logger.debug("writing %s html to %s.html" % (phase_name, output_html_fname))
+ with open(output_html_fname, "wt", encoding="utf-8") as outf:
+ outf.write(cur_html_template)
+
+ return site_name #output_html_fname
\ No newline at end of file
diff --git a/potato/server_utils/html_sanitizer.py b/potato/server_utils/html_sanitizer.py
new file mode 100644
index 0000000000000000000000000000000000000000..1febb31547e7f2115b42c0906b474fda3cd7db5a
--- /dev/null
+++ b/potato/server_utils/html_sanitizer.py
@@ -0,0 +1,396 @@
+"""
+HTML Sanitizer Module
+
+Provides XSS-safe HTML sanitization for the annotation platform. This module
+allows legitimate span annotation HTML while blocking potentially dangerous
+elements and attributes.
+
+The sanitizer uses an allowlist approach - only explicitly permitted elements
+and attributes are kept, everything else is escaped or removed.
+
+Usage:
+ from potato.server_utils.html_sanitizer import sanitize_html
+
+ # In Jinja2 template:
+ {{ instance | sanitize_html }}
+"""
+
+import re
+import html
+import logging
+from typing import Set, Dict, List, Tuple
+from markupsafe import Markup
+
+logger = logging.getLogger(__name__)
+
+# Elements allowed in sanitized HTML
+ALLOWED_ELEMENTS: Set[str] = {
+ # Span annotations
+ 'span',
+ # Basic formatting (may be in source data)
+ 'b', 'i', 'u', 'strong', 'em', 'mark',
+ 's', 'del', 'ins',
+ # Line breaks and horizontal rules
+ 'br', 'hr', 'wbr',
+ # Dialogue/conversation layout elements
+ 'div',
+ # Structural elements for instructional content (Issue #120)
+ 'p',
+ 'ul', 'ol', 'li',
+ 'dl', 'dt', 'dd',
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
+ # Tables for formatted content
+ 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'caption',
+ # Inline semantics
+ 'sub', 'sup', 'small', 'code', 'pre', 'blockquote',
+ 'abbr', 'cite', 'kbd', 'samp', 'var',
+ # Collapsible sections
+ 'details', 'summary',
+ # Links (href sanitized against dangerous patterns)
+ 'a',
+ # Media and figures for instructional/survey content (Issue #129)
+ 'img', 'figure', 'figcaption',
+ # Ruby annotations for CJK text
+ 'ruby', 'rt', 'rp',
+}
+
+# Attributes allowed per element
+ALLOWED_ATTRIBUTES: Dict[str, Set[str]] = {
+ 'span': {
+ 'class',
+ 'style',
+ 'data-annotation-id',
+ 'data-label',
+ 'schema',
+ 'title',
+ },
+ 'div': {
+ 'class',
+ 'style',
+ 'data-speaker',
+ 'data-speaker-index',
+ },
+ 'mark': {'class', 'style'},
+ # Links โ href checked against dangerous patterns in _sanitize_attributes
+ 'a': {'href', 'title', 'target', 'rel'},
+ # Table elements
+ 'td': {'colspan', 'rowspan', 'style'},
+ 'th': {'colspan', 'rowspan', 'style', 'scope'},
+ 'table': {'class', 'style'},
+ # Ordered lists
+ 'ol': {'start', 'type'},
+ # Definition lists
+ 'dl': {'class', 'style'},
+ 'dt': {'class', 'style'},
+ 'dd': {'class', 'style'},
+ # Block-level elements that may need class/style
+ 'blockquote': {'class', 'style'},
+ 'p': {'class', 'style'},
+ 'pre': {'class', 'style'},
+ 'code': {'class'},
+ # Collapsible sections
+ 'details': {'class', 'style', 'open'},
+ 'summary': {'class', 'style'},
+ # Abbreviations with tooltip
+ 'abbr': {'title'},
+ # Deletion/insertion with optional metadata
+ 'del': {'datetime', 'cite'},
+ 'ins': {'datetime', 'cite'},
+ # Table captions
+ 'caption': {'class', 'style'},
+ # Images โ src checked against dangerous patterns like href
+ 'img': {'src', 'alt', 'title', 'style', 'width', 'height'},
+ # Figures and captions for instructional content
+ 'figure': {'class', 'style'},
+ 'figcaption': {'class', 'style'},
+ # Most elements get no attributes
+ '*': set(),
+}
+
+# Allowed CSS properties in style attributes
+ALLOWED_CSS_PROPERTIES: Set[str] = {
+ 'background-color',
+ 'color',
+ 'font-weight',
+ 'font-style',
+ 'font-family',
+ 'font-size',
+ 'text-decoration',
+ 'text-align',
+ 'line-height',
+ # Layout properties for dialogue/pairwise display
+ 'display',
+ 'width',
+ 'max-width',
+ 'padding',
+ 'padding-top', 'padding-bottom', 'padding-left', 'padding-right',
+ 'margin',
+ 'margin-top', 'margin-bottom', 'margin-left', 'margin-right',
+ 'box-sizing',
+ 'vertical-align',
+ 'gap',
+ # List styling
+ 'list-style-type',
+ # Borders
+ 'border',
+ 'border-radius',
+ 'border-collapse',
+}
+
+# Dangerous patterns to block
+DANGEROUS_PATTERNS = [
+ re.compile(r'javascript:', re.IGNORECASE),
+ re.compile(r'vbscript:', re.IGNORECASE),
+ re.compile(r'data:', re.IGNORECASE),
+ re.compile(r'expression\s*\(', re.IGNORECASE),
+]
+
+
+def sanitize_html(text: str) -> Markup:
+ """
+ Sanitize HTML content while preserving legitimate span annotations.
+
+ This function:
+ 1. Parses HTML using regex (lightweight, no external deps)
+ 2. Allows only whitelisted elements and attributes
+ 3. Sanitizes style attributes to only allow safe CSS
+ 4. Escapes all other content
+
+ Args:
+ text: The HTML content to sanitize
+
+ Returns:
+ Markup: Sanitized HTML safe for rendering (wrapped in Markup to prevent
+ double-escaping by Jinja2's auto-escape)
+
+ Example:
+ >>> sanitize_html('
text')
+ Markup('
text')
+
+ >>> sanitize_html('')
+ Markup('<script>alert("xss")</script>')
+ """
+ if not text:
+ return Markup("")
+
+ # Check for dangerous patterns in the raw text
+ for pattern in DANGEROUS_PATTERNS:
+ if pattern.search(text):
+ logger.warning(f"Blocked dangerous pattern in HTML content")
+ text = pattern.sub('', text)
+
+ result = []
+ pos = 0
+
+ # Regex to find HTML tags
+ tag_pattern = re.compile(
+ r'<(/?)(\w+)([^>]*)(/?)>',
+ re.IGNORECASE | re.DOTALL
+ )
+
+ for match in tag_pattern.finditer(text):
+ # Add escaped text before this tag
+ if match.start() > pos:
+ result.append(html.escape(text[pos:match.start()]))
+
+ is_close = match.group(1) == '/'
+ tag_name = match.group(2).lower()
+ attrs_str = match.group(3)
+ is_self_close = match.group(4) == '/'
+
+ if tag_name in ALLOWED_ELEMENTS:
+ # Build sanitized tag
+ if is_close:
+ result.append(f'{tag_name}>')
+ else:
+ sanitized_attrs = _sanitize_attributes(tag_name, attrs_str)
+ if is_self_close:
+ result.append(f'<{tag_name}{sanitized_attrs} />')
+ else:
+ result.append(f'<{tag_name}{sanitized_attrs}>')
+ else:
+ # Escape the entire tag
+ result.append(html.escape(match.group(0)))
+
+ pos = match.end()
+
+ # Add remaining text (escaped)
+ if pos < len(text):
+ result.append(html.escape(text[pos:]))
+
+ # Return as Markup to prevent Jinja2's auto-escape from escaping again
+ return Markup(''.join(result))
+
+
+def _sanitize_attributes(tag_name: str, attrs_str: str) -> str:
+ """
+ Sanitize attributes for a given tag.
+
+ Args:
+ tag_name: The tag name (lowercase)
+ attrs_str: The raw attributes string
+
+ Returns:
+ str: Sanitized attributes string (with leading space if non-empty)
+ """
+ if not attrs_str or not attrs_str.strip():
+ return ""
+
+ # Get allowed attributes for this tag
+ allowed = ALLOWED_ATTRIBUTES.get(tag_name, ALLOWED_ATTRIBUTES.get('*', set()))
+
+ # Parse attributes
+ attr_pattern = re.compile(
+ r'''(\w+(?:-\w+)*)\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+))''',
+ re.IGNORECASE
+ )
+
+ sanitized = []
+ for match in attr_pattern.finditer(attrs_str):
+ attr_name = match.group(1).lower()
+ # Get value from whichever group matched
+ attr_value = match.group(2) or match.group(3) or match.group(4) or ""
+
+ if attr_name not in allowed:
+ continue
+
+ # Special handling for href/src attributes โ block dangerous URLs
+ if attr_name in ('href', 'src'):
+ if any(p.search(attr_value) for p in DANGEROUS_PATTERNS):
+ logger.warning(f"Blocked dangerous pattern in {attr_name} attribute")
+ continue
+
+ # Special handling for style attribute
+ if attr_name == 'style':
+ attr_value = _sanitize_style(attr_value)
+ if not attr_value:
+ continue
+
+ # Special handling for class attribute
+ if attr_name == 'class':
+ attr_value = _sanitize_class(attr_value)
+
+ # Escape the value
+ escaped_value = html.escape(attr_value, quote=True)
+ sanitized.append(f'{attr_name}="{escaped_value}"')
+
+ # Security: auto-add rel="noopener noreferrer" for links with target="_blank"
+ if tag_name == 'a':
+ has_target_blank = any(s.startswith('target="') and '_blank' in s for s in sanitized)
+ has_rel = any(s.startswith('rel="') for s in sanitized)
+ if has_target_blank and not has_rel:
+ sanitized.append('rel="noopener noreferrer"')
+
+ if sanitized:
+ return ' ' + ' '.join(sanitized)
+ return ""
+
+
+def _sanitize_style(style: str) -> str:
+ """
+ Sanitize a CSS style attribute.
+
+ Only allows specific CSS properties that are known to be safe.
+
+ Args:
+ style: The style attribute value
+
+ Returns:
+ str: Sanitized style string
+ """
+ if not style:
+ return ""
+
+ # Check for dangerous patterns
+ for pattern in DANGEROUS_PATTERNS:
+ if pattern.search(style):
+ logger.warning("Blocked dangerous pattern in style attribute")
+ return ""
+
+ sanitized_props = []
+
+ # Parse CSS properties
+ for prop in style.split(';'):
+ prop = prop.strip()
+ if not prop:
+ continue
+
+ if ':' not in prop:
+ continue
+
+ name, value = prop.split(':', 1)
+ name = name.strip().lower()
+ value = value.strip()
+
+ if name in ALLOWED_CSS_PROPERTIES:
+ # Basic value validation - no functions except safe color functions
+ if 'url(' in value.lower():
+ continue
+ sanitized_props.append(f'{name}: {value}')
+
+ return '; '.join(sanitized_props)
+
+
+def _sanitize_class(class_str: str) -> str:
+ """
+ Sanitize a class attribute.
+
+ Only allows alphanumeric characters, hyphens, and underscores.
+
+ Args:
+ class_str: The class attribute value
+
+ Returns:
+ str: Sanitized class string
+ """
+ if not class_str:
+ return ""
+
+ # Split into individual classes
+ classes = class_str.split()
+
+ # Filter to safe class names
+ safe_pattern = re.compile(r'^[a-zA-Z_-][a-zA-Z0-9_-]*$')
+ safe_classes = [c for c in classes if safe_pattern.match(c)]
+
+ return ' '.join(safe_classes)
+
+
+def escape_for_attribute(text: str) -> str:
+ """
+ Escape text for use in an HTML attribute.
+
+ This is a stricter escape than html.escape() - it also escapes
+ backticks and other characters that could be used in template injection.
+
+ Args:
+ text: The text to escape
+
+ Returns:
+ str: Escaped text safe for attribute values
+ """
+ if not text:
+ return ""
+
+ return (
+ html.escape(text, quote=True)
+ .replace('`', '`')
+ .replace('$', '$')
+ )
+
+
+# Register as Jinja2 filter
+def register_jinja_filters(app):
+ """
+ Register HTML sanitization filters with a Flask app.
+
+ Call this during app initialization:
+ from potato.server_utils.html_sanitizer import register_jinja_filters
+ register_jinja_filters(app)
+
+ Args:
+ app: Flask application instance
+ """
+ app.jinja_env.filters['sanitize_html'] = sanitize_html
+ app.jinja_env.filters['escape_attr'] = escape_for_attribute
+ logger.info("Registered HTML sanitization Jinja2 filters")
diff --git a/potato/server_utils/iaa/__init__.py b/potato/server_utils/iaa/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..bea8a90819b9a3c59813b471799de963c5a95175
--- /dev/null
+++ b/potato/server_utils/iaa/__init__.py
@@ -0,0 +1,38 @@
+"""
+Inter-Annotator Agreement (IAA) metrics for Potato.
+
+This package computes IAA across the heterogeneous coverage sample produced
+by the overlap-sampling feature. Metrics are dispatched per annotation schema
+type (nominal, ordinal, continuous, multi-label, ranking, span).
+
+Public entry points:
+ compute_overlap_iaa(item_state_manager, user_state_manager, config)
+ End-to-end report for overlap-sample items that have reached their cap.
+ metrics_for_schema(annotation_type)
+ Inspect which metric functions apply to a given schema type.
+
+Lower-level metric functions live in nominal/ordinal/continuous/multilabel/ranking/span
+modules; alpha.py wraps Krippendorff's alpha (delegated to ``simpledorff``).
+"""
+
+from potato.server_utils.iaa import nominal, ordinal, continuous, multilabel, ranking, span, alpha
+from potato.server_utils.iaa.dispatcher import (
+ metrics_for_schema,
+ compute_overlap_iaa,
+ SchemaKind,
+ classify_schema,
+)
+
+__all__ = [
+ "nominal",
+ "ordinal",
+ "continuous",
+ "multilabel",
+ "ranking",
+ "span",
+ "alpha",
+ "metrics_for_schema",
+ "compute_overlap_iaa",
+ "SchemaKind",
+ "classify_schema",
+]
diff --git a/potato/server_utils/iaa/alpha.py b/potato/server_utils/iaa/alpha.py
new file mode 100644
index 0000000000000000000000000000000000000000..a3e789d5207f4044bbaccc9e0cda0a86a3dc4976
--- /dev/null
+++ b/potato/server_utils/iaa/alpha.py
@@ -0,0 +1,125 @@
+"""
+Krippendorff's alpha wrapper.
+
+Delegates to ``simpledorff`` (already a project dependency). Supports nominal,
+ordinal, interval, ratio, and MASI distance metrics. Accepts long-format data:
+a list of (annotator, item, label) triples.
+
+When ``simpledorff`` is unavailable, falls back to NaN with a logged warning.
+"""
+
+from __future__ import annotations
+
+from typing import Iterable, Sequence, Tuple, Union
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+def _nominal_distance(a, b) -> float:
+ return 0.0 if a == b else 1.0
+
+
+def _ordinal_distance(a, b) -> float:
+ try:
+ return abs(float(a) - float(b))
+ except (TypeError, ValueError):
+ return 0.0 if a == b else 1.0
+
+
+def _interval_distance(a, b) -> float:
+ try:
+ return (float(a) - float(b)) ** 2
+ except (TypeError, ValueError):
+ return 0.0 if a == b else 1.0
+
+
+def _ratio_distance(a, b) -> float:
+ try:
+ a, b = float(a), float(b)
+ if a + b == 0:
+ return 0.0
+ return ((a - b) / (a + b)) ** 2
+ except (TypeError, ValueError):
+ return 0.0 if a == b else 1.0
+
+
+def _masi_distance(a, b) -> float:
+ """
+ MASI distance for multi-label sets. ``a`` and ``b`` are iterables of labels.
+ """
+ set_a = frozenset(a) if not isinstance(a, frozenset) else a
+ set_b = frozenset(b) if not isinstance(b, frozenset) else b
+ if not set_a and not set_b:
+ return 0.0
+ intersection = set_a & set_b
+ union = set_a | set_b
+ if not union:
+ return 0.0
+ jaccard = len(intersection) / len(union)
+ if set_a == set_b:
+ m = 1.0
+ elif set_a < set_b or set_b < set_a:
+ m = 2 / 3
+ elif intersection and set_a != set_b:
+ m = 1 / 3
+ else:
+ m = 0.0
+ return 1.0 - (jaccard * m)
+
+
+_DISTANCES = {
+ "nominal": _nominal_distance,
+ "ordinal": _ordinal_distance,
+ "interval": _interval_distance,
+ "ratio": _ratio_distance,
+ "masi": _masi_distance,
+}
+
+
+def krippendorff_alpha(
+ long_format: Sequence[Tuple[str, str, Union[str, float, frozenset]]],
+ level: str = "nominal",
+) -> float:
+ """
+ Krippendorff's alpha.
+
+ Args:
+ long_format: iterable of (annotator_id, item_id, value) tuples.
+ level: 'nominal', 'ordinal', 'interval', 'ratio', or 'masi'.
+
+ Returns:
+ Alpha as a float, or NaN if undefined.
+ """
+ if level not in _DISTANCES:
+ raise ValueError(f"Unknown level for Krippendorff's alpha: {level!r}")
+
+ try:
+ import simpledorff
+ import pandas as pd
+ except ImportError: # pragma: no cover
+ logger.warning("simpledorff/pandas unavailable; krippendorff_alpha returning NaN")
+ return float("nan")
+
+ rows = list(long_format)
+ if not rows:
+ return float("nan")
+ df = pd.DataFrame(rows, columns=["annotator", "item", "value"])
+ if df["item"].nunique() < 2 or df["annotator"].nunique() < 2:
+ return float("nan")
+
+ dist = _DISTANCES[level]
+ try:
+ return float(
+ simpledorff.calculate_krippendorffs_alpha_for_df(
+ df,
+ experiment_col="item",
+ annotator_col="annotator",
+ class_col="value",
+ metric_fn=dist,
+ )
+ )
+ except Exception as exc: # pragma: no cover
+ logger.warning("krippendorff_alpha failed: %s", exc)
+ return float("nan")
diff --git a/potato/server_utils/iaa/continuous.py b/potato/server_utils/iaa/continuous.py
new file mode 100644
index 0000000000000000000000000000000000000000..945ad7cd853efd75362684d364abb21a995b6d57
--- /dev/null
+++ b/potato/server_utils/iaa/continuous.py
@@ -0,0 +1,126 @@
+"""
+Continuous IAA metrics: Pearson r, MAE, RMSE, intra-class correlation (ICC).
+
+ICC implementation follows Shrout & Fleiss (1979). We expose:
+ icc_2_1 โ single-rater ICC(2,1) (two-way random, agreement, single measure)
+ icc_2_k โ average-rater ICC(2,k) (two-way random, agreement, average measure)
+"""
+
+from __future__ import annotations
+
+from math import isnan, sqrt
+from typing import Sequence
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+def _to_float(seq: Sequence) -> list:
+ out = []
+ for v in seq:
+ try:
+ out.append(float(v))
+ except (TypeError, ValueError):
+ out.append(float("nan"))
+ return out
+
+
+def pearson_r(values_a: Sequence, values_b: Sequence) -> float:
+ a = _to_float(values_a)
+ b = _to_float(values_b)
+ pairs = [(x, y) for x, y in zip(a, b) if not (isnan(x) or isnan(y))]
+ if len(pairs) < 2:
+ return float("nan")
+ try:
+ from scipy.stats import pearsonr
+ r, _ = pearsonr([x for x, _ in pairs], [y for _, y in pairs])
+ return float(r) if not isnan(r) else float("nan")
+ except ImportError: # pragma: no cover
+ pass
+ n = len(pairs)
+ sa = sum(x for x, _ in pairs)
+ sb = sum(y for _, y in pairs)
+ sab = sum(x * y for x, y in pairs)
+ saa = sum(x * x for x, _ in pairs)
+ sbb = sum(y * y for _, y in pairs)
+ num = n * sab - sa * sb
+ den = sqrt((n * saa - sa * sa) * (n * sbb - sb * sb))
+ if den == 0:
+ return float("nan")
+ return num / den
+
+
+def mae(values_a: Sequence, values_b: Sequence) -> float:
+ a = _to_float(values_a)
+ b = _to_float(values_b)
+ pairs = [(x, y) for x, y in zip(a, b) if not (isnan(x) or isnan(y))]
+ if not pairs:
+ return float("nan")
+ return sum(abs(x - y) for x, y in pairs) / len(pairs)
+
+
+def rmse(values_a: Sequence, values_b: Sequence) -> float:
+ a = _to_float(values_a)
+ b = _to_float(values_b)
+ pairs = [(x, y) for x, y in zip(a, b) if not (isnan(x) or isnan(y))]
+ if not pairs:
+ return float("nan")
+ return sqrt(sum((x - y) ** 2 for x, y in pairs) / len(pairs))
+
+
+def _icc_components(matrix):
+ """Mean squares for a two-way ANOVA: MSR (rows/items), MSC (cols/raters), MSE."""
+ try:
+ import numpy as np
+ except ImportError: # pragma: no cover
+ return None
+ arr = np.asarray(matrix, dtype=float)
+ if arr.ndim != 2 or arr.shape[0] < 2 or arr.shape[1] < 2:
+ return None
+ if np.isnan(arr).any():
+ # listwise deletion of items with any missing rating
+ arr = arr[~np.isnan(arr).any(axis=1)]
+ if arr.shape[0] < 2:
+ return None
+ n, k = arr.shape
+ grand = arr.mean()
+ row_means = arr.mean(axis=1)
+ col_means = arr.mean(axis=0)
+ ss_total = ((arr - grand) ** 2).sum()
+ ss_rows = k * ((row_means - grand) ** 2).sum()
+ ss_cols = n * ((col_means - grand) ** 2).sum()
+ ss_err = ss_total - ss_rows - ss_cols
+ df_rows = n - 1
+ df_cols = k - 1
+ df_err = (n - 1) * (k - 1)
+ if df_err <= 0:
+ return None
+ msr = ss_rows / df_rows
+ msc = ss_cols / df_cols
+ mse = ss_err / df_err
+ return msr, msc, mse, n, k
+
+
+def icc_2_1(matrix) -> float:
+ """ICC(2,1): two-way random effects, single rater, absolute agreement."""
+ comps = _icc_components(matrix)
+ if comps is None:
+ return float("nan")
+ msr, msc, mse, n, k = comps
+ denom = msr + (k - 1) * mse + k * (msc - mse) / n
+ if denom == 0:
+ return float("nan")
+ return (msr - mse) / denom
+
+
+def icc_2_k(matrix) -> float:
+ """ICC(2,k): two-way random effects, average of k raters, absolute agreement."""
+ comps = _icc_components(matrix)
+ if comps is None:
+ return float("nan")
+ msr, msc, mse, n, k = comps
+ denom = msr + (msc - mse) / n
+ if denom == 0:
+ return float("nan")
+ return (msr - mse) / denom
diff --git a/potato/server_utils/iaa/dispatcher.py b/potato/server_utils/iaa/dispatcher.py
new file mode 100644
index 0000000000000000000000000000000000000000..19fd80e45c7edbe68a34d1f2cd17d21c9384dc6f
--- /dev/null
+++ b/potato/server_utils/iaa/dispatcher.py
@@ -0,0 +1,553 @@
+"""
+Schema-to-metric dispatcher and the top-level overlap-IAA report.
+
+The dispatcher inspects a schema's ``annotation_type`` and (where relevant)
+its labels block to decide which family of IAA metrics applies, then runs
+those metrics across the overlap-sample items that have reached their cap.
+"""
+
+from __future__ import annotations
+
+from collections import defaultdict
+from enum import Enum
+from typing import Any, Dict, Iterable, List, Optional
+
+import logging
+
+from potato.server_utils.iaa import nominal, ordinal, continuous, multilabel, ranking, span, alpha
+
+logger = logging.getLogger(__name__)
+
+
+class SchemaKind(str, Enum):
+ NOMINAL = "nominal"
+ ORDINAL = "ordinal"
+ CONTINUOUS = "continuous"
+ MULTILABEL = "multilabel"
+ RANKING = "ranking"
+ SPAN = "span"
+ TEXT = "text" # free-form text, no automatic IAA
+ UNSUPPORTED = "unsupported"
+
+
+_KIND_BY_TYPE = {
+ # Nominal (single-label categorical)
+ "radio": SchemaKind.NOMINAL,
+ "select": SchemaKind.NOMINAL,
+ "triage": SchemaKind.NOMINAL,
+ # Ordinal
+ "likert": SchemaKind.ORDINAL,
+ "confidence": SchemaKind.ORDINAL,
+ "semantic_differential": SchemaKind.ORDINAL,
+ "range_slider": SchemaKind.ORDINAL,
+ "vas": SchemaKind.ORDINAL,
+ # Continuous
+ "slider": SchemaKind.CONTINUOUS,
+ "number": SchemaKind.CONTINUOUS,
+ "multirate": SchemaKind.CONTINUOUS,
+ "constant_sum": SchemaKind.CONTINUOUS,
+ "soft_label": SchemaKind.CONTINUOUS,
+ # Multi-label
+ "multiselect": SchemaKind.MULTILABEL, # may be downgraded to NOMINAL if max=1
+ "hierarchical_multiselect": SchemaKind.MULTILABEL,
+ "card_sort": SchemaKind.MULTILABEL,
+ # Ranking
+ "ranking": SchemaKind.RANKING,
+ "bws": SchemaKind.RANKING,
+ "pairwise": SchemaKind.RANKING,
+ "conjoint": SchemaKind.RANKING,
+ "best_worst_scaling": SchemaKind.RANKING,
+ # Span
+ "span": SchemaKind.SPAN,
+ "error_span": SchemaKind.SPAN,
+ "event_annotation": SchemaKind.SPAN,
+ "coreference": SchemaKind.SPAN,
+ "extractive_qa": SchemaKind.SPAN,
+ "span_link": SchemaKind.SPAN,
+ "tree_annotation": SchemaKind.SPAN,
+ # Text
+ "textbox": SchemaKind.TEXT,
+ "text_edit": SchemaKind.TEXT,
+ # Skipped
+ "pure_display": SchemaKind.UNSUPPORTED,
+ "video": SchemaKind.UNSUPPORTED,
+ "audio_annotation": SchemaKind.UNSUPPORTED,
+ "video_annotation": SchemaKind.UNSUPPORTED,
+ "image_annotation": SchemaKind.UNSUPPORTED,
+}
+
+
+def classify_schema(scheme: Dict[str, Any]) -> SchemaKind:
+ """Classify a schema definition into an IAA-relevant kind."""
+ atype = (scheme.get("annotation_type") or "").strip().lower()
+ kind = _KIND_BY_TYPE.get(atype, SchemaKind.UNSUPPORTED)
+ # Downgrade multiselect with max_choices == 1 to NOMINAL
+ if kind == SchemaKind.MULTILABEL and atype == "multiselect":
+ max_choices = scheme.get("max_choices") or scheme.get("max_selections")
+ if max_choices == 1:
+ return SchemaKind.NOMINAL
+ return kind
+
+
+def metrics_for_schema(scheme: Dict[str, Any]) -> List[str]:
+ """Return human-readable names of metrics that apply to ``scheme``."""
+ kind = classify_schema(scheme)
+ table = {
+ SchemaKind.NOMINAL: ["percent_agreement", "cohen_kappa", "fleiss_kappa", "alpha_nominal"],
+ SchemaKind.ORDINAL: ["weighted_kappa_linear", "weighted_kappa_quadratic", "spearman_rho", "alpha_ordinal"],
+ SchemaKind.CONTINUOUS: ["pearson_r", "mae", "rmse", "alpha_interval", "icc_2_k"],
+ SchemaKind.MULTILABEL: ["mean_jaccard", "alpha_masi"],
+ SchemaKind.RANKING: ["kendall_tau", "spearman_footrule"],
+ SchemaKind.SPAN: [
+ "token_level_kappa", "span_f1_exact", "span_f1_partial",
+ "krippendorff_alpha_u", "gamma_mathet",
+ ],
+ SchemaKind.TEXT: [],
+ SchemaKind.UNSUPPORTED: [],
+ }
+ return list(table[kind])
+
+
+# ---------------------------------------------------------------------------
+# Data extraction from Potato's per-user annotation structures
+# ---------------------------------------------------------------------------
+
+def _label_value(label) -> Any:
+ """Extract a comparable value from a Label object (or dict)."""
+ if isinstance(label, dict):
+ return label.get("name") or label.get("value")
+ return getattr(label, "name", None) or getattr(label, "value", None)
+
+
+def _gather_labels(
+ instance_ids: Iterable[str],
+ user_states: Dict[str, Any],
+ schema_name: str,
+):
+ """
+ Per item, return {user_id:
} for one schema.
+
+ For nominal/ordinal/continuous schemas the value is a scalar (the chosen
+ label name or numeric rating). For multi-label schemas, it's a list.
+ """
+ rows: Dict[str, Dict[str, Any]] = {}
+ for iid in instance_ids:
+ per_user: Dict[str, Any] = {}
+ for uid, ustate in user_states.items():
+ labels_by_schema = ustate.get_label_annotations(iid)
+ if not labels_by_schema:
+ continue
+ labels = labels_by_schema.get(schema_name)
+ if not labels:
+ continue
+ vals = [_label_value(l) for l in labels]
+ vals = [v for v in vals if v is not None]
+ if not vals:
+ continue
+ per_user[uid] = vals
+ if per_user:
+ rows[iid] = per_user
+ return rows
+
+
+def _gather_spans(
+ instance_ids: Iterable[str],
+ user_states: Dict[str, Any],
+ schema_name: str,
+):
+ rows: Dict[str, Dict[str, list]] = {}
+ for iid in instance_ids:
+ per_user = {}
+ for uid, ustate in user_states.items():
+ spans_by_schema = ustate.get_span_annotations(iid)
+ if not spans_by_schema:
+ continue
+ spans = spans_by_schema.get(schema_name) or []
+ if not spans:
+ continue
+ per_user[uid] = list(spans)
+ if per_user:
+ rows[iid] = per_user
+ return rows
+
+
+def _text_length_for_item(item) -> int:
+ """Best-effort character length of the item text used for span IAA."""
+ if item is None:
+ return 0
+ try:
+ text = item.get_text()
+ except Exception:
+ return 0
+ return len(text) if isinstance(text, str) else 0
+
+
+# ---------------------------------------------------------------------------
+# Metric computation per kind
+# ---------------------------------------------------------------------------
+
+def _aggregate_nominal(rows):
+ long_rows = []
+ pairwise_kappa = []
+ fleiss_inputs = []
+ users_seen = set()
+ for iid, per_user in rows.items():
+ # Collapse multi-value into the first chosen label (single-label schema)
+ flat = {u: v[0] for u, v in per_user.items() if v}
+ if len(flat) < 2:
+ continue
+ users_seen.update(flat)
+ for u, val in flat.items():
+ long_rows.append((u, iid, val))
+ fleiss_inputs.append(dict(Counter_(flat.values())))
+
+ pair_users = sorted(users_seen)
+ seqs_by_user: Dict[str, list] = {u: [] for u in pair_users}
+ aligned_iids = []
+ for iid, per_user in rows.items():
+ flat = {u: v[0] for u, v in per_user.items() if v}
+ if all(u in flat for u in pair_users):
+ aligned_iids.append(iid)
+ for u in pair_users:
+ seqs_by_user[u].append(flat[u])
+
+ return {
+ "alpha_nominal": alpha.krippendorff_alpha(long_rows, level="nominal"),
+ "fleiss_kappa": nominal.fleiss_kappa(fleiss_inputs),
+ "pairwise_cohen_kappa": nominal.pairwise_cohen_kappa(seqs_by_user) if seqs_by_user else float("nan"),
+ "n_items": len(rows),
+ "n_aligned_items": len(aligned_iids),
+ "n_annotators": len(pair_users),
+ }
+
+
+def _aggregate_ordinal(rows):
+ long_rows = []
+ seqs_by_user: Dict[str, list] = defaultdict(list)
+ aligned_users = None
+ for iid, per_user in rows.items():
+ flat = {u: v[0] for u, v in per_user.items() if v}
+ if len(flat) < 2:
+ continue
+ for u, val in flat.items():
+ long_rows.append((u, iid, val))
+ if aligned_users is None:
+ aligned_users = set(flat)
+ else:
+ aligned_users &= set(flat)
+ for u, val in flat.items():
+ seqs_by_user[u].append(val)
+ weighted_lin = _pairwise_mean(seqs_by_user, ordinal.weighted_kappa, weights="linear")
+ weighted_quad = _pairwise_mean(seqs_by_user, ordinal.weighted_kappa, weights="quadratic")
+ rho = _pairwise_mean(seqs_by_user, ordinal.spearman_rho)
+ return {
+ "weighted_kappa_linear": weighted_lin,
+ "weighted_kappa_quadratic": weighted_quad,
+ "spearman_rho": rho,
+ "alpha_ordinal": alpha.krippendorff_alpha(long_rows, level="ordinal"),
+ "n_items": len(rows),
+ "n_annotators": len(seqs_by_user),
+ }
+
+
+def _aggregate_continuous(rows):
+ long_rows = []
+ seqs_by_user: Dict[str, list] = defaultdict(list)
+ for iid, per_user in rows.items():
+ flat = {}
+ for u, v in per_user.items():
+ try:
+ flat[u] = float(v[0])
+ except (TypeError, ValueError):
+ continue
+ if len(flat) < 2:
+ continue
+ for u, val in flat.items():
+ long_rows.append((u, iid, val))
+ seqs_by_user[u].append(val)
+
+ pearson = _pairwise_mean(seqs_by_user, continuous.pearson_r)
+ mae_val = _pairwise_mean(seqs_by_user, continuous.mae)
+ rmse_val = _pairwise_mean(seqs_by_user, continuous.rmse)
+
+ # ICC needs an items x raters matrix where every rater rates every item.
+ users = sorted(seqs_by_user)
+ aligned_iids = []
+ matrix = []
+ for iid, per_user in rows.items():
+ try:
+ row = [float(per_user[u][0]) for u in users]
+ except (KeyError, TypeError, ValueError):
+ continue
+ matrix.append(row)
+ aligned_iids.append(iid)
+ icc_k = continuous.icc_2_k(matrix) if matrix and users else float("nan")
+
+ return {
+ "pearson_r": pearson,
+ "mae": mae_val,
+ "rmse": rmse_val,
+ "alpha_interval": alpha.krippendorff_alpha(long_rows, level="interval"),
+ "icc_2_k": icc_k,
+ "n_items": len(rows),
+ "n_aligned_items": len(aligned_iids),
+ "n_annotators": len(users),
+ }
+
+
+def _aggregate_multilabel(rows):
+ long_rows = []
+ label_sets_by_user: Dict[str, list] = defaultdict(list)
+ for iid, per_user in rows.items():
+ flat = {u: frozenset(v) for u, v in per_user.items() if v}
+ if len(flat) < 2:
+ continue
+ for u, val in flat.items():
+ long_rows.append((u, iid, val))
+ label_sets_by_user[u].append(val)
+ return {
+ "mean_jaccard": multilabel.mean_jaccard(label_sets_by_user),
+ "alpha_masi": multilabel.alpha_masi(long_rows),
+ "n_items": len(rows),
+ "n_annotators": len(label_sets_by_user),
+ }
+
+
+def _aggregate_ranking(rows):
+ seqs_by_user: Dict[str, list] = defaultdict(list)
+ for iid, per_user in rows.items():
+ flat = {u: list(v) for u, v in per_user.items() if v}
+ if len(flat) < 2:
+ continue
+ for u, val in flat.items():
+ seqs_by_user[u].append(val)
+ tau = _pairwise_rank_mean(seqs_by_user, ranking.kendall_tau)
+ footrule = _pairwise_rank_mean(seqs_by_user, ranking.spearman_footrule)
+ return {
+ "kendall_tau": tau,
+ "spearman_footrule": footrule,
+ "n_items": len(rows),
+ "n_annotators": len(seqs_by_user),
+ }
+
+
+def _aggregate_span(span_rows, item_lookup):
+ token_kappas = []
+ f1_exact = []
+ f1_partial = []
+ alphas_u = []
+ gammas = []
+ n_items = 0
+ annotators = set()
+ for iid, per_user in span_rows.items():
+ if len(per_user) < 2:
+ continue
+ item = item_lookup.get(iid)
+ length = _text_length_for_item(item)
+ if length <= 0:
+ continue
+ annotators.update(per_user)
+ n_items += 1
+ try:
+ tk = span.token_level_kappa(per_user, length)
+ if tk == tk:
+ token_kappas.append(tk)
+ except Exception as exc:
+ logger.debug("token_level_kappa failed on %s: %s", iid, exc)
+ try:
+ exact = span.pairwise_span_f1(per_user, partial=False)
+ partial = span.pairwise_span_f1(per_user, partial=True)
+ if exact == exact:
+ f1_exact.append(exact)
+ if partial == partial:
+ f1_partial.append(partial)
+ except Exception as exc:
+ logger.debug("span_f1 failed on %s: %s", iid, exc)
+ try:
+ au = span.krippendorff_alpha_u(per_user, length)
+ if au == au:
+ alphas_u.append(au)
+ except Exception as exc:
+ logger.debug("alpha_u failed on %s: %s", iid, exc)
+ try:
+ g = span.gamma(per_user, length=length)
+ if g == g:
+ gammas.append(g)
+ except Exception as exc:
+ logger.debug("gamma failed on %s: %s", iid, exc)
+
+ def _mean(xs):
+ return sum(xs) / len(xs) if xs else float("nan")
+
+ return {
+ "token_level_kappa": _mean(token_kappas),
+ "span_f1_exact": _mean(f1_exact),
+ "span_f1_partial": _mean(f1_partial),
+ "krippendorff_alpha_u": _mean(alphas_u),
+ "gamma_mathet": _mean(gammas),
+ "n_items": n_items,
+ "n_annotators": len(annotators),
+ }
+
+
+# ---------------------------------------------------------------------------
+# Pairwise helpers
+# ---------------------------------------------------------------------------
+
+def _pairwise_mean(seqs_by_user, fn, **kwargs):
+ users = list(seqs_by_user)
+ if len(users) < 2:
+ return float("nan")
+ out = []
+ for i in range(len(users)):
+ for j in range(i + 1, len(users)):
+ a = seqs_by_user[users[i]]
+ b = seqs_by_user[users[j]]
+ m = min(len(a), len(b))
+ if m < 2:
+ continue
+ try:
+ v = fn(a[:m], b[:m], **kwargs) if kwargs else fn(a[:m], b[:m])
+ if v == v:
+ out.append(v)
+ except Exception as exc:
+ logger.debug("pairwise metric %s failed: %s", fn.__name__, exc)
+ return sum(out) / len(out) if out else float("nan")
+
+
+def _pairwise_rank_mean(seqs_by_user, fn):
+ users = list(seqs_by_user)
+ if len(users) < 2:
+ return float("nan")
+ out = []
+ for i in range(len(users)):
+ for j in range(i + 1, len(users)):
+ a = seqs_by_user[users[i]]
+ b = seqs_by_user[users[j]]
+ m = min(len(a), len(b))
+ for k in range(m):
+ try:
+ v = fn(a[k], b[k])
+ if v == v:
+ out.append(v)
+ except Exception:
+ continue
+ return sum(out) / len(out) if out else float("nan")
+
+
+# ---------------------------------------------------------------------------
+# Top-level entry point
+# ---------------------------------------------------------------------------
+
+def compute_overlap_iaa(item_state_manager, user_state_manager, config: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ Compute IAA across the overlap-sample items that have reached their cap.
+
+ Returns a dict shape:
+ {
+ "schemas": {
+ "": {
+ "kind": "",
+ "annotation_type": "",
+ "metrics": { : , ... },
+ "n_items": int,
+ "n_annotators": int,
+ }
+ },
+ "items": {
+ "": {
+ "annotators": [...],
+ "cap": int,
+ "schemas": {
+ "": { ... per-item metric breakdown ... }
+ }
+ }
+ },
+ "n_overlap_items": int,
+ }
+ """
+ schemes = _extract_schemes(config)
+ if not schemes:
+ return {"schemas": {}, "items": {}, "n_overlap_items": 0}
+
+ # Overlap items: per-item cap >= 2 AND saturated.
+ overlap_items = []
+ for iid, item in item_state_manager.instance_id_to_instance.items():
+ cap = item_state_manager._get_annotator_cap_for_item(iid)
+ if cap is None or cap < 2:
+ continue
+ if len(item_state_manager.instance_annotators[iid]) < cap:
+ continue
+ overlap_items.append(iid)
+
+ # Build {user_id: user_state} for users who touched any overlap item.
+ relevant_user_ids = set()
+ for iid in overlap_items:
+ relevant_user_ids.update(item_state_manager.instance_annotators[iid])
+ user_states = {}
+ for uid in relevant_user_ids:
+ ustate = user_state_manager.get_user_state(uid) if hasattr(user_state_manager, "get_user_state") else None
+ if ustate is not None:
+ user_states[uid] = ustate
+
+ schema_report: Dict[str, Any] = {}
+ item_report: Dict[str, Any] = {iid: {
+ "annotators": sorted(item_state_manager.instance_annotators[iid]),
+ "cap": item_state_manager._get_annotator_cap_for_item(iid),
+ "schemas": {},
+ } for iid in overlap_items}
+
+ for scheme in schemes:
+ name = scheme.get("name")
+ if not name:
+ continue
+ kind = classify_schema(scheme)
+ if kind in (SchemaKind.TEXT, SchemaKind.UNSUPPORTED):
+ continue
+ if kind == SchemaKind.SPAN:
+ rows = _gather_spans(overlap_items, user_states, name)
+ metrics = _aggregate_span(rows, item_state_manager.instance_id_to_instance)
+ else:
+ rows = _gather_labels(overlap_items, user_states, name)
+ if kind == SchemaKind.NOMINAL:
+ metrics = _aggregate_nominal(rows)
+ elif kind == SchemaKind.ORDINAL:
+ metrics = _aggregate_ordinal(rows)
+ elif kind == SchemaKind.CONTINUOUS:
+ metrics = _aggregate_continuous(rows)
+ elif kind == SchemaKind.MULTILABEL:
+ metrics = _aggregate_multilabel(rows)
+ elif kind == SchemaKind.RANKING:
+ metrics = _aggregate_ranking(rows)
+ else:
+ continue
+ schema_report[name] = {
+ "kind": kind.value,
+ "annotation_type": scheme.get("annotation_type"),
+ "metrics": metrics,
+ }
+ for iid in rows if kind != SchemaKind.SPAN else rows:
+ item_report.setdefault(iid, {"annotators": [], "cap": -1, "schemas": {}})
+ item_report[iid]["schemas"][name] = {"n_annotators": len(rows[iid])}
+
+ return {
+ "schemas": schema_report,
+ "items": item_report,
+ "n_overlap_items": len(overlap_items),
+ }
+
+
+def _extract_schemes(config: Dict[str, Any]):
+ """Pull annotation_schemes from the config (top-level or under a phase)."""
+ if "annotation_schemes" in config and isinstance(config["annotation_schemes"], list):
+ return config["annotation_schemes"]
+ schemes = []
+ phases = config.get("phases", {}) or {}
+ for key, val in phases.items():
+ if isinstance(val, dict) and isinstance(val.get("annotation_schemes"), list):
+ schemes.extend(val["annotation_schemes"])
+ return schemes
+
+
+# Local imports placed at the bottom to avoid circular imports at module load.
+from collections import Counter as Counter_ # noqa: E402
diff --git a/potato/server_utils/iaa/multilabel.py b/potato/server_utils/iaa/multilabel.py
new file mode 100644
index 0000000000000000000000000000000000000000..ddc89fe9b1bdc16c839ac69451ec9880279e57bd
--- /dev/null
+++ b/potato/server_utils/iaa/multilabel.py
@@ -0,0 +1,52 @@
+"""
+Multi-label IAA metrics for schemas where each annotator can select a set of labels
+per item (e.g., multiselect, hierarchical_multiselect, card_sort).
+
+Provides MASI distance, Jaccard distance, and pairwise alpha-MASI.
+"""
+
+from __future__ import annotations
+
+from typing import Dict, Iterable, Sequence
+
+from potato.server_utils.iaa.alpha import krippendorff_alpha, _masi_distance
+
+
+def jaccard_distance(set_a: Iterable, set_b: Iterable) -> float:
+ a = frozenset(set_a)
+ b = frozenset(set_b)
+ if not a and not b:
+ return 0.0
+ union = a | b
+ if not union:
+ return 0.0
+ return 1.0 - len(a & b) / len(union)
+
+
+def masi_distance(set_a: Iterable, set_b: Iterable) -> float:
+ return _masi_distance(set_a, set_b)
+
+
+def mean_jaccard(label_sets_by_user: Dict[str, Sequence[Iterable]]) -> float:
+ """Average pairwise (1 - Jaccard distance) across users and items."""
+ users = list(label_sets_by_user)
+ if len(users) < 2:
+ return float("nan")
+ sims = []
+ for i in range(len(users)):
+ a = list(label_sets_by_user[users[i]])
+ for j in range(i + 1, len(users)):
+ b = list(label_sets_by_user[users[j]])
+ m = min(len(a), len(b))
+ if m == 0:
+ continue
+ for k in range(m):
+ sims.append(1.0 - jaccard_distance(a[k], b[k]))
+ if not sims:
+ return float("nan")
+ return sum(sims) / len(sims)
+
+
+def alpha_masi(long_format_sets) -> float:
+ """Krippendorff's alpha with MASI distance on multi-label sets."""
+ return krippendorff_alpha(long_format_sets, level="masi")
diff --git a/potato/server_utils/iaa/nominal.py b/potato/server_utils/iaa/nominal.py
new file mode 100644
index 0000000000000000000000000000000000000000..38a44f8dd3d0cd4e8c34c05de01ea8568f834189
--- /dev/null
+++ b/potato/server_utils/iaa/nominal.py
@@ -0,0 +1,132 @@
+"""
+Nominal IAA metrics: percent agreement, Cohen's kappa, Fleiss' kappa.
+
+Inputs are lists keyed by item: for two-annotator metrics, two equal-length
+label lists; for multi-annotator metrics, a list of (annotator_id -> label) dicts.
+"""
+
+from __future__ import annotations
+
+from collections import Counter
+from math import isclose
+from typing import Dict, List, Sequence
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+def percent_agreement(labels_a: Sequence, labels_b: Sequence) -> float:
+ """Fraction of items on which two annotators agree."""
+ if len(labels_a) != len(labels_b):
+ raise ValueError("label lists must be the same length")
+ if not labels_a:
+ return float("nan")
+ agree = sum(1 for a, b in zip(labels_a, labels_b) if a == b)
+ return agree / len(labels_a)
+
+
+def cohen_kappa(labels_a: Sequence, labels_b: Sequence) -> float:
+ """
+ Cohen's kappa for two annotators on nominal categories.
+
+ Uses sklearn if available (handles ties and edge cases well); falls back
+ to a direct implementation otherwise.
+ """
+ if len(labels_a) != len(labels_b):
+ raise ValueError("label lists must be the same length")
+ if not labels_a:
+ return float("nan")
+ try:
+ from sklearn.metrics import cohen_kappa_score
+ return float(cohen_kappa_score(list(labels_a), list(labels_b)))
+ except ImportError: # pragma: no cover
+ pass
+
+ n = len(labels_a)
+ po = percent_agreement(labels_a, labels_b)
+ counts_a = Counter(labels_a)
+ counts_b = Counter(labels_b)
+ pe = sum(counts_a[c] * counts_b[c] for c in set(counts_a) | set(counts_b)) / (n * n)
+ if isclose(pe, 1.0):
+ return 1.0 if isclose(po, 1.0) else 0.0
+ return (po - pe) / (1 - pe)
+
+
+def fleiss_kappa(per_item_label_counts: List[Dict[str, int]]) -> float:
+ """
+ Fleiss' kappa for >=2 annotators on nominal categories.
+
+ Args:
+ per_item_label_counts: one dict per item mapping label -> number of
+ annotators who chose it. Each item dict must sum to the same N
+ (the number of annotators rating that item). Items where N < 2
+ are skipped.
+
+ Returns:
+ Fleiss' kappa as a float, or NaN if undefined.
+ """
+ # Use only items rated by at least 2 annotators.
+ rated = [d for d in per_item_label_counts if sum(d.values()) >= 2]
+ if not rated:
+ return float("nan")
+
+ ns = [sum(d.values()) for d in rated]
+ if len(set(ns)) != 1:
+ # Variable-N Fleiss' kappa is rare in practice; restrict to majority N.
+ from statistics import mode
+ majority_n = mode(ns)
+ rated = [d for d, n in zip(rated, ns) if n == majority_n]
+ ns = [majority_n] * len(rated)
+ if not rated:
+ return float("nan")
+
+ n = ns[0]
+ categories = sorted({c for d in rated for c in d})
+ if n < 2 or not categories:
+ return float("nan")
+
+ n_items = len(rated)
+ # Per-item agreement P_i
+ p_is = []
+ for d in rated:
+ total = sum(d.get(c, 0) ** 2 for c in categories)
+ p_is.append((total - n) / (n * (n - 1)))
+ p_bar = sum(p_is) / n_items
+ # Marginal proportions per category
+ p_js = []
+ for c in categories:
+ s = sum(d.get(c, 0) for d in rated)
+ p_js.append(s / (n_items * n))
+ p_e = sum(p * p for p in p_js)
+ if isclose(p_e, 1.0):
+ return 1.0 if isclose(p_bar, 1.0) else 0.0
+ return (p_bar - p_e) / (1 - p_e)
+
+
+def pairwise_cohen_kappa(annotations_by_user: Dict[str, Sequence]) -> float:
+ """
+ Mean Cohen's kappa across every distinct pair of annotators.
+
+ annotations_by_user maps user_id -> aligned label sequence (same length per user).
+ Users contributing fewer than the maximum length are restricted to their
+ overlap with each partner.
+ """
+ users = list(annotations_by_user)
+ if len(users) < 2:
+ return float("nan")
+ kappas = []
+ for i in range(len(users)):
+ for j in range(i + 1, len(users)):
+ a = list(annotations_by_user[users[i]])
+ b = list(annotations_by_user[users[j]])
+ m = min(len(a), len(b))
+ if m == 0:
+ continue
+ try:
+ kappas.append(cohen_kappa(a[:m], b[:m]))
+ except ValueError:
+ continue
+ if not kappas:
+ return float("nan")
+ return sum(kappas) / len(kappas)
diff --git a/potato/server_utils/iaa/ordinal.py b/potato/server_utils/iaa/ordinal.py
new file mode 100644
index 0000000000000000000000000000000000000000..a5dc4fc48f6da649492de041fa44e07804b156b4
--- /dev/null
+++ b/potato/server_utils/iaa/ordinal.py
@@ -0,0 +1,66 @@
+"""
+Ordinal IAA metrics: weighted kappa (linear + quadratic), Spearman's rho.
+"""
+
+from __future__ import annotations
+
+from typing import Sequence
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+def _coerce_ordinal(values: Sequence) -> list:
+ """Try to coerce a sequence of (str|int|float) ratings into numeric ranks."""
+ coerced = []
+ for v in values:
+ if isinstance(v, (int, float)):
+ coerced.append(float(v))
+ else:
+ try:
+ coerced.append(float(v))
+ except (TypeError, ValueError):
+ # Fall back to lexical ordering by stable string sort
+ coerced.append(str(v))
+ if any(isinstance(c, str) for c in coerced):
+ rank = {c: i for i, c in enumerate(sorted(set(coerced)))}
+ return [rank[c] for c in coerced]
+ return coerced
+
+
+def weighted_kappa(labels_a: Sequence, labels_b: Sequence, weights: str = "quadratic") -> float:
+ """
+ Cohen's weighted kappa for ordinal categories.
+
+ weights: 'linear' or 'quadratic' (CKD convention).
+ """
+ if len(labels_a) != len(labels_b):
+ raise ValueError("label lists must be the same length")
+ if not labels_a:
+ return float("nan")
+ try:
+ from sklearn.metrics import cohen_kappa_score
+ a = _coerce_ordinal(labels_a)
+ b = _coerce_ordinal(labels_b)
+ return float(cohen_kappa_score(a, b, weights=weights))
+ except ImportError: # pragma: no cover
+ logger.warning("sklearn unavailable; weighted_kappa returning NaN")
+ return float("nan")
+
+
+def spearman_rho(labels_a: Sequence, labels_b: Sequence) -> float:
+ """Spearman rank correlation between two annotators."""
+ if len(labels_a) != len(labels_b):
+ raise ValueError("label lists must be the same length")
+ if len(labels_a) < 2:
+ return float("nan")
+ try:
+ from scipy.stats import spearmanr
+ a = _coerce_ordinal(labels_a)
+ b = _coerce_ordinal(labels_b)
+ rho, _ = spearmanr(a, b)
+ return float(rho) if rho == rho else float("nan") # NaN-safe
+ except ImportError: # pragma: no cover
+ logger.warning("scipy unavailable; spearman_rho returning NaN")
+ return float("nan")
diff --git a/potato/server_utils/iaa/ranking.py b/potato/server_utils/iaa/ranking.py
new file mode 100644
index 0000000000000000000000000000000000000000..b8914cf87c154a632d56164e27ecea6ca61f6e82
--- /dev/null
+++ b/potato/server_utils/iaa/ranking.py
@@ -0,0 +1,45 @@
+"""
+Ranking IAA metrics for schemas where each annotator produces an ordering
+(e.g., ranking, best-worst scaling, pairwise).
+"""
+
+from __future__ import annotations
+
+from typing import Sequence
+
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+def kendall_tau(ranking_a: Sequence, ranking_b: Sequence) -> float:
+ """Kendall's tau-b between two rankings (lists of comparable items)."""
+ if len(ranking_a) != len(ranking_b):
+ raise ValueError("rankings must be the same length")
+ if len(ranking_a) < 2:
+ return float("nan")
+ try:
+ from scipy.stats import kendalltau
+ tau, _ = kendalltau(list(ranking_a), list(ranking_b))
+ return float(tau) if tau == tau else float("nan")
+ except ImportError: # pragma: no cover
+ logger.warning("scipy unavailable; kendall_tau returning NaN")
+ return float("nan")
+
+
+def spearman_footrule(ranking_a: Sequence, ranking_b: Sequence) -> float:
+ """
+ Normalized Spearman footrule distance. 0 = identical, 1 = maximally disagree.
+
+ Items are matched by identity; missing items get max-rank.
+ """
+ items = list({*ranking_a, *ranking_b})
+ if len(items) < 2:
+ return float("nan")
+ n = len(items)
+ rank_a = {item: i for i, item in enumerate(ranking_a)}
+ rank_b = {item: i for i, item in enumerate(ranking_b)}
+ total = sum(abs(rank_a.get(it, n) - rank_b.get(it, n)) for it in items)
+ # Worst-case footrule for n items is floor(n^2 / 2)
+ worst = (n * n) // 2 if n > 0 else 1
+ return total / worst if worst else float("nan")
diff --git a/potato/server_utils/iaa/span.py b/potato/server_utils/iaa/span.py
new file mode 100644
index 0000000000000000000000000000000000000000..e67b30fae26eaebd41fb8cafd67bb81b27649e2e
--- /dev/null
+++ b/potato/server_utils/iaa/span.py
@@ -0,0 +1,395 @@
+"""
+Span-specific IAA metrics.
+
+Span annotations are unusual: annotators can disagree on (a) **where** spans
+go (unitization / boundary detection) and (b) **what label** each span carries
+(categorization). Token-level kappa and exact-match F1 only capture part of
+this picture, which is why dedicated metrics exist:
+
+- **Token-level Cohen / Fleiss kappa** via BIO conversion โ simple,
+ intuitive, but penalizes near-misses harshly and ignores spans of differing
+ lengths.
+- **Span F1 (exact, partial)** โ IR-style; classic in NER literature
+ (MUC, CoNLL, SemEval).
+- **Krippendorff's alpha_U (unitizing alpha)** โ Krippendorff 2018; treats
+ each character/token as a unit and accounts for both boundary and
+ categorical disagreement.
+- **Gamma (Mathet et al. 2015)** โ state-of-the-art unified measure that
+ jointly handles unit alignment + categorization via the Hungarian algorithm.
+
+All inputs are ``SpanAnnotation``-like objects with ``start``, ``end``, and
+``name`` (label) attributes โ or plain dicts/tuples with the same fields.
+"""
+
+from __future__ import annotations
+
+from collections import Counter, defaultdict
+from typing import Dict, Iterable, List, Optional, Sequence, Tuple
+
+import logging
+
+from potato.server_utils.iaa.nominal import cohen_kappa, fleiss_kappa
+
+logger = logging.getLogger(__name__)
+
+
+# ---------------------------------------------------------------------------
+# Span representation helpers
+# ---------------------------------------------------------------------------
+
+def _span_tuple(span) -> Tuple[int, int, str]:
+ """Normalise a span object to (start, end, label)."""
+ if isinstance(span, dict):
+ return int(span["start"]), int(span["end"]), str(span.get("name") or span.get("label", ""))
+ if isinstance(span, tuple) and len(span) == 3:
+ return int(span[0]), int(span[1]), str(span[2])
+ return int(span.start), int(span.end), str(span.name)
+
+
+def _normalize(spans: Iterable) -> List[Tuple[int, int, str]]:
+ return [_span_tuple(s) for s in spans]
+
+
+# ---------------------------------------------------------------------------
+# Token-level kappa via BIO conversion
+# ---------------------------------------------------------------------------
+
+def spans_to_bio(spans: Iterable, length: int) -> List[str]:
+ """
+ Convert spans to BIO tags over a unit sequence of length ``length``.
+
+ ``length`` can be in characters or tokens depending on the unit; the
+ representation is the same. Overlapping spans are resolved with the rule
+ "longest span wins" โ sufficient for IAA where overlap is rare.
+ """
+ tags = ["O"] * length
+ span_list = sorted(_normalize(spans), key=lambda s: -(s[1] - s[0]))
+ for start, end, label in span_list:
+ start = max(0, start)
+ end = min(length, end)
+ if end <= start:
+ continue
+ if tags[start] != "O":
+ continue # respect longest-wins
+ tags[start] = f"B-{label}"
+ for i in range(start + 1, end):
+ if tags[i] != "O":
+ continue
+ tags[i] = f"I-{label}"
+ return tags
+
+
+def token_level_kappa(
+ spans_by_user: Dict[str, Iterable],
+ length: int,
+) -> float:
+ """
+ Cohen's / Fleiss' kappa over the BIO tag sequence.
+
+ For 2 annotators, returns Cohen's kappa; for >=3, returns Fleiss' kappa.
+ """
+ users = list(spans_by_user)
+ if len(users) < 2 or length <= 0:
+ return float("nan")
+ tag_seqs = {u: spans_to_bio(spans_by_user[u], length) for u in users}
+
+ if len(users) == 2:
+ return cohen_kappa(tag_seqs[users[0]], tag_seqs[users[1]])
+
+ counts_per_position = []
+ for i in range(length):
+ c: Counter = Counter()
+ for u in users:
+ c[tag_seqs[u][i]] += 1
+ counts_per_position.append(dict(c))
+ return fleiss_kappa(counts_per_position)
+
+
+# ---------------------------------------------------------------------------
+# Span F1 (exact and partial match)
+# ---------------------------------------------------------------------------
+
+def _overlap_len(a: Tuple[int, int, str], b: Tuple[int, int, str]) -> int:
+ return max(0, min(a[1], b[1]) - max(a[0], b[0]))
+
+
+def span_f1_exact(spans_a: Iterable, spans_b: Iterable) -> Tuple[float, float, float]:
+ """
+ Strict exact-match F1: (start, end, label) must match exactly.
+
+ Returns (precision, recall, F1) treating spans_b as gold.
+ """
+ a = set(_normalize(spans_a))
+ b = set(_normalize(spans_b))
+ if not a and not b:
+ return 1.0, 1.0, 1.0
+ tp = len(a & b)
+ p = tp / len(a) if a else 0.0
+ r = tp / len(b) if b else 0.0
+ f1 = 2 * p * r / (p + r) if (p + r) else 0.0
+ return p, r, f1
+
+
+def span_f1_partial(
+ spans_a: Iterable,
+ spans_b: Iterable,
+ label_must_match: bool = True,
+ threshold: float = 0.5,
+) -> Tuple[float, float, float]:
+ """
+ Partial-match F1: a span counts as TP if it overlaps a gold span by at
+ least ``threshold`` of either span's length (Dice-overlap convention).
+
+ label_must_match: when True (default) overlapping spans must share the
+ same label to count; False allows boundary-only agreement.
+ """
+ a = _normalize(spans_a)
+ b = _normalize(spans_b)
+ if not a and not b:
+ return 1.0, 1.0, 1.0
+ matched_b = set()
+ tp = 0
+ for sa in a:
+ for idx, sb in enumerate(b):
+ if idx in matched_b:
+ continue
+ if label_must_match and sa[2] != sb[2]:
+ continue
+ ov = _overlap_len(sa, sb)
+ if ov <= 0:
+ continue
+ la, lb = sa[1] - sa[0], sb[1] - sb[0]
+ if la <= 0 or lb <= 0:
+ continue
+ if (ov / la) >= threshold or (ov / lb) >= threshold:
+ tp += 1
+ matched_b.add(idx)
+ break
+ p = tp / len(a) if a else 0.0
+ r = tp / len(b) if b else 0.0
+ f1 = 2 * p * r / (p + r) if (p + r) else 0.0
+ return p, r, f1
+
+
+def pairwise_span_f1(
+ spans_by_user: Dict[str, Iterable],
+ partial: bool = False,
+ threshold: float = 0.5,
+) -> float:
+ """Mean pairwise span-F1 across users."""
+ users = list(spans_by_user)
+ if len(users) < 2:
+ return float("nan")
+ scores = []
+ for i in range(len(users)):
+ for j in range(i + 1, len(users)):
+ if partial:
+ _, _, f1 = span_f1_partial(
+ spans_by_user[users[i]], spans_by_user[users[j]], threshold=threshold,
+ )
+ else:
+ _, _, f1 = span_f1_exact(spans_by_user[users[i]], spans_by_user[users[j]])
+ scores.append(f1)
+ if not scores:
+ return float("nan")
+ return sum(scores) / len(scores)
+
+
+# ---------------------------------------------------------------------------
+# Krippendorff's alpha_U (unitizing alpha)
+# ---------------------------------------------------------------------------
+
+def krippendorff_alpha_u(
+ spans_by_user: Dict[str, Iterable],
+ length: int,
+) -> float:
+ """
+ Krippendorff's unitizing alpha for span annotation.
+
+ Implementation: assign each character/token position a categorical label
+ (one of the span labels or "O") per annotator, then compute Krippendorff's
+ alpha (nominal) over the (annotator, position) pairs. This is the
+ operational form recommended in Krippendorff (2018) when the unit is
+ fixed (per-character) rather than continuous.
+
+ For continuous-domain alpha_U (where annotators may disagree on the unit
+ boundary in a fundamentally continuous space such as audio), prefer gamma.
+ """
+ users = list(spans_by_user)
+ if len(users) < 2 or length <= 0:
+ return float("nan")
+
+ rows = []
+ for u in users:
+ tags = spans_to_bio(spans_by_user[u], length)
+ # Map BIO -> base label (strip B-/I- prefix) so boundary placement
+ # within a contiguous span doesn't count as disagreement.
+ for pos, tag in enumerate(tags):
+ label = "O" if tag == "O" else tag.split("-", 1)[1]
+ rows.append((u, pos, label))
+
+ from potato.server_utils.iaa.alpha import krippendorff_alpha
+ return krippendorff_alpha(rows, level="nominal")
+
+
+# ---------------------------------------------------------------------------
+# Gamma (Mathet et al. 2015)
+# ---------------------------------------------------------------------------
+
+def _positional_dissimilarity(
+ a: Tuple[int, int, str],
+ b: Tuple[int, int, str],
+ delta_empty: float,
+) -> float:
+ """Positional component of the Mathet dissimilarity (normalized)."""
+ if a is None or b is None:
+ return delta_empty
+ # Sum of |starts diff| + |ends diff|, normalized by total span lengths.
+ diff = abs(a[0] - b[0]) + abs(a[1] - b[1])
+ total = (a[1] - a[0]) + (b[1] - b[0])
+ if total <= 0:
+ return delta_empty
+ return diff / total
+
+
+def _categorical_dissimilarity(
+ a: Tuple[int, int, str],
+ b: Tuple[int, int, str],
+ delta_empty: float,
+) -> float:
+ if a is None or b is None:
+ return delta_empty
+ return 0.0 if a[2] == b[2] else 1.0
+
+
+def _pairwise_disorder(
+ spans_a: List[Tuple[int, int, str]],
+ spans_b: List[Tuple[int, int, str]],
+ alpha: float,
+ beta: float,
+ delta_empty: float,
+) -> float:
+ """
+ Optimal-alignment disorder between two annotators' span sets.
+
+ Uses the Hungarian algorithm (``scipy.optimize.linear_sum_assignment``)
+ with padded empty units so that |spans_a| != |spans_b| is handled.
+ """
+ try:
+ import numpy as np
+ from scipy.optimize import linear_sum_assignment
+ except ImportError: # pragma: no cover
+ logger.warning("scipy unavailable; gamma falling back to NaN")
+ return float("nan")
+
+ n = max(len(spans_a), len(spans_b))
+ if n == 0:
+ return 0.0
+ # Pad shorter side with None (= empty unit)
+ a_padded: List[Optional[Tuple[int, int, str]]] = list(spans_a) + [None] * (n - len(spans_a))
+ b_padded: List[Optional[Tuple[int, int, str]]] = list(spans_b) + [None] * (n - len(spans_b))
+
+ cost = np.zeros((n, n), dtype=float)
+ for i in range(n):
+ for j in range(n):
+ pos = _positional_dissimilarity(a_padded[i], b_padded[j], delta_empty)
+ cat = _categorical_dissimilarity(a_padded[i], b_padded[j], delta_empty)
+ cost[i, j] = alpha * pos + beta * cat
+
+ row_ind, col_ind = linear_sum_assignment(cost)
+ total = float(cost[row_ind, col_ind].sum())
+ return total / n
+
+
+def gamma(
+ spans_by_user: Dict[str, Iterable],
+ length: Optional[int] = None,
+ alpha: float = 1.0,
+ beta: float = 1.0,
+ n_samples: int = 30,
+ seed: int = 1234,
+) -> float:
+ """
+ Mathet et al. (2015) gamma agreement.
+
+ Args:
+ spans_by_user: annotator_id -> iterable of spans
+ length: total length of the unit space (characters or tokens). If
+ omitted, inferred from the maximum span end across annotators.
+ alpha: weight on positional dissimilarity.
+ beta: weight on categorical dissimilarity.
+ n_samples: number of random pairings used to estimate the
+ expected-by-chance disorder.
+ seed: RNG seed for reproducibility.
+
+ Returns:
+ gamma in [-1, 1] approximately, where 1 = perfect agreement, 0 =
+ chance-level. NaN if scipy is unavailable.
+
+ Notes:
+ This implementation is a faithful but simplified rendition: positional
+ dissimilarity is normalized by combined span length, and the chance
+ baseline is estimated by re-pairing spans across all annotators
+ ``n_samples`` times. Full pygamma-agreement uses a more sophisticated
+ baseline (continuum-of-shuffles); the simplification is sufficient for
+ the relative IAA comparisons that drive routing decisions.
+ """
+ import random as _random
+
+ users = list(spans_by_user)
+ if len(users) < 2:
+ return float("nan")
+ normed = {u: _normalize(spans_by_user[u]) for u in users}
+
+ # Empty-unit dissimilarity follows Mathet: a moderate constant ~ 1
+ delta_empty = 1.0
+
+ # Observed disorder: mean pairwise disorder across all annotator pairs
+ pair_disorders = []
+ for i in range(len(users)):
+ for j in range(i + 1, len(users)):
+ pair_disorders.append(
+ _pairwise_disorder(normed[users[i]], normed[users[j]], alpha, beta, delta_empty)
+ )
+ if not pair_disorders:
+ return float("nan")
+ if any(d != d for d in pair_disorders): # NaN -> bail
+ return float("nan")
+ observed = sum(pair_disorders) / len(pair_disorders)
+
+ # Expected-by-chance disorder via shuffled pairings
+ all_spans = [s for u in users for s in normed[u]]
+ if len(all_spans) < 2:
+ return 1.0 if observed == 0 else float("nan")
+
+ rng = _random.Random(seed)
+ chance_disorders = []
+ sizes = [len(normed[u]) for u in users]
+ for _ in range(n_samples):
+ shuffled = list(all_spans)
+ rng.shuffle(shuffled)
+ # Re-distribute back to annotators preserving original counts
+ idx = 0
+ shuffled_per_user = []
+ for sz in sizes:
+ shuffled_per_user.append(shuffled[idx:idx + sz])
+ idx += sz
+ sample_pair_disorders = []
+ for i in range(len(users)):
+ for j in range(i + 1, len(users)):
+ sample_pair_disorders.append(
+ _pairwise_disorder(
+ shuffled_per_user[i],
+ shuffled_per_user[j],
+ alpha, beta, delta_empty,
+ )
+ )
+ if sample_pair_disorders:
+ chance_disorders.append(sum(sample_pair_disorders) / len(sample_pair_disorders))
+
+ if not chance_disorders:
+ return float("nan")
+ expected = sum(chance_disorders) / len(chance_disorders)
+ if expected <= 0:
+ return 1.0 if observed == 0 else float("nan")
+ return 1.0 - (observed / expected)
diff --git a/potato/server_utils/instance_display.py b/potato/server_utils/instance_display.py
new file mode 100644
index 0000000000000000000000000000000000000000..96f3cd7fe0456eec067e20797a1a0b5c189318c4
--- /dev/null
+++ b/potato/server_utils/instance_display.py
@@ -0,0 +1,418 @@
+"""
+Instance Display Renderer
+
+Provides the main InstanceDisplayRenderer class that handles rendering
+instance content for display, separate from annotation collection.
+
+This module enables the new `instance_display` configuration section
+that explicitly defines what content to show annotators.
+
+Usage:
+ from potato.server_utils.instance_display import InstanceDisplayRenderer
+
+ renderer = InstanceDisplayRenderer(config)
+ html = renderer.render(instance_data)
+ template_vars = renderer.get_template_variables(instance_data)
+"""
+
+import html as html_module
+import logging
+from typing import Dict, Any, List, Optional
+
+from .displays import display_registry
+
+logger = logging.getLogger(__name__)
+
+
+class InstanceDisplayError(Exception):
+ """Exception raised when instance display rendering fails."""
+ pass
+
+
+class InstanceDisplayRenderer:
+ """
+ Renders instance content for display based on configuration.
+
+ This class separates content display from annotation collection,
+ allowing any combination of display types with any annotation schemes.
+ """
+
+ def __init__(self, config: Dict[str, Any]):
+ """
+ Initialize the renderer.
+
+ Args:
+ config: The full configuration dictionary
+ """
+ self.config = config
+ self.display_config = config.get("instance_display", {})
+ self.fields = self.display_config.get("fields", [])
+ self.layout = self.display_config.get("layout", {})
+
+ # Extract span targets โ query the registry instead of a hardcoded list
+ self.span_targets = [
+ f["key"] for f in self.fields
+ if f.get("span_target") and display_registry.type_supports_span_target(f.get("type", ""))
+ ]
+
+ # Warn about span_target on unsupported types
+ for f in self.fields:
+ if f.get("span_target") and not display_registry.type_supports_span_target(f.get("type", "")):
+ logger.warning(
+ f"Field '{f.get('key')}' has span_target=true but display type "
+ f"'{f.get('type')}' does not support span annotation. "
+ f"Span annotation will not work on this field."
+ )
+
+ # Track if we have instance_display configured
+ self.has_instance_display = bool(self.fields)
+
+ logger.debug(
+ f"InstanceDisplayRenderer initialized: "
+ f"has_instance_display={self.has_instance_display}, "
+ f"span_targets={self.span_targets}"
+ )
+
+ def render(self, instance_data: Dict[str, Any]) -> str:
+ """
+ Render all display fields for an instance.
+
+ Args:
+ instance_data: The instance data dictionary
+
+ Returns:
+ HTML string containing all rendered display fields
+
+ Raises:
+ InstanceDisplayError: If a required field is missing from instance data
+ """
+ if not self.has_instance_display:
+ # No instance_display configured, return empty
+ # (legacy behavior will be handled by the template)
+ return ""
+
+ # Validate all required fields exist
+ self._validate_fields(instance_data)
+
+ # Get layout configuration
+ direction = self.layout.get("direction", "vertical")
+ gap = self.layout.get("gap", "20px")
+
+ # Build container classes and styles
+ container_classes = ["instance-display-container", f"layout-{direction}"]
+ container_style = f"gap: {gap};"
+
+ # Render each field
+ rendered_fields = []
+ for field in self.fields:
+ field_html = self._render_field(field, instance_data)
+ rendered_fields.append(field_html)
+
+ # Combine into container
+ fields_html = "\n".join(rendered_fields)
+
+ # Build data attributes for raw field access by annotation schemas
+ # Include all string/URL fields from instance data for source_field lookups
+ import json
+ raw_data = {}
+ for key, value in instance_data.items():
+ if isinstance(value, (str, int, float, bool)) or value is None:
+ raw_data[key] = value
+ raw_data_json = html_module.escape(json.dumps(raw_data))
+
+ return f'''
+
+ {fields_html}
+
+ '''
+
+ def _validate_fields(self, instance_data: Dict[str, Any]) -> None:
+ """
+ Validate that all configured fields exist in the instance data.
+
+ Fields whose display type is marked ``lazy_populated`` in the
+ display registry (``interactive_chat``, ``live_agent``,
+ ``live_coding_agent``) are exempt -- their data key is expected
+ to be written after initial render (by a live agent session).
+
+ Args:
+ instance_data: The instance data dictionary
+
+ Raises:
+ InstanceDisplayError: If any non-lazy field is missing
+ """
+ non_lazy = [
+ f for f in self.fields
+ if not display_registry.is_lazy_populated(f.get("type", ""))
+ ]
+ missing_non_lazy = [
+ f["key"] for f in non_lazy if f["key"] not in instance_data
+ ]
+ # Every non-lazy field missing is almost always a config/data
+ # key mismatch (e.g. fields reference task_description but the
+ # data uses task), not a transient lazy state -- make it loud so
+ # it isn't silently rendered as a blank page.
+ if non_lazy and len(missing_non_lazy) == len(non_lazy):
+ logger.error(
+ "instance_display: ALL %d non-lazy field(s) %s are absent "
+ "from the instance data (available keys: %s). This is "
+ "almost certainly a config/data key mismatch.",
+ len(non_lazy), missing_non_lazy,
+ list(instance_data.keys()),
+ )
+
+ for field in self.fields:
+ key = field["key"]
+ if key in instance_data:
+ continue
+ field_type = field.get("type", "")
+ if display_registry.is_lazy_populated(field_type):
+ logger.debug(
+ "Skipping validation for lazy-populated field '%s' (type=%s); "
+ "data is written after initial render.",
+ key, field_type,
+ )
+ continue
+ available = list(instance_data.keys())
+ raise InstanceDisplayError(
+ f"Display field '{key}' not found in instance data. "
+ f"Available fields: {available}"
+ )
+
+ def _render_field(self, field: Dict[str, Any], instance_data: Dict[str, Any]) -> str:
+ """
+ Render a single display field.
+
+ Args:
+ field: The field configuration
+ instance_data: The instance data dictionary
+
+ Returns:
+ HTML string for the field
+ """
+ key = field["key"]
+ field_type = field["type"]
+ data = instance_data.get(key)
+
+ # For format-based display types, process the file if data is a file path
+ format_display_types = ["pdf", "document", "spreadsheet", "code"]
+ if field_type in format_display_types and isinstance(data, str):
+ data = self._process_format_file(data, field_type, field)
+
+ try:
+ rendered = display_registry.render(field_type, field, data)
+
+ # Check if resizable is enabled (global setting or per-field override)
+ global_resizable = self.display_config.get("resizable", True)
+ field_resizable = field.get("display_options", {}).get("resizable", global_resizable)
+
+ # Wrap with resizable container if enabled
+ if field_resizable:
+ rendered = self._wrap_resizable(rendered, field)
+
+ return rendered
+ except ValueError as e:
+ logger.error(f"Error rendering field '{key}': {e}")
+ return f'Error rendering field "{key}": {e}
'
+
+ def _wrap_resizable(self, inner_html: str, field: Dict[str, Any]) -> str:
+ """
+ Wrap rendered content in a resizable container.
+
+ Args:
+ inner_html: The rendered field HTML
+ field: The field configuration
+
+ Returns:
+ HTML wrapped in resizable container
+ """
+ display_options = field.get("display_options", {})
+ max_height = display_options.get("max_height", 500)
+ min_height = display_options.get("min_height", 100)
+
+ style = f"max-height: {max_height}px; min-height: {min_height}px; position: relative;"
+
+ return f'''
+ {inner_html}
+
'''
+
+ def _process_format_file(
+ self,
+ file_path: str,
+ display_type: str,
+ field: Dict[str, Any]
+ ) -> Any:
+ """
+ Process a file using the format handler system.
+
+ If the data is a file path and a format handler is available,
+ extract the content and return FormatOutput data.
+
+ Args:
+ file_path: Path to the file to process
+ display_type: The display type (pdf, document, etc.)
+ field: The field configuration
+
+ Returns:
+ Either the original file_path (for client-side rendering like PDF.js)
+ or extracted content dict for server-side rendering
+ """
+ try:
+ from potato.format_handlers import format_handler_registry
+ except ImportError:
+ # Format handlers not available, return original data
+ logger.debug("Format handlers not available, using raw file path")
+ return file_path
+
+ # Check if the file path should be processed
+ # For PDFs, we typically use client-side rendering with PDF.js
+ # unless explicitly configured for server-side extraction
+ display_options = field.get("display_options", {})
+
+ if display_type == "pdf":
+ # By default, PDFs use client-side rendering (return path as-is)
+ # If server_extract is set, use the format handler
+ if not display_options.get("server_extract", False):
+ return file_path
+
+ # Check if format handler can handle this file
+ if not format_handler_registry.can_handle(file_path):
+ logger.debug(f"No format handler for {file_path}, using raw data")
+ return file_path
+
+ try:
+ # Extract content using format handler
+ extraction_options = display_options.get("extraction_options", {})
+ output = format_handler_registry.extract(file_path, options=extraction_options)
+
+ # Return as dict for the display renderer
+ return {
+ "text": output.text,
+ "rendered_html": output.rendered_html,
+ "coordinate_map": output.coordinate_map,
+ "metadata": output.metadata,
+ "format_name": output.format_name,
+ "source_path": output.source_path,
+ }
+ except Exception as e:
+ logger.warning(f"Format handler extraction failed for {file_path}: {e}")
+ return file_path
+
+ def get_template_variables(self, instance_data: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ Get template variables for Jinja access.
+
+ Returns a dictionary with:
+ - display_html: The complete rendered display HTML
+ - display_fields: Dictionary of field key -> rendered HTML
+ - display_raw: Dictionary of field key -> raw data value
+ - span_targets: List of field keys that are span targets
+ - multi_span_mode: Boolean indicating if multiple span targets exist
+ - has_instance_display: Boolean indicating if instance_display is configured
+
+ Args:
+ instance_data: The instance data dictionary
+
+ Returns:
+ Dictionary of template variables
+ """
+ result = {
+ "display_html": "",
+ "display_fields": {},
+ "display_raw": {},
+ "span_targets": self.span_targets,
+ "multi_span_mode": len(self.span_targets) > 1,
+ "has_instance_display": self.has_instance_display,
+ }
+
+ if not self.has_instance_display:
+ return result
+
+ # Validate fields. A missing field here is a real config problem
+ # (lazy-populated types like interactive_chat are already filtered
+ # out by _validate_fields), but the renderer surfaces it inline
+ # via ``display_error`` so the page still loads -- WARN is the
+ # right severity, not ERROR.
+ try:
+ self._validate_fields(instance_data)
+ except InstanceDisplayError as e:
+ logger.warning(f"Field validation failed: {e}")
+ result["display_error"] = str(e)
+ return result
+
+ # Render complete display
+ result["display_html"] = self.render(instance_data)
+
+ # Render individual fields and collect raw data
+ for field in self.fields:
+ key = field["key"]
+ field_type = field["type"]
+ data = instance_data.get(key)
+
+ result["display_raw"][key] = data
+
+ try:
+ result["display_fields"][key] = display_registry.render(field_type, field, data)
+ except ValueError as e:
+ logger.error(f"Error rendering field '{key}': {e}")
+ result["display_fields"][key] = f'Error: {e}
'
+
+ return result
+
+ def get_span_target_fields(self) -> List[Dict[str, Any]]:
+ """
+ Get the list of fields configured as span targets.
+
+ Returns:
+ List of field configuration dictionaries for span targets
+ """
+ return [f for f in self.fields if f.get("span_target")]
+
+ def get_primary_text_field(self) -> Optional[str]:
+ """
+ Get the primary text field key for legacy compatibility.
+
+ Returns the first span target if any, otherwise the first text field,
+ otherwise None.
+
+ Returns:
+ Field key string or None
+ """
+ # First, check span targets
+ if self.span_targets:
+ return self.span_targets[0]
+
+ # Then look for any text field
+ for field in self.fields:
+ if field.get("type") == "text":
+ return field["key"]
+
+ return None
+
+ def should_use_legacy_display(self) -> bool:
+ """
+ Check if legacy display mode should be used.
+
+ Returns True if no instance_display is configured, meaning
+ the template should fall back to displaying text_key.
+
+ Returns:
+ True if legacy mode should be used
+ """
+ return not self.has_instance_display
+
+
+def get_instance_display_renderer(config: Dict[str, Any]) -> InstanceDisplayRenderer:
+ """
+ Get or create an InstanceDisplayRenderer for the given config.
+
+ This is a convenience function that creates a renderer.
+ In the future, this could cache renderers per config hash.
+
+ Args:
+ config: The configuration dictionary
+
+ Returns:
+ InstanceDisplayRenderer instance
+ """
+ return InstanceDisplayRenderer(config)
diff --git a/potato/server_utils/json.py b/potato/server_utils/json.py
new file mode 100644
index 0000000000000000000000000000000000000000..325a0e53a1b10c044211150ea2c02cddf40fdb53
--- /dev/null
+++ b/potato/server_utils/json.py
@@ -0,0 +1,19 @@
+"""
+filename: json.py
+date: 10/16/2024
+author: Tristan Hilbert (aka TFlexSoom)
+desc: Json encoding utilities for the potato tool
+"""
+
+import dataclasses
+import json
+from typing import Any
+
+class EnhancedJSONEncoder(json.JSONEncoder):
+ def default(self, o):
+ if dataclasses.is_dataclass(o):
+ return dataclasses.asdict(o)
+ return super().default(o)
+
+def easy_json(obj: Any):
+ return json.dumps(obj, cls=EnhancedJSONEncoder)
\ No newline at end of file
diff --git a/potato/server_utils/judge_alignment.py b/potato/server_utils/judge_alignment.py
new file mode 100644
index 0000000000000000000000000000000000000000..ca21788fa646e663498af2791679354d7f7de59b
--- /dev/null
+++ b/potato/server_utils/judge_alignment.py
@@ -0,0 +1,349 @@
+"""
+Judge โ human alignment: persistence + agreement computation.
+
+Pairs each LLM-judge verdict (from ``potato/ai/judge.py``) with the human gold
+label for the same instance/schema and computes Cohen's ฮบ, a confusion matrix,
+agreement rate, and the list of disagreements. Judge predictions are persisted
+per *prompt version* so the admin report can track ฮบ as the rubric is calibrated.
+
+Layout under ``{task_dir}/judge_alignment/``:
+ predictions.json -> {prompt_version: {"::": JudgePrediction}}
+ comparisons.json -> [{instance_id, schema, human_label, judge_label, agrees, prompt_version}]
+ (running log written by the inline capture path)
+
+The ฮบ computation reuses ``potato/agreement.py`` (judge vs. human gold as two
+"annotators"). The pure ``compute_alignment_from_pairs`` is the unit-testable core.
+"""
+
+import json
+import logging
+import os
+from collections import Counter, defaultdict
+from typing import Any, Dict, List, Optional, Tuple
+
+logger = logging.getLogger(__name__)
+
+
+# ----- paths / persistence ----------------------------------------------
+
+def _dir(config: Dict[str, Any]) -> str:
+ base = config.get("output_annotation_dir") or config.get("task_dir") or "."
+ return os.path.join(base, "judge_alignment")
+
+
+def _load_json(path: str, default):
+ try:
+ with open(path, "r", encoding="utf-8") as f:
+ return json.load(f)
+ except (FileNotFoundError, ValueError):
+ return default
+
+
+def _save_json(path: str, data) -> None:
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ with open(path, "w", encoding="utf-8") as f:
+ json.dump(data, f, indent=2, ensure_ascii=False)
+
+
+def predictions_path(config: Dict[str, Any]) -> str:
+ return os.path.join(_dir(config), "predictions.json")
+
+
+def comparisons_path(config: Dict[str, Any]) -> str:
+ return os.path.join(_dir(config), "comparisons.json")
+
+
+def load_predictions(config: Dict[str, Any]) -> Dict[str, Dict[str, dict]]:
+ return _load_json(predictions_path(config), {})
+
+
+def save_prediction(config: Dict[str, Any], pred) -> None:
+ """Persist one JudgePrediction (keyed by prompt_version โ instance::schema)."""
+ data = load_predictions(config)
+ version = pred.prompt_version or "default"
+ data.setdefault(version, {})[f"{pred.instance_id}::{pred.schema_name}"] = pred.to_dict()
+ _save_json(predictions_path(config), data)
+
+
+def latest_prompt_version(config: Dict[str, Any]) -> Optional[str]:
+ data = load_predictions(config)
+ if not data:
+ return None
+ # Most-populated version is the "current" working set.
+ return max(data.keys(), key=lambda v: len(data[v]))
+
+
+def record_comparison(config: Dict[str, Any], instance_id: str, schema: str,
+ human_label: Any, judge_label: Any, prompt_version: str) -> None:
+ """Append a humanโjudge comparison to the running log (inline capture)."""
+ log = _load_json(comparisons_path(config), [])
+ log.append({
+ "instance_id": instance_id,
+ "schema": schema,
+ "human_label": str(human_label),
+ "judge_label": str(judge_label),
+ "agrees": str(human_label) == str(judge_label),
+ "prompt_version": prompt_version,
+ })
+ _save_json(comparisons_path(config), log)
+
+
+def running_agreement(config: Dict[str, Any], schema: Optional[str] = None) -> Dict[str, Any]:
+ """Quick running agreement from the comparison log (for the inline badge)."""
+ log = _load_json(comparisons_path(config), [])
+ if schema:
+ log = [c for c in log if c.get("schema") == schema]
+ n = len(log)
+ agree = sum(1 for c in log if c.get("agrees"))
+ pairs = {s: [] for s in {c["schema"] for c in log}}
+ for c in log:
+ pairs[c["schema"]].append((c["instance_id"], c["human_label"], c["judge_label"], None, ""))
+ kappa = None
+ if schema and pairs.get(schema):
+ res = compute_alignment_from_pairs({schema: pairs[schema]}).get(schema, {})
+ kappa = res.get("kappa")
+ return {"n": n, "agreements": agree,
+ "agreement_rate": round(agree / n, 3) if n else 0.0, "kappa": kappa}
+
+
+# ----- human label extraction --------------------------------------------
+
+def human_label_for(instance_id: str, schema_name: str, username: str) -> Optional[str]:
+ """The single categorical label a user assigned for a schema, or None."""
+ from potato.flask_server import get_annotations_for_user_on
+ anns = get_annotations_for_user_on(username, instance_id) or {}
+ chosen = anns.get(schema_name)
+ if not chosen:
+ return None
+ # Single-choice: the (first) selected label name.
+ keys = [k for k in chosen.keys()]
+ return keys[0] if keys else None
+
+
+def majority_human_label(instance_id: str, schema_name: str, users: List[str]) -> Optional[str]:
+ votes = []
+ for u in users:
+ lab = human_label_for(instance_id, schema_name, u)
+ if lab is not None:
+ votes.append(lab)
+ if not votes:
+ return None
+ return Counter(votes).most_common(1)[0][0]
+
+
+# ----- agreement computation (pure core) ----------------------------------
+
+def compute_alignment_from_pairs(
+ pairs_by_schema: Dict[str, List[Tuple[str, Any, Any, Optional[float], str]]],
+) -> Dict[str, Any]:
+ """Compute per-schema judgeโhuman alignment from resolved pairs.
+
+ pairs_by_schema: {schema: [(instance_id, human_label, judge_label,
+ judge_confidence|None, reasoning), ...]}
+ Returns {schema: {kappa, interpretation, agreement_rate, n, confusion,
+ disagreements[]}}.
+ """
+ import pandas as pd
+ from potato.agreement import cohen_kappa_pairwise, interpret_kappa
+
+ out: Dict[str, Any] = {}
+ for schema, pairs in pairs_by_schema.items():
+ pairs = [p for p in pairs if p[1] is not None and p[2] is not None]
+ n = len(pairs)
+ if n == 0:
+ out[schema] = {"kappa": None, "interpretation": "no overlap",
+ "agreement_rate": 0.0, "n": 0, "confusion": {},
+ "disagreements": []}
+ continue
+
+ agree = sum(1 for _, h, j, *_ in pairs if str(h) == str(j))
+ confusion: Dict[str, Dict[str, int]] = defaultdict(lambda: defaultdict(int))
+ disagreements = []
+ rows = []
+ for inst, h, j, conf, reason in pairs:
+ confusion[str(h)][str(j)] += 1
+ rows.append({"unit": inst, "annotator": "human", "annotation": str(h)})
+ rows.append({"unit": inst, "annotator": "judge", "annotation": str(j)})
+ if str(h) != str(j):
+ disagreements.append({
+ "instance_id": inst, "human_label": str(h), "judge_label": str(j),
+ "judge_confidence": conf, "reasoning": reason,
+ })
+
+ kappa = None
+ interp = "n/a"
+ try:
+ res = cohen_kappa_pairwise(pd.DataFrame(rows))
+ kappa = res.get("mean_kappa")
+ if kappa is not None:
+ interp = interpret_kappa(kappa)
+ except Exception as e:
+ logger.warning(f"Judge alignment: kappa failed for {schema}: {e}")
+
+ out[schema] = {
+ "kappa": round(kappa, 3) if isinstance(kappa, (int, float)) else None,
+ "interpretation": interp,
+ "agreement_rate": round(agree / n, 3),
+ "n": n,
+ "confusion": {h: dict(js) for h, js in confusion.items()},
+ "disagreements": disagreements,
+ }
+ return out
+
+
+# ----- gathering from persisted predictions + live human labels -----------
+
+def judge_scoped_schemas(config: Dict[str, Any]) -> List[dict]:
+ """Annotation schemes the judge should evaluate (categorical only).
+
+ Honors ``judge_alignment.schemas`` allow-list if present; otherwise all
+ radio/select/likert schemes.
+ """
+ schemes = config.get("annotation_schemes", []) or []
+ allow = set((config.get("judge_alignment", {}) or {}).get("schemas", {}).keys())
+ cats = {"radio", "select", "likert"}
+ out = []
+ for s in schemes:
+ if s.get("annotation_type") not in cats:
+ continue
+ if allow and s.get("name") not in allow:
+ continue
+ out.append(s)
+ return out
+
+
+def gather_pairs(config: Dict[str, Any], users: List[str], schema_names: List[str],
+ prompt_version: Optional[str]) -> Dict[str, List[Tuple]]:
+ """Build (instance, human_gold, judge_label, conf, reasoning) pairs."""
+ preds = load_predictions(config)
+ version = prompt_version or latest_prompt_version(config)
+ version_preds = preds.get(version, {}) if version else {}
+
+ pairs_by_schema: Dict[str, List[Tuple]] = {s: [] for s in schema_names}
+ for key, pred in version_preds.items():
+ instance_id, _, schema = key.partition("::")
+ if schema not in pairs_by_schema:
+ continue
+ gold = majority_human_label(instance_id, schema, users)
+ if gold is None:
+ continue
+ pairs_by_schema[schema].append((
+ instance_id, gold, pred.get("predicted_label"),
+ pred.get("confidence"), pred.get("reasoning", ""),
+ ))
+ return pairs_by_schema
+
+
+def annotated_instance_ids(users: List[str], schema_name: str) -> List[str]:
+ """Instance ids that at least one user has labeled for this schema."""
+ from potato.flask_server import get_user_state
+ ids = set()
+ for u in users:
+ st = get_user_state(u)
+ if not st:
+ continue
+ for iid in st.get_annotated_instance_ids():
+ if human_label_for(iid, schema_name, u) is not None:
+ ids.add(iid)
+ return sorted(ids)
+
+
+def run_judge_batch(config: Dict[str, Any], users: List[str],
+ rubric_overrides: Optional[Dict[str, str]] = None,
+ max_per_schema: Optional[int] = None) -> Dict[str, Any]:
+ """Run the judge over human-annotated instances and persist predictions.
+
+ rubric_overrides: {schema_name: rubric} to calibrate + create a new prompt
+ version. Few-shot examples (when enabled) are drawn from high-agreement
+ human labels, excluding the instance being judged.
+ """
+ from potato.ai.judge import JudgeService, compute_prompt_version
+ from potato.item_state_management import get_item_state_manager
+
+ # Apply rubric overrides into a working config copy.
+ cfg = dict(config)
+ ja = dict(cfg.get("judge_alignment", {}) or {})
+ if rubric_overrides:
+ schemas_cfg = dict(ja.get("schemas", {}) or {})
+ for name, rubric in rubric_overrides.items():
+ sc = dict(schemas_cfg.get(name, {}) or {})
+ sc["rubric"] = rubric
+ schemas_cfg[name] = sc
+ ja["schemas"] = schemas_cfg
+ cfg["judge_alignment"] = ja
+
+ service = JudgeService(cfg)
+ ism = get_item_state_manager()
+ few_shot_cfg = (ja.get("few_shot") or {})
+ use_few_shot = bool(few_shot_cfg.get("enabled", False))
+
+ n_judged, n_failed, version_seen = 0, 0, None
+ for schema in judge_scoped_schemas(cfg):
+ schema_name = schema.get("name")
+ ids = annotated_instance_ids(users, schema_name)
+ if max_per_schema:
+ ids = ids[:max_per_schema]
+ examples = _few_shot_examples(schema_name, use_few_shot, few_shot_cfg)
+ for iid in ids:
+ try:
+ item = ism.get_item(iid)
+ text = item.get_text() if item else ""
+ except Exception:
+ text = ""
+ shots = [e for e in examples if e.get("id") != iid] or None
+ pred = service.judge_instance(iid, schema, text, few_shot_examples=shots)
+ if pred is None:
+ n_failed += 1
+ continue
+ save_prediction(cfg, pred)
+ version_seen = pred.prompt_version
+ n_judged += 1
+
+ return {"judged": n_judged, "failed": n_failed, "prompt_version": version_seen}
+
+
+def _few_shot_examples(schema_name: str, enabled: bool, cfg: Dict[str, Any]) -> List[dict]:
+ """Gold few-shot examples from high-agreement human labels (or [])."""
+ if not enabled:
+ return []
+ try:
+ from potato.ai.icl_labeler import get_icl_labeler
+ labeler = get_icl_labeler()
+ if labeler is None:
+ return []
+ by_schema = labeler.refresh_high_confidence_examples()
+ examples = by_schema.get(schema_name, [])[: int(cfg.get("max_examples", 5))]
+ return [{"id": getattr(e, "instance_id", ""),
+ "text": getattr(e, "instance_text", getattr(e, "text", "")),
+ "label": getattr(e, "label", getattr(e, "agreed_label", ""))}
+ for e in examples]
+ except Exception as e:
+ logger.warning(f"Judge few-shot example gathering failed: {e}")
+ return []
+
+
+def compute_judge_alignment(config: Dict[str, Any], users: List[str],
+ prompt_version: Optional[str] = None) -> Dict[str, Any]:
+ """Full report: per-schema alignment for a prompt version + version list."""
+ schemas = [s.get("name") for s in judge_scoped_schemas(config)]
+ version = prompt_version or latest_prompt_version(config)
+ pairs = gather_pairs(config, users, schemas, version)
+ per_schema = compute_alignment_from_pairs(pairs)
+
+ preds = load_predictions(config)
+ versions = []
+ for v in preds.keys():
+ v_pairs = gather_pairs(config, users, schemas, v)
+ v_report = compute_alignment_from_pairs(v_pairs)
+ kappas = [r["kappa"] for r in v_report.values() if r.get("kappa") is not None]
+ versions.append({
+ "prompt_version": v,
+ "n_predictions": len(preds[v]),
+ "mean_kappa": round(sum(kappas) / len(kappas), 3) if kappas else None,
+ })
+
+ return {
+ "prompt_version": version,
+ "per_schema": per_schema,
+ "prompt_versions": sorted(versions, key=lambda x: x["prompt_version"]),
+ }
diff --git a/potato/server_utils/mturk_apis.py b/potato/server_utils/mturk_apis.py
new file mode 100644
index 0000000000000000000000000000000000000000..500629bc31afd5b11a6bf3b8aa879b3481f49ea5
--- /dev/null
+++ b/potato/server_utils/mturk_apis.py
@@ -0,0 +1,519 @@
+"""
+Utility functions for handling Amazon Mechanical Turk APIs.
+
+This module provides wrapper classes for interacting with the MTurk API
+via boto3 to manage HITs and assignments.
+
+The MTurk API requires AWS credentials and uses different endpoints for
+sandbox (testing) and production environments.
+
+Key API Operations:
+- GET account balance
+- LIST HITs
+- GET HIT details
+- LIST assignments for a HIT
+- APPROVE/REJECT assignments
+
+For more information, see:
+https://docs.aws.amazon.com/mturk/index.html
+"""
+import os
+import json
+import logging
+from collections import OrderedDict, defaultdict
+
+logger = logging.getLogger(__name__)
+
+# Optional boto3 import - only required if using MTurk API features
+try:
+ import boto3
+ from botocore.exceptions import ClientError
+ BOTO3_AVAILABLE = True
+except ImportError:
+ BOTO3_AVAILABLE = False
+ logger.debug("boto3 not available - MTurk API features will be disabled")
+
+
+class MTurkBase:
+ """
+ Base class for MTurk API operations.
+
+ Provides low-level API access to Amazon Mechanical Turk's REST endpoints.
+ All methods use AWS credential-based authentication via boto3.
+
+ Attributes:
+ client: boto3 MTurk client
+ sandbox (bool): Whether using sandbox environment
+ """
+
+ # MTurk API endpoints
+ SANDBOX_ENDPOINT = 'https://mturk-requester-sandbox.us-east-1.amazonaws.com'
+ PRODUCTION_ENDPOINT = 'https://mturk-requester.us-east-1.amazonaws.com'
+
+ def __init__(self, aws_access_key_id=None, aws_secret_access_key=None, sandbox=True):
+ """
+ Initialize the MTurk API client with AWS credentials.
+
+ Args:
+ aws_access_key_id (str, optional): AWS access key ID.
+ If not provided, uses environment variable or ~/.aws/credentials
+ aws_secret_access_key (str, optional): AWS secret access key.
+ If not provided, uses environment variable or ~/.aws/credentials
+ sandbox (bool): If True, use sandbox environment for testing.
+ Default is True (sandbox mode).
+ """
+ if not BOTO3_AVAILABLE:
+ raise ImportError(
+ "boto3 is required for MTurk API features. "
+ "Install it with: pip install boto3"
+ )
+
+ self.sandbox = sandbox
+ endpoint_url = self.SANDBOX_ENDPOINT if sandbox else self.PRODUCTION_ENDPOINT
+
+ # Build client kwargs
+ client_kwargs = {
+ 'service_name': 'mturk',
+ 'region_name': 'us-east-1',
+ 'endpoint_url': endpoint_url
+ }
+
+ # Add credentials if provided explicitly
+ if aws_access_key_id:
+ client_kwargs['aws_access_key_id'] = aws_access_key_id
+ if aws_secret_access_key:
+ client_kwargs['aws_secret_access_key'] = aws_secret_access_key
+
+ self.client = boto3.client(**client_kwargs)
+
+ env_type = "SANDBOX" if sandbox else "PRODUCTION"
+ logger.info(f"MTurk API client initialized ({env_type})")
+
+ def get_account_balance(self):
+ """
+ Get the MTurk account balance.
+
+ Returns:
+ str: Available balance (e.g., "10000.00" for sandbox, actual balance for production)
+
+ Raises:
+ ClientError: If the API request fails
+ """
+ try:
+ response = self.client.get_account_balance()
+ balance = response['AvailableBalance']
+ logger.debug(f"MTurk account balance: {balance}")
+ return balance
+ except ClientError as e:
+ logger.error(f"Failed to get account balance: {e}")
+ raise
+
+ def list_hits(self, max_results=100):
+ """
+ List all HITs in the account.
+
+ Args:
+ max_results (int): Maximum number of HITs to return (default: 100)
+
+ Returns:
+ list: List of HIT dictionaries containing:
+ - HITId: HIT identifier
+ - Title: HIT title
+ - HITStatus: Current status
+ - MaxAssignments: Total assignments available
+ - NumberOfAssignmentsCompleted: Completed count
+ - NumberOfAssignmentsPending: Pending count
+ - NumberOfAssignmentsAvailable: Available count
+ """
+ try:
+ response = self.client.list_hits(MaxResults=max_results)
+ hits = response.get('HITs', [])
+ logger.debug(f"Listed {len(hits)} HITs")
+ return hits
+ except ClientError as e:
+ logger.error(f"Failed to list HITs: {e}")
+ raise
+
+ def get_hit(self, hit_id):
+ """
+ Get detailed information about a specific HIT.
+
+ Args:
+ hit_id (str): The HIT identifier
+
+ Returns:
+ dict: Complete HIT information including:
+ - HITId, HITTypeId
+ - Title, Description, Question
+ - HITStatus: ASSIGNABLE, UNASSIGNABLE, REVIEWABLE, etc.
+ - MaxAssignments, Reward, AssignmentDurationInSeconds
+ - Creation/Expiration times
+ """
+ try:
+ response = self.client.get_hit(HITId=hit_id)
+ hit = response.get('HIT', {})
+ logger.debug(f"Retrieved HIT {hit_id}: status={hit.get('HITStatus')}")
+ return hit
+ except ClientError as e:
+ logger.error(f"Failed to get HIT {hit_id}: {e}")
+ raise
+
+ def list_assignments_for_hit(self, hit_id, assignment_statuses=None, max_results=100):
+ """
+ Get assignments for a specific HIT.
+
+ Args:
+ hit_id (str): The HIT identifier
+ assignment_statuses (list, optional): Filter by status.
+ Valid values: 'Submitted', 'Approved', 'Rejected'
+ max_results (int): Maximum number of assignments to return
+
+ Returns:
+ list: List of assignment dictionaries containing:
+ - AssignmentId: Assignment identifier
+ - WorkerId: Worker's MTurk ID
+ - HITId: Associated HIT ID
+ - AssignmentStatus: Submitted, Approved, or Rejected
+ - AcceptTime, SubmitTime
+ - Answer: Worker's submitted answer (XML format)
+ """
+ try:
+ params = {
+ 'HITId': hit_id,
+ 'MaxResults': max_results
+ }
+ if assignment_statuses:
+ params['AssignmentStatuses'] = assignment_statuses
+
+ response = self.client.list_assignments_for_hit(**params)
+ assignments = response.get('Assignments', [])
+ logger.debug(f"Listed {len(assignments)} assignments for HIT {hit_id}")
+ return assignments
+ except ClientError as e:
+ logger.error(f"Failed to list assignments for HIT {hit_id}: {e}")
+ raise
+
+ def approve_assignment(self, assignment_id, requester_feedback=""):
+ """
+ Approve an assignment.
+
+ Args:
+ assignment_id (str): The assignment identifier
+ requester_feedback (str, optional): Feedback message to worker
+
+ Returns:
+ dict: Empty dict on success
+
+ Raises:
+ ClientError: If approval fails (e.g., already approved/rejected)
+ """
+ try:
+ response = self.client.approve_assignment(
+ AssignmentId=assignment_id,
+ RequesterFeedback=requester_feedback,
+ OverrideRejection=False
+ )
+ logger.info(f"Approved assignment {assignment_id}")
+ return response
+ except ClientError as e:
+ logger.error(f"Failed to approve assignment {assignment_id}: {e}")
+ raise
+
+ def reject_assignment(self, assignment_id, requester_feedback):
+ """
+ Reject an assignment.
+
+ Args:
+ assignment_id (str): The assignment identifier
+ requester_feedback (str): Required feedback explaining rejection
+
+ Returns:
+ dict: Empty dict on success
+
+ Raises:
+ ClientError: If rejection fails
+ """
+ try:
+ response = self.client.reject_assignment(
+ AssignmentId=assignment_id,
+ RequesterFeedback=requester_feedback
+ )
+ logger.info(f"Rejected assignment {assignment_id}")
+ return response
+ except ClientError as e:
+ logger.error(f"Failed to reject assignment {assignment_id}: {e}")
+ raise
+
+ def get_assignment(self, assignment_id):
+ """
+ Get detailed information about a specific assignment.
+
+ Args:
+ assignment_id (str): The assignment identifier
+
+ Returns:
+ dict: Complete assignment information
+ """
+ try:
+ response = self.client.get_assignment(AssignmentId=assignment_id)
+ assignment = response.get('Assignment', {})
+ logger.debug(f"Retrieved assignment {assignment_id}")
+ return assignment
+ except ClientError as e:
+ logger.error(f"Failed to get assignment {assignment_id}: {e}")
+ raise
+
+
+class MTurkHIT(MTurkBase):
+ """
+ High-level HIT management class for MTurk.
+
+ Extends MTurkBase to provide HIT-specific functionality including:
+ - Assignment tracking and status management
+ - Batch approval/rejection
+ - Worker tracking
+
+ Attributes:
+ hit_id (str): MTurk HIT identifier
+ hit_info (dict): Cached HIT information
+ assignments (OrderedDict): Mapping of assignment IDs to assignment data
+ worker_status (defaultdict): Mapping of status to worker IDs
+ """
+
+ def __init__(self, aws_access_key_id=None, aws_secret_access_key=None,
+ hit_id=None, sandbox=True):
+ """
+ Initialize HIT management with configuration.
+
+ Args:
+ aws_access_key_id (str, optional): AWS access key ID
+ aws_secret_access_key (str, optional): AWS secret access key
+ hit_id (str, optional): MTurk HIT identifier to manage
+ sandbox (bool): Whether to use sandbox environment (default: True)
+ """
+ super().__init__(aws_access_key_id, aws_secret_access_key, sandbox)
+
+ self.hit_id = hit_id
+ self.hit_info = None
+ self.assignments = OrderedDict()
+ self.worker_status = defaultdict(set)
+
+ if hit_id:
+ try:
+ self.hit_info = self.get_hit(hit_id)
+ logger.info(f"Initialized MTurkHIT for HIT {hit_id}")
+ except ClientError as e:
+ logger.warning(f"Could not fetch HIT info: {e}")
+
+ def get_basic_hit_info(self):
+ """
+ Extract basic HIT information for display or logging.
+
+ Returns:
+ dict: Basic HIT information including:
+ - HITId, Title, HITStatus
+ - MaxAssignments, Reward
+ - NumberOfAssignmentsCompleted/Pending/Available
+ """
+ if not self.hit_info:
+ return {}
+
+ keys = [
+ 'HITId', 'Title', 'HITStatus', 'MaxAssignments', 'Reward',
+ 'NumberOfAssignmentsCompleted', 'NumberOfAssignmentsPending',
+ 'NumberOfAssignmentsAvailable'
+ ]
+ return {key: self.hit_info.get(key) for key in keys if key in self.hit_info}
+
+ def refresh_assignments(self):
+ """
+ Refresh local assignment data from MTurk API.
+
+ Fetches current assignment data from the API and updates local state:
+ - Updates assignments mapping
+ - Rebuilds worker status dictionary grouped by assignment status
+ """
+ if not self.hit_id:
+ logger.warning("No HIT ID set, cannot refresh assignments")
+ return
+
+ try:
+ # Get all assignments regardless of status
+ assignments = self.list_assignments_for_hit(self.hit_id)
+
+ self.assignments = OrderedDict()
+ self.worker_status = defaultdict(set)
+
+ for assignment in assignments:
+ assignment_id = assignment['AssignmentId']
+ self.assignments[assignment_id] = assignment
+ worker_id = assignment['WorkerId']
+ status = assignment['AssignmentStatus']
+ self.worker_status[status].add(worker_id)
+
+ logger.info(f"Refreshed {len(assignments)} assignments for HIT {self.hit_id}")
+ except ClientError as e:
+ logger.error(f"Failed to refresh assignments: {e}")
+
+ def get_pending_assignments(self):
+ """
+ Get assignments pending review (submitted but not approved/rejected).
+
+ Returns:
+ list: List of assignment dictionaries with 'Submitted' status
+ """
+ return self.list_assignments_for_hit(
+ self.hit_id,
+ assignment_statuses=['Submitted']
+ )
+
+ def get_approved_assignments(self):
+ """
+ Get approved assignments.
+
+ Returns:
+ list: List of assignment dictionaries with 'Approved' status
+ """
+ return self.list_assignments_for_hit(
+ self.hit_id,
+ assignment_statuses=['Approved']
+ )
+
+ def get_rejected_assignments(self):
+ """
+ Get rejected assignments.
+
+ Returns:
+ list: List of assignment dictionaries with 'Rejected' status
+ """
+ return self.list_assignments_for_hit(
+ self.hit_id,
+ assignment_statuses=['Rejected']
+ )
+
+ def auto_approve_all(self, feedback="Thank you for your work!"):
+ """
+ Approve all pending (submitted) assignments.
+
+ Args:
+ feedback (str): Feedback message to send to workers
+
+ Returns:
+ int: Number of assignments approved
+ """
+ pending = self.get_pending_assignments()
+ approved_count = 0
+
+ for assignment in pending:
+ try:
+ self.approve_assignment(assignment['AssignmentId'], feedback)
+ approved_count += 1
+ except ClientError as e:
+ logger.warning(f"Failed to approve {assignment['AssignmentId']}: {e}")
+
+ logger.info(f"Auto-approved {approved_count} assignments")
+ return approved_count
+
+ def get_worker_ids_by_status(self, status):
+ """
+ Get worker IDs filtered by assignment status.
+
+ Args:
+ status (str): Assignment status ('Submitted', 'Approved', 'Rejected')
+
+ Returns:
+ set: Set of worker IDs with the specified status
+ """
+ self.refresh_assignments()
+ return self.worker_status.get(status, set())
+
+ def get_completed_workers(self):
+ """
+ Get list of workers who have completed (submitted or approved) assignments.
+
+ Returns:
+ set: Set of worker IDs
+ """
+ self.refresh_assignments()
+ return (self.worker_status.get('Submitted', set()) |
+ self.worker_status.get('Approved', set()))
+
+
+# Module-level singleton instance
+_mturk_hit = None
+
+
+def init_mturk_hit(config):
+ """
+ Initialize the global MTurk HIT manager from configuration.
+
+ Args:
+ config (dict): Configuration dictionary containing:
+ - mturk.config_file_path: Path to MTurk config YAML file
+
+ The MTurk config file should contain:
+ - aws_access_key_id: AWS access key (optional, uses env/credentials file)
+ - aws_secret_access_key: AWS secret key (optional)
+ - sandbox: Whether to use sandbox (default: True)
+ - hit_id: Optional HIT ID to manage
+
+ Returns:
+ MTurkHIT: Initialized MTurk HIT manager, or None if not configured
+ """
+ global _mturk_hit
+
+ if not BOTO3_AVAILABLE:
+ logger.warning("boto3 not available, MTurk API disabled")
+ return None
+
+ mturk_config = config.get('mturk', {})
+ if not mturk_config.get('enabled', False):
+ logger.debug("MTurk API not enabled in config")
+ return None
+
+ config_file_path = mturk_config.get('config_file_path')
+ if not config_file_path:
+ logger.warning("MTurk enabled but no config_file_path specified")
+ return None
+
+ try:
+ import yaml
+ with open(config_file_path, 'r') as f:
+ mturk_settings = yaml.safe_load(f)
+
+ _mturk_hit = MTurkHIT(
+ aws_access_key_id=mturk_settings.get('aws_access_key_id'),
+ aws_secret_access_key=mturk_settings.get('aws_secret_access_key'),
+ hit_id=mturk_settings.get('hit_id'),
+ sandbox=mturk_settings.get('sandbox', True)
+ )
+
+ logger.info(f"MTurk HIT manager initialized from {config_file_path}")
+ return _mturk_hit
+
+ except FileNotFoundError:
+ logger.error(f"MTurk config file not found: {config_file_path}")
+ return None
+ except Exception as e:
+ logger.error(f"Failed to initialize MTurk HIT manager: {e}")
+ return None
+
+
+def get_mturk_hit():
+ """
+ Get the global MTurk HIT manager instance.
+
+ Returns:
+ MTurkHIT: The MTurk HIT manager, or None if not initialized
+ """
+ return _mturk_hit
+
+
+def clear_mturk_hit():
+ """
+ Clear the global MTurk HIT manager instance.
+
+ Used primarily for testing to reset state between tests.
+ """
+ global _mturk_hit
+ _mturk_hit = None
diff --git a/potato/server_utils/overlap_sampler.py b/potato/server_utils/overlap_sampler.py
new file mode 100644
index 0000000000000000000000000000000000000000..de941fcf76d114bcb6d28e96e22a8991ff5f1ae9
--- /dev/null
+++ b/potato/server_utils/overlap_sampler.py
@@ -0,0 +1,90 @@
+"""
+Overlap sampling for heterogeneous annotator coverage.
+
+Given the ``num_annotators_per_item.overlap_sample`` config block, selects a
+deterministic fraction of items to receive a raised annotator cap. The cap
+override is written onto each sampled item's metadata so the centralized
+``ItemStateManager._get_annotator_cap_for_item`` helper picks it up.
+
+This module is called once after all items have been loaded.
+"""
+
+from __future__ import annotations
+
+from collections import defaultdict
+from typing import Dict, List, Optional
+
+import logging
+import random as _random
+
+logger = logging.getLogger(__name__)
+
+
+_METADATA_KEY = "required_annotations"
+
+
+def apply_overlap_sample(item_state_manager, config: dict) -> Dict[str, int]:
+ """
+ Stamp ``required_annotations`` on a deterministic sample of items.
+
+ Returns a mapping of sampled instance_id -> assigned cap, for reporting.
+ Items whose existing metadata already carries ``required_annotations``
+ (e.g., from a previous load with persisted state) are not overwritten.
+ """
+ nap = config.get("num_annotators_per_item")
+ if not isinstance(nap, dict):
+ return {}
+ overlap = nap.get("overlap_sample")
+ if not overlap:
+ return {}
+
+ fraction = float(overlap["fraction"])
+ count = int(overlap["count"])
+ stratify_by = overlap.get("stratify_by")
+ seed = int(overlap.get("seed", item_state_manager.random_seed))
+ rng = _random.Random(seed)
+
+ all_ids = list(item_state_manager.instance_id_to_instance.keys())
+ if not all_ids:
+ return {}
+
+ # Build strata
+ strata: Dict[Optional[str], List[str]] = defaultdict(list)
+ if stratify_by:
+ for iid in all_ids:
+ item = item_state_manager.instance_id_to_instance[iid]
+ data = item.get_data() if hasattr(item, "get_data") else {}
+ key = data.get(stratify_by) if isinstance(data, dict) else None
+ # Also accept the indexed category if it matches
+ if key is None and hasattr(item_state_manager, "instance_id_to_categories"):
+ cats = item_state_manager.instance_id_to_categories.get(iid)
+ if cats:
+ key = sorted(cats)[0]
+ strata[key if key is not None else "__uncategorized__"].append(iid)
+ else:
+ strata[None] = list(all_ids)
+
+ sampled: Dict[str, int] = {}
+ for key, ids in strata.items():
+ if not ids:
+ continue
+ # Deterministic ordering across runs
+ ids_sorted = sorted(ids)
+ rng_local = _random.Random(f"{seed}:{key}" if key is not None else seed)
+ rng_local.shuffle(ids_sorted)
+ target = max(1, int(round(len(ids_sorted) * fraction)))
+ for iid in ids_sorted[:target]:
+ item = item_state_manager.instance_id_to_instance[iid]
+ # Don't clobber an existing per-item override (operator may have
+ # set one via item_data; respect that authority).
+ if item.get_metadata(_METADATA_KEY) is not None:
+ continue
+ item.add_metadata(_METADATA_KEY, count)
+ sampled[iid] = count
+
+ if sampled:
+ logger.info(
+ "Overlap sample: %d / %d items raised to %d annotators (fraction=%s, stratify_by=%s, seed=%d)",
+ len(sampled), len(all_ids), count, fraction, stratify_by, seed,
+ )
+ return sampled
diff --git a/potato/server_utils/prolific_apis.py b/potato/server_utils/prolific_apis.py
new file mode 100644
index 0000000000000000000000000000000000000000..d644fcd53953ef4cb37f7fc057d6671d9725f209
--- /dev/null
+++ b/potato/server_utils/prolific_apis.py
@@ -0,0 +1,509 @@
+"""
+Utility functions for handling Prolific APIs
+
+This module provides wrapper classes for interacting with the Prolific API
+(https://api.prolific.com/api/v1/) to manage studies and submissions.
+
+The Prolific API uses token-based authentication and returns JSON responses.
+All endpoints require the Authorization header with format: 'Token {your_token}'
+
+Key API Endpoints Used:
+- GET /studies/ - List all studies
+- GET /studies/{id}/ - Get study details
+- GET /submissions/ - List all submissions
+- GET /submissions?study={id} - Get submissions for a study
+- GET /submissions/{id}/ - Get submission details
+- GET /studies/{id}/submissions/ - Get recent submissions for a study
+- POST /studies/{id}/transition/ - Change study status (START/PAUSE)
+
+For more information, see: https://docs.prolific.com/reference/
+"""
+import os.path
+import pandas as pd
+import requests
+from collections import OrderedDict, defaultdict
+import time
+import json
+import logging
+
+# The base wrapper of prolific apis
+class ProlificBase(object):
+ """
+ Base class for Prolific API operations.
+
+ Provides low-level API access to Prolific's REST endpoints.
+ All methods use token-based authentication and handle HTTP responses.
+
+ Attributes:
+ headers (dict): HTTP headers including Authorization token
+ """
+
+ def __init__(self, token):
+ """
+ Initialize the API client with authentication token.
+
+ Args:
+ token (str): Prolific API token for authentication
+ """
+ self.headers = {
+ 'Authorization': f'Token {token}',
+ }
+
+ def list_all_studies(self):
+ """
+ Retrieve all studies from Prolific.
+
+ Makes a GET request to /api/v1/studies/ to fetch all studies
+ associated with the authenticated account.
+
+ Returns:
+ pandas.DataFrame: DataFrame containing study information with columns:
+ - id: Study identifier
+ - name: Study name
+ - study_type: Type of study
+ - internal_name: Internal reference name
+ - status: Current study status
+ None: If the request fails
+ """
+ url = 'https://api.prolific.com/api/v1/studies/'
+ response = requests.get(url, headers=self.headers)
+ if response.status_code == 200:
+ data = response.json() # If the response contains JSON data
+ studies = pd.DataFrame.from_records(data['results'])
+ print('You currently have %s studies'%len(data['results']))
+ print(studies[['id','name','study_type','internal_name','status']].to_records())
+ return studies
+ else:
+ print(f"Error: {response.status_code} - {response.text}")
+ return None
+
+ def get_study_by_id(self, study_id):
+ """
+ Retrieve detailed information about a specific study.
+
+ Makes a GET request to /api/v1/studies/{study_id}/ to fetch
+ complete study details including configuration and status.
+
+ Args:
+ study_id (str, optional): Prolific study ID. If None, uses self.study_id
+
+ Returns:
+ dict: Complete study information including:
+ - id, name, internal_name
+ - reward, average_reward_per_hour
+ - external_study_url, status
+ - total_available_places, places_taken
+ - and other study configuration fields
+ None: If the request fails
+ """
+ if study_id == None:
+ study_id = self.study_id
+ url = f'https://api.prolific.com/api/v1/studies/{study_id}/'
+ response = requests.get(url, headers=self.headers)
+ if response.status_code == 200:
+ data = response.json() # If the response contains JSON data
+ return data
+ else:
+ print(f"Error: {response.status_code} - {response.text}")
+ return None
+
+ def get_submissions(self):
+ """
+ Retrieve all submissions across all studies.
+
+ Makes a GET request to /api/v1/submissions/ to fetch all submissions.
+ Note: This can be slow for accounts with many submissions.
+
+ Returns:
+ list: List of submission dictionaries containing:
+ - id: Submission identifier
+ - participant_id: Participant's Prolific ID
+ - study_id: Associated study ID
+ - status: Current submission status
+ - and other submission details
+ None: If the request fails
+ """
+ url = 'https://api.prolific.com/api/v1/submissions/'
+ response = requests.get(url, headers=self.headers)
+ if response.status_code == 200:
+ data = response.json() # If the response contains JSON data
+ print('You currently have %s submissions'%len(data['results']))
+ return data['results']
+ else:
+ print(f"Error: {response.status_code} - {response.text}")
+ return None
+
+ def get_submissions_from_study(self, study_id = None):
+ """
+ Retrieve all submissions for a specific study.
+
+ Makes a GET request to /api/v1/submissions?study={study_id} to fetch
+ submissions filtered by study ID.
+
+ Args:
+ study_id (str, optional): Prolific study ID. If None, uses self.study_id
+
+ Returns:
+ list: List of submission dictionaries for the specified study
+ None: If the request fails
+ """
+ if study_id == None:
+ study_id = self.study_id
+ api_endpoint = 'https://api.prolific.com/api/v1/submissions?study={}'
+ url = api_endpoint.format(study_id)
+ response = requests.get(url, headers=self.headers)
+ if response.status_code == 200:
+ data = response.json()['results']
+ print('Successfully fetched %s submissions from study %s' % (len(data), study_id))
+ return data
+ else:
+ print(f"Error: {response.status_code} - {response.text}")
+ return None
+
+ def get_submission_from_id(self, submission_id):
+ """
+ Retrieve detailed information about a specific submission.
+
+ Makes a GET request to /api/v1/submissions/{submission_id}/ to fetch
+ complete submission details including participant info and status.
+
+ Args:
+ submission_id (str): Prolific submission ID
+
+ Returns:
+ dict: Complete submission information including:
+ - id, participant_id, study_id
+ - status: Current submission status
+ - started_at, completed_at timestamps
+ - and other submission details
+ None: If the request fails
+ """
+ url = f'https://api.prolific.com/api/v1/submissions/{submission_id}/'
+ response = requests.get(url, headers=self.headers)
+ if response.status_code == 200:
+ data = response.json() # If the response contains JSON data
+ return data
+ else:
+ print(f"Error: {response.status_code} - {response.text}")
+ return None
+
+ def get_recent_study_submissions(self, study_id):
+ """
+ Retrieve recent submissions for a specific study.
+
+ Makes a GET request to /api/v1/studies/{study_id}/submissions/ to fetch
+ recent submissions. This endpoint may return a subset of submissions
+ compared to get_submissions_from_study().
+
+ Args:
+ study_id (str, optional): Prolific study ID. If None, uses self.study_id
+
+ Returns:
+ list: List of recent submission dictionaries for the specified study
+ None: If the request fails
+ """
+ if study_id == None:
+ study_id = self.study_id
+ url = f'https://api.prolific.com/api/v1/studies/{study_id}/submissions/'
+ response = requests.get(url, headers=self.headers)
+ if response.status_code == 200:
+ data = response.json() # If the response contains JSON data
+ print('You currently have %s submissions' % len(data['results']))
+ return (data['results'])
+ else:
+ print(f"Error: {response.status_code} - {response.text}")
+ return None
+
+ def get_study_status(self, study_id = None):
+ """
+ Get the current status of a study.
+
+ Retrieves study information and extracts the status field.
+
+ Args:
+ study_id (str, optional): Prolific study ID. If None, uses self.study_id
+
+ Returns:
+ str: Study status (e.g., 'ACTIVE', 'PAUSED', 'COMPLETED')
+ None: If the request fails
+ """
+ if study_id == None:
+ study_id = self.study_id
+ data = self.get_study_by_id(study_id)
+ if data:
+ return data['status']
+ else:
+ return None
+
+ def pause_study(self, study_id = None):
+ """
+ Pause a study to stop new participants from joining.
+
+ Makes a POST request to /api/v1/studies/{study_id}/transition/ with
+ action "PAUSE" to change the study status to paused.
+
+ Args:
+ study_id (str, optional): Prolific study ID. If None, uses self.study_id
+
+ Returns:
+ dict: Response from the transition API call
+ """
+ if study_id == None:
+ study_id = self.study_id
+ api_endpoint = 'https://api.prolific.com/api/v1/studies/{}/transition/'
+ url = api_endpoint.format(study_id)
+ data = {
+ "action": "PAUSE"
+ }
+ response = requests.post(url, headers=self.headers, json=data)
+ data = response.json()
+ print(study_id, self.get_study_status(study_id))
+ return data
+
+ def start_study(self, study_id = None):
+ """
+ Start a study to allow new participants to join.
+
+ Makes a POST request to /api/v1/studies/{study_id}/transition/ with
+ action "START" to change the study status to active.
+
+ Args:
+ study_id (str, optional): Prolific study ID. If None, uses self.study_id
+
+ Returns:
+ dict: Response from the transition API call
+ """
+ if study_id == None:
+ study_id = self.study_id
+ api_endpoint = 'https://api.prolific.com/api/v1/studies/{}/transition/'
+ url = api_endpoint.format(study_id)
+ data = {
+ "action": "START"
+ }
+ response = requests.post(url, headers=self.headers, json=data)
+ data = response.json()
+ print(study_id, self.get_study_status(study_id))
+ return data
+
+
+# The class to manage the status of a prolific study
+class ProlificStudy(ProlificBase):
+ """
+ High-level study management class for Prolific studies.
+
+ Extends ProlificBase to provide study-specific functionality including:
+ - Submission tracking and status management
+ - Workload monitoring and automatic study control
+ - Local state persistence
+
+ Attributes:
+ study_id (str): Prolific study identifier
+ study_info (dict): Cached study information
+ submission_info_path (str): Path to local submission data file
+ sessions (OrderedDict): Mapping of submission IDs to submission data
+ user_status_dict (defaultdict): Mapping of status to participant IDs
+ max_concurrent_sessions (int): Maximum allowed concurrent participants
+ checker_period (int): Seconds between workload checks
+ workload_checker_on (bool): Whether workload checker is running
+ """
+
+ def __init__(self, token, study_id, saving_dir, max_concurrent_sessions = 30, workload_checker_period = 60):
+ """
+ Initialize study management with configuration.
+
+ Args:
+ token (str): Prolific API token
+ study_id (str): Prolific study identifier
+ saving_dir (str): Directory to save submission data
+ max_concurrent_sessions (int): Maximum concurrent participants (default: 30)
+ workload_checker_period (int): Seconds between workload checks (default: 60)
+ """
+ ProlificBase.__init__(self, token)
+ self.study_id = study_id
+ self.study_info = self.get_study_by_id(study_id)
+ self.submission_info_path = os.path.join(saving_dir, 'submissions.json')
+ self.sessions = OrderedDict()
+ self.user2session = {}
+ #self.user_status_dict = {'RESERVED':set(), 'AWAITING REVIEW':set(), 'RETURNED':set(), 'TIMED-OUT':set(), 'ACTIVE': set(), 'APPROVED':set(), 'REJECTED':set()}
+ self.study_status = None
+ self.status_path = None
+ self.max_concurrent_sessions = max_concurrent_sessions # How many users can work on the study at the same time
+ self.checker_period = workload_checker_period
+ self.workload_checker_remaining_time = workload_checker_period
+ self.workload_checker_on = False
+
+ def get_basic_study_info(self):
+ """
+ Extract basic study information for display or logging.
+
+ Returns:
+ dict: Basic study information including:
+ - id, name, internal_name
+ - reward, average_reward_per_hour
+ - external_study_url, status
+ - total_available_places, places_taken
+ """
+ keys = ['id', 'name', 'internal_name',
+ 'reward', 'average_reward_per_hour', 'external_study_url', 'status', 'total_available_places', 'places_taken']
+ return {key:self.study_info[key] for key in keys}
+
+ def update_submission_status(self):
+ """
+ Refresh local submission data from Prolific API.
+
+ Fetches current submission data from the API and updates local state:
+ - Saves submission data to local JSON file
+ - Updates sessions mapping
+ - Rebuilds user status dictionary grouped by submission status
+
+ The user_status_dict maps submission statuses to sets of participant IDs:
+ - 'ACTIVE': Currently working participants
+ - 'AWAITING REVIEW': Completed submissions pending review
+ - 'APPROVED': Approved submissions
+ - 'REJECTED': Rejected submissions
+ - 'RETURNED': Participants who returned the study
+ - 'TIMED-OUT': Participants who timed out
+ """
+ submission_data = self.get_submissions_from_study()
+ with open(self.submission_info_path, "wt") as f:
+ for v in submission_data:
+ f.writelines(json.dumps(v) + "\n")
+ self.user_status_dict = defaultdict(set)
+ for v in submission_data:
+ self.sessions[v['id']] = v
+ #self.user2session[v['participant_id']] = v['id']
+ self.user_status_dict[v['status']].add(v['participant_id'])
+ self.reclaim_dropped_user_assignments()
+
+ def get_dropped_users(self):
+ """
+ Get list of participants who are no longer active.
+
+ Returns:
+ list: Participant IDs who have returned, timed out, or been rejected
+ """
+ return list(self.user_status_dict['RETURNED'] | self.user_status_dict['TIMED-OUT'] | self.user_status_dict['REJECTED'])
+
+ def get_dropped_users_by_status(self):
+ """
+ Get dropped participant IDs grouped by Prolific submission status.
+
+ Returns:
+ dict: Mapping from RETURNED, TIMED-OUT, and REJECTED to participant IDs.
+ """
+ return {
+ 'RETURNED': list(self.user_status_dict['RETURNED']),
+ 'TIMED-OUT': list(self.user_status_dict['TIMED-OUT']),
+ 'REJECTED': list(self.user_status_dict['REJECTED']),
+ }
+
+ def reclaim_dropped_user_assignments(self):
+ """
+ Release unannotated Potato assignments for dropped Prolific workers.
+
+ Prolific reports dropped workers as RETURNED, TIMED-OUT, or REJECTED.
+ Their participant IDs are also the Potato usernames for url-direct
+ Prolific login, so the item state manager can safely reclaim any
+ assigned-but-unannotated instances.
+ """
+ dropped_by_status = self.get_dropped_users_by_status()
+ if not any(dropped_by_status.values()):
+ return {}
+
+ status_to_reason = {
+ 'RETURNED': 'prolific_returned',
+ 'TIMED-OUT': 'prolific_timed_out',
+ 'REJECTED': 'prolific_rejected',
+ }
+
+ try:
+ from potato.item_state_management import get_item_state_manager
+ manager = get_item_state_manager()
+ reclaimed = {}
+ for status, user_ids in dropped_by_status.items():
+ if not user_ids:
+ continue
+ reclaimed.update(
+ manager.reclaim_unannotated_assignments_for_users(
+ user_ids,
+ reason=status_to_reason[status],
+ )
+ )
+ return reclaimed
+ except Exception as e:
+ logging.getLogger(__name__).warning(
+ "Could not reclaim assignments for dropped Prolific users: %s",
+ e,
+ )
+ return {}
+
+ def get_concurrent_sessions_count(self):
+ """
+ Get the number of currently active participants.
+
+ Returns:
+ int: Number of participants with 'ACTIVE' status
+ """
+ return len(self.user_status_dict['ACTIVE'])
+
+ def workload_checker(self):
+ """
+ Monitor study workload and automatically manage study status.
+
+ Periodically checks the number of active participants and automatically:
+ - Pauses the study if too many participants are active
+ - Resumes the study when active participants drop below threshold
+
+ The threshold is 20% of max_concurrent_sessions. The checker runs
+ continuously with the specified checker_period interval.
+
+ This method runs in an infinite loop and should be called in a separate
+ thread to avoid blocking the main application.
+ """
+ if self.workload_checker_on:
+ print('Workload checker already in process, time remaining: %s seconds' % self.workload_checker_remaining_time)
+ return None
+ else:
+ print('Workload checker started, checking every %s seconds'%self.checker_period)
+ while True:
+ self.workload_checker_remaining_time = self.checker_period
+ self.workload_checker_on = True
+ print(f"\rChecking workload in: {self.checker_period} seconds")
+ #time.sleep(self.checker_period)
+ for i in range(self.checker_period, 0, -1):
+ #print(f"\rChecking workload in: {i}s", end='', flush=True)
+ self.workload_checker_remaining_time -= 1
+ time.sleep(1)
+ self.update_submission_status()
+ if self.get_concurrent_sessions_count() < 0.2 * self.max_concurrent_sessions:
+ self.workload_checker_on = False
+ print('current workload: ', self.get_concurrent_sessions_count(), ', resuming study %s'%self.study_id)
+ self.start_study()
+ return None
+ else:
+ print('current workload: ', self.get_concurrent_sessions_count(), ', starting another workload checker')
+
+ def update_session_status(self, sess_id):
+ """
+ Update the status of a specific submission.
+
+ Fetches current status from Prolific API and updates local session data.
+
+ Args:
+ sess_id (str): Submission ID to update
+ """
+ status = self.get_submission_from_id(sess_id)['status']
+ self.sessions[sess_id]['status'] = status
+
+ def add_new_user(self, user):
+ """
+ Add a new participant to the local session tracking.
+
+ Fetches current submission status and adds to local state.
+
+ Args:
+ user (dict): User data containing 'SESSION_ID' and 'PROLIFIC_PID'
+ """
+ status = self.get_submission_from_id(user['SESSION_ID'])['status']
+ self.sessions[user['SESSION_ID']] = {'username':user['PROLIFIC_PID'], 'status':status}
+ self.session_status_dict[status].append(user['SESSION_ID'])
diff --git a/potato/server_utils/schemas/__init__.py b/potato/server_utils/schemas/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..9fafb12e418f7f8408baf48a7cd57b4a3a0dbc83
--- /dev/null
+++ b/potato/server_utils/schemas/__init__.py
@@ -0,0 +1,55 @@
+from .likert import generate_likert_layout
+from .multiselect import generate_multiselect_layout
+from .multirate import generate_multirate_layout
+from .number import generate_number_layout
+from .pure_display import generate_pure_display_layout
+from .radio import generate_radio_layout
+from .select import generate_select_layout
+from .span import generate_span_layout, render_span_annotations, get_spans_for_field
+from .span_link import generate_span_link_layout
+from .textbox import generate_textbox_layout
+from .slider import generate_slider_layout
+from .video import generate_video_layout
+from .image_annotation import generate_image_annotation_layout
+from .audio_annotation import generate_audio_annotation_layout
+from .video_annotation import generate_video_annotation_layout
+from .pairwise import generate_pairwise_layout
+from .coreference import generate_coreference_layout
+from .tree_annotation import generate_tree_annotation_layout
+from .triage import generate_triage_layout
+from .event_annotation import generate_event_annotation_layout
+from .tiered_annotation import generate_tiered_annotation_layout
+from .bws import generate_bws_layout
+from .soft_label import generate_soft_label_layout
+from .confidence import generate_confidence_layout
+from .constant_sum import generate_constant_sum_layout
+from .semantic_differential import generate_semantic_differential_layout
+from .ranking import generate_ranking_layout
+from .range_slider import generate_range_slider_layout
+from .hierarchical_multiselect import generate_hierarchical_multiselect_layout
+from .vas import generate_vas_layout
+from .extractive_qa import generate_extractive_qa_layout
+from .rubric_eval import generate_rubric_eval_layout
+from .text_edit import generate_text_edit_layout
+from .error_span import generate_error_span_layout
+from .card_sort import generate_card_sort_layout
+from .conjoint import generate_conjoint_layout
+from .trajectory_eval import generate_trajectory_eval_layout
+from .trajectory_edit import generate_trajectory_edit_layout
+from .process_reward import generate_process_reward_layout
+from .code_review import generate_code_review_layout
+
+# Import identifier utilities for use by other modules
+from .identifier_utils import (
+ validate_schema_config,
+ generate_element_identifier,
+ generate_element_value,
+ generate_validation_attribute,
+ escape_html_content,
+ safe_generate_layout,
+ generate_tooltip_html,
+ generate_layout_attributes
+)
+
+# Import schema registry for centralized schema management
+from .registry import schema_registry, SchemaDefinition
diff --git a/potato/server_utils/schemas/audio_annotation.py b/potato/server_utils/schemas/audio_annotation.py
new file mode 100644
index 0000000000000000000000000000000000000000..e3b6822e1cfe3acc8ed88a78e6bb78e4a4789237
--- /dev/null
+++ b/potato/server_utils/schemas/audio_annotation.py
@@ -0,0 +1,719 @@
+"""
+Audio Annotation Layout
+
+Generates a form interface for audio annotation with segmentation.
+Uses Peaks.js for waveform visualization and segment management.
+
+Features:
+- Waveform visualization (amplitude display)
+- Spectrogram visualization (frequency display) - optional
+- Region/segment selection and playback
+- Zoom and pan for long audio files
+- Two annotation modes:
+ - Label mode: Assign labels to segments (like span annotation)
+ - Questions mode: Answer questions about each segment (radio, multirate, etc.)
+"""
+
+import logging
+import json
+from typing import List, Dict, Tuple, Any
+from .identifier_utils import (
+ safe_generate_layout,
+ escape_html_content
+)
+
+logger = logging.getLogger(__name__)
+
+# Default colors for segment labels
+DEFAULT_COLORS = [
+ "#4ECDC4", # Teal
+ "#FF6B6B", # Red
+ "#45B7D1", # Blue
+ "#96CEB4", # Green
+ "#FFEAA7", # Yellow
+ "#DDA0DD", # Plum
+ "#95A5A6", # Gray
+ "#F39C12", # Orange
+ "#9B59B6", # Purple
+ "#3498DB", # Light Blue
+]
+
+# Valid annotation modes
+VALID_MODES = ["label", "questions", "both"]
+
+# Default spectrogram options
+DEFAULT_SPECTROGRAM_OPTIONS = {
+ "fft_size": 2048,
+ "hop_length": 512,
+ "frequency_range": [0, 8000],
+ "color_map": "viridis",
+}
+
+# Valid color maps for spectrogram
+VALID_COLOR_MAPS = ["viridis", "magma", "plasma", "inferno", "grayscale"]
+
+
+def generate_audio_annotation_layout(annotation_scheme: Dict[str, Any]) -> Tuple[str, List[Tuple[str, str]]]:
+ """
+ Generate HTML for an audio annotation interface.
+
+ Args:
+ annotation_scheme (dict): Configuration including:
+ - name: Schema identifier
+ - description: Display description
+ - mode: "label" | "questions" | "both"
+ - labels: List of segment labels (for label/both modes)
+ - segment_schemes: List of annotation schemes per segment (for questions/both modes)
+ - min_segments: Minimum required segments (default: 0)
+ - max_segments: Maximum allowed segments (default: null/unlimited)
+ - zoom_enabled: Whether to enable zoom (default: True)
+ - playback_rate_control: Show playback speed controls (default: False)
+ - waveform: Whether to show waveform (default: True)
+ - spectrogram: Whether to show spectrogram (default: False)
+ - spectrogram_options: Spectrogram configuration (optional):
+ - fft_size: FFT window size (default: 2048)
+ - hop_length: Hop length between FFT windows (default: 512)
+ - frequency_range: [min_hz, max_hz] (default: [0, 8000])
+ - color_map: Color mapping ("viridis", "magma", "plasma", "inferno", "grayscale")
+
+ Returns:
+ tuple: (html_string, key_bindings)
+ html_string: Complete HTML for the audio annotation interface
+ key_bindings: List of keyboard shortcuts
+
+ Raises:
+ ValueError: If required fields are missing or invalid
+ """
+ return safe_generate_layout(annotation_scheme, _generate_audio_annotation_layout_internal)
+
+
+def _generate_audio_annotation_layout_internal(annotation_scheme: Dict[str, Any]) -> Tuple[str, List[Tuple[str, str]]]:
+ """
+ Internal function to generate audio annotation layout after validation.
+ """
+ schema_name = annotation_scheme.get('name', 'audio_annotation')
+ logger.debug(f"Generating audio annotation layout for schema: {schema_name}")
+
+ # Get mode (default to "label" for simplicity)
+ mode = annotation_scheme.get('mode', 'label')
+ if mode not in VALID_MODES:
+ error_msg = f"Invalid mode '{mode}' in schema: {schema_name}. Must be one of: {VALID_MODES}"
+ logger.error(error_msg)
+ raise ValueError(error_msg)
+
+ # Validate labels for label/both modes
+ labels = []
+ if mode in ['label', 'both']:
+ if 'labels' not in annotation_scheme:
+ error_msg = f"Missing labels in schema: {schema_name} (required for mode '{mode}')"
+ logger.error(error_msg)
+ raise ValueError(error_msg)
+ labels = _process_labels(annotation_scheme['labels'])
+
+ # Validate segment_schemes for questions/both modes
+ segment_schemes = []
+ if mode in ['questions', 'both']:
+ if 'segment_schemes' not in annotation_scheme:
+ error_msg = f"Missing segment_schemes in schema: {schema_name} (required for mode '{mode}')"
+ logger.error(error_msg)
+ raise ValueError(error_msg)
+ segment_schemes = annotation_scheme['segment_schemes']
+ if not isinstance(segment_schemes, list) or not segment_schemes:
+ error_msg = f"segment_schemes must be a non-empty list in schema: {schema_name}"
+ logger.error(error_msg)
+ raise ValueError(error_msg)
+
+ # Get configuration options
+ min_segments = annotation_scheme.get('min_segments', 0)
+ max_segments = annotation_scheme.get('max_segments', None)
+ zoom_enabled = annotation_scheme.get('zoom_enabled', True)
+ playback_rate_control = annotation_scheme.get('playback_rate_control', False)
+
+ # Waveform and spectrogram display options
+ show_waveform = annotation_scheme.get('waveform', True)
+ show_spectrogram = annotation_scheme.get('spectrogram', False)
+
+ # Process spectrogram options with defaults
+ spectrogram_options = _process_spectrogram_options(
+ annotation_scheme.get('spectrogram_options', {}),
+ schema_name
+ )
+
+ # source_field: Links this annotation schema to a display field from instance_display
+ source_field = annotation_scheme.get("source_field", "")
+
+ # Build config object for JavaScript
+ js_config = {
+ "schemaName": schema_name,
+ "mode": mode,
+ "labels": labels,
+ "segmentSchemes": segment_schemes,
+ "minSegments": min_segments,
+ "maxSegments": max_segments,
+ "zoomEnabled": zoom_enabled,
+ "playbackRateControl": playback_rate_control,
+ "sourceField": source_field,
+ "waveform": show_waveform,
+ "spectrogram": show_spectrogram,
+ "spectrogramOptions": spectrogram_options,
+ }
+
+ # Generate HTML
+ html = _generate_html(annotation_scheme, js_config, schema_name, labels, mode)
+
+ # Generate keybindings
+ keybindings = _generate_keybindings(labels, mode)
+
+ logger.info(f"Successfully generated audio annotation layout for {schema_name}")
+ return html, keybindings
+
+
+def _process_labels(labels_config: List) -> List[Dict[str, Any]]:
+ """
+ Process label configuration and assign colors.
+
+ Args:
+ labels_config: List of label configs (strings or dicts)
+
+ Returns:
+ List of processed label dicts with name, color, and optional key_value
+ """
+ processed = []
+ for i, label in enumerate(labels_config):
+ if isinstance(label, str):
+ processed.append({
+ "name": label,
+ "color": DEFAULT_COLORS[i % len(DEFAULT_COLORS)],
+ })
+ elif isinstance(label, dict):
+ processed.append({
+ "name": label.get("name", f"label_{i}"),
+ "color": label.get("color", DEFAULT_COLORS[i % len(DEFAULT_COLORS)]),
+ "key_value": label.get("key_value"),
+ })
+ else:
+ processed.append({
+ "name": str(label),
+ "color": DEFAULT_COLORS[i % len(DEFAULT_COLORS)],
+ })
+ return processed
+
+
+def _process_spectrogram_options(options: Dict[str, Any], schema_name: str) -> Dict[str, Any]:
+ """
+ Process and validate spectrogram configuration options.
+
+ Args:
+ options: User-provided spectrogram options
+ schema_name: Schema name for error messages
+
+ Returns:
+ Validated and merged spectrogram options with defaults
+ """
+ # Start with defaults
+ processed = dict(DEFAULT_SPECTROGRAM_OPTIONS)
+
+ if not options:
+ return processed
+
+ # Validate and merge fft_size
+ if 'fft_size' in options:
+ fft_size = options['fft_size']
+ if isinstance(fft_size, int) and fft_size > 0:
+ # Ensure power of 2 for FFT efficiency
+ if fft_size & (fft_size - 1) == 0:
+ processed['fft_size'] = fft_size
+ else:
+ logger.warning(
+ f"fft_size {fft_size} is not a power of 2 in schema {schema_name}, "
+ f"using default {DEFAULT_SPECTROGRAM_OPTIONS['fft_size']}"
+ )
+
+ # Validate and merge hop_length
+ if 'hop_length' in options:
+ hop_length = options['hop_length']
+ if isinstance(hop_length, int) and hop_length > 0:
+ processed['hop_length'] = hop_length
+
+ # Validate and merge frequency_range
+ if 'frequency_range' in options:
+ freq_range = options['frequency_range']
+ if (isinstance(freq_range, (list, tuple)) and len(freq_range) == 2
+ and all(isinstance(f, (int, float)) for f in freq_range)
+ and freq_range[0] < freq_range[1]):
+ processed['frequency_range'] = list(freq_range)
+ else:
+ logger.warning(
+ f"Invalid frequency_range {freq_range} in schema {schema_name}, "
+ f"using default {DEFAULT_SPECTROGRAM_OPTIONS['frequency_range']}"
+ )
+
+ # Validate and merge color_map
+ if 'color_map' in options:
+ color_map = options['color_map']
+ if color_map in VALID_COLOR_MAPS:
+ processed['color_map'] = color_map
+ else:
+ logger.warning(
+ f"Invalid color_map '{color_map}' in schema {schema_name}, "
+ f"must be one of {VALID_COLOR_MAPS}. Using default '{DEFAULT_SPECTROGRAM_OPTIONS['color_map']}'"
+ )
+
+ return processed
+
+
+def _generate_html(
+ annotation_scheme: Dict[str, Any],
+ js_config: Dict[str, Any],
+ schema_name: str,
+ labels: List[Dict[str, Any]],
+ mode: str
+) -> str:
+ """
+ Generate the HTML for the audio annotation interface.
+ """
+ escaped_name = escape_html_content(schema_name)
+ description = escape_html_content(annotation_scheme.get('description', ''))
+ config_json = json.dumps(js_config)
+
+ # Determine display mode
+ show_waveform = js_config.get('waveform', True)
+ show_spectrogram = js_config.get('spectrogram', False)
+
+ # Generate label buttons (for label/both modes)
+ label_selector = ""
+ if mode in ['label', 'both'] and labels:
+ label_selector = _generate_label_selector(labels)
+
+ # Generate playback rate control
+ playback_rate_html = ""
+ if js_config.get('playbackRateControl'):
+ playback_rate_html = '''
+
+ Speed:
+
+
+ '''
+
+ # source_field attribute for linking to display fields
+ source_field = annotation_scheme.get("source_field", "")
+ source_field_attr = f' data-source-field="{escape_html_content(source_field)}"' if source_field else ""
+
+ # Generate spectrogram HTML if enabled
+ spectrogram_html = ""
+ if show_spectrogram:
+ spectrogram_html = f'''
+
+
+
+ Spectrogram (frequency analysis)
+
+
+
+
+
+
+ '''
+
+ html = f'''
+
+ '''
+
+ return html
+
+
+def _generate_label_selector(labels: List[Dict[str, Any]]) -> str:
+ """
+ Generate HTML for label selection buttons.
+ """
+ buttons = []
+ for label in labels:
+ name = escape_html_content(label["name"])
+ color = label["color"]
+ key_hint = f' Keyboard shortcut: {label["key_value"]}' if label.get("key_value") else ""
+ tooltip = f"Select '{name}' as the label for new segments.{key_hint}"
+ buttons.append(
+ f''
+ )
+
+ return f'''
+
+ Label:
+ {"".join(buttons)}
+
+ '''
+
+
+def _generate_keybindings(labels: List[Dict[str, Any]], mode: str) -> List[Tuple[str, str]]:
+ """
+ Generate keybinding list for the schema.
+ """
+ keybindings = []
+
+ # Playback shortcuts
+ keybindings.append(("Space", "Play/Pause"))
+ keybindings.append(("โ/โ", "Seek 5 seconds"))
+
+ # Label shortcuts (for label/both modes)
+ if mode in ['label', 'both']:
+ for label in labels:
+ if label.get("key_value"):
+ keybindings.append((label["key_value"], f"Select: {label['name']}"))
+
+ # Segment shortcuts
+ keybindings.append(("[/]", "Set segment start/end"))
+ keybindings.append(("Enter", "Create segment"))
+ keybindings.append(("Del", "Delete segment"))
+
+ # Zoom shortcuts
+ keybindings.append(("+/-", "Zoom in/out"))
+ keybindings.append(("0", "Fit to view"))
+
+ return keybindings
diff --git a/potato/server_utils/schemas/bws.py b/potato/server_utils/schemas/bws.py
new file mode 100644
index 0000000000000000000000000000000000000000..d5e19ece083bf9b54aca1453420e9702dec635a6
--- /dev/null
+++ b/potato/server_utils/schemas/bws.py
@@ -0,0 +1,172 @@
+"""
+Best-Worst Scaling (BWS) Layout
+
+Generates a form interface for selecting the best and worst items from a tuple.
+Features include:
+- Labeled items display (A, B, C, D...) populated by JS from var_elems
+- Best selection row โ clickable tiles
+- Worst selection row โ clickable tiles
+- Validation that best != worst
+- Keyboard shortcuts: 1-9 for best, q/w/e/r for worst
+- Two hidden inputs storing the best/worst position labels
+
+Config keys:
+ - name: Schema identifier
+ - description: Display description (shown as heading)
+ - best_description: Question text for best selection
+ - worst_description: Question text for worst selection
+ - tuple_size: Number of items per tuple (default: 4)
+ - sequential_key_binding: Enable keyboard shortcuts (default: true)
+ - label_requirement: Optional validation settings
+"""
+
+import logging
+from typing import Any, Dict, List, Tuple
+
+from potato.ai.ai_help_wrapper import get_ai_wrapper
+from .identifier_utils import (
+ safe_generate_layout,
+ generate_validation_attribute,
+ escape_html_content,
+ generate_layout_attributes,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def generate_bws_layout(
+ annotation_scheme: Dict[str, Any],
+) -> Tuple[str, List[Tuple[str, str]]]:
+ """
+ Generate HTML for a Best-Worst Scaling interface.
+
+ Args:
+ annotation_scheme (dict): Configuration including:
+ - name: Schema identifier
+ - description: Display description
+ - best_description: Question for best selection (default: "Which is BEST?")
+ - worst_description: Question for worst selection (default: "Which is WORST?")
+ - tuple_size: Items per tuple (default: 4)
+ - sequential_key_binding: Enable keyboard shortcuts (default: true)
+ - label_requirement (dict): Optional validation settings
+
+ Returns:
+ tuple: (html_string, key_bindings)
+ """
+ return safe_generate_layout(annotation_scheme, _generate_bws_layout_internal)
+
+
+def _generate_bws_layout_internal(
+ annotation_scheme: Dict[str, Any],
+) -> Tuple[str, List[Tuple[str, str]]]:
+ """Internal function to generate BWS layout after validation."""
+ logger.debug(f"Generating BWS layout for schema: {annotation_scheme['name']}")
+
+ schema_name = annotation_scheme["name"]
+ description = annotation_scheme.get("description", "")
+ best_description = annotation_scheme.get(
+ "best_description", "Which is BEST?"
+ )
+ worst_description = annotation_scheme.get(
+ "worst_description", "Which is WORST?"
+ )
+ tuple_size = annotation_scheme.get("tuple_size", 4)
+ enable_keybindings = annotation_scheme.get("sequential_key_binding", True)
+
+ # Validation attribute for both hidden inputs
+ validation = generate_validation_attribute(annotation_scheme)
+
+ # Escape for HTML
+ escaped_schema = escape_html_content(schema_name)
+ escaped_description = escape_html_content(description)
+ escaped_best_desc = escape_html_content(best_description)
+ escaped_worst_desc = escape_html_content(worst_description)
+
+ # Layout attributes for grid positioning
+ layout_attrs = generate_layout_attributes(annotation_scheme)
+
+ # Position labels: A, B, C, D, ...
+ positions = [chr(ord("A") + i) for i in range(tuple_size)]
+
+ # Build best tiles
+ best_tiles_html = ""
+ for idx, pos in enumerate(positions):
+ key_num = str(idx + 1)
+ shortcut = f"[{key_num}]" if enable_keybindings else ""
+ data_key = f'data-key="{key_num}"' if enable_keybindings else ""
+ best_tiles_html += f"""
+
+ {pos}
+ {shortcut}
+
"""
+
+ # Build worst tiles โ keys q, w, e, r (row below 1, 2, 3, 4)
+ worst_keys = "qwer"
+ worst_tiles_html = ""
+ for idx, pos in enumerate(positions):
+ key_letter = worst_keys[idx] if idx < len(worst_keys) else chr(ord("a") + idx)
+ shortcut = f"[{key_letter}]" if enable_keybindings else ""
+ data_key = f'data-key="{key_letter}"' if enable_keybindings else ""
+ worst_tiles_html += f"""
+
+ {pos}
+ {shortcut}
+
"""
+
+ # Build the complete form
+ schematic = f"""
+
+ """
+
+ # Key bindings โ best: 1,2,3,4 worst: q,w,e,r
+ worst_binding_keys = "qwer"
+ key_bindings = []
+ if enable_keybindings:
+ for idx, pos in enumerate(positions):
+ key_bindings.append((str(idx + 1), f"{schema_name}: Best {pos}"))
+ wk = worst_binding_keys[idx] if idx < len(worst_binding_keys) else chr(ord("a") + idx)
+ key_bindings.append(
+ (wk, f"{schema_name}: Worst {pos}")
+ )
+
+ logger.info(f"Successfully generated BWS layout for {schema_name}")
+ return schematic, key_bindings
diff --git a/potato/server_utils/schemas/card_sort.py b/potato/server_utils/schemas/card_sort.py
new file mode 100644
index 0000000000000000000000000000000000000000..5796efd0e0f5316ca6e53370f0928a78ec5eccdd
--- /dev/null
+++ b/potato/server_utils/schemas/card_sort.py
@@ -0,0 +1,287 @@
+"""
+Card Sorting / Grouping Layout
+
+Drag items (text snippets, labels, concepts) into predefined or user-created groups.
+Open card sorting lets annotators create their own categories; closed card sorting
+provides predefined ones.
+
+Research: Spencer (2009) "Card Sorting: Designing Usable Categories"; Nielsen Norman Group.
+"""
+
+import json
+import logging
+
+from .identifier_utils import (
+ safe_generate_layout,
+ generate_element_identifier,
+ generate_validation_attribute,
+ escape_html_content,
+ generate_layout_attributes
+)
+
+
+logger = logging.getLogger(__name__)
+
+
+def generate_card_sort_layout(annotation_scheme):
+ """
+ Generate HTML for a Card Sorting interface.
+
+ Args:
+ annotation_scheme (dict): Configuration including:
+ - name: Schema identifier
+ - description: Display description
+ - mode: "closed" (predefined groups) or "open" (user-created groups)
+ - groups: List of group names (for closed mode)
+ - items_field: Field in data containing items to sort
+ - allow_empty_groups: Whether empty groups are OK
+ - allow_multiple: Whether an item can appear in multiple groups
+
+ Returns:
+ tuple: (html_string, key_bindings)
+ """
+ return safe_generate_layout(annotation_scheme, _generate_card_sort_layout_internal)
+
+
+def _generate_card_sort_layout_internal(annotation_scheme):
+ schema_name = annotation_scheme['name']
+ description = annotation_scheme['description']
+ mode = annotation_scheme.get('mode', 'closed')
+ groups = annotation_scheme.get('groups', [])
+ items_field = annotation_scheme.get('items_field', 'items')
+ allow_empty_groups = annotation_scheme.get('allow_empty_groups', True)
+ allow_multiple = annotation_scheme.get('allow_multiple', False)
+
+ if mode == 'closed' and not groups:
+ raise ValueError(f"card_sort schema '{schema_name}' in closed mode requires 'groups'")
+
+ layout_attrs = generate_layout_attributes(annotation_scheme)
+ validation = generate_validation_attribute(annotation_scheme)
+ identifiers = generate_element_identifier(schema_name, schema_name, "hidden")
+
+ config_json = json.dumps({
+ 'mode': mode,
+ 'groups': groups,
+ 'items_field': items_field,
+ 'allow_empty_groups': allow_empty_groups,
+ 'allow_multiple': allow_multiple,
+ })
+
+ # Build group containers
+ # NOTE: group-items div must be self-closing (no whitespace) so CSS :empty works
+ groups_html = ""
+ for group in groups:
+ group_id = escape_html_content(group.replace(' ', '-').lower())
+ esc_group = escape_html_content(group)
+ esc_schema = escape_html_content(schema_name)
+ groups_html += (
+ f''
+ )
+
+ new_group_html = ""
+ if mode == 'open':
+ new_group_html = f"""
+
+
+
+
+ """
+
+ html = f"""
+
+
+
+ """
+
+ logger.info(f"Generated card sort layout for {schema_name}")
+ return html, []
diff --git a/potato/server_utils/schemas/code_review.py b/potato/server_utils/schemas/code_review.py
new file mode 100644
index 0000000000000000000000000000000000000000..6975860b35a3e5cb46848f31fbdde305c2847623
--- /dev/null
+++ b/potato/server_utils/schemas/code_review.py
@@ -0,0 +1,482 @@
+"""
+Code Review Schema
+
+GitHub PR review-style annotation with inline diff commenting,
+file-level ratings, and overall verdict.
+
+Features:
+- Click on diff lines in CodingTraceDisplay to add comments
+- Per-comment category (bug, style, suggestion, security, question)
+- File-level correctness and quality ratings
+- Overall verdict (approve, request_changes, comment_only)
+"""
+
+import json
+import logging
+from typing import Dict, Any, Tuple, List
+
+from .identifier_utils import (
+ safe_generate_layout,
+ generate_element_identifier,
+ generate_validation_attribute,
+ escape_html_content,
+ generate_layout_attributes,
+)
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_CATEGORIES = ["bug", "style", "suggestion", "security", "question"]
+DEFAULT_VERDICTS = ["approve", "request_changes", "comment_only"]
+DEFAULT_RATING_DIMS = ["correctness", "quality"]
+
+
+def generate_code_review_layout(
+ annotation_scheme: Dict[str, Any],
+) -> Tuple[str, List[Tuple[str, str]]]:
+ """Generate HTML for a code review annotation interface.
+
+ Args:
+ annotation_scheme: Configuration dict. Required keys: ``name``,
+ ``description``. Optional: ``comment_categories``,
+ ``verdict_options``, ``file_rating_dimensions``.
+
+ Returns:
+ ``(html, keybindings)`` tuple.
+ """
+ return safe_generate_layout(annotation_scheme, _generate_internal)
+
+
+def _generate_internal(
+ annotation_scheme: Dict[str, Any],
+) -> Tuple[str, List[Tuple[str, str]]]:
+ schema_name = annotation_scheme["name"]
+ description = annotation_scheme["description"]
+
+ categories = annotation_scheme.get("comment_categories", DEFAULT_CATEGORIES)
+ verdicts = annotation_scheme.get("verdict_options", DEFAULT_VERDICTS)
+ rating_dims = annotation_scheme.get("file_rating_dimensions", DEFAULT_RATING_DIMS)
+
+ layout_attrs = generate_layout_attributes(annotation_scheme)
+ validation = generate_validation_attribute(annotation_scheme)
+ identifiers = generate_element_identifier(schema_name, schema_name, "hidden")
+ esc_schema = escape_html_content(schema_name)
+
+ config_json = json.dumps({
+ "categories": categories,
+ "verdicts": verdicts,
+ "rating_dims": rating_dims,
+ })
+
+ # Build verdict radios
+ verdict_html = ""
+ for v in verdicts:
+ label = v.replace("_", " ").title()
+ css = f"cr-verdict-{v}"
+ verdict_html += (
+ f''
+ )
+
+ # Build category options
+ cat_options = "".join(
+ f''
+ for c in categories
+ )
+
+ html = f"""
+
+
+
+
+
+ """
+
+ logger.info(
+ f"Successfully generated code_review layout for {schema_name} "
+ f"({len(categories)} categories, {len(verdicts)} verdicts)"
+ )
+ return html, [] # No keybindings
diff --git a/potato/server_utils/schemas/confidence.py b/potato/server_utils/schemas/confidence.py
new file mode 100644
index 0000000000000000000000000000000000000000..39bed10f78e468e3207eb87a389cfac658bf071b
--- /dev/null
+++ b/potato/server_utils/schemas/confidence.py
@@ -0,0 +1,164 @@
+"""
+Confidence-Calibrated Annotation Layout
+
+Generates a confidence rating that pairs with a primary annotation scheme.
+Supports both Likert-style discrete scale and continuous slider.
+
+Research basis:
+- Kutlu et al. (2020) "Annotator Rationales for Labeling Tasks in Crowdsourcing" JAIR
+- Sheng et al. (2008) "Get Another Label?" KDD
+"""
+
+import logging
+
+from potato.ai.ai_help_wrapper import get_ai_wrapper
+from .identifier_utils import (
+ safe_generate_layout,
+ generate_element_identifier,
+ generate_validation_attribute,
+ escape_html_content,
+ generate_layout_attributes,
+)
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_SCALE_POINTS = 5
+DEFAULT_SCALE_TYPE = "likert"
+DEFAULT_LABELS = [
+ "Guessing",
+ "Somewhat confident",
+ "Fairly confident",
+ "Confident",
+ "Certain",
+]
+
+
+def generate_confidence_layout(annotation_scheme):
+ """
+ Generate HTML for a confidence-calibrated annotation interface.
+
+ Args:
+ annotation_scheme (dict): Configuration including:
+ - name: Schema identifier
+ - description: Display description
+ - target_schema: Name of the primary schema this confidence is for (optional)
+ - scale_type: "likert" or "slider" (default "likert")
+ - scale_points: Number of points (default 5, for likert)
+ - labels: Custom scale labels (optional)
+
+ Returns:
+ tuple: (html_string, key_bindings)
+ """
+ return safe_generate_layout(annotation_scheme, _generate_confidence_layout_internal)
+
+
+def _generate_confidence_layout_internal(annotation_scheme):
+ schema_name = annotation_scheme["name"]
+ safe_schema = escape_html_content(schema_name)
+ description = annotation_scheme["description"]
+ scale_type = annotation_scheme.get("scale_type", DEFAULT_SCALE_TYPE)
+ scale_points = annotation_scheme.get("scale_points", DEFAULT_SCALE_POINTS)
+ custom_labels = annotation_scheme.get("labels", None)
+ target_schema = annotation_scheme.get("target_schema", "")
+ layout_attrs = generate_layout_attributes(annotation_scheme)
+ validation = generate_validation_attribute(annotation_scheme)
+
+ if scale_type == "likert":
+ labels = custom_labels if custom_labels else DEFAULT_LABELS[:scale_points]
+ # Pad labels if fewer than scale points
+ while len(labels) < scale_points:
+ labels.append(f"Level {len(labels) + 1}")
+ else:
+ labels = []
+
+ html = f"""
+
+ """
+
+ key_bindings = []
+ logger.info(f"Generated confidence layout for {schema_name} (type={scale_type})")
+ return html, key_bindings
diff --git a/potato/server_utils/schemas/conjoint.py b/potato/server_utils/schemas/conjoint.py
new file mode 100644
index 0000000000000000000000000000000000000000..a4685252464858916b9f8cf48ae971c0ef05980b
--- /dev/null
+++ b/potato/server_utils/schemas/conjoint.py
@@ -0,0 +1,168 @@
+"""
+Discrete Choice / Conjoint Analysis Layout
+
+Present annotators with 2-4 product/concept profiles defined by attribute-level
+combinations and ask them to choose the preferred one (or "none"). Enables estimation
+of attribute importance through experimental design.
+
+Research: Green & Srinivasan (1990); Louviere, Flynn & Marley (2015).
+"""
+
+import json
+import logging
+
+from .identifier_utils import (
+ safe_generate_layout,
+ generate_element_identifier,
+ generate_validation_attribute,
+ escape_html_content,
+ generate_layout_attributes
+)
+
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_PROFILES_PER_SET = 3
+
+
+def generate_conjoint_layout(annotation_scheme):
+ """
+ Generate HTML for a Discrete Choice / Conjoint Analysis interface.
+
+ Args:
+ annotation_scheme (dict): Configuration including:
+ - name: Schema identifier
+ - description: Display description
+ - profiles_per_set: Number of profiles to show (2-4)
+ - attributes: List of {name, levels} dicts
+ - show_none_option: Whether to show "None of these" option
+ - profiles_field: Data field with pre-specified profiles (null = generate)
+
+ Returns:
+ tuple: (html_string, key_bindings)
+ """
+ return safe_generate_layout(annotation_scheme, _generate_conjoint_layout_internal)
+
+
+def _generate_conjoint_layout_internal(annotation_scheme):
+ schema_name = annotation_scheme['name']
+ description = annotation_scheme['description']
+ profiles_per_set = annotation_scheme.get('profiles_per_set', DEFAULT_PROFILES_PER_SET)
+ attributes = annotation_scheme.get('attributes', [])
+ show_none_option = annotation_scheme.get('show_none_option', True)
+ profiles_field = annotation_scheme.get('profiles_field', None)
+
+ if not attributes and not profiles_field:
+ raise ValueError(f"conjoint schema '{schema_name}' requires 'attributes' or 'profiles_field'")
+
+ layout_attrs = generate_layout_attributes(annotation_scheme)
+ validation = generate_validation_attribute(annotation_scheme)
+ identifiers = generate_element_identifier(schema_name, schema_name, "radio")
+
+ config_json = json.dumps({
+ 'profiles_per_set': profiles_per_set,
+ 'attributes': attributes,
+ 'profiles_field': profiles_field,
+ })
+
+ # Build profile cards (placeholder structure โ populated by JS from data or generated)
+ profile_cards_html = ""
+ for i in range(profiles_per_set):
+ profile_num = i + 1
+ radio_id = f"{identifiers['id']}-profile-{profile_num}"
+
+ # Build attribute rows placeholder
+ attr_rows = ""
+ for attr in attributes:
+ attr_rows += f"""
+
+ | {escape_html_content(attr['name'])} |
+ โ |
+
+ """
+
+ profile_cards_html += f"""
+
+
+
+
+
+
+
+ """
+
+ none_option_html = ""
+ if show_none_option:
+ none_radio_id = f"{identifiers['id']}-none"
+ none_option_html = f"""
+
+
+
+ """
+
+ html = f"""
+
+
+
+ """
+
+ logger.info(f"Generated conjoint layout for {schema_name}")
+ return html, []
diff --git a/potato/server_utils/schemas/constant_sum.py b/potato/server_utils/schemas/constant_sum.py
new file mode 100644
index 0000000000000000000000000000000000000000..802c367309c676d4f07ba7b8a966d5e111e880fe
--- /dev/null
+++ b/potato/server_utils/schemas/constant_sum.py
@@ -0,0 +1,222 @@
+"""
+Constant Sum / Points Allocation Layout
+
+Generates number inputs (or sliders) constrained to sum to a fixed total.
+Forces annotators to make relative comparisons between categories.
+
+Research basis:
+- Louviere et al. (2015) "Best-Worst Scaling: Theory, Methods and Applications"
+ Cambridge University Press
+- Thurstone (1927) paired-comparison law of comparative judgment
+"""
+
+import logging
+
+from potato.ai.ai_help_wrapper import get_ai_wrapper
+from .identifier_utils import (
+ safe_generate_layout,
+ generate_element_identifier,
+ generate_validation_attribute,
+ escape_html_content,
+ generate_layout_attributes,
+ generate_tooltip_html,
+)
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_TOTAL_POINTS = 100
+DEFAULT_MIN_PER_ITEM = 0
+DEFAULT_INPUT_TYPE = "number"
+
+
+def generate_constant_sum_layout(annotation_scheme):
+ """
+ Generate HTML for a constant sum / points allocation interface.
+
+ Args:
+ annotation_scheme (dict): Configuration including:
+ - name: Schema identifier
+ - description: Display description
+ - labels: List of category names
+ - total_points: Sum budget (default 100)
+ - min_per_item: Minimum per item (default 0)
+ - input_type: "number" or "slider" (default "number")
+
+ Returns:
+ tuple: (html_string, key_bindings)
+ """
+ return safe_generate_layout(annotation_scheme, _generate_constant_sum_layout_internal)
+
+
+def _generate_constant_sum_layout_internal(annotation_scheme):
+ schema_name = annotation_scheme["name"]
+ safe_schema = escape_html_content(schema_name)
+ description = annotation_scheme["description"]
+ labels = annotation_scheme.get("labels", [])
+ total_points = annotation_scheme.get("total_points", DEFAULT_TOTAL_POINTS)
+ min_per_item = annotation_scheme.get("min_per_item", DEFAULT_MIN_PER_ITEM)
+ input_type = annotation_scheme.get("input_type", DEFAULT_INPUT_TYPE)
+ layout_attrs = generate_layout_attributes(annotation_scheme)
+ validation = generate_validation_attribute(annotation_scheme)
+
+ if not labels:
+ raise ValueError(f"constant_sum schema '{schema_name}' requires 'labels'")
+
+ # Normalize labels
+ label_names = []
+ for lbl in labels:
+ if isinstance(lbl, str):
+ label_names.append(lbl)
+ elif isinstance(lbl, dict) and "name" in lbl:
+ label_names.append(lbl["name"])
+ else:
+ raise ValueError(f"Invalid label format: {lbl}")
+
+ html = f"""
+
+
+ """
+
+ key_bindings = []
+ logger.info(f"Generated constant_sum layout for {schema_name} with {len(label_names)} categories")
+ return html, key_bindings
diff --git a/potato/server_utils/schemas/coreference.py b/potato/server_utils/schemas/coreference.py
new file mode 100644
index 0000000000000000000000000000000000000000..cbd5bae3fa23150027bc5dfc96a94146935294bb
--- /dev/null
+++ b/potato/server_utils/schemas/coreference.py
@@ -0,0 +1,196 @@
+"""
+Coreference Chain Annotation Layout
+
+Generates the UI for creating and managing coreference chains โ
+groupings of text spans that refer to the same entity.
+
+This schema type works in conjunction with a span annotation schema.
+A coreference chain is an n-ary undirected link where span_ids lists
+all mentions of the same entity. It leverages the existing SpanLink
+infrastructure with a specialized chain management UI.
+"""
+
+import logging
+import json
+from .identifier_utils import (
+ safe_generate_layout,
+ escape_html_content,
+)
+
+logger = logging.getLogger(__name__)
+
+# Default colors for coreference chains
+CHAIN_COLOR_PALETTE = [
+ "#6E56CF", # Purple
+ "#EF4444", # Red
+ "#22C55E", # Green
+ "#3B82F6", # Blue
+ "#F59E0B", # Amber
+ "#EC4899", # Pink
+ "#06B6D4", # Cyan
+ "#F97316", # Orange
+ "#8B5CF6", # Violet
+ "#10B981", # Emerald
+ "#DC2626", # Dark red
+ "#A855F7", # Light purple
+ "#14B8A6", # Teal
+ "#F43F5E", # Rose
+ "#84CC16", # Lime
+]
+
+
+def _generate_coreference_layout_internal(annotation_scheme, horizontal=False):
+ """
+ Internal function to generate coreference chain layout.
+
+ Args:
+ annotation_scheme: Configuration dictionary containing:
+ - name: Schema name
+ - description: Description shown to user
+ - span_schema: Name of the span schema providing mentions
+ - entity_types: Optional list of entity types for chain classification
+ - allow_singletons: Whether single-mention chains are allowed (default: True)
+ - visual_display:
+ - highlight_mode: "bracket" | "background" | "underline" (default: "background")
+
+ Returns:
+ tuple: (HTML string, key bindings list)
+ """
+ scheme_name = annotation_scheme["name"]
+ description = annotation_scheme.get("description", "Create coreference chains")
+ span_schema = annotation_scheme.get("span_schema", "")
+ entity_types = annotation_scheme.get("entity_types", [])
+ allow_singletons = annotation_scheme.get("allow_singletons", True)
+ visual_display = annotation_scheme.get("visual_display", {})
+ highlight_mode = visual_display.get("highlight_mode", "background")
+
+ # Build entity type selector HTML
+ entity_types_html = ""
+ if entity_types:
+ for i, etype in enumerate(entity_types):
+ if isinstance(etype, dict):
+ etype_name = etype.get("name", f"Entity_{i}")
+ etype_color = etype.get("color", CHAIN_COLOR_PALETTE[i % len(CHAIN_COLOR_PALETTE)])
+ else:
+ etype_name = str(etype)
+ etype_color = CHAIN_COLOR_PALETTE[i % len(CHAIN_COLOR_PALETTE)]
+
+ entity_types_html += f"""
+
+
+
+
+ """
+
+ # Config data for JS
+ config_data = json.dumps({
+ "schemaName": scheme_name,
+ "spanSchema": span_schema,
+ "entityTypes": entity_types if entity_types else [],
+ "allowSingletons": allow_singletons,
+ "highlightMode": highlight_mode,
+ "colors": CHAIN_COLOR_PALETTE,
+ })
+
+ entity_type_section = ""
+ if entity_types:
+ entity_type_section = f"""
+
+
+
+ {entity_types_html}
+
+
+ """
+
+ schematic = f"""
+
+
+
+
+ {entity_type_section}
+
+
+
+
+
+
+
+
+
+
+
+
+
No coreference chains created yet.
+ Select spans and click "New Chain" to start.
+
+
+
+
+
+
+ """
+
+ key_bindings = []
+ return schematic, key_bindings
+
+
+def generate_coreference_layout(annotation_scheme, horizontal=False):
+ """
+ Generate coreference chain layout HTML.
+
+ Args:
+ annotation_scheme (dict): The annotation scheme configuration
+ horizontal (bool): Whether to display horizontally
+
+ Returns:
+ tuple: (HTML string, key bindings list)
+ """
+ return safe_generate_layout(
+ annotation_scheme, _generate_coreference_layout_internal, horizontal
+ )
diff --git a/potato/server_utils/schemas/error_span.py b/potato/server_utils/schemas/error_span.py
new file mode 100644
index 0000000000000000000000000000000000000000..eb99896938e78b1c68fb2f2ed5ff8b017b6ba5b3
--- /dev/null
+++ b/potato/server_utils/schemas/error_span.py
@@ -0,0 +1,359 @@
+"""
+Error Span with Typed Severity Layout
+
+Mark error spans in text, assign each an error type from a configurable taxonomy
+and a severity level. Computes an overall quality score. This is the MQM
+(Multidimensional Quality Metrics) annotation workflow.
+
+Research: Lommel et al. "Multidimensional Quality Metrics" (themqm.org); WMT 2024 ESA.
+"""
+
+import json
+import logging
+
+from .identifier_utils import (
+ safe_generate_layout,
+ generate_element_identifier,
+ generate_validation_attribute,
+ escape_html_content,
+ generate_layout_attributes
+)
+
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_SEVERITIES = [
+ {"name": "Minor", "weight": -1},
+ {"name": "Major", "weight": -5},
+ {"name": "Critical", "weight": -10},
+]
+DEFAULT_MAX_SCORE = 100
+
+
+def generate_error_span_layout(annotation_scheme):
+ """
+ Generate HTML for an Error Span with Typed Severity interface.
+
+ Args:
+ annotation_scheme (dict): Configuration including:
+ - name: Schema identifier
+ - description: Display description
+ - error_types: List of {name, subtypes?} dicts
+ - severities: List of {name, weight} dicts
+ - show_score: Whether to show quality score
+ - max_score: Maximum quality score
+
+ Returns:
+ tuple: (html_string, key_bindings)
+ """
+ return safe_generate_layout(annotation_scheme, _generate_error_span_layout_internal)
+
+
+def _generate_error_span_layout_internal(annotation_scheme):
+ schema_name = annotation_scheme['name']
+ description = annotation_scheme['description']
+ error_types = annotation_scheme.get('error_types', [])
+ severities = annotation_scheme.get('severities', DEFAULT_SEVERITIES)
+ show_score = annotation_scheme.get('show_score', True)
+ max_score = annotation_scheme.get('max_score', DEFAULT_MAX_SCORE)
+
+ if not error_types:
+ raise ValueError(f"error_span schema '{schema_name}' requires 'error_types'")
+
+ layout_attrs = generate_layout_attributes(annotation_scheme)
+ validation = generate_validation_attribute(annotation_scheme)
+ identifiers = generate_element_identifier(schema_name, schema_name, "hidden")
+
+ # Serialize config for JS
+ config_json = json.dumps({
+ 'error_types': error_types,
+ 'severities': severities,
+ 'max_score': max_score,
+ 'show_score': show_score,
+ })
+
+ # Build error type options HTML for the popup
+ type_options = ""
+ for et in error_types:
+ subtypes = et.get('subtypes', [])
+ if subtypes:
+ type_options += f''
+ else:
+ type_options += f''
+
+ # Build severity radio buttons for the popup
+ severity_radios = ""
+ for sev in severities:
+ severity_radios += f"""
+
+ """
+
+ score_display = ""
+ if show_score:
+ score_display = f"""
+
+ Score: {max_score} / {max_score}
+
+ """
+
+ html = f"""
+
+
+
+ """
+
+ logger.info(f"Generated error span layout for {schema_name}")
+ return html, []
diff --git a/potato/server_utils/schemas/event_annotation.py b/potato/server_utils/schemas/event_annotation.py
new file mode 100644
index 0000000000000000000000000000000000000000..baccba6c5f50c9fd08adf407cc9cfe77d38fd3f5
--- /dev/null
+++ b/potato/server_utils/schemas/event_annotation.py
@@ -0,0 +1,222 @@
+"""
+Event Annotation Layout
+
+Generates the UI for N-ary event annotation with triggers and typed arguments.
+This schema type works in conjunction with a span annotation schema to allow
+users to annotate events like "ATTACK(attacker=John, target=Mary, weapon=knife)".
+"""
+
+import logging
+from .identifier_utils import (
+ safe_generate_layout,
+ generate_element_identifier,
+ escape_html_content,
+ generate_tooltip_html
+)
+from .span import get_span_color, SPAN_COLOR_PALETTE
+
+logger = logging.getLogger(__name__)
+
+# Default colors for event types
+EVENT_COLOR_PALETTE = [
+ "#dc2626", # Red
+ "#2563eb", # Blue
+ "#16a34a", # Green
+ "#9333ea", # Purple
+ "#ea580c", # Orange
+ "#0891b2", # Cyan
+ "#c026d3", # Fuchsia
+ "#ca8a04", # Yellow
+ "#4f46e5", # Indigo
+ "#059669", # Emerald
+]
+
+
+def _generate_event_annotation_layout_internal(annotation_scheme, horizontal=False):
+ """
+ Internal function to generate event annotation layout after validation.
+
+ Args:
+ annotation_scheme: Configuration dictionary containing:
+ - name: Schema name
+ - description: Description shown to user
+ - span_schema: Name of the span schema for entities
+ - event_types: List of event type definitions with:
+ - type: Event type name (e.g., "ATTACK", "HIRE")
+ - color: Optional color for this event type
+ - trigger_labels: Optional list of span labels that can be triggers
+ - arguments: List of argument definitions with:
+ - role: Role name (e.g., "attacker", "target")
+ - entity_types: Optional list of allowed entity types
+ - required: Whether this argument is required (default: false)
+ horizontal: Whether to display horizontally (not used for events)
+
+ Returns:
+ tuple: (HTML string, key bindings list)
+ """
+ scheme_name = annotation_scheme["name"]
+ description = annotation_scheme.get("description", "Annotate events with triggers and arguments")
+ span_schema = annotation_scheme.get("span_schema", "")
+ event_types = annotation_scheme.get("event_types", [])
+ visual_display = annotation_scheme.get("visual_display", {})
+
+ # Build event types HTML
+ event_types_html = ""
+ key_bindings = []
+
+ for i, event_type in enumerate(event_types):
+ type_name = event_type.get("type", f"Event_{i}")
+ color = event_type.get("color", EVENT_COLOR_PALETTE[i % len(EVENT_COLOR_PALETTE)])
+ trigger_labels = event_type.get("trigger_labels", [])
+ arguments = event_type.get("arguments", [])
+
+ # Build arguments data for JavaScript
+ args_data = []
+ for arg in arguments:
+ args_data.append({
+ "role": arg.get("role", ""),
+ "entity_types": arg.get("entity_types", []),
+ "required": arg.get("required", False)
+ })
+
+ import json
+ args_json = json.dumps(args_data)
+
+ # Tooltip with argument info
+ tooltip_parts = []
+ if trigger_labels:
+ tooltip_parts.append(f"Triggers: {', '.join(trigger_labels)}")
+ if arguments:
+ arg_strs = []
+ for arg in arguments:
+ role = arg.get("role", "")
+ req = "(required)" if arg.get("required", False) else "(optional)"
+ entity_types = arg.get("entity_types", [])
+ if entity_types:
+ arg_strs.append(f"{role} {req}: {', '.join(entity_types)}")
+ else:
+ arg_strs.append(f"{role} {req}")
+ tooltip_parts.append("Arguments: " + "; ".join(arg_strs))
+
+ tooltip_attr = ""
+ if tooltip_parts:
+ tooltip_text = " | ".join(tooltip_parts)
+ tooltip_attr = f'data-toggle="tooltip" data-placement="top" title="{escape_html_content(tooltip_text)}"'
+
+ event_types_html += f"""
+
+
+
+
+ """
+
+ # Visual display settings
+ show_arcs = visual_display.get("enabled", True)
+ arc_position = visual_display.get("arc_position", "above")
+ show_labels = visual_display.get("show_labels", True)
+
+ schematic = f"""
+
+
+
+
+
+
+
+
+ {event_types_html}
+
+
+
+
+
+
+
+
Click on a span to set it as the event trigger
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
No events created yet
+
+
+
+
+
+
+
+
+
+
+
+ """
+
+ return schematic, key_bindings
+
+
+def generate_event_annotation_layout(annotation_scheme, horizontal=False):
+ """
+ Generate event annotation layout HTML for the given annotation scheme.
+
+ Args:
+ annotation_scheme (dict): The annotation scheme configuration
+ horizontal (bool): Whether to display horizontally
+
+ Returns:
+ tuple: (HTML string, key bindings list)
+ """
+ return safe_generate_layout(annotation_scheme, _generate_event_annotation_layout_internal, horizontal)
diff --git a/potato/server_utils/schemas/extractive_qa.py b/potato/server_utils/schemas/extractive_qa.py
new file mode 100644
index 0000000000000000000000000000000000000000..84737e6755436838a7206f748ad796f6be196b91
--- /dev/null
+++ b/potato/server_utils/schemas/extractive_qa.py
@@ -0,0 +1,226 @@
+"""
+Extractive QA / Answer Span Layout
+
+Display a question and a passage; annotator highlights the answer span in the passage.
+A streamlined SQuAD-style workflow that combines question display with directed span selection.
+
+Research: Rajpurkar et al. (2016) "SQuAD"; Kwiatkowski et al. (2019) "Natural Questions".
+"""
+
+import logging
+
+from .identifier_utils import (
+ safe_generate_layout,
+ generate_element_identifier,
+ generate_validation_attribute,
+ escape_html_content,
+ generate_layout_attributes
+)
+
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_HIGHLIGHT_COLOR = "#FFEB3B"
+DEFAULT_ALLOW_UNANSWERABLE = True
+
+
+def generate_extractive_qa_layout(annotation_scheme):
+ """
+ Generate HTML for an Extractive QA interface.
+
+ Args:
+ annotation_scheme (dict): Configuration including:
+ - name: Schema identifier
+ - description: Display description
+ - question_field: Field in data containing the question
+ - passage_field: Field in data containing the passage
+ - allow_unanswerable: Whether to show "Unanswerable" button
+ - highlight_color: Color for answer highlight
+
+ Returns:
+ tuple: (html_string, key_bindings)
+ """
+ return safe_generate_layout(annotation_scheme, _generate_extractive_qa_layout_internal)
+
+
+def _generate_extractive_qa_layout_internal(annotation_scheme):
+ schema_name = annotation_scheme['name']
+ description = annotation_scheme['description']
+ question_field = annotation_scheme.get('question_field', 'question')
+ passage_field = annotation_scheme.get('passage_field', '')
+ allow_unanswerable = annotation_scheme.get('allow_unanswerable', DEFAULT_ALLOW_UNANSWERABLE)
+ highlight_color = annotation_scheme.get('highlight_color', DEFAULT_HIGHLIGHT_COLOR)
+
+ layout_attrs = generate_layout_attributes(annotation_scheme)
+ validation = generate_validation_attribute(annotation_scheme)
+ identifiers = generate_element_identifier(schema_name, schema_name, "hidden")
+
+ unanswerable_btn = ""
+ if allow_unanswerable:
+ unanswerable_btn = f"""
+
+ """
+
+ html = f"""
+
+
+
+ """
+
+ logger.info(f"Generated extractive QA layout for {schema_name}")
+ return html, []
diff --git a/potato/server_utils/schemas/hierarchical_multiselect.py b/potato/server_utils/schemas/hierarchical_multiselect.py
new file mode 100644
index 0000000000000000000000000000000000000000..4748c06a9bd9b43551e42ea923762b03f605aca7
--- /dev/null
+++ b/potato/server_utils/schemas/hierarchical_multiselect.py
@@ -0,0 +1,334 @@
+"""
+Hierarchical Multi-Label Selection Layout
+
+Generates an expandable/collapsible tree of checkboxes for hierarchical
+taxonomy labeling. Stores selections as a hidden input with comma-separated values.
+
+Research basis:
+- Silla & Freitas (2011) "A Survey of Hierarchical Classification Across Different
+ Application Domains" Data Mining and Knowledge Discovery
+- Vens et al. (2008) "Decision Trees for Hierarchical Multi-label Classification"
+ Machine Learning
+"""
+
+import json
+import logging
+
+from potato.ai.ai_help_wrapper import get_ai_wrapper
+from .identifier_utils import (
+ safe_generate_layout,
+ generate_element_identifier,
+ generate_validation_attribute,
+ escape_html_content,
+ generate_layout_attributes,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def generate_hierarchical_multiselect_layout(annotation_scheme):
+ """
+ Generate HTML for a hierarchical multi-label selection interface.
+
+ Args:
+ annotation_scheme (dict): Configuration including:
+ - name: Schema identifier
+ - description: Display description
+ - taxonomy: Nested dict/list defining the hierarchy
+ - auto_select_children: Auto-select children when parent selected (default false)
+ - auto_select_parent: Auto-select parent when all children selected (default false)
+ - show_search: Show search/filter box (default false)
+ - max_selections: Maximum number of selections (null = unlimited)
+
+ Returns:
+ tuple: (html_string, key_bindings)
+ """
+ return safe_generate_layout(annotation_scheme, _generate_hierarchical_multiselect_layout_internal)
+
+
+def _build_tree_html(taxonomy, schema_name, prefix="", depth=0):
+ """Recursively build tree HTML from taxonomy dict/list."""
+ html = ""
+ safe_schema = escape_html_content(schema_name)
+
+ if isinstance(taxonomy, dict):
+ for key, children in taxonomy.items():
+ safe_key = escape_html_content(key)
+ node_id = f"{prefix}{key}".replace(" ", "_")
+ safe_node_id = escape_html_content(node_id)
+ has_children = bool(children)
+ toggle = '▶' if has_children else ''
+
+ html += f"""
+
+
+ {toggle}
+
+
+ """
+ if has_children:
+ html += f'
'
+ html += _build_tree_html(children, schema_name, prefix=f"{node_id}.", depth=depth + 1)
+ html += "
"
+ html += "
"
+
+ elif isinstance(taxonomy, list):
+ for item in taxonomy:
+ safe_item = escape_html_content(str(item))
+ node_id = f"{prefix}{item}".replace(" ", "_")
+ safe_node_id = escape_html_content(node_id)
+
+ html += f"""
+
+
+
+
+
+
+ """
+
+ return html
+
+
+def _generate_hierarchical_multiselect_layout_internal(annotation_scheme):
+ schema_name = annotation_scheme["name"]
+ safe_schema = escape_html_content(schema_name)
+ description = annotation_scheme["description"]
+ taxonomy = annotation_scheme.get("taxonomy", {})
+ auto_select_children = annotation_scheme.get("auto_select_children", False)
+ auto_select_parent = annotation_scheme.get("auto_select_parent", False)
+ show_search = annotation_scheme.get("show_search", False)
+ max_selections = annotation_scheme.get("max_selections", None)
+ layout_attrs = generate_layout_attributes(annotation_scheme)
+ validation = generate_validation_attribute(annotation_scheme)
+
+ if not taxonomy:
+ raise ValueError(f"hierarchical_multiselect schema '{schema_name}' requires 'taxonomy'")
+
+ identifiers = generate_element_identifier(schema_name, "selected_labels", "hidden")
+
+ html = f"""
+
+
+ """
+
+ key_bindings = []
+ logger.info(f"Generated hierarchical_multiselect layout for {schema_name}")
+ return html, key_bindings
diff --git a/potato/server_utils/schemas/identifier_utils.py b/potato/server_utils/schemas/identifier_utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..a8c28a6c3759a422a294ed32f0ef6482b5104fff
--- /dev/null
+++ b/potato/server_utils/schemas/identifier_utils.py
@@ -0,0 +1,394 @@
+"""
+Identifier Utilities for Schema Generation
+
+This module provides centralized functions for generating consistent identifiers
+and validating schema configurations across all annotation schema types.
+"""
+
+import html
+import logging
+from collections.abc import Mapping
+from typing import Dict, Any, Tuple, List
+
+logger = logging.getLogger(__name__)
+
+def validate_schema_config(annotation_scheme: dict) -> bool:
+ """
+ Validate schema configuration before generating HTML.
+
+ Args:
+ annotation_scheme: Schema configuration dictionary
+
+ Returns:
+ bool: True if valid, raises exception if invalid
+
+ Raises:
+ ValueError: If configuration is invalid
+ """
+ # Check required fields
+ required_fields = ["name", "description"]
+ for field in required_fields:
+ if field not in annotation_scheme:
+ raise ValueError(f"Missing required field: {field}")
+
+ # Validate schema name
+ schema_name = annotation_scheme["name"]
+ if not schema_name or not str(schema_name).strip():
+ raise ValueError("Schema name cannot be empty")
+
+ # Validate description
+ description = annotation_scheme["description"]
+ if not description or not str(description).strip():
+ raise ValueError("Schema description cannot be empty")
+
+ # Validate labels if present
+ if "labels" in annotation_scheme:
+ labels = annotation_scheme["labels"]
+ if not labels:
+ raise ValueError("Labels list cannot be empty")
+
+ # Check for duplicate labels
+ label_names = []
+ for label in labels:
+ if isinstance(label, str):
+ label_names.append(label.strip())
+ elif isinstance(label, dict) and "name" in label:
+ label_names.append(label["name"].strip())
+ else:
+ raise ValueError(f"Invalid label format: {label}")
+
+ # Check for empty labels
+ if any(not name for name in label_names):
+ raise ValueError("Label names cannot be empty")
+
+ # Check for duplicates
+ if len(label_names) != len(set(label_names)):
+ duplicates = [name for name in set(label_names) if label_names.count(name) > 1]
+ raise ValueError(f"Duplicate labels found: {duplicates}")
+
+ logger.debug(f"Schema configuration validation passed for: {schema_name}")
+ return True
+
+def generate_element_identifier(schema_name: str, label_name: str, element_type: str = "default") -> Dict[str, str]:
+ """
+ Generate consistent identifiers for form elements.
+
+ Args:
+ schema_name: Name of the annotation schema
+ label_name: Name of the specific label/option
+ element_type: Type of element (radio, checkbox, text, etc.)
+
+ Returns:
+ dict: Contains id, name, schema, and label_name attributes
+ """
+ # Sanitize inputs
+ safe_schema = escape_html_content(schema_name.strip())
+ safe_label = escape_html_content(label_name.strip())
+
+ # Generate unique identifier (using underscore to avoid conflicts with CSS selectors)
+ element_id = f"{safe_schema}_{safe_label}_{element_type}".replace(":::", "_")
+
+ # For radio buttons, use schema name as the group name to ensure mutual exclusivity
+ if element_type == "radio":
+ element_name = safe_schema
+ else:
+ element_name = f"{safe_schema}:::{safe_label}"
+
+ return {
+ "id": element_id,
+ "name": element_name,
+ "schema": safe_schema,
+ "label_name": safe_label
+ }
+
+def generate_element_value(label_data: Any, index: int, annotation_scheme: dict) -> str:
+ """
+ Generate consistent value attributes for form elements.
+
+ Args:
+ label_data: Label configuration (string or dict)
+ index: Index of the label in the list
+ annotation_scheme: Full schema configuration
+
+ Returns:
+ str: Value to use for the element
+ """
+ # Handle custom key_value first
+ if isinstance(label_data, dict) and "key_value" in label_data:
+ return str(label_data["key_value"])
+
+ # Handle sequential key binding
+ if annotation_scheme.get("sequential_key_binding"):
+ return str(index % 10)
+
+ # Default to label name
+ if isinstance(label_data, str):
+ return label_data
+ elif isinstance(label_data, dict) and "name" in label_data:
+ return label_data["name"]
+
+ # Fallback to index
+ return str(index)
+
+def escape_html_content(content: str) -> str:
+ """
+ Escape HTML content to prevent injection.
+
+ Args:
+ content: Content to escape
+
+ Returns:
+ str: Escaped content
+ """
+ if not content:
+ return ""
+ return html.escape(str(content))
+
+
+def humanize_label(text: str) -> str:
+ """Turn a machine label (``agent_a_much_better``) into readable text
+ (``Agent A Much Better``) for display only -- the stored annotation
+ value is always the original label name, never this.
+
+ Tokens that are already mixed/upper case or contain digits+letters
+ (acronyms, ``GPT4``, ``v2``) are preserved as-is so we don't mangle
+ them; purely lowercase tokens are capitalized.
+ """
+ if not text:
+ return ""
+ # Never mangle Jinja/template expressions (e.g. dynamic_labels:
+ # "{{instance_obj.labels[0]}}"). Humanizing would rewrite `instance_obj`
+ # to `instance Obj`, producing invalid Jinja and a 500 at render time.
+ if "{{" in str(text) or "{%" in str(text):
+ return str(text)
+ s = str(text).replace("_", " ").replace("-", " ")
+ s = " ".join(s.split()) # collapse whitespace
+ out = []
+ for tok in s.split(" "):
+ if tok.islower():
+ out.append(tok[:1].upper() + tok[1:])
+ else:
+ out.append(tok) # preserve ACRONYMs, GPT4, v2, MixedCase
+ return " ".join(out)
+
+
+def display_label_text(label_data: Any, annotation_scheme: dict) -> str:
+ """Resolve the *visible* text for a label.
+
+ Precedence: explicit ``displayed_label`` on a dict label >
+ humanized name (default, when ``humanize_labels`` is not disabled) >
+ raw name. Stored value is unaffected by this function.
+ """
+ if isinstance(label_data, Mapping):
+ if label_data.get("displayed_label"):
+ return str(label_data["displayed_label"])
+ name = label_data.get("name", "")
+ else:
+ name = label_data
+ if annotation_scheme.get("humanize_labels", True):
+ return humanize_label(name)
+ return str(name)
+
+def safe_generate_layout(annotation_scheme: dict, layout_function: callable, *args, **kwargs) -> Tuple[str, List[Tuple[str, str]]]:
+ """
+ Safely generate layout with proper error handling.
+
+ Args:
+ annotation_scheme: Schema configuration
+ layout_function: Function to generate layout
+ *args, **kwargs: Additional arguments for the layout function
+
+ Returns:
+ tuple: (html_string, key_bindings)
+ """
+ try:
+ # Validate configuration
+ validate_schema_config(annotation_scheme)
+
+ # Generate layout
+ return layout_function(annotation_scheme, *args, **kwargs)
+
+ except Exception as e:
+ schema_name = annotation_scheme.get('name', 'unknown')
+ logger.error(f"Failed to generate layout for schema '{schema_name}': {e}")
+
+ # Return error HTML instead of crashing
+ error_html = f"""
+
+
Error Generating Annotation Form
+
Schema: {escape_html_content(schema_name)}
+
{escape_html_content(str(e))}
+
+ """
+ return error_html, []
+
+def generate_validation_attribute(annotation_scheme: dict, label_name: str = None) -> str:
+ """
+ Generate validation attribute for form elements.
+
+ Args:
+ annotation_scheme: Schema configuration
+ label_name: Specific label name for required_label validation
+
+ Returns:
+ str: Validation attribute value
+ """
+ label_requirement = annotation_scheme.get("label_requirement", {})
+
+ # Normalize: label_requirement: true (bool) โ {"required": true}
+ if isinstance(label_requirement, bool):
+ label_requirement = {"required": True} if label_requirement else {}
+
+ # Support top-level required: true as shorthand for label_requirement.required
+ if not label_requirement and annotation_scheme.get("required") is True:
+ label_requirement = {"required": True}
+
+ # Debug logging
+ logger.debug(f"generate_validation_attribute called with label_requirement: {label_requirement}")
+ logger.debug(f"label_name: {label_name}")
+
+ # Check for required_label validation
+ if label_name and label_requirement.get("required_label"):
+ required_labels = label_requirement["required_label"]
+ if isinstance(required_labels, str) and label_name == required_labels:
+ logger.debug(f"Returning 'required_label' for label: {label_name}")
+ return "required_label"
+ elif isinstance(required_labels, list) and label_name in required_labels:
+ logger.debug(f"Returning 'required_label' for label: {label_name}")
+ return "required_label"
+
+ # Check for general required validation
+ if label_requirement.get("required"):
+ logger.debug(f"Returning 'required' for general requirement")
+ return "required"
+
+ logger.debug(f"Returning empty string - no validation requirements met")
+ return ""
+
+
+def generate_layout_attributes(annotation_scheme: dict) -> str:
+ """
+ Generate layout-related HTML attributes for grid positioning.
+
+ Args:
+ annotation_scheme: Schema configuration that may contain:
+ - layout: dict with layout options
+ - columns: Number of grid columns to span (1-6, default: 1)
+ - rows: Number of grid rows to span (1-4, default: 1)
+ - order: Explicit ordering integer for grid placement
+ - min_width: Minimum width CSS value (e.g., "200px")
+ - max_width: Maximum width CSS value (e.g., "400px")
+ - align_self: Alignment override (start, center, end, stretch)
+
+ Returns:
+ str: HTML attribute string for layout (e.g., 'data-grid-columns="2" data-grid-rows="1"')
+
+ Example config:
+ annotation_schemes:
+ - name: preference
+ description: "Which is better?"
+ layout:
+ columns: 2 # Span 2 columns in the grid
+ rows: 1 # Span 1 row (default)
+ order: 1 # Explicit ordering
+ min_width: "200px"
+ max_width: "400px"
+ align_self: "start"
+ """
+ layout_config = annotation_scheme.get("layout", {})
+ attrs = []
+
+ # Column span (1-6, default: 1)
+ columns = layout_config.get("columns", 1)
+ if not isinstance(columns, int) or columns < 1:
+ columns = 1
+ elif columns > 6:
+ columns = 6
+ attrs.append(f'data-grid-columns="{columns}"')
+
+ # Row span (1-4, default: 1)
+ rows = layout_config.get("rows", 1)
+ if isinstance(rows, int) and rows > 1:
+ rows = min(rows, 4)
+ attrs.append(f'data-grid-rows="{rows}"')
+
+ # Explicit order (integer)
+ order = layout_config.get("order")
+ if isinstance(order, int):
+ attrs.append(f'data-grid-order="{order}"')
+
+ # Min/max width via CSS custom properties in style attribute
+ style_parts = []
+ min_width = layout_config.get("min_width")
+ if min_width and isinstance(min_width, str):
+ style_parts.append(f"--form-min-width: {html.escape(min_width)}")
+
+ max_width = layout_config.get("max_width")
+ if max_width and isinstance(max_width, str):
+ style_parts.append(f"--form-max-width: {html.escape(max_width)}")
+
+ if style_parts:
+ attrs.append(f'style="{"; ".join(style_parts)}"')
+
+ # Align self override
+ align_self = layout_config.get("align_self")
+ valid_alignments = ["start", "center", "end", "stretch"]
+ if align_self and align_self in valid_alignments:
+ attrs.append(f'data-align-self="{align_self}"')
+
+ return " ".join(attrs)
+
+
+def generate_tooltip_html(label_data: Dict[str, Any]) -> str:
+ """
+ Generate tooltip HTML attribute from label data.
+
+ This function provides centralized tooltip generation for all schema types.
+ It checks for tooltip text in the label configuration, either directly or
+ from an external file.
+
+ Args:
+ label_data: Label configuration dictionary that may contain:
+ - tooltip: Direct tooltip text string
+ - tooltip_file: Path to file containing tooltip text
+
+ Returns:
+ str: Tooltip HTML attribute string (e.g., 'data-toggle="tooltip" ...')
+ or empty string if no tooltip is configured
+
+ Example:
+ >>> label_data = {"name": "Option 1", "tooltip": "Select this option"}
+ >>> generate_tooltip_html(label_data)
+ 'data-toggle="tooltip" data-html="true" data-placement="top" title="Select this option"'
+ """
+ if not isinstance(label_data, dict):
+ return ""
+
+ tooltip_text = ""
+
+ # Check for direct tooltip text
+ if "tooltip" in label_data:
+ tooltip_text = label_data["tooltip"]
+ logger.debug(f"Found direct tooltip text for label")
+
+ # Check for tooltip file
+ elif "tooltip_file" in label_data:
+ try:
+ with open(label_data["tooltip_file"], "rt", encoding="utf-8") as f:
+ tooltip_text = "".join(f.readlines())
+ logger.debug(f"Read tooltip from file: {label_data['tooltip_file']}")
+ except FileNotFoundError:
+ logger.error(f"Tooltip file not found: {label_data['tooltip_file']}")
+ return ""
+ except PermissionError:
+ logger.error(f"Permission denied reading tooltip file: {label_data['tooltip_file']}")
+ return ""
+ except Exception as e:
+ logger.error(f"Failed to read tooltip file '{label_data['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 ""
\ No newline at end of file
diff --git a/potato/server_utils/schemas/image_annotation.py b/potato/server_utils/schemas/image_annotation.py
new file mode 100644
index 0000000000000000000000000000000000000000..6b2aca84853c9845ca956c9fdc8a98e95ef67d4a
--- /dev/null
+++ b/potato/server_utils/schemas/image_annotation.py
@@ -0,0 +1,610 @@
+"""
+Image Annotation Layout
+
+Generates a form interface for annotating images with:
+- Bounding boxes (rectangular regions)
+- Polygons (arbitrary shapes)
+- Freeform drawing (brush strokes)
+- Landmarks (point annotations)
+
+Uses Fabric.js for canvas-based annotation with zoom/pan support.
+"""
+
+import logging
+import json
+from .identifier_utils import (
+ safe_generate_layout,
+ escape_html_content
+)
+
+logger = logging.getLogger(__name__)
+
+# Default colors for labels if not specified
+DEFAULT_COLORS = [
+ "#FF6B6B", # Red
+ "#4ECDC4", # Teal
+ "#45B7D1", # Blue
+ "#96CEB4", # Green
+ "#FFEAA7", # Yellow
+ "#DDA0DD", # Plum
+ "#98D8C8", # Mint
+ "#F7DC6F", # Gold
+ "#BB8FCE", # Purple
+ "#85C1E9", # Light Blue
+]
+
+# Valid annotation tools
+VALID_TOOLS = ["bbox", "polygon", "freeform", "landmark", "fill", "eraser", "brush"]
+
+
+def generate_image_annotation_layout(annotation_scheme):
+ """
+ Generate HTML for an image annotation interface.
+
+ Args:
+ annotation_scheme (dict): Configuration including:
+ - name: Schema identifier
+ - description: Display description
+ - tools: List of tools to enable (bbox, polygon, freeform, landmark)
+ - labels: List of label definitions with name and optional color
+ - zoom_enabled: Whether to enable zoom (default: True)
+ - pan_enabled: Whether to enable pan (default: True)
+ - min_annotations: Minimum required annotations (default: 0)
+ - max_annotations: Maximum allowed annotations (default: null/unlimited)
+ - freeform_brush_size: Brush size for freeform tool (default: 5)
+ - freeform_simplify: Whether to simplify freeform paths (default: True)
+
+ Returns:
+ tuple: (html_string, key_bindings)
+ html_string: Complete HTML for the image annotation interface
+ key_bindings: List of keyboard shortcuts
+
+ Raises:
+ ValueError: If required fields are missing or invalid
+ """
+ return safe_generate_layout(annotation_scheme, _generate_image_annotation_layout_internal)
+
+
+def _generate_image_annotation_layout_internal(annotation_scheme):
+ """
+ Internal function to generate image annotation layout after validation.
+ """
+ schema_name = annotation_scheme.get('name', 'image_annotation')
+ logger.debug(f"Generating image annotation layout for schema: {schema_name}")
+
+ # Validate required fields
+ if "labels" not in annotation_scheme:
+ error_msg = f"Missing labels in schema: {schema_name}"
+ logger.error(error_msg)
+ raise ValueError(error_msg)
+
+ if "tools" not in annotation_scheme:
+ error_msg = f"Missing tools in schema: {schema_name}"
+ logger.error(error_msg)
+ raise ValueError(error_msg)
+
+ # Validate tools
+ tools = annotation_scheme["tools"]
+ if not isinstance(tools, list) or not tools:
+ error_msg = f"tools must be a non-empty list in schema: {schema_name}"
+ logger.error(error_msg)
+ raise ValueError(error_msg)
+
+ invalid_tools = [t for t in tools if t not in VALID_TOOLS]
+ if invalid_tools:
+ error_msg = f"Invalid tools: {invalid_tools}. Valid tools are: {VALID_TOOLS}"
+ logger.error(error_msg)
+ raise ValueError(error_msg)
+
+ # Process labels with colors
+ labels = _process_labels(annotation_scheme["labels"])
+
+ # Get configuration options
+ zoom_enabled = annotation_scheme.get("zoom_enabled", True)
+ pan_enabled = annotation_scheme.get("pan_enabled", True)
+ min_annotations = annotation_scheme.get("min_annotations", 0)
+ max_annotations = annotation_scheme.get("max_annotations", None)
+ freeform_brush_size = annotation_scheme.get("freeform_brush_size", 5)
+ freeform_simplify = annotation_scheme.get("freeform_simplify", True)
+
+ # Segmentation mask configuration
+ brush_size = annotation_scheme.get("brush_size", 20)
+ eraser_size = annotation_scheme.get("eraser_size", 20)
+ mask_opacity = annotation_scheme.get("mask_opacity", 0.5)
+
+ # AI support configuration
+ ai_support = annotation_scheme.get("ai_support", {})
+ ai_enabled = ai_support.get("enabled", False)
+
+ # source_field: Links this annotation schema to a display field from instance_display
+ source_field = annotation_scheme.get("source_field", "")
+
+ # Build config object for JavaScript
+ js_config = {
+ "schemaName": schema_name,
+ "tools": tools,
+ "labels": labels,
+ "zoomEnabled": zoom_enabled,
+ "panEnabled": pan_enabled,
+ "minAnnotations": min_annotations,
+ "maxAnnotations": max_annotations,
+ "freeformBrushSize": freeform_brush_size,
+ "freeformSimplify": freeform_simplify,
+ "brushSize": brush_size,
+ "eraserSize": eraser_size,
+ "maskOpacity": mask_opacity,
+ "aiSupport": ai_enabled,
+ "aiFeatures": ai_support.get("features", {}) if ai_enabled else {},
+ "sourceField": source_field,
+ }
+
+ # Generate HTML
+ html = _generate_html(annotation_scheme, js_config, schema_name, labels, tools, ai_enabled, ai_support)
+
+ # Generate keybindings
+ keybindings = _generate_keybindings(labels, tools)
+
+ logger.info(f"Successfully generated image annotation layout for {schema_name}")
+ return html, keybindings
+
+
+def _process_labels(labels_config):
+ """
+ Process label configuration and assign colors.
+
+ Args:
+ labels_config: List of label configs (strings or dicts)
+
+ Returns:
+ List of processed label dicts with name, color, and optional key_value
+ """
+ processed = []
+ for i, label in enumerate(labels_config):
+ if isinstance(label, str):
+ processed.append({
+ "name": label,
+ "color": DEFAULT_COLORS[i % len(DEFAULT_COLORS)],
+ })
+ elif isinstance(label, dict):
+ processed.append({
+ "name": label.get("name", f"label_{i}"),
+ "color": label.get("color", DEFAULT_COLORS[i % len(DEFAULT_COLORS)]),
+ "key_value": label.get("key_value"),
+ })
+ else:
+ processed.append({
+ "name": str(label),
+ "color": DEFAULT_COLORS[i % len(DEFAULT_COLORS)],
+ })
+ return processed
+
+
+def _generate_html(annotation_scheme, js_config, schema_name, labels, tools, ai_enabled=False, ai_support=None):
+ """
+ Generate the HTML for the image annotation interface.
+ """
+ escaped_name = escape_html_content(schema_name)
+ description = escape_html_content(annotation_scheme.get('description', ''))
+ config_json = json.dumps(js_config)
+
+ # source_field attribute for linking to display fields
+ source_field = annotation_scheme.get("source_field", "")
+ source_field_attr = f' data-source-field="{escape_html_content(source_field)}"' if source_field else ""
+
+ # Generate tool buttons
+ tool_buttons = _generate_tool_buttons(tools)
+
+ # Generate label selector
+ label_selector = _generate_label_selector(labels)
+
+ # Generate AI toolbar if enabled
+ ai_toolbar_html = ""
+ ai_init_script = ""
+ if ai_enabled:
+ ai_features = ai_support.get("features", {}) if ai_support else {}
+ ai_toolbar_html = _generate_ai_toolbar(ai_features)
+ ai_init_script = _generate_ai_init_script(escaped_name)
+
+ html = f'''
+
+ '''
+
+ return html
+
+
+def _generate_ai_toolbar(ai_features):
+ """
+ Generate HTML for the AI assistance toolbar.
+ """
+ # Determine which buttons to show based on features
+ detection_enabled = ai_features.get("detection", True)
+ pre_annotate_enabled = ai_features.get("pre_annotate", True)
+ classification_enabled = ai_features.get("classification", False)
+ hint_enabled = ai_features.get("hint", True)
+
+ buttons = []
+
+ if detection_enabled:
+ buttons.append(
+ ''
+ )
+
+ if pre_annotate_enabled:
+ buttons.append(
+ ''
+ )
+
+ if classification_enabled:
+ buttons.append(
+ ''
+ )
+
+ if hint_enabled:
+ buttons.append(
+ ''
+ )
+
+ if not buttons:
+ return ""
+
+ return f'''
+
+
+
+
+ '''
+
+
+def _generate_ai_init_script(escaped_name):
+ """
+ Generate JavaScript initialization code for AI assistant.
+ """
+ return f'''
+ // Initialize AI assistant if enabled and VisualAIAssistantManager is available
+ if (config.aiSupport && typeof VisualAIAssistantManager !== 'undefined') {{
+ var annotationId = Array.from(document.querySelectorAll('.annotation-form')).indexOf(
+ document.getElementById('{escaped_name}')
+ );
+ container.aiAssistant = new VisualAIAssistantManager({{
+ annotationType: 'image_annotation',
+ annotationId: annotationId >= 0 ? annotationId : 0,
+ annotationManager: manager
+ }});
+ }}
+ '''
+
+
+def _generate_tool_buttons(tools):
+ """
+ Generate HTML for tool selection buttons.
+ """
+ tool_info = {
+ "bbox": {"label": "Box", "title": "Bounding Box (B)", "icon": "โก"},
+ "polygon": {"label": "Polygon", "title": "Polygon (P)", "icon": "โฌก"},
+ "freeform": {"label": "Draw", "title": "Freeform Draw (F)", "icon": "โ"},
+ "landmark": {"label": "Point", "title": "Landmark Point (L)", "icon": "โ"},
+ "brush": {"label": "Brush", "title": "Segmentation Brush (M)", "icon": "๐๏ธ"},
+ "fill": {"label": "Fill", "title": "Flood Fill (G)", "icon": "๐ชฃ"},
+ "eraser": {"label": "Eraser", "title": "Eraser (E)", "icon": "โซ"},
+ }
+
+ buttons = []
+ for tool in tools:
+ info = tool_info.get(tool, {"label": tool, "title": tool, "icon": "?"})
+ buttons.append(
+ f''
+ )
+
+ return "\n".join(buttons)
+
+
+def _generate_label_selector(labels):
+ """
+ Generate HTML for label selection buttons.
+ """
+ buttons = []
+ for label in labels:
+ name = escape_html_content(label["name"])
+ color = label["color"]
+ key_hint = f' ({label["key_value"]})' if label.get("key_value") else ""
+ buttons.append(
+ f''
+ )
+
+ return "\n".join(buttons)
+
+
+def _generate_keybindings(labels, tools):
+ """
+ Generate keybinding list for the schema.
+ """
+ keybindings = []
+
+ # Tool shortcuts
+ tool_keys = {
+ "bbox": ("b", "Bounding Box tool"),
+ "polygon": ("p", "Polygon tool"),
+ "freeform": ("f", "Freeform draw tool"),
+ "landmark": ("l", "Landmark point tool"),
+ "brush": ("m", "Segmentation brush tool"),
+ "fill": ("g", "Flood fill tool"),
+ "eraser": ("e", "Eraser tool"),
+ }
+ for tool in tools:
+ if tool in tool_keys:
+ keybindings.append(tool_keys[tool])
+
+ # Label shortcuts
+ for label in labels:
+ if label.get("key_value"):
+ keybindings.append((label["key_value"], f"Select label: {label['name']}"))
+
+ # Common shortcuts
+ keybindings.extend([
+ ("Del", "Delete selected"),
+ ("+/-", "Zoom in/out"),
+ ("0", "Fit to view"),
+ ])
+
+ return keybindings
diff --git a/potato/server_utils/schemas/keybinding_allocator.py b/potato/server_utils/schemas/keybinding_allocator.py
new file mode 100644
index 0000000000000000000000000000000000000000..152954df8fe535ea62fb098a83448265db14cf6c
--- /dev/null
+++ b/potato/server_utils/schemas/keybinding_allocator.py
@@ -0,0 +1,247 @@
+"""
+Keybinding Allocator
+
+Centralized allocation of non-conflicting keyboard shortcuts across all
+annotation schemas. When multiple schemas use sequential_key_binding: true,
+this module assigns keys from separate pools so they don't overlap.
+
+Key pools (QWERTY layout):
+ Pool 0: 1 2 3 4 5 6 7 8 9 0 (number row)
+ Pool 1: q w e r t y u i o p (top letter row)
+ Pool 2: a s d f g h j k l (home row)
+
+Schemas that self-manage keys (pairwise, bws) pre-claim their hardcoded keys.
+Explicit per-label key_value overrides are always honored.
+"""
+
+import logging
+from collections.abc import Mapping
+
+logger = logging.getLogger(__name__)
+
+KEY_POOLS = [
+ ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'],
+ ['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p'],
+ ['a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l'],
+]
+
+# Schema types that manage their own keybindings internally
+SELF_MANAGED_TYPES = {'pairwise', 'bws', 'triage'}
+
+
+def _get_label_name(label_data):
+ """Extract the label name from a label entry (string or dict)."""
+ if isinstance(label_data, str):
+ return label_data
+ if isinstance(label_data, Mapping):
+ return label_data.get("name", "")
+ return str(label_data)
+
+
+def _get_explicit_key(label_data):
+ """Extract explicit key_value from a label entry, or None."""
+ if isinstance(label_data, Mapping):
+ kv = label_data.get("key_value")
+ if kv is not None:
+ return str(kv).lower()
+ return None
+
+
+def _needs_allocation(scheme):
+ """Check if a schema needs keybinding allocation."""
+ ann_type = scheme.get("annotation_type", "")
+ if ann_type in SELF_MANAGED_TYPES:
+ return False
+
+ strategy = scheme.get("keybinding_strategy", "")
+ if strategy == "none":
+ return False
+
+ # Explicit sequential_key_binding
+ if scheme.get("sequential_key_binding"):
+ return True
+
+ # keybinding_strategy set to sequential or mnemonic
+ if strategy in ("sequential", "mnemonic"):
+ return True
+
+ return False
+
+
+def _assign_mnemonic_keys(labels, used_keys):
+ """
+ Assign mnemonic keys based on first available letter of each label name.
+ Falls back to next available letter if the preferred one is taken.
+
+ Returns list of (label_name, key) tuples.
+ """
+ # All available mnemonic letters
+ all_letters = list('abcdefghijklmnopqrstuvwxyz')
+ available = [c for c in all_letters if c not in used_keys]
+
+ assignments = []
+ for label_data in labels:
+ label_name = _get_label_name(label_data)
+ explicit = _get_explicit_key(label_data)
+ if explicit:
+ assignments.append((label_name, explicit))
+ continue
+
+ # Try each character of the label name
+ assigned = False
+ for char in label_name.lower():
+ if char.isalpha() and char in available:
+ assignments.append((label_name, char))
+ available.remove(char)
+ assigned = True
+ break
+
+ if not assigned:
+ # Fall back to next available letter
+ if available:
+ key = available.pop(0)
+ assignments.append((label_name, key))
+ logger.warning(
+ f"No mnemonic match for '{label_name}', "
+ f"assigned fallback key '{key}'"
+ )
+ else:
+ assignments.append((label_name, None))
+ logger.warning(
+ f"No keys available for label '{label_name}'"
+ )
+
+ return assignments
+
+
+def allocate_keybindings(annotation_schemes):
+ """
+ Pre-allocate non-conflicting keys across all annotation schemas.
+
+ Args:
+ annotation_schemes: List of annotation scheme dicts from config.
+
+ Returns:
+ dict: {schema_name: [{"label": str, "key": str|None}, ...]}
+ Only schemas that need allocation are included.
+ """
+ # Step 1: Collect all explicitly-set keys across all schemas
+ globally_used = set()
+
+ for scheme in annotation_schemes:
+ if not _needs_allocation(scheme):
+ continue
+ for label_data in scheme.get("labels", []):
+ explicit = _get_explicit_key(label_data)
+ if explicit:
+ globally_used.add(explicit)
+
+ # Step 2: Pre-claim keys used by self-managed schemas (pairwise, bws)
+ for scheme in annotation_schemes:
+ ann_type = scheme.get("annotation_type", "")
+ if ann_type == "pairwise":
+ if scheme.get("sequential_key_binding", True):
+ globally_used.update({'1', '2', '0'})
+ elif ann_type == "bws":
+ if scheme.get("sequential_key_binding", True):
+ tuple_size = scheme.get("tuple_size", 4)
+ for i in range(1, tuple_size + 1):
+ globally_used.add(str(i))
+ for i in range(tuple_size):
+ if i < 26:
+ globally_used.add(chr(ord('a') + i))
+
+ # Step 3: Build available pools (excluding globally used keys)
+ available_pools = []
+ for pool in KEY_POOLS:
+ available = [k for k in pool if k not in globally_used]
+ available_pools.append(available)
+
+ # Step 4: Allocate keys to schemas that need them
+ allocation = {}
+ next_pool_idx = 0
+
+ for scheme in annotation_schemes:
+ if not _needs_allocation(scheme):
+ continue
+
+ name = scheme.get("name", "")
+ labels = scheme.get("labels", [])
+ strategy = scheme.get("keybinding_strategy", "sequential")
+
+ if strategy == "mnemonic":
+ # Mnemonic allocation uses label name letters
+ assignments = _assign_mnemonic_keys(labels, globally_used)
+ result = []
+ for label_name, key in assignments:
+ result.append({"label": label_name, "key": key})
+ if key:
+ globally_used.add(key)
+ allocation[name] = result
+ continue
+
+ # Sequential allocation from pools
+ # Count how many keys we need (subtract explicit ones)
+ needed = 0
+ for label_data in labels:
+ if _get_explicit_key(label_data) is None:
+ needed += 1
+
+ # Find a pool with enough capacity
+ assigned_pool = None
+ for pool_idx in range(next_pool_idx, len(available_pools)):
+ if len(available_pools[pool_idx]) >= needed:
+ assigned_pool = pool_idx
+ break
+
+ if assigned_pool is None:
+ # Try earlier pools too (in case first schema used mnemonic)
+ for pool_idx in range(len(available_pools)):
+ if len(available_pools[pool_idx]) >= needed:
+ assigned_pool = pool_idx
+ break
+
+ if assigned_pool is None:
+ # Not enough keys in any single pool โ assign what we can
+ logger.warning(
+ f"Schema '{name}' has {needed} labels needing keys "
+ f"but no single pool has enough capacity. "
+ f"Some labels will not have keybindings."
+ )
+ # Use the pool with the most remaining keys
+ assigned_pool = max(
+ range(len(available_pools)),
+ key=lambda i: len(available_pools[i])
+ )
+
+ pool_keys = available_pools[assigned_pool]
+ key_iter = iter(pool_keys)
+
+ result = []
+ consumed = []
+ for label_data in labels:
+ label_name = _get_label_name(label_data)
+ explicit = _get_explicit_key(label_data)
+ if explicit:
+ result.append({"label": label_name, "key": explicit})
+ else:
+ key = next(key_iter, None)
+ if key:
+ result.append({"label": label_name, "key": key})
+ consumed.append(key)
+ globally_used.add(key)
+ else:
+ result.append({"label": label_name, "key": None})
+
+ # Remove consumed keys from the pool
+ available_pools[assigned_pool] = [
+ k for k in available_pools[assigned_pool] if k not in consumed
+ ]
+
+ allocation[name] = result
+
+ # Advance to next pool for the next schema
+ if assigned_pool == next_pool_idx:
+ next_pool_idx = assigned_pool + 1
+
+ return allocation
diff --git a/potato/server_utils/schemas/likert.py b/potato/server_utils/schemas/likert.py
new file mode 100644
index 0000000000000000000000000000000000000000..3c86d72d60eb9b04e39ffbb917d51d8a174e9166
--- /dev/null
+++ b/potato/server_utils/schemas/likert.py
@@ -0,0 +1,181 @@
+"""
+Likert Scale Layout
+
+Generates a likert scale rating interface with radio buttons arranged horizontally.
+Each button represents a point on the scale between min_label and max_label.
+
+This module provides functionality for creating HTML-based Likert scale interfaces
+that can be used for collecting ordinal data responses. The scale supports:
+- Customizable number of points
+- Optional numeric display
+- Keyboard shortcuts
+- Required/optional validation
+- Bad text option for invalid inputs
+"""
+
+import logging
+
+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_element_value,
+ generate_validation_attribute,
+ escape_html_content,
+ generate_layout_attributes
+)
+from .radio import generate_radio_layout
+
+logger = logging.getLogger(__name__)
+
+def generate_likert_layout(annotation_scheme):
+ """
+ Generate HTML for a likert scale annotation interface.
+
+ Args:
+ annotation_scheme (dict): Configuration including:
+ - name: Schema identifier
+ - description: Display description
+ - size: Number of scale points
+ - min_label: Label for minimum value
+ - max_label: Label for maximum value
+ - sequential_key_binding: Enable number key bindings (1-9)
+ - displaying_score: Show numeric values on buttons
+ - label_requirement: Validation settings
+ - required (bool): Whether response is mandatory
+ - bad_text_label (dict): Optional configuration for invalid text option
+ - label_content (str): Label text for bad text option
+ - annotation_id (int): match the config schema index
+
+ Returns:
+ tuple: (html_string, key_bindings)
+ html_string: Complete HTML for the likert scale interface
+ key_bindings: List of (key, description) tuples for keyboard shortcuts
+
+ Raises:
+ Exception: If required fields are missing from annotation_scheme
+ """
+ return safe_generate_layout(annotation_scheme, _generate_likert_layout_internal)
+
+def _generate_likert_layout_internal(annotation_scheme):
+ """
+ Internal function to generate likert layout after validation.
+ """
+ logger.debug(f"Generating likert layout for schema: {annotation_scheme['name']}")
+
+ # Use radio layout if complex labels specified
+ if "labels" in annotation_scheme:
+ logger.info(f"Complex labels detected for {annotation_scheme['name']}, using radio layout")
+ return generate_radio_layout(annotation_scheme, horizontal=False)
+
+ # Validate required fields
+ required_fields = ["size", "min_label", "max_label"]
+ for required in required_fields:
+ if required not in annotation_scheme:
+ error_msg = f'Likert scale for "{annotation_scheme["name"]}" missing required field: {required}'
+ logger.error(error_msg)
+ raise Exception(error_msg)
+
+ logger.debug(f"Creating {annotation_scheme['size']}-point likert scale")
+
+ # Setup validation and key bindings
+ key_bindings = []
+ validation = generate_validation_attribute(annotation_scheme)
+
+ # 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"]
+
+ # Get layout attributes for grid positioning
+ layout_attrs = generate_layout_attributes(annotation_scheme)
+
+ # Initialize form wrapper
+ schematic = f"""
+
+ """
+
+ logger.info(f"Successfully generated likert layout for {annotation_scheme['name']} "
+ f"with {annotation_scheme['size']} points")
+ return schematic, key_bindings
diff --git a/potato/server_utils/schemas/multirate.py b/potato/server_utils/schemas/multirate.py
new file mode 100644
index 0000000000000000000000000000000000000000..54764ba4022a619a9abe0c4ff2725e5ec0bc1677
--- /dev/null
+++ b/potato/server_utils/schemas/multirate.py
@@ -0,0 +1,457 @@
+"""
+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 = """
+
+"""
+
+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 = """
+
+"""
+
+DYNAMIC_MULTIRATE_TEMPLATE = """
+
+"""
+
+
+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
\ No newline at end of file
diff --git a/potato/server_utils/schemas/multiselect.py b/potato/server_utils/schemas/multiselect.py
new file mode 100644
index 0000000000000000000000000000000000000000..58f73e36125aa0966156cf647c59868890f7a552
--- /dev/null
+++ b/potato/server_utils/schemas/multiselect.py
@@ -0,0 +1,260 @@
+"""
+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"""
+ "
+
+ 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''
+ 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"""
+
+ {escape_html_content(instruction)}
+
+
+ """
diff --git a/potato/server_utils/schemas/number.py b/potato/server_utils/schemas/number.py
new file mode 100644
index 0000000000000000000000000000000000000000..9a401967afe34d667615f30e2cee2c31576d37b6
--- /dev/null
+++ b/potato/server_utils/schemas/number.py
@@ -0,0 +1,174 @@
+"""
+Number Layout
+
+Generates a form interface for numeric input. Features include:
+- Custom CSS styling options
+- Tooltip support
+- Required/optional validation
+- Min/max value constraints
+"""
+
+import logging
+
+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
+)
+
+
+logger = logging.getLogger(__name__)
+
+def generate_number_layout(annotation_scheme):
+ """
+ Generate HTML for a numeric input interface.
+
+ Args:
+ annotation_scheme (dict): Configuration including:
+ - name: Schema identifier
+ - description: Display description
+ - custom_css (dict): Optional CSS styling
+ - width: Input width (default: "60px")
+ - height: Input height
+ - font_size: Text size
+ - tooltip: Optional hover text description
+ - tooltip_file: Optional path to tooltip text file
+ - label_requirement (dict): Optional validation settings
+ - required (bool): Whether input is mandatory
+ - min_value (int): Optional minimum allowed value
+ - max_value (int): Optional maximum allowed value
+
+ Returns:
+ tuple: (html_string, key_bindings)
+ html_string: Complete HTML for the number input interface
+ key_bindings: Empty list (no keyboard shortcuts)
+ """
+ return safe_generate_layout(annotation_scheme, _generate_number_layout_internal)
+
+def _generate_number_layout_internal(annotation_scheme):
+ """
+ Internal function to generate number layout after validation.
+ """
+ logger.debug(f"Generating number layout for schema: {annotation_scheme['name']}")
+
+ # Get custom dimensions from config
+ css = annotation_scheme.get("custom_css", {})
+ width = css.get("width", "60px")
+
+ # Get layout attributes for grid positioning
+ layout_attrs = generate_layout_attributes(annotation_scheme)
+
+ # Initialize form wrapper
+ schematic = f"""
+
+ """
+
+ logger.info(f"Successfully generated number layout for {annotation_scheme['name']}")
+ return schematic, []
+
+def _generate_css_style(annotation_scheme):
+ """
+ Generate CSS style string from configuration.
+
+ Args:
+ annotation_scheme (dict): Configuration containing custom_css settings
+
+ Returns:
+ str: Formatted CSS style string
+ """
+ css = annotation_scheme.get("custom_css", {})
+ styles = []
+
+ # Default width if not specified
+ width = css.get("width", "60px")
+ styles.append(f"width: {width}")
+
+ # Optional height
+ if "height" in css:
+ styles.append(f"height: {css['height']}")
+
+ # Optional font size
+ if "font_size" in css:
+ styles.append(f"font-size: {css['font_size']}")
+
+ return "; ".join(styles)
+
+def _generate_tooltip(annotation_scheme):
+ """
+ Generate tooltip HTML attribute from configuration.
+
+ Args:
+ annotation_scheme (dict): Configuration containing tooltip information
+
+ Returns:
+ str: Tooltip HTML attribute or empty string if no tooltip
+ """
+ tooltip_text = ""
+ if "tooltip" in annotation_scheme:
+ tooltip_text = annotation_scheme["tooltip"]
+ elif "tooltip_file" in annotation_scheme:
+ try:
+ with open(annotation_scheme["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 _generate_input_attributes(annotation_scheme):
+ """
+ Generate additional input attributes for number constraints.
+
+ Args:
+ annotation_scheme (dict): Configuration containing min/max values
+
+ Returns:
+ str: Space-separated attribute string
+ """
+ attrs = []
+
+ if "min_value" in annotation_scheme:
+ attrs.append(f'min="{annotation_scheme["min_value"]}"')
+ logger.debug(f"Setting minimum value: {annotation_scheme['min_value']}")
+
+ if "max_value" in annotation_scheme:
+ attrs.append(f'max="{annotation_scheme["max_value"]}"')
+ logger.debug(f"Setting maximum value: {annotation_scheme['max_value']}")
+
+ return " ".join(attrs)
diff --git a/potato/server_utils/schemas/pairwise.py b/potato/server_utils/schemas/pairwise.py
new file mode 100644
index 0000000000000000000000000000000000000000..d3d7f51dd8a80c207b5f033b751214a407572c49
--- /dev/null
+++ b/potato/server_utils/schemas/pairwise.py
@@ -0,0 +1,443 @@
+"""
+Pairwise Comparison Layout
+
+Generates a form interface for comparing two items side by side.
+Features include:
+- Binary mode: Click on preferred tile
+- Scale mode: Slider between items (-N to +N)
+- Optional tie/no-preference button
+- Keyboard shortcuts (1/2/0)
+- Support for items_key or inline items configuration
+"""
+
+import logging
+from typing import Dict, Any, Tuple, List
+
+from potato.ai.ai_help_wrapper import get_ai_wrapper
+from .identifier_utils import (
+ safe_generate_layout,
+ generate_element_identifier,
+ generate_validation_attribute,
+ escape_html_content,
+ generate_layout_attributes
+)
+
+logger = logging.getLogger(__name__)
+
+
+def generate_pairwise_layout(annotation_scheme: Dict[str, Any]) -> Tuple[str, List[Tuple[str, str]]]:
+ """
+ Generate HTML for a pairwise comparison interface.
+
+ Args:
+ annotation_scheme (dict): Configuration including:
+ - name: Schema identifier
+ - description: Display description
+ - mode: "binary" (default) or "scale"
+ - items_key: Key in instance data containing items to compare
+ - items: Inline items config (alternative to items_key)
+ - show_labels: Whether to show A/B labels (default: true)
+ - labels: Custom labels for A/B (default: ["A", "B"])
+ - allow_tie: Show tie/no-preference option (default: false)
+ - tie_label: Custom tie button text (default: "No preference")
+ - sequential_key_binding: Enable keyboard shortcuts (default: true)
+ - label_requirement (dict): Optional validation settings
+
+ For scale mode:
+ - scale.min: Minimum value (e.g., -3 for "A much better")
+ - scale.max: Maximum value (e.g., +3 for "B much better")
+ - scale.step: Step increment (default: 1)
+ - scale.labels.min: Label for min value
+ - scale.labels.max: Label for max value
+ - scale.labels.center: Label for center (default: "Equal")
+
+ Returns:
+ tuple: (html_string, key_bindings)
+ html_string: Complete HTML for the pairwise interface
+ key_bindings: List of (key, description) tuples for keyboard shortcuts
+ """
+ return safe_generate_layout(annotation_scheme, _generate_pairwise_layout_internal)
+
+
+def _generate_pairwise_layout_internal(annotation_scheme: Dict[str, Any]) -> Tuple[str, List[Tuple[str, str]]]:
+ """
+ Internal function to generate pairwise layout after validation.
+ """
+ logger.debug(f"Generating pairwise layout for schema: {annotation_scheme['name']}")
+
+ mode = annotation_scheme.get("mode", "binary")
+ schema_name = annotation_scheme["name"]
+
+ if mode == "scale":
+ return _generate_scale_mode(annotation_scheme)
+ elif mode == "multi_dimension":
+ return _generate_multi_dimension_mode(annotation_scheme)
+ else:
+ return _generate_binary_mode(annotation_scheme)
+
+
+def _generate_binary_mode(annotation_scheme: Dict[str, Any]) -> Tuple[str, List[Tuple[str, str]]]:
+ """
+ Generate binary mode pairwise interface (clickable tiles).
+ """
+ schema_name = annotation_scheme["name"]
+ description = annotation_scheme["description"]
+
+ # Get configuration options
+ show_labels = annotation_scheme.get("show_labels", True)
+ labels = annotation_scheme.get("labels", ["A", "B"])
+ if len(labels) < 2:
+ labels = ["A", "B"]
+
+ allow_tie = annotation_scheme.get("allow_tie", False)
+ tie_label = annotation_scheme.get("tie_label", "No preference")
+
+ # Get items config
+ items_key = annotation_scheme.get("items_key", "text")
+
+ # Validation attribute
+ validation = generate_validation_attribute(annotation_scheme)
+
+ # Key bindings
+ key_bindings = []
+ enable_keybindings = annotation_scheme.get("sequential_key_binding", True)
+
+ # Build the HTML
+ escaped_schema = escape_html_content(schema_name)
+ escaped_description = escape_html_content(description)
+ escaped_items_key = escape_html_content(items_key)
+
+ # Data attributes for JavaScript initialization
+ data_attrs = f'data-annotation-type="pairwise" data-schema-name="{escaped_schema}" data-mode="binary" data-items-key="{escaped_items_key}"'
+
+ # Layout attributes for grid positioning
+ layout_attrs = generate_layout_attributes(annotation_scheme)
+
+ # Tile labels
+ label_a = escape_html_content(labels[0])
+ label_b = escape_html_content(labels[1])
+ shortcut_a = "[1]" if enable_keybindings else ""
+ shortcut_b = "[2]" if enable_keybindings else ""
+ data_key_a = 'data-key="1"' if enable_keybindings else ""
+ data_key_b = 'data-key="2"' if enable_keybindings else ""
+
+ schematic = f"""
+
+ """
+
+ # Add key bindings
+ if enable_keybindings:
+ key_bindings.append(("1", f"{schema_name}: {labels[0]}"))
+ key_bindings.append(("2", f"{schema_name}: {labels[1]}"))
+ if allow_tie:
+ key_bindings.append(("0", f"{schema_name}: {tie_label}"))
+
+ logger.info(f"Successfully generated pairwise binary layout for {schema_name}")
+ return schematic, key_bindings
+
+
+def _generate_scale_mode(annotation_scheme: Dict[str, Any]) -> Tuple[str, List[Tuple[str, str]]]:
+ """
+ Generate scale mode pairwise interface (slider between items).
+ """
+ schema_name = annotation_scheme["name"]
+ description = annotation_scheme["description"]
+
+ # Get configuration options
+ show_labels = annotation_scheme.get("show_labels", True)
+ labels = annotation_scheme.get("labels", ["A", "B"])
+ if len(labels) < 2:
+ labels = ["A", "B"]
+
+ # Get scale configuration
+ scale_config = annotation_scheme.get("scale", {})
+ min_value = scale_config.get("min", -3)
+ max_value = scale_config.get("max", 3)
+ step = scale_config.get("step", 1)
+ default_value = scale_config.get("default", 0)
+
+ # Scale labels
+ scale_labels = scale_config.get("labels", {})
+ min_label = scale_labels.get("min", f"{labels[0]} is better")
+ max_label = scale_labels.get("max", f"{labels[1]} is better")
+ center_label = scale_labels.get("center", "Equal")
+
+ # Get items config
+ items_key = annotation_scheme.get("items_key", "text")
+
+ # Validation attribute
+ validation = generate_validation_attribute(annotation_scheme)
+
+ # Generate identifiers
+ identifiers = generate_element_identifier(schema_name, "scale", "range")
+
+ # Build the HTML
+ escaped_schema = escape_html_content(schema_name)
+ escaped_description = escape_html_content(description)
+ escaped_items_key = escape_html_content(items_key)
+
+ # Data attributes for JavaScript initialization
+ data_attrs = f'data-annotation-type="pairwise" data-schema-name="{escaped_schema}" data-mode="scale" data-items-key="{escaped_items_key}"'
+
+ # Layout attributes for grid positioning
+ layout_attrs = generate_layout_attributes(annotation_scheme)
+
+ # Escaped labels
+ label_a = escape_html_content(labels[0])
+ label_b = escape_html_content(labels[1])
+
+ schematic = f"""
+
+ """
+
+ # No keyboard shortcuts for scale mode (uses slider)
+ key_bindings = []
+
+ logger.info(f"Successfully generated pairwise scale layout for {schema_name}")
+ return schematic, key_bindings
+
+
+def _generate_justification_html(annotation_scheme: Dict[str, Any], schema_name: str) -> str:
+ """
+ Generate justification section HTML (reason checkboxes + rationale textarea).
+
+ Used by both binary and multi_dimension modes when ``justification`` config is present.
+ """
+ justification = annotation_scheme.get("justification")
+ if not justification:
+ return ""
+
+ reason_categories = justification.get("reason_categories", [])
+ min_chars = justification.get("min_rationale_chars", 0)
+ placeholder = justification.get("rationale_placeholder", "Explain your preference...")
+ required = justification.get("required", False)
+
+ escaped_schema = escape_html_content(schema_name)
+
+ # Reason category checkboxes
+ reasons_html = ""
+ for reason in reason_categories:
+ escaped_reason = escape_html_content(reason)
+ reasons_html += f"""
+ """
+
+ req_attr = 'data-required="true"' if required else ''
+
+ html = f"""
+
+
Justification
+ """
+
+ if reasons_html:
+ html += f"""
+
{reasons_html}
+ """
+
+ html += f"""
+
+
+ 0 / {min_chars} characters
+
+
+
+ """
+ return html
+
+
+def _generate_multi_dimension_mode(annotation_scheme: Dict[str, Any]) -> Tuple[str, List[Tuple[str, str]]]:
+ """
+ Generate multi-dimension pairwise interface.
+
+ Each dimension gets its own A/B tile row with a dimension label.
+ Hidden inputs use ``{schema}:::{dimension}`` naming.
+ """
+ schema_name = annotation_scheme["name"]
+ description = annotation_scheme["description"]
+ dimensions = annotation_scheme.get("dimensions", [])
+
+ if not dimensions:
+ raise ValueError(f"pairwise multi_dimension mode requires 'dimensions' list for schema '{schema_name}'")
+
+ labels = annotation_scheme.get("labels", ["A", "B"])
+ if len(labels) < 2:
+ labels = ["A", "B"]
+
+ items_key = annotation_scheme.get("items_key", "text")
+ validation = generate_validation_attribute(annotation_scheme)
+ layout_attrs = generate_layout_attributes(annotation_scheme)
+
+ escaped_schema = escape_html_content(schema_name)
+ escaped_items_key = escape_html_content(items_key)
+ label_a = escape_html_content(labels[0])
+ label_b = escape_html_content(labels[1])
+
+ data_attrs = (
+ f'data-annotation-type="pairwise" data-schema-name="{escaped_schema}" '
+ f'data-mode="multi_dimension" data-items-key="{escaped_items_key}"'
+ )
+
+ schematic = f"""
+
+ """
+
+ logger.info(f"Successfully generated pairwise multi_dimension layout for {schema_name}")
+ return schematic, key_bindings
diff --git a/potato/server_utils/schemas/process_reward.py b/potato/server_utils/schemas/process_reward.py
new file mode 100644
index 0000000000000000000000000000000000000000..a8e0092b11a1d4b494baa9d45e678bc5f689819d
--- /dev/null
+++ b/potato/server_utils/schemas/process_reward.py
@@ -0,0 +1,580 @@
+"""
+Process Reward Schema
+
+Binary per-step correct/incorrect signals for Process Reward Model (PRM)
+training. Two modes:
+- "per_step": annotate each step independently with thumbs-up/down
+- "first_error": click the first wrong step, all subsequent auto-marked wrong
+
+Research: AgentPRM, ToolRM, ToolRL, SPORT
+"""
+
+import json
+import logging
+from typing import Dict, Any, Tuple, List
+
+from .identifier_utils import (
+ safe_generate_layout,
+ generate_element_identifier,
+ generate_validation_attribute,
+ escape_html_content,
+ generate_layout_attributes,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def generate_process_reward_layout(
+ annotation_scheme: Dict[str, Any],
+) -> Tuple[str, List[Tuple[str, str]]]:
+ """Generate HTML for a process reward annotation interface.
+
+ Args:
+ annotation_scheme: Configuration dict. Required keys: ``name``,
+ ``description``. Optional: ``steps_key``, ``mode``.
+
+ Returns:
+ ``(html, keybindings)`` tuple.
+ """
+ return safe_generate_layout(annotation_scheme, _generate_internal)
+
+
+def _generate_internal(
+ annotation_scheme: Dict[str, Any],
+) -> Tuple[str, List[Tuple[str, str]]]:
+ schema_name = annotation_scheme["name"]
+ description = annotation_scheme["description"]
+ steps_key = annotation_scheme.get("steps_key", "steps")
+ step_text_key = annotation_scheme.get("step_text_key", "action")
+ mode = annotation_scheme.get("mode", "first_error") # "first_error" or "per_step"
+ # When true, the per-step Correct/Wrong control is injected to the right
+ # of each rendered trace step (a [data-turn-index] element) rather than a
+ # separate card list at the bottom. Falls back to the card list if no
+ # trace step elements are present (e.g. non-trace displays).
+ inline_with_trace = bool(annotation_scheme.get("inline_with_trace", False))
+
+ layout_attrs = generate_layout_attributes(annotation_scheme)
+ validation = generate_validation_attribute(annotation_scheme)
+ identifiers = generate_element_identifier(schema_name, schema_name, "hidden")
+ esc_schema = escape_html_content(schema_name)
+
+ config_json = json.dumps({
+ "steps_key": steps_key,
+ "step_text_key": step_text_key,
+ "mode": mode,
+ "inline_with_trace": inline_with_trace,
+ })
+
+ container_class = "process-reward-container"
+ if inline_with_trace:
+ container_class += " prm-inline-mode"
+
+ html = f"""
+
+
+
+
+
+ """
+
+ logger.info(
+ f"Successfully generated process_reward layout for {schema_name} "
+ f"(mode={mode})"
+ )
+ return html, [] # No keybindings
diff --git a/potato/server_utils/schemas/pure_display.py b/potato/server_utils/schemas/pure_display.py
new file mode 100644
index 0000000000000000000000000000000000000000..492b873db381751c500c613ddb3de277978eec62
--- /dev/null
+++ b/potato/server_utils/schemas/pure_display.py
@@ -0,0 +1,128 @@
+"""
+Pure Display Layout
+
+Generates a simple display-only interface that shows text content without any
+interaction elements. This is useful for:
+- Displaying instructions
+- Showing static content
+- Presenting read-only information
+- Headers and section dividers
+
+Supports an optional `allow_html: true` flag that lets administrators include
+trusted HTML formatting (e.g., ,
, ) in description and labels.
+When enabled, content is sanitized through the project's HTML sanitizer to
+block dangerous elements while preserving safe formatting.
+"""
+
+import logging
+from .identifier_utils import (
+ safe_generate_layout,
+ escape_html_content,
+ generate_layout_attributes
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _sanitize_or_escape(content: str, allow_html: bool) -> str:
+ """
+ Sanitize or escape content based on the allow_html flag.
+
+ When allow_html is True, uses the project's HTML sanitizer which allows
+ safe elements (b, i, u, em, strong, br, span, div, ul, ol, li, etc.)
+ while blocking scripts and dangerous attributes.
+
+ When allow_html is False (default), fully escapes all HTML.
+ """
+ if not content:
+ return ""
+ if allow_html:
+ from potato.server_utils.html_sanitizer import sanitize_html
+ return str(sanitize_html(str(content)))
+ return escape_html_content(content)
+
+
+def generate_pure_display_layout(annotation_scheme):
+ """
+ Generate HTML for a display-only text interface.
+
+ Args:
+ annotation_scheme (dict): Configuration including:
+ - name: Schema identifier
+ - description: Main text to display as header
+ - labels: List of text strings to display as content
+ - allow_html: (optional, default False) If True, allows trusted
+ HTML in description and labels (sanitized for safety)
+
+ Returns:
+ tuple: (html_string, key_bindings)
+ html_string: Complete HTML for the display interface
+ key_bindings: Empty list (no interactions available)
+
+ Example:
+ annotation_scheme = {
+ "name": "instructions",
+ "description": "Task Instructions",
+ "labels": ["Step 1: Read the text", "Step 2: Select options"]
+ }
+
+ Example with HTML:
+ annotation_scheme = {
+ "name": "consent_header",
+ "description": "Consent Form",
+ "labels": ["Please read the following carefully."],
+ "allow_html": True
+ }
+ """
+ return safe_generate_layout(annotation_scheme, _generate_pure_display_layout_internal)
+
+def _generate_pure_display_layout_internal(annotation_scheme):
+ """
+ Internal function to generate pure display layout after validation.
+ """
+ logger.debug(f"Generating pure display layout for schema: {annotation_scheme['name']}")
+
+ allow_html = annotation_scheme.get('allow_html', False)
+
+ # Get layout attributes for grid positioning
+ layout_attrs = generate_layout_attributes(annotation_scheme)
+
+ # Name is always escaped (used in HTML attributes, not content)
+ escaped_name = escape_html_content(annotation_scheme['name'])
+
+ # Description and labels respect allow_html flag
+ description = _sanitize_or_escape(annotation_scheme['description'], allow_html)
+
+ # Format content with header and body text
+ schematic = f"""
+
+ """
+
+ logger.info(f"Successfully generated pure display layout for {annotation_scheme['name']}")
+ return schematic, []
+
+def format_display_content(labels, allow_html=False):
+ """
+ Format the display content from a list of labels.
+
+ Args:
+ labels (list): List of strings to display
+ allow_html (bool): If True, sanitize rather than escape HTML content
+
+ Returns:
+ str: HTML formatted content with line breaks between items
+ """
+ if not labels:
+ logger.warning("No labels provided for pure display content")
+ return ""
+
+ logger.debug(f"Formatting {len(labels)} content lines")
+ processed_labels = [_sanitize_or_escape(label, allow_html) for label in labels]
+ return "
".join(processed_labels)
diff --git a/potato/server_utils/schemas/radio.py b/potato/server_utils/schemas/radio.py
new file mode 100644
index 0000000000000000000000000000000000000000..8d7ac05bf3a9293c0b65f64f2f1efd1732866b01
--- /dev/null
+++ b/potato/server_utils/schemas/radio.py
@@ -0,0 +1,229 @@
+"""
+Radio Layout
+
+Generates a form interface with mutually exclusive radio button options.
+Features include:
+- Vertical or horizontal layout options
+- Keyboard shortcuts
+- Required/optional validation
+- Tooltip 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 potato.server_utils.config_module import config
+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_radio_layout(annotation_scheme, horizontal=False):
+ """
+ Generate HTML for a radio button 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 tooltip text file
+ - key_value: Keyboard shortcut key
+ - label_requirement (dict): Optional validation settings
+ - required (bool): Whether selection is mandatory
+ - horizontal (bool): Whether to arrange options horizontally
+ - has_free_response (dict): Optional free text input configuration
+ - instruction: Label for free response field
+
+ horizontal (bool): Override horizontal layout setting
+
+ Returns:
+ tuple: (html_string, key_bindings)
+ html_string: Complete HTML for the radio interface
+ key_bindings: List of (key, description) tuples for keyboard shortcuts
+ """
+ return safe_generate_layout(annotation_scheme, _generate_radio_layout_internal, horizontal)
+
+def _generate_radio_layout_internal(annotation_scheme, horizontal=False):
+ """
+ Internal function to generate radio layout after validation.
+ """
+ logger.debug(f"Generating radio layout for schema: {annotation_scheme['name']}")
+
+ # Check for horizontal layout override
+ if annotation_scheme.get("horizontal"):
+ horizontal = True
+ logger.debug("Using horizontal layout")
+
+ # Get layout attributes for grid positioning
+ layout_attrs = generate_layout_attributes(annotation_scheme)
+
+ # Initialize form wrapper
+ schema_name = annotation_scheme["name"]
+ schematic = f"""
+ "
+
+ logger.info(f"Successfully generated radio layout for {schema_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 ""
diff --git a/potato/server_utils/schemas/range_slider.py b/potato/server_utils/schemas/range_slider.py
new file mode 100644
index 0000000000000000000000000000000000000000..7ad10d4ae28ad2200e2add9aadbe26d2d9252f1e
--- /dev/null
+++ b/potato/server_utils/schemas/range_slider.py
@@ -0,0 +1,282 @@
+"""
+Goldilocks Range / Dual-Thumb Slider Layout
+
+Generates a dual-thumb range slider for selecting a min-max range.
+Uses custom div-based handles (not native range inputs) for reliable
+cross-browser rendering.
+
+Research basis:
+- Pavlick & Kwiatkowski (2019) "Inherent Disagreements in Human Textual
+ Inferences" TACL
+- Jurgens (2013) "Embracing Ambiguity: A Comparison of Annotation Methodologies
+ for Crowdsourcing Word Sense Labels" NAACL
+"""
+
+import logging
+
+from potato.ai.ai_help_wrapper import get_ai_wrapper
+from .identifier_utils import (
+ safe_generate_layout,
+ generate_element_identifier,
+ generate_validation_attribute,
+ escape_html_content,
+ generate_layout_attributes,
+)
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_MIN = 0
+DEFAULT_MAX = 100
+DEFAULT_STEP = 1
+
+
+def generate_range_slider_layout(annotation_scheme):
+ """
+ Generate HTML for a dual-thumb range slider interface.
+
+ Args:
+ annotation_scheme (dict): Configuration including:
+ - name: Schema identifier
+ - description: Display description
+ - min_value: Minimum value (default 0)
+ - max_value: Maximum value (default 100)
+ - step: Step size (default 1)
+ - left_label: Label for left end
+ - right_label: Label for right end
+ - show_values: Show numeric values (default true)
+
+ Returns:
+ tuple: (html_string, key_bindings)
+ """
+ return safe_generate_layout(annotation_scheme, _generate_range_slider_layout_internal)
+
+
+def _generate_range_slider_layout_internal(annotation_scheme):
+ schema_name = annotation_scheme["name"]
+ safe_schema = escape_html_content(schema_name)
+ description = annotation_scheme["description"]
+ min_val = annotation_scheme.get("min_value", DEFAULT_MIN)
+ max_val = annotation_scheme.get("max_value", DEFAULT_MAX)
+ step = annotation_scheme.get("step", DEFAULT_STEP)
+ left_label = annotation_scheme.get("left_label", "")
+ right_label = annotation_scheme.get("right_label", "")
+ show_values = annotation_scheme.get("show_values", True)
+ layout_attrs = generate_layout_attributes(annotation_scheme)
+ validation = generate_validation_attribute(annotation_scheme)
+
+ # Default initial range: 25th to 75th percentile
+ range_span = max_val - min_val
+ init_low = min_val + range_span // 4
+ init_high = max_val - range_span // 4
+
+ id_low = generate_element_identifier(schema_name, "range_low", "range")
+ id_high = generate_element_identifier(schema_name, "range_high", "range")
+
+ html = f"""
+
+
+ """
+
+ key_bindings = []
+ logger.info(f"Generated range_slider layout for {schema_name}")
+ return html, key_bindings
diff --git a/potato/server_utils/schemas/ranking.py b/potato/server_utils/schemas/ranking.py
new file mode 100644
index 0000000000000000000000000000000000000000..ff54d302ac200efd5b2eeac837f3b5dfb935a01b
--- /dev/null
+++ b/potato/server_utils/schemas/ranking.py
@@ -0,0 +1,213 @@
+"""
+Ranking / Drag-and-Drop Layout
+
+Generates a draggable list of items that annotators can reorder.
+Uses a hidden input to store the rank order.
+
+Research basis:
+- Kiritchenko & Mohammad (2017) "Best-Worst Scaling More Reliable than Rating
+ Scales" ACL
+- Thurstone (1927) "A Law of Comparative Judgment"
+"""
+
+import logging
+
+from potato.ai.ai_help_wrapper import get_ai_wrapper
+from .identifier_utils import (
+ safe_generate_layout,
+ generate_element_identifier,
+ generate_validation_attribute,
+ escape_html_content,
+ generate_layout_attributes,
+ generate_tooltip_html,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def generate_ranking_layout(annotation_scheme):
+ """
+ Generate HTML for a ranking / drag-and-drop annotation interface.
+
+ Args:
+ annotation_scheme (dict): Configuration including:
+ - name: Schema identifier
+ - description: Display description
+ - labels: List of items to rank
+ - allow_ties: Whether ties are allowed (default false)
+
+ Returns:
+ tuple: (html_string, key_bindings)
+ """
+ return safe_generate_layout(annotation_scheme, _generate_ranking_layout_internal)
+
+
+def _generate_ranking_layout_internal(annotation_scheme):
+ schema_name = annotation_scheme["name"]
+ safe_schema = escape_html_content(schema_name)
+ description = annotation_scheme["description"]
+ labels = annotation_scheme.get("labels", [])
+ allow_ties = annotation_scheme.get("allow_ties", False)
+ layout_attrs = generate_layout_attributes(annotation_scheme)
+ validation = generate_validation_attribute(annotation_scheme)
+
+ if not labels:
+ raise ValueError(f"ranking schema '{schema_name}' requires 'labels'")
+
+ # Normalize labels
+ label_names = []
+ for lbl in labels:
+ if isinstance(lbl, str):
+ label_names.append(lbl)
+ elif isinstance(lbl, dict) and "name" in lbl:
+ label_names.append(lbl["name"])
+ else:
+ raise ValueError(f"Invalid label format: {lbl}")
+
+ identifiers = generate_element_identifier(schema_name, "rank_order", "hidden")
+ initial_order = ",".join(label_names)
+
+ html = f"""
+
+
+ """
+
+ key_bindings = []
+ logger.info(f"Generated ranking layout for {schema_name} with {len(label_names)} items")
+ return html, key_bindings
diff --git a/potato/server_utils/schemas/registry.py b/potato/server_utils/schemas/registry.py
new file mode 100644
index 0000000000000000000000000000000000000000..7e277d6170b0c4bf9dd6a4a65a2e16a2f59f2ad9
--- /dev/null
+++ b/potato/server_utils/schemas/registry.py
@@ -0,0 +1,597 @@
+"""
+Schema Registry
+
+Provides a centralized registry for managing annotation schema types.
+This module serves as the single source of truth for available annotation
+types and their generators, replacing the hardcoded dictionary in front_end.py.
+
+Usage:
+ from potato.server_utils.schemas.registry import schema_registry
+
+ # Get a schema generator
+ generator = schema_registry.get("radio")
+
+ # Generate layout for an annotation scheme
+ html, keybindings = schema_registry.generate(annotation_scheme_dict)
+
+ # List all available schemas
+ schemas = schema_registry.list_schemas()
+"""
+
+from dataclasses import dataclass, field
+from typing import Callable, Dict, List, Tuple, Any, Optional
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class SchemaDefinition:
+ """
+ Defines metadata and generator for an annotation schema type.
+
+ Attributes:
+ name: Unique identifier for the schema type (e.g., "radio", "multiselect")
+ generator: Callable that generates HTML and keybindings for this schema type
+ required_fields: List of required configuration fields
+ optional_fields: List of optional configuration fields
+ supports_keybindings: Whether this schema type supports keyboard shortcuts
+ description: Human-readable description of the schema type
+ """
+ name: str
+ generator: Callable[[Dict[str, Any]], Tuple[str, List[Tuple[str, str]]]]
+ required_fields: List[str] = field(default_factory=list)
+ optional_fields: List[str] = field(default_factory=list)
+ supports_keybindings: bool = True
+ description: str = ""
+
+
+class SchemaRegistry:
+ """
+ Centralized registry for annotation schema types.
+
+ Provides methods to register, retrieve, and list schema types,
+ as well as generate layouts from annotation scheme configurations.
+ """
+
+ def __init__(self):
+ self._schemas: Dict[str, SchemaDefinition] = {}
+ logger.debug("SchemaRegistry initialized")
+
+ def register(self, schema: SchemaDefinition) -> None:
+ """
+ Register a new schema type.
+
+ Args:
+ schema: SchemaDefinition to register
+
+ Raises:
+ ValueError: If a schema with the same name is already registered
+ """
+ if schema.name in self._schemas:
+ raise ValueError(f"Schema '{schema.name}' is already registered")
+
+ self._schemas[schema.name] = schema
+ logger.debug(f"Registered schema: {schema.name}")
+
+ def get(self, name: str) -> Optional[SchemaDefinition]:
+ """
+ Get a schema definition by name.
+
+ Args:
+ name: The schema type name
+
+ Returns:
+ SchemaDefinition if found, None otherwise
+ """
+ return self._schemas.get(name)
+
+ def get_generator(self, name: str) -> Optional[Callable]:
+ """
+ Get the generator function for a schema type.
+
+ Args:
+ name: The schema type name
+
+ Returns:
+ Generator callable if found, None otherwise
+ """
+ schema = self.get(name)
+ return schema.generator if schema else None
+
+ def generate(self, annotation_scheme: Dict[str, Any]) -> Tuple[str, List[Tuple[str, str]]]:
+ """
+ Generate HTML and keybindings for an annotation scheme.
+
+ Args:
+ annotation_scheme: Configuration dictionary with 'annotation_type' key
+
+ Returns:
+ Tuple of (html_string, keybindings_list)
+
+ Raises:
+ ValueError: If annotation_type is missing or not supported
+ """
+ import json as json_module
+
+ annotation_type = annotation_scheme.get("annotation_type")
+ if not annotation_type:
+ raise ValueError("annotation_scheme must have 'annotation_type' field")
+
+ schema = self.get(annotation_type)
+ if not schema:
+ supported = ", ".join(sorted(self._schemas.keys()))
+ raise ValueError(
+ f"Unsupported annotation type: '{annotation_type}'. "
+ f"Supported types are: {supported}"
+ )
+
+ logger.debug(f"Generating layout for annotation type: {annotation_type}")
+ html, keybindings = schema.generator(annotation_scheme)
+
+ # Wrap HTML with display_logic attributes if present
+ html = self._wrap_with_display_logic(html, annotation_scheme)
+
+ return html, keybindings
+
+ def _wrap_with_display_logic(self, html: str, annotation_scheme: Dict[str, Any]) -> str:
+ """
+ Wrap the generated HTML with display_logic attributes and container if needed.
+
+ If the annotation scheme has display_logic, wraps the HTML in a container
+ div with data-display-logic attribute containing the serialized logic,
+ and applies the initial hidden state.
+
+ Args:
+ html: The generated HTML from the schema generator
+ annotation_scheme: The annotation scheme configuration
+
+ Returns:
+ The HTML, possibly wrapped with display_logic container
+ """
+ import json as json_module
+ from html import escape
+
+ display_logic = annotation_scheme.get("display_logic")
+ if not display_logic:
+ return html
+
+ schema_name = annotation_scheme.get("name", "")
+
+ # Serialize display_logic to JSON for the data attribute
+ display_logic_json = json_module.dumps(display_logic)
+ # Escape for HTML attribute (single quotes in JSON are safe)
+ display_logic_escaped = escape(display_logic_json)
+
+ # Wrap in a container with display logic attributes
+ # Initially hidden - the frontend JS will evaluate and show if conditions are met
+ wrapped_html = f'''
+{html}
+
'''
+
+ return wrapped_html
+
+ def list_schemas(self) -> List[Dict[str, Any]]:
+ """
+ List all registered schemas with their metadata.
+
+ Returns:
+ List of dictionaries containing schema metadata
+ """
+ return [
+ {
+ "name": schema.name,
+ "description": schema.description,
+ "required_fields": schema.required_fields,
+ "optional_fields": schema.optional_fields,
+ "supports_keybindings": schema.supports_keybindings,
+ }
+ for schema in sorted(self._schemas.values(), key=lambda s: s.name)
+ ]
+
+ def is_registered(self, name: str) -> bool:
+ """
+ Check if a schema type is registered.
+
+ Args:
+ name: The schema type name
+
+ Returns:
+ True if registered, False otherwise
+ """
+ return name in self._schemas
+
+ def get_supported_types(self) -> List[str]:
+ """
+ Get a list of all supported annotation types.
+
+ Returns:
+ Sorted list of annotation type names
+ """
+ return sorted(self._schemas.keys())
+
+
+# Global registry instance
+schema_registry = SchemaRegistry()
+
+
+def _register_builtin_schemas():
+ """
+ Register all built-in annotation schema types.
+ Called automatically when this module is imported.
+ """
+ from .radio import generate_radio_layout
+ from .multiselect import generate_multiselect_layout
+ from .multirate import generate_multirate_layout
+ from .likert import generate_likert_layout
+ from .textbox import generate_textbox_layout
+ from .number import generate_number_layout
+ from .slider import generate_slider_layout
+ from .span import generate_span_layout
+ from .span_link import generate_span_link_layout
+ from .select import generate_select_layout
+ from .pure_display import generate_pure_display_layout
+ from .video import generate_video_layout
+ from .image_annotation import generate_image_annotation_layout
+ from .audio_annotation import generate_audio_annotation_layout
+ from .video_annotation import generate_video_annotation_layout
+ from .pairwise import generate_pairwise_layout
+ from .coreference import generate_coreference_layout
+ from .tree_annotation import generate_tree_annotation_layout
+ from .triage import generate_triage_layout
+ from .event_annotation import generate_event_annotation_layout
+ from .tiered_annotation import generate_tiered_annotation_layout
+ from .bws import generate_bws_layout
+ from .soft_label import generate_soft_label_layout
+ from .confidence import generate_confidence_layout
+ from .constant_sum import generate_constant_sum_layout
+ from .semantic_differential import generate_semantic_differential_layout
+ from .ranking import generate_ranking_layout
+ from .range_slider import generate_range_slider_layout
+ from .hierarchical_multiselect import generate_hierarchical_multiselect_layout
+ from .vas import generate_vas_layout
+ from .extractive_qa import generate_extractive_qa_layout
+ from .rubric_eval import generate_rubric_eval_layout
+ from .text_edit import generate_text_edit_layout
+ from .error_span import generate_error_span_layout
+ from .card_sort import generate_card_sort_layout
+ from .conjoint import generate_conjoint_layout
+ from .trajectory_eval import generate_trajectory_eval_layout
+ from .trajectory_edit import generate_trajectory_edit_layout
+ from .process_reward import generate_process_reward_layout
+ from .code_review import generate_code_review_layout
+
+ schemas = [
+ SchemaDefinition(
+ name="radio",
+ generator=generate_radio_layout,
+ required_fields=["name", "description", "labels"],
+ optional_fields=["horizontal", "label_requirement", "sequential_key_binding", "has_free_response", "option_randomization", "dynamic_options", "dynamic_options_field"],
+ supports_keybindings=True,
+ description="Single-choice radio button selection"
+ ),
+ SchemaDefinition(
+ name="multiselect",
+ generator=generate_multiselect_layout,
+ required_fields=["name", "description", "labels"],
+ optional_fields=["display_config", "label_requirement", "sequential_key_binding", "video_as_label", "has_free_response", "option_randomization", "dynamic_options", "dynamic_options_field"],
+ supports_keybindings=True,
+ description="Multiple-choice checkbox selection"
+ ),
+ SchemaDefinition(
+ name="multirate",
+ generator=generate_multirate_layout,
+ required_fields=["name", "description", "options", "labels"],
+ optional_fields=["label_requirement"],
+ supports_keybindings=False,
+ description="Rate multiple items on a scale"
+ ),
+ SchemaDefinition(
+ name="likert",
+ generator=generate_likert_layout,
+ required_fields=["name", "description", "min_label", "max_label", "size"],
+ optional_fields=["label_requirement"],
+ supports_keybindings=True,
+ description="Likert scale rating"
+ ),
+ SchemaDefinition(
+ name="text",
+ generator=generate_textbox_layout,
+ required_fields=["name", "description"],
+ optional_fields=["label_requirement", "placeholder", "rows"],
+ supports_keybindings=False,
+ description="Free-form text input"
+ ),
+ SchemaDefinition(
+ name="number",
+ generator=generate_number_layout,
+ required_fields=["name", "description"],
+ optional_fields=["min", "max", "step", "label_requirement"],
+ supports_keybindings=False,
+ description="Numeric input field"
+ ),
+ SchemaDefinition(
+ name="slider",
+ generator=generate_slider_layout,
+ required_fields=["name", "description", "min_value", "max_value", "starting_value"],
+ optional_fields=["step", "label_requirement"],
+ supports_keybindings=False,
+ description="Slider for selecting a value in a range"
+ ),
+ SchemaDefinition(
+ name="span",
+ generator=generate_span_layout,
+ required_fields=["name", "description", "labels"],
+ optional_fields=["sequential_key_binding", "bad_text_label", "title", "allow_discontinuous", "entity_linking", "show_span_labels"],
+ supports_keybindings=True,
+ description="Text span annotation/highlighting with optional entity linking to knowledge bases"
+ ),
+ SchemaDefinition(
+ name="select",
+ generator=generate_select_layout,
+ required_fields=["name", "description", "labels"],
+ optional_fields=["label_requirement", "option_randomization", "dynamic_options", "dynamic_options_field"],
+ supports_keybindings=False,
+ description="Dropdown selection"
+ ),
+ SchemaDefinition(
+ name="pure_display",
+ generator=generate_pure_display_layout,
+ required_fields=["name", "description"],
+ optional_fields=["labels", "allow_html"],
+ supports_keybindings=False,
+ description="Display-only content (instructions, headers)"
+ ),
+ SchemaDefinition(
+ name="video",
+ generator=generate_video_layout,
+ required_fields=["name", "description", "video_path"],
+ optional_fields=["autoplay", "loop", "muted", "controls", "custom_css", "fallback_text", "additional_sources"],
+ supports_keybindings=False,
+ description="Video player display"
+ ),
+ SchemaDefinition(
+ name="image_annotation",
+ generator=generate_image_annotation_layout,
+ required_fields=["name", "description", "tools", "labels"],
+ optional_fields=["zoom_enabled", "pan_enabled", "min_annotations", "max_annotations", "freeform_brush_size", "freeform_simplify"],
+ supports_keybindings=True,
+ description="Image annotation with bounding boxes, polygons, freeform drawing, and landmarks"
+ ),
+ SchemaDefinition(
+ name="audio_annotation",
+ generator=generate_audio_annotation_layout,
+ required_fields=["name", "description"],
+ optional_fields=["mode", "labels", "segment_schemes", "min_segments", "max_segments", "zoom_enabled", "playback_rate_control"],
+ supports_keybindings=True,
+ description="Audio segmentation and annotation with waveform visualization"
+ ),
+ SchemaDefinition(
+ name="video_annotation",
+ generator=generate_video_annotation_layout,
+ required_fields=["name", "description"],
+ optional_fields=["mode", "labels", "segment_schemes", "min_segments", "max_segments", "timeline_height", "overview_height", "zoom_enabled", "playback_rate_control", "frame_stepping", "show_timecode", "video_fps", "tracking_options"],
+ supports_keybindings=True,
+ description="Video annotation with temporal segments, frame classification, keyframes, and object tracking"
+ ),
+ SchemaDefinition(
+ name="span_link",
+ generator=generate_span_link_layout,
+ required_fields=["name", "description", "link_types", "span_schema"],
+ optional_fields=["visual_display"],
+ supports_keybindings=False,
+ description="Create relationships/links between spans (e.g., PERSON works_for ORGANIZATION)"
+ ),
+ SchemaDefinition(
+ name="pairwise",
+ generator=generate_pairwise_layout,
+ required_fields=["name", "description"],
+ optional_fields=["mode", "items_key", "items", "show_labels", "labels", "allow_tie", "tie_label", "sequential_key_binding", "scale", "label_requirement"],
+ supports_keybindings=True,
+ description="Pairwise comparison of two items (binary selection or scale rating)"
+ ),
+ SchemaDefinition(
+ name="coreference",
+ generator=generate_coreference_layout,
+ required_fields=["name", "description", "span_schema"],
+ optional_fields=["entity_types", "allow_singletons", "visual_display"],
+ supports_keybindings=False,
+ description="Coreference chain annotation for grouping mentions of the same entity"
+ ),
+ SchemaDefinition(
+ name="tree_annotation",
+ generator=generate_tree_annotation_layout,
+ required_fields=["name", "description"],
+ optional_fields=["node_scheme", "path_selection", "branch_comparison"],
+ supports_keybindings=False,
+ description="Annotation of conversation tree nodes with path selection"
+ ),
+ SchemaDefinition(
+ name="triage",
+ generator=generate_triage_layout,
+ required_fields=["name", "description"],
+ optional_fields=["accept_label", "reject_label", "skip_label", "auto_advance", "show_progress", "accept_key", "reject_key", "skip_key"],
+ supports_keybindings=True,
+ description="Binary accept/reject triage for rapid data curation"
+ ),
+ SchemaDefinition(
+ name="event_annotation",
+ generator=generate_event_annotation_layout,
+ required_fields=["name", "description", "event_types", "span_schema"],
+ optional_fields=["visual_display"],
+ supports_keybindings=False,
+ description="N-ary event annotation with triggers and typed arguments"
+ ),
+ SchemaDefinition(
+ name="tiered_annotation",
+ generator=generate_tiered_annotation_layout,
+ required_fields=["name", "description", "tiers", "source_field"],
+ optional_fields=["media_type", "tier_height", "show_tier_labels", "collapsed_tiers", "zoom_enabled", "playback_rate_control", "overview_height"],
+ supports_keybindings=True,
+ description="Hierarchical multi-tier annotation for audio/video (ELAN-style)"
+ ),
+ SchemaDefinition(
+ name="bws",
+ generator=generate_bws_layout,
+ required_fields=["name", "description"],
+ optional_fields=["best_description", "worst_description", "tuple_size", "sequential_key_binding", "label_requirement"],
+ supports_keybindings=True,
+ description="Best-Worst Scaling: select the best and worst item from a set"
+ ),
+ SchemaDefinition(
+ name="soft_label",
+ generator=generate_soft_label_layout,
+ required_fields=["name", "description", "labels"],
+ optional_fields=["total", "min_per_label", "show_distribution_chart"],
+ supports_keybindings=False,
+ description="Probability distribution across labels via constrained sliders"
+ ),
+ SchemaDefinition(
+ name="confidence",
+ generator=generate_confidence_layout,
+ required_fields=["name", "description"],
+ optional_fields=["target_schema", "scale_type", "scale_points", "labels", "min_value", "max_value", "step", "left_label", "right_label"],
+ supports_keybindings=False,
+ description="Confidence rating meta-annotation for any primary annotation"
+ ),
+ SchemaDefinition(
+ name="constant_sum",
+ generator=generate_constant_sum_layout,
+ required_fields=["name", "description", "labels"],
+ optional_fields=["total_points", "min_per_item", "input_type"],
+ supports_keybindings=False,
+ description="Allocate a fixed budget of points across categories"
+ ),
+ SchemaDefinition(
+ name="semantic_differential",
+ generator=generate_semantic_differential_layout,
+ required_fields=["name", "description", "pairs"],
+ optional_fields=["scale_points"],
+ supports_keybindings=False,
+ description="Bipolar adjective scales for measuring connotative meaning"
+ ),
+ SchemaDefinition(
+ name="ranking",
+ generator=generate_ranking_layout,
+ required_fields=["name", "description", "labels"],
+ optional_fields=["allow_ties"],
+ supports_keybindings=False,
+ description="Drag-and-drop ranking of items by preference or relevance"
+ ),
+ SchemaDefinition(
+ name="range_slider",
+ generator=generate_range_slider_layout,
+ required_fields=["name", "description"],
+ optional_fields=["min_value", "max_value", "step", "left_label", "right_label", "show_values"],
+ supports_keybindings=False,
+ description="Dual-thumb slider for selecting an acceptable range"
+ ),
+ SchemaDefinition(
+ name="hierarchical_multiselect",
+ generator=generate_hierarchical_multiselect_layout,
+ required_fields=["name", "description", "taxonomy"],
+ optional_fields=["auto_select_children", "auto_select_parent", "show_search", "max_selections"],
+ supports_keybindings=False,
+ description="Hierarchical tree-structured multi-label selection"
+ ),
+ SchemaDefinition(
+ name="vas",
+ generator=generate_vas_layout,
+ required_fields=["name", "description"],
+ optional_fields=["left_label", "right_label", "min_value", "max_value", "show_value"],
+ supports_keybindings=False,
+ description="Continuous visual analog scale for fine-grained magnitude estimation"
+ ),
+ SchemaDefinition(
+ name="extractive_qa",
+ generator=generate_extractive_qa_layout,
+ required_fields=["name", "description"],
+ optional_fields=["question_field", "passage_field", "allow_unanswerable", "highlight_color"],
+ supports_keybindings=False,
+ description="SQuAD-style extractive question answering with answer span highlighting"
+ ),
+ SchemaDefinition(
+ name="rubric_eval",
+ generator=generate_rubric_eval_layout,
+ required_fields=["name", "description", "criteria"],
+ optional_fields=["scale_points", "scale_labels", "show_overall"],
+ supports_keybindings=False,
+ description="Multi-criteria rubric evaluation grid for LLM and text quality assessment"
+ ),
+ SchemaDefinition(
+ name="text_edit",
+ generator=generate_text_edit_layout,
+ required_fields=["name", "description"],
+ optional_fields=["source_field", "show_diff", "show_edit_distance", "allow_reset"],
+ supports_keybindings=False,
+ description="Inline text editing with diff tracking for post-editing and correction tasks"
+ ),
+ SchemaDefinition(
+ name="error_span",
+ generator=generate_error_span_layout,
+ required_fields=["name", "description", "error_types"],
+ optional_fields=["severities", "show_score", "max_score"],
+ supports_keybindings=False,
+ description="MQM-style error span annotation with typed severity for quality evaluation"
+ ),
+ SchemaDefinition(
+ name="card_sort",
+ generator=generate_card_sort_layout,
+ required_fields=["name", "description"],
+ optional_fields=["mode", "groups", "items_field", "allow_empty_groups", "allow_multiple"],
+ supports_keybindings=False,
+ description="Drag-and-drop card sorting into predefined or user-created groups"
+ ),
+ SchemaDefinition(
+ name="conjoint",
+ generator=generate_conjoint_layout,
+ required_fields=["name", "description"],
+ optional_fields=["profiles_per_set", "attributes", "show_none_option", "profiles_field"],
+ supports_keybindings=False,
+ description="Discrete choice conjoint analysis with side-by-side profile comparison"
+ ),
+ SchemaDefinition(
+ name="trajectory_eval",
+ generator=generate_trajectory_eval_layout,
+ required_fields=["name", "description"],
+ optional_fields=["steps_key", "step_text_key", "correctness_options", "error_types", "severities", "show_score", "max_score"],
+ supports_keybindings=False,
+ description="Per-step trajectory evaluation with error taxonomy and severity scoring"
+ ),
+ SchemaDefinition(
+ name="process_reward",
+ generator=generate_process_reward_layout,
+ required_fields=["name", "description"],
+ optional_fields=["steps_key", "step_text_key", "mode"],
+ supports_keybindings=False,
+ description="Binary per-step process reward signals for PRM training"
+ ),
+ SchemaDefinition(
+ name="trajectory_edit",
+ generator=generate_trajectory_edit_layout,
+ required_fields=["name", "description"],
+ optional_fields=["steps_key", "step_text_key", "editable_fields", "show_diff", "show_edit_distance", "allow_reset", "require_reason_on_edit", "edit_final_answer", "final_answer_key"],
+ supports_keybindings=False,
+ description="Per-step trajectory correction/editing for SFT/DPO training data"
+ ),
+ SchemaDefinition(
+ name="code_review",
+ generator=generate_code_review_layout,
+ required_fields=["name", "description"],
+ optional_fields=["comment_categories", "verdict_options", "file_rating_dimensions"],
+ supports_keybindings=False,
+ description="GitHub PR-style code review with inline comments and file ratings"
+ ),
+ ]
+
+ for schema in schemas:
+ schema_registry.register(schema)
+
+ logger.debug(f"Registered {len(schemas)} built-in schemas")
+
+
+# Auto-register built-in schemas on import
+_register_builtin_schemas()
diff --git a/potato/server_utils/schemas/rubric_eval.py b/potato/server_utils/schemas/rubric_eval.py
new file mode 100644
index 0000000000000000000000000000000000000000..4676400d28b2ec0fc6d50ba65b057f130a956834
--- /dev/null
+++ b/potato/server_utils/schemas/rubric_eval.py
@@ -0,0 +1,138 @@
+"""
+Multi-Criteria Rubric Evaluation Layout
+
+Rate items on multiple criteria simultaneously in a structured grid.
+THE missing schema for LLM evaluation โ enables MT-Bench-style multi-dimensional scoring.
+
+Research: Zheng et al. (2023) "Judging LLM-as-a-judge with MT-Bench"; Ke et al. (2024) "CritiqueLLM".
+"""
+
+import logging
+
+from .identifier_utils import (
+ safe_generate_layout,
+ generate_element_identifier,
+ generate_validation_attribute,
+ escape_html_content,
+ generate_layout_attributes
+)
+
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_SCALE_POINTS = 5
+DEFAULT_SCALE_LABELS = ["Poor", "Below Average", "Average", "Good", "Excellent"]
+
+
+def generate_rubric_eval_layout(annotation_scheme):
+ """
+ Generate HTML for a Multi-Criteria Rubric Evaluation interface.
+
+ Args:
+ annotation_scheme (dict): Configuration including:
+ - name: Schema identifier
+ - description: Display description
+ - scale_points: Number of scale points (default 5)
+ - scale_labels: Labels for each scale point
+ - criteria: List of {name, description} dicts
+ - show_overall: Whether to include an "Overall" row
+
+ Returns:
+ tuple: (html_string, key_bindings)
+ """
+ return safe_generate_layout(annotation_scheme, _generate_rubric_eval_layout_internal)
+
+
+def _generate_rubric_eval_layout_internal(annotation_scheme):
+ schema_name = annotation_scheme['name']
+ description = annotation_scheme['description']
+ scale_points = annotation_scheme.get('scale_points', DEFAULT_SCALE_POINTS)
+ scale_labels = annotation_scheme.get('scale_labels', DEFAULT_SCALE_LABELS[:scale_points])
+ criteria = annotation_scheme.get('criteria', [])
+ show_overall = annotation_scheme.get('show_overall', False)
+
+ if not criteria:
+ raise ValueError(f"rubric_eval schema '{schema_name}' requires at least one criterion in 'criteria'")
+
+ # Pad or trim scale_labels to match scale_points
+ while len(scale_labels) < scale_points:
+ scale_labels.append(f"{len(scale_labels) + 1}")
+ scale_labels = scale_labels[:scale_points]
+
+ layout_attrs = generate_layout_attributes(annotation_scheme)
+ validation = generate_validation_attribute(annotation_scheme)
+
+ # Build header row
+ header_cells = ''
+ for i, label in enumerate(scale_labels):
+ header_cells += f''
+
+ # Build criterion rows
+ rows_html = ""
+ all_criteria = list(criteria)
+ if show_overall:
+ all_criteria.append({"name": "overall", "description": "Overall quality"})
+
+ for criterion in all_criteria:
+ crit_name = criterion['name']
+ crit_desc = criterion.get('description', '')
+ crit_label = f"{schema_name}:{crit_name}"
+
+ identifiers = generate_element_identifier(schema_name, crit_name, "radio")
+ # Override name: each criterion row needs its own radio group
+ # (generate_element_identifier uses schema-only name for radios,
+ # but rubric_eval needs per-criterion groups)
+ row_group_name = f"{escape_html_content(schema_name)}:::{escape_html_content(crit_name)}"
+
+ cells = f"""
+
+ {escape_html_content(crit_name)}
+ {f'{escape_html_content(crit_desc)} ' if crit_desc else ''}
+ |
+ """
+
+ for i in range(scale_points):
+ value = str(i + 1)
+ radio_id = f"{identifiers['id']}-{value}"
+ cells += f"""
+
+
+ |
+ """
+
+ is_overall_class = " rubric-overall-row" if crit_name == "overall" else ""
+ rows_html += f'{cells}
'
+
+ html = f"""
+
+ """
+
+ logger.info(f"Generated rubric eval layout for {schema_name} with {len(criteria)} criteria")
+ return html, []
diff --git a/potato/server_utils/schemas/select.py b/potato/server_utils/schemas/select.py
new file mode 100644
index 0000000000000000000000000000000000000000..37fdfadceef91e744ac4594930c3ecbfac348d02
--- /dev/null
+++ b/potato/server_utils/schemas/select.py
@@ -0,0 +1,93 @@
+"""
+Select Layout
+"""
+
+import os
+from pathlib import Path
+
+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
+)
+
+
+def generate_select_layout(annotation_scheme):
+ """
+ Generate HTML for a select dropdown interface.
+
+ Args:
+ annotation_scheme (dict): Configuration including:
+ - name: Schema identifier
+ - description: Display description
+ - labels: List of options or path to file containing options
+ - use_predefined_labels: Use predefined label sets (country, ethnicity, religion)
+ - label_requirement (dict): Optional validation settings
+ - required (bool): Whether selection is mandatory
+
+ Returns:
+ tuple: (html_string, key_bindings)
+ html_string: Complete HTML for the select interface
+ key_bindings: Empty list (no keyboard shortcuts)
+ """
+ return safe_generate_layout(annotation_scheme, _generate_select_layout_internal)
+
+def _generate_select_layout_internal(annotation_scheme):
+ """
+ Internal function to generate select layout after validation.
+ """
+ # Generate consistent identifiers
+ identifiers = generate_element_identifier(annotation_scheme["name"], "select-one", "select")
+ validation = generate_validation_attribute(annotation_scheme)
+
+ # Get layout attributes for grid positioning
+ layout_attrs = generate_layout_attributes(annotation_scheme)
+
+ schematic = (
+ f'\n"
+ return schematic, []
diff --git a/potato/server_utils/schemas/semantic_differential.py b/potato/server_utils/schemas/semantic_differential.py
new file mode 100644
index 0000000000000000000000000000000000000000..39fd6e4da41082fed5df60daa3ea70840a258177
--- /dev/null
+++ b/potato/server_utils/schemas/semantic_differential.py
@@ -0,0 +1,137 @@
+"""
+Semantic Differential Layout
+
+Generates bipolar adjective scales arranged in a matrix.
+Each row has a left and right pole with radio buttons between them.
+
+Research basis:
+- Osgood, Suci & Tannenbaum (1957) "The Measurement of Meaning"
+ University of Illinois Press
+- Mohammad (2018) "Obtaining Reliable Human Ratings of Valence, Arousal, and Dominance
+ for 20,000 English Words" ACL
+"""
+
+import logging
+
+from potato.ai.ai_help_wrapper import get_ai_wrapper
+from .identifier_utils import (
+ safe_generate_layout,
+ generate_element_identifier,
+ generate_validation_attribute,
+ escape_html_content,
+ generate_layout_attributes,
+)
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_SCALE_POINTS = 7
+
+
+def generate_semantic_differential_layout(annotation_scheme):
+ """
+ Generate HTML for a semantic differential annotation interface.
+
+ Args:
+ annotation_scheme (dict): Configuration including:
+ - name: Schema identifier
+ - description: Display description
+ - pairs: List of [left_adjective, right_adjective] pairs
+ - scale_points: Number of points per scale (default 7)
+
+ Returns:
+ tuple: (html_string, key_bindings)
+ """
+ return safe_generate_layout(annotation_scheme, _generate_semantic_differential_layout_internal)
+
+
+def _generate_semantic_differential_layout_internal(annotation_scheme):
+ schema_name = annotation_scheme["name"]
+ safe_schema = escape_html_content(schema_name)
+ description = annotation_scheme["description"]
+ pairs = annotation_scheme.get("pairs", [])
+ scale_points = annotation_scheme.get("scale_points", DEFAULT_SCALE_POINTS)
+ layout_attrs = generate_layout_attributes(annotation_scheme)
+ validation = generate_validation_attribute(annotation_scheme)
+
+ if not pairs:
+ raise ValueError(f"semantic_differential schema '{schema_name}' requires 'pairs'")
+
+ for pair in pairs:
+ if not isinstance(pair, list) or len(pair) != 2:
+ raise ValueError(f"Each pair must be a list of two strings, got: {pair}")
+
+ html = f"""
+
+ """
+
+ # JS for mutual exclusivity within each pair row
+ html += """
+
+ """
+
+ key_bindings = []
+ logger.info(f"Generated semantic_differential layout for {schema_name} with {len(pairs)} pairs")
+ return html, key_bindings
diff --git a/potato/server_utils/schemas/slider.py b/potato/server_utils/schemas/slider.py
new file mode 100644
index 0000000000000000000000000000000000000000..a6ee6971ac218259ff9137cefd8ab6ba9b77d446
--- /dev/null
+++ b/potato/server_utils/schemas/slider.py
@@ -0,0 +1,923 @@
+# """
+# slider Layout
+# """
+
+# # Needed for the fall-back radio layout
+# from potato.ai.ai_help_wrapper import get_ai_wrapper, get_dynamic_ai_help
+# from .radio import generate_radio_layout
+# from .identifier_utils import (
+# safe_generate_layout,
+# generate_element_identifier,
+# escape_html_content
+# )
+
+# def test_and_get(key, d):
+# val = d[key]
+# try:
+# return int(val)
+# except:
+# raise Exception(
+# 'Slider scale %s\'s value for "%s" is not an int' % (d["name"], key)
+# )
+
+# def generate_slider_layout(annotation_scheme):
+# """
+# Generate HTML for a slider input interface.
+
+# Args:
+# annotation_scheme (dict): Configuration including:
+# - name: Schema identifier
+# - description: Display description
+# - starting_value: Initial slider value
+# - min_value: Minimum allowed value
+# - max_value: Maximum allowed value
+# - show_labels: Whether to show min/max labels
+# - labels: If present, fall back to radio layout
+
+# Returns:
+# tuple: (html_string, key_bindings)
+# html_string: Complete HTML for the slider interface
+# key_bindings: Empty list (no keyboard shortcuts)
+# """
+# return safe_generate_layout(annotation_scheme, generate_slider_layout_internal)
+
+# def generate_slider_layout_internal(annotation_scheme):
+# from .identifier_utils import escape_html_content, generate_element_identifier
+
+# if "labels" in annotation_scheme:
+# return generate_radio_layout(annotation_scheme, horizontal=False)
+
+# for required in ["starting_value", "min_value", "max_value"]:
+# if required not in annotation_scheme:
+# raise Exception(
+# f'Slider scale for "{annotation_scheme["name"]}" did not include {required}'
+# )
+
+# min_value = test_and_get("min_value", annotation_scheme)
+# max_value = test_and_get("max_value", annotation_scheme)
+# starting_value = test_and_get("starting_value", annotation_scheme)
+
+# if min_value >= max_value:
+# raise Exception(
+# f'Slider scale for "{annotation_scheme["name"]}" must have minimum value < max value ({min_value} >= {max_value})'
+# )
+
+# show_labels = annotation_scheme.get("show_labels", True)
+# min_label = str(min_value) if show_labels else ''
+# max_label = str(max_value) if show_labels else ''
+
+# identifiers = generate_element_identifier(annotation_scheme["name"], "slider", "range")
+
+# # Get step from annotation_scheme or default to 5
+# step_value = annotation_scheme.get("step", 1)
+# print(step_value)
+# max_tick = annotation_scheme.get("maxTick", 8)
+# print("max_tick")
+# print(max_tick)
+
+# schematic = f"""
+
+#
+
+#
+# """
+# key_bindings = []
+# return schematic, key_bindings
+
+
+"""
+slider Layout
+"""
+
+import logging
+
+# Needed for the fall-back radio layout
+from potato.ai.ai_help_wrapper import get_ai_wrapper, get_dynamic_ai_help
+from .radio import generate_radio_layout
+from .identifier_utils import (
+ safe_generate_layout,
+ generate_element_identifier,
+ escape_html_content
+)
+
+logger = logging.getLogger(__name__)
+
+def test_and_get(key, d):
+ val = d[key]
+ try:
+ return int(val)
+ except:
+ raise Exception(
+ 'Slider scale %s\'s value for "%s" is not an int' % (d["name"], key)
+ )
+
+def generate_slider_layout(annotation_scheme):
+ """
+ Generate HTML for a slider input interface.
+
+ Args:
+ annotation_scheme (dict): Configuration including:
+ - name: Schema identifier
+ - description: Display description
+ - starting_value: Initial slider value
+ - min_value: Minimum allowed value
+ - max_value: Maximum allowed value
+ - show_labels: Whether to show min/max labels
+ - labels: If present, fall back to radio layout
+
+ Returns:
+ tuple: (html_string, key_bindings)
+ html_string: Complete HTML for the slider interface
+ key_bindings: Empty list (no keyboard shortcuts)
+ """
+ return safe_generate_layout(annotation_scheme, generate_slider_layout_internal)
+
+def generate_slider_layout_internal(annotation_scheme):
+ from .identifier_utils import escape_html_content, generate_element_identifier, generate_validation_attribute
+
+ if "labels" in annotation_scheme:
+ return generate_radio_layout(annotation_scheme, horizontal=False)
+
+ for required in ["starting_value", "min_value", "max_value"]:
+ if required not in annotation_scheme:
+ raise Exception(
+ f'Slider scale for "{annotation_scheme["name"]}" did not include {required}'
+ )
+
+ min_value = test_and_get("min_value", annotation_scheme)
+ max_value = test_and_get("max_value", annotation_scheme)
+ starting_value = test_and_get("starting_value", annotation_scheme)
+
+ if min_value >= max_value:
+ raise Exception(
+ f'Slider scale for "{annotation_scheme["name"]}" must have minimum value < max value ({min_value} >= {max_value})'
+ )
+
+ show_labels = annotation_scheme.get("show_labels", True)
+ min_label = str(min_value) if show_labels else ''
+ max_label = str(max_value) if show_labels else ''
+
+ identifiers = generate_element_identifier(annotation_scheme["name"], "slider", "range")
+ validation = generate_validation_attribute(annotation_scheme)
+
+ # Get step from annotation_scheme or default to 1
+ step_value = annotation_scheme.get("step", 1)
+ max_tick = annotation_scheme.get("maxTick", 8)
+
+ schematic = f"""
+
+
+
+ """
+ key_bindings = []
+ return schematic, key_bindings
\ No newline at end of file
diff --git a/potato/server_utils/schemas/soft_label.py b/potato/server_utils/schemas/soft_label.py
new file mode 100644
index 0000000000000000000000000000000000000000..6b84d9129b9951ee54b513011f1cb3e370d92db4
--- /dev/null
+++ b/potato/server_utils/schemas/soft_label.py
@@ -0,0 +1,237 @@
+"""
+Soft Label / Probability Distribution Layout
+
+Generates a set of constrained sliders where annotators distribute probability
+mass across labels. All sliders are constrained to sum to a fixed total (default 100).
+
+Research basis:
+- Fornaciari et al. (2021) "Beyond Black & White: Leveraging Annotator Disagreement
+ via Soft-Label Multi-Task Learning" ACL
+- Plank et al. (2014) "Linguistically Debatable or Just Plain Wrong?" ACL
+"""
+
+import logging
+
+from potato.ai.ai_help_wrapper import get_ai_wrapper
+from .identifier_utils import (
+ safe_generate_layout,
+ generate_element_identifier,
+ generate_validation_attribute,
+ escape_html_content,
+ generate_layout_attributes,
+ generate_tooltip_html,
+)
+
+logger = logging.getLogger(__name__)
+
+# Defaults
+DEFAULT_TOTAL = 100
+DEFAULT_MIN_PER_LABEL = 0
+
+
+def generate_soft_label_layout(annotation_scheme):
+ """
+ Generate HTML for a soft label / probability distribution interface.
+
+ Args:
+ annotation_scheme (dict): Configuration including:
+ - name: Schema identifier
+ - description: Display description
+ - labels: List of label names (strings or dicts with 'name')
+ - total: Sum constraint (default 100)
+ - min_per_label: Minimum per label (default 0)
+ - show_distribution_chart: Show bar chart (default true)
+
+ Returns:
+ tuple: (html_string, key_bindings)
+ """
+ return safe_generate_layout(annotation_scheme, _generate_soft_label_layout_internal)
+
+
+def _generate_soft_label_layout_internal(annotation_scheme):
+ schema_name = annotation_scheme["name"]
+ safe_schema = escape_html_content(schema_name)
+ description = annotation_scheme["description"]
+ labels = annotation_scheme.get("labels", [])
+ total = annotation_scheme.get("total", DEFAULT_TOTAL)
+ min_per_label = annotation_scheme.get("min_per_label", DEFAULT_MIN_PER_LABEL)
+ show_chart = annotation_scheme.get("show_distribution_chart", True)
+
+ if not labels:
+ raise ValueError(f"soft_label schema '{schema_name}' requires 'labels'")
+
+ layout_attrs = generate_layout_attributes(annotation_scheme)
+ validation = generate_validation_attribute(annotation_scheme)
+
+ # Normalize labels
+ label_names = []
+ for lbl in labels:
+ if isinstance(lbl, str):
+ label_names.append(lbl)
+ elif isinstance(lbl, dict) and "name" in lbl:
+ label_names.append(lbl["name"])
+ else:
+ raise ValueError(f"Invalid label format: {lbl}")
+
+ # Calculate initial equal distribution
+ initial_value = total // len(label_names)
+ remainder = total - (initial_value * len(label_names))
+
+ html = f"""
+
+ """
+
+ # Inline JS for sum constraint
+ html += f"""
+
+ """
+
+ key_bindings = []
+ logger.info(f"Generated soft_label layout for {schema_name} with {len(label_names)} labels")
+ return html, key_bindings
diff --git a/potato/server_utils/schemas/span.py b/potato/server_utils/schemas/span.py
new file mode 100644
index 0000000000000000000000000000000000000000..85a6d646b29cfa5fb5256b3d2b362c9d04b30644
--- /dev/null
+++ b/potato/server_utils/schemas/span.py
@@ -0,0 +1,566 @@
+"""
+Span Layout
+"""
+
+import logging
+from collections.abc import Mapping
+from collections import defaultdict
+from potato.ai.ai_help_wrapper import get_ai_wrapper, get_dynamic_ai_help
+from potato.server_utils.config_module import config
+from .identifier_utils import (
+ safe_generate_layout,
+ generate_element_identifier,
+ generate_validation_attribute,
+ escape_html_content,
+ generate_layout_attributes
+)
+
+
+from item_state_management import SpanAnnotation
+
+logger = logging.getLogger(__name__)
+
+SPAN_COLOR_PALETTE = [
+ "(110, 86, 207)", # Primary purple #6E56CF
+ "(239, 68, 68)", # Destructive red #EF4444
+ "(113, 113, 122)", # Gray #71717A
+ "(245, 158, 11)", # Amber #F59E0B
+ "(16, 185, 129)", # Success green #10B981
+ "(59, 130, 246)", # Blue #3B82F6
+ "(220, 38, 38)", # Red #DC2626
+ "(139, 92, 246)", # Purple #8B5CF6
+ "(156, 163, 175)", # Light gray #9CA3AF
+ "(107, 114, 128)", # Medium gray #6B7280
+ "(55, 65, 81)", # Dark gray #374151
+ "(249, 115, 22)", # Orange #F97316
+ "(6, 182, 212)", # Cyan #06B6D4
+ "(236, 72, 153)", # Pink #EC4899
+ "(5, 150, 105)", # Dark green #059669
+ "(124, 58, 237)", # Violet #7C3AED
+ "(22, 163, 74)", # Green #16A34A
+ "(234, 88, 12)", # Dark orange #EA580C
+ "(37, 99, 235)", # Blue #2563EB
+ "(127, 29, 29)", # Dark red #7F1D1D
+ "(168, 85, 247)", # Purple #A855F7
+ "(34, 197, 94)", # Green #22C55E
+]
+
+span_counter = 0
+SPAN_COLOR_PALETTE_LENGTH = len(SPAN_COLOR_PALETTE)
+
+
+def reset_span_counter():
+ """Reset the span color counter to 0. Used for test isolation."""
+ global span_counter
+ span_counter = 0
+
+def get_span_color(schema, span_label):
+ """
+ Returns the color of a span with this label as a string with an RGB triple
+ in parentheses, or None if the span is unmapped.
+ """
+
+ if "ui" not in config or "spans" not in config["ui"]:
+ return None
+
+ span_ui = config["ui"]["spans"]
+
+ if "span_colors" not in span_ui:
+ return None
+
+ if schema in span_ui["span_colors"]:
+ schema_colors = span_ui["span_colors"][schema]
+ if span_label in schema_colors:
+ return schema_colors[span_label]
+
+ return None
+
+
+def set_span_color(schema, span_label, color):
+ """
+ Sets the color of a span with this label as a string with an RGB triple in parentheses.
+
+ :color: a string containing an RGB triple in parentheses
+ """
+ if "ui" not in config:
+ ui = {}
+ config["ui"] = ui
+ else:
+ ui = config["ui"]
+
+ if "spans" not in ui:
+ span_ui = {}
+ ui["spans"] = span_ui
+ else:
+ span_ui = ui["spans"]
+
+ if "span_colors" not in span_ui:
+ span_colors = defaultdict(dict)
+ span_ui["span_colors"] = span_colors
+ else:
+ span_colors = span_ui["span_colors"]
+
+ # Ensure the schema key exists (span_colors may be a regular dict, not defaultdict)
+ if schema not in span_colors:
+ span_colors[schema] = {}
+ span_colors[schema][span_label] = color
+
+def _generate_span_layout_internal(annotation_scheme, horizontal=False):
+ """
+ Internal function to generate span layout after validation.
+
+ Configuration options:
+ allow_discontinuous (bool): Enable discontinuous span selection via Ctrl/Cmd+click.
+ When enabled, users can hold Ctrl (Windows/Linux) or Cmd (Mac) and click to
+ add additional non-contiguous text ranges to an existing span annotation.
+ Default: false
+
+ entity_linking (dict): Configuration for knowledge base entity linking.
+ When enabled, users can link annotated spans to external knowledge bases
+ like Wikidata or UMLS. Configuration options:
+ - enabled (bool): Whether entity linking is enabled. Default: false
+ - knowledge_bases (list): List of KB configurations, each with:
+ - name (str): Display name for the KB
+ - type (str): KB type ("wikidata", "umls", "rest")
+ - api_key (str): Optional API key for authenticated services
+ - language (str): Language code for results. Default: "en"
+ - auto_search (bool): Automatically search when span is created. Default: true
+ - required (bool): Require entity link before saving span. Default: false
+
+ Example:
+ entity_linking:
+ enabled: true
+ knowledge_bases:
+ - name: wikidata
+ type: wikidata
+ language: en
+ - name: umls
+ type: umls
+ api_key: ${UMLS_API_KEY}
+ auto_search: true
+ required: false
+ """
+ import json as json_module
+
+ # Initialize form wrapper
+ scheme_name = annotation_scheme["name"]
+
+ # Get target_field for multi-span support (optional)
+ target_field = annotation_scheme.get("target_field", "")
+ target_field_attr = f' data-target-field="{escape_html_content(target_field)}"' if target_field else ""
+
+ # Check for discontinuous span support
+ allow_discontinuous = annotation_scheme.get("allow_discontinuous", False)
+ discontinuous_attr = ' data-allow-discontinuous="true"' if allow_discontinuous else ""
+
+ # Check for entity linking support
+ entity_linking = annotation_scheme.get("entity_linking", {})
+ entity_linking_enabled = entity_linking.get("enabled", False)
+ entity_linking_attr = ""
+ if entity_linking_enabled:
+ # Serialize entity_linking config to JSON for frontend
+ el_config = {
+ "enabled": True,
+ "knowledge_bases": entity_linking.get("knowledge_bases", []),
+ "auto_search": entity_linking.get("auto_search", True),
+ "required": entity_linking.get("required", False),
+ "multi_select": entity_linking.get("multi_select", False)
+ }
+ el_json = json_module.dumps(el_config)
+ entity_linking_attr = f' data-entity-linking=\'{escape_html_content(el_json)}\''
+
+ # Check for show_span_labels option (default: true)
+ show_span_labels = annotation_scheme.get("show_span_labels", True)
+ show_labels_attr = '' if show_span_labels else ' data-show-span-labels="false"'
+
+ # Get layout attributes for grid positioning
+ layout_attrs = generate_layout_attributes(annotation_scheme)
+
+ schematic = f"""
+ "
+ 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 generate_span_layout(annotation_scheme, horizontal=False):
+ """
+ Generate span layout HTML for the given annotation scheme.
+
+ Args:
+ annotation_scheme (dict): The annotation scheme configuration
+ horizontal (bool): Whether to display horizontally
+
+ Returns:
+ tuple: (HTML string, key bindings list)
+ """
+ return safe_generate_layout(annotation_scheme, _generate_span_layout_internal, horizontal)
+
+
+def render_span_annotations(text, span_annotations, target_field=None):
+ """
+ Render span annotations into HTML with boundary-based algorithm.
+ Supports discontinuous spans with additional_parts.
+
+ Args:
+ text (str): The original text to annotate
+ span_annotations: Dictionary of span_id -> span data, or list of SpanAnnotation objects,
+ or field-keyed dict: {field_key: [span_list]}
+ target_field (str, optional): Filter spans to only those targeting this field
+ Returns:
+ str: HTML with span annotations rendered
+ """
+ if not span_annotations:
+ return text
+
+ # Handle field-keyed format for multi-span mode: {field_key: [spans]}
+ if isinstance(span_annotations, dict):
+ # Check if this is a field-keyed dict (values are lists)
+ first_value = next(iter(span_annotations.values()), None)
+ if isinstance(first_value, list):
+ # Field-keyed format - extract spans for target_field
+ if target_field:
+ field_spans = span_annotations.get(target_field, [])
+ return render_span_annotations(text, field_spans, target_field=None)
+ else:
+ # No target field specified, flatten all spans
+ all_spans = []
+ for field_spans in span_annotations.values():
+ all_spans.extend(field_spans)
+ return render_span_annotations(text, all_spans, target_field=None)
+
+ # Regular dict format: span_id -> span_data
+ sorted_spans = sorted(
+ span_annotations.items(),
+ key=lambda x: x[1].get('start', 0)
+ )
+ else:
+ # Convert list of SpanAnnotation objects to list of tuples
+ spans_as_tuples = []
+ for span in span_annotations:
+ if hasattr(span, 'get_id'):
+ # SpanAnnotation object with methods
+ # Filter by target_field if specified
+ span_target = span.get_target_field() if hasattr(span, 'get_target_field') else None
+ if target_field and span_target and span_target != target_field:
+ continue # Skip spans not targeting this field
+
+ span_id = span.get_id()
+ # Get additional_parts for discontinuous spans
+ additional_parts = []
+ if hasattr(span, 'get_additional_parts'):
+ additional_parts = span.get_additional_parts() or []
+ elif hasattr(span, 'additional_parts'):
+ additional_parts = getattr(span, 'additional_parts', []) or []
+
+ # Get KB entity linking data
+ kb_id = None
+ kb_source = None
+ kb_label = None
+ if hasattr(span, 'get_kb_id'):
+ kb_id = span.get_kb_id()
+ kb_source = span.get_kb_source() if hasattr(span, 'get_kb_source') else None
+ kb_label = span.get_kb_label() if hasattr(span, 'get_kb_label') else None
+ elif hasattr(span, 'kb_id'):
+ kb_id = getattr(span, 'kb_id', None)
+ kb_source = getattr(span, 'kb_source', None)
+ kb_label = getattr(span, 'kb_label', None)
+
+ span_data = {
+ 'schema': span.get_schema() if hasattr(span, 'get_schema') else getattr(span, 'schema', ''),
+ 'name': span.get_name() if hasattr(span, 'get_name') else getattr(span, 'name', ''),
+ 'title': span.get_title() if hasattr(span, 'get_title') else getattr(span, 'title', ''),
+ 'start': span.get_start() if hasattr(span, 'get_start') else getattr(span, 'start', 0),
+ 'end': span.get_end() if hasattr(span, 'get_end') else getattr(span, 'end', 0),
+ 'target_field': span_target,
+ 'additional_parts': additional_parts,
+ 'kb_id': kb_id,
+ 'kb_source': kb_source,
+ 'kb_label': kb_label,
+ }
+ elif isinstance(span, dict):
+ # Filter by target_field if specified
+ span_target = span.get('target_field')
+ if target_field and span_target and span_target != target_field:
+ continue # Skip spans not targeting this field
+
+ span_id = span.get('id', f"span_{span.get('start', 0)}_{span.get('end', 0)}")
+ span_data = span
+ else:
+ continue
+ spans_as_tuples.append((span_id, span_data))
+ sorted_spans = sorted(spans_as_tuples, key=lambda x: x[1].get('start', 0))
+
+ # Create boundary points (including additional_parts for discontinuous spans)
+ boundaries = []
+ for span_id, span_data in sorted_spans:
+ # Add primary span boundaries
+ boundaries.append((span_data['start'], 'start', span_id, span_data))
+ boundaries.append((span_data['end'], 'end', span_id, span_data))
+
+ # Add boundaries for additional parts (discontinuous spans)
+ additional_parts = span_data.get('additional_parts', [])
+ for part in additional_parts:
+ # Create a modified span_data for this part that includes discontinuous marker
+ part_data = span_data.copy()
+ part_data['_is_discontinuous_part'] = True
+ boundaries.append((part['start'], 'start', span_id, part_data))
+ boundaries.append((part['end'], 'end', span_id, part_data))
+
+ # Sort boundaries by position
+ boundaries.sort(key=lambda x: x[0])
+
+ # Build the rendered text
+ result = ""
+ current_pos = 0
+ active_spans = []
+
+ for pos, boundary_type, span_id, span_data in boundaries:
+ # Add text before this boundary
+ if pos > current_pos:
+ result += text[current_pos:pos]
+
+ if boundary_type == 'start':
+ # Start a new span
+ active_spans.append(span_id)
+ # Get color for this span
+ color = get_span_color(span_data['schema'], span_data['name'])
+ if not color:
+ color = "(128, 128, 128)" # Default gray
+ # Convert RGB to hex with alpha
+ color_parts = color.strip("()").split(", ")
+ r, g, b = int(color_parts[0]), int(color_parts[1]), int(color_parts[2])
+ hex_color = f"#{r:02x}{g:02x}{b:02x}66" # 66 = 40% alpha to match label background
+
+ # Add target_field attribute if present
+ target_attr = f' data-target-field="{span_data.get("target_field", "")}"' if span_data.get("target_field") else ""
+
+ # Check if this is a discontinuous span part
+ is_discontinuous = span_data.get('_is_discontinuous_part', False) or len(span_data.get('additional_parts', [])) > 0
+ discontinuous_class = ' discontinuous-part' if is_discontinuous else ''
+ discontinuous_attr = ' data-discontinuous="true"' if is_discontinuous else ""
+
+ # Add KB entity linking attributes
+ kb_id = span_data.get('kb_id', '')
+ kb_source = span_data.get('kb_source', '')
+ kb_label = span_data.get('kb_label', '')
+ kb_attr = ""
+ kb_class = ""
+ if kb_id:
+ kb_attr = f' data-kb-id="{escape_html_content(kb_id)}" data-kb-source="{escape_html_content(kb_source)}"'
+ if kb_label:
+ kb_attr += f' data-kb-label="{escape_html_content(kb_label)}"'
+ kb_class = ' has-entity-link'
+
+ result += f''
+ elif boundary_type == 'end':
+ # End the span
+ result += ""
+ # Remove from active spans
+ active_spans = [s for s in active_spans if s != span_id]
+
+ current_pos = pos
+
+ # Add remaining text
+ if current_pos < len(text):
+ result += text[current_pos:]
+
+ return result
+
+
+def get_spans_for_field(span_annotations, target_field):
+ """
+ Extract spans for a specific target field from span annotations.
+
+ Args:
+ span_annotations: Span annotations in any format
+ target_field: The field key to filter by
+
+ Returns:
+ List of spans targeting the specified field
+ """
+ if not span_annotations:
+ return []
+
+ # Handle field-keyed format
+ if isinstance(span_annotations, dict):
+ first_value = next(iter(span_annotations.values()), None)
+ if isinstance(first_value, list):
+ return span_annotations.get(target_field, [])
+
+ # Handle list of SpanAnnotation objects
+ result = []
+ if isinstance(span_annotations, (list, tuple)):
+ for span in span_annotations:
+ if hasattr(span, 'get_target_field'):
+ if span.get_target_field() == target_field:
+ result.append(span)
+ elif isinstance(span, dict) and span.get('target_field') == target_field:
+ result.append(span)
+
+ return result
\ No newline at end of file
diff --git a/potato/server_utils/schemas/span_link.py b/potato/server_utils/schemas/span_link.py
new file mode 100644
index 0000000000000000000000000000000000000000..b36a626c13815f1673e8a91e7f872de0c684de0c
--- /dev/null
+++ b/potato/server_utils/schemas/span_link.py
@@ -0,0 +1,308 @@
+"""
+Span Link Layout
+
+Generates the UI for creating and managing relationships/links between spans.
+This schema type works in conjunction with a span annotation schema to allow
+users to annotate relationships like "PERSON works_for ORGANIZATION".
+"""
+
+import logging
+from .identifier_utils import (
+ safe_generate_layout,
+ generate_element_identifier,
+ escape_html_content,
+ generate_tooltip_html
+)
+from .span import get_span_color, SPAN_COLOR_PALETTE
+
+logger = logging.getLogger(__name__)
+
+# Default colors for link types
+LINK_COLOR_PALETTE = [
+ "#dc2626", # Red
+ "#22c55e", # Green
+ "#a855f7", # Purple
+ "#f59e0b", # Amber
+ "#3b82f6", # Blue
+ "#ec4899", # Pink
+ "#06b6d4", # Cyan
+ "#f97316", # Orange
+ "#8b5cf6", # Violet
+ "#10b981", # Emerald
+]
+
+
+def _generate_span_link_layout_internal(annotation_scheme, horizontal=False):
+ """
+ Internal function to generate span link layout after validation.
+
+ Args:
+ annotation_scheme: Configuration dictionary containing:
+ - name: Schema name
+ - description: Description shown to user
+ - span_schema: Name of the span schema to link
+ - link_types: List of link type definitions with:
+ - name: Link type name (e.g., "WORKS_FOR")
+ - directed: Whether the link is directed (default: false)
+ - allowed_source_labels: Optional list of allowed source span labels
+ - allowed_target_labels: Optional list of allowed target span labels
+ - max_spans: Maximum spans in a link (default: 2, higher for n-ary)
+ - color: Optional color for this link type
+ horizontal: Whether to display horizontally (not used for links)
+
+ Returns:
+ tuple: (HTML string, key bindings list)
+ """
+ scheme_name = annotation_scheme["name"]
+ description = annotation_scheme.get("description", "Create relationships between spans")
+ span_schema = annotation_scheme.get("span_schema", "")
+ link_types = annotation_scheme.get("link_types", [])
+ visual_display = annotation_scheme.get("visual_display", {})
+
+ # Build link types HTML
+ link_types_html = ""
+ key_bindings = []
+
+ for i, link_type in enumerate(link_types):
+ link_name = link_type.get("name", f"Link_{i}")
+ directed = link_type.get("directed", False)
+ max_spans = link_type.get("max_spans", 2)
+ color = link_type.get("color", LINK_COLOR_PALETTE[i % len(LINK_COLOR_PALETTE)])
+
+ # Direction indicator
+ direction_icon = "โ" if directed else "โ"
+ direction_class = "directed" if directed else "undirected"
+
+ # Tooltip with constraints
+ tooltip_parts = []
+ if link_type.get("allowed_source_labels"):
+ tooltip_parts.append(f"Source: {', '.join(link_type['allowed_source_labels'])}")
+ if link_type.get("allowed_target_labels"):
+ tooltip_parts.append(f"Target: {', '.join(link_type['allowed_target_labels'])}")
+ if max_spans > 2:
+ tooltip_parts.append(f"N-ary: up to {max_spans} spans")
+
+ tooltip_attr = ""
+ if tooltip_parts:
+ tooltip_text = "; ".join(tooltip_parts)
+ tooltip_attr = f'data-toggle="tooltip" data-placement="top" title="{escape_html_content(tooltip_text)}"'
+
+ link_types_html += f"""
+
+
+
+
+ """
+
+ # Visual display settings
+ show_arcs = visual_display.get("enabled", True)
+ arc_position = visual_display.get("arc_position", "above")
+ show_labels = visual_display.get("show_labels", True)
+ # Multi-line arc mode: "single_line" (horizontal scroll) or "bracket" (wrapped text with bracket arcs)
+ multi_line_mode = visual_display.get("multi_line_mode", "bracket")
+
+ schematic = f"""
+
+
+
+
+
+
+
+
+ {link_types_html}
+
+
+
+
+
+
+
+
Click on highlighted spans to select them for linking
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ """
+
+ return schematic, key_bindings
+
+
+def generate_span_link_layout(annotation_scheme, horizontal=False):
+ """
+ Generate span link layout HTML for the given annotation scheme.
+
+ Args:
+ annotation_scheme (dict): The annotation scheme configuration
+ horizontal (bool): Whether to display horizontally
+
+ Returns:
+ tuple: (HTML string, key bindings list)
+ """
+ return safe_generate_layout(annotation_scheme, _generate_span_link_layout_internal, horizontal)
+
+
+def render_link_arcs(links, span_positions):
+ """
+ Generate SVG arcs for visualizing links between spans.
+
+ Args:
+ links: List of SpanLink objects
+ span_positions: Dictionary mapping span_id -> {x, y, width, height} positions
+
+ Returns:
+ str: SVG markup for the link arcs
+ """
+ if not links or not span_positions:
+ return ""
+
+ svg_paths = []
+
+ for link in links:
+ span_ids = link.get_span_ids()
+ if len(span_ids) < 2:
+ continue
+
+ # Get color for this link type
+ link_color = link.get_properties().get("color", "#dc2626")
+
+ # For binary links, draw a simple arc
+ if len(span_ids) == 2:
+ span1_id, span2_id = span_ids
+ if span1_id not in span_positions or span2_id not in span_positions:
+ continue
+
+ pos1 = span_positions[span1_id]
+ pos2 = span_positions[span2_id]
+
+ # Calculate arc endpoints (center of each span)
+ x1 = pos1["x"] + pos1["width"] / 2
+ y1 = pos1["y"]
+ x2 = pos2["x"] + pos2["width"] / 2
+ y2 = pos2["y"]
+
+ # Calculate control point for the arc
+ mid_x = (x1 + x2) / 2
+ arc_height = min(abs(x2 - x1) / 3, 50) # Arc height proportional to distance
+
+ # Create SVG path
+ if link.is_directed():
+ # Directed link with arrow
+ svg_paths.append(f"""
+
+ """)
+ else:
+ # Undirected link
+ svg_paths.append(f"""
+
+ """)
+
+ # For n-ary links, connect all spans to a central point
+ else:
+ # Calculate center point
+ valid_positions = [span_positions[sid] for sid in span_ids if sid in span_positions]
+ if len(valid_positions) < 2:
+ continue
+
+ center_x = sum(p["x"] + p["width"] / 2 for p in valid_positions) / len(valid_positions)
+ center_y = min(p["y"] for p in valid_positions) - 30 # Above the spans
+
+ # Draw lines from each span to center
+ for span_id in span_ids:
+ if span_id not in span_positions:
+ continue
+ pos = span_positions[span_id]
+ x = pos["x"] + pos["width"] / 2
+ y = pos["y"]
+ svg_paths.append(f"""
+
+ """)
+
+ # Draw central node
+ svg_paths.append(f"""
+
+ """)
+
+ # Wrap in SVG with defs for arrow markers
+ svg = f"""
+
+ """
+
+ return svg
diff --git a/potato/server_utils/schemas/text_edit.py b/potato/server_utils/schemas/text_edit.py
new file mode 100644
index 0000000000000000000000000000000000000000..d5e8deaaa9ed63f5028852fdb0ff6f8a63faf2e5
--- /dev/null
+++ b/potato/server_utils/schemas/text_edit.py
@@ -0,0 +1,265 @@
+"""
+Inline Text Editing / Post-Edit Layout
+
+Annotators directly edit displayed text, with the system tracking insertions,
+deletions, and substitutions as a structured diff. Used for MT post-editing,
+grammar correction, text simplification, and paraphrase generation.
+
+Research: WMT post-editing shared tasks; MQM-APE (COLING 2025).
+"""
+
+import logging
+
+from .identifier_utils import (
+ safe_generate_layout,
+ generate_element_identifier,
+ generate_validation_attribute,
+ escape_html_content,
+ generate_layout_attributes
+)
+
+
+logger = logging.getLogger(__name__)
+
+
+def generate_text_edit_layout(annotation_scheme):
+ """
+ Generate HTML for an Inline Text Editing interface.
+
+ Args:
+ annotation_scheme (dict): Configuration including:
+ - name: Schema identifier
+ - description: Display description
+ - source_field: Field in data containing text to edit
+ - show_diff: Whether to show real-time diff highlighting
+ - show_edit_distance: Whether to show edit distance counter
+ - allow_reset: Whether to show "Reset to original" button
+
+ Returns:
+ tuple: (html_string, key_bindings)
+ """
+ return safe_generate_layout(annotation_scheme, _generate_text_edit_layout_internal)
+
+
+def _generate_text_edit_layout_internal(annotation_scheme):
+ schema_name = annotation_scheme['name']
+ description = annotation_scheme['description']
+ source_field = annotation_scheme.get('source_field', '')
+ show_diff = annotation_scheme.get('show_diff', True)
+ show_edit_distance = annotation_scheme.get('show_edit_distance', True)
+ allow_reset = annotation_scheme.get('allow_reset', True)
+
+ layout_attrs = generate_layout_attributes(annotation_scheme)
+ validation = generate_validation_attribute(annotation_scheme)
+ identifiers = generate_element_identifier(schema_name, schema_name, "hidden")
+
+ reset_btn = ""
+ if allow_reset:
+ reset_btn = f"""
+
+ """
+
+ diff_display = ""
+ if show_diff:
+ diff_display = f"""
+
+ """
+
+ edit_distance_display = ""
+ if show_edit_distance:
+ edit_distance_display = f"""
+
+ Words changed: 0
+ Chars changed: 0
+
+ """
+
+ html = f"""
+
+
+
+ """
+
+ logger.info(f"Generated text edit layout for {schema_name}")
+ return html, []
diff --git a/potato/server_utils/schemas/textbox.py b/potato/server_utils/schemas/textbox.py
new file mode 100644
index 0000000000000000000000000000000000000000..fe685877729780e672f9fa1e86232fcd7f075ce9
--- /dev/null
+++ b/potato/server_utils/schemas/textbox.py
@@ -0,0 +1,197 @@
+"""
+Textbox Layout
+
+Supports enhanced rationale/justification features:
+- min_chars: Minimum character count with counter display
+- show_char_count: Show character counter below textarea
+- collapsible: Start collapsed, expand on click
+- target_schema: Visual grouping with a preceding schema
+- placeholder: Placeholder text in the input
+"""
+
+import logging
+
+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
+)
+
+
+logger = logging.getLogger(__name__)
+
+def generate_textbox_layout(annotation_scheme):
+ """
+ Generate HTML for a textbox input interface.
+
+ Args:
+ annotation_scheme (dict): Configuration including:
+ - name: Schema identifier
+ - description: Display description
+ - labels: Optional list of labels for multiple textboxes
+ - label_requirement (dict): Optional validation settings
+ - required (bool): Whether input is mandatory
+ - textarea (dict): Optional textarea configuration
+ - on (bool): Whether to use textarea instead of input
+ - rows (int): Number of rows for textarea
+ - cols (int): Number of columns for textarea
+ - allow_paste (bool): Whether to allow pasting text
+ - custom_css (dict): Optional CSS styling
+ - min_chars (int): Minimum characters required
+ - show_char_count (bool): Show character counter
+ - collapsible (bool): Start collapsed, expand on click
+ - target_schema (str): Name of schema this is a rationale for
+ - placeholder (str): Placeholder text
+
+ Returns:
+ tuple: (html_string, key_bindings)
+ html_string: Complete HTML for the textbox interface
+ key_bindings: Empty list (no keyboard shortcuts)
+ """
+ return safe_generate_layout(annotation_scheme, _generate_textbox_layout_internal)
+
+def _generate_textbox_layout_internal(annotation_scheme):
+ """
+ Internal function to generate textbox layout after validation.
+ """
+ logger.debug(f"Generating textbox layout for schema: {annotation_scheme['name']}")
+
+ # Layout attributes for grid positioning
+ layout_attrs = generate_layout_attributes(annotation_scheme)
+
+ # Enhanced features
+ min_chars = annotation_scheme.get("min_chars", 0)
+ show_char_count = annotation_scheme.get("show_char_count", False)
+ collapsible = annotation_scheme.get("collapsible", False)
+ target_schema = annotation_scheme.get("target_schema", "")
+ placeholder = escape_html_content(annotation_scheme.get("placeholder", ""))
+
+ # CSS classes for target_schema grouping
+ target_class = "shadcn-textbox-target-grouped" if target_schema else ""
+
+ # Initialize form wrapper
+ schema_name = annotation_scheme['name']
+ schematic = f"""
+ "
+
+ logger.debug(f"Generated textbox schematic for {annotation_scheme['name']}")
+ logger.info(f"Successfully generated textbox layout for {annotation_scheme['name']} with {len(labels)} fields")
+ return schematic, []
diff --git a/potato/server_utils/schemas/tiered_annotation.py b/potato/server_utils/schemas/tiered_annotation.py
new file mode 100644
index 0000000000000000000000000000000000000000..443bf06302be1862b7137a03bd973426e041fbe1
--- /dev/null
+++ b/potato/server_utils/schemas/tiered_annotation.py
@@ -0,0 +1,555 @@
+"""
+Tiered Annotation Schema
+
+Generates a multi-tier timeline annotation interface for audio/video content.
+This schema supports ELAN-style hierarchical annotation with independent and
+dependent tiers, where dependent tiers can have various constraint relationships
+to their parent tiers.
+
+Features:
+- Multi-row timeline visualization with waveform (via Peaks.js)
+- Independent tiers for direct time alignment
+- Dependent tiers with parent-child constraints
+- Tier selection with dynamic label buttons
+- Constraint validation in real-time
+- EAF and TextGrid export support
+
+Example configuration:
+ annotation_schemes:
+ - annotation_type: tiered_annotation
+ name: linguistic_tiers
+ description: "Multi-tier linguistic annotation"
+ source_field: audio_url
+ media_type: audio
+ tiers:
+ - name: utterance
+ tier_type: independent
+ labels:
+ - name: Speaker_A
+ color: "#4ECDC4"
+ - name: Speaker_B
+ color: "#FF6B6B"
+ - name: word
+ tier_type: dependent
+ parent_tier: utterance
+ constraint_type: time_subdivision
+ labels:
+ - name: Word
+ color: "#95E1D3"
+"""
+
+import json
+import logging
+from typing import Dict, Any, Tuple, List
+
+from .identifier_utils import safe_generate_layout, escape_html_content
+
+logger = logging.getLogger(__name__)
+
+# Default colors for tier labels
+DEFAULT_COLORS = [
+ "#4ECDC4", # Teal
+ "#FF6B6B", # Red
+ "#45B7D1", # Blue
+ "#96CEB4", # Green
+ "#FFEAA7", # Yellow
+ "#DDA0DD", # Plum
+ "#95A5A6", # Gray
+ "#F39C12", # Orange
+ "#9B59B6", # Purple
+ "#3498DB", # Light Blue
+]
+
+# Valid constraint types
+VALID_CONSTRAINT_TYPES = [
+ "time_subdivision",
+ "included_in",
+ "symbolic_association",
+ "symbolic_subdivision",
+ "none"
+]
+
+
+def generate_tiered_annotation_layout(
+ annotation_scheme: Dict[str, Any]
+) -> Tuple[str, List[Tuple[str, str]]]:
+ """
+ Generate HTML for a tiered annotation interface.
+
+ Args:
+ annotation_scheme: Configuration dictionary including:
+ - name: Schema identifier
+ - description: Display description
+ - source_field: Field name containing media URL
+ - media_type: "audio" or "video"
+ - tiers: List of tier definitions
+ - tier_height: Height per tier row in pixels (default: 50)
+ - show_tier_labels: Show tier name labels (default: True)
+ - collapsed_tiers: List of tier names to start collapsed
+ - zoom_enabled: Enable zoom controls (default: True)
+ - playback_rate_control: Show playback speed controls (default: True)
+
+ Returns:
+ Tuple of (html_string, keybindings_list)
+ """
+ return safe_generate_layout(annotation_scheme, _generate_tiered_annotation_layout_internal)
+
+
+def _generate_tiered_annotation_layout_internal(
+ annotation_scheme: Dict[str, Any]
+) -> Tuple[str, List[Tuple[str, str]]]:
+ """Internal implementation of tiered annotation layout generation."""
+
+ schema_name = annotation_scheme.get("name", "tiered_annotation")
+ description = annotation_scheme.get("description", "")
+ source_field = annotation_scheme.get("source_field", "audio_url")
+ media_type = annotation_scheme.get("media_type", "audio").lower()
+
+ # Validate media type
+ if media_type not in ("audio", "video"):
+ raise ValueError(f"Invalid media_type '{media_type}'. Must be 'audio' or 'video'.")
+
+ # Get tier definitions
+ tiers = annotation_scheme.get("tiers", [])
+ if not tiers:
+ raise ValueError("tiered_annotation requires at least one tier definition")
+
+ # Validate and process tiers
+ processed_tiers = _process_tiers(tiers)
+
+ # Validate tier structure
+ _validate_tier_structure(processed_tiers)
+
+ # Configuration options
+ tier_height = annotation_scheme.get("tier_height", 50)
+ show_tier_labels = annotation_scheme.get("show_tier_labels", True)
+ collapsed_tiers = annotation_scheme.get("collapsed_tiers", [])
+ zoom_enabled = annotation_scheme.get("zoom_enabled", True)
+ playback_rate_control = annotation_scheme.get("playback_rate_control", True)
+ overview_height = annotation_scheme.get("overview_height", 40)
+
+ # Build tier rows HTML
+ tier_rows_html = _generate_tier_rows_html(
+ schema_name, processed_tiers, tier_height, show_tier_labels, collapsed_tiers
+ )
+
+ # Build tier selector options
+ tier_selector_html = _generate_tier_selector_html(processed_tiers)
+
+ # Build JavaScript configuration
+ js_config = {
+ "schemaName": schema_name,
+ "mediaType": media_type,
+ "sourceField": source_field,
+ "tiers": processed_tiers,
+ "tierHeight": tier_height,
+ "showTierLabels": show_tier_labels,
+ "collapsedTiers": collapsed_tiers,
+ "zoomEnabled": zoom_enabled,
+ "playbackRateControl": playback_rate_control,
+ "overviewHeight": overview_height,
+ }
+ config_json = json.dumps(js_config)
+
+ # Generate media element
+ media_element = _generate_media_element(schema_name, media_type)
+
+ # Generate playback controls
+ playback_controls = _generate_playback_controls(
+ schema_name, playback_rate_control, zoom_enabled
+ )
+
+ # Generate the complete HTML
+ html = f'''
+
+'''
+
+ # Define keybindings
+ keybindings = [
+ ("Space", "Play/Pause"),
+ (",", "Step back 1 frame"),
+ (".", "Step forward 1 frame"),
+ ("Delete/Backspace", "Delete selected annotation"),
+ ("Esc", "Deselect annotation"),
+ ]
+
+ return html, keybindings
+
+
+def _process_tiers(tiers: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
+ """
+ Process tier definitions, assigning colors and normalizing fields.
+
+ Args:
+ tiers: Raw tier configuration list
+
+ Returns:
+ Processed tier list with normalized fields
+ """
+ processed = []
+ color_index = 0
+
+ for tier in tiers:
+ tier_data = {
+ "name": tier.get("name", f"tier_{len(processed)}"),
+ "tier_type": tier.get("tier_type", "independent").lower(),
+ "parent_tier": tier.get("parent_tier", None),
+ "constraint_type": tier.get("constraint_type", "none").lower(),
+ "description": tier.get("description", ""),
+ "linguistic_type": tier.get("linguistic_type"),
+ "labels": [],
+ }
+
+ # Validate tier_type
+ if tier_data["tier_type"] not in ("independent", "dependent"):
+ raise ValueError(
+ f"Tier '{tier_data['name']}' has invalid tier_type: {tier_data['tier_type']}"
+ )
+
+ # Validate constraint_type
+ if tier_data["constraint_type"] not in VALID_CONSTRAINT_TYPES:
+ raise ValueError(
+ f"Tier '{tier_data['name']}' has invalid constraint_type: {tier_data['constraint_type']}"
+ )
+
+ # Process labels
+ raw_labels = tier.get("labels", [])
+ for label in raw_labels:
+ if isinstance(label, str):
+ label_data = {
+ "name": label,
+ "color": DEFAULT_COLORS[color_index % len(DEFAULT_COLORS)],
+ }
+ color_index += 1
+ elif isinstance(label, dict):
+ label_data = {
+ "name": label.get("name", ""),
+ "color": label.get("color", DEFAULT_COLORS[color_index % len(DEFAULT_COLORS)]),
+ "description": label.get("description", ""),
+ "tooltip": label.get("tooltip", ""),
+ }
+ if not label.get("color"):
+ color_index += 1
+ else:
+ continue
+
+ if label_data["name"]:
+ tier_data["labels"].append(label_data)
+
+ processed.append(tier_data)
+
+ return processed
+
+
+def _validate_tier_structure(tiers: List[Dict[str, Any]]) -> None:
+ """
+ Validate the tier hierarchy structure.
+
+ Args:
+ tiers: Processed tier list
+
+ Raises:
+ ValueError: If structure is invalid
+ """
+ tier_names = {t["name"] for t in tiers}
+
+ for tier in tiers:
+ # Check dependent tier requirements
+ if tier["tier_type"] == "dependent":
+ if not tier.get("parent_tier"):
+ raise ValueError(
+ f"Dependent tier '{tier['name']}' must have a parent_tier"
+ )
+ if tier["parent_tier"] not in tier_names:
+ raise ValueError(
+ f"Tier '{tier['name']}' references unknown parent '{tier['parent_tier']}'"
+ )
+ if tier["parent_tier"] == tier["name"]:
+ raise ValueError(
+ f"Tier '{tier['name']}' cannot be its own parent"
+ )
+
+ # Check for cycles
+ def has_cycle(tier_name: str, visited: set) -> bool:
+ if tier_name in visited:
+ return True
+ visited.add(tier_name)
+ tier = next((t for t in tiers if t["name"] == tier_name), None)
+ if tier and tier.get("parent_tier"):
+ return has_cycle(tier["parent_tier"], visited)
+ return False
+
+ for tier in tiers:
+ if has_cycle(tier["name"], set()):
+ raise ValueError(f"Cycle detected in tier hierarchy involving '{tier['name']}'")
+
+
+def _generate_tier_rows_html(
+ schema_name: str,
+ tiers: List[Dict[str, Any]],
+ tier_height: int,
+ show_tier_labels: bool,
+ collapsed_tiers: List[str]
+) -> str:
+ """Generate HTML for tier rows."""
+ rows = []
+
+ for tier in tiers:
+ tier_name = tier["name"]
+ is_collapsed = tier_name in collapsed_tiers
+ collapsed_class = "collapsed" if is_collapsed else ""
+ indent_class = "tier-dependent" if tier["tier_type"] == "dependent" else ""
+
+ row_html = f'''
+
+'''
+
+ if show_tier_labels:
+ row_html += f'''
+
+ {escape_html_content(tier_name)}
+
+
+'''
+
+ row_html += f'''
+
+
+
+
+'''
+ rows.append(row_html)
+
+ return "\n".join(rows)
+
+
+def _generate_tier_selector_html(tiers: List[Dict[str, Any]]) -> str:
+ """Generate HTML for tier selector dropdown."""
+ options = []
+ for tier in tiers:
+ indent = " " if tier["tier_type"] == "dependent" else ""
+ tier_type_indicator = " (โ)" if tier["tier_type"] == "dependent" else ""
+ options.append(
+ f''
+ )
+ return "\n".join(options)
+
+
+def _generate_media_element(schema_name: str, media_type: str) -> str:
+ """Generate the media player element (audio or video)."""
+ if media_type == "video":
+ return f'''
+
+'''
+ else:
+ # Audio (default)
+ return f'''
+
+'''
+
+
+def _generate_playback_controls(
+ schema_name: str,
+ playback_rate_control: bool,
+ zoom_enabled: bool
+) -> str:
+ """Generate playback and zoom controls HTML."""
+ controls = []
+
+ if playback_rate_control:
+ controls.append(f'''
+
+
+
+
+''')
+
+ if zoom_enabled:
+ controls.append(f'''
+
+
+
+
+
+''')
+
+ # Time display
+ controls.append(f'''
+
+ 00:00.000
+ /
+ 00:00.000
+
+''')
+
+ return "\n".join(controls)
diff --git a/potato/server_utils/schemas/trajectory_edit.py b/potato/server_utils/schemas/trajectory_edit.py
new file mode 100644
index 0000000000000000000000000000000000000000..105779ae16c7467b3f9ea16709bac757cbf9d8ed
--- /dev/null
+++ b/potato/server_utils/schemas/trajectory_edit.py
@@ -0,0 +1,531 @@
+"""
+Trajectory Correction / Editing Layout
+
+Annotators rewrite the steps of an agent trace (reasoning, tool calls,
+observations) and optionally the final answer, producing a *corrected*
+trajectory alongside the original. The corrected/original pair is exported as
+SFT targets and DPO preference pairs (see
+``potato/export/trajectory_correction_exporter.py``).
+
+This is the editing counterpart to ``trajectory_eval`` (which *scores* steps).
+It reuses ``trajectory_eval``'s data-loading + per-step-card structure and
+``text_edit``'s live word/char diff. Each step shows the original text
+read-only plus an editable textarea pre-filled with the original; a per-step
+"edited" flag is set automatically when the text diverges.
+
+Research / motivation: Labelbox Agent Trajectory Editor; Datadog "edited
+outputs"; SFT/DPO post-training from human-corrected trajectories.
+"""
+
+import json
+import logging
+from typing import Any, Dict, List, Tuple
+
+from .identifier_utils import (
+ safe_generate_layout,
+ generate_element_identifier,
+ generate_validation_attribute,
+ escape_html_content,
+ generate_layout_attributes,
+)
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_EDITABLE_FIELDS = ["action"]
+
+
+def generate_trajectory_edit_layout(
+ annotation_scheme: Dict[str, Any],
+) -> Tuple[str, List[Tuple[str, str]]]:
+ """Generate HTML for a trajectory correction/editing interface.
+
+ Args:
+ annotation_scheme: Configuration dict. Required: ``name``,
+ ``description``. Optional: ``steps_key``, ``step_text_key``,
+ ``editable_fields``, ``show_diff``, ``show_edit_distance``,
+ ``allow_reset``, ``require_reason_on_edit``, ``edit_final_answer``,
+ ``final_answer_key``.
+
+ Returns:
+ ``(html, keybindings)`` tuple.
+ """
+ return safe_generate_layout(annotation_scheme, _generate_internal)
+
+
+def _generate_internal(
+ annotation_scheme: Dict[str, Any],
+) -> Tuple[str, List[Tuple[str, str]]]:
+ schema_name = annotation_scheme["name"]
+ description = annotation_scheme["description"]
+
+ steps_key = annotation_scheme.get("steps_key", "steps")
+ step_text_key = annotation_scheme.get("step_text_key", "action")
+ editable_fields = annotation_scheme.get("editable_fields") or [step_text_key]
+ if not isinstance(editable_fields, list):
+ editable_fields = [step_text_key]
+ show_diff = annotation_scheme.get("show_diff", True)
+ show_edit_distance = annotation_scheme.get("show_edit_distance", True)
+ allow_reset = annotation_scheme.get("allow_reset", True)
+ require_reason_on_edit = annotation_scheme.get("require_reason_on_edit", False)
+ edit_final_answer = annotation_scheme.get("edit_final_answer", False)
+ final_answer_key = annotation_scheme.get("final_answer_key", "final_answer")
+
+ layout_attrs = generate_layout_attributes(annotation_scheme)
+ validation = generate_validation_attribute(annotation_scheme)
+ identifiers = generate_element_identifier(schema_name, schema_name, "hidden")
+
+ config_json = json.dumps({
+ "steps_key": steps_key,
+ "step_text_key": step_text_key,
+ "editable_fields": editable_fields,
+ "show_diff": show_diff,
+ "show_edit_distance": show_edit_distance,
+ "allow_reset": allow_reset,
+ "require_reason_on_edit": require_reason_on_edit,
+ "edit_final_answer": edit_final_answer,
+ "final_answer_key": final_answer_key,
+ })
+
+ esc_schema = escape_html_content(schema_name)
+
+ html = f"""
+
+
+
+ """
+
+ logger.info(f"Generated trajectory edit layout for {schema_name}")
+ return html, []
diff --git a/potato/server_utils/schemas/trajectory_eval.py b/potato/server_utils/schemas/trajectory_eval.py
new file mode 100644
index 0000000000000000000000000000000000000000..eb509f6e479db6774c79c5d865e296aeb30c7c60
--- /dev/null
+++ b/potato/server_utils/schemas/trajectory_eval.py
@@ -0,0 +1,509 @@
+"""
+Trajectory Evaluation Layout
+
+Per-step error marking for agent traces. For each step the annotator marks:
+correctness (correct / incorrect / partially_correct), error type from a
+configurable taxonomy, severity, and a free-text rationale.
+
+Research: TRAIL (Trace Reasoning and Agentic Issue Localization),
+ AgentRewardBench, Anthropic "Demystifying Evals for AI Agents".
+"""
+
+import json
+import logging
+from typing import Dict, Any, Tuple, List
+
+from .identifier_utils import (
+ safe_generate_layout,
+ generate_element_identifier,
+ generate_validation_attribute,
+ escape_html_content,
+ generate_layout_attributes,
+)
+
+logger = logging.getLogger(__name__)
+
+DEFAULT_CORRECTNESS = ["correct", "incorrect", "partially_correct"]
+DEFAULT_SEVERITIES = [
+ {"name": "minor", "weight": -1},
+ {"name": "major", "weight": -5},
+ {"name": "critical", "weight": -10},
+]
+
+
+def generate_trajectory_eval_layout(
+ annotation_scheme: Dict[str, Any],
+) -> Tuple[str, List[Tuple[str, str]]]:
+ """Generate HTML for a trajectory evaluation interface.
+
+ Args:
+ annotation_scheme: Configuration dict. Required keys: ``name``,
+ ``description``. Optional: ``steps_key``, ``step_text_key``,
+ ``correctness_options``, ``error_types``, ``severities``,
+ ``show_score``.
+
+ Returns:
+ ``(html, keybindings)`` tuple.
+ """
+ return safe_generate_layout(annotation_scheme, _generate_internal)
+
+
+def _generate_internal(
+ annotation_scheme: Dict[str, Any],
+) -> Tuple[str, List[Tuple[str, str]]]:
+ schema_name = annotation_scheme["name"]
+ description = annotation_scheme["description"]
+
+ steps_key = annotation_scheme.get("steps_key", "steps")
+ step_text_key = annotation_scheme.get("step_text_key", "action")
+ correctness_options = annotation_scheme.get("correctness_options", DEFAULT_CORRECTNESS)
+ error_types = annotation_scheme.get("error_types", [])
+ severities = annotation_scheme.get("severities", DEFAULT_SEVERITIES)
+ show_score = annotation_scheme.get("show_score", True)
+ max_score = annotation_scheme.get("max_score", 100)
+
+ layout_attrs = generate_layout_attributes(annotation_scheme)
+ validation = generate_validation_attribute(annotation_scheme)
+ identifiers = generate_element_identifier(schema_name, schema_name, "hidden")
+
+ # Serialize config for JS IIFE
+ config_json = json.dumps({
+ "steps_key": steps_key,
+ "step_text_key": step_text_key,
+ "correctness_options": correctness_options,
+ "error_types": error_types,
+ "severities": severities,
+ "show_score": show_score,
+ "max_score": max_score,
+ })
+
+ # Build error type '
+ for et in error_types:
+ subtypes = et.get("subtypes", [])
+ if subtypes:
+ type_options_html += f'"
+ else:
+ type_options_html += (
+ f''
+ )
+
+ # Build severity radio buttons
+ severity_radios_html = ""
+ for sev in severities:
+ severity_radios_html += f"""
+ """
+
+ # Build correctness buttons
+ correctness_btns = ""
+ for opt in correctness_options:
+ label = opt.replace("_", " ").title()
+ css_cls = f"traj-correctness-{opt}"
+ correctness_btns += (
+ f' '
+ )
+
+ score_html = ""
+ if show_score:
+ score_html = f"""
+
+ Score: {max_score} / {max_score}
+
"""
+
+ esc_schema = escape_html_content(schema_name)
+
+ html = f"""
+
+
+
+ """
+
+ logger.info(f"Generated trajectory eval layout for {schema_name}")
+ return html, []
diff --git a/potato/server_utils/schemas/tree_annotation.py b/potato/server_utils/schemas/tree_annotation.py
new file mode 100644
index 0000000000000000000000000000000000000000..5932b38e64e3e12c2c1f1fff1910b6251b4e6182
--- /dev/null
+++ b/potato/server_utils/schemas/tree_annotation.py
@@ -0,0 +1,141 @@
+"""
+Tree Annotation Layout
+
+Generates annotation interface for conversation tree structures.
+Supports per-node annotation, path selection, and branch comparison.
+
+Users can:
+- Annotate individual nodes (e.g., rate each response)
+- Select preferred paths through the tree
+- Compare branches at decision points
+"""
+
+import json
+import logging
+from .identifier_utils import (
+ safe_generate_layout,
+ escape_html_content,
+)
+
+logger = logging.getLogger(__name__)
+
+
+def _generate_tree_annotation_layout_internal(annotation_scheme, horizontal=False):
+ """
+ Internal function to generate tree annotation layout.
+
+ Args:
+ annotation_scheme: Configuration dictionary containing:
+ - name: Schema identifier
+ - description: Description shown to user
+ - node_scheme: Annotation scheme config for per-node annotation
+ (e.g., {annotation_type: "likert", size: 5, ...})
+ - path_selection:
+ - enabled: Whether path selection is enabled
+ - description: Instruction text for path selection
+ - branch_comparison:
+ - enabled: Whether branch comparison is enabled
+
+ Returns:
+ tuple: (HTML string, key bindings list)
+ """
+ scheme_name = annotation_scheme["name"]
+ description = annotation_scheme.get("description", "Annotate the conversation tree")
+ node_scheme = annotation_scheme.get("node_scheme", {})
+ path_selection = annotation_scheme.get("path_selection", {})
+ branch_comparison = annotation_scheme.get("branch_comparison", {})
+
+ path_enabled = path_selection.get("enabled", False)
+ path_desc = path_selection.get("description", "Select the best response path")
+ branch_enabled = branch_comparison.get("enabled", False)
+
+ config_data = json.dumps({
+ "schemaName": scheme_name,
+ "nodeScheme": node_scheme,
+ "pathSelection": {
+ "enabled": path_enabled,
+ "description": path_desc,
+ },
+ "branchComparison": {
+ "enabled": branch_enabled,
+ },
+ })
+
+ # Path selection section
+ path_section = ""
+ if path_enabled:
+ path_section = f"""
+
+
Path Selection
+
{escape_html_content(path_desc)}
+
+ No path selected. Click on nodes in the tree to build a path.
+
+
+
+ """
+
+ # Node annotation mode description
+ node_ann_desc = ""
+ if node_scheme:
+ node_type = node_scheme.get("annotation_type", "")
+ node_ann_desc = f"""
+
+
Click a node in the tree above to annotate it.
+ Node annotation type: {escape_html_content(node_type)}
+
+ """
+
+ schematic = f"""
+
+
+
+
+ {node_ann_desc}
+
+
+
+
+ {path_section}
+
+
+
+
+
+ """
+
+ key_bindings = []
+ return schematic, key_bindings
+
+
+def generate_tree_annotation_layout(annotation_scheme, horizontal=False):
+ """
+ Generate tree annotation layout HTML.
+
+ Args:
+ annotation_scheme (dict): The annotation scheme configuration
+ horizontal (bool): Whether to display horizontally
+
+ Returns:
+ tuple: (HTML string, key bindings list)
+ """
+ return safe_generate_layout(
+ annotation_scheme, _generate_tree_annotation_layout_internal, horizontal
+ )
diff --git a/potato/server_utils/schemas/triage.py b/potato/server_utils/schemas/triage.py
new file mode 100644
index 0000000000000000000000000000000000000000..47a099ce577000e45b8d0c64ad62bf32ccb1b568
--- /dev/null
+++ b/potato/server_utils/schemas/triage.py
@@ -0,0 +1,179 @@
+"""
+Triage Layout
+
+Generates a Prodigy-style binary accept/reject/skip interface for rapid data curation.
+Features include:
+- Three large buttons: Accept (green), Reject (red), Skip (gray)
+- Keyboard shortcuts: a (accept), r (reject), s (skip)
+- Auto-advance to next item option
+- Progress indicator display
+- Minimal UI optimized for rapid decisions
+"""
+
+import logging
+from typing import Dict, Any, Tuple, List
+
+from .identifier_utils import (
+ safe_generate_layout,
+ escape_html_content,
+ generate_layout_attributes,
+)
+
+logger = logging.getLogger(__name__)
+
+# Default labels for triage actions
+DEFAULT_ACCEPT_LABEL = "Keep"
+DEFAULT_REJECT_LABEL = "Discard"
+DEFAULT_SKIP_LABEL = "Unsure"
+
+# Default keyboard shortcuts (1/2/3 are adjacent and easy for rapid annotation)
+DEFAULT_KEYBINDINGS = {
+ "accept": "1",
+ "reject": "2",
+ "skip": "3",
+}
+
+
+def generate_triage_layout(annotation_scheme: Dict[str, Any]) -> Tuple[str, List[Tuple[str, str]]]:
+ """
+ Generate HTML for a triage (accept/reject/skip) interface.
+
+ Args:
+ annotation_scheme (dict): Configuration including:
+ - name: Schema identifier
+ - description: Display description
+ - accept_label: Custom label for accept button (default: "Accept")
+ - reject_label: Custom label for reject button (default: "Reject")
+ - skip_label: Custom label for skip button (default: "Skip")
+ - auto_advance: Whether to auto-advance after selection (default: true)
+ - show_progress: Whether to show progress indicator (default: true)
+ - accept_key: Custom keyboard shortcut for accept (default: "a")
+ - reject_key: Custom keyboard shortcut for reject (default: "r")
+ - skip_key: Custom keyboard shortcut for skip (default: "s")
+
+ Returns:
+ tuple: (html_string, key_bindings)
+ html_string: Complete HTML for the triage interface
+ key_bindings: List of (key, description) tuples for keyboard shortcuts
+ """
+ return safe_generate_layout(annotation_scheme, _generate_triage_layout_internal)
+
+
+def _generate_triage_layout_internal(annotation_scheme: Dict[str, Any]) -> Tuple[str, List[Tuple[str, str]]]:
+ """
+ Internal function to generate triage layout after validation.
+ """
+ logger.debug(f"Generating triage layout for schema: {annotation_scheme['name']}")
+
+ schema_name = annotation_scheme["name"]
+ description = annotation_scheme.get("description", "")
+
+ # Get custom labels or use defaults
+ accept_label = annotation_scheme.get("accept_label", DEFAULT_ACCEPT_LABEL)
+ reject_label = annotation_scheme.get("reject_label", DEFAULT_REJECT_LABEL)
+ skip_label = annotation_scheme.get("skip_label", DEFAULT_SKIP_LABEL)
+
+ # Get keyboard shortcuts
+ accept_key = annotation_scheme.get("accept_key", DEFAULT_KEYBINDINGS["accept"])
+ reject_key = annotation_scheme.get("reject_key", DEFAULT_KEYBINDINGS["reject"])
+ skip_key = annotation_scheme.get("skip_key", DEFAULT_KEYBINDINGS["skip"])
+
+ # Get options
+ auto_advance = annotation_scheme.get("auto_advance", True)
+ show_progress = annotation_scheme.get("show_progress", True)
+
+ # Get layout attributes for grid positioning
+ layout_attrs = generate_layout_attributes(annotation_scheme)
+
+ # Build keybindings list
+ key_bindings = [
+ (accept_key, f"{schema_name}: {accept_label}"),
+ (reject_key, f"{schema_name}: {reject_label}"),
+ (skip_key, f"{schema_name}: {skip_label}"),
+ ]
+
+ # Escape for HTML attributes
+ safe_schema_name = escape_html_content(schema_name)
+ safe_description = escape_html_content(description)
+ safe_accept_label = escape_html_content(accept_label)
+ safe_reject_label = escape_html_content(reject_label)
+ safe_skip_label = escape_html_content(skip_label)
+
+ # Build the HTML
+ html = f"""
+
+ """
+
+ logger.info(f"Successfully generated triage layout for {schema_name}")
+ return html, key_bindings
diff --git a/potato/server_utils/schemas/vas.py b/potato/server_utils/schemas/vas.py
new file mode 100644
index 0000000000000000000000000000000000000000..a04ee2e1f237749cb9751502aad426d5b471dfa6
--- /dev/null
+++ b/potato/server_utils/schemas/vas.py
@@ -0,0 +1,131 @@
+"""
+Visual Analog Scale (VAS) Layout
+
+A continuous line scale with no tick marks or discrete bins. Annotators click/drag
+to a position, returning a precise float value. Psychophysically superior to Likert
+scales for fine-grained judgments.
+
+Research: Stevens (1957) "On the Psychophysical Law"; standard in clinical pain assessment.
+"""
+
+import logging
+
+from .identifier_utils import (
+ safe_generate_layout,
+ generate_element_identifier,
+ generate_validation_attribute,
+ escape_html_content,
+ generate_layout_attributes
+)
+
+
+logger = logging.getLogger(__name__)
+
+# Defaults
+DEFAULT_MIN = 0
+DEFAULT_MAX = 100
+DEFAULT_SHOW_VALUE = False
+
+
+def generate_vas_layout(annotation_scheme):
+ """
+ Generate HTML for a Visual Analog Scale interface.
+
+ Args:
+ annotation_scheme (dict): Configuration including:
+ - name: Schema identifier
+ - description: Display description
+ - left_label: Label for the left endpoint
+ - right_label: Label for the right endpoint
+ - min_value: Minimum value (default 0)
+ - max_value: Maximum value (default 100)
+ - show_value: Whether to show numeric value after selection (default False)
+ - precision: Decimal places to round to (default 1)
+
+ Returns:
+ tuple: (html_string, key_bindings)
+ """
+ return safe_generate_layout(annotation_scheme, _generate_vas_layout_internal)
+
+
+def _generate_vas_layout_internal(annotation_scheme):
+ schema_name = annotation_scheme['name']
+ description = annotation_scheme['description']
+ left_label = annotation_scheme.get('left_label', '')
+ right_label = annotation_scheme.get('right_label', '')
+ min_value = annotation_scheme.get('min_value', DEFAULT_MIN)
+ max_value = annotation_scheme.get('max_value', DEFAULT_MAX)
+ show_value = annotation_scheme.get('show_value', DEFAULT_SHOW_VALUE)
+ precision = annotation_scheme.get('precision', 1)
+
+ layout_attrs = generate_layout_attributes(annotation_scheme)
+ validation = generate_validation_attribute(annotation_scheme)
+ identifiers = generate_element_identifier(schema_name, schema_name, "range")
+
+ # The key difference from slider: a fine precision-based step (vs the
+ # slider's integer ticks), no tick marks, no value display by default,
+ # minimal styling. Step is derived from precision so the scale is
+ # effectively continuous (precision=1 โ step=0.1, precision=2 โ 0.01).
+ step_value = 10 ** -precision if precision > 0 else 1
+ initial_value = round((min_value + max_value) / 2, precision)
+
+ value_display = ""
+ if show_value:
+ value_display = f"""
+
+ โ
+
+ """
+
+ html = f"""
+
+ """
+
+ # Inline JS to round value and update display
+ if show_value:
+ html += f"""
+
+ """
+
+ logger.info(f"Generated VAS layout for {schema_name}")
+ return html, []
diff --git a/potato/server_utils/schemas/video.py b/potato/server_utils/schemas/video.py
new file mode 100644
index 0000000000000000000000000000000000000000..d7d9b18e28ed55e45753c67412b5a1f3157f4237
--- /dev/null
+++ b/potato/server_utils/schemas/video.py
@@ -0,0 +1,225 @@
+"""
+Video Layout
+
+Generates a form interface for displaying video content. Features include:
+- Custom video player controls
+- Autoplay options
+- Loop control
+- Muting options
+- Custom CSS styling
+- Multiple video source support
+- Fallback content support
+"""
+
+import logging
+import os.path
+
+from potato.ai.ai_help_wrapper import get_ai_wrapper, get_dynamic_ai_help
+from .identifier_utils import (
+ safe_generate_layout,
+ escape_html_content,
+ generate_layout_attributes
+)
+
+logger = logging.getLogger(__name__)
+
+def generate_video_layout(annotation_scheme):
+ """
+ Generate HTML for a video player interface.
+
+ Args:
+ annotation_scheme (dict): Configuration including:
+ - name: Schema identifier
+ - description: Display description
+ - video_path: Path to video file
+ - custom_css (dict): Optional CSS styling
+ - width: Video width (default: "320")
+ - height: Video height (default: "240")
+ - autoplay (bool): Whether to start playing automatically
+ - loop (bool): Whether to loop video playback
+ - muted (bool): Whether to mute audio by default
+ - controls (bool): Whether to show player controls
+ - fallback_text (str): Optional text to show if video fails
+ - additional_sources (list): Optional additional video formats
+
+ Returns:
+ tuple: (html_string, key_bindings)
+ html_string: Complete HTML for the video interface
+ key_bindings: Empty list (no keyboard shortcuts)
+
+ Raises:
+ ValueError: If video_path is missing or invalid
+ """
+ return safe_generate_layout(annotation_scheme, _generate_video_layout_internal)
+
+def _is_url(path: str) -> bool:
+ """
+ Check if a path is a URL.
+
+ Args:
+ path: The path to check
+
+ Returns:
+ bool: True if path is a URL, False otherwise
+ """
+ return path.startswith(('http://', 'https://', '//', 'data:'))
+
+
+def _generate_video_layout_internal(annotation_scheme):
+ """
+ Internal function to generate video layout after validation.
+ """
+ logger.debug(f"Generating video layout for schema: {annotation_scheme['name']}")
+
+ # Validate video path
+ if "video_path" not in annotation_scheme:
+ error_msg = f"Missing video_path in schema: {annotation_scheme['name']}"
+ logger.error(error_msg)
+ raise ValueError(error_msg)
+
+ video_path = annotation_scheme["video_path"]
+
+ # Only check file existence for local paths, not URLs
+ if not _is_url(video_path) and not os.path.exists(video_path):
+ # Log a warning but don't fail - the file might be served from a different location
+ logger.warning(f"Video file not found locally: {video_path}. "
+ f"Assuming it will be served from a web-accessible location.")
+
+ # Get layout attributes for grid positioning
+ layout_attrs = generate_layout_attributes(annotation_scheme)
+
+ # Initialize form wrapper
+ schematic = f"""
+ "
+
+ logger.info(f"Successfully generated video layout for {annotation_scheme['name']}")
+ return schematic, []
+
+def _generate_video_attributes(annotation_scheme):
+ """
+ Generate HTML attributes for video element.
+
+ Args:
+ annotation_scheme (dict): Video configuration settings
+
+ Returns:
+ str: Space-separated video attributes
+ """
+ attrs = []
+
+ # Handle playback controls
+ if annotation_scheme.get("controls", True):
+ attrs.append("controls")
+ logger.debug("Enabled video controls")
+
+ if annotation_scheme.get("autoplay"):
+ attrs.append("autoplay")
+ logger.debug("Enabled autoplay")
+
+ if annotation_scheme.get("loop"):
+ attrs.append("loop")
+ logger.debug("Enabled video loop")
+
+ if annotation_scheme.get("muted"):
+ attrs.append("muted")
+ logger.debug("Enabled muted playback")
+
+ return " ".join(attrs)
+
+def _generate_css_style(annotation_scheme):
+ """
+ Generate CSS style string from configuration.
+
+ Args:
+ annotation_scheme (dict): Configuration containing custom_css settings
+
+ Returns:
+ str: Formatted CSS style string
+ """
+ css = annotation_scheme.get("custom_css", {})
+ styles = []
+
+ # Default dimensions if not specified
+ width = css.get("width", "320")
+ height = css.get("height", "240")
+
+ styles.append(f"width: {width}px")
+ styles.append(f"height: {height}px")
+
+ return "; ".join(styles)
+
+def _generate_video_sources(annotation_scheme):
+ """
+ Generate source elements for video formats.
+
+ Args:
+ annotation_scheme (dict): Configuration containing video sources
+
+ Returns:
+ str: HTML for video source elements
+ """
+ sources = []
+
+ # Add main video source
+ mime_type = _get_mime_type(annotation_scheme["video_path"])
+ sources.append(
+ f''
+ )
+ logger.debug(f"Added primary video source: {annotation_scheme['video_path']}")
+
+ # Add additional sources if specified
+ for source in annotation_scheme.get("additional_sources", []):
+ mime_type = _get_mime_type(source)
+ sources.append(f'')
+ logger.debug(f"Added additional video source: {source}")
+
+ return "\n".join(sources)
+
+def _generate_fallback_content(annotation_scheme):
+ """
+ Generate fallback content for browsers that don't support video.
+
+ Args:
+ annotation_scheme (dict): Configuration containing fallback settings
+
+ Returns:
+ str: HTML for fallback content
+ """
+ fallback = annotation_scheme.get("fallback_text", "Your browser does not support the video tag.")
+ logger.debug("Added fallback content for video element")
+ return escape_html_content(fallback)
+
+def _get_mime_type(file_path):
+ """
+ Determine MIME type from video file extension.
+
+ Args:
+ file_path (str): Path to video file
+
+ Returns:
+ str: MIME type string
+ """
+ ext = os.path.splitext(file_path)[1].lower()
+ mime_types = {
+ '.mp4': 'video/mp4',
+ '.webm': 'video/webm',
+ '.ogg': 'video/ogg'
+ }
+ return mime_types.get(ext, 'video/mp4')
\ No newline at end of file
diff --git a/potato/server_utils/schemas/video_annotation.py b/potato/server_utils/schemas/video_annotation.py
new file mode 100644
index 0000000000000000000000000000000000000000..70e0a47fc0f7c00b507dd9300869f5f2bbcf7bac
--- /dev/null
+++ b/potato/server_utils/schemas/video_annotation.py
@@ -0,0 +1,923 @@
+"""
+Video Annotation Layout
+
+Generates a form interface for video annotation with:
+- Temporal segment marking (like audio annotation)
+- Frame-level classification
+- Keyframe annotation
+- Object tracking across frames (basic support)
+
+Uses Peaks.js for timeline visualization with synchronized video playback.
+
+Features:
+- Waveform/timeline visualization from video's audio track
+- Video preview panel with frame counter
+- Segment creation and labeling
+- Frame-by-frame stepping (,/. keys)
+- Multiple playback speeds (0.1x to 2.0x)
+- Keyframe marking (K key)
+"""
+
+import logging
+import json
+from typing import List, Dict, Tuple, Any
+from .identifier_utils import (
+ safe_generate_layout,
+ escape_html_content
+)
+
+logger = logging.getLogger(__name__)
+
+# Default colors for segment/frame labels
+DEFAULT_COLORS = [
+ "#4ECDC4", # Teal
+ "#FF6B6B", # Red
+ "#45B7D1", # Blue
+ "#96CEB4", # Green
+ "#FFEAA7", # Yellow
+ "#DDA0DD", # Plum
+ "#95A5A6", # Gray
+ "#F39C12", # Orange
+ "#9B59B6", # Purple
+ "#3498DB", # Light Blue
+]
+
+# Valid annotation modes
+VALID_MODES = ["segment", "frame", "keyframe", "tracking", "combined"]
+
+
+def generate_video_annotation_layout(annotation_scheme: Dict[str, Any]) -> Tuple[str, List[Tuple[str, str]]]:
+ """
+ Generate HTML for a video annotation interface.
+
+ Args:
+ annotation_scheme (dict): Configuration including:
+ - name: Schema identifier
+ - description: Display description
+ - mode: "segment" | "frame" | "keyframe" | "tracking" | "combined"
+ - labels: List of labels (for segment/frame/keyframe modes)
+ - segment_schemes: List of annotation schemes per segment (optional)
+ - min_segments: Minimum required segments (default: 0)
+ - max_segments: Maximum allowed segments (default: null/unlimited)
+ - timeline_height: Height of timeline in pixels (default: 70)
+ - overview_height: Height of overview bar (default: 40)
+ - zoom_enabled: Whether to enable zoom (default: True)
+ - playback_rate_control: Show playback speed controls (default: True)
+ - frame_stepping: Enable frame-by-frame navigation (default: True)
+ - show_timecode: Show timecode display (default: True)
+ - video_fps: Frames per second for frame calculations (default: 30)
+
+ Returns:
+ tuple: (html_string, key_bindings)
+ html_string: Complete HTML for the video annotation interface
+ key_bindings: List of keyboard shortcuts
+
+ Raises:
+ ValueError: If required fields are missing or invalid
+ """
+ return safe_generate_layout(annotation_scheme, _generate_video_annotation_layout_internal)
+
+
+def _generate_video_annotation_layout_internal(annotation_scheme: Dict[str, Any]) -> Tuple[str, List[Tuple[str, str]]]:
+ """
+ Internal function to generate video annotation layout after validation.
+ """
+ schema_name = annotation_scheme.get('name', 'video_annotation')
+ logger.debug(f"Generating video annotation layout for schema: {schema_name}")
+
+ # Get mode (default to "segment")
+ mode = annotation_scheme.get('mode', 'segment')
+ if mode not in VALID_MODES:
+ error_msg = f"Invalid mode '{mode}' in schema: {schema_name}. Must be one of: {VALID_MODES}"
+ logger.error(error_msg)
+ raise ValueError(error_msg)
+
+ # Validate labels for segment/frame/keyframe/tracking/combined modes
+ labels = []
+ if mode in ['segment', 'frame', 'keyframe', 'tracking', 'combined']:
+ if 'labels' not in annotation_scheme:
+ error_msg = f"Missing labels in schema: {schema_name} (required for mode '{mode}')"
+ logger.error(error_msg)
+ raise ValueError(error_msg)
+ labels = _process_labels(annotation_scheme['labels'])
+
+ # Validate segment_schemes for combined mode
+ segment_schemes = []
+ if mode in ['combined'] and 'segment_schemes' in annotation_scheme:
+ segment_schemes = annotation_scheme['segment_schemes']
+ if not isinstance(segment_schemes, list):
+ error_msg = f"segment_schemes must be a list in schema: {schema_name}"
+ logger.error(error_msg)
+ raise ValueError(error_msg)
+
+ # Get configuration options
+ min_segments = annotation_scheme.get('min_segments', 0)
+ max_segments = annotation_scheme.get('max_segments', None)
+ timeline_height = annotation_scheme.get('timeline_height', 70)
+ overview_height = annotation_scheme.get('overview_height', 40)
+ zoom_enabled = annotation_scheme.get('zoom_enabled', True)
+ playback_rate_control = annotation_scheme.get('playback_rate_control', True)
+ frame_stepping = annotation_scheme.get('frame_stepping', True)
+ show_timecode = annotation_scheme.get('show_timecode', True)
+ video_fps = annotation_scheme.get('video_fps', 30)
+
+ # AI support configuration
+ ai_support = annotation_scheme.get("ai_support", {})
+ ai_enabled = ai_support.get("enabled", False)
+
+ # source_field: Links this annotation schema to a display field from instance_display
+ source_field = annotation_scheme.get("source_field", "")
+
+ # Build config object for JavaScript
+ js_config = {
+ "schemaName": schema_name,
+ "mode": mode,
+ "labels": labels,
+ "segmentSchemes": segment_schemes,
+ "minSegments": min_segments,
+ "maxSegments": max_segments,
+ "timelineHeight": timeline_height,
+ "overviewHeight": overview_height,
+ "zoomEnabled": zoom_enabled,
+ "playbackRateControl": playback_rate_control,
+ "frameStepping": frame_stepping,
+ "showTimecode": show_timecode,
+ "videoFps": video_fps,
+ "aiSupport": ai_enabled,
+ "aiFeatures": ai_support.get("features", {}) if ai_enabled else {},
+ "sourceField": source_field,
+ }
+
+ # Generate HTML
+ html = _generate_html(annotation_scheme, js_config, schema_name, labels, mode, ai_enabled, ai_support)
+
+ # Generate keybindings
+ keybindings = _generate_keybindings(labels, mode, frame_stepping)
+
+ logger.info(f"Successfully generated video annotation layout for {schema_name}")
+ return html, keybindings
+
+
+def _process_labels(labels_config: List) -> List[Dict[str, Any]]:
+ """
+ Process label configuration and assign colors.
+
+ Args:
+ labels_config: List of label configs (strings or dicts)
+
+ Returns:
+ List of processed label dicts with name, color, and optional key_value
+ """
+ processed = []
+ for i, label in enumerate(labels_config):
+ if isinstance(label, str):
+ processed.append({
+ "name": label,
+ "color": DEFAULT_COLORS[i % len(DEFAULT_COLORS)],
+ })
+ elif isinstance(label, dict):
+ processed.append({
+ "name": label.get("name", f"label_{i}"),
+ "color": label.get("color", DEFAULT_COLORS[i % len(DEFAULT_COLORS)]),
+ "key_value": label.get("key_value"),
+ })
+ else:
+ processed.append({
+ "name": str(label),
+ "color": DEFAULT_COLORS[i % len(DEFAULT_COLORS)],
+ })
+ return processed
+
+
+def _generate_html(
+ annotation_scheme: Dict[str, Any],
+ js_config: Dict[str, Any],
+ schema_name: str,
+ labels: List[Dict[str, Any]],
+ mode: str,
+ ai_enabled: bool = False,
+ ai_support: Dict[str, Any] = None
+) -> str:
+ """
+ Generate the HTML for the video annotation interface.
+ """
+ escaped_name = escape_html_content(schema_name)
+ description = escape_html_content(annotation_scheme.get('description', ''))
+ config_json = json.dumps(js_config)
+ timeline_height = js_config.get('timelineHeight', 70)
+ overview_height = js_config.get('overviewHeight', 40)
+
+ # Generate label buttons
+ label_selector = ""
+ if labels:
+ label_selector = _generate_label_selector(labels)
+
+ # Generate AI toolbar if enabled
+ ai_toolbar_html = ""
+ ai_init_script = ""
+ if ai_enabled:
+ ai_features = ai_support.get("features", {}) if ai_support else {}
+ ai_toolbar_html = _generate_video_ai_toolbar(ai_features, mode)
+ ai_init_script = _generate_video_ai_init_script(escaped_name)
+
+ # Generate playback rate control
+ playback_rate_html = ""
+ if js_config.get('playbackRateControl'):
+ playback_rate_html = '''
+
+ Speed:
+
+
+ '''
+
+ # Generate frame stepping controls
+ frame_stepping_html = ""
+ if js_config.get('frameStepping'):
+ frame_stepping_html = '''
+
+
+
+
+ '''
+
+ # Generate mode-specific controls
+ mode_controls_html = ""
+ if mode in ['keyframe', 'combined']:
+ mode_controls_html += '''
+
+
+
+ '''
+ if mode in ['frame', 'combined']:
+ mode_controls_html += '''
+
+
+
+ '''
+ if mode in ['tracking', 'combined']:
+ mode_controls_html += '''
+
+
+
+
+
+ '''
+
+ # Generate timecode display
+ timecode_html = ""
+ if js_config.get('showTimecode'):
+ timecode_html = '''
+
+ Frame: 0
+ 00:00:00.000
+
+ '''
+
+ # source_field attribute for linking to display fields
+ source_field = annotation_scheme.get("source_field", "")
+ source_field_attr = f' data-source-field="{escape_html_content(source_field)}"' if source_field else ""
+
+ html = f'''
+
+ '''
+
+ return html
+
+
+def _generate_video_ai_toolbar(ai_features: Dict[str, Any], mode: str) -> str:
+ """
+ Generate HTML for the video AI assistance toolbar.
+ """
+ scene_detection_enabled = ai_features.get("scene_detection", True)
+ frame_classification_enabled = ai_features.get("frame_classification", False)
+ keyframe_detection_enabled = ai_features.get("keyframe_detection", False)
+ tracking_enabled = ai_features.get("tracking", False)
+ pre_annotate_enabled = ai_features.get("pre_annotate", True)
+ hint_enabled = ai_features.get("hint", True)
+
+ buttons = []
+
+ if scene_detection_enabled and mode in ['segment', 'combined']:
+ buttons.append(
+ ''
+ )
+
+ if pre_annotate_enabled:
+ buttons.append(
+ ''
+ )
+
+ if frame_classification_enabled and mode in ['frame', 'combined']:
+ buttons.append(
+ ''
+ )
+
+ if keyframe_detection_enabled and mode in ['keyframe', 'combined']:
+ buttons.append(
+ ''
+ )
+
+ if tracking_enabled and mode == 'tracking':
+ buttons.append(
+ ''
+ )
+
+ if hint_enabled:
+ buttons.append(
+ ''
+ )
+
+ if not buttons:
+ return ""
+
+ return f'''
+
+
+
+
+ '''
+
+
+def _generate_video_ai_init_script(escaped_name: str) -> str:
+ """
+ Generate JavaScript initialization code for video AI assistant.
+ """
+ return f'''
+ // Initialize AI assistant if enabled and VisualAIAssistantManager is available
+ if (config.aiSupport && typeof VisualAIAssistantManager !== 'undefined') {{
+ var annotationId = Array.from(document.querySelectorAll('.annotation-form')).indexOf(
+ document.getElementById('{escaped_name}')
+ );
+ container.aiAssistant = new VisualAIAssistantManager({{
+ annotationType: 'video_annotation',
+ annotationId: annotationId >= 0 ? annotationId : 0,
+ annotationManager: manager
+ }});
+ }}
+ '''
+
+
+def _generate_label_selector(labels: List[Dict[str, Any]]) -> str:
+ """
+ Generate HTML for label selection buttons.
+ """
+ import html
+ buttons = []
+ for label in labels:
+ # Escape for both content and attributes
+ name_escaped = escape_html_content(label["name"])
+ name_attr = html.escape(label["name"], quote=True)
+ color = label["color"]
+ key_hint = f' ({label["key_value"]})' if label.get("key_value") else ""
+ key_hint_escaped = html.escape(key_hint, quote=True)
+ buttons.append(
+ f''
+ )
+
+ return f'''
+
+ Label:
+ {"".join(buttons)}
+
+ '''
+
+
+def _generate_keybindings(labels: List[Dict[str, Any]], mode: str, frame_stepping: bool) -> List[Tuple[str, str]]:
+ """
+ Generate keybinding list for the schema.
+ """
+ keybindings = []
+
+ # Playback shortcuts
+ keybindings.append(("Space", "Play/Pause"))
+ keybindings.append(("Left/Right", "Seek 5 seconds"))
+
+ # Frame stepping
+ if frame_stepping:
+ keybindings.append((",", "Previous frame"))
+ keybindings.append((".", "Next frame"))
+
+ # Label shortcuts
+ for label in labels:
+ if label.get("key_value"):
+ keybindings.append((label["key_value"], f"Select: {label['name']}"))
+
+ # Segment shortcuts
+ if mode in ['segment', 'combined']:
+ keybindings.append(("[", "Set segment start"))
+ keybindings.append(("]", "Set segment end"))
+ keybindings.append(("Enter", "Create segment"))
+
+ # Keyframe shortcut
+ if mode in ['keyframe', 'combined']:
+ keybindings.append(("K", "Mark keyframe"))
+
+ # Frame classification
+ if mode in ['frame', 'combined']:
+ keybindings.append(("C", "Classify current frame"))
+
+ # Delete shortcut
+ keybindings.append(("Del", "Delete selected"))
+
+ # Zoom shortcuts
+ keybindings.append(("+/-", "Zoom in/out"))
+ keybindings.append(("0", "Fit to view"))
+
+ return keybindings
diff --git a/potato/server_utils/triage.py b/potato/server_utils/triage.py
new file mode 100644
index 0000000000000000000000000000000000000000..41690990346e0cfcdbd4df8e8863d499e7ac56d9
--- /dev/null
+++ b/potato/server_utils/triage.py
@@ -0,0 +1,270 @@
+"""
+Signal-based triage scoring for the annotation queue.
+
+Captures a per-item quality signal โ an agent error, a production thumbs-down, a
+low automated score, or any custom field โ and turns it into a numeric
+*triage priority* so the assignment queue can surface the worst / most-suspect
+traces first instead of annotating in arrival (FIFO) order.
+
+The same scorer runs over statically loaded data and over traces ingested at
+runtime (webhook / Langfuse), because both funnel through
+``ItemStateManager.add_item``. The priority is stored on the item's metadata
+(``triage_priority`` / ``triage_reason`` / ``triage_rule``); the ``priority``
+assignment strategy reads it; the inline badge and the admin queue page surface it.
+
+Config (all optional):
+
+ triage:
+ enabled: true
+ order: desc # high priority first (default); 'asc' = low first
+ default_priority: 0 # items matching no rule
+ show_badge: true # show a "why prioritized" banner during annotation
+ signal_field: null # read a numeric priority straight from this field
+ invert_signal: false # if true, a LOWER field value => HIGHER priority
+ rules: # evaluated in order; highest matching priority wins
+ - name: "Agent errored"
+ when: {field: "status", equals: "error"}
+ priority: 100
+ badge: "Agent errored"
+ - name: "Negative feedback"
+ when: {field: "feedback", in: ["thumbs_down", "negative"]}
+ priority: 80
+ - name: "Low score"
+ when: {field: "score", lt: 0.5}
+ priority: 60
+
+When ``enabled`` with no ``rules`` and no ``signal_field``, a turnkey set of
+built-in defaults is used (error status, negative feedback, low score) so
+ingested traces are triaged out of the box.
+"""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+
+logger = logging.getLogger(__name__)
+
+
+# Turnkey defaults applied when triage is enabled but no rules/signal_field are
+# configured. These match the signals ingested traces most commonly carry.
+DEFAULT_RULES = [
+ {"name": "Agent errored", "badge": "Agent errored", "priority": 100,
+ "when": {"field": "status", "in": ["error", "failed", "failure"]}},
+ {"name": "Negative feedback", "badge": "Negative feedback", "priority": 80,
+ "when": {"field": "feedback", "in": ["thumbs_down", "negative", "down", "๐"]}},
+ {"name": "Low score", "badge": "Low score", "priority": 60,
+ "when": {"field": "score", "lt": 0.5}},
+]
+
+
+@dataclass
+class TriageScore:
+ """The triage outcome for one item."""
+ priority: float
+ reason: str | None = None # human-readable badge text (None when unflagged)
+ rule: str | None = None # the rule name that matched (None for default/field)
+
+ def to_metadata(self) -> dict:
+ return {
+ "triage_priority": self.priority,
+ "triage_reason": self.reason,
+ "triage_rule": self.rule,
+ }
+
+
+def _lookup(data: dict, field: str):
+ """Resolve a possibly dotted field path against an item dict.
+
+ Supports nested dicts (``metadata.score``). Returns None if any segment is
+ missing or a non-dict is traversed.
+ """
+ cur = data
+ for part in str(field).split("."):
+ if not isinstance(cur, dict) or part not in cur:
+ return None
+ cur = cur[part]
+ return cur
+
+
+def _as_number(value):
+ """Coerce a value to float, or None if it isn't numeric."""
+ if isinstance(value, bool):
+ return None
+ if isinstance(value, (int, float)):
+ return float(value)
+ if isinstance(value, str):
+ try:
+ return float(value.strip())
+ except (ValueError, AttributeError):
+ return None
+ return None
+
+
+def _matches(condition: dict, data: dict) -> bool:
+ """Evaluate a single rule's ``when`` condition against item data.
+
+ Supported operators: equals, in, lt, lte, gt, gte, exists, contains.
+ String comparisons for equals/in are case-insensitive. Numeric comparisons
+ coerce both sides. ``contains`` tests membership in a list/string field.
+ """
+ field = condition.get("field")
+ if field is None:
+ return False
+ value = _lookup(data, field)
+
+ if "exists" in condition:
+ present = value is not None
+ return present == bool(condition["exists"])
+
+ # Absent fields never match value-based operators.
+ if value is None:
+ return False
+
+ if "equals" in condition:
+ target = condition["equals"]
+ if isinstance(value, str) and isinstance(target, str):
+ return value.strip().lower() == target.strip().lower()
+ return value == target
+
+ if "in" in condition:
+ options = condition["in"] or []
+ norm = [o.lower() if isinstance(o, str) else o for o in options]
+ v = value.lower() if isinstance(value, str) else value
+ return v in norm
+
+ if "contains" in condition:
+ target = condition["contains"]
+ if isinstance(value, (list, tuple, set)):
+ tnorm = target.lower() if isinstance(target, str) else target
+ return any(
+ (item.lower() if isinstance(item, str) else item) == tnorm
+ for item in value
+ )
+ if isinstance(value, str) and isinstance(target, str):
+ return target.lower() in value.lower()
+ return False
+
+ for op, py in (("lt", "<"), ("lte", "<="), ("gt", ">"), ("gte", ">=")):
+ if op in condition:
+ lhs, rhs = _as_number(value), _as_number(condition[op])
+ if lhs is None or rhs is None:
+ return False
+ if op == "lt":
+ return lhs < rhs
+ if op == "lte":
+ return lhs <= rhs
+ if op == "gt":
+ return lhs > rhs
+ return lhs >= rhs
+
+ return False
+
+
+class TriageScorer:
+ """Scores items into a triage priority from the ``triage`` config block."""
+
+ def __init__(self, triage_config: dict):
+ cfg = triage_config or {}
+ self.enabled = bool(cfg.get("enabled", False))
+ self.order = str(cfg.get("order", "desc")).lower()
+ self.default_priority = float(cfg.get("default_priority", 0) or 0)
+ self.show_badge = bool(cfg.get("show_badge", True))
+ self.signal_field = cfg.get("signal_field")
+ self.invert_signal = bool(cfg.get("invert_signal", False))
+
+ rules = cfg.get("rules")
+ if not rules and not self.signal_field:
+ rules = DEFAULT_RULES
+ self.rules = rules or []
+
+ def score(self, item_data: dict) -> TriageScore:
+ """Return the TriageScore for one item (highest matching rule wins)."""
+ if not self.enabled:
+ return TriageScore(priority=self.default_priority)
+
+ best: TriageScore | None = None
+ for rule in self.rules:
+ cond = rule.get("when") or {}
+ try:
+ if _matches(cond, item_data):
+ pr = float(rule.get("priority", 0) or 0)
+ if best is None or pr > best.priority:
+ badge = rule.get("badge") or rule.get("name")
+ best = TriageScore(priority=pr, reason=badge, rule=rule.get("name"))
+ except Exception as e: # a malformed rule must never break loading
+ logger.warning(f"Triage rule {rule.get('name')!r} failed: {e}")
+
+ if best is not None:
+ return best
+
+ # No rule matched: optionally read a direct numeric signal.
+ if self.signal_field is not None:
+ raw = _as_number(_lookup(item_data, self.signal_field))
+ if raw is not None:
+ pr = -raw if self.invert_signal else raw
+ return TriageScore(priority=pr, reason=None, rule=None)
+
+ return TriageScore(priority=self.default_priority)
+
+
+def build_scorer(config: dict) -> TriageScorer | None:
+ """Build a TriageScorer from a server config, or None when triage is off."""
+ triage_cfg = (config or {}).get("triage") or {}
+ if not triage_cfg.get("enabled"):
+ return None
+ return TriageScorer(triage_cfg)
+
+
+def compute_triage_queue(config: dict) -> dict:
+ """Build the admin triage-queue report from the live ItemStateManager.
+
+ Returns the remaining (incomplete) items ranked by triage priority, with the
+ reason/rule that flagged them, current annotation count, and whether they are
+ already assigned. Used by the ``/admin/triage-queue`` page.
+ """
+ from potato.item_state_management import get_item_state_manager
+
+ scorer = build_scorer(config)
+ order = (scorer.order if scorer else "desc")
+ reverse = order != "asc"
+
+ ism = get_item_state_manager()
+ rows = []
+ # Preserve the configured/global ordering as the deterministic tie-break.
+ ordering = {iid: i for i, iid in enumerate(ism.instance_id_ordering)}
+ for iid in ism.instance_id_ordering:
+ item = ism.get_item(iid)
+ if item is None:
+ continue
+ # Skip items that have reached their annotation cap.
+ try:
+ if ism._item_is_saturated(iid):
+ continue
+ except Exception:
+ pass
+ priority = item.get_metadata("triage_priority")
+ if priority is None:
+ priority = scorer.default_priority if scorer else 0
+ n_ann = len(ism.instance_annotators.get(iid, set()))
+ rows.append({
+ "id": iid,
+ "priority": priority,
+ "reason": item.get_metadata("triage_reason"),
+ "rule": item.get_metadata("triage_rule"),
+ "annotations": n_ann,
+ "assigned": n_ann > 0,
+ "_order": ordering.get(iid, 0),
+ })
+
+ rows.sort(key=lambda r: (r["priority"], -r["_order"]), reverse=reverse)
+ for r in rows:
+ r.pop("_order", None)
+
+ return {
+ "enabled": bool(scorer),
+ "order": order,
+ "n_items": len(rows),
+ "n_flagged": sum(1 for r in rows if r["reason"]),
+ "items": rows,
+ }
diff --git a/potato/server_utils/waveform_service.py b/potato/server_utils/waveform_service.py
new file mode 100644
index 0000000000000000000000000000000000000000..db84c5ffb93237dad5500f6a0104b330550b2997
--- /dev/null
+++ b/potato/server_utils/waveform_service.py
@@ -0,0 +1,546 @@
+"""
+Waveform Service
+
+Handles generation and caching of audio waveform data for the audio annotation feature.
+Uses BBC's audiowaveform tool to generate pre-computed waveform data files.
+
+Features:
+- LRU cache for waveform files
+- Background look-ahead pre-computation for upcoming instances
+- Support for both local files and URLs
+- Graceful fallback if audiowaveform not installed
+"""
+
+import os
+import logging
+import hashlib
+import subprocess
+import shutil
+import tempfile
+import threading
+import time
+from typing import Optional, List, Dict
+from collections import OrderedDict
+from urllib.parse import urlparse
+from pathlib import Path
+
+try:
+ import requests
+ REQUESTS_AVAILABLE = True
+except ImportError:
+ REQUESTS_AVAILABLE = False
+
+logger = logging.getLogger(__name__)
+
+
+class WaveformService:
+ """
+ Service for generating and caching audio waveform data.
+
+ Uses BBC's audiowaveform tool to generate pre-computed waveform data
+ that can be efficiently rendered by Peaks.js on the frontend.
+ """
+
+ # Default configuration
+ DEFAULT_LOOK_AHEAD = 5
+ DEFAULT_CACHE_MAX_SIZE = 100
+ DEFAULT_CLIENT_FALLBACK_MAX_DURATION = 1800 # 30 minutes in seconds
+
+ # Waveform generation settings
+ WAVEFORM_ZOOM_LEVEL = 256 # Samples per pixel
+ WAVEFORM_BITS = 8 # 8-bit resolution
+
+ def __init__(
+ self,
+ cache_dir: str,
+ look_ahead: int = DEFAULT_LOOK_AHEAD,
+ cache_max_size: int = DEFAULT_CACHE_MAX_SIZE,
+ client_fallback_max_duration: int = DEFAULT_CLIENT_FALLBACK_MAX_DURATION
+ ):
+ """
+ Initialize the WaveformService.
+
+ Args:
+ cache_dir: Directory to store generated waveform files
+ look_ahead: Number of instances to pre-compute ahead
+ cache_max_size: Maximum number of cached waveform files
+ client_fallback_max_duration: Max duration (seconds) for client-side fallback
+ """
+ self.cache_dir = cache_dir
+ self.look_ahead = look_ahead
+ self.cache_max_size = cache_max_size
+ self.client_fallback_max_duration = client_fallback_max_duration
+
+ # LRU cache tracking
+ self._cache_order: OrderedDict = OrderedDict()
+ self._cache_lock = threading.Lock()
+
+ # Background pre-computation
+ self._precompute_thread: Optional[threading.Thread] = None
+ self._precompute_queue: List[str] = []
+ self._precompute_lock = threading.Lock()
+ self._stop_precompute = threading.Event()
+
+ # Check if audiowaveform is installed
+ self._audiowaveform_available = self._check_audiowaveform_installed()
+
+ # Ensure cache directory exists
+ self._ensure_cache_dir()
+
+ logger.info(f"WaveformService initialized: cache_dir={cache_dir}, "
+ f"look_ahead={look_ahead}, audiowaveform_available={self._audiowaveform_available}")
+
+ def _ensure_cache_dir(self) -> None:
+ """Create the cache directory if it doesn't exist."""
+ if not os.path.exists(self.cache_dir):
+ os.makedirs(self.cache_dir, exist_ok=True)
+ logger.info(f"Created waveform cache directory: {self.cache_dir}")
+
+ def _check_audiowaveform_installed(self) -> bool:
+ """
+ Check if the audiowaveform tool is installed and available.
+
+ Returns:
+ True if audiowaveform is available, False otherwise
+ """
+ try:
+ result = subprocess.run(
+ ['audiowaveform', '--version'],
+ capture_output=True,
+ text=True,
+ timeout=5
+ )
+ if result.returncode == 0:
+ version = result.stdout.strip() or result.stderr.strip()
+ logger.info(f"audiowaveform found: {version}")
+ return True
+ except (subprocess.SubprocessError, FileNotFoundError, OSError) as e:
+ logger.warning(f"audiowaveform not available: {e}")
+
+ return False
+
+ @property
+ def is_available(self) -> bool:
+ """Check if waveform generation is available."""
+ return self._audiowaveform_available
+
+ def _get_cache_key(self, audio_path: str) -> str:
+ """
+ Generate a unique cache key for an audio file.
+
+ Args:
+ audio_path: Path or URL to the audio file
+
+ Returns:
+ MD5 hash of the path as cache key
+ """
+ return hashlib.md5(audio_path.encode('utf-8')).hexdigest()
+
+ def _get_waveform_cache_path(self, audio_path: str) -> str:
+ """
+ Get the cache file path for a waveform.
+
+ Args:
+ audio_path: Path or URL to the audio file
+
+ Returns:
+ Full path to the waveform cache file
+ """
+ cache_key = self._get_cache_key(audio_path)
+ return os.path.join(self.cache_dir, f"{cache_key}.dat")
+
+ def _is_url(self, path: str) -> bool:
+ """
+ Check if a path is a URL.
+
+ Args:
+ path: The path to check
+
+ Returns:
+ True if path is a URL, False otherwise
+ """
+ return path.startswith(('http://', 'https://', '//'))
+
+ def _download_audio(self, url: str) -> Optional[str]:
+ """
+ Download an audio file from URL to a temporary file.
+
+ Args:
+ url: URL of the audio file
+
+ Returns:
+ Path to temporary file, or None if download failed
+ """
+ if not REQUESTS_AVAILABLE:
+ logger.error("requests library not available for downloading audio")
+ return None
+
+ try:
+ # Determine file extension from URL
+ parsed = urlparse(url)
+ path = parsed.path
+ ext = os.path.splitext(path)[1] or '.mp3'
+
+ # Create temporary file
+ temp_fd, temp_path = tempfile.mkstemp(suffix=ext)
+ os.close(temp_fd)
+
+ logger.debug(f"Downloading audio from {url} to {temp_path}")
+
+ response = requests.get(url, stream=True, timeout=60)
+ response.raise_for_status()
+
+ with open(temp_path, 'wb') as f:
+ for chunk in response.iter_content(chunk_size=8192):
+ f.write(chunk)
+
+ logger.debug(f"Downloaded audio: {os.path.getsize(temp_path)} bytes")
+ return temp_path
+
+ except Exception as e:
+ logger.error(f"Failed to download audio from {url}: {e}")
+ return None
+
+ def _generate_waveform(self, audio_path: str, output_path: str) -> bool:
+ """
+ Generate waveform data using audiowaveform tool.
+
+ Args:
+ audio_path: Path to the audio file (local)
+ output_path: Path to write the waveform data file
+
+ Returns:
+ True if generation succeeded, False otherwise
+ """
+ if not self._audiowaveform_available:
+ logger.warning("audiowaveform not available, cannot generate waveform")
+ return False
+
+ try:
+ # Build command
+ cmd = [
+ 'audiowaveform',
+ '-i', audio_path,
+ '-o', output_path,
+ '-z', str(self.WAVEFORM_ZOOM_LEVEL),
+ '-b', str(self.WAVEFORM_BITS),
+ ]
+
+ logger.debug(f"Running: {' '.join(cmd)}")
+
+ result = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ timeout=300 # 5 minute timeout for long files
+ )
+
+ if result.returncode == 0:
+ logger.info(f"Generated waveform: {output_path}")
+ return True
+ else:
+ logger.error(f"audiowaveform failed: {result.stderr}")
+ return False
+
+ except subprocess.TimeoutExpired:
+ logger.error(f"audiowaveform timed out for {audio_path}")
+ return False
+ except Exception as e:
+ logger.error(f"Error generating waveform for {audio_path}: {e}")
+ return False
+
+ def _update_cache_order(self, cache_path: str) -> None:
+ """
+ Update LRU cache order and evict if necessary.
+
+ Args:
+ cache_path: Path to the cache file being accessed
+ """
+ with self._cache_lock:
+ # Move to end (most recently used)
+ if cache_path in self._cache_order:
+ self._cache_order.move_to_end(cache_path)
+ else:
+ self._cache_order[cache_path] = True
+
+ # Evict oldest if over limit
+ while len(self._cache_order) > self.cache_max_size:
+ oldest_path, _ = self._cache_order.popitem(last=False)
+ if os.path.exists(oldest_path):
+ try:
+ os.remove(oldest_path)
+ logger.debug(f"Evicted from cache: {oldest_path}")
+ except OSError as e:
+ logger.warning(f"Failed to remove cache file {oldest_path}: {e}")
+
+ def get_waveform_path(self, audio_path: str, generate: bool = True) -> Optional[str]:
+ """
+ Get the waveform data file path for an audio file.
+
+ If the waveform doesn't exist and generate=True, it will be generated.
+
+ Args:
+ audio_path: Path or URL to the audio file
+ generate: Whether to generate if not cached
+
+ Returns:
+ Path to waveform data file, or None if not available
+ """
+ cache_path = self._get_waveform_cache_path(audio_path)
+
+ # Check if already cached
+ if os.path.exists(cache_path):
+ self._update_cache_order(cache_path)
+ logger.debug(f"Waveform cache hit: {cache_path}")
+ return cache_path
+
+ if not generate:
+ return None
+
+ # Generate waveform
+ temp_audio = None
+ try:
+ # Handle URL vs local path
+ if self._is_url(audio_path):
+ temp_audio = self._download_audio(audio_path)
+ if not temp_audio:
+ return None
+ local_path = temp_audio
+ else:
+ local_path = audio_path
+ if not os.path.exists(local_path):
+ logger.warning(f"Audio file not found: {local_path}")
+ return None
+
+ # Generate waveform
+ if self._generate_waveform(local_path, cache_path):
+ self._update_cache_order(cache_path)
+ return cache_path
+ else:
+ return None
+
+ finally:
+ # Clean up temporary file
+ if temp_audio and os.path.exists(temp_audio):
+ try:
+ os.remove(temp_audio)
+ except OSError:
+ pass
+
+ def get_waveform_url(self, audio_path: str, base_url: str = '/api/waveform/') -> Optional[str]:
+ """
+ Get the URL to fetch waveform data for an audio file.
+
+ Args:
+ audio_path: Path or URL to the audio file
+ base_url: Base URL for the waveform API endpoint
+
+ Returns:
+ URL to fetch waveform data
+ """
+ cache_key = self._get_cache_key(audio_path)
+ return f"{base_url}{cache_key}"
+
+ def precompute_batch(self, audio_paths: List[str]) -> None:
+ """
+ Pre-compute waveforms for a batch of audio files.
+
+ This is called synchronously and blocks until all are complete.
+ Use start_background_precompute for non-blocking operation.
+
+ Args:
+ audio_paths: List of audio file paths or URLs
+ """
+ for audio_path in audio_paths:
+ if audio_path:
+ self.get_waveform_path(audio_path, generate=True)
+
+ def queue_precompute(self, audio_paths: List[str]) -> None:
+ """
+ Add audio files to the background pre-computation queue.
+
+ Args:
+ audio_paths: List of audio file paths or URLs to pre-compute
+ """
+ with self._precompute_lock:
+ # Only add paths not already in queue or cached
+ for path in audio_paths:
+ if path and path not in self._precompute_queue:
+ cache_path = self._get_waveform_cache_path(path)
+ if not os.path.exists(cache_path):
+ self._precompute_queue.append(path)
+
+ # Start background thread if not running
+ if self._precompute_thread is None or not self._precompute_thread.is_alive():
+ self._start_background_precompute()
+
+ def _start_background_precompute(self) -> None:
+ """Start the background pre-computation thread."""
+ self._stop_precompute.clear()
+ self._precompute_thread = threading.Thread(
+ target=self._background_precompute_worker,
+ daemon=True
+ )
+ self._precompute_thread.start()
+ logger.debug("Started background waveform pre-computation thread")
+
+ def _background_precompute_worker(self) -> None:
+ """Background worker for pre-computing waveforms."""
+ while not self._stop_precompute.is_set():
+ # Get next item from queue
+ audio_path = None
+ with self._precompute_lock:
+ if self._precompute_queue:
+ audio_path = self._precompute_queue.pop(0)
+
+ if audio_path:
+ logger.debug(f"Background pre-computing waveform for: {audio_path}")
+ self.get_waveform_path(audio_path, generate=True)
+ else:
+ # No more items, exit thread
+ break
+
+ # Small delay between items to avoid overloading
+ time.sleep(0.1)
+
+ logger.debug("Background waveform pre-computation thread finished")
+
+ def stop_background_precompute(self) -> None:
+ """Stop the background pre-computation thread."""
+ self._stop_precompute.set()
+ if self._precompute_thread and self._precompute_thread.is_alive():
+ self._precompute_thread.join(timeout=5)
+
+ def get_audio_duration(self, audio_path: str) -> Optional[float]:
+ """
+ Get the duration of an audio file in seconds.
+
+ Uses ffprobe if available, otherwise returns None.
+
+ Args:
+ audio_path: Path to the audio file
+
+ Returns:
+ Duration in seconds, or None if cannot determine
+ """
+ try:
+ result = subprocess.run(
+ [
+ 'ffprobe',
+ '-v', 'error',
+ '-show_entries', 'format=duration',
+ '-of', 'default=noprint_wrappers=1:nokey=1',
+ audio_path
+ ],
+ capture_output=True,
+ text=True,
+ timeout=10
+ )
+ if result.returncode == 0:
+ return float(result.stdout.strip())
+ except (subprocess.SubprocessError, ValueError, FileNotFoundError):
+ pass
+
+ return None
+
+ def should_use_client_fallback(self, audio_path: str) -> bool:
+ """
+ Determine if client-side waveform generation should be used.
+
+ Client-side is preferred for short files when server-side is not available.
+
+ Args:
+ audio_path: Path to the audio file
+
+ Returns:
+ True if client-side fallback should be used
+ """
+ if self._audiowaveform_available:
+ return False
+
+ duration = self.get_audio_duration(audio_path)
+ if duration is not None and duration <= self.client_fallback_max_duration:
+ return True
+
+ return False
+
+ def clear_cache(self) -> int:
+ """
+ Clear all cached waveform files.
+
+ Returns:
+ Number of files removed
+ """
+ count = 0
+ with self._cache_lock:
+ for cache_path in list(self._cache_order.keys()):
+ if os.path.exists(cache_path):
+ try:
+ os.remove(cache_path)
+ count += 1
+ except OSError as e:
+ logger.warning(f"Failed to remove {cache_path}: {e}")
+ self._cache_order.clear()
+
+ logger.info(f"Cleared {count} cached waveform files")
+ return count
+
+ def get_cache_stats(self) -> Dict:
+ """
+ Get statistics about the waveform cache.
+
+ Returns:
+ Dictionary with cache statistics
+ """
+ with self._cache_lock:
+ cached_files = len(self._cache_order)
+ total_size = 0
+ for cache_path in self._cache_order.keys():
+ if os.path.exists(cache_path):
+ total_size += os.path.getsize(cache_path)
+
+ return {
+ 'cached_files': cached_files,
+ 'max_files': self.cache_max_size,
+ 'total_size_bytes': total_size,
+ 'total_size_mb': round(total_size / (1024 * 1024), 2),
+ 'cache_dir': self.cache_dir,
+ 'audiowaveform_available': self._audiowaveform_available,
+ }
+
+
+# Global instance (initialized when needed)
+_waveform_service: Optional[WaveformService] = None
+
+
+def get_waveform_service() -> Optional[WaveformService]:
+ """Get the global WaveformService instance."""
+ return _waveform_service
+
+
+def init_waveform_service(
+ cache_dir: str,
+ look_ahead: int = WaveformService.DEFAULT_LOOK_AHEAD,
+ cache_max_size: int = WaveformService.DEFAULT_CACHE_MAX_SIZE,
+ client_fallback_max_duration: int = WaveformService.DEFAULT_CLIENT_FALLBACK_MAX_DURATION
+) -> WaveformService:
+ """
+ Initialize the global WaveformService instance.
+
+ Args:
+ cache_dir: Directory to store generated waveform files
+ look_ahead: Number of instances to pre-compute ahead
+ cache_max_size: Maximum number of cached waveform files
+ client_fallback_max_duration: Max duration for client-side fallback
+
+ Returns:
+ The initialized WaveformService instance
+ """
+ global _waveform_service
+ _waveform_service = WaveformService(
+ cache_dir=cache_dir,
+ look_ahead=look_ahead,
+ cache_max_size=cache_max_size,
+ client_fallback_max_duration=client_fallback_max_duration
+ )
+ return _waveform_service
diff --git a/potato/setup_multilingual_config.py b/potato/setup_multilingual_config.py
new file mode 100644
index 0000000000000000000000000000000000000000..693f1f167eefbdce6c65fdc9a59928a96b7a2c36
--- /dev/null
+++ b/potato/setup_multilingual_config.py
@@ -0,0 +1,167 @@
+"""
+Multilingual Configuration Setup Module
+
+This module provides functionality for setting up multilingual annotation tasks.
+It processes a base configuration and creates language-specific configurations
+for each supported language, including:
+
+- Language-specific survey flow files
+- Translated configuration files
+- Localized output directories
+- Multilingual guideline integration
+
+The module supports dynamic text replacement using key-based translation mappings
+and creates the necessary directory structure for multilingual annotation projects.
+"""
+
+from argparse import ArgumentParser
+import yaml
+import os
+import pandas as pd
+import json
+from collections import defaultdict
+
+
+def arguments():
+ """
+ Creates and returns the argument parser for multilingual configuration setup.
+
+ Returns:
+ ArgumentParser: Configured argument parser with multilingual_config_file argument
+ """
+ parser = ArgumentParser()
+ parser.set_defaults(show_path=False, show_similarity=False)
+
+ parser.add_argument("multilingual_config_file")
+
+ return parser.parse_args()
+
+
+def main():
+ """
+ Main function for setting up multilingual annotation configurations.
+
+ This function processes a multilingual configuration file and creates
+ language-specific configurations for each supported language. It handles:
+
+ 1. Directory structure creation
+ 2. Translation mapping from guideline files
+ 3. Configuration file generation for each language
+ 4. Survey flow file localization
+ 5. Output directory setup
+
+ Side Effects:
+ - Creates directories for surveyflow, annotation_output, data_files, configs, htmls
+ - Generates language-specific configuration files
+ - Creates localized survey flow files
+ - Sets up language-specific output directories
+ """
+ args = arguments()
+
+ # Load multilingual annotation configuration
+ with open(args.multilingual_config_file, "rt") as f:
+ multilingual_config = yaml.safe_load(f)
+
+ # Create basic folder structure for the multilingual project
+ for folder in ["surveyflow", "annotation_output", "data_files", "configs", "htmls"]:
+ cur_path = multilingual_config["base_dir"] + folder
+ if not os.path.exists(cur_path):
+ os.makedirs(cur_path)
+ print("Created directory: %s" % (cur_path))
+
+ # Build translation mapping dictionary
+ # This maps translation keys to their language-specific text values
+ key2text = defaultdict(dict)
+
+ # Load multilingual annotation guidelines if specified and available
+ if "multilingual_guideline_file" in multilingual_config and os.path.exists(
+ multilingual_config["multilingual_guideline_file"]
+ ):
+ # Load multilingual annotation guideline from CSV
+ multilingual_guideline_df = pd.read_csv(multilingual_config["multilingual_guideline_file"])
+ for i, row in multilingual_guideline_df.iterrows():
+ # Only process rows with proper key format (enclosed in brackets)
+ if type(row["key"]) != str or row["key"][0] != "[" or row["key"][-1] != "]":
+ continue
+ # Create translation mapping for each supported language
+ for lang in multilingual_config["languages"]:
+ key2text[row["key"]][lang] = (
+ row[lang]
+ if type(row[lang]) == str
+ else row[multilingual_config["base_language"]]
+ )
+
+ # Generate configuration for each supported language
+ for lang in multilingual_config["languages"]:
+ # Load and process the base configuration file
+ with open(multilingual_config["base_config_file"], "rt") as f:
+ page = f.read()
+ # Replace translation keys with language-specific text
+ for key in key2text:
+ page = page.replace(key, key2text[key][lang])
+
+ # Update surveyflow output path for this language
+ surveyflow_output_path = multilingual_config["surveyflow_output_path"].replace(
+ "[LANGUAGE]", lang
+ )
+ page = page.replace(
+ multilingual_config["surveyflow_path"], surveyflow_output_path + lang + "-"
+ )
+ page = page.replace("[LANGUAGE]", lang)
+
+ # Parse the processed configuration
+ config = yaml.safe_load(page)
+
+ # Set up language-specific output directory
+ config["output_annotation_dir"] = multilingual_config["output_annotation_dir"].replace(
+ "[LANGUAGE]", lang
+ )
+ if not os.path.exists(config["output_annotation_dir"]):
+ os.makedirs(config["output_annotation_dir"])
+
+ """
+ # setup the site_dir path for each language
+ config['path_under_site_dir'] = multilingual_config['path_under_site_dir'].replace("[LANGUAGE]", lang)
+ config["site_dir"] += config['path_under_site_dir']
+ if not os.path.exists(config["site_dir"]):
+ os.makedirs(config["site_dir"])
+ """
+
+ # Update task name and data files for this language
+ config["annotation_task_name"] = multilingual_config["annotation_task_name"].replace(
+ "[LANGUAGE]", lang
+ )
+ config["data_files"] = [
+ it.replace("[LANGUAGE]", lang) for it in multilingual_config["data_files"]
+ ]
+
+ # Save the language-specific configuration file
+ with open(multilingual_config["base_dir"] + "configs/%s.yaml" % lang, "wt") as f:
+ json.dump(config, f, indent=4)
+
+ # Create the directory for surveyflow output path
+ if not os.path.exists(surveyflow_output_path):
+ os.makedirs(surveyflow_output_path)
+
+ # Process and localize surveyflow files
+ surveyflow_files = os.listdir(multilingual_config["surveyflow_path"])
+ for file in surveyflow_files:
+ # Skip directories, only process files
+ if os.path.isdir(multilingual_config["surveyflow_path"] + file):
+ continue
+ # Read the surveyflow file
+ with open(multilingual_config["surveyflow_path"] + file, "r", encoding="utf-8") as f:
+ page = f.read()
+ # Replace translation keys with language-specific text
+ for key in key2text:
+ page = page.replace(key, key2text[key][lang])
+ page = page.replace("[LANGUAGE]", lang)
+ # Write the localized surveyflow file
+ with open(surveyflow_output_path + lang + "-" + file, "wt", encoding="utf-8") as f:
+ f.write(page)
+
+ # for key in ["surveyflow_output_path", ]
+
+
+if __name__ == "__main__":
+ main()
diff --git a/potato/setup_multitask_config.py b/potato/setup_multitask_config.py
new file mode 100644
index 0000000000000000000000000000000000000000..f47601dca012ca33f8ea316a253ed58251a93f33
--- /dev/null
+++ b/potato/setup_multitask_config.py
@@ -0,0 +1,166 @@
+"""
+Multitask Configuration Setup Module
+
+This module provides functionality for setting up multitask annotation projects.
+It processes a base configuration and creates task-specific configurations
+for each supported task, including:
+
+- Task-specific survey flow files
+- Specialized configuration files
+- Task-specific output directories
+- Multitask guideline integration
+
+The module supports dynamic text replacement using key-based task mappings
+and creates the necessary directory structure for multitask annotation projects.
+"""
+
+from argparse import ArgumentParser
+import yaml
+import os
+import pandas as pd
+import json
+from collections import defaultdict
+
+
+def arguments():
+ """
+ Creates and returns the argument parser for multitask configuration setup.
+
+ Returns:
+ ArgumentParser: Configured argument parser with multitask_config_file argument
+ """
+ parser = ArgumentParser()
+ parser.set_defaults(show_path=False, show_similarity=False)
+
+ parser.add_argument("multitask_config_file")
+
+ return parser.parse_args()
+
+
+def main():
+ """
+ Main function for setting up multitask annotation configurations.
+
+ This function processes a multitask configuration file and creates
+ task-specific configurations for each supported task. It handles:
+
+ 1. Directory structure creation
+ 2. Task mapping from guideline files
+ 3. Configuration file generation for each task
+ 4. Survey flow file specialization
+ 5. Output directory setup
+
+ Side Effects:
+ - Creates directories for surveyflow, annotation_output, data_files, configs, htmls
+ - Generates task-specific configuration files
+ - Creates specialized survey flow files
+ - Sets up task-specific output directories
+ """
+ args = arguments()
+
+ # Load multitask annotation configuration
+ with open(args.multitask_config_file, "rt") as f:
+ multitask_config = yaml.safe_load(f)
+
+ # Create basic folder structure for the multitask project
+ for folder in ["surveyflow", "annotation_output", "data_files", "configs", "htmls"]:
+ cur_path = multitask_config["base_dir"] + folder
+ if not os.path.exists(cur_path):
+ os.makedirs(cur_path)
+ print("Created directory: %s" % (cur_path))
+
+ # Build task mapping dictionary
+ # This maps task keys to their task-specific text values
+ key2text = defaultdict(dict)
+
+ # Load multitask annotation guidelines if specified and available
+ if "multitask_guideline_file" in multitask_config and os.path.exists(
+ multitask_config["multitask_guideline_file"]
+ ):
+ # Load multitask annotation guideline from CSV
+ multitask_guideline_df = pd.read_csv(multitask_config["multitask_guideline_file"])
+ for i, row in multitask_guideline_df.iterrows():
+ # Only process rows with proper key format (enclosed in brackets)
+ if type(row["key"]) != str or row["key"][0] != "[" or row["key"][-1] != "]":
+ continue
+ # Create task mapping for each supported task
+ for task in multitask_config["tasks"]:
+ key2text[row["key"]][task] = (
+ row[task] if type(row[task]) == str else row[multitask_config["base_task"]]
+ )
+
+ # Generate configuration for each supported task
+ for task in multitask_config["tasks"]:
+ # Load and process the base configuration file
+ with open(multitask_config["base_config_file"], "rt") as f:
+ page = f.read()
+ # Replace task keys with task-specific text
+ for key in key2text:
+ page = page.replace(key, key2text[key][task])
+
+ # Update surveyflow output path for this task
+ surveyflow_output_path = multitask_config["surveyflow_output_path"].replace(
+ "[TASK]", task
+ )
+ page = page.replace(
+ multitask_config["surveyflow_path"], surveyflow_output_path + task + "-"
+ )
+ page = page.replace("[TASK]", task)
+
+ # Parse the processed configuration
+ config = yaml.safe_load(page)
+
+ # Set up task-specific output directory
+ config["output_annotation_dir"] = multitask_config["output_annotation_dir"].replace(
+ "[TASK]", task
+ )
+ if not os.path.exists(config["output_annotation_dir"]):
+ os.makedirs(config["output_annotation_dir"])
+
+ """
+ # setup the site_dir path for each task
+ config['path_under_site_dir'] = multitask_config['path_under_site_dir'].replace("[TASK]", task)
+ config["site_dir"] += config['path_under_site_dir']
+ if not os.path.exists(config["site_dir"]):
+ os.makedirs(config["site_dir"])
+ """
+
+ # Update task name and data files for this task
+ config["annotation_task_name"] = multitask_config["annotation_task_name"].replace(
+ "[TASK]", task
+ )
+ config["data_files"] = [
+ it.replace("[TASK]", task) for it in multitask_config["data_files"]
+ ]
+ # config["prestudy"] = [it.replace("[TASK]", task) for it in multitask_config['prestudy']]
+
+ # Save the task-specific configuration file
+ with open(multitask_config["base_dir"] + "configs/%s.yaml" % task, "wt") as f:
+ json.dump(config, f, indent=4)
+
+ # Create the directory for surveyflow output path
+ if not os.path.exists(surveyflow_output_path):
+ os.makedirs(surveyflow_output_path)
+
+ # Process and specialize surveyflow files
+ surveyflow_files = os.listdir(multitask_config["surveyflow_path"])
+ for file in surveyflow_files:
+ # Skip directories, only process files
+ if os.path.isdir(multitask_config["surveyflow_path"] + file):
+ continue
+ # Read the surveyflow file
+ with open(multitask_config["surveyflow_path"] + file, "r", encoding="utf-8") as f:
+ page = f.read()
+ # Replace task keys with task-specific text
+ for key in key2text:
+ page = page.replace(key, key2text[key][task])
+ page = page.replace("[TASK]", task)
+ # Write the specialized surveyflow file
+ with open(surveyflow_output_path + task + "-" + file, "wt", encoding="utf-8") as f:
+ f.write(page)
+
+ # for key in ["surveyflow_output_path", ]
+
+
+if __name__ == "__main__":
+ main()
diff --git a/potato/similarity.py b/potato/similarity.py
new file mode 100644
index 0000000000000000000000000000000000000000..9b6dbca1c4f9a3521f5c41b597ce191d345c7165
--- /dev/null
+++ b/potato/similarity.py
@@ -0,0 +1,273 @@
+"""
+Similarity Engine Module
+
+Provides semantic similarity search for adjudication items using
+sentence-transformers embeddings. Uses a guarded import pattern so the
+system degrades gracefully when sentence-transformers is not installed.
+
+Key Components:
+- SimilarityEngine: Manages embeddings, caching, and similarity search
+- Singleton management: init/get/clear pattern matching other managers
+"""
+
+import json
+import logging
+import os
+import threading
+from typing import Any, Dict, List, Optional, Tuple
+
+logger = logging.getLogger(__name__)
+
+# Guarded import โ same pattern as simpledorff in admin.py
+try:
+ from sentence_transformers import SentenceTransformer
+ import numpy as np
+ _SENTENCE_TRANSFORMERS_AVAILABLE = True
+except ImportError:
+ _SENTENCE_TRANSFORMERS_AVAILABLE = False
+
+# Singleton
+_SIMILARITY_ENGINE = None
+_SIMILARITY_LOCK = threading.Lock()
+
+
+class SimilarityEngine:
+ """
+ Manages sentence-transformer embeddings for finding semantically
+ similar annotation items during adjudication.
+ """
+
+ def __init__(self, config: Dict[str, Any], adj_config):
+ """
+ Initialize the similarity engine.
+
+ Args:
+ config: Full application configuration
+ adj_config: AdjudicationConfig dataclass instance
+ """
+ self.config = config
+ self.adj_config = adj_config
+ self.logger = logging.getLogger(__name__)
+ self._lock = threading.Lock()
+
+ self.enabled = False
+ self.model = None
+ self.embeddings = {} # instance_id -> numpy array
+ self.text_cache = {} # instance_id -> text preview
+
+ if not _SENTENCE_TRANSFORMERS_AVAILABLE:
+ self.logger.warning(
+ "sentence-transformers not installed. "
+ "Similarity search disabled. Install with: "
+ "pip install sentence-transformers"
+ )
+ return
+
+ if not adj_config.similarity_enabled:
+ return
+
+ try:
+ model_name = adj_config.similarity_model
+ self.logger.info(f"Loading similarity model: {model_name}")
+ self.model = SentenceTransformer(model_name)
+ self.enabled = True
+ self._load_cache()
+ self.logger.info(
+ f"Similarity engine ready: model={model_name}, "
+ f"cached_embeddings={len(self.embeddings)}"
+ )
+ except Exception as e:
+ self.logger.error(f"Failed to load similarity model: {e}")
+ self.enabled = False
+
+ def precompute_embeddings(self, item_texts: Dict[str, str]) -> int:
+ """
+ Batch-encode texts and store embeddings.
+
+ Args:
+ item_texts: Mapping of instance_id to text content
+
+ Returns:
+ Number of new embeddings computed
+ """
+ if not self.enabled or not self.model:
+ return 0
+
+ with self._lock:
+ # Filter out items already cached
+ new_items = {
+ iid: text for iid, text in item_texts.items()
+ if iid not in self.embeddings
+ }
+
+ if not new_items:
+ return 0
+
+ ids = list(new_items.keys())
+ texts = list(new_items.values())
+
+ try:
+ vecs = self.model.encode(texts, show_progress_bar=False)
+ for i, iid in enumerate(ids):
+ self.embeddings[iid] = vecs[i]
+ self.text_cache[iid] = texts[i][:200] # preview
+
+ self._save_cache()
+ self.logger.info(f"Computed {len(ids)} new embeddings")
+ return len(ids)
+ except Exception as e:
+ self.logger.error(f"Error computing embeddings: {e}")
+ return 0
+
+ def find_similar(
+ self, instance_id: str, top_k: Optional[int] = None
+ ) -> List[Tuple[str, float]]:
+ """
+ Find the most similar items to a given instance.
+
+ Args:
+ instance_id: The reference instance ID
+ top_k: Number of results (defaults to config value)
+
+ Returns:
+ List of (instance_id, similarity_score) tuples, highest first
+ """
+ if not self.enabled or instance_id not in self.embeddings:
+ return []
+
+ if top_k is None:
+ top_k = self.adj_config.similarity_top_k
+
+ with self._lock:
+ ref_vec = self.embeddings[instance_id]
+ results = []
+
+ for other_id, other_vec in self.embeddings.items():
+ if other_id == instance_id:
+ continue
+ score = self._cosine_similarity(ref_vec, other_vec)
+ results.append((other_id, float(score)))
+
+ results.sort(key=lambda x: x[1], reverse=True)
+ return results[:top_k]
+
+ def update_embedding(self, instance_id: str, text: str) -> bool:
+ """
+ Compute and store embedding for a single item.
+
+ Args:
+ instance_id: The instance ID
+ text: The text content
+
+ Returns:
+ True if successful
+ """
+ if not self.enabled or not self.model:
+ return False
+
+ with self._lock:
+ try:
+ vec = self.model.encode([text], show_progress_bar=False)[0]
+ self.embeddings[instance_id] = vec
+ self.text_cache[instance_id] = text[:200]
+ self._save_cache()
+ return True
+ except Exception as e:
+ self.logger.error(f"Error updating embedding for {instance_id}: {e}")
+ return False
+
+ def get_stats(self) -> Dict[str, Any]:
+ """Get similarity engine statistics."""
+ return {
+ "available": _SENTENCE_TRANSFORMERS_AVAILABLE,
+ "enabled": self.enabled,
+ "model": self.adj_config.similarity_model if self.adj_config else None,
+ "embedding_count": len(self.embeddings),
+ "top_k": self.adj_config.similarity_top_k if self.adj_config else None,
+ }
+
+ def _cosine_similarity(self, a, b) -> float:
+ """Compute cosine similarity between two vectors."""
+ dot = float(np.dot(a, b))
+ norm_a = float(np.linalg.norm(a))
+ norm_b = float(np.linalg.norm(b))
+ if norm_a == 0 or norm_b == 0:
+ return 0.0
+ return dot / (norm_a * norm_b)
+
+ def _get_cache_dir(self) -> str:
+ """Get the cache directory path."""
+ output_dir = self.config.get("output_annotation_dir", "annotation_output")
+ adj_subdir = self.adj_config.output_subdir if self.adj_config else "adjudication"
+ cache_dir = os.path.join(output_dir, adj_subdir, ".similarity_cache")
+ os.makedirs(cache_dir, exist_ok=True)
+ return cache_dir
+
+ def _save_cache(self) -> None:
+ """Save embeddings and text cache to disk."""
+ try:
+ import pickle
+ cache_dir = self._get_cache_dir()
+
+ # Save embeddings as pickle (numpy arrays)
+ emb_path = os.path.join(cache_dir, "embeddings.pkl")
+ with open(emb_path, "wb") as f:
+ pickle.dump(self.embeddings, f)
+
+ # Save text cache as JSON
+ text_path = os.path.join(cache_dir, "text_cache.json")
+ with open(text_path, "w") as f:
+ json.dump(self.text_cache, f)
+
+ except Exception as e:
+ self.logger.error(f"Failed to save similarity cache: {e}")
+
+ def _load_cache(self) -> None:
+ """Load cached embeddings and text previews from disk."""
+ try:
+ import pickle
+ cache_dir = self._get_cache_dir()
+
+ emb_path = os.path.join(cache_dir, "embeddings.pkl")
+ if os.path.exists(emb_path):
+ with open(emb_path, "rb") as f:
+ self.embeddings = pickle.load(f)
+
+ text_path = os.path.join(cache_dir, "text_cache.json")
+ if os.path.exists(text_path):
+ with open(text_path, "r") as f:
+ self.text_cache = json.load(f)
+
+ if self.embeddings:
+ self.logger.info(
+ f"Loaded {len(self.embeddings)} cached embeddings"
+ )
+ except Exception as e:
+ self.logger.warning(f"Failed to load similarity cache: {e}")
+ self.embeddings = {}
+ self.text_cache = {}
+
+
+def init_similarity_engine(
+ config: Dict[str, Any], adj_config
+) -> Optional[SimilarityEngine]:
+ """Initialize the singleton SimilarityEngine."""
+ global _SIMILARITY_ENGINE
+
+ with _SIMILARITY_LOCK:
+ if _SIMILARITY_ENGINE is None:
+ _SIMILARITY_ENGINE = SimilarityEngine(config, adj_config)
+
+ return _SIMILARITY_ENGINE
+
+
+def get_similarity_engine() -> Optional[SimilarityEngine]:
+ """Get the singleton SimilarityEngine instance."""
+ return _SIMILARITY_ENGINE
+
+
+def clear_similarity_engine():
+ """Clear the singleton (for testing)."""
+ global _SIMILARITY_ENGINE
+ with _SIMILARITY_LOCK:
+ _SIMILARITY_ENGINE = None
diff --git a/potato/simulator/__init__.py b/potato/simulator/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..6da6d6b569222e73bc3c26af39cb597fe831c853
--- /dev/null
+++ b/potato/simulator/__init__.py
@@ -0,0 +1,88 @@
+"""
+User Simulator for Potato Annotation Platform.
+
+This module provides tools for simulating multiple annotators with varying
+competence levels and behaviors for testing purposes.
+
+Example usage:
+ from potato.simulator import SimulatorManager, SimulatorConfig
+
+ config = SimulatorConfig(
+ user_count=10,
+ strategy="random",
+ competence_distribution={"good": 0.5, "average": 0.3, "poor": 0.2}
+ )
+
+ manager = SimulatorManager(config, "http://localhost:8000")
+ results = manager.run_parallel(max_annotations_per_user=20)
+
+ print(manager.get_summary())
+"""
+
+from .config import (
+ SimulatorConfig,
+ UserConfig,
+ TimingConfig,
+ LLMStrategyConfig,
+ BiasedStrategyConfig,
+ PatternStrategyConfig,
+ AgentStrategyConfig,
+ InteractiveConfig,
+ CompetenceLevel,
+ AnnotationStrategyType,
+)
+from .competence_profiles import (
+ CompetenceProfile,
+ create_competence_profile,
+)
+from .annotation_strategies import (
+ AnnotationStrategy,
+ RandomStrategy,
+ BiasedStrategy,
+ LLMStrategy,
+ PatternStrategy,
+ create_strategy,
+)
+from .agent_strategy import AgentSimulatorStrategy
+from .interactive_runner import InteractiveSessionRunner, InteractiveSessionResult
+from .timing_models import TimingModel
+from .user_simulator import SimulatedUser, UserSimulationResult, AnnotationRecord
+from .simulator_manager import SimulatorManager
+from .reporting import SimulationReporter
+
+__all__ = [
+ # Config
+ "SimulatorConfig",
+ "UserConfig",
+ "TimingConfig",
+ "LLMStrategyConfig",
+ "BiasedStrategyConfig",
+ "PatternStrategyConfig",
+ "AgentStrategyConfig",
+ "InteractiveConfig",
+ "CompetenceLevel",
+ "AnnotationStrategyType",
+ # Competence
+ "CompetenceProfile",
+ "create_competence_profile",
+ # Strategies
+ "AnnotationStrategy",
+ "RandomStrategy",
+ "BiasedStrategy",
+ "LLMStrategy",
+ "PatternStrategy",
+ "AgentSimulatorStrategy",
+ "InteractiveSessionRunner",
+ "InteractiveSessionResult",
+ "create_strategy",
+ # Timing
+ "TimingModel",
+ # User simulation
+ "SimulatedUser",
+ "UserSimulationResult",
+ "AnnotationRecord",
+ # Manager
+ "SimulatorManager",
+ # Reporting
+ "SimulationReporter",
+]
diff --git a/potato/simulator/__main__.py b/potato/simulator/__main__.py
new file mode 100644
index 0000000000000000000000000000000000000000..161874f67d0eaf1422c3664f26391a74a3b13ad3
--- /dev/null
+++ b/potato/simulator/__main__.py
@@ -0,0 +1,11 @@
+"""
+Entry point for running the simulator as a module.
+
+Usage:
+ python -m potato.simulator --server http://localhost:8000 --users 10
+"""
+
+from .cli import main
+
+if __name__ == "__main__":
+ main()
diff --git a/potato/simulator/agent_strategy.py b/potato/simulator/agent_strategy.py
new file mode 100644
index 0000000000000000000000000000000000000000..10ee8fdb25fc57e3753977af326a80158c724416
--- /dev/null
+++ b/potato/simulator/agent_strategy.py
@@ -0,0 +1,894 @@
+"""
+Agent (vision-LLM) annotation strategy.
+
+The :class:`AgentSimulatorStrategy` consumes the *full* structured payload of
+an instance โ text fields, dialogue arrays (agent traces, conversations),
+spreadsheet/table data, image references โ and asks a vision-capable LLM to
+produce a complete annotation set covering every schema for that instance.
+
+It mirrors :class:`LLMStrategy` but differs in two important ways:
+
+1. It reads ``instance["data"]`` (the full raw payload that
+ ``/api/current_instance`` returns under the ``data`` key) instead of the
+ single ``text`` field. This gives the model access to dialogue traces,
+ metadata tables, and image URLs.
+
+2. It batches the per-instance call: a single LLM query produces labels for
+ every schema. Subsequent ``generate_annotation`` calls for the same
+ instance are served from a per-instance cache. This keeps cost roughly
+ 1ร (instances) instead of (instances ร schemas).
+"""
+
+from __future__ import annotations
+
+import base64
+import io
+import json
+import logging
+import os
+import random
+import re
+from typing import Any, Dict, List, Optional, Tuple
+
+from pydantic import BaseModel, Field
+
+from .annotation_strategies import AnnotationStrategy, RandomStrategy
+from .competence_profiles import CompetenceProfile
+from .config import AgentStrategyConfig
+
+logger = logging.getLogger(__name__)
+
+
+_FIELD_DETECTORS = {
+ "dialogue": (
+ "conversation",
+ "dialogue",
+ "trace",
+ "messages",
+ "turns",
+ "structured_turns", # coding-agent traces with role/content/tool_calls
+ ),
+ "spreadsheet": ("metadata_table", "table", "spreadsheet"),
+ "image": ("image", "image_url", "screenshot", "screenshot_url", "media", "image_path"),
+}
+
+# Cap on how much tool I/O text we render per turn to keep prompts bounded.
+_MAX_TOOL_INPUT_CHARS = 400
+_MAX_TOOL_OUTPUT_CHARS = 800
+
+
+class _AgentLabelResponse(BaseModel):
+ """Pydantic schema returned by the LLM for a single instance.
+
+ The model returns a flat dict keyed by ```` whose value is
+ either a label string (radio/multiselect/likert) or a numeric value
+ (slider/likert as int) or a free-text response (text/textbox). The
+ strategy maps these to the wire format the simulator submits.
+ """
+
+ annotations: Dict[str, Any] = Field(
+ default_factory=dict,
+ description="Mapping of schema_name -> chosen label/value/text.",
+ )
+ reasoning: str = Field(
+ default="",
+ description="One sentence explaining the labels (kept short).",
+ )
+
+
+class AgentSimulatorStrategy(AnnotationStrategy):
+ """Vision-LLM strategy for multi-modal / structured agent content."""
+
+ def __init__(self, config: AgentStrategyConfig):
+ self.config = config
+ self.endpoint = self._create_endpoint()
+ self.random_strategy = RandomStrategy()
+ # Per-instance result cache: instance_id -> dict[schema_name -> raw model value]
+ self._cache: Dict[str, Dict[str, Any]] = {}
+ # Errors are reported per-instance to avoid hammering the LLM with retries
+ self._failed_instances: set = set()
+
+ # ------------------------------------------------------------------
+ # Endpoint construction
+ # ------------------------------------------------------------------
+
+ def _create_endpoint(self):
+ try:
+ from potato.ai.ai_endpoint import AIEndpointFactory
+
+ ai_cfg: Dict[str, Any] = {
+ "model": self.config.model,
+ "api_key": self.config.api_key,
+ "max_tokens": self.config.max_tokens,
+ "temperature": self.config.temperature,
+ }
+ if self.config.base_url:
+ ai_cfg["base_url"] = self.config.base_url
+
+ return AIEndpointFactory.create_endpoint({
+ "ai_support": {
+ "enabled": True,
+ "endpoint_type": self.config.endpoint_type,
+ "ai_config": ai_cfg,
+ }
+ })
+ except Exception as e:
+ logger.warning("AgentSimulatorStrategy: endpoint init failed: %s", e)
+ return None
+
+ # ------------------------------------------------------------------
+ # Public API
+ # ------------------------------------------------------------------
+
+ def generate_annotation(
+ self,
+ instance: Dict[str, Any],
+ schema: Dict[str, Any],
+ competence: CompetenceProfile,
+ gold_answer: Optional[Dict[str, Any]] = None,
+ ) -> Dict[str, Any]:
+ if not self.endpoint:
+ return self.random_strategy.generate_annotation(
+ instance, schema, competence, gold_answer
+ )
+
+ instance_id = instance.get("instance_id") or instance.get("id") or ""
+ schema_name = schema.get("name")
+ annotation_type = schema.get("annotation_type") or schema.get("type")
+ labels = self.random_strategy._extract_labels(schema)
+
+ # Per-instance cache: one LLM call answers every schema
+ results = self._get_or_query(instance, instance_id)
+ if results is None:
+ return self.random_strategy.generate_annotation(
+ instance, schema, competence, gold_answer
+ )
+
+ # Optional noise: mirrors LLMStrategy
+ if self.config.add_noise and random.random() < self.config.noise_rate:
+ return self.random_strategy.generate_annotation(
+ instance, schema, competence, gold_answer
+ )
+
+ raw_value = results.get(schema_name)
+ if raw_value is None:
+ logger.debug(
+ "Agent strategy: no value for schema=%s (instance=%s); falling back",
+ schema_name, instance_id,
+ )
+ return self.random_strategy.generate_annotation(
+ instance, schema, competence, gold_answer
+ )
+
+ formatted = self._format_value(
+ schema_name, raw_value, annotation_type, labels, schema,
+ instance=instance,
+ )
+ if not formatted:
+ return self.random_strategy.generate_annotation(
+ instance, schema, competence, gold_answer
+ )
+ return formatted
+
+ # ------------------------------------------------------------------
+ # Per-instance batch query
+ # ------------------------------------------------------------------
+
+ def _get_or_query(
+ self, instance: Dict[str, Any], instance_id: str
+ ) -> Optional[Dict[str, Any]]:
+ if self.config.cache_per_instance and instance_id in self._cache:
+ return self._cache[instance_id]
+ if instance_id in self._failed_instances:
+ return None
+
+ schemas = instance.get("__all_schemas__") or instance.get("schemas") or []
+ if not schemas:
+ # The simulator should be passing schemas via the instance dict
+ # (see SimulatedUser.generate_annotations). If it isn't, we can
+ # still produce annotations for the single schema by callers
+ # passing schema directly each time, but caching is then per-call.
+ logger.debug("Agent strategy: no schemas attached to instance %s", instance_id)
+ return None
+
+ prompt, image_payloads = self._build_request(instance, schemas)
+ try:
+ response = self._invoke(prompt, image_payloads)
+ except Exception as e:
+ logger.warning(
+ "Agent strategy: LLM call failed for instance=%s: %s", instance_id, e
+ )
+ self._failed_instances.add(instance_id)
+ return None
+
+ parsed = self._parse_response(response)
+ if parsed is None:
+ self._failed_instances.add(instance_id)
+ return None
+
+ # Models occasionally key by the schema's annotation_type instead of
+ # its name (e.g. "code_review" instead of "review"). Re-key to schema
+ # names so downstream lookup always works.
+ parsed = self._normalize_keys_to_schema_names(parsed, schemas)
+
+ if self.config.cache_per_instance:
+ self._cache[instance_id] = parsed
+ return parsed
+
+ def _normalize_keys_to_schema_names(
+ self, parsed: Dict[str, Any], schemas: List[Dict[str, Any]]
+ ) -> Dict[str, Any]:
+ """Re-key parsed annotations to match the schema *names* the
+ simulator uses, even if the LLM keyed by annotation_type, label, or
+ a case-variant. Idempotent for already-correct keys.
+ """
+ if not isinstance(parsed, dict) or not schemas:
+ return parsed
+
+ names = {s.get("name"): s for s in schemas if s.get("name")}
+ # Exact-name path -- fast and keeps existing behaviour.
+ unmatched = [k for k in parsed.keys() if k not in names]
+ if not unmatched:
+ return parsed
+
+ # Build alternate-key lookup: annotation_type -> schema name.
+ type_to_name: Dict[str, str] = {}
+ lower_name_to_name: Dict[str, str] = {n.lower(): n for n in names}
+ for s in schemas:
+ atype = s.get("annotation_type") or s.get("type")
+ if atype and atype not in names and atype not in type_to_name:
+ type_to_name[atype] = s["name"]
+
+ out = dict(parsed)
+ for key in list(unmatched):
+ value = out[key]
+ target: Optional[str] = None
+ if key in type_to_name:
+ target = type_to_name[key]
+ elif key.lower() in lower_name_to_name:
+ target = lower_name_to_name[key.lower()]
+ if target and target not in out:
+ out[target] = value
+ # Keep the original key too -- harmless and aids debugging.
+ return out
+
+ # ------------------------------------------------------------------
+ # Prompt construction
+ # ------------------------------------------------------------------
+
+ def _build_request(
+ self,
+ instance: Dict[str, Any],
+ schemas: List[Dict[str, Any]],
+ ) -> Tuple[str, List[Any]]:
+ data = instance.get("data") or {}
+ if not isinstance(data, dict):
+ data = {}
+
+ text_blocks: List[str] = []
+
+ # Top-level text/task description
+ task_text = (
+ data.get("task_description")
+ or data.get("text")
+ or instance.get("text", "")
+ )
+ if task_text:
+ text_blocks.append(f"## Task\n{task_text}".strip())
+
+ # Dialogue / conversation arrays
+ if self.config.include_dialogue_text:
+ for key in _FIELD_DETECTORS["dialogue"]:
+ value = data.get(key)
+ if value:
+ rendered = self._render_dialogue(value)
+ if rendered:
+ text_blocks.append(f"## {key.title()}\n{rendered}")
+ break # Only render the first matching dialogue field
+
+ # Spreadsheet / table data
+ if self.config.include_spreadsheet:
+ for key in _FIELD_DETECTORS["spreadsheet"]:
+ value = data.get(key)
+ if value:
+ rendered = self._render_spreadsheet(value)
+ if rendered:
+ text_blocks.append(f"## {key.replace('_', ' ').title()}\n{rendered}")
+ break
+
+ # Other plain-text fields not already consumed
+ consumed = (
+ {"task_description", "text", "id"}
+ | set(_FIELD_DETECTORS["dialogue"])
+ | set(_FIELD_DETECTORS["spreadsheet"])
+ | set(_FIELD_DETECTORS["image"])
+ | {"gold_labels"}
+ )
+ for k, v in data.items():
+ if k in consumed or k.startswith("_"):
+ continue
+ if isinstance(v, (str, int, float)) and str(v).strip():
+ text_blocks.append(f"## {k}\n{v}")
+
+ # Schema spec section (instance is needed for step-aware schemas)
+ text_blocks.append(self._render_schema_spec(schemas, instance))
+
+ text_blocks.append(
+ "Respond with a single JSON object {\"annotations\": {...}, \"reasoning\": \"...\"} "
+ "where each key under 'annotations' is exactly the schema name listed above. "
+ "The value type matches the schema:\n"
+ "- radio / multiselect / likert with named labels: a string label\n"
+ "- likert / slider / number without labels: an integer in the allowed range\n"
+ "- text / textbox: a short free-text string\n"
+ "- multiselect: a JSON array of label strings\n"
+ "- process_reward: an integer step index (or null) for first_error mode, "
+ "or a JSON array of 1/-1/0 for per_step mode\n"
+ "- code_review: a JSON object with verdict, comments, file_ratings keys\n"
+ "Always include EVERY schema name as a key under 'annotations'."
+ )
+
+ prompt = "\n\n".join(text_blocks)
+
+ # Collect image payloads (paths or URLs)
+ image_payloads = self._collect_images(data)
+ return prompt, image_payloads
+
+ def _render_dialogue(self, value: Any) -> str:
+ if isinstance(value, str):
+ return value[: self.config.max_dialogue_chars]
+ if not isinstance(value, list):
+ return ""
+ lines: List[str] = []
+ for i, turn in enumerate(value, start=1):
+ if isinstance(turn, dict):
+ speaker = turn.get("speaker") or turn.get("role") or f"Turn {i}"
+ text = turn.get("text") or turn.get("content") or ""
+ lines.append(f"{i}. {speaker}: {text}")
+ # Coding-agent shape: each turn may carry a list of
+ # {tool, input, output, output_type, language} entries.
+ # Render them so the LLM rater can see the actions taken.
+ tool_calls = turn.get("tool_calls")
+ if isinstance(tool_calls, list):
+ for call in tool_calls:
+ if not isinstance(call, dict):
+ continue
+ lines.append(self._render_tool_call(call))
+ else:
+ lines.append(f"{i}. {turn}")
+ rendered = "\n".join(lines)
+ return rendered[: self.config.max_dialogue_chars]
+
+ def _render_tool_call(self, call: Dict[str, Any]) -> str:
+ tool_name = call.get("tool") or call.get("name") or "tool"
+ # Inputs may be a dict or a string -- format for readability.
+ raw_input = call.get("input") or call.get("arguments") or {}
+ if isinstance(raw_input, dict):
+ input_str = ", ".join(f"{k}={v!r}" for k, v in raw_input.items())
+ else:
+ input_str = str(raw_input)
+ input_str = input_str[:_MAX_TOOL_INPUT_CHARS]
+
+ output = call.get("output")
+ if output is None:
+ return f" [tool: {tool_name}({input_str})]"
+ output_str = str(output)
+ if len(output_str) > _MAX_TOOL_OUTPUT_CHARS:
+ output_str = (
+ output_str[:_MAX_TOOL_OUTPUT_CHARS]
+ + f"\n [...truncated {len(output_str) - _MAX_TOOL_OUTPUT_CHARS} chars]"
+ )
+ return f" [tool: {tool_name}({input_str})]\n -> {output_str}"
+
+ def _render_spreadsheet(self, value: Any) -> str:
+ if isinstance(value, list) and value and isinstance(value[0], dict):
+ keys = list(value[0].keys())
+ header = " | ".join(keys)
+ rows = [
+ " | ".join(str(row.get(k, "")) for k in keys) for row in value
+ ]
+ return header + "\n" + "\n".join(rows)
+ if isinstance(value, dict):
+ return "\n".join(f"{k}: {v}" for k, v in value.items())
+ return str(value)
+
+ def _render_schema_spec(
+ self,
+ schemas: List[Dict[str, Any]],
+ instance: Optional[Dict[str, Any]] = None,
+ ) -> str:
+ data = (instance or {}).get("data") or {}
+ lines = ["## Schemas to label"]
+ for schema in schemas:
+ name = schema.get("name", "?")
+ atype = schema.get("annotation_type") or schema.get("type") or "?"
+ desc = schema.get("description", "")
+ labels = self.random_strategy._extract_labels(schema)
+ allowed: str
+ if labels:
+ allowed = "labels=" + ", ".join(labels)
+ elif atype == "likert":
+ size = schema.get("size", 5)
+ allowed = f"integer 1..{size}"
+ elif atype in ("slider", "number"):
+ lo = schema.get("min_value", schema.get("min", 0))
+ hi = schema.get("max_value", schema.get("max", 100))
+ allowed = f"integer {lo}..{hi}"
+ elif atype == "process_reward":
+ steps_key = schema.get("steps_key", "structured_turns")
+ steps = data.get(steps_key) if isinstance(data, dict) else None
+ n = len(steps) if isinstance(steps, list) else 0
+ mode = schema.get("mode", "first_error")
+ if mode == "first_error":
+ allowed = (
+ f"first_error mode: integer 0..{max(n - 1, 0)} "
+ f"(index of the first wrong step in the {n}-step trace), "
+ "or null if every step is correct"
+ )
+ else:
+ allowed = (
+ f"per_step mode: list of {n} entries, each one of "
+ "1 (correct), -1 (incorrect), 0 (unmarked)"
+ )
+ elif atype == "code_review":
+ verdicts = schema.get(
+ "verdict_options", ["approve", "request_changes", "comment_only"]
+ )
+ allowed = (
+ "object with keys: "
+ "verdict (one of " + ", ".join(verdicts) + "), "
+ "comments (list of {file, line?, category, body}), "
+ "file_ratings (object: filename -> {dim: 1..5})"
+ )
+ else:
+ allowed = "free text"
+ lines.append(f"- {name} ({atype}): {desc} [{allowed}]")
+ return "\n".join(lines)
+
+ # ------------------------------------------------------------------
+ # Image handling
+ # ------------------------------------------------------------------
+
+ def _collect_images(self, data: Dict[str, Any]) -> List[Any]:
+ """Return up to ``max_image_count`` ImageData objects."""
+ try:
+ from potato.ai.ai_endpoint import ImageData
+ except Exception:
+ return []
+
+ candidates: List[str] = []
+ for key in _FIELD_DETECTORS["image"]:
+ value = data.get(key)
+ if not value:
+ continue
+ if isinstance(value, str):
+ candidates.append(value)
+ elif isinstance(value, list):
+ for item in value:
+ if isinstance(item, str):
+ candidates.append(item)
+
+ images: List[Any] = []
+ for path_or_url in candidates[: self.config.max_image_count]:
+ payload = self._load_image(path_or_url, ImageData)
+ if payload is not None:
+ images.append(payload)
+ return images
+
+ def _load_image(self, path_or_url: str, ImageData):
+ try:
+ if path_or_url.startswith(("http://", "https://", "data:")):
+ # Remote / inline data URI -- pass through unchanged
+ return ImageData(url=path_or_url) if hasattr(ImageData, "url") else None
+
+ if not os.path.exists(path_or_url):
+ logger.debug("Agent strategy: image not found at %s", path_or_url)
+ return None
+
+ with open(path_or_url, "rb") as f:
+ raw = f.read()
+
+ if self.config.max_image_dim:
+ raw = self._maybe_resize(raw)
+
+ b64 = base64.b64encode(raw).decode("ascii")
+ # ImageData supports a few constructor signatures across providers;
+ # try the most compatible one first.
+ try:
+ return ImageData(base64=b64, mime_type=self._guess_mime(path_or_url))
+ except TypeError:
+ try:
+ return ImageData(data=b64, mime_type=self._guess_mime(path_or_url))
+ except TypeError:
+ return ImageData(b64)
+ except Exception as e:
+ logger.debug("Agent strategy: failed to load image %s: %s", path_or_url, e)
+ return None
+
+ def _maybe_resize(self, raw: bytes) -> bytes:
+ try:
+ from PIL import Image # noqa: WPS433
+ except Exception:
+ return raw
+ try:
+ img = Image.open(io.BytesIO(raw))
+ longest = max(img.size)
+ if longest <= self.config.max_image_dim:
+ return raw
+ scale = self.config.max_image_dim / longest
+ new_size = (max(1, int(img.size[0] * scale)), max(1, int(img.size[1] * scale)))
+ img = img.convert("RGB").resize(new_size)
+ buf = io.BytesIO()
+ img.save(buf, format="JPEG", quality=85)
+ return buf.getvalue()
+ except Exception:
+ return raw
+
+ def _guess_mime(self, path: str) -> str:
+ lowered = path.lower()
+ if lowered.endswith(".png"):
+ return "image/png"
+ if lowered.endswith((".jpg", ".jpeg")):
+ return "image/jpeg"
+ if lowered.endswith(".webp"):
+ return "image/webp"
+ if lowered.endswith(".gif"):
+ return "image/gif"
+ return "image/jpeg"
+
+ # ------------------------------------------------------------------
+ # LLM invocation + response parsing
+ # ------------------------------------------------------------------
+
+ def _invoke(self, prompt: str, image_payloads: List[Any]) -> Any:
+ """Call the endpoint, preferring vision API when images are present."""
+ if image_payloads and hasattr(self.endpoint, "query_with_image"):
+ return self.endpoint.query_with_image(
+ prompt, image_payloads, _AgentLabelResponse
+ )
+ return self.endpoint.query(prompt, _AgentLabelResponse)
+
+ def _parse_response(self, response: Any) -> Optional[Dict[str, Any]]:
+ if response is None:
+ return None
+ # Endpoints with structured output return a dict-like object
+ if hasattr(response, "model_dump"):
+ data = response.model_dump()
+ elif isinstance(response, dict):
+ data = response
+ elif isinstance(response, str):
+ data = self._loose_json_parse(response)
+ else:
+ try:
+ data = dict(response)
+ except Exception:
+ return None
+ if not isinstance(data, dict):
+ return None
+ annotations = data.get("annotations") if isinstance(data, dict) else None
+ if isinstance(annotations, dict):
+ return annotations
+ # Some endpoints return the raw annotations dict directly
+ if all(isinstance(k, str) for k in data.keys()) and "reasoning" not in data:
+ return data
+ return None
+
+ def _loose_json_parse(self, text: str) -> Dict[str, Any]:
+ try:
+ return json.loads(text)
+ except Exception:
+ pass
+ match = re.search(r"\{.*\}", text, flags=re.DOTALL)
+ if match:
+ try:
+ return json.loads(match.group(0))
+ except Exception:
+ pass
+ return {}
+
+ # ------------------------------------------------------------------
+ # Format translation: model output -> wire annotation
+ # ------------------------------------------------------------------
+
+ def _format_value(
+ self,
+ schema_name: str,
+ raw_value: Any,
+ annotation_type: str,
+ labels: List[str],
+ schema: Dict[str, Any],
+ instance: Optional[Dict[str, Any]] = None,
+ ) -> Optional[Dict[str, Any]]:
+ if annotation_type == "process_reward":
+ return self._format_process_reward(
+ schema_name, raw_value, schema, instance
+ )
+
+ if annotation_type == "code_review":
+ return self._format_code_review(schema_name, raw_value, schema)
+
+ if annotation_type == "multiselect":
+ chosen = self._coerce_multilabels(raw_value, labels)
+ if not chosen:
+ return None
+ return {f"{schema_name}:{label}": "on" for label in chosen}
+
+ if annotation_type == "radio":
+ chosen = self._coerce_label(raw_value, labels)
+ if chosen is None:
+ return None
+ return {f"{schema_name}:{chosen}": "on"}
+
+ if annotation_type == "likert":
+ size = schema.get("size", 5)
+ chosen = self._coerce_int(raw_value, 1, size)
+ if chosen is None and labels:
+ # Some likert schemas use named labels (e.g. ["Wrong","Right"])
+ lbl = self._coerce_label(raw_value, labels)
+ if lbl is not None:
+ return {f"{schema_name}:{lbl}": "on"}
+ if chosen is None:
+ return None
+ return {f"{schema_name}:{chosen}": "on"}
+
+ if annotation_type in ("slider", "number"):
+ lo = schema.get("min_value", schema.get("min", 0))
+ hi = schema.get("max_value", schema.get("max", 100))
+ chosen = self._coerce_int(raw_value, lo, hi)
+ if chosen is None:
+ return None
+ return {f"{schema_name}:{chosen}": str(chosen)}
+
+ if annotation_type in ("text", "textbox"):
+ return {f"{schema_name}:text": str(raw_value)[:1000]}
+
+ # Unknown type โ return string form
+ return {f"{schema_name}:{raw_value}": "on"}
+
+ def _coerce_label(self, raw_value: Any, labels: List[str]) -> Optional[str]:
+ if not labels:
+ return None
+ if isinstance(raw_value, str):
+ candidate = raw_value.strip()
+ for label in labels:
+ if label.lower() == candidate.lower():
+ return label
+ for label in labels:
+ if label.lower() in candidate.lower() or candidate.lower() in label.lower():
+ return label
+ return None
+
+ def _coerce_multilabels(self, raw_value: Any, labels: List[str]) -> List[str]:
+ if not labels:
+ return []
+ if isinstance(raw_value, list):
+ chosen: List[str] = []
+ for item in raw_value:
+ resolved = self._coerce_label(item, labels)
+ if resolved and resolved not in chosen:
+ chosen.append(resolved)
+ return chosen
+ if isinstance(raw_value, str):
+ parts = re.split(r"[,;|]", raw_value)
+ chosen = []
+ for part in parts:
+ resolved = self._coerce_label(part, labels)
+ if resolved and resolved not in chosen:
+ chosen.append(resolved)
+ return chosen
+ return []
+
+ # ------------------------------------------------------------------
+ # Custom-schema wire-format helpers
+ # ------------------------------------------------------------------
+
+ def _format_process_reward(
+ self,
+ schema_name: str,
+ raw_value: Any,
+ schema: Dict[str, Any],
+ instance: Optional[Dict[str, Any]],
+ ) -> Optional[Dict[str, Any]]:
+ """Build the wire-format payload for a process_reward schema.
+
+ Server expects ``{":::": ""}`` where the JSON
+ is ``{"steps": [{"index": N, "reward": 1|-1|0}, ...], "mode": ...}``.
+ """
+ steps_key = schema.get("steps_key", "structured_turns")
+ mode = schema.get("mode", "first_error")
+ data = (instance or {}).get("data") or {}
+ steps = data.get(steps_key) if isinstance(data, dict) else None
+ n = len(steps) if isinstance(steps, list) else 0
+ if n == 0:
+ return None
+
+ if mode == "first_error":
+ first_wrong = self._coerce_first_wrong_index(raw_value, n)
+ entries = []
+ for idx in range(n):
+ if first_wrong is None:
+ reward = 1
+ elif idx < first_wrong:
+ reward = 1
+ else:
+ reward = -1
+ entries.append({"index": idx, "reward": reward})
+ else:
+ entries = self._coerce_per_step_rewards(raw_value, n)
+ if entries is None:
+ return None
+
+ payload = {"steps": entries, "mode": mode}
+ return {f"{schema_name}:::{schema_name}": json.dumps(payload)}
+
+ def _coerce_first_wrong_index(self, raw_value: Any, n: int) -> Optional[int]:
+ """Interpret the LLM's first-error response as an int in 0..n-1 or None."""
+ if raw_value is None:
+ return None
+ if isinstance(raw_value, str) and raw_value.strip().lower() in (
+ "null", "none", "all_correct", "n/a", ""
+ ):
+ return None
+ if isinstance(raw_value, dict):
+ for key in ("first_wrong", "first_error", "index", "step"):
+ if key in raw_value:
+ return self._coerce_first_wrong_index(raw_value[key], n)
+ return None
+ idx = self._coerce_int(raw_value, 0, max(n - 1, 0))
+ return idx
+
+ def _coerce_per_step_rewards(
+ self, raw_value: Any, n: int
+ ) -> Optional[List[Dict[str, int]]]:
+ """Interpret the LLM's per_step response as a list of n {index,reward} entries."""
+ items: List[int] = []
+ if isinstance(raw_value, list):
+ for v in raw_value:
+ if isinstance(v, dict) and "reward" in v:
+ items.append(self._normalize_reward(v["reward"]))
+ else:
+ items.append(self._normalize_reward(v))
+ elif isinstance(raw_value, str):
+ for part in re.split(r"[\s,;|]+", raw_value):
+ if not part:
+ continue
+ items.append(self._normalize_reward(part))
+ else:
+ return None
+
+ if len(items) < n:
+ items.extend([0] * (n - len(items)))
+ items = items[:n]
+ return [{"index": i, "reward": r} for i, r in enumerate(items)]
+
+ def _normalize_reward(self, value: Any) -> int:
+ """Map various encodings to the server's {1, -1, 0} reward space."""
+ if isinstance(value, str):
+ v = value.strip().lower()
+ if v in ("1", "+1", "correct", "good", "true", "yes", "ok"):
+ return 1
+ if v in ("-1", "incorrect", "wrong", "bad", "false", "no"):
+ return -1
+ return 0
+ try:
+ i = int(value)
+ except (TypeError, ValueError):
+ return 0
+ if i > 0:
+ return 1
+ if i < 0:
+ return -1
+ return 0
+
+ def _format_code_review(
+ self,
+ schema_name: str,
+ raw_value: Any,
+ schema: Dict[str, Any],
+ ) -> Optional[Dict[str, Any]]:
+ """Build the wire-format payload for a code_review schema.
+
+ Server expects ``{":::": ""}`` where the JSON
+ is ``{"verdict": "...", "comments": [...], "file_ratings": {...}}``.
+ """
+ verdicts = schema.get(
+ "verdict_options",
+ ["approve", "request_changes", "comment_only"],
+ )
+ categories = schema.get(
+ "comment_categories",
+ ["bug", "style", "suggestion", "security", "question"],
+ )
+ rating_dims = schema.get(
+ "file_rating_dimensions",
+ ["correctness", "readability", "maintainability"],
+ )
+
+ verdict, comments, file_ratings = "comment_only", [], {}
+
+ if isinstance(raw_value, dict):
+ v = raw_value.get("verdict")
+ if isinstance(v, str):
+ v_lower = v.strip().lower()
+ for option in verdicts:
+ if v_lower == option.lower() or v_lower in option.lower():
+ verdict = option
+ break
+
+ raw_comments = raw_value.get("comments") or []
+ if isinstance(raw_comments, list):
+ for c in raw_comments:
+ if not isinstance(c, dict):
+ continue
+ body = str(c.get("body") or c.get("text") or c.get("comment") or "").strip()
+ if not body:
+ continue
+ cat = str(c.get("category") or "").strip().lower()
+ if cat not in {x.lower() for x in categories}:
+ cat = categories[0]
+ else:
+ # restore original casing
+ cat = next(x for x in categories if x.lower() == cat)
+ entry = {
+ "category": cat,
+ "body": body[:1000],
+ }
+ if c.get("file"):
+ entry["file"] = str(c["file"])
+ line = c.get("line")
+ if isinstance(line, int):
+ entry["line"] = line
+ comments.append(entry)
+
+ raw_ratings = raw_value.get("file_ratings") or raw_value.get("ratings") or {}
+ if isinstance(raw_ratings, dict):
+ for filename, dims in raw_ratings.items():
+ if not isinstance(dims, dict):
+ continue
+ clean_dims: Dict[str, int] = {}
+ for dim, score in dims.items():
+ dim_match = next(
+ (d for d in rating_dims if d.lower() == str(dim).lower()),
+ None,
+ )
+ if dim_match is None:
+ continue
+ clamped = self._coerce_int(score, 1, 5)
+ if clamped is not None:
+ clean_dims[dim_match] = clamped
+ if clean_dims:
+ file_ratings[str(filename)] = clean_dims
+
+ elif isinstance(raw_value, str):
+ v_lower = raw_value.strip().lower()
+ for option in verdicts:
+ if v_lower == option.lower() or v_lower in option.lower():
+ verdict = option
+ break
+
+ payload = {
+ "verdict": verdict,
+ "comments": comments,
+ "file_ratings": file_ratings,
+ }
+ return {f"{schema_name}:::{schema_name}": json.dumps(payload)}
+
+ def _coerce_int(self, raw_value: Any, lo: int, hi: int) -> Optional[int]:
+ try:
+ value = int(float(raw_value))
+ except (TypeError, ValueError):
+ if isinstance(raw_value, str):
+ m = re.search(r"-?\d+", raw_value)
+ if m:
+ try:
+ value = int(m.group(0))
+ except ValueError:
+ return None
+ else:
+ return None
+ else:
+ return None
+ if value < lo:
+ value = lo
+ elif value > hi:
+ value = hi
+ return value
diff --git a/potato/simulator/annotation_strategies.py b/potato/simulator/annotation_strategies.py
new file mode 100644
index 0000000000000000000000000000000000000000..3ad283a9acdf6e37ad7f97bc674135ca61e69bfa
--- /dev/null
+++ b/potato/simulator/annotation_strategies.py
@@ -0,0 +1,780 @@
+"""
+Annotation strategies for simulated users.
+
+This module defines different strategies for generating annotations:
+- Random: Uniform random selection
+- Biased: Weighted selection based on label preferences
+- LLM: Use an LLM to generate annotations
+- Pattern: Consistent per-user patterns
+"""
+
+from abc import ABC, abstractmethod
+from typing import Dict, List, Any, Optional, Tuple
+import random
+import logging
+import re
+
+from pydantic import BaseModel, Field
+
+from .competence_profiles import CompetenceProfile
+from .config import (
+ LLMStrategyConfig,
+ BiasedStrategyConfig,
+ PatternStrategyConfig,
+ AgentStrategyConfig,
+ AnnotationStrategyType,
+)
+
+
+class _LLMResponse(BaseModel):
+ """Structured-output schema for the per-schema LLMStrategy query.
+
+ Endpoints that support structured output (Ollama, OllamaVision, OpenAI
+ via the responses API, etc.) pass this Pydantic class via
+ ``output_format`` so the model emits a deterministic JSON object. The
+ ``label`` field is interpreted by ``_parse_llm_result`` according to
+ the schema's annotation_type (label name / number / free text).
+ """
+
+ label: str = Field(
+ default="",
+ description=(
+ "The single label, integer, or short text the annotator chose."
+ ),
+ )
+
+logger = logging.getLogger(__name__)
+
+
+class AnnotationStrategy(ABC):
+ """Abstract base class for annotation strategies.
+
+ Annotation strategies determine how a simulated user generates
+ annotations for different schema types.
+ """
+
+ @abstractmethod
+ def generate_annotation(
+ self,
+ instance: Dict[str, Any],
+ schema: Dict[str, Any],
+ competence: CompetenceProfile,
+ gold_answer: Optional[Dict[str, Any]] = None,
+ ) -> Dict[str, Any]:
+ """Generate an annotation for the given instance and schema.
+
+ Args:
+ instance: The data instance containing text and metadata
+ schema: The annotation schema definition
+ competence: Competence profile for accuracy modeling
+ gold_answer: Gold standard answer if available (for competence)
+
+ Returns:
+ Dictionary with annotation data in the format expected by the API
+ """
+ pass
+
+
+class RandomStrategy(AnnotationStrategy):
+ """Random annotation selection strategy.
+
+ Selects labels uniformly at random. When gold standards are available
+ and competence should be correct, uses the gold answer instead.
+ """
+
+ def generate_annotation(
+ self,
+ instance: Dict[str, Any],
+ schema: Dict[str, Any],
+ competence: CompetenceProfile,
+ gold_answer: Optional[Dict[str, Any]] = None,
+ ) -> Dict[str, Any]:
+ """Generate random annotation.
+
+ Args:
+ instance: Data instance
+ schema: Annotation schema
+ competence: Competence profile
+ gold_answer: Gold standard if available
+
+ Returns:
+ Annotation dictionary
+ """
+ # Check both 'annotation_type' (config format) and 'type' (API format)
+ annotation_type = schema.get("annotation_type") or schema.get("type")
+ labels = self._extract_labels(schema)
+ schema_name = schema.get("name")
+
+ # If we have gold answer and competence says be correct, use gold
+ if gold_answer and schema_name in gold_answer:
+ if competence.should_be_correct():
+ return self._format_gold_answer(schema_name, gold_answer[schema_name], annotation_type)
+ else:
+ # Select wrong answer
+ correct = gold_answer[schema_name]
+ wrong = competence.select_wrong_answer(str(correct), labels)
+ return self._format_annotation(schema_name, wrong, annotation_type)
+
+ # No gold standard - just random selection
+ return self._generate_by_type(annotation_type, schema, labels, instance)
+
+ def _extract_labels(self, schema: Dict[str, Any]) -> List[str]:
+ """Extract label options from schema.
+
+ Args:
+ schema: Annotation schema
+
+ Returns:
+ List of label names
+ """
+ labels = schema.get("labels", [])
+ if not labels:
+ return []
+
+ if isinstance(labels[0], dict):
+ return [l.get("name") for l in labels if l.get("name")]
+ return [str(l) for l in labels]
+
+ def _format_gold_answer(
+ self, schema_name: str, gold_value: Any, annotation_type: str
+ ) -> Dict[str, Any]:
+ """Format gold answer as annotation.
+
+ Args:
+ schema_name: Schema name
+ gold_value: Gold standard value
+ annotation_type: Type of annotation
+
+ Returns:
+ Formatted annotation
+ """
+ return self._format_annotation(schema_name, gold_value, annotation_type)
+
+ def _format_annotation(
+ self, schema_name: str, value: Any, annotation_type: str
+ ) -> Dict[str, Any]:
+ """Format a value as an annotation.
+
+ Args:
+ schema_name: Schema name
+ value: Annotation value
+ annotation_type: Type of annotation
+
+ Returns:
+ Formatted annotation dictionary in the format expected by the server
+ (schema:value -> "on" for selection types)
+ """
+ if annotation_type == "multiselect":
+ if isinstance(value, list):
+ return {f"{schema_name}:{v}": "on" for v in value}
+ return {f"{schema_name}:{value}": "on"}
+ elif annotation_type in ["radio", "likert"]:
+ # Selection-based types use schema:value format
+ return {f"{schema_name}:{value}": "on"}
+ elif annotation_type in ["slider", "number"]:
+ # Numeric types store the value
+ return {f"{schema_name}:{value}": str(value)}
+ elif annotation_type in ["text", "textbox"]:
+ # Text types store the text content
+ return {f"{schema_name}:text": str(value)}
+ else:
+ # Default: use schema:value format
+ return {f"{schema_name}:{value}": "on"}
+
+ def _generate_by_type(
+ self,
+ annotation_type: str,
+ schema: Dict[str, Any],
+ labels: List[str],
+ instance: Dict[str, Any],
+ ) -> Dict[str, Any]:
+ """Generate annotation based on schema type.
+
+ Args:
+ annotation_type: Type of annotation
+ schema: Full schema definition
+ labels: Available labels
+ instance: Data instance
+
+ Returns:
+ Generated annotation
+ """
+ schema_name = schema.get("name")
+
+ if annotation_type == "radio":
+ if labels:
+ selected_label = random.choice(labels)
+ # Format as "schema:label": "on" to match frontend format
+ return {f"{schema_name}:{selected_label}": "on"}
+ return {}
+
+ elif annotation_type == "multiselect":
+ if labels:
+ # Select 1-3 random labels
+ num_selections = random.randint(1, min(3, len(labels)))
+ selections = random.sample(labels, num_selections)
+ return {f"{schema_name}:{label}": "on" for label in selections}
+ return {}
+
+ elif annotation_type == "likert":
+ size = schema.get("size", 5)
+ selected_value = str(random.randint(1, size))
+ # Format as "schema:value": "on" to match frontend format
+ return {f"{schema_name}:{selected_value}": "on"}
+
+ elif annotation_type == "slider":
+ min_val = schema.get("min_value", schema.get("min", 0))
+ max_val = schema.get("max_value", schema.get("max", 100))
+ selected_value = str(random.randint(min_val, max_val))
+ # Format as "schema:value": "value" for slider
+ return {f"{schema_name}:{selected_value}": selected_value}
+
+ elif annotation_type in ["text", "textbox"]:
+ text_response = self._generate_text_response(instance)
+ # Format as "schema:text": "value" for textbox
+ return {f"{schema_name}:text": text_response}
+
+ elif annotation_type == "number":
+ min_val = schema.get("min_value", 0)
+ max_val = schema.get("max_value", 100)
+ selected_value = str(random.randint(min_val, max_val))
+ # Format as "schema:value": "value"
+ return {f"{schema_name}:{selected_value}": selected_value}
+
+ elif annotation_type == "span":
+ return self._generate_span_annotation(instance, schema, labels)
+
+ else:
+ logger.warning(f"Unknown annotation type: {annotation_type}")
+ if labels:
+ return {schema_name: random.choice(labels)}
+ return {}
+
+ def _generate_text_response(self, instance: Dict[str, Any]) -> str:
+ """Generate a placeholder text response.
+
+ Args:
+ instance: Data instance
+
+ Returns:
+ Generated text
+ """
+ responses = [
+ "Simulated annotation response.",
+ "This is a test response.",
+ "Generated text for testing purposes.",
+ "Sample annotation text.",
+ ]
+ return random.choice(responses)
+
+ def _generate_span_annotation(
+ self,
+ instance: Dict[str, Any],
+ schema: Dict[str, Any],
+ labels: List[str],
+ ) -> Dict[str, Any]:
+ """Generate span annotations for text.
+
+ Args:
+ instance: Data instance with text
+ schema: Span annotation schema
+ labels: Available span labels
+
+ Returns:
+ Span annotation dictionary
+ """
+ text = instance.get("text", "")
+ if not text or not labels:
+ return {}
+
+ words = text.split()
+ if len(words) < 2:
+ return {}
+
+ # Generate 0-3 random spans
+ num_spans = random.randint(0, min(3, len(words) // 2))
+ schema_name = schema.get("name", "spans")
+ annotations = {}
+
+ for _ in range(num_spans):
+ start_word_idx = random.randint(0, len(words) - 2)
+ end_word_idx = random.randint(
+ start_word_idx + 1, min(start_word_idx + 5, len(words))
+ )
+
+ # Calculate character offsets
+ start_char = sum(len(w) + 1 for w in words[:start_word_idx])
+ end_char = sum(len(w) + 1 for w in words[:end_word_idx]) - 1
+
+ label = random.choice(labels)
+ span_key = f"{schema_name}:{label}:{start_char}:{end_char}"
+ annotations[span_key] = "true"
+
+ return annotations
+
+
+class BiasedStrategy(AnnotationStrategy):
+ """Annotation strategy with configurable label biases.
+
+ Selects labels according to configured weights, allowing simulation
+ of annotators with specific label preferences.
+ """
+
+ def __init__(self, config: BiasedStrategyConfig):
+ """Initialize biased strategy.
+
+ Args:
+ config: Configuration with label weights
+ """
+ self.config = config
+ self.random_strategy = RandomStrategy()
+
+ def generate_annotation(
+ self,
+ instance: Dict[str, Any],
+ schema: Dict[str, Any],
+ competence: CompetenceProfile,
+ gold_answer: Optional[Dict[str, Any]] = None,
+ ) -> Dict[str, Any]:
+ """Generate biased annotation.
+
+ Args:
+ instance: Data instance
+ schema: Annotation schema
+ competence: Competence profile
+ gold_answer: Gold standard if available
+
+ Returns:
+ Annotation dictionary
+ """
+ # If gold answer available and competence says be correct, use it
+ if gold_answer and competence.should_be_correct():
+ schema_name = schema.get("name")
+ if schema_name in gold_answer:
+ annotation_type = schema.get("annotation_type") or schema.get("type")
+ return self.random_strategy._format_gold_answer(
+ schema_name, gold_answer[schema_name], annotation_type
+ )
+
+ annotation_type = schema.get("annotation_type") or schema.get("type")
+ labels = self.random_strategy._extract_labels(schema)
+ schema_name = schema.get("name")
+
+ if annotation_type in ["radio", "multiselect"] and labels:
+ # Use weighted selection based on bias config
+ weights = [self.config.label_weights.get(l, 1.0) for l in labels]
+ total = sum(weights)
+ if total > 0:
+ weights = [w / total for w in weights]
+ else:
+ weights = [1.0 / len(labels)] * len(labels)
+
+ selected = random.choices(labels, weights=weights, k=1)[0]
+
+ # Use consistent schema:value format for all selection types
+ return {f"{schema_name}:{selected}": "on"}
+
+ # Fall back to random for other types
+ return self.random_strategy.generate_annotation(
+ instance, schema, competence, gold_answer
+ )
+
+
+class LLMStrategy(AnnotationStrategy):
+ """LLM-powered annotation strategy.
+
+ Uses the existing potato.ai infrastructure to generate realistic
+ annotations based on text content.
+ """
+
+ def __init__(self, config: LLMStrategyConfig):
+ """Initialize LLM strategy.
+
+ Args:
+ config: LLM configuration
+ """
+ self.config = config
+ self.endpoint = self._create_endpoint()
+ self.random_strategy = RandomStrategy()
+
+ def _create_endpoint(self):
+ """Create LLM endpoint using existing infrastructure.
+
+ Returns:
+ AI endpoint or None if creation fails
+ """
+ try:
+ from potato.ai.ai_endpoint import AIEndpointFactory
+
+ ai_config = {
+ "ai_support": {
+ "enabled": True,
+ "endpoint_type": self.config.endpoint_type,
+ "ai_config": {
+ "model": self.config.model,
+ "api_key": self.config.api_key,
+ "max_tokens": self.config.max_tokens,
+ "temperature": self.config.temperature,
+ },
+ }
+ }
+
+ if self.config.base_url:
+ ai_config["ai_support"]["ai_config"]["base_url"] = self.config.base_url
+
+ return AIEndpointFactory.create_endpoint(ai_config)
+
+ except Exception as e:
+ logger.warning(f"Failed to create LLM endpoint: {e}")
+ return None
+
+ def generate_annotation(
+ self,
+ instance: Dict[str, Any],
+ schema: Dict[str, Any],
+ competence: CompetenceProfile,
+ gold_answer: Optional[Dict[str, Any]] = None,
+ ) -> Dict[str, Any]:
+ """Generate LLM-based annotation.
+
+ Args:
+ instance: Data instance
+ schema: Annotation schema
+ competence: Competence profile
+ gold_answer: Gold standard if available
+
+ Returns:
+ Annotation dictionary
+ """
+ if not self.endpoint:
+ logger.warning("LLM endpoint not available, falling back to random")
+ return self.random_strategy.generate_annotation(
+ instance, schema, competence, gold_answer
+ )
+
+ try:
+ annotation_type = schema.get("annotation_type") or schema.get("type")
+ labels = self.random_strategy._extract_labels(schema)
+ schema_name = schema.get("name")
+ description = schema.get("description", "")
+ text = instance.get("text", "")
+
+ # Build prompt for LLM
+ prompt = self._build_prompt(text, labels, description, annotation_type)
+
+ # Query LLM. Most endpoints (Ollama, OllamaVision, OpenAI) require
+ # a Pydantic schema as the second argument; AnthropicEndpoint's
+ # query() takes only `prompt`; VLLMEndpoint accepts both.
+ try:
+ result = self.endpoint.query(prompt, _LLMResponse)
+ except TypeError:
+ # Endpoint signature is query(prompt) โ single-arg providers
+ result = self.endpoint.query(prompt)
+
+ # Add noise if configured
+ if self.config.add_noise and random.random() < self.config.noise_rate:
+ logger.debug("Adding noise to LLM response")
+ return self.random_strategy.generate_annotation(
+ instance, schema, competence, gold_answer
+ )
+
+ # Parse result
+ parsed = self._parse_llm_result(result, labels, schema_name, annotation_type)
+ if parsed:
+ return parsed
+
+ # Fallback to random
+ return self.random_strategy.generate_annotation(
+ instance, schema, competence, gold_answer
+ )
+
+ except Exception as e:
+ logger.warning(f"LLM annotation failed: {e}")
+ return self.random_strategy.generate_annotation(
+ instance, schema, competence, gold_answer
+ )
+
+ def _build_prompt(
+ self,
+ text: str,
+ labels: List[str],
+ description: str,
+ annotation_type: str,
+ ) -> str:
+ """Build prompt for LLM.
+
+ Args:
+ text: Text to annotate
+ labels: Available labels
+ description: Task description
+ annotation_type: Type of annotation
+
+ Returns:
+ Prompt string
+ """
+ labels_str = ", ".join(labels)
+
+ if annotation_type in ["radio", "multiselect"]:
+ prompt = f"""You are an annotator. Given the following text, select the most appropriate label.
+
+Task: {description if description else 'Classify the text'}
+Labels: {labels_str}
+
+Text: {text[:500]}
+
+Respond with ONLY the label name, nothing else."""
+ elif annotation_type == "likert":
+ prompt = f"""You are an annotator. Rate the following text on a scale.
+
+Task: {description if description else 'Rate the text'}
+
+Text: {text[:500]}
+
+Respond with ONLY a number from 1-5, nothing else."""
+ else:
+ prompt = f"""You are an annotator. Analyze the following text.
+
+Task: {description if description else 'Analyze the text'}
+
+Text: {text[:500]}
+
+Respond briefly."""
+
+ return prompt
+
+ def _parse_llm_result(
+ self,
+ result: Any,
+ labels: List[str],
+ schema_name: str,
+ annotation_type: str,
+ ) -> Optional[Dict[str, Any]]:
+ """Parse LLM result into annotation format.
+
+ Args:
+ result: LLM response (string, dict, or Pydantic model instance)
+ labels: Available labels
+ schema_name: Schema name
+ annotation_type: Type of annotation
+
+ Returns:
+ Parsed annotation or None
+ """
+ if result is None:
+ return None
+
+ # Structured-output endpoints return either a Pydantic model or a dict
+ # with a ``label`` key (or, for OllamaEndpoint's parseStringToJson
+ # fallback, ``response`` / ``content``). Extract that into a string
+ # for the existing matching logic.
+ if hasattr(result, "model_dump"):
+ try:
+ result = result.model_dump()
+ except Exception:
+ pass
+ if isinstance(result, dict):
+ for key in ("label", "response", "content"):
+ if key in result and result[key] not in (None, ""):
+ result = result[key]
+ break
+ else:
+ # Dict with no recognised key โ stringify everything
+ result = str(result)
+
+ # Convert to string
+ result_str = str(result).strip().lower()
+
+ if annotation_type in ["radio", "multiselect"]:
+ # Try to match a label
+ for label in labels:
+ if label.lower() in result_str or result_str in label.lower():
+ return self.random_strategy._format_annotation(
+ schema_name, label, annotation_type
+ )
+
+ elif annotation_type == "likert":
+ # Try to extract a number
+ numbers = re.findall(r"\d+", result_str)
+ if numbers:
+ return self.random_strategy._format_annotation(
+ schema_name, numbers[0], annotation_type
+ )
+
+ elif annotation_type in ["text", "textbox"]:
+ return self.random_strategy._format_annotation(
+ schema_name, str(result)[:500], annotation_type
+ )
+
+ return None
+
+
+class PatternStrategy(AnnotationStrategy):
+ """Pattern-based annotation strategy for consistent user behavior.
+
+ Allows defining specific behavior patterns per user for testing
+ scenarios that require consistent annotation patterns.
+ """
+
+ def __init__(self, config: PatternStrategyConfig, user_id: str):
+ """Initialize pattern strategy.
+
+ Args:
+ config: Pattern configuration
+ user_id: User ID for pattern lookup
+ """
+ self.config = config
+ self.user_id = user_id
+ self.user_pattern = config.patterns.get(user_id, {})
+ self.random_strategy = RandomStrategy()
+
+ def generate_annotation(
+ self,
+ instance: Dict[str, Any],
+ schema: Dict[str, Any],
+ competence: CompetenceProfile,
+ gold_answer: Optional[Dict[str, Any]] = None,
+ ) -> Dict[str, Any]:
+ """Generate pattern-based annotation.
+
+ Args:
+ instance: Data instance
+ schema: Annotation schema
+ competence: Competence profile
+ gold_answer: Gold standard if available
+
+ Returns:
+ Annotation dictionary
+ """
+ preferred_label = self.user_pattern.get("preferred_label")
+ bias_strength = self.user_pattern.get("bias_strength", 0.5)
+
+ # Check for keyword patterns
+ text = instance.get("text", "").lower()
+ keyword_labels = self.user_pattern.get("keywords", {})
+ for keyword, label in keyword_labels.items():
+ if keyword.lower() in text:
+ return self.random_strategy._format_annotation(
+ schema.get("name"), label, schema.get("annotation_type")
+ )
+
+ # Use preferred label with configured probability
+ if preferred_label and random.random() < bias_strength:
+ labels = self.random_strategy._extract_labels(schema)
+ if preferred_label in labels:
+ return self.random_strategy._format_annotation(
+ schema.get("name"),
+ preferred_label,
+ schema.get("annotation_type"),
+ )
+
+ # Fall back to random
+ return self.random_strategy.generate_annotation(
+ instance, schema, competence, gold_answer
+ )
+
+
+class GoldStandardStrategy(AnnotationStrategy):
+ """Strategy that uses gold standard answers when available.
+
+ This is primarily useful for testing quality control systems
+ by providing known correct annotations.
+ """
+
+ def __init__(self):
+ self.random_strategy = RandomStrategy()
+
+ def generate_annotation(
+ self,
+ instance: Dict[str, Any],
+ schema: Dict[str, Any],
+ competence: CompetenceProfile,
+ gold_answer: Optional[Dict[str, Any]] = None,
+ ) -> Dict[str, Any]:
+ """Generate annotation from gold standard.
+
+ Args:
+ instance: Data instance
+ schema: Annotation schema
+ competence: Competence profile (determines if we use gold)
+ gold_answer: Gold standard if available
+
+ Returns:
+ Annotation dictionary
+ """
+ schema_name = schema.get("name")
+ annotation_type = schema.get("annotation_type") or schema.get("type")
+
+ if gold_answer and schema_name in gold_answer:
+ # Use competence to decide if we get it right
+ if competence.should_be_correct():
+ return self.random_strategy._format_gold_answer(
+ schema_name, gold_answer[schema_name], annotation_type
+ )
+ else:
+ # Select wrong answer
+ labels = self.random_strategy._extract_labels(schema)
+ correct = str(gold_answer[schema_name])
+ wrong = competence.select_wrong_answer(correct, labels)
+ return self.random_strategy._format_annotation(
+ schema_name, wrong, annotation_type
+ )
+
+ # No gold standard - fall back to random
+ return self.random_strategy.generate_annotation(
+ instance, schema, competence, gold_answer
+ )
+
+
+def create_strategy(
+ strategy_type: AnnotationStrategyType,
+ llm_config: Optional[LLMStrategyConfig] = None,
+ biased_config: Optional[BiasedStrategyConfig] = None,
+ pattern_config: Optional[PatternStrategyConfig] = None,
+ agent_config: Optional[AgentStrategyConfig] = None,
+ user_id: str = "",
+) -> AnnotationStrategy:
+ """Factory function to create annotation strategies.
+
+ Args:
+ strategy_type: Type of strategy to create
+ llm_config: LLM configuration (for LLM strategy)
+ biased_config: Bias configuration (for biased strategy)
+ pattern_config: Pattern configuration (for pattern strategy)
+ agent_config: Agent (vision-LLM) configuration (for AGENT strategy)
+ user_id: User ID (for pattern strategy)
+
+ Returns:
+ AnnotationStrategy instance
+ """
+ if strategy_type == AnnotationStrategyType.RANDOM:
+ return RandomStrategy()
+
+ elif strategy_type == AnnotationStrategyType.BIASED:
+ if biased_config:
+ return BiasedStrategy(biased_config)
+ return RandomStrategy()
+
+ elif strategy_type == AnnotationStrategyType.LLM:
+ if llm_config:
+ return LLMStrategy(llm_config)
+ logger.warning("LLM strategy requested but no config provided, using random")
+ return RandomStrategy()
+
+ elif strategy_type == AnnotationStrategyType.PATTERN:
+ if pattern_config:
+ return PatternStrategy(pattern_config, user_id)
+ return RandomStrategy()
+
+ elif strategy_type == AnnotationStrategyType.GOLD_STANDARD:
+ return GoldStandardStrategy()
+
+ elif strategy_type == AnnotationStrategyType.AGENT:
+ # Local import to avoid pulling pydantic / vision deps unless used
+ from .agent_strategy import AgentSimulatorStrategy
+ if agent_config:
+ return AgentSimulatorStrategy(agent_config)
+ logger.warning("AGENT strategy requested but no agent_config provided, using random")
+ return RandomStrategy()
+
+ else:
+ return RandomStrategy()
diff --git a/potato/simulator/cli.py b/potato/simulator/cli.py
new file mode 100644
index 0000000000000000000000000000000000000000..7d4bb5146b2e9a50ea3172aa9b537bf93206d1ad
--- /dev/null
+++ b/potato/simulator/cli.py
@@ -0,0 +1,369 @@
+"""
+Command-line interface for the user simulator.
+
+Usage:
+ python -m potato.simulator --server http://localhost:8000 --users 10
+ python -m potato.simulator --config simulator-config.yaml --server http://localhost:8000
+"""
+
+import argparse
+import logging
+import sys
+import os
+
+from .config import (
+ SimulatorConfig,
+ TimingConfig,
+ LLMStrategyConfig,
+ BiasedStrategyConfig,
+ AnnotationStrategyType,
+)
+from .simulator_manager import SimulatorManager
+
+
+def setup_logging(verbose: bool = False) -> None:
+ """Configure logging for the CLI.
+
+ Args:
+ verbose: If True, enable debug logging
+ """
+ level = logging.DEBUG if verbose else logging.INFO
+ logging.basicConfig(
+ level=level,
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
+ datefmt="%H:%M:%S",
+ )
+
+ # Suppress noisy loggers
+ if not verbose:
+ logging.getLogger("urllib3").setLevel(logging.WARNING)
+ logging.getLogger("requests").setLevel(logging.WARNING)
+
+
+def parse_args() -> argparse.Namespace:
+ """Parse command-line arguments.
+
+ Returns:
+ Parsed arguments namespace
+ """
+ parser = argparse.ArgumentParser(
+ description="User Simulator for Potato Annotation Platform",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog="""
+Examples:
+ # Basic random simulation
+ python -m potato.simulator --server http://localhost:8000 --users 10
+
+ # With configuration file
+ python -m potato.simulator --config simulator.yaml --server http://localhost:8000
+
+ # LLM-powered simulation with Ollama
+ python -m potato.simulator --server http://localhost:8000 --users 5 \\
+ --strategy llm --llm-endpoint ollama --llm-model llama3.2
+
+ # Biased simulation
+ python -m potato.simulator --server http://localhost:8000 --users 20 \\
+ --strategy biased --bias-weights positive=0.6,negative=0.3,neutral=0.1
+
+ # Fast scalability test
+ python -m potato.simulator --server http://localhost:8000 --users 100 \\
+ --parallel 20 --max-annotations 5 --fast-mode
+""",
+ )
+
+ # Required arguments
+ parser.add_argument(
+ "--server",
+ "-s",
+ required=True,
+ help="Potato server URL (e.g., http://localhost:8000)",
+ )
+
+ # Configuration file (alternative to CLI args)
+ parser.add_argument(
+ "--config",
+ "-c",
+ help="Path to YAML configuration file",
+ )
+
+ # User configuration
+ parser.add_argument(
+ "--users",
+ "-u",
+ type=int,
+ default=10,
+ help="Number of simulated users (default: 10)",
+ )
+ parser.add_argument(
+ "--competence",
+ help="Competence distribution as comma-separated key=value pairs "
+ "(e.g., good=0.5,average=0.3,poor=0.2)",
+ )
+
+ # Strategy configuration
+ parser.add_argument(
+ "--strategy",
+ choices=["random", "biased", "llm", "pattern", "gold_standard"],
+ default="random",
+ help="Annotation strategy (default: random)",
+ )
+
+ # LLM configuration
+ parser.add_argument(
+ "--llm-endpoint",
+ choices=["openai", "anthropic", "ollama", "gemini", "huggingface", "vllm"],
+ help="LLM endpoint type (for --strategy llm)",
+ )
+ parser.add_argument(
+ "--llm-model",
+ help="LLM model name (for --strategy llm)",
+ )
+ parser.add_argument(
+ "--llm-api-key",
+ help="LLM API key (or set via environment variable)",
+ )
+ parser.add_argument(
+ "--llm-base-url",
+ help="LLM base URL (for local endpoints like Ollama)",
+ )
+
+ # Biased strategy configuration
+ parser.add_argument(
+ "--bias-weights",
+ help="Label bias weights as comma-separated key=value pairs "
+ "(e.g., positive=0.6,negative=0.3,neutral=0.1)",
+ )
+
+ # Execution configuration
+ parser.add_argument(
+ "--parallel",
+ "-p",
+ type=int,
+ default=5,
+ help="Maximum concurrent users (default: 5)",
+ )
+ parser.add_argument(
+ "--max-annotations",
+ "-m",
+ type=int,
+ help="Maximum annotations per user (default: unlimited)",
+ )
+ parser.add_argument(
+ "--sequential",
+ action="store_true",
+ help="Run users sequentially instead of in parallel",
+ )
+
+ # Timing configuration
+ parser.add_argument(
+ "--fast-mode",
+ action="store_true",
+ help="Disable waiting between annotations (for testing)",
+ )
+ parser.add_argument(
+ "--timing-min",
+ type=float,
+ default=2.0,
+ help="Minimum annotation time in seconds (default: 2.0)",
+ )
+ parser.add_argument(
+ "--timing-max",
+ type=float,
+ default=30.0,
+ help="Maximum annotation time in seconds (default: 30.0)",
+ )
+
+ # Quality control testing
+ parser.add_argument(
+ "--attention-fail-rate",
+ type=float,
+ default=0.0,
+ help="Rate at which to fail attention checks (0-1, default: 0)",
+ )
+ parser.add_argument(
+ "--fast-response-rate",
+ type=float,
+ default=0.0,
+ help="Rate of suspiciously fast responses (0-1, default: 0)",
+ )
+
+ # Gold standards
+ parser.add_argument(
+ "--gold-file",
+ help="Path to JSON file with gold standard answers",
+ )
+
+ # Output configuration
+ parser.add_argument(
+ "--output-dir",
+ "-o",
+ default="simulator_output",
+ help="Output directory for results (default: simulator_output)",
+ )
+ parser.add_argument(
+ "--no-export",
+ action="store_true",
+ help="Don't export results to files",
+ )
+
+ # Other options
+ parser.add_argument(
+ "--verbose",
+ "-v",
+ action="store_true",
+ help="Enable verbose logging",
+ )
+
+ return parser.parse_args()
+
+
+def parse_key_value_pairs(s: str) -> dict:
+ """Parse comma-separated key=value pairs.
+
+ Args:
+ s: String like "key1=val1,key2=val2"
+
+ Returns:
+ Dictionary of parsed pairs
+ """
+ result = {}
+ if not s:
+ return result
+
+ for pair in s.split(","):
+ if "=" in pair:
+ key, value = pair.split("=", 1)
+ # Try to convert to float
+ try:
+ result[key.strip()] = float(value.strip())
+ except ValueError:
+ result[key.strip()] = value.strip()
+
+ return result
+
+
+def build_config_from_args(args: argparse.Namespace) -> SimulatorConfig:
+ """Build SimulatorConfig from CLI arguments.
+
+ Args:
+ args: Parsed arguments
+
+ Returns:
+ SimulatorConfig instance
+ """
+ # If config file provided, use it as base
+ if args.config:
+ config = SimulatorConfig.from_yaml(args.config)
+ else:
+ config = SimulatorConfig()
+
+ # Override with CLI arguments
+ config.user_count = args.users
+ config.parallel_users = args.parallel
+ config.simulate_wait = not args.fast_mode
+ config.attention_check_fail_rate = args.attention_fail_rate
+ config.respond_fast_rate = args.fast_response_rate
+ config.output_dir = args.output_dir
+
+ # Parse competence distribution
+ if args.competence:
+ config.competence_distribution = parse_key_value_pairs(args.competence)
+
+ # Parse strategy
+ try:
+ config.strategy = AnnotationStrategyType(args.strategy)
+ except ValueError:
+ config.strategy = AnnotationStrategyType.RANDOM
+
+ # LLM configuration
+ if args.strategy == "llm" and args.llm_endpoint:
+ api_key = args.llm_api_key
+ if not api_key:
+ # Try common environment variables
+ env_vars = {
+ "openai": "OPENAI_API_KEY",
+ "anthropic": "ANTHROPIC_API_KEY",
+ "huggingface": "HF_TOKEN",
+ "gemini": "GOOGLE_API_KEY",
+ }
+ env_var = env_vars.get(args.llm_endpoint)
+ if env_var:
+ api_key = os.environ.get(env_var)
+
+ config.llm_config = LLMStrategyConfig(
+ endpoint_type=args.llm_endpoint,
+ model=args.llm_model,
+ api_key=api_key,
+ base_url=args.llm_base_url,
+ )
+
+ # Biased configuration
+ if args.strategy == "biased" and args.bias_weights:
+ config.biased_config = BiasedStrategyConfig(
+ label_weights=parse_key_value_pairs(args.bias_weights)
+ )
+
+ # Timing configuration
+ config.timing = TimingConfig(
+ annotation_time_min=args.timing_min,
+ annotation_time_max=args.timing_max,
+ )
+
+ # Gold standards file
+ if args.gold_file:
+ config.gold_standard_file = args.gold_file
+
+ return config
+
+
+def main() -> int:
+ """Main entry point for CLI.
+
+ Returns:
+ Exit code (0 for success, 1 for error)
+ """
+ args = parse_args()
+ setup_logging(args.verbose)
+
+ logger = logging.getLogger(__name__)
+
+ try:
+ # Build configuration
+ config = build_config_from_args(args)
+
+ logger.info(f"Starting simulator with {config.user_count} users")
+ logger.info(f"Server: {args.server}")
+ logger.info(f"Strategy: {config.strategy.value}")
+
+ # Create manager
+ manager = SimulatorManager(config, args.server)
+
+ # Run simulation
+ if args.sequential:
+ results = manager.run_sequential(args.max_annotations)
+ else:
+ results = manager.run_parallel(args.max_annotations)
+
+ # Print summary
+ manager.print_summary()
+
+ # Export results
+ if not args.no_export:
+ manager.export_results()
+
+ return 0
+
+ except KeyboardInterrupt:
+ logger.info("Simulation interrupted by user")
+ return 1
+
+ except Exception as e:
+ logger.error(f"Simulation failed: {e}")
+ if args.verbose:
+ import traceback
+ traceback.print_exc()
+ return 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/potato/simulator/competence_profiles.py b/potato/simulator/competence_profiles.py
new file mode 100644
index 0000000000000000000000000000000000000000..6959ae8b8a55080c1054275effd06c6eeef9ed04
--- /dev/null
+++ b/potato/simulator/competence_profiles.py
@@ -0,0 +1,223 @@
+"""
+Competence profiles for simulated users.
+
+This module defines different competence levels that determine how
+accurately a simulated user will annotate items.
+
+When gold standards are available, competence determines the probability
+of selecting the gold answer. Without gold standards, competence affects
+consistency and selection patterns.
+"""
+
+from abc import ABC, abstractmethod
+from typing import List, Optional, Dict, Any
+import random
+
+from .config import CompetenceLevel
+
+
+class CompetenceProfile(ABC):
+ """Abstract base class for competence profiles.
+
+ Competence profiles determine:
+ 1. Whether to select the correct answer (if gold standard available)
+ 2. How to select an answer when being incorrect
+ """
+
+ @abstractmethod
+ def should_be_correct(self) -> bool:
+ """Determine if this annotation should be correct.
+
+ Returns:
+ True if the annotation should match the gold standard
+ """
+ pass
+
+ @abstractmethod
+ def select_wrong_answer(self, correct: str, options: List[str]) -> str:
+ """Select an incorrect answer when making a mistake.
+
+ Args:
+ correct: The correct answer (to avoid)
+ options: All available options
+
+ Returns:
+ A selected incorrect option
+ """
+ pass
+
+ def get_accuracy(self) -> float:
+ """Get the expected accuracy for this profile.
+
+ Returns:
+ Expected accuracy as a float between 0 and 1
+ """
+ return 0.5
+
+
+class PerfectCompetence(CompetenceProfile):
+ """Always correct annotations (100% accuracy).
+
+ Use this for testing gold standard tracking or simulating
+ expert annotators.
+ """
+
+ def should_be_correct(self) -> bool:
+ return True
+
+ def select_wrong_answer(self, correct: str, options: List[str]) -> str:
+ # Never called, but return correct just in case
+ return correct
+
+ def get_accuracy(self) -> float:
+ return 1.0
+
+
+class GoodCompetence(CompetenceProfile):
+ """High-quality annotator (80-90% accuracy).
+
+ Simulates a careful, well-trained annotator who occasionally
+ makes mistakes on ambiguous items.
+ """
+
+ def __init__(self, accuracy_range: tuple = (0.80, 0.90)):
+ self.accuracy = random.uniform(*accuracy_range)
+
+ def should_be_correct(self) -> bool:
+ return random.random() < self.accuracy
+
+ def select_wrong_answer(self, correct: str, options: List[str]) -> str:
+ wrong_options = [o for o in options if o != correct]
+ if wrong_options:
+ return random.choice(wrong_options)
+ return correct
+
+ def get_accuracy(self) -> float:
+ return self.accuracy
+
+
+class AverageCompetence(CompetenceProfile):
+ """Typical annotator (60-70% accuracy).
+
+ Simulates an average crowdworker with moderate attention
+ and understanding of the task.
+ """
+
+ def __init__(self, accuracy_range: tuple = (0.60, 0.70)):
+ self.accuracy = random.uniform(*accuracy_range)
+
+ def should_be_correct(self) -> bool:
+ return random.random() < self.accuracy
+
+ def select_wrong_answer(self, correct: str, options: List[str]) -> str:
+ wrong_options = [o for o in options if o != correct]
+ if wrong_options:
+ return random.choice(wrong_options)
+ return correct
+
+ def get_accuracy(self) -> float:
+ return self.accuracy
+
+
+class PoorCompetence(CompetenceProfile):
+ """Low-quality annotator (40-50% accuracy).
+
+ Simulates an inattentive or untrained annotator who often
+ makes mistakes or doesn't fully understand the task.
+ """
+
+ def __init__(self, accuracy_range: tuple = (0.40, 0.50)):
+ self.accuracy = random.uniform(*accuracy_range)
+
+ def should_be_correct(self) -> bool:
+ return random.random() < self.accuracy
+
+ def select_wrong_answer(self, correct: str, options: List[str]) -> str:
+ wrong_options = [o for o in options if o != correct]
+ if wrong_options:
+ return random.choice(wrong_options)
+ return correct
+
+ def get_accuracy(self) -> float:
+ return self.accuracy
+
+
+class RandomCompetence(CompetenceProfile):
+ """Random selection regardless of correct answer.
+
+ Does not use gold standards at all - simply selects randomly
+ from available options. Expected accuracy is ~1/N for N labels.
+ """
+
+ def should_be_correct(self) -> bool:
+ # Always select randomly - don't use gold standard
+ return False
+
+ def select_wrong_answer(self, correct: str, options: List[str]) -> str:
+ # Select uniformly at random from all options (including correct)
+ return random.choice(options)
+
+ def get_accuracy(self) -> float:
+ # Accuracy depends on number of options, estimate ~0.33 for 3 options
+ return 0.33
+
+
+class AdversarialCompetence(CompetenceProfile):
+ """Intentionally selects wrong answers.
+
+ Use this for testing quality control systems that should
+ detect and flag malicious annotators.
+ """
+
+ def should_be_correct(self) -> bool:
+ return False
+
+ def select_wrong_answer(self, correct: str, options: List[str]) -> str:
+ # Specifically avoid the correct answer
+ wrong_options = [o for o in options if o != correct]
+ if wrong_options:
+ return random.choice(wrong_options)
+ # If only one option, have to return it
+ return options[0] if options else correct
+
+ def get_accuracy(self) -> float:
+ return 0.0
+
+
+def create_competence_profile(level: CompetenceLevel) -> CompetenceProfile:
+ """Factory function to create competence profiles.
+
+ Args:
+ level: The competence level enum value
+
+ Returns:
+ A CompetenceProfile instance for the specified level
+ """
+ profiles = {
+ CompetenceLevel.PERFECT: PerfectCompetence,
+ CompetenceLevel.GOOD: GoodCompetence,
+ CompetenceLevel.AVERAGE: AverageCompetence,
+ CompetenceLevel.POOR: PoorCompetence,
+ CompetenceLevel.RANDOM: RandomCompetence,
+ CompetenceLevel.ADVERSARIAL: AdversarialCompetence,
+ }
+
+ profile_class = profiles.get(level, AverageCompetence)
+ return profile_class()
+
+
+def create_competence_profile_from_string(level_str: str) -> CompetenceProfile:
+ """Create competence profile from string name.
+
+ Args:
+ level_str: String name of competence level (e.g., "good", "average")
+
+ Returns:
+ A CompetenceProfile instance
+ """
+ try:
+ level = CompetenceLevel(level_str.lower())
+ except ValueError:
+ level = CompetenceLevel.AVERAGE
+
+ return create_competence_profile(level)
diff --git a/potato/simulator/config.py b/potato/simulator/config.py
new file mode 100644
index 0000000000000000000000000000000000000000..ddd0885591873344f6237017beaccebfd2046cec
--- /dev/null
+++ b/potato/simulator/config.py
@@ -0,0 +1,577 @@
+"""
+Configuration classes for the user simulator.
+
+This module defines all configuration dataclasses used to configure
+the simulator behavior, including user competence, timing, and strategies.
+"""
+
+from dataclasses import dataclass, field
+from typing import Dict, List, Optional, Any, Literal, Union
+from enum import Enum
+import os
+import yaml
+
+
+class CompetenceLevel(Enum):
+ """Competence levels for simulated users.
+
+ Each level defines a range of accuracy for the simulated annotator:
+ - PERFECT: 100% accuracy (always matches gold standard)
+ - GOOD: 80-90% accuracy
+ - AVERAGE: 60-70% accuracy
+ - POOR: 40-50% accuracy
+ - RANDOM: Random selection (~1/N accuracy for N labels)
+ - ADVERSARIAL: Intentionally wrong (avoids gold standard)
+ """
+
+ PERFECT = "perfect"
+ GOOD = "good"
+ AVERAGE = "average"
+ POOR = "poor"
+ RANDOM = "random"
+ ADVERSARIAL = "adversarial"
+
+
+class AnnotationStrategyType(Enum):
+ """Annotation generation strategies.
+
+ - RANDOM: Uniform random selection from available labels
+ - BIASED: Weighted random selection based on label preferences
+ - LLM: Use an LLM to generate annotations based on text content
+ - PATTERN: Consistent per-user patterns for testing specific behaviors
+ - GOLD_STANDARD: Use gold answer when available, random otherwise
+ - AGENT: Vision-capable LLM that reads structured / multi-modal instance
+ content (dialogue traces, spreadsheets, image fields) and emits a
+ single batched annotation covering every schema for the instance.
+ """
+
+ RANDOM = "random"
+ BIASED = "biased"
+ LLM = "llm"
+ PATTERN = "pattern"
+ GOLD_STANDARD = "gold_standard"
+ AGENT = "agent"
+
+
+@dataclass
+class TimingConfig:
+ """Configuration for annotation timing behavior.
+
+ Attributes:
+ annotation_time_min: Minimum time per annotation in seconds
+ annotation_time_max: Maximum time per annotation in seconds
+ annotation_time_mean: Mean time for normal distribution
+ annotation_time_std: Standard deviation for normal distribution
+ distribution: Timing distribution model (uniform, normal, exponential)
+ fast_response_threshold: Threshold for flagging suspiciously fast responses
+ session_duration_max: Maximum session duration in minutes (optional)
+ """
+
+ annotation_time_min: float = 2.0
+ annotation_time_max: float = 30.0
+ annotation_time_mean: float = 10.0
+ annotation_time_std: float = 5.0
+ distribution: Literal["uniform", "normal", "exponential"] = "normal"
+ fast_response_threshold: float = 1.0
+ session_duration_max: Optional[float] = None
+
+
+@dataclass
+class LLMStrategyConfig:
+ """Configuration for LLM-based annotation strategy.
+
+ Uses the existing potato.ai endpoint infrastructure.
+
+ Attributes:
+ endpoint_type: LLM provider (openai, anthropic, ollama, etc.)
+ model: Model name/identifier
+ api_key: API key for cloud providers (can use env var reference)
+ base_url: Base URL for local providers like Ollama
+ temperature: Temperature for generation (0-2)
+ max_tokens: Maximum tokens in response
+ add_noise: Whether to occasionally add noise to LLM outputs
+ noise_rate: Probability of adding noise (0-1)
+ """
+
+ endpoint_type: str = "openai"
+ model: Optional[str] = None # Uses provider default if None
+ api_key: Optional[str] = None
+ base_url: Optional[str] = None
+ temperature: float = 0.1
+ max_tokens: int = 100
+ add_noise: bool = True
+ noise_rate: float = 0.05
+
+
+@dataclass
+class InteractiveConfig:
+ """Configuration for driving live ``interactive_chat`` sessions.
+
+ When enabled, the simulator runs a multi-turn chat against the server's
+ ``/agent_chat/*`` routes before annotating each instance whose display
+ contains an ``interactive_chat`` field. The simulator plays the user
+ role; the server-side ``agent_proxy`` plays the agent (echo, OpenAI,
+ HTTP, etc. -- whatever the annotation config specifies).
+
+ Attributes:
+ enabled: Whether to attempt an interactive session per instance.
+ endpoint_type: AI endpoint used to generate the user persona's
+ messages. Defaults to ``ollama`` (text only -- the persona
+ usually doesn't need vision).
+ model: Persona model name. Defaults to provider default.
+ api_key: Optional API key (env-var refs supported).
+ base_url: Optional endpoint base URL.
+ temperature: Sampling temperature for persona messages.
+ max_tokens: Per-message token cap.
+ max_turns: Hard upper bound on turn count per session.
+ persona_system_prompt: System prompt that defines the user persona.
+ Should encourage natural multi-turn behavior and a clear
+ ``DONE`` signal when the task is complete.
+ done_marker: Substring (case-insensitive) the persona emits when
+ it considers the task complete. The runner finishes the
+ session immediately when seen.
+ first_message_template: Template applied to the persona's first
+ message. ``{task}`` is replaced with the task description. If
+ None, the persona generates the first message from scratch.
+ """
+
+ enabled: bool = False
+ endpoint_type: str = "ollama"
+ model: Optional[str] = None
+ api_key: Optional[str] = None
+ base_url: Optional[str] = None
+ temperature: float = 0.7
+ max_tokens: int = 200
+ max_turns: int = 6
+ persona_system_prompt: str = (
+ "You are a curious end-user testing an AI assistant. "
+ "Send concise, natural messages that drive the assistant to "
+ "complete the task. When the assistant has fully completed the "
+ "task, respond with a short acknowledgement and the literal "
+ "marker [DONE]."
+ )
+ done_marker: str = "[DONE]"
+ first_message_template: Optional[str] = (
+ "Please help me with this task: {task}"
+ )
+
+
+@dataclass
+class AgentStrategyConfig:
+ """Configuration for the agent (vision-LLM) annotation strategy.
+
+ Drives a vision-capable LLM that consumes structured / multi-modal
+ instance content (dialogue arrays, spreadsheets, image fields) and
+ produces a batched annotation over every schema for the instance.
+
+ Attributes:
+ endpoint_type: AI endpoint (default ``ollama_vision``). Any vision
+ endpoint registered with ``AIEndpointFactory`` works
+ (``anthropic_vision``, ``openai_vision``, etc.).
+ model: Model identifier (e.g. ``gemma3:4b``, ``llava:latest``,
+ ``llama3.2-vision``). Defaults to provider default.
+ api_key: Cloud-provider API key (env-var refs supported, e.g.
+ ``${ANTHROPIC_API_KEY}``).
+ base_url: Custom endpoint URL (Ollama: ``http://localhost:11434``).
+ temperature: Sampling temperature.
+ max_tokens: Cap on response tokens.
+ max_image_dim: Resize images so the longest edge is at most this
+ many pixels before sending. ``None`` keeps the original.
+ max_image_count: Skip image attachment past this many images per
+ instance (some models cap at 1โ4).
+ include_dialogue_text: Render dialogue arrays as
+ ``: `` lines in the prompt.
+ include_spreadsheet: Render spreadsheet/table fields as plain text.
+ max_dialogue_chars: Truncate long dialogue payloads to this many
+ characters in the prompt to fit the model's context window.
+ cache_per_instance: When True (default), one LLM call per instance
+ answers all schemas; subsequent ``generate_annotation`` calls
+ for the same instance return cached results.
+ add_noise: Probability of falling back to a random annotation per
+ schema (mirrors ``LLMStrategyConfig`` so existing competence
+ modeling still applies).
+ noise_rate: Probability used for noise injection (0โ1).
+ """
+
+ endpoint_type: str = "ollama_vision"
+ model: Optional[str] = None
+ api_key: Optional[str] = None
+ base_url: Optional[str] = None
+ temperature: float = 0.1
+ max_tokens: int = 800
+ max_image_dim: Optional[int] = 1024
+ max_image_count: int = 4
+ include_dialogue_text: bool = True
+ include_spreadsheet: bool = True
+ max_dialogue_chars: int = 12000
+ cache_per_instance: bool = True
+ add_noise: bool = False
+ noise_rate: float = 0.0
+
+
+@dataclass
+class BiasedStrategyConfig:
+ """Configuration for biased annotation strategy.
+
+ Attributes:
+ label_weights: Dictionary mapping label names to selection weights.
+ Higher weights mean higher probability of selection.
+ Example: {"positive": 0.6, "negative": 0.3, "neutral": 0.1}
+ """
+
+ label_weights: Dict[str, float] = field(default_factory=dict)
+
+
+@dataclass
+class PatternStrategyConfig:
+ """Configuration for pattern-based annotation strategy.
+
+ Allows defining specific behavior patterns per user.
+
+ Attributes:
+ patterns: Dictionary mapping user_id to behavior configuration.
+ Each pattern can specify:
+ - preferred_label: Label this user tends to select
+ - bias_strength: How strongly they prefer it (0-1)
+ - keywords: Text patterns that trigger specific labels
+ """
+
+ patterns: Dict[str, Dict[str, Any]] = field(default_factory=dict)
+
+
+@dataclass
+class UserConfig:
+ """Configuration for a single simulated user.
+
+ Attributes:
+ user_id: Unique identifier for this user
+ competence: Competence level determining accuracy
+ strategy: Annotation strategy type
+ timing: Timing configuration for this user
+ llm_config: LLM configuration if strategy is LLM
+ biased_config: Bias configuration if strategy is BIASED
+ pattern_config: Pattern configuration if strategy is PATTERN
+ max_annotations: Maximum annotations for this user (optional)
+ """
+
+ user_id: str
+ competence: CompetenceLevel = CompetenceLevel.AVERAGE
+ strategy: AnnotationStrategyType = AnnotationStrategyType.RANDOM
+ timing: TimingConfig = field(default_factory=TimingConfig)
+ llm_config: Optional[LLMStrategyConfig] = None
+ biased_config: Optional[BiasedStrategyConfig] = None
+ pattern_config: Optional[PatternStrategyConfig] = None
+ agent_config: Optional[AgentStrategyConfig] = None
+ max_annotations: Optional[int] = None
+
+
+@dataclass
+class SimulatorConfig:
+ """Master configuration for the user simulator.
+
+ Attributes:
+ user_count: Number of simulated users to create
+ competence_distribution: Distribution of competence levels
+ (keys are competence level names, values are proportions)
+ users: Explicit list of user configurations (overrides user_count)
+ timing: Global timing configuration (can be overridden per-user)
+ strategy: Default annotation strategy
+ llm_config: LLM configuration for LLM strategy
+ biased_config: Bias configuration for biased strategy
+ gold_standard_file: Path to JSON file with gold standard labels
+ parallel_users: Maximum concurrent users
+ delay_between_users: Delay between starting users (seconds)
+ attention_check_fail_rate: Rate at which users fail attention checks
+ respond_fast_rate: Rate of suspiciously fast responses
+ simulate_wait: Whether to actually wait between annotations
+ output_dir: Directory for output files
+ export_format: Output format (json, csv, jsonl)
+ """
+
+ # User configuration
+ user_count: int = 10
+ competence_distribution: Dict[str, float] = field(
+ default_factory=lambda: {"good": 0.5, "average": 0.3, "poor": 0.2}
+ )
+ users: List[UserConfig] = field(default_factory=list)
+
+ # Global timing configuration
+ timing: TimingConfig = field(default_factory=TimingConfig)
+
+ # Strategy configuration - default to random
+ strategy: AnnotationStrategyType = AnnotationStrategyType.RANDOM
+ llm_config: Optional[LLMStrategyConfig] = None
+ biased_config: Optional[BiasedStrategyConfig] = None
+ agent_config: Optional[AgentStrategyConfig] = None
+ interactive: Optional[InteractiveConfig] = None
+
+ # Gold standard data for competence-based accuracy
+ gold_standard_file: Optional[str] = None
+
+ # Execution configuration
+ parallel_users: int = 5
+ delay_between_users: float = 0.5
+
+ # Quality control testing options
+ attention_check_fail_rate: float = 0.0
+ respond_fast_rate: float = 0.0
+
+ # Whether to actually wait (set False for fast testing)
+ simulate_wait: bool = False
+
+ # Output configuration
+ output_dir: str = "simulator_output"
+ export_format: Literal["json", "csv", "jsonl"] = "json"
+
+ @classmethod
+ def from_yaml(cls, yaml_path: str) -> "SimulatorConfig":
+ """Load configuration from YAML file.
+
+ Args:
+ yaml_path: Path to YAML configuration file
+
+ Returns:
+ SimulatorConfig instance
+ """
+ with open(yaml_path, "r") as f:
+ data = yaml.safe_load(f)
+
+ return cls._parse_config(data)
+
+ @classmethod
+ def from_dict(cls, data: Dict[str, Any]) -> "SimulatorConfig":
+ """Load configuration from dictionary.
+
+ Args:
+ data: Configuration dictionary
+
+ Returns:
+ SimulatorConfig instance
+ """
+ return cls._parse_config(data)
+
+ @classmethod
+ def _parse_config(cls, data: Dict[str, Any]) -> "SimulatorConfig":
+ """Parse configuration from dictionary.
+
+ Args:
+ data: Raw configuration dictionary
+
+ Returns:
+ SimulatorConfig instance
+ """
+ # Handle nested 'simulator' key if present
+ if "simulator" in data:
+ data = data["simulator"]
+
+ # Parse timing config
+ timing = TimingConfig()
+ if "timing" in data:
+ timing_data = data["timing"]
+ if "annotation_time" in timing_data:
+ at = timing_data["annotation_time"]
+ timing = TimingConfig(
+ annotation_time_min=at.get("min", 2.0),
+ annotation_time_max=at.get("max", 30.0),
+ annotation_time_mean=at.get("mean", 10.0),
+ annotation_time_std=at.get("std", 5.0),
+ distribution=at.get("distribution", "normal"),
+ fast_response_threshold=timing_data.get(
+ "fast_response_threshold", 1.0
+ ),
+ session_duration_max=timing_data.get("session_duration_max"),
+ )
+ else:
+ timing = TimingConfig(
+ annotation_time_min=timing_data.get("annotation_time_min", 2.0),
+ annotation_time_max=timing_data.get("annotation_time_max", 30.0),
+ annotation_time_mean=timing_data.get("annotation_time_mean", 10.0),
+ annotation_time_std=timing_data.get("annotation_time_std", 5.0),
+ distribution=timing_data.get("distribution", "normal"),
+ fast_response_threshold=timing_data.get(
+ "fast_response_threshold", 1.0
+ ),
+ session_duration_max=timing_data.get("session_duration_max"),
+ )
+
+ # Parse LLM config
+ llm_config = None
+ if "llm_config" in data:
+ llm_data = data["llm_config"]
+ # Handle environment variable references
+ api_key = llm_data.get("api_key")
+ if api_key and api_key.startswith("${") and api_key.endswith("}"):
+ env_var = api_key[2:-1]
+ api_key = os.environ.get(env_var)
+
+ llm_config = LLMStrategyConfig(
+ endpoint_type=llm_data.get("endpoint_type", "openai"),
+ model=llm_data.get("model"),
+ api_key=api_key,
+ base_url=llm_data.get("base_url"),
+ temperature=llm_data.get("temperature", 0.1),
+ max_tokens=llm_data.get("max_tokens", 100),
+ add_noise=llm_data.get("add_noise", True),
+ noise_rate=llm_data.get("noise_rate", 0.05),
+ )
+
+ # Parse biased config
+ biased_config = None
+ if "biased_config" in data:
+ biased_config = BiasedStrategyConfig(
+ label_weights=data["biased_config"].get("label_weights", {})
+ )
+
+ # Parse interactive (chat-driving) config
+ interactive_config = None
+ if "interactive" in data:
+ ic = data["interactive"]
+ api_key = ic.get("api_key")
+ if api_key and api_key.startswith("${") and api_key.endswith("}"):
+ api_key = os.environ.get(api_key[2:-1])
+ kwargs = {
+ "enabled": ic.get("enabled", True),
+ "endpoint_type": ic.get("endpoint_type", "ollama"),
+ "model": ic.get("model"),
+ "api_key": api_key,
+ "base_url": ic.get("base_url"),
+ "temperature": ic.get("temperature", 0.7),
+ "max_tokens": ic.get("max_tokens", 200),
+ "max_turns": ic.get("max_turns", 6),
+ "done_marker": ic.get("done_marker", "[DONE]"),
+ }
+ if "persona_system_prompt" in ic:
+ kwargs["persona_system_prompt"] = ic["persona_system_prompt"]
+ if "first_message_template" in ic:
+ kwargs["first_message_template"] = ic["first_message_template"]
+ interactive_config = InteractiveConfig(**kwargs)
+
+ # Parse agent (vision-LLM) config
+ agent_config = None
+ if "agent_config" in data:
+ ad = data["agent_config"]
+ api_key = ad.get("api_key")
+ if api_key and api_key.startswith("${") and api_key.endswith("}"):
+ api_key = os.environ.get(api_key[2:-1])
+ agent_config = AgentStrategyConfig(
+ endpoint_type=ad.get("endpoint_type", "ollama_vision"),
+ model=ad.get("model"),
+ api_key=api_key,
+ base_url=ad.get("base_url"),
+ temperature=ad.get("temperature", 0.1),
+ max_tokens=ad.get("max_tokens", 800),
+ max_image_dim=ad.get("max_image_dim", 1024),
+ max_image_count=ad.get("max_image_count", 4),
+ include_dialogue_text=ad.get("include_dialogue_text", True),
+ include_spreadsheet=ad.get("include_spreadsheet", True),
+ max_dialogue_chars=ad.get("max_dialogue_chars", 12000),
+ cache_per_instance=ad.get("cache_per_instance", True),
+ add_noise=ad.get("add_noise", False),
+ noise_rate=ad.get("noise_rate", 0.0),
+ )
+
+ # Parse strategy
+ strategy_str = data.get("strategy", "random")
+ try:
+ strategy = AnnotationStrategyType(strategy_str)
+ except ValueError:
+ strategy = AnnotationStrategyType.RANDOM
+
+ # Parse users section
+ users_data = data.get("users", {})
+ user_count = users_data.get("count", data.get("user_count", 10))
+ competence_dist = users_data.get(
+ "competence_distribution",
+ data.get(
+ "competence_distribution", {"good": 0.5, "average": 0.3, "poor": 0.2}
+ ),
+ )
+
+ # Parse execution config
+ execution = data.get("execution", {})
+ parallel_users = execution.get("parallel_users", data.get("parallel_users", 5))
+ delay_between = execution.get(
+ "delay_between_users", data.get("delay_between_users", 0.5)
+ )
+ max_annotations = execution.get("max_annotations_per_user")
+
+ # Parse QC config
+ qc_config = data.get("quality_control", {})
+ attention_fail_rate = qc_config.get(
+ "attention_check_fail_rate", data.get("attention_check_fail_rate", 0.0)
+ )
+ respond_fast_rate = qc_config.get(
+ "respond_fast_rate", data.get("respond_fast_rate", 0.0)
+ )
+
+ # Parse output config
+ output_config = data.get("output", {})
+ output_dir = output_config.get("dir", data.get("output_dir", "simulator_output"))
+ export_format = output_config.get(
+ "format", data.get("export_format", "json")
+ )
+
+ return cls(
+ user_count=user_count,
+ competence_distribution=competence_dist,
+ timing=timing,
+ strategy=strategy,
+ llm_config=llm_config,
+ biased_config=biased_config,
+ agent_config=agent_config,
+ interactive=interactive_config,
+ gold_standard_file=data.get("gold_standard_file"),
+ parallel_users=parallel_users,
+ delay_between_users=delay_between,
+ attention_check_fail_rate=attention_fail_rate,
+ respond_fast_rate=respond_fast_rate,
+ simulate_wait=data.get("simulate_wait", False),
+ output_dir=output_dir,
+ export_format=export_format,
+ )
+
+ def to_dict(self) -> Dict[str, Any]:
+ """Convert configuration to dictionary.
+
+ Returns:
+ Configuration as dictionary
+ """
+ return {
+ "user_count": self.user_count,
+ "competence_distribution": self.competence_distribution,
+ "timing": {
+ "annotation_time_min": self.timing.annotation_time_min,
+ "annotation_time_max": self.timing.annotation_time_max,
+ "annotation_time_mean": self.timing.annotation_time_mean,
+ "annotation_time_std": self.timing.annotation_time_std,
+ "distribution": self.timing.distribution,
+ "fast_response_threshold": self.timing.fast_response_threshold,
+ "session_duration_max": self.timing.session_duration_max,
+ },
+ "strategy": self.strategy.value,
+ "llm_config": (
+ {
+ "endpoint_type": self.llm_config.endpoint_type,
+ "model": self.llm_config.model,
+ "temperature": self.llm_config.temperature,
+ "max_tokens": self.llm_config.max_tokens,
+ "add_noise": self.llm_config.add_noise,
+ "noise_rate": self.llm_config.noise_rate,
+ }
+ if self.llm_config
+ else None
+ ),
+ "biased_config": (
+ {"label_weights": self.biased_config.label_weights}
+ if self.biased_config
+ else None
+ ),
+ "gold_standard_file": self.gold_standard_file,
+ "parallel_users": self.parallel_users,
+ "delay_between_users": self.delay_between_users,
+ "attention_check_fail_rate": self.attention_check_fail_rate,
+ "respond_fast_rate": self.respond_fast_rate,
+ "simulate_wait": self.simulate_wait,
+ "output_dir": self.output_dir,
+ "export_format": self.export_format,
+ }
diff --git a/potato/simulator/interactive_runner.py b/potato/simulator/interactive_runner.py
new file mode 100644
index 0000000000000000000000000000000000000000..2662e08c2ea659f17620fbc46d6c0ccd7834f1d9
--- /dev/null
+++ b/potato/simulator/interactive_runner.py
@@ -0,0 +1,236 @@
+"""
+Interactive chat session driver for the simulator.
+
+When the annotation server's ``instance_display`` includes an
+``interactive_chat`` field, the annotator is expected to chat with a live
+agent backend before submitting trajectory ratings.
+:class:`InteractiveSessionRunner` plays the user side of that chat: it asks
+a small "persona" LLM to generate user messages, posts them to the server's
+``/agent_chat/send`` route, and finishes with ``/agent_chat/finish`` so the
+captured conversation is written into the instance data.
+
+After the runner returns, the regular annotation pipeline (e.g.
+:class:`AgentSimulatorStrategy`) picks up the freshly populated
+``conversation`` field and produces ratings.
+"""
+
+from __future__ import annotations
+
+import logging
+from dataclasses import dataclass
+from typing import Any, Dict, List, Optional
+
+import requests
+
+from .config import InteractiveConfig
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class InteractiveSessionResult:
+ """Outcome of one interactive_chat run."""
+
+ instance_id: str
+ completed: bool
+ turns: int
+ conversation: List[Dict[str, Any]]
+ error: Optional[str] = None
+
+
+class InteractiveSessionRunner:
+ """Drive a multi-turn ``interactive_chat`` against the server.
+
+ The runner is stateless across instances: ``run`` is called once per
+ instance and returns the resulting conversation. The persona LLM is
+ initialized lazily on first use so importing the module is cheap.
+ """
+
+ def __init__(self, config: InteractiveConfig, server_url: str):
+ self.config = config
+ self.server_url = server_url.rstrip("/")
+ self._endpoint = None # lazy
+
+ # ------------------------------------------------------------------
+ # Persona endpoint setup
+ # ------------------------------------------------------------------
+
+ def _get_endpoint(self):
+ if self._endpoint is not None:
+ return self._endpoint
+ try:
+ from potato.ai.ai_endpoint import AIEndpointFactory
+
+ ai_cfg: Dict[str, Any] = {
+ "model": self.config.model,
+ "api_key": self.config.api_key,
+ "max_tokens": self.config.max_tokens,
+ "temperature": self.config.temperature,
+ }
+ if self.config.base_url:
+ ai_cfg["base_url"] = self.config.base_url
+
+ self._endpoint = AIEndpointFactory.create_endpoint({
+ "ai_support": {
+ "enabled": True,
+ "endpoint_type": self.config.endpoint_type,
+ "ai_config": ai_cfg,
+ }
+ })
+ except Exception as e:
+ logger.warning("InteractiveSessionRunner: persona endpoint init failed: %s", e)
+ self._endpoint = None
+ return self._endpoint
+
+ # ------------------------------------------------------------------
+ # Persona messaging
+ # ------------------------------------------------------------------
+
+ def _generate_persona_message(
+ self,
+ task: str,
+ history: List[Dict[str, str]],
+ ) -> Optional[str]:
+ endpoint = self._get_endpoint()
+ if endpoint is None:
+ return None
+
+ if not history and self.config.first_message_template:
+ return self.config.first_message_template.format(task=task)
+
+ # Build chat history. The persona's "user" role is what we send to
+ # the agent, so from the persona LLM's perspective those are
+ # *assistant* messages and the agent's replies are *user* prompts.
+ messages: List[Dict[str, str]] = [
+ {"role": "system", "content": self.config.persona_system_prompt
+ + f"\n\nThe task you want completed is:\n{task}"}
+ ]
+ for msg in history:
+ if msg["role"] == "user": # what the persona previously sent
+ messages.append({"role": "assistant", "content": msg["content"]})
+ else: # agent's reply
+ messages.append({"role": "user", "content": msg["content"]})
+
+ try:
+ if hasattr(endpoint, "chat_query"):
+ reply = endpoint.chat_query(messages)
+ else:
+ # Fall back to flattening into a single prompt
+ flat = "\n".join(f'{m["role"]}: {m["content"]}' for m in messages)
+ reply = endpoint.query(flat + "\nassistant:", None)
+ except Exception as e:
+ logger.warning("Persona LLM call failed: %s", e)
+ return None
+
+ if isinstance(reply, dict):
+ reply = reply.get("response") or reply.get("content") or str(reply)
+ text = str(reply or "").strip()
+ return text or None
+
+ # ------------------------------------------------------------------
+ # Server interaction
+ # ------------------------------------------------------------------
+
+ def _send_to_agent(
+ self, session: requests.Session, message: str
+ ) -> Optional[Dict[str, Any]]:
+ try:
+ resp = session.post(
+ f"{self.server_url}/agent_chat/send",
+ json={"message": message},
+ timeout=120,
+ )
+ except requests.exceptions.RequestException as e:
+ logger.warning("agent_chat/send request failed: %s", e)
+ return None
+ if resp.status_code != 200:
+ logger.warning(
+ "agent_chat/send returned %d: %s", resp.status_code, resp.text[:200]
+ )
+ return None
+ try:
+ return resp.json()
+ except ValueError:
+ return None
+
+ def _finish(self, session: requests.Session) -> bool:
+ try:
+ resp = session.post(
+ f"{self.server_url}/agent_chat/finish",
+ timeout=60,
+ )
+ except requests.exceptions.RequestException as e:
+ logger.warning("agent_chat/finish request failed: %s", e)
+ return False
+ if resp.status_code != 200:
+ logger.warning(
+ "agent_chat/finish returned %d: %s",
+ resp.status_code, resp.text[:200],
+ )
+ return False
+ return True
+
+ # ------------------------------------------------------------------
+ # Main entry point
+ # ------------------------------------------------------------------
+
+ def run(
+ self,
+ session: requests.Session,
+ instance_id: str,
+ task_description: str,
+ ) -> InteractiveSessionResult:
+ """Drive one chat session end-to-end."""
+ history: List[Dict[str, str]] = []
+ completed = False
+ error: Optional[str] = None
+
+ for turn in range(self.config.max_turns):
+ user_msg = self._generate_persona_message(task_description, history)
+ if not user_msg:
+ error = error or "persona produced no message"
+ break
+
+ # Strip the [DONE] marker before sending so the agent doesn't see
+ # it; remember that we should finish after this turn.
+ should_finish = self.config.done_marker.lower() in user_msg.lower()
+ send_msg = user_msg.replace(self.config.done_marker, "").strip() or "Thanks!"
+
+ agent_reply = self._send_to_agent(session, send_msg)
+ if agent_reply is None:
+ error = error or "agent send failed"
+ break
+ history.append({"role": "user", "content": send_msg})
+ history.append({
+ "role": "agent",
+ "content": agent_reply.get("content", ""),
+ })
+
+ if should_finish:
+ completed = True
+ break
+
+ ok = self._finish(session)
+ if not ok and not error:
+ error = "finish failed"
+ if ok and not completed:
+ # We hit max_turns without an explicit DONE; still consider the
+ # session "done" for accounting purposes.
+ completed = True
+
+ # Build the conversation array the way the server's finish route does
+ conversation = [
+ {
+ "speaker": "User" if msg["role"] == "user" else "Agent",
+ "text": msg["content"],
+ }
+ for msg in history
+ ]
+
+ return InteractiveSessionResult(
+ instance_id=instance_id,
+ completed=completed,
+ turns=len(history) // 2,
+ conversation=conversation,
+ error=error,
+ )
diff --git a/potato/simulator/playwright_simulator.py b/potato/simulator/playwright_simulator.py
new file mode 100644
index 0000000000000000000000000000000000000000..ddba8ecbd994896fdfa8b5fcf8f7e725785f9127
--- /dev/null
+++ b/potato/simulator/playwright_simulator.py
@@ -0,0 +1,502 @@
+"""
+Playwright-driven simulated user.
+
+Drives the actual browser UI: registers + logs in via the login form,
+navigates to ``/annotate``, applies annotations through real DOM events
+(clicking radios, filling textareas, ticking checkboxes), then clicks
+the Next button to advance.
+
+Designed as a slow-but-real smoke test of the rendered annotation UI:
+the same code paths a human user would hit. The annotation values still
+come from an :class:`AnnotationStrategy` (typically
+:class:`AgentSimulatorStrategy`); Playwright is only the *driver*.
+
+Constraints:
+ - Single user, sequential. Browser sessions don't parallelize cleanly.
+ - Requires ``playwright`` + a chromium install
+ (``pip install playwright && playwright install chromium``).
+ - Falls back gracefully if Playwright is unavailable: raises a clear
+ ``ImportError`` at construction time.
+"""
+
+from __future__ import annotations
+
+import logging
+import time
+from dataclasses import dataclass
+from datetime import datetime
+from typing import Any, Dict, List, Optional
+
+import requests
+
+from .annotation_strategies import AnnotationStrategy, create_strategy
+from .competence_profiles import create_competence_profile
+from .config import (
+ AnnotationStrategyType,
+ InteractiveConfig,
+ UserConfig,
+)
+from .interactive_runner import InteractiveSessionRunner
+from .timing_models import NoWaitTimingModel, TimingModel
+from .user_simulator import AnnotationRecord, UserSimulationResult
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class _DomSubmissionResult:
+ """How many DOM inputs we successfully applied vs total annotations."""
+
+ applied: int
+ total: int
+
+
+class PlaywrightSimulatedUser:
+ """Browser-driven simulated user.
+
+ Public surface mirrors :class:`SimulatedUser` enough that a smoke runner
+ can use either. ``run_simulation()`` returns a
+ :class:`UserSimulationResult` so existing reporting works.
+ """
+
+ def __init__(
+ self,
+ user_config: UserConfig,
+ server_url: str,
+ gold_standards: Optional[Dict[str, Dict[str, Any]]] = None,
+ simulate_wait: bool = False,
+ interactive_config: Optional[InteractiveConfig] = None,
+ headless: bool = True,
+ debounce_seconds: float = 1.8,
+ ):
+ try:
+ from playwright.sync_api import sync_playwright # noqa: F401
+ except ImportError as e:
+ raise ImportError(
+ "PlaywrightSimulatedUser requires playwright. Install it with:\n"
+ " pip install playwright\n"
+ " playwright install chromium"
+ ) from e
+
+ self.config = user_config
+ self.server_url = server_url.rstrip("/")
+ self.gold_standards = gold_standards or {}
+ self.headless = headless
+ self.debounce_seconds = debounce_seconds
+
+ self.competence = create_competence_profile(user_config.competence)
+ self.strategy = self._create_strategy()
+
+ self.timing = (
+ TimingModel(user_config.timing)
+ if simulate_wait
+ else NoWaitTimingModel(user_config.timing)
+ )
+
+ # API session is mirrored to/from Playwright for /api/* fetches
+ self.api_session = requests.Session()
+
+ self.interactive_runner: Optional[InteractiveSessionRunner] = None
+ if interactive_config and interactive_config.enabled:
+ self.interactive_runner = InteractiveSessionRunner(
+ interactive_config, server_url
+ )
+
+ self.result = UserSimulationResult(user_id=user_config.user_id)
+ self.schemas: List[Dict[str, Any]] = []
+
+ # Lazily set in start()
+ self._pw = None
+ self._browser = None
+ self._context = None
+ self._page = None
+
+ # ------------------------------------------------------------------
+ # Strategy & lifecycle
+ # ------------------------------------------------------------------
+
+ def _create_strategy(self) -> AnnotationStrategy:
+ return create_strategy(
+ strategy_type=self.config.strategy,
+ llm_config=self.config.llm_config,
+ biased_config=self.config.biased_config,
+ pattern_config=self.config.pattern_config,
+ agent_config=self.config.agent_config,
+ user_id=self.config.user_id,
+ )
+
+ def start(self) -> None:
+ from playwright.sync_api import sync_playwright
+
+ self._pw = sync_playwright().start()
+ self._browser = self._pw.chromium.launch(headless=self.headless)
+ self._context = self._browser.new_context()
+ self._page = self._context.new_page()
+
+ def stop(self) -> None:
+ for closer in (self._page, self._context, self._browser):
+ if closer is None:
+ continue
+ try:
+ closer.close()
+ except Exception:
+ pass
+ if self._pw is not None:
+ try:
+ self._pw.stop()
+ except Exception:
+ pass
+ self._page = self._context = self._browser = self._pw = None
+
+ # ------------------------------------------------------------------
+ # Cookie sharing
+ # ------------------------------------------------------------------
+
+ def _sync_cookies_to_api(self) -> None:
+ """Copy Playwright cookies into the requests session."""
+ if self._context is None:
+ return
+ for cookie in self._context.cookies():
+ self.api_session.cookies.set(
+ cookie["name"], cookie["value"], domain=cookie.get("domain")
+ )
+
+ # ------------------------------------------------------------------
+ # Login via UI
+ # ------------------------------------------------------------------
+
+ def login_via_ui(self) -> bool:
+ """Register + log in. Uses the API for the credential exchange (the
+ UI form mode is brittle across templates), then loads the cookies
+ into Playwright so DOM interactions work in a logged-in session."""
+ password = "simulated_password_123"
+ try:
+ self.api_session.post(
+ f"{self.server_url}/register",
+ data={"action": "signup", "email": self.config.user_id, "pass": password},
+ allow_redirects=True,
+ timeout=30,
+ )
+ self.api_session.post(
+ f"{self.server_url}/auth",
+ data={"action": "login", "email": self.config.user_id, "pass": password},
+ allow_redirects=True,
+ timeout=30,
+ )
+ except requests.exceptions.RequestException as e:
+ logger.warning("API login failed: %s", e)
+ return False
+
+ # Push cookies into Playwright so the page renders as logged-in.
+ self._sync_cookies_to_browser()
+
+ # Navigate to /annotate; walk past consent/instructions if needed.
+ page = self._page
+ try:
+ page.goto(f"{self.server_url}/annotate", wait_until="domcontentloaded", timeout=15000)
+ except Exception as e:
+ logger.warning("page.goto /annotate failed: %s", e)
+
+ for _ in range(10):
+ self._sync_cookies_to_api()
+ if self._is_in_annotation_phase():
+ # Make sure the annotation page is what's rendered
+ try:
+ page.wait_for_selector("#next-btn", timeout=5000)
+ except Exception:
+ pass
+ return True
+ # Try a UI advance (consent / instructions screen)
+ self._advance_phase_screen()
+ time.sleep(0.5)
+ try:
+ page.goto(f"{self.server_url}/annotate", wait_until="domcontentloaded", timeout=10000)
+ except Exception:
+ pass
+
+ return self._is_in_annotation_phase()
+
+ def _sync_cookies_to_browser(self) -> None:
+ """Push cookies from the API session into the Playwright context."""
+ if self._context is None:
+ return
+ cookies = []
+ for c in self.api_session.cookies:
+ cookies.append({
+ "name": c.name,
+ "value": c.value,
+ "url": self.server_url,
+ })
+ if cookies:
+ try:
+ self._context.add_cookies(cookies)
+ except Exception as e:
+ logger.warning("add_cookies failed: %s", e)
+
+ def _is_in_annotation_phase(self) -> bool:
+ """We're in annotation phase iff /api/current_instance returns 200."""
+ try:
+ r = self.api_session.get(
+ f"{self.server_url}/api/current_instance", timeout=10
+ )
+ except requests.exceptions.RequestException:
+ return False
+ return r.status_code == 200
+
+ def _advance_phase_screen(self) -> None:
+ """Click any visible 'Next' / 'Continue' / 'I agree' button."""
+ page = self._page
+ candidates = [
+ "#next-btn:visible",
+ "button:has-text('Continue')",
+ "button:has-text('I Agree')",
+ "button:has-text('Start')",
+ "input[type='submit']:visible",
+ ]
+ for sel in candidates:
+ loc = page.locator(sel)
+ if loc.count() > 0:
+ try:
+ loc.first.click()
+ return
+ except Exception:
+ continue
+
+ # ------------------------------------------------------------------
+ # API helpers
+ # ------------------------------------------------------------------
+
+ def fetch_schemas(self) -> List[Dict[str, Any]]:
+ try:
+ r = self.api_session.get(f"{self.server_url}/api/schemas", timeout=30)
+ if r.status_code != 200:
+ return []
+ data = r.json()
+ if isinstance(data, dict):
+ schemas = (
+ list(data["schemas"].values())
+ if isinstance(data.get("schemas"), dict)
+ else data.get("schemas", list(data.values()))
+ )
+ else:
+ schemas = data
+ self.schemas = schemas
+ return schemas
+ except requests.exceptions.RequestException as e:
+ logger.warning("schema fetch failed: %s", e)
+ return []
+
+ def fetch_current_instance(self) -> Optional[Dict[str, Any]]:
+ try:
+ r = self.api_session.get(
+ f"{self.server_url}/api/current_instance", timeout=30
+ )
+ except requests.exceptions.RequestException:
+ return None
+ if r.status_code != 200:
+ return None
+ return r.json()
+
+ # ------------------------------------------------------------------
+ # DOM annotation application
+ # ------------------------------------------------------------------
+
+ def _apply_annotations(self, annotations: Dict[str, Any]) -> _DomSubmissionResult:
+ """Translate a wire-format annotation dict into DOM clicks/fills."""
+ page = self._page
+ applied = 0
+ total = 0
+ for key, value in annotations.items():
+ total += 1
+ if ":" not in key:
+ continue
+ schema, label = key.split(":", 1)
+ if label == "text":
+ # textarea / textbox
+ # name: :::text via generate_element_identifier
+ sel = f"[name=\"{schema}:::text\"]"
+ if page.locator(sel).count() == 0:
+ sel = f"textarea[name*='{schema}']"
+ if page.locator(sel).count() > 0:
+ try:
+ page.locator(sel).first.fill(str(value)[:1000])
+ applied += 1
+ except Exception as e:
+ logger.debug("fill failed for %s: %s", sel, e)
+ continue
+
+ # Try radio (name=schema, value=label)
+ radio_sel = f"input[type='radio'][name=\"{schema}\"][value=\"{label}\"]"
+ if page.locator(radio_sel).count() > 0:
+ try:
+ page.locator(radio_sel).first.check(force=True)
+ applied += 1
+ continue
+ except Exception:
+ pass
+
+ # Try checkbox / multiselect (name=:::