Code-only deploy
Browse files- static/stats.html +30 -9
- stt_quality.py +22 -3
static/stats.html
CHANGED
|
@@ -285,19 +285,40 @@ function qualityPanel(q) {
|
|
| 285 |
if (!q?.visits) return '<h2>Transcription quality</h2><div class="panel"><p class="empty">No visit reports yet.</p></div>';
|
| 286 |
return `<h2>Transcription quality – All</h2>${qualityGroup(q, 'All visits')}
|
| 287 |
<h2>By operating system</h2>${q.by_os.map(g => qualityGroup(g, g.name)).join('')}
|
| 288 |
-
<h2>By visitor (anyone with a visit long enough to judge)</h2>${q.by_visitor.map(g => qualityGroup(g, `${g.name} · ${g.device}`)).join('')}`;
|
| 289 |
}
|
| 290 |
// Closed: verdict counts, then each problem's share of the group's /t time. Open: the group's full numbers.
|
| 291 |
-
|
|
|
|
|
|
|
|
|
|
| 292 |
const short = g.verdicts.too_short || 0, judged = `${g.visits - short} judged, ${short} under ${MIN_ACTIVE_S} s`;
|
| 293 |
const verdicts = Object.entries(g.verdicts).filter(([k]) => k !== 'too_short')
|
| 294 |
-
.map(([k, n]) => ` · ${
|
| 295 |
-
const shares = Object.entries(g.problem_secs).map(([c, s]) =>
|
| 296 |
-
`${c} ${g.total_s ? Math.round(100 * s / g.total_s) : 0}%`).join(' · ');
|
| 297 |
return `<details class="group"><summary><b>${esc(name)}</b> · ${plural(g.visits, 'visit')} (${judged})${verdicts}
|
| 298 |
-
<br><span class="sub">${
|
| 299 |
}
|
| 300 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
const v = q.verdicts, judged = q.visits - (v.too_short || 0);
|
| 302 |
const pct = n => judged ? ` (${Math.round(100 * (n || 0) / judged)}%)` : '';
|
| 303 |
const share = s => mins(s) + (q.total_s ? ` (${Math.round(100 * s / q.total_s)}%)` : '');
|
|
@@ -311,10 +332,10 @@ function qualityBody(q) {
|
|
| 311 |
];
|
| 312 |
// A chart with nothing in it is only named, on one line under the rest.
|
| 313 |
const empty = charts.filter(([, e]) => !e.some(([, n]) => n)).map(([title]) => title.split(' (')[0]);
|
| 314 |
-
return `<div class="panel">${verdictBar(v, 'big')}
|
| 315 |
<div class="legend verdicts">${JUDGED.map(k =>
|
| 316 |
`<span><i style="background:${VERDICT_COLOR[k]}"></i>${v[k] || 0} ${VERDICT[k]}${pct(v[k])}</span>`).join('')}</div>
|
| 317 |
-
<p class="sub">${v.too_short || 0} too short to judge · ${mins(q.missed_s)} speech missed (drops, lost audio)</p></div>
|
| 318 |
<div class="grid2">${charts.filter(([, e]) => e.some(([, n]) => n)).map(([title, e]) =>
|
| 319 |
`<div><h2>${title}</h2><div class="panel">${bars(e)}</div></div>`).join('')}</div>
|
| 320 |
${empty.length ? `<p class="sub" style="margin-top:12px">Nothing to show: ${empty.join(' · ')}.</p>` : ''}`;
|
|
|
|
| 285 |
if (!q?.visits) return '<h2>Transcription quality</h2><div class="panel"><p class="empty">No visit reports yet.</p></div>';
|
| 286 |
return `<h2>Transcription quality – All</h2>${qualityGroup(q, 'All visits')}
|
| 287 |
<h2>By operating system</h2>${q.by_os.map(g => qualityGroup(g, g.name)).join('')}
|
| 288 |
+
<h2>By visitor (anyone with a visit long enough to judge)</h2>${q.by_visitor.map(g => qualityGroup(g, `${g.name} · ${g.device}`, sessionList(g.sessions))).join('')}`;
|
| 289 |
}
|
| 290 |
// Closed: verdict counts, then each problem's share of the group's /t time. Open: the group's full numbers.
|
| 291 |
+
const shortVerdict = k => VERDICT[k]?.split(':')[0] || k;
|
| 292 |
+
const shareLine = g => Object.entries(g.problem_secs).map(([c, s]) =>
|
| 293 |
+
`${c} ${g.total_s ? Math.round(100 * s / g.total_s) : 0}%`).join(' · ') || 'no problem shown';
|
| 294 |
+
function qualityGroup(g, name, more = '') {
|
| 295 |
const short = g.verdicts.too_short || 0, judged = `${g.visits - short} judged, ${short} under ${MIN_ACTIVE_S} s`;
|
| 296 |
const verdicts = Object.entries(g.verdicts).filter(([k]) => k !== 'too_short')
|
| 297 |
+
.map(([k, n]) => ` · ${shortVerdict(k)} ${n}`).join('');
|
|
|
|
|
|
|
| 298 |
return `<details class="group"><summary><b>${esc(name)}</b> · ${plural(g.visits, 'visit')} (${judged})${verdicts}
|
| 299 |
+
<br><span class="sub">${shareLine(g)}</span>${verdictBar(g.verdicts, 'thin')}</summary>${more}${qualityBody(g)}</details>`;
|
| 300 |
}
|
| 301 |
+
// A visit's clock time in Prague, as much of the date as the period needs:
|
| 302 |
+
// "9:20–9:30" today, "Tue 9:20–9:30" over 7 days, "Tue 27.10. 9:20–9:30" beyond.
|
| 303 |
+
const PRAGUE = new Intl.DateTimeFormat('en-GB', {timeZone: 'Europe/Prague', weekday: 'short',
|
| 304 |
+
day: 'numeric', month: 'numeric', hour: 'numeric', minute: '2-digit', hourCycle: 'h23'});
|
| 305 |
+
function span(s) {
|
| 306 |
+
if (!s.start) return 'Time unknown';
|
| 307 |
+
const at = iso => Object.fromEntries(PRAGUE.formatToParts(new Date(iso)).map(p => [p.type, p.value]));
|
| 308 |
+
const a = at(s.start), b = at(s.end || s.start), hm = p => `${+p.hour}:${p.minute}`;
|
| 309 |
+
const day = days === '1' ? '' : `${a.weekday} ${days === '7' ? '' : `${+a.day}.${+a.month}. `}`;
|
| 310 |
+
return `${day}${hm(a)}–${hm(b)}`;
|
| 311 |
+
}
|
| 312 |
+
// First thing in an open visitor, right under its summary line.
|
| 313 |
+
const sessionList = list => list?.length ? `<h2 style="margin-top:12px">Sessions, newest first</h2>${list.map(sessionGroup).join('')}
|
| 314 |
+
<h2>All sessions of this visitor</h2>` : '';
|
| 315 |
+
// One visit: when, its verdict, its on-screen time. Open: its own numbers.
|
| 316 |
+
function sessionGroup(s) {
|
| 317 |
+
const [k] = Object.keys(s.verdicts);
|
| 318 |
+
return `<details class="group"><summary><b>${span(s)}</b> · <b style="color:${VERDICT_COLOR[k] || 'var(--muted)'}">${
|
| 319 |
+
esc(shortVerdict(k))}</b> · ${mins(s.total_s)}<br><span class="sub">${shareLine(s)}</span></summary>${qualityBody(s, true)}</details>`;
|
| 320 |
+
}
|
| 321 |
+
function qualityBody(q, single = false) {
|
| 322 |
const v = q.verdicts, judged = q.visits - (v.too_short || 0);
|
| 323 |
const pct = n => judged ? ` (${Math.round(100 * (n || 0) / judged)}%)` : '';
|
| 324 |
const share = s => mins(s) + (q.total_s ? ` (${Math.round(100 * s / q.total_s)}%)` : '');
|
|
|
|
| 332 |
];
|
| 333 |
// A chart with nothing in it is only named, on one line under the rest.
|
| 334 |
const empty = charts.filter(([, e]) => !e.some(([, n]) => n)).map(([title]) => title.split(' (')[0]);
|
| 335 |
+
return `${single ? `<p class="sub">${mins(q.missed_s)} speech missed (drops, lost audio)</p>` : `<div class="panel">${verdictBar(v, 'big')}
|
| 336 |
<div class="legend verdicts">${JUDGED.map(k =>
|
| 337 |
`<span><i style="background:${VERDICT_COLOR[k]}"></i>${v[k] || 0} ${VERDICT[k]}${pct(v[k])}</span>`).join('')}</div>
|
| 338 |
+
<p class="sub">${v.too_short || 0} too short to judge · ${mins(q.missed_s)} speech missed (drops, lost audio)</p></div>`}
|
| 339 |
<div class="grid2">${charts.filter(([, e]) => e.some(([, n]) => n)).map(([title, e]) =>
|
| 340 |
`<div><h2>${title}</h2><div class="panel">${bars(e)}</div></div>`).join('')}</div>
|
| 341 |
${empty.length ? `<p class="sub" style="margin-top:12px">Nothing to show: ${empty.join(' · ')}.</p>` : ''}`;
|
stt_quality.py
CHANGED
|
@@ -170,11 +170,27 @@ def summarize(visits: list[dict]) -> dict:
|
|
| 170 |
}
|
| 171 |
|
| 172 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
def breakdown(visits: list[dict], numbers: dict[str, int]) -> dict:
|
| 174 |
"""summarize for all visits, plus "by_os" and "by_visitor": the same per
|
| 175 |
group. OSes: most /t time first. Visitors: those with a judged (not
|
| 176 |
-
too_short) visit, most visits first
|
| 177 |
-
|
|
|
|
| 178 |
# Reports from before "vid" was logged borrow it from a later one of the same client.
|
| 179 |
vid_of = {v["client"]: v["vid"] for v in visits if v.get("vid")}
|
| 180 |
by_os: dict[str, list[dict]] = defaultdict(list)
|
|
@@ -186,7 +202,10 @@ def breakdown(visits: list[dict], numbers: dict[str, int]) -> dict:
|
|
| 186 |
oses = [{"name": name, **summarize(group)} for name, group in by_os.items()]
|
| 187 |
people = [{"name": f"Visitor {numbers[key]}" if key in numbers else f"id {key[:6]}",
|
| 188 |
"device": f"{group[-1].get('os', '')} {group[-1].get('browser', '')}".strip(),
|
| 189 |
-
**summarize(group)
|
|
|
|
|
|
|
|
|
|
| 190 |
oses.sort(key=lambda s: -s["total_s"])
|
| 191 |
people = sorted((p for p in people if p["visits"] > p["verdicts"].get("too_short", 0)),
|
| 192 |
key=lambda s: (-s["visits"], -s["total_s"]))
|
|
|
|
| 170 |
}
|
| 171 |
|
| 172 |
|
| 173 |
+
def times(visit: dict) -> tuple[str, str]:
|
| 174 |
+
"""(start, end) of a visit, ISO UTC, "" when unknown. The page ends the sid
|
| 175 |
+
with its start time (Date.now() in base 36); the end is the last report."""
|
| 176 |
+
end = visit.get("ts", "")
|
| 177 |
+
try:
|
| 178 |
+
start = datetime.fromtimestamp(int(visit["sid"][-8:], 36) / 1000, timezone.utc)
|
| 179 |
+
except (KeyError, ValueError, OverflowError, OSError):
|
| 180 |
+
return "", end
|
| 181 |
+
if start.year < 2025: # not a time: a sid from before it carried one
|
| 182 |
+
return "", end
|
| 183 |
+
start_iso = start.isoformat(timespec="seconds")
|
| 184 |
+
# the page's clock, not ours: never after the report that closed the visit
|
| 185 |
+
return (min(start_iso, end) if end else start_iso), end
|
| 186 |
+
|
| 187 |
+
|
| 188 |
def breakdown(visits: list[dict], numbers: dict[str, int]) -> dict:
|
| 189 |
"""summarize for all visits, plus "by_os" and "by_visitor": the same per
|
| 190 |
group. OSes: most /t time first. Visitors: those with a judged (not
|
| 191 |
+
too_short) visit, most visits first, each with its "sessions" (one per
|
| 192 |
+
visit, newest first, with "start" and "end"). `numbers` maps a visits log
|
| 193 |
+
id to its "Visitor N" on the Visitors tab."""
|
| 194 |
# Reports from before "vid" was logged borrow it from a later one of the same client.
|
| 195 |
vid_of = {v["client"]: v["vid"] for v in visits if v.get("vid")}
|
| 196 |
by_os: dict[str, list[dict]] = defaultdict(list)
|
|
|
|
| 202 |
oses = [{"name": name, **summarize(group)} for name, group in by_os.items()]
|
| 203 |
people = [{"name": f"Visitor {numbers[key]}" if key in numbers else f"id {key[:6]}",
|
| 204 |
"device": f"{group[-1].get('os', '')} {group[-1].get('browser', '')}".strip(),
|
| 205 |
+
**summarize(group),
|
| 206 |
+
"sessions": [dict(zip(("start", "end"), times(v))) | summarize([v])
|
| 207 |
+
for v in reversed(group)]}
|
| 208 |
+
for key, group in by_who.items()]
|
| 209 |
oses.sort(key=lambda s: -s["total_s"])
|
| 210 |
people = sorted((p for p in people if p["visits"] > p["verdicts"].get("too_short", 0)),
|
| 211 |
key=lambda s: (-s["visits"], -s["total_s"]))
|