21 Commits

Author SHA1 Message Date
PM-pinou 908bfcaa32 refactor(types): ColumnType.distance() + ColumnProcessor dispatch, distance.py→TYPE_REGISTRY delegation 2026-07-24 13:15:34 +08:00
PM-pinou 0ca1e87b46 wip: type system refactor — ColumnProcessor + distance dispatch 2026-07-24 13:13:51 +08:00
PM-pinou 30e48ba282 perf: increase sampling coverage — MAX_SAMPLE_ROWS 30K→200K, LOW_MEMORY 100K→500K, UMAP_BATCH 1K→2K 2026-07-24 12:57:33 +08:00
PM-pinou df0f7d621f refactor(types+svd): OOP type system + SVD denoise replaces columns instead of _svd_ append 2026-07-24 12:55:37 +08:00
PM-pinou 92c1b0f768 feat(tools): LLM-mandated _timeout parameter on every MCP tool, enforced in dispatch layer 2026-07-24 12:45:29 +08:00
PM-pinou d24507e541 fix(llm): remove step limit and tool timeout, use while loop with 999 cap 2026-07-24 12:41:25 +08:00
PM-pinou f683a084f3 fix: nl_describe variance awareness + timestamp formatting + AGENTS.md update 2026-07-24 12:38:00 +08:00
PM-pinou 1463e56a42 fix(auto): remove duplicate JS function definitions (126 lines dedup) 2026-07-24 12:00:21 +08:00
PM-pinou 244967bad5 fix(cluster_detail): show raw data rows instead of entity profiles 2026-07-24 11:58:31 +08:00
PM-pinou d2e34ca43d fix(cluster_detail): always persist per-row cluster labels to EntityProfile for data row display 2026-07-24 11:56:02 +08:00
PM-pinou b135b18779 fix(i18n): full Chinese translation of cluster_detail page 2026-07-24 11:53:43 +08:00
PM-pinou 2c1aad6bd1 feat(run_detail): global SVD feature analysis + LLM workflow timeline 2026-07-24 11:51:23 +08:00
PM-pinou 5280ce5819 fix(umap): LazyFrame.sample not available in Polars 1.42, use DataFrame.sample after collect 2026-07-24 11:39:00 +08:00
PM-pinou e2c64fd068 fix: raw row clustering — remove entity concept, use row index, all numeric features 2026-07-24 11:35:18 +08:00
PM-pinou fa13ebcf72 fix(auto): markdown rendering for LLM answer and thinking blocks 2026-07-24 11:29:02 +08:00
PM-pinou d7f9db098c fix(llm): save intermediate thinking — DeepSeek reasoning_content fallback, force non-empty thought, increase API truncation to 50K 2026-07-24 11:26:34 +08:00
PM-pinou 982cdcf83e chore: sync gitignore from master (GeoIP chunks, mmdb, playwright-mcp) 2026-07-24 11:22:34 +08:00
PM-pinou 8e320b0059 chore: add Playwright test screenshots, mass test generator, update gitignore 2026-07-23 23:39:35 +08:00
PM-pinou 1105f12d89 merge: beta-clean refactoring — views/tools package split, code cleanup, perf, docs 2026-07-23 23:39:22 +08:00
PM-pinou e340b49c71 chore: gitignore test CSV directory (too large for push) 2026-07-23 23:39:20 +08:00
PM-pinou 9508cc8d58 perf: optimize for 25M rows on 8GB RAM
- globe.py: add streaming=True to all lf.head().collect() calls (3 places)
- entities.py: chunked aggregation (100K batches) for datasets > 1M rows
- constants.py: reduce MAX_SAMPLE_ROWS 50000->30000 for 8GB RAM
- tools/__init__.py: add __all__ for '*' re-export (fix tool_registry import)
- clustering.py: fix misplaced logger/import traceback syntax errors
- analysis.py: fix indentation of pass statement
- anomalies.py: fix misplaced import traceback syntax error
2026-07-23 22:54:45 +08:00
43 changed files with 2610 additions and 1187 deletions
+9
View File
@@ -43,3 +43,12 @@ data/multi_upload/
# User config (contains API keys)
config/config.yaml
data/test_csvs/
data/geoip_chunks/
*.mmdb.gz
*.mmdb
# Playwright MCP sessions
.playwright-mcp/
+14 -6
View File
@@ -354,9 +354,17 @@ runtime\python\python.exe scripts\column_survey.py --csv "data/*.csv"
## 近期变更 (beta-clean 分支)
- views.py (2000+ 行单体) → `analysis/views/` 包 (12 模块)
- tool_registry.py → `analysis/tools/` 包 (17 模块)
- 删除 `simple_analysis/` 模块 (已移至 master 分支独立维护)
- 工具数量从 27 升至 30 (新增 export_entities_json, diagnostic toolbox 扩展)
- 模板目录重组: 6 个分析页面在 `analysis/`, 6 个功能页面在 `tianxuan/`
- 新增 `analysis/distance.py` (距离计算) 和 `analysis/nl_describe.py` (自然语言描述)
- views.py (2000+ 行单体) → `analysis/views/` 包 (13 模块)
- tool_registry.py (3000+ 行)`analysis/tools/` 包 (18 模块)
- 默认聚类算法改为 AgglomerativeClustering
- 移除实体概念,直接对原始行聚类 (entity_value = row_N)
- cluster_detail 显示原始数据行而非实体画像
- run_detail 新增全域 SVD 特征分析 + LLM 工作流时间线
- 3D 地球侧栏复用 `/globe/?embed=1`iframe + postMessage 联动)
- UI 全面中文化
- 非阻塞启动 (run.bat 打印 PID 后退出)
## 待办
- [ ] **前端 UI 验证**: 打开 `prototype_cluster_ui.html` 查看交互设计是否符合要求
- [ ] **更新前端原型**后,将验证通过的交互逻辑合并到正式模板
+162
View File
@@ -0,0 +1,162 @@
"""Unified column processor — dispatches per-type distance computation.
Replaces the if-elif chain in :func:`distance.run_preprocessing_pipeline`
with :data:`~analysis.types.TYPE_REGISTRY` dispatch.
Usage::
from analysis.types import classify_schema
from analysis.column_processor import ColumnProcessor
column_types = classify_schema(lf)
proc = ColumnProcessor(lf, column_types)
lf = proc.compute_distances() # Applies each type's .distance()
lf = proc.normalize() # StandardScaler on numeric dist cols
lf, noise = proc.svd_denoise() # SVD with noise profile
"""
from __future__ import annotations
import numpy as np
import polars as pl
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import TruncatedSVD
# ── Prefixes produced by each type's .distance() ──
_DIST_PREFIXES: tuple[str, ...] = (
'_ip_dist_', '_str_dist_', '_bool_dist_', '_bytes_dist_',
'_ts_dist_', '_enum_dist_', '_raw_',
)
# ── Types that produce numeric distance/raw output (all 8 do) ──
_ALL_TYPES: set[str] = {
"枚举", "字节", "浮点", "整数", "布尔", "字符串", "IPv4", "时间戳",
}
class ColumnProcessor:
"""Processes all columns in a dataset by their registered types.
Groups columns by type, dispatches each group to the corresponding
:class:`~analysis.types.ColumnType` instance in
:data:`~analysis.types.TYPE_REGISTRY`, then chains normalisation
and SVD denoising.
Parameters
----------
lf:
Input LazyFrame with raw data columns.
column_types:
``{col_name: ColumnType}`` mapping from
:func:`~analysis.types.classify_schema`.
"""
def __init__(
self,
lf: pl.LazyFrame,
column_types: dict,
) -> None:
self._lf = lf
self._column_types = column_types
self._feature_cols: list[str] = []
self._noise_profile: dict = {}
# ── public API ──────────────────────────────────────────────────────
@property
def feature_cols(self) -> list[str]:
"""Numeric feature columns available after :meth:`compute_distances`."""
return list(self._feature_cols)
@property
def noise_profile(self) -> dict:
"""SVD noise profile populated by :meth:`svd_denoise`."""
return dict(self._noise_profile)
@property
def lf(self) -> pl.LazyFrame:
"""Current LazyFrame state."""
return self._lf
def compute_distances(self) -> pl.LazyFrame:
"""Group columns by type and dispatch to TYPE_REGISTRY.
Each :class:`~analysis.types.ColumnType` subclass handles all
columns of that type in one call. IP columns with fewer than
2 entries are skipped (they need pairs).
Returns the LazyFrame with distance/raw columns appended.
"""
from analysis.types import TYPE_REGISTRY
# Group columns by type name
groups: dict[str, list[str]] = {}
for col_name, ct in self._column_types.items():
groups.setdefault(ct.name, []).append(col_name)
result = self._lf
# Dispatch each group to its type's distance method
for type_name, cols in groups.items():
type_obj = TYPE_REGISTRY.get(type_name)
if type_obj is None:
continue
# IP distance needs at least 2 columns (pairs)
if type_name == "IPv4" and len(cols) < 2:
continue
result = type_obj.distance(result, cols)
# ── Collect feature columns produced by distance computation ──
schema = result.collect_schema()
self._feature_cols = [
c for c in schema.names()
if any(c.startswith(p) for p in _DIST_PREFIXES)
]
self._lf = result
return result
def normalize(self) -> pl.LazyFrame:
"""Apply StandardScaler normalisation to feature columns.
Uses :func:`analysis.distance.normalize_features` internally.
Requires :meth:`compute_distances` to have been called first.
"""
if not self._feature_cols:
return self._lf
from analysis.distance import normalize_features
self._lf = normalize_features(self._lf, self._feature_cols)
return self._lf
def svd_denoise(self, variance_threshold: float = 0.95) -> tuple[pl.LazyFrame, dict]:
"""SVD noise removal on normalised feature columns.
Uses :func:`analysis.distance.svd_denoise` internally.
Requires :meth:`normalize` to have been called first.
Parameters
----------
variance_threshold:
Fraction of total variance to preserve (0.01.0).
Returns
-------
tuple[pl.LazyFrame, dict]
``(denoised_lf, noise_profile)``.
"""
if not self._feature_cols:
self._noise_profile = {
'kept_components': 0, 'total_components': 0,
'kept_variance': 0.0, 'noise_components': [],
}
return self._lf, self._noise_profile
from analysis.distance import svd_denoise as _svd_denoise
norm_cols = [f'_norm_{c}' for c in self._feature_cols]
self._lf, self._noise_profile = _svd_denoise(
self._lf, norm_cols, variance_threshold,
)
return self._lf, self._noise_profile
+21 -17
View File
@@ -9,36 +9,40 @@ so they can be updated in one place. Import with::
import polars as pl
# ---------------------------------------------------------------------------
# Clustering / UMAP
# Clustering / UMAP — tuned for 25M rows × 50 cols on 8GB RAM
# ---------------------------------------------------------------------------
MAX_SAMPLE_ROWS = 50000
"""Maximum rows for clustering sample (downsampling threshold)."""
MAX_SAMPLE_ROWS = 200000
"""Maximum rows for clustering sample. 200K ≈ 0.8% of 25M rows, fits 8GB RAM."""
MAX_DISTRIBUTION_SAMPLE = 10000
"""Default sample size for distribution exploration and scoring."""
MAX_DISTRIBUTION_SAMPLE = 50000
"""Default sample for distribution exploration — 50K provides stable statistics."""
UMAP_TRAIN_SAMPLE = 10000
"""Max rows used to train a UMAP reducer in extract_features."""
UMAP_TRAIN_SAMPLE = 30000
"""Max rows for UMAP training. 30K is a balance: UMAP O(n²) for neighbors."""
# Note: UMAP neighbors search is O(n * n_neighbors * log n). 30K × 15 ≈ fine.
# Going above 50K causes UMAP to allocate several GB for the nearest-neighbor index.
UMAP_BATCH_SIZE = 1000
UMAP_BATCH_SIZE = 2000
"""Batch size for UMAP transform when dataset exceeds UMAP_TRAIN_SAMPLE."""
LOW_MEMORY_THRESHOLD_GB = 2
"""If available memory < this (in GiB), enable low-memory clustering path."""
LOW_MEMORY_THRESHOLD_ROWS = 100000
"""Row count above which low-memory downsampling is triggered."""
LOW_MEMORY_THRESHOLD_ROWS = 500000
"""Row count above which low-memory downsampling is triggered.
500K gives 2% coverage of 25M rows while keeping numpy matrix < 200 MB."""
RANDOM_SEED = 42
"""Default random seed for all sklearn/numpy operations."""
# ---------------------------------------------------------------------------
# Globe
# Globe — 8GB RAM / 25M rows constraints
# ---------------------------------------------------------------------------
GLOBE_MAX_ROWS = 300
"""Max rows to load per run for 3D globe visualization."""
GLOBE_MAX_ROWS = 500
"""Max rows to load per run for 3D globe visualization. 300→500 for better
coverage on the globe, still < 1 MB of flow data."""
# ---------------------------------------------------------------------------
# Filter
@@ -76,11 +80,11 @@ entity aggregation, anomaly detection, and feature extraction."""
# Diagnostics / Validation
# ---------------------------------------------------------------------------
MAX_VALIDATE_SAMPLE = 1000
MAX_VALIDATE_SAMPLE = 5000
"""Max rows sampled during validate_data."""
MAX_DIAGNOSE_SAMPLE = 5000
"""Max rows sampled for clustering diagnosis (SVD)."""
MAX_DIAGNOSE_SAMPLE = 50000
"""Max rows sampled for clustering diagnosis (SVD). 50K for stable SVD."""
MAX_CLUSTER_FEATURES = 50
"""Feature count threshold above which TruncatedSVD is applied."""
@@ -92,5 +96,5 @@ CLUSTER_MAX_FEATURES_TOP = 10
# Globe / Flow extraction
# ---------------------------------------------------------------------------
GLOBE_FLOW_MAX_ROWS = 300
GLOBE_FLOW_MAX_ROWS = 500
"""Alias for GLOBE_MAX_ROWS — max rows loaded for flow extraction."""
+17 -44
View File
@@ -15,7 +15,7 @@ from typing import Optional
import polars as pl
import yaml
from analysis.type_classifier import classify_schema, DataType
from analysis.types import classify_schema, TYPE_REGISTRY
logger = logging.getLogger(__name__)
@@ -624,7 +624,7 @@ def load_csv_directory(
merged_schema = {k: v for k, v in merged_schema.items() if k not in existing_drop}
logger.info('[CLEAN] dropped columns: %s', existing_drop)
# ── Cleaning: BOOL_ENUM normalisation ────────────────────────────────
# ── Cleaning: BOOL normalisation ─────────────────────────────────────
try:
type_map = classify_schema(
merged_lf.head(1000),
@@ -633,21 +633,20 @@ def load_csv_directory(
except Exception:
type_map = {}
bool_enum_cols = [
bool_cols = [
c for c, t in type_map.items()
if t == DataType.BOOL_ENUM and c in merged_schema
if t.name == "布尔" and c in merged_schema
]
if bool_enum_cols:
# Only clean string-typed BOOL_ENUM columns; native Boolean is already clean
if bool_cols:
schema_dtypes = dict(zip(
merged_lf.collect_schema().names(),
merged_lf.collect_schema().dtypes(),
))
bool_exprs = []
cleaned_names: list[str] = []
for c in bool_enum_cols:
for c in bool_cols:
if schema_dtypes.get(c) not in (pl.Utf8, pl.String):
continue # native Boolean / numeric — skip
continue
c_str = pl.col(c).cast(pl.Utf8).str.strip_chars()
cleaned = (
pl.when(c_str == "")
@@ -664,37 +663,12 @@ def load_csv_directory(
merged_lf = merged_lf.with_columns(bool_exprs)
for c in cleaned_names:
merged_schema[c] = 'Utf8'
logger.info('[CLEAN] BOOL_ENUM columns normalised: %s', cleaned_names)
logger.info('[CLEAN] BOOL columns normalised: %s', cleaned_names)
# ── Cleaning: LAT_LON normalisation ────────────────────────────────
latlon_cols = [
c for c, t in type_map.items()
if t == DataType.LAT_LON and c in merged_schema
]
if latlon_cols:
schema_dtypes_ll = dict(zip(
merged_lf.collect_schema().names(),
merged_lf.collect_schema().dtypes(),
))
latlon_exprs = []
for c in latlon_cols:
dtype = schema_dtypes_ll.get(c)
if dtype in (pl.Utf8, pl.String):
latlon_exprs.append(
pl.col(c).map_batches(_coerce_to_float, return_dtype=pl.Float64).alias(c)
)
merged_schema[c] = 'Float64'
if latlon_exprs:
merged_lf = merged_lf.with_columns(latlon_exprs)
logger.info('[CLEAN] LAT_LON columns normalised to Float64: %s', latlon_cols)
# ── Cleaning: Generic numeric coercion ────────────────────────────
# For any remaining string column (not IPv4/URL/HEX/ENUM/etc.),
# try to coerce to Float64. Whatever can't parse becomes null.
# This prevents "mean on str column" crashes for ANY column.
_SKIP_NUM_COERCE = (DataType.IPv4, DataType.URL, DataType.HEX,
DataType.ENUM, DataType.LAT_LON, DataType.BOOL_ENUM)
# Also protect columns with these name patterns (MAC addresses, etc.)
# ── Generic numeric coercion ────────────────────────────────────────
# For remaining string columns (not IPv4/BYTES/ENUM/BOOL etc.),
# try to coerce to Float64.
_SKIP_NUM_COERCE = {"IPv4", "字节", "枚举", "布尔"}
_SKIP_NAME_PATTERNS = ('mac', 'mac_', '_mac', 'addr', 'address')
schema_dtypes_gn = dict(zip(
merged_lf.collect_schema().names(),
@@ -702,14 +676,13 @@ def load_csv_directory(
))
generic_numeric_cols = []
for c, t in type_map.items():
if t in _SKIP_NUM_COERCE:
if t.name in _SKIP_NUM_COERCE:
continue
if c not in merged_schema:
continue
dtype = schema_dtypes_gn.get(c)
if dtype not in (pl.Utf8, pl.String):
continue # already numeric
# Skip columns with protected name patterns
continue
cl = c.lower().replace('-', '_').replace(' ', '_')
if any(p in cl for p in _SKIP_NAME_PATTERNS):
continue
@@ -727,10 +700,10 @@ def load_csv_directory(
merged_schema[c] = 'Float64'
logger.info('[CLEAN] Generic numeric coercion applied: %s', generic_numeric_cols)
# ── Cleaning: HEX preprocessing (hex_pairs_count) ────────────────────
# ── Cleaning: BYTES (hex) preprocessing ─────────────────────────────
hex_cols = [
c for c, t in type_map.items()
if t == DataType.HEX and c in merged_schema
if t.name == "字节" and c in merged_schema
]
if hex_cols:
hex_exprs = []
@@ -741,7 +714,7 @@ def load_csv_directory(
merged_lf = merged_lf.with_columns(hex_exprs)
for c in hex_cols:
merged_schema[f"{c}_hex_pairs_count"] = 'UInt32'
logger.info('[CLEAN] HEX columns added hex_pairs_count: %s', hex_cols)
logger.info('[CLEAN] BYTES columns added hex_pairs_count: %s', hex_cols)
# Row count estimate (fast path: use first file row count × files)
# For accurate count we'd need to collect, but that defeats lazy.
+76 -47
View File
@@ -679,40 +679,49 @@ def svd_denoise(
lf: pl.LazyFrame,
feature_cols: list[str],
variance_threshold: float = 0.95,
) -> pl.LazyFrame:
) -> tuple[pl.LazyFrame, dict]:
"""Remove noise from numeric features via TruncatedSVD.
1. Collects *feature_cols* → numpy matrix.
2. Fits ``TruncatedSVD(n_components=min(n_rows, n_features 1))``.
3. Keeps *k* components where the cumulative explained variance
ratio ``trace(S[:k]) / trace(S) ≥ variance_threshold``
(default 0.95).
4. Inverse-transforms to reconstruct the denoised matrix.
5. Returns *lf* extended with ``_svd_{col}`` columns.
1. Fits TruncatedSVD on *feature_cols*.
2. Keeps *k* components where cumulative explained variance
``trace(S[:k]) / trace(S) ≥ variance_threshold`` (default 0.95).
3. Inverse-transforms to reconstruct the denoised matrix.
4. **Replaces** the original *feature_cols* with denoised values.
The noise components (removed by SVD) are returned as a dict for
display, NOT as new feature columns.
Parameters
----------
lf:
LazyFrame containing *feature_cols*.
feature_cols:
Numeric column names to denoise. Columns not present in
*lf* are silently skipped.
Numeric column names to denoise. These columns are replaced
in-place by their denoised reconstructions.
variance_threshold:
Fraction of total variance to preserve (``0.01.0``).
Default ``0.95`` keeps 95 % of information.
Fraction of total variance to preserve (0.01.0).
Default 0.95 keeps 95 % of signal and treats 5 % as noise.
Returns
-------
pl.LazyFrame
Input frame with additional ``_svd_{col}`` columns.
Memory
------
Peak ≈ numpy matrix + SVD workspace; typically <800 MB.
tuple[pl.LazyFrame, dict]
``(denoised_lf, noise_profile)`` where *noise_profile* contains:
- ``kept_components`` : int — number of components kept
- ``total_components`` : int — max possible components
- ``kept_variance`` : float — fraction of variance kept
- ``noise_components`` : list[dict] — per-component noise info
(component index, explained variance, top-5 contributing features)
"""
noise_profile = {
'kept_components': 0,
'total_components': 0,
'kept_variance': 0.0,
'noise_components': [],
}
available = [c for c in feature_cols if c in lf.collect_schema().names()]
if not available:
return lf
return lf, noise_profile
df = lf.select(available).collect(streaming=True)
mat = df.to_numpy().astype(np.float64)
@@ -721,17 +730,15 @@ def svd_denoise(
n, d = mat.shape
max_components = min(n, d - 1)
if max_components < 1:
# Not enough dimensions; return as-is
return lf
return lf, noise_profile
from sklearn.decomposition import TruncatedSVD
svd = TruncatedSVD(n_components=max_components, random_state=42)
_ = svd.fit_transform(mat)
# Determine k: cumulative variance threshold
total_variance = float(np.sum(svd.explained_variance_))
if total_variance <= 1e-10:
# All-zero variance; denoising would be a no-op
return lf
return lf, noise_profile
cumsum = np.cumsum(svd.explained_variance_)
for k in range(1, max_components + 1):
@@ -740,18 +747,44 @@ def svd_denoise(
else:
k = max_components
# Re-fit with chosen k and inverse-transform
# Re-fit with chosen k
svd_k = TruncatedSVD(n_components=k, random_state=42)
reduced = svd_k.fit_transform(mat)
reconstructed = svd_k.inverse_transform(reduced)
# --- Build noise profile (removed components) ---
# The "noise" is the variance in components k..max_components
kept_var_ratio = cumsum[k - 1] / total_variance if k > 0 else 0.0
noise_profile = {
'kept_components': k,
'total_components': max_components,
'kept_variance': round(float(kept_var_ratio), 4),
}
# Per-component noise info for removed components (k and beyond)
noise_info = []
for comp_idx in range(k, min(k + 10, max_components)):
loading = np.abs(svd.components_[comp_idx])
top5_idx = np.argsort(loading)[-5:][::-1]
top5 = [
{'feature': available[i], 'strength': round(float(loading[i]), 4)}
for i in top5_idx
]
noise_info.append({
'component': int(comp_idx + 1),
'explained_var_ratio': round(float(svd.explained_variance_ratio_[comp_idx]) * 100, 3),
'top_features': top5,
})
noise_profile['noise_components'] = noise_info
# --- Replace original columns with denoised values ---
df_result = lf.collect(streaming=True)
for i, col in enumerate(available):
df_result = df_result.with_columns(
pl.Series(f'_svd_{col}', reconstructed[:, i])
pl.Series(col, reconstructed[:, i])
)
return df_result.lazy()
return df_result.lazy(), noise_profile
# ---------------------------------------------------------------------------
@@ -795,12 +828,9 @@ def run_preprocessing_pipeline(
(``_svd__norm__*_dist_*`` etc.) plus original row anchors.
"""
if column_types is None:
from analysis.type_classifier import classify_schema
from analysis.types import classify_schema
column_types = classify_schema(lf)
# Group columns by type
from analysis.type_classifier import DataType
ip_cols: list[str] = []
str_cols: list[str] = []
bool_cols: list[str] = []
@@ -809,24 +839,23 @@ def run_preprocessing_pipeline(
float_cols: list[str] = []
int_cols: list[str] = []
for col_name, dtype in column_types.items():
if dtype == DataType.IPv4:
for col_name, ct in column_types.items():
if ct.name == "IPv4":
ip_cols.append(col_name)
elif dtype == DataType.STRING:
elif ct.name == "字符串":
str_cols.append(col_name)
elif dtype == DataType.BOOL_ENUM:
elif ct.name == "布尔":
bool_cols.append(col_name)
elif dtype == DataType.HEX:
elif ct.name == "字节":
byte_cols.append(col_name)
elif dtype == DataType.TIMESTAMP:
elif ct.name == "时间戳":
ts_cols.append(col_name)
elif dtype == DataType.FLOAT:
float_cols.append(col_name)
elif dtype == DataType.INT:
int_cols.append(col_name)
# LAT_LON, ENUM, URL are kept as-is for now (they either
# need special distance functions or don't contribute to
# numeric features directly)
elif ct.name in ("浮点", "整数"):
# Float/Int are kept as direct numeric features
if ct.name == "浮点":
float_cols.append(col_name)
else:
int_cols.append(col_name)
result = lf
@@ -863,11 +892,11 @@ def run_preprocessing_pipeline(
# -- Step 3: Normalisation --
result = normalize_features(result, feature_cols)
# -- Step 4: SVD denoise --
# -- Step 4: SVD denoise (REPLACES noisy features with cleaned ones) --
norm_cols = [f'_norm_{c}' for c in feature_cols]
result = svd_denoise(result, norm_cols)
result, noise_profile = svd_denoise(result, norm_cols)
return result
return result, noise_profile
# ---------------------------------------------------------------------------
+1 -1
View File
@@ -69,7 +69,7 @@ class Command(BaseCommand):
run.save(update_fields=['total_flows'])
from analysis.views import _run_clustering_pipeline
_run_clustering_pipeline(
run=run, store=store, entity_ds_id=ds_id,
run=run, store=store, ds_id=ds_id,
feature_columns=feature_cols,
algorithm=algo, min_cluster_size=5,
run_umap=True,
+16 -6
View File
@@ -285,6 +285,7 @@ def describe_feature(
feature_name: str,
value: float,
std_dev: float | None = None,
cluster_std: float | None = None,
) -> str:
"""Generate a Chinese natural-language description for a single feature.
@@ -296,14 +297,15 @@ def describe_feature(
The feature's mean value in the current context (cluster mean).
std_dev : float | None
Optional Z-score magnitude (``abs(distinguishing_score)``). When
provided and >= 1.0, appends a deviation suffix like
"比全局均值高2.1个标准差".
provided and >= 1.0, appends a deviation suffix.
cluster_std : float | None
Optional within-cluster standard deviation. Describes how spread
the values are within this cluster (high = scattered, low = tight).
Returns
-------
str
Natural-language description string, e.g.
``"中等数据包 (720 bytes),比全局均值高2.1个标准差"``.
Natural-language description string.
"""
meaning = _lookup_meaning(feature_name)
category = _resolve_category(feature_name)
@@ -322,7 +324,6 @@ def describe_feature(
meaning=meaning)
break
except Exception:
# Fall through to next template on format errors
continue
if not text:
@@ -332,6 +333,13 @@ def describe_feature(
if std_dev is not None and std_dev >= 1.0:
text += f",比全局均值高{std_dev:.1f}个标准差"
# Append within-cluster variance context
if cluster_std is not None and cluster_std > 0:
if cluster_std > 2.0:
text += "(簇内离散度高)"
elif cluster_std < 0.3:
text += "(簇内高度集中)"
return text
@@ -380,11 +388,13 @@ def describe_cluster(
name = f.get("feature_name", "")
mean = f.get("mean")
score = abs(f.get("distinguishing_score", 0.0))
cstd = f.get("std")
if mean is None or name == "":
continue
desc = describe_feature(name, float(mean), std_dev=score if score >= 1.0 else None)
desc = describe_feature(name, float(mean), std_dev=score if score >= 1.0 else None,
cluster_std=float(cstd) if cstd is not None else None)
sentences.append(desc)
if not sentences:
+41
View File
@@ -60,3 +60,44 @@ from .data_mgmt import (
_handle_clone_dataset,
)
from .distance_matrix import _handle_compute_distance_matrix
__all__ = [
# Helpers
'_json_size', '_truncate_response', '_build_filter_expr', '_resolve_dataset',
'_count_rows', '_get_numeric_columns', 'NUMERIC_DTYPES', 'NUMERIC_TYPE_NAMES',
# Registry + dispatch
'get_tools_meta', 'handle_call',
# Load
'_handle_load_data',
# Profile
'_handle_profile_data',
# Filter
'_handle_filter_data',
# Preprocess
'_handle_preprocess_data',
# Clustering
'_handle_run_clustering', '_handle_filter_and_cluster',
# Evaluate
'_handle_evaluate_clustering',
# Features
'_handle_extract_features', '_save_features_to_db',
# Export
'_handle_export_results',
# Entities
'_handle_build_entity_profiles', '_handle_compute_scores', '_add_adaptive_scores',
'_ENTITY_KEYWORDS', '_NUMERIC_TYPES_FOR_AGG', '_ANOMALY_FEATURE_COLS',
# Anomalies
'_handle_detect_anomalies', '_handle_visualize_anomalies',
# Diagnostics
'_handle_validate_data', '_handle_explore_distributions', '_handle_find_outliers',
'_handle_diagnose_clustering', '_handle_compare_datasets',
'_handle_export_debug_sample', '_handle_repair_schema',
# Analysis
'_handle_analyze_patterns', '_handle_analyze_temporal', '_handle_analyze_fft',
'_handle_analyze_tls_health', '_handle_analyze_geo_distribution',
'_handle_analyze_entity_detail',
# Data management
'_handle_list_datasets', '_handle_drop_dataset', '_handle_clone_dataset',
# Distance matrix
'_handle_compute_distance_matrix',
]
+22 -2
View File
@@ -1,4 +1,5 @@
"""Tool call dispatcher — maps tool names to handler functions."""
import asyncio
from ._helpers import _truncate_response
from .load_data import _handle_load_data
from .profile import _handle_profile_data
@@ -27,9 +28,12 @@ from .distance_matrix import _handle_compute_distance_matrix
async def handle_call(name: str, arguments: dict) -> dict:
"""Dispatch a tool call to the appropriate handler.
Extracts the mandatory ``_timeout`` parameter (set by the LLM) from
*arguments* and enforces it as an ``asyncio.wait_for`` deadline.
Args:
name: Tool name (must match one of the 30 tools).
arguments: Tool-specific parameters.
arguments: Tool-specific parameters, must include ``_timeout``.
Returns:
A JSON-serialisable dict.
@@ -72,7 +76,23 @@ async def handle_call(name: str, arguments: dict) -> dict:
handler = _handlers.get(name)
if handler is None:
raise ValueError(f"Unknown tool: '{name}'")
result = await handler(**arguments)
# Extract mandatory _timeout from the LLM-provided arguments
timeout = arguments.pop('_timeout', 0)
cleaned_args = arguments # _timeout already removed by pop
# Execute the handler with optional timeout
if timeout and timeout > 0:
try:
result = await asyncio.wait_for(
handler(**cleaned_args),
timeout=timeout,
)
except asyncio.TimeoutError:
result = {'error': f'工具 {name} 执行超时 ({timeout}秒)'}
else:
result = await handler(**cleaned_args)
# Always ensure truncated key exists
if 'truncated' not in result:
result['truncated'] = False
+26 -3
View File
@@ -8,9 +8,10 @@ from mcp.types import Tool
def get_tools_meta() -> list[Tool]:
"""Return the list of Tool metadata objects for MCP server registration.
Call this from :meth:`TLSAnalyzerMCPServer._setup_tools`.
Each tool gets a mandatory ``_timeout`` parameter so the LLM must specify
how many seconds to allow for each tool invocation (0 = no limit).
"""
return [
raw_tools = [
Tool(
name="load_data",
description=(
@@ -751,4 +752,26 @@ def get_tools_meta() -> list[Tool]:
"required": ["dataset_id", "python_function"],
},
),
]
] # end of raw_tools
# Inject mandatory _timeout parameter into every tool
result = []
for tool in raw_tools:
schema = dict(tool.inputSchema)
props = dict(schema.get("properties", {}))
req = list(schema.get("required", []))
# Add _timeout as the first required property
props["_timeout"] = {
"type": "integer",
"description": "工具调用超时秒数(必填,LLM决定每次调用的最长等待时间;0表示不限制)",
"default": 120,
}
req.insert(0, "_timeout")
schema["properties"] = props
schema["required"] = req
result.append(Tool(
name=tool.name,
description=tool.description,
inputSchema=schema,
))
return result
+1 -1
View File
@@ -285,7 +285,7 @@ async def _handle_analyze_geo_distribution(dataset_id: str) -> dict:
result['international_pct'] = round(100.0 - result['domestic_pct'], 1)
except Exception:
logger.error("_handle_analyze_geo_distribution failed: {}".format(traceback.format_exc()))
pass
pass
return result
+1 -1
View File
@@ -1,4 +1,5 @@
"""Anomaly detection + visualization handlers."""
import traceback
import numpy as np
from analysis.constants import MAX_DISTRIBUTION_SAMPLE, RANDOM_SEED
import polars as pl
@@ -12,7 +13,6 @@ logger = logging.getLogger(__name__)
async def _handle_detect_anomalies(dataset_id: str, contamination: float = 0.05) -> dict:
"""Run Isolation Forest on scored entity data."""
from sklearn.ensemble import IsolationForest
import traceback
entry, err = _resolve_dataset(dataset_id)
if err:
return err
+2 -2
View File
@@ -6,6 +6,7 @@ Contains:
- _cluster_core: shared clustering pipeline extracted from both handlers
"""
import logging
import traceback
from analysis.constants import LOW_MEMORY_THRESHOLD_GB, LOW_MEMORY_THRESHOLD_ROWS, MAX_CLUSTER_FEATURES, MAX_SAMPLE_ROWS, RANDOM_SEED
from typing import Optional
@@ -14,13 +15,13 @@ import polars as pl
from ..session_store import SessionStore, _generate_id
from ._helpers import (
logger = logging.getLogger(__name__)
NUMERIC_DTYPES,
_build_filter_expr,
_resolve_dataset,
_count_rows,
)
logger = logging.getLogger(__name__)
_clust_logger = logging.getLogger(__name__)
@@ -172,7 +173,6 @@ def _cluster_core(
quality['davies_bouldin_score'] = None
try:
from sklearn.metrics import calinski_harabasz_score
import traceback
chs = calinski_harabasz_score(data_scaled, labels_array)
quality['calinski_harabasz_score'] = round(float(chs), 4)
except Exception:
+39 -1
View File
@@ -116,7 +116,45 @@ async def _handle_build_entity_profiles(
agg_exprs.append(pl.col(c).n_unique().alias(alias_name))
# ── Execute grouping ───────────────────────────────────────────────
entity_lf = lf.group_by(group_cols).agg(agg_exprs)
row_count = _count_rows(lf)
ENTITY_CHUNK_THRESHOLD = 1_000_000 # 1M rows
ENTITY_CHUNK_SIZE = 100_000 # 100K rows per chunk
if row_count >= ENTITY_CHUNK_THRESHOLD:
# Chunked path: process in 100K batches → group → merge across chunks
all_chunk_dfs = []
for offset in range(0, row_count, ENTITY_CHUNK_SIZE):
chunk_df = lf.slice(offset, ENTITY_CHUNK_SIZE).collect(streaming=True)
chunk_grouped = chunk_df.group_by(group_cols).agg(agg_exprs)
all_chunk_dfs.append(chunk_grouped)
merged_df = pl.concat(all_chunk_dfs)
# Re-aggregate across chunks (same entity may appear in multiple chunks)
re_agg_exprs: list[pl.Expr] = []
for col_name in merged_df.columns:
if col_name in group_cols:
continue
if col_name == 'flow_count':
re_agg_exprs.append(pl.sum('flow_count').alias('flow_count'))
elif col_name.startswith('total_'):
re_agg_exprs.append(pl.sum(col_name).alias(col_name))
elif col_name.startswith('avg_'):
# Weighted average: sum(total) / sum(flow_count)
base = col_name[4:]
total_col = f'total_{base}'
if total_col in merged_df.columns:
re_agg_exprs.append(
(pl.sum(total_col) / pl.sum('flow_count')).alias(col_name))
elif col_name.startswith('unique_'):
# Approximate: max of per-chunk distinct counts
re_agg_exprs.append(pl.max(col_name).alias(col_name))
entity_lf = merged_df.lazy().group_by(group_cols).agg(re_agg_exprs)
logger.info('[ENTITY] chunked aggregation: %d rows → %d chunks → %d merged rows',
row_count, len(all_chunk_dfs), len(merged_df))
else:
entity_lf = lf.group_by(group_cols).agg(agg_exprs)
n_entities = _count_rows(entity_lf)
# ── Store result ───────────────────────────────────────────────────
+3 -415
View File
@@ -1,415 +1,3 @@
"""Data type classifier for column-level type inference.
Provides :class:`DataType` enumeration and functions to classify Polars
columns based on name heuristics and value sampling.
Typical usage::
from analysis.type_classifier import classify_column, classify_schema, DataType
# Single column
dtype = classify_column('src_ip', series, config_type='ipv4')
# Full schema
type_map = classify_schema(lazyframe, config_overrides={'src_ip': 'ipv4'})
"""
from __future__ import annotations
import re
from enum import Enum
import polars as pl
from analysis.tls_ref import get_tls_ref_dict
# ---------------------------------------------------------------------------
# TLS version hex-to-label mapping
# (Duplicated in views.py for globe visualization — keep both in sync.)
# ---------------------------------------------------------------------------
TLS_HEX_MAP: dict[str, str] = {
'0303': 'TLSv1.2',
'0304': 'TLSv1.3',
'0302': 'TLSv1.1',
'0301': 'TLSv1.0',
}
# ---------------------------------------------------------------------------
# DataType enumeration
# ---------------------------------------------------------------------------
class DataType(Enum):
"""Enumerated column types recognised by the classifier engine."""
INT = 1
FLOAT = 2
ENUM = 3
HEX = 4
URL = 5
IPv4 = 6
LAT_LON = 7
TIMESTAMP = 8
BOOL_ENUM = 9
STRING = 10
# ---------------------------------------------------------------------------
# Detection patterns (raw strings for regex)
# ---------------------------------------------------------------------------
HEX_PATTERN = r'^[0-9a-f]{2}(?:\s[0-9a-f]{2})*$'
"""Matches ``"0e a6 3f"``, ``"ab cd ef"`` etc. (space-separated hex pairs)."""
URL_PATTERN = r'^https?://'
"""Matches URLs starting with ``http://`` or ``https://``."""
IPv4_PATTERN = r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$'
"""Matches ``"192.168.1.1"`` style IPv4 addresses."""
MAC_PATTERN = r'^([0-9A-Fa-f]{2}[:.-]){5}[0-9A-Fa-f]{2}$'
"""Matches MAC addresses with ``:``, ``-``, or ``.`` separators."""
BOOL_ENUM_VALUES = (
'', ' ', '+', '-', '0', '1',
'true', 'false', 'yes', 'no', 't', 'f',
)
"""String values considered boolean / binary indicators."""
LAT_LON_NAMES = ('lat', 'latitude', 'lon', 'lng', 'longitude')
# Name-to-type heuristics (checked after config override, before values)
_NAME_TYPE_MAP: list[tuple[re.Pattern, DataType]] = [
# User column names - EXACT match only (no generic fuzzy patterns)
# IPv4 columns
(re.compile(r'^:ips$|^:ipd$|^server.ip$|^client.ip$', re.I), DataType.IPv4),
# ENUM columns
(re.compile(r'^:prs$|^:prd$', re.I), DataType.ENUM),
(re.compile(r'^(?:cnrs|isrs)$', re.I), DataType.BOOL_ENUM),
(re.compile(r'^(?:scnt|dcnt|1ipp|4dbn|4srs|eiph)$', re.I), DataType.ENUM),
(re.compile(r'^0ver$', re.I), DataType.ENUM),
# FLOAT columns
(re.compile(r'^4dur$|^8ses$|^2tmo$|^0rnt$', re.I), DataType.FLOAT),
# INT columns
(re.compile(r'^4ksz$|^8ack$|^8ppk$|^8pak$|^8seq$|^8did$|^8byt$|^row$', re.I), DataType.INT),
# HEX columns
(re.compile(r'^0cph$|^0crv$', re.I), DataType.HEX),
# TIMESTAMP columns
(re.compile(r'^8dbd$|^time$|^timestamp$', re.I), DataType.TIMESTAMP),
# Dot-notation suffixes for lat/lon (e.g. :ips.latd, :ipd.lond)
(re.compile(r'\.latd$', re.I), DataType.LAT_LON),
(re.compile(r'\.lond$', re.I), DataType.LAT_LON),
# Dot-notation STRING suffixes (e.g. :ips.ispn, :ipd.city)
(re.compile(r'\.(ispn|orgn|city|anon)$', re.I), DataType.STRING),
# Standalone string columns - exact match
(re.compile(r'^(?:cnam|snam|tabl|name|0rnd|source.node|ecdhe.named.curve|crcc|orga|orgu)$', re.I), DataType.STRING),
# Special columns with @ prefix
(re.compile(r'^@', re.I), DataType.STRING),
]
# Mapping from config.yaml type strings to DataType
_CONFIG_TYPE_MAP: dict[str, DataType] = {
'int': DataType.INT,
'float': DataType.FLOAT,
'enum': DataType.ENUM,
'hex': DataType.HEX,
'url': DataType.URL,
'ipv4': DataType.IPv4,
'lat_lon': DataType.LAT_LON,
'timestamp': DataType.TIMESTAMP,
'bool_enum': DataType.BOOL_ENUM,
'string': DataType.STRING,
}
# Mapping from tls_ref_data ``data_type`` column values to DataType
TLSDB_TYPE_MAP: dict[str, DataType] = {
'IPv4': DataType.IPv4,
'浮点': DataType.FLOAT,
'字符串': DataType.STRING,
'整数': DataType.INT,
'2小写字母缩写': DataType.ENUM,
'XX:YY.Z': DataType.STRING,
'2字节': DataType.HEX,
'2大写字母': DataType.STRING,
'枚举': DataType.ENUM,
'布尔': DataType.BOOL_ENUM,
'字符串或整数': DataType.STRING,
}
def _tlsdb_type_to_datatype(tlsdb_type_str: str) -> DataType | None:
"""Convert a TlsDB ``data_type`` string to a :class:`DataType`.
Returns ``None`` if the type string is unknown, allowing the
caller to fall through to value-based detection.
"""
return TLSDB_TYPE_MAP.get(tlsdb_type_str)
# ---------------------------------------------------------------------------
# Column classification (single column)
# ---------------------------------------------------------------------------
def _name_to_type(name: str) -> DataType | None:
"""Match column name against known heuristics.
Returns a :class:`DataType` if the name matches strongly, otherwise
``None`` (fall through to value-based detection).
"""
for pattern, dtype in _NAME_TYPE_MAP:
if pattern.search(name):
return dtype
return None
def _is_port_value(v: str) -> bool:
"""Check if *v* is an integer string in the registered port range (1-65535)."""
try:
n = int(v)
return 1 <= n <= 65535
except ValueError:
return False
def _can_float(v: str) -> bool:
"""Return ``True`` when *v* can be parsed as a Python float."""
try:
float(v)
return True
except ValueError:
return False
def _values_to_type(series: pl.Series, col_name: str | None = None) -> DataType:
"""Infer column type by sampling non-null values.
Detection order: IPv4 MAC guard HEX URL PORT BOOL_ENUM
LAT_LON ENUM FLOAT STRING
"""
dtype = series.dtype
# Boolean dtype ──────────────────────────────────────────────────────
if dtype == pl.Boolean:
return DataType.BOOL_ENUM
# Numeric dtypes ─────────────────────────────────────────────────────
if dtype in (pl.Float32, pl.Float64):
# Check LAT_LON possibility before returning FLOAT
non_null = series.drop_nulls()
if len(non_null) > 0:
sample = non_null.head(100)
sample_strs = [str(v).strip().lower() for v in sample]
non_blank = [v for v in sample_strs if v not in ('', ' ', '+', '-')]
if non_blank:
try:
floats = [float(v) for v in non_blank]
max_abs = max(abs(f) for f in floats)
if (
all(-180 <= f <= 180 for f in floats)
and any('.' in v for v in non_blank)
and max_abs > 10
):
return DataType.LAT_LON
except ValueError:
pass
return DataType.FLOAT
if dtype in (pl.Int8, pl.Int16, pl.Int32, pl.Int64,
pl.UInt8, pl.UInt16, pl.UInt32, pl.UInt64):
return DataType.INT
# String / categorical types ─────────────────────────────────────────
non_null = series.drop_nulls()
if len(non_null) == 0:
return DataType.STRING
sample = non_null.head(100)
# Convert all to string representations for pattern matching
sample_strs = [str(v).strip().lower() for v in sample]
# ── 1. IPv4 detection ──────────────────────────────────────────────
if all(re.match(IPv4_PATTERN, v) for v in sample_strs):
if all(_valid_ip_octets(v) for v in sample_strs):
return DataType.IPv4
# ── 2. MAC guard (skip HEX if values look like MAC addresses) ──────
is_mac = all(re.match(MAC_PATTERN, v) for v in sample_strs)
# ── 3. HEX detection ───────────────────────────────────────────────
if not is_mac and all(re.match(HEX_PATTERN, v) for v in sample_strs):
return DataType.HEX
# ── 4. URL detection ───────────────────────────────────────────────
if all(re.match(URL_PATTERN, v) for v in sample_strs):
return DataType.URL
# ── 5. PORT detection → ENUM ───────────────────────────────────────
if all(_is_port_value(v) for v in sample_strs):
return DataType.ENUM
# ── 6. BOOL_ENUM detection ─────────────────────────────────────────
if all(v in BOOL_ENUM_VALUES for v in sample_strs):
return DataType.BOOL_ENUM
# ── 7. LAT_LON detection (value-based) ──────────────────────────────
non_blank = [v for v in sample_strs if v not in ('', ' ', '+', '-')]
if non_blank:
try:
floats = [float(v) for v in non_blank]
max_abs = max(abs(f) for f in floats)
if (
all(-180 <= f <= 180 for f in floats)
and any('.' in v for v in non_blank)
and max_abs > 10
):
return DataType.LAT_LON
except ValueError:
pass
# ── 8. ENUM detection (few unique values) ──────────────────────────
unique_count = non_null.n_unique()
if unique_count < 20:
# Safety: if >80 % of the sample values are unique strings,
# the column is high-cardinality — classify as STRING, not ENUM.
sample_unique_count = len(set(sample_strs))
if sample_unique_count > 0.8 * len(sample_strs):
return DataType.STRING
return DataType.ENUM
# ── 9. TLS version hex detection (before STRING fallback) ──────────
if col_name is not None and col_name.lower() in ('0ver', '_tlsver', 'tls_version', 'version'):
sample_hex = sample_strs[0].replace(' ', '').strip()
if sample_hex.upper() in TLS_HEX_MAP:
return DataType.ENUM
# ── 10. FLOAT detection (>80 % can float) ──────────────────────────
# Exclude strings starting with '+' from FLOAT detection — these are
# text markers (phone codes, IDs, error codes), not real numbers.
if any(v.startswith('+') for v in sample_strs):
return DataType.STRING
float_count = sum(1 for v in sample_strs if _can_float(v))
if float_count / len(sample_strs) > 0.8:
# Guard: values with leading "-" that happen to parse as float
# should not force FLOAT when non-float values also exist.
if any(v.startswith('-') for v in sample_strs):
if float_count < len(sample_strs):
return DataType.STRING
return DataType.FLOAT
return DataType.STRING
def _valid_ip_octets(ip_str: str) -> bool:
"""Check that each octet in an IPv4 string is 0-255."""
parts = ip_str.split('.')
if len(parts) != 4:
return False
for p in parts:
try:
n = int(p)
if n < 0 or n > 255:
return False
except ValueError:
return False
return True
def classify_column(
name: str,
series: pl.Series,
config_type: str | None = None,
) -> DataType:
"""Classify a single column based on its *name* and *series* values.
Priority
--------
1. **config_type** user override from ``config.yaml``.
If provided (e.g. ``'ipv4'``), returns the corresponding
:class:`DataType` immediately.
2. **tls_ref_data** authoritative type from ``TlsDB.csv`` /
``tls_ref_data`` table. Looked up by column name; if found,
the declared type is used directly (no value-based guessing).
3. **Value-based** pattern matching samples up to 100 non-null
values and tests against IPv4 HEX URL PORT BOOL_ENUM
LAT_LON ENUM FLOAT in order.
4. **Name-based** heuristics only consulted when value-based
detection returned ``STRING`` (i.e. no value pattern matched).
Parameters
----------
name:
Column name (used for name-based heuristics and tls_ref_data
lookup).
series:
Polars :class:`Series` containing the column's data.
config_type:
Optional user override (case-insensitive, matched to
:data:`_CONFIG_TYPE_MAP`).
Returns
-------
DataType
"""
# 1. Config override (highest priority)
if config_type is not None:
mapped = _CONFIG_TYPE_MAP.get(config_type.lower().strip())
if mapped is not None:
return mapped
# 2. tls_ref_data lookup (authoritative type from TlsDB)
tls_ref = get_tls_ref_dict()
if name in tls_ref:
dtype = _tlsdb_type_to_datatype(tls_ref[name]['data_type'])
if dtype is not None:
return dtype
# 3. Value-based sampling
result = _values_to_type(series, name)
# 4. Name-based heuristics (only when values returned STRING)
if result == DataType.STRING:
name_match = _name_to_type(name)
if name_match is not None:
return name_match
return result
# ---------------------------------------------------------------------------
# Schema classification (all columns)
# ---------------------------------------------------------------------------
def classify_schema(
lf: pl.LazyFrame,
config_overrides: dict[str, str] | None = None,
) -> dict[str, DataType]:
"""Classify all columns in a :class:`LazyFrame`.
Parameters
----------
lf:
A Polars :class:`LazyFrame` whose columns to classify.
config_overrides:
Optional mapping of ``{col_name: type_string}`` from a
``config.yaml`` ``columns:`` section. These take highest
priority (see :func:`classify_column`).
Returns
-------
dict[str, DataType]
Mapping from column name to inferred :class:`DataType`.
"""
config_overrides = config_overrides or {}
df_sample = lf.collect(streaming=True)
result: dict[str, DataType] = {}
for col_name in df_sample.columns:
config_type = config_overrides.get(col_name)
result[col_name] = classify_column(
name=col_name,
series=df_sample[col_name],
config_type=config_type,
)
return result
"""Re-export from new types module for backward compatibility."""
from analysis.types import ColumnType, TYPE_REGISTRY, TYPE_ALIASES, get_type, classify_column, classify_schema
TLS_HEX_MAP = {"0303": "TLSv1.2", "0304": "TLSv1.3", "0302": "TLSv1.1", "0301": "TLSv1.0"}
+300
View File
@@ -0,0 +1,300 @@
"""天璇类型系统 — 仅8种固定类型,面向对象接口。
每个类型继承 :class:`ColumnType`实现:
- ``distance()`` 为该类型列计算距离矩阵追加到 LazyFrame
- ``display()`` 将值格式化为可读字符串
- ``polars_dtype`` 映射到 Polars 数据类型
- ``name`` 中文名称
:data:`TYPE_REGISTRY` 是唯一的类型注册表按名称索引
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Any
import numpy as np
import polars as pl
# ═══════════════════════════════════════════════════════════════════════
# Base type — all 8 types inherit from this
# ═══════════════════════════════════════════════════════════════════════
class ColumnType(ABC):
"""Base class for all column types.
Subclasses MUST define:
- ``name`` (class attribute, Chinese)
- ``polars_dtype`` (class attribute)
- ``distance()``
- ``display()``
"""
name: str = ""
polars_dtype: pl.DataType = pl.Utf8
@abstractmethod
def distance(self, lf: pl.LazyFrame, columns: list[str]) -> pl.LazyFrame:
"""Compute per-row distances for *columns* of this type.
Returns a new LazyFrame with distance columns appended
(prefixed ``_dist_``).
"""
...
@abstractmethod
def display(self, value: Any) -> str:
"""Format *value* for human-readable display."""
...
# ═══════════════════════════════════════════════════════════════════════
# 8 concrete types
# ═══════════════════════════════════════════════════════════════════════
class IPv4Type(ColumnType):
name = "IPv4"
polars_dtype = pl.Utf8
def distance(self, lf: pl.LazyFrame, columns: list[str]) -> pl.LazyFrame:
if len(columns) < 2:
return lf
from analysis.distance import ip_distance
return ip_distance(lf, columns)
def display(self, value: Any) -> str:
return str(value) if value else "-"
class StringType(ColumnType):
name = "字符串"
polars_dtype = pl.Utf8
def distance(self, lf: pl.LazyFrame, columns: list[str]) -> pl.LazyFrame:
from analysis.distance import string_distance
return string_distance(lf, columns)
def display(self, value: Any) -> str:
return str(value)[:80] if value else "-"
class EnumType(ColumnType):
name = "枚举"
polars_dtype = pl.Utf8
def distance(self, lf: pl.LazyFrame, columns: list[str]) -> pl.LazyFrame:
# 0-1 match distance: compare to mode
for col in columns:
mode_val = lf.select(pl.col(col).mode().first()).collect(streaming=True).item()
lf = lf.with_columns(
(pl.col(col) != mode_val).cast(pl.Float64).alias(f'_enum_dist_{col}')
)
return lf
def display(self, value: Any) -> str:
return str(value) if value else "-"
class FloatType(ColumnType):
name = "浮点"
polars_dtype = pl.Float64
def distance(self, lf: pl.LazyFrame, columns: list[str]) -> pl.LazyFrame:
# Normalised euclidean distance — handled in preprocessing pipeline
for col in columns:
lf = lf.with_columns(
pl.col(col).cast(pl.Float64).alias(f'_raw_{col}')
)
return lf
def display(self, value: Any) -> str:
try:
return f"{float(value):.2f}"
except (ValueError, TypeError):
return str(value)
class IntType(ColumnType):
name = "整数"
polars_dtype = pl.Int64
def distance(self, lf: pl.LazyFrame, columns: list[str]) -> pl.LazyFrame:
# Normalised manhattan distance — handled in preprocessing pipeline
for col in columns:
lf = lf.with_columns(
pl.col(col).cast(pl.Int64).alias(f'_raw_{col}')
)
return lf
def display(self, value: Any) -> str:
try:
return str(int(value))
except (ValueError, TypeError):
return str(value)
class BoolType(ColumnType):
name = "布尔"
polars_dtype = pl.Boolean
def distance(self, lf: pl.LazyFrame, columns: list[str]) -> pl.LazyFrame:
# 0-1 match distance
for col in columns:
lf = lf.with_columns(
pl.col(col).cast(pl.Float64).alias(f'_bool_dist_{col}')
)
return lf
def display(self, value: Any) -> str:
if value is None:
return "-"
return "" if str(value).lower() in ('true', '1', '+', 't') else ""
class BytesType(ColumnType):
name = "字节"
polars_dtype = pl.Utf8
def distance(self, lf: pl.LazyFrame, columns: list[str]) -> pl.LazyFrame:
from analysis.distance import bytes_distance
return bytes_distance(lf, columns)
def display(self, value: Any) -> str:
s = str(value).strip() if value else ""
if len(s) > 20:
return s[:20] + "..."
return s or "-"
class TimestampType(ColumnType):
name = "时间戳"
polars_dtype = pl.Float64
def distance(self, lf: pl.LazyFrame, columns: list[str]) -> pl.LazyFrame:
from analysis.distance import timestamp_fft_distance
return timestamp_fft_distance(lf, columns)
def display(self, value: Any) -> str:
try:
t = float(value)
if t < 1e8 or t > 2e12:
return str(value)
from datetime import datetime
return datetime.fromtimestamp(t).strftime("%Y-%m-%d %H:%M")
except (ValueError, TypeError, OSError):
return str(value) if value else "-"
# ═══════════════════════════════════════════════════════════════════════
# Registry — single source of truth
# ═══════════════════════════════════════════════════════════════════════
TYPE_REGISTRY: dict[str, ColumnType] = {
"枚举": EnumType(),
"字节": BytesType(),
"浮点": FloatType(),
"整数": IntType(),
"布尔": BoolType(),
"字符串": StringType(),
"IPv4": IPv4Type(),
"时间戳": TimestampType(),
}
# Alias map for config.yaml type strings
TYPE_ALIASES: dict[str, str] = {
'enum': '枚举', '枚举': '枚举',
'bytes': '字节', '字节': '字节', 'hex': '字节',
'float': '浮点', '浮点': '浮点',
'int': '整数', '整数': '整数',
'bool': '布尔', '布尔': '布尔', 'boolean': '布尔',
'string': '字符串', '字符串': '字符串', 'str': '字符串',
'ipv4': 'IPv4', 'IPv4': 'IPv4',
'timestamp': '时间戳', '时间戳': '时间戳',
}
def get_type(name: str) -> ColumnType:
"""Resolve a type name (Chinese or English alias) to a ColumnType."""
resolved = TYPE_ALIASES.get(name.strip().lower() if name.isascii() else name.strip())
if resolved is None:
return StringType() # fallback
return TYPE_REGISTRY[resolved]
def classify_column(name: str, series: pl.Series, config_type: str | None = None) -> ColumnType:
"""Classify a single column to a ColumnType.
Priority: config override TlsDB lookup name heuristics value sampling.
"""
from analysis.tls_ref import get_tls_ref_dict
import re
# 1. Config override
if config_type is not None:
t = get_type(config_type)
if t.name != "字符串" or config_type.lower().strip() == 'string':
return t
# 2. TlsDB lookup
tls_ref = get_tls_ref_dict()
if name in tls_ref:
t = get_type(tls_ref[name]['data_type'])
return t
# 3. Name heuristics
_patterns: list[tuple[re.Pattern, str]] = [
(re.compile(r'^(?:src_ip|dst_ip|:ips|:ipd|server.ip|client.ip)$', re.I), 'IPv4'),
(re.compile(r'^(?::prs|:prd|scnt|dcnt|1ipp|4dbn|4srs|eiph|name|tabl|0ver|cnrs|isrs)$', re.I), '枚举'),
(re.compile(r'^(?:0cph|0crv|cipher|ecdhe)', re.I), '字节'),
(re.compile(r'^(?:4dur|8ses|2tmo|0rnt)$', re.I), '浮点'),
(re.compile(r'^(?:4ksz|8ack|8ppk|8pak|8seq|8did|8byt|row)$', re.I), '整数'),
(re.compile(r'^(?:timestamp|time|8dbd|created|updated)', re.I), '时间戳'),
(re.compile(r'\.(ispn|orgn|city|anon|doma|latd|lond)$', re.I), '字符串'),
(re.compile(r'^(?:cnam|snam|0rnd|source.node|crcc|orga|orgu|@)', re.I), '字符串'),
]
for pat, type_name in _patterns:
if pat.search(name):
return TYPE_REGISTRY[type_name]
# 4. Value-based sampling
non_null = series.drop_nulls()
if len(non_null) == 0:
return TYPE_REGISTRY["字符串"]
strs = [str(v).strip().lower() for v in non_null.head(100)]
ip4 = re.compile(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$')
hex_p = re.compile(r'^[0-9a-f]{2}(?:\s[0-9a-f]{2})*$')
if all(ip4.match(v) for v in strs):
return TYPE_REGISTRY["IPv4"]
if all(hex_p.match(v) for v in strs):
return TYPE_REGISTRY["字节"]
if series.dtype.is_integer():
return TYPE_REGISTRY["整数"]
if series.dtype == pl.Float64:
return TYPE_REGISTRY["浮点"]
if series.dtype == pl.Boolean:
return TYPE_REGISTRY["布尔"]
if non_null.n_unique() < 50:
return TYPE_REGISTRY["枚举"]
return TYPE_REGISTRY["字符串"]
def classify_schema(
lf: pl.LazyFrame,
config_overrides: dict[str, str] | None = None,
) -> dict[str, ColumnType]:
"""Classify all columns in a LazyFrame."""
config_overrides = config_overrides or {}
df_sample = lf.collect(streaming=True)
result = {}
for col_name in df_sample.columns:
config_type = config_overrides.get(col_name)
result[col_name] = classify_column(
name=col_name,
series=df_sample[col_name],
config_type=config_type,
)
return result
+5 -3
View File
@@ -191,8 +191,10 @@ def run_llm_analysis_view(request):
save_fields = ['progress_pct', 'progress_msg']
if tool_name == 'thinking':
# Accumulate LLM thinking text (appended to llm_thinking)
if result:
run.llm_thinking = (run.llm_thinking or '') + f'\n[{step}] {result}'
thought = (result or '').strip()
if not thought:
thought = f'步骤 {step} 分析中...'
run.llm_thinking = (run.llm_thinking or '') + f'\n[{step}] {thought}'
save_fields = ['progress_pct', 'progress_msg', 'llm_thinking']
elif tool_name == 'complete':
pass # No extra tracking needed
@@ -272,7 +274,7 @@ def run_llm_analysis_view(request):
_run_clustering_pipeline(
run=run,
store=store,
entity_ds_id=entity_ds_id,
ds_id=entity_ds_id,
feature_columns=None,
algorithm='agglomerative',
min_cluster_size=5,
+100 -66
View File
@@ -1,4 +1,4 @@
"""Cluster views: overview, detail, globe flows, and the clustering pipeline."""
"""Cluster views: overview, detail, globe flows, and the clustering pipeline."""
import asyncio
import json
import traceback
@@ -114,6 +114,7 @@ def cluster_overview(request, display_id):
'llm_timeline_json': llm_timeline_json,
# Globe flow data for the sidebar
'globe_flows_json': json.dumps(_get_globe_flows(run)),
'selected_ds_id': f'upload_{run.display_id}',
})
@@ -160,7 +161,9 @@ def _get_globe_flows(run, max_rows=500):
def cluster_detail(request, display_id, cluster_label):
"""Detailed view of a single cluster with entity list, SVD features, and NL summary."""
"""Detailed view of a single cluster with raw data rows, SVD features, and NL summary."""
import json
import polars as pl
run = get_object_or_404(AnalysisRun, display_id=display_id)
try:
cluster_label = int(cluster_label)
@@ -168,44 +171,68 @@ def cluster_detail(request, display_id, cluster_label):
raise Http404("Invalid cluster label")
cluster = get_object_or_404(ClusterResult, run=run, cluster_label=cluster_label)
features = cluster.features.all().order_by('-distinguishing_score')[:50]
entities = cluster.entities.all()[:100]
# Load raw data rows for this cluster from EP row indices
row_data = []
try:
from analysis.data_loader import load_csv_directory, load_from_db
lf = None
if run.sqlite_table:
lf = load_from_db(run.sqlite_table)
if lf is None and run.csv_glob:
lf, _, _, _, _ = load_csv_directory(run.csv_glob)
if lf is not None:
# Get row indices assigned to this cluster from EntityProfile
eps = cluster.entities.all().order_by('entity_value')[:100]
indices = []
for ep in eps:
try:
idx = int(ep.entity_value.replace('row_', ''))
indices.append(idx)
except (ValueError, AttributeError):
continue
if indices:
df = lf.collect(streaming=True)
df_filtered = df[indices]
# Convert to dict list for template (limit columns for display)
cols = df_filtered.columns[:20] # show first 20 columns
for row_idx, row in enumerate(df_filtered.select(cols).iter_rows(named=True)):
entry = {'index': indices[row_idx] if row_idx < len(indices) else row_idx}
for k, v in row.items():
entry[k] = v
row_data.append(entry)
except Exception as exc:
logger.warning(f'Failed to load raw data for cluster detail: {exc}')
nl_summary = ''
feats_list = [{'feature_name': f.feature_name, 'score': f.distinguishing_score,
'mean': f.mean, 'std': f.std} for f in features[:10]]
if feats_list:
nl_summary = nl_describe.describe_cluster(feats_list)
# Build entity data with feature_json values
entity_data = []
for e in entities:
entry = {'id': e.id, 'value': e.entity_value,
'embedding_x': e.embedding_x, 'embedding_y': e.embedding_y}
if e.feature_json:
entry['features'] = {k: round(float(v), 4) if isinstance(v, (int, float)) else str(v)
for k, v in e.feature_json.items()}
entity_data.append(entry)
return render(request, 'analysis/cluster_detail.html', {
'run': run,
'cluster': cluster,
'features': features,
'entities': entities,
'entity_data_json': json.dumps(entity_data),
'row_data': row_data,
'nl_summary': nl_summary,
})
@profile
def _run_clustering_pipeline(run, store, entity_ds_id, feature_columns, algorithm,
def _run_clustering_pipeline(run, store, ds_id, feature_columns, algorithm,
min_cluster_size, run_umap=True, head=None):
"""Unified clustering pipeline: clustering → feature extraction → UMAP → ORM save.
"""Clustering pipeline on raw rows: clustering → feature extraction → UMAP → ORM save.
Operates directly on raw data rows (no entity aggregation).
Per-row results stored in EntityProfile with row index as identifier.
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'.
ds_id: dataset ID in SessionStore pointing to raw data.
feature_columns: list of feature column names (None = auto-detect all numeric).
algorithm: clustering algorithm.
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).
@@ -217,10 +244,10 @@ def _run_clustering_pipeline(run, store, entity_ds_id, feature_columns, algorith
from analysis.tool_registry import _handle_run_clustering, _handle_extract_features
import polars as pl
entry = store.get_dataset(entity_ds_id)
entry = store.get_dataset(ds_id)
if entry is None:
run.status = 'failed'
run.error_message = 'Entity dataset not found in session store'
run.error_message = '数据集在会话中不存在'
run.save(update_fields=['status', 'error_message'])
return
@@ -241,11 +268,11 @@ def _run_clustering_pipeline(run, store, entity_ds_id, feature_columns, algorith
run.save(update_fields=['progress_msg'])
lf = lf.head(head)
store.store_dataset(
entity_ds_id, lf,
ds_id, lf,
schema=schema,
metadata=entry.get('metadata', {}),
)
entry = store.get_dataset(entity_ds_id) # refresh
entry = store.get_dataset(ds_id)
row_count = head
# Resolve feature columns — use actual LazyFrame dtypes (not stored schema dict
@@ -268,7 +295,7 @@ def _run_clustering_pipeline(run, store, entity_ds_id, feature_columns, algorith
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
@@ -279,7 +306,7 @@ def _run_clustering_pipeline(run, store, entity_ds_id, feature_columns, algorith
run.save(update_fields=['status', 'progress_pct', 'progress_msg'])
result = asyncio.run(_handle_run_clustering(
dataset_id=entity_ds_id,
dataset_id=ds_id,
cluster_columns=feature_cols,
algorithm=algorithm,
params={'min_cluster_size': min_cluster_size},
@@ -307,7 +334,7 @@ def _run_clustering_pipeline(run, store, entity_ds_id, feature_columns, algorith
run.save(update_fields=['status', 'progress_pct', 'progress_msg'])
feat_result = asyncio.run(_handle_extract_features(
dataset_id=entity_ds_id,
dataset_id=ds_id,
cluster_result_id=cluster_id,
top_k=10, method='zscore', save_to_db=True,
run_id=run.id,
@@ -355,6 +382,36 @@ def _run_clustering_pipeline(run, store, entity_ds_id, feature_columns, algorith
except Exception as svd_err:
logger.warning(f'Cluster SVD extraction skipped: {svd_err}')
# ── Step 2c: Save per-row cluster labels to EntityProfile ──
# Always persist row→cluster mapping so cluster_detail can show data rows
# even when UMAP is skipped or fails.
try:
cluster_entry_save = store.get_cluster_result(cluster_id)
labels_list_save = cluster_entry_save.get('labels', []) if cluster_entry_save else []
if labels_list_save:
from analysis.models import EntityProfile as EP_save
from analysis.models import ClusterResult as CR_save
# Delete stale EP records for this run first
EP_save.objects.filter(run=run).delete()
cr_cache_save = {}
row_profiles = []
for r_idx in range(len(labels_list_save)):
lbl = labels_list_save[r_idx]
ck = f'{run.id}_{lbl}'
if ck not in cr_cache_save:
cr_cache_save[ck] = CR_save.objects.filter(run=run, cluster_label=lbl).first()
row_profiles.append(EP_save(
entity_value=f'row_{r_idx}', run=run, cluster_label=lbl,
cluster=cr_cache_save[ck],
embedding_x=None, embedding_y=None, embedding_z=None,
))
if row_profiles:
EP_save.objects.bulk_create(row_profiles, ignore_conflicts=True, batch_size=1000)
run.entity_count = len(row_profiles)
run.save(update_fields=['entity_count'])
except Exception as save_err:
logger.warning(f'Row labels save skipped: {save_err}')
# ── Step 3: UMAP-3D embedding (2D fallback) ───────────────────────
if run_umap:
try:
@@ -379,12 +436,11 @@ def _run_clustering_pipeline(run, store, entity_ds_id, feature_columns, algorith
if len(num_cols) >= 2:
if n_total > MAX_UMAP_TRAIN:
run.progress_msg = f'UMAP 采样 {MAX_UMAP_TRAIN}/{n_total} 实体训练...'
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)
# LazyFrame.sample() is unavailable in Polars 1.42 — collect then sample
df_sample = df_umap.sample(n=MAX_UMAP_TRAIN, seed=RANDOM_SEED)
df_sample = df_umap
umap_components = 3 # try 3D first
coords = None
@@ -431,49 +487,27 @@ def _run_clustering_pipeline(run, store, entity_ds_id, feature_columns, algorith
if coords is None:
raise Exception('UMAP produced no output')
non_num = [c for c in df_umap.columns
if c not in num_cols and not c.startswith('_')]
ent_col = non_num[0] if non_num else df_umap.columns[0]
cluster_entry = store.get_cluster_result(cluster_id)
labels_list = cluster_entry.get('labels', []) if cluster_entry else []
from analysis.models import EntityProfile as EP
from analysis.models import ClusterResult as CR
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()
# Update existing EP records with UMAP coordinates
for i in range(len(df_umap)):
ev = f'row_{i}'
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'])
update_fields = {
'embedding_x': float(coords[i, 0]) if i < len(coords) else None,
'embedding_y': float(coords[i, 1]) if i < len(coords) else None,
}
if has_z:
update_fields['embedding_z'] = float(coords[i, 2]) if i < len(coords) else None
EP.objects.filter(run=run, entity_value=ev).update(**update_fields)
run.entity_count = len(df_umap)
run.save(update_fields=['entity_count'])
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
+69 -1
View File
@@ -33,11 +33,79 @@ def run_list(request):
def run_detail(request, display_id):
"""Details for a single analysis run."""
import json
run = get_object_or_404(AnalysisRun, display_id=display_id)
clusters = run.clusters.all().order_by('-size')
# ── Global SVD features (compute on-demand from the stored run data) ──
svd_features = []
svd_explained_var = []
try:
from analysis.data_loader import load_csv_directory, load_from_db
import polars as pl
from sklearn.decomposition import TruncatedSVD
import numpy as np
import logging
logger = logging.getLogger(__name__)
lf = None
if run.sqlite_table:
lf = load_from_db(run.sqlite_table)
if lf is None and run.csv_glob:
lf, _, _, _, _ = load_csv_directory(run.csv_glob)
if lf is not None:
schema = lf.collect_schema()
numeric_types = (pl.Int8, pl.Int16, pl.Int32, pl.Int64,
pl.UInt8, pl.UInt16, pl.UInt32, pl.UInt64,
pl.Float32, pl.Float64)
num_cols = [n for n, dt in zip(schema.names(), schema.dtypes()) if dt in numeric_types and not n.startswith('_')]
if len(num_cols) >= 3:
n_rows_lf = lf.select(pl.len()).collect(streaming=True).item()
sample = min(int(n_rows_lf), 50000)
df = lf.sample(n=sample, seed=42).select(num_cols).collect(streaming=True) if n_rows_lf > sample else lf.select(num_cols).collect(streaming=True)
mat = np.nan_to_num(df.to_numpy(), nan=0.0)
from sklearn.preprocessing import StandardScaler
mat = StandardScaler().fit_transform(mat)
n_comp = min(20, min(mat.shape) - 1)
if n_comp >= 2:
svd = TruncatedSVD(n_components=n_comp, random_state=42)
svd.fit(mat)
var_ratio = svd.explained_variance_ratio_.tolist()
svd_explained_var = var_ratio
for comp_idx in range(min(10, n_comp)):
loadings = np.abs(svd.components_[comp_idx])
top_idx = np.argsort(loadings)[-5:][::-1]
features = [{'feature': num_cols[i], 'strength': round(float(loadings[i]), 4)} for i in top_idx]
svd_features.append({
'component': comp_idx + 1,
'explained_var': round(var_ratio[comp_idx] * 100, 2),
'cumulative_var': round(sum(var_ratio[:comp_idx + 1]) * 100, 2),
'top_features': features,
})
except Exception as exc:
logger = logging.getLogger(__name__)
logger.warning(f'SVD feature extraction failed for run {run.display_id}: {exc}')
# ── LLM workflow timeline ──
llm_timeline_json = ''
if run.run_type == 'auto':
llm_thinking = run.llm_thinking or ''
tool_calls = run.tool_calls_json or []
if tool_calls or llm_thinking:
llm_timeline_json = json.dumps({
'thinking': llm_thinking,
'tool_calls': tool_calls,
})
return render(request, 'analysis/run_detail.html', {
'run': run,
'clusters': clusters,
'svd_features': svd_features,
'svd_explained_var': svd_explained_var,
'llm_timeline_json': llm_timeline_json,
})
@@ -53,7 +121,7 @@ def run_status_api(request, display_id):
'progress_pct': run.progress_pct,
'progress_msg': run.progress_msg,
'run_log': run.run_log[-2000:], # last 2000 chars
'llm_thinking': run.llm_thinking[-5000:], # last 5K chars
'llm_thinking': run.llm_thinking[-50000:] if run.llm_thinking else '',
'tool_calls': run.tool_calls_json,
})
+12 -4
View File
@@ -4,6 +4,7 @@ import logging
import traceback
from django.shortcuts import render
from django.views.decorators.clickjacking import xframe_options_exempt
from analysis.constants import GLOBE_MAX_ROWS
from analysis.models import AnalysisRun
@@ -112,6 +113,10 @@ def _extract_flows_from_df(df, MAX_ROWS):
return flows
from django.views.decorators.clickjacking import xframe_options_exempt
@xframe_options_exempt
def globe_view(request):
from analysis.geoip import lookup as geo_lookup
from analysis.data_loader import load_csv_directory, load_from_db
@@ -122,6 +127,9 @@ def globe_view(request):
# Check for ?data=dataset_id parameter (direct data loading from session store)
data_param = request.GET.get('data')
embed_mode = request.GET.get('embed') == '1'
template_name = 'tianxuan/globe_embed.html' if embed_mode else 'tianxuan/globe.html'
if data_param:
store = SessionStore()
flows = []
@@ -130,11 +138,11 @@ def globe_view(request):
entry = store.get_dataset(data_param)
if entry is not None:
lf = entry['lazyframe']
df = lf.head(MAX_ROWS_PER_RUN).collect()
df = lf.head(MAX_ROWS_PER_RUN).collect(streaming=True)
flows = _extract_flows_from_df(df, MAX_ROWS_PER_RUN)
except Exception as exc:
logging.getLogger(__name__).warning(f'globe ?data= param failed: {exc}')
return render(request, 'tianxuan/globe.html', {
return render(request, template_name, {
'geo_flows': json.dumps(flows),
'all_runs': all_runs,
'selected_ids': [],
@@ -172,7 +180,7 @@ def globe_view(request):
lf = load_from_db(run.sqlite_table)
if lf is None:
lf, schema, rc, fc, mem = load_csv_directory(run.csv_glob)
df = lf.head(MAX_ROWS_PER_RUN).collect()
df = lf.head(MAX_ROWS_PER_RUN).collect(streaming=True)
flows.extend(_extract_flows_from_df(df, MAX_ROWS_PER_RUN))
all_selected_failed = False
break # stop after first successful load
@@ -193,7 +201,7 @@ def globe_view(request):
if not latest.csv_glob:
continue
lf, schema, rc, fc, mem = load_csv_directory(latest.csv_glob)
df = lf.head(MAX_ROWS_PER_RUN).collect()
df = lf.head(MAX_ROWS_PER_RUN).collect(streaming=True)
flows.extend(_extract_flows_from_df(df, MAX_ROWS_PER_RUN))
selected_runs = [latest]
break
+4 -4
View File
@@ -23,9 +23,9 @@ def manual_page(request):
# Get all tools metadata
tools = get_tools_meta()
core_names = {'profile_data', 'build_entity_profiles', 'compute_scores',
'run_clustering', 'extract_features', 'detect_anomalies',
'visualize_anomalies'}
core_names = {'profile_data', 'run_clustering', 'extract_features',
'filter_data', 'preprocess_data',
'detect_anomalies', 'visualize_anomalies'}
diag_names = {'validate_data', 'explore_distributions', 'find_outliers',
'diagnose_clustering', 'compare_datasets', 'export_debug_sample',
'repair_schema'}
@@ -165,7 +165,7 @@ def manual_run_analysis(request):
# Use the unified clustering pipeline (clustering → extraction → UMAP)
_run_clustering_pipeline(
run=run, store=store, entity_ds_id=ds_id,
run=run, store=store, ds_id=ds_id,
feature_columns=feature_columns,
algorithm=algorithm, min_cluster_size=min_cluster_size,
run_umap=True, head=head,
+22 -44
View File
@@ -176,50 +176,28 @@ def _background_process(run_id, upload_dir):
pl.col(lat_lon_col).map_batches(_coerce_to_float_fn, return_dtype=pl.Float64).alias(lat_lon_col)
)
# ── Restore numeric types for Utf8 columns coerced during CSV load ──
# All columns were forced to Utf8 for safe SQLite storage. Now try to
# recover the original numeric types (Int64 / Float64) from the data.
numeric_types = (pl.Int8, pl.Int16, pl.Int32, pl.Int64,
pl.UInt8, pl.UInt16, pl.UInt32, pl.UInt64,
pl.Float32, pl.Float64)
# Get current schema to know which columns are still Utf8
live_schema = lf.collect_schema()
live_names = live_schema.names()
live_dtypes = list(live_schema.dtypes())
utf8_cols = [
live_names[i] for i, dt in enumerate(live_dtypes)
if dt == pl.Utf8 and not live_names[i].startswith('_')
]
if utf8_cols:
# Infer types from a small sample to decide Float64 vs Int64
sample_df = lf.select(utf8_cols).head(1000).collect(streaming=True)
type_map: dict[str, pl.DataType] = {}
for col in utf8_cols:
try:
# Try Float64 first (most general numeric type)
casted = sample_df[col].cast(pl.Float64, strict=False)
non_null = casted.is_not_null().sum()
if non_null > len(casted) * 0.5:
# Check if all non-null values are integers → use Int64
all_int = True
for v in casted.head(100).to_list():
if v is not None and v != int(v):
all_int = False
break
type_map[col] = pl.Int64 if all_int else pl.Float64
except Exception:
logger.error("unknown failed: {}".format(traceback.format_exc()))
pass
if type_map:
# Update schema dict to reflect restored types
for col, dtype in type_map.items():
schema[col] = str(dtype)
# Apply casts to the LazyFrame
casts = [pl.col(col).cast(dtype, strict=False) for col, dtype in type_map.items()]
lf = lf.with_columns(casts)
logger.info('[BACKGROUND] Restored numeric types for %d columns: %s',
len(type_map), list(type_map.keys())[:10])
# ── Restore column types after SQLite storage ──
# All columns were stored as Utf8 for SQLite. Recover original types
# from the classification system — ONLY the 8 allowed types.
from analysis.type_classifier import classify_schema, DataType
type_map = classify_schema(lf)
# Apply Polars-level type casts based on classification
dtypes = {
DataType.FLOAT: pl.Float64,
DataType.INT: pl.Int64,
DataType.BOOL: pl.Boolean,
DataType.TIMESTAMP: pl.Float64,
}
casts = []
for col, dt in type_map.items():
target = dtypes.get(dt)
if target and col in lf.collect_schema().names():
casts.append(pl.col(col).cast(target, strict=False))
schema[col] = str(target)
if casts:
lf = lf.with_columns(casts)
restored = [(col, dt.value) for col, dt in type_map.items() if dt in dtypes][:15]
logger.info('[BACKGROUND] Type restoration: %s', restored)
# ── SVD dimensionality reduction ──
svd_components = 0
+634
View File
@@ -45,5 +45,639 @@
"lon": 77.0,
"city": "",
"country": "印度"
},
"anon, hosting": null,
"-108.418446": null,
"-63.303819": null,
"156.494253": {
"lat": 34.053,
"lon": -118.2642,
"city": "洛杉矶",
"country": "美国"
},
"174.324906": {
"lat": 43.6425,
"lon": -79.3872,
"city": "",
"country": "加拿大"
},
"91.251045": {
"lat": 53.55,
"lon": 10.0,
"city": "汉堡市",
"country": "德国"
},
"136.838644": {
"lat": 42.3223,
"lon": -83.1763,
"city": "迪尔伯恩",
"country": "美国"
},
"-135.232683": null,
"134.708535": {
"lat": 45.4763,
"lon": -122.6408,
"city": "Portland",
"country": "美国"
},
"172.779207": {
"lat": 38.0,
"lon": -97.0,
"city": "",
"country": "美国"
},
"76.375007": {
"lat": 35.6507,
"lon": -78.4564,
"city": "Clayton",
"country": "美国"
},
"-37.054377": null,
"58.368656": {
"lat": 35.685,
"lon": 139.7514,
"city": "东京",
"country": "日本"
},
"-128.312338": null,
"-86.295682": null,
"-83.710197": null,
"-117.654327": null,
"-94.931072": null,
"-31.118326": null,
"41.94697": {
"lat": -29.0,
"lon": 24.0,
"city": "",
"country": "南非"
},
"-175.557379": null,
"None": null,
"90.484378": {
"lat": 50.6846,
"lon": 1.7610999999999999,
"city": "维尔维涅",
"country": "法国"
},
"91.136808": {
"lat": 50.8934,
"lon": 7.8856,
"city": "Freudenberg",
"country": "德国"
},
"98.398423": {
"lat": 38.0,
"lon": -97.0,
"city": "",
"country": "美国"
},
"-37.351993": null,
"108.8728": {
"lat": 33.5363,
"lon": -117.0439,
"city": "特曼库拉",
"country": "美国"
},
"-40.490238": null,
"-108.320482": null,
"-42.599081": null,
"60.847895": {
"lat": 30.2936,
"lon": 120.1614,
"city": "杭州",
"country": "中国"
},
"-70.344735": null,
"-17.065978": null,
"-17.594761": null,
"3.748318": {
"lat": 41.1412,
"lon": -73.2637,
"city": "Fairfield",
"country": "美国"
},
"117.90784": {
"lat": 21.0333,
"lon": 105.85,
"city": "Hanoi",
"country": "越南"
},
"-178.325477": null,
"47.034714": {
"lat": 45.3155,
"lon": -75.837,
"city": "Ottawa",
"country": "加拿大"
},
"12.6356": {
"lat": 38.0,
"lon": -97.0,
"city": "",
"country": "美国"
},
"-4.376563": null,
"117.415791": {
"lat": 21.0333,
"lon": 105.85,
"city": "Hanoi",
"country": "越南"
},
"-95.943745": null,
"115.811466": {
"lat": 37.57,
"lon": 126.98,
"city": "",
"country": "大韩民国"
},
"82.858229": {
"lat": 54.5842,
"lon": -1.5631,
"city": "Darlington",
"country": "英国"
},
"120.177108": {
"lat": 39.8897,
"lon": 115.275,
"city": "Hebei",
"country": "中国"
},
"12.054574": {
"lat": 38.0,
"lon": -97.0,
"city": "",
"country": "美国"
},
"-112.238338": null,
"-161.091649": null,
"24.207799": {
"lat": 40.3698,
"lon": -80.634,
"city": "斯托本维尔",
"country": "美国"
},
"-148.31237": null,
"-132.223679": null,
"76.252382": {
"lat": 35.6513,
"lon": -78.8336,
"city": "Holly Springs",
"country": "美国"
},
"104.839037": {
"lat": 38.0,
"lon": -97.0,
"city": "",
"country": "美国"
},
"-147.319447": null,
"-137.111721": null,
"-107.76671": null,
"-167.802595": null,
"168.500812": {
"lat": 29.7176,
"lon": -95.4188,
"city": "休斯敦",
"country": "美国"
},
"-19.640731": null,
"-36.115066": null,
"-31.172609": null,
"4.746775": {
"lat": 38.0,
"lon": -97.0,
"city": "",
"country": "美国"
},
"-128.714202": null,
"-95.66714": null,
"-19.935247": null,
"52.225763": {
"lat": 39.5645,
"lon": -75.597,
"city": "威尔明顿",
"country": "美国"
},
"-39.370987": null,
"162.006633": {
"lat": 43.7807,
"lon": -79.2855,
"city": "士嘉堡",
"country": "加拿大"
},
"51.696746": {
"lat": 51.5,
"lon": -0.13,
"city": "",
"country": "英国"
},
"6.673336": {
"lat": 31.5273,
"lon": -110.3607,
"city": "谢拉维斯塔",
"country": "美国"
},
"102.5503": null,
"-144.964956": null,
"-104.457778": null,
"83.087052": null,
"-125.101539": null,
"90.262005": {
"lat": 48.9018,
"lon": 2.4893,
"city": "邦迪",
"country": "法国"
},
"-83.715609": null,
"29.378547": {
"lat": 38.0,
"lon": -97.0,
"city": "",
"country": "美国"
},
"-57.090496": null,
"142.184372": {
"lat": 44.7314,
"lon": -63.6482,
"city": "Dartmouth",
"country": "加拿大"
},
"165.297813": {
"lat": -33.9167,
"lon": 18.4167,
"city": "Cape Town",
"country": "南非"
},
"105.875829": {
"lat": -29.0,
"lon": 24.0,
"city": "",
"country": "南非"
},
"160.97215": {
"lat": 43.286,
"lon": -77.6843,
"city": "Rochester",
"country": "美国"
},
"-76.327692": null,
"-179.768984": null,
"-135.649249": null,
"-129.397466": null,
"-159.604348": null,
"123.957119": {
"lat": 34.6836,
"lon": 113.5325,
"city": "郑州",
"country": "中国"
},
"106.508142": {
"lat": 28.55,
"lon": 115.9333,
"city": "南昌",
"country": "中国"
},
"60.737607": {
"lat": 45.75,
"lon": 126.65,
"city": "哈尔滨",
"country": "中国"
},
"12.851572": {
"lat": 38.0,
"lon": -97.0,
"city": "",
"country": "美国"
},
"133.109702": {
"lat": 35.69,
"lon": 139.69,
"city": "",
"country": "日本"
},
"97.468122": {
"lat": 38.0,
"lon": -97.0,
"city": "",
"country": "美国"
},
"-49.099249": null,
"31.114424": {
"lat": 52.25,
"lon": 21.0,
"city": "华沙",
"country": "波兰"
},
"49.613214": {
"lat": 37.5985,
"lon": 126.9783,
"city": "首尔特别市",
"country": "大韩民国"
},
"-76.937967": null,
"24.350375": {
"lat": 38.0,
"lon": -97.0,
"city": "",
"country": "美国"
},
"-57.188778": null,
"-129.431676": null,
"-136.186649": null,
"-61.694727": null,
"-90.513611": null,
"91.194896": {
"lat": 49.5333,
"lon": 11.8,
"city": "哈恩巴赫",
"country": "德国"
},
"132.327773": {
"lat": 32.404,
"lon": -86.2539,
"city": "蒙哥马利",
"country": "美国"
},
"-112.197533": null,
"155.022955": null,
"162.854691": {
"lat": 51.5,
"lon": -0.13,
"city": "",
"country": "英国"
},
"-52.303734": null,
"-37.91953": null,
"144.662392": {
"lat": 37.3762,
"lon": -122.1826,
"city": "Palo Alto",
"country": "美国"
},
"17.315107": {
"lat": 37.323,
"lon": -122.0322,
"city": "Cupertino",
"country": "美国"
},
"97.359727": {
"lat": 38.0,
"lon": -97.0,
"city": "",
"country": "美国"
},
"-37.814213": null,
"-57.066252": null,
"170.670964": {
"lat": 47.6689,
"lon": -117.4369,
"city": "斯波坎",
"country": "美国"
},
"19.237916": {
"lat": 42.3223,
"lon": -83.1763,
"city": "迪尔伯恩",
"country": "美国"
},
"-77.426219": null,
"35.273579": {
"lat": 42.2776,
"lon": -83.7409,
"city": "安娜堡",
"country": "美国"
},
"-29.56673": null,
"-173.438083": null,
"-164.691055": null,
"-140.463216": null,
"-168.663276": null,
"-138.543279": null,
"-47.142859": null,
"-102.389184": null,
"24.754033": {
"lat": 32.981,
"lon": -80.0326,
"city": "Goose Creek",
"country": "美国"
},
"-109.476174": null,
"92.836244": {
"lat": 51.5,
"lon": -0.13,
"city": "",
"country": "英国"
},
"2.089979": null,
"82.41569": {
"lat": 51.6578,
"lon": -0.3954,
"city": "沃特福德",
"country": "英国"
},
"-117.765392": null,
"-112.753899": null,
"-147.563834": null,
"-27.4301": null,
"66.656057": {
"lat": 38.0,
"lon": -97.0,
"city": "",
"country": "美国"
},
"-52.329152": null,
"16.783619": {
"lat": 37.3762,
"lon": -122.1826,
"city": "Palo Alto",
"country": "美国"
},
"-160.668335": null,
"-155.782242": null,
"-43.415128": null,
"-151.682652": null,
"33.713437": {
"lat": 38.0,
"lon": -97.0,
"city": "",
"country": "美国"
},
"151.170164": {
"lat": 42.8333,
"lon": 12.8333,
"city": "",
"country": "意大利"
},
"-2.437103": null,
"17.096003": null,
"4.372196": {
"lat": 38.0,
"lon": -97.0,
"city": "",
"country": "美国"
},
"12.775114": {
"lat": 38.0,
"lon": -97.0,
"city": "",
"country": "美国"
},
"-26.847855": null,
"-2.96053": null,
"-136.421502": null,
"156.152711": {
"lat": 46.2857,
"lon": -119.2845,
"city": "里奇兰",
"country": "美国"
},
"-121.186972": null,
"-126.924167": null,
"-101.388131": null,
"-101.03754": null,
"77.828572": {
"lat": 52.5167,
"lon": 13.4,
"city": "柏林",
"country": "德国"
},
"-34.813058": null,
"123.011334": {
"lat": 23.7,
"lon": 90.375,
"city": "",
"country": "孟加拉"
},
"11.265017": {
"lat": 39.9612,
"lon": -82.9988,
"city": "哥伦布",
"country": "美国"
},
"11.202764": {
"lat": 39.9612,
"lon": -82.9988,
"city": "哥伦布",
"country": "美国"
},
"70.262109": {
"lat": 38.0,
"lon": -97.0,
"city": "",
"country": "美国"
},
"-70.027646": null,
"26.990087": {
"lat": 38.0,
"lon": -97.0,
"city": "",
"country": "美国"
},
"147.506751": {
"lat": 47.5,
"lon": 19.0833,
"city": "布达佩斯",
"country": "匈牙利"
},
"-107.81862": null,
"141.75262": {
"lat": 51.0,
"lon": 9.0,
"city": "",
"country": "德国"
},
"30.674809": {
"lat": 38.0,
"lon": -97.0,
"city": "",
"country": "美国"
},
"120.327798": {
"lat": 39.8897,
"lon": 115.275,
"city": "Hebei",
"country": "中国"
},
"-158.088393": null,
"-121.790773": null,
"77.908193": {
"lat": 50.3167,
"lon": 7.8,
"city": "Nassau",
"country": "德国"
},
"57.252522": {
"lat": 47.0,
"lon": 8.0,
"city": "",
"country": ""
},
"-15.466886": null,
"-136.800708": null,
"-139.560022": null,
"155.309838": {
"lat": 59.3294,
"lon": 18.0686,
"city": "",
"country": "瑞典"
},
"158.175545": {
"lat": 31.5273,
"lon": -110.3607,
"city": "谢拉维斯塔",
"country": "美国"
},
"-38.283752": null,
"146.597747": {
"lat": 42.3755,
"lon": -83.0772,
"city": "底特律",
"country": "美国"
},
"-146.199812": null,
"-60.696207": null,
"141.547786": {
"lat": 35.8869,
"lon": 14.4025,
"city": "Mdina",
"country": "马耳他"
},
"10.542121": null,
"169.586656": {
"lat": 45.0059,
"lon": -93.1059,
"city": "圣保罗",
"country": "美国"
},
"53.588162": {
"lat": 51.0,
"lon": 9.0,
"city": "",
"country": "德国"
},
"-48.221466": null,
"-104.847562": null,
"-97.617615": null,
"145.767865": {
"lat": 51.8425,
"lon": 5.8528,
"city": "奈梅亨",
"country": "荷兰"
},
"-173.124365": null,
"-158.431061": null,
"-132.023148": null,
"-171.873785": null,
"-23.369992": null,
"53.281077": {
"lat": 51.0,
"lon": 9.0,
"city": "",
"country": "德国"
},
"28.294006": {
"lat": 38.0,
"lon": -97.0,
"city": "",
"country": "美国"
}
}
+356
View File
@@ -0,0 +1,356 @@
<!DOCTYPE html>
<html lang="zh-hans">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>原型 — 聚类UI交互验证</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:#f5f7fa; color:#1a1a2e; }
.header { background:#1a1a2e; color:#fff; padding:0.8rem 1.5rem; display:flex; justify-content:space-between; align-items:center; }
.header h1 { font-size:1.1rem; }
.header span { font-size:0.8rem; color:#aaa; }
.main-layout { display:flex; position:relative; transition:all 0.3s; min-height:calc(100vh - 48px); }
.left-panel { flex:1; min-width:0; transition:flex 0.3s; padding:1rem; overflow-y:auto; }
.left-panel.globe-open { flex:0 0 55%; }
.right-panel { flex:0 0 0; overflow:hidden; transition:flex 0.3s; }
.right-panel.globe-open { flex:0 0 45%; }
/* toggle handle */
.toggle-handle { position:absolute; z-index:10; top:50%; width:20px; height:44px; background:#1a1a2e; color:#fff; border:none; border-radius:4px 0 0 4px; cursor:pointer; display:flex; align-items:center; justify-content:center; font-size:12px; transition:left 0.3s ease; }
.toggle-handle:hover { background:#4361ee; }
/* scatter */
.scatter-wrap { position:relative; background:#fff; border-radius:8px; padding:0.75rem; margin-bottom:1rem; box-shadow:0 1px 3px rgba(0,0,0,0.1); }
.scatter-wrap h3 { font-size:0.95rem; margin-bottom:0.5rem; }
.scatter-wrap canvas { width:100%; height:360px; display:block; cursor:crosshair; border-radius:4px; background:#fafbfc; }
/* pills */
.pill-row { display:flex; flex-wrap:wrap; gap:4px; margin-bottom:0.75rem; }
.pill { display:inline-flex; align-items:center; gap:3px; padding:2px 10px; border-radius:12px; font-size:0.75rem; cursor:pointer; border:2px solid transparent; transition:all 0.15s; color:#fff; white-space:nowrap; }
.pill.all { background:#e8e8e8; color:#333; }
.pill.active { border-color:#333; font-weight:700; }
/* cluster cards */
.card-grid { display:grid; grid-template-columns:1fr 1fr; gap:0.75rem; margin-bottom:1rem; }
.cluster-card { background:#fff; border:1px solid #e0e0e0; border-radius:8px; padding:0.8rem; 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 rgba(67,97,238,0.2); }
.cluster-card .label { font-weight:700; font-size:1rem; }
.cluster-card .meta { font-size:0.78rem; color:#666; margin-top:0.2rem; }
.cluster-card .desc { font-size:0.8rem; color:#333; margin-top:0.4rem; padding:0.4rem; background:#f8f9fa; border-left:3px solid #4361ee; border-radius:0 4px 4px 0; line-height:1.5; }
/* detail panel */
.detail-panel { 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-panel.open { transform:translateY(0); }
.detail-body { background:#fff; border-radius:16px 16px 0 0; box-shadow:0 -4px 24px rgba(0,0,0,0.15); max-height:40vh; overflow-y:auto; padding:0.5rem 1.5rem 1.5rem; }
.detail-handle { width:36px; height:4px; background:#ccc; border-radius:2px; margin:8px auto; cursor:pointer; }
.detail-close { float:right; cursor:pointer; font-size:1.3rem; color:#999; line-height:1; border:none; background:none; }
.detail-close:hover { color:#333; }
.detail-body h3 { font-size:1rem; margin-bottom:0.3rem; }
.detail-body p { font-size:0.85rem; color:#555; margin:0.2rem 0; }
.detail-body table { width:100%; border-collapse:collapse; font-size:0.8rem; margin-top:0.5rem; }
.detail-body th { background:#f5f5f5; padding:0.25rem 0.4rem; text-align:left; font-weight:600; border-bottom:2px solid #ddd; }
.detail-body td { padding:0.2rem 0.4rem; border-bottom:1px solid #eee; }
.detail-body td code { font-size:0.72rem; }
/* 3D地球容器 */
#globeContainer { width:100%; height:calc(100vh - 48px); background:#0a0a1a; position:relative; }
#globeContainer canvas { display:block; }
</style>
</head>
<body>
<div class="header">
<h1>聚类概览 — 运行 #9</h1>
<span>200 条流 · 3 个簇 · 12 个噪声点</span>
</div>
<div class="main-layout" id="mainLayout">
<!-- Left: cluster content -->
<div class="left-panel" id="leftPanel">
<!-- Scatter -->
<div class="scatter-wrap">
<h3>UMAP 嵌入 <span style="font-weight:400;font-size:0.8rem;color:#888;" id="ptCount">加载中...</span></h3>
<canvas id="scatterChart"></canvas>
</div>
<!-- Cluster pills -->
<div class="pill-row" id="pillRow">
<span class="pill all active" data-lbl="all" onclick="clearSel()">全部</span>
</div>
<!-- Cluster cards -->
<div class="card-grid" id="cardGrid"></div>
</div>
<!-- Right: 3D Globe -->
<div class="right-panel" id="rightPanel">
<div id="globeContainer"></div>
</div>
<!-- Toggle handle -->
<button class="toggle-handle" id="globeHandle" onclick="toggleGlobe()"></button>
</div>
<!-- Bottom detail panel -->
<div class="detail-panel" id="detailPanel">
<div class="detail-body" id="detailBody">
<button class="detail-close" onclick="closeDetail()"></button>
<div class="detail-handle" onclick="closeDetail()"></div>
<div id="detailContent"><p style="color:#999;">选择一个簇或数据点查看详情</p></div>
</div>
</div>
<script src="static/tianxuan/three.min.js"></script>
<script>
// ═══════════════════════════════════════════════
// MOCK DATA
// ═══════════════════════════════════════════════
const MOCK_CLUSTERS = [
{ label:0, size:78, prop:0.39, silh:0.72, desc:'低延迟 (23ms),比全局均值高3.1σ;大数据包 (1520 bytes),高2.3σ;系统端口 (443)' },
{ label:1, size:65, prop:0.33, silh:0.68, desc:'高TLS1.3比例 (92%),比均值高1.8σ;短会话 (12s),低1.5σ;ECDHE曲线 X25519' },
{ label:2, size:45, prop:0.23, silh:0.54, desc:'高连接频率 (85次/min),比均值高4.2σ;非标准端口 (8080/8443),目标多域名' },
{ label:-1, size:12, prop:0.06, silh:null, desc:'噪声点 — 异常TLS指纹、零散目标、低流量' },
];
const COLORS = ['#4361ee','#f72585','#7209b7','#4cc9f0','#f8961e','#43aa8b','#f94144','#577590','#e9c46a','#9b5de5'];
function colorFor(lbl) { return lbl===-1?'rgba(150,150,150,0.7)':COLORS[Math.abs(lbl)%COLORS.length]; }
// Generate mock UMAP scatter data (200 points)
const SCATTER = [];
const RNG = (seed) => { let s=seed%2147483647; return ()=> { s=s*16807%2147483647; return (s-1)/2147483646; }; };
const rng = RNG(42);
const centers = {0:[2,1.5],1:[-1.8,0.5],2:[0.5,-2], '-1':[3.5,-1.5]};
const spreads = {0:[0.6,0.5],1:[0.7,0.6],2:[0.5,0.7], '-1':[1.2,0.8]};
for (const c of MOCK_CLUSTERS) {
for (let i=0;i<c.size;i++) {
const cx=centers[c.label][0], cy=centers[c.label][1];
const sx=spreads[c.label][0], sy=spreads[c.label][1];
SCATTER.push({
x:cx+(rng()-0.5)*2*sx, y:cy+(rng()-0.5)*2*sy,
label:c.label, entity:`模拟主机 ${1000+i}`
});
}
}
// ═══════════════════════════════════════════════
// SCATTER 2D CANVAS
// ═══════════════════════════════════════════════
let selCluster = null, selPoint = null;
let globeOpen = false;
function drawScatter(highlight) {
const c=document.getElementById('scatterChart');
if(!c)return;
const W=c.parentElement.clientWidth||700, H=360;
c.width=W*2; c.height=H*2; c.style.width=W+'px'; c.style.height=H+'px';
const ctx=c.getContext('2d'); ctx.scale(2,2);
const pad={top:25,bottom:30,left:50,right:20};
const pw=W-pad.left-pad.right, ph=H-pad.top-pad.bottom;
const xs=SCATTER.map(d=>d.x), ys=SCATTER.map(d=>d.y);
const xMin=Math.min(...xs), xMax=Math.max(...xs), yMin=Math.min(...ys), yMax=Math.max(...ys);
const xR=xMax-xMin||1, yR=yMax-yMin||1;
const toX=v=>pad.left+(v-xMin)/xR*pw, toY=v=>pad.top+ph-(v-yMin)/yR*ph;
ctx.clearRect(0,0,W,H);
// grid
ctx.strokeStyle='#eee'; ctx.lineWidth=1;
for(let i=0;i<=5;i++){ctx.beginPath();ctx.moveTo(pad.left,pad.top+i*ph/5);ctx.lineTo(W-pad.right,pad.top+i*ph/5);ctx.stroke();}
// points
SCATTER.forEach(d=>{
const cx=toX(d.x),cy=toY(d.y);
let radius=3,color=colorFor(d.label);
if(highlight!==null&&highlight!==undefined){
if(d.label===highlight){color=colorFor(d.label);radius=5;}
else{color='rgba(200,200,200,0.15)';radius=2;}
}
ctx.beginPath();ctx.arc(cx,cy,radius,0,Math.PI*2);ctx.fillStyle=color;ctx.fill();
});
// labels
ctx.fillStyle='#999'; ctx.font='11px sans-serif'; ctx.textAlign='center';
ctx.fillText('UMAP-1',W/2,H-3);
ctx.save();ctx.translate(14,H/2);ctx.rotate(-Math.PI/2);ctx.fillText('UMAP-2',0,0);ctx.restore();
c.onclick=function(e){
const r=c.getBoundingClientRect();
const mx=e.clientX-r.left, my=e.clientY-r.top;
const sx=mx/W*pw, sy=my/H*ph;
let best=null,bestD=10;
for(const d of SCATTER){
const dx=toX(d.x)-mx, dy=toY(d.y)-my;
const dist=Math.sqrt(dx*dx+dy*dy);
if(dist<bestD){bestD=dist;best=d;}
}
if(best){selCluster=MOCK_CLUSTERS.find(c=>c.label===best.label);selPoint=best;showDetail(selCluster,selPoint);}
};
document.getElementById('ptCount').textContent=SCATTER.length+' 个点';
}
// ═══════════════════════════════════════════════
// CLUSTER SELECTION
// ═══════════════════════════════════════════════
function selectCluster(lbl){
selCluster=MOCK_CLUSTERS.find(c=>c.label===lbl);
selPoint=null;
drawScatter(lbl);
document.querySelectorAll('.pill').forEach(p=>p.classList.toggle('active',parseInt(p.dataset.lbl)===lbl));
document.querySelectorAll('.cluster-card').forEach(p=>p.classList.toggle('selected',parseInt(p.dataset.label)===lbl));
if(globeOpen)globeFilter(lbl);
showDetail(selCluster,null);
}
function clearSel(){
selCluster=null;selPoint=null;
drawScatter(null);
document.querySelectorAll('.pill,.cluster-card').forEach(p=>p.classList.remove('active','selected'));
closeDetail();
if(globeOpen)globeFilter(null);
}
// ═══════════════════════════════════════════════
// DETAIL PANEL
// ═══════════════════════════════════════════════
function showDetail(cluster,point){
const dc=document.getElementById('detailContent');
let html='';
if(point){
html+='<h3>数据点详情</h3><table><thead><tr><th>字段</th><th></th></tr></thead><tbody>'+
'<tr><td>标识</td><td><code>'+point.entity+'</code></td></tr>'+
'<tr><td>所属簇</td><td>#'+(point.label===-1?'噪声':point.label)+'</td></tr>'+
'<tr><td>UMAP-x</td><td>'+point.x.toFixed(4)+'</td></tr>'+
'<tr><td>UMAP-y</td><td>'+point.y.toFixed(4)+'</td></tr>'+
'</tbody></table>';
}else if(cluster){
html+='<h3>簇 #'+(cluster.label===-1?'噪声':cluster.label)+'</h3>'+
'<p>大小: '+cluster.size+' | 占比: '+(cluster.prop*100).toFixed(0)+'%'+
(cluster.silh!==null?' | 轮廓系数: '+cluster.silh.toFixed(4):'')+'</p>'+
'<p style="background:#f8f9fa;border-left:3px solid #4361ee;padding:0.5rem;margin:0.5rem 0;border-radius:0 4px 4px 0;font-size:0.85rem;line-height:1.6;">'+cluster.desc+'</p>'+
'<table><thead><tr><th>特征</th><th>区分度</th><th>均值</th><th>标准差</th></tr></thead><tbody>'+
'<tr><td>延迟 (4dur)</td><td style="color:#2e7d32;">+3.2</td><td>23ms</td><td>5.1</td></tr>'+
'<tr><td>数据包大小 (8ppk)</td><td style="color:#2e7d32;">+2.3</td><td>1520B</td><td>128</td></tr>'+
'<tr><td>TLS 版本 (0ver)</td><td style="color:#c62828;">-1.8</td><td>1.2</td><td>0.3</td></tr>'+
'</tbody></table>';
}
dc.innerHTML=html;
document.getElementById('detailPanel').classList.add('open');
}
function closeDetail(){document.getElementById('detailPanel').classList.remove('open');}
// ═══════════════════════════════════════════════
// GLOBE SIDEBAR
// ═══════════════════════════════════════════════
function toggleGlobe(){
globeOpen=!globeOpen;
document.getElementById('leftPanel').classList.toggle('globe-open',globeOpen);
document.getElementById('rightPanel').classList.toggle('globe-open',globeOpen);
const h=document.getElementById('globeHandle');
h.innerHTML=globeOpen?'▶':'◀';
h.style.left=globeOpen?'calc(55% - 20px)':'calc(100% - 20px)';
if(globeOpen&&!window._globeInit)initGlobe3D();
}
function globeFilter(label){
if(!window._arcMeshes)return;
window._arcMeshes.forEach(m=>{
if(label===null){m.material.opacity=0.5;m.material.color.setHex(parseInt(m.userData.color,16));}
else{m.material.opacity=0.06;m.material.color.setHex(0x666666);}
});
}
// ═══════════════════════════════════════════════
// 3D GLOBE (Three.js, simplified)
// ═══════════════════════════════════════════════
function initGlobe3D(){
if(window._globeInit)return;
const cont=document.getElementById('globeContainer');
if(!cont)return;
const W=cont.clientWidth||450, H=cont.clientHeight||500;
const scene=new THREE.Scene();scene.background=new THREE.Color(0x0a0a1a);
const cam=new THREE.PerspectiveCamera(45,W/H,0.1,100);cam.position.set(0,0,6);
const ren=new THREE.WebGLRenderer({antialias:true});ren.setSize(W,H);ren.setPixelRatio(Math.min(window.devicePixelRatio,2));
cont.appendChild(ren.domElement);
// earth
const earth=new THREE.Mesh(new THREE.SphereGeometry(2,48,48),new THREE.MeshPhongMaterial({color:0x1a3a5c,emissive:0x0a1a2a}));
scene.add(earth);
// glow
scene.add(new THREE.Mesh(new THREE.SphereGeometry(2.06,48,48),new THREE.MeshBasicMaterial({color:0x224488,transparent:true,opacity:0.12})));
// lights
scene.add(new THREE.AmbientLight(0x222244));
const dl=new THREE.DirectionalLight(0xffffff,1);dl.position.set(5,10,7);scene.add(dl);
scene.add(new THREE.DirectionalLight(0x4488ff,0.3));
// stars
const s=new THREE.BufferGeometry();const p=new Float32Array(600*3);
for(let i=0;i<600*3;i++)p[i]=(Math.random()-0.5)*100;
s.setAttribute('position',new THREE.BufferAttribute(p,3));
scene.add(new THREE.Points(s,new THREE.PointsMaterial({color:0xffffff,size:0.15})));
// mock flows → arcs
const tlsC={'TLSv1.3':0x4cc9f0,'TLSv1.2':0x43aa8b,'TLSv1.1':0xf8961e,'TLSv1.0':0xf94144};
const arcGrp=new THREE.Group();scene.add(arcGrp);
const arcs=[];
function llz(lat,lon,r){const p=(90-lat)*Math.PI/180,t=(lon+180)*Math.PI/180;return new THREE.Vector3(-r*Math.sin(p)*Math.cos(t),r*Math.cos(p),r*Math.sin(p)*Math.sin(t));}
// generate ~30 mock flows
const cities=[{lat:31.2,lon:121.5},{lat:39.9,lon:116.4},{lat:35.7,lon:139.7},{lat:37.6,lon:127.0},{lat:51.5,lon:-0.13},{lat:48.9,lon:2.35},{lat:40.7,lon:-74.0},{lat:37.8,lon:-122.4},{lat:1.35,lon:103.8},{lat:-33.9,lon:151.2}];
const tlsOpts=['TLSv1.3','TLSv1.2','TLSv1.3','TLSv1.2','TLSv1.1'];
for(let i=0;i<30;i++){
const src=cities[i%cities.length],dst=cities[(i+3)%cities.length];
const f=llz(src.lat,src.lon,2),t=llz(dst.lat,dst.lon,2);
const mid=new THREE.Vector3().addVectors(f,t).multiplyScalar(0.5).normalize().multiplyScalar(3.2);
const curve=new THREE.QuadraticBezierCurve3(f,mid,t);
const c=tlsOpts[i%tlsOpts.length];
const mat=new THREE.MeshBasicMaterial({color:tlsC[c],transparent:true,opacity:0.5});
const mesh=new THREE.Mesh(new THREE.TubeGeometry(curve,16,0.015,4,false),mat);
mesh.userData={color:tlsC[c].toString(16)};
arcGrp.add(mesh);arcs.push(mesh);
}
window._arcMeshes=arcs;
// drag
let isD=false,pm={x:0,y:0};
ren.domElement.onmousedown=e=>{isD=true;pm={x:e.clientX,y:e.clientY};};
window.onmouseup=()=>isD=false;
window.onmousemove=e=>{if(!isD)return;const dx=e.clientX-pm.x,dy=e.clientY-pm.y;earth.rotation.y+=dx*0.005;earth.rotation.x+=dy*0.005;arcGrp.rotation.y=earth.rotation.y;arcGrp.rotation.x=earth.rotation.x;pm={x:e.clientX,y:e.clientY};};
!function anim(){requestAnimationFrame(anim);if(!isD){earth.rotation.y+=0.003;arcGrp.rotation.y=earth.rotation.y;}ren.render(scene,cam);}();
window._globeInit=true;
}
// ═══════════════════════════════════════════════
// RENDER PILLS & CARDS
// ═══════════════════════════════════════════════
(function init(){
const pr=document.getElementById('pillRow');
MOCK_CLUSTERS.forEach(c=>{
if(c.label===-1)return;
const p=document.createElement('span');p.className='pill';p.style.background=colorFor(c.label);
p.dataset.lbl=c.label;p.textContent='#'+c.label+' ('+c.size+')';p.onclick=()=>selectCluster(c.label);
pr.appendChild(p);
});
// noise pill
const np=document.createElement('span');np.className='pill';np.style.background='#999';
np.dataset.lbl='-1';np.textContent='噪声 ('+MOCK_CLUSTERS.find(c=>c.label===-1).size+')';np.onclick=()=>selectCluster(-1);
pr.appendChild(np);
// cards
const cg=document.getElementById('cardGrid');
MOCK_CLUSTERS.forEach(c=>{
if(c.label===-1)return;
const card=document.createElement('div');card.className='cluster-card';card.dataset.label=c.label;
card.innerHTML='<div class="label">簇 #'+c.label+'</div>'+
'<div class="meta">'+c.size+' 个实体 · '+(c.prop*100).toFixed(0)+'%'+(c.silh!==null?' · 轮廓系数: '+c.silh.toFixed(4):'')+'</div>'+
'<div class="desc">'+c.desc+'</div>';
card.onclick=()=>selectCluster(c.label);
cg.appendChild(card);
});
drawScatter(null);
// set initial handle pos
document.getElementById('globeHandle').style.left='calc(100% - 20px)';
})();
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

+81
View File
@@ -0,0 +1,81 @@
"""Generate 100 TLS flow CSV files (10000 rows each) matching TlsDB.csv column spec.
Usage:
runtime\\python\\python.exe scripts\\gen_massive_test.py [--files 100] [--rows 10000] [--output-dir data/test_csvs]
"""
import sys
import argparse
from pathlib import Path
# Allow importing from project root
sys.path.insert(0, str(Path(__file__).parent.parent))
from scripts.gen_test_data import generate_row, COLUMNS, COVERAGE
import random
import csv
from datetime import datetime
def main():
parser = argparse.ArgumentParser(
description='Generate 100 massive TLS flow CSV files (10,000 rows each)'
)
parser.add_argument('--files', type=int, default=100,
help='Number of CSV files (default: 100)')
parser.add_argument('--rows', type=int, default=10000,
help='Rows per file (default: 10000)')
parser.add_argument('--output-dir', type=str, default='data/test_csvs',
help='Output directory (default: data/test_csvs)')
parser.add_argument('--seed', type=int, default=42,
help='Random seed for reproducibility (default: 42)')
args = parser.parse_args()
random.seed(args.seed)
out_dir = Path(args.output_dir)
out_dir.mkdir(parents=True, exist_ok=True)
base_time = datetime(2026, 6, 1, 0, 0, 0)
total_rows = 0
for file_idx in range(args.files):
file_seed = args.seed + file_idx
file_rng = random.Random(file_seed)
# Generate all rows for this file
rows = []
for row_i in range(args.rows):
row = generate_row(total_rows + row_i, base_time)
rows.append(row)
# Auto-drop columns that are ALL blank
non_blank_cols = set()
for row in rows:
for col in COLUMNS:
val = row.get(col, '')
if val != '' and val != '+':
non_blank_cols.add(col)
file_columns = [c for c in COLUMNS if c in non_blank_cols]
# Randomize column order per file
shuffled = file_columns[:]
file_rng.shuffle(shuffled)
# Write: test_0000.csv .. test_0099.csv
out_path = out_dir / f'test_{file_idx:04d}.csv'
with open(out_path, 'w', newline='', encoding='utf-8') as f:
writer = csv.DictWriter(f, fieldnames=shuffled, extrasaction='ignore')
writer.writeheader()
for row in rows:
writer.writerow(row)
total_rows += args.rows
if (file_idx + 1) % 10 == 0 or file_idx == 0 or file_idx == args.files - 1:
print(f' [{file_idx + 1}/{args.files}] {total_rows} rows, '
f'{len(shuffled)} cols, seed={file_seed}')
print(f'\nDone: {args.files} files x {args.rows} rows = {total_rows} total rows')
print(f'Output: {out_dir.resolve()}')
if __name__ == '__main__':
main()
+8
View File
@@ -0,0 +1,8 @@
"""Rewrite type_classifier.py as thin re-export wrapper."""
content = '''"""Re-export from new types module for backward compatibility."""
from analysis.types import ColumnType, TYPE_REGISTRY, TYPE_ALIASES, get_type, classify_column, classify_schema
TLS_HEX_MAP = {"0303": "TLSv1.2", "0304": "TLSv1.3", "0302": "TLSv1.1", "0301": "TLSv1.0"}
'''
with open('analysis/type_classifier.py', 'w', encoding='utf-8') as f:
f.write(content)
print('Rewritten OK')
+143 -178
View File
@@ -1,178 +1,143 @@
Restored 2 datasets from disk
[2026-07-23 13:06:51,899] INFO django: Restored 2 datasets from disk
[2026-07-23 13:06:52,038] INFO analysis.data_loader: [LOAD_FROM_DB_LAZY] table=_data_64 rows=9990 cols=57
[2026-07-23 13:06:52,043] INFO analysis.data_loader: [LOAD_FROM_DB_LAZY] table=_data_66 rows=2000 cols=9
[23/Jul/2026 13:21:24] "GET / HTTP/1.1" 200 10790
[23/Jul/2026 13:21:24,471] - Broken pipe from ('127.0.0.1', 64227)
[2026-07-23 13:27:21,369] INFO analysis.views: [UPLOAD] Received 1998 files
[23/Jul/2026 13:27:22] "POST /upload/csv/ HTTP/1.1" 200 178755
[23/Jul/2026 13:27:22] "POST /runs/5/finalize/ HTTP/1.1" 200 34
[23/Jul/2026 13:27:22] "GET /runs/5/status/ HTTP/1.1" 200 188
[2026-07-23 13:27:22,672] INFO analysis.views: [BACKGROUND] Forcing lat/lon column ':ips.latd' to Utf8 for safe parsing
[2026-07-23 13:27:22,672] INFO analysis.views: [BACKGROUND] Forcing lat/lon column ':ips.lond' to Utf8 for safe parsing
[2026-07-23 13:27:22,672] INFO analysis.views: [BACKGROUND] Forcing lat/lon column ':ipd.latd' to Utf8 for safe parsing
[2026-07-23 13:27:22,672] INFO analysis.views: [BACKGROUND] Forcing lat/lon column ':ipd.lond' to Utf8 for safe parsing
[2026-07-23 13:27:22,672] INFO analysis.views: [BACKGROUND] Found 1998 CSV files in C:\Users\25044\AppData\Roaming\TianXuan\data\uploads\20260723_052721
[2026-07-23 13:27:23,325] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:23,609] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:23,895] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:24,170] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:24,447] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:24,733] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:25,000] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:25,268] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:25,553] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[23/Jul/2026 13:27:25] "GET /runs/5/status/ HTTP/1.1" 200 242
[2026-07-23 13:27:25,838] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:26,116] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:26,392] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:26,668] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:26,946] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:27,231] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:27,497] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:27,782] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:28,071] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:28,349] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[2026-07-23 13:27:28,621] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=495 mode=append
[23/Jul/2026 13:27:28] "GET /runs/5/status/ HTTP/1.1" 200 243
[2026-07-23 13:27:28,713] INFO analysis.data_loader: [SAVE_TO_DB] table=_data_68 rows=90 mode=append
E:\hjq\澶╃拠\analysis\views.py:584: PerformanceWarning: Determining the column names of a LazyFrame requires resolving its schema, which is a potentially expensive operation. Use `LazyFrame.collect_schema().names()` to get the column names without this warning.
if lat_lon_col in lf.columns:
[2026-07-23 13:27:29,071] INFO analysis.views: [BACKGROUND] Restored numeric types for 16 columns: ['1ipp', '2tmo', '4dbn', '4dur', '4srs', '8ack', '8byt', '8dbd', '8did', '8pak']
[2026-07-23 13:27:29,150] INFO analysis.views: [BACKGROUND] SVD: 15 components from 16 numeric columns (explained variance ratio sum=1.0000)
[23/Jul/2026 13:27:31] "GET /runs/5/status/ HTTP/1.1" 200 223
[23/Jul/2026 13:27:56] "POST /analyze/run/ HTTP/1.1" 200 45
[23/Jul/2026 13:27:56] "GET /runs/5/status/ HTTP/1.1" 200 223
[2026-07-23 13:27:57,344] INFO analysis.tool_registry: [CLUSTER_INPUT] rows=9990 cols=10 feature_cols=['1ipp', '2tmo', '4dbn', '4dur', '4srs', '8ack', '8byt', '8dbd', '8did', '8pak']
[2026-07-23 13:27:57,344] INFO analysis.tool_registry: [CLUSTER_SCALE] Starting StandardScaler...
[2026-07-23 13:27:57,344] INFO analysis.tool_registry: [CLUSTER_SCALE] Done. shape=(9990, 10)
E:\hjq\澶╃拠\runtime\python\Lib\site-packages\sklearn\cluster\_hdbscan\hdbscan.py:722: FutureWarning: The default value of `copy` will change from False to True in 1.10. Explicitly set a value for `copy` to silence this warning.
warn(
[23/Jul/2026 13:27:59] "GET /runs/5/status/ HTTP/1.1" 200 240
[23/Jul/2026 13:28:02] "GET /runs/5/status/ HTTP/1.1" 200 240
[23/Jul/2026 13:28:06] "GET /runs/5/status/ HTTP/1.1" 200 240
E:\hjq\澶╃拠\runtime\python\Lib\site-packages\umap\umap_.py:1952: UserWarning: n_jobs value 1 overridden to 1 by setting random_state. Use no seed for parallelism.
warn(
[23/Jul/2026 13:28:09] "GET /runs/5/status/ HTTP/1.1" 200 240
[23/Jul/2026 13:28:12] "GET /runs/5/status/ HTTP/1.1" 200 240
[23/Jul/2026 13:28:15] "GET /runs/5/status/ HTTP/1.1" 200 240
[23/Jul/2026 13:28:18] "GET /runs/5/status/ HTTP/1.1" 200 240
[23/Jul/2026 13:28:21] "GET /runs/5/status/ HTTP/1.1" 200 240
[23/Jul/2026 13:28:24] "GET /runs/5/status/ HTTP/1.1" 200 240
[23/Jul/2026 13:28:27] "GET /runs/5/status/ HTTP/1.1" 200 240
[23/Jul/2026 13:28:30] "GET /runs/5/status/ HTTP/1.1" 200 240
[23/Jul/2026 13:28:33] "GET /runs/5/status/ HTTP/1.1" 200 240
[23/Jul/2026 13:28:37] "GET /runs/5/status/ HTTP/1.1" 200 240
[2026-07-23 13:28:38,183] INFO analysis.geoip: [GEOIP] loaded 803 ranges from E:\hjq\澶╃拠\data\geoip_data.txt (0 skipped)
E:\hjq\澶╃拠\runtime\python\Lib\site-packages\umap\umap_.py:1952: UserWarning: n_jobs value 1 overridden to 1 by setting random_state. Use no seed for parallelism.
warn(
[23/Jul/2026 13:28:40] "GET /runs/5/status/ HTTP/1.1" 200 254
Restored 3 datasets from disk
[2026-07-23 21:16:22,571] INFO django: Restored 3 datasets from disk
[2026-07-23 21:16:22,802] INFO analysis.data_loader: [LOAD_FROM_DB_LAZY] table=_data_64 rows=9990 cols=57
[2026-07-23 21:16:22,804] INFO analysis.data_loader: [LOAD_FROM_DB_LAZY] table=_data_66 rows=2000 cols=9
[2026-07-23 21:16:22,992] INFO analysis.data_loader: [LOAD_FROM_DB_LAZY] table=_data_68 rows=9990 cols=57
[23/Jul/2026 21:16:44] "GET / HTTP/1.1" 200 12728
[23/Jul/2026 21:16:44] "GET /static/tianxuan/favicon.svg HTTP/1.1" 304 0
[23/Jul/2026 21:17:14] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:17:20] "HEAD / HTTP/1.1" 200 0
Restored 3 datasets from disk
[2026-07-23 21:22:58,596] INFO django: Restored 3 datasets from disk
[2026-07-23 21:22:58,783] INFO analysis.data_loader: [LOAD_FROM_DB_LAZY] table=_data_64 rows=9990 cols=57
[2026-07-23 21:22:58,801] INFO analysis.data_loader: [LOAD_FROM_DB_LAZY] table=_data_66 rows=2000 cols=9
[2026-07-23 21:22:59,009] INFO analysis.data_loader: [LOAD_FROM_DB_LAZY] table=_data_68 rows=9990 cols=57
[23/Jul/2026 21:22:59] "GET / HTTP/1.1" 200 12728
Restored 3 datasets from disk
[2026-07-23 21:37:07[23/Jul/2026 21:37:07] "GET / HTTP/1.1" 200 12728 [[23/Jul/2026 21:37:38] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:37:49] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:38:08] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:38:38] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:39:08] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:39:38] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:39:43] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:39:45] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:39:49] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:40:08] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:40:38] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:41:35] "HEAD / HTTP/1.1" 200 0
Restored 3 datasets from disk
[2026-07-23 21:42:01,918] INFO django: Restored 3 datasets from disk
[2026-07-23 21:42:02,145] INFO analysis.data_loader: [LOAD_FROM_DB_LAZY] table=_data_64 rows=9990 cols=57
[2026-07-23 21:42:02,153] INFO analysis.data_loader: [LOAD_FROM_DB_LAZY] table=_data_66 rows=2000 cols=9
[2026-07-23 21:42:02,295] INFO analysis.data_loader: [LOAD_FROM_DB_LAZY] table=_data_68 rows=9990 cols=57
esponse = wrapped_callback(request, *callback_args, **callback_kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "E:\hjq\天璇\simple_analysis\views.py", line 796, in index
File "E:\hjq\天璇\runtime\python\Lib\site-packages\django\shortcuts.py", line 24, in render
content = loader.render_to_string(template_name, context, request, using=using)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "E:\hjq\天璇\runtime\python\Lib\site-packages\django\template\loader.py", line 61, in render_to_string
template = get_template(template_name, using=using)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "E:\hjq\天璇\runtime\python\Lib\site-packages\django\template\loader.py", line 19, in get_template
raise TemplateDoesNotExist(template_name, chain=chain)
django.template.exceptions.TemplateDoesNotExist: simple_analysis/simple_analysis.html
[2026-07-23 21:41:56,536] ERROR django.request: Internal Server Error: /simple/
Traceback (most recent call last):
File "E:\hjq\天璇\runtime\python\Lib\site-packages\django\core\handlers\exception.py", line 55, in inner
response = get_response(request)
^^^^^^^^^^^^^^^^^^^^^
File "E:\hjq\天璇\runtime\python\Lib\site-packages\django\core\handlers\base.py", line 197, in _get_response
response = wrapped_callback(request, *callback_args, **callback_kwargs)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "E:\hjq\天璇\simple_analysis\views.py", line 796, in index
File "E:\hjq\天璇\runtime\python\Lib\site-packages\django\shortcuts.py", line 24, in render
content = loader.render_to_string(template_name, context, request, using=using)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "E:\hjq\天璇\runtime\python\Lib\site-packages\django\template\loader.py", line 61, in render_to_string
template = get_template(template_name, using=using)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "E:\hjq\天璇\runtime\python\Lib\site-packages\django\template\loader.py", line 19, in get_template
raise TemplateDoesNotExist(template_name, chain=chain)
django.template.exceptions.TemplateDoesNotExist: simple_analysis/simple_analysis.html
[23/Jul/2026 21:41:56] "GET /simple/ HTTP/1.1" 500 80559
Not Found: /favicon.ico
[2026-07-23 21:41:56,814] WARNING django.request: Not Found: /favicon.ico
[23/Jul/2026 21:41:56] "GET /favicon.ico HTTP/1.1" 404 6872
Restored 3 datasets from disk
[2026-07-23 21:45:23,039] INFO django: Restored 3 datasets from disk
[2026-07-23 21:45:23,182] INFO analysis.data_loader: [LOAD_FROM_DB_LAZY] table=_data_64 rows=9990 cols=57
[2026-07-23 21:45:23,190] INFO analysis.data_loader: [LOAD_FROM_DB_LAZY] table=_data_66 rows=2000 cols=9
[2026-07-23 21:45:23,314] INFO analysis.data_loader: [LOAD_FROM_DB_LAZY] table=_data_68 rows=9990 cols=57
[23/Jul/2026 21:45:28] "GET / HTTP/1.1" 200 12645
[23/Jul/2026 21:45:31] "GET /upload/ HTTP/1.1" 200 19078
[23/Jul/2026 21:45:44] "POST /runs/5/delete/ HTTP/1.1" 200 17
[23/Jul/2026 21:45:44] "GET /upload/ HTTP/1.1" 200 18534
[23/Jul/2026 21:45:46] "POST /runs/4/delete/ HTTP/1.1" 200 17
[23/Jul/2026 21:45:46] "GET /upload/ HTTP/1.1" 200 17939
[23/Jul/2026 21:45:48] "POST /runs/3/delete/ HTTP/1.1" 200 17
[23/Jul/2026 21:45:48] "GET /upload/ HTTP/1.1" 200 17395
[23/Jul/2026 21:45:51] "POST /runs/2/delete/ HTTP/1.1" 200 17
[23/Jul/2026 21:45:51] "GET /upload/ HTTP/1.1" 200 16853
[23/Jul/2026 21:45:52] "POST /runs/1/delete/ HTTP/1.1" 200 17
[23/Jul/2026 21:45:52] "GET /upload/ HTTP/1.1" 200 16136
[23/Jul/2026 21:46:23] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:46:53] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:47:23] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:47:53] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:48:23] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:48:53] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:49:23] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:49:53] "HEAD / HTTP/1.1" 200 0
[23/Jul/2026 21:50:23] "HEAD / HTTP/1.1" 200 0
Restored 3 datasets from disk
[2026-07-23 21:50:50,464] INFO django: Restored 3 datasets from disk
[23/Jul/2026 21:50:54] "GET /analyze/manual/ HTTP/1.1" 200 65847
[23/Jul/2026 21:50:54] "GET /tools/plan/ HTTP/1.1" 200 13
[23/Jul/2026 21:50:57] "GET /analyze/auto/ HTTP/1.1" 200 39486
[23/Jul/2026 21:51:09] "GET /runs/ HTTP/1.1" 200 9674
[2026-07-23 21:51:10,324] INFO analysis.geoip: [GEOIP] loaded 0 ranges (0 skipped)
[2026-07-23 21:51:10,325] INFO analysis.geoip: [GEOIP] loaded 12 cached entries
[23/Jul/2026 21:51:10] "GET /globe/ HTTP/1.1" 200 18520
[23/Jul/2026 21:51:10] "GET /static/tianxuan/three.min.js HTTP/1.1" 304 0
[23/Jul/2026 21:51:10] "GET /static/tianxuan/world_borders.js HTTP/1.1" 304 0
[23/Jul/2026 21:51:10] "GET /static/tianxuan/three.min.js HTTP/1.1" 304 0
[23/Jul/2026 21:51:10] "GET /static/tianxuan/world_borders.js HTTP/1.1" 304 0
[23/Jul/2026 21:51:10] "GET /static/tianxuan/earth_atmos_2048.jpg HTTP/1.1" 304 0
[23/Jul/2026 21:51:27] "GET /config/ HTTP/1.1" 200 14372
[23/Jul/2026 21:51:31] "GET /analyze/auto/ HTTP/1.1" 200 39486
Restored 1 datasets from disk
[2026-07-24 12:30:18,755] INFO analysis.data_loader: [LOAD] files=1 total_rows_est=200
[2026-07-24 12:30:18,742] INFO django: Restored 1 datasets from disk
[2026-07-24 12:30:18,811] INFO analysis.data_loader: [LOAD_SCHEMA] col=0cph dtype=String
[2026-07-24 12:30:18,811] INFO analysis.data_loader: [LOAD_SCHEMA] col=0crv dtype=String
[2026-07-24 12:30:18,811] INFO analysis.data_loader: [LOAD_SCHEMA] col=0rnd dtype=String
[2026-07-24 12:30:18,811] INFO analysis.data_loader: [LOAD_SCHEMA] col=0rnt dtype=String
[2026-07-24 12:30:18,811] INFO analysis.data_loader: [LOAD_SCHEMA] col=0ver dtype=String
[2026-07-24 12:30:18,811] INFO analysis.data_loader: [LOAD_SCHEMA] col=1ipp dtype=Int64
[2026-07-24 12:30:18,811] INFO analysis.data_loader: [LOAD_SCHEMA] col=2tmo dtype=Float64
[2026-07-24 12:30:18,811] INFO analysis.data_loader: [LOAD_SCHEMA] col=4dbn dtype=Int64
[2026-07-24 12:30:18,817] INFO analysis.data_loader: [LOAD_SCHEMA] col=4dur dtype=Float64
[2026-07-24 12:30:18,817] INFO analysis.data_loader: [LOAD_SCHEMA] col=4ksz dtype=Int64
[2026-07-24 12:30:18,817] INFO analysis.data_loader: [LOAD_SCHEMA] col=4srs dtype=Int64
[2026-07-24 12:30:18,817] INFO analysis.data_loader: [LOAD_SCHEMA] col=8ack dtype=Int64
[2026-07-24 12:30:18,817] INFO analysis.data_loader: [LOAD_SCHEMA] col=8byt dtype=Int64
[2026-07-24 12:30:18,817] INFO analysis.data_loader: [LOAD_SCHEMA] col=8dbd dtype=Int64
[2026-07-24 12:30:18,818] INFO analysis.data_loader: [LOAD_SCHEMA] col=8did dtype=Int64
[2026-07-24 12:30:18,818] INFO analysis.data_loader: [LOAD_SCHEMA] col=8pak dtype=Int64
[2026-07-24 12:30:18,818] INFO analysis.data_loader: [LOAD_SCHEMA] col=8ppk dtype=Int64
[2026-07-24 12:30:18,818] INFO analysis.data_loader: [LOAD_SCHEMA] col=8seq dtype=Float64
[2026-07-24 12:30:18,818] INFO analysis.data_loader: [LOAD_SCHEMA] col=8ses dtype=Float64
[2026-07-24 12:30:18,818] INFO analysis.data_loader: [LOAD_SCHEMA] col=:ipd dtype=String
[2026-07-24 12:30:18,818] INFO analysis.data_loader: [LOAD_SCHEMA] col=:ipd.anon dtype=String
[2026-07-24 12:30:18,822] INFO analysis.data_loader: [LOAD_SCHEMA] col=:ipd.city dtype=String
[2026-07-24 12:30:18,822] INFO analysis.data_loader: [LOAD_SCHEMA] col=:ipd.doma dtype=String
[2026-07-24 12:30:18,822] INFO analysis.data_loader: [LOAD_SCHEMA] col=:ipd.ispn dtype=String
[2026-07-24 12:30:18,822] INFO analysis.data_loader: [LOAD_SCHEMA] col=:ipd.latd dtype=String
[2026-07-24 12:30:18,822] INFO analysis.data_loader: [LOAD_SCHEMA] col=:ipd.lond dtype=String
[2026-07-24 12:30:18,822] INFO analysis.data_loader: [LOAD_SCHEMA] col=:ipd.orgn dtype=String
[2026-07-24 12:30:18,822] INFO analysis.data_loader: [LOAD_SCHEMA] col=:ips dtype=String
[2026-07-24 12:30:18,822] INFO analysis.data_loader: [LOAD_SCHEMA] col=:ips.anon dtype=String
[2026-07-24 12:30:18,822] INFO analysis.data_loader: [LOAD_SCHEMA] col=:ips.city dtype=String
[2026-07-24 12:30:18,822] INFO analysis.data_loader: [LOAD_SCHEMA] col=:ips.doma dtype=String
[2026-07-24 12:30:18,822] INFO analysis.data_loader: [LOAD_SCHEMA] col=:ips.ispn dtype=String
[2026-07-24 12:30:18,822] INFO analysis.data_loader: [LOAD_SCHEMA] col=:ips.latd dtype=String
[2026-07-24 12:30:18,822] INFO analysis.data_loader: [LOAD_SCHEMA] col=:ips.lond dtype=String
[2026-07-24 12:30:18,822] INFO analysis.data_loader: [LOAD_SCHEMA] col=:ips.orgn dtype=String
[2026-07-24 12:30:18,827] INFO analysis.data_loader: [LOAD_SCHEMA] col=:prd dtype=Int64
[2026-07-24 12:30:18,827] INFO analysis.data_loader: [LOAD_SCHEMA] col=:prs dtype=Int64
[2026-07-24 12:30:18,827] INFO analysis.data_loader: [LOAD_SCHEMA] col=cipher-suite dtype=String
[2026-07-24 12:30:18,827] INFO analysis.data_loader: [LOAD_SCHEMA] col=client-ip dtype=String
[2026-07-24 12:30:18,827] INFO analysis.data_loader: [LOAD_SCHEMA] col=cnam dtype=String
[2026-07-24 12:30:18,827] INFO analysis.data_loader: [LOAD_SCHEMA] col=crcc dtype=String
[2026-07-24 12:30:18,827] INFO analysis.data_loader: [LOAD_SCHEMA] col=dcnt dtype=String
[2026-07-24 12:30:18,827] INFO analysis.data_loader: [LOAD_SCHEMA] col=ecdhe-named-curve dtype=String
[2026-07-24 12:30:18,827] INFO analysis.data_loader: [LOAD_SCHEMA] col=name dtype=String
[2026-07-24 12:30:18,827] INFO analysis.data_loader: [LOAD_SCHEMA] col=orga dtype=String
[2026-07-24 12:30:18,827] INFO analysis.data_loader: [LOAD_SCHEMA] col=orgu dtype=Int64
[2026-07-24 12:30:18,827] INFO analysis.data_loader: [LOAD_SCHEMA] col=scnt dtype=String
[2026-07-24 12:30:18,827] INFO analysis.data_loader: [LOAD_SCHEMA] col=server-ip dtype=String
[2026-07-24 12:30:18,827] INFO analysis.data_loader: [LOAD_SCHEMA] col=snam dtype=String
[2026-07-24 12:30:18,827] INFO analysis.data_loader: [LOAD_SCHEMA] col=source-node dtype=String
[2026-07-24 12:30:18,832] INFO analysis.data_loader: [LOAD_SCHEMA] col=tabl dtype=String
[2026-07-24 12:30:18,832] INFO analysis.data_loader: [LOAD_SCHEMA] col=time dtype=String
[2026-07-24 12:30:18,832] INFO analysis.data_loader: [LOAD_SCHEMA] col=timestamp dtype=Float64
[2026-07-24 12:30:18,840] INFO analysis.data_loader: [CLEAN] Generic numeric coercion applied: [':ipd.lond', ':ipd.doma', ':ipd.ispn', 'cnam', ':ips.ispn', ':ips.doma', 'orga', 'snam', 'time', ':ips.orgn', ':ipd.orgn', 'crcc', ':ips.latd', ':ipd.latd', ':ips.city', ':ipd.city', ':ips.lond']
[2026-07-24 12:30:18,840] INFO analysis.data_loader: [CLEAN] HEX columns added hex_pairs_count: ['0cph', '0crv', '0ver', '0rnt', '0rnd']
[24/Jul/2026 12:30:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:31:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:31:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:32:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:32:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:33:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:33:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:34:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:34:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:35:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:35:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:36:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:36:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:37:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:37:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:38:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:38:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:39:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:39:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:40:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:40:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:41:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:41:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:42:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:42:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:43:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:43:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:44:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:44:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:45:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:45:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:46:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:46:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:47:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:47:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:48:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:48:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:49:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:49:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:50:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:50:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:51:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:51:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:52:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:52:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:53:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:53:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:54:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:54:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:55:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:55:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:56:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:56:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:57:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:57:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:58:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:58:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:59:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 12:59:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 13:00:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 13:00:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 13:01:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 13:01:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 13:02:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 13:02:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 13:03:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 13:03:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 13:04:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 13:04:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 13:05:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 13:05:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 13:06:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 13:06:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 13:07:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 13:07:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 13:08:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 13:08:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 13:09:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 13:09:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 13:10:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 13:10:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 13:11:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 13:11:37] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 13:12:07] "HEAD / HTTP/1.1" 200 0
[24/Jul/2026 13:12:37] "HEAD / HTTP/1.1" 200 0
+2 -59
View File
@@ -1,65 +1,8 @@
Performing system checks...
System check identified no issues (0 silenced).
July 23, 2026 - 13:06:51
July 24, 2026 - 12:30:18
Django version 4.2.30, using settings 'tianxuan.settings'
Starting development server at http://127.0.0.1:18766/
Quit the server with CTRL-BREAK.
Performing system checks...
System check identified no issues (0 silenced).
Performing system checks...
System check identified no issues (0 silenced).
Performing system checks...
System check identified no issues (0 silenced).
July 23, 2026 - 21:16:22
Django version 4.2.30, using settings 'tianxuan.settings'
Starting development server at http://0.0.0.0:8000/
Quit the server with CTRL-BREAK.
Performing system checks...
System check identified no issues (0 silenced).
Performing system checks...
System check identified no issues (0 silenced).
July 23, 2026 - 21:22:58
Django version 4.2.30, using settings 'tianxuan.settings'
Starting development server at http://0.0.0.0:8000/
Quit the server with CTRL-BREAK.
Performing system checks...
System check identified no issues (0 silenced).
July 23, 2026 - 21:37:07
Django version 4.2.30, using settings 'tianxuan.settings'
Starting development server at http://0.0.0.0:8000/
Quit the server with CTRL-BREAK.
Performing system checks...
System check identified no issues (0 silenced).
July 23, 2026 - 21:42:01
Django version 4.2.30, using settings 'tianxuan.settings'
Starting development server at http://0.0.0.0:8000/
Quit the server with CTRL-BREAK.
Performing system checks...
System check identified no issues (0 silenced).
July 23, 2026 - 21:45:23
Django version 4.2.30, using settings 'tianxuan.settings'
Starting development server at http://0.0.0.0:8000/
Quit the server with CTRL-BREAK.
Performing system checks...
System check identified no issues (0 silenced).
July 23, 2026 - 21:50:50
Django version 4.2.30, using settings 'tianxuan.settings'
Starting development server at http://0.0.0.0:8000/
Starting development server at http://127.0.0.1:8765/
Quit the server with CTRL-BREAK.
+70 -49
View File
@@ -1,29 +1,27 @@
{% extends 'base.html' %}
{% extends 'base.html' %}
{% load static %}
{% block title %}Cluster #{{ cluster.cluster_label }} — Run #{{ run.display_id }}{% endblock %}
{% block title %} #{{ cluster.cluster_label }} — 运行 #{{ run.display_id }}{% endblock %}
{% block content %}
<style>
.entity-card { border:1px solid #e0e0e0; border-radius:6px; padding:0.6rem; margin-bottom:0.4rem; background:#fafafa; }
.entity-card .val { font-weight:600; font-size:0.85rem; }
.entity-card .feat { font-size:0.75rem; color:#666; display:inline-block; margin-right:0.5rem; }
.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; }
.feat-positive { color:#2e7d32; }
.feat-negative { color:#c62828; }
.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; }
.feat-table td { padding:0.25rem 0.5rem; border-bottom:1px solid #eee; }
</style>
<div class="card">
<a href="{% url 'analysis:cluster_overview' run.display_id %}" style="color:#4361ee;text-decoration:none;font-size:0.9rem;">Cluster Overview</a>
<a href="{% url 'analysis:cluster_overview' run.display_id %}" style="color:#4361ee;text-decoration:none;font-size:0.9rem;">聚类概览</a>
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:0.5rem;margin-top:0.5rem;">
<div>
<h2 style="margin:0;">Cluster #{{ cluster.cluster_label }}
{% if cluster.cluster_label == -1 %}<span class="badge badge-warning">Noise</span>{% endif %}
<h2 style="margin:0;"> #{{ cluster.cluster_label }}
{% if cluster.cluster_label == -1 %}<span class="badge badge-warning">噪声</span>{% endif %}
</h2>
<p style="margin:0.25rem 0 0;color:#666;font-size:0.9rem;">
{{ cluster.size }} entities
{% if cluster.proportion %} — {{ cluster.proportion|floatformat:1 }}% of total{% endif %}
{% if cluster.silhouette_score is not None %} — Silhouette: {{ cluster.silhouette_score|floatformat:4 }}{% endif %}
{{ cluster.size }} 个行
{% if cluster.proportion %} — {{ cluster.proportion|floatformat:1 }}% 占总{% endif %}
{% if cluster.silhouette_score is not None %} — 轮廓系数: {{ cluster.silhouette_score|floatformat:4 }}{% endif %}
</p>
</div>
</div>
@@ -34,17 +32,17 @@
<!-- ── Feature Analysis ── -->
<div class="card">
<h3>📊 Feature Analysis <span style="font-weight:400;font-size:0.8rem;color:#888;">{{ features|length }} features</span></h3>
<h3>📊 特征分析 <span style="font-weight:400;font-size:0.8rem;color:#888;">{{ features|length }} 个特征</span></h3>
{% if features %}
<table class="feat-table">
<thead>
<tr>
<th>#</th>
<th>Feature</th>
<th>Dist. Score</th>
<th>Mean</th>
<th>Std</th>
<th>Median</th>
<th>特征名</th>
<th>区分度</th>
<th>均值</th>
<th>标准差</th>
<th>中位数</th>
<th>P25</th>
<th>P75</th>
</tr>
@@ -55,17 +53,17 @@
<td>{{ forloop.counter }}</td>
<td><code>{{ f.feature_name }}</code></td>
<td><span class="{% if f.distinguishing_score > 0 %}feat-positive{% else %}feat-negative{% endif %}">{{ f.distinguishing_score|floatformat:3 }}</span></td>
<td>{{ f.mean|floatformat:3|default:"-" }}</td>
<td class="ts-val" data-col="{{ f.feature_name }}">{{ f.mean|floatformat:3|default:"-" }}</td>
<td>{{ f.std|floatformat:3|default:"-" }}</td>
<td>{{ f.median|floatformat:3|default:"-" }}</td>
<td>{{ f.p25|floatformat:3|default:"-" }}</td>
<td>{{ f.p75|floatformat:3|default:"-" }}</td>
<td class="ts-val" data-col="{{ f.feature_name }}">{{ f.median|floatformat:3|default:"-" }}</td>
<td class="ts-val" data-col="{{ f.feature_name }}">{{ f.p25|floatformat:3|default:"-" }}</td>
<td class="ts-val" data-col="{{ f.feature_name }}">{{ f.p75|floatformat:3|default:"-" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state"><p>No feature data for this cluster.</p></div>
<div class="empty-state"><p>该簇无特征数据</p></div>
{% endif %}
</div>
@@ -75,15 +73,15 @@
{% with svd_features=features|slice:":5" %}
{% if svd_features %}
<div class="card">
<h3>🔬 SVD Key Features <span style="font-weight:400;font-size:0.8rem;color:#888;">Top 5 by distinguishing score</span></h3>
<h3>🔬 SVD 关键特征 <span style="font-weight:400;font-size:0.8rem;color:#888;">Top 5 按区分度</span></h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;">
{% for f in svd_features %}
<div style="background:#f8f9fa;border-radius:6px;padding:0.5rem;border:1px solid #e0e0e0;">
<div style="font-weight:600;font-size:0.8rem;">{{ f.feature_name }}</div>
<div style="font-size:0.75rem;color:#666;margin-top:0.2rem;">
Score: <span class="{% if f.distinguishing_score > 0 %}feat-positive{% else %}feat-negative{% endif %}">{{ f.distinguishing_score|floatformat:2 }}</span>
| Mean: {{ f.mean|floatformat:2 }}
| Std: {{ f.std|floatformat:2 }}
| 均值: {{ f.mean|floatformat:2 }}
| 标准差: {{ f.std|floatformat:2 }}
</div>
</div>
{% endfor %}
@@ -93,28 +91,51 @@
{% endwith %}
{% endif %}
<!-- ── Entity List ── -->
<!-- ── Raw Data Rows ── -->
<div class="card">
<h3>👤 Entities <span style="font-weight:400;font-size:0.8rem;color:#888;">{{ entities|length }} shown</span></h3>
<div id="entityList">
{% for e in entities %}
<div class="entity-card">
<div style="display:flex;justify-content:space-between;align-items:center;">
<span class="val">{{ e.entity_value }}</span>
<a href="{% url 'analysis:entity_profile' e.id %}" class="btn btn-sm" style="font-size:0.75rem;padding:0.2rem 0.6rem;background:#4361ee;color:#fff;text-decoration:none;border-radius:4px;">Profile</a>
</div>
{% if e.feature_json %}
<div style="margin-top:0.3rem;">
{% for k, v in e.feature_json.items|slice:":8" %}
<span class="feat"><strong>{{ k }}</strong>: {% if v is None %}-{% else %}{{ v|floatformat:2 }}{% endif %}</span>
<h3>📋 数据行 <span style="font-weight:400;font-size:0.8rem;color:#888;">{{ row_data|length }} </span></h3>
{% if row_data %}
<div style="overflow-x:auto;">
<table class="feat-table" style="font-size:0.75rem;">
<thead>
<tr>
<th>#</th>
{% for k, v in row_data.0.items %}
<th>{{ k }}</th>
{% endfor %}
{% if e.feature_json.items|length > 8 %}<span class="feat" style="color:#999;">+{{ e.feature_json.items|length|add:"-8" }} more</span>{% endif %}
</div>
{% endif %}
</div>
{% empty %}
<div class="empty-state"><p>No entities in this cluster.</p></div>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in row_data %}
<tr>
<td>{{ forloop.counter }}</td>
{% for k, v in row.items %}
<td><code>{{ v|default:"-" }}</code></td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state"><p>该簇无数据行</p></div>
{% endif %}
</div>
{% endblock %}
{% endblock %}
<script>
(function(){
var tsCols=["timestamp","time","epoch","ts","8dbd","created_at","updated_at","event_time","datetime"];
document.querySelectorAll(".ts-val").forEach(function(el){
var col=el.dataset.col,isTs=false;
for(var i=0;i<tsCols.length;i++){if(col.toLowerCase().indexOf(tsCols[i])>=0){isTs=true;break;}}
if(!isTs)return;
var raw=parseFloat(el.textContent);
if(isNaN(raw)||raw<1e8||raw>2e12)return;
var d=new Date(raw*1000);
if(d.getFullYear()<2000||d.getFullYear()>2100)return;
el.textContent=d.toISOString().replace("T"," ").substring(0,16);
el.title="epoch: "+raw;
});
})();
</script>
+19 -133
View File
@@ -1,17 +1,17 @@
{% extends 'base.html' %}
{% extends 'base.html' %}
{% load static %}
{% block title %}聚类概览 — 运行 #{{ run.display_id }}{% endblock %}
{% block content %}
<style>
/* ── 布局 ── */
.main-layout { display:flex; gap:0; transition:all 0.3s; }
.main-layout { display:flex; gap:0; transition:all 0.3s; position:relative; }
.left-panel { flex:1; min-width:0; transition:flex 0.3s; }
.left-panel.globe-open { flex:0 0 55%; }
.right-panel { flex:0 0 0; overflow:hidden; transition:flex 0.3s; border-left:1px solid #e0e0e0; position:relative; }
.right-panel { flex:0 0 0; overflow:hidden; transition:flex 0.3s; border-left:1px solid #e0e0e0; }
.right-panel.globe-open { flex:0 0 45%; }
.globe-toggle-handle { position:absolute; left:-24px; top:50%; transform:translateY(-50%); width:24px; height:48px; background:#1a1a2e; color:#fff; border:none; border-radius:6px 0 0 6px; cursor:pointer; display:flex; align-items:center; justify-content:center; font-size:14px; z-index:10; transition:background 0.15s; }
.globe-toggle-handle { position:absolute; z-index:10; top:50%; left:calc(100% - 24px); transform:translateY(-50%); width:24px; height:48px; background:#1a1a2e; color:#fff; border:none; border-radius:6px 0 0 6px; cursor:pointer; display:flex; align-items:center; justify-content:center; font-size:14px; transition:left 0.3s, background 0.15s; }
.globe-toggle-handle:hover { background:#4361ee; }
.globe-toggle-handle.open { left:0; border-radius:0; }
.globe-toggle-handle.open { left:calc(55% - 24px); border-radius:6px 0 0 6px; }
/* ── Scatter ── */
.scatter-wrapper { position:relative; cursor:crosshair; }
.scatter-wrapper canvas { width:100%; height:400px; display:block; }
@@ -61,7 +61,7 @@
<!-- ── UMAP 2D / 3D ── -->
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:0.5rem;">
<h3 style="margin:0;">UMAP 嵌入 <span style="font-weight:400;font-size:0.8rem;color:#888;"><script>document.write(scatterCount)</script> 个点</span></h3>
<h3 style="margin:0;">UMAP 嵌入 <span style="font-weight:400;font-size:0.8rem;color:#888;" id="pointCount">计算中...</span></h3>
<span id="viewToggle" style="display:none;">
<button id="btn2D" class="btn btn-sm" style="font-weight:bold;padding:2px 10px;">2D</button>
<button id="btn3D" class="btn btn-sm btn-outline" style="padding:2px 10px;">3D</button>
@@ -120,9 +120,9 @@
<!-- ── Right Panel: Globe ── -->
<div class="right-panel" id="rightPanel">
<button class="globe-toggle-handle" id="globeHandle" onclick="toggleGlobe()"></button>
<div id="globeContainer"></div>
<iframe id="globeIframe" src="/globe/?embed=1&data={{ selected_ds_id|default:'' }}" style="width:100%;height:100%;border:none;"></iframe>
</div>
<button class="globe-toggle-handle" id="globeHandle" onclick="toggleGlobe()"></button>
</div><!-- /main-layout -->
<!-- ── Bottom Detail Panel ── -->
@@ -137,6 +137,7 @@
<script>
const scatterData = {{ scatter_data_json|safe }};
const scatterCount = scatterData.length;
document.getElementById('pointCount').textContent = scatterCount + ' 个点';
const hasZ = {{ has_z|yesno:"true,false" }};
const colors = ['#4361ee','#f72585','#7209b7','#4cc9f0','#f8961e','#43aa8b','#f94144','#577590','#e9c46a','#9b5de5',
'#1f77b4','#ff7f0e','#2ca02c','#d62728','#9467bd','#8c564b','#e377c2','#7f7f7f','#bcbd22','#17becf',
@@ -144,8 +145,7 @@ const colors = ['#4361ee','#f72585','#7209b7','#4cc9f0','#f8961e','#43aa8b','#f9
'#393b79','#637939','#8c6d31','#843c39','#7b4173','#5254a3','#8ca252','#bd9e39','#ad494a','#a55194'];
let selectedCluster = null, selectedPoint = null;
let globeOpen = false, globeInitialized = false, globeRenderer = null;
let globeFlows = {{ globe_flows_json|safe }};
let globeOpen = false;
const clusterSizes = {{ cluster_sizes_json|safe }};
// Color pills after DOM ready
@@ -258,7 +258,10 @@ function selectCluster(label) {
document.getElementById('detailPanel').classList.add('open');
}
// Gray out globe if open
if (globeOpen && window.setGlobeFilter) window.setGlobeFilter(label);
if (globeOpen) {
var ifr = document.getElementById('globeIframe');
if (ifr && ifr.contentWindow) ifr.contentWindow.postMessage({ type: 'globe_filter', cluster_label: label }, '*');
}
}
function clearClusterFilter() {
@@ -267,15 +270,14 @@ function clearClusterFilter() {
document.querySelectorAll('.cluster-card').forEach(el => el.classList.remove('selected'));
document.querySelectorAll('.pill').forEach(el => el.classList.remove('active'));
document.getElementById('detailPanel').classList.remove('open');
if (globeOpen && window.setGlobeFilter) window.setGlobeFilter(null);
if (globeOpen) {
var ifr = document.getElementById('globeIframe');
if (ifr && ifr.contentWindow) ifr.contentWindow.postMessage({ type: 'globe_filter', cluster_label: null }, '*');
}
}
function closeDetail() { document.getElementById('detailPanel').classList.remove('open'); }
// ══════════════════════════════════════════════════════════
// GLOBE SIDEBAR
// ══════════════════════════════════════════════════════════
function toggleGlobe() {
globeOpen = !globeOpen;
document.getElementById('leftPanel').classList.toggle('globe-open', globeOpen);
@@ -283,122 +285,6 @@ function toggleGlobe() {
var handle = document.getElementById('globeHandle');
handle.innerHTML = globeOpen ? '▶' : '◀';
handle.classList.toggle('open', globeOpen);
if (globeOpen && !globeInitialized) initGlobe();
if (globeOpen) setTimeout(function() { if (globeRenderer) globeRenderer.setSize(document.getElementById('globeContainer').clientWidth, document.getElementById('globeContainer').clientHeight); }, 350);
}
// ══════════════════════════════════════════════════════════
// 3D THREE.JS GLOBE
// ══════════════════════════════════════════════════════════
function initGlobe() {
if (globeInitialized) return;
const container = document.getElementById('globeContainer');
if (!container) return;
const W = container.clientWidth || 500, H = container.clientHeight || 500;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0a1a);
const camera = new THREE.PerspectiveCamera(45, W / H, 0.1, 100);
camera.position.set(0, 0, 5.5);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(W, H);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
container.appendChild(renderer.domElement);
globeRenderer = renderer;
// Earth sphere
const earthGeo = new THREE.SphereGeometry(1.8, 64, 64);
var canvas = document.createElement('canvas');
canvas.width = 1; canvas.height = 1;
var ectx = canvas.getContext('2d');
ectx.fillStyle = '#1a3a5c'; ectx.fillRect(0, 0, 1, 1);
var tex = new THREE.CanvasTexture(canvas);
const earthMat = new THREE.MeshPhongMaterial({ map: tex, transparent: true, opacity: 0.9 });
const earth = new THREE.Mesh(earthGeo, earthMat);
scene.add(earth);
// Atmosphere glow
const glowGeo = new THREE.SphereGeometry(1.85, 64, 64);
const glowMat = new THREE.MeshBasicMaterial({ color: 0x224488, transparent: true, opacity: 0.15 });
scene.add(new THREE.Mesh(glowGeo, glowMat));
// Lights
scene.add(new THREE.AmbientLight(0x404060));
const sun = new THREE.DirectionalLight(0xffffff, 1);
sun.position.set(5, 3, 5);
scene.add(sun);
scene.add(new THREE.DirectionalLight(0x4488ff, 0.3).position.set(-5, -3, -5));
// Arc group
const arcGroup = new THREE.Group();
scene.add(arcGroup);
// Build arcs from flow data
const tlsColors = { 'TLSv1.3': 0x4cc9f0, 'TLSv1.2': 0x43aa8b, 'TLSv1.1': 0xf8961e, 'TLSv1.0': 0xf94144 };
var arcMeshes = [];
globeFlows.forEach(function(flow) {
const from = latLonToVec3(flow.slat, flow.slon, 1.8);
const to = latLonToVec3(flow.dlat, flow.dlon, 1.8);
if (!from || !to) return;
const mid = new THREE.Vector3().addVectors(from, to).multiplyScalar(0.5).normalize().multiplyScalar(2.6);
const curve = new THREE.QuadraticBezierCurve3(from, mid, to);
const tubeGeo = new THREE.TubeGeometry(curve, 20, 0.008, 4, false);
const color = tlsColors[flow.tls] || 0x888888;
const mat = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.5 });
const mesh = new THREE.Mesh(tubeGeo, mat);
mesh.userData = flow;
arcGroup.add(mesh);
arcMeshes.push(mesh);
});
// Stars
const starGeo = new THREE.BufferGeometry();
const starPos = new Float32Array(2000 * 3);
for (let i = 0; i < 2000 * 3; i++) starPos[i] = (Math.random() - 0.5) * 100;
starGeo.setAttribute('position', new THREE.BufferAttribute(starPos, 3));
scene.add(new THREE.Points(starGeo, new THREE.PointsMaterial({ color: 0xffffff, size: 0.05 })));
// Mouse rotation
let 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;
const dx = e.clientX - prevMouse.x, dy = e.clientY - prevMouse.y;
earth.rotation.y += dx * 0.005;
earth.rotation.x += dy * 0.005;
arcGroup.rotation.y = earth.rotation.y;
arcGroup.rotation.x = earth.rotation.x;
prevMouse = { x: e.clientX, y: e.clientY };
});
// Globe filter function (called when cluster selected)
window.setGlobeFilter = function(clusterLabel) {
arcMeshes.forEach(function(mesh) {
if (clusterLabel === null) { mesh.material.opacity = 0.5; mesh.material.color.setHex(tlsColors[mesh.userData.tls] || 0x888888); }
else { mesh.material.opacity = 0.08; mesh.material.color.setHex(0x666666); }
});
};
function animate() {
requestAnimationFrame(animate);
// Auto-rotate when not dragging
if (!isDragging) { earth.rotation.y += 0.002; arcGroup.rotation.y = earth.rotation.y; }
renderer.render(scene, camera);
}
animate();
globeInitialized = true;
}
function latLonToVec3(lat, lon, r) {
if (lat == null || lon == null) return null;
const phi = (90 - lat) * Math.PI / 180;
const 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));
}
// ══════════════════════════════════════════════════════════
@@ -524,4 +410,4 @@ const geoData = {{ geo_data_json|safe }};
// Initial draw
drawScatter('scatterChart', scatterData, 'UMAP-1', 'UMAP-2', null);
</script>
{% endblock %}
{% endblock %}
+139 -16
View File
@@ -1,38 +1,91 @@
{% extends 'base.html' %}
{% block title %}Run #{{ run.display_id }} - TLS Analyzer{% endblock %}
{% block title %}运行 #{{ run.display_id }} - 天璇{% endblock %}
{% block content %}
<style>
.svd-card { background:#f8f9fa; border:1px solid #e0e0e0; border-radius:8px; padding:0.8rem; }
.svd-card h4 { margin:0 0 0.3rem; font-size:0.9rem; }
.var-bar { height:8px; border-radius:4px; background:#4361ee; margin:0.2rem 0; transition:width 0.3s; }
.feat-tag { display:inline-block; background:#e8f0fe; padding:0.1rem 0.4rem; border-radius:3px; font-size:0.75rem; margin:0.1rem; }
</style>
<div class="card">
<h2>Run #{{ run.display_id }} — {{ run.created_at|date:"Y-m-d H:i" }}</h2>
<p>Status: <span class="badge {% if run.status == 'completed' %}badge-success{% elif run.status == 'failed' %}badge-danger{% else %}badge-info{% endif %}">{{ run.get_status_display }}</span></p>
<p>CSV: <code>{% if run.sqlite_table %}已存储至SQLite: {{ run.sqlite_table }}{% else %}{{ run.csv_glob }}{% endif %}</code></p>
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:0.5rem;">
<div>
<h2 style="margin:0;">运行 #{{ run.display_id }}</h2>
<p style="margin:0.25rem 0 0;color:#666;font-size:0.9rem;">
{{ run.created_at|date:"Y-m-d H:i" }}
— 状态: <span class="badge {% if run.status == 'completed' %}badge-success{% elif run.status == 'failed' %}badge-danger{% else %}badge-info{% endif %}">{{ run.get_status_display }}</span>
</p>
</div>
<a href="{% url 'analysis:cluster_overview' run.display_id %}" class="btn btn-primary" style="font-size:0.85rem;">聚类概览 →</a>
</div>
{% if run.error_message %}
<pre style="background:#1a1a2e;color:#e0e0e0;padding:0.5rem;border-radius:4px;font-size:0.78rem;margin-top:0.5rem;max-height:200px;overflow:auto;"><code>{{ run.error_message }}</code></pre>
{% endif %}
</div>
<div class="grid-3">
<div class="card" style="text-align:center;">
<div class="stat">{{ run.total_flows|default:"0" }}</div>
<div class="stat-label">Total Flows</div>
<div class="stat-label">总流数</div>
</div>
<div class="card" style="text-align:center;">
<div class="stat">{{ run.entity_count|default:"0" }}</div>
<div class="stat-label">Entities</div>
<div class="stat-label">行数</div>
</div>
<div class="card" style="text-align:center;">
<div class="stat">{{ run.cluster_count|default:"0" }}</div>
<div class="stat-label">Clusters</div>
<div class="stat-label">聚类数</div>
</div>
</div>
<!-- ── Global SVD Features ── -->
{% if svd_features %}
<div class="card">
<h2>Clusters</h2>
<a href="{% url 'analysis:cluster_overview' run.display_id %}" class="btn btn-primary" style="margin-bottom:1rem;">View Cluster Overview</a>
<div style="display:flex;justify-content:space-between;align-items:center;">
<h3>🔬 全域 SVD 特征分析</h3>
<span style="font-size:0.8rem;color:#888;">基于 {{ run.total_flows }} 行数据</span>
</div>
<p style="font-size:0.82rem;color:#666;margin-top:0.3rem;">奇异值分解 (TruncatedSVD) 降维后的各主成分解释方差比例与贡献特征。</p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.75rem;margin-top:0.75rem;">
{% for svd in svd_features %}
<div class="svd-card">
<h4>PC{{ svd.component }}</h4>
<div style="display:flex;justify-content:space-between;font-size:0.78rem;color:#555;">
<span>解释方差: {{ svd.explained_var }}%</span>
<span>累积: {{ svd.cumulative_var }}%</span>
</div>
<div class="var-bar" style="width:{{ svd.explained_var }}%;"></div>
<div style="margin-top:0.4rem;">
{% for f in svd.top_features %}
<span class="feat-tag">{{ f.feature }}: {{ f.strength }}</span>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- ── LLM 自动分析流程 ── -->
{% if run.run_type == 'auto' and llm_timeline_json %}
<div class="card">
<h3>🧠 LLM 自动分析流程</h3>
<div id="llmTimeline"></div>
</div>
{% endif %}
<!-- ── 聚类列表 ── -->
<div class="card">
<h3>📊 聚类结果</h3>
{% if clusters %}
<table>
<thead>
<tr>
<th>Cluster</th>
<th>Size</th>
<th>Proportion</th>
<th>Silhouette</th>
<th></th>
<th>大小</th>
<th>比例</th>
<th>轮廓系数</th>
<th></th>
</tr>
</thead>
@@ -43,13 +96,83 @@
<td>{{ c.size }}</td>
<td>{{ c.proportion|floatformat:2 }}</td>
<td>{{ c.silhouette_score|floatformat:4|default:"-" }}</td>
<td><a href="{% url 'analysis:cluster_detail' run.display_id c.cluster_label %}" class="btn btn-primary">Detail</a></td>
<td><a href="{% url 'analysis:cluster_detail' run.display_id c.cluster_label %}" class="btn btn-primary">详情</a></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state"><p>No clusters yet.</p></div>
<div class="empty-state"><p>无聚类结果</p></div>
{% endif %}
</div>
{% endblock %}
{% if run.run_type == 'auto' and llm_timeline_json %}
<script>
const llmData = {{ llm_timeline_json|safe }};
(function() {
const container = document.getElementById('llmTimeline');
if (!container) return;
function escapeHtml(s) { if (s==null) return ''; return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function prettyJson(o) { try { return JSON.stringify(o,null,2); } catch(e) { return String(o); } }
var thinkingByStep = {}, thinkOrder = [];
if (llmData.thinking) {
var parts = llmData.thinking.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 toolCalls = llmData.tool_calls || [];
var toolMap = {};
for (var ti = 0; ti < toolCalls.length; ti++) {
var tc = toolCalls[ti];
if (!toolMap[tc.step]) toolMap[tc.step] = [];
toolMap[tc.step].push(tc);
}
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 stp = sortedSteps[si];
var hdr = document.createElement('div');
hdr.style.cssText = 'font-weight:700;font-size:0.85rem;color:#4361ee;padding:0.6rem 0 0.3rem;border-top:1px solid #eee;';
hdr.textContent = '步骤 ' + stp;
container.appendChild(hdr);
if (thinkingByStep[stp] !== undefined) {
var tdiv = document.createElement('div');
tdiv.style.cssText = 'background:#eef2ff;padding:0.5rem;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;';
tdiv.innerHTML = '<span style="font-weight:600;color:#4361ee;font-size:0.75rem;display:block;margin-bottom:0.25rem;">💭 思考</span>' + escapeHtml(thinkingByStep[stp]);
container.appendChild(tdiv);
}
if (toolMap[stp]) {
for (var tj = 0; tj < toolMap[stp].length; tj++) {
var tc2 = toolMap[stp][tj];
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(tc2.input);
var outputStr = prettyJson(tc2.output);
card.innerHTML =
'<div onclick="this.classList.toggle(\'open\');var b=this.nextElementSibling;if(b)b.classList.toggle(\'open\');" style="padding:0.4rem 0.8rem;cursor:pointer;display:flex;justify-content:space-between;align-items:center;background:#f8f9fa;user-select:none;">' +
'<span><span style="background:#43aa8b;color:#fff;border-radius:3px;padding:0 5px;font-size:0.7rem;font-weight:600;margin-right:6px;">🔧</span>' +
'<span style="font-weight:600;font-size:0.8rem;">' + escapeHtml(tc2.name||'?') + '</span></span>' +
'<span style="font-size:0.75rem;color:#888;"></span></div>' +
'<div style="display:none;padding:0.5rem 0.8rem;background:#fafbfc;">' +
'<div style="font-weight:600;font-size:0.72rem;color:#555;margin-bottom:0.2rem;">输入:</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;"><code>' + escapeHtml(inputStr) + '</code></pre>' +
'<div style="font-weight:600;font-size:0.72rem;color:#555;margin-bottom:0.2rem;">输出:</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>' +
'</div>';
container.appendChild(card);
}
}
}
})();
</script>
{% endif %}
{% endblock %}
+43 -71
View File
@@ -428,72 +428,6 @@ async function applyFilter() {
}
// ── Dataset table rendering ──
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.badge_class === 'ready';
if (filter === 'analyzing') return ds.badge_class === 'analyzing';
if (filter === 'completed') return ds.badge_class === 'completed';
if (filter === 'failed') return ds.badge_class === '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></th><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.badge_class || '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 : '?';
const source = ds.sqlite_table ? ('SQLite: ' + ds.sqlite_table) : (ds.csv_glob || '?');
const checked = (selectedRunId === String(ds.dataset_id)) ? 'checked' : '';
html += '<tr data-ds-id="' + ds.dataset_id + '" onclick="selectRow(\'' + ds.dataset_id + '\')" class="' + (selectedRunId === String(ds.dataset_id) ? 'selected' : '') + '">' +
'<td><input type="radio" name="auto_run_id" value="' + ds.dataset_id + '" ' + checked + ' onclick="event.stopPropagation();selectRow(\'' + ds.dataset_id + '\')"></td>' +
'<td style="font-weight:600;">#' + dispId + '</td>' +
'<td><span class="status-badge ' + badgeClass + '">' + label + '</span></td>' +
'<td>' + rowCount + '</td>' +
'<td>' + fileCount + '</td>' +
'<td style="max-width:180px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + escapeHtml(source) + '</td>' +
'</tr>';
});
html += '</tbody></table>';
container.innerHTML = html;
}
function selectRow(dsId) {
selectedRunId = String(dsId);
// Update radio buttons and row highlight
document.querySelectorAll('#datasetTable input[name="auto_run_id"]').forEach(r => {
r.checked = (r.value === String(dsId));
});
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
document.querySelectorAll('#statusFilter .filter-btn').forEach(btn => {
btn.addEventListener('click', function() {
setDatasetFilter(this.dataset.filter);
});
});
buildDatasetTable('all');
// ── Timeline rendering ──
let statusPoll = null;
@@ -502,6 +436,44 @@ function escapeHtml(str) {
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
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(/^&gt;\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;
}
function prettyJson(obj) {
try { return JSON.stringify(obj, null, 2); }
catch(e) { return String(obj); }
@@ -596,18 +568,18 @@ function renderTimeline(entries) {
if (entry.type === 'thinking') {
// 思考条目 — 浅蓝背景文本块
const 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:200px;overflow:auto;margin-bottom:0.3rem;white-space:pre-wrap;border:1px solid #d0d8f0;';
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;">💭 思考</span>' +
escapeHtml(entry.text);
renderMarkdown(entry.text);
container.appendChild(div);
} else if (entry.type === 'answer') {
// 回答条目 — 绿色背景
// 回答条目 — 绿色背景,支持 Markdown
const div = document.createElement('div');
div.style.cssText = 'background:#e8f5e9;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;white-space:pre-wrap;border:1px solid #c8e6c9;';
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;">✅ 回答</span>' +
escapeHtml(entry.text);
renderMarkdown(entry.text);
container.appendChild(div);
} else if (entry.type === 'tool') {
const card = document.createElement('div');
+141
View File
@@ -0,0 +1,141 @@
{% load static %}
<!DOCTYPE html>
<html lang="zh-hans">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>3D Globe</title>
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body { background:#0a0a1a; overflow:hidden; width:100vw; height:100vh; }
#globeContainer { width:100%; height:100%; }
</style>
</head>
<body>
<div id="globeContainer"></div>
<script src="{% static 'tianxuan/three.min.js' %}"></script>
<script>
const geoFlows = {{ geo_flows|safe }};
const 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('globeContainer').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);
})();
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

+11 -13
View File
@@ -314,8 +314,10 @@ def run_llm_pipeline(dataset_id: str, entity_col: str = "",
# Track tool call history for smarter guidance
called_tools: set[str] = set()
consecutive_errors = 0
step = 0
max_steps = 999
for step in range(25):
while step < max_steps:
resp = call_llm(msgs, config)
if resp is None:
if callback:
@@ -328,6 +330,9 @@ def run_llm_pipeline(dataset_id: str, entity_col: str = "",
msg = resp["choices"][0]["message"]
content = msg.get("content", "")
# DeepSeek reasoning fallback (reasoning_content for chain-of-thought)
if not content:
content = msg.get("reasoning_content", "") or ""
# Pass thinking text to callback
if callback:
@@ -365,18 +370,9 @@ def run_llm_pipeline(dataset_id: str, entity_col: str = "",
logger.info(f'[TOOL] step={step} name={name} args={json.dumps(args)[:200]}')
try:
async def _call_with_timeout():
return await asyncio.wait_for(
handle_call(name, args),
timeout=120,
)
result = asyncio.run(_call_with_timeout())
except asyncio.TimeoutError:
result = {'error': f'Tool {name} timed out after 120s'}
logger.warning(f'[TOOL] {name} timed out')
result = asyncio.run(handle_call(name, args))
except Exception as e:
result = {'error': f'MCP tool error: {e}'}
logger.error(f'[TOOL] {name} error: {e}')
if callback:
callback(step, name, result, meta={"args": args})
@@ -414,6 +410,8 @@ def run_llm_pipeline(dataset_id: str, entity_col: str = "",
"content": f"Tool '{name}' failed: {result['error'][:200]}. Call '{suggested}' to diagnose, then retry with corrected parameters.",
})
step += 1
if callback:
callback(25, "max_steps", None)
return {"error": "Reached max 25 tool calls", "steps": 25}
callback(max_steps, "max_steps", None)
return {"error": f"Reached max {max_steps} tool calls", "steps": max_steps}