PhoenixAgent / app /templates /classify.html
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
Raw
History Blame Contribute Delete
43.6 kB
{% 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 }}) &middot; {{ 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 &middot; {{ 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 &middot; {{ file_info.num_rows }} rows
{% if file_info.dataset_label %} &mdash; {{ 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 %}