Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3fd3896aba | |||
| 66f68a2062 | |||
| b768d02987 |
@@ -67,9 +67,9 @@ class Command(BaseCommand):
|
|||||||
_self.stdout.write(f' → 聚类特征: {feature_cols}')
|
_self.stdout.write(f' → 聚类特征: {feature_cols}')
|
||||||
run.total_flows = row_count
|
run.total_flows = row_count
|
||||||
run.save(update_fields=['total_flows'])
|
run.save(update_fields=['total_flows'])
|
||||||
from analysis.views import _run_clustering_pipeline
|
from analysis.services.clustering import run_clustering_pipeline
|
||||||
_run_clustering_pipeline(
|
run_clustering_pipeline(
|
||||||
run=run, store=store, ds_id=ds_id,
|
run=run, store=store, entity_ds_id=ds_id,
|
||||||
feature_columns=feature_cols,
|
feature_columns=feature_cols,
|
||||||
algorithm=algo, min_cluster_size=5,
|
algorithm=algo, min_cluster_size=5,
|
||||||
run_umap=True,
|
run_umap=True,
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""Analysis services package — pure business logic, no request/response/rendering."""
|
||||||
|
from .clustering import run_clustering_pipeline, compute_umap_embedding, save_entity_profiles
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
"""Clustering service layer: pure business logic for the clustering pipeline.
|
||||||
|
|
||||||
|
Extracted from analysis/views/clustering.py — no request/response, no rendering.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import traceback
|
||||||
|
import logging
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
import polars as pl
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from analysis.constants import RANDOM_SEED, UMAP_BATCH_SIZE, UMAP_TRAIN_SAMPLE, NUMERIC_DTYPES
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def run_clustering_pipeline(run, store, entity_ds_id, feature_columns, algorithm,
|
||||||
|
min_cluster_size, run_umap=True, head=None):
|
||||||
|
"""Pure business logic — no request/response, no rendering.
|
||||||
|
|
||||||
|
Unified clustering pipeline: clustering → feature extraction → UMAP → ORM save.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
run: AnalysisRun ORM object.
|
||||||
|
store: SessionStore instance.
|
||||||
|
entity_ds_id: dataset ID in SessionStore pointing to entity-aggregated data.
|
||||||
|
feature_columns: list of feature column names (None = auto-detect).
|
||||||
|
algorithm: 'hdbscan' or 'kmeans'.
|
||||||
|
min_cluster_size: int for HDBSCAN.
|
||||||
|
run_umap: bool, whether to compute and save UMAP-2D embeddings.
|
||||||
|
head: optional int, downsample to at most this many rows (min 100).
|
||||||
|
"""
|
||||||
|
warnings.filterwarnings('ignore', category=RuntimeWarning, module='sklearn')
|
||||||
|
|
||||||
|
try:
|
||||||
|
from analysis.tool_registry import _handle_run_clustering, _handle_extract_features
|
||||||
|
|
||||||
|
entry = store.get_dataset(entity_ds_id)
|
||||||
|
if entry is None:
|
||||||
|
run.status = 'failed'
|
||||||
|
run.error_message = 'Entity dataset not found in session store'
|
||||||
|
run.save(update_fields=['status', 'error_message'])
|
||||||
|
return
|
||||||
|
|
||||||
|
schema = entry.get('schema', {})
|
||||||
|
|
||||||
|
# ── Downsampling ──
|
||||||
|
if head is not None and head > 0:
|
||||||
|
lf = entry['lazyframe']
|
||||||
|
try:
|
||||||
|
row_count = lf.select(pl.len()).collect(streaming=True).item()
|
||||||
|
except Exception:
|
||||||
|
logger.error("unknown failed: {}".format(traceback.format_exc()))
|
||||||
|
row_count = 0
|
||||||
|
|
||||||
|
if row_count > head:
|
||||||
|
head = max(head, 100) # ensure minimum rows for clustering
|
||||||
|
run.progress_msg = f'正在采样 {head}/{row_count} 行...'
|
||||||
|
run.save(update_fields=['progress_msg'])
|
||||||
|
lf = lf.head(head)
|
||||||
|
store.store_dataset(
|
||||||
|
entity_ds_id, lf,
|
||||||
|
schema=schema,
|
||||||
|
metadata=entry.get('metadata', {}),
|
||||||
|
)
|
||||||
|
entry = store.get_dataset(entity_ds_id) # refresh
|
||||||
|
row_count = head
|
||||||
|
|
||||||
|
# Resolve feature columns — use actual LazyFrame dtypes (not stored schema dict
|
||||||
|
# which may be stale after Utf8 coercion in _background_process).
|
||||||
|
numeric_types = (pl.Int8, pl.Int16, pl.Int32, pl.Int64,
|
||||||
|
pl.UInt8, pl.UInt16, pl.UInt32, pl.UInt64,
|
||||||
|
pl.Float32, pl.Float64)
|
||||||
|
|
||||||
|
lf = entry['lazyframe']
|
||||||
|
live_schema = lf.collect_schema()
|
||||||
|
live_names = live_schema.names()
|
||||||
|
live_dtypes = list(live_schema.dtypes())
|
||||||
|
|
||||||
|
if feature_columns:
|
||||||
|
feature_cols = [
|
||||||
|
c for c in feature_columns
|
||||||
|
if c in live_names and live_dtypes[live_names.index(c)] in numeric_types
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
feature_cols = [
|
||||||
|
live_names[i] for i, dt in enumerate(live_dtypes)
|
||||||
|
if dt in numeric_types and not live_names[i].startswith('_')
|
||||||
|
][:10]
|
||||||
|
|
||||||
|
# ── Step 1: Clustering ────────────────────────────────────────────────
|
||||||
|
# If no numeric columns detected, pass empty list to let
|
||||||
|
# _handle_run_clustering apply its Utf8→Float64 coercion fallback.
|
||||||
|
run.status = 'clustering'
|
||||||
|
run.progress_pct = 70
|
||||||
|
run.progress_msg = '正在进行聚类分析...'
|
||||||
|
run.save(update_fields=['status', 'progress_pct', 'progress_msg'])
|
||||||
|
|
||||||
|
result = asyncio.run(_handle_run_clustering(
|
||||||
|
dataset_id=entity_ds_id,
|
||||||
|
cluster_columns=feature_cols,
|
||||||
|
algorithm=algorithm,
|
||||||
|
params={'min_cluster_size': min_cluster_size},
|
||||||
|
random_state=RANDOM_SEED,
|
||||||
|
))
|
||||||
|
|
||||||
|
if 'error' in result:
|
||||||
|
err = result['error']
|
||||||
|
if 'numeric' in err.lower():
|
||||||
|
available = [
|
||||||
|
f"{k}({v})" for k, v in schema.items()
|
||||||
|
if v.split('(')[0].strip() in numeric_types and not k.startswith('_')
|
||||||
|
]
|
||||||
|
err += f"。可用数值列: {available}"
|
||||||
|
raise Exception(err)
|
||||||
|
|
||||||
|
cluster_id = result.get('cluster_result_id', '')
|
||||||
|
run.cluster_count = result.get('n_clusters', 0)
|
||||||
|
run.save(update_fields=['cluster_count'])
|
||||||
|
|
||||||
|
# ── Step 2: Feature extraction ──────────────────────────────────────
|
||||||
|
run.status = 'extracting'
|
||||||
|
run.progress_pct = 90
|
||||||
|
run.progress_msg = '正在提取聚类特征...'
|
||||||
|
run.save(update_fields=['status', 'progress_pct', 'progress_msg'])
|
||||||
|
|
||||||
|
feat_result = asyncio.run(_handle_extract_features(
|
||||||
|
dataset_id=entity_ds_id,
|
||||||
|
cluster_result_id=cluster_id,
|
||||||
|
top_k=10, method='zscore', save_to_db=True,
|
||||||
|
run_id=run.id,
|
||||||
|
))
|
||||||
|
|
||||||
|
if 'error' in feat_result:
|
||||||
|
raise Exception(feat_result['error'])
|
||||||
|
|
||||||
|
# ── Step 2b: Cluster SVD feature extraction ──────────────────────
|
||||||
|
run.progress_msg = '正在进行聚类SVD特征提取...'
|
||||||
|
run.save(update_fields=['progress_msg'])
|
||||||
|
try:
|
||||||
|
from analysis.distance import cluster_svd_extract
|
||||||
|
lf = entry['lazyframe']
|
||||||
|
cluster_entry = store.get_cluster_result(cluster_id)
|
||||||
|
labels_list = cluster_entry.get('labels', []) if cluster_entry else []
|
||||||
|
|
||||||
|
if labels_list and feature_cols:
|
||||||
|
svd_results = cluster_svd_extract(lf, labels_list, feature_cols)
|
||||||
|
|
||||||
|
# Persist SVD features to ClusterFeature (method='cluster_svd')
|
||||||
|
from analysis.models import ClusterFeature as CF
|
||||||
|
from analysis.models import ClusterResult as CR
|
||||||
|
for label, svd_info in svd_results.items():
|
||||||
|
cr = CR.objects.filter(run=run, cluster_label=label).first()
|
||||||
|
if cr is None:
|
||||||
|
continue
|
||||||
|
# Delete old zscore entries for this cluster, replace with SVD
|
||||||
|
CF.objects.filter(cluster=cr, distinguishing_method='zscore').delete()
|
||||||
|
for feat_name in svd_info.get('features', [])[:10]:
|
||||||
|
idx = svd_info['features'].index(feat_name) if feat_name in svd_info['features'] else -1
|
||||||
|
strength = (svd_info.get('feature_strength', [])[idx]
|
||||||
|
if idx >= 0 and idx < len(svd_info.get('feature_strength', []))
|
||||||
|
else None)
|
||||||
|
CF.objects.get_or_create(
|
||||||
|
cluster=cr,
|
||||||
|
feature_name=feat_name,
|
||||||
|
defaults={
|
||||||
|
'mean': None, 'std': None, 'median': None,
|
||||||
|
'p25': None, 'p75': None, 'missing_rate': None,
|
||||||
|
'distinguishing_score': float(strength) if strength is not None else None,
|
||||||
|
'distinguishing_method': 'cluster_svd',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as svd_err:
|
||||||
|
logger.warning(f'Cluster SVD extraction skipped: {svd_err}')
|
||||||
|
|
||||||
|
# ── Step 3: UMAP-3D embedding (2D fallback) ───────────────────────
|
||||||
|
if run_umap:
|
||||||
|
try:
|
||||||
|
lf = entry['lazyframe']
|
||||||
|
# UMAP: sample max 10K for training, batch-transform rest in 1K batches
|
||||||
|
MAX_UMAP_TRAIN = UMAP_TRAIN_SAMPLE
|
||||||
|
BATCH_SIZE = UMAP_BATCH_SIZE
|
||||||
|
try:
|
||||||
|
n_total = lf.select(pl.len()).collect(streaming=True).item()
|
||||||
|
except Exception:
|
||||||
|
logger.error("unknown failed: {}".format(traceback.format_exc()))
|
||||||
|
n_total = 0
|
||||||
|
from sklearn.preprocessing import StandardScaler
|
||||||
|
import umap
|
||||||
|
|
||||||
|
live_schema = lf.collect_schema()
|
||||||
|
num_cols = [name for name, dt in zip(live_schema.names(), live_schema.dtypes())
|
||||||
|
if dt in (pl.Int8, pl.Int16, pl.Int32, pl.Int64,
|
||||||
|
pl.UInt8, pl.UInt16, pl.UInt32, pl.UInt64,
|
||||||
|
pl.Float32, pl.Float64) and not name.startswith('_')]
|
||||||
|
|
||||||
|
if len(num_cols) >= 2:
|
||||||
|
if n_total > MAX_UMAP_TRAIN:
|
||||||
|
run.progress_msg = f'UMAP 采样 {MAX_UMAP_TRAIN}/{n_total} 实体训练...'
|
||||||
|
run.save(update_fields=['progress_msg'])
|
||||||
|
df_sample = lf.sample(n=MAX_UMAP_TRAIN, seed=RANDOM_SEED).collect(streaming=True)
|
||||||
|
df_umap = lf.collect(streaming=True)
|
||||||
|
else:
|
||||||
|
df_umap = lf.collect(streaming=True)
|
||||||
|
|
||||||
|
coords, umap_components = compute_umap_embedding(
|
||||||
|
df_umap, num_cols, n_total,
|
||||||
|
max_train=MAX_UMAP_TRAIN,
|
||||||
|
batch_size=BATCH_SIZE,
|
||||||
|
seed=RANDOM_SEED,
|
||||||
|
)
|
||||||
|
|
||||||
|
cluster_entry = store.get_cluster_result(cluster_id)
|
||||||
|
labels_list = cluster_entry.get('labels', []) if cluster_entry else []
|
||||||
|
|
||||||
|
save_entity_profiles(run, df_umap, labels_list, coords, umap_components)
|
||||||
|
except Exception as umap_err:
|
||||||
|
logger.warning(f'UMAP embedding skipped: {umap_err}')
|
||||||
|
|
||||||
|
# Fallback: if entity_count not set (UMAP skipped), derive from cluster sizes
|
||||||
|
if run.entity_count is None:
|
||||||
|
from django.db.models import Sum
|
||||||
|
total = run.clusters.aggregate(total=Sum('size'))['total'] or 0
|
||||||
|
run.entity_count = total
|
||||||
|
run.save(update_fields=['entity_count'])
|
||||||
|
|
||||||
|
# ── Done ──
|
||||||
|
run.status = 'completed'
|
||||||
|
run.progress_pct = 100
|
||||||
|
run.progress_msg = '分析完成'
|
||||||
|
run.save(update_fields=['status', 'progress_pct', 'progress_msg', 'entity_count'])
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
tb = traceback.format_exc()
|
||||||
|
logger.error(tb)
|
||||||
|
try:
|
||||||
|
run.status = 'failed'
|
||||||
|
run.error_message = tb
|
||||||
|
run.progress_msg = f'失败: {tb}'
|
||||||
|
run.run_log += f'\n[ERROR] {tb}'
|
||||||
|
run.save(update_fields=['status', 'error_message', 'progress_msg', 'run_log'])
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning('save failed after pipeline error: %s', e)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_umap_embedding(df, num_cols, n_total, max_train, batch_size, seed):
|
||||||
|
"""UMAP 3D→2D fallback, returns (coords, umap_components).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
df: Collected Polars DataFrame with entity-level data.
|
||||||
|
num_cols: list of numeric column names to use for UMAP.
|
||||||
|
n_total: total row count.
|
||||||
|
max_train: max rows for UMAP training sample.
|
||||||
|
batch_size: batch size for transform when dataset exceeds max_train.
|
||||||
|
seed: random seed.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(coords, umap_components): numpy array of shape (n_total, umap_components),
|
||||||
|
and umap_components is 2 or 3.
|
||||||
|
"""
|
||||||
|
from sklearn.preprocessing import StandardScaler
|
||||||
|
import umap
|
||||||
|
|
||||||
|
if len(df) <= 2 or len(num_cols) < 2:
|
||||||
|
return None, 0
|
||||||
|
|
||||||
|
if n_total > max_train:
|
||||||
|
df_sample = df.sample(n=max_train, seed=seed)
|
||||||
|
else:
|
||||||
|
df_sample = df
|
||||||
|
|
||||||
|
umap_components = 3 # try 3D first
|
||||||
|
coords = None
|
||||||
|
|
||||||
|
for attempt_n in (3, 2):
|
||||||
|
try:
|
||||||
|
if n_total > max_train:
|
||||||
|
# Train UMAP on sample, batch-transform full dataset
|
||||||
|
mat_sample = df_sample.select(num_cols).to_numpy()
|
||||||
|
scaler = StandardScaler().fit(mat_sample)
|
||||||
|
mat_sample_scaled = np.nan_to_num(
|
||||||
|
scaler.transform(mat_sample), nan=0.0)
|
||||||
|
reducer = umap.UMAP(n_components=attempt_n,
|
||||||
|
random_state=seed,
|
||||||
|
n_neighbors=15, min_dist=0.1,
|
||||||
|
metric='euclidean')
|
||||||
|
reducer.fit(mat_sample_scaled)
|
||||||
|
# Batch transform in batch_size-row batches
|
||||||
|
coords_list = []
|
||||||
|
for start in range(0, len(df), batch_size):
|
||||||
|
end = min(start + batch_size, len(df))
|
||||||
|
mat_batch = scaler.transform(
|
||||||
|
df[start:end].select(num_cols).to_numpy())
|
||||||
|
mat_batch = np.nan_to_num(mat_batch, nan=0.0)
|
||||||
|
coords_list.append(reducer.transform(mat_batch))
|
||||||
|
coords = np.vstack(coords_list)
|
||||||
|
else:
|
||||||
|
mat = np.nan_to_num(StandardScaler().fit_transform(
|
||||||
|
df.select(num_cols).to_numpy()), nan=0.0)
|
||||||
|
reducer = umap.UMAP(n_components=attempt_n,
|
||||||
|
random_state=seed,
|
||||||
|
n_neighbors=15, min_dist=0.1,
|
||||||
|
metric='euclidean')
|
||||||
|
coords = reducer.fit_transform(mat)
|
||||||
|
umap_components = attempt_n
|
||||||
|
break
|
||||||
|
except Exception as e_umap:
|
||||||
|
if attempt_n == 2:
|
||||||
|
raise
|
||||||
|
logger.warning(f'UMAP 3D failed ({e_umap}), falling back to 2D')
|
||||||
|
|
||||||
|
if coords is None:
|
||||||
|
raise Exception('UMAP produced no output')
|
||||||
|
|
||||||
|
return coords, umap_components
|
||||||
|
|
||||||
|
|
||||||
|
def save_entity_profiles(run, df_umap, labels_list, coords, umap_components):
|
||||||
|
"""Persist per-row cluster labels + UMAP coords to EntityProfile.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
run: AnalysisRun ORM object.
|
||||||
|
df_umap: Collected Polars DataFrame (used for entity column detection + row iteration).
|
||||||
|
labels_list: list of cluster labels per row.
|
||||||
|
coords: numpy array of UMAP coordinates (must not be None).
|
||||||
|
umap_components: 2 or 3.
|
||||||
|
"""
|
||||||
|
from analysis.models import EntityProfile as EP
|
||||||
|
from analysis.models import ClusterResult as CR
|
||||||
|
|
||||||
|
# Determine entity column (first non-numeric, non-underscore column)
|
||||||
|
df_num_cols = [c for c, dt in zip(df_umap.columns, df_umap.dtypes)
|
||||||
|
if dt in NUMERIC_DTYPES]
|
||||||
|
non_num = [c for c in df_umap.columns
|
||||||
|
if c not in df_num_cols and not c.startswith('_')]
|
||||||
|
ent_col = non_num[0] if non_num else df_umap.columns[0]
|
||||||
|
|
||||||
|
profiles = []
|
||||||
|
cr_cache = {}
|
||||||
|
for i, row in enumerate(df_umap.iter_rows(named=True)):
|
||||||
|
ev = str(row.get(ent_col, '')) or 'entity'
|
||||||
|
ev = f'{ev}_{i}'
|
||||||
|
lbl = labels_list[i] if i < len(labels_list) else -1
|
||||||
|
cache_key = f'{run.id}_{lbl}'
|
||||||
|
if cache_key not in cr_cache:
|
||||||
|
cr_cache[cache_key] = CR.objects.filter(
|
||||||
|
run=run, cluster_label=lbl).first()
|
||||||
|
has_z = umap_components >= 3 and coords.shape[1] >= 3
|
||||||
|
profiles.append(EP(
|
||||||
|
entity_value=ev, run=run, cluster_label=lbl,
|
||||||
|
cluster=cr_cache[cache_key],
|
||||||
|
embedding_x=float(coords[i, 0]) if i < len(coords) else None,
|
||||||
|
embedding_y=float(coords[i, 1]) if i < len(coords) else None,
|
||||||
|
embedding_z=float(coords[i, 2]) if has_z
|
||||||
|
and i < len(coords) else None,
|
||||||
|
))
|
||||||
|
if profiles:
|
||||||
|
EP.objects.bulk_create(profiles, ignore_conflicts=True, batch_size=1000)
|
||||||
|
run.entity_count = len(profiles)
|
||||||
|
run.save(update_fields=['entity_count'])
|
||||||
@@ -12,7 +12,7 @@ from django.views.decorators.csrf import csrf_exempt
|
|||||||
from config import get_config
|
from config import get_config
|
||||||
from analysis.models import AnalysisRun
|
from analysis.models import AnalysisRun
|
||||||
from .pipeline import _run_pipeline_worker
|
from .pipeline import _run_pipeline_worker
|
||||||
from .clustering import _run_clustering_pipeline
|
from analysis.services.clustering import run_clustering_pipeline
|
||||||
from .helpers import _PLANS_DIR, _add_to_auto_index
|
from .helpers import _PLANS_DIR, _add_to_auto_index
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -271,7 +271,7 @@ def run_llm_analysis_view(request):
|
|||||||
break
|
break
|
||||||
|
|
||||||
if entity_ds_id:
|
if entity_ds_id:
|
||||||
_run_clustering_pipeline(
|
run_clustering_pipeline(
|
||||||
run=run,
|
run=run,
|
||||||
store=store,
|
store=store,
|
||||||
ds_id=entity_ds_id,
|
ds_id=entity_ds_id,
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ def _run_clustering_pipeline(run, store, ds_id, feature_columns, algorithm,
|
|||||||
Operates directly on raw data rows (no entity aggregation).
|
Operates directly on raw data rows (no entity aggregation).
|
||||||
Per-row results stored in EntityProfile with row index as identifier.
|
Per-row results stored in EntityProfile with row index as identifier.
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
Args:
|
Args:
|
||||||
run: AnalysisRun ORM object.
|
run: AnalysisRun ORM object.
|
||||||
store: SessionStore instance.
|
store: SessionStore instance.
|
||||||
@@ -525,3 +526,9 @@ def _run_clustering_pipeline(run, store, ds_id, feature_columns, algorithm,
|
|||||||
run.save(update_fields=['status', 'error_message', 'progress_msg', 'run_log'])
|
run.save(update_fields=['status', 'error_message', 'progress_msg', 'run_log'])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning('save failed after pipeline error: %s', e)
|
logger.warning('save failed after pipeline error: %s', e)
|
||||||
|
"""
|
||||||
|
Delegates to the service layer.
|
||||||
|
"""
|
||||||
|
from analysis.services.clustering import run_clustering_pipeline
|
||||||
|
run_clustering_pipeline(run, store, feature_columns, algorithm,
|
||||||
|
min_cluster_size, run_umap=run_umap, head=head)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from django.views.decorators.csrf import csrf_exempt
|
|||||||
|
|
||||||
from analysis.models import AnalysisRun
|
from analysis.models import AnalysisRun
|
||||||
from .pipeline import _run_pipeline_worker
|
from .pipeline import _run_pipeline_worker
|
||||||
from .clustering import _run_clustering_pipeline
|
from analysis.services.clustering import run_clustering_pipeline
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -23,9 +23,9 @@ def manual_page(request):
|
|||||||
|
|
||||||
# Get all tools metadata
|
# Get all tools metadata
|
||||||
tools = get_tools_meta()
|
tools = get_tools_meta()
|
||||||
core_names = {'profile_data', 'run_clustering', 'extract_features',
|
core_names = {'profile_data', 'build_entity_profiles', 'compute_scores',
|
||||||
'filter_data', 'preprocess_data',
|
'run_clustering', 'extract_features', 'detect_anomalies',
|
||||||
'detect_anomalies', 'visualize_anomalies'}
|
'visualize_anomalies'}
|
||||||
diag_names = {'validate_data', 'explore_distributions', 'find_outliers',
|
diag_names = {'validate_data', 'explore_distributions', 'find_outliers',
|
||||||
'diagnose_clustering', 'compare_datasets', 'export_debug_sample',
|
'diagnose_clustering', 'compare_datasets', 'export_debug_sample',
|
||||||
'repair_schema'}
|
'repair_schema'}
|
||||||
@@ -164,8 +164,8 @@ 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}'
|
||||||
|
|
||||||
# Use the unified clustering pipeline (clustering → extraction → UMAP)
|
# Use the unified clustering pipeline (clustering → extraction → UMAP)
|
||||||
_run_clustering_pipeline(
|
run_clustering_pipeline(
|
||||||
run=run, store=store, ds_id=ds_id,
|
run=run, store=store, entity_ds_id=ds_id,
|
||||||
feature_columns=feature_columns,
|
feature_columns=feature_columns,
|
||||||
algorithm=algorithm, min_cluster_size=min_cluster_size,
|
algorithm=algorithm, min_cluster_size=min_cluster_size,
|
||||||
run_umap=True, head=head,
|
run_umap=True, head=head,
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* Shared cluster page styles.
|
||||||
|
* Extracted from templates/analysis/cluster_overview.html and cluster_detail.html
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Legend pills ── */
|
||||||
|
.legend-pills {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 8px;
|
||||||
|
padding: 6px 0;
|
||||||
|
}
|
||||||
|
.pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: all 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.pill:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
.pill.active {
|
||||||
|
border-color: #333;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Cluster card ── */
|
||||||
|
.cluster-card {
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.cluster-card:hover {
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.cluster-card.selected {
|
||||||
|
border-color: #4361ee;
|
||||||
|
box-shadow: 0 0 0 2px #4361ee33;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── NL summary ── */
|
||||||
|
.nl-summary {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-left: 3px solid #4361ee;
|
||||||
|
padding: 0.6rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Feature table ── */
|
||||||
|
.feat-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.feat-table th {
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 0.3rem 0.5rem;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 2px solid #ddd;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.feat-table td {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
.feat-table td code {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Feature scores ── */
|
||||||
|
.feat-positive {
|
||||||
|
color: #2e7d32;
|
||||||
|
}
|
||||||
|
.feat-negative {
|
||||||
|
color: #c62828;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Detail panel (overlay) ── */
|
||||||
|
.detail-overlay {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
transform: translateY(100%);
|
||||||
|
transition: transform 0.35s cubic-bezier(.4,0,.2,1);
|
||||||
|
}
|
||||||
|
.detail-overlay.open {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
.detail-content {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
box-shadow: 0 -4px 24px rgba(0,0,0,0.15);
|
||||||
|
max-height: 45vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem 2rem 2rem;
|
||||||
|
}
|
||||||
|
.detail-handle {
|
||||||
|
width: 40px;
|
||||||
|
height: 4px;
|
||||||
|
background: #ccc;
|
||||||
|
border-radius: 2px;
|
||||||
|
margin: 0.5rem auto;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.detail-handle:hover {
|
||||||
|
background: #999;
|
||||||
|
}
|
||||||
|
.detail-close {
|
||||||
|
float: right;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #999;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.detail-close:hover {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* Three.js 3D globe rendering for TLS flow analysis.
|
||||||
|
* Extracted from templates/tianxuan/globe_embed.html
|
||||||
|
*
|
||||||
|
* Usage: initGlobe('globeContainer', geoFlows, options)
|
||||||
|
*/
|
||||||
|
function initGlobe(containerId, geoFlows, options) {
|
||||||
|
options = options || {};
|
||||||
|
var W = window.innerWidth, H = window.innerHeight;
|
||||||
|
|
||||||
|
var scene = new THREE.Scene();
|
||||||
|
var camera = new THREE.PerspectiveCamera(45, W / H, 0.1, 1000);
|
||||||
|
camera.position.set(0, 2, 12);
|
||||||
|
|
||||||
|
var renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||||
|
renderer.setSize(W, H);
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
document.getElementById(containerId).appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
// Earth
|
||||||
|
var earthGeo = new THREE.SphereGeometry(5, 64, 64);
|
||||||
|
var earthMat = new THREE.MeshPhongMaterial({ color: 0x1a3a5c, emissive: 0x0a1a2a, specular: new THREE.Color(0x333333), shininess: 5 });
|
||||||
|
var earth = new THREE.Mesh(earthGeo, earthMat);
|
||||||
|
scene.add(earth);
|
||||||
|
|
||||||
|
// Glow
|
||||||
|
var glow = new THREE.Mesh(new THREE.SphereGeometry(5.1, 64, 64), new THREE.MeshBasicMaterial({ color: 0x224488, transparent: true, opacity: 0.1 }));
|
||||||
|
scene.add(glow);
|
||||||
|
|
||||||
|
// Lights
|
||||||
|
scene.add(new THREE.AmbientLight(0x222244));
|
||||||
|
var dl = new THREE.DirectionalLight(0xffffff, 1);
|
||||||
|
dl.position.set(5, 10, 7);
|
||||||
|
scene.add(dl);
|
||||||
|
scene.add(new THREE.DirectionalLight(0x4488ff, 0.3));
|
||||||
|
|
||||||
|
// Stars
|
||||||
|
var starGeo = new THREE.BufferGeometry();
|
||||||
|
var starPos = new Float32Array(1000 * 3);
|
||||||
|
for (var i = 0; i < 1000 * 3; i++) starPos[i] = (Math.random() - 0.5) * 200;
|
||||||
|
starGeo.setAttribute('position', new THREE.BufferAttribute(starPos, 3));
|
||||||
|
scene.add(new THREE.Points(starGeo, new THREE.PointsMaterial({ color: 0xffffff, size: 0.15 })));
|
||||||
|
|
||||||
|
// Lat/lon grid
|
||||||
|
var gridMat = new THREE.LineBasicMaterial({ color: 0x6699cc, transparent: true, opacity: 0.1 });
|
||||||
|
for (var lat = -80; lat <= 80; lat += 20) {
|
||||||
|
var pts = [];
|
||||||
|
for (var lon = 0; lon <= 360; lon += 5) {
|
||||||
|
var phi = (90 - lat) * Math.PI / 180;
|
||||||
|
var theta = (lon + 180) * Math.PI / 180;
|
||||||
|
pts.push(new THREE.Vector3(-5.02 * Math.sin(phi) * Math.cos(theta), 5.02 * Math.cos(phi), 5.02 * Math.sin(phi) * Math.sin(theta)));
|
||||||
|
}
|
||||||
|
scene.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), gridMat));
|
||||||
|
}
|
||||||
|
for (var lon = 0; lon < 360; lon += 20) {
|
||||||
|
var pts = [];
|
||||||
|
for (var lat = -90; lat <= 90; lat += 5) {
|
||||||
|
var phi = (90 - lat) * Math.PI / 180;
|
||||||
|
var theta = (lon + 180) * Math.PI / 180;
|
||||||
|
pts.push(new THREE.Vector3(-5.02 * Math.sin(phi) * Math.cos(theta), 5.02 * Math.cos(phi), 5.02 * Math.sin(phi) * Math.sin(theta)));
|
||||||
|
}
|
||||||
|
scene.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), gridMat));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flow arcs
|
||||||
|
var tlsColors = { 'TLSv1.3': 0x4cc9f0, 'TLSv1.2': 0x43aa8b, 'TLSv1.1': 0xf8961e, 'TLSv1.0': 0xf94144 };
|
||||||
|
var flowGroup = new THREE.Group();
|
||||||
|
scene.add(flowGroup);
|
||||||
|
|
||||||
|
function latLonToVec3(lat, lon, r) {
|
||||||
|
if (lat == null || lon == null) return null;
|
||||||
|
var phi = (90 - lat) * Math.PI / 180;
|
||||||
|
var theta = (lon + 180) * Math.PI / 180;
|
||||||
|
return new THREE.Vector3(-r * Math.sin(phi) * Math.cos(theta), r * Math.cos(phi), r * Math.sin(phi) * Math.sin(theta));
|
||||||
|
}
|
||||||
|
|
||||||
|
var arcMeshes = [];
|
||||||
|
var flowData = Array.isArray(geoFlows) ? geoFlows : (typeof geoFlows === 'string' ? JSON.parse(geoFlows) : []);
|
||||||
|
flowData.forEach(function (flow) {
|
||||||
|
var from = latLonToVec3(flow.slat, flow.slon, 5);
|
||||||
|
var to = latLonToVec3(flow.dlat, flow.dlon, 5);
|
||||||
|
if (!from || !to) return;
|
||||||
|
var mid = new THREE.Vector3().addVectors(from, to).multiplyScalar(0.5).normalize().multiplyScalar(7);
|
||||||
|
var curve = new THREE.QuadraticBezierCurve3(from, mid, to);
|
||||||
|
var color = tlsColors[flow.tls] || 0x888888;
|
||||||
|
var mat = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.6 });
|
||||||
|
var mesh = new THREE.Mesh(new THREE.TubeGeometry(curve, 20, 0.02, 4, false), mat);
|
||||||
|
mesh.userData = flow;
|
||||||
|
flowGroup.add(mesh);
|
||||||
|
arcMeshes.push(mesh);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mouse rotation
|
||||||
|
var isDragging = false, prevMouse = { x: 0, y: 0 };
|
||||||
|
renderer.domElement.addEventListener('mousedown', function (e) { isDragging = true; prevMouse = { x: e.clientX, y: e.clientY }; });
|
||||||
|
window.addEventListener('mouseup', function () { isDragging = false; });
|
||||||
|
window.addEventListener('mousemove', function (e) {
|
||||||
|
if (!isDragging) return;
|
||||||
|
var dx = e.clientX - prevMouse.x, dy = e.clientY - prevMouse.y;
|
||||||
|
earth.rotation.y += dx * 0.005;
|
||||||
|
earth.rotation.z += dy * 0.005;
|
||||||
|
flowGroup.rotation.y = earth.rotation.y;
|
||||||
|
flowGroup.rotation.z = earth.rotation.z;
|
||||||
|
prevMouse = { x: e.clientX, y: e.clientY };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for cluster filter from parent
|
||||||
|
window.addEventListener('message', function (e) {
|
||||||
|
if (e.data && e.data.type === 'globe_filter') {
|
||||||
|
var label = e.data.cluster_label;
|
||||||
|
arcMeshes.forEach(function (mesh) {
|
||||||
|
if (label === null) {
|
||||||
|
mesh.material.opacity = 0.6;
|
||||||
|
mesh.material.color.setHex(tlsColors[mesh.userData.tls] || 0x888888);
|
||||||
|
} else {
|
||||||
|
mesh.material.opacity = 0.06;
|
||||||
|
mesh.material.color.setHex(0x666666);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-rotate
|
||||||
|
(function animate() {
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
if (!isDragging) { earth.rotation.y += 0.003; flowGroup.rotation.y = earth.rotation.y; }
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
})();
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Simple Markdown-to-HTML converter.
|
||||||
|
* Extracted from templates/tianxuan/auto.html
|
||||||
|
*
|
||||||
|
* Usage: renderMarkdown(text) → HTML string
|
||||||
|
*/
|
||||||
|
function renderMarkdown(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
// Escape HTML first
|
||||||
|
var html = escapeHtml(text);
|
||||||
|
// Code blocks (```...```) — must be before inline code
|
||||||
|
html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, '<pre style="background:#1a1a2e;color:#e0e0e0;padding:0.5rem;border-radius:4px;font-size:0.78rem;overflow:auto;margin:0.4rem 0;"><code>$2</code></pre>');
|
||||||
|
// Inline code
|
||||||
|
html = html.replace(/`([^`]+)`/g, '<code style="background:#1a1a2e;color:#e0e0e0;padding:0.1rem 0.3rem;border-radius:3px;font-size:0.78rem;">$1</code>');
|
||||||
|
// Bold (**text** or __text__)
|
||||||
|
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||||
|
html = html.replace(/__([^_]+)__/g, '<strong>$1</strong>');
|
||||||
|
// Italic (*text* or _text_)
|
||||||
|
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||||||
|
html = html.replace(/(?<!_)_{1}(?!_)([^_]+)_{1}(?!_)/g, '<em>$1</em>');
|
||||||
|
// Links [text](url)
|
||||||
|
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" style="color:#4361ee;">$1</a>');
|
||||||
|
// Headers (# ## ###)
|
||||||
|
html = html.replace(/^### (.+)$/gm, '<h4 style="margin:0.5rem 0 0.3rem;font-size:0.95rem;">$1</h4>');
|
||||||
|
html = html.replace(/^## (.+)$/gm, '<h3 style="margin:0.5rem 0 0.3rem;font-size:1rem;">$1</h3>');
|
||||||
|
html = html.replace(/^# (.+)$/gm, '<h2 style="margin:0.5rem 0 0.3rem;font-size:1.1rem;">$1</h2>');
|
||||||
|
// Blockquotes
|
||||||
|
html = html.replace(/^>\s?(.+)$/gm, '<blockquote style="border-left:3px solid #4361ee;padding:0.3rem 0.6rem;margin:0.3rem 0;background:#f0f4ff;border-radius:0 4px 4px 0;">$1</blockquote>');
|
||||||
|
// Unordered lists
|
||||||
|
html = html.replace(/^[\s]*[-*+]\s+(.+)$/gm, '<li style="margin:0.15rem 0;">$1</li>');
|
||||||
|
html = html.replace(/(<li[\s\S]*?<\/li>)\n(?!<li)/g, function (m) { return '<ul style="padding-left:1.5rem;margin:0.3rem 0;">' + m + '</ul>'; });
|
||||||
|
// Ordered lists
|
||||||
|
html = html.replace(/^\d+\.\s+(.+)$/gm, '<li style="margin:0.15rem 0;">$1</li>');
|
||||||
|
// Horizontal rules
|
||||||
|
html = html.replace(/^---$/gm, '<hr style="border:none;border-top:1px solid #ddd;margin:0.5rem 0;">');
|
||||||
|
// Paragraphs (double newlines → paragraphs)
|
||||||
|
html = html.replace(/\n\n/g, '</p><p style="margin:0.4rem 0;">');
|
||||||
|
// Line breaks (single newlines → br)
|
||||||
|
html = html.replace(/\n/g, '<br>');
|
||||||
|
// Wrap in paragraph if not already wrapped
|
||||||
|
if (!html.startsWith('<')) html = '<p style="margin:0.4rem 0;">' + html + '</p>';
|
||||||
|
return html;
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* LLM workflow timeline rendering.
|
||||||
|
* Extracted from templates/tianxuan/auto.html and templates/analysis/run_detail.html
|
||||||
|
*
|
||||||
|
* Provides:
|
||||||
|
* buildTimeline(llmThinking, toolCalls) → entries[]
|
||||||
|
* renderTimeline(entries, containerId)
|
||||||
|
* escapeHtml(str)
|
||||||
|
* prettyJson(obj)
|
||||||
|
* toggleStep(el)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML special characters.
|
||||||
|
*/
|
||||||
|
function escapeHtml(str) {
|
||||||
|
if (str == null) return '';
|
||||||
|
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pretty-print object as JSON string.
|
||||||
|
*/
|
||||||
|
function prettyJson(obj) {
|
||||||
|
try { return JSON.stringify(obj, null, 2); }
|
||||||
|
catch (e) { return String(obj); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle a collapsible step card.
|
||||||
|
*/
|
||||||
|
function toggleStep(el) {
|
||||||
|
el.classList.toggle('open');
|
||||||
|
var body = el.nextElementSibling;
|
||||||
|
if (body) body.classList.toggle('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build timeline entries from LLM thinking text and tool calls.
|
||||||
|
*
|
||||||
|
* @param {string} llmThinking - Raw LLM thinking text with [step] markers
|
||||||
|
* @param {Array} toolCalls - Array of { step, name, input, output }
|
||||||
|
* @returns {Array} entries - Interleaved array of { step, type, text/name/input/output }
|
||||||
|
*/
|
||||||
|
function buildTimeline(llmThinking, toolCalls) {
|
||||||
|
var thinkingByStep = {};
|
||||||
|
var thinkOrder = [];
|
||||||
|
if (llmThinking) {
|
||||||
|
var parts = llmThinking.split(/\n(?=\[\d+\])/);
|
||||||
|
for (var pi = 0; pi < parts.length; pi++) {
|
||||||
|
var m = parts[pi].match(/^\[(\d+)\]\s*(.*)/s);
|
||||||
|
if (m) {
|
||||||
|
var step = parseInt(m[1]);
|
||||||
|
if (!(step in thinkingByStep)) {
|
||||||
|
thinkOrder.push(step);
|
||||||
|
}
|
||||||
|
thinkingByStep[step] = (thinkingByStep[step] || '') + (thinkingByStep[step] ? '\n' : '') + m[2].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var toolMap = {};
|
||||||
|
(toolCalls || []).forEach(function (tc) {
|
||||||
|
var s = tc.step;
|
||||||
|
if (!toolMap[s]) toolMap[s] = [];
|
||||||
|
toolMap[s].push({
|
||||||
|
type: 'tool',
|
||||||
|
name: tc.name,
|
||||||
|
input: tc.input,
|
||||||
|
output: tc.output,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var entries = [];
|
||||||
|
var allSteps = new Set(thinkOrder.concat(Object.keys(toolMap).map(Number)));
|
||||||
|
var sortedSteps = Array.from(allSteps).sort(function (a, b) { return a - b; });
|
||||||
|
|
||||||
|
for (var si = 0; si < sortedSteps.length; si++) {
|
||||||
|
var step = sortedSteps[si];
|
||||||
|
if (thinkingByStep[step] !== undefined) {
|
||||||
|
var isLastStep = step === sortedSteps[sortedSteps.length - 1];
|
||||||
|
var hasTool = toolMap[step] && toolMap[step].length > 0;
|
||||||
|
if (isLastStep && !hasTool) {
|
||||||
|
entries.push({ step: step, type: 'answer', text: thinkingByStep[step] });
|
||||||
|
} else {
|
||||||
|
entries.push({ step: step, type: 'thinking', text: thinkingByStep[step] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolMap[step]) {
|
||||||
|
for (var tj = 0; tj < toolMap[step].length; tj++) {
|
||||||
|
var te = toolMap[step][tj];
|
||||||
|
entries.push({ step: step, type: 'tool', name: te.name, input: te.input, output: te.output, thinking: '' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render timeline entries into a DOM container.
|
||||||
|
*
|
||||||
|
* @param {Array} entries - From buildTimeline()
|
||||||
|
* @param {string} containerId - DOM element ID to render into
|
||||||
|
*/
|
||||||
|
function renderTimeline(entries, containerId) {
|
||||||
|
var container = document.getElementById(containerId);
|
||||||
|
if (!container || !entries || entries.length === 0) return;
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
var lastStep = -1;
|
||||||
|
|
||||||
|
for (var ei = 0; ei < entries.length; ei++) {
|
||||||
|
var entry = entries[ei];
|
||||||
|
|
||||||
|
if (entry.step !== lastStep) {
|
||||||
|
var header = document.createElement('div');
|
||||||
|
header.style.cssText = 'font-weight:700;font-size:0.85rem;color:#4361ee;padding:0.6rem 0 0.3rem 0;border-top:1px solid #eee;margin-top:0.4rem;';
|
||||||
|
header.textContent = '\u6b65\u9aa4 ' + entry.step;
|
||||||
|
container.appendChild(header);
|
||||||
|
lastStep = entry.step;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.type === 'thinking') {
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.style.cssText = 'background:#eef2ff;padding:0.6rem;border-radius:6px;font-size:0.78rem;line-height:1.6;color:#333;max-height:300px;overflow:auto;margin-bottom:0.3rem;border:1px solid #d0d8f0;';
|
||||||
|
div.innerHTML =
|
||||||
|
'<span style="font-weight:600;color:#4361ee;font-size:0.75rem;display:block;margin-bottom:0.3rem;">\ud83d\udcad \u601d\u8003</span>' +
|
||||||
|
(typeof renderMarkdown === 'function' ? renderMarkdown(entry.text) : escapeHtml(entry.text).replace(/\n/g, '<br>'));
|
||||||
|
container.appendChild(div);
|
||||||
|
} else if (entry.type === 'answer') {
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.style.cssText = 'background:#e8f5e9;padding:0.6rem 0.8rem;border-radius:6px;font-size:0.82rem;line-height:1.7;color:#333;max-height:400px;overflow:auto;margin-bottom:0.3rem;border:1px solid #c8e6c9;';
|
||||||
|
div.innerHTML =
|
||||||
|
'<span style="font-weight:600;color:#2e7d32;font-size:0.75rem;display:block;margin-bottom:0.3rem;">\u2705 \u56de\u7b54</span>' +
|
||||||
|
(typeof renderMarkdown === 'function' ? renderMarkdown(entry.text) : escapeHtml(entry.text).replace(/\n/g, '<br>'));
|
||||||
|
container.appendChild(div);
|
||||||
|
} else if (entry.type === 'tool') {
|
||||||
|
var card = document.createElement('div');
|
||||||
|
card.style.cssText = 'border:1px solid #e0e0e0;border-radius:6px;margin-bottom:0.3rem;overflow:hidden;background:#fff;';
|
||||||
|
var inputStr = prettyJson(entry.input);
|
||||||
|
var outputStr = prettyJson(entry.output);
|
||||||
|
var bodyInner = '';
|
||||||
|
bodyInner +=
|
||||||
|
'<div style="font-weight:600;font-size:0.72rem;color:#555;margin-bottom:0.2rem;">\u8f93\u5165 (INPUT):</div>' +
|
||||||
|
'<pre style="background:#1a1a2e;color:#e0e0e0;padding:0.5rem;border-radius:4px;font-size:0.7rem;max-height:200px;overflow:auto;margin:0 0 0.3rem 0;"><code>' + escapeHtml(inputStr) + '</code></pre>' +
|
||||||
|
'<div style="font-weight:600;font-size:0.72rem;color:#555;margin-bottom:0.2rem;">\u8f93\u51fa (OUTPUT):</div>' +
|
||||||
|
'<pre style="background:#1a1a2e;color:#e0e0e0;padding:0.5rem;border-radius:4px;font-size:0.7rem;max-height:200px;overflow:auto;margin:0;"><code>' + escapeHtml(outputStr) + '</code></pre>';
|
||||||
|
card.innerHTML =
|
||||||
|
'<div class="tool-call-header" onclick="toggleStep(this)" style="padding:0.4rem 0.8rem;">' +
|
||||||
|
'<span><span style="background:#43aa8b;color:#fff;border-radius:3px;padding:0 5px;font-size:0.7rem;font-weight:600;margin-right:6px;">\ud83d\udd27</span>' +
|
||||||
|
'<span style="font-weight:600;font-size:0.8rem;">' + escapeHtml(entry.name || '?') + '</span></span>' +
|
||||||
|
'<span class="arrow">\u25b6</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="tool-call-body" style="padding:0.5rem 0.8rem;background:#fafbfc;">' +
|
||||||
|
bodyInner +
|
||||||
|
'</div>';
|
||||||
|
container.appendChild(card);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user