Anonymous-EmpathyAI commited on
Commit
ca36b06
·
verified ·
1 Parent(s): f719a1a

Improve review editing, evidence navigation, comments, and change history

Browse files
Files changed (6) hide show
  1. app.py +15 -14
  2. models.py +3 -3
  3. static/admin.css +59 -47
  4. static/app.js +516 -164
  5. static/style.css +63 -0
  6. templates/review.html +12 -11
app.py CHANGED
@@ -230,7 +230,7 @@ def api_get_files():
230
  total_reviews = (
231
  db.session.query(Review)
232
  .join(Assignment)
233
- .filter(Assignment.file_id == f.id, Review.status.in_(('accept', 'reject')))
234
  .count()
235
  )
236
  total_assignees = Assignment.query.filter_by(file_id=f.id).count()
@@ -333,10 +333,9 @@ def api_submit_review():
333
  data = request.get_json()
334
  assignment_id = data.get('assignment_id')
335
  sample_index = data.get('sample_index')
336
- status = data.get('status') # 'accept' or 'reject'
337
- comment = data.get('comment', '')
338
 
339
- if not assignment_id or sample_index is None or status not in ('accept', 'reject'):
340
  return jsonify({'error': 'assignment_id, sample_index, and valid status required'}), 400
341
 
342
  assignment = Assignment.query.get(assignment_id)
@@ -351,14 +350,13 @@ def api_submit_review():
351
 
352
  if review:
353
  review.status = status
354
- review.comment = comment
355
  review.reviewed_at = datetime.now(timezone.utc)
356
  else:
357
  review = Review(
358
  assignment_id=assignment_id,
359
  sample_index=sample_index,
360
  status=status,
361
- comment=comment,
362
  )
363
  db.session.add(review)
364
 
@@ -376,7 +374,7 @@ def api_save_annotations():
376
  sample_index = data.get('sample_index')
377
  annotations = data.get('annotations') # dict: {emotion, distortions, empathy, case_formulation}
378
 
379
- if not assignment_id or sample_index is None or annotations is None:
380
  return jsonify({'error': 'assignment_id, sample_index, and annotations required'}), 400
381
 
382
  assignment = Assignment.query.get(assignment_id)
@@ -397,12 +395,11 @@ def api_save_annotations():
397
  except (TypeError, _json.JSONDecodeError):
398
  pass
399
 
400
- # An already-open page using an older frontend may save without the new
401
- # field. Do not silently erase a previously edited 5P formulation.
402
- if (isinstance(annotations, dict) and 'case_formulation' not in annotations
403
- and isinstance(previous_annotations, dict)
404
- and 'case_formulation' in previous_annotations):
405
- annotations['case_formulation'] = previous_annotations['case_formulation']
406
  annotations_json = _json.dumps(
407
  annotations, ensure_ascii=False, sort_keys=True, separators=(',', ':')
408
  )
@@ -410,6 +407,8 @@ def api_save_annotations():
410
  if review:
411
  review.annotations_edit = annotations_json
412
  review.reviewed_at = datetime.now(timezone.utc)
 
 
413
  else:
414
  # Create a minimal review record to hold the annotation edits
415
  review = Review(
@@ -512,6 +511,7 @@ def api_export(assignment_id):
512
  'Distortions': anno.get('distortions'),
513
  'Empathy_Level': anno.get('empathy'),
514
  'Case_Formulation_5P': anno.get('case_formulation'),
 
515
  'review_status': review.status if review else 'pending',
516
  'review_comment': review.comment if review else '',
517
  'is_edited': bool(review and review.annotations_edit),
@@ -522,7 +522,7 @@ def api_export(assignment_id):
522
  writer = csv.writer(output)
523
  writer.writerow([
524
  'sample_id', 'sample_index', 'review_focus_rank', 'conversation_cut', 'emotion_type', 'problem_type',
525
- 'Emotion', 'Distortions', 'Empathy_Level', 'Case_Formulation_5P',
526
  'review_status', 'review_comment', 'is_edited'
527
  ])
528
  for r in export_rows:
@@ -532,6 +532,7 @@ def api_export(assignment_id):
532
  _json.dumps(r['Distortions'], ensure_ascii=False) if r['Distortions'] else '',
533
  _json.dumps(r['Empathy_Level'], ensure_ascii=False) if r['Empathy_Level'] else '',
534
  _json.dumps(r['Case_Formulation_5P'], ensure_ascii=False) if r['Case_Formulation_5P'] else '',
 
535
  r['review_status'], r['review_comment'], r['is_edited'],
536
  ])
537
  resp = app.response_class(
 
230
  total_reviews = (
231
  db.session.query(Review)
232
  .join(Assignment)
233
+ .filter(Assignment.file_id == f.id, Review.status.in_(('complete', 'accept', 'reject')))
234
  .count()
235
  )
236
  total_assignees = Assignment.query.filter_by(file_id=f.id).count()
 
333
  data = request.get_json()
334
  assignment_id = data.get('assignment_id')
335
  sample_index = data.get('sample_index')
336
+ status = data.get('status') # 'complete'; old accept/reject rows remain readable
 
337
 
338
+ if not assignment_id or sample_index is None or status != 'complete':
339
  return jsonify({'error': 'assignment_id, sample_index, and valid status required'}), 400
340
 
341
  assignment = Assignment.query.get(assignment_id)
 
350
 
351
  if review:
352
  review.status = status
 
353
  review.reviewed_at = datetime.now(timezone.utc)
354
  else:
355
  review = Review(
356
  assignment_id=assignment_id,
357
  sample_index=sample_index,
358
  status=status,
359
+ comment='',
360
  )
361
  db.session.add(review)
362
 
 
374
  sample_index = data.get('sample_index')
375
  annotations = data.get('annotations') # dict: {emotion, distortions, empathy, case_formulation}
376
 
377
+ if not assignment_id or sample_index is None or not isinstance(annotations, dict):
378
  return jsonify({'error': 'assignment_id, sample_index, and annotations required'}), 400
379
 
380
  assignment = Assignment.query.get(assignment_id)
 
395
  except (TypeError, _json.JSONDecodeError):
396
  pass
397
 
398
+ # Older open tabs may not know newer fields. Preserve existing edits.
399
+ if isinstance(previous_annotations, dict):
400
+ for field in ('case_formulation', 'review_notes'):
401
+ if field not in annotations and field in previous_annotations:
402
+ annotations[field] = previous_annotations[field]
 
403
  annotations_json = _json.dumps(
404
  annotations, ensure_ascii=False, sort_keys=True, separators=(',', ':')
405
  )
 
407
  if review:
408
  review.annotations_edit = annotations_json
409
  review.reviewed_at = datetime.now(timezone.utc)
410
+ if previous_annotations != annotations and review.status != 'pending':
411
+ review.status = 'pending'
412
  else:
413
  # Create a minimal review record to hold the annotation edits
414
  review = Review(
 
511
  'Distortions': anno.get('distortions'),
512
  'Empathy_Level': anno.get('empathy'),
513
  'Case_Formulation_5P': anno.get('case_formulation'),
514
+ 'review_notes': anno.get('review_notes') or {},
515
  'review_status': review.status if review else 'pending',
516
  'review_comment': review.comment if review else '',
517
  'is_edited': bool(review and review.annotations_edit),
 
522
  writer = csv.writer(output)
523
  writer.writerow([
524
  'sample_id', 'sample_index', 'review_focus_rank', 'conversation_cut', 'emotion_type', 'problem_type',
525
+ 'Emotion', 'Distortions', 'Empathy_Level', 'Case_Formulation_5P', 'review_notes',
526
  'review_status', 'review_comment', 'is_edited'
527
  ])
528
  for r in export_rows:
 
532
  _json.dumps(r['Distortions'], ensure_ascii=False) if r['Distortions'] else '',
533
  _json.dumps(r['Empathy_Level'], ensure_ascii=False) if r['Empathy_Level'] else '',
534
  _json.dumps(r['Case_Formulation_5P'], ensure_ascii=False) if r['Case_Formulation_5P'] else '',
535
+ _json.dumps(r['review_notes'], ensure_ascii=False) if r['review_notes'] else '',
536
  r['review_status'], r['review_comment'], r['is_edited'],
537
  ])
538
  resp = app.response_class(
models.py CHANGED
@@ -66,7 +66,7 @@ class Assignment(db.Model):
66
  def to_dict(self):
67
  reviewed_count = Review.query.filter(
68
  Review.assignment_id == self.id,
69
- Review.status.in_(('accept', 'reject')),
70
  ).count()
71
  total = self.csv_file.total_samples if self.csv_file else 0
72
  return {
@@ -88,8 +88,8 @@ class Review(db.Model):
88
  id = db.Column(db.Integer, primary_key=True)
89
  assignment_id = db.Column(db.Integer, db.ForeignKey('assignments.id'), nullable=False)
90
  sample_index = db.Column(db.Integer, nullable=False)
91
- status = db.Column(db.String(20), nullable=False) # 'pending', 'accept', or 'reject'
92
- comment = db.Column(db.Text, default='')
93
  # JSON string storing annotator-edited annotation data, including case formulation.
94
  annotations_edit = db.Column(db.Text, default=None)
95
  reviewed_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
 
66
  def to_dict(self):
67
  reviewed_count = Review.query.filter(
68
  Review.assignment_id == self.id,
69
+ Review.status.in_(('complete', 'accept', 'reject')),
70
  ).count()
71
  total = self.csv_file.total_samples if self.csv_file else 0
72
  return {
 
88
  id = db.Column(db.Integer, primary_key=True)
89
  assignment_id = db.Column(db.Integer, db.ForeignKey('assignments.id'), nullable=False)
90
  sample_index = db.Column(db.Integer, nullable=False)
91
+ status = db.Column(db.String(20), nullable=False) # 'pending', 'complete'; legacy accept/reject remain readable
92
+ comment = db.Column(db.Text, default='') # Legacy overall review comment
93
  # JSON string storing annotator-edited annotation data, including case formulation.
94
  annotations_edit = db.Column(db.Text, default=None)
95
  reviewed_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
static/admin.css CHANGED
@@ -275,49 +275,7 @@
275
 
276
  /* Review controls on review page */
277
  .review-card { border-color: rgba(88,166,255,.3); }
278
- .review-buttons {
279
- display: flex;
280
- gap: 8px;
281
- margin-bottom: 10px;
282
- }
283
- .review-btn {
284
- flex: 1;
285
- padding: 10px;
286
- border: 2px solid var(--border);
287
- border-radius: var(--radius-sm);
288
- background: transparent;
289
- font-size: .85rem;
290
- font-weight: 600;
291
- cursor: pointer;
292
- transition: all .15s;
293
- font-family: inherit;
294
- }
295
- .accept-btn { color: var(--text-muted); }
296
- .accept-btn:hover, .accept-btn.active {
297
- border-color: #34d399;
298
- background: rgba(52,211,153,.1);
299
- color: #34d399;
300
- }
301
- .reject-btn { color: var(--text-muted); }
302
- .reject-btn:hover, .reject-btn.active {
303
- border-color: #f87171;
304
- background: rgba(248,113,113,.1);
305
- color: #f87171;
306
- }
307
- .review-comment {
308
- width: 100%;
309
- padding: 10px;
310
- background: var(--bg-primary);
311
- border: 1px solid var(--border);
312
- border-radius: var(--radius-sm);
313
- color: var(--text-primary);
314
- font-size: .8rem;
315
- font-family: inherit;
316
- resize: vertical;
317
- outline: none;
318
- margin-bottom: 10px;
319
- }
320
- .review-comment:focus { border-color: var(--accent); }
321
  .submit-review-btn {
322
  width: 100%;
323
  padding: 10px;
@@ -340,8 +298,7 @@
340
  font-size: .72rem;
341
  font-weight: 700;
342
  }
343
- .review-status-accept { background: rgba(52,211,153,.12); color: #34d399; border: 1px solid rgba(52,211,153,.3); }
344
- .review-status-reject { background: rgba(248,113,113,.12); color: #f87171; border: 1px solid rgba(248,113,113,.3); }
345
  .review-status-pending { background: rgba(251,191,36,.12); color: #fbbf24; border: 1px solid rgba(251,191,36,.3); }
346
 
347
  /* Sample list review status dots */
@@ -351,8 +308,7 @@
351
  display: inline-block;
352
  margin-left: 4px;
353
  }
354
- .dot-accept { background: #34d399; }
355
- .dot-reject { background: #f87171; }
356
  .dot-pending { background: var(--border); }
357
 
358
  /* ===== Edit Mode Styles ===== */
@@ -371,6 +327,19 @@
371
  }
372
  .edit-toggle-btn:hover { border-color: var(--accent); color: var(--accent); }
373
  .edit-toggle-btn.active { border-color: var(--accent); color: var(--accent); background: rgba(88,166,255,.1); }
 
 
 
 
 
 
 
 
 
 
 
 
 
374
 
375
  /* ===== Annotation History ===== */
376
  .history-modal {
@@ -463,6 +432,30 @@
463
  .history-field + .history-field { margin-top: 7px; }
464
  .history-field-label { color: var(--text-muted); font-size: .66rem; text-transform: uppercase; letter-spacing: .05em; }
465
  .history-reasoning { margin-top: 3px; color: var(--text-secondary); white-space: pre-wrap; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
466
  .history-json { margin-top: 8px; }
467
  .history-json summary { color: var(--text-muted); cursor: pointer; font-size: .7rem; }
468
  .history-json pre {
@@ -481,6 +474,7 @@
481
  .history-modal { padding: 10px; }
482
  .history-dialog { max-height: 94vh; }
483
  .history-content { padding: 14px; }
 
484
  }
485
 
486
  .edited-badge {
@@ -509,6 +503,24 @@
509
  input.edit-field { padding: 5px 9px; }
510
  textarea.edit-field { padding: 6px 9px; resize: vertical; }
511
  select.edit-field { padding: 5px 9px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
512
 
513
  .edit-row {
514
  display: flex; align-items: center; gap: 6px;
 
275
 
276
  /* Review controls on review page */
277
  .review-card { border-color: rgba(88,166,255,.3); }
278
+ .review-help { margin: 0 0 10px; color: var(--text-muted); font-size: .76rem; line-height: 1.55; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
  .submit-review-btn {
280
  width: 100%;
281
  padding: 10px;
 
298
  font-size: .72rem;
299
  font-weight: 700;
300
  }
301
+ .review-status-complete { background: rgba(52,211,153,.12); color: #34d399; border: 1px solid rgba(52,211,153,.3); }
 
302
  .review-status-pending { background: rgba(251,191,36,.12); color: #fbbf24; border: 1px solid rgba(251,191,36,.3); }
303
 
304
  /* Sample list review status dots */
 
308
  display: inline-block;
309
  margin-left: 4px;
310
  }
311
+ .dot-complete { background: #34d399; }
 
312
  .dot-pending { background: var(--border); }
313
 
314
  /* ===== Edit Mode Styles ===== */
 
327
  }
328
  .edit-toggle-btn:hover { border-color: var(--accent); color: var(--accent); }
329
  .edit-toggle-btn.active { border-color: var(--accent); color: var(--accent); background: rgba(88,166,255,.1); }
330
+ .edit-toggle-btn:disabled { opacity: .45; cursor: not-allowed; }
331
+ .edit-cancel-btn {
332
+ padding: 3px 9px;
333
+ border: 1px solid rgba(248,113,113,.4);
334
+ border-radius: var(--radius-sm);
335
+ background: rgba(248,113,113,.08);
336
+ color: #f87171;
337
+ font-size: .72rem;
338
+ font-family: inherit;
339
+ cursor: pointer;
340
+ }
341
+ .edit-cancel-btn:hover { background: rgba(248,113,113,.18); }
342
+ .edit-cancel-btn[hidden], .restore-field-btn[hidden] { display: none; }
343
 
344
  /* ===== Annotation History ===== */
345
  .history-modal {
 
432
  .history-field + .history-field { margin-top: 7px; }
433
  .history-field-label { color: var(--text-muted); font-size: .66rem; text-transform: uppercase; letter-spacing: .05em; }
434
  .history-reasoning { margin-top: 3px; color: var(--text-secondary); white-space: pre-wrap; }
435
+ .history-change-count { margin-bottom: 10px; color: #fbbf24; font-size: .76rem; font-weight: 700; }
436
+ .history-change { padding: 9px 0; border-top: 1px solid var(--border); }
437
+ .history-change-label { margin-bottom: 6px; color: var(--text-primary); font-size: .76rem; font-weight: 700; }
438
+ .history-change-values { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px; }
439
+ .history-before, .history-after {
440
+ min-width: 0;
441
+ padding: 8px 9px;
442
+ border-radius: var(--radius-sm);
443
+ color: var(--text-secondary);
444
+ background: var(--bg-primary);
445
+ white-space: pre-wrap;
446
+ overflow-wrap: anywhere;
447
+ }
448
+ .history-before span, .history-after span {
449
+ display: block;
450
+ margin-bottom: 4px;
451
+ color: var(--text-muted);
452
+ font-size: .63rem;
453
+ font-weight: 700;
454
+ text-transform: uppercase;
455
+ }
456
+ .history-before del { color: #fda4af; background: rgba(248,113,113,.2); }
457
+ .history-after ins { color: #a7f3d0; background: rgba(52,211,153,.2); text-decoration: none; }
458
+ .history-no-change { color: var(--text-muted); font-style: italic; }
459
  .history-json { margin-top: 8px; }
460
  .history-json summary { color: var(--text-muted); cursor: pointer; font-size: .7rem; }
461
  .history-json pre {
 
474
  .history-modal { padding: 10px; }
475
  .history-dialog { max-height: 94vh; }
476
  .history-content { padding: 14px; }
477
+ .history-change-values { grid-template-columns: 1fr; }
478
  }
479
 
480
  .edited-badge {
 
503
  input.edit-field { padding: 5px 9px; }
504
  textarea.edit-field { padding: 6px 9px; resize: vertical; }
505
  select.edit-field { padding: 5px 9px; }
506
+ .restore-field-btn, .clear-field-btn {
507
+ padding: 3px 6px;
508
+ border: 1px solid var(--border);
509
+ border-radius: var(--radius-sm);
510
+ background: transparent;
511
+ color: var(--text-muted);
512
+ font-size: .67rem;
513
+ font-family: inherit;
514
+ cursor: pointer;
515
+ white-space: nowrap;
516
+ }
517
+ .restore-field-btn { margin-top: 4px; }
518
+ .edit-row .restore-field-btn { margin-top: 0; }
519
+ .clear-field-btn { color: #fbbf24; border-color: rgba(251,191,36,.3); }
520
+ .restore-field-btn:hover, .clear-field-btn:hover { border-color: var(--accent); color: var(--accent); }
521
+ .edit-row .edit-field { min-width: 0; }
522
+ .review-note-input { margin-top: 2px; }
523
+ .review-summary-group { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); }
524
 
525
  .edit-row {
526
  display: flex; align-items: center; gap: 6px;
static/app.js CHANGED
@@ -1,11 +1,17 @@
1
  // ===== State =====
2
  let allData=[], filteredData=[], currentIndex=0;
3
- let assignmentId=null, reviewsMap={}, currentReviewStatus=null;
4
  let editMode={emotion:false, distortions:false, empathy:false, caseFormulation:false};
5
  let editData={emotion:null, distortions:null, empathy:null, caseFormulation:null}; // working copies
 
 
 
 
6
  let selectingEvidence=null; // {card, itemIdx}
 
7
  let annotationSaveInProgress=false;
8
  let focusOnly=false;
 
9
  function currentSampleIndex(){return filteredData[currentIndex]?.sample_index ?? currentIndex;}
10
  function orderForReview(rows){
11
  return [...rows].sort((a,b)=>{
@@ -34,15 +40,16 @@ const DISTORTION_OPTS=[
34
 
35
  // ===== Helpers =====
36
  function safeJSON(s){try{return JSON.parse(s);}catch(e){return null;}}
37
- function escA(s){return (s||'').replace(/"/g,'"').replace(/'/g,''');}
38
- function escH(s){return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
39
  function escAttr(s){return escA(escH(String(s||'')));}
40
  function parseAnno(row){
41
  return {
42
  emotion:safeJSON(row['Emotion']),
43
  distortions:safeJSON(row['Distortions']),
44
  empathy:safeJSON(row['Empathy_Level']),
45
- case_formulation:safeJSON(row['Case_Formulation_5P'])
 
46
  };
47
  }
48
  function getEffectiveAnno(row){
@@ -71,6 +78,120 @@ function displayEmotion(row){
71
  }
72
  function scoreColor(s,m=7){const r=s/m;return r>=.7?'#34d399':r>=.4?'#fbbf24':'#f87171';}
73
  function deepCopy(o){return JSON.parse(JSON.stringify(o));}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
 
75
  // ===== Conversation parsing & highlights =====
76
  function parseConv(s){
@@ -170,15 +291,53 @@ function buildQuotes(anno){
170
  if(dim.evidence_quote&&dim.evidence_quote!=='No evidence present')
171
  quotes.push({quote:dim.evidence_quote,cls:`emp-hl-${i%9}`,type:'emp',idx:i});
172
  });
 
 
 
 
 
173
  return quotes;
174
  }
175
- function scrollToEvidence(type,idx){
176
- const m=document.querySelector(`.conversation-view mark.evidence-hl[data-type="${type}"][data-idx="${idx}"]`);
177
- if(!m)return;
178
- document.querySelectorAll('mark.evidence-pulse').forEach(x=>x.classList.remove('evidence-pulse'));
179
- m.scrollIntoView({behavior:'smooth',block:'center'});
180
- m.classList.add('evidence-pulse');
181
- setTimeout(()=>m.classList.remove('evidence-pulse'),2000);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  }
183
 
184
  // ===== Render: Conversation =====
@@ -203,9 +362,9 @@ function renderConvHeader(row){
203
  <span class="sample-id-badge" title="Stable sample ID">${escH(row.sample_id||'')}</span>
204
  ${row.review_focus_rank?`<span class="focus-review-badge" title="Reviewed in the previous CSV; annotations here are from the revised version">Re-review ${row.review_focus_rank}/${allData.filter(r=>r.review_focus_rank).length}</span>`:''}
205
  <span class="meta-badge ${emotionCls(en)}">${escH(en)}</span>
206
- ${ei?`<span class="intensity-badge ${intCls}">${ei}</span>`:''}
207
- ${row.problem_type?`<span class="problem-badge">${row.problem_type}</span>`:''}
208
- ${row.experience_type?`<span class="experience-badge">${row.experience_type}</span>`:''}`;
209
  }
210
 
211
  // ===== Tooltip =====
@@ -227,7 +386,7 @@ function showTooltip(mk,anno){
227
  } else if(type==='emp'){
228
  const i=itemIndex;
229
  const dim=(anno.empathy?.empathy_dimensions||[])[i];
230
- 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>`;}
231
  }
232
  if(!html)return;
233
  const tip=document.getElementById('tooltip');
@@ -250,10 +409,8 @@ function renderEmotion(row){
250
  const emotionsMatch=predictedEmotion!=='—' &&
251
  predictedEmotion.trim().toLowerCase()===goldEmotion.trim().toLowerCase();
252
  const el=document.getElementById('emotionContent');
253
- const hasEdit=!!(reviewsMap[row.sample_index]?.annotations_edit);
254
- const btn=document.getElementById('editEmotionBtn');
255
- btn.classList.toggle('active',editMode.emotion);
256
- btn.textContent=editMode.emotion?'✓ Done':'✏️ Edit';
257
 
258
  if(editMode.emotion){
259
  const d=editData.emotion;
@@ -262,25 +419,26 @@ function renderEmotion(row){
262
  const appReason = d?.appraisal_reasoning || '';
263
  el.innerHTML=`
264
  <div class="edit-group"><div class="edit-label">Gold Emotion</div>
265
- <input class="edit-field" value="${escA(goldEmotion)}" readonly/></div>
266
  <div class="edit-group"><div class="edit-label">LLM Predicted Emotion</div>
267
- <input class="edit-field" value="${escA(predictedEmotion)}" readonly/></div>
268
  <div class="edit-group"><div class="edit-label">Intensity</div>
269
  <select class="edit-field" id="ee-intensity">
270
  ${INTENSITY_OPTS.map(o=>`<option value="${o}"${(d?.intensity||'').toLowerCase()===o.toLowerCase()?' selected':''}>${o}</option>`).join('')}
271
  </select></div>
272
  <div class="edit-group"><div class="edit-label">Specific Term</div>
273
- <input class="edit-field" id="ee-term" value="${escA(d?.specific_emotion_term||'')}"/></div>
274
  <div class="edit-group"><div class="edit-label">Compound Emotion</div>
275
- <input class="edit-field" id="ee-compound" value="${escA(d?.compound_emotion||'')}" placeholder="None"/></div>
276
  <div class="edit-group"><div class="edit-label">Standard Reasoning</div>
277
  <textarea class="edit-field" id="ee-std-reason" rows="3">${escH(stdReason)}</textarea></div>
278
  <div class="edit-group"><div class="edit-label">Appraisal Reasoning</div>
279
- <textarea class="edit-field" id="ee-app-reason" rows="5">${escH(appReason)}</textarea></div>`;
 
 
280
  ['ee-intensity','ee-term','ee-compound','ee-std-reason','ee-app-reason'].forEach(id=>{
281
  document.getElementById(id)?.addEventListener('input',()=>{
282
- editData.emotion={
283
- ...editData.emotion,
284
  // Preserve the read-only LLM prediction when saving other emotion edits.
285
  primary_emotion:editData.emotion?.primary_emotion || d?.primary_emotion || '',
286
  intensity:document.getElementById('ee-intensity').value,
@@ -288,9 +446,13 @@ function renderEmotion(row){
288
  compound_emotion:document.getElementById('ee-compound').value,
289
  standard_reasoning:document.getElementById('ee-std-reason').value,
290
  appraisal_reasoning:document.getElementById('ee-app-reason').value,
291
- };
292
  });
293
  });
 
 
 
 
294
  } else {
295
  if(!e){
296
  el.innerHTML=`<div class="emotion-comparison">
@@ -327,18 +489,19 @@ function renderEmotion(row){
327
  ${emotionsMatch?'✓ Match':'≠ Different'}
328
  </span>
329
  <span class="intensity-badge ${intCls}">${escH(e.intensity||'—')}</span>
330
- ${hasEdit?'<span class="edited-badge">Edited</span>':''}
331
  </div>
332
- ${e.specific_emotion_term?`<p class="specific-term">Term: <span>${escH(e.specific_emotion_term)}</span></p>`:''}
333
- ${e.compound_emotion!==undefined?`<p class="compound-emotion">Compound emotion: <span>${escH(e.compound_emotion||'None')}</span></p>`:''}
334
- ${stdR?`<div class="emotion-reasoning-block">
335
  <div class="reasoning-label">Standard Reasoning</div>
336
  <p class="emotion-reasoning">${escH(stdR)}</p>
337
  </div>`:''}
338
- ${appR?`<div class="emotion-reasoning-block" style="margin-top:8px">
339
  <div class="reasoning-label">Appraisal Reasoning</div>
340
  <p class="emotion-reasoning" style="border-left-color:var(--accent)">${escH(appR)}</p>
341
- </div>`:''}`;
 
342
  }
343
  }
344
 
@@ -347,10 +510,7 @@ function renderDistortions(row){
347
  const anno=getEffectiveAnno(row);
348
  const el=document.getElementById('distortionsContent');
349
  const dists=editMode.distortions ? editData.distortions : (anno.distortions?.distortions||[]);
350
- const hasEdit=!!(reviewsMap[row.sample_index]?.annotations_edit);
351
- const btn=document.getElementById('editDistBtn');
352
- btn.classList.toggle('active',editMode.distortions);
353
- btn.textContent=editMode.distortions?'✓ Done':'✏️ Edit';
354
 
355
  if(editMode.distortions){
356
  if(!dists.length){
@@ -364,13 +524,13 @@ function renderDistortions(row){
364
  <select class="edit-field" data-d-field="name" data-d-idx="${i}" style="flex:1">
365
  <option value="" disabled ${!d.cognitive_distortion?'selected':''}>Select Distortion...</option>
366
  ${DISTORTION_OPTS.map(o=>`<option value="${o}" ${d.cognitive_distortion===o?'selected':''}>${o}</option>`).join('')}
367
- <option value="${escA(d.cognitive_distortion||'')}" ${!DISTORTION_OPTS.includes(d.cognitive_distortion||'')&&d.cognitive_distortion?'selected':''} hidden>${escA(d.cognitive_distortion||'Custom')}</option>
368
  </select>
369
  <button class="remove-item-btn" data-remove-dist="${i}">✕</button>
370
  </div>
371
  <div class="edit-label" style="margin-top:6px">Evidence Quote</div>
372
  <div class="edit-row">
373
- <input class="edit-field" data-d-field="quote" data-d-idx="${i}" value="${escA(d.evidence_quote||'')}" placeholder="Evidence quote" style="flex:1"/>
374
  <button class="select-evidence-btn" data-sel-card="distortions" data-sel-idx="${i}">📌 Select</button>
375
  </div>
376
  <div class="edit-label" style="margin-top:6px">Reasoning</div>
@@ -379,10 +539,14 @@ function renderDistortions(row){
379
  <textarea class="edit-field" data-d-field="reframe" data-d-idx="${i}" rows="3" placeholder="A balanced, supportive alternative response...">${escH(d.reframe_response||'')}</textarea>
380
  <div class="edit-label" style="margin-top:6px">Reframe Reasoning</div>
381
  <textarea class="edit-field" data-d-field="reframe-reason" data-d-idx="${i}" rows="3" placeholder="Explain why this reframe is appropriate...">${escH(d.reframe_reasoning||'')}</textarea>
 
 
382
  </div>`;
383
  }).join('');
384
  }
385
- el.innerHTML+=`<button class="add-item-btn" id="addDistBtn">+ Add Distortion</button>`;
 
 
386
  // events
387
  el.querySelectorAll('[data-d-field]').forEach(inp=>{
388
  inp.addEventListener('input',()=>{
@@ -397,24 +561,37 @@ function renderDistortions(row){
397
  });
398
  el.querySelectorAll('[data-remove-dist]').forEach(btn=>{
399
  btn.addEventListener('click',()=>{
400
- editData.distortions.splice(parseInt(btn.dataset.removeDist),1);
 
 
401
  renderDistortions(row);
402
  });
403
  });
 
 
 
404
  el.querySelectorAll('[data-sel-card]').forEach(btn=>{
405
  btn.addEventListener('click',()=>startSelectingEvidence(btn.dataset.selCard,parseInt(btn.dataset.selIdx),btn));
406
  });
407
  document.getElementById('addDistBtn')?.addEventListener('click',()=>{
408
- editData.distortions.push({cognitive_distortion:'',evidence_quote:'',reasoning:'',reframe_response:'',reframe_reasoning:''});
 
 
 
409
  renderDistortions(row);
410
  });
 
 
 
 
411
  } else {
412
- if(!dists.length){el.innerHTML=`<div class="no-distortions"><span>No distortions detected</span></div>`;return;}
413
  el.innerHTML=dists.map((d,i)=>{
414
  const c=DIST_COLORS[i%DIST_COLORS.length];
415
  const q=(d.evidence_quote||'').replace(/^(listener|speaker):\s*/i,'');
416
- return `<div class="distortion-item" data-dist-idx="${i}">
417
- <div class="distortion-name"><span class="dist-color-dot" style="background:${c}"></span>${escH(d.cognitive_distortion||'—')} ${hasEdit&&i===0?'<span class="edited-badge">Edited</span>':''}</div>
 
418
  ${q?`<div class="distortion-quote" style="border-color:${c}">${escH(q)}</div>`:''}
419
  ${d.reasoning?`<div class="distortion-reasoning">${escH(d.reasoning)}</div>`:''}
420
  ${d.reframe_response||d.reframe_reasoning?`<div class="distortion-reframe">
@@ -424,10 +601,14 @@ function renderDistortions(row){
424
  <div>${d.reframe_reasoning?escH(d.reframe_reasoning):'<span class="distortion-reframe-empty">Not provided yet</span>'}</div>
425
  </div>
426
  </div>`:''}
 
427
  </div>`;
428
- }).join('');
429
  el.querySelectorAll('.distortion-item').forEach(item=>{
430
- item.addEventListener('click',()=>scrollToEvidence('dist',parseInt(item.dataset.distIdx)));
 
 
 
431
  });
432
  }
433
  }
@@ -438,10 +619,7 @@ function renderEmpathy(row){
438
  const el=document.getElementById('empathyContent');
439
  const scoreEl=document.getElementById('overallScore');
440
  const dims=editMode.empathy ? editData.empathy : (anno.empathy?.empathy_dimensions||[]);
441
- const hasEdit=!!(reviewsMap[row.sample_index]?.annotations_edit);
442
- const btn=document.getElementById('editEmpBtn');
443
- btn.classList.toggle('active',editMode.empathy);
444
- btn.textContent=editMode.empathy?'✓ Done':'✏️ Edit';
445
 
446
  if(!anno.empathy&&!editMode.empathy){el.innerHTML='<p style="color:var(--text-muted);font-size:.8rem">No annotation.</p>';scoreEl.textContent='';return;}
447
  const total=dims.reduce((s,d)=>s+(d.score||0),0);
@@ -454,7 +632,7 @@ function renderEmpathy(row){
454
  return `<div class="empathy-item editing" data-emp-idx="${i}">
455
  <div class="edit-row">
456
  <span class="emp-color-dot" style="background:${c}"></span>
457
- <input class="edit-field" data-e-field="name" data-e-idx="${i}" value="${escA(dim.dimension_name||'')}" placeholder="Dimension name" style="flex:1"/>
458
  <button class="remove-item-btn" data-remove-emp="${i}">✕</button>
459
  </div>
460
  <div class="edit-label" style="margin-top:6px">Score (0–7)</div>
@@ -464,14 +642,18 @@ function renderEmpathy(row){
464
  </div>
465
  <div class="edit-label" style="margin-top:6px">Evidence Quote</div>
466
  <div class="edit-row">
467
- <input class="edit-field" data-e-field="quote" data-e-idx="${i}" value="${escA(dim.evidence_quote||'')}" placeholder="Evidence quote" style="flex:1"/>
468
  <button class="select-evidence-btn" data-sel-card="empathy" data-sel-idx="${i}">📌 Select</button>
469
  </div>
470
  <div class="edit-label" style="margin-top:6px">Reasoning</div>
471
- <textarea class="edit-field" data-e-field="reason" data-e-idx="${i}" rows="2">${dim.reasoning||''}</textarea>
 
 
472
  </div>`;
473
  }).join('');
474
- el.innerHTML+=`<button class="add-item-btn" id="addEmpBtn">+ Add Dimension</button>`;
 
 
475
  // events
476
  el.querySelectorAll('[data-e-field]').forEach(inp=>{
477
  inp.addEventListener('input',()=>{
@@ -489,15 +671,30 @@ function renderEmpathy(row){
489
  });
490
  });
491
  el.querySelectorAll('[data-remove-emp]').forEach(btn=>{
492
- btn.addEventListener('click',()=>{editData.empathy.splice(parseInt(btn.dataset.removeEmp),1);renderEmpathy(row);});
 
 
 
 
 
 
 
 
493
  });
494
  el.querySelectorAll('[data-sel-card]').forEach(btn=>{
495
  btn.addEventListener('click',()=>startSelectingEvidence(btn.dataset.selCard,parseInt(btn.dataset.selIdx),btn));
496
  });
497
  document.getElementById('addEmpBtn')?.addEventListener('click',()=>{
498
- editData.empathy.push({dimension_name:'',score:0,evidence_quote:'',reasoning:''});
 
 
 
499
  renderEmpathy(row);
500
  });
 
 
 
 
501
  } else {
502
  el.innerHTML=dims.map((dim,i)=>{
503
  const c=EMP_COLORS[i%EMP_COLORS.length];
@@ -505,23 +702,26 @@ function renderEmpathy(row){
505
  const pct=Math.min(100,(sc/7)*100);
506
  const sC=scoreColor(sc);
507
  const q=(dim.evidence_quote||'').replace(/^(listener|speaker):\s*/i,'');
508
- return `<div class="empathy-item" data-emp-idx="${i}">
 
509
  <div class="empathy-item-header">
510
  <span class="emp-color-dot" style="background:${c}"></span>
511
- <span class="empathy-name">${dim.dimension_name||'—'}</span>
512
  <div class="score-bar-container">
513
  <div class="score-bar"><div class="score-bar-fill" style="width:${pct}%;background:${sC}"></div></div>
514
  <span class="score-value" style="color:${sC}">${sc}</span>
515
  </div>
516
  </div>
517
- ${q&&q!=='No evidence present'?`<div class="empathy-quote" style="border-color:${c}" title="${escA(q)}">"${q}"</div>`:''}
518
- ${dim.reasoning?`<div class="empathy-reasoning">${dim.reasoning}</div>`:''}
 
519
  </div>`;
520
- }).join('');
521
  el.querySelectorAll('.empathy-item').forEach(item=>{
522
  item.addEventListener('click',()=>{
523
  item.classList.toggle('active');
524
- scrollToEvidence('emp',parseInt(item.dataset.empIdx));
 
525
  });
526
  });
527
  }
@@ -532,9 +732,8 @@ function renderCaseFormulation(row){
532
  const formulation=editMode.caseFormulation
533
  ? editData.caseFormulation : getEffectiveAnno(row).case_formulation;
534
  const el=document.getElementById('caseFormulationContent');
535
- const btn=document.getElementById('editCaseFormulationBtn');
536
- btn.classList.toggle('active',editMode.caseFormulation);
537
- btn.textContent=editMode.caseFormulation?'✓ Done':'✏️ Edit';
538
  if(!formulation&&!editMode.caseFormulation){
539
  el.innerHTML='<div class="case5p-empty">No case formulation available</div>';
540
  return;
@@ -553,6 +752,8 @@ function renderCaseFormulation(row){
553
  </div></div>
554
  <div class="edit-group"><div class="edit-label">Reasoning</div>
555
  <textarea class="edit-field" data-case-key="${factor.key}" data-case-field="reasoning" rows="3">${escH(item.reasoning||'')}</textarea></div>
 
 
556
  </section>`;
557
  }).join('');
558
  el.querySelectorAll('[data-case-field]').forEach(input=>{
@@ -565,23 +766,45 @@ function renderCaseFormulation(row){
565
  el.querySelectorAll('[data-sel-case]').forEach(button=>{
566
  button.addEventListener('click',()=>startSelectingEvidence('caseFormulation',button.dataset.selCase,button));
567
  });
 
 
 
 
568
  return;
569
  }
570
  el.innerHTML=CASE_5P_FACTORS.map(factor=>{
571
  const item=formulation[factor.key];
572
  if(!item)return '';
573
  const quote=(item.evidence_quote||'').replace(/^(listener|speaker):\s*/i,'');
574
- return `<section class="case5p-item" style="--case-color:${factor.color}">
575
- <div class="case5p-title"><span class="case5p-dot"></span>${factor.label}</div>
 
576
  ${item.identified_factors?`<div class="case5p-identified">${escH(item.identified_factors)}</div>`:''}
577
- ${quote&&quote!=='N/A'?`<div class="case5p-evidence"><span>Evidence</span>“${escH(quote)}”</div>`:''}
578
  ${item.reasoning?`<div class="case5p-reasoning"><span>Reasoning</span>${escH(item.reasoning)}</div>`:''}
 
579
  </section>`;
580
  }).join('')||'<div class="case5p-empty">No case formulation available</div>';
 
 
 
 
 
 
581
  }
582
 
583
  // ===== Evidence Selection Mode =====
584
  function startSelectingEvidence(card, itemIdx, triggerBtn){
 
 
 
 
 
 
 
 
 
 
585
  selectingEvidence={card, itemIdx};
586
  const panel=document.querySelector('.conversation-panel');
587
  panel.classList.add('selecting-mode');
@@ -624,20 +847,45 @@ function captureSelection(){
624
  }
625
 
626
  // ===== Save Annotations =====
627
- async function saveAnnotations(){
628
  const row=filteredData[currentIndex];
629
  const anno=getEffectiveAnno(row);
630
- // Merge current editData into the annotation structure
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
631
  const toSave={
632
- emotion: editData.emotion || anno.emotion,
633
- distortions: anno.distortions
634
- ? {...anno.distortions, distortions: editData.distortions||anno.distortions?.distortions||[]}
635
- : {distortions: editData.distortions||[]},
636
- empathy: anno.empathy
637
- ? {...anno.empathy, empathy_dimensions: editData.empathy||anno.empathy?.empathy_dimensions||[]}
638
- : {empathy_dimensions: editData.empathy||[]},
639
- case_formulation: editData.caseFormulation ?? anno.case_formulation
 
640
  };
 
641
  try {
642
  const res=await fetch('/api/reviews/annotations',{
643
  method:'PATCH',
@@ -660,55 +908,54 @@ async function toggleEdit(card){
660
  if(editMode[card]){
661
  if(annotationSaveInProgress)return;
662
  annotationSaveInProgress=true;
663
- const ok=await saveAnnotations();
 
664
  annotationSaveInProgress=false;
665
  if(ok){
666
- editMode[card]=false;
667
- stopSelectingEvidence();
668
  renderAll();
669
  } else {
670
  alert('Failed to save changes. Your edits are still available.');
 
671
  }
672
  } else {
 
673
  // Entering edit mode — init editData from effective annotation
674
  if(card==='emotion') editData.emotion=deepCopy(anno.emotion||{primary_emotion:'',intensity:'',specific_emotion_term:'',reasoning:''});
675
  else if(card==='distortions') editData.distortions=deepCopy(anno.distortions?.distortions||[]);
676
  else if(card==='empathy') editData.empathy=deepCopy(anno.empathy?.empathy_dimensions||[]);
677
  else if(card==='caseFormulation') editData.caseFormulation=deepCopy(anno.case_formulation||{});
 
 
 
 
 
 
 
678
  editMode[card]=true;
679
  if(card==='emotion') renderEmotion(row);
680
  else if(card==='distortions') renderDistortions(row);
681
  else if(card==='empathy') renderEmpathy(row);
682
  else if(card==='caseFormulation') renderCaseFormulation(row);
 
683
  }
684
  }
 
 
 
 
685
 
686
  // ===== Review State =====
687
  function renderReviewState(){
688
  const review=reviewsMap[currentSampleIndex()];
689
  const badge=document.getElementById('reviewStatusBadge');
690
- const acceptBtn=document.getElementById('acceptBtn');
691
- const rejectBtn=document.getElementById('rejectBtn');
692
- const comment=document.getElementById('reviewComment');
693
  const submitBtn=document.getElementById('submitReviewBtn');
694
- acceptBtn.classList.remove('active');
695
- rejectBtn.classList.remove('active');
696
- currentReviewStatus=null;
697
- if(review){
698
- badge.style.display='inline-block';
699
- badge.textContent=review.status==='accept'?'✓ Accepted':review.status==='reject'?'✗ Rejected':'Edits saved · Pending review';
700
- badge.className='review-status-badge '+(review.status==='accept'?'review-status-accept':review.status==='reject'?'review-status-reject':'review-status-pending');
701
- currentReviewStatus=review.status==='pending'?null:review.status;
702
- if(review.status==='accept') acceptBtn.classList.add('active');
703
- else if(review.status==='reject') rejectBtn.classList.add('active');
704
- comment.value=review.comment||'';
705
- submitBtn.textContent=review.status==='pending'?'Submit Review':'Update Review';
706
- } else {
707
- badge.style.display='none';
708
- comment.value='';
709
- submitBtn.textContent='Submit Review';
710
- }
711
- submitBtn.disabled=false;
712
  }
713
 
714
  // ===== Sample List =====
@@ -723,7 +970,7 @@ function renderSampleList(){
723
  filteredData.forEach((row,i)=>{
724
  const en=displayEmotion(row);
725
  const review=reviewsMap[row.sample_index];
726
- const dotCls=review?.status==='accept'?'dot-accept':review?.status==='reject'?'dot-reject':'dot-pending';
727
  const hasEdit=!!(review?.annotations_edit);
728
  const item=document.createElement('div');
729
  item.className='sample-item'+(i===currentIndex?' active':'')+(row.review_focus_rank?' focus-review':'');
@@ -733,12 +980,18 @@ function renderSampleList(){
733
  <div class="sample-item-id">${escH(row.sample_id||'')}</div>
734
  <div class="sample-item-emotion" style="color:var(--emotion-color)">${escH(en)}</div>
735
  <div class="sample-item-problem">${escH(row.problem_type||'')}</div>`;
736
- item.addEventListener('click',()=>{currentIndex=i;renderAll();});
737
  list.appendChild(item);
738
  });
739
  }
740
 
741
  // ===== Navigation =====
 
 
 
 
 
 
742
  function updateNav(){
743
  document.getElementById('prevBtn').disabled=currentIndex<=0;
744
  document.getElementById('nextBtn').disabled=currentIndex>=filteredData.length-1;
@@ -751,12 +1004,13 @@ function updateNav(){
751
  // ===== Render All =====
752
  function renderAll(){
753
  if(!filteredData.length)return;
754
- ['editEmotionBtn','editDistBtn','editEmpBtn','editCaseFormulationBtn'].forEach(id=>{
755
- document.getElementById(id).disabled=false;
756
- });
757
  // Exit edit modes on navigation
758
  editMode={emotion:false,distortions:false,empathy:false,caseFormulation:false};
759
  editData={emotion:null,distortions:null,empathy:null,caseFormulation:null};
 
 
 
 
760
  stopSelectingEvidence();
761
  const row=filteredData[currentIndex];
762
  renderConvHeader(row);
@@ -766,11 +1020,13 @@ function renderAll(){
766
  renderEmpathy(row);
767
  renderCaseFormulation(row);
768
  renderReviewState();
 
769
  updateNav();
770
  }
771
 
772
  // ===== Filter =====
773
  function applyFilter(q){
 
774
  q=q.toLowerCase().trim();
775
  filteredData=orderForReview(allData.filter(row=>{
776
  if(focusOnly&&!row.review_focus_rank)return false;
@@ -785,6 +1041,10 @@ function applyFilter(q){
785
  } else {
786
  editMode={emotion:false,distortions:false,empathy:false,caseFormulation:false};
787
  editData={emotion:null,distortions:null,empathy:null,caseFormulation:null};
 
 
 
 
788
  stopSelectingEvidence();
789
  document.getElementById('currentIdx').textContent='0';
790
  document.getElementById('totalSamples').textContent='0';
@@ -801,19 +1061,19 @@ function applyFilter(q){
801
  ['editEmotionBtn','editDistBtn','editEmpBtn','editCaseFormulationBtn'].forEach(id=>{
802
  document.getElementById(id).disabled=true;
803
  });
 
804
  }
805
  }
806
 
807
  // ===== Submit Review =====
808
  async function submitReview(){
809
- if(!currentReviewStatus){alert('Please select Accept or Reject');return;}
810
- const comment=document.getElementById('reviewComment').value.trim();
811
  const btn=document.getElementById('submitReviewBtn');
812
- btn.disabled=true;btn.textContent='Submitting...';
813
  try{
814
  const res=await fetch('/api/reviews',{
815
  method:'POST',headers:{'Content-Type':'application/json'},
816
- body:JSON.stringify({assignment_id:assignmentId,sample_index:currentSampleIndex(),status:currentReviewStatus,comment})
817
  });
818
  const data=await res.json();
819
  if(res.ok){
@@ -821,12 +1081,12 @@ async function submitReview(){
821
  renderReviewState();renderSampleList();
822
  btn.textContent='✓ Saved!';
823
  setTimeout(()=>{
824
- btn.textContent='Update Review';btn.disabled=false;
825
- const next=filteredData.findIndex((row,i)=>i>currentIndex&&(!reviewsMap[row.sample_index]||reviewsMap[row.sample_index].status==='pending'));
826
- if(next!==-1){currentIndex=next;renderAll();}
827
  },800);
828
- } else {alert(data.error||'Failed');btn.textContent='Submit Review';btn.disabled=false;}
829
- } catch(e){alert('Network error');btn.textContent='Submit Review';btn.disabled=false;}
830
  }
831
 
832
  // ===== Annotation History =====
@@ -836,50 +1096,125 @@ function closeHistory(){
836
  modal.setAttribute('aria-hidden','true');
837
  }
838
 
839
- function historySnapshotHtml(entry,version,row){
840
- const a=entry.annotations||{};
841
- const emotion=a.emotion||{};
842
- const distortions=a.distortions?.distortions||[];
843
- const empathy=a.empathy?.empathy_dimensions||[];
844
- const when=entry.created_at
845
- ? new Date(entry.created_at).toLocaleString()
846
- : 'Unknown time';
847
- const emotionParts=[
848
- displayEmotion(row),
849
- emotion.specific_emotion_term,
850
- emotion.compound_emotion && emotion.compound_emotion!=='None'
851
- ? `Compound: ${emotion.compound_emotion}` : null,
852
- emotion.intensity
853
- ].filter(Boolean).map(v=>escH(String(v)));
854
- const distortionNames=distortions
855
- .map(d=>d.cognitive_distortion).filter(Boolean).map(v=>escH(String(v)));
856
- const empathyNames=empathy.map(d=>{
857
- const name=escH(String(d.dimension_name||'Dimension'));
858
- return d.score===undefined||d.score===null ? name : `${name} (${escH(String(d.score))}/7)`;
859
- });
860
- const fullJson=escH(JSON.stringify(a,null,2));
861
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
862
  return `<article class="history-entry">
863
  <div class="history-entry-head">
864
  <span class="history-version">Version ${version}</span>
865
  <span class="history-meta">${escH(entry.edited_by||'unknown')} · ${escH(when)}</span>
866
  </div>
867
  <div class="history-summary">
868
- <div class="history-field">
869
- <div class="history-field-label">Emotion</div>
870
- <div>${emotionParts.join(' · ')||'—'}</div>
871
- </div>
872
- ${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>`:''}
873
- ${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>`:''}
874
- <div class="history-field">
875
- <div class="history-field-label">Distortions (${distortions.length})</div>
876
- <div>${distortionNames.join(', ')||'None'}</div>
877
- </div>
878
- <div class="history-field">
879
- <div class="history-field-label">Empathy dimensions (${empathy.length})</div>
880
- <div>${empathyNames.join(', ')||'None'}</div>
881
- </div>
882
- <details class="history-json"><summary>View full snapshot</summary><pre>${fullJson}</pre></details>
883
  </div>
884
  </article>`;
885
  }
@@ -901,7 +1236,8 @@ async function openHistory(){
901
  content.innerHTML='<div class="history-empty">No edit history yet. A version will appear after an annotation is changed and saved.</div>';
902
  return;
903
  }
904
- content.innerHTML=entries.map((entry,i)=>historySnapshotHtml(entry,entries.length-i,row)).join('');
 
905
  }catch(err){
906
  content.innerHTML=`<div class="history-empty">${escH(err.message||'Failed to load history')}</div>`;
907
  }
@@ -941,17 +1277,22 @@ document.addEventListener('DOMContentLoaded',async()=>{
941
  assignmentId=parseInt(parts[parts.length-1]);
942
  if(isNaN(assignmentId))return;
943
  try{const m=await fetch('/api/me').then(r=>r.json());document.getElementById('userBadge').textContent=m.username;}catch(e){}
944
- document.getElementById('logoutBtn').addEventListener('click',async()=>{await fetch('/api/logout',{method:'POST'});window.location.href='/login';});
945
- document.getElementById('prevBtn').addEventListener('click',()=>{if(currentIndex>0){currentIndex--;renderAll();}});
946
- document.getElementById('nextBtn').addEventListener('click',()=>{if(currentIndex<filteredData.length-1){currentIndex++;renderAll();}});
947
- document.getElementById('searchInput').addEventListener('input',e=>applyFilter(e.target.value));
 
 
 
 
 
 
948
  document.getElementById('focusFilterBtn').addEventListener('click',e=>{
 
949
  focusOnly=!focusOnly;
950
  e.currentTarget.setAttribute('aria-pressed',String(focusOnly));
951
  applyFilter(document.getElementById('searchInput').value);
952
  });
953
- document.getElementById('acceptBtn').addEventListener('click',()=>{currentReviewStatus='accept';document.getElementById('acceptBtn').classList.add('active');document.getElementById('rejectBtn').classList.remove('active');});
954
- document.getElementById('rejectBtn').addEventListener('click',()=>{currentReviewStatus='reject';document.getElementById('rejectBtn').classList.add('active');document.getElementById('acceptBtn').classList.remove('active');});
955
  document.getElementById('submitReviewBtn').addEventListener('click',submitReview);
956
  document.getElementById('historyBtn').addEventListener('click',openHistory);
957
  document.getElementById('historyCloseBtn').addEventListener('click',closeHistory);
@@ -965,6 +1306,9 @@ document.addEventListener('DOMContentLoaded',async()=>{
965
  toggleEdit(card);
966
  });
967
  });
 
 
 
968
  // Capture text selection from conversation
969
  document.getElementById('conversationView').addEventListener('mouseup',()=>{
970
  if(selectingEvidence) captureSelection();
@@ -977,8 +1321,16 @@ document.addEventListener('DOMContentLoaded',async()=>{
977
  return;
978
  }
979
  if(e.target.tagName==='INPUT'||e.target.tagName==='TEXTAREA'||e.target.tagName==='SELECT')return;
980
- if(e.key==='ArrowLeft'&&currentIndex>0){currentIndex--;renderAll();}
981
- if(e.key==='ArrowRight'&&currentIndex<filteredData.length-1){currentIndex++;renderAll();}
 
 
 
 
 
 
 
 
982
  });
983
  await loadAssignmentData();
984
  });
 
1
  // ===== State =====
2
  let allData=[], filteredData=[], currentIndex=0;
3
+ let assignmentId=null, reviewsMap={};
4
  let editMode={emotion:false, distortions:false, empathy:false, caseFormulation:false};
5
  let editData={emotion:null, distortions:null, empathy:null, caseFormulation:null}; // working copies
6
+ let editNotes={emotion:null, distortions:null, empathy:null, caseFormulation:null};
7
+ let editSummaryNotes={distortions:'',empathy:''};
8
+ let editBaselines={emotion:null, distortions:null, empathy:null, caseFormulation:null};
9
+ let editOrigins=new WeakMap(); // Saved field/comment values, keyed by each editable item
10
  let selectingEvidence=null; // {card, itemIdx}
11
+ let evidenceStatusTimer=null;
12
  let annotationSaveInProgress=false;
13
  let focusOnly=false;
14
+ let lastFilterQuery='';
15
  function currentSampleIndex(){return filteredData[currentIndex]?.sample_index ?? currentIndex;}
16
  function orderForReview(rows){
17
  return [...rows].sort((a,b)=>{
 
40
 
41
  // ===== Helpers =====
42
  function safeJSON(s){try{return JSON.parse(s);}catch(e){return null;}}
43
+ function escA(s){return String(s??'').replace(/"/g,'&quot;').replace(/'/g,'&#39;');}
44
+ function escH(s){return String(s??'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
45
  function escAttr(s){return escA(escH(String(s||'')));}
46
  function parseAnno(row){
47
  return {
48
  emotion:safeJSON(row['Emotion']),
49
  distortions:safeJSON(row['Distortions']),
50
  empathy:safeJSON(row['Empathy_Level']),
51
+ case_formulation:safeJSON(row['Case_Formulation_5P']),
52
+ review_notes:{}
53
  };
54
  }
55
  function getEffectiveAnno(row){
 
78
  }
79
  function scoreColor(s,m=7){const r=s/m;return r>=.7?'#34d399':r>=.4?'#fbbf24':'#f87171';}
80
  function deepCopy(o){return JSON.parse(JSON.stringify(o));}
81
+ function sameValue(a,b){
82
+ if(a===b)return true;
83
+ if(!a||!b||typeof a!=='object'||typeof b!=='object'||Array.isArray(a)!==Array.isArray(b))return false;
84
+ const ak=Object.keys(a),bk=Object.keys(b);
85
+ return ak.length===bk.length&&ak.every(key=>Object.hasOwn(b,key)&&sameValue(a[key],b[key]));
86
+ }
87
+ function isReviewed(review){return ['complete','accept','reject'].includes(review?.status);}
88
+ function activeEditCard(){return Object.keys(editMode).find(card=>editMode[card])||null;}
89
+ const EDIT_BUTTON_IDS={emotion:'editEmotionBtn',distortions:'editDistBtn',empathy:'editEmpBtn',caseFormulation:'editCaseFormulationBtn'};
90
+ function syncEditButtons(){
91
+ const active=activeEditCard();
92
+ Object.entries(EDIT_BUTTON_IDS).forEach(([card,id])=>{
93
+ const button=document.getElementById(id);
94
+ const cancel=document.querySelector(`[data-cancel-card="${card}"]`);
95
+ button.classList.toggle('active',editMode[card]);
96
+ button.textContent=editMode[card]?'✓ Save':'✏️ Edit';
97
+ button.disabled=!filteredData.length||annotationSaveInProgress||Boolean(active&&active!==card);
98
+ cancel.hidden=!editMode[card];
99
+ cancel.disabled=annotationSaveInProgress;
100
+ });
101
+ const complete=document.getElementById('submitReviewBtn');
102
+ if(complete&&active)complete.disabled=true;
103
+ }
104
+ function confirmLeaveEdit(){
105
+ if(annotationSaveInProgress)return false;
106
+ const card=activeEditCard();
107
+ if(!card)return true;
108
+ const baseline=editBaselines[card];
109
+ if(baseline&&sameValue(editData[card],baseline.data)&&sameValue(editNotes[card],baseline.notes)
110
+ &&(baseline.summary??'')===(editSummaryNotes[card]??''))return true;
111
+ return window.confirm('Discard unsaved changes in the current edit?');
112
+ }
113
+ function noteFor(anno,card,key){
114
+ const value=anno.review_notes?.[card==='caseFormulation'?'case_formulation':card];
115
+ return key===undefined ? (typeof value==='string'?value:'') : (value?.[key]||'');
116
+ }
117
+ function noteHtml(note){
118
+ return note?`<div class="saved-review-note"><span>Reviewer comment</span>${escH(note)}</div>`:'';
119
+ }
120
+ function changeTag(changed){return changed?'<span class="change-tag">Changed</span>':'';}
121
+
122
+ function rememberEditOrigins(card){
123
+ if(card==='emotion'){
124
+ editOrigins.set(editData.emotion,{value:deepCopy(editData.emotion),comment:editNotes.emotion});
125
+ }else if(card==='caseFormulation'){
126
+ CASE_5P_FACTORS.forEach(factor=>{
127
+ editData.caseFormulation[factor.key] ||= {};
128
+ const item=editData.caseFormulation[factor.key];
129
+ editOrigins.set(item,{value:deepCopy(item),comment:editNotes.caseFormulation[factor.key]||''});
130
+ });
131
+ }else{
132
+ editData[card].forEach((item,i)=>{
133
+ editOrigins.set(item,{value:deepCopy(item),comment:editNotes[card][i]||''});
134
+ });
135
+ }
136
+ }
137
+ function editObjectForInput(card,input){
138
+ if(card==='emotion')return editData.emotion;
139
+ if(card==='caseFormulation')return editData.caseFormulation?.[input.dataset.caseKey];
140
+ if(card==='distortions')return editData.distortions?.[Number(input.dataset.dIdx)];
141
+ return editData.empathy?.[Number(input.dataset.eIdx)];
142
+ }
143
+ function initialFieldValue(card,input){
144
+ if(input.dataset.reviewSummary)return editBaselines[card]?.summary??'';
145
+ const origin=editOrigins.get(editObjectForInput(card,input));
146
+ if(!origin)return undefined;
147
+ if(input.dataset.reviewNote)return origin.comment;
148
+ const field=card==='emotion'
149
+ ? {'ee-term':'specific_emotion_term','ee-compound':'compound_emotion',
150
+ 'ee-std-reason':'standard_reasoning','ee-app-reason':'appraisal_reasoning'}[input.id]
151
+ : card==='distortions'
152
+ ? {name:'cognitive_distortion',quote:'evidence_quote',reason:'reasoning',
153
+ reframe:'reframe_response','reframe-reason':'reframe_reasoning'}[input.dataset.dField]
154
+ : card==='empathy'
155
+ ? {name:'dimension_name',quote:'evidence_quote',reason:'reasoning'}[input.dataset.eField]
156
+ : input.dataset.caseField;
157
+ if(!field)return undefined;
158
+ if(card==='emotion'&&field==='standard_reasoning')
159
+ return origin.value.standard_reasoning||origin.value.reasoning||'';
160
+ return origin.value[field]??'';
161
+ }
162
+ function addRestoreButtons(container,card){
163
+ container.querySelectorAll('input.edit-field:not([readonly]), textarea.edit-field').forEach(input=>{
164
+ const original=initialFieldValue(card,input);
165
+ if(original===undefined)return;
166
+ const button=document.createElement('button');
167
+ button.type='button';
168
+ button.className='restore-field-btn';
169
+ button.textContent='↶ Restore';
170
+ button.title='Restore the value saved before this edit';
171
+ const update=()=>{button.hidden=input.value===String(original);};
172
+ input.addEventListener('input',update);
173
+ button.addEventListener('click',()=>{
174
+ input.value=String(original);
175
+ input.dispatchEvent(new Event('input',{bubbles:true}));
176
+ input.focus();
177
+ });
178
+ input.insertAdjacentElement('afterend',button);
179
+ if(input.dataset.dField==='quote'||input.dataset.eField==='quote'||input.dataset.caseField==='evidence_quote'){
180
+ const clear=document.createElement('button');
181
+ clear.type='button';
182
+ clear.className='clear-field-btn';
183
+ clear.textContent='Clear';
184
+ clear.title='Clear the current evidence quote before selecting another';
185
+ clear.addEventListener('click',()=>{
186
+ input.value='';
187
+ input.dispatchEvent(new Event('input',{bubbles:true}));
188
+ input.focus();
189
+ });
190
+ input.insertAdjacentElement('afterend',clear);
191
+ }
192
+ update();
193
+ });
194
+ }
195
 
196
  // ===== Conversation parsing & highlights =====
197
  function parseConv(s){
 
291
  if(dim.evidence_quote&&dim.evidence_quote!=='No evidence present')
292
  quotes.push({quote:dim.evidence_quote,cls:`emp-hl-${i%9}`,type:'emp',idx:i});
293
  });
294
+ CASE_5P_FACTORS.forEach((factor,i)=>{
295
+ const quote=anno.case_formulation?.[factor.key]?.evidence_quote;
296
+ if(quote&&quote!=='N/A'&&quote!=='No evidence present')
297
+ quotes.push({quote,cls:`case-hl-${i}`,type:'case',idx:factor.key});
298
+ });
299
  return quotes;
300
  }
301
+ function quoteCandidates(quote){
302
+ const clean=quote.replace(/^(listener|speaker):\s*/i,'').trim();
303
+ const fragments=clean.split(/\n+|\.{3,}|…|;|\|/).map(part=>part.trim())
304
+ .filter(part=>part.length>=12&&part.split(/\s+/).length>=3);
305
+ return [...new Set([clean,...fragments])].filter(Boolean);
306
+ }
307
+ function showEvidenceStatus(message){
308
+ const status=document.getElementById('evidenceStatus');
309
+ if(!status)return;
310
+ status.textContent=message;
311
+ status.hidden=!message;
312
+ clearTimeout(evidenceStatusTimer);
313
+ if(message)evidenceStatusTimer=setTimeout(()=>{status.hidden=true;},4500);
314
+ }
315
+ function scrollToEvidence(type,idx,quote=''){
316
+ let target=document.querySelector(`.conversation-view mark.evidence-hl[data-type="${type}"][data-idx="${idx}"]`);
317
+ if(!target&&quote){
318
+ const role= /^(listener|speaker):\s*/i.exec(quote)?.[1]?.toLowerCase();
319
+ const messages=parseConv(filteredData[currentIndex]?.conversation_cut||'');
320
+ const candidates=quoteCandidates(quote);
321
+ let messageIndex=-1,matched='';
322
+ for(const restrictRole of [true,false]){
323
+ for(const candidate of candidates){
324
+ messageIndex=messages.findIndex(msg=>(!restrictRole||!role||msg.role===role)&&findEvidenceRange(msg.text,candidate));
325
+ if(messageIndex!==-1){matched=candidate;break;}
326
+ }
327
+ if(messageIndex!==-1)break;
328
+ }
329
+ const bubble=document.querySelectorAll('.conversation-view .message-bubble')[messageIndex];
330
+ if(bubble){
331
+ target=[...bubble.querySelectorAll('mark.evidence-hl')]
332
+ .find(mark=>findEvidenceRange(mark.textContent,matched))||bubble;
333
+ }
334
+ }
335
+ if(!target){showEvidenceStatus('This evidence quote could not be located in the conversation.');return;}
336
+ showEvidenceStatus('');
337
+ document.querySelectorAll('.evidence-pulse').forEach(x=>x.classList.remove('evidence-pulse'));
338
+ target.scrollIntoView({behavior:'smooth',block:'center'});
339
+ target.classList.add('evidence-pulse');
340
+ setTimeout(()=>target.classList.remove('evidence-pulse'),2000);
341
  }
342
 
343
  // ===== Render: Conversation =====
 
362
  <span class="sample-id-badge" title="Stable sample ID">${escH(row.sample_id||'')}</span>
363
  ${row.review_focus_rank?`<span class="focus-review-badge" title="Reviewed in the previous CSV; annotations here are from the revised version">Re-review ${row.review_focus_rank}/${allData.filter(r=>r.review_focus_rank).length}</span>`:''}
364
  <span class="meta-badge ${emotionCls(en)}">${escH(en)}</span>
365
+ ${ei?`<span class="intensity-badge ${intCls}">${escH(ei)}</span>`:''}
366
+ ${row.problem_type?`<span class="problem-badge">${escH(row.problem_type)}</span>`:''}
367
+ ${row.experience_type?`<span class="experience-badge">${escH(row.experience_type)}</span>`:''}`;
368
  }
369
 
370
  // ===== Tooltip =====
 
386
  } else if(type==='emp'){
387
  const i=itemIndex;
388
  const dim=(anno.empathy?.empathy_dimensions||[])[i];
389
+ if(dim){const c=EMP_COLORS[i%EMP_COLORS.length],sc=scoreColor(dim.score||0);html=`<div class="tooltip-title" style="color:${c}">💡 ${escH(dim.dimension_name||'Dimension')}</div><div class="tooltip-label">Score</div><span class="tooltip-score" style="background:${sc}22;color:${sc};border:1px solid ${sc}44">${escH(dim.score)}/7</span><div class="tooltip-label">Reasoning</div><div class="tooltip-text">${escH(dim.reasoning||'—')}</div>`;}
390
  }
391
  if(!html)return;
392
  const tip=document.getElementById('tooltip');
 
409
  const emotionsMatch=predictedEmotion!=='—' &&
410
  predictedEmotion.trim().toLowerCase()===goldEmotion.trim().toLowerCase();
411
  const el=document.getElementById('emotionContent');
412
+ const originalEmotion=parseAnno(row).emotion||{};
413
+ const changed=!sameValue(e,parseAnno(row).emotion)||Boolean(noteFor(anno,'emotion'));
 
 
414
 
415
  if(editMode.emotion){
416
  const d=editData.emotion;
 
419
  const appReason = d?.appraisal_reasoning || '';
420
  el.innerHTML=`
421
  <div class="edit-group"><div class="edit-label">Gold Emotion</div>
422
+ <input class="edit-field" value="${escAttr(goldEmotion)}" readonly/></div>
423
  <div class="edit-group"><div class="edit-label">LLM Predicted Emotion</div>
424
+ <input class="edit-field" value="${escAttr(predictedEmotion)}" readonly/></div>
425
  <div class="edit-group"><div class="edit-label">Intensity</div>
426
  <select class="edit-field" id="ee-intensity">
427
  ${INTENSITY_OPTS.map(o=>`<option value="${o}"${(d?.intensity||'').toLowerCase()===o.toLowerCase()?' selected':''}>${o}</option>`).join('')}
428
  </select></div>
429
  <div class="edit-group"><div class="edit-label">Specific Term</div>
430
+ <input class="edit-field" id="ee-term" value="${escAttr(d?.specific_emotion_term||'')}"/></div>
431
  <div class="edit-group"><div class="edit-label">Compound Emotion</div>
432
+ <input class="edit-field" id="ee-compound" value="${escAttr(d?.compound_emotion||'')}" placeholder="None"/></div>
433
  <div class="edit-group"><div class="edit-label">Standard Reasoning</div>
434
  <textarea class="edit-field" id="ee-std-reason" rows="3">${escH(stdReason)}</textarea></div>
435
  <div class="edit-group"><div class="edit-label">Appraisal Reasoning</div>
436
+ <textarea class="edit-field" id="ee-app-reason" rows="5">${escH(appReason)}</textarea></div>
437
+ <div class="edit-group"><div class="edit-label">Review comment (why this change?)</div>
438
+ <textarea class="edit-field review-note-input" data-review-note="emotion" rows="2" placeholder="Explain changes to the emotion annotation...">${escH(editNotes.emotion||'')}</textarea></div>`;
439
  ['ee-intensity','ee-term','ee-compound','ee-std-reason','ee-app-reason'].forEach(id=>{
440
  document.getElementById(id)?.addEventListener('input',()=>{
441
+ Object.assign(editData.emotion,{
 
442
  // Preserve the read-only LLM prediction when saving other emotion edits.
443
  primary_emotion:editData.emotion?.primary_emotion || d?.primary_emotion || '',
444
  intensity:document.getElementById('ee-intensity').value,
 
446
  compound_emotion:document.getElementById('ee-compound').value,
447
  standard_reasoning:document.getElementById('ee-std-reason').value,
448
  appraisal_reasoning:document.getElementById('ee-app-reason').value,
449
+ });
450
  });
451
  });
452
+ el.querySelector('[data-review-note="emotion"]').addEventListener('input',event=>{
453
+ editNotes.emotion=event.target.value;
454
+ });
455
+ addRestoreButtons(el,'emotion');
456
  } else {
457
  if(!e){
458
  el.innerHTML=`<div class="emotion-comparison">
 
489
  ${emotionsMatch?'✓ Match':'≠ Different'}
490
  </span>
491
  <span class="intensity-badge ${intCls}">${escH(e.intensity||'—')}</span>
492
+ ${changeTag(changed)}
493
  </div>
494
+ ${e.specific_emotion_term?`<p class="specific-term ${!sameValue(e.specific_emotion_term,originalEmotion.specific_emotion_term)?'changed-field':''}">Term: <span>${escH(e.specific_emotion_term)}</span></p>`:''}
495
+ ${e.compound_emotion!==undefined?`<p class="compound-emotion ${!sameValue(e.compound_emotion,originalEmotion.compound_emotion)?'changed-field':''}">Compound emotion: <span>${escH(e.compound_emotion||'None')}</span></p>`:''}
496
+ ${stdR?`<div class="emotion-reasoning-block ${!sameValue(stdR,originalEmotion.standard_reasoning||originalEmotion.reasoning||'')?'changed-field':''}">
497
  <div class="reasoning-label">Standard Reasoning</div>
498
  <p class="emotion-reasoning">${escH(stdR)}</p>
499
  </div>`:''}
500
+ ${appR?`<div class="emotion-reasoning-block ${!sameValue(appR,originalEmotion.appraisal_reasoning||'')?'changed-field':''}" style="margin-top:8px">
501
  <div class="reasoning-label">Appraisal Reasoning</div>
502
  <p class="emotion-reasoning" style="border-left-color:var(--accent)">${escH(appR)}</p>
503
+ </div>`:''}
504
+ ${noteHtml(noteFor(anno,'emotion'))}`;
505
  }
506
  }
507
 
 
510
  const anno=getEffectiveAnno(row);
511
  const el=document.getElementById('distortionsContent');
512
  const dists=editMode.distortions ? editData.distortions : (anno.distortions?.distortions||[]);
513
+ const originalDists=parseAnno(row).distortions?.distortions||[];
 
 
 
514
 
515
  if(editMode.distortions){
516
  if(!dists.length){
 
524
  <select class="edit-field" data-d-field="name" data-d-idx="${i}" style="flex:1">
525
  <option value="" disabled ${!d.cognitive_distortion?'selected':''}>Select Distortion...</option>
526
  ${DISTORTION_OPTS.map(o=>`<option value="${o}" ${d.cognitive_distortion===o?'selected':''}>${o}</option>`).join('')}
527
+ <option value="${escAttr(d.cognitive_distortion||'')}" ${!DISTORTION_OPTS.includes(d.cognitive_distortion||'')&&d.cognitive_distortion?'selected':''} hidden>${escH(d.cognitive_distortion||'Custom')}</option>
528
  </select>
529
  <button class="remove-item-btn" data-remove-dist="${i}">✕</button>
530
  </div>
531
  <div class="edit-label" style="margin-top:6px">Evidence Quote</div>
532
  <div class="edit-row">
533
+ <input class="edit-field" data-d-field="quote" data-d-idx="${i}" value="${escAttr(d.evidence_quote||'')}" placeholder="Evidence quote" style="flex:1"/>
534
  <button class="select-evidence-btn" data-sel-card="distortions" data-sel-idx="${i}">📌 Select</button>
535
  </div>
536
  <div class="edit-label" style="margin-top:6px">Reasoning</div>
 
539
  <textarea class="edit-field" data-d-field="reframe" data-d-idx="${i}" rows="3" placeholder="A balanced, supportive alternative response...">${escH(d.reframe_response||'')}</textarea>
540
  <div class="edit-label" style="margin-top:6px">Reframe Reasoning</div>
541
  <textarea class="edit-field" data-d-field="reframe-reason" data-d-idx="${i}" rows="3" placeholder="Explain why this reframe is appropriate...">${escH(d.reframe_reasoning||'')}</textarea>
542
+ <div class="edit-label" style="margin-top:6px">Review comment (why this change?)</div>
543
+ <textarea class="edit-field review-note-input" data-review-note="distortions" data-d-idx="${i}" rows="2" placeholder="Explain changes to this distortion...">${escH(editNotes.distortions[i]||'')}</textarea>
544
  </div>`;
545
  }).join('');
546
  }
547
+ el.innerHTML+=`<button class="add-item-btn" id="addDistBtn">+ Add Distortion</button>
548
+ <div class="edit-group review-summary-group"><div class="edit-label">Overall comment (including added or removed distortions)</div>
549
+ <textarea class="edit-field review-note-input" data-review-summary="distortions" rows="2" placeholder="Explain any changes to the distortion set...">${escH(editSummaryNotes.distortions)}</textarea></div>`;
550
  // events
551
  el.querySelectorAll('[data-d-field]').forEach(inp=>{
552
  inp.addEventListener('input',()=>{
 
561
  });
562
  el.querySelectorAll('[data-remove-dist]').forEach(btn=>{
563
  btn.addEventListener('click',()=>{
564
+ const index=parseInt(btn.dataset.removeDist);
565
+ editData.distortions.splice(index,1);
566
+ editNotes.distortions.splice(index,1);
567
  renderDistortions(row);
568
  });
569
  });
570
+ el.querySelectorAll('[data-review-note="distortions"]').forEach(input=>{
571
+ input.addEventListener('input',()=>{editNotes.distortions[Number(input.dataset.dIdx)]=input.value;});
572
+ });
573
  el.querySelectorAll('[data-sel-card]').forEach(btn=>{
574
  btn.addEventListener('click',()=>startSelectingEvidence(btn.dataset.selCard,parseInt(btn.dataset.selIdx),btn));
575
  });
576
  document.getElementById('addDistBtn')?.addEventListener('click',()=>{
577
+ const item={cognitive_distortion:'',evidence_quote:'',reasoning:'',reframe_response:'',reframe_reasoning:''};
578
+ editData.distortions.push(item);
579
+ editNotes.distortions.push('');
580
+ editOrigins.set(item,{value:deepCopy(item),comment:''});
581
  renderDistortions(row);
582
  });
583
+ el.querySelector('[data-review-summary="distortions"]').addEventListener('input',event=>{
584
+ editSummaryNotes.distortions=event.target.value;
585
+ });
586
+ addRestoreButtons(el,'distortions');
587
  } else {
588
+ if(!dists.length){el.innerHTML=`<div class="no-distortions"><span>No distortions detected</span> ${changeTag(originalDists.length>0)}</div>${noteHtml(noteFor(anno,'distortions_summary'))}`;return;}
589
  el.innerHTML=dists.map((d,i)=>{
590
  const c=DIST_COLORS[i%DIST_COLORS.length];
591
  const q=(d.evidence_quote||'').replace(/^(listener|speaker):\s*/i,'');
592
+ const changed=!sameValue(d,originalDists[i])||Boolean(noteFor(anno,'distortions',i));
593
+ return `<div class="distortion-item ${changed?'changed-item':''}" data-dist-idx="${i}">
594
+ <div class="distortion-name"><span class="dist-color-dot" style="background:${c}"></span>${escH(d.cognitive_distortion||'—')} ${changeTag(changed)}</div>
595
  ${q?`<div class="distortion-quote" style="border-color:${c}">${escH(q)}</div>`:''}
596
  ${d.reasoning?`<div class="distortion-reasoning">${escH(d.reasoning)}</div>`:''}
597
  ${d.reframe_response||d.reframe_reasoning?`<div class="distortion-reframe">
 
601
  <div>${d.reframe_reasoning?escH(d.reframe_reasoning):'<span class="distortion-reframe-empty">Not provided yet</span>'}</div>
602
  </div>
603
  </div>`:''}
604
+ ${noteHtml(noteFor(anno,'distortions',i))}
605
  </div>`;
606
+ }).join('')+noteHtml(noteFor(anno,'distortions_summary'));
607
  el.querySelectorAll('.distortion-item').forEach(item=>{
608
+ item.addEventListener('click',()=>{
609
+ const index=parseInt(item.dataset.distIdx);
610
+ scrollToEvidence('dist',index,dists[index]?.evidence_quote||'');
611
+ });
612
  });
613
  }
614
  }
 
619
  const el=document.getElementById('empathyContent');
620
  const scoreEl=document.getElementById('overallScore');
621
  const dims=editMode.empathy ? editData.empathy : (anno.empathy?.empathy_dimensions||[]);
622
+ const originalDims=parseAnno(row).empathy?.empathy_dimensions||[];
 
 
 
623
 
624
  if(!anno.empathy&&!editMode.empathy){el.innerHTML='<p style="color:var(--text-muted);font-size:.8rem">No annotation.</p>';scoreEl.textContent='';return;}
625
  const total=dims.reduce((s,d)=>s+(d.score||0),0);
 
632
  return `<div class="empathy-item editing" data-emp-idx="${i}">
633
  <div class="edit-row">
634
  <span class="emp-color-dot" style="background:${c}"></span>
635
+ <input class="edit-field" data-e-field="name" data-e-idx="${i}" value="${escAttr(dim.dimension_name||'')}" placeholder="Dimension name" style="flex:1"/>
636
  <button class="remove-item-btn" data-remove-emp="${i}">✕</button>
637
  </div>
638
  <div class="edit-label" style="margin-top:6px">Score (0–7)</div>
 
642
  </div>
643
  <div class="edit-label" style="margin-top:6px">Evidence Quote</div>
644
  <div class="edit-row">
645
+ <input class="edit-field" data-e-field="quote" data-e-idx="${i}" value="${escAttr(dim.evidence_quote||'')}" placeholder="Evidence quote" style="flex:1"/>
646
  <button class="select-evidence-btn" data-sel-card="empathy" data-sel-idx="${i}">📌 Select</button>
647
  </div>
648
  <div class="edit-label" style="margin-top:6px">Reasoning</div>
649
+ <textarea class="edit-field" data-e-field="reason" data-e-idx="${i}" rows="2">${escH(dim.reasoning||'')}</textarea>
650
+ <div class="edit-label" style="margin-top:6px">Review comment (why this change?)</div>
651
+ <textarea class="edit-field review-note-input" data-review-note="empathy" data-e-idx="${i}" rows="2" placeholder="Explain changes to this dimension...">${escH(editNotes.empathy[i]||'')}</textarea>
652
  </div>`;
653
  }).join('');
654
+ el.innerHTML+=`<button class="add-item-btn" id="addEmpBtn">+ Add Dimension</button>
655
+ <div class="edit-group review-summary-group"><div class="edit-label">Overall comment (including added or removed dimensions)</div>
656
+ <textarea class="edit-field review-note-input" data-review-summary="empathy" rows="2" placeholder="Explain any changes to the empathy dimensions...">${escH(editSummaryNotes.empathy)}</textarea></div>`;
657
  // events
658
  el.querySelectorAll('[data-e-field]').forEach(inp=>{
659
  inp.addEventListener('input',()=>{
 
671
  });
672
  });
673
  el.querySelectorAll('[data-remove-emp]').forEach(btn=>{
674
+ btn.addEventListener('click',()=>{
675
+ const index=parseInt(btn.dataset.removeEmp);
676
+ editData.empathy.splice(index,1);
677
+ editNotes.empathy.splice(index,1);
678
+ renderEmpathy(row);
679
+ });
680
+ });
681
+ el.querySelectorAll('[data-review-note="empathy"]').forEach(input=>{
682
+ input.addEventListener('input',()=>{editNotes.empathy[Number(input.dataset.eIdx)]=input.value;});
683
  });
684
  el.querySelectorAll('[data-sel-card]').forEach(btn=>{
685
  btn.addEventListener('click',()=>startSelectingEvidence(btn.dataset.selCard,parseInt(btn.dataset.selIdx),btn));
686
  });
687
  document.getElementById('addEmpBtn')?.addEventListener('click',()=>{
688
+ const item={dimension_name:'',score:0,evidence_quote:'',reasoning:''};
689
+ editData.empathy.push(item);
690
+ editNotes.empathy.push('');
691
+ editOrigins.set(item,{value:deepCopy(item),comment:''});
692
  renderEmpathy(row);
693
  });
694
+ el.querySelector('[data-review-summary="empathy"]').addEventListener('input',event=>{
695
+ editSummaryNotes.empathy=event.target.value;
696
+ });
697
+ addRestoreButtons(el,'empathy');
698
  } else {
699
  el.innerHTML=dims.map((dim,i)=>{
700
  const c=EMP_COLORS[i%EMP_COLORS.length];
 
702
  const pct=Math.min(100,(sc/7)*100);
703
  const sC=scoreColor(sc);
704
  const q=(dim.evidence_quote||'').replace(/^(listener|speaker):\s*/i,'');
705
+ const changed=!sameValue(dim,originalDims[i])||Boolean(noteFor(anno,'empathy',i));
706
+ return `<div class="empathy-item ${changed?'changed-item':''}" data-emp-idx="${i}">
707
  <div class="empathy-item-header">
708
  <span class="emp-color-dot" style="background:${c}"></span>
709
+ <span class="empathy-name">${escH(dim.dimension_name||'—')} ${changeTag(changed)}</span>
710
  <div class="score-bar-container">
711
  <div class="score-bar"><div class="score-bar-fill" style="width:${pct}%;background:${sC}"></div></div>
712
  <span class="score-value" style="color:${sC}">${sc}</span>
713
  </div>
714
  </div>
715
+ ${q&&q!=='No evidence present'?`<div class="empathy-quote" style="border-color:${c}" title="${escAttr(q)}">"${escH(q)}"</div>`:''}
716
+ ${dim.reasoning?`<div class="empathy-reasoning">${escH(dim.reasoning)}</div>`:''}
717
+ ${noteHtml(noteFor(anno,'empathy',i))}
718
  </div>`;
719
+ }).join('')+noteHtml(noteFor(anno,'empathy_summary'));
720
  el.querySelectorAll('.empathy-item').forEach(item=>{
721
  item.addEventListener('click',()=>{
722
  item.classList.toggle('active');
723
+ const index=parseInt(item.dataset.empIdx);
724
+ scrollToEvidence('emp',index,dims[index]?.evidence_quote||'');
725
  });
726
  });
727
  }
 
732
  const formulation=editMode.caseFormulation
733
  ? editData.caseFormulation : getEffectiveAnno(row).case_formulation;
734
  const el=document.getElementById('caseFormulationContent');
735
+ const anno=getEffectiveAnno(row);
736
+ const originalFormulation=parseAnno(row).case_formulation||{};
 
737
  if(!formulation&&!editMode.caseFormulation){
738
  el.innerHTML='<div class="case5p-empty">No case formulation available</div>';
739
  return;
 
752
  </div></div>
753
  <div class="edit-group"><div class="edit-label">Reasoning</div>
754
  <textarea class="edit-field" data-case-key="${factor.key}" data-case-field="reasoning" rows="3">${escH(item.reasoning||'')}</textarea></div>
755
+ <div class="edit-group"><div class="edit-label">Review comment (why this change?)</div>
756
+ <textarea class="edit-field review-note-input" data-review-note="caseFormulation" data-case-key="${factor.key}" rows="2" placeholder="Explain changes to this 5P factor...">${escH(editNotes.caseFormulation[factor.key]||'')}</textarea></div>
757
  </section>`;
758
  }).join('');
759
  el.querySelectorAll('[data-case-field]').forEach(input=>{
 
766
  el.querySelectorAll('[data-sel-case]').forEach(button=>{
767
  button.addEventListener('click',()=>startSelectingEvidence('caseFormulation',button.dataset.selCase,button));
768
  });
769
+ el.querySelectorAll('[data-review-note="caseFormulation"]').forEach(input=>{
770
+ input.addEventListener('input',()=>{editNotes.caseFormulation[input.dataset.caseKey]=input.value;});
771
+ });
772
+ addRestoreButtons(el,'caseFormulation');
773
  return;
774
  }
775
  el.innerHTML=CASE_5P_FACTORS.map(factor=>{
776
  const item=formulation[factor.key];
777
  if(!item)return '';
778
  const quote=(item.evidence_quote||'').replace(/^(listener|speaker):\s*/i,'');
779
+ const changed=!sameValue(item,originalFormulation[factor.key])||Boolean(noteFor(anno,'caseFormulation',factor.key));
780
+ return `<section class="case5p-item ${changed?'changed-item':''}" style="--case-color:${factor.color}">
781
+ <div class="case5p-title"><span class="case5p-dot"></span>${factor.label} ${changeTag(changed)}</div>
782
  ${item.identified_factors?`<div class="case5p-identified">${escH(item.identified_factors)}</div>`:''}
783
+ ${quote&&quote!=='N/A'?`<button type="button" class="case5p-evidence" data-case-evidence="${factor.key}" title="Go to this quote in the conversation"><span>Evidence · click to locate</span>“${escH(quote)}”</button>`:''}
784
  ${item.reasoning?`<div class="case5p-reasoning"><span>Reasoning</span>${escH(item.reasoning)}</div>`:''}
785
+ ${noteHtml(noteFor(anno,'caseFormulation',factor.key))}
786
  </section>`;
787
  }).join('')||'<div class="case5p-empty">No case formulation available</div>';
788
+ el.querySelectorAll('[data-case-evidence]').forEach(button=>{
789
+ button.addEventListener('click',()=>{
790
+ const key=button.dataset.caseEvidence;
791
+ scrollToEvidence('case',key,formulation[key]?.evidence_quote||'');
792
+ });
793
+ });
794
  }
795
 
796
  // ===== Evidence Selection Mode =====
797
  function startSelectingEvidence(card, itemIdx, triggerBtn){
798
+ const input=card==='distortions'
799
+ ? document.querySelector(`[data-d-field="quote"][data-d-idx="${itemIdx}"]`)
800
+ : card==='empathy'
801
+ ? document.querySelector(`[data-e-field="quote"][data-e-idx="${itemIdx}"]`)
802
+ : document.querySelector(`[data-case-field="evidence_quote"][data-case-key="${itemIdx}"]`);
803
+ if(input?.value.trim()){
804
+ alert('Clear the Evidence Quote field before selecting a different quote. Restore will bring back the saved value.');
805
+ input.focus();
806
+ return;
807
+ }
808
  selectingEvidence={card, itemIdx};
809
  const panel=document.querySelector('.conversation-panel');
810
  panel.classList.add('selecting-mode');
 
847
  }
848
 
849
  // ===== Save Annotations =====
850
+ async function saveAnnotations(card){
851
  const row=filteredData[currentIndex];
852
  const anno=getEffectiveAnno(row);
853
+ const baseline=editBaselines[card];
854
+ if(baseline&&sameValue(editData[card],baseline.data)&&sameValue(editNotes[card],baseline.notes)
855
+ &&(baseline.summary??'')===(editSummaryNotes[card]??''))return true;
856
+ const notes=deepCopy(anno.review_notes||{});
857
+ const noteKey=card==='caseFormulation'?'case_formulation':card;
858
+ let cardNotes;
859
+ if(card==='emotion')cardNotes=(editNotes.emotion||'').trim();
860
+ else if(card==='caseFormulation'){
861
+ cardNotes=Object.fromEntries(Object.entries(editNotes.caseFormulation)
862
+ .map(([key,value])=>[key,value.trim()]).filter(([,value])=>value));
863
+ }else{
864
+ cardNotes=Object.fromEntries(editNotes[card]
865
+ .map((value,index)=>[index,(value||'').trim()]).filter(([,value])=>value));
866
+ }
867
+ if(cardNotes&&((typeof cardNotes==='string'&&cardNotes.length)||Object.keys(cardNotes).length))
868
+ notes[noteKey]=cardNotes;
869
+ else delete notes[noteKey];
870
+ if(card==='distortions'||card==='empathy'){
871
+ const summary=(editSummaryNotes[card]||'').trim();
872
+ if(summary)notes[`${card}_summary`]=summary;
873
+ else delete notes[`${card}_summary`];
874
+ }
875
+
876
+ // Save only the active card; unsaved edits in other cards must never leak in.
877
  const toSave={
878
+ emotion: card==='emotion'?editData.emotion:anno.emotion,
879
+ distortions: card==='distortions'
880
+ ? {...(anno.distortions||{}),distortions:editData.distortions}
881
+ : anno.distortions,
882
+ empathy: card==='empathy'
883
+ ? {...(anno.empathy||{}),empathy_dimensions:editData.empathy}
884
+ : anno.empathy,
885
+ case_formulation: card==='caseFormulation'?editData.caseFormulation:anno.case_formulation,
886
+ review_notes:notes
887
  };
888
+ if(sameValue(toSave,{...anno,review_notes:anno.review_notes||{}}))return true;
889
  try {
890
  const res=await fetch('/api/reviews/annotations',{
891
  method:'PATCH',
 
908
  if(editMode[card]){
909
  if(annotationSaveInProgress)return;
910
  annotationSaveInProgress=true;
911
+ syncEditButtons();
912
+ const ok=await saveAnnotations(card);
913
  annotationSaveInProgress=false;
914
  if(ok){
915
+ renderSampleList();
 
916
  renderAll();
917
  } else {
918
  alert('Failed to save changes. Your edits are still available.');
919
+ syncEditButtons();
920
  }
921
  } else {
922
+ if(activeEditCard())return;
923
  // Entering edit mode — init editData from effective annotation
924
  if(card==='emotion') editData.emotion=deepCopy(anno.emotion||{primary_emotion:'',intensity:'',specific_emotion_term:'',reasoning:''});
925
  else if(card==='distortions') editData.distortions=deepCopy(anno.distortions?.distortions||[]);
926
  else if(card==='empathy') editData.empathy=deepCopy(anno.empathy?.empathy_dimensions||[]);
927
  else if(card==='caseFormulation') editData.caseFormulation=deepCopy(anno.case_formulation||{});
928
+ if(card==='emotion')editNotes.emotion=noteFor(anno,'emotion');
929
+ else if(card==='caseFormulation')editNotes.caseFormulation=Object.fromEntries(
930
+ CASE_5P_FACTORS.map(factor=>[factor.key,noteFor(anno,'caseFormulation',factor.key)]));
931
+ else editNotes[card]=editData[card].map((_,i)=>noteFor(anno,card,i));
932
+ if(card==='distortions'||card==='empathy')editSummaryNotes[card]=noteFor(anno,`${card}_summary`);
933
+ rememberEditOrigins(card);
934
+ editBaselines[card]={data:deepCopy(editData[card]),notes:deepCopy(editNotes[card]),summary:editSummaryNotes[card]||''};
935
  editMode[card]=true;
936
  if(card==='emotion') renderEmotion(row);
937
  else if(card==='distortions') renderDistortions(row);
938
  else if(card==='empathy') renderEmpathy(row);
939
  else if(card==='caseFormulation') renderCaseFormulation(row);
940
+ syncEditButtons();
941
  }
942
  }
943
+ function cancelEdit(card){
944
+ if(!editMode[card]||annotationSaveInProgress)return;
945
+ renderAll();
946
+ }
947
 
948
  // ===== Review State =====
949
  function renderReviewState(){
950
  const review=reviewsMap[currentSampleIndex()];
951
  const badge=document.getElementById('reviewStatusBadge');
 
 
 
952
  const submitBtn=document.getElementById('submitReviewBtn');
953
+ const done=isReviewed(review);
954
+ badge.style.display='inline-block';
955
+ badge.textContent=done?'✓ Completed':review?.annotations_edit?'Edits saved · Review pending':'Not reviewed';
956
+ badge.className='review-status-badge '+(done?'review-status-complete':'review-status-pending');
957
+ submitBtn.textContent=done?'✓ Review complete':'Complete review';
958
+ submitBtn.disabled=done;
 
 
 
 
 
 
 
 
 
 
 
 
959
  }
960
 
961
  // ===== Sample List =====
 
970
  filteredData.forEach((row,i)=>{
971
  const en=displayEmotion(row);
972
  const review=reviewsMap[row.sample_index];
973
+ const dotCls=isReviewed(review)?'dot-complete':'dot-pending';
974
  const hasEdit=!!(review?.annotations_edit);
975
  const item=document.createElement('div');
976
  item.className='sample-item'+(i===currentIndex?' active':'')+(row.review_focus_rank?' focus-review':'');
 
980
  <div class="sample-item-id">${escH(row.sample_id||'')}</div>
981
  <div class="sample-item-emotion" style="color:var(--emotion-color)">${escH(en)}</div>
982
  <div class="sample-item-problem">${escH(row.problem_type||'')}</div>`;
983
+ item.addEventListener('click',()=>navigateTo(i));
984
  list.appendChild(item);
985
  });
986
  }
987
 
988
  // ===== Navigation =====
989
+ function navigateTo(index){
990
+ if(index===currentIndex||index<0||index>=filteredData.length)return;
991
+ if(!confirmLeaveEdit())return;
992
+ currentIndex=index;
993
+ renderAll();
994
+ }
995
  function updateNav(){
996
  document.getElementById('prevBtn').disabled=currentIndex<=0;
997
  document.getElementById('nextBtn').disabled=currentIndex>=filteredData.length-1;
 
1004
  // ===== Render All =====
1005
  function renderAll(){
1006
  if(!filteredData.length)return;
 
 
 
1007
  // Exit edit modes on navigation
1008
  editMode={emotion:false,distortions:false,empathy:false,caseFormulation:false};
1009
  editData={emotion:null,distortions:null,empathy:null,caseFormulation:null};
1010
+ editNotes={emotion:null,distortions:null,empathy:null,caseFormulation:null};
1011
+ editSummaryNotes={distortions:'',empathy:''};
1012
+ editBaselines={emotion:null,distortions:null,empathy:null,caseFormulation:null};
1013
+ editOrigins=new WeakMap();
1014
  stopSelectingEvidence();
1015
  const row=filteredData[currentIndex];
1016
  renderConvHeader(row);
 
1020
  renderEmpathy(row);
1021
  renderCaseFormulation(row);
1022
  renderReviewState();
1023
+ syncEditButtons();
1024
  updateNav();
1025
  }
1026
 
1027
  // ===== Filter =====
1028
  function applyFilter(q){
1029
+ lastFilterQuery=q;
1030
  q=q.toLowerCase().trim();
1031
  filteredData=orderForReview(allData.filter(row=>{
1032
  if(focusOnly&&!row.review_focus_rank)return false;
 
1041
  } else {
1042
  editMode={emotion:false,distortions:false,empathy:false,caseFormulation:false};
1043
  editData={emotion:null,distortions:null,empathy:null,caseFormulation:null};
1044
+ editNotes={emotion:null,distortions:null,empathy:null,caseFormulation:null};
1045
+ editSummaryNotes={distortions:'',empathy:''};
1046
+ editBaselines={emotion:null,distortions:null,empathy:null,caseFormulation:null};
1047
+ editOrigins=new WeakMap();
1048
  stopSelectingEvidence();
1049
  document.getElementById('currentIdx').textContent='0';
1050
  document.getElementById('totalSamples').textContent='0';
 
1061
  ['editEmotionBtn','editDistBtn','editEmpBtn','editCaseFormulationBtn'].forEach(id=>{
1062
  document.getElementById(id).disabled=true;
1063
  });
1064
+ document.querySelectorAll('[data-cancel-card]').forEach(button=>{button.hidden=true;});
1065
  }
1066
  }
1067
 
1068
  // ===== Submit Review =====
1069
  async function submitReview(){
1070
+ if(activeEditCard())return;
 
1071
  const btn=document.getElementById('submitReviewBtn');
1072
+ btn.disabled=true;btn.textContent='Completing...';
1073
  try{
1074
  const res=await fetch('/api/reviews',{
1075
  method:'POST',headers:{'Content-Type':'application/json'},
1076
+ body:JSON.stringify({assignment_id:assignmentId,sample_index:currentSampleIndex(),status:'complete'})
1077
  });
1078
  const data=await res.json();
1079
  if(res.ok){
 
1081
  renderReviewState();renderSampleList();
1082
  btn.textContent='✓ Saved!';
1083
  setTimeout(()=>{
1084
+ const next=filteredData.findIndex((row,i)=>i>currentIndex&&!isReviewed(reviewsMap[row.sample_index]));
1085
+ if(next!==-1)navigateTo(next);
1086
+ else renderReviewState();
1087
  },800);
1088
+ } else {alert(data.error||'Failed');renderReviewState();}
1089
+ } catch(e){alert('Network error');renderReviewState();}
1090
  }
1091
 
1092
  // ===== Annotation History =====
 
1096
  modal.setAttribute('aria-hidden','true');
1097
  }
1098
 
1099
+ function normalizedSnapshot(row,edits){
1100
+ const original=parseAnno(row),saved=edits||{};
1101
+ return {
1102
+ emotion:{...(original.emotion||{}),...(saved.emotion||{})},
1103
+ distortions:saved.distortions??original.distortions,
1104
+ empathy:saved.empathy??original.empathy,
1105
+ case_formulation:saved.case_formulation??original.case_formulation,
1106
+ review_notes:saved.review_notes||{}
1107
+ };
1108
+ }
1109
+ function arrayMatchKey(item){
1110
+ if(item&&typeof item==='object'){
1111
+ if(item.cognitive_distortion)return `dist:${item.cognitive_distortion}`;
1112
+ if(item.dimension_name)return `emp:${item.dimension_name}`;
1113
+ }
1114
+ return JSON.stringify(item);
1115
+ }
1116
+ function arrayMatches(before,after){
1117
+ const n=before.length,m=after.length;
1118
+ const oldKeys=before.map(arrayMatchKey),newKeys=after.map(arrayMatchKey);
1119
+ const lengths=Array.from({length:n+1},()=>Array(m+1).fill(0));
1120
+ for(let i=n-1;i>=0;i--)for(let j=m-1;j>=0;j--)
1121
+ lengths[i][j]=oldKeys[i]===newKeys[j]
1122
+ ? 1+lengths[i+1][j+1] : Math.max(lengths[i+1][j],lengths[i][j+1]);
1123
+ const matches=[];
1124
+ for(let i=0,j=0;i<n&&j<m;){
1125
+ if(oldKeys[i]===newKeys[j]){matches.push([i,j]);i++;j++;}
1126
+ else if(lengths[i+1][j]>=lengths[i][j+1])i++;
1127
+ else j++;
1128
+ }
1129
+ return matches;
1130
+ }
1131
+ function collectChanges(before,after,path=[],result=[]){
1132
+ if(sameValue(before,after))return result;
1133
+ if(Array.isArray(before)||Array.isArray(after)){
1134
+ const oldItems=Array.isArray(before)?before:[];
1135
+ const newItems=Array.isArray(after)?after:[];
1136
+ let oldStart=0,newStart=0;
1137
+ for(const [oldEnd,newEnd] of [...arrayMatches(oldItems,newItems),[oldItems.length,newItems.length]]){
1138
+ const paired=Math.min(oldEnd-oldStart,newEnd-newStart);
1139
+ for(let offset=0;offset<paired;offset++)
1140
+ collectChanges(oldItems[oldStart+offset],newItems[newStart+offset],
1141
+ [...path,String(newStart+offset)],result);
1142
+ for(let index=oldStart+paired;index<oldEnd;index++)
1143
+ result.push({path:[...path,String(index)],before:oldItems[index],after:undefined});
1144
+ for(let index=newStart+paired;index<newEnd;index++)
1145
+ result.push({path:[...path,String(index)],before:undefined,after:newItems[index]});
1146
+ if(oldEnd<oldItems.length)
1147
+ collectChanges(oldItems[oldEnd],newItems[newEnd],[...path,String(newEnd)],result);
1148
+ oldStart=oldEnd+1;
1149
+ newStart=newEnd+1;
1150
+ }
1151
+ }else if((before&&typeof before==='object')||(after&&typeof after==='object')){
1152
+ const oldFields=before&&typeof before==='object'?before:{};
1153
+ const newFields=after&&typeof after==='object'?after:{};
1154
+ const keys=[...new Set([...Object.keys(oldFields),...Object.keys(newFields)])].sort();
1155
+ keys.forEach(key=>collectChanges(oldFields[key],newFields[key],[...path,key],result));
1156
+ }else result.push({path,before,after});
1157
+ return result;
1158
+ }
1159
+ const HISTORY_LABELS={
1160
+ emotion:'Emotion',distortions:'Distortions',empathy:'Empathy',empathy_dimensions:'Dimensions',
1161
+ case_formulation:'Case Formulation (5P)',review_notes:'Reviewer comments',
1162
+ distortions_summary:'Distortions (added/removed)',empathy_summary:'Empathy (added/removed)',
1163
+ presenting_problem:'Presenting Problem',predisposing_factors:'Predisposing Factors',
1164
+ precipitating_factors:'Precipitating Factors',perpetuating_factors:'Perpetuating Factors',
1165
+ protective_factors:'Protective Factors',cognitive_distortion:'Distortion type',
1166
+ evidence_quote:'Evidence Quote',identified_factors:'Identified Factors',
1167
+ reframe_response:'Reframe Response',reframe_reasoning:'Reframe Reasoning',
1168
+ standard_reasoning:'Standard Reasoning',appraisal_reasoning:'Appraisal Reasoning',
1169
+ specific_emotion_term:'Specific Term',compound_emotion:'Compound Emotion',
1170
+ primary_emotion:'LLM Prediction',intensity:'Intensity',score:'Score',
1171
+ dimension_name:'Dimension',reasoning:'Reasoning'
1172
+ };
1173
+ function historyPathLabel(path){
1174
+ return path.filter((part,i)=>!(part==='distortions'&&path[i-1]==='distortions'))
1175
+ .map(part=>/^\d+$/.test(part)?`#${Number(part)+1}`:(HISTORY_LABELS[part]||part.replace(/_/g,' ')))
1176
+ .join(' › ');
1177
+ }
1178
+ function historyValue(value){
1179
+ if(value===undefined||value===null||value==='')return '∅';
1180
+ if(typeof value==='object')return JSON.stringify(value,null,2);
1181
+ return String(value);
1182
+ }
1183
+ function highlightedChange(before,after,side){
1184
+ const oldTokens=historyValue(before).match(/\s+|[^\s]+/g)||[];
1185
+ const newTokens=historyValue(after).match(/\s+|[^\s]+/g)||[];
1186
+ let start=0,end=0;
1187
+ while(start<oldTokens.length&&start<newTokens.length&&oldTokens[start]===newTokens[start])start++;
1188
+ while(end<oldTokens.length-start&&end<newTokens.length-start&&
1189
+ oldTokens[oldTokens.length-1-end]===newTokens[newTokens.length-1-end])end++;
1190
+ const tokens=side==='before'?oldTokens:newTokens;
1191
+ const tag=side==='before'?'del':'ins';
1192
+ const prefix=tokens.slice(0,start).join('');
1193
+ const middle=tokens.slice(start,tokens.length-end).join('');
1194
+ const suffix=end?tokens.slice(-end).join(''):'';
1195
+ return `${escH(prefix)}<${tag}>${escH(middle)}</${tag}>${escH(suffix)}`;
1196
+ }
1197
+ function historySnapshotHtml(entry,previous,version,row){
1198
+ const before=normalizedSnapshot(row,previous);
1199
+ const after=normalizedSnapshot(row,entry.annotations);
1200
+ const changes=collectChanges(before,after);
1201
+ const when=entry.created_at?new Date(entry.created_at).toLocaleString():'Unknown time';
1202
+ const changeHtml=changes.map(change=>`<div class="history-change">
1203
+ <div class="history-change-label">${escH(historyPathLabel(change.path))}</div>
1204
+ <div class="history-change-values">
1205
+ <div class="history-before"><span>Before</span>${highlightedChange(change.before,change.after,'before')}</div>
1206
+ <div class="history-after"><span>After</span>${highlightedChange(change.before,change.after,'after')}</div>
1207
+ </div>
1208
+ </div>`).join('');
1209
  return `<article class="history-entry">
1210
  <div class="history-entry-head">
1211
  <span class="history-version">Version ${version}</span>
1212
  <span class="history-meta">${escH(entry.edited_by||'unknown')} · ${escH(when)}</span>
1213
  </div>
1214
  <div class="history-summary">
1215
+ <div class="history-change-count">${changes.length} changed field${changes.length===1?'':'s'}</div>
1216
+ ${changeHtml||'<div class="history-no-change">No content changes in this saved version.</div>'}
1217
+ <details class="history-json"><summary>View full snapshot</summary><pre>${escH(JSON.stringify(entry.annotations||{},null,2))}</pre></details>
 
 
 
 
 
 
 
 
 
 
 
 
1218
  </div>
1219
  </article>`;
1220
  }
 
1236
  content.innerHTML='<div class="history-empty">No edit history yet. A version will appear after an annotation is changed and saved.</div>';
1237
  return;
1238
  }
1239
+ content.innerHTML=entries.map((entry,i)=>
1240
+ historySnapshotHtml(entry,entries[i+1]?.annotations,entries.length-i,row)).join('');
1241
  }catch(err){
1242
  content.innerHTML=`<div class="history-empty">${escH(err.message||'Failed to load history')}</div>`;
1243
  }
 
1277
  assignmentId=parseInt(parts[parts.length-1]);
1278
  if(isNaN(assignmentId))return;
1279
  try{const m=await fetch('/api/me').then(r=>r.json());document.getElementById('userBadge').textContent=m.username;}catch(e){}
1280
+ document.getElementById('logoutBtn').addEventListener('click',async()=>{
1281
+ if(!confirmLeaveEdit())return;
1282
+ await fetch('/api/logout',{method:'POST'});window.location.href='/login';
1283
+ });
1284
+ document.getElementById('prevBtn').addEventListener('click',()=>navigateTo(currentIndex-1));
1285
+ document.getElementById('nextBtn').addEventListener('click',()=>navigateTo(currentIndex+1));
1286
+ document.getElementById('searchInput').addEventListener('input',e=>{
1287
+ if(!confirmLeaveEdit()){e.target.value=lastFilterQuery;return;}
1288
+ applyFilter(e.target.value);
1289
+ });
1290
  document.getElementById('focusFilterBtn').addEventListener('click',e=>{
1291
+ if(!confirmLeaveEdit())return;
1292
  focusOnly=!focusOnly;
1293
  e.currentTarget.setAttribute('aria-pressed',String(focusOnly));
1294
  applyFilter(document.getElementById('searchInput').value);
1295
  });
 
 
1296
  document.getElementById('submitReviewBtn').addEventListener('click',submitReview);
1297
  document.getElementById('historyBtn').addEventListener('click',openHistory);
1298
  document.getElementById('historyCloseBtn').addEventListener('click',closeHistory);
 
1306
  toggleEdit(card);
1307
  });
1308
  });
1309
+ document.querySelectorAll('[data-cancel-card]').forEach(button=>{
1310
+ button.addEventListener('click',()=>cancelEdit(button.dataset.cancelCard));
1311
+ });
1312
  // Capture text selection from conversation
1313
  document.getElementById('conversationView').addEventListener('mouseup',()=>{
1314
  if(selectingEvidence) captureSelection();
 
1321
  return;
1322
  }
1323
  if(e.target.tagName==='INPUT'||e.target.tagName==='TEXTAREA'||e.target.tagName==='SELECT')return;
1324
+ if(e.key==='ArrowLeft')navigateTo(currentIndex-1);
1325
+ if(e.key==='ArrowRight')navigateTo(currentIndex+1);
1326
+ });
1327
+ window.addEventListener('beforeunload',event=>{
1328
+ const card=activeEditCard(),baseline=editBaselines[card];
1329
+ if(card&&baseline&&(!sameValue(editData[card],baseline.data)||!sameValue(editNotes[card],baseline.notes)
1330
+ ||(baseline.summary??'')!==(editSummaryNotes[card]??''))){
1331
+ event.preventDefault();
1332
+ event.returnValue='';
1333
+ }
1334
  });
1335
  await loadAssignmentData();
1336
  });
static/style.css CHANGED
@@ -411,6 +411,17 @@ mark.evidence-pulse {
411
  animation: evidence-glow 0.6s ease-in-out 3;
412
  border-radius: 4px;
413
  }
 
 
 
 
 
 
 
 
 
 
 
414
  @keyframes evidence-glow {
415
  0%, 100% { box-shadow: none; filter: brightness(1); }
416
  50% { box-shadow: 0 0 12px 4px rgba(251, 191, 36, 0.5); filter: brightness(1.5); }
@@ -432,6 +443,13 @@ mark.emp-hl-6 { background: var(--hl-empathy-feelings); border-bottom-color: #81
432
  mark.emp-hl-7 { background: var(--hl-empathy-acceptance); border-bottom-color: #2dd4bf; color: inherit; }
433
  mark.emp-hl-8 { background: var(--hl-empathy-responsiveness); border-bottom-color: #e879f9; color: inherit; }
434
 
 
 
 
 
 
 
 
435
  /* ===== Annotation Panel ===== */
436
  .annotation-panel {
437
  background: var(--bg-secondary);
@@ -518,13 +536,24 @@ mark.emp-hl-8 { background: var(--hl-empathy-responsiveness); border-bottom-colo
518
  line-height: 1.55;
519
  }
520
  .case5p-evidence {
 
 
 
 
 
521
  padding: 7px 9px;
522
  border-radius: 4px;
523
  background: rgba(255,255,255,0.04);
524
  color: var(--text-secondary);
525
  font-family: 'JetBrains Mono', monospace;
 
526
  font-style: italic;
527
  }
 
 
 
 
 
528
  .case5p-evidence span,
529
  .case5p-reasoning span {
530
  display: block;
@@ -547,6 +576,40 @@ mark.emp-hl-8 { background: var(--hl-empathy-responsiveness); border-bottom-colo
547
  .case5p-evidence-edit { align-items: flex-start; margin-bottom: 0; }
548
  .case5p-evidence-edit .edit-field { min-width: 0; }
549
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
550
  /* Emotion */
551
  .emotion-primary {
552
  display: flex; align-items: center; gap: 10px;
 
411
  animation: evidence-glow 0.6s ease-in-out 3;
412
  border-radius: 4px;
413
  }
414
+ .message-bubble.evidence-pulse { animation: evidence-glow 0.6s ease-in-out 3; }
415
+ .evidence-status {
416
+ margin: 8px 12px;
417
+ padding: 7px 10px;
418
+ border: 1px solid rgba(251,191,36,.4);
419
+ border-radius: var(--radius-sm);
420
+ color: #fbbf24;
421
+ background: rgba(251,191,36,.1);
422
+ font-size: .75rem;
423
+ }
424
+ .evidence-status[hidden] { display: none; }
425
  @keyframes evidence-glow {
426
  0%, 100% { box-shadow: none; filter: brightness(1); }
427
  50% { box-shadow: 0 0 12px 4px rgba(251, 191, 36, 0.5); filter: brightness(1.5); }
 
443
  mark.emp-hl-7 { background: var(--hl-empathy-acceptance); border-bottom-color: #2dd4bf; color: inherit; }
444
  mark.emp-hl-8 { background: var(--hl-empathy-responsiveness); border-bottom-color: #e879f9; color: inherit; }
445
 
446
+ /* 5P evidence that does not overlap an existing annotation highlight. */
447
+ mark.case-hl-0 { background: rgba(96,165,250,.22); border-bottom-color: #60a5fa; color: inherit; }
448
+ mark.case-hl-1 { background: rgba(167,139,250,.22); border-bottom-color: #a78bfa; color: inherit; }
449
+ mark.case-hl-2 { background: rgba(251,146,60,.22); border-bottom-color: #fb923c; color: inherit; }
450
+ mark.case-hl-3 { background: rgba(248,113,113,.22); border-bottom-color: #f87171; color: inherit; }
451
+ mark.case-hl-4 { background: rgba(52,211,153,.22); border-bottom-color: #34d399; color: inherit; }
452
+
453
  /* ===== Annotation Panel ===== */
454
  .annotation-panel {
455
  background: var(--bg-secondary);
 
536
  line-height: 1.55;
537
  }
538
  .case5p-evidence {
539
+ display: block;
540
+ width: 100%;
541
+ border: 1px solid transparent;
542
+ text-align: left;
543
+ cursor: pointer;
544
  padding: 7px 9px;
545
  border-radius: 4px;
546
  background: rgba(255,255,255,0.04);
547
  color: var(--text-secondary);
548
  font-family: 'JetBrains Mono', monospace;
549
+ font-size: 0.75rem;
550
  font-style: italic;
551
  }
552
+ .case5p-evidence:hover, .case5p-evidence:focus-visible {
553
+ border-color: var(--case-color);
554
+ background: rgba(255,255,255,0.08);
555
+ outline: none;
556
+ }
557
  .case5p-evidence span,
558
  .case5p-reasoning span {
559
  display: block;
 
576
  .case5p-evidence-edit { align-items: flex-start; margin-bottom: 0; }
577
  .case5p-evidence-edit .edit-field { min-width: 0; }
578
 
579
+ .changed-item { border-color: rgba(251,191,36,.55) !important; box-shadow: inset 3px 0 rgba(251,191,36,.6); }
580
+ .changed-field { border-radius: 4px; background: rgba(251,191,36,.09); }
581
+ .change-tag {
582
+ display: inline-block;
583
+ margin-left: 5px;
584
+ padding: 1px 6px;
585
+ border: 1px solid rgba(251,191,36,.35);
586
+ border-radius: 10px;
587
+ color: #fbbf24;
588
+ background: rgba(251,191,36,.12);
589
+ font-size: .62rem;
590
+ font-weight: 700;
591
+ vertical-align: middle;
592
+ }
593
+ .saved-review-note {
594
+ margin-top: 9px;
595
+ padding: 8px 10px;
596
+ border-left: 3px solid #fbbf24;
597
+ border-radius: 4px;
598
+ background: rgba(251,191,36,.08);
599
+ color: var(--text-secondary);
600
+ font-size: .76rem;
601
+ line-height: 1.5;
602
+ white-space: pre-wrap;
603
+ }
604
+ .saved-review-note span {
605
+ display: block;
606
+ margin-bottom: 3px;
607
+ color: #fbbf24;
608
+ font-size: .65rem;
609
+ font-weight: 700;
610
+ text-transform: uppercase;
611
+ }
612
+
613
  /* Emotion */
614
  .emotion-primary {
615
  display: flex; align-items: center; gap: 10px;
templates/review.html CHANGED
@@ -7,8 +7,8 @@
7
  <link rel="preconnect" href="https://fonts.googleapis.com"/>
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
9
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"/>
10
- <link rel="stylesheet" href="/static/style.css?v=20261002c"/>
11
- <link rel="stylesheet" href="/static/admin.css?v=20261002b"/>
12
  </head>
13
  <body>
14
  <!-- Header -->
@@ -80,7 +80,8 @@
80
  <span class="legend-item"><span class="legend-dot listener"></span>Listener</span>
81
  </div>
82
  </div>
83
- <div class="selection-hint" id="selectionHint">✏️ Bôi đen đoạn text trong hội thoại để chọn evidence — nhấn <strong>Esc</strong> để huỷ</div>
 
84
  <div class="conversation-view" id="conversationView"></div>
85
  </section>
86
 
@@ -96,6 +97,7 @@
96
  </div>
97
  <h3>Case Formulation (5P)</h3>
98
  <button class="edit-toggle-btn" id="editCaseFormulationBtn" data-card="caseFormulation">✏️ Edit</button>
 
99
  </div>
100
  <div id="caseFormulationContent" class="anno-content"></div>
101
  </div>
@@ -109,6 +111,7 @@
109
  </div>
110
  <h3>Emotion</h3>
111
  <button class="edit-toggle-btn" id="editEmotionBtn" data-card="emotion">✏️ Edit</button>
 
112
  </div>
113
  <div id="emotionContent" class="anno-content"></div>
114
  </div>
@@ -122,6 +125,7 @@
122
  </div>
123
  <h3>Distortions</h3>
124
  <button class="edit-toggle-btn" id="editDistBtn" data-card="distortions">✏️ Edit</button>
 
125
  </div>
126
  <div id="distortionsContent" class="anno-content"></div>
127
  </div>
@@ -136,6 +140,7 @@
136
  <h3>Empathy</h3>
137
  <div class="overall-score" id="overallScore"></div>
138
  <button class="edit-toggle-btn" id="editEmpBtn" data-card="empathy">✏️ Edit</button>
 
139
  </div>
140
  <div id="empathyContent" class="anno-content"></div>
141
  </div>
@@ -147,17 +152,13 @@
147
  <path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>
148
  </svg>
149
  </div>
150
- <h3>Your Review</h3>
151
  <div class="review-status-badge" id="reviewStatusBadge" style="display:none"></div>
152
  <button class="edit-toggle-btn" id="historyBtn" type="button">🕘 History</button>
153
  </div>
154
  <div class="anno-content">
155
- <div class="review-buttons">
156
- <button class="review-btn accept-btn" id="acceptBtn">✓ Accept</button>
157
- <button class="review-btn reject-btn" id="rejectBtn">✗ Reject</button>
158
- </div>
159
- <textarea class="review-comment" id="reviewComment" placeholder="Add a comment (optional)..." rows="3"></textarea>
160
- <button class="submit-review-btn" id="submitReviewBtn">Submit Review</button>
161
  </div>
162
  </div>
163
  </aside>
@@ -192,6 +193,6 @@
192
  </button>
193
  </div>
194
 
195
- <script src="/static/app.js?v=20261022a"></script>
196
  </body>
197
  </html>
 
7
  <link rel="preconnect" href="https://fonts.googleapis.com"/>
8
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
9
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"/>
10
+ <link rel="stylesheet" href="/static/style.css?v=20261031a"/>
11
+ <link rel="stylesheet" href="/static/admin.css?v=20261031a"/>
12
  </head>
13
  <body>
14
  <!-- Header -->
 
80
  <span class="legend-item"><span class="legend-dot listener"></span>Listener</span>
81
  </div>
82
  </div>
83
+ <div class="selection-hint" id="selectionHint">Bôi đen đoạn text trong hội thoại để chọn evidence. Nếu muốn thay quote cũ, hãy xoá hết text trong ô Evidence Quote trước; nút Restore có thể khôi phục giá trị đã lưu. Nhấn <strong>Esc</strong> để huỷ chọn.</div>
84
+ <div class="evidence-status" id="evidenceStatus" role="status" aria-live="polite" hidden></div>
85
  <div class="conversation-view" id="conversationView"></div>
86
  </section>
87
 
 
97
  </div>
98
  <h3>Case Formulation (5P)</h3>
99
  <button class="edit-toggle-btn" id="editCaseFormulationBtn" data-card="caseFormulation">✏️ Edit</button>
100
+ <button class="edit-cancel-btn" data-cancel-card="caseFormulation" type="button" hidden>Cancel</button>
101
  </div>
102
  <div id="caseFormulationContent" class="anno-content"></div>
103
  </div>
 
111
  </div>
112
  <h3>Emotion</h3>
113
  <button class="edit-toggle-btn" id="editEmotionBtn" data-card="emotion">✏️ Edit</button>
114
+ <button class="edit-cancel-btn" data-cancel-card="emotion" type="button" hidden>Cancel</button>
115
  </div>
116
  <div id="emotionContent" class="anno-content"></div>
117
  </div>
 
125
  </div>
126
  <h3>Distortions</h3>
127
  <button class="edit-toggle-btn" id="editDistBtn" data-card="distortions">✏️ Edit</button>
128
+ <button class="edit-cancel-btn" data-cancel-card="distortions" type="button" hidden>Cancel</button>
129
  </div>
130
  <div id="distortionsContent" class="anno-content"></div>
131
  </div>
 
140
  <h3>Empathy</h3>
141
  <div class="overall-score" id="overallScore"></div>
142
  <button class="edit-toggle-btn" id="editEmpBtn" data-card="empathy">✏️ Edit</button>
143
+ <button class="edit-cancel-btn" data-cancel-card="empathy" type="button" hidden>Cancel</button>
144
  </div>
145
  <div id="empathyContent" class="anno-content"></div>
146
  </div>
 
152
  <path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>
153
  </svg>
154
  </div>
155
+ <h3>Review Progress</h3>
156
  <div class="review-status-badge" id="reviewStatusBadge" style="display:none"></div>
157
  <button class="edit-toggle-btn" id="historyBtn" type="button">🕘 History</button>
158
  </div>
159
  <div class="anno-content">
160
+ <p class="review-help">Add an explanation beside each annotation you change. Use Complete review even when no changes are needed.</p>
161
+ <button class="submit-review-btn" id="submitReviewBtn" type="button">Complete review</button>
 
 
 
 
162
  </div>
163
  </div>
164
  </aside>
 
193
  </button>
194
  </div>
195
 
196
+ <script src="/static/app.js?v=20261031a"></script>
197
  </body>
198
  </html>