Spaces:
Sleeping
Sleeping
VarunRS5457
Add preview page, short filenames, Qvey q-headers, rate limit/timeout protection, column validation, session optimization, encoding detection, progress feedback, file size validation
1cc9d84 | {% extends "base.html" %} | |
| {% block title %}Classify Columns — Aseesa Ingestion Agent{% endblock %} | |
| {% block main_class %}{% endblock %} | |
| {% block footer %}{% endblock %} | |
| {% block content %} | |
| <!-- Full-height split-panel layout (subtracts nav ~65px + footer-bar 64px) --> | |
| <div class="flex flex-col" style="height: calc(100vh - 65px);"> | |
| <!-- Top bar: step indicator + legend --> | |
| <div class="flex items-center justify-between px-2 py-3 border-b border-gray-100 bg-white flex-shrink-0"> | |
| <!-- Step indicator (compact) --> | |
| <div class="flex items-center gap-2"> | |
| <div class="w-6 h-6 rounded-full bg-brand-100 text-brand-600 flex items-center justify-center text-xs"> | |
| <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg> | |
| </div> | |
| <span class="text-xs text-brand-600">Upload</span> | |
| <div class="w-6 h-px bg-brand-300"></div> | |
| <div class="w-6 h-6 rounded-full bg-brand-600 text-white flex items-center justify-center text-xs font-medium">2</div> | |
| <span class="text-xs font-medium text-brand-600">Classify</span> | |
| <div class="w-6 h-px bg-gray-300"></div> | |
| <div class="w-6 h-6 rounded-full bg-gray-200 text-gray-400 flex items-center justify-center text-xs">3</div> | |
| <span class="text-xs text-gray-400">Generate</span> | |
| </div> | |
| <!-- Classification legend --> | |
| <div class="flex items-center gap-2"> | |
| <span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-violet-100 text-violet-700"> | |
| <span class="w-1.5 h-1.5 rounded-full bg-violet-500"></span>Sample ID | |
| </span> | |
| <span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-sky-100 text-sky-700"> | |
| <span class="w-1.5 h-1.5 rounded-full bg-sky-500"></span>Subject ID | |
| </span> | |
| <span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-rose-100 text-rose-700"> | |
| <span class="w-1.5 h-1.5 rounded-full bg-rose-500"></span>Timepoint | |
| </span> | |
| <span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-amber-100 text-amber-700"> | |
| <span class="w-1.5 h-1.5 rounded-full bg-amber-500"></span>Metadata | |
| </span> | |
| <span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-emerald-100 text-emerald-700"> | |
| <span class="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>Data | |
| </span> | |
| <span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-indigo-100 text-indigo-700"> | |
| <span class="w-1.5 h-1.5 rounded-full bg-indigo-500"></span>Join Key | |
| </span> | |
| <span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium bg-gray-100 text-gray-500"> | |
| <span class="w-1.5 h-1.5 rounded-full bg-gray-400"></span>Exclude | |
| </span> | |
| </div> | |
| </div> | |
| <!-- Main split panels --> | |
| <div class="flex flex-1 overflow-hidden"> | |
| <!-- ========== LEFT PANEL: file list ========== --> | |
| <div class="w-72 flex-shrink-0 border-r border-gray-200 bg-gray-50 flex flex-col overflow-hidden"> | |
| <!-- Left panel header --> | |
| <div class="px-3 pt-3 pb-2 flex-shrink-0"> | |
| <div class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2"> | |
| Files ({{ num_files }}) · {{ total_columns }} cols | |
| </div> | |
| <!-- Search --> | |
| <div class="relative"> | |
| <svg class="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/> | |
| </svg> | |
| <input type="text" id="column-search" placeholder="Search columns..." | |
| class="w-full text-xs rounded-lg border-gray-200 pl-8 pr-3 py-1.5 focus:border-brand-500 focus:ring-brand-500" | |
| oninput="filterColumns(this.value)"> | |
| <span id="search-count" class="absolute right-2 top-1/2 -translate-y-1/2 text-[10px] text-gray-400 hidden"></span> | |
| </div> | |
| </div> | |
| <!-- Auto-detection banner (compact) --> | |
| {% if detected_sample_id %} | |
| <div class="mx-3 mb-2 px-2.5 py-2 bg-emerald-50 border border-emerald-200 rounded-lg flex-shrink-0"> | |
| <div class="text-[10px] text-emerald-700"> | |
| <span class="font-medium">Sample ID:</span> <span class="font-mono">{{ detected_sample_id[1] }}</span> | |
| {% if detected_subject_id %} | |
| <br><span class="font-medium">Subject ID:</span> <span class="font-mono">{{ detected_subject_id[1] }}</span> | |
| {% endif %} | |
| {% if detected_timepoint %} | |
| <br><span class="font-medium">Timepoint:</span> <span class="font-mono">{{ detected_timepoint[1] }}</span> | |
| {% endif %} | |
| </div> | |
| </div> | |
| {% endif %} | |
| <!-- File list (scrollable) --> | |
| <div class="flex-1 overflow-y-auto px-2 pb-2 space-y-1" id="file-list"> | |
| {% for file_info in files %} | |
| <button type="button" | |
| class="file-list-item w-full text-left px-3 py-2.5 rounded-lg transition-all text-xs group | |
| {{ 'bg-white border border-brand-200 shadow-sm' if loop.index0 == 0 else 'hover:bg-white hover:shadow-sm border border-transparent' }}" | |
| data-file-idx="{{ loop.index0 }}" | |
| onclick="selectFile({{ loop.index0 }})"> | |
| <div class="flex items-center gap-2 min-w-0"> | |
| <svg class="w-3.5 h-3.5 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/> | |
| </svg> | |
| <span class="font-mono font-medium text-gray-900 truncate">{{ file_info.filename }}</span> | |
| </div> | |
| <div class="flex items-center justify-between mt-1 ml-5.5"> | |
| <span class="text-[10px] text-gray-400">{{ file_info.columns|length }} cols · {{ file_info.num_rows }} rows</span> | |
| <!-- Summary badges --> | |
| <div class="flex items-center gap-0.5 file-summary" data-file-idx="{{ loop.index0 }}"> | |
| <span class="summary-badge w-4 h-4 rounded text-[9px] font-bold flex items-center justify-center bg-violet-100 text-violet-700 hidden" data-cls="sample_id">0</span> | |
| <span class="summary-badge w-4 h-4 rounded text-[9px] font-bold flex items-center justify-center bg-sky-100 text-sky-700 hidden" data-cls="subject_id">0</span> | |
| <span class="summary-badge w-4 h-4 rounded text-[9px] font-bold flex items-center justify-center bg-rose-100 text-rose-700 hidden" data-cls="timepoint">0</span> | |
| <span class="summary-badge w-4 h-4 rounded text-[9px] font-bold flex items-center justify-center bg-amber-100 text-amber-700 hidden" data-cls="metadata">0</span> | |
| <span class="summary-badge w-4 h-4 rounded text-[9px] font-bold flex items-center justify-center bg-emerald-100 text-emerald-700 hidden" data-cls="data">0</span> | |
| <span class="summary-badge w-4 h-4 rounded text-[9px] font-bold flex items-center justify-center bg-indigo-100 text-indigo-700 hidden" data-cls="join_key">0</span> | |
| <span class="summary-badge w-4 h-4 rounded text-[9px] font-bold flex items-center justify-center bg-gray-100 text-gray-500 hidden" data-cls="exclude">0</span> | |
| </div> | |
| </div> | |
| </button> | |
| {% endfor %} | |
| </div> | |
| <!-- AI Classify section at bottom of left panel --> | |
| {% if llm_available %} | |
| <div class="flex-shrink-0 border-t border-gray-200 bg-white px-3 py-3 space-y-2"> | |
| <div class="flex items-center gap-2"> | |
| <svg class="w-3.5 h-3.5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/> | |
| </svg> | |
| <span class="text-xs font-medium text-gray-900">AI Auto-classify</span> | |
| </div> | |
| <input type="text" id="ai-prompt" placeholder="e.g. gene expression COVID vs control" | |
| class="w-full text-xs rounded-lg border-gray-300 px-2.5 py-1.5 focus:border-brand-500 focus:ring-brand-500"> | |
| <div class="flex items-center gap-2"> | |
| <label class="cursor-pointer inline-flex items-center gap-1 px-2 py-1 border border-gray-200 rounded text-[10px] text-gray-500 hover:bg-gray-50"> | |
| <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"/> | |
| </svg> | |
| Refs | |
| <input type="file" id="ref-file-input" multiple accept=".csv,.txt,.html,.htm,.pdf,.dat,.tsv" class="hidden" | |
| onchange="uploadReferenceFiles()"> | |
| </label> | |
| <div id="ref-file-list" class="flex-1 text-[10px] text-gray-400 truncate"></div> | |
| <button type="button" id="auto-classify-btn" onclick="autoClassify()" | |
| class="px-3 py-1.5 bg-purple-600 text-white text-xs font-medium rounded-lg hover:bg-purple-700 transition-colors flex items-center gap-1.5"> | |
| <span>Classify</span> | |
| <svg id="ai-spinner" class="w-3 h-3 hidden spinner" fill="none" viewBox="0 0 24 24"> | |
| <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> | |
| <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path> | |
| </svg> | |
| </button> | |
| </div> | |
| <div id="ai-error" class="hidden text-[10px] text-red-600 bg-red-50 rounded p-2"></div> | |
| <div id="ai-success" class="hidden text-[10px] text-emerald-600 bg-emerald-50 rounded p-2"></div> | |
| <!-- Learning stats indicator --> | |
| <div id="learning-stats" class="flex items-center gap-1.5 pt-1 border-t border-gray-100 mt-1"> | |
| <svg class="w-3 h-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"/> | |
| </svg> | |
| <span id="learning-label" class="text-[10px] text-gray-400">Loading learning data...</span> | |
| </div> | |
| <!-- Exclude from training checkbox --> | |
| <label class="flex items-center gap-1.5 pt-1 cursor-pointer"> | |
| <input type="checkbox" id="exclude-training-cb" class="rounded border-gray-300 text-red-500 focus:ring-red-400 w-3.5 h-3.5" | |
| onchange="document.getElementById('exclude-training-hidden').value = this.checked ? '1' : '0'"> | |
| <span class="text-[10px] text-gray-500">Exclude from training data</span> | |
| </label> | |
| </div> | |
| {% endif %} | |
| </div> | |
| <!-- ========== RIGHT PANEL: column detail ========== --> | |
| <div class="flex-1 flex flex-col overflow-hidden bg-white"> | |
| <form id="classify-form" hx-post="/ui/generate" hx-target="#generate-result" hx-indicator="#generate-spinner" class="flex flex-col flex-1 overflow-hidden"> | |
| <input type="hidden" name="session_id" value="{{ session_id }}"> | |
| <input type="hidden" name="product" value="stars"> | |
| <input type="hidden" name="exclude_training" id="exclude-training-hidden" value="0"> | |
| <!-- All file sections (only one visible at a time) --> | |
| {% for file_info in files %} | |
| <div class="file-detail flex flex-col flex-1 overflow-hidden {{ '' if loop.index0 == 0 else 'hidden' }}" | |
| data-file-idx="{{ loop.index0 }}" data-filename="{{ file_info.filename }}"> | |
| <!-- File detail header --> | |
| <div class="flex items-center justify-between px-5 py-3 border-b border-gray-100 flex-shrink-0"> | |
| <div> | |
| <h2 class="text-sm font-bold text-gray-900 font-mono">{{ file_info.filename }}</h2> | |
| <p class="text-xs text-gray-400 mt-0.5"> | |
| {{ file_info.columns|length }} columns · {{ file_info.num_rows }} rows | |
| {% if file_info.dataset_label %} — {{ file_info.dataset_label }}{% endif %} | |
| </p> | |
| </div> | |
| <!-- Bulk actions + Undo/Redo --> | |
| <div class="flex items-center gap-1.5"> | |
| <button type="button" id="undo-btn" onclick="undoClassifications()" disabled | |
| class="px-2 py-1 text-[10px] rounded bg-gray-100 text-gray-400 hover:bg-gray-200 hover:text-gray-600 transition-colors font-medium disabled:opacity-40 disabled:cursor-not-allowed" title="Undo"> | |
| <svg class="w-3 h-3 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a5 5 0 015 5v2M3 10l4-4m-4 4l4 4"/></svg> | |
| </button> | |
| <button type="button" id="redo-btn" onclick="redoClassifications()" disabled | |
| class="px-2 py-1 text-[10px] rounded bg-gray-100 text-gray-400 hover:bg-gray-200 hover:text-gray-600 transition-colors font-medium disabled:opacity-40 disabled:cursor-not-allowed" title="Redo"> | |
| <svg class="w-3 h-3 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 10H11a5 5 0 00-5 5v2m15-7l-4-4m4 4l-4 4"/></svg> | |
| </button> | |
| <div class="w-px h-4 bg-gray-200 mx-1"></div> | |
| <span class="text-xs text-gray-400 mr-1">Set all:</span> | |
| <button type="button" onclick="bulkClassify({{ loop.index0 }}, 'data')" class="px-2 py-1 text-[10px] rounded bg-emerald-100 text-emerald-700 hover:bg-emerald-200 transition-colors font-medium">Data</button> | |
| <button type="button" onclick="bulkClassify({{ loop.index0 }}, 'metadata')" class="px-2 py-1 text-[10px] rounded bg-amber-100 text-amber-700 hover:bg-amber-200 transition-colors font-medium">Metadata</button> | |
| <button type="button" onclick="bulkClassify({{ loop.index0 }}, 'exclude')" class="px-2 py-1 text-[10px] rounded bg-gray-100 text-gray-500 hover:bg-gray-200 transition-colors font-medium">Exclude</button> | |
| </div> | |
| </div> | |
| <!-- Column table (scrollable) --> | |
| <div class="flex-1 overflow-y-auto"> | |
| <table class="w-full text-sm"> | |
| <thead class="sticky top-0 bg-gray-50 z-10"> | |
| <tr class="border-b border-gray-200"> | |
| <th class="text-left text-xs font-medium text-gray-500 px-5 py-2">Column</th> | |
| <th class="text-left text-xs font-medium text-gray-500 px-3 py-2">Label</th> | |
| <th class="text-left text-xs font-medium text-gray-500 px-3 py-2 w-28">Type</th> | |
| <th class="text-right text-xs font-medium text-gray-500 px-5 py-2 w-36">Classification</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {% set file_idx = loop.index0 %} | |
| {% for col in file_info.columns %} | |
| {% set default_val = defaults.get((file_info.filename, col.name), 'data') %} | |
| <tr class="column-row border-b border-gray-50 transition-colors cursor-pointer hover:bg-blue-50/40" | |
| data-file="{{ file_info.filename }}" data-col="{{ col.name }}" data-label="{{ col.label or '' }}" data-file-idx="{{ file_idx }}" | |
| onclick="showPreview('{{ file_info.filename }}', '{{ col.name }}')"> | |
| <td class="px-5 py-2"> | |
| <div class="flex items-center gap-1.5"> | |
| <span class="font-mono text-sm font-medium text-gray-900">{{ col.name }}</span> | |
| {% if col.name in common_columns %} | |
| <span class="px-1 py-0.5 bg-indigo-100 text-indigo-600 rounded text-[9px] leading-none font-medium">shared</span> | |
| {% endif %} | |
| </div> | |
| </td> | |
| <td class="px-3 py-2" title="{{ col.label or '' }}"> | |
| <div class="text-xs text-gray-500 truncate max-w-[220px]">{{ col.label or '—' }}</div> | |
| </td> | |
| <td class="px-3 py-2"> | |
| <span class="px-1.5 py-0.5 bg-gray-100 text-gray-500 rounded text-xs font-mono">{{ col.dtype }}</span> | |
| {% if col.sas_format %} | |
| <span class="px-1.5 py-0.5 bg-gray-100 text-gray-500 rounded text-xs font-mono ml-1">{{ col.sas_format }}</span> | |
| {% endif %} | |
| </td> | |
| <td class="px-5 py-2 text-right"> | |
| <select name="col_{{ file_info.filename }}__{{ col.name }}" | |
| class="classification-select text-xs rounded-lg border-gray-300 focus:border-brand-500 focus:ring-brand-500 px-2 py-1 bg-white w-32" | |
| onchange="updateRowStyle(this); updateFileSummary({{ file_idx }})" onclick="event.stopPropagation()"> | |
| <option value="sample_id" {% if default_val == 'sample_id' %}selected{% endif %}>Sample ID</option> | |
| <option value="subject_id" {% if default_val == 'subject_id' %}selected{% endif %}>Subject ID</option> | |
| <option value="timepoint" {% if default_val == 'timepoint' %}selected{% endif %}>Timepoint</option> | |
| <option value="metadata" {% if default_val == 'metadata' %}selected{% endif %}>Metadata</option> | |
| <option value="data" {% if default_val == 'data' %}selected{% endif %}>Data</option> | |
| <option value="join_key" {% if default_val == 'join_key' %}selected{% endif %}>Join Key</option> | |
| <option value="exclude" {% if default_val == 'exclude' %}selected{% endif %}>Exclude</option> | |
| </select> | |
| </td> | |
| </tr> | |
| {% endfor %} | |
| </tbody> | |
| </table> | |
| </div> | |
| <!-- Data preview panel (shown when a column row is clicked) --> | |
| <div id="preview-panel-{{ loop.index0 }}" class="preview-panel hidden flex-shrink-0 border-t border-gray-200 bg-gray-50 max-h-44 overflow-y-auto"> | |
| <div class="px-5 py-2 flex items-center justify-between sticky top-0 bg-gray-50 z-10"> | |
| <div class="flex items-center gap-2"> | |
| <svg class="w-3.5 h-3.5 text-brand-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/> | |
| </svg> | |
| <span class="text-xs font-medium text-gray-700" id="preview-title-{{ loop.index0 }}">Data Preview</span> | |
| </div> | |
| <button type="button" onclick="hidePreview({{ loop.index0 }})" class="text-gray-400 hover:text-gray-600"> | |
| <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/> | |
| </svg> | |
| </button> | |
| </div> | |
| <div id="preview-content-{{ loop.index0 }}" class="px-5 pb-3"></div> | |
| </div> | |
| </div> | |
| {% endfor %} | |
| <!-- Common columns banner (shown inside right panel if multi-file) --> | |
| {% if common_columns %} | |
| <div class="px-5 py-2 bg-indigo-50 border-t border-indigo-100 flex items-center gap-2 flex-shrink-0"> | |
| <svg class="w-3.5 h-3.5 text-indigo-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/> | |
| </svg> | |
| <span class="text-[10px] text-indigo-600 font-medium">Shared columns:</span> | |
| <div class="flex flex-wrap gap-1"> | |
| {% for col in common_columns %} | |
| <span class="px-1.5 py-0.5 bg-indigo-100 text-indigo-700 rounded text-[10px] font-mono">{{ col }}</span> | |
| {% endfor %} | |
| </div> | |
| </div> | |
| {% endif %} | |
| <!-- ========== STICKY FOOTER BAR ========== --> | |
| <div class="flex-shrink-0 border-t border-gray-200 bg-white px-5 py-3 flex items-center gap-4"> | |
| <!-- Output format pills --> | |
| <div class="flex items-center gap-1.5"> | |
| <span class="text-xs text-gray-500 mr-1">Format:</span> | |
| <label class="cursor-pointer"> | |
| <input type="radio" name="output_format" value="csv" checked class="peer hidden"> | |
| <span class="peer-checked:bg-brand-600 peer-checked:text-white px-3 py-1.5 rounded-lg text-xs font-medium bg-gray-100 text-gray-600 transition-colors">CSV</span> | |
| </label> | |
| <label class="cursor-pointer"> | |
| <input type="radio" name="output_format" value="xlsx" class="peer hidden"> | |
| <span class="peer-checked:bg-brand-600 peer-checked:text-white px-3 py-1.5 rounded-lg text-xs font-medium bg-gray-100 text-gray-600 transition-colors">Excel</span> | |
| </label> | |
| <label class="cursor-pointer"> | |
| <input type="radio" name="output_format" value="xpt" class="peer hidden"> | |
| <span class="peer-checked:bg-brand-600 peer-checked:text-white px-3 py-1.5 rounded-lg text-xs font-medium bg-gray-100 text-gray-600 transition-colors">XPT</span> | |
| </label> | |
| </div> | |
| <div class="flex-1"></div> | |
| <!-- Generate spinner --> | |
| <div id="generate-spinner" class="htmx-indicator flex items-center gap-2 text-brand-600"> | |
| <svg class="w-4 h-4 spinner" fill="none" viewBox="0 0 24 24"> | |
| <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> | |
| <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path> | |
| </svg> | |
| <span class="text-xs font-medium">Generating...</span> | |
| </div> | |
| <!-- Generate button --> | |
| <button type="submit" | |
| class="px-6 py-2 bg-brand-600 text-white text-sm font-medium rounded-lg hover:bg-brand-700 transition-colors flex items-center gap-2"> | |
| <span>Generate Output</span> | |
| <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3"/> | |
| </svg> | |
| </button> | |
| </div> | |
| <div id="generate-result"></div> | |
| </form> | |
| </div> | |
| </div> | |
| </div> | |
| {% endblock %} | |
| {% block scripts %} | |
| <script> | |
| // ===== Sample values data (for data preview) ===== | |
| const sampleValuesData = {{ all_sample_values | tojson }}; | |
| // ===== State ===== | |
| let activeFileIdx = 0; | |
| const fileDetails = document.querySelectorAll('.file-detail'); | |
| const fileListItems = document.querySelectorAll('.file-list-item'); | |
| const NUM_FILES = {{ num_files }}; | |
| // ===== Undo/Redo stack ===== | |
| const undoStack = []; | |
| const redoStack = []; | |
| const MAX_UNDO = 30; | |
| function captureState() { | |
| const state = {}; | |
| document.querySelectorAll('.classification-select').forEach(s => { | |
| state[s.name] = s.value; | |
| }); | |
| return state; | |
| } | |
| function restoreState(state) { | |
| document.querySelectorAll('.classification-select').forEach(s => { | |
| if (state[s.name] !== undefined) { | |
| s.value = state[s.name]; | |
| updateRowStyle(s); | |
| } | |
| }); | |
| updateAllSummaries(); | |
| } | |
| function pushUndo() { | |
| undoStack.push(captureState()); | |
| if (undoStack.length > MAX_UNDO) undoStack.shift(); | |
| redoStack.length = 0; | |
| updateUndoRedoButtons(); | |
| } | |
| function undoClassifications() { | |
| if (!undoStack.length) return; | |
| redoStack.push(captureState()); | |
| restoreState(undoStack.pop()); | |
| updateUndoRedoButtons(); | |
| } | |
| function redoClassifications() { | |
| if (!redoStack.length) return; | |
| undoStack.push(captureState()); | |
| restoreState(redoStack.pop()); | |
| updateUndoRedoButtons(); | |
| } | |
| function updateUndoRedoButtons() { | |
| document.getElementById('undo-btn').disabled = undoStack.length === 0; | |
| document.getElementById('redo-btn').disabled = redoStack.length === 0; | |
| } | |
| // ===== File selection ===== | |
| function selectFile(idx) { | |
| if (idx === activeFileIdx && fileDetails[idx] && !fileDetails[idx].classList.contains('hidden')) return; | |
| activeFileIdx = idx; | |
| fileListItems.forEach((item, i) => { | |
| if (i === idx) { | |
| item.classList.add('bg-white', 'border-brand-200', 'shadow-sm'); | |
| item.classList.remove('border-transparent'); | |
| } else { | |
| item.classList.remove('bg-white', 'border-brand-200', 'shadow-sm'); | |
| item.classList.add('border-transparent'); | |
| } | |
| }); | |
| fileDetails.forEach((detail, i) => { | |
| if (i === idx) { | |
| detail.classList.remove('hidden'); | |
| } else { | |
| detail.classList.add('hidden'); | |
| } | |
| }); | |
| } | |
| // ===== Row styling ===== | |
| const rowColors = { | |
| sample_id: 'bg-violet-50', | |
| subject_id: 'bg-sky-50', | |
| timepoint: 'bg-rose-50', | |
| metadata: 'bg-amber-50', | |
| data: '', | |
| join_key: 'bg-indigo-50', | |
| exclude: 'bg-gray-50', | |
| }; | |
| function updateRowStyle(select) { | |
| const row = select.closest('tr'); | |
| Object.values(rowColors).forEach(c => { if (c) row.classList.remove(c); }); | |
| const color = rowColors[select.value]; | |
| if (color) row.classList.add(color); | |
| } | |
| // ===== Data preview ===== | |
| function showPreview(filename, colName) { | |
| const panel = document.getElementById(`preview-panel-${activeFileIdx}`); | |
| const title = document.getElementById(`preview-title-${activeFileIdx}`); | |
| const content = document.getElementById(`preview-content-${activeFileIdx}`); | |
| if (!panel || !content) return; | |
| const fileData = sampleValuesData[filename]; | |
| const colData = fileData ? fileData[colName] : null; | |
| title.textContent = `Preview: ${colName}`; | |
| if (!colData) { | |
| content.innerHTML = '<p class="text-[11px] text-gray-400 italic">No sample data available</p>'; | |
| } else { | |
| let html = '<div class="grid grid-cols-2 gap-x-6 gap-y-1 text-[11px]">'; | |
| html += `<span class="text-gray-500">Distinct values (sample):</span><span class="font-medium text-gray-700">${colData.distinct_count ?? '—'}</span>`; | |
| html += `<span class="text-gray-500">Null count (sample):</span><span class="font-medium text-gray-700">${colData.null_count ?? '—'}</span>`; | |
| if (colData.stats) { | |
| const s = colData.stats; | |
| if (s.min !== undefined) html += `<span class="text-gray-500">Min:</span><span class="font-mono text-gray-700">${s.min}</span>`; | |
| if (s.max !== undefined) html += `<span class="text-gray-500">Max:</span><span class="font-mono text-gray-700">${s.max}</span>`; | |
| if (s.mean !== undefined) html += `<span class="text-gray-500">Mean:</span><span class="font-mono text-gray-700">${Number(s.mean).toFixed(2)}</span>`; | |
| } | |
| html += '</div>'; | |
| if (colData.sample_values && colData.sample_values.length > 0) { | |
| html += '<div class="mt-2"><span class="text-[10px] text-gray-500 uppercase tracking-wider font-medium">Sample values</span>'; | |
| html += '<div class="flex flex-wrap gap-1.5 mt-1">'; | |
| for (const v of colData.sample_values) { | |
| const display = String(v).length > 40 ? String(v).substring(0, 40) + '…' : String(v); | |
| html += `<span class="px-2 py-0.5 bg-white border border-gray-200 rounded text-[11px] font-mono text-gray-700">${display}</span>`; | |
| } | |
| html += '</div></div>'; | |
| } | |
| content.innerHTML = html; | |
| } | |
| // Highlight the clicked row | |
| const detail = fileDetails[activeFileIdx]; | |
| detail.querySelectorAll('.column-row').forEach(r => r.classList.remove('ring-1', 'ring-brand-300')); | |
| const row = detail.querySelector(`tr[data-col="${colName}"]`); | |
| if (row) row.classList.add('ring-1', 'ring-brand-300'); | |
| panel.classList.remove('hidden'); | |
| } | |
| function hidePreview(fileIdx) { | |
| const panel = document.getElementById(`preview-panel-${fileIdx}`); | |
| if (panel) panel.classList.add('hidden'); | |
| const detail = fileDetails[fileIdx]; | |
| if (detail) detail.querySelectorAll('.column-row').forEach(r => r.classList.remove('ring-1', 'ring-brand-300')); | |
| } | |
| // ===== Summary badges ===== | |
| function updateFileSummary(fileIdx) { | |
| const summaryDiv = document.querySelector(`.file-summary[data-file-idx="${fileIdx}"]`); | |
| const detail = fileDetails[fileIdx]; | |
| if (!summaryDiv || !detail) return; | |
| const selects = detail.querySelectorAll('.classification-select'); | |
| const counts = {}; | |
| selects.forEach(s => { counts[s.value] = (counts[s.value] || 0) + 1; }); | |
| summaryDiv.querySelectorAll('.summary-badge').forEach(badge => { | |
| const cls = badge.dataset.cls; | |
| if (cls && counts[cls]) { | |
| badge.textContent = counts[cls]; | |
| badge.classList.remove('hidden'); | |
| } else { | |
| badge.classList.add('hidden'); | |
| } | |
| }); | |
| } | |
| function updateAllSummaries() { | |
| for (let i = 0; i < NUM_FILES; i++) updateFileSummary(i); | |
| } | |
| // ===== Bulk classify ===== | |
| function bulkClassify(fileIdx, classification) { | |
| pushUndo(); | |
| const detail = fileDetails[fileIdx]; | |
| if (!detail) return; | |
| detail.querySelectorAll('.classification-select').forEach(select => { | |
| select.value = classification; | |
| updateRowStyle(select); | |
| }); | |
| updateFileSummary(fileIdx); | |
| } | |
| // ===== Column search ===== | |
| function filterColumns(query) { | |
| const q = query.toLowerCase().trim(); | |
| const countEl = document.getElementById('search-count'); | |
| let matchCount = 0; | |
| let totalCount = 0; | |
| const fileMatchCounts = new Array(NUM_FILES).fill(0); | |
| document.querySelectorAll('.column-row').forEach(row => { | |
| totalCount++; | |
| const col = (row.dataset.col || '').toLowerCase(); | |
| const label = (row.dataset.label || '').toLowerCase(); | |
| // Also search by classification value | |
| const select = row.querySelector('.classification-select'); | |
| const cls = select ? select.value.toLowerCase() : ''; | |
| const match = !q || col.includes(q) || label.includes(q) || cls.includes(q); | |
| row.style.display = match ? '' : 'none'; | |
| if (match) { | |
| matchCount++; | |
| const fIdx = parseInt(row.dataset.fileIdx, 10); | |
| if (!isNaN(fIdx)) fileMatchCounts[fIdx]++; | |
| } | |
| }); | |
| if (q) { | |
| countEl.textContent = `${matchCount}/${totalCount}`; | |
| countEl.classList.remove('hidden'); | |
| fileListItems.forEach((item, i) => { | |
| item.style.display = fileMatchCounts[i] > 0 ? '' : 'none'; | |
| }); | |
| const firstMatch = fileMatchCounts.findIndex(c => c > 0); | |
| if (firstMatch >= 0 && fileMatchCounts[activeFileIdx] === 0) { | |
| selectFile(firstMatch); | |
| } | |
| } else { | |
| countEl.classList.add('hidden'); | |
| fileListItems.forEach(item => { item.style.display = ''; }); | |
| } | |
| } | |
| // ===== Reference file upload ===== | |
| async function uploadReferenceFiles() { | |
| const input = document.getElementById('ref-file-input'); | |
| if (!input.files.length) return; | |
| const formData = new FormData(); | |
| for (const file of input.files) { | |
| formData.append('ref_files', file); | |
| } | |
| try { | |
| const response = await fetch('/ui/upload-reference/{{ session_id }}', { | |
| method: 'POST', | |
| body: formData, | |
| }); | |
| const html = await response.text(); | |
| document.getElementById('ref-file-list').innerHTML = html; | |
| } catch (err) { | |
| document.getElementById('ref-file-list').innerHTML = | |
| '<span class="text-[10px] text-red-500">Upload failed</span>'; | |
| } | |
| input.value = ''; | |
| } | |
| // ===== Auto-classify with AI ===== | |
| let _statusPollInterval = null; | |
| function startStatusPolling() { | |
| const successDiv = document.getElementById('ai-success'); | |
| _statusPollInterval = setInterval(async () => { | |
| try { | |
| const resp = await fetch('/ui/classify-status/{{ session_id }}'); | |
| const data = await resp.json(); | |
| if (data.message && data.status !== 'done' && data.status !== 'idle') { | |
| successDiv.textContent = data.message; | |
| successDiv.classList.remove('hidden'); | |
| } | |
| } catch {} | |
| }, 1500); | |
| } | |
| function stopStatusPolling() { | |
| if (_statusPollInterval) { | |
| clearInterval(_statusPollInterval); | |
| _statusPollInterval = null; | |
| } | |
| } | |
| async function autoClassify() { | |
| const btn = document.getElementById('auto-classify-btn'); | |
| const spinner = document.getElementById('ai-spinner'); | |
| const errorDiv = document.getElementById('ai-error'); | |
| const successDiv = document.getElementById('ai-success'); | |
| const prompt = document.getElementById('ai-prompt').value; | |
| if (!prompt.trim()) { | |
| errorDiv.textContent = 'Describe your analysis intent first'; | |
| errorDiv.classList.remove('hidden'); | |
| successDiv.classList.add('hidden'); | |
| return; | |
| } | |
| // Save state before AI classify for undo | |
| pushUndo(); | |
| btn.disabled = true; | |
| spinner.classList.remove('hidden'); | |
| errorDiv.classList.add('hidden'); | |
| successDiv.textContent = 'Preparing...'; | |
| successDiv.classList.remove('hidden'); | |
| // Start polling for status updates | |
| startStatusPolling(); | |
| try { | |
| const response = await fetch('/ui/auto-classify/{{ session_id }}', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({user_prompt: prompt}), | |
| }); | |
| const data = await response.json(); | |
| if (!response.ok) { | |
| throw new Error(data.error || 'Auto-classification failed'); | |
| } | |
| let applied = 0; | |
| let lowConfCount = 0; | |
| const LOW_CONF_THRESHOLD = 0.6; | |
| // Clear any previous confidence badges | |
| document.querySelectorAll('.conf-badge').forEach(el => el.remove()); | |
| for (const col of data.columns) { | |
| const selectName = `col_${col.source_file}__${col.source_column}`; | |
| const select = document.querySelector(`select[name="${selectName}"]`); | |
| if (select) { | |
| select.value = col.classification; | |
| updateRowStyle(select); | |
| applied++; | |
| // Flag low-confidence classifications | |
| const conf = col.confidence ?? 1.0; | |
| const row = select.closest('tr'); | |
| if (row && conf < LOW_CONF_THRESHOLD) { | |
| lowConfCount++; | |
| row.classList.add('ring-2', 'ring-amber-400', 'ring-inset'); | |
| const badge = document.createElement('span'); | |
| badge.className = 'conf-badge inline-flex items-center ml-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-amber-100 text-amber-800 cursor-help'; | |
| badge.title = col.reasoning || 'Low confidence'; | |
| badge.textContent = `${Math.round(conf * 100)}%`; | |
| select.parentNode.appendChild(badge); | |
| } else if (row) { | |
| row.classList.remove('ring-2', 'ring-amber-400', 'ring-inset'); | |
| } | |
| } | |
| } | |
| updateAllSummaries(); | |
| let msg = `Classified ${applied} columns. Review & adjust. (Ctrl+Z to undo)`; | |
| if (lowConfCount > 0) { | |
| msg += ` ⚠ ${lowConfCount} column${lowConfCount !== 1 ? 's' : ''} flagged as low confidence — please review.`; | |
| } | |
| successDiv.textContent = msg; | |
| successDiv.classList.remove('hidden'); | |
| } catch (err) { | |
| errorDiv.textContent = err.message; | |
| errorDiv.classList.remove('hidden'); | |
| // Undo the push since nothing changed | |
| undoStack.pop(); | |
| } finally { | |
| stopStatusPolling(); | |
| btn.disabled = false; | |
| spinner.classList.add('hidden'); | |
| } | |
| } | |
| // ===== Learning stats ===== | |
| async function fetchLearningStats() { | |
| const label = document.getElementById('learning-label'); | |
| if (!label) return; | |
| try { | |
| const resp = await fetch('/ui/learning-stats'); | |
| const stats = await resp.json(); | |
| const n = stats.total_examples || 0; | |
| const acc = stats.avg_accuracy; | |
| if (n === 0) { | |
| label.textContent = 'No past examples yet — classify to start learning'; | |
| } else { | |
| let text = `${n} past example${n !== 1 ? 's' : ''}`; | |
| if (stats.examples_with_ai > 0 && acc !== undefined) { | |
| text += ` · ${(acc * 100).toFixed(0)}% AI accuracy`; | |
| } | |
| if (stats.total_corrections > 0) { | |
| text += ` · ${stats.total_corrections} correction${stats.total_corrections !== 1 ? 's' : ''} learned`; | |
| } | |
| label.textContent = text; | |
| label.classList.remove('text-gray-400'); | |
| label.classList.add('text-emerald-600'); | |
| } | |
| } catch { | |
| label.textContent = 'Learning stats unavailable'; | |
| } | |
| } | |
| // ===== Keyboard shortcuts ===== | |
| document.addEventListener('keydown', (e) => { | |
| if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { | |
| e.preventDefault(); | |
| undoClassifications(); | |
| } | |
| if ((e.ctrlKey || e.metaKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) { | |
| e.preventDefault(); | |
| redoClassifications(); | |
| } | |
| }); | |
| // ===== Init ===== | |
| document.querySelectorAll('.classification-select').forEach(updateRowStyle); | |
| updateAllSummaries(); | |
| selectFile(0); | |
| fetchLearningStats(); | |
| </script> | |
| {% endblock %} | |