cuongpm-cs's picture
Deploy current annotation tool to HF Spaces
3cbca04
Raw
History Blame Contribute Delete
38.6 kB
// ===== State =====
let allData=[], filteredData=[], currentIndex=0;
let assignmentId=null, reviewsMap={}, currentReviewStatus=null;
let editMode={emotion:false, distortions:false, empathy:false};
let editData={emotion:null, distortions:null, empathy:null}; // working copies
let selectingEvidence=null; // {card, itemIdx, field}
const DIST_COLORS=['#fb923c','#f87171','#fbbf24','#a78bfa','#34d399'];
const EMP_COLORS=['#60a5fa','#a78bfa','#34d399','#fbbf24','#fb923c','#f87171','#818cf8','#2dd4bf','#e879f9'];
const INTENSITY_OPTS=['Low','Medium','High'];
const DISTORTION_OPTS=[
'All-or-Nothing Thinking', 'Overgeneralization', 'Mental Filter',
'Disqualifying the Positive', 'Mind Reading', 'Fortune Telling',
'Catastrophizing', 'Minimization', 'Emotional Reasoning',
'Should Statements', 'Labeling', 'Personalization'
];
// ===== Helpers =====
function safeJSON(s){try{return JSON.parse(s);}catch(e){return null;}}
function escA(s){return (s||'').replace(/"/g,'"').replace(/'/g,''');}
function escH(s){return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
function parseAnno(row){
return {
emotion:safeJSON(row['Emotion']),
distortions:safeJSON(row['Distortions']),
empathy:safeJSON(row['Empathy_Level'])
};
}
function getEffectiveAnno(row){
const review=reviewsMap[currentIndex];
if(review&&review.annotations_edit){
const ed=safeJSON(review.annotations_edit);
if(ed) return ed;
}
return parseAnno(row);
}
function emotionCls(e){
if(!e)return 'emotion-default';
const l=e.toLowerCase();
if(l.includes('fear'))return 'emotion-fear';
if(l.includes('sad'))return 'emotion-sadness';
if(l.includes('anger')||l.includes('angry'))return 'emotion-anger';
if(l.includes('joy')||l.includes('happy'))return 'emotion-joy';
if(l.includes('anxi'))return 'emotion-anxiety';
if(l.includes('shame'))return 'emotion-shame';
if(l.includes('depress'))return 'emotion-depression';
return 'emotion-default';
}
function displayEmotion(row){
return row?.emotion_type || '—';
}
function scoreColor(s,m=7){const r=s/m;return r>=.7?'#34d399':r>=.4?'#fbbf24':'#f87171';}
function deepCopy(o){return JSON.parse(JSON.stringify(o));}
// ===== Conversation parsing & highlights =====
function parseConv(s){
if(!s)return [];
const msgs=[];
s.split('\n').forEach(line=>{
const t=line.trim();
if(!t)return;
if(t.startsWith('speaker:')||t.startsWith('listener:')){
const isL=t.startsWith('listener:');
const c=t.replace(/^(speaker|listener):\s*/,'');
if(c)msgs.push({role:isL?'listener':'speaker',text:c});
} else if(msgs.length>0) msgs[msgs.length-1].text+=' '+t;
});
return msgs;
}
function applyHighlights(msgs,quotes){
return msgs.map(msg=>{
let text=msg.text;
quotes.forEach(({quote,cls})=>{
if(!quote||quote==='No evidence present')return;
const q=quote.replace(/^(listener|speaker):\s*/i,'').trim();
if(!q)return;
const i=text.indexOf(q);
if(i!==-1)text=text.slice(0,i)+`<mark class="evidence-hl ${cls}" data-quote="${escA(q)}">`+text.slice(i,i+q.length)+'</mark>'+text.slice(i+q.length);
});
return {...msg,htmlText:text};
});
}
function buildQuotes(anno){
const quotes=[];
(anno.distortions?.distortions||[]).forEach((d,i)=>{
if(d.evidence_quote&&d.evidence_quote!=='No evidence present')
quotes.push({quote:d.evidence_quote,cls:`dist-hl-${i%3}`,type:'dist',idx:i});
});
(anno.empathy?.empathy_dimensions||[]).forEach((dim,i)=>{
if(dim.evidence_quote&&dim.evidence_quote!=='No evidence present')
quotes.push({quote:dim.evidence_quote,cls:`emp-hl-${i%9}`,type:'emp',idx:i});
});
return quotes;
}
function scrollToEvidence(prefix,idx){
const m=document.querySelector(`.conversation-view mark.${prefix}-${idx}`);
if(!m)return;
document.querySelectorAll('mark.evidence-pulse').forEach(x=>x.classList.remove('evidence-pulse'));
m.scrollIntoView({behavior:'smooth',block:'center'});
m.classList.add('evidence-pulse');
setTimeout(()=>m.classList.remove('evidence-pulse'),2000);
}
// ===== Render: Conversation =====
function renderConversation(row){
const anno=getEffectiveAnno(row);
const quotes=buildQuotes(anno);
const msgs=applyHighlights(parseConv(row['conversation_cut']),quotes);
const view=document.getElementById('conversationView');
view.innerHTML=msgs.map(m=>`<div class="message-bubble ${m.role}"><div class="message-label">${m.role==='listener'?'Listener':'Speaker'}</div><div class="message-text">${m.htmlText||escH(m.text)}</div></div>`).join('');
view.querySelectorAll('mark.evidence-hl').forEach(mk=>{
mk.addEventListener('mouseenter',()=>showTooltip(mk,anno));
mk.addEventListener('mouseleave',hideTooltip);
});
}
function renderConvHeader(row){
const anno=getEffectiveAnno(row);
const e=anno.emotion;
const en=displayEmotion(row);
const ei=e?.intensity||'';
const intCls=ei.toLowerCase().includes('high')?'intensity-high':ei.toLowerCase().includes('medium')?'intensity-medium':'intensity-low';
document.getElementById('convMeta').innerHTML=`
<span class="meta-badge ${emotionCls(en)}">${en}</span>
${ei?`<span class="intensity-badge ${intCls}">${ei}</span>`:''}
${row.problem_type?`<span class="problem-badge">${row.problem_type}</span>`:''}
${row.experience_type?`<span class="experience-badge">${row.experience_type}</span>`:''}`;
}
// ===== Tooltip =====
function showTooltip(mk,anno){
const cls=Array.from(mk.classList).find(c=>c.startsWith('dist-hl-')||c.startsWith('emp-hl-'));
let html='';
if(cls?.startsWith('dist-hl-')){
const i=parseInt(cls.split('-').pop());
const d=(anno.distortions?.distortions||[])[i];
if(d){const c=DIST_COLORS[i%DIST_COLORS.length];html=`<div class="tooltip-title" style="color:${c}">⚠️ ${d.cognitive_distortion||'Distortion'}</div><div class="tooltip-label">Reasoning</div><div class="tooltip-text">${d.reasoning||'—'}</div>`;}
} else if(cls?.startsWith('emp-hl-')){
const i=parseInt(cls.split('-').pop());
const dim=(anno.empathy?.empathy_dimensions||[])[i];
if(dim){const c=EMP_COLORS[i%EMP_COLORS.length],sc=scoreColor(dim.score||0);html=`<div class="tooltip-title" style="color:${c}">💡 ${dim.dimension_name}</div><div class="tooltip-label">Score</div><span class="tooltip-score" style="background:${sc}22;color:${sc};border:1px solid ${sc}44">${dim.score}/7</span><div class="tooltip-label">Reasoning</div><div class="tooltip-text">${dim.reasoning||'—'}</div>`;}
}
if(!html)return;
const tip=document.getElementById('tooltip');
document.getElementById('tooltipContent').innerHTML=html;
tip.classList.add('visible');
const r=mk.getBoundingClientRect();
let top=r.bottom+8,left=r.left;
if(left+320>window.innerWidth)left=window.innerWidth-330;
if(top+200>window.innerHeight)top=r.top-200;
tip.style.top=top+'px';tip.style.left=left+'px';
}
function hideTooltip(){document.getElementById('tooltip').classList.remove('visible');}
// ===== Render: Emotion (View + Edit) =====
function renderEmotion(row){
const anno=getEffectiveAnno(row);
const e=anno.emotion;
const goldEmotion=displayEmotion(row);
const predictedEmotion=e?.primary_emotion || '—';
const emotionsMatch=predictedEmotion!=='—' &&
predictedEmotion.trim().toLowerCase()===goldEmotion.trim().toLowerCase();
const el=document.getElementById('emotionContent');
const hasEdit=!!(reviewsMap[currentIndex]?.annotations_edit);
const btn=document.getElementById('editEmotionBtn');
btn.classList.toggle('active',editMode.emotion);
btn.textContent=editMode.emotion?'✓ Done':'✏️ Edit';
if(editMode.emotion){
const d=editData.emotion;
// Support both old (reasoning) and new (standard_reasoning + appraisal_reasoning) formats
const stdReason = d?.standard_reasoning || d?.reasoning || '';
const appReason = d?.appraisal_reasoning || '';
el.innerHTML=`
<div class="edit-group"><div class="edit-label">Gold Emotion</div>
<input class="edit-field" value="${escA(goldEmotion)}" readonly/></div>
<div class="edit-group"><div class="edit-label">LLM Predicted Emotion</div>
<input class="edit-field" value="${escA(predictedEmotion)}" readonly/></div>
<div class="edit-group"><div class="edit-label">Intensity</div>
<select class="edit-field" id="ee-intensity">
${INTENSITY_OPTS.map(o=>`<option value="${o}"${(d?.intensity||'').toLowerCase()===o.toLowerCase()?' selected':''}>${o}</option>`).join('')}
</select></div>
<div class="edit-group"><div class="edit-label">Specific Term</div>
<input class="edit-field" id="ee-term" value="${escA(d?.specific_emotion_term||'')}"/></div>
<div class="edit-group"><div class="edit-label">Standard Reasoning</div>
<textarea class="edit-field" id="ee-std-reason" rows="3">${stdReason}</textarea></div>
<div class="edit-group"><div class="edit-label">Appraisal Reasoning</div>
<textarea class="edit-field" id="ee-app-reason" rows="5">${appReason}</textarea></div>`;
['ee-intensity','ee-term','ee-std-reason','ee-app-reason'].forEach(id=>{
document.getElementById(id)?.addEventListener('input',()=>{
editData.emotion={
// Preserve the read-only LLM prediction when saving other emotion edits.
primary_emotion:editData.emotion?.primary_emotion || d?.primary_emotion || '',
intensity:document.getElementById('ee-intensity').value,
specific_emotion_term:document.getElementById('ee-term').value,
standard_reasoning:document.getElementById('ee-std-reason').value,
appraisal_reasoning:document.getElementById('ee-app-reason').value,
};
});
});
} else {
if(!e){
el.innerHTML=`<div class="emotion-comparison">
<div class="emotion-comparison-item">
<div class="emotion-comparison-label">Gold Emotion</div>
<span class="emotion-badge ${emotionCls(goldEmotion)}">${escH(goldEmotion)}</span>
</div>
<div class="emotion-comparison-item">
<div class="emotion-comparison-label">LLM Prediction</div>
<span class="emotion-badge emotion-unavailable">—</span>
</div>
</div>
<p style="color:var(--text-muted);font-size:.8rem">No LLM annotation details.</p>`;
return;
}
const intCls=(e.intensity||'').toLowerCase().includes('high')?'intensity-high':(e.intensity||'').toLowerCase().includes('medium')?'intensity-medium':'intensity-low';
// Determine which reasoning fields to show (new format takes priority)
const hasNewFormat = !!(e.standard_reasoning || e.appraisal_reasoning);
const stdR = e.standard_reasoning || (!hasNewFormat ? e.reasoning : '');
const appR = e.appraisal_reasoning || '';
el.innerHTML=`
<div class="emotion-comparison">
<div class="emotion-comparison-item">
<div class="emotion-comparison-label">Gold Emotion</div>
<span class="emotion-badge ${emotionCls(goldEmotion)}">${escH(goldEmotion)}</span>
</div>
<div class="emotion-comparison-item">
<div class="emotion-comparison-label">LLM Prediction</div>
<span class="emotion-badge ${emotionCls(predictedEmotion)}">${escH(predictedEmotion)}</span>
</div>
</div>
<div class="emotion-prediction-meta">
<span class="emotion-match-status ${emotionsMatch?'is-match':'is-different'}">
${emotionsMatch?'✓ Match':'≠ Different'}
</span>
<span class="intensity-badge ${intCls}">${escH(e.intensity||'—')}</span>
${hasEdit?'<span class="edited-badge">Edited</span>':''}
</div>
${e.specific_emotion_term?`<p class="specific-term">Term: <span>${e.specific_emotion_term}</span></p>`:''}
${stdR?`<div class="emotion-reasoning-block">
<div class="reasoning-label">Standard Reasoning</div>
<p class="emotion-reasoning">${escH(stdR)}</p>
</div>`:''}
${appR?`<div class="emotion-reasoning-block" style="margin-top:8px">
<div class="reasoning-label">Appraisal Reasoning</div>
<p class="emotion-reasoning" style="border-left-color:var(--accent)">${escH(appR)}</p>
</div>`:''}`;
}
}
// ===== Render: Distortions (View + Edit) =====
function renderDistortions(row){
const anno=getEffectiveAnno(row);
const el=document.getElementById('distortionsContent');
const dists=editMode.distortions ? editData.distortions : (anno.distortions?.distortions||[]);
const hasEdit=!!(reviewsMap[currentIndex]?.annotations_edit);
const btn=document.getElementById('editDistBtn');
btn.classList.toggle('active',editMode.distortions);
btn.textContent=editMode.distortions?'✓ Done':'✏️ Edit';
if(editMode.distortions){
if(!dists.length){
el.innerHTML=`<div class="no-distortions"><span>No distortions</span></div>`;
} else {
el.innerHTML=dists.map((d,i)=>{
const c=DIST_COLORS[i%DIST_COLORS.length];
return `<div class="distortion-item editing" data-dist-idx="${i}">
<div class="edit-row">
<span class="dist-color-dot" style="background:${c}"></span>
<select class="edit-field" data-d-field="name" data-d-idx="${i}" style="flex:1">
<option value="" disabled ${!d.cognitive_distortion?'selected':''}>Select Distortion...</option>
${DISTORTION_OPTS.map(o=>`<option value="${o}" ${d.cognitive_distortion===o?'selected':''}>${o}</option>`).join('')}
<option value="${escA(d.cognitive_distortion||'')}" ${!DISTORTION_OPTS.includes(d.cognitive_distortion||'')&&d.cognitive_distortion?'selected':''} hidden>${escA(d.cognitive_distortion||'Custom')}</option>
</select>
<button class="remove-item-btn" data-remove-dist="${i}">✕</button>
</div>
<div class="edit-label" style="margin-top:6px">Evidence Quote</div>
<div class="edit-row">
<input class="edit-field" data-d-field="quote" data-d-idx="${i}" value="${escA(d.evidence_quote||'')}" placeholder="Evidence quote" style="flex:1"/>
<button class="select-evidence-btn" data-sel-card="distortions" data-sel-idx="${i}">📌 Select</button>
</div>
<div class="edit-label" style="margin-top:6px">Reasoning</div>
<textarea class="edit-field" data-d-field="reason" data-d-idx="${i}" rows="2" placeholder="Reasoning...">${d.reasoning||''}</textarea>
</div>`;
}).join('');
}
el.innerHTML+=`<button class="add-item-btn" id="addDistBtn">+ Add Distortion</button>`;
// events
el.querySelectorAll('[data-d-field]').forEach(inp=>{
inp.addEventListener('input',()=>{
const idx=parseInt(inp.dataset.dIdx);
const f=inp.dataset.dField;
if(f==='name') editData.distortions[idx].cognitive_distortion=inp.value;
else if(f==='quote') editData.distortions[idx].evidence_quote=inp.value;
else if(f==='reason') editData.distortions[idx].reasoning=inp.value;
});
});
el.querySelectorAll('[data-remove-dist]').forEach(btn=>{
btn.addEventListener('click',()=>{
editData.distortions.splice(parseInt(btn.dataset.removeDist),1);
renderDistortions(row);
});
});
el.querySelectorAll('[data-sel-card]').forEach(btn=>{
btn.addEventListener('click',()=>startSelectingEvidence(btn.dataset.selCard,parseInt(btn.dataset.selIdx),btn));
});
document.getElementById('addDistBtn')?.addEventListener('click',()=>{
editData.distortions.push({cognitive_distortion:'',evidence_quote:'',reasoning:''});
renderDistortions(row);
});
} else {
if(!dists.length){el.innerHTML=`<div class="no-distortions"><span>No distortions detected</span></div>`;return;}
el.innerHTML=dists.map((d,i)=>{
const c=DIST_COLORS[i%DIST_COLORS.length];
const q=(d.evidence_quote||'').replace(/^(listener|speaker):\s*/i,'');
return `<div class="distortion-item" data-dist-idx="${i}">
<div class="distortion-name"><span class="dist-color-dot" style="background:${c}"></span>${d.cognitive_distortion||'—'} ${hasEdit&&i===0?'<span class="edited-badge">Edited</span>':''}</div>
${q?`<div class="distortion-quote" style="border-color:${c}">${q}</div>`:''}
${d.reasoning?`<div class="distortion-reasoning">${d.reasoning}</div>`:''}
</div>`;
}).join('');
el.querySelectorAll('.distortion-item').forEach(item=>{
item.addEventListener('click',()=>scrollToEvidence('dist-hl',parseInt(item.dataset.distIdx)%3));
});
}
}
// ===== Render: Empathy (View + Edit) =====
function renderEmpathy(row){
const anno=getEffectiveAnno(row);
const el=document.getElementById('empathyContent');
const scoreEl=document.getElementById('overallScore');
const dims=editMode.empathy ? editData.empathy : (anno.empathy?.empathy_dimensions||[]);
const hasEdit=!!(reviewsMap[currentIndex]?.annotations_edit);
const btn=document.getElementById('editEmpBtn');
btn.classList.toggle('active',editMode.empathy);
btn.textContent=editMode.empathy?'✓ Done':'✏️ Edit';
if(!anno.empathy&&!editMode.empathy){el.innerHTML='<p style="color:var(--text-muted);font-size:.8rem">No annotation.</p>';scoreEl.textContent='';return;}
const total=dims.reduce((s,d)=>s+(d.score||0),0);
scoreEl.textContent=dims.length?`${total} / ${dims.length*7}`:'';
if(editMode.empathy){
el.innerHTML=dims.map((dim,i)=>{
const c=EMP_COLORS[i%EMP_COLORS.length];
const sc=dim.score||0;
return `<div class="empathy-item editing" data-emp-idx="${i}">
<div class="edit-row">
<span class="emp-color-dot" style="background:${c}"></span>
<input class="edit-field" data-e-field="name" data-e-idx="${i}" value="${escA(dim.dimension_name||'')}" placeholder="Dimension name" style="flex:1"/>
<button class="remove-item-btn" data-remove-emp="${i}">✕</button>
</div>
<div class="edit-label" style="margin-top:6px">Score (0–7)</div>
<div class="edit-row">
<input type="range" class="score-slider" data-e-field="score" data-e-idx="${i}" min="0" max="7" value="${sc}"/>
<span class="score-display" id="score-disp-${i}" style="color:${scoreColor(sc)}">${sc}</span>
</div>
<div class="edit-label" style="margin-top:6px">Evidence Quote</div>
<div class="edit-row">
<input class="edit-field" data-e-field="quote" data-e-idx="${i}" value="${escA(dim.evidence_quote||'')}" placeholder="Evidence quote" style="flex:1"/>
<button class="select-evidence-btn" data-sel-card="empathy" data-sel-idx="${i}">📌 Select</button>
</div>
<div class="edit-label" style="margin-top:6px">Reasoning</div>
<textarea class="edit-field" data-e-field="reason" data-e-idx="${i}" rows="2">${dim.reasoning||''}</textarea>
</div>`;
}).join('');
el.innerHTML+=`<button class="add-item-btn" id="addEmpBtn">+ Add Dimension</button>`;
// events
el.querySelectorAll('[data-e-field]').forEach(inp=>{
inp.addEventListener('input',()=>{
const idx=parseInt(inp.dataset.eIdx);
const f=inp.dataset.eField;
if(f==='name') editData.empathy[idx].dimension_name=inp.value;
else if(f==='score'){
const v=parseInt(inp.value);
editData.empathy[idx].score=v;
const disp=document.getElementById(`score-disp-${idx}`);
if(disp){disp.textContent=v;disp.style.color=scoreColor(v);}
}
else if(f==='quote') editData.empathy[idx].evidence_quote=inp.value;
else if(f==='reason') editData.empathy[idx].reasoning=inp.value;
});
});
el.querySelectorAll('[data-remove-emp]').forEach(btn=>{
btn.addEventListener('click',()=>{editData.empathy.splice(parseInt(btn.dataset.removeEmp),1);renderEmpathy(row);});
});
el.querySelectorAll('[data-sel-card]').forEach(btn=>{
btn.addEventListener('click',()=>startSelectingEvidence(btn.dataset.selCard,parseInt(btn.dataset.selIdx),btn));
});
document.getElementById('addEmpBtn')?.addEventListener('click',()=>{
editData.empathy.push({dimension_name:'',score:0,evidence_quote:'',reasoning:''});
renderEmpathy(row);
});
} else {
el.innerHTML=dims.map((dim,i)=>{
const c=EMP_COLORS[i%EMP_COLORS.length];
const sc=dim.score||0;
const pct=Math.min(100,(sc/7)*100);
const sC=scoreColor(sc);
const q=(dim.evidence_quote||'').replace(/^(listener|speaker):\s*/i,'');
return `<div class="empathy-item" data-emp-idx="${i}">
<div class="empathy-item-header">
<span class="emp-color-dot" style="background:${c}"></span>
<span class="empathy-name">${dim.dimension_name||'—'}</span>
<div class="score-bar-container">
<div class="score-bar"><div class="score-bar-fill" style="width:${pct}%;background:${sC}"></div></div>
<span class="score-value" style="color:${sC}">${sc}</span>
</div>
</div>
${q&&q!=='No evidence present'?`<div class="empathy-quote" style="border-color:${c}" title="${escA(q)}">"${q}"</div>`:''}
${dim.reasoning?`<div class="empathy-reasoning">${dim.reasoning}</div>`:''}
</div>`;
}).join('');
el.querySelectorAll('.empathy-item').forEach(item=>{
item.addEventListener('click',()=>{
item.classList.toggle('active');
scrollToEvidence('emp-hl',parseInt(item.dataset.empIdx)%9);
});
});
}
}
// ===== Evidence Selection Mode =====
function startSelectingEvidence(card, itemIdx, triggerBtn){
selectingEvidence={card, itemIdx};
const panel=document.querySelector('.conversation-panel');
panel.classList.add('selecting-mode');
const hint=document.getElementById('selectionHint');
hint.classList.add('visible');
// Mark button as active
document.querySelectorAll('.select-evidence-btn').forEach(b=>b.classList.remove('selecting'));
triggerBtn?.classList.add('selecting');
}
function stopSelectingEvidence(){
selectingEvidence=null;
document.querySelector('.conversation-panel')?.classList.remove('selecting-mode');
const hint=document.getElementById('selectionHint');
hint?.classList.remove('visible');
document.querySelectorAll('.select-evidence-btn').forEach(b=>b.classList.remove('selecting'));
}
function captureSelection(){
if(!selectingEvidence)return;
const sel=window.getSelection();
if(!sel||sel.rangeCount===0||sel.isCollapsed)return;
const text=sel.toString().trim();
if(!text)return;
const {card,itemIdx}=selectingEvidence;
// Set the value in editData
if(card==='distortions'&&editData.distortions[itemIdx]!==undefined){
editData.distortions[itemIdx].evidence_quote=text;
} else if(card==='empathy'&&editData.empathy[itemIdx]!==undefined){
editData.empathy[itemIdx].evidence_quote=text;
}
sel.removeAllRanges();
stopSelectingEvidence();
// Re-render the card to update input field
const row=filteredData[currentIndex];
if(card==='distortions') renderDistortions(row);
else if(card==='empathy') renderEmpathy(row);
}
// ===== Save Annotations =====
async function saveAnnotations(){
const row=filteredData[currentIndex];
const anno=getEffectiveAnno(row);
// Merge current editData into the annotation structure
const toSave={
emotion: editData.emotion || anno.emotion,
distortions: anno.distortions
? {...anno.distortions, distortions: editData.distortions||anno.distortions?.distortions||[]}
: {distortions: editData.distortions||[]},
empathy: anno.empathy
? {...anno.empathy, empathy_dimensions: editData.empathy||anno.empathy?.empathy_dimensions||[]}
: {empathy_dimensions: editData.empathy||[]}
};
try {
const res=await fetch('/api/reviews/annotations',{
method:'PATCH',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({assignment_id:assignmentId,sample_index:currentIndex,annotations:toSave})
});
const data=await res.json();
if(res.ok){
reviewsMap[currentIndex]=data;
return true;
}
} catch(e){}
return false;
}
// ===== Toggle Edit Mode =====
function toggleEdit(card){
const row=filteredData[currentIndex];
const anno=getEffectiveAnno(row);
if(editMode[card]){
// Exiting edit mode — auto-save
editMode[card]=false;
stopSelectingEvidence();
saveAnnotations().then(ok=>{
if(ok){
// Re-render all to show updated highlights
renderAll();
}
});
} else {
// Entering edit mode — init editData from effective annotation
if(card==='emotion') editData.emotion=deepCopy(anno.emotion||{primary_emotion:'',intensity:'',specific_emotion_term:'',reasoning:''});
else if(card==='distortions') editData.distortions=deepCopy(anno.distortions?.distortions||[]);
else if(card==='empathy') editData.empathy=deepCopy(anno.empathy?.empathy_dimensions||[]);
editMode[card]=true;
if(card==='emotion') renderEmotion(row);
else if(card==='distortions') renderDistortions(row);
else if(card==='empathy') renderEmpathy(row);
}
}
// ===== Review State =====
function renderReviewState(){
const review=reviewsMap[currentIndex];
const badge=document.getElementById('reviewStatusBadge');
const acceptBtn=document.getElementById('acceptBtn');
const rejectBtn=document.getElementById('rejectBtn');
const comment=document.getElementById('reviewComment');
const submitBtn=document.getElementById('submitReviewBtn');
acceptBtn.classList.remove('active');
rejectBtn.classList.remove('active');
currentReviewStatus=null;
if(review&&review.status&&review.status!=='reject'||(review&&review.status==='accept')){
// Has a real review decision
}
if(review){
badge.style.display='inline-block';
badge.textContent=review.status==='accept'?'✓ Accepted':'✗ Rejected';
badge.className='review-status-badge '+(review.status==='accept'?'review-status-accept':'review-status-reject');
currentReviewStatus=review.status;
if(review.status==='accept') acceptBtn.classList.add('active');
else rejectBtn.classList.add('active');
comment.value=review.comment||'';
submitBtn.textContent='Update Review';
} else {
badge.style.display='none';
comment.value='';
submitBtn.textContent='Submit Review';
}
submitBtn.disabled=false;
}
// ===== Sample List =====
function renderSampleList(){
const list=document.getElementById('sampleList');
list.innerHTML='';
filteredData.forEach((row,i)=>{
const en=displayEmotion(row);
const review=reviewsMap[i];
const dotCls=review?(review.status==='accept'?'dot-accept':'dot-reject'):'dot-pending';
const hasEdit=!!(review?.annotations_edit);
const item=document.createElement('div');
item.className='sample-item'+(i===currentIndex?' active':'');
item.dataset.idx=i;
item.innerHTML=`<div class="sample-item-idx">#${i+1} <span class="sample-review-dot ${dotCls}"></span>${hasEdit?'<span style="color:#fbbf24;font-size:.6rem">✏</span>':''}</div>
<div class="sample-item-emotion" style="color:var(--emotion-color)">${en}</div>
<div class="sample-item-problem">${row.problem_type||''}</div>`;
item.addEventListener('click',()=>{currentIndex=i;renderAll();});
list.appendChild(item);
});
}
// ===== Navigation =====
function updateNav(){
document.getElementById('prevBtn').disabled=currentIndex<=0;
document.getElementById('nextBtn').disabled=currentIndex>=filteredData.length-1;
document.getElementById('currentIdx').textContent=currentIndex+1;
document.getElementById('totalSamples').textContent=filteredData.length;
document.querySelectorAll('.sample-item').forEach(it=>it.classList.toggle('active',parseInt(it.dataset.idx)===currentIndex));
document.querySelector('.sample-item.active')?.scrollIntoView({block:'nearest'});
}
// ===== Render All =====
function renderAll(){
if(!filteredData.length)return;
// Exit edit modes on navigation
editMode={emotion:false,distortions:false,empathy:false};
stopSelectingEvidence();
const row=filteredData[currentIndex];
renderConvHeader(row);
renderConversation(row);
renderEmotion(row);
renderDistortions(row);
renderEmpathy(row);
renderReviewState();
updateNav();
}
// ===== Filter =====
function applyFilter(q){
q=q.toLowerCase().trim();
filteredData=q?allData.filter(row=>{
return displayEmotion(row).toLowerCase().includes(q)||
(row.problem_type||'').toLowerCase().includes(q);
}):[...allData];
currentIndex=0;renderSampleList();renderAll();
}
// ===== Submit Review =====
async function submitReview(){
if(!currentReviewStatus){alert('Please select Accept or Reject');return;}
const comment=document.getElementById('reviewComment').value.trim();
const btn=document.getElementById('submitReviewBtn');
btn.disabled=true;btn.textContent='Submitting...';
try{
const res=await fetch('/api/reviews',{
method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({assignment_id:assignmentId,sample_index:currentIndex,status:currentReviewStatus,comment})
});
const data=await res.json();
if(res.ok){
reviewsMap[currentIndex]=data;
renderReviewState();renderSampleList();
btn.textContent='✓ Saved!';
setTimeout(()=>{
btn.textContent='Update Review';btn.disabled=false;
const next=filteredData.findIndex((_,i)=>i>currentIndex&&!reviewsMap[i]);
if(next!==-1){currentIndex=next;renderAll();}
},800);
} else {alert(data.error||'Failed');btn.textContent='Submit Review';btn.disabled=false;}
} catch(e){alert('Network error');btn.textContent='Submit Review';btn.disabled=false;}
}
// ===== Annotation History =====
function closeHistory(){
const modal=document.getElementById('historyModal');
modal.classList.remove('visible');
modal.setAttribute('aria-hidden','true');
}
function historySnapshotHtml(entry,version,row){
const a=entry.annotations||{};
const emotion=a.emotion||{};
const distortions=a.distortions?.distortions||[];
const empathy=a.empathy?.empathy_dimensions||[];
const when=entry.created_at
? new Date(entry.created_at).toLocaleString()
: 'Unknown time';
const emotionParts=[
displayEmotion(row),
emotion.specific_emotion_term,
emotion.intensity
].filter(Boolean).map(v=>escH(String(v)));
const distortionNames=distortions
.map(d=>d.cognitive_distortion).filter(Boolean).map(v=>escH(String(v)));
const empathyNames=empathy.map(d=>{
const name=escH(String(d.dimension_name||'Dimension'));
return d.score===undefined||d.score===null ? name : `${name} (${escH(String(d.score))}/7)`;
});
const fullJson=escH(JSON.stringify(a,null,2));
return `<article class="history-entry">
<div class="history-entry-head">
<span class="history-version">Version ${version}</span>
<span class="history-meta">${escH(entry.edited_by||'unknown')} · ${escH(when)}</span>
</div>
<div class="history-summary">
<div class="history-field">
<div class="history-field-label">Emotion</div>
<div>${emotionParts.join(' · ')||'—'}</div>
</div>
${emotion.standard_reasoning?`<div class="history-field"><div class="history-field-label">Standard Reasoning</div><div class="history-reasoning">${escH(emotion.standard_reasoning)}</div></div>`:''}
${emotion.appraisal_reasoning?`<div class="history-field"><div class="history-field-label">Appraisal Reasoning</div><div class="history-reasoning">${escH(emotion.appraisal_reasoning)}</div></div>`:''}
<div class="history-field">
<div class="history-field-label">Distortions (${distortions.length})</div>
<div>${distortionNames.join(', ')||'None'}</div>
</div>
<div class="history-field">
<div class="history-field-label">Empathy dimensions (${empathy.length})</div>
<div>${empathyNames.join(', ')||'None'}</div>
</div>
<details class="history-json"><summary>View full snapshot</summary><pre>${fullJson}</pre></details>
</div>
</article>`;
}
async function openHistory(){
if(!assignmentId||!filteredData.length)return;
const modal=document.getElementById('historyModal');
const content=document.getElementById('historyContent');
const row=filteredData[currentIndex];
document.getElementById('historySubtitle').textContent=`Sample #${currentIndex+1} · ${displayEmotion(row)}`;
content.innerHTML='<div class="history-loading">Loading history...</div>';
modal.classList.add('visible');
modal.setAttribute('aria-hidden','false');
try{
const res=await fetch(`/api/assignments/${assignmentId}/samples/${currentIndex}/annotation-history`);
const entries=await res.json();
if(!res.ok)throw new Error(entries.error||'Failed to load history');
if(!entries.length){
content.innerHTML='<div class="history-empty">No edit history yet. A version will appear after an annotation is changed and saved.</div>';
return;
}
content.innerHTML=entries.map((entry,i)=>historySnapshotHtml(entry,entries.length-i,row)).join('');
}catch(err){
content.innerHTML=`<div class="history-empty">${escH(err.message||'Failed to load history')}</div>`;
}
}
// ===== Load Data =====
async function loadAssignmentData(){
try{
let fileId,filename;
const r1=await fetch('/api/my-assignments');
const myAssign=await r1.json();
let a=myAssign.find(x=>x.id===assignmentId);
if(!a){
const r2=await fetch('/api/assignments');
if(r2.ok){const all=await r2.json();a=all.find(x=>x.id===assignmentId);}
}
if(!a){document.getElementById('reviewSubtitle').textContent='Assignment not found';return;}
fileId=a.file_id;filename=a.filename;
const r3=await fetch(`/api/files/${fileId}/data`);
const fd=await r3.json();
allData=fd.samples||[];filteredData=[...allData];
const r4=await fetch(`/api/assignments/${assignmentId}/reviews`);
reviewsMap=await r4.json();
document.getElementById('reviewTitle').textContent='Review';
document.getElementById('reviewSubtitle').textContent=filename||'Annotation Review';
currentIndex=0;renderSampleList();renderAll();
} catch(err){console.error(err);document.getElementById('reviewSubtitle').textContent='Failed to load';}
}
// ===== DOMContentLoaded =====
document.addEventListener('DOMContentLoaded',async()=>{
const parts=window.location.pathname.split('/');
assignmentId=parseInt(parts[parts.length-1]);
if(isNaN(assignmentId))return;
try{const m=await fetch('/api/me').then(r=>r.json());document.getElementById('userBadge').textContent=m.username;}catch(e){}
document.getElementById('logoutBtn').addEventListener('click',async()=>{await fetch('/api/logout',{method:'POST'});window.location.href='/login';});
document.getElementById('prevBtn').addEventListener('click',()=>{if(currentIndex>0){currentIndex--;renderAll();}});
document.getElementById('nextBtn').addEventListener('click',()=>{if(currentIndex<filteredData.length-1){currentIndex++;renderAll();}});
document.getElementById('searchInput').addEventListener('input',e=>applyFilter(e.target.value));
document.getElementById('acceptBtn').addEventListener('click',()=>{currentReviewStatus='accept';document.getElementById('acceptBtn').classList.add('active');document.getElementById('rejectBtn').classList.remove('active');});
document.getElementById('rejectBtn').addEventListener('click',()=>{currentReviewStatus='reject';document.getElementById('rejectBtn').classList.add('active');document.getElementById('acceptBtn').classList.remove('active');});
document.getElementById('submitReviewBtn').addEventListener('click',submitReview);
document.getElementById('historyBtn').addEventListener('click',openHistory);
document.getElementById('historyCloseBtn').addEventListener('click',closeHistory);
document.getElementById('historyModal').addEventListener('click',e=>{
if(e.target===e.currentTarget)closeHistory();
});
// Edit toggle buttons
['editEmotionBtn','editDistBtn','editEmpBtn'].forEach(id=>{
document.getElementById(id)?.addEventListener('click',()=>{
const card={'editEmotionBtn':'emotion','editDistBtn':'distortions','editEmpBtn':'empathy'}[id];
toggleEdit(card);
});
});
// Capture text selection from conversation
document.getElementById('conversationView').addEventListener('mouseup',()=>{
if(selectingEvidence) captureSelection();
});
// Escape key cancels selection
document.addEventListener('keydown',e=>{
if(e.key==='Escape'){
if(document.getElementById('historyModal').classList.contains('visible'))closeHistory();
else stopSelectingEvidence();
return;
}
if(e.target.tagName==='INPUT'||e.target.tagName==='TEXTAREA'||e.target.tagName==='SELECT')return;
if(e.key==='ArrowLeft'&&currentIndex>0){currentIndex--;renderAll();}
if(e.key==='ArrowRight'&&currentIndex<filteredData.length-1){currentIndex++;renderAll();}
});
await loadAssignmentData();
});
// ===== Export =====
function doExport(format){
if(!assignmentId)return;
const url=`/api/assignments/${assignmentId}/export?format=${format}`;
if(format==='csv'){
// Direct download
const a=document.createElement('a');
a.href=url;a.download='';a.click();
} else {
// JSON: fetch and download as file
fetch(url).then(r=>r.json()).then(data=>{
const blob=new Blob([JSON.stringify(data,null,2)],{type:'application/json'});
const a=document.createElement('a');
a.href=URL.createObjectURL(blob);
a.download=`export_assignment_${assignmentId}.json`;
a.click();URL.revokeObjectURL(a.href);
}).catch(e=>alert('Export failed'));
}
document.getElementById('exportMenu').style.display='none';
}
// Wire up export buttons after DOM ready
document.addEventListener('DOMContentLoaded',()=>{
const exportBtn=document.getElementById('exportBtn');
const exportMenu=document.getElementById('exportMenu');
if(exportBtn&&exportMenu){
exportBtn.addEventListener('click',(e)=>{
e.stopPropagation();
exportMenu.style.display=exportMenu.style.display==='none'?'block':'none';
});
document.getElementById('exportJSON')?.addEventListener('click',()=>doExport('json'));
document.getElementById('exportCSV')?.addEventListener('click',()=>doExport('csv'));
// Close menu on outside click
document.addEventListener('click',()=>{exportMenu.style.display='none';});
}
});