v1.1.3: row-level clustering + dataset selector with status filter
- Add cluster_mode toggle (raw row-level vs entity-aggregated) in manual.html - manual_run_analysis supports cluster_mode='raw' skipping entity aggregation - Replace simple dataset dropdown with selector showing status badges and filter tabs - Filter tabs: 全部/待分析/已分析/错误, default 待分析 - E2E verified: raw mode entity_count=total_flows, entity mode regression OK
This commit is contained in:
+61
-1
@@ -593,6 +593,46 @@ def manual_page(request):
|
||||
store = SessionStore()
|
||||
datasets = store.list_datasets() if hasattr(store, 'list_datasets') else []
|
||||
|
||||
# Get upload AnalysisRun statuses for dataset badges
|
||||
from analysis.models import AnalysisRun
|
||||
upload_runs = AnalysisRun.objects.filter(run_type='upload').order_by('-created_at')[:50]
|
||||
run_status_map = {
|
||||
f'upload_{r.display_id}': {
|
||||
'status': r.status,
|
||||
'total_flows': r.total_flows,
|
||||
'display_id': r.display_id,
|
||||
}
|
||||
for r in upload_runs
|
||||
}
|
||||
|
||||
# Build datasets_with_status: each dataset annotated with run status
|
||||
datasets_with_status = []
|
||||
for ds in datasets:
|
||||
run_info = run_status_map.get(ds['dataset_id'])
|
||||
if run_info:
|
||||
datasets_with_status.append({
|
||||
**ds,
|
||||
'run_status': run_info['status'],
|
||||
'total_flows': run_info['total_flows'],
|
||||
'display_id': run_info['display_id'],
|
||||
})
|
||||
else:
|
||||
datasets_with_status.append({**ds, 'run_status': 'ready', 'total_flows': None, 'display_id': None})
|
||||
|
||||
# Map statuses to user-facing labels for the filter tabs
|
||||
# ready → 待分析, completed → 已分析, failed → 错误
|
||||
# Everything else (pending/loading/profiling/aggregating/clustering/extracting) → analyzing → 分析中
|
||||
for ds_info in datasets_with_status:
|
||||
s = ds_info['run_status']
|
||||
if s in ('completed',):
|
||||
ds_info['status_label'] = '已分析'
|
||||
elif s in ('failed',):
|
||||
ds_info['status_label'] = '错误'
|
||||
elif s in ('pending', 'loading', 'profiling', 'aggregating', 'clustering', 'extracting'):
|
||||
ds_info['status_label'] = '分析中'
|
||||
else:
|
||||
ds_info['status_label'] = '待分析'
|
||||
|
||||
return render(request, 'tianxuan/manual.html', {
|
||||
'core_tools': core_tools,
|
||||
'diag_tools': diag_tools,
|
||||
@@ -600,6 +640,7 @@ def manual_page(request):
|
||||
'other_tools': other_tools,
|
||||
'tools_meta': _json.dumps(meta_list, ensure_ascii=False),
|
||||
'datasets': datasets,
|
||||
'datasets_with_status_json': _json.dumps(datasets_with_status, ensure_ascii=False),
|
||||
})
|
||||
|
||||
|
||||
@@ -858,6 +899,7 @@ def manual_run_analysis(request):
|
||||
algorithm = body.get('algorithm', 'hdbscan')
|
||||
min_cluster_size = int(body.get('min_cluster_size', 5))
|
||||
head = body.get('head')
|
||||
cluster_mode = body.get('cluster_mode', 'entity')
|
||||
|
||||
def _analysis_fn(run, ctx):
|
||||
from analysis.session_store import SessionStore
|
||||
@@ -867,6 +909,7 @@ def manual_run_analysis(request):
|
||||
min_cluster_size = ctx['min_cluster_size']
|
||||
head = ctx.get('head')
|
||||
pk = ctx['pk']
|
||||
cluster_mode = ctx.get('cluster_mode', 'entity')
|
||||
|
||||
store = SessionStore()
|
||||
try:
|
||||
@@ -888,6 +931,23 @@ def manual_run_analysis(request):
|
||||
|
||||
ds_id = upload_ds_id if store.get_dataset(upload_ds_id) else f'upload_{pk}'
|
||||
|
||||
# ── Cluster mode: entity aggregation vs raw ──
|
||||
if cluster_mode == 'entity':
|
||||
# Build entity profiles first, then cluster on entity dataset
|
||||
from analysis.tool_registry import _handle_build_entity_profiles
|
||||
import asyncio
|
||||
entity_result = asyncio.new_event_loop().run_until_complete(
|
||||
_handle_build_entity_profiles(dataset_id=ds_id, auto_detect=True)
|
||||
)
|
||||
if 'error' in entity_result:
|
||||
run.error_message = f'实体聚合失败: {entity_result["error"]}'
|
||||
run.status = 'failed'
|
||||
run.save(update_fields=['status', 'error_message'])
|
||||
return
|
||||
ds_id = entity_result['dataset_id']
|
||||
run.entity_count = entity_result.get('n_entities')
|
||||
run.save(update_fields=['entity_count'])
|
||||
|
||||
# Use the unified clustering pipeline (clustering → extraction → PCA)
|
||||
from analysis.views import _run_clustering_pipeline
|
||||
_run_clustering_pipeline(
|
||||
@@ -910,7 +970,7 @@ def manual_run_analysis(request):
|
||||
args=(pk, _analysis_fn),
|
||||
kwargs=dict(feature_columns=feature_columns, algorithm=algorithm,
|
||||
min_cluster_size=min_cluster_size, head=head, pk=pk,
|
||||
display_id=run.display_id),
|
||||
display_id=run.display_id, cluster_mode=cluster_mode),
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
|
||||
@@ -48,6 +48,21 @@
|
||||
.pin-btn.pinned { color:#f4a261; }
|
||||
.pin-btn.unpinned { color:#aaa; }
|
||||
#autoPlansList { max-height:180px; overflow-y:auto; margin-top:0.25rem; padding-top:0.25rem; border-top:1px dashed #eee; }
|
||||
/* Dataset selector table */
|
||||
.ds-table { width:100%; border-collapse:collapse; font-size:0.85rem; }
|
||||
.ds-table th { background:#f5f5f5; padding:0.4rem 0.5rem; text-align:left; font-weight:600; border-bottom:2px solid #ddd; position:sticky; top:0; }
|
||||
.ds-table td { padding:0.35rem 0.5rem; border-bottom:1px solid #eee; }
|
||||
.ds-table tr { cursor:pointer; transition: background 0.15s; }
|
||||
.ds-table tr:hover { background:#e8f0fe; }
|
||||
.ds-table tr.selected { background:#d4e6ff; font-weight:600; }
|
||||
.status-badge { display:inline-block; padding:0.15rem 0.5rem; border-radius:3px; font-size:0.75rem; font-weight:600; }
|
||||
.status-badge.ready { background:#e8f5e9; color:#2e7d32; }
|
||||
.status-badge.analyzing { background:#fff3e0; color:#e65100; }
|
||||
.status-badge.completed { background:#e3f2fd; color:#1565c0; }
|
||||
.status-badge.failed { background:#ffebee; color:#c62828; }
|
||||
.filter-btn { padding:0.3rem 0.75rem; border:1px solid #ccc; border-radius:4px; background:#fafafa; cursor:pointer; font-size:0.8rem; transition: all 0.15s; }
|
||||
.filter-btn.active { background:#4361ee; color:#fff; border-color:#4361ee; }
|
||||
.filter-btn:hover:not(.active) { background:#e8e8e8; }
|
||||
</style>
|
||||
|
||||
<div class="card">
|
||||
@@ -66,12 +81,26 @@
|
||||
<div id="autoPlansList"></div>
|
||||
</div>
|
||||
|
||||
<!-- Dataset selector + Run All -->
|
||||
<!-- Dataset selector with status badges -->
|
||||
<div class="card" id="datasetSelectorCard">
|
||||
<h3>📁 选择数据集</h3>
|
||||
<div style="display:flex;gap:0.5rem;margin-bottom:0.75rem;" id="statusFilter">
|
||||
<button class="btn filter-btn" data-filter="all">全部</button>
|
||||
<button class="btn filter-btn active" data-filter="ready">待分析</button>
|
||||
<button class="btn filter-btn" data-filter="completed">已分析</button>
|
||||
<button class="btn filter-btn" data-filter="failed">错误</button>
|
||||
</div>
|
||||
<div id="datasetTable" style="max-height:300px;overflow-y:auto;border:1px solid #e0e0e0;border-radius:4px;">
|
||||
<p style="text-align:center;color:#999;padding:1rem;">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dataset selector + Run All (keeping dropdown hidden as data source) -->
|
||||
<div class="card">
|
||||
<div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap;">
|
||||
<div style="flex:1;min-width:200px;">
|
||||
<label style="font-weight:600;font-size:0.9rem;">📁 数据集</label>
|
||||
<select id="datasetSelect" onchange="onDatasetChange()" style="width:100%;padding:0.4rem;border:1px solid #ccc;border-radius:4px;margin-top:0.25rem;">
|
||||
<select id="datasetSelect" onchange="onDatasetChange()" style="display:none;">
|
||||
<option value="">-- 选择 --</option>
|
||||
{% for ds in datasets %}
|
||||
<option value="{{ ds.dataset_id }}">{{ ds.dataset_id }} ({{ ds.row_count|default:"?" }} rows, {{ ds.file_count|default:"?" }} files)</option>
|
||||
@@ -82,6 +111,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cluster Mode -->
|
||||
<div class="card" id="clusterModeCard">
|
||||
<label style="font-weight:600;">聚类模式</label>
|
||||
<div style="display:flex;gap:1.5rem;margin-top:0.5rem;">
|
||||
<label><input type="radio" name="clusterMode" value="entity" checked onchange="clusterMode='entity'"> 实体聚合后聚类(默认)</label>
|
||||
<label><input type="radio" name="clusterMode" value="raw" onchange="clusterMode='raw'"> 逐行直接聚类</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter builder card -->
|
||||
<div class="card" id="filterCard" style="display:none;">
|
||||
<h3>🔍 数据筛选</h3>
|
||||
@@ -143,6 +181,81 @@ const CATEGORIES = {
|
||||
let stepCounter = 0;
|
||||
const workflow = [];
|
||||
let running = false;
|
||||
var clusterMode = 'entity';
|
||||
|
||||
// ── Dataset selector with status badges ──
|
||||
const DATASETS_WITH_STATUS = {{ datasets_with_status_json|safe }};
|
||||
let activeFilter = 'ready';
|
||||
|
||||
function buildDatasetTable(filter) {
|
||||
const container = document.getElementById('datasetTable');
|
||||
let rows = DATASETS_WITH_STATUS.filter(ds => {
|
||||
if (filter === 'all') return true;
|
||||
if (filter === 'ready') return ds.run_status === 'ready' || (!ds.run_status || ds.run_status === 'ready');
|
||||
if (filter === 'completed') return ds.run_status === 'completed';
|
||||
if (filter === 'failed') return ds.run_status === 'failed';
|
||||
return true;
|
||||
});
|
||||
|
||||
if (!rows.length) {
|
||||
container.innerHTML = '<p style="text-align:center;color:#999;padding:1rem;">无匹配的数据集</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<table class="ds-table"><thead><tr><th>ID</th><th>状态</th><th>行数</th><th>文件</th><th></th></tr></thead><tbody>';
|
||||
rows.forEach(ds => {
|
||||
const label = ds.status_label || '待分析';
|
||||
const badgeClass = (ds.run_status === 'completed') ? 'completed' :
|
||||
(ds.run_status === 'failed') ? 'failed' :
|
||||
(ds.run_status && ds.run_status !== 'ready') ? 'analyzing' : 'ready';
|
||||
const dispId = ds.display_id || ds.dataset_id;
|
||||
const rowCount = ds.row_count != null ? ds.row_count.toLocaleString() : '?';
|
||||
const fileCount = ds.file_count != null ? ds.file_count : '?';
|
||||
html += `<tr data-ds-id="${ds.dataset_id}" onclick="selectDataset('${ds.dataset_id}')">
|
||||
<td style="font-weight:600;">${dispId}</td>
|
||||
<td><span class="status-badge ${badgeClass}">${label}</span></td>
|
||||
<td>${rowCount}</td>
|
||||
<td>${fileCount}</td>
|
||||
<td><button class="btn" onclick="event.stopPropagation();selectDataset('${ds.dataset_id}')" style="font-size:0.75rem;padding:0.2rem 0.5rem;">选择</button></td>
|
||||
</tr>`;
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function selectDataset(dsId) {
|
||||
const sel = document.getElementById('datasetSelect');
|
||||
let opt = Array.from(sel.options).find(o => o.value === dsId);
|
||||
if (!opt) {
|
||||
opt = document.createElement('option');
|
||||
opt.value = dsId;
|
||||
opt.text = dsId;
|
||||
sel.add(opt);
|
||||
}
|
||||
sel.value = dsId;
|
||||
onDatasetChange();
|
||||
|
||||
// Highlight selected row
|
||||
document.querySelectorAll('#datasetTable tr').forEach(tr => tr.classList.remove('selected'));
|
||||
const row = document.querySelector(`#datasetTable tr[data-ds-id="${dsId}"]`);
|
||||
if (row) row.classList.add('selected');
|
||||
}
|
||||
|
||||
function setDatasetFilter(filter) {
|
||||
activeFilter = filter;
|
||||
document.querySelectorAll('#statusFilter .filter-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.filter === filter);
|
||||
});
|
||||
buildDatasetTable(filter);
|
||||
}
|
||||
|
||||
// Init filter tabs & build default table
|
||||
document.querySelectorAll('#statusFilter .filter-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
setDatasetFilter(this.dataset.filter);
|
||||
});
|
||||
});
|
||||
buildDatasetTable('ready');
|
||||
|
||||
// ── Tool picker ──
|
||||
function showToolPicker() {
|
||||
@@ -248,6 +361,20 @@ async function executeStep(idx) {
|
||||
const resultDiv = document.getElementById(`result-${idx}`);
|
||||
resultDiv.style.display = 'none';
|
||||
const params = collectParams(idx);
|
||||
|
||||
// Cluster mode: skip build_entity_profiles in raw mode
|
||||
if (clusterMode === 'raw' && step.tool.name === 'build_entity_profiles') {
|
||||
step.status = 'done';
|
||||
resultDiv.textContent = '[已跳过] 逐行直接聚类模式,无需实体聚合';
|
||||
resultDiv.style.display = 'block';
|
||||
renderSteps();
|
||||
return;
|
||||
}
|
||||
// Pass cluster_mode to run_clustering tool
|
||||
if (clusterMode === 'raw' && step.tool.name === 'run_clustering') {
|
||||
params.cluster_mode = 'raw';
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/tools/run/', {
|
||||
method: 'POST',
|
||||
|
||||
Reference in New Issue
Block a user