cuongpm-cs commited on
Commit
a73045d
·
1 Parent(s): b8b9460

Initial deploy

Browse files
.dockerignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ annotation.db
5
+ .git/
6
+ .gitignore
7
+ *.md
Dockerfile ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # ── System deps ──
4
+ RUN apt-get update && \
5
+ apt-get install -y --no-install-recommends gcc libffi-dev && \
6
+ rm -rf /var/lib/apt/lists/*
7
+
8
+ # ── Create non-root user (HF Spaces requirement) ──
9
+ RUN useradd -m -u 1000 user
10
+ ENV HOME=/home/user \
11
+ PATH=/home/user/.local/bin:$PATH
12
+
13
+ # ── App directory ──
14
+ WORKDIR /home/user/app
15
+
16
+ # ── Install Python deps ──
17
+ COPY --chown=user requirements.txt .
18
+ RUN pip install --no-cache-dir --upgrade pip && \
19
+ pip install --no-cache-dir -r requirements.txt
20
+
21
+ # ── Copy application code ──
22
+ COPY --chown=user app.py models.py csv_utils.py ./
23
+ COPY --chown=user templates/ ./templates/
24
+ COPY --chown=user static/ ./static/
25
+
26
+ # ── Create data directories (for DB + CSV files) ──
27
+ RUN mkdir -p /home/user/app/csv_data && \
28
+ chown -R user:user /home/user/app
29
+
30
+ # ── Copy CSV data into container ──
31
+ # Place your .csv files in a csv_data/ folder next to the Dockerfile
32
+ COPY --chown=user csv_data/ ./csv_data/
33
+
34
+ # ── Environment variables ──
35
+ ENV CSV_FOLDER=/home/user/app/csv_data \
36
+ DB_PATH=/home/user/app/annotation.db \
37
+ SECRET_KEY=esconv-hf-spaces-secret-key-2024
38
+
39
+ # ── Switch to non-root user ──
40
+ USER user
41
+
42
+ # ── HF Spaces uses port 7860 ──
43
+ EXPOSE 7860
44
+
45
+ # ── Start with gunicorn ──
46
+ CMD ["gunicorn", "--bind", "0.0.0.0:7860", "--workers", "2", "--timeout", "120", "app:app"]
README.md CHANGED
@@ -1,10 +1,24 @@
1
  ---
2
- title: Empathy Label
3
- emoji: 🌍
4
  colorFrom: blue
5
- colorTo: red
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: ESConv Annotation Tool
3
+ emoji: 📝
4
  colorFrom: blue
5
+ colorTo: green
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
  ---
10
 
11
+ # ESConv Annotation Management System
12
+
13
+ A web-based annotation review tool for the ESConv dataset with cognitive distortion, emotion, and empathy labeling.
14
+
15
+ ## Features
16
+ - 🔐 Admin & Annotator role-based access
17
+ - 📊 Interactive conversation view with evidence highlighting
18
+ - ✏️ Edit mode for annotations (emotion, distortions, empathy)
19
+ - 📌 Text selection for evidence quotes
20
+ - 📥 Export to JSON/CSV
21
+ - 📈 Progress tracking dashboard
22
+
23
+ ## Default Login
24
+ - **Admin**: `admin` / `admin123`
__pycache__/app.cpython-313.pyc ADDED
Binary file (22.3 kB). View file
 
__pycache__/csv_utils.cpython-313.pyc ADDED
Binary file (2.76 kB). View file
 
__pycache__/models.cpython-313.pyc ADDED
Binary file (7.33 kB). View file
 
annotation.db ADDED
Binary file (57.3 kB). View file
 
app.js ADDED
@@ -0,0 +1,528 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ===== Global State =====
2
+ let allData = [];
3
+ let filteredData = [];
4
+ let currentIndex = 0;
5
+ let activeHighlights = new Set();
6
+
7
+ // Color palettes
8
+ const DIST_COLORS = ['#fb923c', '#f87171', '#fbbf24', '#a78bfa', '#34d399'];
9
+ const EMP_COLORS = ['#60a5fa','#a78bfa','#34d399','#fbbf24','#fb923c','#f87171','#818cf8','#2dd4bf','#e879f9'];
10
+
11
+ // ===== CSV Parser =====
12
+ function parseCSV(text) {
13
+ const rows = [];
14
+ let i = 0;
15
+ const len = text.length;
16
+
17
+ function parseField() {
18
+ if (text[i] === '"') {
19
+ i++; // skip opening quote
20
+ let field = '';
21
+ while (i < len) {
22
+ if (text[i] === '"' && text[i + 1] === '"') {
23
+ field += '"';
24
+ i += 2;
25
+ } else if (text[i] === '"') {
26
+ i++; // skip closing quote
27
+ break;
28
+ } else {
29
+ field += text[i++];
30
+ }
31
+ }
32
+ return field;
33
+ } else {
34
+ let start = i;
35
+ while (i < len && text[i] !== ',' && text[i] !== '\n' && text[i] !== '\r') i++;
36
+ return text.slice(start, i);
37
+ }
38
+ }
39
+
40
+ while (i < len) {
41
+ const row = [];
42
+ while (i < len) {
43
+ row.push(parseField());
44
+ if (i < len && text[i] === ',') { i++; continue; }
45
+ if (i < len && (text[i] === '\n' || text[i] === '\r')) {
46
+ if (text[i] === '\r' && text[i + 1] === '\n') i++;
47
+ i++;
48
+ break;
49
+ }
50
+ if (i >= len) break;
51
+ }
52
+ if (row.length > 1 || (row.length === 1 && row[0] !== '')) {
53
+ rows.push(row);
54
+ }
55
+ }
56
+ return rows;
57
+ }
58
+
59
+ function csvToObjects(rows) {
60
+ if (rows.length < 2) return [];
61
+ const headers = rows[0];
62
+ return rows.slice(1).map(row => {
63
+ const obj = {};
64
+ headers.forEach((h, idx) => { obj[h.trim()] = row[idx] || ''; });
65
+ return obj;
66
+ });
67
+ }
68
+
69
+ // ===== JSON Parse helpers =====
70
+ function safeParseJSON(str) {
71
+ if (!str) return null;
72
+ try {
73
+ // The CSV uses "" for escaped quotes inside JSON strings
74
+ // They've already been unescaped by our parser to single "
75
+ return JSON.parse(str);
76
+ } catch (e) {
77
+ return null;
78
+ }
79
+ }
80
+
81
+ // ===== Parse the annotation columns =====
82
+ function parseAnnotations(row) {
83
+ const emotion = safeParseJSON(row['Emotion']);
84
+ const distortions = safeParseJSON(row['Distortions']);
85
+ const empathy = safeParseJSON(row['Empathy_Level']);
86
+ return { emotion, distortions, empathy };
87
+ }
88
+
89
+ // ===== Parse conversation_cut =====
90
+ function parseConversation(convStr) {
91
+ if (!convStr) return [];
92
+ const lines = convStr.split('\n');
93
+ const messages = [];
94
+ lines.forEach(line => {
95
+ const trimmed = line.trim();
96
+ if (!trimmed) return;
97
+ if (trimmed.startsWith('speaker:') || trimmed.startsWith('listener:')) {
98
+ const isListener = trimmed.startsWith('listener:');
99
+ const content = trimmed.replace(/^(speaker|listener):\s*/, '');
100
+ if (content) {
101
+ messages.push({ role: isListener ? 'listener' : 'speaker', text: content });
102
+ }
103
+ } else if (messages.length > 0) {
104
+ // continuation line
105
+ messages[messages.length - 1].text += ' ' + trimmed;
106
+ }
107
+ });
108
+ return messages;
109
+ }
110
+
111
+ // ===== Highlight text in conversation =====
112
+ function highlightTextInMessages(messages, quotes, cssClass) {
113
+ // returns messages array with highlight markers
114
+ return messages.map(msg => {
115
+ let text = msg.text;
116
+ quotes.forEach(({ quote, cls }) => {
117
+ if (!quote || quote === 'No evidence present') return;
118
+ // clean the quote from listener:/speaker: prefix
119
+ let q = quote.replace(/^(listener|speaker):\s*/i, '').trim();
120
+ if (!q) return;
121
+ const idx = text.indexOf(q);
122
+ if (idx !== -1) {
123
+ text = text.slice(0, idx)
124
+ + `<mark class="evidence-hl ${cls}" data-quote="${escapeAttr(q)}">`
125
+ + text.slice(idx, idx + q.length)
126
+ + '</mark>'
127
+ + text.slice(idx + q.length);
128
+ }
129
+ });
130
+ return { ...msg, htmlText: text };
131
+ });
132
+ }
133
+
134
+ function escapeAttr(str) {
135
+ return str.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
136
+ }
137
+
138
+ function escapeHtml(str) {
139
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
140
+ }
141
+
142
+ // ===== Emotion color class =====
143
+ function emotionClass(emotion) {
144
+ if (!emotion) return 'emotion-default';
145
+ const e = emotion.toLowerCase();
146
+ if (e.includes('fear')) return 'emotion-fear';
147
+ if (e.includes('sad')) return 'emotion-sadness';
148
+ if (e.includes('anger') || e.includes('angry')) return 'emotion-anger';
149
+ if (e.includes('joy') || e.includes('happy')) return 'emotion-joy';
150
+ if (e.includes('anxi')) return 'emotion-anxiety';
151
+ if (e.includes('shame')) return 'emotion-shame';
152
+ if (e.includes('depress')) return 'emotion-depression';
153
+ return 'emotion-default';
154
+ }
155
+
156
+ // ===== Score color =====
157
+ function scoreColor(score, max = 7) {
158
+ const ratio = score / max;
159
+ if (ratio >= 0.7) return '#34d399';
160
+ if (ratio >= 0.4) return '#fbbf24';
161
+ return '#f87171';
162
+ }
163
+
164
+ // ===== Render sample list =====
165
+ function renderSampleList() {
166
+ const list = document.getElementById('sampleList');
167
+ list.innerHTML = '';
168
+ filteredData.forEach((row, i) => {
169
+ const { emotion } = parseAnnotations(row);
170
+ const eName = emotion?.primary_emotion || row['emotion_type'] || '—';
171
+ const problem = row['problem_type'] || '';
172
+
173
+ const item = document.createElement('div');
174
+ item.className = 'sample-item' + (i === currentIndex ? ' active' : '');
175
+ item.dataset.idx = i;
176
+ item.innerHTML = `
177
+ <div class="sample-item-idx">#${i + 1}</div>
178
+ <div class="sample-item-emotion" style="color:var(--emotion-color)">${eName}</div>
179
+ <div class="sample-item-problem">${problem}</div>
180
+ `;
181
+ item.addEventListener('click', () => {
182
+ currentIndex = i;
183
+ renderAll();
184
+ });
185
+ list.appendChild(item);
186
+ });
187
+ }
188
+
189
+ // ===== Render conversation =====
190
+ function renderConversation(row) {
191
+ const { distortions, empathy } = parseAnnotations(row);
192
+ const rawMessages = parseConversation(row['conversation_cut']);
193
+
194
+ // Build highlight quotes list
195
+ const allQuotes = [];
196
+ // Distortion quotes
197
+ const dists = distortions?.distortions || [];
198
+ dists.forEach((d, idx) => {
199
+ if (d.evidence_quote && d.evidence_quote !== 'No evidence present') {
200
+ allQuotes.push({ quote: d.evidence_quote, cls: `dist-hl-${idx % 3}`, type: 'distortion', idx });
201
+ }
202
+ });
203
+ // Empathy quotes
204
+ const dims = empathy?.empathy_dimensions || [];
205
+ dims.forEach((dim, idx) => {
206
+ if (dim.evidence_quote && dim.evidence_quote !== 'No evidence present') {
207
+ allQuotes.push({ quote: dim.evidence_quote, cls: `emp-hl-${idx % 9}`, type: 'empathy', idx });
208
+ }
209
+ });
210
+
211
+ // Apply highlights
212
+ const highlighted = highlightTextInMessages(rawMessages, allQuotes, '');
213
+
214
+ const view = document.getElementById('conversationView');
215
+ view.innerHTML = highlighted.map(msg => {
216
+ const htmlContent = msg.htmlText || escapeHtml(msg.text);
217
+ return `
218
+ <div class="message-bubble ${msg.role}">
219
+ <div class="message-label">${msg.role === 'listener' ? 'Listener' : 'Speaker'}</div>
220
+ <div class="message-text">${htmlContent}</div>
221
+ </div>`;
222
+ }).join('');
223
+
224
+ // Attach tooltip events to marks
225
+ view.querySelectorAll('mark.evidence-hl').forEach(mark => {
226
+ mark.addEventListener('mouseenter', (e) => showMarkTooltip(e, mark, allQuotes));
227
+ mark.addEventListener('mouseleave', hideTooltip);
228
+ });
229
+ }
230
+
231
+ // ===== Render conv header =====
232
+ function renderConvHeader(row) {
233
+ const { emotion } = parseAnnotations(row);
234
+ const eName = emotion?.primary_emotion || row['emotion_type'] || '—';
235
+ const eIntensity = emotion?.intensity || '';
236
+ const problem = row['problem_type'] || '';
237
+ const expType = row['experience_type'] || '';
238
+
239
+ const intClass = eIntensity.toLowerCase().includes('high') ? 'intensity-high'
240
+ : eIntensity.toLowerCase().includes('medium') ? 'intensity-medium' : 'intensity-low';
241
+
242
+ document.getElementById('convMeta').innerHTML = `
243
+ <span class="meta-badge ${emotionClass(eName)}">${eName}</span>
244
+ ${eIntensity ? `<span class="intensity-badge ${intClass}">${eIntensity}</span>` : ''}
245
+ ${problem ? `<span class="problem-badge">${problem}</span>` : ''}
246
+ ${expType ? `<span class="experience-badge">${expType}</span>` : ''}
247
+ `;
248
+ }
249
+
250
+ // ===== Render Emotion Card =====
251
+ function renderEmotion(row) {
252
+ const { emotion } = parseAnnotations(row);
253
+ const el = document.getElementById('emotionContent');
254
+ if (!emotion) { el.innerHTML = '<p style="color:var(--text-muted);font-size:.8rem">No emotion annotation.</p>'; return; }
255
+
256
+ const intClass = (emotion.intensity || '').toLowerCase().includes('high') ? 'intensity-high'
257
+ : (emotion.intensity || '').toLowerCase().includes('medium') ? 'intensity-medium' : 'intensity-low';
258
+
259
+ el.innerHTML = `
260
+ <div class="emotion-primary">
261
+ <span class="emotion-badge ${emotionClass(emotion.primary_emotion)}">${emotion.primary_emotion || '—'}</span>
262
+ <span class="intensity-badge ${intClass}">${emotion.intensity || '—'}</span>
263
+ </div>
264
+ ${emotion.specific_emotion_term ? `<p class="specific-term">Specific term: <span>${emotion.specific_emotion_term}</span></p>` : ''}
265
+ ${emotion.reasoning ? `<p class="emotion-reasoning">${emotion.reasoning}</p>` : ''}
266
+ `;
267
+ }
268
+
269
+ /// ===== Scroll to evidence in conversation =====
270
+ function scrollToEvidence(cssClassPrefix, idx) {
271
+ const targetCls = `${cssClassPrefix}-${idx}`;
272
+ const mark = document.querySelector(`.conversation-view mark.${targetCls}`);
273
+ if (!mark) return;
274
+
275
+ // Remove previous pulse from all marks
276
+ document.querySelectorAll('mark.evidence-pulse').forEach(m => m.classList.remove('evidence-pulse'));
277
+
278
+ // Scroll into view
279
+ mark.scrollIntoView({ behavior: 'smooth', block: 'center' });
280
+
281
+ // Add pulse animation
282
+ mark.classList.add('evidence-pulse');
283
+ setTimeout(() => mark.classList.remove('evidence-pulse'), 2000);
284
+ }
285
+
286
+ // ===== Render Distortions Card =====
287
+ function renderDistortions(row) {
288
+ const { distortions } = parseAnnotations(row);
289
+ const el = document.getElementById('distortionsContent');
290
+ const dists = distortions?.distortions || [];
291
+
292
+ if (dists.length === 0) {
293
+ el.innerHTML = `
294
+ <div class="no-distortions">
295
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
296
+ <path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
297
+ </svg>
298
+ <span>No cognitive distortions detected</span>
299
+ </div>`;
300
+ return;
301
+ }
302
+
303
+ el.innerHTML = dists.map((d, idx) => {
304
+ const color = DIST_COLORS[idx % DIST_COLORS.length];
305
+ const quoteClean = (d.evidence_quote || '').replace(/^(listener|speaker):\s*/i, '');
306
+ return `
307
+ <div class="distortion-item" data-dist-idx="${idx}">
308
+ <div class="distortion-name">
309
+ <span class="dist-color-dot" style="background:${color}"></span>
310
+ ${d.cognitive_distortion || '—'}
311
+ </div>
312
+ ${quoteClean ? `<div class="distortion-quote" style="border-color:${color}">"${quoteClean}"</div>` : ''}
313
+ ${d.reasoning ? `<div class="distortion-reasoning">${d.reasoning}</div>` : ''}
314
+ </div>`;
315
+ }).join('');
316
+
317
+ // Click to scroll to evidence
318
+ el.querySelectorAll('.distortion-item').forEach(item => {
319
+ item.addEventListener('click', () => {
320
+ const idx = parseInt(item.dataset.distIdx);
321
+ scrollToEvidence('dist-hl', idx % 3);
322
+ });
323
+ });
324
+ }
325
+
326
+ // ===== Render Empathy Card =====
327
+ function renderEmpathy(row) {
328
+ const { empathy } = parseAnnotations(row);
329
+ const el = document.getElementById('empathyContent');
330
+ const scoreEl = document.getElementById('overallScore');
331
+
332
+ if (!empathy) { el.innerHTML = '<p style="color:var(--text-muted);font-size:.8rem">No empathy annotation.</p>'; scoreEl.textContent = ''; return; }
333
+
334
+ const dims = empathy.empathy_dimensions || [];
335
+ const overall = empathy.overall_score;
336
+ scoreEl.textContent = overall !== undefined ? `${overall} / ${dims.length * 7}` : '';
337
+
338
+ el.innerHTML = dims.map((dim, idx) => {
339
+ const color = EMP_COLORS[idx % EMP_COLORS.length];
340
+ const score = dim.score || 0;
341
+ const pct = Math.min(100, (score / 7) * 100);
342
+ const sColor = scoreColor(score);
343
+ const quoteClean = (dim.evidence_quote || '').replace(/^(listener|speaker):\s*/i, '');
344
+ return `
345
+ <div class="empathy-item" data-emp-idx="${idx}">
346
+ <div class="empathy-item-header">
347
+ <span class="emp-color-dot" style="background:${color}"></span>
348
+ <span class="empathy-name">${dim.dimension_name || '—'}</span>
349
+ <div class="score-bar-container">
350
+ <div class="score-bar">
351
+ <div class="score-bar-fill" style="width:${pct}%;background:${sColor}"></div>
352
+ </div>
353
+ <span class="score-value" style="color:${sColor}">${score}</span>
354
+ </div>
355
+ </div>
356
+ ${quoteClean && quoteClean !== 'No evidence present'
357
+ ? `<div class="empathy-quote" style="border-color:${color}" title="${escapeAttr(quoteClean)}">"${quoteClean}"</div>`
358
+ : ''}
359
+ ${dim.reasoning ? `<div class="empathy-reasoning">${dim.reasoning}</div>` : ''}
360
+ </div>`;
361
+ }).join('');
362
+
363
+ // Click to expand reasoning + scroll to evidence
364
+ el.querySelectorAll('.empathy-item').forEach(item => {
365
+ item.addEventListener('click', () => {
366
+ item.classList.toggle('active');
367
+ const idx = parseInt(item.dataset.empIdx);
368
+ scrollToEvidence('emp-hl', idx % 9);
369
+ });
370
+ });
371
+ }
372
+
373
+ // ===== Tooltip =====
374
+ function showMarkTooltip(event, mark, allQuotes) {
375
+ const tooltip = document.getElementById('tooltip');
376
+ const content = document.getElementById('tooltipContent');
377
+
378
+ const markQuote = mark.dataset.quote || '';
379
+ // Find matching annotation
380
+ const cls = Array.from(mark.classList).find(c => c.startsWith('dist-hl-') || c.startsWith('emp-hl-'));
381
+
382
+ const { distortions, empathy } = parseAnnotations(filteredData[currentIndex]);
383
+ let html = '';
384
+
385
+ if (cls && cls.startsWith('dist-hl-')) {
386
+ const idx = parseInt(cls.split('-').pop());
387
+ const d = (distortions?.distortions || [])[idx];
388
+ if (d) {
389
+ const color = DIST_COLORS[idx % DIST_COLORS.length];
390
+ html = `
391
+ <div class="tooltip-title" style="color:${color}">⚠️ ${d.cognitive_distortion || 'Distortion'}</div>
392
+ <div class="tooltip-label">Type</div>
393
+ <div class="tooltip-text">Cognitive Distortion</div>
394
+ <div class="tooltip-label">Reasoning</div>
395
+ <div class="tooltip-text">${d.reasoning || '—'}</div>
396
+ `;
397
+ }
398
+ } else if (cls && cls.startsWith('emp-hl-')) {
399
+ const idx = parseInt(cls.split('-').pop());
400
+ const dim = (empathy?.empathy_dimensions || [])[idx];
401
+ if (dim) {
402
+ const color = EMP_COLORS[idx % EMP_COLORS.length];
403
+ const sColor = scoreColor(dim.score || 0);
404
+ html = `
405
+ <div class="tooltip-title" style="color:${color}">💡 ${dim.dimension_name || 'Empathy'}</div>
406
+ <div class="tooltip-label">Score</div>
407
+ <span class="tooltip-score" style="background:${sColor}22;color:${sColor};border:1px solid ${sColor}44">${dim.score} / 7</span>
408
+ <div class="tooltip-label">Reasoning</div>
409
+ <div class="tooltip-text">${dim.reasoning || '—'}</div>
410
+ `;
411
+ }
412
+ }
413
+
414
+ if (!html) return;
415
+ content.innerHTML = html;
416
+ tooltip.classList.add('visible');
417
+
418
+ // Position tooltip
419
+ const rect = mark.getBoundingClientRect();
420
+ const tRect = tooltip.getBoundingClientRect();
421
+ let top = rect.bottom + 8;
422
+ let left = rect.left;
423
+ if (left + 320 > window.innerWidth) left = window.innerWidth - 330;
424
+ if (top + 200 > window.innerHeight) top = rect.top - 200;
425
+ tooltip.style.top = top + 'px';
426
+ tooltip.style.left = left + 'px';
427
+ }
428
+
429
+ function hideTooltip() {
430
+ const tooltip = document.getElementById('tooltip');
431
+ tooltip.classList.remove('visible');
432
+ }
433
+
434
+ // ===== Navigation =====
435
+ function updateNavButtons() {
436
+ document.getElementById('prevBtn').disabled = currentIndex <= 0;
437
+ document.getElementById('nextBtn').disabled = currentIndex >= filteredData.length - 1;
438
+ document.getElementById('currentIdx').textContent = currentIndex + 1;
439
+ document.getElementById('totalSamples').textContent = filteredData.length;
440
+
441
+ // Update sidebar active
442
+ document.querySelectorAll('.sample-item').forEach(item => {
443
+ item.classList.toggle('active', parseInt(item.dataset.idx) === currentIndex);
444
+ });
445
+
446
+ // Scroll active into view
447
+ const activeItem = document.querySelector('.sample-item.active');
448
+ if (activeItem) activeItem.scrollIntoView({ block: 'nearest' });
449
+ }
450
+
451
+ function renderAll() {
452
+ if (filteredData.length === 0) return;
453
+ const row = filteredData[currentIndex];
454
+ renderConvHeader(row);
455
+ renderConversation(row);
456
+ renderEmotion(row);
457
+ renderDistortions(row);
458
+ renderEmpathy(row);
459
+ updateNavButtons();
460
+ }
461
+
462
+ // ===== Filter / Search =====
463
+ function applyFilter(query) {
464
+ const q = query.toLowerCase().trim();
465
+ filteredData = q
466
+ ? allData.filter(row => {
467
+ const { emotion } = parseAnnotations(row);
468
+ const eName = (emotion?.primary_emotion || row['emotion_type'] || '').toLowerCase();
469
+ const prob = (row['problem_type'] || '').toLowerCase();
470
+ return eName.includes(q) || prob.includes(q);
471
+ })
472
+ : [...allData];
473
+ currentIndex = 0;
474
+ renderSampleList();
475
+ renderAll();
476
+ }
477
+
478
+ // ===== Load CSV =====
479
+ function loadCSV(text) {
480
+ const rows = parseCSV(text);
481
+ const data = csvToObjects(rows);
482
+ allData = data.filter(d => d['conversation_cut']);
483
+ filteredData = [...allData];
484
+ currentIndex = 0;
485
+
486
+ document.getElementById('welcomeScreen').style.display = 'none';
487
+ document.getElementById('mainLayout').style.display = 'grid';
488
+ document.getElementById('sampleCounter').style.display = 'flex';
489
+ document.getElementById('navArrows').style.display = 'flex';
490
+ document.getElementById('fileLabel').textContent = `${allData.length} samples`;
491
+
492
+ renderSampleList();
493
+ renderAll();
494
+ }
495
+
496
+ // ===== Event Listeners =====
497
+ document.addEventListener('DOMContentLoaded', () => {
498
+ const csvInput = document.getElementById('csvInput');
499
+ csvInput.addEventListener('change', (e) => {
500
+ const file = e.target.files[0];
501
+ if (!file) return;
502
+ const reader = new FileReader();
503
+ reader.onload = (ev) => loadCSV(ev.target.result);
504
+ reader.readAsText(file, 'UTF-8');
505
+ });
506
+
507
+ document.getElementById('prevBtn').addEventListener('click', () => {
508
+ if (currentIndex > 0) { currentIndex--; renderAll(); }
509
+ });
510
+ document.getElementById('nextBtn').addEventListener('click', () => {
511
+ if (currentIndex < filteredData.length - 1) { currentIndex++; renderAll(); }
512
+ });
513
+
514
+ document.getElementById('searchInput').addEventListener('input', (e) => {
515
+ applyFilter(e.target.value);
516
+ });
517
+
518
+ // Keyboard navigation
519
+ document.addEventListener('keydown', (e) => {
520
+ if (e.target.tagName === 'INPUT') return;
521
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
522
+ if (currentIndex > 0) { currentIndex--; renderAll(); }
523
+ }
524
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
525
+ if (currentIndex < filteredData.length - 1) { currentIndex++; renderAll(); }
526
+ }
527
+ });
528
+ });
app.py ADDED
@@ -0,0 +1,472 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Flask application for the ESConv Annotation Management System."""
2
+
3
+ import os
4
+ from datetime import datetime, timezone
5
+ from functools import wraps
6
+
7
+ import bcrypt
8
+ from flask import (
9
+ Flask, jsonify, redirect, render_template, request, session, url_for
10
+ )
11
+
12
+ from models import db, User, CsvFile, Assignment, Review
13
+ from csv_utils import parse_csv_file, count_samples, scan_csv_folder
14
+
15
+ # ── Config ──────────────────────────────────────────────────────────────────
16
+ BASE_DIR = os.path.abspath(os.path.dirname(__file__))
17
+ CSV_FOLDER = os.environ.get('CSV_FOLDER', os.path.join(os.path.dirname(BASE_DIR), 'csv_data'))
18
+ DB_PATH = os.environ.get('DB_PATH', os.path.join(BASE_DIR, 'annotation.db'))
19
+
20
+ app = Flask(__name__)
21
+ app.secret_key = os.environ.get('SECRET_KEY', 'esconv-annotation-secret-key-2024')
22
+ app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{DB_PATH}'
23
+ app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
24
+
25
+ db.init_app(app)
26
+
27
+
28
+ # ── Helpers ─────────────────────────────────────────────────────────────────
29
+ def hash_password(pw):
30
+ return bcrypt.hashpw(pw.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
31
+
32
+
33
+ def check_password(pw, hashed):
34
+ return bcrypt.checkpw(pw.encode('utf-8'), hashed.encode('utf-8'))
35
+
36
+
37
+ def login_required(f):
38
+ @wraps(f)
39
+ def wrapper(*args, **kwargs):
40
+ if 'user_id' not in session:
41
+ if request.path.startswith('/api/'):
42
+ return jsonify({'error': 'Not authenticated'}), 401
43
+ return redirect(url_for('login_page'))
44
+ return f(*args, **kwargs)
45
+ return wrapper
46
+
47
+
48
+ def admin_required(f):
49
+ @wraps(f)
50
+ def wrapper(*args, **kwargs):
51
+ if 'user_id' not in session:
52
+ if request.path.startswith('/api/'):
53
+ return jsonify({'error': 'Not authenticated'}), 401
54
+ return redirect(url_for('login_page'))
55
+ if session.get('role') != 'admin':
56
+ if request.path.startswith('/api/'):
57
+ return jsonify({'error': 'Admin access required'}), 403
58
+ return redirect(url_for('login_page'))
59
+ return f(*args, **kwargs)
60
+ return wrapper
61
+
62
+
63
+ def sync_csv_files():
64
+ """Scan the CSV folder and sync with the database."""
65
+ os.makedirs(CSV_FOLDER, exist_ok=True)
66
+ disk_files = scan_csv_folder(CSV_FOLDER)
67
+ disk_names = {f['filename'] for f in disk_files}
68
+
69
+ # Add new files
70
+ for finfo in disk_files:
71
+ existing = CsvFile.query.filter_by(filename=finfo['filename']).first()
72
+ if not existing:
73
+ total = count_samples(finfo['filepath'])
74
+ new_file = CsvFile(
75
+ filename=finfo['filename'],
76
+ filepath=finfo['filepath'],
77
+ total_samples=total,
78
+ )
79
+ db.session.add(new_file)
80
+
81
+ # Remove DB entries for files no longer on disk
82
+ all_db_files = CsvFile.query.all()
83
+ for dbf in all_db_files:
84
+ if dbf.filename not in disk_names:
85
+ db.session.delete(dbf)
86
+
87
+ db.session.commit()
88
+
89
+
90
+ # ── Auth Routes ─────────────────────────────────────────────────────────────
91
+ @app.route('/login', methods=['GET'])
92
+ def login_page():
93
+ if 'user_id' in session:
94
+ return redirect(url_for('dashboard'))
95
+ return render_template('login.html')
96
+
97
+
98
+ @app.route('/api/login', methods=['POST'])
99
+ def api_login():
100
+ data = request.get_json()
101
+ username = data.get('username', '').strip()
102
+ password = data.get('password', '')
103
+
104
+ user = User.query.filter_by(username=username).first()
105
+ if not user or not check_password(password, user.password_hash):
106
+ return jsonify({'error': 'Invalid username or password'}), 401
107
+
108
+ session['user_id'] = user.id
109
+ session['username'] = user.username
110
+ session['role'] = user.role
111
+ return jsonify({'ok': True, 'role': user.role, 'username': user.username})
112
+
113
+
114
+ @app.route('/api/logout', methods=['POST'])
115
+ def api_logout():
116
+ session.clear()
117
+ return jsonify({'ok': True})
118
+
119
+
120
+ @app.route('/api/me')
121
+ @login_required
122
+ def api_me():
123
+ return jsonify({
124
+ 'user_id': session['user_id'],
125
+ 'username': session['username'],
126
+ 'role': session['role'],
127
+ })
128
+
129
+
130
+ # ── Page Routes ─────────────────────────────────────────────────────────────
131
+ @app.route('/')
132
+ @login_required
133
+ def dashboard():
134
+ if session.get('role') == 'admin':
135
+ return redirect(url_for('admin_page'))
136
+ return redirect(url_for('annotator_page'))
137
+
138
+
139
+ @app.route('/admin')
140
+ @admin_required
141
+ def admin_page():
142
+ return render_template('admin.html')
143
+
144
+
145
+ @app.route('/annotator')
146
+ @login_required
147
+ def annotator_page():
148
+ return render_template('annotator.html')
149
+
150
+
151
+ @app.route('/review/<int:assignment_id>')
152
+ @login_required
153
+ def review_page(assignment_id):
154
+ assignment = Assignment.query.get_or_404(assignment_id)
155
+ # Only the assigned user or admin can access
156
+ if session.get('role') != 'admin' and assignment.user_id != session['user_id']:
157
+ return redirect(url_for('dashboard'))
158
+ return render_template('review.html')
159
+
160
+
161
+ # ── Admin API: Users ────────────────────────────────────────────────────────
162
+ @app.route('/api/users', methods=['GET'])
163
+ @admin_required
164
+ def api_get_users():
165
+ users = User.query.order_by(User.created_at).all()
166
+ return jsonify([u.to_dict() for u in users])
167
+
168
+
169
+ @app.route('/api/users', methods=['POST'])
170
+ @admin_required
171
+ def api_create_user():
172
+ data = request.get_json()
173
+ username = data.get('username', '').strip()
174
+ password = data.get('password', '')
175
+ role = data.get('role', 'annotator')
176
+
177
+ if not username or not password:
178
+ return jsonify({'error': 'Username and password required'}), 400
179
+ if len(password) < 4:
180
+ return jsonify({'error': 'Password must be at least 4 characters'}), 400
181
+ if role not in ('admin', 'annotator'):
182
+ return jsonify({'error': 'Invalid role'}), 400
183
+ if User.query.filter_by(username=username).first():
184
+ return jsonify({'error': 'Username already exists'}), 400
185
+
186
+ user = User(username=username, password_hash=hash_password(password), role=role)
187
+ db.session.add(user)
188
+ db.session.commit()
189
+ return jsonify(user.to_dict()), 201
190
+
191
+
192
+ @app.route('/api/users/<int:user_id>', methods=['DELETE'])
193
+ @admin_required
194
+ def api_delete_user(user_id):
195
+ user = User.query.get_or_404(user_id)
196
+ if user.id == session['user_id']:
197
+ return jsonify({'error': 'Cannot delete yourself'}), 400
198
+ db.session.delete(user)
199
+ db.session.commit()
200
+ return jsonify({'ok': True})
201
+
202
+
203
+ # ── Admin API: Files ────────────────────────────────────────────────────────
204
+ @app.route('/api/files', methods=['GET'])
205
+ @admin_required
206
+ def api_get_files():
207
+ sync_csv_files()
208
+ files = CsvFile.query.order_by(CsvFile.filename).all()
209
+ result = []
210
+ for f in files:
211
+ d = f.to_dict()
212
+ # Count total reviews across all assignments for this file
213
+ total_reviews = (
214
+ db.session.query(Review)
215
+ .join(Assignment)
216
+ .filter(Assignment.file_id == f.id)
217
+ .count()
218
+ )
219
+ total_assignees = Assignment.query.filter_by(file_id=f.id).count()
220
+ d['total_reviews'] = total_reviews
221
+ d['total_assignees'] = total_assignees
222
+ result.append(d)
223
+ return jsonify(result)
224
+
225
+
226
+ @app.route('/api/files/<int:file_id>/data', methods=['GET'])
227
+ @login_required
228
+ def api_get_file_data(file_id):
229
+ csv_file = CsvFile.query.get_or_404(file_id)
230
+ rows = parse_csv_file(csv_file.filepath)
231
+ return jsonify({'filename': csv_file.filename, 'samples': rows})
232
+
233
+
234
+ # ── Admin API: Assignments ──────────────────────────────────────────────────
235
+ @app.route('/api/assignments', methods=['GET'])
236
+ @admin_required
237
+ def api_get_assignments():
238
+ assignments = Assignment.query.order_by(Assignment.assigned_at.desc()).all()
239
+ return jsonify([a.to_dict() for a in assignments])
240
+
241
+
242
+ @app.route('/api/assignments', methods=['POST'])
243
+ @admin_required
244
+ def api_create_assignment():
245
+ data = request.get_json()
246
+ user_id = data.get('user_id')
247
+ file_id = data.get('file_id')
248
+
249
+ if not user_id or not file_id:
250
+ return jsonify({'error': 'user_id and file_id required'}), 400
251
+
252
+ user = User.query.get(user_id)
253
+ csv_file = CsvFile.query.get(file_id)
254
+ if not user:
255
+ return jsonify({'error': 'User not found'}), 404
256
+ if not csv_file:
257
+ return jsonify({'error': 'File not found'}), 404
258
+
259
+ existing = Assignment.query.filter_by(user_id=user_id, file_id=file_id).first()
260
+ if existing:
261
+ return jsonify({'error': 'Assignment already exists'}), 400
262
+
263
+ assignment = Assignment(user_id=user_id, file_id=file_id)
264
+ db.session.add(assignment)
265
+ db.session.commit()
266
+ return jsonify(assignment.to_dict()), 201
267
+
268
+
269
+ @app.route('/api/assignments/<int:assignment_id>', methods=['DELETE'])
270
+ @admin_required
271
+ def api_delete_assignment(assignment_id):
272
+ assignment = Assignment.query.get_or_404(assignment_id)
273
+ db.session.delete(assignment)
274
+ db.session.commit()
275
+ return jsonify({'ok': True})
276
+
277
+
278
+ # ── Annotator API ───────────────────────────────────────────────────────────
279
+ @app.route('/api/my-assignments', methods=['GET'])
280
+ @login_required
281
+ def api_my_assignments():
282
+ assignments = Assignment.query.filter_by(user_id=session['user_id']).all()
283
+ return jsonify([a.to_dict() for a in assignments])
284
+
285
+
286
+ @app.route('/api/assignments/<int:assignment_id>/reviews', methods=['GET'])
287
+ @login_required
288
+ def api_get_reviews(assignment_id):
289
+ assignment = Assignment.query.get_or_404(assignment_id)
290
+ if session.get('role') != 'admin' and assignment.user_id != session['user_id']:
291
+ return jsonify({'error': 'Access denied'}), 403
292
+ reviews = Review.query.filter_by(assignment_id=assignment_id).all()
293
+ return jsonify({r.sample_index: r.to_dict() for r in reviews})
294
+
295
+
296
+ @app.route('/api/reviews', methods=['POST'])
297
+ @login_required
298
+ def api_submit_review():
299
+ data = request.get_json()
300
+ assignment_id = data.get('assignment_id')
301
+ sample_index = data.get('sample_index')
302
+ status = data.get('status') # 'accept' or 'reject'
303
+ comment = data.get('comment', '')
304
+
305
+ if not assignment_id or sample_index is None or status not in ('accept', 'reject'):
306
+ return jsonify({'error': 'assignment_id, sample_index, and valid status required'}), 400
307
+
308
+ assignment = Assignment.query.get(assignment_id)
309
+ if not assignment:
310
+ return jsonify({'error': 'Assignment not found'}), 404
311
+ if session.get('role') != 'admin' and assignment.user_id != session['user_id']:
312
+ return jsonify({'error': 'Access denied'}), 403
313
+
314
+ review = Review.query.filter_by(
315
+ assignment_id=assignment_id, sample_index=sample_index
316
+ ).first()
317
+
318
+ if review:
319
+ review.status = status
320
+ review.comment = comment
321
+ review.reviewed_at = datetime.now(timezone.utc)
322
+ else:
323
+ review = Review(
324
+ assignment_id=assignment_id,
325
+ sample_index=sample_index,
326
+ status=status,
327
+ comment=comment,
328
+ )
329
+ db.session.add(review)
330
+
331
+ db.session.commit()
332
+ return jsonify(review.to_dict())
333
+
334
+
335
+ @app.route('/api/reviews/annotations', methods=['PATCH'])
336
+ @login_required
337
+ def api_save_annotations():
338
+ """Save annotator-edited annotations for a specific sample."""
339
+ import json as _json
340
+ data = request.get_json()
341
+ assignment_id = data.get('assignment_id')
342
+ sample_index = data.get('sample_index')
343
+ annotations = data.get('annotations') # dict: {emotion, distortions, empathy}
344
+
345
+ if not assignment_id or sample_index is None or annotations is None:
346
+ return jsonify({'error': 'assignment_id, sample_index, and annotations required'}), 400
347
+
348
+ assignment = Assignment.query.get(assignment_id)
349
+ if not assignment:
350
+ return jsonify({'error': 'Assignment not found'}), 404
351
+ if session.get('role') != 'admin' and assignment.user_id != session['user_id']:
352
+ return jsonify({'error': 'Access denied'}), 403
353
+
354
+ # Upsert review — annotations can be saved without status yet (use 'pending' placeholder)
355
+ review = Review.query.filter_by(
356
+ assignment_id=assignment_id, sample_index=sample_index
357
+ ).first()
358
+
359
+ if review:
360
+ review.annotations_edit = _json.dumps(annotations, ensure_ascii=False)
361
+ review.reviewed_at = datetime.now(timezone.utc)
362
+ else:
363
+ # Create a minimal review record to hold the annotation edits
364
+ review = Review(
365
+ assignment_id=assignment_id,
366
+ sample_index=sample_index,
367
+ status='reject', # default, annotator must explicitly submit verdict
368
+ comment='',
369
+ annotations_edit=_json.dumps(annotations, ensure_ascii=False),
370
+ )
371
+ db.session.add(review)
372
+
373
+ db.session.commit()
374
+ return jsonify(review.to_dict())
375
+
376
+
377
+
378
+ # ── Export API ──────────────────────────────────────────────────────────────
379
+ @app.route('/api/assignments/<int:assignment_id>/export', methods=['GET'])
380
+ @login_required
381
+ def api_export(assignment_id):
382
+ """Export reviewed data as JSON or CSV."""
383
+ import json as _json
384
+ import csv
385
+ import io
386
+
387
+ fmt = request.args.get('format', 'json') # 'json' or 'csv'
388
+ assignment = Assignment.query.get_or_404(assignment_id)
389
+ if session.get('role') != 'admin' and assignment.user_id != session['user_id']:
390
+ return jsonify({'error': 'Access denied'}), 403
391
+
392
+ csv_file = assignment.csv_file
393
+ rows = parse_csv_file(csv_file.filepath)
394
+ reviews = {r.sample_index: r for r in Review.query.filter_by(assignment_id=assignment_id).all()}
395
+
396
+ export_rows = []
397
+ for i, row in enumerate(rows):
398
+ review = reviews.get(i)
399
+ # Get effective annotations (edited or original)
400
+ if review and review.annotations_edit:
401
+ anno = _json.loads(review.annotations_edit)
402
+ else:
403
+ anno = {
404
+ 'emotion': _json.loads(row.get('Emotion', 'null')) if row.get('Emotion') else None,
405
+ 'distortions': _json.loads(row.get('Distortions', 'null')) if row.get('Distortions') else None,
406
+ 'empathy': _json.loads(row.get('Empathy_Level', 'null')) if row.get('Empathy_Level') else None,
407
+ }
408
+
409
+ export_rows.append({
410
+ 'sample_index': i,
411
+ 'conversation_cut': row.get('conversation_cut', ''),
412
+ 'emotion_type': row.get('emotion_type', ''),
413
+ 'problem_type': row.get('problem_type', ''),
414
+ 'Emotion': anno.get('emotion'),
415
+ 'Distortions': anno.get('distortions'),
416
+ 'Empathy_Level': anno.get('empathy'),
417
+ 'review_status': review.status if review else 'pending',
418
+ 'review_comment': review.comment if review else '',
419
+ 'is_edited': bool(review and review.annotations_edit),
420
+ })
421
+
422
+ if fmt == 'csv':
423
+ output = io.StringIO()
424
+ writer = csv.writer(output)
425
+ writer.writerow([
426
+ 'sample_index', 'conversation_cut', 'emotion_type', 'problem_type',
427
+ 'Emotion', 'Distortions', 'Empathy_Level',
428
+ 'review_status', 'review_comment', 'is_edited'
429
+ ])
430
+ for r in export_rows:
431
+ writer.writerow([
432
+ r['sample_index'], r['conversation_cut'], r['emotion_type'], r['problem_type'],
433
+ _json.dumps(r['Emotion'], ensure_ascii=False) if r['Emotion'] else '',
434
+ _json.dumps(r['Distortions'], ensure_ascii=False) if r['Distortions'] else '',
435
+ _json.dumps(r['Empathy_Level'], ensure_ascii=False) if r['Empathy_Level'] else '',
436
+ r['review_status'], r['review_comment'], r['is_edited'],
437
+ ])
438
+ resp = app.response_class(
439
+ output.getvalue(),
440
+ mimetype='text/csv',
441
+ headers={'Content-Disposition': f'attachment; filename=export_{csv_file.filename}'}
442
+ )
443
+ return resp
444
+ else:
445
+ return jsonify(export_rows)
446
+
447
+ # ── Init DB ─────────────────────────────────────────────────────────────────
448
+ def init_db():
449
+ """Create tables and seed admin user."""
450
+ db.create_all()
451
+ if not User.query.filter_by(username='admin').first():
452
+ admin = User(
453
+ username='admin',
454
+ password_hash=hash_password('admin123'),
455
+ role='admin',
456
+ )
457
+ db.session.add(admin)
458
+ db.session.commit()
459
+ print(' ✓ Created default admin user (admin / admin123)')
460
+
461
+ # Initial CSV scan
462
+ os.makedirs(CSV_FOLDER, exist_ok=True)
463
+ sync_csv_files()
464
+ file_count = CsvFile.query.count()
465
+ print(f' ✓ Found {file_count} CSV file(s) in {CSV_FOLDER}')
466
+
467
+
468
+ if __name__ == '__main__':
469
+ with app.app_context():
470
+ init_db()
471
+ print(f'\n 🚀 Server running at http://localhost:5000\n')
472
+ app.run(debug=True, host='0.0.0.0', port=5000)
csv_data/ESConv-100samples-Annotated-gpt-4o-mini.csv ADDED
The diff for this file is too large to render. See raw diff
 
csv_utils.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CSV parsing utilities for the Annotation Management System."""
2
+
3
+ import csv
4
+ import json
5
+ import os
6
+
7
+
8
+ def parse_csv_file(filepath):
9
+ """Parse a CSV file and return list of row dicts with only conversation samples."""
10
+ rows = []
11
+ with open(filepath, 'r', encoding='utf-8') as f:
12
+ reader = csv.DictReader(f)
13
+ for row in reader:
14
+ # Only include rows that have conversation_cut data
15
+ if row.get('conversation_cut', '').strip():
16
+ rows.append(dict(row))
17
+ return rows
18
+
19
+
20
+ def count_samples(filepath):
21
+ """Count the number of valid conversation samples in a CSV file."""
22
+ count = 0
23
+ with open(filepath, 'r', encoding='utf-8') as f:
24
+ reader = csv.DictReader(f)
25
+ for row in reader:
26
+ if row.get('conversation_cut', '').strip():
27
+ count += 1
28
+ return count
29
+
30
+
31
+ def get_sample(filepath, index):
32
+ """Get a specific sample by index from a CSV file."""
33
+ rows = parse_csv_file(filepath)
34
+ if 0 <= index < len(rows):
35
+ return rows[index]
36
+ return None
37
+
38
+
39
+ def scan_csv_folder(folder_path):
40
+ """Scan a folder and return list of CSV file info dicts."""
41
+ files = []
42
+ if not os.path.isdir(folder_path):
43
+ return files
44
+ for fname in sorted(os.listdir(folder_path)):
45
+ if fname.lower().endswith('.csv'):
46
+ fpath = os.path.join(folder_path, fname)
47
+ files.append({
48
+ 'filename': fname,
49
+ 'filepath': fpath,
50
+ 'size_bytes': os.path.getsize(fpath),
51
+ })
52
+ return files
index.html ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>ESConv Annotation Review Tool</title>
7
+ <meta name="description" content="Review and validate LLM-generated annotations for emotional support conversations including Emotion, Cognitive Distortions, and Empathy Level dimensions." />
8
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
9
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
+ <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" />
11
+ <link rel="stylesheet" href="style.css" />
12
+ </head>
13
+ <body>
14
+ <!-- Header -->
15
+ <header class="header">
16
+ <div class="header-inner">
17
+ <div class="header-logo">
18
+ <div class="logo-icon">
19
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
20
+ <path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
21
+ </svg>
22
+ </div>
23
+ <div>
24
+ <h1>ESConv Annotation Review</h1>
25
+ <p>LLM Annotation Quality Assessment</p>
26
+ </div>
27
+ </div>
28
+ <div class="header-controls">
29
+ <div class="file-upload-area" id="fileUploadArea">
30
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
31
+ <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
32
+ </svg>
33
+ <span id="fileLabel">Load CSV File</span>
34
+ <input type="file" id="csvInput" accept=".csv" />
35
+ </div>
36
+ <div class="sample-counter" id="sampleCounter" style="display:none">
37
+ <span id="currentIdx">1</span> / <span id="totalSamples">0</span>
38
+ </div>
39
+ </div>
40
+ </div>
41
+ </header>
42
+
43
+ <!-- Main Layout -->
44
+ <main class="main-layout" id="mainLayout" style="display:none">
45
+
46
+ <!-- Left: Navigation Panel -->
47
+ <aside class="nav-panel">
48
+ <div class="nav-panel-header">
49
+ <h2>Samples</h2>
50
+ <div class="search-box">
51
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
52
+ <circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
53
+ </svg>
54
+ <input type="text" id="searchInput" placeholder="Filter by emotion..." />
55
+ </div>
56
+ </div>
57
+ <div class="sample-list" id="sampleList"></div>
58
+ </aside>
59
+
60
+ <!-- Center: Conversation View -->
61
+ <section class="conversation-panel">
62
+ <div class="conv-header">
63
+ <div class="conv-meta" id="convMeta"></div>
64
+ <div class="legend">
65
+ <span class="legend-item"><span class="legend-dot speaker"></span>Speaker</span>
66
+ <span class="legend-item"><span class="legend-dot listener"></span>Listener</span>
67
+ </div>
68
+ </div>
69
+ <div class="conversation-view" id="conversationView"></div>
70
+ </section>
71
+
72
+ <!-- Right: Annotation Panel -->
73
+ <aside class="annotation-panel">
74
+ <!-- Emotion Card -->
75
+ <div class="anno-card" id="emotionCard">
76
+ <div class="anno-card-header">
77
+ <div class="anno-card-icon emotion-icon">
78
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
79
+ <circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2M9 9h.01M15 9h.01"/>
80
+ </svg>
81
+ </div>
82
+ <h3>Emotion Analysis</h3>
83
+ </div>
84
+ <div id="emotionContent" class="anno-content"></div>
85
+ </div>
86
+
87
+ <!-- Distortions Card -->
88
+ <div class="anno-card" id="distortionsCard">
89
+ <div class="anno-card-header">
90
+ <div class="anno-card-icon distortion-icon">
91
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
92
+ <path d="M12 9v4M12 17h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
93
+ </svg>
94
+ </div>
95
+ <h3>Cognitive Distortions</h3>
96
+ </div>
97
+ <div id="distortionsContent" class="anno-content"></div>
98
+ </div>
99
+
100
+ <!-- Empathy Card -->
101
+ <div class="anno-card" id="empathyCard">
102
+ <div class="anno-card-header">
103
+ <div class="anno-card-icon empathy-icon">
104
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
105
+ <path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/>
106
+ </svg>
107
+ </div>
108
+ <h3>Empathy Level</h3>
109
+ <div class="overall-score" id="overallScore"></div>
110
+ </div>
111
+ <div id="empathyContent" class="anno-content"></div>
112
+ </div>
113
+ </aside>
114
+ </main>
115
+
116
+ <!-- Welcome Screen -->
117
+ <div class="welcome-screen" id="welcomeScreen">
118
+ <div class="welcome-card">
119
+ <div class="welcome-icon">
120
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
121
+ <path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
122
+ </svg>
123
+ </div>
124
+ <h2>Welcome to ESConv Annotation Review</h2>
125
+ <p>Load your annotated CSV file to begin reviewing LLM-generated labels for emotional support conversations.</p>
126
+ <div class="welcome-features">
127
+ <div class="feature-item">
128
+ <span class="feature-dot" style="background: var(--emotion-color)"></span>
129
+ <span>Emotion classification with intensity</span>
130
+ </div>
131
+ <div class="feature-item">
132
+ <span class="feature-dot" style="background: var(--distortion-color)"></span>
133
+ <span>Cognitive distortion detection</span>
134
+ </div>
135
+ <div class="feature-item">
136
+ <span class="feature-dot" style="background: var(--empathy-color)"></span>
137
+ <span>Multi-dimensional empathy scoring</span>
138
+ </div>
139
+ <div class="feature-item">
140
+ <span class="feature-dot" style="background: var(--highlight-color)"></span>
141
+ <span>Interactive evidence highlighting</span>
142
+ </div>
143
+ </div>
144
+ <label class="welcome-upload-btn" for="csvInput">
145
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
146
+ <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
147
+ </svg>
148
+ Load CSV File
149
+ </label>
150
+ </div>
151
+ </div>
152
+
153
+ <!-- Tooltip -->
154
+ <div class="tooltip" id="tooltip">
155
+ <div class="tooltip-arrow"></div>
156
+ <div class="tooltip-content" id="tooltipContent"></div>
157
+ </div>
158
+
159
+ <!-- Navigation Arrows -->
160
+ <div class="nav-arrows" id="navArrows" style="display:none">
161
+ <button class="nav-btn" id="prevBtn" title="Previous sample">
162
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
163
+ <path d="M15 18l-6-6 6-6"/>
164
+ </svg>
165
+ </button>
166
+ <button class="nav-btn" id="nextBtn" title="Next sample">
167
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
168
+ <path d="M9 18l6-6-6-6"/>
169
+ </svg>
170
+ </button>
171
+ </div>
172
+
173
+ <script src="app.js"></script>
174
+ </body>
175
+ </html>
models.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SQLAlchemy models for the Annotation Management System."""
2
+
3
+ from datetime import datetime, timezone
4
+ from flask_sqlalchemy import SQLAlchemy
5
+
6
+ db = SQLAlchemy()
7
+
8
+
9
+ class User(db.Model):
10
+ __tablename__ = 'users'
11
+
12
+ id = db.Column(db.Integer, primary_key=True)
13
+ username = db.Column(db.String(80), unique=True, nullable=False)
14
+ password_hash = db.Column(db.String(128), nullable=False)
15
+ role = db.Column(db.String(20), nullable=False, default='annotator') # 'admin' or 'annotator'
16
+ created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
17
+
18
+ assignments = db.relationship('Assignment', backref='user', lazy=True, cascade='all, delete-orphan')
19
+
20
+ def to_dict(self):
21
+ return {
22
+ 'id': self.id,
23
+ 'username': self.username,
24
+ 'role': self.role,
25
+ 'created_at': self.created_at.isoformat() if self.created_at else None,
26
+ }
27
+
28
+
29
+ class CsvFile(db.Model):
30
+ __tablename__ = 'csv_files'
31
+
32
+ id = db.Column(db.Integer, primary_key=True)
33
+ filename = db.Column(db.String(255), unique=True, nullable=False)
34
+ filepath = db.Column(db.String(512), nullable=False)
35
+ total_samples = db.Column(db.Integer, default=0)
36
+ created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
37
+
38
+ assignments = db.relationship('Assignment', backref='csv_file', lazy=True, cascade='all, delete-orphan')
39
+
40
+ def to_dict(self):
41
+ return {
42
+ 'id': self.id,
43
+ 'filename': self.filename,
44
+ 'total_samples': self.total_samples,
45
+ 'created_at': self.created_at.isoformat() if self.created_at else None,
46
+ }
47
+
48
+
49
+ class Assignment(db.Model):
50
+ __tablename__ = 'assignments'
51
+
52
+ id = db.Column(db.Integer, primary_key=True)
53
+ user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
54
+ file_id = db.Column(db.Integer, db.ForeignKey('csv_files.id'), nullable=False)
55
+ assigned_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
56
+
57
+ reviews = db.relationship('Review', backref='assignment', lazy=True, cascade='all, delete-orphan')
58
+
59
+ # Prevent duplicate assignments
60
+ __table_args__ = (db.UniqueConstraint('user_id', 'file_id', name='uq_user_file'),)
61
+
62
+ def to_dict(self):
63
+ reviewed_count = Review.query.filter_by(assignment_id=self.id).count()
64
+ total = self.csv_file.total_samples if self.csv_file else 0
65
+ return {
66
+ 'id': self.id,
67
+ 'user_id': self.user_id,
68
+ 'username': self.user.username if self.user else None,
69
+ 'file_id': self.file_id,
70
+ 'filename': self.csv_file.filename if self.csv_file else None,
71
+ 'total_samples': total,
72
+ 'reviewed_count': reviewed_count,
73
+ 'progress': round((reviewed_count / total * 100), 1) if total > 0 else 0,
74
+ 'assigned_at': self.assigned_at.isoformat() if self.assigned_at else None,
75
+ }
76
+
77
+
78
+ class Review(db.Model):
79
+ __tablename__ = 'reviews'
80
+
81
+ id = db.Column(db.Integer, primary_key=True)
82
+ assignment_id = db.Column(db.Integer, db.ForeignKey('assignments.id'), nullable=False)
83
+ sample_index = db.Column(db.Integer, nullable=False)
84
+ status = db.Column(db.String(20), nullable=False) # 'accept' or 'reject'
85
+ comment = db.Column(db.Text, default='')
86
+ # JSON string storing annotator-edited annotation data (emotion/distortions/empathy)
87
+ annotations_edit = db.Column(db.Text, default=None)
88
+ reviewed_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc))
89
+
90
+ # One review per sample per assignment
91
+ __table_args__ = (db.UniqueConstraint('assignment_id', 'sample_index', name='uq_assignment_sample'),)
92
+
93
+ def to_dict(self):
94
+ return {
95
+ 'id': self.id,
96
+ 'assignment_id': self.assignment_id,
97
+ 'sample_index': self.sample_index,
98
+ 'status': self.status,
99
+ 'comment': self.comment,
100
+ 'annotations_edit': self.annotations_edit, # raw JSON string
101
+ 'reviewed_at': self.reviewed_at.isoformat() if self.reviewed_at else None,
102
+ }
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ flask
2
+ flask-sqlalchemy
3
+ bcrypt
4
+ gunicorn
static/admin.css ADDED
@@ -0,0 +1,516 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ===== Admin/Annotator Dashboard Styles ===== */
2
+ .admin-main {
3
+ margin-top: var(--header-h);
4
+ padding: 24px 32px;
5
+ max-width: 1200px;
6
+ margin-left: auto;
7
+ margin-right: auto;
8
+ min-height: calc(100vh - var(--header-h));
9
+ overflow-y: auto;
10
+ }
11
+
12
+ /* User badge in header */
13
+ .user-badge {
14
+ padding: 4px 12px;
15
+ background: rgba(88,166,255,.1);
16
+ border: 1px solid rgba(88,166,255,.25);
17
+ border-radius: 20px;
18
+ font-size: .78rem;
19
+ font-weight: 600;
20
+ color: var(--accent);
21
+ }
22
+ .logout-btn {
23
+ display: flex; align-items: center; gap: 6px;
24
+ padding: 6px 14px;
25
+ background: transparent;
26
+ border: 1px solid var(--border);
27
+ border-radius: var(--radius-sm);
28
+ color: var(--text-muted);
29
+ font-size: .8rem;
30
+ cursor: pointer;
31
+ transition: all .2s;
32
+ }
33
+ .logout-btn:hover { border-color: #f87171; color: #f87171; }
34
+
35
+ /* Tab Navigation */
36
+ .tab-nav {
37
+ display: flex;
38
+ gap: 4px;
39
+ margin-bottom: 24px;
40
+ border-bottom: 1px solid var(--border);
41
+ padding-bottom: 0;
42
+ }
43
+ .tab-btn {
44
+ padding: 10px 20px;
45
+ background: transparent;
46
+ border: none;
47
+ border-bottom: 2px solid transparent;
48
+ color: var(--text-muted);
49
+ font-size: .85rem;
50
+ font-weight: 500;
51
+ cursor: pointer;
52
+ transition: all .2s;
53
+ font-family: inherit;
54
+ }
55
+ .tab-btn:hover { color: var(--text-primary); }
56
+ .tab-btn.active {
57
+ color: var(--accent);
58
+ border-bottom-color: var(--accent);
59
+ }
60
+
61
+ /* Tab Content */
62
+ .tab-content { display: none; }
63
+ .tab-content.active { display: block; }
64
+
65
+ /* Section Header */
66
+ .section-header {
67
+ display: flex;
68
+ align-items: center;
69
+ justify-content: space-between;
70
+ margin-bottom: 20px;
71
+ }
72
+ .section-header h2 {
73
+ font-size: 1.1rem;
74
+ font-weight: 700;
75
+ }
76
+ .section-header p {
77
+ font-size: .8rem;
78
+ color: var(--text-muted);
79
+ margin-left: 12px;
80
+ }
81
+ .section-header p code {
82
+ background: var(--bg-card);
83
+ padding: 2px 8px;
84
+ border-radius: 4px;
85
+ font-size: .75rem;
86
+ color: var(--accent);
87
+ }
88
+
89
+ /* Buttons */
90
+ .primary-btn {
91
+ display: inline-flex; align-items: center; gap: 6px;
92
+ padding: 8px 16px;
93
+ background: linear-gradient(135deg, var(--accent), #388bfd);
94
+ color: #fff;
95
+ border: none;
96
+ border-radius: var(--radius-sm);
97
+ font-size: .82rem;
98
+ font-weight: 600;
99
+ cursor: pointer;
100
+ transition: opacity .2s;
101
+ font-family: inherit;
102
+ }
103
+ .primary-btn:hover { opacity: .85; }
104
+ .ghost-btn {
105
+ padding: 8px 16px;
106
+ background: transparent;
107
+ border: 1px solid var(--border);
108
+ border-radius: var(--radius-sm);
109
+ color: var(--text-secondary);
110
+ font-size: .82rem;
111
+ cursor: pointer;
112
+ transition: all .2s;
113
+ font-family: inherit;
114
+ }
115
+ .ghost-btn:hover { border-color: var(--text-muted); }
116
+ .danger-btn {
117
+ padding: 4px 10px;
118
+ background: rgba(248,113,113,.1);
119
+ border: 1px solid rgba(248,113,113,.25);
120
+ border-radius: var(--radius-sm);
121
+ color: #f87171;
122
+ font-size: .75rem;
123
+ cursor: pointer;
124
+ transition: all .2s;
125
+ font-family: inherit;
126
+ }
127
+ .danger-btn:hover { background: rgba(248,113,113,.2); }
128
+
129
+ /* Create Forms */
130
+ .create-form {
131
+ background: var(--bg-card);
132
+ border: 1px solid var(--border);
133
+ border-radius: var(--radius);
134
+ padding: 14px 18px;
135
+ margin-bottom: 20px;
136
+ }
137
+ .form-row {
138
+ display: flex;
139
+ gap: 10px;
140
+ align-items: center;
141
+ flex-wrap: wrap;
142
+ }
143
+ .form-row input, .form-row select {
144
+ padding: 8px 12px;
145
+ background: var(--bg-primary);
146
+ border: 1px solid var(--border);
147
+ border-radius: var(--radius-sm);
148
+ color: var(--text-primary);
149
+ font-size: .85rem;
150
+ outline: none;
151
+ min-width: 140px;
152
+ font-family: inherit;
153
+ }
154
+ .form-row input:focus, .form-row select:focus { border-color: var(--accent); }
155
+ .form-error {
156
+ color: #f87171;
157
+ font-size: .78rem;
158
+ margin-top: 8px;
159
+ min-height: 1em;
160
+ }
161
+
162
+ /* Data Table */
163
+ .table-container { overflow-x: auto; }
164
+ .data-table {
165
+ width: 100%;
166
+ border-collapse: collapse;
167
+ font-size: .85rem;
168
+ }
169
+ .data-table th {
170
+ text-align: left;
171
+ padding: 10px 14px;
172
+ background: var(--bg-card);
173
+ color: var(--text-muted);
174
+ font-size: .72rem;
175
+ font-weight: 600;
176
+ text-transform: uppercase;
177
+ letter-spacing: .06em;
178
+ border-bottom: 1px solid var(--border);
179
+ }
180
+ .data-table td {
181
+ padding: 10px 14px;
182
+ border-bottom: 1px solid var(--border-light);
183
+ color: var(--text-secondary);
184
+ }
185
+ .data-table tbody tr:hover { background: var(--bg-hover); }
186
+ .role-badge {
187
+ padding: 2px 8px;
188
+ border-radius: 10px;
189
+ font-size: .72rem;
190
+ font-weight: 600;
191
+ }
192
+ .role-admin { background: rgba(167,139,250,.12); color: #a78bfa; }
193
+ .role-annotator { background: rgba(52,211,153,.12); color: #34d399; }
194
+
195
+ /* File Cards */
196
+ .card-grid {
197
+ display: grid;
198
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
199
+ gap: 14px;
200
+ }
201
+ .file-card {
202
+ background: var(--bg-card);
203
+ border: 1px solid var(--border);
204
+ border-radius: var(--radius);
205
+ padding: 18px;
206
+ cursor: pointer;
207
+ transition: all .2s;
208
+ }
209
+ .file-card:hover { border-color: var(--accent); transform: translateY(-2px); box-shadow: var(--shadow); }
210
+ .file-card-header {
211
+ display: flex;
212
+ align-items: center;
213
+ gap: 10px;
214
+ margin-bottom: 12px;
215
+ }
216
+ .file-card-name {
217
+ font-weight: 600;
218
+ font-size: .85rem;
219
+ word-break: break-all;
220
+ }
221
+ .file-card-stats {
222
+ display: flex;
223
+ justify-content: space-between;
224
+ font-size: .78rem;
225
+ color: var(--text-muted);
226
+ margin-bottom: 8px;
227
+ }
228
+ .progress-bar {
229
+ height: 5px;
230
+ background: var(--border);
231
+ border-radius: 3px;
232
+ overflow: hidden;
233
+ margin-bottom: 10px;
234
+ }
235
+ .progress-fill {
236
+ height: 100%;
237
+ border-radius: 3px;
238
+ transition: width .4s ease;
239
+ }
240
+ .completed-badge {
241
+ font-size: .75rem;
242
+ color: #34d399;
243
+ font-weight: 600;
244
+ text-align: center;
245
+ }
246
+ .continue-badge {
247
+ font-size: .75rem;
248
+ color: var(--accent);
249
+ font-weight: 600;
250
+ text-align: center;
251
+ }
252
+
253
+ /* Empty State */
254
+ .empty-state {
255
+ text-align: center;
256
+ padding: 60px 20px;
257
+ color: var(--text-muted);
258
+ }
259
+
260
+ /* Progress in table */
261
+ .progress-cell {
262
+ display: flex;
263
+ align-items: center;
264
+ gap: 8px;
265
+ }
266
+ .progress-cell .progress-bar {
267
+ width: 80px;
268
+ margin-bottom: 0;
269
+ }
270
+ .progress-text {
271
+ font-size: .75rem;
272
+ font-variant-numeric: tabular-nums;
273
+ min-width: 60px;
274
+ }
275
+
276
+ /* Review controls on review page */
277
+ .review-card { border-color: rgba(88,166,255,.3); }
278
+ .review-buttons {
279
+ display: flex;
280
+ gap: 8px;
281
+ margin-bottom: 10px;
282
+ }
283
+ .review-btn {
284
+ flex: 1;
285
+ padding: 10px;
286
+ border: 2px solid var(--border);
287
+ border-radius: var(--radius-sm);
288
+ background: transparent;
289
+ font-size: .85rem;
290
+ font-weight: 600;
291
+ cursor: pointer;
292
+ transition: all .15s;
293
+ font-family: inherit;
294
+ }
295
+ .accept-btn { color: var(--text-muted); }
296
+ .accept-btn:hover, .accept-btn.active {
297
+ border-color: #34d399;
298
+ background: rgba(52,211,153,.1);
299
+ color: #34d399;
300
+ }
301
+ .reject-btn { color: var(--text-muted); }
302
+ .reject-btn:hover, .reject-btn.active {
303
+ border-color: #f87171;
304
+ background: rgba(248,113,113,.1);
305
+ color: #f87171;
306
+ }
307
+ .review-comment {
308
+ width: 100%;
309
+ padding: 10px;
310
+ background: var(--bg-primary);
311
+ border: 1px solid var(--border);
312
+ border-radius: var(--radius-sm);
313
+ color: var(--text-primary);
314
+ font-size: .8rem;
315
+ font-family: inherit;
316
+ resize: vertical;
317
+ outline: none;
318
+ margin-bottom: 10px;
319
+ }
320
+ .review-comment:focus { border-color: var(--accent); }
321
+ .submit-review-btn {
322
+ width: 100%;
323
+ padding: 10px;
324
+ background: linear-gradient(135deg, var(--accent), #388bfd);
325
+ color: #fff;
326
+ border: none;
327
+ border-radius: var(--radius-sm);
328
+ font-weight: 600;
329
+ font-size: .85rem;
330
+ cursor: pointer;
331
+ transition: opacity .2s;
332
+ font-family: inherit;
333
+ }
334
+ .submit-review-btn:hover { opacity: .85; }
335
+ .submit-review-btn:disabled { opacity: .4; cursor: not-allowed; }
336
+ .review-status-badge {
337
+ margin-left: auto;
338
+ padding: 2px 10px;
339
+ border-radius: 10px;
340
+ font-size: .72rem;
341
+ font-weight: 700;
342
+ }
343
+ .review-status-accept { background: rgba(52,211,153,.12); color: #34d399; border: 1px solid rgba(52,211,153,.3); }
344
+ .review-status-reject { background: rgba(248,113,113,.12); color: #f87171; border: 1px solid rgba(248,113,113,.3); }
345
+
346
+ /* Sample list review status dots */
347
+ .sample-review-dot {
348
+ width: 7px; height: 7px;
349
+ border-radius: 50%;
350
+ display: inline-block;
351
+ margin-left: 4px;
352
+ }
353
+ .dot-accept { background: #34d399; }
354
+ .dot-reject { background: #f87171; }
355
+ .dot-pending { background: var(--border); }
356
+
357
+ /* ===== Edit Mode Styles ===== */
358
+ .edit-toggle-btn {
359
+ margin-left: auto;
360
+ padding: 3px 10px;
361
+ background: transparent;
362
+ border: 1px solid var(--border);
363
+ border-radius: var(--radius-sm);
364
+ color: var(--text-muted);
365
+ font-size: .72rem;
366
+ cursor: pointer;
367
+ transition: all .15s;
368
+ font-family: inherit;
369
+ display: flex; align-items: center; gap: 4px;
370
+ }
371
+ .edit-toggle-btn:hover { border-color: var(--accent); color: var(--accent); }
372
+ .edit-toggle-btn.active { border-color: var(--accent); color: var(--accent); background: rgba(88,166,255,.1); }
373
+
374
+ .edited-badge {
375
+ padding: 2px 8px;
376
+ background: rgba(251,191,36,.12);
377
+ border: 1px solid rgba(251,191,36,.3);
378
+ border-radius: 10px;
379
+ font-size: .65rem;
380
+ font-weight: 700;
381
+ color: #fbbf24;
382
+ }
383
+
384
+ /* Inline edit inputs */
385
+ .edit-field {
386
+ width: 100%;
387
+ background: var(--bg-primary);
388
+ border: 1px solid var(--border);
389
+ border-radius: var(--radius-sm);
390
+ color: var(--text-primary);
391
+ font-size: .82rem;
392
+ font-family: inherit;
393
+ outline: none;
394
+ transition: border-color .15s;
395
+ }
396
+ .edit-field:focus { border-color: var(--accent); }
397
+ input.edit-field { padding: 5px 9px; }
398
+ textarea.edit-field { padding: 6px 9px; resize: vertical; }
399
+ select.edit-field { padding: 5px 9px; }
400
+
401
+ .edit-row {
402
+ display: flex; align-items: center; gap: 6px;
403
+ margin-bottom: 6px;
404
+ }
405
+ .edit-label {
406
+ font-size: .68rem;
407
+ color: var(--text-muted);
408
+ text-transform: uppercase;
409
+ letter-spacing: .06em;
410
+ font-weight: 600;
411
+ margin-bottom: 3px;
412
+ }
413
+ .edit-group { margin-bottom: 8px; }
414
+ .edit-group:last-child { margin-bottom: 0; }
415
+
416
+ /* Evidence select mode */
417
+ .select-evidence-btn {
418
+ padding: 4px 10px;
419
+ background: rgba(251,191,36,.1);
420
+ border: 1px solid rgba(251,191,36,.3);
421
+ border-radius: var(--radius-sm);
422
+ color: #fbbf24;
423
+ font-size: .72rem;
424
+ cursor: pointer;
425
+ font-family: inherit;
426
+ white-space: nowrap;
427
+ transition: all .15s;
428
+ flex-shrink: 0;
429
+ }
430
+ .select-evidence-btn:hover { background: rgba(251,191,36,.2); }
431
+ .select-evidence-btn.selecting { background: rgba(251,191,36,.25); border-color: #fbbf24; animation: pulse-border .8s infinite; }
432
+ @keyframes pulse-border { 0%,100% { box-shadow: 0 0 0 0 rgba(251,191,36,.4); } 50% { box-shadow: 0 0 0 4px rgba(251,191,36,0); } }
433
+
434
+ /* Conversation selection mode overlay */
435
+ .conversation-panel.selecting-mode .conversation-view {
436
+ cursor: text;
437
+ outline: 2px dashed rgba(251,191,36,.5);
438
+ outline-offset: -4px;
439
+ }
440
+ .selection-hint {
441
+ position: sticky;
442
+ top: 0;
443
+ z-index: 10;
444
+ background: rgba(251,191,36,.15);
445
+ border: 1px solid rgba(251,191,36,.4);
446
+ border-radius: var(--radius-sm);
447
+ padding: 6px 12px;
448
+ font-size: .78rem;
449
+ color: #fbbf24;
450
+ text-align: center;
451
+ margin-bottom: 8px;
452
+ display: none;
453
+ }
454
+ .selection-hint.visible { display: block; }
455
+
456
+ /* Distortion edit item */
457
+ .distortion-item.editing {
458
+ border-color: var(--distortion-color);
459
+ background: var(--distortion-bg);
460
+ }
461
+ .empathy-item.editing {
462
+ border-color: rgba(52,211,153,.5);
463
+ background: rgba(52,211,153,.08);
464
+ }
465
+
466
+ /* Score slider */
467
+ .score-slider { width: 100%; accent-color: var(--empathy-color); }
468
+ .score-display { font-weight: 700; font-size: .85rem; min-width: 20px; }
469
+
470
+ /* Add/remove item buttons */
471
+ .add-item-btn {
472
+ width: 100%; padding: 7px;
473
+ background: transparent;
474
+ border: 1px dashed var(--border);
475
+ border-radius: var(--radius-sm);
476
+ color: var(--text-muted);
477
+ font-size: .78rem;
478
+ cursor: pointer; font-family: inherit;
479
+ transition: all .15s;
480
+ margin-top: 6px;
481
+ }
482
+ .add-item-btn:hover { border-color: var(--accent); color: var(--accent); }
483
+ .remove-item-btn {
484
+ padding: 2px 7px;
485
+ background: rgba(248,113,113,.08);
486
+ border: 1px solid rgba(248,113,113,.2);
487
+ border-radius: 4px;
488
+ color: #f87171;
489
+ font-size: .7rem;
490
+ cursor: pointer; font-family: inherit;
491
+ flex-shrink: 0;
492
+ }
493
+
494
+ /* Save annotation button */
495
+ .save-anno-btn {
496
+ width: 100%; padding: 8px;
497
+ background: linear-gradient(135deg,#fbbf24,#f59e0b);
498
+ color: #1a1200;
499
+ border: none; border-radius: var(--radius-sm);
500
+ font-weight: 700; font-size: .82rem;
501
+ cursor: pointer; font-family: inherit;
502
+ margin-top: 8px; transition: opacity .15s;
503
+ }
504
+ .save-anno-btn:hover { opacity: .9; }
505
+ .save-anno-btn.saved { background: linear-gradient(135deg,#34d399,#059669); color: #fff; }
506
+
507
+ .reset-anno-btn {
508
+ width: 100%; padding: 6px;
509
+ background: transparent;
510
+ border: 1px solid var(--border);
511
+ border-radius: var(--radius-sm);
512
+ color: var(--text-muted);
513
+ font-size: .75rem; cursor: pointer; font-family: inherit;
514
+ margin-top: 4px; transition: all .15s;
515
+ }
516
+ .reset-anno-btn:hover { border-color: #f87171; color: #f87171; }
static/admin.js ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ===== Admin Dashboard JS =====
2
+
3
+ // ── Tab switching ──
4
+ document.querySelectorAll('.tab-btn').forEach(btn => {
5
+ btn.addEventListener('click', () => {
6
+ document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
7
+ document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
8
+ btn.classList.add('active');
9
+ document.getElementById('tab-' + btn.dataset.tab).classList.add('active');
10
+ });
11
+ });
12
+
13
+ // ── Logout ──
14
+ document.getElementById('logoutBtn').addEventListener('click', async () => {
15
+ await fetch('/api/logout', { method: 'POST' });
16
+ window.location.href = '/login';
17
+ });
18
+
19
+ // ── User info ──
20
+ fetch('/api/me').then(r => r.json()).then(u => {
21
+ document.getElementById('userBadge').textContent = u.username;
22
+ });
23
+
24
+ // ── State ──
25
+ let allUsers = [];
26
+ let allFiles = [];
27
+
28
+ // ── Files ──
29
+ async function loadFiles() {
30
+ const res = await fetch('/api/files');
31
+ allFiles = await res.json();
32
+ const grid = document.getElementById('filesList');
33
+ if (allFiles.length === 0) {
34
+ grid.innerHTML = '<div class="empty-state"><p>No CSV files found in the data folder.</p></div>';
35
+ return;
36
+ }
37
+ grid.innerHTML = allFiles.map(f => {
38
+ return `
39
+ <div class="file-card" style="cursor:default">
40
+ <div class="file-card-header">
41
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--accent);flex-shrink:0">
42
+ <path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
43
+ <polyline points="14 2 14 8 20 8"/>
44
+ </svg>
45
+ <span class="file-card-name">${f.filename}</span>
46
+ </div>
47
+ <div class="file-card-stats">
48
+ <span>${f.total_samples} samples</span>
49
+ <span>${f.total_assignees} assignee(s)</span>
50
+ </div>
51
+ <div class="file-card-stats" style="margin-top:4px">
52
+ <span>${f.total_reviews} total reviews</span>
53
+ </div>
54
+ </div>`;
55
+ }).join('');
56
+ }
57
+
58
+ // ── Users ──
59
+ async function loadUsers() {
60
+ const res = await fetch('/api/users');
61
+ allUsers = await res.json();
62
+ const tbody = document.getElementById('usersBody');
63
+ tbody.innerHTML = allUsers.map(u => {
64
+ const roleClass = u.role === 'admin' ? 'role-admin' : 'role-annotator';
65
+ const created = u.created_at ? new Date(u.created_at).toLocaleDateString() : '—';
66
+ return `<tr>
67
+ <td>${u.id}</td>
68
+ <td style="font-weight:600;color:var(--text-primary)">${u.username}</td>
69
+ <td><span class="role-badge ${roleClass}">${u.role}</span></td>
70
+ <td>${created}</td>
71
+ <td>${u.role !== 'admin' ? `<button class="danger-btn" onclick="deleteUser(${u.id})">Delete</button>` : '—'}</td>
72
+ </tr>`;
73
+ }).join('');
74
+ }
75
+
76
+ // ── Create User ──
77
+ document.getElementById('addUserBtn').addEventListener('click', () => {
78
+ document.getElementById('createUserForm').style.display = 'block';
79
+ document.getElementById('newUsername').focus();
80
+ });
81
+ document.getElementById('cancelUserBtn').addEventListener('click', () => {
82
+ document.getElementById('createUserForm').style.display = 'none';
83
+ document.getElementById('userFormError').textContent = '';
84
+ });
85
+ document.getElementById('submitUserBtn').addEventListener('click', async () => {
86
+ const username = document.getElementById('newUsername').value.trim();
87
+ const password = document.getElementById('newPassword').value;
88
+ const role = document.getElementById('newRole').value;
89
+ const errEl = document.getElementById('userFormError');
90
+ errEl.textContent = '';
91
+ const res = await fetch('/api/users', {
92
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
93
+ body: JSON.stringify({ username, password, role })
94
+ });
95
+ const data = await res.json();
96
+ if (!res.ok) { errEl.textContent = data.error; return; }
97
+ document.getElementById('createUserForm').style.display = 'none';
98
+ document.getElementById('newUsername').value = '';
99
+ document.getElementById('newPassword').value = '';
100
+ loadUsers();
101
+ loadAssignDropdowns();
102
+ });
103
+
104
+ async function deleteUser(id) {
105
+ if (!confirm('Delete this user?')) return;
106
+ await fetch(`/api/users/${id}`, { method: 'DELETE' });
107
+ loadUsers();
108
+ loadAssignments();
109
+ }
110
+
111
+ // ── Assignments ──
112
+ async function loadAssignments() {
113
+ const res = await fetch('/api/assignments');
114
+ const data = await res.json();
115
+ const tbody = document.getElementById('assignBody');
116
+ if (data.length === 0) {
117
+ tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-muted)">No assignments yet</td></tr>';
118
+ return;
119
+ }
120
+ tbody.innerHTML = data.map(a => {
121
+ const pct = a.progress;
122
+ const barColor = pct >= 100 ? '#34d399' : pct > 50 ? '#fbbf24' : '#60a5fa';
123
+ const date = a.assigned_at ? new Date(a.assigned_at).toLocaleDateString() : '—';
124
+ return `<tr>
125
+ <td style="font-weight:600;color:var(--text-primary)">${a.username}</td>
126
+ <td>${a.filename}</td>
127
+ <td>
128
+ <div class="progress-cell">
129
+ <div class="progress-bar"><div class="progress-fill" style="width:${pct}%;background:${barColor}"></div></div>
130
+ <span class="progress-text" style="color:${barColor}">${a.reviewed_count}/${a.total_samples} (${pct}%)</span>
131
+ </div>
132
+ </td>
133
+ <td>${date}</td>
134
+ <td>
135
+ <button class="danger-btn" onclick="deleteAssignment(${a.id})">Remove</button>
136
+ </td>
137
+ </tr>`;
138
+ }).join('');
139
+ }
140
+
141
+ async function loadAssignDropdowns() {
142
+ // Populate user dropdown
143
+ const userSel = document.getElementById('assignUser');
144
+ const fileSel = document.getElementById('assignFile');
145
+ const usersRes = await fetch('/api/users');
146
+ const users = await usersRes.json();
147
+ const filesRes = await fetch('/api/files');
148
+ const files = await filesRes.json();
149
+ userSel.innerHTML = '<option value="">Select User...</option>' +
150
+ users.filter(u => u.role === 'annotator').map(u => `<option value="${u.id}">${u.username}</option>`).join('');
151
+ fileSel.innerHTML = '<option value="">Select File...</option>' +
152
+ files.map(f => `<option value="${f.id}">${f.filename} (${f.total_samples} samples)</option>`).join('');
153
+ }
154
+
155
+ document.getElementById('addAssignBtn').addEventListener('click', () => {
156
+ document.getElementById('createAssignForm').style.display = 'block';
157
+ loadAssignDropdowns();
158
+ });
159
+ document.getElementById('cancelAssignBtn').addEventListener('click', () => {
160
+ document.getElementById('createAssignForm').style.display = 'none';
161
+ document.getElementById('assignFormError').textContent = '';
162
+ });
163
+ document.getElementById('submitAssignBtn').addEventListener('click', async () => {
164
+ const user_id = document.getElementById('assignUser').value;
165
+ const file_id = document.getElementById('assignFile').value;
166
+ const errEl = document.getElementById('assignFormError');
167
+ errEl.textContent = '';
168
+ if (!user_id || !file_id) { errEl.textContent = 'Select both user and file'; return; }
169
+ const res = await fetch('/api/assignments', {
170
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
171
+ body: JSON.stringify({ user_id: parseInt(user_id), file_id: parseInt(file_id) })
172
+ });
173
+ const data = await res.json();
174
+ if (!res.ok) { errEl.textContent = data.error; return; }
175
+ document.getElementById('createAssignForm').style.display = 'none';
176
+ loadAssignments();
177
+ loadFiles();
178
+ });
179
+
180
+ async function deleteAssignment(id) {
181
+ if (!confirm('Remove this assignment?')) return;
182
+ await fetch(`/api/assignments/${id}`, { method: 'DELETE' });
183
+ loadAssignments();
184
+ loadFiles();
185
+ }
186
+
187
+ // ── Init ──
188
+ loadFiles();
189
+ loadUsers();
190
+ loadAssignments();
static/app.js ADDED
@@ -0,0 +1,675 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ===== State =====
2
+ let allData=[], filteredData=[], currentIndex=0;
3
+ let assignmentId=null, reviewsMap={}, currentReviewStatus=null;
4
+ let editMode={emotion:false, distortions:false, empathy:false};
5
+ let editData={emotion:null, distortions:null, empathy:null}; // working copies
6
+ let selectingEvidence=null; // {card, itemIdx, field}
7
+
8
+ const DIST_COLORS=['#fb923c','#f87171','#fbbf24','#a78bfa','#34d399'];
9
+ const EMP_COLORS=['#60a5fa','#a78bfa','#34d399','#fbbf24','#fb923c','#f87171','#818cf8','#2dd4bf','#e879f9'];
10
+ const EMOTION_OPTS=['Fear','Sadness','Anger','Joy','Anxiety','Shame','Depression','Disgust','Surprise'];
11
+ const INTENSITY_OPTS=['Low','Medium','High'];
12
+ const DISTORTION_OPTS=[
13
+ 'All-or-Nothing Thinking', 'Overgeneralization', 'Mental Filter',
14
+ 'Disqualifying the Positive', 'Mind Reading', 'Fortune Telling',
15
+ 'Catastrophizing', 'Minimization', 'Emotional Reasoning',
16
+ 'Should Statements', 'Labeling', 'Personalization'
17
+ ];
18
+
19
+ // ===== Helpers =====
20
+ function safeJSON(s){try{return JSON.parse(s);}catch(e){return null;}}
21
+ function escA(s){return (s||'').replace(/"/g,'&quot;').replace(/'/g,'&#39;');}
22
+ function escH(s){return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
23
+ function parseAnno(row){
24
+ return {
25
+ emotion:safeJSON(row['Emotion']),
26
+ distortions:safeJSON(row['Distortions']),
27
+ empathy:safeJSON(row['Empathy_Level'])
28
+ };
29
+ }
30
+ function getEffectiveAnno(row){
31
+ const review=reviewsMap[currentIndex];
32
+ if(review&&review.annotations_edit){
33
+ const ed=safeJSON(review.annotations_edit);
34
+ if(ed) return ed;
35
+ }
36
+ return parseAnno(row);
37
+ }
38
+ function emotionCls(e){
39
+ if(!e)return 'emotion-default';
40
+ const l=e.toLowerCase();
41
+ if(l.includes('fear'))return 'emotion-fear';
42
+ if(l.includes('sad'))return 'emotion-sadness';
43
+ if(l.includes('anger')||l.includes('angry'))return 'emotion-anger';
44
+ if(l.includes('joy')||l.includes('happy'))return 'emotion-joy';
45
+ if(l.includes('anxi'))return 'emotion-anxiety';
46
+ if(l.includes('shame'))return 'emotion-shame';
47
+ if(l.includes('depress'))return 'emotion-depression';
48
+ return 'emotion-default';
49
+ }
50
+ function scoreColor(s,m=7){const r=s/m;return r>=.7?'#34d399':r>=.4?'#fbbf24':'#f87171';}
51
+ function deepCopy(o){return JSON.parse(JSON.stringify(o));}
52
+
53
+ // ===== Conversation parsing & highlights =====
54
+ function parseConv(s){
55
+ if(!s)return [];
56
+ const msgs=[];
57
+ s.split('\n').forEach(line=>{
58
+ const t=line.trim();
59
+ if(!t)return;
60
+ if(t.startsWith('speaker:')||t.startsWith('listener:')){
61
+ const isL=t.startsWith('listener:');
62
+ const c=t.replace(/^(speaker|listener):\s*/,'');
63
+ if(c)msgs.push({role:isL?'listener':'speaker',text:c});
64
+ } else if(msgs.length>0) msgs[msgs.length-1].text+=' '+t;
65
+ });
66
+ return msgs;
67
+ }
68
+ function applyHighlights(msgs,quotes){
69
+ return msgs.map(msg=>{
70
+ let text=msg.text;
71
+ quotes.forEach(({quote,cls})=>{
72
+ if(!quote||quote==='No evidence present')return;
73
+ const q=quote.replace(/^(listener|speaker):\s*/i,'').trim();
74
+ if(!q)return;
75
+ const i=text.indexOf(q);
76
+ if(i!==-1)text=text.slice(0,i)+`<mark class="evidence-hl ${cls}" data-quote="${escA(q)}">`+text.slice(i,i+q.length)+'</mark>'+text.slice(i+q.length);
77
+ });
78
+ return {...msg,htmlText:text};
79
+ });
80
+ }
81
+ function buildQuotes(anno){
82
+ const quotes=[];
83
+ (anno.distortions?.distortions||[]).forEach((d,i)=>{
84
+ if(d.evidence_quote&&d.evidence_quote!=='No evidence present')
85
+ quotes.push({quote:d.evidence_quote,cls:`dist-hl-${i%3}`,type:'dist',idx:i});
86
+ });
87
+ (anno.empathy?.empathy_dimensions||[]).forEach((dim,i)=>{
88
+ if(dim.evidence_quote&&dim.evidence_quote!=='No evidence present')
89
+ quotes.push({quote:dim.evidence_quote,cls:`emp-hl-${i%9}`,type:'emp',idx:i});
90
+ });
91
+ return quotes;
92
+ }
93
+ function scrollToEvidence(prefix,idx){
94
+ const m=document.querySelector(`.conversation-view mark.${prefix}-${idx}`);
95
+ if(!m)return;
96
+ document.querySelectorAll('mark.evidence-pulse').forEach(x=>x.classList.remove('evidence-pulse'));
97
+ m.scrollIntoView({behavior:'smooth',block:'center'});
98
+ m.classList.add('evidence-pulse');
99
+ setTimeout(()=>m.classList.remove('evidence-pulse'),2000);
100
+ }
101
+
102
+ // ===== Render: Conversation =====
103
+ function renderConversation(row){
104
+ const anno=getEffectiveAnno(row);
105
+ const quotes=buildQuotes(anno);
106
+ const msgs=applyHighlights(parseConv(row['conversation_cut']),quotes);
107
+ const view=document.getElementById('conversationView');
108
+ view.innerHTML=msgs.map(m=>`<div class="message-bubble ${m.role}"><div class="message-label">${m.role==='listener'?'Listener':'Speaker'}</div><div class="message-text">${m.htmlText||escH(m.text)}</div></div>`).join('');
109
+ view.querySelectorAll('mark.evidence-hl').forEach(mk=>{
110
+ mk.addEventListener('mouseenter',()=>showTooltip(mk,anno));
111
+ mk.addEventListener('mouseleave',hideTooltip);
112
+ });
113
+ }
114
+ function renderConvHeader(row){
115
+ const anno=getEffectiveAnno(row);
116
+ const e=anno.emotion;
117
+ const en=e?.primary_emotion||row['emotion_type']||'—';
118
+ const ei=e?.intensity||'';
119
+ const intCls=ei.toLowerCase().includes('high')?'intensity-high':ei.toLowerCase().includes('medium')?'intensity-medium':'intensity-low';
120
+ document.getElementById('convMeta').innerHTML=`
121
+ <span class="meta-badge ${emotionCls(en)}">${en}</span>
122
+ ${ei?`<span class="intensity-badge ${intCls}">${ei}</span>`:''}
123
+ ${row.problem_type?`<span class="problem-badge">${row.problem_type}</span>`:''}
124
+ ${row.experience_type?`<span class="experience-badge">${row.experience_type}</span>`:''}`;
125
+ }
126
+
127
+ // ===== Tooltip =====
128
+ function showTooltip(mk,anno){
129
+ const cls=Array.from(mk.classList).find(c=>c.startsWith('dist-hl-')||c.startsWith('emp-hl-'));
130
+ let html='';
131
+ if(cls?.startsWith('dist-hl-')){
132
+ const i=parseInt(cls.split('-').pop());
133
+ const d=(anno.distortions?.distortions||[])[i];
134
+ if(d){const c=DIST_COLORS[i%DIST_COLORS.length];html=`<div class="tooltip-title" style="color:${c}">⚠️ ${d.cognitive_distortion||'Distortion'}</div><div class="tooltip-label">Reasoning</div><div class="tooltip-text">${d.reasoning||'—'}</div>`;}
135
+ } else if(cls?.startsWith('emp-hl-')){
136
+ const i=parseInt(cls.split('-').pop());
137
+ const dim=(anno.empathy?.empathy_dimensions||[])[i];
138
+ 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>`;}
139
+ }
140
+ if(!html)return;
141
+ const tip=document.getElementById('tooltip');
142
+ document.getElementById('tooltipContent').innerHTML=html;
143
+ tip.classList.add('visible');
144
+ const r=mk.getBoundingClientRect();
145
+ let top=r.bottom+8,left=r.left;
146
+ if(left+320>window.innerWidth)left=window.innerWidth-330;
147
+ if(top+200>window.innerHeight)top=r.top-200;
148
+ tip.style.top=top+'px';tip.style.left=left+'px';
149
+ }
150
+ function hideTooltip(){document.getElementById('tooltip').classList.remove('visible');}
151
+
152
+ // ===== Render: Emotion (View + Edit) =====
153
+ function renderEmotion(row){
154
+ const anno=getEffectiveAnno(row);
155
+ const e=anno.emotion;
156
+ const el=document.getElementById('emotionContent');
157
+ const hasEdit=!!(reviewsMap[currentIndex]?.annotations_edit);
158
+ const btn=document.getElementById('editEmotionBtn');
159
+ btn.classList.toggle('active',editMode.emotion);
160
+ btn.textContent=editMode.emotion?'✓ Done':'✏️ Edit';
161
+
162
+ if(editMode.emotion){
163
+ const d=editData.emotion;
164
+ el.innerHTML=`
165
+ <div class="edit-group"><div class="edit-label">Primary Emotion</div>
166
+ <select class="edit-field" id="ee-primary">
167
+ ${EMOTION_OPTS.map(o=>`<option value="${o}"${d?.primary_emotion===o?' selected':''}>${o}</option>`).join('')}
168
+ <option value="${d?.primary_emotion||''}"${!EMOTION_OPTS.includes(d?.primary_emotion)?'selected':''} hidden>${d?.primary_emotion||'Custom'}</option>
169
+ </select></div>
170
+ <div class="edit-group"><div class="edit-label">Intensity</div>
171
+ <select class="edit-field" id="ee-intensity">
172
+ ${INTENSITY_OPTS.map(o=>`<option value="${o}"${d?.intensity===o?' selected':''}>${o}</option>`).join('')}
173
+ </select></div>
174
+ <div class="edit-group"><div class="edit-label">Specific Term</div>
175
+ <input class="edit-field" id="ee-term" value="${escA(d?.specific_emotion_term||'')}"/></div>
176
+ <div class="edit-group"><div class="edit-label">Reasoning</div>
177
+ <textarea class="edit-field" id="ee-reason" rows="4">${d?.reasoning||''}</textarea></div>`;
178
+ ['ee-primary','ee-intensity','ee-term','ee-reason'].forEach(id=>{
179
+ document.getElementById(id)?.addEventListener('input',()=>{
180
+ editData.emotion={
181
+ primary_emotion:document.getElementById('ee-primary').value,
182
+ intensity:document.getElementById('ee-intensity').value,
183
+ specific_emotion_term:document.getElementById('ee-term').value,
184
+ reasoning:document.getElementById('ee-reason').value
185
+ };
186
+ });
187
+ });
188
+ } else {
189
+ if(!e){el.innerHTML='<p style="color:var(--text-muted);font-size:.8rem">No annotation.</p>';return;}
190
+ const intCls=(e.intensity||'').toLowerCase().includes('high')?'intensity-high':(e.intensity||'').toLowerCase().includes('medium')?'intensity-medium':'intensity-low';
191
+ el.innerHTML=`
192
+ <div class="emotion-primary">
193
+ <span class="emotion-badge ${emotionCls(e.primary_emotion)}">${e.primary_emotion||'—'}</span>
194
+ <span class="intensity-badge ${intCls}">${e.intensity||'—'}</span>
195
+ ${hasEdit?'<span class="edited-badge">Edited</span>':''}
196
+ </div>
197
+ ${e.specific_emotion_term?`<p class="specific-term">Term: <span>${e.specific_emotion_term}</span></p>`:''}
198
+ ${e.reasoning?`<p class="emotion-reasoning">${e.reasoning}</p>`:''}`;
199
+ }
200
+ }
201
+
202
+ // ===== Render: Distortions (View + Edit) =====
203
+ function renderDistortions(row){
204
+ const anno=getEffectiveAnno(row);
205
+ const el=document.getElementById('distortionsContent');
206
+ const dists=editMode.distortions ? editData.distortions : (anno.distortions?.distortions||[]);
207
+ const hasEdit=!!(reviewsMap[currentIndex]?.annotations_edit);
208
+ const btn=document.getElementById('editDistBtn');
209
+ btn.classList.toggle('active',editMode.distortions);
210
+ btn.textContent=editMode.distortions?'✓ Done':'✏️ Edit';
211
+
212
+ if(editMode.distortions){
213
+ if(!dists.length){
214
+ el.innerHTML=`<div class="no-distortions"><span>No distortions</span></div>`;
215
+ } else {
216
+ el.innerHTML=dists.map((d,i)=>{
217
+ const c=DIST_COLORS[i%DIST_COLORS.length];
218
+ return `<div class="distortion-item editing" data-dist-idx="${i}">
219
+ <div class="edit-row">
220
+ <span class="dist-color-dot" style="background:${c}"></span>
221
+ <select class="edit-field" data-d-field="name" data-d-idx="${i}" style="flex:1">
222
+ <option value="" disabled ${!d.cognitive_distortion?'selected':''}>Select Distortion...</option>
223
+ ${DISTORTION_OPTS.map(o=>`<option value="${o}" ${d.cognitive_distortion===o?'selected':''}>${o}</option>`).join('')}
224
+ <option value="${escA(d.cognitive_distortion||'')}" ${!DISTORTION_OPTS.includes(d.cognitive_distortion||'')&&d.cognitive_distortion?'selected':''} hidden>${escA(d.cognitive_distortion||'Custom')}</option>
225
+ </select>
226
+ <button class="remove-item-btn" data-remove-dist="${i}">✕</button>
227
+ </div>
228
+ <div class="edit-label" style="margin-top:6px">Evidence Quote</div>
229
+ <div class="edit-row">
230
+ <input class="edit-field" data-d-field="quote" data-d-idx="${i}" value="${escA(d.evidence_quote||'')}" placeholder="Evidence quote" style="flex:1"/>
231
+ <button class="select-evidence-btn" data-sel-card="distortions" data-sel-idx="${i}">📌 Select</button>
232
+ </div>
233
+ <div class="edit-label" style="margin-top:6px">Reasoning</div>
234
+ <textarea class="edit-field" data-d-field="reason" data-d-idx="${i}" rows="2" placeholder="Reasoning...">${d.reasoning||''}</textarea>
235
+ </div>`;
236
+ }).join('');
237
+ }
238
+ el.innerHTML+=`<button class="add-item-btn" id="addDistBtn">+ Add Distortion</button>`;
239
+ // events
240
+ el.querySelectorAll('[data-d-field]').forEach(inp=>{
241
+ inp.addEventListener('input',()=>{
242
+ const idx=parseInt(inp.dataset.dIdx);
243
+ const f=inp.dataset.dField;
244
+ if(f==='name') editData.distortions[idx].cognitive_distortion=inp.value;
245
+ else if(f==='quote') editData.distortions[idx].evidence_quote=inp.value;
246
+ else if(f==='reason') editData.distortions[idx].reasoning=inp.value;
247
+ });
248
+ });
249
+ el.querySelectorAll('[data-remove-dist]').forEach(btn=>{
250
+ btn.addEventListener('click',()=>{
251
+ editData.distortions.splice(parseInt(btn.dataset.removeDist),1);
252
+ renderDistortions(row);
253
+ });
254
+ });
255
+ el.querySelectorAll('[data-sel-card]').forEach(btn=>{
256
+ btn.addEventListener('click',()=>startSelectingEvidence(btn.dataset.selCard,parseInt(btn.dataset.selIdx),btn));
257
+ });
258
+ document.getElementById('addDistBtn')?.addEventListener('click',()=>{
259
+ editData.distortions.push({cognitive_distortion:'',evidence_quote:'',reasoning:''});
260
+ renderDistortions(row);
261
+ });
262
+ } else {
263
+ if(!dists.length){el.innerHTML=`<div class="no-distortions"><span>No distortions detected</span></div>`;return;}
264
+ el.innerHTML=dists.map((d,i)=>{
265
+ const c=DIST_COLORS[i%DIST_COLORS.length];
266
+ const q=(d.evidence_quote||'').replace(/^(listener|speaker):\s*/i,'');
267
+ return `<div class="distortion-item" data-dist-idx="${i}">
268
+ <div class="distortion-name"><span class="dist-color-dot" style="background:${c}"></span>${d.cognitive_distortion||'—'} ${hasEdit&&i===0?'<span class="edited-badge">Edited</span>':''}</div>
269
+ ${q?`<div class="distortion-quote" style="border-color:${c}">${q}</div>`:''}
270
+ ${d.reasoning?`<div class="distortion-reasoning">${d.reasoning}</div>`:''}
271
+ </div>`;
272
+ }).join('');
273
+ el.querySelectorAll('.distortion-item').forEach(item=>{
274
+ item.addEventListener('click',()=>scrollToEvidence('dist-hl',parseInt(item.dataset.distIdx)%3));
275
+ });
276
+ }
277
+ }
278
+
279
+ // ===== Render: Empathy (View + Edit) =====
280
+ function renderEmpathy(row){
281
+ const anno=getEffectiveAnno(row);
282
+ const el=document.getElementById('empathyContent');
283
+ const scoreEl=document.getElementById('overallScore');
284
+ const dims=editMode.empathy ? editData.empathy : (anno.empathy?.empathy_dimensions||[]);
285
+ const hasEdit=!!(reviewsMap[currentIndex]?.annotations_edit);
286
+ const btn=document.getElementById('editEmpBtn');
287
+ btn.classList.toggle('active',editMode.empathy);
288
+ btn.textContent=editMode.empathy?'✓ Done':'✏️ Edit';
289
+
290
+ if(!anno.empathy&&!editMode.empathy){el.innerHTML='<p style="color:var(--text-muted);font-size:.8rem">No annotation.</p>';scoreEl.textContent='';return;}
291
+ const total=dims.reduce((s,d)=>s+(d.score||0),0);
292
+ scoreEl.textContent=dims.length?`${total} / ${dims.length*7}`:'';
293
+
294
+ if(editMode.empathy){
295
+ el.innerHTML=dims.map((dim,i)=>{
296
+ const c=EMP_COLORS[i%EMP_COLORS.length];
297
+ const sc=dim.score||0;
298
+ return `<div class="empathy-item editing" data-emp-idx="${i}">
299
+ <div class="edit-row">
300
+ <span class="emp-color-dot" style="background:${c}"></span>
301
+ <input class="edit-field" data-e-field="name" data-e-idx="${i}" value="${escA(dim.dimension_name||'')}" placeholder="Dimension name" style="flex:1"/>
302
+ <button class="remove-item-btn" data-remove-emp="${i}">✕</button>
303
+ </div>
304
+ <div class="edit-label" style="margin-top:6px">Score (0–7)</div>
305
+ <div class="edit-row">
306
+ <input type="range" class="score-slider" data-e-field="score" data-e-idx="${i}" min="0" max="7" value="${sc}"/>
307
+ <span class="score-display" id="score-disp-${i}" style="color:${scoreColor(sc)}">${sc}</span>
308
+ </div>
309
+ <div class="edit-label" style="margin-top:6px">Evidence Quote</div>
310
+ <div class="edit-row">
311
+ <input class="edit-field" data-e-field="quote" data-e-idx="${i}" value="${escA(dim.evidence_quote||'')}" placeholder="Evidence quote" style="flex:1"/>
312
+ <button class="select-evidence-btn" data-sel-card="empathy" data-sel-idx="${i}">📌 Select</button>
313
+ </div>
314
+ <div class="edit-label" style="margin-top:6px">Reasoning</div>
315
+ <textarea class="edit-field" data-e-field="reason" data-e-idx="${i}" rows="2">${dim.reasoning||''}</textarea>
316
+ </div>`;
317
+ }).join('');
318
+ el.innerHTML+=`<button class="add-item-btn" id="addEmpBtn">+ Add Dimension</button>`;
319
+ // events
320
+ el.querySelectorAll('[data-e-field]').forEach(inp=>{
321
+ inp.addEventListener('input',()=>{
322
+ const idx=parseInt(inp.dataset.eIdx);
323
+ const f=inp.dataset.eField;
324
+ if(f==='name') editData.empathy[idx].dimension_name=inp.value;
325
+ else if(f==='score'){
326
+ const v=parseInt(inp.value);
327
+ editData.empathy[idx].score=v;
328
+ const disp=document.getElementById(`score-disp-${idx}`);
329
+ if(disp){disp.textContent=v;disp.style.color=scoreColor(v);}
330
+ }
331
+ else if(f==='quote') editData.empathy[idx].evidence_quote=inp.value;
332
+ else if(f==='reason') editData.empathy[idx].reasoning=inp.value;
333
+ });
334
+ });
335
+ el.querySelectorAll('[data-remove-emp]').forEach(btn=>{
336
+ btn.addEventListener('click',()=>{editData.empathy.splice(parseInt(btn.dataset.removeEmp),1);renderEmpathy(row);});
337
+ });
338
+ el.querySelectorAll('[data-sel-card]').forEach(btn=>{
339
+ btn.addEventListener('click',()=>startSelectingEvidence(btn.dataset.selCard,parseInt(btn.dataset.selIdx),btn));
340
+ });
341
+ document.getElementById('addEmpBtn')?.addEventListener('click',()=>{
342
+ editData.empathy.push({dimension_name:'',score:0,evidence_quote:'',reasoning:''});
343
+ renderEmpathy(row);
344
+ });
345
+ } else {
346
+ el.innerHTML=dims.map((dim,i)=>{
347
+ const c=EMP_COLORS[i%EMP_COLORS.length];
348
+ const sc=dim.score||0;
349
+ const pct=Math.min(100,(sc/7)*100);
350
+ const sC=scoreColor(sc);
351
+ const q=(dim.evidence_quote||'').replace(/^(listener|speaker):\s*/i,'');
352
+ return `<div class="empathy-item" data-emp-idx="${i}">
353
+ <div class="empathy-item-header">
354
+ <span class="emp-color-dot" style="background:${c}"></span>
355
+ <span class="empathy-name">${dim.dimension_name||'—'}</span>
356
+ <div class="score-bar-container">
357
+ <div class="score-bar"><div class="score-bar-fill" style="width:${pct}%;background:${sC}"></div></div>
358
+ <span class="score-value" style="color:${sC}">${sc}</span>
359
+ </div>
360
+ </div>
361
+ ${q&&q!=='No evidence present'?`<div class="empathy-quote" style="border-color:${c}" title="${escA(q)}">"${q}"</div>`:''}
362
+ ${dim.reasoning?`<div class="empathy-reasoning">${dim.reasoning}</div>`:''}
363
+ </div>`;
364
+ }).join('');
365
+ el.querySelectorAll('.empathy-item').forEach(item=>{
366
+ item.addEventListener('click',()=>{
367
+ item.classList.toggle('active');
368
+ scrollToEvidence('emp-hl',parseInt(item.dataset.empIdx)%9);
369
+ });
370
+ });
371
+ }
372
+ }
373
+
374
+ // ===== Evidence Selection Mode =====
375
+ function startSelectingEvidence(card, itemIdx, triggerBtn){
376
+ selectingEvidence={card, itemIdx};
377
+ const panel=document.querySelector('.conversation-panel');
378
+ panel.classList.add('selecting-mode');
379
+ const hint=document.getElementById('selectionHint');
380
+ hint.classList.add('visible');
381
+ // Mark button as active
382
+ document.querySelectorAll('.select-evidence-btn').forEach(b=>b.classList.remove('selecting'));
383
+ triggerBtn?.classList.add('selecting');
384
+ }
385
+ function stopSelectingEvidence(){
386
+ selectingEvidence=null;
387
+ document.querySelector('.conversation-panel')?.classList.remove('selecting-mode');
388
+ const hint=document.getElementById('selectionHint');
389
+ hint?.classList.remove('visible');
390
+ document.querySelectorAll('.select-evidence-btn').forEach(b=>b.classList.remove('selecting'));
391
+ }
392
+ function captureSelection(){
393
+ if(!selectingEvidence)return;
394
+ const sel=window.getSelection();
395
+ if(!sel||sel.rangeCount===0||sel.isCollapsed)return;
396
+ const text=sel.toString().trim();
397
+ if(!text)return;
398
+ const {card,itemIdx}=selectingEvidence;
399
+ // Set the value in editData
400
+ if(card==='distortions'&&editData.distortions[itemIdx]!==undefined){
401
+ editData.distortions[itemIdx].evidence_quote=text;
402
+ } else if(card==='empathy'&&editData.empathy[itemIdx]!==undefined){
403
+ editData.empathy[itemIdx].evidence_quote=text;
404
+ }
405
+ sel.removeAllRanges();
406
+ stopSelectingEvidence();
407
+ // Re-render the card to update input field
408
+ const row=filteredData[currentIndex];
409
+ if(card==='distortions') renderDistortions(row);
410
+ else if(card==='empathy') renderEmpathy(row);
411
+ }
412
+
413
+ // ===== Save Annotations =====
414
+ async function saveAnnotations(){
415
+ const row=filteredData[currentIndex];
416
+ const anno=getEffectiveAnno(row);
417
+ // Merge current editData into the annotation structure
418
+ const toSave={
419
+ emotion: editData.emotion || anno.emotion,
420
+ distortions: anno.distortions
421
+ ? {...anno.distortions, distortions: editData.distortions||anno.distortions?.distortions||[]}
422
+ : {distortions: editData.distortions||[]},
423
+ empathy: anno.empathy
424
+ ? {...anno.empathy, empathy_dimensions: editData.empathy||anno.empathy?.empathy_dimensions||[]}
425
+ : {empathy_dimensions: editData.empathy||[]}
426
+ };
427
+ try {
428
+ const res=await fetch('/api/reviews/annotations',{
429
+ method:'PATCH',
430
+ headers:{'Content-Type':'application/json'},
431
+ body:JSON.stringify({assignment_id:assignmentId,sample_index:currentIndex,annotations:toSave})
432
+ });
433
+ const data=await res.json();
434
+ if(res.ok){
435
+ reviewsMap[currentIndex]=data;
436
+ return true;
437
+ }
438
+ } catch(e){}
439
+ return false;
440
+ }
441
+
442
+ // ===== Toggle Edit Mode =====
443
+ function toggleEdit(card){
444
+ const row=filteredData[currentIndex];
445
+ const anno=getEffectiveAnno(row);
446
+ if(editMode[card]){
447
+ // Exiting edit mode — auto-save
448
+ editMode[card]=false;
449
+ stopSelectingEvidence();
450
+ saveAnnotations().then(ok=>{
451
+ if(ok){
452
+ // Re-render all to show updated highlights
453
+ renderAll();
454
+ }
455
+ });
456
+ } else {
457
+ // Entering edit mode — init editData from effective annotation
458
+ if(card==='emotion') editData.emotion=deepCopy(anno.emotion||{primary_emotion:'',intensity:'',specific_emotion_term:'',reasoning:''});
459
+ else if(card==='distortions') editData.distortions=deepCopy(anno.distortions?.distortions||[]);
460
+ else if(card==='empathy') editData.empathy=deepCopy(anno.empathy?.empathy_dimensions||[]);
461
+ editMode[card]=true;
462
+ if(card==='emotion') renderEmotion(row);
463
+ else if(card==='distortions') renderDistortions(row);
464
+ else if(card==='empathy') renderEmpathy(row);
465
+ }
466
+ }
467
+
468
+ // ===== Review State =====
469
+ function renderReviewState(){
470
+ const review=reviewsMap[currentIndex];
471
+ const badge=document.getElementById('reviewStatusBadge');
472
+ const acceptBtn=document.getElementById('acceptBtn');
473
+ const rejectBtn=document.getElementById('rejectBtn');
474
+ const comment=document.getElementById('reviewComment');
475
+ const submitBtn=document.getElementById('submitReviewBtn');
476
+ acceptBtn.classList.remove('active');
477
+ rejectBtn.classList.remove('active');
478
+ currentReviewStatus=null;
479
+ if(review&&review.status&&review.status!=='reject'||(review&&review.status==='accept')){
480
+ // Has a real review decision
481
+ }
482
+ if(review){
483
+ badge.style.display='inline-block';
484
+ badge.textContent=review.status==='accept'?'✓ Accepted':'✗ Rejected';
485
+ badge.className='review-status-badge '+(review.status==='accept'?'review-status-accept':'review-status-reject');
486
+ currentReviewStatus=review.status;
487
+ if(review.status==='accept') acceptBtn.classList.add('active');
488
+ else rejectBtn.classList.add('active');
489
+ comment.value=review.comment||'';
490
+ submitBtn.textContent='Update Review';
491
+ } else {
492
+ badge.style.display='none';
493
+ comment.value='';
494
+ submitBtn.textContent='Submit Review';
495
+ }
496
+ submitBtn.disabled=false;
497
+ }
498
+
499
+ // ===== Sample List =====
500
+ function renderSampleList(){
501
+ const list=document.getElementById('sampleList');
502
+ list.innerHTML='';
503
+ filteredData.forEach((row,i)=>{
504
+ const anno=parseAnno(row);
505
+ const en=anno.emotion?.primary_emotion||row.emotion_type||'—';
506
+ const review=reviewsMap[i];
507
+ const dotCls=review?(review.status==='accept'?'dot-accept':'dot-reject'):'dot-pending';
508
+ const hasEdit=!!(review?.annotations_edit);
509
+ const item=document.createElement('div');
510
+ item.className='sample-item'+(i===currentIndex?' active':'');
511
+ item.dataset.idx=i;
512
+ item.innerHTML=`<div class="sample-item-idx">#${i+1} <span class="sample-review-dot ${dotCls}"></span>${hasEdit?'<span style="color:#fbbf24;font-size:.6rem">✏</span>':''}</div>
513
+ <div class="sample-item-emotion" style="color:var(--emotion-color)">${en}</div>
514
+ <div class="sample-item-problem">${row.problem_type||''}</div>`;
515
+ item.addEventListener('click',()=>{currentIndex=i;renderAll();});
516
+ list.appendChild(item);
517
+ });
518
+ }
519
+
520
+ // ===== Navigation =====
521
+ function updateNav(){
522
+ document.getElementById('prevBtn').disabled=currentIndex<=0;
523
+ document.getElementById('nextBtn').disabled=currentIndex>=filteredData.length-1;
524
+ document.getElementById('currentIdx').textContent=currentIndex+1;
525
+ document.getElementById('totalSamples').textContent=filteredData.length;
526
+ document.querySelectorAll('.sample-item').forEach(it=>it.classList.toggle('active',parseInt(it.dataset.idx)===currentIndex));
527
+ document.querySelector('.sample-item.active')?.scrollIntoView({block:'nearest'});
528
+ }
529
+
530
+ // ===== Render All =====
531
+ function renderAll(){
532
+ if(!filteredData.length)return;
533
+ // Exit edit modes on navigation
534
+ editMode={emotion:false,distortions:false,empathy:false};
535
+ stopSelectingEvidence();
536
+ const row=filteredData[currentIndex];
537
+ renderConvHeader(row);
538
+ renderConversation(row);
539
+ renderEmotion(row);
540
+ renderDistortions(row);
541
+ renderEmpathy(row);
542
+ renderReviewState();
543
+ updateNav();
544
+ }
545
+
546
+ // ===== Filter =====
547
+ function applyFilter(q){
548
+ q=q.toLowerCase().trim();
549
+ filteredData=q?allData.filter(row=>{
550
+ const a=parseAnno(row);
551
+ return (a.emotion?.primary_emotion||row.emotion_type||'').toLowerCase().includes(q)||
552
+ (row.problem_type||'').toLowerCase().includes(q);
553
+ }):[...allData];
554
+ currentIndex=0;renderSampleList();renderAll();
555
+ }
556
+
557
+ // ===== Submit Review =====
558
+ async function submitReview(){
559
+ if(!currentReviewStatus){alert('Please select Accept or Reject');return;}
560
+ const comment=document.getElementById('reviewComment').value.trim();
561
+ const btn=document.getElementById('submitReviewBtn');
562
+ btn.disabled=true;btn.textContent='Submitting...';
563
+ try{
564
+ const res=await fetch('/api/reviews',{
565
+ method:'POST',headers:{'Content-Type':'application/json'},
566
+ body:JSON.stringify({assignment_id:assignmentId,sample_index:currentIndex,status:currentReviewStatus,comment})
567
+ });
568
+ const data=await res.json();
569
+ if(res.ok){
570
+ reviewsMap[currentIndex]=data;
571
+ renderReviewState();renderSampleList();
572
+ btn.textContent='✓ Saved!';
573
+ setTimeout(()=>{
574
+ btn.textContent='Update Review';btn.disabled=false;
575
+ const next=filteredData.findIndex((_,i)=>i>currentIndex&&!reviewsMap[i]);
576
+ if(next!==-1){currentIndex=next;renderAll();}
577
+ },800);
578
+ } else {alert(data.error||'Failed');btn.textContent='Submit Review';btn.disabled=false;}
579
+ } catch(e){alert('Network error');btn.textContent='Submit Review';btn.disabled=false;}
580
+ }
581
+
582
+ // ===== Load Data =====
583
+ async function loadAssignmentData(){
584
+ try{
585
+ let fileId,filename;
586
+ const r1=await fetch('/api/my-assignments');
587
+ const myAssign=await r1.json();
588
+ let a=myAssign.find(x=>x.id===assignmentId);
589
+ if(!a){
590
+ const r2=await fetch('/api/assignments');
591
+ if(r2.ok){const all=await r2.json();a=all.find(x=>x.id===assignmentId);}
592
+ }
593
+ if(!a){document.getElementById('reviewSubtitle').textContent='Assignment not found';return;}
594
+ fileId=a.file_id;filename=a.filename;
595
+ const r3=await fetch(`/api/files/${fileId}/data`);
596
+ const fd=await r3.json();
597
+ allData=fd.samples||[];filteredData=[...allData];
598
+ const r4=await fetch(`/api/assignments/${assignmentId}/reviews`);
599
+ reviewsMap=await r4.json();
600
+ document.getElementById('reviewTitle').textContent='Review';
601
+ document.getElementById('reviewSubtitle').textContent=filename||'Annotation Review';
602
+ currentIndex=0;renderSampleList();renderAll();
603
+ } catch(err){console.error(err);document.getElementById('reviewSubtitle').textContent='Failed to load';}
604
+ }
605
+
606
+ // ===== DOMContentLoaded =====
607
+ document.addEventListener('DOMContentLoaded',async()=>{
608
+ const parts=window.location.pathname.split('/');
609
+ assignmentId=parseInt(parts[parts.length-1]);
610
+ if(isNaN(assignmentId))return;
611
+ try{const m=await fetch('/api/me').then(r=>r.json());document.getElementById('userBadge').textContent=m.username;}catch(e){}
612
+ document.getElementById('logoutBtn').addEventListener('click',async()=>{await fetch('/api/logout',{method:'POST'});window.location.href='/login';});
613
+ document.getElementById('prevBtn').addEventListener('click',()=>{if(currentIndex>0){currentIndex--;renderAll();}});
614
+ document.getElementById('nextBtn').addEventListener('click',()=>{if(currentIndex<filteredData.length-1){currentIndex++;renderAll();}});
615
+ document.getElementById('searchInput').addEventListener('input',e=>applyFilter(e.target.value));
616
+ document.getElementById('acceptBtn').addEventListener('click',()=>{currentReviewStatus='accept';document.getElementById('acceptBtn').classList.add('active');document.getElementById('rejectBtn').classList.remove('active');});
617
+ document.getElementById('rejectBtn').addEventListener('click',()=>{currentReviewStatus='reject';document.getElementById('rejectBtn').classList.add('active');document.getElementById('acceptBtn').classList.remove('active');});
618
+ document.getElementById('submitReviewBtn').addEventListener('click',submitReview);
619
+ // Edit toggle buttons
620
+ ['editEmotionBtn','editDistBtn','editEmpBtn'].forEach(id=>{
621
+ document.getElementById(id)?.addEventListener('click',()=>{
622
+ const card={'editEmotionBtn':'emotion','editDistBtn':'distortions','editEmpBtn':'empathy'}[id];
623
+ toggleEdit(card);
624
+ });
625
+ });
626
+ // Capture text selection from conversation
627
+ document.getElementById('conversationView').addEventListener('mouseup',()=>{
628
+ if(selectingEvidence) captureSelection();
629
+ });
630
+ // Escape key cancels selection
631
+ document.addEventListener('keydown',e=>{
632
+ if(e.key==='Escape'){stopSelectingEvidence();return;}
633
+ if(e.target.tagName==='INPUT'||e.target.tagName==='TEXTAREA'||e.target.tagName==='SELECT')return;
634
+ if(e.key==='ArrowLeft'&&currentIndex>0){currentIndex--;renderAll();}
635
+ if(e.key==='ArrowRight'&&currentIndex<filteredData.length-1){currentIndex++;renderAll();}
636
+ });
637
+ await loadAssignmentData();
638
+ });
639
+
640
+ // ===== Export =====
641
+ function doExport(format){
642
+ if(!assignmentId)return;
643
+ const url=`/api/assignments/${assignmentId}/export?format=${format}`;
644
+ if(format==='csv'){
645
+ // Direct download
646
+ const a=document.createElement('a');
647
+ a.href=url;a.download='';a.click();
648
+ } else {
649
+ // JSON: fetch and download as file
650
+ fetch(url).then(r=>r.json()).then(data=>{
651
+ const blob=new Blob([JSON.stringify(data,null,2)],{type:'application/json'});
652
+ const a=document.createElement('a');
653
+ a.href=URL.createObjectURL(blob);
654
+ a.download=`export_assignment_${assignmentId}.json`;
655
+ a.click();URL.revokeObjectURL(a.href);
656
+ }).catch(e=>alert('Export failed'));
657
+ }
658
+ document.getElementById('exportMenu').style.display='none';
659
+ }
660
+
661
+ // Wire up export buttons after DOM ready
662
+ document.addEventListener('DOMContentLoaded',()=>{
663
+ const exportBtn=document.getElementById('exportBtn');
664
+ const exportMenu=document.getElementById('exportMenu');
665
+ if(exportBtn&&exportMenu){
666
+ exportBtn.addEventListener('click',(e)=>{
667
+ e.stopPropagation();
668
+ exportMenu.style.display=exportMenu.style.display==='none'?'block':'none';
669
+ });
670
+ document.getElementById('exportJSON')?.addEventListener('click',()=>doExport('json'));
671
+ document.getElementById('exportCSV')?.addEventListener('click',()=>doExport('csv'));
672
+ // Close menu on outside click
673
+ document.addEventListener('click',()=>{exportMenu.style.display='none';});
674
+ }
675
+ });
static/style.css ADDED
@@ -0,0 +1,686 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ===== CSS Variables ===== */
2
+ :root {
3
+ --bg-primary: #0d1117;
4
+ --bg-secondary: #161b22;
5
+ --bg-card: #1c2128;
6
+ --bg-hover: #21262d;
7
+ --border: #30363d;
8
+ --border-light: #21262d;
9
+ --text-primary: #e6edf3;
10
+ --text-secondary: #8b949e;
11
+ --text-muted: #6e7681;
12
+ --accent: #58a6ff;
13
+ --accent-hover: #79c0ff;
14
+
15
+ --emotion-color: #a78bfa;
16
+ --emotion-bg: rgba(167, 139, 250, 0.12);
17
+ --distortion-color: #fb923c;
18
+ --distortion-bg: rgba(251, 146, 60, 0.12);
19
+ --empathy-color: #34d399;
20
+ --empathy-bg: rgba(52, 211, 153, 0.12);
21
+ --highlight-color: #fbbf24;
22
+
23
+ --speaker-bg: #1a2332;
24
+ --speaker-border: #1d4ed8;
25
+ --listener-bg: #1a2b1f;
26
+ --listener-border: #16a34a;
27
+
28
+ /* Highlight colors for evidence quotes */
29
+ --hl-distortion-1: rgba(251, 146, 60, 0.25);
30
+ --hl-distortion-2: rgba(248, 113, 113, 0.25);
31
+ --hl-distortion-3: rgba(251, 191, 36, 0.25);
32
+
33
+ --hl-empathy-concern: rgba(96, 165, 250, 0.22);
34
+ --hl-empathy-expressiveness: rgba(167, 139, 250, 0.22);
35
+ --hl-empathy-resonate: rgba(52, 211, 153, 0.22);
36
+ --hl-empathy-warmth: rgba(251, 191, 36, 0.22);
37
+ --hl-empathy-attuned: rgba(251, 146, 60, 0.22);
38
+ --hl-empathy-cognitive: rgba(248, 113, 113, 0.22);
39
+ --hl-empathy-feelings: rgba(129, 140, 248, 0.22);
40
+ --hl-empathy-acceptance: rgba(45, 212, 191, 0.22);
41
+ --hl-empathy-responsiveness: rgba(232, 121, 249, 0.22);
42
+
43
+ --radius-sm: 6px;
44
+ --radius: 10px;
45
+ --radius-lg: 14px;
46
+ --shadow: 0 4px 16px rgba(0,0,0,0.4);
47
+ --shadow-card: 0 2px 8px rgba(0,0,0,0.3);
48
+
49
+ --header-h: 60px;
50
+ }
51
+
52
+ /* ===== Reset & Base ===== */
53
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
54
+ html { font-size: 16px; scroll-behavior: smooth; }
55
+ body {
56
+ font-family: 'Inter', system-ui, sans-serif;
57
+ background: var(--bg-primary);
58
+ color: var(--text-primary);
59
+ min-height: 100vh;
60
+ overflow: hidden;
61
+ }
62
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
63
+ ::-webkit-scrollbar-track { background: transparent; }
64
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
65
+ ::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
66
+
67
+ /* ===== Header ===== */
68
+ .header {
69
+ position: fixed;
70
+ top: 0; left: 0; right: 0;
71
+ height: var(--header-h);
72
+ background: rgba(13, 17, 23, 0.92);
73
+ border-bottom: 1px solid var(--border);
74
+ backdrop-filter: blur(12px);
75
+ z-index: 100;
76
+ }
77
+ .header-inner {
78
+ max-width: 100%;
79
+ height: 100%;
80
+ padding: 0 20px;
81
+ display: flex;
82
+ align-items: center;
83
+ justify-content: space-between;
84
+ gap: 16px;
85
+ }
86
+ .header-logo {
87
+ display: flex;
88
+ align-items: center;
89
+ gap: 10px;
90
+ }
91
+ .logo-icon {
92
+ width: 36px; height: 36px;
93
+ background: linear-gradient(135deg, var(--accent), var(--empathy-color));
94
+ border-radius: var(--radius-sm);
95
+ display: flex; align-items: center; justify-content: center;
96
+ color: #fff;
97
+ flex-shrink: 0;
98
+ }
99
+ .header-logo h1 { font-size: 1rem; font-weight: 600; color: var(--text-primary); }
100
+ .header-logo p { font-size: 0.75rem; color: var(--text-muted); }
101
+ .header-controls { display: flex; align-items: center; gap: 12px; }
102
+
103
+ .file-upload-area {
104
+ display: flex; align-items: center; gap: 8px;
105
+ padding: 7px 14px;
106
+ background: var(--bg-card);
107
+ border: 1px solid var(--border);
108
+ border-radius: var(--radius-sm);
109
+ cursor: pointer;
110
+ font-size: 0.85rem;
111
+ color: var(--text-secondary);
112
+ transition: all 0.2s;
113
+ position: relative;
114
+ overflow: hidden;
115
+ }
116
+ .file-upload-area:hover { border-color: var(--accent); color: var(--accent); background: var(--bg-hover); }
117
+ .file-upload-area input[type="file"] {
118
+ position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%;
119
+ }
120
+ .sample-counter {
121
+ padding: 6px 14px;
122
+ background: var(--bg-card);
123
+ border: 1px solid var(--border);
124
+ border-radius: var(--radius-sm);
125
+ font-size: 0.85rem;
126
+ color: var(--text-secondary);
127
+ font-variant-numeric: tabular-nums;
128
+ }
129
+ .sample-counter #currentIdx { color: var(--accent); font-weight: 600; }
130
+
131
+ /* ===== Welcome Screen ===== */
132
+ .welcome-screen {
133
+ position: fixed;
134
+ inset: 0;
135
+ display: flex;
136
+ align-items: center;
137
+ justify-content: center;
138
+ background: var(--bg-primary);
139
+ z-index: 50;
140
+ padding: 20px;
141
+ }
142
+ .welcome-card {
143
+ max-width: 480px;
144
+ width: 100%;
145
+ background: var(--bg-card);
146
+ border: 1px solid var(--border);
147
+ border-radius: var(--radius-lg);
148
+ padding: 40px;
149
+ text-align: center;
150
+ box-shadow: var(--shadow);
151
+ }
152
+ .welcome-icon {
153
+ width: 80px; height: 80px;
154
+ background: linear-gradient(135deg, rgba(88,166,255,0.15), rgba(52,211,153,0.15));
155
+ border: 1px solid var(--border);
156
+ border-radius: 20px;
157
+ display: flex; align-items: center; justify-content: center;
158
+ margin: 0 auto 24px;
159
+ color: var(--accent);
160
+ }
161
+ .welcome-card h2 { font-size: 1.3rem; font-weight: 700; margin-bottom: 10px; }
162
+ .welcome-card p { color: var(--text-secondary); line-height: 1.6; margin-bottom: 28px; }
163
+ .welcome-features {
164
+ display: flex; flex-direction: column; gap: 10px;
165
+ margin-bottom: 32px;
166
+ text-align: left;
167
+ }
168
+ .feature-item {
169
+ display: flex; align-items: center; gap: 10px;
170
+ font-size: 0.875rem; color: var(--text-secondary);
171
+ }
172
+ .feature-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
173
+ .welcome-upload-btn {
174
+ display: inline-flex; align-items: center; gap: 8px;
175
+ padding: 12px 28px;
176
+ background: linear-gradient(135deg, var(--accent), #388bfd);
177
+ color: #fff;
178
+ border-radius: var(--radius-sm);
179
+ font-weight: 600;
180
+ cursor: pointer;
181
+ transition: opacity 0.2s, transform 0.2s;
182
+ font-size: 0.9rem;
183
+ }
184
+ .welcome-upload-btn:hover { opacity: 0.9; transform: translateY(-1px); }
185
+
186
+ /* ===== Main Layout ===== */
187
+ .main-layout {
188
+ position: fixed;
189
+ inset: 0;
190
+ top: var(--header-h);
191
+ display: grid;
192
+ grid-template-columns: 200px 1fr 480px;
193
+ overflow: hidden;
194
+ }
195
+
196
+ /* ===== Nav Panel ===== */
197
+ .nav-panel {
198
+ background: var(--bg-secondary);
199
+ border-right: 1px solid var(--border);
200
+ display: flex;
201
+ flex-direction: column;
202
+ overflow: hidden;
203
+ }
204
+ .nav-panel-header {
205
+ padding: 14px;
206
+ border-bottom: 1px solid var(--border);
207
+ flex-shrink: 0;
208
+ }
209
+ .nav-panel-header h2 {
210
+ font-size: 0.8rem;
211
+ font-weight: 600;
212
+ color: var(--text-muted);
213
+ text-transform: uppercase;
214
+ letter-spacing: 0.08em;
215
+ margin-bottom: 10px;
216
+ }
217
+ .search-box {
218
+ display: flex; align-items: center; gap: 6px;
219
+ background: var(--bg-card);
220
+ border: 1px solid var(--border);
221
+ border-radius: var(--radius-sm);
222
+ padding: 6px 10px;
223
+ }
224
+ .search-box svg { color: var(--text-muted); flex-shrink: 0; }
225
+ .search-box input {
226
+ background: transparent; border: none; outline: none;
227
+ color: var(--text-primary); font-size: 0.8rem; width: 100%;
228
+ }
229
+ .search-box input::placeholder { color: var(--text-muted); }
230
+
231
+ .sample-list {
232
+ flex: 1;
233
+ overflow-y: auto;
234
+ padding: 6px;
235
+ display: flex;
236
+ flex-direction: column;
237
+ gap: 2px;
238
+ }
239
+ .sample-item {
240
+ padding: 8px 10px;
241
+ border-radius: var(--radius-sm);
242
+ cursor: pointer;
243
+ transition: background 0.15s;
244
+ border: 1px solid transparent;
245
+ }
246
+ .sample-item:hover { background: var(--bg-hover); }
247
+ .sample-item.active {
248
+ background: rgba(88,166,255,0.1);
249
+ border-color: rgba(88,166,255,0.3);
250
+ }
251
+ .sample-item-idx {
252
+ font-size: 0.65rem;
253
+ color: var(--text-muted);
254
+ font-variant-numeric: tabular-nums;
255
+ margin-bottom: 2px;
256
+ }
257
+ .sample-item-emotion {
258
+ font-size: 0.75rem;
259
+ font-weight: 600;
260
+ margin-bottom: 2px;
261
+ }
262
+ .sample-item-problem {
263
+ font-size: 0.7rem;
264
+ color: var(--text-muted);
265
+ white-space: nowrap;
266
+ overflow: hidden;
267
+ text-overflow: ellipsis;
268
+ }
269
+
270
+ /* ===== Conversation Panel ===== */
271
+ .conversation-panel {
272
+ display: flex;
273
+ flex-direction: column;
274
+ overflow: hidden;
275
+ background: var(--bg-primary);
276
+ }
277
+ .conv-header {
278
+ padding: 12px 20px;
279
+ border-bottom: 1px solid var(--border);
280
+ display: flex;
281
+ align-items: center;
282
+ justify-content: space-between;
283
+ flex-shrink: 0;
284
+ background: var(--bg-secondary);
285
+ }
286
+ .conv-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
287
+ .meta-badge {
288
+ padding: 3px 10px;
289
+ border-radius: 20px;
290
+ font-size: 0.72rem;
291
+ font-weight: 600;
292
+ letter-spacing: 0.03em;
293
+ }
294
+ .legend { display: flex; align-items: center; gap: 14px; }
295
+ .legend-item {
296
+ display: flex; align-items: center; gap: 5px;
297
+ font-size: 0.75rem; color: var(--text-muted);
298
+ }
299
+ .legend-dot {
300
+ width: 8px; height: 8px; border-radius: 2px;
301
+ }
302
+ .legend-dot.speaker { background: var(--speaker-border); }
303
+ .legend-dot.listener { background: var(--listener-border); }
304
+
305
+ .conversation-view {
306
+ flex: 1;
307
+ overflow-y: auto;
308
+ padding: 20px;
309
+ display: flex;
310
+ flex-direction: column;
311
+ gap: 8px;
312
+ }
313
+ .message-bubble {
314
+ max-width: 85%;
315
+ padding: 10px 14px;
316
+ border-radius: var(--radius);
317
+ border-left: 3px solid transparent;
318
+ font-size: 0.875rem;
319
+ line-height: 1.6;
320
+ position: relative;
321
+ transition: all 0.2s;
322
+ }
323
+ .message-bubble.speaker {
324
+ background: var(--speaker-bg);
325
+ border-color: var(--speaker-border);
326
+ align-self: flex-end;
327
+ border-radius: var(--radius) var(--radius) var(--radius-sm) var(--radius);
328
+ }
329
+ .message-bubble.listener {
330
+ background: var(--listener-bg);
331
+ border-color: var(--listener-border);
332
+ align-self: flex-start;
333
+ border-radius: var(--radius) var(--radius) var(--radius) var(--radius-sm);
334
+ }
335
+ .message-label {
336
+ font-size: 0.65rem;
337
+ font-weight: 700;
338
+ text-transform: uppercase;
339
+ letter-spacing: 0.08em;
340
+ margin-bottom: 4px;
341
+ opacity: 0.7;
342
+ }
343
+ .message-bubble.speaker .message-label { color: #60a5fa; }
344
+ .message-bubble.listener .message-label { color: #4ade80; }
345
+
346
+ /* Highlighted evidence text */
347
+ mark.evidence-hl {
348
+ background: transparent;
349
+ border-radius: 3px;
350
+ padding: 1px 0;
351
+ cursor: pointer;
352
+ transition: all 0.15s;
353
+ border-bottom: 2px solid transparent;
354
+ position: relative;
355
+ }
356
+ mark.evidence-hl:hover { filter: brightness(1.3); }
357
+ mark.evidence-pulse {
358
+ animation: evidence-glow 0.6s ease-in-out 3;
359
+ border-radius: 4px;
360
+ }
361
+ @keyframes evidence-glow {
362
+ 0%, 100% { box-shadow: none; filter: brightness(1); }
363
+ 50% { box-shadow: 0 0 12px 4px rgba(251, 191, 36, 0.5); filter: brightness(1.5); }
364
+ }
365
+
366
+ /* Distortion highlights */
367
+ mark.dist-hl-0 { background: var(--hl-distortion-1); border-bottom-color: #fb923c; color: inherit; }
368
+ mark.dist-hl-1 { background: var(--hl-distortion-2); border-bottom-color: #f87171; color: inherit; }
369
+ mark.dist-hl-2 { background: var(--hl-distortion-3); border-bottom-color: #fbbf24; color: inherit; }
370
+
371
+ /* Empathy dimension highlights */
372
+ mark.emp-hl-0 { background: var(--hl-empathy-concern); border-bottom-color: #60a5fa; color: inherit; }
373
+ mark.emp-hl-1 { background: var(--hl-empathy-expressiveness); border-bottom-color: #a78bfa; color: inherit; }
374
+ mark.emp-hl-2 { background: var(--hl-empathy-resonate); border-bottom-color: #34d399; color: inherit; }
375
+ mark.emp-hl-3 { background: var(--hl-empathy-warmth); border-bottom-color: #fbbf24; color: inherit; }
376
+ mark.emp-hl-4 { background: var(--hl-empathy-attuned); border-bottom-color: #fb923c; color: inherit; }
377
+ mark.emp-hl-5 { background: var(--hl-empathy-cognitive); border-bottom-color: #f87171; color: inherit; }
378
+ mark.emp-hl-6 { background: var(--hl-empathy-feelings); border-bottom-color: #818cf8; color: inherit; }
379
+ mark.emp-hl-7 { background: var(--hl-empathy-acceptance); border-bottom-color: #2dd4bf; color: inherit; }
380
+ mark.emp-hl-8 { background: var(--hl-empathy-responsiveness); border-bottom-color: #e879f9; color: inherit; }
381
+
382
+ /* ===== Annotation Panel ===== */
383
+ .annotation-panel {
384
+ background: var(--bg-secondary);
385
+ border-left: 1px solid var(--border);
386
+ overflow-y: auto;
387
+ min-height: 0;
388
+ padding: 16px;
389
+ display: flex;
390
+ flex-direction: column;
391
+ gap: 14px;
392
+ }
393
+
394
+ .anno-card {
395
+ background: var(--bg-card);
396
+ border: 1px solid var(--border);
397
+ border-radius: var(--radius);
398
+ overflow: visible;
399
+ flex-shrink: 0;
400
+ transition: border-color 0.2s;
401
+ }
402
+ .anno-card:hover { border-color: var(--border-light); }
403
+ .anno-card-header {
404
+ padding: 13px 16px;
405
+ display: flex;
406
+ align-items: center;
407
+ gap: 10px;
408
+ border-bottom: 1px solid var(--border);
409
+ background: rgba(255,255,255,0.02);
410
+ }
411
+ .anno-card-header h3 {
412
+ font-size: 0.92rem;
413
+ font-weight: 600;
414
+ flex: 1;
415
+ }
416
+ .anno-card-icon {
417
+ width: 30px; height: 30px;
418
+ border-radius: var(--radius-sm);
419
+ display: flex; align-items: center; justify-content: center;
420
+ flex-shrink: 0;
421
+ }
422
+ .emotion-icon { background: var(--emotion-bg); color: var(--emotion-color); }
423
+ .distortion-icon { background: var(--distortion-bg); color: var(--distortion-color); }
424
+ .empathy-icon { background: var(--empathy-bg); color: var(--empathy-color); }
425
+
426
+ .anno-content { padding: 14px 16px; }
427
+
428
+ /* Emotion */
429
+ .emotion-primary {
430
+ display: flex; align-items: center; gap: 10px;
431
+ margin-bottom: 10px;
432
+ }
433
+ .emotion-badge {
434
+ padding: 6px 14px;
435
+ border-radius: 20px;
436
+ font-size: 0.85rem;
437
+ font-weight: 700;
438
+ background: var(--emotion-bg);
439
+ color: var(--emotion-color);
440
+ border: 1px solid rgba(167,139,250,0.3);
441
+ }
442
+ .intensity-badge {
443
+ padding: 4px 10px;
444
+ border-radius: var(--radius-sm);
445
+ font-size: 0.75rem;
446
+ font-weight: 600;
447
+ border: 1px solid;
448
+ }
449
+ .intensity-high { background: rgba(248,113,113,0.15); color: #f87171; border-color: rgba(248,113,113,0.3); }
450
+ .intensity-medium { background: rgba(251,191,36,0.15); color: #fbbf24; border-color: rgba(251,191,36,0.3); }
451
+ .intensity-low { background: rgba(52,211,153,0.15); color: #34d399; border-color: rgba(52,211,153,0.3); }
452
+ .emotion-reasoning {
453
+ font-size: 0.83rem;
454
+ color: var(--text-secondary);
455
+ line-height: 1.7;
456
+ padding: 10px 12px;
457
+ background: rgba(255,255,255,0.03);
458
+ border-radius: var(--radius-sm);
459
+ border-left: 3px solid var(--emotion-color);
460
+ }
461
+ .specific-term {
462
+ font-size: 0.78rem;
463
+ color: var(--text-muted);
464
+ margin-bottom: 8px;
465
+ }
466
+ .specific-term span { color: var(--emotion-color); font-weight: 600; }
467
+
468
+ /* Distortions */
469
+ .distortion-item {
470
+ padding: 12px 14px;
471
+ border-radius: var(--radius-sm);
472
+ border: 1px solid var(--border);
473
+ background: rgba(255,255,255,0.02);
474
+ margin-bottom: 10px;
475
+ cursor: pointer;
476
+ transition: all 0.15s;
477
+ position: relative;
478
+ }
479
+ .distortion-item:last-child { margin-bottom: 0; }
480
+ .distortion-item:hover { border-color: var(--distortion-color); background: var(--distortion-bg); }
481
+ .distortion-item.active { border-color: var(--distortion-color); background: var(--distortion-bg); }
482
+ .distortion-name {
483
+ font-size: 0.85rem;
484
+ font-weight: 700;
485
+ color: var(--distortion-color);
486
+ margin-bottom: 7px;
487
+ display: flex; align-items: center; gap: 8px;
488
+ }
489
+ .dist-color-dot {
490
+ width: 9px; height: 9px; border-radius: 2px; flex-shrink: 0;
491
+ }
492
+ .distortion-quote {
493
+ font-size: 0.78rem;
494
+ color: var(--text-secondary);
495
+ font-style: italic;
496
+ margin-bottom: 7px;
497
+ padding: 6px 10px;
498
+ background: rgba(255,255,255,0.04);
499
+ border-radius: 4px;
500
+ border-left: 3px solid;
501
+ font-family: 'JetBrains Mono', monospace;
502
+ line-height: 1.5;
503
+ }
504
+ .distortion-reasoning {
505
+ font-size: 0.78rem;
506
+ color: var(--text-muted);
507
+ line-height: 1.6;
508
+ }
509
+ .no-distortions {
510
+ text-align: center;
511
+ padding: 20px;
512
+ color: var(--text-muted);
513
+ font-size: 0.85rem;
514
+ }
515
+ .no-distortions svg { margin: 0 auto 6px; display: block; opacity: 0.4; }
516
+
517
+ /* Empathy */
518
+ .overall-score {
519
+ margin-left: auto;
520
+ padding: 3px 10px;
521
+ background: var(--empathy-bg);
522
+ color: var(--empathy-color);
523
+ border-radius: 20px;
524
+ font-size: 0.75rem;
525
+ font-weight: 700;
526
+ border: 1px solid rgba(52,211,153,0.3);
527
+ }
528
+ .empathy-item {
529
+ padding: 12px 14px;
530
+ border-radius: var(--radius-sm);
531
+ border: 1px solid var(--border);
532
+ background: rgba(255,255,255,0.02);
533
+ margin-bottom: 8px;
534
+ cursor: pointer;
535
+ transition: all 0.15s;
536
+ }
537
+ .empathy-item:last-child { margin-bottom: 0; }
538
+ .empathy-item:hover { border-color: rgba(52,211,153,0.4); background: rgba(52,211,153,0.06); }
539
+ .empathy-item.active { border-color: rgba(52,211,153,0.5); background: rgba(52,211,153,0.08); }
540
+ .empathy-item-header {
541
+ display: flex; align-items: center; gap: 10px;
542
+ margin-bottom: 8px;
543
+ }
544
+ .emp-color-dot {
545
+ width: 9px; height: 9px; border-radius: 2px; flex-shrink: 0;
546
+ }
547
+ .empathy-name {
548
+ font-size: 0.82rem;
549
+ font-weight: 600;
550
+ flex: 1;
551
+ color: var(--text-primary);
552
+ }
553
+ .score-bar-container {
554
+ display: flex; align-items: center; gap: 10px;
555
+ flex-shrink: 0;
556
+ }
557
+ .score-bar {
558
+ width: 80px; height: 6px;
559
+ background: var(--border);
560
+ border-radius: 3px;
561
+ overflow: hidden;
562
+ }
563
+ .score-bar-fill {
564
+ height: 100%;
565
+ border-radius: 3px;
566
+ transition: width 0.4s ease;
567
+ }
568
+ .score-value {
569
+ font-size: 0.78rem;
570
+ font-weight: 700;
571
+ width: 28px;
572
+ text-align: right;
573
+ font-variant-numeric: tabular-nums;
574
+ }
575
+ .empathy-quote {
576
+ font-size: 0.76rem;
577
+ color: var(--text-secondary);
578
+ font-style: italic;
579
+ padding: 5px 10px;
580
+ background: rgba(255,255,255,0.03);
581
+ border-radius: 4px;
582
+ border-left: 3px solid;
583
+ font-family: 'JetBrains Mono', monospace;
584
+ margin-bottom: 6px;
585
+ line-height: 1.5;
586
+ white-space: normal;
587
+ word-break: break-word;
588
+ }
589
+ .empathy-reasoning {
590
+ font-size: 0.78rem;
591
+ color: var(--text-muted);
592
+ line-height: 1.6;
593
+ display: none;
594
+ }
595
+ .empathy-item.active .empathy-reasoning { display: block; }
596
+
597
+ /* ===== Tooltip ===== */
598
+ .tooltip {
599
+ position: fixed;
600
+ z-index: 999;
601
+ pointer-events: none;
602
+ opacity: 0;
603
+ transition: opacity 0.15s;
604
+ max-width: 320px;
605
+ }
606
+ .tooltip.visible { opacity: 1; pointer-events: auto; }
607
+ .tooltip-content {
608
+ background: #1c2128;
609
+ border: 1px solid var(--border);
610
+ border-radius: var(--radius);
611
+ padding: 12px 14px;
612
+ box-shadow: 0 8px 32px rgba(0,0,0,0.5);
613
+ font-size: 0.78rem;
614
+ line-height: 1.6;
615
+ }
616
+ .tooltip-title {
617
+ font-weight: 700;
618
+ margin-bottom: 6px;
619
+ font-size: 0.82rem;
620
+ }
621
+ .tooltip-label {
622
+ font-size: 0.65rem;
623
+ text-transform: uppercase;
624
+ letter-spacing: 0.08em;
625
+ color: var(--text-muted);
626
+ margin-bottom: 3px;
627
+ margin-top: 8px;
628
+ }
629
+ .tooltip-text { color: var(--text-secondary); }
630
+ .tooltip-score {
631
+ display: inline-flex; align-items: center; gap: 4px;
632
+ padding: 2px 8px;
633
+ border-radius: 10px;
634
+ font-weight: 700;
635
+ font-size: 0.75rem;
636
+ }
637
+
638
+ /* ===== Nav Arrows ===== */
639
+ .nav-arrows {
640
+ position: fixed;
641
+ bottom: 24px;
642
+ right: 500px;
643
+ display: flex;
644
+ gap: 8px;
645
+ z-index: 200;
646
+ }
647
+ .nav-btn {
648
+ width: 40px; height: 40px;
649
+ background: var(--bg-card);
650
+ border: 1px solid var(--border);
651
+ border-radius: 50%;
652
+ display: flex; align-items: center; justify-content: center;
653
+ cursor: pointer;
654
+ color: var(--text-secondary);
655
+ transition: all 0.2s;
656
+ }
657
+ .nav-btn:hover { background: var(--bg-hover); border-color: var(--accent); color: var(--accent); }
658
+ .nav-btn:disabled { opacity: 0.3; cursor: not-allowed; }
659
+
660
+ /* ===== Emotion colors by type ===== */
661
+ .emotion-fear { background: rgba(248,113,113,0.15); color: #f87171; border-color: rgba(248,113,113,0.3); }
662
+ .emotion-sadness { background: rgba(96,165,250,0.15); color: #60a5fa; border-color: rgba(96,165,250,0.3); }
663
+ .emotion-anger { background: rgba(251,146,60,0.15); color: #fb923c; border-color: rgba(251,146,60,0.3); }
664
+ .emotion-joy { background: rgba(251,191,36,0.15); color: #fbbf24; border-color: rgba(251,191,36,0.3); }
665
+ .emotion-anxiety { background: rgba(167,139,250,0.15); color: #a78bfa; border-color: rgba(167,139,250,0.3); }
666
+ .emotion-shame { background: rgba(232,121,249,0.15); color: #e879f9; border-color: rgba(232,121,249,0.3); }
667
+ .emotion-depression { background: rgba(99,102,241,0.15); color: #818cf8; border-color: rgba(99,102,241,0.3); }
668
+ .emotion-default { background: var(--emotion-bg); color: var(--emotion-color); border: 1px solid rgba(167,139,250,0.3); }
669
+
670
+ /* Problem type badge */
671
+ .problem-badge {
672
+ padding: 3px 8px;
673
+ background: rgba(255,255,255,0.05);
674
+ border: 1px solid var(--border);
675
+ border-radius: var(--radius-sm);
676
+ font-size: 0.7rem;
677
+ color: var(--text-muted);
678
+ }
679
+ .experience-badge {
680
+ padding: 3px 8px;
681
+ background: rgba(88,166,255,0.08);
682
+ border: 1px solid rgba(88,166,255,0.2);
683
+ border-radius: var(--radius-sm);
684
+ font-size: 0.7rem;
685
+ color: var(--accent);
686
+ }
style.css ADDED
@@ -0,0 +1,685 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ===== CSS Variables ===== */
2
+ :root {
3
+ --bg-primary: #0d1117;
4
+ --bg-secondary: #161b22;
5
+ --bg-card: #1c2128;
6
+ --bg-hover: #21262d;
7
+ --border: #30363d;
8
+ --border-light: #21262d;
9
+ --text-primary: #e6edf3;
10
+ --text-secondary: #8b949e;
11
+ --text-muted: #6e7681;
12
+ --accent: #58a6ff;
13
+ --accent-hover: #79c0ff;
14
+
15
+ --emotion-color: #a78bfa;
16
+ --emotion-bg: rgba(167, 139, 250, 0.12);
17
+ --distortion-color: #fb923c;
18
+ --distortion-bg: rgba(251, 146, 60, 0.12);
19
+ --empathy-color: #34d399;
20
+ --empathy-bg: rgba(52, 211, 153, 0.12);
21
+ --highlight-color: #fbbf24;
22
+
23
+ --speaker-bg: #1a2332;
24
+ --speaker-border: #1d4ed8;
25
+ --listener-bg: #1a2b1f;
26
+ --listener-border: #16a34a;
27
+
28
+ /* Highlight colors for evidence quotes */
29
+ --hl-distortion-1: rgba(251, 146, 60, 0.25);
30
+ --hl-distortion-2: rgba(248, 113, 113, 0.25);
31
+ --hl-distortion-3: rgba(251, 191, 36, 0.25);
32
+
33
+ --hl-empathy-concern: rgba(96, 165, 250, 0.22);
34
+ --hl-empathy-expressiveness: rgba(167, 139, 250, 0.22);
35
+ --hl-empathy-resonate: rgba(52, 211, 153, 0.22);
36
+ --hl-empathy-warmth: rgba(251, 191, 36, 0.22);
37
+ --hl-empathy-attuned: rgba(251, 146, 60, 0.22);
38
+ --hl-empathy-cognitive: rgba(248, 113, 113, 0.22);
39
+ --hl-empathy-feelings: rgba(129, 140, 248, 0.22);
40
+ --hl-empathy-acceptance: rgba(45, 212, 191, 0.22);
41
+ --hl-empathy-responsiveness: rgba(232, 121, 249, 0.22);
42
+
43
+ --radius-sm: 6px;
44
+ --radius: 10px;
45
+ --radius-lg: 14px;
46
+ --shadow: 0 4px 16px rgba(0,0,0,0.4);
47
+ --shadow-card: 0 2px 8px rgba(0,0,0,0.3);
48
+
49
+ --header-h: 60px;
50
+ }
51
+
52
+ /* ===== Reset & Base ===== */
53
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
54
+ html { font-size: 14px; scroll-behavior: smooth; }
55
+ body {
56
+ font-family: 'Inter', system-ui, sans-serif;
57
+ background: var(--bg-primary);
58
+ color: var(--text-primary);
59
+ min-height: 100vh;
60
+ overflow: hidden;
61
+ }
62
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
63
+ ::-webkit-scrollbar-track { background: transparent; }
64
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
65
+ ::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
66
+
67
+ /* ===== Header ===== */
68
+ .header {
69
+ position: fixed;
70
+ top: 0; left: 0; right: 0;
71
+ height: var(--header-h);
72
+ background: rgba(13, 17, 23, 0.92);
73
+ border-bottom: 1px solid var(--border);
74
+ backdrop-filter: blur(12px);
75
+ z-index: 100;
76
+ }
77
+ .header-inner {
78
+ max-width: 100%;
79
+ height: 100%;
80
+ padding: 0 20px;
81
+ display: flex;
82
+ align-items: center;
83
+ justify-content: space-between;
84
+ gap: 16px;
85
+ }
86
+ .header-logo {
87
+ display: flex;
88
+ align-items: center;
89
+ gap: 10px;
90
+ }
91
+ .logo-icon {
92
+ width: 36px; height: 36px;
93
+ background: linear-gradient(135deg, var(--accent), var(--empathy-color));
94
+ border-radius: var(--radius-sm);
95
+ display: flex; align-items: center; justify-content: center;
96
+ color: #fff;
97
+ flex-shrink: 0;
98
+ }
99
+ .header-logo h1 { font-size: 1rem; font-weight: 600; color: var(--text-primary); }
100
+ .header-logo p { font-size: 0.75rem; color: var(--text-muted); }
101
+ .header-controls { display: flex; align-items: center; gap: 12px; }
102
+
103
+ .file-upload-area {
104
+ display: flex; align-items: center; gap: 8px;
105
+ padding: 7px 14px;
106
+ background: var(--bg-card);
107
+ border: 1px solid var(--border);
108
+ border-radius: var(--radius-sm);
109
+ cursor: pointer;
110
+ font-size: 0.85rem;
111
+ color: var(--text-secondary);
112
+ transition: all 0.2s;
113
+ position: relative;
114
+ overflow: hidden;
115
+ }
116
+ .file-upload-area:hover { border-color: var(--accent); color: var(--accent); background: var(--bg-hover); }
117
+ .file-upload-area input[type="file"] {
118
+ position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%;
119
+ }
120
+ .sample-counter {
121
+ padding: 6px 14px;
122
+ background: var(--bg-card);
123
+ border: 1px solid var(--border);
124
+ border-radius: var(--radius-sm);
125
+ font-size: 0.85rem;
126
+ color: var(--text-secondary);
127
+ font-variant-numeric: tabular-nums;
128
+ }
129
+ .sample-counter #currentIdx { color: var(--accent); font-weight: 600; }
130
+
131
+ /* ===== Welcome Screen ===== */
132
+ .welcome-screen {
133
+ position: fixed;
134
+ inset: 0;
135
+ display: flex;
136
+ align-items: center;
137
+ justify-content: center;
138
+ background: var(--bg-primary);
139
+ z-index: 50;
140
+ padding: 20px;
141
+ }
142
+ .welcome-card {
143
+ max-width: 480px;
144
+ width: 100%;
145
+ background: var(--bg-card);
146
+ border: 1px solid var(--border);
147
+ border-radius: var(--radius-lg);
148
+ padding: 40px;
149
+ text-align: center;
150
+ box-shadow: var(--shadow);
151
+ }
152
+ .welcome-icon {
153
+ width: 80px; height: 80px;
154
+ background: linear-gradient(135deg, rgba(88,166,255,0.15), rgba(52,211,153,0.15));
155
+ border: 1px solid var(--border);
156
+ border-radius: 20px;
157
+ display: flex; align-items: center; justify-content: center;
158
+ margin: 0 auto 24px;
159
+ color: var(--accent);
160
+ }
161
+ .welcome-card h2 { font-size: 1.3rem; font-weight: 700; margin-bottom: 10px; }
162
+ .welcome-card p { color: var(--text-secondary); line-height: 1.6; margin-bottom: 28px; }
163
+ .welcome-features {
164
+ display: flex; flex-direction: column; gap: 10px;
165
+ margin-bottom: 32px;
166
+ text-align: left;
167
+ }
168
+ .feature-item {
169
+ display: flex; align-items: center; gap: 10px;
170
+ font-size: 0.875rem; color: var(--text-secondary);
171
+ }
172
+ .feature-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
173
+ .welcome-upload-btn {
174
+ display: inline-flex; align-items: center; gap: 8px;
175
+ padding: 12px 28px;
176
+ background: linear-gradient(135deg, var(--accent), #388bfd);
177
+ color: #fff;
178
+ border-radius: var(--radius-sm);
179
+ font-weight: 600;
180
+ cursor: pointer;
181
+ transition: opacity 0.2s, transform 0.2s;
182
+ font-size: 0.9rem;
183
+ }
184
+ .welcome-upload-btn:hover { opacity: 0.9; transform: translateY(-1px); }
185
+
186
+ /* ===== Main Layout ===== */
187
+ .main-layout {
188
+ position: fixed;
189
+ inset: 0;
190
+ top: var(--header-h);
191
+ display: grid;
192
+ grid-template-columns: 220px 1fr 340px;
193
+ overflow: hidden;
194
+ }
195
+
196
+ /* ===== Nav Panel ===== */
197
+ .nav-panel {
198
+ background: var(--bg-secondary);
199
+ border-right: 1px solid var(--border);
200
+ display: flex;
201
+ flex-direction: column;
202
+ overflow: hidden;
203
+ }
204
+ .nav-panel-header {
205
+ padding: 14px;
206
+ border-bottom: 1px solid var(--border);
207
+ flex-shrink: 0;
208
+ }
209
+ .nav-panel-header h2 {
210
+ font-size: 0.8rem;
211
+ font-weight: 600;
212
+ color: var(--text-muted);
213
+ text-transform: uppercase;
214
+ letter-spacing: 0.08em;
215
+ margin-bottom: 10px;
216
+ }
217
+ .search-box {
218
+ display: flex; align-items: center; gap: 6px;
219
+ background: var(--bg-card);
220
+ border: 1px solid var(--border);
221
+ border-radius: var(--radius-sm);
222
+ padding: 6px 10px;
223
+ }
224
+ .search-box svg { color: var(--text-muted); flex-shrink: 0; }
225
+ .search-box input {
226
+ background: transparent; border: none; outline: none;
227
+ color: var(--text-primary); font-size: 0.8rem; width: 100%;
228
+ }
229
+ .search-box input::placeholder { color: var(--text-muted); }
230
+
231
+ .sample-list {
232
+ flex: 1;
233
+ overflow-y: auto;
234
+ padding: 6px;
235
+ display: flex;
236
+ flex-direction: column;
237
+ gap: 2px;
238
+ }
239
+ .sample-item {
240
+ padding: 8px 10px;
241
+ border-radius: var(--radius-sm);
242
+ cursor: pointer;
243
+ transition: background 0.15s;
244
+ border: 1px solid transparent;
245
+ }
246
+ .sample-item:hover { background: var(--bg-hover); }
247
+ .sample-item.active {
248
+ background: rgba(88,166,255,0.1);
249
+ border-color: rgba(88,166,255,0.3);
250
+ }
251
+ .sample-item-idx {
252
+ font-size: 0.65rem;
253
+ color: var(--text-muted);
254
+ font-variant-numeric: tabular-nums;
255
+ margin-bottom: 2px;
256
+ }
257
+ .sample-item-emotion {
258
+ font-size: 0.75rem;
259
+ font-weight: 600;
260
+ margin-bottom: 2px;
261
+ }
262
+ .sample-item-problem {
263
+ font-size: 0.7rem;
264
+ color: var(--text-muted);
265
+ white-space: nowrap;
266
+ overflow: hidden;
267
+ text-overflow: ellipsis;
268
+ }
269
+
270
+ /* ===== Conversation Panel ===== */
271
+ .conversation-panel {
272
+ display: flex;
273
+ flex-direction: column;
274
+ overflow: hidden;
275
+ background: var(--bg-primary);
276
+ }
277
+ .conv-header {
278
+ padding: 12px 20px;
279
+ border-bottom: 1px solid var(--border);
280
+ display: flex;
281
+ align-items: center;
282
+ justify-content: space-between;
283
+ flex-shrink: 0;
284
+ background: var(--bg-secondary);
285
+ }
286
+ .conv-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
287
+ .meta-badge {
288
+ padding: 3px 10px;
289
+ border-radius: 20px;
290
+ font-size: 0.72rem;
291
+ font-weight: 600;
292
+ letter-spacing: 0.03em;
293
+ }
294
+ .legend { display: flex; align-items: center; gap: 14px; }
295
+ .legend-item {
296
+ display: flex; align-items: center; gap: 5px;
297
+ font-size: 0.75rem; color: var(--text-muted);
298
+ }
299
+ .legend-dot {
300
+ width: 8px; height: 8px; border-radius: 2px;
301
+ }
302
+ .legend-dot.speaker { background: var(--speaker-border); }
303
+ .legend-dot.listener { background: var(--listener-border); }
304
+
305
+ .conversation-view {
306
+ flex: 1;
307
+ overflow-y: auto;
308
+ padding: 20px;
309
+ display: flex;
310
+ flex-direction: column;
311
+ gap: 8px;
312
+ }
313
+ .message-bubble {
314
+ max-width: 85%;
315
+ padding: 10px 14px;
316
+ border-radius: var(--radius);
317
+ border-left: 3px solid transparent;
318
+ font-size: 0.875rem;
319
+ line-height: 1.6;
320
+ position: relative;
321
+ transition: all 0.2s;
322
+ }
323
+ .message-bubble.speaker {
324
+ background: var(--speaker-bg);
325
+ border-color: var(--speaker-border);
326
+ align-self: flex-end;
327
+ border-radius: var(--radius) var(--radius) var(--radius-sm) var(--radius);
328
+ }
329
+ .message-bubble.listener {
330
+ background: var(--listener-bg);
331
+ border-color: var(--listener-border);
332
+ align-self: flex-start;
333
+ border-radius: var(--radius) var(--radius) var(--radius) var(--radius-sm);
334
+ }
335
+ .message-label {
336
+ font-size: 0.65rem;
337
+ font-weight: 700;
338
+ text-transform: uppercase;
339
+ letter-spacing: 0.08em;
340
+ margin-bottom: 4px;
341
+ opacity: 0.7;
342
+ }
343
+ .message-bubble.speaker .message-label { color: #60a5fa; }
344
+ .message-bubble.listener .message-label { color: #4ade80; }
345
+
346
+ /* Highlighted evidence text */
347
+ mark.evidence-hl {
348
+ background: transparent;
349
+ border-radius: 3px;
350
+ padding: 1px 0;
351
+ cursor: pointer;
352
+ transition: all 0.15s;
353
+ border-bottom: 2px solid transparent;
354
+ position: relative;
355
+ }
356
+ mark.evidence-hl:hover { filter: brightness(1.3); }
357
+ mark.evidence-pulse {
358
+ animation: evidence-glow 0.6s ease-in-out 3;
359
+ border-radius: 4px;
360
+ }
361
+ @keyframes evidence-glow {
362
+ 0%, 100% { box-shadow: none; filter: brightness(1); }
363
+ 50% { box-shadow: 0 0 12px 4px rgba(251, 191, 36, 0.5); filter: brightness(1.5); }
364
+ }
365
+
366
+ /* Distortion highlights */
367
+ mark.dist-hl-0 { background: var(--hl-distortion-1); border-bottom-color: #fb923c; color: inherit; }
368
+ mark.dist-hl-1 { background: var(--hl-distortion-2); border-bottom-color: #f87171; color: inherit; }
369
+ mark.dist-hl-2 { background: var(--hl-distortion-3); border-bottom-color: #fbbf24; color: inherit; }
370
+
371
+ /* Empathy dimension highlights */
372
+ mark.emp-hl-0 { background: var(--hl-empathy-concern); border-bottom-color: #60a5fa; color: inherit; }
373
+ mark.emp-hl-1 { background: var(--hl-empathy-expressiveness); border-bottom-color: #a78bfa; color: inherit; }
374
+ mark.emp-hl-2 { background: var(--hl-empathy-resonate); border-bottom-color: #34d399; color: inherit; }
375
+ mark.emp-hl-3 { background: var(--hl-empathy-warmth); border-bottom-color: #fbbf24; color: inherit; }
376
+ mark.emp-hl-4 { background: var(--hl-empathy-attuned); border-bottom-color: #fb923c; color: inherit; }
377
+ mark.emp-hl-5 { background: var(--hl-empathy-cognitive); border-bottom-color: #f87171; color: inherit; }
378
+ mark.emp-hl-6 { background: var(--hl-empathy-feelings); border-bottom-color: #818cf8; color: inherit; }
379
+ mark.emp-hl-7 { background: var(--hl-empathy-acceptance); border-bottom-color: #2dd4bf; color: inherit; }
380
+ mark.emp-hl-8 { background: var(--hl-empathy-responsiveness); border-bottom-color: #e879f9; color: inherit; }
381
+
382
+ /* ===== Annotation Panel ===== */
383
+ .annotation-panel {
384
+ background: var(--bg-secondary);
385
+ border-left: 1px solid var(--border);
386
+ overflow-y: auto;
387
+ min-height: 0;
388
+ padding: 14px;
389
+ display: flex;
390
+ flex-direction: column;
391
+ gap: 12px;
392
+ }
393
+
394
+ .anno-card {
395
+ background: var(--bg-card);
396
+ border: 1px solid var(--border);
397
+ border-radius: var(--radius);
398
+ overflow: visible;
399
+ flex-shrink: 0;
400
+ transition: border-color 0.2s;
401
+ }
402
+ .anno-card:hover { border-color: var(--border-light); }
403
+ .anno-card-header {
404
+ padding: 12px 14px;
405
+ display: flex;
406
+ align-items: center;
407
+ gap: 8px;
408
+ border-bottom: 1px solid var(--border);
409
+ background: rgba(255,255,255,0.02);
410
+ }
411
+ .anno-card-header h3 {
412
+ font-size: 0.85rem;
413
+ font-weight: 600;
414
+ flex: 1;
415
+ }
416
+ .anno-card-icon {
417
+ width: 28px; height: 28px;
418
+ border-radius: var(--radius-sm);
419
+ display: flex; align-items: center; justify-content: center;
420
+ flex-shrink: 0;
421
+ }
422
+ .emotion-icon { background: var(--emotion-bg); color: var(--emotion-color); }
423
+ .distortion-icon { background: var(--distortion-bg); color: var(--distortion-color); }
424
+ .empathy-icon { background: var(--empathy-bg); color: var(--empathy-color); }
425
+
426
+ .anno-content { padding: 12px 14px; }
427
+
428
+ /* Emotion */
429
+ .emotion-primary {
430
+ display: flex; align-items: center; gap: 10px;
431
+ margin-bottom: 10px;
432
+ }
433
+ .emotion-badge {
434
+ padding: 5px 12px;
435
+ border-radius: 20px;
436
+ font-size: 0.8rem;
437
+ font-weight: 700;
438
+ background: var(--emotion-bg);
439
+ color: var(--emotion-color);
440
+ border: 1px solid rgba(167,139,250,0.3);
441
+ }
442
+ .intensity-badge {
443
+ padding: 3px 8px;
444
+ border-radius: var(--radius-sm);
445
+ font-size: 0.7rem;
446
+ font-weight: 600;
447
+ border: 1px solid;
448
+ }
449
+ .intensity-high { background: rgba(248,113,113,0.15); color: #f87171; border-color: rgba(248,113,113,0.3); }
450
+ .intensity-medium { background: rgba(251,191,36,0.15); color: #fbbf24; border-color: rgba(251,191,36,0.3); }
451
+ .intensity-low { background: rgba(52,211,153,0.15); color: #34d399; border-color: rgba(52,211,153,0.3); }
452
+ .emotion-reasoning {
453
+ font-size: 0.78rem;
454
+ color: var(--text-secondary);
455
+ line-height: 1.6;
456
+ padding: 8px 10px;
457
+ background: rgba(255,255,255,0.03);
458
+ border-radius: var(--radius-sm);
459
+ border-left: 2px solid var(--emotion-color);
460
+ }
461
+ .specific-term {
462
+ font-size: 0.72rem;
463
+ color: var(--text-muted);
464
+ margin-bottom: 6px;
465
+ }
466
+ .specific-term span { color: var(--emotion-color); font-weight: 600; }
467
+
468
+ /* Distortions */
469
+ .distortion-item {
470
+ padding: 10px;
471
+ border-radius: var(--radius-sm);
472
+ border: 1px solid var(--border);
473
+ background: rgba(255,255,255,0.02);
474
+ margin-bottom: 8px;
475
+ cursor: pointer;
476
+ transition: all 0.15s;
477
+ position: relative;
478
+ }
479
+ .distortion-item:last-child { margin-bottom: 0; }
480
+ .distortion-item:hover { border-color: var(--distortion-color); background: var(--distortion-bg); }
481
+ .distortion-item.active { border-color: var(--distortion-color); background: var(--distortion-bg); }
482
+ .distortion-name {
483
+ font-size: 0.78rem;
484
+ font-weight: 700;
485
+ color: var(--distortion-color);
486
+ margin-bottom: 5px;
487
+ display: flex; align-items: center; gap: 6px;
488
+ }
489
+ .dist-color-dot {
490
+ width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0;
491
+ }
492
+ .distortion-quote {
493
+ font-size: 0.72rem;
494
+ color: var(--text-secondary);
495
+ font-style: italic;
496
+ margin-bottom: 5px;
497
+ padding: 4px 8px;
498
+ background: rgba(255,255,255,0.04);
499
+ border-radius: 4px;
500
+ border-left: 2px solid;
501
+ font-family: 'JetBrains Mono', monospace;
502
+ }
503
+ .distortion-reasoning {
504
+ font-size: 0.72rem;
505
+ color: var(--text-muted);
506
+ line-height: 1.5;
507
+ }
508
+ .no-distortions {
509
+ text-align: center;
510
+ padding: 16px;
511
+ color: var(--text-muted);
512
+ font-size: 0.8rem;
513
+ }
514
+ .no-distortions svg { margin: 0 auto 6px; display: block; opacity: 0.4; }
515
+
516
+ /* Empathy */
517
+ .overall-score {
518
+ margin-left: auto;
519
+ padding: 3px 10px;
520
+ background: var(--empathy-bg);
521
+ color: var(--empathy-color);
522
+ border-radius: 20px;
523
+ font-size: 0.75rem;
524
+ font-weight: 700;
525
+ border: 1px solid rgba(52,211,153,0.3);
526
+ }
527
+ .empathy-item {
528
+ padding: 10px;
529
+ border-radius: var(--radius-sm);
530
+ border: 1px solid var(--border);
531
+ background: rgba(255,255,255,0.02);
532
+ margin-bottom: 6px;
533
+ cursor: pointer;
534
+ transition: all 0.15s;
535
+ }
536
+ .empathy-item:last-child { margin-bottom: 0; }
537
+ .empathy-item:hover { border-color: rgba(52,211,153,0.4); background: rgba(52,211,153,0.06); }
538
+ .empathy-item.active { border-color: rgba(52,211,153,0.5); background: rgba(52,211,153,0.08); }
539
+ .empathy-item-header {
540
+ display: flex; align-items: center; gap: 8px;
541
+ margin-bottom: 6px;
542
+ }
543
+ .emp-color-dot {
544
+ width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0;
545
+ }
546
+ .empathy-name {
547
+ font-size: 0.75rem;
548
+ font-weight: 600;
549
+ flex: 1;
550
+ color: var(--text-primary);
551
+ }
552
+ .score-bar-container {
553
+ display: flex; align-items: center; gap: 8px;
554
+ flex-shrink: 0;
555
+ }
556
+ .score-bar {
557
+ width: 60px; height: 5px;
558
+ background: var(--border);
559
+ border-radius: 3px;
560
+ overflow: hidden;
561
+ }
562
+ .score-bar-fill {
563
+ height: 100%;
564
+ border-radius: 3px;
565
+ transition: width 0.4s ease;
566
+ }
567
+ .score-value {
568
+ font-size: 0.72rem;
569
+ font-weight: 700;
570
+ width: 24px;
571
+ text-align: right;
572
+ font-variant-numeric: tabular-nums;
573
+ }
574
+ .empathy-quote {
575
+ font-size: 0.7rem;
576
+ color: var(--text-muted);
577
+ font-style: italic;
578
+ padding: 3px 7px;
579
+ background: rgba(255,255,255,0.03);
580
+ border-radius: 3px;
581
+ border-left: 2px solid;
582
+ font-family: 'JetBrains Mono', monospace;
583
+ margin-bottom: 4px;
584
+ overflow: hidden;
585
+ text-overflow: ellipsis;
586
+ white-space: nowrap;
587
+ }
588
+ .empathy-reasoning {
589
+ font-size: 0.7rem;
590
+ color: var(--text-muted);
591
+ line-height: 1.5;
592
+ display: none;
593
+ }
594
+ .empathy-item.active .empathy-reasoning { display: block; }
595
+
596
+ /* ===== Tooltip ===== */
597
+ .tooltip {
598
+ position: fixed;
599
+ z-index: 999;
600
+ pointer-events: none;
601
+ opacity: 0;
602
+ transition: opacity 0.15s;
603
+ max-width: 320px;
604
+ }
605
+ .tooltip.visible { opacity: 1; pointer-events: auto; }
606
+ .tooltip-content {
607
+ background: #1c2128;
608
+ border: 1px solid var(--border);
609
+ border-radius: var(--radius);
610
+ padding: 12px 14px;
611
+ box-shadow: 0 8px 32px rgba(0,0,0,0.5);
612
+ font-size: 0.78rem;
613
+ line-height: 1.6;
614
+ }
615
+ .tooltip-title {
616
+ font-weight: 700;
617
+ margin-bottom: 6px;
618
+ font-size: 0.82rem;
619
+ }
620
+ .tooltip-label {
621
+ font-size: 0.65rem;
622
+ text-transform: uppercase;
623
+ letter-spacing: 0.08em;
624
+ color: var(--text-muted);
625
+ margin-bottom: 3px;
626
+ margin-top: 8px;
627
+ }
628
+ .tooltip-text { color: var(--text-secondary); }
629
+ .tooltip-score {
630
+ display: inline-flex; align-items: center; gap: 4px;
631
+ padding: 2px 8px;
632
+ border-radius: 10px;
633
+ font-weight: 700;
634
+ font-size: 0.75rem;
635
+ }
636
+
637
+ /* ===== Nav Arrows ===== */
638
+ .nav-arrows {
639
+ position: fixed;
640
+ bottom: 24px;
641
+ right: 360px;
642
+ display: flex;
643
+ gap: 8px;
644
+ z-index: 200;
645
+ }
646
+ .nav-btn {
647
+ width: 40px; height: 40px;
648
+ background: var(--bg-card);
649
+ border: 1px solid var(--border);
650
+ border-radius: 50%;
651
+ display: flex; align-items: center; justify-content: center;
652
+ cursor: pointer;
653
+ color: var(--text-secondary);
654
+ transition: all 0.2s;
655
+ }
656
+ .nav-btn:hover { background: var(--bg-hover); border-color: var(--accent); color: var(--accent); }
657
+ .nav-btn:disabled { opacity: 0.3; cursor: not-allowed; }
658
+
659
+ /* ===== Emotion colors by type ===== */
660
+ .emotion-fear { background: rgba(248,113,113,0.15); color: #f87171; border-color: rgba(248,113,113,0.3); }
661
+ .emotion-sadness { background: rgba(96,165,250,0.15); color: #60a5fa; border-color: rgba(96,165,250,0.3); }
662
+ .emotion-anger { background: rgba(251,146,60,0.15); color: #fb923c; border-color: rgba(251,146,60,0.3); }
663
+ .emotion-joy { background: rgba(251,191,36,0.15); color: #fbbf24; border-color: rgba(251,191,36,0.3); }
664
+ .emotion-anxiety { background: rgba(167,139,250,0.15); color: #a78bfa; border-color: rgba(167,139,250,0.3); }
665
+ .emotion-shame { background: rgba(232,121,249,0.15); color: #e879f9; border-color: rgba(232,121,249,0.3); }
666
+ .emotion-depression { background: rgba(99,102,241,0.15); color: #818cf8; border-color: rgba(99,102,241,0.3); }
667
+ .emotion-default { background: var(--emotion-bg); color: var(--emotion-color); border: 1px solid rgba(167,139,250,0.3); }
668
+
669
+ /* Problem type badge */
670
+ .problem-badge {
671
+ padding: 3px 8px;
672
+ background: rgba(255,255,255,0.05);
673
+ border: 1px solid var(--border);
674
+ border-radius: var(--radius-sm);
675
+ font-size: 0.7rem;
676
+ color: var(--text-muted);
677
+ }
678
+ .experience-badge {
679
+ padding: 3px 8px;
680
+ background: rgba(88,166,255,0.08);
681
+ border: 1px solid rgba(88,166,255,0.2);
682
+ border-radius: var(--radius-sm);
683
+ font-size: 0.7rem;
684
+ color: var(--accent);
685
+ }
templates/admin.html ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8"/>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
6
+ <title>Admin — ESConv Annotation</title>
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&display=swap" rel="stylesheet"/>
10
+ <link rel="stylesheet" href="/static/style.css"/>
11
+ <link rel="stylesheet" href="/static/admin.css"/>
12
+ </head>
13
+ <body>
14
+ <!-- Header -->
15
+ <header class="header">
16
+ <div class="header-inner">
17
+ <div class="header-logo">
18
+ <div class="logo-icon">
19
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
20
+ <path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
21
+ </svg>
22
+ </div>
23
+ <div>
24
+ <h1>Admin Dashboard</h1>
25
+ <p>Manage files, users & assignments</p>
26
+ </div>
27
+ </div>
28
+ <div class="header-controls">
29
+ <span class="user-badge" id="userBadge">admin</span>
30
+ <button class="logout-btn" id="logoutBtn">
31
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
32
+ <path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9"/>
33
+ </svg>
34
+ Logout
35
+ </button>
36
+ </div>
37
+ </div>
38
+ </header>
39
+
40
+ <main class="admin-main">
41
+ <!-- Tab Navigation -->
42
+ <div class="tab-nav">
43
+ <button class="tab-btn active" data-tab="files">📁 CSV Files</button>
44
+ <button class="tab-btn" data-tab="users">👥 Users</button>
45
+ <button class="tab-btn" data-tab="assignments">📋 Assignments</button>
46
+ </div>
47
+
48
+ <!-- Files Tab -->
49
+ <div class="tab-content active" id="tab-files">
50
+ <div class="section-header">
51
+ <h2>CSV Files</h2>
52
+ <p>Files in <code id="csvFolderPath"></code></p>
53
+ </div>
54
+ <div class="card-grid" id="filesList"></div>
55
+ </div>
56
+
57
+ <!-- Users Tab -->
58
+ <div class="tab-content" id="tab-users">
59
+ <div class="section-header">
60
+ <h2>Annotator Accounts</h2>
61
+ <button class="primary-btn" id="addUserBtn">
62
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
63
+ <path d="M12 5v14M5 12h14"/>
64
+ </svg>
65
+ Add User
66
+ </button>
67
+ </div>
68
+ <!-- Create User Form (hidden) -->
69
+ <div class="create-form" id="createUserForm" style="display:none">
70
+ <div class="form-row">
71
+ <input type="text" id="newUsername" placeholder="Username"/>
72
+ <input type="password" id="newPassword" placeholder="Password"/>
73
+ <select id="newRole"><option value="annotator">Annotator</option><option value="admin">Admin</option></select>
74
+ <button class="primary-btn" id="submitUserBtn">Create</button>
75
+ <button class="ghost-btn" id="cancelUserBtn">Cancel</button>
76
+ </div>
77
+ <div class="form-error" id="userFormError"></div>
78
+ </div>
79
+ <div class="table-container">
80
+ <table class="data-table" id="usersTable">
81
+ <thead><tr><th>ID</th><th>Username</th><th>Role</th><th>Created</th><th>Actions</th></tr></thead>
82
+ <tbody id="usersBody"></tbody>
83
+ </table>
84
+ </div>
85
+ </div>
86
+
87
+ <!-- Assignments Tab -->
88
+ <div class="tab-content" id="tab-assignments">
89
+ <div class="section-header">
90
+ <h2>File Assignments</h2>
91
+ <button class="primary-btn" id="addAssignBtn">
92
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
93
+ <path d="M12 5v14M5 12h14"/>
94
+ </svg>
95
+ Assign File
96
+ </button>
97
+ </div>
98
+ <!-- Assign Form (hidden) -->
99
+ <div class="create-form" id="createAssignForm" style="display:none">
100
+ <div class="form-row">
101
+ <select id="assignUser"><option value="">Select User...</option></select>
102
+ <select id="assignFile"><option value="">Select File...</option></select>
103
+ <button class="primary-btn" id="submitAssignBtn">Assign</button>
104
+ <button class="ghost-btn" id="cancelAssignBtn">Cancel</button>
105
+ </div>
106
+ <div class="form-error" id="assignFormError"></div>
107
+ </div>
108
+ <div class="table-container">
109
+ <table class="data-table" id="assignTable">
110
+ <thead><tr><th>Annotator</th><th>File</th><th>Progress</th><th>Assigned</th><th>Actions</th></tr></thead>
111
+ <tbody id="assignBody"></tbody>
112
+ </table>
113
+ </div>
114
+ </div>
115
+ </main>
116
+ <script src="/static/admin.js"></script>
117
+ </body>
118
+ </html>
templates/annotator.html ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8"/>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
6
+ <title>Annotator — ESConv Annotation</title>
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&display=swap" rel="stylesheet"/>
10
+ <link rel="stylesheet" href="/static/style.css"/>
11
+ <link rel="stylesheet" href="/static/admin.css"/>
12
+ </head>
13
+ <body>
14
+ <header class="header">
15
+ <div class="header-inner">
16
+ <div class="header-logo">
17
+ <div class="logo-icon">
18
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
19
+ <path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
20
+ </svg>
21
+ </div>
22
+ <div>
23
+ <h1>My Assignments</h1>
24
+ <p>Review LLM annotations</p>
25
+ </div>
26
+ </div>
27
+ <div class="header-controls">
28
+ <span class="user-badge" id="userBadge">annotator</span>
29
+ <button class="logout-btn" id="logoutBtn">
30
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
31
+ <path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9"/>
32
+ </svg>
33
+ Logout
34
+ </button>
35
+ </div>
36
+ </div>
37
+ </header>
38
+
39
+ <main class="admin-main">
40
+ <div class="section-header">
41
+ <h2>Assigned Files</h2>
42
+ </div>
43
+ <div class="card-grid" id="assignmentCards"></div>
44
+ <div class="empty-state" id="emptyState" style="display:none">
45
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="opacity:.3;margin-bottom:12px">
46
+ <path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
47
+ </svg>
48
+ <p>No files assigned to you yet.</p>
49
+ <p style="font-size:.8rem;color:var(--text-muted)">Contact admin to get files assigned.</p>
50
+ </div>
51
+ </main>
52
+
53
+ <script>
54
+ // Fetch user info
55
+ fetch('/api/me').then(r=>r.json()).then(u=>{
56
+ document.getElementById('userBadge').textContent = u.username;
57
+ });
58
+
59
+ // Logout
60
+ document.getElementById('logoutBtn').addEventListener('click', async()=>{
61
+ await fetch('/api/logout',{method:'POST'});
62
+ window.location.href = '/login';
63
+ });
64
+
65
+ // Load assignments
66
+ async function loadAssignments() {
67
+ const res = await fetch('/api/my-assignments');
68
+ const data = await res.json();
69
+ const grid = document.getElementById('assignmentCards');
70
+ const empty = document.getElementById('emptyState');
71
+
72
+ if (data.length === 0) { empty.style.display='block'; grid.innerHTML=''; return; }
73
+ empty.style.display='none';
74
+
75
+ grid.innerHTML = data.map(a => {
76
+ const pct = a.progress || 0;
77
+ const barColor = pct >= 100 ? '#34d399' : pct > 50 ? '#fbbf24' : '#60a5fa';
78
+ return `
79
+ <div class="file-card" onclick="window.location.href='/review/${a.id}'">
80
+ <div class="file-card-header">
81
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--accent)">
82
+ <path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/>
83
+ <polyline points="14 2 14 8 20 8"/>
84
+ </svg>
85
+ <span class="file-card-name">${a.filename}</span>
86
+ </div>
87
+ <div class="file-card-stats">
88
+ <span>${a.reviewed_count} / ${a.total_samples} reviewed</span>
89
+ <span style="color:${barColor};font-weight:700">${pct}%</span>
90
+ </div>
91
+ <div class="progress-bar">
92
+ <div class="progress-fill" style="width:${pct}%;background:${barColor}"></div>
93
+ </div>
94
+ ${pct >= 100 ? '<div class="completed-badge">✓ Completed</div>' : '<div class="continue-badge">Continue Review →</div>'}
95
+ </div>`;
96
+ }).join('');
97
+ }
98
+ loadAssignments();
99
+ </script>
100
+ </body>
101
+ </html>
templates/login.html ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8"/>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
6
+ <title>Login — ESConv Annotation</title>
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&display=swap" rel="stylesheet"/>
10
+ <link rel="stylesheet" href="/static/style.css"/>
11
+ <style>
12
+ body { display:flex; align-items:center; justify-content:center; min-height:100vh; overflow:auto; }
13
+ .login-card {
14
+ max-width:400px; width:100%;
15
+ background:var(--bg-card); border:1px solid var(--border); border-radius:var(--radius-lg);
16
+ padding:40px; box-shadow:var(--shadow);
17
+ }
18
+ .login-card h1 { text-align:center; font-size:1.5rem; margin-bottom:6px; }
19
+ .login-card p { text-align:center; color:var(--text-muted); font-size:.85rem; margin-bottom:28px; }
20
+ .login-icon {
21
+ width:64px; height:64px; margin:0 auto 20px;
22
+ background:linear-gradient(135deg,rgba(88,166,255,.15),rgba(52,211,153,.15));
23
+ border:1px solid var(--border); border-radius:16px;
24
+ display:flex; align-items:center; justify-content:center; color:var(--accent);
25
+ }
26
+ .form-group { margin-bottom:16px; }
27
+ .form-group label { display:block; font-size:.8rem; font-weight:600; color:var(--text-secondary); margin-bottom:6px; }
28
+ .form-group input {
29
+ width:100%; padding:10px 14px;
30
+ background:var(--bg-primary); border:1px solid var(--border); border-radius:var(--radius-sm);
31
+ color:var(--text-primary); font-size:.9rem; outline:none; transition:border-color .2s;
32
+ }
33
+ .form-group input:focus { border-color:var(--accent); }
34
+ .login-btn {
35
+ width:100%; padding:12px;
36
+ background:linear-gradient(135deg,var(--accent),#388bfd); color:#fff;
37
+ border:none; border-radius:var(--radius-sm); font-weight:600; font-size:.9rem;
38
+ cursor:pointer; transition:opacity .2s;
39
+ }
40
+ .login-btn:hover { opacity:.9; }
41
+ .login-error {
42
+ padding:8px 12px; background:rgba(248,113,113,.1); border:1px solid rgba(248,113,113,.3);
43
+ border-radius:var(--radius-sm); color:#f87171; font-size:.8rem; margin-bottom:14px;
44
+ display:none; text-align:center;
45
+ }
46
+ .login-error.show { display:block; }
47
+ </style>
48
+ </head>
49
+ <body>
50
+ <div class="login-card">
51
+ <div class="login-icon">
52
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
53
+ <path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
54
+ </svg>
55
+ </div>
56
+ <h1>ESConv Annotation</h1>
57
+ <p>Sign in to review annotations</p>
58
+ <div class="login-error" id="loginError"></div>
59
+ <form id="loginForm">
60
+ <div class="form-group">
61
+ <label for="username">Username</label>
62
+ <input type="text" id="username" name="username" autocomplete="username" autofocus required/>
63
+ </div>
64
+ <div class="form-group">
65
+ <label for="password">Password</label>
66
+ <input type="password" id="password" name="password" autocomplete="current-password" required/>
67
+ </div>
68
+ <button type="submit" class="login-btn">Sign In</button>
69
+ </form>
70
+ </div>
71
+ <script>
72
+ document.getElementById('loginForm').addEventListener('submit', async (e) => {
73
+ e.preventDefault();
74
+ const errEl = document.getElementById('loginError');
75
+ errEl.classList.remove('show');
76
+ const username = document.getElementById('username').value.trim();
77
+ const password = document.getElementById('password').value;
78
+ try {
79
+ const res = await fetch('/api/login', {
80
+ method:'POST', headers:{'Content-Type':'application/json'},
81
+ body: JSON.stringify({ username, password })
82
+ });
83
+ const data = await res.json();
84
+ if (res.ok) {
85
+ window.location.href = '/';
86
+ } else {
87
+ errEl.textContent = data.error || 'Login failed';
88
+ errEl.classList.add('show');
89
+ }
90
+ } catch(err) {
91
+ errEl.textContent = 'Network error';
92
+ errEl.classList.add('show');
93
+ }
94
+ });
95
+ </script>
96
+ </body>
97
+ </html>
templates/review.html ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8"/>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
6
+ <title>Review — ESConv Annotation</title>
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"/>
11
+ <link rel="stylesheet" href="/static/admin.css"/>
12
+ </head>
13
+ <body>
14
+ <!-- Header -->
15
+ <header class="header">
16
+ <div class="header-inner">
17
+ <div class="header-logo">
18
+ <a href="/" style="display:flex;align-items:center;gap:10px;text-decoration:none;color:inherit">
19
+ <div class="logo-icon">
20
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
21
+ <path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
22
+ </svg>
23
+ </div>
24
+ <div>
25
+ <h1 id="reviewTitle">ESConv Review</h1>
26
+ <p id="reviewSubtitle">Loading...</p>
27
+ </div>
28
+ </a>
29
+ </div>
30
+ <div class="header-controls">
31
+ <div class="sample-counter" id="sampleCounter">
32
+ <span id="currentIdx">0</span> / <span id="totalSamples">0</span>
33
+ </div>
34
+ <span class="user-badge" id="userBadge"></span>
35
+ <div class="export-dropdown" style="position:relative">
36
+ <button class="logout-btn" id="exportBtn" style="padding:6px 12px;font-size:.8rem;border-color:var(--empathy-color);color:var(--empathy-color)">
37
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
38
+ <path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/>
39
+ </svg>
40
+ Export
41
+ </button>
42
+ <div class="export-menu" id="exportMenu" style="display:none;position:absolute;top:100%;right:0;margin-top:6px;background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-sm);box-shadow:var(--shadow);z-index:200;min-width:160px;overflow:hidden">
43
+ <button class="export-option" id="exportJSON" style="display:flex;align-items:center;gap:8px;width:100%;padding:10px 14px;background:none;border:none;color:var(--text-primary);font-size:.82rem;cursor:pointer;font-family:inherit;text-align:left;transition:background .15s" onmouseover="this.style.background='var(--bg-hover)'" onmouseout="this.style.background='none'">📄 Export JSON</button>
44
+ <button class="export-option" id="exportCSV" style="display:flex;align-items:center;gap:8px;width:100%;padding:10px 14px;background:none;border:none;color:var(--text-primary);font-size:.82rem;cursor:pointer;font-family:inherit;text-align:left;border-top:1px solid var(--border);transition:background .15s" onmouseover="this.style.background='var(--bg-hover)'" onmouseout="this.style.background='none'">📊 Export CSV</button>
45
+ </div>
46
+ </div>
47
+ <button class="logout-btn" id="logoutBtn" style="padding:6px 12px;font-size:.8rem">
48
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
49
+ <path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4M16 17l5-5-5-5M21 12H9"/>
50
+ </svg>
51
+ Logout
52
+ </button>
53
+ </div>
54
+ </div>
55
+ </header>
56
+
57
+ <!-- Main Layout -->
58
+ <main class="main-layout" id="mainLayout">
59
+ <!-- Left: Navigation Panel -->
60
+ <aside class="nav-panel">
61
+ <div class="nav-panel-header">
62
+ <h2>Samples</h2>
63
+ <div class="search-box">
64
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
65
+ <circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>
66
+ </svg>
67
+ <input type="text" id="searchInput" placeholder="Filter..."/>
68
+ </div>
69
+ </div>
70
+ <div class="sample-list" id="sampleList"></div>
71
+ </aside>
72
+
73
+ <!-- Center: Conversation View -->
74
+ <section class="conversation-panel">
75
+ <div class="conv-header">
76
+ <div class="conv-meta" id="convMeta"></div>
77
+ <div class="legend">
78
+ <span class="legend-item"><span class="legend-dot speaker"></span>Speaker</span>
79
+ <span class="legend-item"><span class="legend-dot listener"></span>Listener</span>
80
+ </div>
81
+ </div>
82
+ <div class="selection-hint" id="selectionHint">✏️ Bôi đen đoạn text trong hội thoại để chọn evidence — nhấn <strong>Esc</strong> để huỷ</div>
83
+ <div class="conversation-view" id="conversationView"></div>
84
+ </section>
85
+
86
+ <!-- Right: Annotation + Review Panel -->
87
+ <aside class="annotation-panel">
88
+ <!-- Emotion Card -->
89
+ <div class="anno-card">
90
+ <div class="anno-card-header">
91
+ <div class="anno-card-icon emotion-icon">
92
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
93
+ <circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2M9 9h.01M15 9h.01"/>
94
+ </svg>
95
+ </div>
96
+ <h3>Emotion</h3>
97
+ <button class="edit-toggle-btn" id="editEmotionBtn" data-card="emotion">✏️ Edit</button>
98
+ </div>
99
+ <div id="emotionContent" class="anno-content"></div>
100
+ </div>
101
+ <!-- Distortions Card -->
102
+ <div class="anno-card">
103
+ <div class="anno-card-header">
104
+ <div class="anno-card-icon distortion-icon">
105
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
106
+ <path d="M12 9v4M12 17h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
107
+ </svg>
108
+ </div>
109
+ <h3>Distortions</h3>
110
+ <button class="edit-toggle-btn" id="editDistBtn" data-card="distortions">✏️ Edit</button>
111
+ </div>
112
+ <div id="distortionsContent" class="anno-content"></div>
113
+ </div>
114
+ <!-- Empathy Card -->
115
+ <div class="anno-card">
116
+ <div class="anno-card-header">
117
+ <div class="anno-card-icon empathy-icon">
118
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
119
+ <path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z"/>
120
+ </svg>
121
+ </div>
122
+ <h3>Empathy</h3>
123
+ <div class="overall-score" id="overallScore"></div>
124
+ <button class="edit-toggle-btn" id="editEmpBtn" data-card="empathy">✏️ Edit</button>
125
+ </div>
126
+ <div id="empathyContent" class="anno-content"></div>
127
+ </div>
128
+ <!-- Review Card -->
129
+ <div class="anno-card review-card">
130
+ <div class="anno-card-header">
131
+ <div class="anno-card-icon" style="background:rgba(88,166,255,.12);color:var(--accent)">
132
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
133
+ <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"/>
134
+ </svg>
135
+ </div>
136
+ <h3>Your Review</h3>
137
+ <div class="review-status-badge" id="reviewStatusBadge" style="display:none"></div>
138
+ </div>
139
+ <div class="anno-content">
140
+ <div class="review-buttons">
141
+ <button class="review-btn accept-btn" id="acceptBtn">✓ Accept</button>
142
+ <button class="review-btn reject-btn" id="rejectBtn">✗ Reject</button>
143
+ </div>
144
+ <textarea class="review-comment" id="reviewComment" placeholder="Add a comment (optional)..." rows="3"></textarea>
145
+ <button class="submit-review-btn" id="submitReviewBtn">Submit Review</button>
146
+ </div>
147
+ </div>
148
+ </aside>
149
+ </main>
150
+
151
+ <!-- Tooltip -->
152
+ <div class="tooltip" id="tooltip">
153
+ <div class="tooltip-content" id="tooltipContent"></div>
154
+ </div>
155
+
156
+ <!-- Nav Arrows -->
157
+ <div class="nav-arrows" id="navArrows">
158
+ <button class="nav-btn" id="prevBtn" title="Previous">
159
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg>
160
+ </button>
161
+ <button class="nav-btn" id="nextBtn" title="Next">
162
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>
163
+ </button>
164
+ </div>
165
+
166
+ <script src="/static/app.js"></script>
167
+ </body>
168
+ </html>