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()
|
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()
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user