Spaces:
Sleeping
Sleeping
Improve review editing, evidence navigation, comments, and change history
Browse files- app.py +15 -14
- models.py +3 -3
- static/admin.css +59 -47
- static/app.js +516 -164
- static/style.css +63 -0
- 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') # '
|
| 337 |
-
comment = data.get('comment', '')
|
| 338 |
|
| 339 |
-
if not assignment_id or sample_index is None or status
|
| 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=
|
| 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
|
| 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 |
-
#
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
and
|
| 404 |
-
|
| 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', '
|
| 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-
|
| 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-
|
| 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-
|
| 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={}
|
| 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
|
| 38 |
-
function escH(s){return (s
|
| 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
|
| 176 |
-
const
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 254 |
-
const
|
| 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="${
|
| 266 |
<div class="edit-group"><div class="edit-label">LLM Predicted Emotion</div>
|
| 267 |
-
<input class="edit-field" value="${
|
| 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="${
|
| 274 |
<div class="edit-group"><div class="edit-label">Compound Emotion</div>
|
| 275 |
-
<input class="edit-field" id="ee-compound" value="${
|
| 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 |
-
${
|
| 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
|
| 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="${
|
| 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="${
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 417 |
-
|
|
|
|
| 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',()=>
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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="${
|
| 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="${
|
| 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',()=>{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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="${
|
| 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 |
-
|
|
|
|
| 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
|
| 536 |
-
|
| 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 |
-
|
| 575 |
-
|
|
|
|
| 576 |
${item.identified_factors?`<div class="case5p-identified">${escH(item.identified_factors)}</div>`:''}
|
| 577 |
-
${quote&"e!=='N/A'?`<
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 631 |
const toSave={
|
| 632 |
-
emotion: editData.emotion
|
| 633 |
-
distortions:
|
| 634 |
-
? {...anno.distortions,
|
| 635 |
-
:
|
| 636 |
-
empathy:
|
| 637 |
-
? {...anno.empathy,
|
| 638 |
-
:
|
| 639 |
-
case_formulation: editData.caseFormulation
|
|
|
|
| 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 |
-
|
|
|
|
| 664 |
annotationSaveInProgress=false;
|
| 665 |
if(ok){
|
| 666 |
-
|
| 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 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 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?
|
| 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',()=>
|
| 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(
|
| 810 |
-
const comment=document.getElementById('reviewComment').value.trim();
|
| 811 |
const btn=document.getElementById('submitReviewBtn');
|
| 812 |
-
btn.disabled=true;btn.textContent='
|
| 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:
|
| 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 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
},800);
|
| 828 |
-
} else {alert(data.error||'Failed');
|
| 829 |
-
} catch(e){alert('Network error');
|
| 830 |
}
|
| 831 |
|
| 832 |
// ===== Annotation History =====
|
|
@@ -836,50 +1096,125 @@ function closeHistory(){
|
|
| 836 |
modal.setAttribute('aria-hidden','true');
|
| 837 |
}
|
| 838 |
|
| 839 |
-
function
|
| 840 |
-
const
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
?
|
| 846 |
-
:
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
|
| 850 |
-
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
|
| 854 |
-
|
| 855 |
-
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
|
| 859 |
-
});
|
| 860 |
-
|
| 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-
|
| 869 |
-
|
| 870 |
-
|
| 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)=>
|
|
|
|
| 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()=>{
|
| 945 |
-
|
| 946 |
-
|
| 947 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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'
|
| 981 |
-
if(e.key==='ArrowRight'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,'"').replace(/'/g,''');}
|
| 44 |
+
function escH(s){return String(s??'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}
|
| 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&"e!=='N/A'&"e!=='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&"e){
|
| 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&"e!=='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=
|
| 11 |
-
<link rel="stylesheet" href="/static/admin.css?v=
|
| 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">
|
|
|
|
| 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>
|
| 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 |
-
<
|
| 156 |
-
|
| 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=
|
| 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>
|