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:
PM-pinou
2026-07-21 14:00:18 +08:00
parent 9de1d93146
commit ec83d5df85
2 changed files with 190 additions and 3 deletions
+61 -1
View File
@@ -593,6 +593,46 @@ def manual_page(request):
store = SessionStore() store = SessionStore()
datasets = store.list_datasets() if hasattr(store, 'list_datasets') else [] 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', { return render(request, 'tianxuan/manual.html', {
'core_tools': core_tools, 'core_tools': core_tools,
'diag_tools': diag_tools, 'diag_tools': diag_tools,
@@ -600,6 +640,7 @@ def manual_page(request):
'other_tools': other_tools, 'other_tools': other_tools,
'tools_meta': _json.dumps(meta_list, ensure_ascii=False), 'tools_meta': _json.dumps(meta_list, ensure_ascii=False),
'datasets': datasets, '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') algorithm = body.get('algorithm', 'hdbscan')
min_cluster_size = int(body.get('min_cluster_size', 5)) min_cluster_size = int(body.get('min_cluster_size', 5))
head = body.get('head') head = body.get('head')
cluster_mode = body.get('cluster_mode', 'entity')
def _analysis_fn(run, ctx): def _analysis_fn(run, ctx):
from analysis.session_store import SessionStore from analysis.session_store import SessionStore
@@ -867,6 +909,7 @@ def manual_run_analysis(request):
min_cluster_size = ctx['min_cluster_size'] min_cluster_size = ctx['min_cluster_size']
head = ctx.get('head') head = ctx.get('head')
pk = ctx['pk'] pk = ctx['pk']
cluster_mode = ctx.get('cluster_mode', 'entity')
store = SessionStore() store = SessionStore()
try: 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}' 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) # Use the unified clustering pipeline (clustering → extraction → PCA)
from analysis.views import _run_clustering_pipeline from analysis.views import _run_clustering_pipeline
_run_clustering_pipeline( _run_clustering_pipeline(
@@ -910,7 +970,7 @@ def manual_run_analysis(request):
args=(pk, _analysis_fn), args=(pk, _analysis_fn),
kwargs=dict(feature_columns=feature_columns, algorithm=algorithm, kwargs=dict(feature_columns=feature_columns, algorithm=algorithm,
min_cluster_size=min_cluster_size, head=head, pk=pk, 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, daemon=True,
) )
t.start() t.start()
+129 -2
View File
@@ -48,6 +48,21 @@
.pin-btn.pinned { color:#f4a261; } .pin-btn.pinned { color:#f4a261; }
.pin-btn.unpinned { color:#aaa; } .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; } #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> </style>
<div class="card"> <div class="card">
@@ -66,12 +81,26 @@
<div id="autoPlansList"></div> <div id="autoPlansList"></div>
</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 class="card">
<div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap;"> <div style="display:flex;align-items:center;gap:1rem;flex-wrap:wrap;">
<div style="flex:1;min-width:200px;"> <div style="flex:1;min-width:200px;">
<label style="font-weight:600;font-size:0.9rem;">📁 数据集</label> <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> <option value="">-- 选择 --</option>
{% for ds in datasets %} {% for ds in datasets %}
<option value="{{ ds.dataset_id }}">{{ ds.dataset_id }} ({{ ds.row_count|default:"?" }} rows, {{ ds.file_count|default:"?" }} files)</option> <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>
</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 --> <!-- Filter builder card -->
<div class="card" id="filterCard" style="display:none;"> <div class="card" id="filterCard" style="display:none;">
<h3>🔍 数据筛选</h3> <h3>🔍 数据筛选</h3>
@@ -143,6 +181,81 @@ const CATEGORIES = {
let stepCounter = 0; let stepCounter = 0;
const workflow = []; const workflow = [];
let running = false; 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 ── // ── Tool picker ──
function showToolPicker() { function showToolPicker() {
@@ -248,6 +361,20 @@ async function executeStep(idx) {
const resultDiv = document.getElementById(`result-${idx}`); const resultDiv = document.getElementById(`result-${idx}`);
resultDiv.style.display = 'none'; resultDiv.style.display = 'none';
const params = collectParams(idx); 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 { try {
const resp = await fetch('/tools/run/', { const resp = await fetch('/tools/run/', {
method: 'POST', method: 'POST',