expressible 0.1.2 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -19
- package/package.json +12 -3
- package/dist/ui/static/static/index.html +0 -486
package/README.md
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
# Expressible CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Train small, local text classifiers from labeled examples. No API keys, no cloud, no Python, no GPU. Your data never leaves your machine.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Open source (Apache 2.0) · Built by [Expressible AI](https://expressible.ai)
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
## Distill
|
|
10
10
|
|
|
11
|
-
Train small, task-specific ML models from input/output examples. Runs entirely on your machine. Your data never leaves your environment.
|
|
12
|
-
|
|
13
11
|

|
|
14
12
|
|
|
15
13
|
```
|
|
@@ -136,6 +134,10 @@ Everything.
|
|
|
136
134
|
|
|
137
135
|
---
|
|
138
136
|
|
|
137
|
+
Works in environments where data can't leave the network — healthcare, financial services, legal, government, or any organization with data residency requirements.
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
139
141
|
### Use Cases
|
|
140
142
|
|
|
141
143
|
**Legal document review** — Classify contract clauses by type across thousands of agreements. Privileged documents stay within your perimeter.
|
|
@@ -160,25 +162,17 @@ With 50 labeled examples (~30 minutes of work), no API keys, and no ML expertise
|
|
|
160
162
|
| 20 Newsgroups (5 categories) | 80.0% | [Public dataset](https://huggingface.co/datasets/SetFit/20_newsgroups) |
|
|
161
163
|
| AG News (4 categories) | 64.0% | [Public dataset](https://huggingface.co/datasets/fancyzhx/ag_news) |
|
|
162
164
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
Public dataset results use real-world text from established ML benchmarks — 50 samples drawn from datasets containing 120,000+ entries. All samples and the test harness are included in the repo so you can reproduce these results:
|
|
165
|
+
Reproduce these results:
|
|
166
166
|
|
|
167
167
|
```bash
|
|
168
168
|
npx tsx tests/harness/run.ts
|
|
169
169
|
```
|
|
170
170
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
---
|
|
171
|
+
**Known limitation:** Distill struggles with sentiment and tone classification (44–50% accuracy). The embedding model captures what text is *about*, not how it *evaluates*. "Amazing camera" and "terrible camera" produce nearly identical vectors. Details in [benchmarks](docs/benchmarks.md).
|
|
174
172
|
|
|
175
|
-
|
|
173
|
+
AG News improves to 80% with 100 training samples. More data helps — see [benchmarks](docs/benchmarks.md) for scaling details. Public dataset results use real-world text from established ML benchmarks — 50 samples drawn from datasets containing 120,000+ entries.
|
|
176
174
|
|
|
177
|
-
-
|
|
178
|
-
- Financial services processing sensitive transactions
|
|
179
|
-
- Government contractors with data residency requirements
|
|
180
|
-
- Legal teams working with privileged documents
|
|
181
|
-
- Any organization with data sovereignty obligations
|
|
175
|
+
Accuracy improves as you add more examples through the review-retrain loop. Full results, methodology, and known limitations: **[docs/benchmarks.md](docs/benchmarks.md)**
|
|
182
176
|
|
|
183
177
|
---
|
|
184
178
|
|
|
@@ -248,9 +242,7 @@ my-task/
|
|
|
248
242
|
|
|
249
243
|
---
|
|
250
244
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
The CLI is the open-source, local-first layer of the Expressible platform. For teams that need governance, traceability, and managed deployment across AI-generated workloads, see [expressible.ai](https://expressible.ai).
|
|
245
|
+
The CLI is fully standalone and open source. [Expressible AI](https://expressible.ai) offers additional tooling for teams that need governance and managed deployment.
|
|
254
246
|
|
|
255
247
|
### Contributing
|
|
256
248
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "expressible",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Train small, local text classifiers from labeled examples. No API keys, no cloud, no GPU.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"bin": {
|
|
@@ -14,8 +14,17 @@
|
|
|
14
14
|
"test": "vitest run",
|
|
15
15
|
"test:watch": "vitest"
|
|
16
16
|
},
|
|
17
|
-
"keywords": ["ml", "local", "training", "cli", "inference"],
|
|
17
|
+
"keywords": ["ml", "local", "training", "cli", "inference", "classification", "nlp", "text-classification", "offline", "on-premise"],
|
|
18
18
|
"license": "Apache-2.0",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/expressibleai/expressible-cli.git"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://expressible.ai",
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/expressibleai/expressible-cli/issues"
|
|
26
|
+
},
|
|
27
|
+
"author": "Expressible AI, Inc.",
|
|
19
28
|
"dependencies": {
|
|
20
29
|
"@tensorflow/tfjs": "^4.22.0",
|
|
21
30
|
"@tensorflow/tfjs-node": "^4.22.0",
|
|
@@ -1,486 +0,0 @@
|
|
|
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>Distill — Review</title>
|
|
7
|
-
<style>
|
|
8
|
-
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
9
|
-
|
|
10
|
-
body {
|
|
11
|
-
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
12
|
-
background: #f5f5f5;
|
|
13
|
-
color: #333;
|
|
14
|
-
min-height: 100vh;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
.header {
|
|
18
|
-
background: #fff;
|
|
19
|
-
border-bottom: 1px solid #e0e0e0;
|
|
20
|
-
padding: 16px 24px;
|
|
21
|
-
display: flex;
|
|
22
|
-
align-items: center;
|
|
23
|
-
justify-content: space-between;
|
|
24
|
-
flex-wrap: wrap;
|
|
25
|
-
gap: 12px;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
.header h1 {
|
|
29
|
-
font-size: 20px;
|
|
30
|
-
font-weight: 600;
|
|
31
|
-
color: #111;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
.header h1 span {
|
|
35
|
-
color: #6366f1;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
.stats-bar {
|
|
39
|
-
display: flex;
|
|
40
|
-
gap: 20px;
|
|
41
|
-
font-size: 13px;
|
|
42
|
-
color: #666;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
.stats-bar .stat-value {
|
|
46
|
-
font-weight: 600;
|
|
47
|
-
color: #333;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
.progress-container {
|
|
51
|
-
background: #fff;
|
|
52
|
-
padding: 12px 24px;
|
|
53
|
-
border-bottom: 1px solid #e0e0e0;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
.progress-bar {
|
|
57
|
-
width: 100%;
|
|
58
|
-
height: 6px;
|
|
59
|
-
background: #e5e7eb;
|
|
60
|
-
border-radius: 3px;
|
|
61
|
-
overflow: hidden;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
.progress-fill {
|
|
65
|
-
height: 100%;
|
|
66
|
-
background: #6366f1;
|
|
67
|
-
border-radius: 3px;
|
|
68
|
-
transition: width 0.3s ease;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
.progress-text {
|
|
72
|
-
font-size: 12px;
|
|
73
|
-
color: #999;
|
|
74
|
-
margin-top: 4px;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
.main {
|
|
78
|
-
max-width: 1200px;
|
|
79
|
-
margin: 24px auto;
|
|
80
|
-
padding: 0 24px;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
.panels {
|
|
84
|
-
display: grid;
|
|
85
|
-
grid-template-columns: 1fr 1fr;
|
|
86
|
-
gap: 16px;
|
|
87
|
-
margin-bottom: 20px;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
@media (max-width: 768px) {
|
|
91
|
-
.panels { grid-template-columns: 1fr; }
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
.panel {
|
|
95
|
-
background: #fff;
|
|
96
|
-
border: 1px solid #e0e0e0;
|
|
97
|
-
border-radius: 8px;
|
|
98
|
-
overflow: hidden;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
.panel-header {
|
|
102
|
-
padding: 12px 16px;
|
|
103
|
-
background: #fafafa;
|
|
104
|
-
border-bottom: 1px solid #e0e0e0;
|
|
105
|
-
font-size: 13px;
|
|
106
|
-
font-weight: 600;
|
|
107
|
-
text-transform: uppercase;
|
|
108
|
-
letter-spacing: 0.5px;
|
|
109
|
-
color: #888;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
.panel-body {
|
|
113
|
-
padding: 16px;
|
|
114
|
-
font-size: 14px;
|
|
115
|
-
line-height: 1.6;
|
|
116
|
-
white-space: pre-wrap;
|
|
117
|
-
word-break: break-word;
|
|
118
|
-
min-height: 120px;
|
|
119
|
-
max-height: 400px;
|
|
120
|
-
overflow-y: auto;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
.actions {
|
|
124
|
-
background: #fff;
|
|
125
|
-
border: 1px solid #e0e0e0;
|
|
126
|
-
border-radius: 8px;
|
|
127
|
-
padding: 20px;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
.action-buttons {
|
|
131
|
-
display: flex;
|
|
132
|
-
gap: 12px;
|
|
133
|
-
justify-content: center;
|
|
134
|
-
margin-bottom: 16px;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
.btn {
|
|
138
|
-
padding: 10px 32px;
|
|
139
|
-
border: 2px solid transparent;
|
|
140
|
-
border-radius: 8px;
|
|
141
|
-
font-size: 15px;
|
|
142
|
-
font-weight: 600;
|
|
143
|
-
cursor: pointer;
|
|
144
|
-
transition: all 0.15s ease;
|
|
145
|
-
display: flex;
|
|
146
|
-
align-items: center;
|
|
147
|
-
gap: 8px;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
.btn:active { transform: scale(0.97); }
|
|
151
|
-
|
|
152
|
-
.btn-approve {
|
|
153
|
-
background: #ecfdf5;
|
|
154
|
-
color: #059669;
|
|
155
|
-
border-color: #a7f3d0;
|
|
156
|
-
}
|
|
157
|
-
.btn-approve:hover { background: #d1fae5; }
|
|
158
|
-
.btn-approve.active { background: #059669; color: #fff; }
|
|
159
|
-
|
|
160
|
-
.btn-reject {
|
|
161
|
-
background: #fef2f2;
|
|
162
|
-
color: #dc2626;
|
|
163
|
-
border-color: #fecaca;
|
|
164
|
-
}
|
|
165
|
-
.btn-reject:hover { background: #fee2e2; }
|
|
166
|
-
.btn-reject.active { background: #dc2626; color: #fff; }
|
|
167
|
-
|
|
168
|
-
.correction-area {
|
|
169
|
-
margin-top: 12px;
|
|
170
|
-
display: none;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
.correction-area.visible {
|
|
174
|
-
display: block;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
.correction-area label {
|
|
178
|
-
display: block;
|
|
179
|
-
font-size: 13px;
|
|
180
|
-
font-weight: 500;
|
|
181
|
-
color: #666;
|
|
182
|
-
margin-bottom: 6px;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
.correction-area textarea {
|
|
186
|
-
width: 100%;
|
|
187
|
-
min-height: 80px;
|
|
188
|
-
padding: 10px 12px;
|
|
189
|
-
border: 1px solid #d1d5db;
|
|
190
|
-
border-radius: 6px;
|
|
191
|
-
font-family: 'SF Mono', Monaco, Consolas, monospace;
|
|
192
|
-
font-size: 13px;
|
|
193
|
-
resize: vertical;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
.correction-area textarea:focus {
|
|
197
|
-
outline: none;
|
|
198
|
-
border-color: #6366f1;
|
|
199
|
-
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
.nav {
|
|
203
|
-
display: flex;
|
|
204
|
-
justify-content: space-between;
|
|
205
|
-
align-items: center;
|
|
206
|
-
margin-top: 16px;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
.nav-btn {
|
|
210
|
-
padding: 8px 20px;
|
|
211
|
-
border: 1px solid #d1d5db;
|
|
212
|
-
border-radius: 6px;
|
|
213
|
-
background: #fff;
|
|
214
|
-
font-size: 14px;
|
|
215
|
-
cursor: pointer;
|
|
216
|
-
color: #374151;
|
|
217
|
-
}
|
|
218
|
-
.nav-btn:hover { background: #f9fafb; }
|
|
219
|
-
.nav-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
220
|
-
|
|
221
|
-
.nav-info {
|
|
222
|
-
font-size: 14px;
|
|
223
|
-
color: #666;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
.done-btn {
|
|
227
|
-
padding: 8px 20px;
|
|
228
|
-
border: 1px solid #6366f1;
|
|
229
|
-
border-radius: 6px;
|
|
230
|
-
background: #6366f1;
|
|
231
|
-
color: #fff;
|
|
232
|
-
font-size: 14px;
|
|
233
|
-
font-weight: 500;
|
|
234
|
-
cursor: pointer;
|
|
235
|
-
}
|
|
236
|
-
.done-btn:hover { background: #4f46e5; }
|
|
237
|
-
|
|
238
|
-
.empty-state {
|
|
239
|
-
text-align: center;
|
|
240
|
-
padding: 60px 20px;
|
|
241
|
-
color: #999;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
.keyboard-hint {
|
|
245
|
-
text-align: center;
|
|
246
|
-
font-size: 12px;
|
|
247
|
-
color: #aaa;
|
|
248
|
-
margin-top: 10px;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
kbd {
|
|
252
|
-
padding: 2px 6px;
|
|
253
|
-
background: #f3f4f6;
|
|
254
|
-
border: 1px solid #d1d5db;
|
|
255
|
-
border-radius: 3px;
|
|
256
|
-
font-size: 11px;
|
|
257
|
-
font-family: inherit;
|
|
258
|
-
}
|
|
259
|
-
</style>
|
|
260
|
-
</head>
|
|
261
|
-
<body>
|
|
262
|
-
<div class="header">
|
|
263
|
-
<h1><span>distill</span> — Review</h1>
|
|
264
|
-
<div class="stats-bar" id="stats-bar">
|
|
265
|
-
<div>Reviewed: <span class="stat-value" id="stat-reviewed">0</span></div>
|
|
266
|
-
<div>Approved: <span class="stat-value" id="stat-approved">0</span></div>
|
|
267
|
-
<div>Rejected: <span class="stat-value" id="stat-rejected">0</span></div>
|
|
268
|
-
<div>Approval rate: <span class="stat-value" id="stat-rate">—</span></div>
|
|
269
|
-
</div>
|
|
270
|
-
</div>
|
|
271
|
-
|
|
272
|
-
<div class="progress-container">
|
|
273
|
-
<div class="progress-bar">
|
|
274
|
-
<div class="progress-fill" id="progress-fill" style="width: 0%"></div>
|
|
275
|
-
</div>
|
|
276
|
-
<div class="progress-text" id="progress-text">Loading...</div>
|
|
277
|
-
</div>
|
|
278
|
-
|
|
279
|
-
<div class="main">
|
|
280
|
-
<div id="content">
|
|
281
|
-
<div class="panels">
|
|
282
|
-
<div class="panel">
|
|
283
|
-
<div class="panel-header">Input</div>
|
|
284
|
-
<div class="panel-body" id="input-display"></div>
|
|
285
|
-
</div>
|
|
286
|
-
<div class="panel">
|
|
287
|
-
<div class="panel-header">Predicted Output</div>
|
|
288
|
-
<div class="panel-body" id="output-display"></div>
|
|
289
|
-
</div>
|
|
290
|
-
</div>
|
|
291
|
-
|
|
292
|
-
<div class="actions">
|
|
293
|
-
<div class="action-buttons">
|
|
294
|
-
<button class="btn btn-approve" id="btn-approve" onclick="score(true)">
|
|
295
|
-
👍 Approve
|
|
296
|
-
</button>
|
|
297
|
-
<button class="btn btn-reject" id="btn-reject" onclick="score(false)">
|
|
298
|
-
👎 Reject
|
|
299
|
-
</button>
|
|
300
|
-
</div>
|
|
301
|
-
|
|
302
|
-
<div class="correction-area" id="correction-area">
|
|
303
|
-
<label for="correction">What should the correct output be?</label>
|
|
304
|
-
<textarea id="correction" placeholder="Enter the correct output..."></textarea>
|
|
305
|
-
</div>
|
|
306
|
-
|
|
307
|
-
<div class="nav">
|
|
308
|
-
<button class="nav-btn" id="btn-prev" onclick="navigate(-1)">← Previous</button>
|
|
309
|
-
<span class="nav-info" id="nav-info">— / —</span>
|
|
310
|
-
<button class="nav-btn" id="btn-next" onclick="navigate(1)">Next →</button>
|
|
311
|
-
<button class="done-btn" onclick="finish()">Done</button>
|
|
312
|
-
</div>
|
|
313
|
-
|
|
314
|
-
<div class="keyboard-hint">
|
|
315
|
-
<kbd>→</kbd> Approve <kbd>←</kbd> Reject <kbd>Enter</kbd> Next
|
|
316
|
-
</div>
|
|
317
|
-
</div>
|
|
318
|
-
</div>
|
|
319
|
-
|
|
320
|
-
<div class="empty-state" id="empty-state" style="display:none">
|
|
321
|
-
<h2>All items reviewed!</h2>
|
|
322
|
-
<p>You've reviewed all items. Close this tab or click Done.</p>
|
|
323
|
-
</div>
|
|
324
|
-
</div>
|
|
325
|
-
|
|
326
|
-
<script>
|
|
327
|
-
let items = [];
|
|
328
|
-
let currentIndex = 0;
|
|
329
|
-
|
|
330
|
-
async function init() {
|
|
331
|
-
const res = await fetch('/api/items');
|
|
332
|
-
items = await res.json();
|
|
333
|
-
if (items.length === 0) {
|
|
334
|
-
document.getElementById('content').style.display = 'none';
|
|
335
|
-
document.getElementById('empty-state').style.display = 'block';
|
|
336
|
-
return;
|
|
337
|
-
}
|
|
338
|
-
// Start at first unreviewed item
|
|
339
|
-
const firstUnreviewed = items.findIndex(i => !i.reviewedAt);
|
|
340
|
-
currentIndex = firstUnreviewed >= 0 ? firstUnreviewed : 0;
|
|
341
|
-
render();
|
|
342
|
-
updateStats();
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
function render() {
|
|
346
|
-
const item = items[currentIndex];
|
|
347
|
-
if (!item) return;
|
|
348
|
-
|
|
349
|
-
document.getElementById('input-display').textContent = item.input;
|
|
350
|
-
document.getElementById('output-display').textContent = item.predictedOutput;
|
|
351
|
-
document.getElementById('nav-info').textContent =
|
|
352
|
-
`${currentIndex + 1} / ${items.length}`;
|
|
353
|
-
|
|
354
|
-
document.getElementById('btn-prev').disabled = currentIndex === 0;
|
|
355
|
-
document.getElementById('btn-next').disabled = currentIndex === items.length - 1;
|
|
356
|
-
|
|
357
|
-
// Show current state
|
|
358
|
-
const approveBtn = document.getElementById('btn-approve');
|
|
359
|
-
const rejectBtn = document.getElementById('btn-reject');
|
|
360
|
-
const correctionArea = document.getElementById('correction-area');
|
|
361
|
-
|
|
362
|
-
approveBtn.classList.toggle('active', item.reviewedAt && item.approved === true);
|
|
363
|
-
rejectBtn.classList.toggle('active', item.reviewedAt && item.approved === false);
|
|
364
|
-
|
|
365
|
-
if (item.reviewedAt && item.approved === false) {
|
|
366
|
-
correctionArea.classList.add('visible');
|
|
367
|
-
document.getElementById('correction').value = item.correctedOutput || '';
|
|
368
|
-
} else {
|
|
369
|
-
correctionArea.classList.remove('visible');
|
|
370
|
-
document.getElementById('correction').value = '';
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
async function score(approved) {
|
|
375
|
-
const item = items[currentIndex];
|
|
376
|
-
const correctedOutput = !approved
|
|
377
|
-
? document.getElementById('correction').value.trim() || undefined
|
|
378
|
-
: undefined;
|
|
379
|
-
|
|
380
|
-
await fetch('/api/score', {
|
|
381
|
-
method: 'POST',
|
|
382
|
-
headers: { 'Content-Type': 'application/json' },
|
|
383
|
-
body: JSON.stringify({
|
|
384
|
-
id: item.id,
|
|
385
|
-
approved,
|
|
386
|
-
correctedOutput,
|
|
387
|
-
}),
|
|
388
|
-
});
|
|
389
|
-
|
|
390
|
-
item.approved = approved;
|
|
391
|
-
item.reviewedAt = new Date().toISOString();
|
|
392
|
-
if (correctedOutput) item.correctedOutput = correctedOutput;
|
|
393
|
-
|
|
394
|
-
// Show/hide correction area
|
|
395
|
-
const correctionArea = document.getElementById('correction-area');
|
|
396
|
-
if (!approved) {
|
|
397
|
-
correctionArea.classList.add('visible');
|
|
398
|
-
document.getElementById('correction').focus();
|
|
399
|
-
} else {
|
|
400
|
-
correctionArea.classList.remove('visible');
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
render();
|
|
404
|
-
updateStats();
|
|
405
|
-
|
|
406
|
-
// Auto-advance on approve
|
|
407
|
-
if (approved && currentIndex < items.length - 1) {
|
|
408
|
-
setTimeout(() => navigate(1), 300);
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
function navigate(delta) {
|
|
413
|
-
const newIndex = currentIndex + delta;
|
|
414
|
-
if (newIndex >= 0 && newIndex < items.length) {
|
|
415
|
-
// Save any pending correction before navigating
|
|
416
|
-
const item = items[currentIndex];
|
|
417
|
-
if (item.reviewedAt && item.approved === false) {
|
|
418
|
-
const correction = document.getElementById('correction').value.trim();
|
|
419
|
-
if (correction && correction !== item.correctedOutput) {
|
|
420
|
-
fetch('/api/score', {
|
|
421
|
-
method: 'POST',
|
|
422
|
-
headers: { 'Content-Type': 'application/json' },
|
|
423
|
-
body: JSON.stringify({
|
|
424
|
-
id: item.id,
|
|
425
|
-
approved: false,
|
|
426
|
-
correctedOutput: correction,
|
|
427
|
-
}),
|
|
428
|
-
});
|
|
429
|
-
item.correctedOutput = correction;
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
currentIndex = newIndex;
|
|
433
|
-
render();
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
async function updateStats() {
|
|
438
|
-
const res = await fetch('/api/stats');
|
|
439
|
-
const stats = await res.json();
|
|
440
|
-
|
|
441
|
-
document.getElementById('stat-reviewed').textContent = stats.reviewed;
|
|
442
|
-
document.getElementById('stat-approved').textContent = stats.approved;
|
|
443
|
-
document.getElementById('stat-rejected').textContent = stats.rejected;
|
|
444
|
-
document.getElementById('stat-rate').textContent =
|
|
445
|
-
stats.reviewed > 0 ? stats.approvalRate + '%' : '—';
|
|
446
|
-
|
|
447
|
-
const pct = stats.total > 0 ? (stats.reviewed / stats.total) * 100 : 0;
|
|
448
|
-
document.getElementById('progress-fill').style.width = pct + '%';
|
|
449
|
-
document.getElementById('progress-text').textContent =
|
|
450
|
-
`${stats.reviewed} of ${stats.total} reviewed`;
|
|
451
|
-
|
|
452
|
-
if (stats.remaining === 0 && stats.total > 0) {
|
|
453
|
-
document.getElementById('content').style.display = 'none';
|
|
454
|
-
document.getElementById('empty-state').style.display = 'block';
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
async function finish() {
|
|
459
|
-
if (confirm('End review session?')) {
|
|
460
|
-
await fetch('/api/shutdown', { method: 'POST' });
|
|
461
|
-
document.body.innerHTML =
|
|
462
|
-
'<div style="display:flex;align-items:center;justify-content:center;height:100vh;color:#666;font-family:sans-serif">' +
|
|
463
|
-
'<div style="text-align:center"><h2>Review complete</h2><p>You can close this tab.</p></div></div>';
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
// Keyboard shortcuts
|
|
468
|
-
document.addEventListener('keydown', (e) => {
|
|
469
|
-
if (e.target.tagName === 'TEXTAREA') return;
|
|
470
|
-
|
|
471
|
-
if (e.key === 'ArrowRight') {
|
|
472
|
-
e.preventDefault();
|
|
473
|
-
score(true);
|
|
474
|
-
} else if (e.key === 'ArrowLeft') {
|
|
475
|
-
e.preventDefault();
|
|
476
|
-
score(false);
|
|
477
|
-
} else if (e.key === 'Enter') {
|
|
478
|
-
e.preventDefault();
|
|
479
|
-
navigate(1);
|
|
480
|
-
}
|
|
481
|
-
});
|
|
482
|
-
|
|
483
|
-
init();
|
|
484
|
-
</script>
|
|
485
|
-
</body>
|
|
486
|
-
</html>
|