codebook / potato /server_utils /schemas /trajectory_edit.py
davidjurgens's picture
Deploy: Potato — Codebook Annotation
aceb1b2 verified
Raw
History Blame Contribute Delete
25.5 kB
"""
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"""
<form id="{esc_schema}" class="annotation-form trajectory-edit-container"
action="javascript:void(0)"
data-annotation-id="{escape_html_content(str(annotation_scheme.get('annotation_id', '')))}"
data-annotation-type="trajectory_edit"
data-schema-name="{esc_schema}"
data-steps-key="{escape_html_content(steps_key)}"
data-step-text-key="{escape_html_content(step_text_key)}"
{layout_attrs}>
<fieldset schema="{esc_schema}">
<legend class="trajedit-title">{escape_html_content(description)}</legend>
<div class="trajedit-summary" id="{esc_schema}-summary">
<span class="trajedit-summary-item">Steps edited:
<strong id="{esc_schema}-n-edited">0</strong></span>
<span class="trajedit-summary-item">Total edit distance:
<strong id="{esc_schema}-total-dist">0</strong></span>
</div>
<!-- Step editors rendered by JS from instance data -->
<div class="trajedit-steps-container" id="{esc_schema}-steps"></div>
<input type="hidden"
class="annotation-input trajectory-edit-data-input"
id="{identifiers['id']}"
name="{identifiers['name']}"
schema="{identifiers['schema']}"
label_name="{identifiers['label_name']}"
validation="{validation}"
value="">
</fieldset>
</form>
<script>
(function() {{
var _trajEditState = {{}};
var SCHEMA = '{esc_schema}';
var CONFIG = {config_json};
function getState() {{
if (!_trajEditState[SCHEMA]) {{
_trajEditState[SCHEMA] = {{ entries: {{}}, final_answer: null }};
}}
return _trajEditState[SCHEMA];
}}
function entryKey(idx, field) {{ return idx + '::' + field; }}
/* ---- read steps from the embedded instance data ---- */
function readInstance() {{
try {{
var el = document.querySelector('[data-instance-json]');
if (el) return JSON.parse(el.getAttribute('data-instance-json'));
}} catch (e) {{}}
// Fallback: parse displayed text content as JSON
var textEl = document.getElementById('text-content') ||
document.getElementById('instance-text');
if (textEl) {{
try {{ return JSON.parse(textEl.textContent || textEl.innerText); }}
catch (e2) {{}}
}}
return {{}};
}}
function getStepText(step, field) {{
if (step == null) return '';
if (typeof step === 'string') return field === CONFIG.step_text_key ? step : '';
var v = step[field];
if (v == null) return '';
return typeof v === 'string' ? v : JSON.stringify(v);
}}
/* ---- build editors ---- */
function buildEditors() {{
var container = document.getElementById(SCHEMA + '-steps');
if (!container) return;
var instance = readInstance();
var steps = (instance && instance[CONFIG.steps_key]) || [];
if (!steps.length) {{
container.innerHTML = '<div class="trajedit-no-steps">' +
'No trace steps found in field "' + escapeHtml(CONFIG.steps_key) +
'".</div>';
return;
}}
container.innerHTML = '';
steps.forEach(function(step, idx) {{
container.appendChild(buildStepCard(step, idx));
}});
if (CONFIG.edit_final_answer) {{
var fa = instance ? instance[CONFIG.final_answer_key] : null;
if (fa != null) {{
container.appendChild(buildFinalAnswerCard(
typeof fa === 'string' ? fa : JSON.stringify(fa)));
}}
}}
}}
function buildEditorBlock(idx, field, original, edited) {{
var keyAttr = 'data-step="' + idx + '" data-field="' + escapeHtml(field) + '"';
var fieldLabel = field.charAt(0).toUpperCase() + field.slice(1);
var textareaId = SCHEMA + '-editor-' + idx + '-' + field;
var origId = SCHEMA + '-orig-' + idx + '-' + field;
// Tool/action fields are code-like → monospace for precise editing.
var monoCls = (field === CONFIG.step_text_key) ? ' trajedit-mono' : '';
var resetBtn = CONFIG.allow_reset
? '<button type="button" class="trajedit-reset-btn" ' + keyAttr +
'>Reset</button>'
: '';
var distHtml = CONFIG.show_edit_distance
? '<div class="trajedit-stats"><span>Words changed: <strong class="trajedit-word-dist" ' +
keyAttr + '>0</strong></span> <span>Chars changed: <strong class="trajedit-char-dist" ' +
keyAttr + '>0</strong></span></div>'
: '';
var diffHtml = CONFIG.show_diff
? '<div class="trajedit-diff"><div class="trajedit-diff-label">Changes:</div>' +
'<div class="trajedit-diff-content" ' + keyAttr + '></div></div>'
: '';
var reasonHtml = CONFIG.require_reason_on_edit
? '<div class="trajedit-reason-block"><label class="trajedit-reason-label">' +
'Reason for edit:</label><input type="text" class="trajedit-reason" ' + keyAttr +
' placeholder="why this change?"></div>'
: '';
return '<div class="trajedit-field-block">' +
'<div class="trajedit-field-label">' + escapeHtml(fieldLabel) +
' <span class="trajedit-edited-flag" ' + keyAttr + '></span></div>' +
'<div class="trajedit-original' + monoCls + '" id="' + origId + '">' +
'<span class="trajedit-original-label">Original: </span>' +
'<span class="trajedit-original-text" ' + keyAttr + '>' + escapeHtml(original) + '</span></div>' +
'<label class="trajedit-editor-label" for="' + textareaId + '">Corrected:</label>' +
'<textarea class="trajedit-textarea' + monoCls + '" id="' + textareaId + '" ' + keyAttr +
' rows="2" aria-describedby="' + origId +
'" aria-label="Corrected ' + escapeHtml(fieldLabel) + ' for step ' + (idx + 1) +
'">' + escapeHtml(edited) + '</textarea>' +
distHtml + diffHtml + reasonHtml + resetBtn +
'</div>';
}}
function buildStepCard(step, idx) {{
var card = document.createElement('div');
card.className = 'trajedit-step-card';
card.setAttribute('data-step-index', idx);
var thought = (step && typeof step === 'object' && step.thought &&
CONFIG.editable_fields.indexOf('thought') === -1)
? '<div class="trajedit-step-thought"><span class="trajedit-thought-label">Thought:</span> ' +
escapeHtml(getStepText(step, 'thought')) + '</div>'
: '';
var blocks = '';
CONFIG.editable_fields.forEach(function(field) {{
var original = getStepText(step, field);
// Only render an editor for fields that exist on the step
if (original === '' && !(step && typeof step === 'object' && field in step)) return;
var saved = getState().entries[entryKey(idx, field)];
var edited = saved ? saved.edited_text : original;
blocks += buildEditorBlock(idx, field, original, edited);
}});
card.innerHTML =
'<div class="trajedit-step-header"><span class="trajedit-step-number">Step ' +
(idx + 1) + '</span></div>' + thought + blocks;
attachBlockHandlers(card);
return card;
}}
function buildFinalAnswerCard(original) {{
var card = document.createElement('div');
card.className = 'trajedit-step-card trajedit-final-card';
card.setAttribute('data-final', 'true');
var saved = getState().final_answer;
var edited = saved ? saved.edited_text : original;
var keyAttr = 'data-final="true"';
var resetBtn = CONFIG.allow_reset
? '<button type="button" class="trajedit-reset-btn" ' + keyAttr + '>Reset</button>' : '';
var distHtml = CONFIG.show_edit_distance
? '<div class="trajedit-stats"><span>Words changed: <strong class="trajedit-word-dist" ' +
keyAttr + '>0</strong></span> <span>Chars changed: <strong class="trajedit-char-dist" ' +
keyAttr + '>0</strong></span></div>' : '';
var diffHtml = CONFIG.show_diff
? '<div class="trajedit-diff"><div class="trajedit-diff-label">Changes:</div>' +
'<div class="trajedit-diff-content" ' + keyAttr + '></div></div>' : '';
card.innerHTML =
'<div class="trajedit-step-header"><span class="trajedit-step-number trajedit-final-label">' +
'Final Answer</span> <span class="trajedit-edited-flag" ' + keyAttr + '></span></div>' +
'<div class="trajedit-original"><span class="trajedit-original-label">Original:</span> ' +
'<span class="trajedit-original-text" ' + keyAttr + '>' + escapeHtml(original) + '</span></div>' +
'<label class="trajedit-editor-label">Corrected:</label>' +
'<textarea class="trajedit-textarea" ' + keyAttr +
' rows="3" aria-label="Corrected final answer">' + escapeHtml(edited) + '</textarea>' +
distHtml + diffHtml + resetBtn;
attachBlockHandlers(card);
return card;
}}
/* ---- handlers ---- */
function blockSelector(el, cls) {{
var isFinal = el.getAttribute('data-final') === 'true';
var card = el.closest('.trajedit-step-card');
if (isFinal) return card.querySelector('.' + cls + '[data-final="true"]');
var idx = el.getAttribute('data-step');
var field = el.getAttribute('data-field');
return card.querySelector('.' + cls + '[data-step="' + idx + '"][data-field="' + field + '"]');
}}
function onEdit(textarea) {{
var isFinal = textarea.getAttribute('data-final') === 'true';
var card = textarea.closest('.trajedit-step-card');
var originalEl = blockSelector(textarea, 'trajedit-original-text');
var original = originalEl ? originalEl.textContent : '';
var edited = textarea.value;
var srcWords = original.trim().split(/\\s+/).filter(Boolean);
var editWords = edited.trim().split(/\\s+/).filter(Boolean);
var wordDist = levenshtein(srcWords, editWords);
var charDist = levenshteinStr(original, edited);
var isEdited = edited !== original;
if (CONFIG.show_edit_distance) {{
var w = blockSelector(textarea, 'trajedit-word-dist');
var c = blockSelector(textarea, 'trajedit-char-dist');
if (w) w.textContent = wordDist;
if (c) c.textContent = charDist;
}}
if (CONFIG.show_diff) {{
var d = blockSelector(textarea, 'trajedit-diff-content');
if (d) d.innerHTML = computeWordDiff(srcWords, editWords);
}}
var flag = blockSelector(textarea, 'trajedit-edited-flag');
if (flag) flag.textContent = isEdited ? '✎ edited' : '';
// Non-color cue: mark the enclosing block/card edited (border accent).
var block = textarea.closest('.trajedit-field-block') ||
textarea.closest('.trajedit-step-card');
if (block) block.classList.toggle('trajedit-edited', isEdited);
// Auto-grow to fit content (capped via CSS max-height).
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 400) + 'px';
var state = getState();
if (isFinal) {{
state.final_answer = {{
original_text: original, edited_text: edited, edited: isEdited,
edit_distance_chars: charDist, edit_distance_words: wordDist
}};
}} else {{
var idx = parseInt(textarea.getAttribute('data-step'), 10);
var field = textarea.getAttribute('data-field');
var reasonEl = blockSelector(textarea, 'trajedit-reason');
state.entries[entryKey(idx, field)] = {{
step_index: idx, field: field, original_text: original,
edited_text: edited, edited: isEdited,
edit_distance_chars: charDist, edit_distance_words: wordDist,
reason: reasonEl ? reasonEl.value : ''
}};
}}
saveState();
}}
function attachBlockHandlers(card) {{
card.querySelectorAll('.trajedit-textarea').forEach(function(ta) {{
ta.addEventListener('input', function() {{ onEdit(ta); }});
}});
card.querySelectorAll('.trajedit-reason').forEach(function(inp) {{
inp.addEventListener('input', function() {{
var ta = blockSelector(inp, 'trajedit-textarea');
if (ta) onEdit(ta);
}});
}});
card.querySelectorAll('.trajedit-reset-btn').forEach(function(btn) {{
btn.addEventListener('click', function() {{
var ta = blockSelector(btn, 'trajedit-textarea');
var orig = blockSelector(btn, 'trajedit-original-text');
if (ta && orig) {{ ta.value = orig.textContent; onEdit(ta); }}
}});
}});
}}
/* ---- persistence ---- */
function saveState() {{
var state = getState();
var steps = [];
var nEdited = 0, totalDist = 0;
Object.keys(state.entries).forEach(function(k) {{
var e = state.entries[k];
steps.push(e);
if (e.edited) {{ nEdited += 1; totalDist += (e.edit_distance_chars || 0); }}
}});
if (state.final_answer && state.final_answer.edited) {{
nEdited += 1; totalDist += (state.final_answer.edit_distance_chars || 0);
}}
steps.sort(function(a, b) {{
return a.step_index - b.step_index || (a.field < b.field ? -1 : 1);
}});
var nEl = document.getElementById(SCHEMA + '-n-edited');
var tEl = document.getElementById(SCHEMA + '-total-dist');
if (nEl) nEl.textContent = nEdited;
if (tEl) tEl.textContent = totalDist;
var data = JSON.stringify({{
steps: steps,
final_answer: state.final_answer,
n_steps_edited: nEdited,
total_edit_distance: totalDist
}});
var input = document.getElementById(SCHEMA).querySelector('.trajectory-edit-data-input');
if (input) {{
input.value = data;
input.setAttribute('data-modified', 'true');
input.dispatchEvent(new Event('change', {{ bubbles: true }}));
}}
}}
// CRITICAL (IIFE-overwrite guard): load server-restored value into
// state BEFORE building editors, so buildStepCard prefills textareas
// with the saved edited_text rather than clobbering it with originals.
function restoreFromHiddenInput() {{
var input = document.getElementById(SCHEMA).querySelector('.trajectory-edit-data-input');
if (!input) return false;
if (input.getAttribute('data-server-set') === null &&
!(input.getAttribute('value') || input.value)) return false;
var val = input.getAttribute('value') || input.value;
if (!val) return false;
try {{
var data = JSON.parse(val);
var state = getState();
(data.steps || []).forEach(function(e) {{
state.entries[entryKey(e.step_index, e.field)] = e;
}});
state.final_answer = data.final_answer || null;
return true;
}} catch (e) {{ return false; }}
}}
function restoreVisualState() {{
// Re-run onEdit for each textarea so diff/flags/stats reflect saved text.
var container = document.getElementById(SCHEMA + '-steps');
if (!container) return;
container.querySelectorAll('.trajedit-textarea').forEach(function(ta) {{
var isFinal = ta.getAttribute('data-final') === 'true';
var state = getState();
var saved = isFinal ? state.final_answer
: state.entries[entryKey(parseInt(ta.getAttribute('data-step'), 10),
ta.getAttribute('data-field'))];
if (saved) ta.value = saved.edited_text;
if (saved && saved.reason) {{
var r = blockSelector(ta, 'trajedit-reason');
if (r) r.value = saved.reason;
}}
onEdit(ta);
}});
}}
/* ---- diff helpers (from text_edit) ---- */
function levenshtein(a, b) {{
var m = a.length, n = b.length;
var dp = Array.from({{length: m + 1}}, function() {{ return new Array(n + 1).fill(0); }});
for (var i = 0; i <= m; i++) dp[i][0] = i;
for (var j = 0; j <= n; j++) dp[0][j] = j;
for (var i = 1; i <= m; i++)
for (var j = 1; j <= n; j++)
dp[i][j] = a[i-1] === b[j-1] ? dp[i-1][j-1] :
1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]);
return dp[m][n];
}}
function levenshteinStr(a, b) {{
var m = a.length, n = b.length;
if (m === 0) return n;
if (n === 0) return m;
var prev = new Array(n + 1), curr = new Array(n + 1);
for (var j = 0; j <= n; j++) prev[j] = j;
for (var i = 1; i <= m; i++) {{
curr[0] = i;
for (var j = 1; j <= n; j++)
curr[j] = a[i-1] === b[j-1] ? prev[j-1] :
1 + Math.min(prev[j], curr[j-1], prev[j-1]);
var tmp = prev; prev = curr; curr = tmp;
}}
return prev[n];
}}
function computeWordDiff(srcWords, editWords) {{
var html = '', si = 0, ei = 0, li = 0;
var lcs = lcsWords(srcWords, editWords);
while (si < srcWords.length || ei < editWords.length) {{
if (li < lcs.length && si < srcWords.length && ei < editWords.length &&
srcWords[si] === lcs[li] && editWords[ei] === lcs[li]) {{
html += '<span class="trajedit-diff-same">' + escapeHtml(lcs[li]) + '</span> ';
si++; ei++; li++;
}} else if (li < lcs.length && ei < editWords.length && editWords[ei] === lcs[li]) {{
html += '<span class="trajedit-diff-del">' + escapeHtml(srcWords[si]) + '</span> '; si++;
}} else if (li < lcs.length && si < srcWords.length && srcWords[si] === lcs[li]) {{
html += '<span class="trajedit-diff-ins">' + escapeHtml(editWords[ei]) + '</span> '; ei++;
}} else {{
if (si < srcWords.length) {{ html += '<span class="trajedit-diff-del">' + escapeHtml(srcWords[si]) + '</span> '; si++; }}
if (ei < editWords.length) {{ html += '<span class="trajedit-diff-ins">' + escapeHtml(editWords[ei]) + '</span> '; ei++; }}
}}
}}
return html;
}}
function lcsWords(a, b) {{
var m = a.length, n = b.length;
var dp = Array.from({{length: m + 1}}, function() {{ return new Array(n + 1).fill(0); }});
for (var i = 1; i <= m; i++)
for (var j = 1; j <= n; j++)
dp[i][j] = a[i-1] === b[j-1] ? dp[i-1][j-1] + 1 : Math.max(dp[i-1][j], dp[i][j-1]);
var result = [], i = m, j = n;
while (i > 0 && j > 0) {{
if (a[i-1] === b[j-1]) {{ result.unshift(a[i-1]); i--; j--; }}
else if (dp[i-1][j] > dp[i][j-1]) i--; else j--;
}}
return result;
}}
function escapeHtml(str) {{
var div = document.createElement('div');
div.textContent = str == null ? '' : str;
return div.innerHTML;
}}
/* ---- init ---- */
var hadServerData = restoreFromHiddenInput();
function tryBuild() {{
buildEditors();
if (hadServerData) restoreVisualState();
}}
if (document.readyState === 'complete') tryBuild();
else document.addEventListener('DOMContentLoaded', tryBuild);
// Expose for the annotation.js restore pipeline / tests
window._trajEditState = _trajEditState;
window._trajEditBuild = buildEditors;
window._trajEditRestore = restoreVisualState;
window._trajEditSave = saveState;
}})();
</script>
"""
logger.info(f"Generated trajectory edit layout for {schema_name}")
return html, []