zofiasmolenasana
Fix auth: no Content-Type on GET (mobile/HF); jsonAuthHeaders for POST; remove fetch monkey-patch
6606bb6 unverified
Raw
History Blame Contribute Delete
114 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Spreadsheet Labeler</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Segoe UI',system-ui,-apple-system,sans-serif;background:#f5f5f5;color:#222}
/* Top bar */
.topbar{position:sticky;top:0;z-index:100;background:#fff;border-bottom:1px solid #ddd;
padding:10px 24px;display:flex;align-items:center;gap:14px;flex-wrap:wrap;box-shadow:0 1px 4px rgba(0,0,0,.08)}
.topbar h1{font-size:18px;font-weight:600;white-space:nowrap}
.progress{font-size:14px;color:#555;min-width:100px}
.sheet-info{font-size:13px;color:#777;flex:1}
#username-input{padding:6px 10px;border:1px solid #ccc;border-radius:6px;font-size:13px;width:140px}
#save-btn{padding:10px 28px;font-size:15px;font-weight:600;background:#2563eb;color:#fff;
border:none;border-radius:8px;cursor:pointer;transition:background .15s}
#save-btn:hover{background:#1d4ed8}
#save-btn:disabled{background:#94a3b8;cursor:not-allowed}
#skip-btn{padding:8px 16px;font-size:13px;font-weight:600;background:#fff;color:#dc2626;
border:2px solid #dc2626;border-radius:8px;cursor:pointer;transition:all .15s}
#skip-btn:hover{background:#fef2f2}
#skip-btn:disabled{opacity:.4;cursor:not-allowed}
#clear-all-btn{padding:8px 16px;font-size:13px;font-weight:600;background:#fff;color:#dc2626;
border:2px solid #dc2626;border-radius:8px;cursor:pointer;transition:all .15s;white-space:nowrap}
#clear-all-btn:hover{background:#dc2626;color:#fff}
/* Review mode */
#rag-label-btn{padding:3px 10px;font-size:11px;color:#666;background:#e8e8e8;border:1px solid #bbb;
border-radius:4px;cursor:pointer;margin-left:6px}
#rag-label-btn:hover{color:#0f766e;border-color:#5eead4;background:#ecfdf5}
#rag-label-btn.active{color:#0f766e;border-color:#14b8a6;background:#ccfbf1;font-weight:600}
#review-btn{padding:3px 10px;font-size:11px;color:#888;background:#fafafa;border:1px solid #ccc;
border-radius:4px;cursor:pointer;transition:all .15s;letter-spacing:.3px}
#review-btn:hover{color:#2563eb;border-color:#93c5fd}
#review-btn.active{color:#2563eb;border-color:#2563eb;background:#eff6ff}
.review-nav{display:none;align-items:center;gap:6px;font-size:11px;color:#555}
.review-nav.visible{display:flex}
.review-nav button{padding:2px 8px;font-size:11px;border:1px solid #ccc;border-radius:4px;
background:#fff;cursor:pointer;transition:all .1s}
.review-nav button:hover{border-color:#2563eb;color:#2563eb}
#review-counter{font-variant-numeric:tabular-nums}
#exit-review{color:#dc2626;border-color:#fca5a5}
#exit-review:hover{color:#fff;background:#dc2626;border-color:#dc2626}
#exit-rag-label{color:#dc2626;border-color:#fca5a5}
#exit-rag-label:hover{color:#fff;background:#dc2626;border-color:#dc2626}
/* Prev button & history dropdown */
.prev-wrapper{position:relative}
#prev-btn{padding:8px 16px;font-size:13px;font-weight:600;background:#fff;color:#555;
border:2px solid #ccc;border-radius:8px;cursor:pointer;transition:all .15s;white-space:nowrap}
#prev-btn:hover{border-color:#2563eb;color:#2563eb}
.history-popup{position:absolute;top:100%;left:0;margin-top:6px;background:#fff;
border:1px solid #ddd;border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,.12);
z-index:200;min-width:340px;max-height:360px;overflow-y:auto;display:none}
.history-popup.open{display:block}
.history-popup .history-header{padding:10px 14px;font-size:13px;font-weight:600;color:#333;
border-bottom:1px solid #eee}
.history-popup .history-empty{padding:16px 14px;font-size:13px;color:#888;text-align:center}
.history-popup .history-item{padding:8px 14px;cursor:pointer;border-bottom:1px solid #f0f0f0;
transition:background .1s;display:flex;flex-direction:column;gap:2px}
.history-popup .history-item:hover{background:#f0f7ff}
.history-popup .history-item:last-child{border-bottom:none}
.history-popup .history-item .hi-name{font-size:13px;font-weight:500;color:#222;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.history-popup .history-item .hi-meta{font-size:11px;color:#888}
/* Selector bars (table & level) */
.selector-bar{display:flex;align-items:center;gap:6px;padding:0 24px;background:#fff;
border-bottom:1px solid #eee;height:34px}
.selector-bar .bar-label{font-size:12px;color:#555;font-weight:600;min-width:42px}
.sel-btn{padding:2px 10px;font-size:11px;font-weight:600;border:2px solid #bbb;border-radius:5px;
cursor:pointer;background:#fff;transition:all .12s}
.sel-btn:hover{border-color:#666}
.sel-btn.active{color:#fff}
.add-btn{padding:2px 8px;font-size:13px;font-weight:700;border:2px dashed #bbb;border-radius:5px;
cursor:pointer;background:#fff;color:#888;transition:all .12s}
.add-btn:hover{border-color:#666;color:#444}
/* Legend */
.legend{display:flex;gap:6px;padding:6px 24px;background:#fff;border-bottom:1px solid #eee;flex-wrap:wrap}
.legend-item{display:flex;align-items:center;gap:3px;font-size:10px;padding:2px 6px;border-radius:4px;
border:1px solid #ddd}
.legend-item .swatch{width:12px;height:12px;border-radius:2px;border:1px solid #bbb}
.legend-sep{width:1px;background:#ddd;align-self:stretch;margin:0 2px}
/* Table */
.table-wrap{overflow:auto;padding:16px 24px;max-height:calc(100vh - 210px)}
table{border-collapse:collapse;background:#fff;font-size:13px}
td{border:1px solid #d0d0d0;padding:4px 6px;min-width:50px;max-width:300px;
overflow:hidden;text-overflow:ellipsis;cursor:cell;user-select:none;
transition:background .1s;white-space:nowrap;position:relative}
td.wrap-cell{white-space:normal;word-wrap:break-word}
td.bold-cell{font-weight:700}
td.italic-cell{font-style:italic}
td.underline-cell{text-decoration:underline}
td.selected{outline:2px solid #2563eb;outline-offset:-2px;z-index:1}
/* Source-border classes */
td.src-bt{border-top:2.5px solid #333}
td.src-bb{border-bottom:2.5px solid #333}
td.src-bl{border-left:2.5px solid #333}
td.src-br{border-right:2.5px solid #333}
/* Fixed label colours */
td.label-value{background:#c6efce !important}
td.label-aggregation{background:#d5a6e6 !important}
td.label-header{background:#a8d8ea !important}
td.label-metadata{background:#bdd7ee !important}
td.label-comment{background:#fde68a !important;border:2px dashed #d97706 !important}
td.label-empty{background:#f0f0f0 !important;color:#aaa}
td.label-junk{background:#f8d7da !important;color:#999;text-decoration:line-through}
/* Link-mode pulse on comment cell awaiting target */
td.link-pending{animation:linkPulse 1s ease-in-out infinite}
@keyframes linkPulse{0%,100%{box-shadow:0 0 0 0 rgba(217,119,6,.5)}50%{box-shadow:0 0 0 6px rgba(217,119,6,0)}}
/* Link arrow overlay */
.link-arrows{position:absolute;top:0;left:0;pointer-events:none;z-index:50}
/* Link mode banner */
.link-banner{position:fixed;bottom:0;left:0;right:0;z-index:200;background:#fffbeb;
border-top:2px solid #d97706;padding:10px 24px;display:flex;align-items:center;gap:12px;
box-shadow:0 -2px 8px rgba(0,0,0,.1);display:none}
.link-banner.active{display:flex}
.link-banner .link-text{flex:1;font-size:14px;font-weight:500;color:#92400e}
.link-banner .link-cancel{padding:6px 16px;font-size:13px;font-weight:600;
background:#fff;border:2px solid #d97706;border-radius:6px;color:#d97706;cursor:pointer}
.link-banner .link-cancel:hover{background:#fef3c7}
/* Comment target indicator */
td .comment-target-badge{position:absolute;bottom:1px;right:1px;font-size:7px;font-weight:700;
color:#92400e;background:#fde68a;padding:0 3px;border-radius:3px;line-height:12px;pointer-events:none}
/* Formula subtitle */
td .formula-sub{display:block;font-size:9px;color:#888;font-weight:400;font-style:normal;
text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:280px;
line-height:1.2;margin-top:1px}
/* Table-ID badge */
td .tid{position:absolute;top:1px;right:1px;font-size:8px;font-weight:700;color:#fff;
padding:0 3px;border-radius:3px;line-height:13px;opacity:.9;pointer-events:none}
/* Comment indicator (orange triangle, like Excel) */
td .comment-mark{position:absolute;top:0;right:0;width:0;height:0;
border-style:solid;border-width:0 8px 8px 0;border-color:transparent #e67e22 transparent transparent;
pointer-events:none}
td.has-comment{cursor:help}
/* Label tag (bottom-left: shows label type + level) */
td .label-tag{position:absolute;bottom:1px;left:1px;font-size:7px;font-weight:700;
color:rgba(0,0,0,.5);line-height:1;pointer-events:none;letter-spacing:.3px}
/* Login overlay */
.login-overlay{position:fixed;inset:0;z-index:999;background:#f5f5f5;display:flex;
align-items:center;justify-content:center}
.login-box{background:#fff;padding:40px 36px;border-radius:12px;box-shadow:0 4px 24px rgba(0,0,0,.12);
text-align:center;max-width:360px;width:100%}
.login-box h2{margin-bottom:8px;font-size:20px}
.login-box p{color:#666;font-size:13px;margin-bottom:20px}
.login-box input{width:100%;padding:10px 14px;border:1px solid #ccc;border-radius:8px;
font-size:15px;margin-bottom:12px}
.login-box button{width:100%;padding:10px;font-size:15px;font-weight:600;background:#2563eb;
color:#fff;border:none;border-radius:8px;cursor:pointer}
.login-box button:hover{background:#1d4ed8}
.login-error{color:#dc2626;font-size:13px;margin-top:8px;display:none}
.done-msg{text-align:center;padding:80px 20px;font-size:22px;color:#16a34a;font-weight:600}
/* Keyboard hint bar */
.hint{padding:4px 24px;background:#fafafa;border-bottom:1px solid #eee;font-size:11px;color:#888;line-height:1.7}
.hint kbd{background:#eee;border:1px solid #ccc;border-radius:3px;padding:1px 5px;font-family:inherit;font-size:10px}
/* Help button */
#help-btn{width:34px;height:34px;border-radius:50%;border:2px solid #ccc;background:#fff;
font-size:16px;font-weight:700;color:#666;cursor:pointer;transition:all .15s;flex-shrink:0}
#help-btn:hover{border-color:#2563eb;color:#2563eb}
/* Language toggle */
.lang-toggle{display:flex;gap:0;flex-shrink:0}
.lang-toggle button{padding:4px 8px;font-size:11px;font-weight:600;border:1.5px solid #ccc;
background:#fff;cursor:pointer;color:#666;transition:all .12s}
.lang-toggle button:first-child{border-radius:5px 0 0 5px;border-right:none}
.lang-toggle button:last-child{border-radius:0 5px 5px 0}
.lang-toggle button.active{background:#2563eb;color:#fff;border-color:#2563eb}
/* Help panel */
.help-overlay{position:fixed;inset:0;z-index:500;background:rgba(0,0,0,.3);display:none}
.help-overlay.open{display:flex;justify-content:flex-end}
.help-panel{width:min(620px,90vw);height:100vh;background:#fff;overflow-y:auto;
box-shadow:-4px 0 24px rgba(0,0,0,.15);padding:0}
.help-header{position:sticky;top:0;z-index:1;background:#fff;padding:16px 24px;
border-bottom:1px solid #ddd;display:flex;align-items:center;gap:12px}
.help-header h2{flex:1;font-size:17px;font-weight:600;margin:0}
.help-lang-btn{padding:4px 12px;font-size:12px;font-weight:600;border:2px solid #ccc;
border-radius:5px;cursor:pointer;background:#fff;transition:all .12s}
.help-lang-btn.active{background:#2563eb;color:#fff;border-color:#2563eb}
.help-lang-btn:hover{border-color:#2563eb}
.help-close{width:30px;height:30px;border:none;background:none;font-size:22px;color:#888;
cursor:pointer;border-radius:4px}
.help-close:hover{background:#f0f0f0;color:#333}
.help-body{padding:20px 24px 40px}
.help-body h3{font-size:15px;font-weight:600;margin:20px 0 8px;padding-bottom:4px;border-bottom:1px solid #eee}
.help-body h3:first-child{margin-top:0}
.help-body h4{font-size:13px;font-weight:600;margin:14px 0 4px;color:#444}
.help-body p,.help-body li{font-size:13px;line-height:1.6;color:#333}
.help-body ul,.help-body ol{padding-left:20px;margin:4px 0 8px}
.help-body table.ref-table{border-collapse:collapse;width:100%;margin:8px 0 12px;font-size:12px}
.help-body table.ref-table th,.help-body table.ref-table td{border:1px solid #ddd;padding:4px 8px;text-align:left}
.help-body table.ref-table th{background:#f5f5f5;font-weight:600}
.help-body kbd{background:#eee;border:1px solid #ccc;border-radius:3px;padding:1px 5px;font-family:inherit;font-size:11px}
.help-body code{background:#f0f0f0;padding:1px 4px;border-radius:3px;font-size:12px}
.help-body pre{background:#f8f8f8;border:1px solid #e0e0e0;border-radius:6px;padding:10px 12px;
overflow-x:auto;font-size:11px;line-height:1.5;margin:8px 0 12px;white-space:pre}
.help-body .check-list{list-style:none;padding-left:0}
.help-body .check-list li{padding-left:22px;position:relative;margin-bottom:4px}
.help-body .check-list li::before{content:"☐";position:absolute;left:0;color:#888}
.help-body .warn{color:#b45309;font-weight:600}
/* Accuracy indicator */
.acc-badge{display:inline-flex;align-items:center;gap:5px;padding:3px 10px;border-radius:6px;
font-size:12px;font-weight:600;background:#e8f5e9;color:#2e7d32;cursor:pointer;
border:1px solid #c8e6c9;transition:all .15s;white-space:nowrap}
.acc-badge:hover{background:#c8e6c9;border-color:#a5d6a7}
.acc-badge.no-model{background:#f5f5f5;color:#999;border-color:#ddd;cursor:default}
.sparkline-popup{position:absolute;top:100%;right:0;margin-top:6px;background:#fff;
border:1px solid #ddd;border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,.12);
padding:16px;z-index:200;min-width:320px;display:none}
.sparkline-popup.open{display:block}
.sparkline-popup h4{font-size:13px;font-weight:600;margin:0 0 10px;color:#333}
.sparkline-popup svg{display:block}
.acc-wrapper{position:relative}
</style>
</head>
<body>
<div class="login-overlay" id="login-overlay" style="display:none">
<div class="login-box">
<h2 data-i18n="app_title">Spreadsheet Labeler</h2>
<p data-i18n="login_prompt">Enter the password to start labeling</p>
<input type="password" id="login-password" data-i18n-placeholder="login_placeholder" placeholder="Password" autofocus>
<button id="login-btn" data-i18n="login_btn">Log in</button>
<div class="login-error" id="login-error" data-i18n="login_error">Wrong password. Try again.</div>
</div>
</div>
<div class="topbar">
<h1 data-i18n="app_title">Spreadsheet Labeler</h1>
<button id="review-btn" title="Review all labeled sheets">review</button>
<button id="rag-label-btn" title="Label table structure for RAG eval only (not training)">RAG label</button>
<div class="review-nav" id="rag-label-nav">
<button id="rag-label-prev-btn">&larr;</button>
<input id="rag-label-input" type="number" min="1" style="width:40px;text-align:center;font-size:11px;border:1px solid #ccc;border-radius:3px;padding:1px 2px">
<span id="rag-label-counter"></span>
<button id="rag-label-next-btn">&rarr;</button>
<button id="exit-rag-label">&times;</button>
</div>
<div class="review-nav" id="review-nav">
<button id="review-prev-btn">&larr;</button>
<input id="review-input" type="number" min="1" style="width:40px;text-align:center;font-size:11px;border:1px solid #ccc;border-radius:3px;padding:1px 2px">
<span id="review-counter"></span>
<button id="review-next-btn">&rarr;</button>
<button id="exit-review">&times;</button>
</div>
<span class="progress" id="progress" data-i18n="loading">Loading...</span>
<span class="sheet-info" id="sheet-info"></span>
<input type="text" id="username-input" data-i18n-placeholder="username_placeholder" placeholder="Your name" value="">
<div class="acc-wrapper" id="acc-wrapper" style="display:none">
<span class="acc-badge" id="acc-badge"></span>
<div class="sparkline-popup" id="sparkline-popup">
<h4 id="sparkline-title"></h4>
<svg id="sparkline-svg" width="280" height="100"></svg>
</div>
</div>
<div class="lang-toggle" id="lang-toggle">
<button data-lang="en" class="active">EN</button>
<button data-lang="pl">PL</button>
</div>
<button id="help-btn" title="Labeling guide / Instrukcja">?</button>
<div class="prev-wrapper" id="prev-wrapper">
<button id="prev-btn" data-i18n="prev_btn">&larr; PREV</button>
<div class="history-popup" id="history-popup"></div>
</div>
<button id="clear-all-btn" data-i18n="clear_all_btn">CLEAR ALL</button>
<button id="skip-btn" disabled data-i18n="skip_btn">SKIP</button>
<button id="save-btn" disabled data-i18n="save_btn">SAVE &amp; NEXT</button>
</div>
<div class="help-overlay" id="help-overlay">
<div class="help-panel">
<div class="help-header">
<h2 id="help-title">Labeling Guide</h2>
<button class="help-lang-btn active" data-lang="en">EN</button>
<button class="help-lang-btn" data-lang="pl">PL</button>
<button class="help-close" id="help-close">&times;</button>
</div>
<div class="help-body" id="help-body"></div>
</div>
</div>
<div class="selector-bar" id="table-bar">
<span class="bar-label">Table:</span>
</div>
<div class="selector-bar" id="level-bar">
<span class="bar-label">Level:</span>
</div>
<div class="legend" id="legend-bar"></div>
<div class="hint" id="hint"></div>
<div class="table-wrap" id="table-wrap" style="position:relative">
<p style="padding:40px;color:#888" data-i18n="loading_sheet">Loading first sheet...</p>
</div>
<div class="link-banner" id="link-banner">
<span class="link-text" id="link-banner-text"></span>
<button class="link-cancel" id="link-cancel">Esc</button>
</div>
<script>
(function(){
/* ── i18n ───────────────────────────────────────────────────────────── */
const I18N = {
en: {
app_title: "Spreadsheet Labeler",
login_prompt: "Enter the password to start labeling",
login_placeholder: "Password",
login_btn: "Log in",
login_error: "Wrong password. Try again.",
loading: "Loading\u2026",
loading_sheet: "Loading first sheet\u2026",
username_placeholder: "Your name",
save_btn: "SAVE & NEXT",
prev_btn: "\u2190 PREV",
clear_all_btn: "CLEAR ALL",
clear_all_confirm: "Remove all labels from this sheet? This can be undone with Ctrl+Z.",
history_title: "Recently labeled sheets",
history_empty: "No labeled sheets yet.",
history_cells: "cells",
saving: "Saving\u2026",
done: "Done!",
all_done: "All sheets have been labeled!",
waiting_others: "No sheets available right now \u2014 other labelers are working on the remaining ones.",
labelled_progress: "labelled",
table_label: "Table:",
t0_sheet: "T0 sheet",
add_table: "Add table",
level_label: "Level:",
add_level: "Add level",
lbl_value: "Value",
lbl_col_hdr: "ColHdr",
lbl_row_hdr: "RowHdr",
lbl_aggregation: "Aggregation",
lbl_header: "Header",
lbl_metadata: "Metadata",
lbl_comment: "Comment",
lbl_empty: "Empty",
lbl_junk: "Junk",
lbl_clear: "Clear",
link_banner_text: "Click the cell this comment refers to (or press Esc to cancel)",
hint: 'Click to select \u00b7 <kbd>Shift</kbd>+click for range \u00b7 <kbd>Ctrl/Cmd</kbd>+click to toggle \u00b7 <kbd>R</kbd>=RowHdr \u00b7 <kbd>C</kbd>=ColHdr (at active level) \u00b7 <kbd>H</kbd>=Header \u00b7 <kbd>N</kbd>=Comment \u00b7 <kbd>J</kbd>=Junk \u00b7 <kbd>T</kbd>+digits = table \u00b7 <kbd>L</kbd>+digits = level \u00b7 <kbd>Enter</kbd> = Save & Next',
error_prefix: "Error: ",
save_error: "Save error: ",
relogin_hint: "Log in again (refresh the page and enter the password).",
help_title: "Labeling Guide",
ai_accuracy: "AI v{v}: {n}%",
ai_no_model: "No model",
accuracy_title: "Prediction accuracy over time",
ai_assisted: "AI-assisted",
t0_warning: "T0 is active \u2014 data cells on T0 won't produce chunks. Switch to T1+ first?",
skip_btn: "SKIP",
skip_confirm: "Permanently skip this sheet? It will be excluded from all training, evaluation, and RAG.",
review_done: "Review complete! All labeled sheets reviewed.",
review_save_btn: "SAVE & NEXT \u25B6",
rag_label_save_btn: "SAVE (RAG only) \u25B6",
rag_label_done: "RAG structure labeling complete. Saved under data/rag_eval_labeled/ (never used for training).",
},
pl: {
app_title: "Labelowanie arkuszy",
login_prompt: "Wpisz has\u0142o, aby rozpocz\u0105\u0107 labelowanie",
login_placeholder: "Has\u0142o",
login_btn: "Zaloguj si\u0119",
login_error: "B\u0142\u0119dne has\u0142o. Spr\u00f3buj ponownie.",
loading: "\u0141adowanie\u2026",
loading_sheet: "\u0141adowanie pierwszego arkusza\u2026",
username_placeholder: "Twoje imi\u0119",
save_btn: "ZAPISZ I DALEJ",
prev_btn: "\u2190 WRÓĆ",
clear_all_btn: "WYCZYŚĆ WSZYSTKO",
clear_all_confirm: "Usunąć wszystkie etykiety z tego arkusza? Można cofnąć przez Ctrl+Z.",
history_title: "Ostatnio oznaczone arkusze",
history_empty: "Brak oznaczonych arkuszy.",
history_cells: "komórek",
saving: "Zapisywanie\u2026",
done: "Gotowe!",
all_done: "Wszystkie arkusze zosta\u0142y oznaczone!",
waiting_others: "Brak dost\u0119pnych arkuszy \u2014 pozosta\u0142e s\u0105 w trakcie labelowania przez innych.",
labelled_progress: "oznaczonych",
table_label: "Tabela:",
t0_sheet: "T0 arkusz",
add_table: "Dodaj tabel\u0119",
level_label: "Poziom:",
add_level: "Dodaj poziom",
lbl_value: "Warto\u015b\u0107",
lbl_col_hdr: "NagKol",
lbl_row_hdr: "NagWier",
lbl_aggregation: "Agregacja",
lbl_header: "Nag\u0142\u00f3wek",
lbl_metadata: "Metadane",
lbl_comment: "Komentarz",
lbl_empty: "Pusta",
lbl_junk: "Śmieć",
lbl_clear: "Wyczy\u015b\u0107",
link_banner_text: "Kliknij kom\u00f3rk\u0119, do kt\u00f3rej odnosi si\u0119 ten komentarz (lub naci\u015bnij Esc, aby anulowa\u0107)",
hint: 'Kliknij, aby zaznaczy\u0107 \u00b7 <kbd>Shift</kbd>+klik = zakres \u00b7 <kbd>Ctrl/Cmd</kbd>+klik = prze\u0142\u0105cz \u00b7 <kbd>R</kbd>=NagWier \u00b7 <kbd>C</kbd>=NagKol (na aktywnym poziomie) \u00b7 <kbd>H</kbd>=Nag\u0142\u00f3wek \u00b7 <kbd>N</kbd>=Komentarz \u00b7 <kbd>J</kbd>=Śmieć \u00b7 <kbd>T</kbd>+cyfry = tabela \u00b7 <kbd>L</kbd>+cyfry = poziom \u00b7 <kbd>Enter</kbd> = Zapisz i dalej',
error_prefix: "B\u0142\u0105d: ",
save_error: "B\u0142\u0105d zapisu: ",
relogin_hint: "Zaloguj si\u0119 ponownie (od\u015bwie\u017c stron\u0119 i wpisz has\u0142o).",
help_title: "Instrukcja labelowania",
ai_accuracy: "SI v{v}: {n}%",
ai_no_model: "Brak modelu",
accuracy_title: "Dokładność predykcji w czasie",
ai_assisted: "Wspomagane SI",
t0_warning: "T0 jest aktywne \u2014 dane na T0 nie wygeneruj\u0105 chunk\u00f3w. Prze\u0142\u0105czy\u0107 na T1+?",
skip_btn: "POMI\u0143",
skip_confirm: "Trwale pomin\u0105\u0107 ten arkusz? Zostanie wykluczony z treningu, ewaluacji i RAG.",
review_done: "Przegl\u0105d zako\u0144czony! Wszystkie oznaczone arkusze sprawdzone.",
review_save_btn: "ZAPISZ I DALEJ \u25B6",
rag_label_save_btn: "ZAPISZ (tylko RAG) \u25B6",
rag_label_done: "Zako\u0144czono etykiety struktury RAG. Pliki: data/rag_eval_labeled/ (bez treningu).",
}
};
let lang = localStorage.getItem("labeler_lang") || "en";
function t(key) { return (I18N[lang] && I18N[lang][key]) || I18N.en[key] || key; }
function applyLang() {
document.documentElement.lang = lang;
document.querySelectorAll("[data-i18n]").forEach(el => {
const key = el.dataset.i18n;
const val = t(key);
if (val) el.textContent = val;
});
document.querySelectorAll("[data-i18n-placeholder]").forEach(el => {
const key = el.dataset.i18nPlaceholder;
const val = t(key);
if (val) el.placeholder = val;
});
document.getElementById("hint").innerHTML = t("hint");
document.getElementById("lang-toggle").querySelectorAll("button").forEach(b => {
b.classList.toggle("active", b.dataset.lang === lang);
});
}
document.getElementById("lang-toggle").addEventListener("click", (e) => {
const btn = e.target.closest("button[data-lang]");
if (!btn) return;
lang = btn.dataset.lang;
localStorage.setItem("labeler_lang", lang);
applyLang();
renderTableBar();
renderLevelBar();
renderLegend();
renderAccuracyBadge();
if (currentSheet) updateProgress(lastProgress);
});
/* ── Color palettes ────────────────────────────────────────────────── */
const TABLE_COLORS = [
"#888","#e74c3c","#2980b9","#27ae60","#f39c12","#8e44ad",
"#e67e22","#1abc9c","#c0392b","#2c3e50"
];
function colHeaderColor(level) {
const base = [255, 235, 156];
const cycle = (level - 1) % 12;
const darken = cycle * 14;
return `rgb(${Math.max(base[0] - darken, 80)}, ${Math.max(base[1] - (darken * 1.2 | 0), 60)}, ${Math.max(base[2] - (darken * 2 | 0), 20)})`;
}
function rowHeaderColor(level) {
const base = [244, 176, 132];
const cycle = (level - 1) % 12;
const darken = cycle * 12;
return `rgb(${Math.max(base[0] - darken, 80)}, ${Math.max(base[1] - (darken * 1.3 | 0), 50)}, ${Math.max(base[2] - (darken * 1.5 | 0), 20)})`;
}
/* ── State ─────────────────────────────────────────────────────────── */
let currentSheet = null;
let cellLabels = {};
let selectedCells = new Set();
let lastClickedKey = null;
let activeTable = 1;
let activeLevel = 1;
let maxTableId = 1;
let maxLevel = 3;
let lastProgress = null;
let hasPredictions = false;
let currentModelVersion = null;
let accuracyHistory = [];
/* ── Undo / Redo stacks ─────────────────────────────────────────── */
const MAX_UNDO = 50;
let undoStack = [];
let redoStack = [];
function pushUndo() {
undoStack.push(_snapshot());
if (undoStack.length > MAX_UNDO) undoStack.shift();
redoStack = [];
}
function _snapshot() { return JSON.stringify({labels: cellLabels, links: commentLinks}); }
function _restore(s) { const o = JSON.parse(s); cellLabels = o.labels; commentLinks = o.links || {}; }
function doUndo() {
if (undoStack.length === 0) return;
redoStack.push(_snapshot());
_restore(undoStack.pop());
applyLabelClasses();
drawCommentLinks();
}
function doRedo() {
if (redoStack.length === 0) return;
undoStack.push(_snapshot());
_restore(redoStack.pop());
applyLabelClasses();
drawCommentLinks();
}
function clearUndoRedo() {
undoStack = [];
redoStack = [];
}
const tableBar = document.getElementById("table-bar");
const levelBar = document.getElementById("level-bar");
const legendBar = document.getElementById("legend-bar");
const tableWrap = document.getElementById("table-wrap");
const saveBtn = document.getElementById("save-btn");
const skipBtn = document.getElementById("skip-btn");
const progressEl = document.getElementById("progress");
const sheetInfoEl = document.getElementById("sheet-info");
const usernameInput = document.getElementById("username-input");
usernameInput.value = localStorage.getItem("labeler_username") || "";
usernameInput.addEventListener("input", () => {
localStorage.setItem("labeler_username", usernameInput.value);
});
/* ── Comment linking state ──────────────────────────────────────────── */
let commentLinks = {}; // comment cell key -> target cell key
let linkMode = null; // null or the comment cell key waiting for a target
const linkBanner = document.getElementById("link-banner");
const linkBannerText = document.getElementById("link-banner-text");
const linkCancelBtn = document.getElementById("link-cancel");
function enterLinkMode(commentKey) {
linkMode = commentKey;
linkBanner.classList.add("active");
linkBannerText.textContent = t("link_banner_text");
const td = tableWrap.querySelector(`td[data-key="${commentKey}"]`);
if (td) td.classList.add("link-pending");
}
function exitLinkMode() {
if (linkMode) {
const td = tableWrap.querySelector(`td[data-key="${linkMode}"]`);
if (td) td.classList.remove("link-pending");
}
linkMode = null;
linkBanner.classList.remove("active");
}
function completeLinkMode(targetKey) {
if (!linkMode) return;
if (targetKey === linkMode) { exitLinkMode(); return; }
commentLinks[linkMode] = targetKey;
exitLinkMode();
drawCommentLinks();
applyLabelClasses();
}
linkCancelBtn.addEventListener("click", () => exitLinkMode());
function drawCommentLinks() {
tableWrap.querySelectorAll(".link-arrows").forEach(el => el.remove());
const keys = Object.keys(commentLinks);
if (keys.length === 0) return;
const wrapRect = tableWrap.getBoundingClientRect();
const scrollLeft = tableWrap.scrollLeft;
const scrollTop = tableWrap.scrollTop;
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.classList.add("link-arrows");
svg.style.width = tableWrap.scrollWidth + "px";
svg.style.height = tableWrap.scrollHeight + "px";
const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
const marker = document.createElementNS("http://www.w3.org/2000/svg", "marker");
marker.setAttribute("id", "arrowhead");
marker.setAttribute("markerWidth", "8");
marker.setAttribute("markerHeight", "6");
marker.setAttribute("refX", "8");
marker.setAttribute("refY", "3");
marker.setAttribute("orient", "auto");
const polygon = document.createElementNS("http://www.w3.org/2000/svg", "polygon");
polygon.setAttribute("points", "0 0, 8 3, 0 6");
polygon.setAttribute("fill", "#d97706");
marker.appendChild(polygon);
defs.appendChild(marker);
svg.appendChild(defs);
for (const [src, tgt] of Object.entries(commentLinks)) {
const srcTd = tableWrap.querySelector(`td[data-key="${src}"]`);
const tgtTd = tableWrap.querySelector(`td[data-key="${tgt}"]`);
if (!srcTd || !tgtTd) continue;
const sr = srcTd.getBoundingClientRect();
const tr = tgtTd.getBoundingClientRect();
const x1 = sr.left + sr.width / 2 - wrapRect.left + scrollLeft;
const y1 = sr.top + sr.height / 2 - wrapRect.top + scrollTop;
const x2 = tr.left + tr.width / 2 - wrapRect.left + scrollLeft;
const y2 = tr.top + tr.height / 2 - wrapRect.top + scrollTop;
const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
line.setAttribute("x1", x1);
line.setAttribute("y1", y1);
line.setAttribute("x2", x2);
line.setAttribute("y2", y2);
line.setAttribute("stroke", "#d97706");
line.setAttribute("stroke-width", "2");
line.setAttribute("stroke-dasharray", "4,3");
line.setAttribute("marker-end", "url(#arrowhead)");
svg.appendChild(line);
}
tableWrap.appendChild(svg);
}
/* ── Dynamic style injection ───────────────────────────────────────── */
const dynamicStyle = document.createElement("style");
document.head.appendChild(dynamicStyle);
function tableColor(i) {
return TABLE_COLORS[i % TABLE_COLORS.length];
}
function refreshDynamicCSS() {
let css = "";
for (let i = 0; i <= maxTableId; i++) {
const c = tableColor(i);
css += `.t${i}-active{border-color:${c}!important;background:${c}!important}\n`;
css += `.tid-${i}{background:${c}}\n`;
}
for (let lv = 1; lv <= maxLevel; lv++) {
css += `td.label-col_header_${lv}{background:${colHeaderColor(lv)}!important}\n`;
css += `td.label-row_header_${lv}{background:${rowHeaderColor(lv)}!important}\n`;
}
dynamicStyle.textContent = css;
}
/* ── Table selector ────────────────────────────────────────────────── */
function renderTableBar() {
tableBar.innerHTML = `<span class="bar-label">${t("table_label")}</span>`;
for (let i = 0; i <= maxTableId; i++) {
const btn = document.createElement("button");
btn.className = "sel-btn";
btn.dataset.tid = i;
btn.textContent = i === 0 ? t("t0_sheet") : `T${i}`;
if (i === activeTable) btn.classList.add("active", `t${i}-active`);
btn.addEventListener("click", () => setActiveTable(i));
tableBar.appendChild(btn);
}
const addBtn = document.createElement("button");
addBtn.className = "add-btn";
addBtn.textContent = "+";
addBtn.title = t("add_table");
addBtn.addEventListener("click", () => {
maxTableId++;
renderTableBar();
setActiveTable(maxTableId);
});
tableBar.appendChild(addBtn);
}
function setActiveTable(tid) {
if (tid > maxTableId) {
maxTableId = tid;
renderTableBar();
}
activeTable = tid;
tableBar.querySelectorAll(".sel-btn").forEach(b => {
const t = parseInt(b.dataset.tid);
b.className = "sel-btn";
if (t === tid) b.classList.add("active", `t${t}-active`);
});
}
/* ── Level selector ────────────────────────────────────────────────── */
function renderLevelBar() {
levelBar.innerHTML = `<span class="bar-label">${t("level_label")}</span>`;
for (let lv = 1; lv <= maxLevel; lv++) {
const btn = document.createElement("button");
btn.className = "sel-btn";
btn.dataset.lv = lv;
btn.textContent = `L${lv}`;
btn.style.borderColor = rowHeaderColor(lv);
if (lv === activeLevel) {
btn.classList.add("active");
btn.style.background = rowHeaderColor(lv);
btn.style.borderColor = rowHeaderColor(lv);
}
btn.addEventListener("click", () => setActiveLevel(lv));
levelBar.appendChild(btn);
}
const addBtn = document.createElement("button");
addBtn.className = "add-btn";
addBtn.textContent = "+";
addBtn.title = t("add_level");
addBtn.addEventListener("click", () => {
maxLevel++;
refreshDynamicCSS();
renderLevelBar();
renderLegend();
setActiveLevel(maxLevel);
});
levelBar.appendChild(addBtn);
}
function setActiveLevel(lv) {
if (lv > maxLevel) {
maxLevel = lv;
refreshDynamicCSS();
renderLevelBar();
}
activeLevel = lv;
renderLegend();
levelBar.querySelectorAll(".sel-btn").forEach(b => {
const l = parseInt(b.dataset.lv);
b.classList.remove("active");
b.style.background = "#fff";
if (l === lv) {
b.classList.add("active");
b.style.background = rowHeaderColor(l);
}
});
}
/* ── Legend ─────────────────────────────────────────────────────────── */
function renderLegend() {
let html = '';
html += `<div class="legend-item"><div class="swatch" style="background:#c6efce"></div><b>V</b>&thinsp;${t("lbl_value")}</div>`;
html += `<div class="legend-sep"></div>`;
html += `<div class="legend-item"><div class="swatch" style="background:${colHeaderColor(activeLevel)}"></div><b>C</b>&thinsp;${t("lbl_col_hdr")}</div>`;
html += `<div class="legend-item"><div class="swatch" style="background:${rowHeaderColor(activeLevel)}"></div><b>R</b>&thinsp;${t("lbl_row_hdr")}</div>`;
html += `<div class="legend-sep"></div>`;
html += `<div class="legend-item"><div class="swatch" style="background:#d5a6e6"></div><b>A</b>&thinsp;${t("lbl_aggregation")}</div>`;
html += `<div class="legend-item"><div class="swatch" style="background:#a8d8ea"></div><b>H</b>&thinsp;${t("lbl_header")}</div>`;
html += `<div class="legend-item"><div class="swatch" style="background:#bdd7ee"></div><b>M</b>&thinsp;${t("lbl_metadata")}</div>`;
html += `<div class="legend-item"><div class="swatch" style="background:#fde68a;border:2px dashed #d97706"></div><b>N</b>&thinsp;${t("lbl_comment")}</div>`;
html += `<div class="legend-item"><div class="swatch" style="background:#f0f0f0"></div><b>E</b>&thinsp;${t("lbl_empty")}</div>`;
html += `<div class="legend-item"><div class="swatch" style="background:#f8d7da;text-decoration:line-through"></div><b>J</b>&thinsp;${t("lbl_junk")}</div>`;
html += `<div class="legend-item"><div class="swatch" style="background:#fff"></div><b>X</b>&thinsp;${t("lbl_clear")}</div>`;
legendBar.innerHTML = html;
}
/* ── Initialize UI chrome ──────────────────────────────────────────── */
applyLang();
refreshDynamicCSS();
renderTableBar();
renderLevelBar();
renderLegend();
/* ── Accuracy tracking ─────────────────────────────────────────────── */
const accWrapper = document.getElementById("acc-wrapper");
const accBadge = document.getElementById("acc-badge");
const sparkPopup = document.getElementById("sparkline-popup");
const sparkTitle = document.getElementById("sparkline-title");
const sparkSvg = document.getElementById("sparkline-svg");
async function fetchAccuracy() {
try {
const resp = await fetch("/api/accuracy", {headers: authHeaders()});
if (!resp.ok) return;
accuracyHistory = await safeJson(resp);
renderAccuracyBadge();
} catch { /* ignore */ }
}
function renderAccuracyBadge() {
if (!accuracyHistory.length && !hasPredictions) {
accWrapper.style.display = "none";
return;
}
accWrapper.style.display = "";
if (!accuracyHistory.length) {
accBadge.className = "acc-badge no-model";
accBadge.textContent = t("ai_no_model");
return;
}
accBadge.className = "acc-badge";
const recent = accuracyHistory.slice(-10);
const hasStrict = recent.some(e => e.accuracy_strict !== undefined);
const avg = hasStrict
? recent.reduce((s, e) => s + (e.accuracy_strict ?? e.accuracy), 0) / recent.length
: recent.reduce((s, e) => s + e.accuracy, 0) / recent.length;
const latestVersion = currentModelVersion || (recent.length ? recent[recent.length - 1].model_version : null);
const vNum = latestVersion ? latestVersion.replace(/^v0*/, "") : "?";
accBadge.textContent = t("ai_accuracy").replace("{v}", vNum).replace("{n}", Math.round(avg * 100));
}
accBadge.addEventListener("click", () => {
if (!accuracyHistory.length) return;
sparkTitle.textContent = t("accuracy_title");
renderSparkline();
sparkPopup.classList.toggle("open");
});
document.addEventListener("click", (e) => {
if (!e.target.closest(".acc-wrapper")) sparkPopup.classList.remove("open");
});
function renderSparkline() {
if (!accuracyHistory.length) return;
const W = 280, H = 100, pad = 20;
const data = accuracyHistory.map(e => e.accuracy_strict ?? e.accuracy);
const n = data.length;
const xStep = n > 1 ? (W - 2 * pad) / (n - 1) : 0;
const yMin = Math.max(0, Math.min(...data) - 0.05);
const yMax = Math.min(1, Math.max(...data) + 0.05);
const yRange = yMax - yMin || 1;
const toX = i => pad + i * xStep;
const toY = v => H - pad - ((v - yMin) / yRange) * (H - 2 * pad);
let path = `M ${toX(0)} ${toY(data[0])}`;
for (let i = 1; i < n; i++) path += ` L ${toX(i)} ${toY(data[i])}`;
let dots = "";
for (let i = 0; i < n; i++) {
dots += `<circle cx="${toX(i)}" cy="${toY(data[i])}" r="3" fill="#2563eb" />`;
}
const yLabels = [yMin, (yMin + yMax) / 2, yMax];
let gridLines = "";
for (const v of yLabels) {
const y = toY(v);
gridLines += `<line x1="${pad}" y1="${y}" x2="${W - pad}" y2="${y}" stroke="#eee" stroke-width="1" />`;
gridLines += `<text x="${pad - 4}" y="${y + 3}" fill="#999" font-size="9" text-anchor="end">${Math.round(v * 100)}%</text>`;
}
sparkSvg.innerHTML = `${gridLines}<path d="${path}" fill="none" stroke="#2563eb" stroke-width="2" />${dots}`;
}
function prefillFromPredictions(predictions) {
if (!predictions || !predictions.length) {
hasPredictions = false;
return;
}
hasPredictions = true;
for (const p of predictions) {
const key = `${p.row},${p.col}`;
cellLabels[key] = { label: p.label, table: p.table || 1 };
if (p.table > maxTableId) maxTableId = p.table;
const m = p.label.match(/^(?:row_header|col_header)_(\d+)$/);
if (m) {
const lv = parseInt(m[1]);
if (lv > maxLevel) maxLevel = lv;
}
}
}
/* ── Fetch next sheet ──────────────────────────────────────────────── */
async function loadNextSheet() {
if (ragLabelMode) return;
saveBtn.disabled = true; skipBtn.disabled = true;
try {
const username = usernameInput.value.trim() || "anonymous";
const resp = await fetch(`/api/next_sheet?labeler=${encodeURIComponent(username)}`, {
headers: authHeaders(),
credentials: "include",
});
const data = await safeJson(resp);
if (!resp.ok) throw new Error(data.detail || `HTTP ${resp.status}`);
if (data.done) {
const hasWork = data.progress && (data.progress.in_progress > 0 || data.progress.unlabelled > 0);
const msg = hasWork ? t("waiting_others") : t("all_done");
tableWrap.innerHTML = `<div class="done-msg">${msg}</div>`;
progressEl.textContent = hasWork ? "" : t("done");
if (data.progress) updateProgress(data.progress);
sheetInfoEl.textContent = "";
return;
}
currentSheet = data.sheet;
cellLabels = {};
commentLinks = {};
exitLinkMode();
selectedCells.clear();
lastClickedKey = null;
maxTableId = 1;
maxLevel = 3;
activeLevel = 1;
clearUndoRedo();
currentModelVersion = data.model_version || null;
prefillFromPredictions(data.predictions);
refreshDynamicCSS();
renderTableBar();
renderLevelBar();
renderLegend();
setActiveTable(1);
updateProgress(data.progress);
let infoText = `${currentSheet.spreadsheet_id} / ${currentSheet.sheet_name}`;
if (hasPredictions) infoText += ` — ${t("ai_assisted")}`;
sheetInfoEl.textContent = infoText;
renderTable();
saveBtn.disabled = false; skipBtn.disabled = false;
fetchAccuracy();
} catch (err) {
tableWrap.innerHTML = `<p style="padding:40px;color:red">${t("error_prefix")}${err.message}</p>`;
}
}
function updateProgress(p) {
if (!p) return;
lastProgress = p;
const done = p.labelled || p.done || 0;
const total = p.total || 0;
const inProg = p.in_progress || 0;
let text = `${done} / ${total} ${t("labelled_progress")}`;
if (inProg > 0) text += ` (${inProg} in progress)`;
progressEl.textContent = text;
}
/* ── Render HTML table ─────────────────────────────────────────────── */
function renderTable() {
if (!currentSheet) return;
const cells = currentSheet.cells;
const merges = currentSheet.merges || [];
const cellMap = {};
cells.forEach(c => { cellMap[`${c.row},${c.col}`] = c; });
const hidden = new Set();
merges.forEach(m => {
for (let r = m.startRow; r < m.endRow; r++) {
for (let c = m.startCol; c < m.endCol; c++) {
if (r !== m.startRow || c !== m.startCol) hidden.add(`${r},${c}`);
}
}
});
let maxRow = 0, maxCol = 0;
cells.forEach(c => {
if (c.value !== "" || c.is_bold) {
maxRow = Math.max(maxRow, c.row);
maxCol = Math.max(maxCol, c.col);
}
});
merges.forEach(m => {
maxRow = Math.max(maxRow, m.endRow - 1);
maxCol = Math.max(maxCol, m.endCol - 1);
});
maxRow = Math.min(maxRow + 2, (currentSheet.grid_size?.rows || 200) - 1);
maxCol = Math.min(maxCol + 2, (currentSheet.grid_size?.cols || 50) - 1);
let html = "<table>";
for (let r = 0; r <= maxRow; r++) {
html += "<tr>";
for (let c = 0; c <= maxCol; c++) {
const key = `${r},${c}`;
if (hidden.has(key)) continue;
const cell = cellMap[key];
const val = cell ? cell.value : "";
const rowspan = cell && cell.is_merge_origin ? cell.rowspan : 1;
const colspan = cell && cell.is_merge_origin ? cell.colspan : 1;
let cls = [];
if (cell) {
if (cell.is_bold) cls.push("bold-cell");
if (cell.is_italic) cls.push("italic-cell");
if (cell.is_underline) cls.push("underline-cell");
if (cell.wrap_text) cls.push("wrap-cell");
if (cell.has_border_top) cls.push("src-bt");
if (cell.has_border_bottom) cls.push("src-bb");
if (cell.has_border_left) cls.push("src-bl");
if (cell.has_border_right) cls.push("src-br");
if (cell.comment) cls.push("has-comment");
}
const info = cellLabels[key];
if (info) cls.push(`label-${info.label}`);
let styles = [];
if (cell) {
if (cell.bg_color && !info) styles.push(`background:${cell.bg_color}`);
if (cell.font_color) styles.push(`color:${cell.font_color}`);
if (cell.font_size && cell.font_size > 11) styles.push(`font-size:${Math.min(cell.font_size, 24)}px`);
if (cell.h_align) styles.push(`text-align:${cell.h_align}`);
if (cell.v_align) styles.push(`vertical-align:${cell.v_align === "center" ? "middle" : cell.v_align}`);
if (cell.indent && cell.indent > 0) styles.push(`padding-left:${6 + cell.indent * 12}px`);
}
html += `<td data-key="${key}"`;
if (rowspan > 1) html += ` rowspan="${rowspan}"`;
if (colspan > 1) html += ` colspan="${colspan}"`;
if (cls.length) html += ` class="${cls.join(" ")}"`;
if (styles.length) html += ` style="${styles.join(";")}"`;
html += ">";
if (info) {
const tc = TABLE_COLORS[info.table] || TABLE_COLORS[info.table % TABLE_COLORS.length];
html += `<span class="tid" style="background:${tc}${info.table === 0 ? ';opacity:.5' : ''}">T${info.table}</span>`;
}
if (info) {
const tag = labelTag(info.label);
if (tag) html += `<span class="label-tag">${tag}</span>`;
}
let display = escHtml(val);
if (cell && cell.number_format && val) {
display = `<span title="fmt: ${escHtml(cell.number_format)}">${display}</span>`;
}
html += display;
if (cell && cell.formula) {
html += `<span class="formula-sub" title="${escHtml(cell.formula)}">${escHtml(cell.formula)}</span>`;
}
if (cell && cell.comment) {
const tip = cell.comment_author
? `[${escHtml(cell.comment_author)}] ${escHtml(cell.comment)}`
: escHtml(cell.comment);
html += `<span class="comment-mark" title="${tip}"></span>`;
}
html += "</td>";
}
html += "</tr>";
}
html += "</table>";
tableWrap.innerHTML = html;
}
function escHtml(s) {
if (!s) return "";
return s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
}
function labelTag(label) {
if (!label) return "";
const m = label.match(/^(row_header|col_header)_(\d+)$/);
if (m) {
const axis = m[1] === "row_header" ? "R" : "C";
return `${axis}${m[2]}`;
}
const SHORT = {value:"VAL", aggregation:"AGG", header:"HDR", metadata:"MET", comment:"COM", empty:"---", junk:"JNK"};
return SHORT[label] || "";
}
/* ── Selection ─────────────────────────────────────────────────────── */
tableWrap.addEventListener("mousedown", (e) => {
const td = e.target.closest("td[data-key]");
if (!td) return;
const key = td.dataset.key;
if (linkMode) {
e.preventDefault();
e.stopPropagation();
completeLinkMode(key);
return;
}
if (e.shiftKey && lastClickedKey) {
const [r1,c1] = lastClickedKey.split(",").map(Number);
const [r2,c2] = key.split(",").map(Number);
for (let r = Math.min(r1,r2); r <= Math.max(r1,r2); r++) {
for (let c = Math.min(c1,c2); c <= Math.max(c1,c2); c++) {
selectedCells.add(`${r},${c}`);
}
}
} else if (e.metaKey || e.ctrlKey) {
if (selectedCells.has(key)) selectedCells.delete(key);
else selectedCells.add(key);
} else {
selectedCells.clear();
selectedCells.add(key);
}
lastClickedKey = key;
refreshSelection();
});
function refreshSelection() {
tableWrap.querySelectorAll("td.selected").forEach(td => td.classList.remove("selected"));
selectedCells.forEach(key => {
const td = tableWrap.querySelector(`td[data-key="${key}"]`);
if (td) td.classList.add("selected");
});
}
/* ── Keyboard labeling ─────────────────────────────────────────────── */
let waitingForTableNum = false;
let waitingForLevelNum = false;
let digitBuffer = "";
let digitTimer = null;
function flushTableDigits() {
const n = parseInt(digitBuffer);
digitBuffer = "";
digitTimer = null;
if (!isNaN(n) && n >= 0) setActiveTable(n);
}
function flushLevelDigits() {
const n = parseInt(digitBuffer);
digitBuffer = "";
digitTimer = null;
if (!isNaN(n) && n >= 1) setActiveLevel(n);
}
const SIMPLE_LABELS = {
v: "value",
a: "aggregation",
h: "header",
m: "metadata",
n: "comment",
e: "empty",
j: "junk",
};
document.addEventListener("keydown", (e) => {
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") return;
// T + digits for table selection (multi-digit: T12, T25, etc.)
if (waitingForTableNum) {
if (/^\d$/.test(e.key)) {
e.preventDefault();
digitBuffer += e.key;
clearTimeout(digitTimer);
digitTimer = setTimeout(flushTableDigits, 500);
return;
}
clearTimeout(digitTimer);
flushTableDigits();
waitingForTableNum = false;
}
if (e.key === "t" && !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
waitingForTableNum = true;
waitingForLevelNum = false;
digitBuffer = "";
clearTimeout(digitTimer);
return;
}
// L + digits for level selection (multi-digit: L10, L12, etc.)
if (waitingForLevelNum) {
if (/^\d$/.test(e.key)) {
e.preventDefault();
digitBuffer += e.key;
clearTimeout(digitTimer);
digitTimer = setTimeout(flushLevelDigits, 500);
return;
}
clearTimeout(digitTimer);
flushLevelDigits();
waitingForLevelNum = false;
}
if (e.key === "l" && !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
waitingForLevelNum = true;
waitingForTableNum = false;
digitBuffer = "";
clearTimeout(digitTimer);
return;
}
if ((e.ctrlKey || e.metaKey) && e.key === "z" && !e.shiftKey) {
e.preventDefault();
doUndo();
return;
}
if ((e.ctrlKey || e.metaKey) && (e.key === "Z" || e.key === "y")) {
e.preventDefault();
doRedo();
return;
}
if (e.key === "Enter") {
e.preventDefault();
doSave();
return;
}
if (e.key === "Escape" && linkMode) {
e.preventDefault();
exitLinkMode();
return;
}
const k = e.key.toLowerCase();
// Shift+R / Shift+C = group header (one level above active)
if (e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey && (k === "r" || k === "c")) {
if (linkMode) return;
e.preventDefault();
const groupLevel = activeLevel + 1;
if (groupLevel > maxLevel) {
maxLevel = groupLevel;
renderLevelBar();
}
const label = k === "r"
? `row_header_${groupLevel}`
: `col_header_${groupLevel}`;
const DATA_LABELS = new Set(["value","aggregation","row_header","col_header"]);
if (activeTable === 0) {
if (!confirm(t("t0_warning"))) return;
}
if (selectedCells.size > 0) pushUndo();
selectedCells.forEach(key => {
cellLabels[key] = {label: label, table: activeTable};
delete commentLinks[key];
});
applyLabelClasses();
drawCommentLinks();
return;
}
if (e.shiftKey || e.altKey || e.ctrlKey || e.metaKey) return;
if (linkMode) return;
let label = null;
let clear = false;
if (k === "x") {
clear = true;
} else if (k === "r") {
label = `row_header_${activeLevel}`;
} else if (k === "c") {
label = `col_header_${activeLevel}`;
} else if (SIMPLE_LABELS[k]) {
label = SIMPLE_LABELS[k];
} else {
return;
}
e.preventDefault();
const DATA_LABELS = new Set(["value","aggregation","row_header","col_header"]);
const isDataLabel = label && DATA_LABELS.has(label.replace(/_\d+$/, ""));
if (isDataLabel && activeTable === 0) {
if (!confirm(t("t0_warning"))) return;
}
if (selectedCells.size > 0) pushUndo();
if (clear) {
selectedCells.forEach(key => {
delete cellLabels[key];
delete commentLinks[key];
});
} else {
selectedCells.forEach(key => {
cellLabels[key] = {label: label, table: activeTable};
if (label !== "comment") delete commentLinks[key];
});
}
applyLabelClasses();
drawCommentLinks();
if (label === "comment" && selectedCells.size === 1) {
const commentKey = [...selectedCells][0];
enterLinkMode(commentKey);
}
});
function applyLabelClasses() {
const targetSet = new Set(Object.values(commentLinks));
tableWrap.querySelectorAll("td[data-key]").forEach(td => {
const key = td.dataset.key;
td.className = td.className.replace(/\blabel-\S+/g, "").trim();
td.querySelectorAll(".tid").forEach(d => d.remove());
td.querySelectorAll(".label-tag").forEach(d => d.remove());
td.querySelectorAll(".comment-target-badge").forEach(d => d.remove());
const cell = currentSheet && currentSheet.cells.find(c => `${c.row},${c.col}` === key);
if (cell && cell.comment) td.classList.add("has-comment");
const info = cellLabels[key];
if (info) {
td.classList.add(`label-${info.label}`);
const badge = document.createElement("span");
const tc = TABLE_COLORS[info.table] || TABLE_COLORS[info.table % TABLE_COLORS.length];
badge.className = "tid";
badge.style.background = tc;
if (info.table === 0) badge.style.opacity = "0.5";
badge.textContent = `T${info.table}`;
td.prepend(badge);
const tag = labelTag(info.label);
if (tag) {
const tagEl = document.createElement("span");
tagEl.className = "label-tag";
tagEl.textContent = tag;
td.prepend(tagEl);
}
if (info.label === "comment" && commentLinks[key]) {
const linkBadge = document.createElement("span");
linkBadge.className = "comment-target-badge";
linkBadge.textContent = "\u2192" + commentLinks[key];
linkBadge.title = `linked to ${commentLinks[key]}`;
td.appendChild(linkBadge);
}
}
if (targetSet.has(key)) {
const tgtBadge = document.createElement("span");
tgtBadge.className = "comment-target-badge";
tgtBadge.textContent = "\u2190COM";
td.appendChild(tgtBadge);
}
});
}
/* ── Clear All ──────────────────────────────────────────────────────── */
document.getElementById("clear-all-btn").addEventListener("click", () => {
if (!currentSheet) return;
if (Object.keys(cellLabels).length === 0 && Object.keys(commentLinks).length === 0) return;
if (!confirm(t("clear_all_confirm"))) return;
pushUndo();
cellLabels = {};
commentLinks = {};
exitLinkMode();
applyLabelClasses();
drawCommentLinks();
});
/* ── Previous sheet / history ──────────────────────────────────────── */
const prevBtn = document.getElementById("prev-btn");
const historyPopup = document.getElementById("history-popup");
prevBtn.addEventListener("click", async () => {
if (historyPopup.classList.contains("open")) {
historyPopup.classList.remove("open");
return;
}
historyPopup.innerHTML = `<div class="history-header">${t("history_title")}</div>
<div class="history-empty">${t("loading")}</div>`;
historyPopup.classList.add("open");
try {
const resp = await fetch("/api/history", {headers: authHeaders()});
const data = await safeJson(resp);
const sheets = data.sheets || [];
if (sheets.length === 0) {
historyPopup.innerHTML = `<div class="history-header">${t("history_title")}</div>
<div class="history-empty">${t("history_empty")}</div>`;
return;
}
let html = `<div class="history-header">${t("history_title")}</div>`;
for (const s of sheets) {
const ts = s.timestamp ? new Date(s.timestamp).toLocaleString() : "";
const parts = [];
if (s.labeler) parts.push(s.labeler);
if (s.num_labeled_cells) parts.push(`${s.num_labeled_cells} ${t("history_cells")}`);
if (ts) parts.push(ts);
const meta = parts.join(" · ");
html += `<div class="history-item" data-dfid="${s.drive_file_id}"
data-sn="${escHtml(s.sheet_name)}" data-mri="${s.meta_row_index}"
data-lfid="${s.labels_file_id || ""}">
<span class="hi-name">${escHtml(s.file_name)} / ${escHtml(s.sheet_name)}</span>
<span class="hi-meta">${meta}</span>
</div>`;
}
historyPopup.innerHTML = html;
historyPopup.querySelectorAll(".history-item").forEach(item => {
item.addEventListener("click", () => {
historyPopup.classList.remove("open");
loadPreviousSheet(
item.dataset.dfid,
item.dataset.sn,
parseInt(item.dataset.mri),
item.dataset.lfid || "",
);
});
});
} catch (err) {
historyPopup.innerHTML = `<div class="history-empty" style="color:#dc2626">${err.message}</div>`;
}
});
document.addEventListener("click", (e) => {
if (!e.target.closest("#prev-wrapper")) historyPopup.classList.remove("open");
});
async function loadPreviousSheet(driveFileId, sheetName, metaRowIndex, labelsFileId) {
saveBtn.disabled = true; skipBtn.disabled = true;
tableWrap.innerHTML = `<p style="padding:40px;color:#888">${t("loading")}</p>`;
try {
let url = `/api/load_labeled_sheet?drive_file_id=${encodeURIComponent(driveFileId)}`
+ `&sheet_name=${encodeURIComponent(sheetName)}`
+ `&meta_row_index=${metaRowIndex}`;
if (labelsFileId) url += `&labels_file_id=${encodeURIComponent(labelsFileId)}`;
const resp = await fetch(url, {headers: authHeaders()});
const data = await safeJson(resp);
if (!resp.ok) throw new Error(data.detail || `HTTP ${resp.status}`);
currentSheet = data.sheet;
cellLabels = {};
commentLinks = {};
exitLinkMode();
selectedCells.clear();
lastClickedKey = null;
maxTableId = 1;
maxLevel = 3;
activeLevel = 1;
clearUndoRedo();
if (data.saved_labels) {
prefillFromSavedLabels(data.saved_labels);
}
refreshDynamicCSS();
renderTableBar();
renderLevelBar();
renderLegend();
setActiveTable(1);
sheetInfoEl.textContent = `${currentSheet.spreadsheet_id} / ${currentSheet.sheet_name}`;
renderTable();
saveBtn.disabled = false; skipBtn.disabled = false;
} catch (err) {
tableWrap.innerHTML = `<p style="padding:40px;color:red">${t("error_prefix")}${err.message}</p>`;
if (reviewMode) throw err;
}
}
function prefillFromSavedLabels(labels) {
if (!labels || !labels.length) return;
for (const c of labels) {
const key = `${c.row},${c.col}`;
cellLabels[key] = { label: c.label, table: c.table || 0 };
if (c.table > maxTableId) maxTableId = c.table;
const m = c.label.match(/^(?:row_header|col_header)_(\d+)$/);
if (m) {
const lv = parseInt(m[1]);
if (lv > maxLevel) maxLevel = lv;
}
if (c.label === "comment" && c.comment_target_row != null) {
commentLinks[key] = `${c.comment_target_row},${c.comment_target_col}`;
}
}
}
/* ── Skip ─────────────────────────────────────────────────────────── */
skipBtn.addEventListener("click", doSkip);
async function doSkip() {
if (!currentSheet) return;
if (!confirm(t("skip_confirm"))) return;
const username = usernameInput.value.trim() || "anonymous";
saveBtn.disabled = true; skipBtn.disabled = true;
try {
const resp = await fetch("/api/skip", {
method: "POST",
headers: jsonAuthHeaders(),
credentials: "include",
body: JSON.stringify({
drive_file_id: currentSheet.drive_file_id || "",
meta_row_index: currentSheet.meta_row_index || 0,
labeled_by: username,
}),
});
const data = await safeJson(resp);
if (!resp.ok) throw new Error(data.detail || `HTTP ${resp.status}`);
if (data.done) {
const hasWork = data.progress && (data.progress.in_progress > 0 || data.progress.unlabelled > 0);
const msg = hasWork ? t("waiting_others") : t("all_done");
tableWrap.innerHTML = `<div class="done-msg">${msg}</div>`;
progressEl.textContent = hasWork ? "" : t("done");
if (data.progress) updateProgress(data.progress);
sheetInfoEl.textContent = "";
return;
}
currentSheet = data.sheet;
cellLabels = {};
commentLinks = {};
exitLinkMode();
selectedCells.clear();
lastClickedKey = null;
maxTableId = 1;
maxLevel = 3;
activeLevel = 1;
clearUndoRedo();
currentModelVersion = data.model_version || null;
prefillFromPredictions(data.predictions);
refreshDynamicCSS();
renderTableBar();
renderLevelBar();
renderLegend();
setActiveTable(1);
updateProgress(data.progress);
let infoText = `${currentSheet.spreadsheet_id} / ${currentSheet.sheet_name}`;
if (hasPredictions) infoText += ` — ${t("ai_assisted")}`;
sheetInfoEl.textContent = infoText;
renderTable();
saveBtn.disabled = false; skipBtn.disabled = false;
} catch (err) {
alert(t("error_prefix") + err.message);
saveBtn.disabled = false; skipBtn.disabled = false;
}
}
/* ── Save & Next ───────────────────────────────────────────────────── */
saveBtn.addEventListener("click", doSave);
async function doSave() {
if (!currentSheet) return;
if (ragLabelMode) { await doRagLabelSave(); return; }
if (reviewMode) { await doReviewSave(); return; }
const nonEmptyCells = currentSheet.cells.filter(c => c.value !== "");
const labeledCount = Object.keys(cellLabels).length;
const coverage = nonEmptyCells.length > 0 ? labeledCount / nonEmptyCells.length : 1;
if (coverage < 0.5) {
const pct = Math.round(coverage * 100);
if (!confirm(`Only ${pct}% of non-empty cells are labeled. Save anyway?`)) {
return;
}
}
const username = usernameInput.value.trim() || "anonymous";
saveBtn.disabled = true; skipBtn.disabled = true;
saveBtn.textContent = t("saving");
const cellMap = {};
currentSheet.cells.forEach(c => { cellMap[`${c.row},${c.col}`] = c; });
const labeledCells = [];
for (const [key, info] of Object.entries(cellLabels)) {
const [r, c] = key.split(",").map(Number);
const orig = cellMap[key];
const entry = {
row: r, col: c,
value: orig ? orig.value : "",
label: info.label,
table: info.table
};
if (info.label === "comment" && commentLinks[key]) {
const [tr, tc] = commentLinks[key].split(",").map(Number);
entry.comment_target_row = tr;
entry.comment_target_col = tc;
}
labeledCells.push(entry);
}
currentSheet.cells.forEach(cell => {
const key = `${cell.row},${cell.col}`;
if (!cellLabels[key] && cell.value !== "") {
labeledCells.push({row: cell.row, col: cell.col, value: cell.value, label: "unlabeled", table: 0});
}
});
try {
const resp = await fetch("/api/save_and_next", {
method: "POST",
headers: jsonAuthHeaders(),
credentials: "include",
body: JSON.stringify({
spreadsheet_id: currentSheet.spreadsheet_id,
sheet_name: currentSheet.sheet_name,
labeled_by: username,
drive_file_id: currentSheet.drive_file_id || "",
meta_row_index: currentSheet.meta_row_index || 0,
cells: labeledCells,
}),
});
const data = await safeJson(resp);
if (!resp.ok) throw new Error(data.detail || `HTTP ${resp.status}`);
if (data.done) {
const hasWork = data.progress && (data.progress.in_progress > 0 || data.progress.unlabelled > 0);
const msg = hasWork ? t("waiting_others") : t("all_done");
tableWrap.innerHTML = `<div class="done-msg">${msg}</div>`;
progressEl.textContent = hasWork ? "" : t("done");
if (data.progress) updateProgress(data.progress);
sheetInfoEl.textContent = "";
saveBtn.textContent = t("save_btn");
return;
}
currentSheet = data.sheet;
cellLabels = {};
commentLinks = {};
exitLinkMode();
selectedCells.clear();
lastClickedKey = null;
maxTableId = 1;
maxLevel = 3;
activeLevel = 1;
clearUndoRedo();
currentModelVersion = data.model_version || null;
prefillFromPredictions(data.predictions);
refreshDynamicCSS();
renderTableBar();
renderLevelBar();
renderLegend();
setActiveTable(1);
updateProgress(data.progress);
let infoText2 = `${currentSheet.spreadsheet_id} / ${currentSheet.sheet_name}`;
if (hasPredictions) infoText2 += ` — ${t("ai_assisted")}`;
sheetInfoEl.textContent = infoText2;
renderTable();
saveBtn.disabled = false; skipBtn.disabled = false;
saveBtn.textContent = t("save_btn");
fetchAccuracy();
} catch (err) {
alert(t("save_error") + err.message);
saveBtn.disabled = false; skipBtn.disabled = false;
saveBtn.textContent = t("save_btn");
}
}
/* ── RAG eval structure labeling (never training data) ─────────────── */
let ragLabelMode = false;
let ragLabelList = [];
let ragLabelIndex = 0;
const ragLabelBtn = document.getElementById("rag-label-btn");
const ragLabelNav = document.getElementById("rag-label-nav");
const ragLabelCounter = document.getElementById("rag-label-counter");
const ragLabelInput = document.getElementById("rag-label-input");
const ragLabelPrevBtn = document.getElementById("rag-label-prev-btn");
const ragLabelNextBtn = document.getElementById("rag-label-next-btn");
const exitRagLabelBtn = document.getElementById("exit-rag-label");
const prevWrapperEl = document.getElementById("prev-wrapper");
function updateRagLabelUI() {
ragLabelInput.value = ragLabelIndex + 1;
ragLabelInput.max = ragLabelList.length;
ragLabelCounter.textContent = `/ ${ragLabelList.length}`;
ragLabelPrevBtn.disabled = ragLabelIndex <= 0;
ragLabelNextBtn.disabled = ragLabelIndex >= ragLabelList.length - 1;
}
ragLabelInput.addEventListener("change", () => {
const n = parseInt(ragLabelInput.value);
if (n >= 1 && n <= ragLabelList.length) loadRagLabelSheet(n - 1);
else ragLabelInput.value = ragLabelIndex + 1;
});
ragLabelInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); ragLabelInput.blur(); ragLabelInput.dispatchEvent(new Event("change")); }
});
async function loadRagLabelSheet(idx) {
if (idx < 0 || idx >= ragLabelList.length) return;
ragLabelIndex = idx;
updateRagLabelUI();
const s = ragLabelList[idx];
saveBtn.disabled = true; skipBtn.disabled = true;
tableWrap.innerHTML = `<p style="padding:40px;color:#888">${t("loading")}</p>`;
try {
const url = `/api/rag_label/sheet?sheet_id=${encodeURIComponent(s.sheet_id)}`;
const resp = await fetch(url, {headers: authHeaders()});
const data = await safeJson(resp);
if (!resp.ok) throw new Error(data.detail || `HTTP ${resp.status}`);
currentSheet = data.sheet;
if (currentSheet.rag_sheet_id == null) currentSheet.rag_sheet_id = s.sheet_id;
cellLabels = {};
commentLinks = {};
exitLinkMode();
selectedCells.clear();
lastClickedKey = null;
maxTableId = 1;
maxLevel = 3;
activeLevel = 1;
clearUndoRedo();
currentModelVersion = data.model_version || null;
if (data.saved_labels && data.saved_labels.length) {
prefillFromSavedLabels(data.saved_labels);
} else {
prefillFromPredictions(data.predictions);
}
refreshDynamicCSS();
renderTableBar();
renderLevelBar();
renderLegend();
setActiveTable(1);
progressEl.textContent = `RAG structure ${ragLabelIndex + 1}/${ragLabelList.length} (not training)`;
let infoText = `[RAG] ${currentSheet.spreadsheet_id} / ${currentSheet.sheet_name}`;
if (hasPredictions) infoText += ` — ${t("ai_assisted")}`;
sheetInfoEl.textContent = infoText;
renderTable();
saveBtn.disabled = false;
skipBtn.disabled = true;
saveBtn.textContent = t("rag_label_save_btn") || "SAVE (RAG only) \u25B6";
} catch (err) {
tableWrap.innerHTML = `<p style="padding:40px;color:red">${t("error_prefix")}${err.message}</p>`;
}
}
function enterRagLabelMode(sheets, nQueriesTotal) {
if (reviewMode) exitReviewMode();
ragLabelList = sheets;
ragLabelIndex = 0;
ragLabelMode = true;
ragLabelBtn.classList.add("active");
ragLabelNav.classList.add("visible");
skipBtn.style.display = "none";
if (prevWrapperEl) prevWrapperEl.style.display = "none";
const nq = nQueriesTotal != null ? nQueriesTotal : sheets.reduce((a, s) => a + (s.qa_count || 0), 0);
progressEl.textContent = `RAG structure: ${sheets.length} sheet(s), ${nq} Q&A — not training`;
updateRagLabelUI();
loadRagLabelSheet(0);
}
function exitRagLabelMode(reloadNext = true) {
ragLabelMode = false;
ragLabelList = [];
ragLabelIndex = 0;
ragLabelBtn.classList.remove("active");
ragLabelNav.classList.remove("visible");
skipBtn.style.display = "";
if (prevWrapperEl) prevWrapperEl.style.display = "";
saveBtn.textContent = t("save_btn");
if (reloadNext) loadNextSheet();
}
ragLabelBtn.addEventListener("click", async () => {
if (ragLabelMode) { exitRagLabelMode(true); return; }
if (reviewMode) exitReviewMode();
ragLabelBtn.textContent = "...";
try {
const resp = await fetch("/api/rag_label/sheets", {headers: authHeaders()});
const data = await safeJson(resp);
if (!resp.ok) throw new Error(data.detail || `HTTP ${resp.status}`);
const sheets = data.sheets || [];
const d = data.diagnostics || {};
if (sheets.length === 0) {
let msg = "No RAG sheets could be listed.\n\n";
if (d.dataset_exists === false && !d.drive_rag_folder_configured) {
msg += "The file is missing on this server:\n" + (d.dataset_path || "data/rag_eval_dataset.json")
+ "\n\nOn Hugging Face Spaces, the data/ folder is usually not in git. Either upload rag_eval_dataset.json into data/, "
+ "or use OAuth and set DRIVE_ROOT_FOLDER_ID (creates 'rag_eval' next to 'data' and 'labels'), "
+ "or DRIVE_RAG_EVAL_FOLDER_ID, or DRIVE_RAG_EVAL_PARENT_FOLDER_ID, or DRIVE_LABELS_FOLDER_ID (sibling of 'labels').";
} else if (d.dataset_exists === false && d.drive_rag_folder_configured) {
msg += "Local mirror missing and no rag_eval_dataset.json on Drive in the resolved 'rag_eval' folder. "
+ "Upload rag_eval_dataset.json there, or add a Q&A from /rag-eval to create it.";
} else if ((d.raw_query_count || 0) === 0) {
msg += "No Q&A rows yet. Add questions from the RAG eval UI (e.g. /rag-eval) for sheets in metadata with status rag_eval; the dataset file on Drive is OK.";
} else {
msg += "Found " + d.raw_query_count + " queries, but none could be matched to a sheet. "
+ "Each query needs sheet_id or both drive_file_id and sheet_name.";
if ((d.queries_skipped_no_sheet_key || 0) > 0) {
msg += "\n(" + d.queries_skipped_no_sheet_key + " queries skipped for missing ids.)";
}
}
alert(msg);
return;
}
enterRagLabelMode(sheets, data.n_queries);
} catch (err) {
alert("RAG label error: " + err.message);
} finally {
ragLabelBtn.textContent = "RAG label";
}
});
ragLabelPrevBtn.addEventListener("click", () => loadRagLabelSheet(ragLabelIndex - 1));
ragLabelNextBtn.addEventListener("click", () => loadRagLabelSheet(ragLabelIndex + 1));
exitRagLabelBtn.addEventListener("click", () => exitRagLabelMode(true));
async function doRagLabelSave() {
if (!currentSheet || !currentSheet.rag_sheet_id) return;
const username = usernameInput.value.trim() || "anonymous";
saveBtn.disabled = true;
saveBtn.textContent = t("saving");
const cellMap = {};
currentSheet.cells.forEach(c => { cellMap[`${c.row},${c.col}`] = c; });
const labeledCells = [];
for (const [key, info] of Object.entries(cellLabels)) {
const [r, c] = key.split(",").map(Number);
const orig = cellMap[key];
const entry = { row: r, col: c, value: orig ? orig.value : "", label: info.label, table: info.table };
if (info.label === "comment" && commentLinks[key]) {
const [tr, tc] = commentLinks[key].split(",").map(Number);
entry.comment_target_row = tr;
entry.comment_target_col = tc;
}
labeledCells.push(entry);
}
currentSheet.cells.forEach(cell => {
const key = `${cell.row},${cell.col}`;
if (!cellLabels[key] && cell.value !== "") {
labeledCells.push({row: cell.row, col: cell.col, value: cell.value, label: "unlabeled", table: 0});
}
});
try {
const resp = await fetch("/api/rag_label/save", {
method: "POST",
headers: jsonAuthHeaders(),
credentials: "include",
body: JSON.stringify({
sheet_id: currentSheet.rag_sheet_id,
spreadsheet_id: currentSheet.spreadsheet_id,
sheet_name: currentSheet.sheet_name,
labeled_by: username,
drive_file_id: currentSheet.drive_file_id || "",
cells: labeledCells,
}),
});
const data = await safeJson(resp);
if (!resp.ok) throw new Error(data.detail || `HTTP ${resp.status}`);
if (ragLabelIndex < ragLabelList.length - 1) {
await loadRagLabelSheet(ragLabelIndex + 1);
} else {
tableWrap.innerHTML = `<div class="done-msg">${t("rag_label_done") || "RAG structure labeling complete. Labels are in data/rag_eval_labeled/ (never used for training)."}</div>`;
sheetInfoEl.textContent = "";
exitRagLabelMode(false);
progressEl.textContent = "";
loadNextSheet();
}
} catch (err) {
let msg = t("save_error") + err.message;
if (String(err.message || "").includes("Not authenticated")) {
msg += " " + (t("relogin_hint") || "Log in again (refresh the page and enter the password).");
authToken = "";
localStorage.removeItem("labeler_token");
if (loginOverlay) loginOverlay.style.display = "flex";
}
alert(msg);
} finally {
saveBtn.disabled = false;
saveBtn.textContent = t("rag_label_save_btn") || "SAVE (RAG only) \u25B6";
}
}
/* ── Review mode ──────────────────────────────────────────────────── */
let reviewMode = false;
let reviewList = [];
let reviewIndex = 0;
const reviewBtn = document.getElementById("review-btn");
const reviewNav = document.getElementById("review-nav");
const reviewCounter = document.getElementById("review-counter");
const reviewInput = document.getElementById("review-input");
const reviewPrevBtn = document.getElementById("review-prev-btn");
const reviewNextBtn = document.getElementById("review-next-btn");
const exitReviewBtn = document.getElementById("exit-review");
function updateReviewUI() {
reviewInput.value = reviewIndex + 1;
reviewInput.max = reviewList.length;
reviewCounter.textContent = `/ ${reviewList.length}`;
reviewPrevBtn.disabled = reviewIndex <= 0;
reviewNextBtn.disabled = reviewIndex >= reviewList.length - 1;
}
reviewInput.addEventListener("change", () => {
const n = parseInt(reviewInput.value);
if (n >= 1 && n <= reviewList.length) loadReviewSheet(n - 1);
else reviewInput.value = reviewIndex + 1;
});
reviewInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); reviewInput.blur(); reviewInput.dispatchEvent(new Event("change")); }
});
async function loadReviewSheet(idx, skipFailures = true) {
if (idx < 0 || idx >= reviewList.length) return;
reviewIndex = idx;
updateReviewUI();
const s = reviewList[idx];
try {
await loadPreviousSheet(s.drive_file_id, s.sheet_name, s.meta_row_index, s.labels_file_id || "");
saveBtn.textContent = t("review_save_btn") || "SAVE & NEXT \u25B6";
} catch (err) {
if (skipFailures && idx < reviewList.length - 1) {
console.warn(`Review: skipping ${s.sheet_name} (${err.message}), trying next...`);
await loadReviewSheet(idx + 1, true);
}
}
}
function enterReviewMode(sheets) {
reviewList = sheets;
reviewIndex = 0;
reviewMode = true;
reviewBtn.classList.add("active");
reviewNav.classList.add("visible");
skipBtn.style.display = "none";
updateReviewUI();
loadReviewSheet(0);
}
function exitReviewMode() {
reviewMode = false;
reviewList = [];
reviewIndex = 0;
reviewBtn.classList.remove("active");
reviewNav.classList.remove("visible");
skipBtn.style.display = "";
saveBtn.textContent = t("save_btn");
loadNextSheet();
}
reviewBtn.addEventListener("click", async () => {
if (reviewMode) { exitReviewMode(); return; }
if (ragLabelMode) exitRagLabelMode(false);
reviewBtn.textContent = "...";
try {
const resp = await fetch("/api/review_list", {headers: authHeaders()});
const data = await safeJson(resp);
if (!resp.ok) throw new Error(data.detail || `HTTP ${resp.status}`);
const sheets = data.sheets || [];
if (sheets.length === 0) { alert("No labeled sheets to review."); return; }
enterReviewMode(sheets);
} catch (err) {
alert("Review error: " + err.message);
} finally {
reviewBtn.textContent = "review";
}
});
reviewPrevBtn.addEventListener("click", () => loadReviewSheet(reviewIndex - 1));
reviewNextBtn.addEventListener("click", () => loadReviewSheet(reviewIndex + 1));
exitReviewBtn.addEventListener("click", exitReviewMode);
async function doReviewSave() {
if (!currentSheet) return;
const username = usernameInput.value.trim() || "anonymous";
saveBtn.disabled = true;
saveBtn.textContent = t("saving");
const cellMap = {};
currentSheet.cells.forEach(c => { cellMap[`${c.row},${c.col}`] = c; });
const labeledCells = [];
for (const [key, info] of Object.entries(cellLabels)) {
const [r, c] = key.split(",").map(Number);
const orig = cellMap[key];
const entry = { row: r, col: c, value: orig ? orig.value : "", label: info.label, table: info.table };
if (info.label === "comment" && commentLinks[key]) {
const [tr, tc] = commentLinks[key].split(",").map(Number);
entry.comment_target_row = tr;
entry.comment_target_col = tc;
}
labeledCells.push(entry);
}
currentSheet.cells.forEach(cell => {
const key = `${cell.row},${cell.col}`;
if (!cellLabels[key] && cell.value !== "") {
labeledCells.push({row: cell.row, col: cell.col, value: cell.value, label: "unlabeled", table: 0});
}
});
try {
const resp = await fetch("/api/save_review", {
method: "POST",
headers: jsonAuthHeaders(),
credentials: "include",
body: JSON.stringify({
spreadsheet_id: currentSheet.spreadsheet_id,
sheet_name: currentSheet.sheet_name,
labeled_by: username,
drive_file_id: currentSheet.drive_file_id || "",
meta_row_index: currentSheet.meta_row_index || 0,
cells: labeledCells,
}),
});
const data = await safeJson(resp);
if (!resp.ok) throw new Error(data.detail || `HTTP ${resp.status}`);
if (reviewIndex < reviewList.length - 1) {
await loadReviewSheet(reviewIndex + 1);
} else {
tableWrap.innerHTML = `<div class="done-msg">${t("review_done")}</div>`;
sheetInfoEl.textContent = "";
saveBtn.disabled = true;
}
} catch (err) {
alert(t("save_error") + err.message);
} finally {
saveBtn.disabled = false;
saveBtn.textContent = t("review_save_btn") || "SAVE & NEXT \u25B6";
}
}
/* ── Auth ───────────────────────────────────────────────────────────── */
const loginOverlay = document.getElementById("login-overlay");
const loginBtn = document.getElementById("login-btn");
const loginPwd = document.getElementById("login-password");
const loginErr = document.getElementById("login-error");
let authToken = localStorage.getItem("labeler_token") || "";
/** Only Authorization — never send ``Content-Type: application/json`` on GET (breaks some proxies / mobile). */
function authHeaders() {
const h = {};
if (authToken) h["Authorization"] = `Bearer ${authToken}`;
return h;
}
function jsonAuthHeaders() {
const h = authHeaders();
h["Content-Type"] = "application/json";
return h;
}
async function safeJson(resp) {
const text = await resp.text();
try { return JSON.parse(text); }
catch { throw new Error(text.startsWith("<") ? "Server returned HTML instead of JSON — the app may be restarting. Try again in a minute." : text.slice(0, 200)); }
}
async function checkAuth() {
let mode = { password_required: true };
try {
const rm = await fetch("/api/auth/mode");
if (rm.ok) {
const ct = rm.headers.get("content-type") || "";
if (ct.includes("application/json")) mode = await rm.json();
}
} catch { /* ignore */ }
if (!mode.password_required) {
loginOverlay.style.display = "none";
loadNextSheet();
return;
}
try {
const r = await fetch("/api/auth/check", {headers: authHeaders(), credentials: "include"});
const ct = r.headers.get("content-type") || "";
if (!r.ok || !ct.includes("application/json")) {
authToken = "";
localStorage.removeItem("labeler_token");
loginOverlay.style.display = "flex";
return;
}
const d = await r.json();
if (d.authenticated) {
loginOverlay.style.display = "none";
loadNextSheet();
} else {
authToken = "";
localStorage.removeItem("labeler_token");
loginOverlay.style.display = "flex";
}
} catch (e) {
console.warn("auth/check failed", e);
authToken = "";
localStorage.removeItem("labeler_token");
loginOverlay.style.display = "flex";
}
}
async function doLogin() {
loginErr.style.display = "none";
try {
const r = await fetch("/api/auth", {
method: "POST",
headers: {"Content-Type": "application/json"},
credentials: "include",
body: JSON.stringify({password: loginPwd.value}),
});
if (r.ok) {
const d = await r.json();
if (d.token) {
authToken = d.token;
localStorage.setItem("labeler_token", authToken);
}
loginOverlay.style.display = "none";
loadNextSheet();
} else {
loginErr.style.display = "block";
}
} catch {
loginErr.style.display = "block";
}
}
loginBtn.addEventListener("click", doLogin);
loginPwd.addEventListener("keydown", (e) => { if (e.key === "Enter") doLogin(); });
/* ── Help panel ──────────────────────────────────────────────────────── */
const helpOverlay = document.getElementById("help-overlay");
const helpBody = document.getElementById("help-body");
const helpTitle = document.getElementById("help-title");
let helpLang = "en";
const HELP = {};
HELP.en = `
<h3>What you are doing</h3>
<p>You are labeling cells in spreadsheets so that a machine-learning pipeline can learn their structure.
Your labels are used for: <strong>(1)</strong> training a graph neural network that predicts cell roles,
<strong>(2)</strong> extracting text chunks an LLM can search over.</p>
<p class="warn">A mislabeled header means every value under it loses its meaning.</p>
<h3>Quick reference</h3>
<table class="ref-table">
<tr><th>Key</th><th>Label</th><th>Use for</th></tr>
<tr><td><kbd>V</kbd></td><td>value</td><td>Any data cell &mdash; numeric or text (measurement, name, code, status)</td></tr>
<tr><td><kbd>R</kbd></td><td>row_header</td><td>Row label on the left side (at active level)</td></tr>
<tr><td><kbd>Shift</kbd>+<kbd>R</kbd></td><td>row_header</td><td>Same, one level above active (group header shortcut)</td></tr>
<tr><td><kbd>C</kbd></td><td>col_header</td><td>Column label at the top (at active level)</td></tr>
<tr><td><kbd>Shift</kbd>+<kbd>C</kbd></td><td>col_header</td><td>Same, one level above active</td></tr>
<tr><td><kbd>A</kbd></td><td>aggregation</td><td>Computed cell (SUM, AVERAGE, formula)</td></tr>
<tr><td><kbd>H</kbd></td><td>header</td><td>Table title, section heading &mdash; gives context to the data below</td></tr>
<tr><td><kbd>M</kbd></td><td>metadata</td><td>Footnotes, source notes, dates, disclaimers &mdash; not a title</td></tr>
<tr><td><kbd>N</kbd></td><td>comment</td><td>Inline footnote/annotation referring to another cell</td></tr>
<tr><td><kbd>E</kbd></td><td>empty</td><td>Intentionally blank spacer cell</td></tr>
<tr><td><kbd>J</kbd></td><td>junk</td><td>Useless/erroneous content to discard (e.g. &ldquo;Unnamed 1&rdquo;)</td></tr>
<tr><td><kbd>X</kbd></td><td><em>(clear)</em></td><td>Remove a label</td></tr>
</table>
<h3>Selection</h3>
<ul>
<li><strong>Click</strong> a cell to select it.</li>
<li><kbd>Shift</kbd>+click &mdash; range select from last click.</li>
<li><kbd>Ctrl/Cmd</kbd>+click &mdash; toggle individual cells.</li>
<li>Then press a label key to apply to entire selection.</li>
</ul>
<h3>Tables</h3>
<p>Each sheet can have multiple <strong>tables</strong> (separate grids). Assign cells to the correct table.</p>
<ul>
<li><strong>T0</strong> = sheet-level (titles, notes for the whole sheet).</li>
<li><strong>T1, T2, &hellip;</strong> = individual tables.</li>
<li>Set active table: click in the <strong>Table</strong> bar or <kbd>T</kbd> + digits (e.g. <kbd>T</kbd><kbd>1</kbd><kbd>2</kbd> for T12).</li>
<li>Click <strong>[+]</strong> to add more tables (no limit).</li>
</ul>
<p class="warn">T0 cells do NOT produce chunks. Only T1+ cells generate data. Never put value/header cells in T0.</p>
<h3>Header levels</h3>
<p>Headers can have multiple hierarchy levels. The <strong>Level</strong> bar controls which level <kbd>R</kbd> and <kbd>C</kbd> assign.</p>
<ul>
<li><strong>L1</strong> = innermost (most specific, closest to data). Start here.</li>
<li><strong>L2</strong> = broader grouping that contains L1.</li>
<li><strong>L3, L4, &hellip;</strong> = even broader.</li>
<li>Set level: click in the <strong>Level</strong> bar or <kbd>L</kbd> + digits (e.g. L12).</li>
</ul>
<h4>Row headers (R) &mdash; multi-level (horizontal)</h4>
<p>Multi-level row headers occupy <strong>separate columns</strong>, broadest on the left (highest level number):</p>
<pre> Region (R3) | Country (R2) | City (R1) | Sales (V)
Europe | Poland | Warsaw | 100
| | Krak&oacute;w | 80
| Germany | Berlin | 120</pre>
<p>System scans <strong>leftward</strong> &rarr; chunk: <code>Row: Europe &gt; Poland &gt; Warsaw</code></p>
<h4>Row headers &mdash; vertical (group rows above data)</h4>
<p>Common in financial reports / SAP exports: <strong>group header on its own row</strong> above detail rows, often with aggregation values:</p>
<pre> Q1 Q2
Revenue | 50000 | 60000 | &larr; Shift+R (R2), data = A
Products | 30000 | 35000 | &larr; R (R1), data = V
Services | 20000 | 25000 | &larr; R (R1), data = V
Expenses | 40000 | 45000 | &larr; Shift+R (R2), data = A
Salaries | 25000 | 28000 | &larr; R (R1), data = V
Rent | 15000 | 17000 | &larr; R (R1), data = V</pre>
<p><strong>Workflow:</strong> label detail row headers with <kbd>R</kbd>, group headers with <kbd>Shift+R</kbd> (one level up, no switching needed). Group row data &rarr; <kbd>A</kbd> (aggregation, even without formulas).</p>
<p>System scans <strong>leftward + upward</strong> &rarr; chunk: <code>Row: Revenue &gt; Products</code></p>
<h4>Column headers (C) &mdash; multi-level</h4>
<p>Multi-level column headers are stacked in <strong>separate rows</strong>, broadest on top (highest level number):</p>
<pre> 2024 Revenue (C3, merged)
Q1 | Q2 (C2)
Jan|Feb | Mar|Apr (C1, closest to data)
100|120 | 130|140 (values)</pre>
<p>System scans <strong>upward</strong> &rarr; chunk: <code>Column: 2024 Revenue &gt; Q1 &gt; Jan</code></p>
<h3>Label details</h3>
<h4>value (V)</h4>
<p>Any data cell inside the grid &mdash; numeric (amounts, quantities) or text (names, codes, statuses). Each value cell produces a chunk with full header path.</p>
<h4>aggregation (A)</h4>
<p>Computed from other cells (SUM, AVERAGE, etc.). System extracts the formula automatically. Do <strong>not</strong> label as value.</p>
<p><strong>Aggregation vs. header levels:</strong> Rows stacked vertically in the same column (Salaries, Benefits, Total) are NOT header levels.
"Salaries" and "Benefits" are row_header L1. "Total" is row_header for an aggregation row. The formula captures the relationship.</p>
<h4>header (H)</h4>
<p>Table title or section heading that gives context to the data below. Examples: &ldquo;Consolidated Balance Sheet&rdquo;, &ldquo;Q3 2024 Revenue&rdquo;, &ldquo;(In millions)&rdquo;.</p>
<ul>
<li><strong>T0 + H</strong> = sheet-level header (appears in ALL chunks as context).</li>
<li><strong>T1+ H</strong> = table-level header (appears only in that table's chunks).</li>
</ul>
<p><strong>Header vs. metadata:</strong> If the cell is a title/heading that names or describes the data, use <code>header</code>. If it's a footnote, disclaimer, source note, or other annotation, use <code>metadata</code>.</p>
<h4>metadata (M)</h4>
<p>Footnotes, source notes, dates, disclaimers, URLs &mdash; supplementary information that is NOT a title or heading.</p>
<ul>
<li><strong>T0 + M</strong> = sheet-level metadata (may appear in chunks as context).</li>
<li><strong>T1+ M</strong> = table-level metadata.</li>
</ul>
<h4>comment (N) &mdash; Inline footnote</h4>
<p>A cell containing a note/annotation referring to another cell &mdash; written in a regular cell instead of using Excel's built-in comments.
Examples: <code>*estimated</code>, <code>see note 3</code>, <code>revised Q3</code>.</p>
<ol>
<li>Select the cell with the comment text, press <kbd>N</kbd>.</li>
<li>A banner appears: click the <strong>target cell</strong> the comment refers to.</li>
<li>A dashed orange arrow connects the two cells.</li>
<li>Press <kbd>Esc</kbd> to cancel linking.</li>
</ol>
<p>The comment text appears in the target cell's chunk as <code>Note: ...</code>.</p>
<p><strong>Comment vs. metadata:</strong> Use <code>metadata</code> for broad context (titles, dates). Use <code>comment</code> for annotations tied to a <em>specific cell</em>.</p>
<p><strong>Excel built-in comments</strong> (orange triangle) are extracted automatically &mdash; no labeling needed.</p>
<h4>empty (E)</h4>
<p>Structurally meaningful blank cells (spacers between tables). Unlabeled non-empty cells are saved as "unlabeled".</p>
<h4>junk (J)</h4>
<p>Cells containing useless or erroneous content that should be <strong>discarded</strong> when building chunks. These are cells that obviously ended up in the spreadsheet by mistake or carry no meaningful information.</p>
<p>Examples: auto-generated placeholder headers like <code>Unnamed: 0</code>, <code>Unnamed 1</code>, <code>Column1</code>; corrupted or garbled text; test/debug data left behind; duplicated artifact cells.</p>
<p>Junk cells are <strong>completely ignored</strong> during chunk extraction &mdash; they produce no chunks, are not used as headers, and do not appear in any output. The model also learns to recognize them so they can be filtered automatically.</p>
<p><strong>Junk vs. empty:</strong> Use <code>empty</code> for intentionally blank spacer cells. Use <code>junk</code> for cells that <em>have content</em> but that content is meaningless or erroneous.</p>
<h3>Multiple tables on one sheet</h3>
<ol>
<li>Identify distinct data grids. Each grid with own headers = separate table.</li>
<li>Sheet-level content (overall title, date, footnotes) &rarr; <strong>T0</strong>.</li>
<li>Switch to <strong>T1</strong>, label first table's cells.</li>
<li>Switch to <strong>T2</strong>, label second table's cells.</li>
<li>Add more with <strong>[+]</strong> as needed.</li>
</ol>
<h3>Common mistakes</h3>
<ol>
<li><strong>Forgetting to switch tables</strong> &mdash; headers bleed across tables.</li>
<li><strong>Labeling aggregation as value</strong> &mdash; chunk loses formula info.</li>
<li><strong>Wrong header level</strong> &mdash; most specific = L1, broader groupings = higher numbers.</li>
<li><strong>Confusing aggregation with header levels</strong> &mdash; vertical stacking in same column = aggregation, NOT levels.</li>
<li><strong>Putting data in T0</strong> &mdash; no chunk is produced.</li>
<li><strong>Not labeling empty spacers</strong> &mdash; mark separating rows/cols as empty.</li>
<li><strong>Labeling footnotes as metadata</strong> &mdash; cell-specific annotations should be <code>comment</code> (N), not <code>metadata</code>.</li>
<li><strong>Forgetting to link a comment</strong> &mdash; always click the target cell after pressing N, or the note won't attach to any chunk.</li>
</ol>
<h3>Self-check before saving</h3>
<ul class="check-list">
<li>Every data cell has a label (value or aggregation).</li>
<li>Every header cell has the correct label and level.</li>
<li>Tables are separated by different table IDs (check colored dots).</li>
<li>Sheet-level titles/notes are on T0; table titles on their table's ID.</li>
<li>Aggregation cells (bold totals, formulas) are labeled A, not V.</li>
<li>Hierarchy: level 1 = most specific, higher numbers = broader groupings.</li>
<li>Inline footnotes labeled as comment (N), linked to target (orange arrow visible).</li>
</ul>
<h3>All keyboard shortcuts</h3>
<table class="ref-table">
<tr><th>Shortcut</th><th>Action</th></tr>
<tr><td><kbd>V</kbd></td><td>Label as value</td></tr>
<tr><td><kbd>R</kbd></td><td>Label as row_header (at active level)</td></tr>
<tr><td><kbd>Shift</kbd>+<kbd>R</kbd></td><td>Label as row_header one level above active (group header)</td></tr>
<tr><td><kbd>C</kbd></td><td>Label as col_header (at active level)</td></tr>
<tr><td><kbd>Shift</kbd>+<kbd>C</kbd></td><td>Label as col_header one level above active</td></tr>
<tr><td><kbd>A</kbd></td><td>Label as aggregation</td></tr>
<tr><td><kbd>H</kbd></td><td>Label as header (table title / section heading)</td></tr>
<tr><td><kbd>M</kbd></td><td>Label as metadata (footnotes, notes, disclaimers)</td></tr>
<tr><td><kbd>N</kbd></td><td>Label as comment (then click target cell)</td></tr>
<tr><td><kbd>E</kbd></td><td>Label as empty</td></tr>
<tr><td><kbd>J</kbd></td><td>Label as junk (discarded from chunks)</td></tr>
<tr><td><kbd>X</kbd></td><td>Clear label</td></tr>
<tr><td><kbd>T</kbd> + digits</td><td>Set active table (e.g. T12)</td></tr>
<tr><td><kbd>L</kbd> + digits</td><td>Set active level (e.g. L5)</td></tr>
<tr><td><kbd>Esc</kbd></td><td>Cancel comment linking</td></tr>
<tr><td><kbd>Enter</kbd></td><td>Save &amp; load next sheet</td></tr>
<tr><td><kbd>Shift</kbd>+click</td><td>Range select</td></tr>
<tr><td><kbd>Ctrl/Cmd</kbd>+click</td><td>Toggle select</td></tr>
</table>
`;
HELP.pl = `
<h3>Co robisz i dlaczego</h3>
<p>Oznaczasz kom&oacute;rki w arkuszach kalkulacyjnych, aby system uczenia maszynowego m&oacute;gł automatycznie rozumieć ich strukturę.
Twoje etykiety służą do: <strong>(1)</strong> trenowania sieci neuronowej przewidującej role kom&oacute;rek,
<strong>(2)</strong> wyodrębniania fragment&oacute;w tekstu (chunk&oacute;w), po kt&oacute;rych LLM może wyszukiwać.</p>
<p class="warn">Źle oznaczony nagł&oacute;wek oznacza, że każda wartość pod nim traci swoje znaczenie.</p>
<h3>Szybka ściąga</h3>
<table class="ref-table">
<tr><th>Klawisz</th><th>Etykieta</th><th>Do czego</th></tr>
<tr><td><kbd>V</kbd></td><td>value</td><td>Kom&oacute;rka z danymi &mdash; liczbowymi lub tekstowymi (pomiar, imię, kod, status)</td></tr>
<tr><td><kbd>R</kbd></td><td>row_header</td><td>Nagł&oacute;wek wiersza po lewej stronie (na aktywnym poziomie)</td></tr>
<tr><td><kbd>Shift</kbd>+<kbd>R</kbd></td><td>row_header</td><td>Jak wyżej, ale jeden poziom powyżej aktywnego (skr&oacute;t dla nagł&oacute;wka grupy)</td></tr>
<tr><td><kbd>C</kbd></td><td>col_header</td><td>Nagł&oacute;wek kolumny na g&oacute;rze (na aktywnym poziomie)</td></tr>
<tr><td><kbd>Shift</kbd>+<kbd>C</kbd></td><td>col_header</td><td>Jak wyżej, ale jeden poziom powyżej aktywnego</td></tr>
<tr><td><kbd>A</kbd></td><td>aggregation</td><td>Kom&oacute;rka obliczeniowa (SUMA, ŚREDNIA, formuła)</td></tr>
<tr><td><kbd>H</kbd></td><td>header</td><td>Tytuł tabeli, nagł&oacute;wek sekcji &mdash; kontekst dla danych poniżej</td></tr>
<tr><td><kbd>M</kbd></td><td>metadata</td><td>Przypisy, noty źr&oacute;dłowe, daty, zastrzeżenia &mdash; nie tytuł</td></tr>
<tr><td><kbd>N</kbd></td><td>comment</td><td>Komentarz/przypis w kom&oacute;rce odnoszący się do innej kom&oacute;rki</td></tr>
<tr><td><kbd>E</kbd></td><td>empty</td><td>Celowo pusta kom&oacute;rka (odstęp strukturalny)</td></tr>
<tr><td><kbd>J</kbd></td><td>junk</td><td>Bezużyteczna/błędna treść do odrzucenia (np. &bdquo;Unnamed 1&rdquo;)</td></tr>
<tr><td><kbd>X</kbd></td><td><em>(wyczyść)</em></td><td>Usuń etykietę</td></tr>
</table>
<h3>Zaznaczanie</h3>
<ul>
<li><strong>Kliknij</strong> kom&oacute;rkę, aby ją zaznaczyć.</li>
<li><kbd>Shift</kbd>+klik &mdash; zaznacz prostokątny zakres.</li>
<li><kbd>Ctrl/Cmd</kbd>+klik &mdash; dodaj/usuń pojedyncze kom&oacute;rki.</li>
<li>Naciśnij klawisz etykiety, aby zastosować do zaznaczenia.</li>
</ul>
<h3>Tabele</h3>
<p>Każdy arkusz może zawierać wiele <strong>tabel</strong> (oddzielnych siatek). Przypisz kom&oacute;rki do właściwej tabeli.</p>
<ul>
<li><strong>T0</strong> = poziom arkusza (tytuły, notatki dla całego arkusza).</li>
<li><strong>T1, T2, &hellip;</strong> = poszczeg&oacute;lne tabele.</li>
<li>Ustaw aktywną tabelę: kliknij w pasku <strong>Table</strong> lub <kbd>T</kbd> + cyfry (np. <kbd>T</kbd><kbd>1</kbd><kbd>2</kbd> dla T12).</li>
<li>Kliknij <strong>[+]</strong>, aby dodać więcej (bez limitu).</li>
</ul>
<p class="warn">Kom&oacute;rki T0 NIE generują chunk&oacute;w. Tylko T1+ generuje dane. Nigdy nie umieszczaj value/header w T0.</p>
<h3>Poziomy nagł&oacute;wk&oacute;w</h3>
<p>Nagł&oacute;wki mogą mieć wiele poziom&oacute;w hierarchii. Pasek <strong>Level</strong> kontroluje, kt&oacute;ry poziom przypisują <kbd>R</kbd> i <kbd>C</kbd>.</p>
<ul>
<li><strong>L1</strong> = najbardziej szczeg&oacute;łowy (najbliższy danych). Zacznij tutaj.</li>
<li><strong>L2</strong> = szersze grupowanie obejmujące L1.</li>
<li><strong>L3, L4, &hellip;</strong> = jeszcze szersze grupowania.</li>
<li>Ustaw poziom: kliknij w pasku <strong>Level</strong> lub <kbd>L</kbd> + cyfry (np. L12).</li>
</ul>
<h4>Nagł&oacute;wki wierszy (R) &mdash; wielopoziomowe (poziome)</h4>
<p>Wielopoziomowe nagł&oacute;wki wierszy zajmują <strong>osobne kolumny</strong>, najszerszy po lewej (najwyższy numer poziomu):</p>
<pre> Region (R3) | Kraj (R2) | Miasto (R1) | Sprzedaż (V)
Europa | Polska | Warszawa | 100
| | Krak&oacute;w | 80
| Niemcy | Berlin | 120</pre>
<p>System skanuje <strong>w lewo</strong> &rarr; chunk: <code>Row: Europa &gt; Polska &gt; Warszawa</code></p>
<h4>Nagł&oacute;wki wierszy &mdash; pionowe (wiersze grupujące nad danymi)</h4>
<p>Częste w raportach finansowych / eksportach z SAP: <strong>nagł&oacute;wek grupy na osobnym wierszu</strong> nad wierszami szczeg&oacute;łowymi, często z wartościami agregacji:</p>
<pre> Q1 Q2
Przychody | 50000 | 60000 | &larr; Shift+R (R2), dane = A
Produkty | 30000 | 35000 | &larr; R (R1), dane = V
Usługi | 20000 | 25000 | &larr; R (R1), dane = V
Koszty | 40000 | 45000 | &larr; Shift+R (R2), dane = A
Pensje | 25000 | 28000 | &larr; R (R1), dane = V
Czynsz | 15000 | 17000 | &larr; R (R1), dane = V</pre>
<p><strong>Workflow:</strong> oznacz nagł&oacute;wki szczeg&oacute;łowe klawiszem <kbd>R</kbd>, nagł&oacute;wki grup klawiszem <kbd>Shift</kbd>+<kbd>R</kbd> (jeden poziom wyżej, bez przełączania). Dane w wierszu grupy &rarr; <kbd>A</kbd> (agregacja, nawet bez formuł).</p>
<p>System skanuje <strong>w lewo + w g&oacute;</strong> &rarr; chunk: <code>Row: Przychody &gt; Produkty</code></p>
<h4>Nagł&oacute;wki kolumn (C) &mdash; wielopoziomowe</h4>
<p>Wielopoziomowe nagł&oacute;wki kolumn to <strong>osobne wiersze</strong>, najszerszy na g&oacute;rze (najwyższy numer poziomu):</p>
<pre> Przychody 2024 (C3, scalona)
Q1 | Q2 (C2)
Sty|Lut | Mar|Kwi (C1, najbliższy danych)
100|120 | 130|140 (wartości)</pre>
<p>System skanuje <strong>w g&oacute;</strong> &rarr; chunk: <code>Column: Przychody 2024 &gt; Q1 &gt; Sty</code></p>
<h3>Szczeg&oacute;ły etykiet</h3>
<h4>value (V) &mdash; Wartość</h4>
<p>Dowolna kom&oacute;rka z danymi wewnątrz siatki &mdash; liczbowa (kwoty, ilości) lub tekstowa (imiona, kody, statusy). Każda kom&oacute;rka value = jeden chunk z pełną ścieżką nagł&oacute;wk&oacute;w.</p>
<h4>aggregation (A) &mdash; Agregacja</h4>
<p>Obliczona z innych kom&oacute;rek (SUMA, ŚREDNIA itp.). System wyodrębnia formułę automatycznie. <strong>Nie</strong> oznaczaj jako value.</p>
<p><strong>Agregacja vs. poziomy nagł&oacute;wk&oacute;w:</strong> Wiersze ułożone pionowo w tej samej kolumnie (Wynagrodzenia, Świadczenia, Razem) to NIE poziomy nagł&oacute;wk&oacute;w.
&bdquo;Wynagrodzenia&rdquo; i &bdquo;Świadczenia&rdquo; to row_header L1. &bdquo;Razem&rdquo; to row_header wiersza agregacji. Formuła wychwytuje relację.</p>
<h4>header (H) &mdash; Nagł&oacute;wek</h4>
<p>Tytuł tabeli lub nagł&oacute;wek sekcji dający kontekst danym poniżej. Przykłady: &bdquo;Bilans skonsolidowany&rdquo;, &bdquo;Przychody Q3 2024&rdquo;, &bdquo;(w milionach)&rdquo;.</p>
<ul>
<li><strong>T0 + H</strong> = nagł&oacute;wek arkusza (pojawia się we WSZYSTKICH chunkach jako kontekst).</li>
<li><strong>T1+ H</strong> = nagł&oacute;wek tabeli (tylko w chunkach tej tabeli).</li>
</ul>
<p><strong>Nagł&oacute;wek vs. metadane:</strong> Jeśli kom&oacute;rka jest tytułem/nagł&oacute;wkiem opisującym dane, użyj <code>header</code>. Jeśli to przypis, zastrzeżenie, nota źr&oacute;dłowa lub inna adnotacja, użyj <code>metadata</code>.</p>
<h4>metadata (M) &mdash; Metadane</h4>
<p>Przypisy, noty źr&oacute;dłowe, daty, zastrzeżenia, URLe &mdash; informacje uzupełniające, kt&oacute;re NIE są tytułem ani nagł&oacute;wkiem.</p>
<ul>
<li><strong>T0 + M</strong> = metadane arkusza (mogą pojawić się w chunkach jako kontekst).</li>
<li><strong>T1+ M</strong> = metadane tabeli.</li>
</ul>
<h4>comment (N) &mdash; Komentarz / przypis</h4>
<p>Kom&oacute;rka z adnotacją/przypisem odnoszącym się do innej kom&oacute;rki &mdash; wpisana w zwykłą kom&oacute;rkę zamiast wbudowanego komentarza Excela.
Przykłady: <code>*dane szacunkowe</code>, <code>patrz nota 3</code>, <code>skorygowano</code>.</p>
<ol>
<li>Zaznacz kom&oacute;rkę z komentarzem, naciśnij <kbd>N</kbd>.</li>
<li>Pojawi się banner: kliknij <strong>kom&oacute;rkę docelową</strong>, do kt&oacute;rej odnosi się komentarz.</li>
<li>Przerywana pomarańczowa strzałka połączy dwie kom&oacute;rki.</li>
<li>Naciśnij <kbd>Esc</kbd>, aby anulować linkowanie.</li>
</ol>
<p>Tekst komentarza trafia do chunka docelowej kom&oacute;rki jako <code>Note: ...</code>.</p>
<p><strong>Komentarz vs. metadane:</strong> Używaj <code>metadata</code> dla og&oacute;lnego kontekstu (tytuły, daty). Używaj <code>comment</code> dla adnotacji do <em>konkretnej kom&oacute;rki</em>.</p>
<p><strong>Wbudowane komentarze Excela</strong> (pomarańczowy tr&oacute;jkąt) są wyodrębniane automatycznie &mdash; nie wymagają labelowania.</p>
<h4>empty (E) &mdash; Pusta</h4>
<p>Strukturalnie istotne puste kom&oacute;rki (separatory między tabelami). Niezaetykietowane niepuste kom&oacute;rki zapisywane jako &bdquo;unlabeled&rdquo;.</p>
<h4>junk (J) &mdash; Śmieć</h4>
<p>Kom&oacute;rki z bezużyteczną lub błędną treścią, kt&oacute;re powinny być <strong>odrzucone</strong> przy budowaniu chunk&oacute;w. Chodzi o kom&oacute;rki, kt&oacute;re w oczywisty spos&oacute;b znalazły się w arkuszu przez pomyłkę lub nie niosą żadnej znaczącej informacji.</p>
<p>Przykłady: automatycznie generowane nazwy kolumn jak <code>Unnamed: 0</code>, <code>Unnamed 1</code>, <code>Column1</code>; uszkodzony lub zniekształcony tekst; dane testowe/debugowe pozostawione przez pomyłkę; zduplikowane artefakty.</p>
<p>Kom&oacute;rki junk są <strong>całkowicie ignorowane</strong> podczas ekstrakcji chunk&oacute;w &mdash; nie generują chunk&oacute;w, nie są używane jako nagł&oacute;wki i nie pojawiają się w żadnym wyniku. Model r&oacute;wnież uczy się je rozpoznawać, aby m&oacute;c je filtrować automatycznie.</p>
<p><strong>Junk vs. empty:</strong> Używaj <code>empty</code> dla celowo pustych kom&oacute;rek-separator&oacute;w. Używaj <code>junk</code> dla kom&oacute;rek, kt&oacute;re <em>mają treść</em>, ale ta treść jest bezsensowna lub błędna.</p>
<h3>Wiele tabel na jednym arkuszu</h3>
<ol>
<li>Zidentyfikuj oddzielne siatki danych. Każda siatka z własnymi nagł&oacute;wkami = osobna tabela.</li>
<li>Treści na poziomie arkusza (og&oacute;lny tytuł, data, przypisy) &rarr; <strong>T0</strong>.</li>
<li>Przełącz na <strong>T1</strong>, oznacz kom&oacute;rki pierwszej tabeli.</li>
<li>Przełącz na <strong>T2</strong>, oznacz kom&oacute;rki drugiej tabeli.</li>
<li>Dodaj więcej klikając <strong>[+]</strong>.</li>
</ol>
<h3>Najczęstsze błędy</h3>
<ol>
<li><strong>Zapomnienie o przełączeniu tabeli</strong> &mdash; nagł&oacute;wki przenikają między tabelami.</li>
<li><strong>Oznaczanie agregacji jako value</strong> &mdash; chunk traci informację o formule.</li>
<li><strong>Błędny poziom nagł&oacute;wka</strong> &mdash; najbardziej szczeg&oacute;łowy = L1, szersze grupowania = wyższy numer.</li>
<li><strong>Mylenie agregacji z poziomami nagł&oacute;wk&oacute;w</strong> &mdash; pionowe ułożenie w tej samej kolumnie = agregacja, NIE poziomy.</li>
<li><strong>Umieszczanie danych w T0</strong> &mdash; nie powstanie żaden chunk.</li>
<li><strong>Brak oznaczania pustych separator&oacute;w</strong> &mdash; oznacz oddzielające wiersze/kolumny jako empty.</li>
<li><strong>Mylenie header z metadata</strong> &mdash; tytuły i nagł&oacute;wki sekcji to <code>header</code> (H), przypisy i noty to <code>metadata</code> (M). Adnotacje do konkretnej kom&oacute;rki to <code>comment</code> (N).</li>
<li><strong>Brak linkowania komentarza</strong> &mdash; po naciśnięciu N kliknij docelową kom&oacute;rkę, inaczej notatka nie trafi do żadnego chunka.</li>
</ol>
<h3>Kontrola przed zapisem</h3>
<ul class="check-list">
<li>Każda kom&oacute;rka z danymi ma etykietę (value lub aggregation).</li>
<li>Każda kom&oacute;rka nagł&oacute;wka ma prawidłową etykietę i poziom.</li>
<li>Tabele mają r&oacute;żne ID (sprawdź kolorowe kropki).</li>
<li>Tytuły/notatki arkusza na T0; tytuły tabel na ID ich tabeli.</li>
<li>Kom&oacute;rki agregacji (pogrubione sumy, formuły) oznaczone A, nie V.</li>
<li>Hierarchia: poziom 1 = najbardziej szczeg&oacute;łowy (najbliższy danych), wyższe = szersze grupowania.</li>
<li>Przypisy w kom&oacute;rkach oznaczone jako comment (N) i zlinkowane z celem (widoczna pomarańczowa strzałka).</li>
</ul>
<h3>Skr&oacute;ty klawiszowe</h3>
<table class="ref-table">
<tr><th>Skr&oacute;t</th><th>Akcja</th></tr>
<tr><td><kbd>V</kbd></td><td>Oznacz jako value (wartość)</td></tr>
<tr><td><kbd>R</kbd></td><td>Oznacz jako row_header (aktywny poziom)</td></tr>
<tr><td><kbd>Shift</kbd>+<kbd>R</kbd></td><td>Oznacz jako row_header jeden poziom wyżej (nagł&oacute;wek grupy)</td></tr>
<tr><td><kbd>C</kbd></td><td>Oznacz jako col_header (aktywny poziom)</td></tr>
<tr><td><kbd>Shift</kbd>+<kbd>C</kbd></td><td>Oznacz jako col_header jeden poziom wyżej</td></tr>
<tr><td><kbd>A</kbd></td><td>Oznacz jako aggregation (agregacja)</td></tr>
<tr><td><kbd>H</kbd></td><td>Oznacz jako header (tytuł tabeli / nagł&oacute;wek sekcji)</td></tr>
<tr><td><kbd>M</kbd></td><td>Oznacz jako metadata (przypisy, notatki, zastrzeżenia)</td></tr>
<tr><td><kbd>N</kbd></td><td>Oznacz jako comment (komentarz, potem kliknij cel)</td></tr>
<tr><td><kbd>E</kbd></td><td>Oznacz jako empty (pusta)</td></tr>
<tr><td><kbd>J</kbd></td><td>Oznacz jako junk (śmieć, odrzucone z chunków)</td></tr>
<tr><td><kbd>X</kbd></td><td>Wyczyść etykietę</td></tr>
<tr><td><kbd>T</kbd> + cyfry</td><td>Ustaw aktywną tabelę (np. T12)</td></tr>
<tr><td><kbd>L</kbd> + cyfry</td><td>Ustaw aktywny poziom (np. L5)</td></tr>
<tr><td><kbd>Esc</kbd></td><td>Anuluj linkowanie komentarza</td></tr>
<tr><td><kbd>Enter</kbd></td><td>Zapisz i wczytaj następny</td></tr>
<tr><td><kbd>Shift</kbd>+klik</td><td>Zaznaczanie zakresu</td></tr>
<tr><td><kbd>Ctrl/Cmd</kbd>+klik</td><td>Przełączanie zaznaczenia</td></tr>
</table>
`;
function showHelp(helpL) {
helpLang = helpL;
helpBody.innerHTML = HELP[helpL];
helpTitle.textContent = helpL === "pl" ? I18N.pl.help_title : I18N.en.help_title;
helpOverlay.querySelectorAll(".help-lang-btn").forEach(b => {
b.classList.toggle("active", b.dataset.lang === helpL);
});
helpOverlay.classList.add("open");
}
document.getElementById("help-btn").addEventListener("click", () => showHelp(lang));
document.getElementById("help-close").addEventListener("click", () => helpOverlay.classList.remove("open"));
helpOverlay.addEventListener("click", (e) => {
if (e.target === helpOverlay) helpOverlay.classList.remove("open");
});
helpOverlay.querySelectorAll(".help-lang-btn").forEach(b => {
b.addEventListener("click", () => showHelp(b.dataset.lang));
});
/* ── Init ───────────────────────────────────────────────────────────── */
checkAuth();
})();
</script>
</body>
</html>