codebook / potato /server_utils /schemas /constant_sum.py
davidjurgens's picture
Deploy: Potato — Codebook Annotation
aceb1b2 verified
Raw
History Blame Contribute Delete
8.54 kB
"""
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"""
<form id="{safe_schema}" class="annotation-form constant_sum shadcn-constant-sum-container"
data-annotation-id="{escape_html_content(str(annotation_scheme.get('annotation_id', '')))}"
data-annotation-type="constant_sum"
data-schema-name="{safe_schema}"
data-constant-sum-total="{total_points}"
{layout_attrs}>
{get_ai_wrapper()}
<fieldset schema="{safe_schema}">
<legend class="shadcn-constant-sum-title">{escape_html_content(description)}</legend>
<div class="constant-sum-status">
<span class="constant-sum-allocated" id="constant-sum-allocated-{safe_schema}">
Allocated: <strong>0</strong> / {total_points}
</span>
<span class="constant-sum-remaining" id="constant-sum-remaining-{safe_schema}">
Remaining: <strong>{total_points}</strong>
</span>
</div>
<div class="constant-sum-items">
"""
for i, lbl in enumerate(label_names):
safe_lbl = escape_html_content(lbl)
identifiers = generate_element_identifier(schema_name, lbl, "number")
tooltip_html = ""
if isinstance(labels[i], dict):
tooltip_html = generate_tooltip_html(labels[i])
if input_type == "slider":
html += f"""
<div class="constant-sum-item" {tooltip_html}>
<label class="constant-sum-label" for="{identifiers['id']}">{safe_lbl}</label>
<input type="range"
min="{min_per_item}"
max="{total_points}"
step="1"
value="{min_per_item}"
class="constant-sum-slider annotation-input"
id="{identifiers['id']}"
name="{identifiers['name']}"
schema="{identifiers['schema']}"
label_name="{identifiers['label_name']}"
validation="{validation}"
data-constant-sum-group="{safe_schema}">
<span class="constant-sum-value" id="constant-sum-val-{identifiers['id']}">{min_per_item}</span>
</div>
"""
else:
html += f"""
<div class="constant-sum-item" {tooltip_html}>
<label class="constant-sum-label" for="{identifiers['id']}">{safe_lbl}</label>
<input type="number"
min="{min_per_item}"
max="{total_points}"
step="1"
value=""
class="constant-sum-number annotation-input"
id="{identifiers['id']}"
name="{identifiers['name']}"
schema="{identifiers['schema']}"
label_name="{identifiers['label_name']}"
validation="{validation}"
data-constant-sum-group="{safe_schema}"
placeholder="0">
</div>
"""
html += f"""
</div>
</fieldset>
</form>
<script>
(function() {{
const schema = "{safe_schema}";
const total = {total_points};
const minVal = {min_per_item};
const inputs = document.querySelectorAll('[data-constant-sum-group="' + schema + '"]');
function getSum(exclude) {{
let sum = 0;
inputs.forEach(inp => {{
if (inp !== exclude) sum += (parseInt(inp.value) || 0);
}});
return sum;
}}
function clampInput(inp) {{
let val = parseInt(inp.value);
if (isNaN(val) || val < minVal) {{
val = minVal;
inp.value = val || '';
}}
// Cap so total is not exceeded
const othersSum = getSum(inp);
const maxAllowed = total - othersSum;
if (val > maxAllowed) {{
val = Math.max(minVal, maxAllowed);
inp.value = val;
}}
return val;
}}
function updateConstantSum() {{
let sum = 0;
inputs.forEach(inp => {{
const val = parseInt(inp.value) || 0;
sum += val;
const valEl = document.getElementById('constant-sum-val-' + inp.id);
if (valEl) valEl.textContent = val;
}});
const allocEl = document.getElementById('constant-sum-allocated-' + schema);
const remEl = document.getElementById('constant-sum-remaining-' + schema);
const remaining = total - sum;
if (allocEl) {{
allocEl.innerHTML = 'Allocated: <strong>' + sum + '</strong> / ' + total;
allocEl.className = 'constant-sum-allocated ' +
(sum === total ? 'valid' : sum > total ? 'over' : '');
}}
if (remEl) {{
remEl.innerHTML = 'Remaining: <strong>' + remaining + '</strong>';
remEl.className = 'constant-sum-remaining ' +
(remaining === 0 ? 'valid' : remaining < 0 ? 'over' : '');
}}
}}
inputs.forEach(inp => {{
inp.addEventListener('input', function() {{
clampInput(this);
updateConstantSum();
}});
// Also clamp on blur for pasted/typed values
inp.addEventListener('blur', function() {{
clampInput(this);
updateConstantSum();
}});
}});
updateConstantSum();
}})();
</script>
"""
key_bindings = []
logger.info(f"Generated constant_sum layout for {schema_name} with {len(label_names)} categories")
return html, key_bindings