From c54917791ecd4387384d6b7458bfbb44eea99bb4 Mon Sep 17 00:00:00 2001
From: PM-pinou <2504420230@qq.com>
Date: Fri, 24 Jul 2026 13:43:49 +0800
Subject: [PATCH] cleanup: remove dead backup files, orphaned static files, fix
merge conflict markers
---
analysis/tool_registry_backup.py | 3368 ------------------------------
analysis/views/clustering.py | 3 +-
analysis/views_backup.py | 2308 --------------------
scripts/fix_merge_conflict.py | 11 +
static/tianxuan/cluster.css | 133 --
static/tianxuan/globe_embed.js | 129 --
static/tianxuan/markdown.js | 43 -
static/tianxuan/timeline.js | 162 --
8 files changed, 12 insertions(+), 6145 deletions(-)
delete mode 100644 analysis/tool_registry_backup.py
delete mode 100644 analysis/views_backup.py
create mode 100644 scripts/fix_merge_conflict.py
delete mode 100644 static/tianxuan/cluster.css
delete mode 100644 static/tianxuan/globe_embed.js
delete mode 100644 static/tianxuan/markdown.js
delete mode 100644 static/tianxuan/timeline.js
diff --git a/analysis/tool_registry_backup.py b/analysis/tool_registry_backup.py
deleted file mode 100644
index cb4e805..0000000
--- a/analysis/tool_registry_backup.py
+++ /dev/null
@@ -1,3368 +0,0 @@
-"""Tool definitions and handler functions for the TLS Analyzer MCP server.
-
-Each tool is defined by a :class:`Tool ` metadata object and
-an async handler function. Handlers interact with the :class:`SessionStore`
-singleton and the Django ORM.
-
-All 12 tools:
- 1. load_data 7. filter_and_cluster
- 2. profile_data 8. build_entity_profiles
- 3. filter_data 9. export_results
- 4. preprocess_data 10. list_datasets
- 5. run_clustering 11. drop_dataset
- 6. evaluate_clustering 12. clone_dataset
-"""
-import json
-import os
-from pathlib import Path
-from typing import Any, Optional
-
-import numpy as np
-import polars as pl
-from mcp.types import Tool
-
-from .data_loader import load_csv_directory
-from .data_profiler import profile_dataset as _profile_dataset
-from .session_store import SessionStore, _generate_id
-from .profile_util import profile
-
-
-# ═══════════════════════════════════════════════════════════════════════
-# Tool metadata
-# ═══════════════════════════════════════════════════════════════════════
-
-def _json_size(obj: Any) -> int:
- return len(json.dumps(obj, default=str, ensure_ascii=False))
-
-
-_MAX_RESPONSE_KB = 64
-_MAX_RESPONSE_BYTES = _MAX_RESPONSE_KB * 1024
-
-
-def _truncate_response(result: dict, max_kb: int = _MAX_RESPONSE_KB) -> dict:
- """Truncate large lists/keys in *result* to stay under *max_kb*."""
- if _json_size(result) <= max_kb * 1024:
- result.setdefault('truncated', False)
- return result
-
- # Drop verbose keys first
- for costly_key in ('column_stats', 'features', 'correlation_matrix',
- 'labels', 'cluster_labels'):
- if costly_key in result and isinstance(result[costly_key], dict):
- result[costly_key] = {'__truncated__': True,
- 'key_count': len(result[costly_key])}
- elif costly_key in result and isinstance(result[costly_key], list):
- lst = result[costly_key]
- if len(lst) > 10:
- result[costly_key] = lst[:10] + [{'__truncated__': True,
- 'omitted': len(lst) - 10}]
- result['truncated'] = True
- return result
-
-
-def get_tools_meta() -> list[Tool]:
- """Return the list of Tool metadata objects for MCP server registration.
-
- Call this from :meth:`TLSAnalyzerMCPServer._setup_tools`.
- """
- return [
- Tool(
- name="load_data",
- description=(
- "加载匹配glob模式的CSV文件。自动检测每个文件的BOM,验证schema一致性,"
- "纵向合并,并将结果作为惰性数据集存储在会话存储中。"
- ),
- inputSchema={
- "type": "object",
- "properties": {
- "csv_glob": {
- "type": "string",
- "description": "Glob pattern for CSV files (e.g. 'data/*.csv')",
- },
- "config_path": {
- "type": "string",
- "description": "Optional path to a YAML config file",
- },
- "encoding": {
- "type": "string",
- "description": "Fallback encoding when no BOM is found",
- "default": "utf-8",
- },
- "delimiter": {
- "type": "string",
- "description": "Column delimiter",
- "default": ",",
- },
- "schema_strict": {
- "type": "boolean",
- "description": "When True, raise on column mismatch; when False, merge leniently with nulls",
- "default": False,
- },
- "recursive": {
- "type": "boolean",
- "description": "When True, use recursive glob for **/*.csv patterns",
- "default": False,
- },
- },
- "required": ["csv_glob"],
- },
- ),
- Tool(
- name="profile_data",
- description=(
- "计算数据集的每列统计信息和数值相关矩阵。结果自动截断至约16 KB以"
- "节省LLM上下文窗口空间。"
- ),
- inputSchema={
- "type": "object",
- "properties": {
- "dataset_id": {
- "type": "string",
- "description": "Dataset identifier from a previous load_data call",
- },
- "sample_size": {
- "type": "integer",
- "description": "Maximum rows to profile (sampled if larger)",
- "default": 1000,
- },
- "columns": {
- "type": "array",
- "items": {"type": "string"},
- "description": "Columns to profile (default: all)",
- },
- },
- "required": ["dataset_id"],
- },
- ),
- Tool(
- name="filter_data",
- description=(
- "对数据集应用列级过滤器,将结果存储为新的派生数据集。支持eq/neq/gt/gte/"
- "lt/lte/contains/not_contains/in/not_in/is_null/is_not_null运算符。"
- ),
- inputSchema={
- "type": "object",
- "properties": {
- "dataset_id": {
- "type": "string",
- "description": "Source dataset identifier",
- },
- "filters": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "column": {"type": "string"},
- "op": {
- "type": "string",
- "enum": [
- "eq", "neq", "gt", "gte", "lt", "lte",
- "contains", "not_contains",
- "in", "not_in",
- "is_null", "is_not_null",
- ],
- },
- "value": {"description": "Operand value"},
- },
- "required": ["column", "op"],
- },
- "description": "List of filter expressions",
- },
- "logic": {
- "type": "string",
- "enum": ["and", "or"],
- "description": "Combine filters with AND or OR logic",
- "default": "and",
- },
- },
- "required": ["dataset_id", "filters"],
- },
- ),
- Tool(
- name="preprocess_data",
- description=(
- "预处理选定列:标准化(StandardScaler)、独热编码、缺失值填充和列删除。"
- ),
- inputSchema={
- "type": "object",
- "properties": {
- "dataset_id": {
- "type": "string",
- "description": "Source dataset identifier",
- },
- "columns": {
- "type": "array",
- "items": {"type": "string"},
- "description": "Columns to preprocess",
- },
- "config": {
- "type": "object",
- "properties": {
- "standardize": {
- "type": "boolean",
- "description": "Apply StandardScaler to numeric columns",
- "default": False,
- },
- "onehot": {
- "type": "boolean",
- "description": "One-hot encode categorical columns",
- "default": False,
- },
- "fillna": {
- "type": "string",
- "enum": ["mean", "median", "zero", "drop"],
- "description": "Missing value strategy",
- },
- "drop": {
- "type": "array",
- "items": {"type": "string"},
- "description": "Columns to drop entirely",
- },
- },
- },
- },
- "required": ["dataset_id", "columns"],
- },
- ),
- Tool(
- name="run_clustering",
- description=(
- "对选定列执行聚类。使用HDBSCAN(默认)或KMeans。特征在聚类前进行"
- "标准化缩放。返回聚类标签和质量指标。"
- ),
- inputSchema={
- "type": "object",
- "properties": {
- "dataset_id": {
- "type": "string",
- "description": "Dataset identifier",
- },
- "cluster_columns": {
- "type": "array",
- "items": {"type": "string"},
- "description": "Columns to use as clustering features",
- },
- "algorithm": {
- "type": "string",
- "enum": ["agglomerative", "hdbscan", "kmeans"],
- "description": "Clustering algorithm (default: agglomerative/Ward)",
- "default": "agglomerative",
- },
- "params": {
- "type": "object",
- "description": (
- "Algorithm parameters. Agglomerative: n_clusters (5), metric ('euclidean'), "
- "linkage ('ward'). HDBSCAN: min_cluster_size (5), "
- "min_samples (None), metric ('euclidean'). "
- "KMeans: n_clusters (3), random_state (42)."
- ),
- },
- "random_state": {
- "type": "integer",
- "description": "Random seed for reproducibility",
- "default": 42,
- },
- },
- "required": ["dataset_id", "cluster_columns"],
- },
- ),
- Tool(
- name="evaluate_clustering",
- description=(
- "评估数据集的聚类结果。当前支持轮廓系数(silhouette)、Davies-Bouldin指数、"
- "Calinski-Harabasz指数、噪声比(noise_ratio)和簇大小(cluster_sizes)指标。"
- ),
- inputSchema={
- "type": "object",
- "properties": {
- "cluster_result_id": {
- "type": "string",
- "description": "Result ID from run_clustering",
- },
- "dataset_id": {
- "type": "string",
- "description": "Original dataset for metric computation",
- },
- "metrics": {
- "type": "array",
- "items": {"type": "string"},
- "description": (
- "Metrics to compute. Options: silhouette, "
- "davies_bouldin, calinski_harabasz, noise_ratio, "
- "cluster_sizes (default: all)"
- ),
- },
- },
- "required": ["cluster_result_id", "dataset_id"],
- },
- ),
- Tool(
- name="extract_features",
- description=(
- "提取每个聚类的区分特征并自动保存到Django ORM(ClusterFeature模型)。"
- "使用z-score或ANOVA对每个聚类的特征重要性进行排序。"
- ),
- inputSchema={
- "type": "object",
- "properties": {
- "dataset_id": {
- "type": "string",
- "description": "Dataset containing the feature columns",
- },
- "cluster_result_id": {
- "type": "string",
- "description": "Result ID from run_clustering",
- },
- "top_k": {
- "type": "integer",
- "description": "Top distinguishing features per cluster",
- "default": 10,
- },
- "method": {
- "type": "string",
- "enum": ["zscore", "anova"],
- "description": "Method to rank feature importance",
- "default": "zscore",
- },
- "save_to_db": {
- "type": "boolean",
- "description": "Save features to Django ORM if True",
- "default": True,
- },
- },
- "required": ["dataset_id", "cluster_result_id"],
- },
- ),
- Tool(
- name="export_results",
- description=(
- "将数据集或聚类结果以CSV或Parquet格式导出到磁盘。数据集物化后写入;"
- "聚类结果序列化为JSON。"
- ),
- inputSchema={
- "type": "object",
- "properties": {
- "result_id": {
- "type": "string",
- "description": "Dataset ID or cluster result ID to export",
- },
- "output_path": {
- "type": "string",
- "description": "Output file path or directory",
- },
- "format": {
- "type": "string",
- "enum": ["csv", "parquet", "json"],
- "description": "Output format",
- "default": "csv",
- },
- "overwrite": {
- "type": "boolean",
- "description": "Overwrite existing file",
- "default": False,
- },
- },
- "required": ["result_id", "output_path"],
- },
- ),
- Tool(
- name="list_datasets",
- description="列出会话存储中的所有活跃数据集和结果。",
- inputSchema={
- "type": "object",
- "properties": {},
- },
- ),
- Tool(
- name="drop_dataset",
- description=(
- "从会话存储中移除数据集(或结果)并通过垃圾回收释放内存。"
- ),
- inputSchema={
- "type": "object",
- "properties": {
- "dataset_id": {
- "type": "string",
- "description": "Dataset or result identifier to remove",
- },
- },
- "required": ["dataset_id"],
- },
- ),
- Tool(
- name="clone_dataset",
- description=(
- "浅拷贝数据集。由于LazyFrame是查询计划,不会复制实际数据。适用于分支分析。"
- ),
- inputSchema={
- "type": "object",
- "properties": {
- "dataset_id": {
- "type": "string",
- "description": "Source dataset identifier",
- },
- },
- "required": ["dataset_id"],
- },
- ),
- Tool(
- name="filter_and_cluster",
- description=(
- "一步完成:先应用列级过滤器,再对过滤结果执行聚类。支持eq/neq/gt/gte/lt/lte/"
- "contains/not_contains/in/not_in/is_null/is_not_null运算符和HDBSCAN/KMeans聚类。"
- "自动选择数值列作为聚类特征。将过滤后的数据集和聚类结果都存储在会话中。"
- ),
- inputSchema={
- "type": "object",
- "properties": {
- "dataset_id": {
- "type": "string",
- "description": "Source dataset identifier",
- },
- "filters": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "column": {"type": "string"},
- "op": {
- "type": "string",
- "enum": [
- "eq", "neq", "gt", "gte", "lt", "lte",
- "contains", "not_contains",
- "in", "not_in",
- "is_null", "is_not_null",
- ],
- },
- "value": {"description": "Operand value"},
- },
- "required": ["column", "op"],
- },
- "description": "List of filter expressions",
- },
- "logic": {
- "type": "string",
- "enum": ["and", "or"],
- "description": "Combine filters with AND or OR logic",
- "default": "and",
- },
- "algorithm": {
- "type": "string",
- "enum": ["agglomerative", "hdbscan", "kmeans"],
- "description": "Clustering algorithm (default: agglomerative/Ward)",
- "default": "agglomerative",
- },
- "params": {
- "type": "object",
- "description": (
- "Algorithm parameters. Agglomerative: n_clusters (5), metric ('euclidean'), "
- "linkage ('ward'). HDBSCAN: min_cluster_size (5), "
- "min_samples (None), metric ('euclidean'). "
- "KMeans: n_clusters (3), random_state (42)."
- ),
- },
- },
- "required": ["dataset_id", "filters"],
- },
- ),
- Tool(
- name="build_entity_profiles",
- description=(
- "按实体列(源IP、SNI等)分组原始流数据。自动检测实体列(如 src_ip, "
- "dst_ip, sni, host, mac, domain),按列分组并计算每个实体的聚合特征:"
- "流数量、唯一端口数、TLS版本分布、字节总量、唯一目标数。"
- "生成的数据集每个实体一行。结果存储在会话中,可供后续 compute_scores 使用。"
- ),
- inputSchema={
- "type": "object",
- "properties": {
- "dataset_id": {
- "type": "string",
- "description": "Dataset ID from load_data or profile_data",
- },
- "entity_column": {
- "type": "string",
- "description": "Specific entity column to group by (e.g. 'src_ip')",
- },
- "entity_columns": {
- "type": "array",
- "items": {"type": "string"},
- "description": "Multiple entity columns to group by",
- },
- "auto_detect": {
- "type": "boolean",
- "description": "Auto-detect entity columns (IP, SNI, host, MAC, domain)",
- "default": True,
- },
- },
- "required": ["dataset_id"],
- },
- ),
- Tool(
- name="compute_scores",
- description=(
- "在实体数据上计算自适应代理/异常/威胁评分。从数据自动学习"
- "特征权重,计算加权代理评分(proxy_score)、基于IQR的风险等级"
- "(normal/watch/suspicious/critical)和威胁评分(threat_score)。"
- "返回评分摘要与阈值。"
- ),
- inputSchema={
- "type": "object",
- "properties": {
- "dataset_id": {
- "type": "string",
- "description": "Entity dataset ID from filter_and_cluster or a scored dataset",
- },
- },
- "required": ["dataset_id"],
- },
- ),
- Tool(
- name="detect_anomalies",
- description=(
- "对已评分的实体数据运行孤立森林(Isolation Forest)异常检测。识别偏离"
- "正常模式的实体。分块处理以应对大数据集。返回异常实体ID列表。"
- ),
- inputSchema={
- "type": "object",
- "properties": {
- "dataset_id": {
- "type": "string",
- "description": "Scored entity dataset ID (after compute_scores)",
- },
- "contamination": {
- "type": "number",
- "description": "Expected proportion of anomalies (0.0-0.5)",
- "default": 0.05,
- },
- },
- "required": ["dataset_id"],
- },
- ),
- Tool(
- name="visualize_anomalies",
- description=(
- "为高风险实体生成UMAP-2D嵌入和聚类可视化数据。按风险等级和聚类着色。"
- "返回用于前端渲染的散点图JSON数据。"
- ),
- inputSchema={
- "type": "object",
- "properties": {
- "dataset_id": {
- "type": "string",
- "description": "Scored entity dataset ID (after compute_scores)",
- },
- },
- "required": ["dataset_id"],
- },
- ),
- # ── Edge-case / Diagnostic tools (unexpected situations) ────────
- Tool(
- name="validate_data",
- description=(
- "诊断工具:检查数据质量问题——文件间schema一致性、缺失值比例、列类型冲突、"
- "编码问题和空列。当流水线步骤因schema错误失败,或怀疑CSV数据损坏/格式异常时使用。"
- ),
- inputSchema={
- "type": "object",
- "properties": {
- "dataset_id": {"type": "string", "description": "Dataset ID from load_data"},
- "fix_issues": {"type": "boolean", "description": "Auto-fix schema inconsistencies", "default": False},
- },
- "required": ["dataset_id"],
- },
- ),
- Tool(
- name="explore_distributions",
- description=(
- "诊断工具:计算每列分布统计(唯一值计数、空值比例、最小/最大/均值/标准差、"
- "前10高频值、直方图分箱)。当聚类产生意外结果、实体检测失败,或需要在选择"
- "分析策略前了解数据时使用。"
- ),
- inputSchema={
- "type": "object",
- "properties": {
- "dataset_id": {"type": "string", "description": "Dataset ID from load_data or entity data"},
- "columns": {"type": "array", "items": {"type": "string"},
- "description": "Specific columns to analyze (default: auto-detect numeric)"},
- "max_sample": {"type": "integer", "description": "Max rows to sample", "default": 10000},
- },
- "required": ["dataset_id"],
- },
- ),
- Tool(
- name="find_outliers",
- description=(
- "诊断工具:使用IQR方法检测数值列中的统计异常值。返回任何数值列超过中位数3倍IQR的行。"
- "当怀疑数据质量问题或极端值偏离分析时使用。"
- ),
- inputSchema={
- "type": "object",
- "properties": {
- "dataset_id": {"type": "string", "description": "Dataset ID from load_data"},
- "columns": {"type": "array", "items": {"type": "string"},
- "description": "Columns to check (default: all numeric)"},
- "threshold": {"type": "number", "description": "IQR multiplier (default 3.0)", "default": 3.0},
- },
- "required": ["dataset_id"],
- },
- ),
- Tool(
- name="diagnose_clustering",
- description=(
- "诊断工具:当聚类产生太少/太多聚类、全部为噪声或轮廓系数低时——诊断原因。"
- "检查:特征方差、相关矩阵、特征降维分析、数据稀疏性,并建议参数调整"
- "(min_cluster_size、算法选择、特征选择)。"
- ),
- inputSchema={
- "type": "object",
- "properties": {
- "dataset_id": {"type": "string", "description": "Entity dataset ID"},
- "cluster_result_id": {"type": "string", "description": "Cluster result ID from previous run_clustering"},
- },
- "required": ["dataset_id", "cluster_result_id"],
- },
- ),
- Tool(
- name="compare_datasets",
- description=(
- "诊断工具:比较两个数据集(如处理前/后,或两次不同运行)。报告schema差异、"
- "行数变化、列值分布偏移。用于验证处理步骤未损坏数据或比较不同运行的结果。"
- ),
- inputSchema={
- "type": "object",
- "properties": {
- "dataset_id_a": {"type": "string", "description": "First dataset ID"},
- "dataset_id_b": {"type": "string", "description": "Second dataset ID"},
- },
- "required": ["dataset_id_a", "dataset_id_b"],
- },
- ),
- Tool(
- name="export_debug_sample",
- description=(
- "诊断工具:导出原始数据样本为JSON用于外部调试。当流水线深处发生错误且需要"
- "检查实际数据值时使用。返回前N行所有列。"
- ),
- inputSchema={
- "type": "object",
- "properties": {
- "dataset_id": {"type": "string", "description": "Dataset ID"},
- "max_rows": {"type": "integer", "description": "Rows to export", "default": 50},
- },
- "required": ["dataset_id"],
- },
- ),
- Tool(
- name="repair_schema",
- description=(
- "诊断工具:通过对齐列名(不区分大小写合并、下划线/连字符规范化)并用空值填充"
- "缺失列,尝试修复文件间的schema不匹配。当load_data因CSV文件具有不同列集而"
- "失败时使用。"
- ),
- inputSchema={
- "type": "object",
- "properties": {
- "dataset_id": {"type": "string", "description": "Failed dataset ID from load_data"},
- },
- "required": ["dataset_id"],
- },
- ),
- # ── Read-only analysis tools (LLM deep-dive, no side effects) ────
- Tool(
- name="analyze_patterns",
- description=(
- "分析工具:检测流量模式——top源/目标IP、端口分布、TLS版本分布、协议混合。"
- "返回汇总统计。只读,不修改数据。用于了解高层流量结构。"
- ),
- inputSchema={
- "type": "object",
- "properties": {
- "dataset_id": {"type": "string", "description": "Dataset ID from load_data"},
- "top_n": {"type": "integer", "description": "Number of top items per category", "default": 10},
- },
- "required": ["dataset_id"],
- },
- ),
- Tool(
- name="analyze_temporal",
- description=(
- "分析工具:分析随时间变化的流量——小时/日流量计数、繁忙时段、空闲时段。"
- "需要时间戳列。只读。用于了解时间相关模式和周期性。"
- ),
- inputSchema={
- "type": "object",
- "properties": {
- "dataset_id": {"type": "string", "description": "Dataset ID from load_data"},
- "timestamp_column": {"type": "string", "description": "Column name for timestamps (auto-detected if omitted)"},
- },
- "required": ["dataset_id"],
- },
- ),
- Tool(
- name="analyze_fft",
- description=(
- "FFT频谱分析——从时间戳列提取周期模式和频率特征。适用于检测时间周期性"
- "(如每日/每周流量模式)。返回频率、幅度、top周期、频谱质心、周期性评分。"
- "只读,无副作用。"
- ),
- inputSchema={
- "type": "object",
- "properties": {
- "dataset_id": {
- "type": "string",
- "description": "Dataset ID from load_data",
- },
- "timestamp_column": {
- "type": "string",
- "description": "Column name containing timestamps or numeric time values",
- },
- "top_n": {
- "type": "integer",
- "description": "Number of top frequency peaks to return",
- "default": 5,
- },
- },
- "required": ["dataset_id", "timestamp_column"],
- },
- ),
- Tool(
- name="analyze_tls_health",
- description=(
- "分析工具:TLS安全态势评估——检查过时的TLS版本(1.0/1.1)、弱加密套件、"
- "证书问题、SNI异常。只读。用于评估流量的TLS安全性。"
- ),
- inputSchema={
- "type": "object",
- "properties": {
- "dataset_id": {"type": "string", "description": "Dataset ID from load_data"},
- },
- "required": ["dataset_id"],
- },
- ),
- Tool(
- name="analyze_geo_distribution",
- description=(
- "分析工具:流量地理分布——top源/目标国家、异常地点对(如不可能的行程)、"
- "ISP多样性。只读。需要国家或经纬度列。用于检测地理异常。"
- ),
- inputSchema={
- "type": "object",
- "properties": {
- "dataset_id": {"type": "string", "description": "Dataset ID from load_data"},
- },
- "required": ["dataset_id"],
- },
- ),
- Tool(
- name="analyze_entity_detail",
- description=(
- "分析工具:深入分析单个实体——其所有流、使用的TLS版本、目标多样性、时间模式、"
- "异常评分。只读。在compute_scores后调查特定可疑实体时使用。"
- ),
- inputSchema={
- "type": "object",
- "properties": {
- "dataset_id": {"type": "string", "description": "Entity dataset ID (after filter_and_cluster or compute_scores)"},
- "entity_value": {"type": "string", "description": "Entity value to investigate (e.g. an IP address or SNI)"},
- },
- "required": ["dataset_id", "entity_value"],
- },
- ),
- Tool(
- name="compute_distance_matrix",
- description=(
- "LLM驱动工具:对数据集的每一行执行用户编写的Python函数,返回距离/相似度评分摘要。"
- "LLM提供函数体,签名格式为`def distance_fn(row: dict) -> float:`。执行器将其包装,"
- "在受限命名空间(仅math、numpy)中逐行运行,返回得分的最小/最大/均值/标准差以及"
- "评分行样本。用于现有工具无法表达的一次性度量、过滤或评分任务。"
- ),
- inputSchema={
- "type": "object",
- "properties": {
- "dataset_id": {
- "type": "string",
- "description": "Source dataset identifier",
- },
- "python_function": {
- "type": "string",
- "description": (
- "Body of a Python function with signature `def distance_fn(row: dict) -> float:`. "
- "Example: \"return abs(row.get('col1', 0)) + row.get('col2', 0)\". "
- "Available namespace: math, numpy (as np). No dangerous modules."
- ),
- },
- "columns": {
- "type": "array",
- "items": {"type": "string"},
- "description": "Columns to pass to the function (default: auto-detect numeric columns)",
- },
- "max_sample": {
- "type": "integer",
- "description": "Maximum rows to evaluate (default 10000 for performance)",
- "default": 10000,
- },
- },
- "required": ["dataset_id", "python_function"],
- },
- ),
- ]
-
-
-# ═══════════════════════════════════════════════════════════════════════
-# Handler implementations
-# ═══════════════════════════════════════════════════════════════════════
-
-async def handle_call(name: str, arguments: dict) -> dict:
- """Dispatch a tool call to the appropriate handler.
-
- Args:
- name: Tool name (must match one of the 11 tools).
- arguments: Tool-specific parameters.
-
- Returns:
- A JSON-serialisable dict.
-
- Raises:
- ValueError: If *name* is not a recognised tool.
- """
- _handlers = {
- 'load_data': _handle_load_data,
- 'profile_data': _handle_profile_data,
- 'filter_data': _handle_filter_data,
- 'preprocess_data': _handle_preprocess_data,
- 'run_clustering': _handle_run_clustering,
- 'evaluate_clustering': _handle_evaluate_clustering,
- 'extract_features': _handle_extract_features,
- 'export_results': _handle_export_results,
- 'list_datasets': _handle_list_datasets,
- 'drop_dataset': _handle_drop_dataset,
- 'clone_dataset': _handle_clone_dataset,
- 'build_entity_profiles': _handle_build_entity_profiles,
- 'compute_scores': _handle_compute_scores,
- 'filter_and_cluster': _handle_filter_and_cluster,
- 'detect_anomalies': _handle_detect_anomalies,
- 'visualize_anomalies': _handle_visualize_anomalies,
- 'validate_data': _handle_validate_data,
- 'explore_distributions': _handle_explore_distributions,
- 'find_outliers': _handle_find_outliers,
- 'diagnose_clustering': _handle_diagnose_clustering,
- 'compare_datasets': _handle_compare_datasets,
- 'export_debug_sample': _handle_export_debug_sample,
- 'repair_schema': _handle_repair_schema,
- 'analyze_patterns': _handle_analyze_patterns,
- 'analyze_temporal': _handle_analyze_temporal,
- 'analyze_fft': _handle_analyze_fft,
- 'analyze_tls_health': _handle_analyze_tls_health,
- 'analyze_geo_distribution': _handle_analyze_geo_distribution,
- 'analyze_entity_detail': _handle_analyze_entity_detail,
- 'compute_distance_matrix': _handle_compute_distance_matrix,
- }
- handler = _handlers.get(name)
- if handler is None:
- raise ValueError(f"Unknown tool: '{name}'")
- result = await handler(**arguments)
- # Always ensure truncated key exists
- if 'truncated' not in result:
- result['truncated'] = False
- return _truncate_response(result)
-
-
-# ── 1. load_data ────────────────────────────────────────────────────────
-
-async def _handle_load_data(
- csv_glob: str,
- config_path: Optional[str] = None,
- encoding: str = 'utf-8',
- delimiter: str = ',',
- schema_strict: bool = False,
- recursive: bool = False,
-) -> dict:
- """Load, validate, merge CSVs, and store in session."""
- try:
- lf, schema, row_count, file_count, memory_mb = load_csv_directory(
- glob_pattern=csv_glob,
- encoding=encoding,
- delimiter=delimiter,
- config_path=config_path,
- sample_rows=10000,
- schema_strict=schema_strict,
- recursive=recursive,
- )
- except FileNotFoundError as e:
- return {'error': f'File not found: {e}', 'truncated': False}
- except ValueError as e:
- return {'error': f'Schema validation failed: {e}', 'truncated': False}
-
- store = SessionStore()
- dataset_id = _generate_id('ds')
- store.store_dataset(
- dataset_id=dataset_id,
- lazyframe=lf,
- schema=schema,
- metadata={
- 'row_count': row_count,
- 'file_count': file_count,
- 'memory_mb': round(memory_mb, 2),
- 'csv_glob': csv_glob,
- 'encoding': encoding,
- },
- )
-
- return {
- 'dataset_id': dataset_id,
- 'schema': schema,
- 'row_count': row_count,
- 'file_count': file_count,
- 'memory_mb': round(memory_mb, 2),
- }
-
-
-# ── 2. profile_data ─────────────────────────────────────────────────────
-
-async def _handle_profile_data(
- dataset_id: str,
- sample_size: int = 1000,
- columns: Optional[list[str]] = None,
-) -> dict:
- """Profile dataset columns and correlations."""
- store = SessionStore()
- entry = store.get_dataset(dataset_id)
- if entry is None:
- return {'error': f'Dataset not found: {dataset_id}', 'truncated': False}
-
- lf: pl.LazyFrame = entry['lazyframe']
- profile = _profile_dataset(lf, sample_size=sample_size, columns=columns)
- return profile
-
-
-# ── 3. filter_data ──────────────────────────────────────────────────────
-
-def _build_filter_expr(column: str, op: str, value: Any) -> pl.Expr:
- """Build a single Polars expression from a filter descriptor."""
- col = pl.col(column)
- op_map = {
- 'eq': col == value,
- 'neq': col != value,
- 'gt': col > value,
- 'gte': col >= value,
- 'lt': col < value,
- 'lte': col <= value,
- 'contains': col.str.contains(value) if value is not None else col.cast(str).str.contains(''),
- 'not_contains': ~(col.str.contains(value) if value is not None else col.cast(str).str.contains('')),
- 'in': col.is_in(value) if isinstance(value, (list, tuple)) else col.is_in([value]),
- 'not_in': ~(col.is_in(value) if isinstance(value, (list, tuple)) else col.is_in([value])),
- 'is_null': col.is_null(),
- 'is_not_null': col.is_not_null(),
- }
- expr = op_map.get(op)
- if expr is None:
- raise ValueError(f"Unsupported filter operator: {op}")
- return expr
-
-
-async def _handle_filter_data(
- dataset_id: str,
- filters: list[dict],
- logic: str = 'and',
-) -> dict:
- """Apply filters and store filtered dataset."""
- store = SessionStore()
- entry = store.get_dataset(dataset_id)
- if entry is None:
- return {'error': f'Dataset not found: {dataset_id}', 'truncated': False}
-
- lf: pl.LazyFrame = entry['lazyframe']
- full_schema = entry.get('schema', {})
-
- exprs = [_build_filter_expr(
- f['column'], f.get('op', 'eq'), f.get('value'),
- ) for f in filters]
-
- if not exprs:
- return {'error': 'No filter expressions could be built', 'truncated': False}
-
- if logic == 'and':
- combined = exprs[0]
- for e in exprs[1:]:
- combined = combined & e
- else:
- combined = exprs[0]
- for e in exprs[1:]:
- combined = combined | e
-
- filtered_lf = lf.filter(combined)
-
- # Estimate row count (fast streaming count)
- try:
- row_count = filtered_lf.select(pl.len()).collect(streaming=True).item()
- except Exception:
- row_count = None
-
- new_id = _generate_id('flt')
- store.store_dataset(
- dataset_id=new_id,
- lazyframe=filtered_lf,
- schema=full_schema,
- metadata={
- 'row_count': row_count,
- 'parent_dataset_id': dataset_id,
- 'filter_count': len(filters),
- 'logic': logic,
- },
- parent_id=dataset_id,
- )
-
- return {
- 'filtered_dataset_id': new_id,
- 'row_count': row_count,
- 'column_deltas': {'kept': list(full_schema.keys()), 'dropped': []},
- }
-
-
-# ── 4. preprocess_data ──────────────────────────────────────────────────
-
-async def _handle_preprocess_data(
- dataset_id: str,
- columns: list[str],
- config: Optional[dict] = None,
-) -> dict:
- """Preprocess selected columns (scale, encode, impute, drop)."""
- store = SessionStore()
- entry = store.get_dataset(dataset_id)
- if entry is None:
- return {'error': f'Dataset not found: {dataset_id}', 'truncated': False}
-
- lf: pl.LazyFrame = entry['lazyframe']
- cfg = config or {}
- column_mapping: dict[str, str] = {}
-
- df = lf.collect(streaming=True)
-
- # Resolve columns that actually exist
- available = set(df.columns)
- target_cols = [c for c in columns if c in available]
- if not target_cols:
- return {'error': 'None of the specified columns exist in the dataset',
- 'truncated': False}
-
- # Drop columns
- drop_cols = cfg.get('drop', [])
- if drop_cols:
- df = df.drop([c for c in drop_cols if c in df.columns])
- for c in drop_cols:
- if c in available:
- column_mapping[c] = f''
-
- # Fill missing values
- fill_strategy = cfg.get('fillna')
- if fill_strategy and fill_strategy != 'drop':
- for col in target_cols:
- if col not in df.columns:
- continue
- if df[col].null_count() == 0:
- continue
- if fill_strategy == 'mean':
- mean_val = df[col].drop_nulls().mean()
- if mean_val is not None:
- df = df.with_columns(pl.col(col).fill_null(mean_val))
- elif fill_strategy == 'median':
- med_val = df[col].drop_nulls().median()
- if med_val is not None:
- df = df.with_columns(pl.col(col).fill_null(med_val))
- elif fill_strategy == 'zero':
- df = df.with_columns(pl.col(col).fill_null(0))
- elif fill_strategy == 'drop':
- df = df.drop_nulls(subset=target_cols)
-
- # Standard scaling (z-score)
- if cfg.get('standardize'):
- from sklearn.preprocessing import StandardScaler
- num_cols = [c for c in target_cols if c in df.columns
- and df[c].dtype in (pl.Float32, pl.Float64, pl.Int32, pl.Int64)]
- if num_cols:
- scaler = StandardScaler()
- scaled = scaler.fit_transform(df.select(num_cols).to_numpy())
- for i, col in enumerate(num_cols):
- col_scaled = f'{col}_scaled'
- df = df.with_columns(pl.Series(col_scaled, scaled[:, i]))
- column_mapping[col] = col_scaled
-
- # One-hot encoding
- if cfg.get('onehot'):
- str_cols = [c for c in target_cols if c in df.columns
- and df[c].dtype in (pl.Utf8, pl.String, pl.Categorical)]
- if str_cols:
- df = df.to_dummies(columns=str_cols, drop_first=False)
- for c in str_cols:
- column_mapping[c] = f''
-
- new_id = _generate_id('pp')
- new_lf = df.lazy()
- new_schema = {name: str(dtype) for name, dtype in
- zip(new_lf.collect_schema().names(), new_lf.collect_schema().dtypes())}
-
- store.store_dataset(
- dataset_id=new_id,
- lazyframe=new_lf,
- schema=new_schema,
- metadata={
- 'row_count': len(df),
- 'parent_dataset_id': dataset_id,
- 'preprocessing_config': cfg,
- },
- parent_id=dataset_id,
- )
-
- return {
- 'preprocessed_dataset_id': new_id,
- 'column_mapping': column_mapping,
- 'row_count': len(df),
- 'column_count': len(new_schema),
- }
-
-
-# ── 5. run_clustering ───────────────────────────────────────────────────
-
-async def _handle_run_clustering(
- dataset_id: str,
- cluster_columns: list[str],
- algorithm: str = 'agglomerative',
- params: Optional[dict] = None,
- random_state: int = 42,
-) -> dict:
- """Run HDBSCAN or KMeans clustering on selected columns (async)."""
- store = SessionStore()
- entry = store.get_dataset(dataset_id)
- if entry is None:
- return {'error': f'Dataset not found: {dataset_id}', 'truncated': False}
-
- lf: pl.LazyFrame = entry['lazyframe']
- p = params or {}
-
- try:
- # Collect cluster features - numeric only
- available = lf.collect_schema()
- available_names = available.names()
- available_dtypes = available.dtypes()
- numeric_dtypes = (pl.Int8, pl.Int16, pl.Int32, pl.Int64,
- pl.UInt8, pl.UInt16, pl.UInt32, pl.UInt64,
- pl.Float32, pl.Float64)
- feature_cols = [
- c for c in cluster_columns
- if c in available_names
- and available_dtypes[available_names.index(c)] in numeric_dtypes
- ]
- if not feature_cols:
- # Fall back: auto-select all numeric columns (exclude internal ones)
- feature_cols = [
- available_names[i] for i, dt in enumerate(available_dtypes)
- if dt in numeric_dtypes and not available_names[i].startswith('_')
- ][:10]
- if not feature_cols:
- # Fallback: try coercing Utf8 columns to Float64 (common when data loaded from
- # SQLite TEXT storage without proper dtype restoration).
- utf8_cols = [
- available_names[i] for i, dt in enumerate(available_dtypes)
- if dt == pl.Utf8 and not available_names[i].startswith('_')
- ][:10]
- if utf8_cols:
- # Attempt per-column cast; filter to columns where coercion succeeded
- for col in utf8_cols:
- try:
- casted = lf.select(
- pl.col(col).cast(pl.Float64, strict=False).alias(col)
- ).collect(streaming=True)
- null_ratio = casted[col].is_null().sum() / max(len(casted), 1)
- if null_ratio < 0.9: # at least 10% successfully parsed as floats
- lf = lf.with_columns(
- pl.col(col).cast(pl.Float64, strict=False)
- )
- feature_cols.append(col)
- except Exception:
- pass
- if not feature_cols:
- all_numeric = [f"{available_names[i]}" for i, dt in enumerate(available_dtypes)
- if dt in numeric_dtypes]
- return {
- 'error': f'No numeric cluster columns found in dataset. Available: {all_numeric}',
- 'truncated': False
- }
-
- # Downsample before collect: compute row count via fast lazy count
- try:
- row_count = lf.select(pl.len()).collect(streaming=True).item()
- except Exception:
- row_count = 0
- if row_count > 100000:
- # Sample to 100K before collect to avoid OOM on 10M+ rows
- lf = lf.collect(streaming=True).sample(n=100000, seed=42).lazy()
-
- df_features = lf.select(feature_cols).collect(streaming=True)
-
- # Drop all-NaN columns (keep rows, drop useless columns)
- for col in df_features.columns:
- if df_features[col].is_null().all():
- df_features = df_features.drop(col)
- feature_cols = [c for c in feature_cols if c != col]
-
- if df_features.width == 0:
- return {'error': 'No valid feature columns after dropping all-NaN columns',
- 'truncated': False}
-
- data = df_features.to_numpy()
-
- # Fill remaining NaN with column mean
- if data.dtype.kind == 'f':
- col_mean = np.nanmean(data, axis=0)
- col_mean = np.nan_to_num(col_mean, nan=0.0)
- data = np.where(np.isnan(data), col_mean, data)
-
- # Zero-sample / too-few-samples guard
- if len(data) < 3:
- return {
- 'cluster_result_id': _generate_id('clust'),
- 'n_clusters': 0,
- 'n_noise': 0,
- 'quality_metrics': {'note': 'too few samples'},
- 'truncated': False,
- }
-
- # ── D14: Low memory check (safe fallback if psutil not installed) ──
- low_memory = False
- try:
- import psutil
- mem = psutil.virtual_memory()
- low_memory = mem.available < 2 * 1024**3 # 2 GB
- except ImportError:
- pass
-
- # ── D14: Auto-downsample for low memory (>100K rows → 50K) ────────
- if low_memory and len(data) > 100000:
- rng = np.random.default_rng(42)
- idx = rng.choice(len(data), 50000, replace=False)
- data = data[idx]
-
- # ── D5: Downsampling → extract matching indices ──────────────
- # Downsample to 50K max, store indices so _handle_extract_features
- # can filter the same rows.
- _ds_idx = None
- if len(data) > 50000:
- rng = np.random.default_rng(42)
- _ds_idx = rng.choice(len(data), 50000, replace=False)
- data = data[_ds_idx]
-
- # ── H17: Variance filtering ───────────────────────────────────────
- # Remove features with zero / near-zero variance (< 1e-10)
- if data.shape[1] > 1:
- variances = np.var(data, axis=0)
- keep_var = np.where(variances >= 1e-10)[0]
- if len(keep_var) == 0:
- # All features have zero variance — fall back to unfiltered data
- keep_var = np.arange(data.shape[1])
- if len(keep_var) < data.shape[1]:
- data = data[:, keep_var]
- feature_cols = [feature_cols[i] for i in keep_var]
-
- # Log clustering input
- import logging
- _clust_logger = logging.getLogger(__name__)
- _clust_logger.info(f'[CLUSTER_INPUT] rows={len(data)} cols={data.shape[1] if len(data.shape) > 1 else 1} feature_cols={feature_cols}')
-
- # Standard scale
- from sklearn.preprocessing import StandardScaler
- _clust_logger.info('[CLUSTER_SCALE] Starting StandardScaler...')
- data_scaled = StandardScaler().fit_transform(data)
- _clust_logger.info(f'[CLUSTER_SCALE] Done. shape={data_scaled.shape}')
-
- # ── H17: Correlation filtering ────────────────────────────────────
- # Remove features with Pearson correlation > 0.95 (keep the first one)
- if data_scaled.shape[1] > 1:
- corr = np.corrcoef(data_scaled.T)
- upper = np.triu(np.abs(corr), 1)
- to_drop = [j for j in range(upper.shape[1]) if any(upper[:, j] > 0.95)]
- if to_drop:
- keep = [j for j in range(data_scaled.shape[1]) if j not in to_drop]
- data_scaled = data_scaled[:, keep]
- feature_cols = [feature_cols[j] for j in keep]
-
- # ── D14: SVD dimensionality reduction (if >50 features) ───────────
- n_features = data_scaled.shape[1]
- if n_features > 50:
- from sklearn.decomposition import TruncatedSVD
- svd = TruncatedSVD(n_components=50, random_state=random_state)
- data_scaled = svd.fit_transform(data_scaled)
-
- # ── H17: HDBSCAN hyperparameter auto-tuning based on data size ────
- n = len(data_scaled)
- if algorithm == 'hdbscan':
- min_cluster = p.get('min_cluster_size', max(3, min(50, n // 20)))
- p['min_cluster_size'] = min_cluster
- p['min_samples'] = p.get('min_samples', min_cluster)
- p['cluster_selection_epsilon'] = p.get('cluster_selection_epsilon', 0.0)
- else:
- # KMeans safety cap
- p['min_cluster_size'] = min(
- p.get('min_cluster_size', 5),
- max(n // 2, 2),
- )
-
- # Clustering
- from sklearn.cluster import HDBSCAN, MiniBatchKMeans, AgglomerativeClustering
- if algorithm == 'kmeans':
- n_clusters = p.get('n_clusters', 3)
- model = MiniBatchKMeans(
- n_clusters=n_clusters, random_state=random_state,
- batch_size=1024, n_init='auto',
- )
- elif algorithm in ('agglomerative', 'ward'):
- n_clusters = p.get('n_clusters', 5)
- model = AgglomerativeClustering(
- n_clusters=n_clusters,
- metric=p.get('metric', 'euclidean'),
- linkage=p.get('linkage', 'ward'),
- distance_threshold=None,
- )
- else:
- model = HDBSCAN(
- min_cluster_size=p['min_cluster_size'],
- min_samples=p.get('min_samples', None),
- metric=p.get('metric', 'euclidean'),
- cluster_selection_epsilon=p.get('cluster_selection_epsilon', 0.0),
- )
-
- labels_array = model.fit_predict(data_scaled)
- n_clusters_found = int(len(set(labels_array)) - (1 if -1 in labels_array else 0))
- noise_count = int((labels_array == -1).sum()) if algorithm == 'hdbscan' else 0
- noise_ratio = round(noise_count / len(labels_array), 4) if algorithm == 'hdbscan' else 0.0
-
- # Map back to original rows
- full_labels = labels_array
-
- # Quality metrics
- quality = {
- 'n_clusters': n_clusters_found,
- 'n_noise': noise_count,
- 'noise_ratio': noise_ratio,
- 'algorithm': algorithm,
- }
-
- if n_clusters_found > 1:
- try:
- from sklearn.metrics import silhouette_score
- score = silhouette_score(data_scaled, labels_array, random_state=random_state)
- quality['silhouette_score'] = round(float(score), 4)
- except Exception:
- quality['silhouette_score'] = None
- try:
- from sklearn.metrics import davies_bouldin_score
- dbs = davies_bouldin_score(data_scaled, labels_array)
- quality['davies_bouldin_score'] = round(float(dbs), 4)
- except Exception:
- quality['davies_bouldin_score'] = None
- try:
- from sklearn.metrics import calinski_harabasz_score
- chs = calinski_harabasz_score(data_scaled, labels_array)
- quality['calinski_harabasz_score'] = round(float(chs), 4)
- except Exception:
- quality['calinski_harabasz_score'] = None
-
- # Cluster sizes
- cluster_sizes = {}
- for label in np.unique(labels_array):
- cluster_sizes[int(label)] = int((labels_array == label).sum())
- quality['cluster_sizes'] = cluster_sizes
-
- except Exception:
- raise
-
- result_id = _generate_id('clust')
- store.store_cluster_result(
- result_id=result_id,
- labels=full_labels.tolist(),
- n_clusters=n_clusters_found,
- params={
- 'algorithm': algorithm,
- 'params': p,
- 'random_state': random_state,
- 'feature_columns': feature_cols,
- '_ds_idx': _ds_idx.tolist() if _ds_idx is not None else None,
- },
- parent_dataset_id=dataset_id,
- )
-
- return {
- 'cluster_result_id': result_id,
- 'n_clusters': n_clusters_found,
- 'n_noise': noise_count,
- 'labels_sample': full_labels[:50].tolist(),
- 'quality_metrics': quality,
- }
-
-
-# ── 6. evaluate_clustering ──────────────────────────────────────────────
-
-async def _handle_evaluate_clustering(
- cluster_result_id: str,
- dataset_id: str,
- metrics: Optional[list[str]] = None,
-) -> dict:
- """Evaluate clustering result against the dataset."""
- store = SessionStore()
- cluster_entry = store.get_cluster_result(cluster_result_id)
- if cluster_entry is None:
- return {'error': f'Cluster result not found: {cluster_result_id}',
- 'truncated': False}
-
- dataset_entry = store.get_dataset(dataset_id)
- if dataset_entry is None:
- return {'error': f'Dataset not found: {dataset_id}', 'truncated': False}
-
- labels = cluster_entry['labels']
- default_metrics = ['silhouette', 'davies_bouldin', 'calinski_harabasz',
- 'noise_ratio', 'cluster_sizes']
- requested = metrics or default_metrics
-
- scores: dict = {'n_clusters': cluster_entry['n_clusters']}
-
- try:
- lf: pl.LazyFrame = dataset_entry['lazyframe']
- params = cluster_entry.get('params', {})
- feature_cols = params.get('feature_columns', [])
- if not feature_cols and 'feature_columns' in params:
- pass
- elif feature_cols:
- df_features = lf.select(feature_cols).collect(streaming=True)
- data = df_features.to_numpy()
-
- # Align labels with data (handle missing rows)
- valid_mask = ~np.isnan(data).any(axis=1) if data.dtype.kind == 'f' else np.ones(len(data), dtype=bool)
- data_clean = data[valid_mask]
- labels_clean = np.array(labels)[:len(data_clean)]
-
- unique_labels = set(labels_clean)
- n_clusters_actual = len(unique_labels - {-1})
-
- for m in requested:
- if m == 'silhouette' and n_clusters_actual >= 2:
- from sklearn.metrics import silhouette_score
- scores['silhouette'] = round(float(
- silhouette_score(data_clean, labels_clean, random_state=42)), 4)
- elif m == 'davies_bouldin' and n_clusters_actual >= 2:
- from sklearn.metrics import davies_bouldin_score
- scores['davies_bouldin'] = round(float(
- davies_bouldin_score(data_clean, labels_clean)), 4)
- elif m == 'calinski_harabasz' and n_clusters_actual >= 2:
- from sklearn.metrics import calinski_harabasz_score
- scores['calinski_harabasz'] = round(float(
- calinski_harabasz_score(data_clean, labels_clean)), 4)
- elif m == 'noise_ratio':
- noise = int((labels_clean == -1).sum())
- scores['noise_ratio'] = round(noise / len(labels_clean), 4)
- elif m == 'cluster_sizes':
- sizes = {}
- for label in unique_labels:
- sizes[int(label)] = int((labels_clean == label).sum())
- scores['cluster_sizes'] = sizes
-
- scores['n_clusters'] = cluster_entry['n_clusters']
-
- except Exception:
- raise
-
- return {'scores': scores}
-
-
-# ── 7. extract_features ─────────────────────────────────────────────────
-
-async def _handle_extract_features(
- dataset_id: str,
- cluster_result_id: str,
- top_k: int = 10,
- method: str = 'zscore',
- save_to_db: bool = True,
- run_id: int | None = None,
-) -> dict:
- """Compute per-cluster distinguishing features and optionally persist."""
- store = SessionStore()
- dataset_entry = store.get_dataset(dataset_id)
- if dataset_entry is None:
- return {'error': f'Dataset not found: {dataset_id}', 'truncated': False}
-
- cluster_entry = store.get_cluster_result(cluster_result_id)
- if cluster_entry is None:
- return {'error': f'Cluster result not found: {cluster_result_id}',
- 'truncated': False}
-
- try:
- lf: pl.LazyFrame = dataset_entry['lazyframe']
- labels = cluster_entry['labels']
-
- # D5: If clustering downsampled the data, filter matching rows
- _ds_idx = cluster_entry.get('params', {}).get('_ds_idx')
- if _ds_idx is not None:
- # Filter in lazy plan before collect — avoids loading full 10M rows
- df = (lf.with_row_index('__ds_idx')
- .filter(pl.col('__ds_idx').is_in(_ds_idx))
- .drop('__ds_idx')
- .collect(streaming=True))
- else:
- df = lf.collect(streaming=True)
-
- # Truncate labels if df was filtered but labels weren't
- if len(labels) != len(df):
- labels = labels[:len(df)]
-
- # Only use numeric columns for feature extraction
- numeric_cols = [c for c in df.columns
- if df[c].dtype in (pl.Float32, pl.Float64,
- pl.Int32, pl.Int64,
- pl.UInt32, pl.UInt64)]
- if len(labels) != len(df):
- labels = labels[:len(df)]
-
- # Global statistics per column
- global_stats = {}
- for col in numeric_cols:
- s = df[col].drop_nulls()
- if len(s) > 0:
- global_stats[col] = {
- 'mean': float(s.mean()),
- 'std': float(s.std()) if s.std() is not None else 1.0,
- }
-
- unique_labels = sorted(set(labels))
- all_features = []
-
- for label in unique_labels:
- mask = np.array(labels) == label
- if mask.sum() < 2:
- continue
-
- cluster_df = df.filter(pl.Series('__mask__', mask))
- cluster_features = []
-
- for col in numeric_cols:
- c_series = cluster_df[col].drop_nulls()
- gs = global_stats.get(col)
- if gs is None or gs['std'] == 0 or len(c_series) == 0:
- continue
-
- c_mean = float(c_series.mean())
- c_std = float(c_series.std()) if c_series.std() is not None else 0.0
-
- if method == 'zscore':
- score = (c_mean - gs['mean']) / max(gs['std'], 1e-10)
- else:
- # ANOVA-like: F = (between-group variance) / (within-group variance)
- score = abs(c_mean - gs['mean']) / max(c_std + gs['std'], 1e-10)
-
- cluster_features.append({
- 'feature_name': col,
- 'cluster_label': int(label),
- 'mean': round(c_mean, 4),
- 'std': round(c_std, 4),
- 'global_mean': round(gs['mean'], 4),
- 'global_std': round(gs['std'], 4),
- 'distinguishing_score': round(float(score), 4),
- 'distinguishing_method': method,
- })
-
- # Sort by distinguishing score descending, take top_k
- cluster_features.sort(key=lambda x: abs(x['distinguishing_score']), reverse=True)
- all_features.extend(cluster_features[:top_k])
-
- # ── Compute UMAP-2D embedding ──────────────────────────────────────
- if len(numeric_cols) >= 2 and len(df) > 2:
- try:
- from sklearn.preprocessing import StandardScaler
- import umap
- MAX_UMAP_TRAIN = 10000
- BATCH_SIZE = 1000
- data_matrix = df.select(numeric_cols).to_numpy()
- if len(df) > MAX_UMAP_TRAIN:
- # Train UMAP on 10K sample, batch-transform full dataset
- idx_sample = np.random.RandomState(42).choice(
- len(df), size=MAX_UMAP_TRAIN, replace=False)
- mat_sample = data_matrix[idx_sample]
- scaler = StandardScaler().fit(mat_sample)
- mat_sample_scaled = np.nan_to_num(scaler.transform(mat_sample),
- nan=0.0, posinf=0.0, neginf=0.0)
- reducer = umap.UMAP(n_components=2, random_state=42,
- n_neighbors=15, min_dist=0.1,
- metric='euclidean')
- reducer.fit(mat_sample_scaled)
- # Batch transform in 1K-row batches
- coords_list = []
- for start in range(0, len(df), BATCH_SIZE):
- end = min(start + BATCH_SIZE, len(df))
- mat_batch = scaler.transform(data_matrix[start:end])
- mat_batch = np.nan_to_num(mat_batch, nan=0.0, posinf=0.0, neginf=0.0)
- coords_list.append(reducer.transform(mat_batch))
- coords = np.vstack(coords_list)
- else:
- data_scaled = StandardScaler().fit_transform(data_matrix)
- # Handle NaN/Inf values (np is imported at module level)
- data_scaled = np.nan_to_num(data_scaled, nan=0.0, posinf=0.0, neginf=0.0)
- reducer = umap.UMAP(n_components=2, random_state=42,
- n_neighbors=15, min_dist=0.1,
- metric='euclidean')
- coords = reducer.fit_transform(data_scaled)
- non_numeric = [c for c in df.columns if c not in numeric_cols and not c.startswith('_')]
- entity_col_umap = non_numeric[0] if non_numeric else df.columns[0]
- labels_list = cluster_entry.get('labels', [])
- # Save: create EntityProfile records with coordinates
- from analysis.models import EntityProfile as EP
- from analysis.models import ClusterResult as CR
- from analysis.models import AnalysisRun as AR
- from asgiref.sync import sync_to_async
- csv_glob = dataset_entry.get('metadata', {}).get('csv_glob', 'manual')
- run_obj = await sync_to_async(
- AR.objects.filter(csv_glob=csv_glob).order_by('-id').first,
- thread_sensitive=True
- )()
- if run_obj:
- run_id_for_ep = run_obj.id
- profiles = []
- cr_cache = {}
- for i, row in enumerate(df.iter_rows(named=True)):
- if i >= len(coords): break
- ev = str(row.get(entity_col_umap, ''))
- if not ev: continue
- lbl = labels_list[i] if i < len(labels_list) else -1
- cache_key = f'{run_id_for_ep}_{lbl}'
- if cache_key not in cr_cache:
- cr_cache[cache_key] = CR.objects.filter(run_id=run_id_for_ep, cluster_label=lbl).first()
- profiles.append(EP(
- entity_value=ev, run_id=run_id_for_ep, cluster_label=lbl,
- cluster=cr_cache[cache_key],
- embedding_x=float(coords[i, 0]),
- embedding_y=float(coords[i, 1]),
- ))
- if profiles:
- EP.objects.bulk_create(profiles, ignore_conflicts=True, batch_size=1000)
- except Exception:
- import traceback, sys; print(f'[UMAP_ERR] {traceback.format_exc()}', file=sys.stderr)
-
- # Persist to Django ORM
- db_saved = False
- if save_to_db:
- try:
- from asgiref.sync import sync_to_async
- from analysis.models import AnalysisRun as AnalysisRunModel
- ce = cluster_entry # local ref for closure
- async def _do_save(ce_inner):
- if run_id is not None:
- run = await sync_to_async(AnalysisRunModel.objects.get)(id=run_id)
- else:
- csv_glob_fallback = dataset_entry.get('metadata', {}).get('csv_glob', 'manual')
- run = await sync_to_async(
- AnalysisRunModel.objects.filter(csv_glob=csv_glob_fallback).order_by('-id').first,
- thread_sensitive=True
- )()
- if run is None:
- run = await sync_to_async(AnalysisRunModel.objects.create)(
- csv_glob=csv_glob_fallback, status='running', total_flows=0,
- )
- run.status = 'completed'
- run.cluster_count = ce_inner['n_clusters']
- await sync_to_async(run.save)()
- return await sync_to_async(_save_features_to_db)(cluster_result_id, all_features, run_id=run.id)
- db_saved = await _do_save(ce)
- except Exception as e:
- import traceback, sys; print(f'[DB_SAVE_ERROR] {e}\n{traceback.format_exc()}', file=sys.stderr)
- db_saved = False
-
- except Exception:
- raise
-
- result_id = _generate_id('feat')
- store.store_feature_result(
- result_id=result_id,
- features=all_features,
- parent_cluster_result_id=cluster_result_id,
- )
-
- return {
- 'feature_result_id': result_id,
- 'features': all_features,
- 'n_features': len(all_features),
- 'db_saved': db_saved,
- }
-
-
-@profile
-def _save_features_to_db(
- cluster_result_id: str,
- features: list[dict],
- run_id: int | None = None,
-) -> bool:
- """Save extracted features to the Django ORM ClusterFeature model.
-
- If run_id is provided, use it directly (skip csv_glob lookup).
- """
- if run_id is not None:
- from analysis.models import AnalysisRun as AnalysisRun2
- run = AnalysisRun2.objects.filter(id=run_id).first()
- if run is None:
- return False
- else:
- # Try to find the ClusterResult record by run association
- store = SessionStore()
- cluster_entry = store.get_cluster_result(cluster_result_id)
- if cluster_entry is None:
- return False
-
- parent_ds_id = cluster_entry.get('parent_dataset_id', '')
- from analysis.models import AnalysisRun
- ds_entry = store.get_dataset(parent_ds_id)
- if ds_entry is None:
- return False
-
- csv_glob = ds_entry.get('metadata', {}).get('csv_glob', '')
- # Compute n_clusters from features before using it
- unique_global = sorted(set(f['cluster_label'] for f in features))
- n_clusters_global = len([l for l in unique_global if l >= 0])
- try:
- run = AnalysisRun.objects.filter(csv_glob=csv_glob).order_by('-created_at').first()
- if run is None:
- run = AnalysisRun.objects.create(
- csv_glob=csv_glob,
- status='completed',
- cluster_count=n_clusters_global,
- )
- except Exception:
- return False
-
- from analysis.models import ClusterResult as ClusterResultModel
-
- # Compute actual cluster sizes from full label list (not from features count!)
- # Use the cluster_entry which has the complete labels array
- store = SessionStore()
- cluster_entry = store.get_cluster_result(cluster_result_id)
- label_counts = {}
- if cluster_entry:
- full_labels = cluster_entry.get('labels', [])
- for lbl in full_labels:
- label_counts[lbl] = label_counts.get(lbl, 0) + 1
-
- unique_labels = sorted(set(
- f['cluster_label'] for f in features
- ))
- # Compute n_clusters from features (excluding noise -1)
- n_clusters = len([l for l in unique_labels if l >= 0])
- for label in unique_labels:
- if label == -1:
- continue # skip noise cluster
- label_features = [f for f in features if f['cluster_label'] == label]
- if not label_features:
- continue
-
- cluster_db, _ = ClusterResultModel.objects.get_or_create(
- run=run,
- cluster_label=label,
- defaults={'size': label_counts.get(label, 1)},
- )
- # Update size if already exists
- if label_counts.get(label) is not None:
- ClusterResultModel.objects.filter(
- run=run, cluster_label=label
- ).update(size=label_counts[label])
-
- from analysis.models import ClusterFeature as ClusterFeatureModel
- for feat in label_features:
- try:
- ClusterFeatureModel.objects.get_or_create(
- cluster=cluster_db,
- feature_name=feat['feature_name'],
- defaults={
- 'mean': feat.get('mean'),
- 'std': feat.get('std'),
- 'median': None,
- 'p25': None,
- 'p75': None,
- 'missing_rate': None,
- 'distinguishing_score': feat.get('distinguishing_score'),
- 'distinguishing_method': feat.get('distinguishing_method', 'zscore'),
- }
- )
- except Exception:
- continue # skip problematic features
-
- # Update run counts
- run.cluster_count = n_clusters
- run.save(update_fields=['cluster_count'])
- return True
-
- return True
-
-
-# ── 8. export_results ───────────────────────────────────────────────────
-
-async def _handle_export_results(
- result_id: str,
- output_path: str,
- format: str = 'csv',
- overwrite: bool = False,
-) -> dict:
- """Export a dataset (or result) to disk."""
- store = SessionStore()
-
- out = Path(output_path)
- file_paths: list[str] = []
-
- # Check if it's a dataset
- dataset_entry = store.get_dataset(result_id)
- if dataset_entry is not None:
- lf: pl.LazyFrame = dataset_entry['lazyframe']
- try:
- df = lf.collect(streaming=True)
- if out.suffix == '':
- out = out / f"export_{result_id}.{format}"
-
- if out.exists() and not overwrite:
- return {'error': f'Output exists and overwrite=False: {out}',
- 'truncated': False}
-
- out.parent.mkdir(parents=True, exist_ok=True)
-
- if format == 'csv':
- df.write_csv(out)
- elif format == 'parquet':
- df.write_parquet(out)
- elif format == 'json':
- df.write_json(out)
- else:
- return {'error': f'Unsupported format: {format}',
- 'truncated': False}
-
- file_paths.append(str(out.resolve()))
- except Exception:
- raise
-
- return {'file_paths': file_paths, 'format': format,
- 'row_count': len(df)}
-
- # Check if it's a cluster result
- cluster_entry = store.get_cluster_result(result_id)
- if cluster_entry is not None:
- if out.suffix == '':
- out = out / f"cluster_{result_id}.json"
- out.parent.mkdir(parents=True, exist_ok=True)
-
- if out.exists() and not overwrite:
- return {'error': f'Output exists and overwrite=False: {out}',
- 'truncated': False}
-
- export_data = {
- 'result_id': result_id,
- 'n_clusters': cluster_entry['n_clusters'],
- 'params': cluster_entry['params'],
- 'labels_sample': cluster_entry['labels'][:1000],
- }
- with open(out, 'w', encoding='utf-8') as f:
- json.dump(export_data, f, default=str)
- file_paths.append(str(out.resolve()))
-
- return {'file_paths': file_paths, 'format': 'json'}
-
- # Check feature result
- feat_entry = store.get_feature_result(result_id)
- if feat_entry is not None:
- if out.suffix == '':
- out = out / f"features_{result_id}.json"
- out.parent.mkdir(parents=True, exist_ok=True)
-
- if out.exists() and not overwrite:
- return {'error': f'Output exists and overwrite=False: {out}',
- 'truncated': False}
-
- with open(out, 'w', encoding='utf-8') as f:
- json.dump(feat_entry['features'], f, default=str)
- file_paths.append(str(out.resolve()))
- return {'file_paths': file_paths, 'format': 'json'}
-
- return {'error': f'No dataset or result found for ID: {result_id}',
- 'truncated': False}
-
-
-# ── 9. list_datasets ────────────────────────────────────────────────────
-
-async def _handle_list_datasets(
- _filter_type: Optional[str] = None,
-) -> dict:
- """List all active datasets and results."""
- store = SessionStore()
- datasets = store.list_datasets()
- return {'datasets': datasets, 'count': len(datasets)}
-
-
-# ── 10. drop_dataset ────────────────────────────────────────────────────
-
-async def _handle_drop_dataset(dataset_id: str) -> dict:
- """Remove a dataset/result and release memory."""
- store = SessionStore()
-
- dropped = (store.drop_dataset(dataset_id)
- or store.drop_cluster_result(dataset_id)
- or store.drop_feature_result(dataset_id))
-
- if dropped:
- return {'dropped': True, 'dataset_id': dataset_id}
- return {'error': f'No dataset or result found: {dataset_id}',
- 'truncated': False}
-
-
-# ── 11. clone_dataset ───────────────────────────────────────────────────
-
-async def _handle_clone_dataset(dataset_id: str) -> dict:
- """Shallow-clone a dataset."""
- store = SessionStore()
- new_id = store.clone_dataset(dataset_id)
- if new_id is None:
- return {'error': f'Dataset not found: {dataset_id}', 'truncated': False}
- return {'original_dataset_id': dataset_id, 'cloned_dataset_id': new_id}
-
-
-# ── 12. build_entity_profiles ────────────────────────────────────────────
-
-_ENTITY_KEYWORDS = [
- 'src_ip', 'dst_ip', 'source_ip', 'destination_ip', 'src_addr', 'dst_addr',
- 'sip', 'dip', 'ip_src', 'ip_dst', 'saddr', 'daddr',
- 'sni', 'host', 'domain', 'uri', 'url', 'fqdn', 'server_name',
- 'mac', 'mac_addr', 'src_mac', 'dst_mac',
- 'user_agent', 'ua', 'asn', 'organization', 'org',
-]
-
-_NUMERIC_TYPES_FOR_AGG = (pl.Int8, pl.Int16, pl.Int32, pl.Int64,
- pl.UInt8, pl.UInt16, pl.UInt32, pl.UInt64,
- pl.Float32, pl.Float64)
-
-
-async def _handle_build_entity_profiles(
- dataset_id: str,
- entity_column: Optional[str] = None,
- entity_columns: Optional[list[str]] = None,
- auto_detect: bool = True,
-) -> dict:
- """Group raw flow data by entity column(s) and compute per-entity aggregate features.
-
- Stores the result in the session store and returns the new dataset_id.
- """
- store = SessionStore()
- entry = store.get_dataset(dataset_id)
- if entry is None:
- return {'error': f'Dataset not found: {dataset_id}', 'truncated': False}
-
- lf: pl.LazyFrame = entry['lazyframe']
- schema = entry.get('schema', {})
- col_names = list(schema.keys())
-
- # ── Resolve entity columns ──────────────────────────────────────────
- if entity_column and entity_column in col_names:
- group_cols = [entity_column]
- elif entity_columns:
- group_cols = [c for c in entity_columns if c in col_names]
- if not group_cols:
- return {'error': f'None of entity_columns {entity_columns} exist in dataset. Available: {col_names[:20]}',
- 'truncated': False}
- elif auto_detect:
- # Auto-detect: find columns matching entity keywords
- matched = []
- for kw in _ENTITY_KEYWORDS:
- found = [c for c in col_names if kw.lower() in c.lower()]
- matched.extend(found)
- # Deduplicate while preserving order
- seen = set()
- group_cols = [c for c in matched if not (c in seen or seen.add(c))]
- if not group_cols:
- # Fallback: pick first non-numeric, non-internal column
- for c in col_names:
- if c.startswith('_'):
- continue
- group_cols = [c]
- break
- # Limit to at most 3 entity columns
- group_cols = group_cols[:3]
- else:
- return {'error': 'No entity column specified and auto_detect is disabled',
- 'truncated': False}
-
- if not group_cols:
- return {'error': 'Could not determine entity column(s) for grouping',
- 'truncated': False}
-
- # ── Build aggregation expressions ──────────────────────────────────
- # Always: count of flows
- agg_exprs: list[pl.Expr] = [
- pl.len().alias('flow_count'),
- pl.col(group_cols[0]).n_unique().alias(f'unique_{group_cols[0]}'),
- ]
-
- # Numeric aggregations (sum, mean)
- numeric_cols = [c for c in col_names if c not in group_cols
- and schema[c].startswith(('Int', 'UInt', 'Float'))]
- for c in numeric_cols[:20]:
- agg_exprs.append(pl.sum(c).alias(f'total_{c}'))
- agg_exprs.append(pl.mean(c).alias(f'avg_{c}'))
-
- # Unique counts for string-like entity columns (dst_ip, dst_port, sni, etc.)
- str_cols = [c for c in col_names if c not in group_cols
- and schema[c].startswith(('Utf8', 'String', 'Cat'))]
- for c in str_cols[:10]:
- agg_exprs.append(pl.col(c).n_unique().alias(f'unique_{c}'))
-
- # Also count distinct values of specific keyword-matching columns
- for kw in ['dst_ip', 'dip', 'dst_addr', 'daddr', 'ip_dst', 'dest_ip',
- 'dst_port', 'dport', 'destination_port', 'dest_port',
- 'port', 'protocol', 'proto', 'sni', 'server_name',
- 'cipher_suite', 'cipher', 'tls_version', 'version', '0ver',
- 'country', 'scnt', 'dcnt', 'asn', 'organization']:
- found = [c for c in col_names if c not in group_cols and kw.lower() in c.lower()]
- for c in found:
- alias_name = f'unique_{c}'
- if alias_name not in [str(a.meta.output_name()) if hasattr(a, 'meta') else '' for a in agg_exprs]:
- agg_exprs.append(pl.col(c).n_unique().alias(alias_name))
-
- # ── Execute grouping ───────────────────────────────────────────────
- try:
- entity_lf = lf.group_by(group_cols).agg(agg_exprs)
- # Estimate row count
- n_entities = entity_lf.select(pl.len()).collect(streaming=True).item()
- except Exception:
- raise
-
- # ── Store result ───────────────────────────────────────────────────
- new_schema = {name: str(dtype) for name, dtype in
- zip(entity_lf.collect_schema().names(), entity_lf.collect_schema().dtypes())}
- new_id = f'entity_{_generate_id("ds")}'
- store.store_dataset(
- dataset_id=new_id,
- lazyframe=entity_lf,
- schema=new_schema,
- metadata={
- 'row_count': n_entities,
- 'entity_columns': group_cols,
- 'parent_dataset_id': dataset_id,
- 'n_agg_features': len(agg_exprs),
- },
- parent_id=dataset_id,
- )
-
- return {
- 'dataset_id': new_id,
- 'entity_columns': group_cols,
- 'n_entities': n_entities,
- 'n_features': len(agg_exprs),
- 'aggregated_columns': list(new_schema.keys())[:20],
- }
-
-
-# ── Adaptive anomaly scoring helpers (inlined from entity_aggregator) ─────
-
-_ANOMALY_FEATURE_COLS = [
- 'non_std_port_rate', 'modern_tls_rate', 'sni_missing_ratio',
- 'recoverable_ratio', 'flow_count', 'total_bytes_sent',
- 'total_packets', 'unique_dst_ips', 'unique_dst_ports',
- 'unique_protocols', 'has_sni', 'avg_sni_length',
- 'countries_visited',
-]
-
-
-@profile
-def _add_adaptive_scores(lf: pl.LazyFrame, feature_names: list[str]) -> pl.LazyFrame:
- """Add anomaly scores using data-driven weights (SVD + IQR + percentile).
-
- Steps:
- 1. Find which anomaly feature columns exist in the aggregated data
- 2. Collect a sample (max 10K rows) for SVD weight learning
- 3. Learn feature weights from SVD first component loadings
- 4. Compute weighted proxy_score across ALL rows (vectorized, no per-row ops)
- 5. Add percentile rank calibration
- 6. Infer risk thresholds from data distribution
- """
- _agg_cols = lf.collect_schema().names()
- avail = [c for c in _ANOMALY_FEATURE_COLS if c in _agg_cols]
- if not avail:
- return lf
-
- try:
- # Step 1-2: Sample up to 10K rows for weight learning
- n_rows = lf.select(pl.len()).collect(streaming=True).item()
- sample_lf = lf if n_rows <= 10000 else lf.sample(n=10000, seed=42)
- df_sample = sample_lf.select(avail).collect(streaming=True)
- mat = df_sample.to_numpy()
- mat = np.nan_to_num(mat, nan=0.0)
-
- # Step 3: SVD weight learning
- from sklearn.decomposition import TruncatedSVD
- n_comp = min(1, min(mat.shape) - 1)
- if n_comp < 1 or mat.shape[1] < 2:
- return lf
- svd = TruncatedSVD(n_components=1, random_state=42).fit(mat)
- raw_weights = np.abs(svd.components_[0])
- # Normalise to sum = 1
- weights = dict(zip(avail, raw_weights / raw_weights.sum()))
-
- # Step 4: Weighted sum expression (all vectorised, no loop)
- expr_parts = []
- for col, w in weights.items():
- if col == 'modern_tls_rate':
- # modern_tls_rate is good — invert for anomaly scoring
- expr_parts.append((1.0 - pl.col(col).fill_null(0.5)) * w)
- else:
- expr_parts.append(pl.col(col).fill_null(0.0) * w)
- if expr_parts:
- proxy_expr = expr_parts[0]
- for p in expr_parts[1:]:
- proxy_expr = proxy_expr + p
- else:
- return lf
-
- lf = lf.with_columns(proxy_expr.clip(0, 1).alias('_raw_score'))
-
- # Step 5: Percentile rank calibration (0-1, relative position)
- sample_scores = df_sample.select(
- pl.sum_horizontal([
- (1.0 - pl.col('modern_tls_rate').fill_null(0.5)) * weights.get('modern_tls_rate', 0)
- if c == 'modern_tls_rate'
- else pl.col(c).fill_null(0.0) * weights.get(c, 0)
- for c in avail
- ]).clip(0, 1)
- ).to_series().to_numpy()
-
- # Compute IQR thresholds from sample
- q25, q50, q75 = np.percentile(sample_scores, [25, 50, 75])
- iqr = q75 - q25
- upper_fence = float(q75 + 1.5 * iqr) if iqr > 0 else 0.9
- extreme_fence = float(q75 + 3.0 * iqr) if iqr > 0 else 0.99
-
- # Step 6: Risk level labels (0.0-1.0)
- lf = lf.with_columns([
- pl.col('_raw_score').alias('proxy_score'),
- pl.when(pl.col('_raw_score') >= extreme_fence)
- .then(pl.lit('critical'))
- .when(pl.col('_raw_score') >= upper_fence)
- .then(pl.lit('suspicious'))
- .when(pl.col('_raw_score') >= q50)
- .then(pl.lit('watch'))
- .otherwise(pl.lit('normal'))
- .alias('risk_level'),
- (pl.col('_raw_score') * 0.6 + 0.4 * (pl.col('_raw_score') > q75).cast(pl.Float64))
- .alias('threat_score'),
- ]).drop('_raw_score')
-
- valid_counts = {c: int((mat[:, i] != 0).sum()) for i, c in enumerate(avail)}
- import logging
- logger = logging.getLogger(__name__)
- logger.info(
- '[SCORE] adaptive weights from SVD: %s | thresholds: p50=%.3f upper=%.3f extreme=%.3f | sample=%d/%d',
- {c: f'{w:.3f}' for c, w in sorted(weights.items(), key=lambda x: -x[1])},
- float(q50), upper_fence, extreme_fence,
- min(n_rows, 10000), n_rows,
- )
-
- except Exception as exc:
- import logging
- logging.getLogger(__name__).warning('[SCORE] adaptive scoring skipped: %s', exc)
-
- return lf
-
-
-# ── 12. compute_scores ─────────────────────────────────────────────────────
-
-async def _handle_compute_scores(dataset_id: str) -> dict:
- """Compute adaptive proxy/anomaly/threat scores on entity data."""
- store = SessionStore()
- entry = store.get_dataset(dataset_id)
- if entry is None:
- return {'error': f'Dataset not found: {dataset_id}', 'truncated': False}
-
- lf = entry['lazyframe']
- lf = _add_adaptive_scores(lf, [])
- # Update schema to include new score columns (proxy_score, risk_level, threat_score)
- try:
- updated_schema = lf.collect_schema()
- new_schema = {name: str(dtype) for name, dtype in
- zip(updated_schema.names(), updated_schema.dtypes())}
- except Exception:
- new_schema = entry.get('schema', {})
- store.store_dataset(dataset_id, lf, schema=new_schema,
- metadata=entry.get('metadata', {}))
- return {'status': 'scored', 'dataset_id': dataset_id}
-
-
-# ── 13. filter_and_cluster ─────────────────────────────────────────────────
-
-async def _handle_filter_and_cluster(
- dataset_id: str,
- filters: list[dict],
- logic: str = 'and',
- algorithm: str = 'hdbscan',
- params: dict | None = None,
-) -> dict:
- """Apply column-level filters then run clustering on the filtered result."""
- store = SessionStore()
- entry = store.get_dataset(dataset_id)
- if entry is None:
- return {'error': f'Dataset not found: {dataset_id}', 'truncated': False}
-
- lf: pl.LazyFrame = entry['lazyframe']
- full_schema = entry.get('schema', {})
-
- # ── Step 1: Apply filters ─────────────────────────────────────────────
- try:
- exprs = [_build_filter_expr(
- f['column'], f.get('op', 'eq'), f.get('value'),
- ) for f in filters]
-
- if exprs:
- if logic == 'and':
- combined = exprs[0]
- for e in exprs[1:]:
- combined = combined & e
- else:
- combined = exprs[0]
- for e in exprs[1:]:
- combined = combined | e
- filtered_lf = lf.filter(combined)
- else:
- filtered_lf = lf
-
- # Estimate row count (fast streaming count)
- try:
- row_count = filtered_lf.select(pl.len()).collect(streaming=True).item()
- except Exception:
- row_count = None
-
- except Exception:
- raise
-
- # ── Step 2: Auto-detect numeric feature columns ───────────────────────
- try:
- available = filtered_lf.collect_schema()
- available_names = available.names()
- available_dtypes = available.dtypes()
- numeric_dtypes = (pl.Int8, pl.Int16, pl.Int32, pl.Int64,
- pl.UInt8, pl.UInt16, pl.UInt32, pl.UInt64,
- pl.Float32, pl.Float64)
- feature_cols = [
- available_names[i] for i, dt in enumerate(available_dtypes)
- if dt in numeric_dtypes and not available_names[i].startswith('_')
- ][:10]
-
- if not feature_cols:
- return {
- 'error': 'No numeric columns found for clustering after filtering',
- 'filtered_dataset_id': None,
- 'truncated': False,
- }
- except Exception:
- raise
-
- # ── Step 3: Store filtered dataset ────────────────────────────────────
- filtered_dataset_id = _generate_id('flt')
- store.store_dataset(
- dataset_id=filtered_dataset_id,
- lazyframe=filtered_lf,
- schema=full_schema,
- metadata={
- 'row_count': row_count,
- 'parent_dataset_id': dataset_id,
- 'filter_count': len(filters),
- 'logic': logic,
- },
- parent_id=dataset_id,
- )
-
- # ── Step 4: Run clustering (inlined from _handle_run_clustering) ───────
- p = params or {}
- try:
- # Downsample before collect
- try:
- total_count = filtered_lf.select(pl.len()).collect(streaming=True).item()
- except Exception:
- total_count = 0
-
- clamp_lf = filtered_lf
- if total_count > 100000:
- clamp_lf = filtered_lf.collect(streaming=True).sample(n=100000, seed=42).lazy()
-
- df_features = clamp_lf.select(feature_cols).collect(streaming=True)
-
- # Drop all-NaN columns
- for col in df_features.columns:
- if df_features[col].is_null().all():
- df_features = df_features.drop(col)
- feature_cols = [c for c in feature_cols if c != col]
-
- if df_features.width == 0:
- return {'error': 'No valid feature columns after dropping all-NaN columns',
- 'filtered_dataset_id': filtered_dataset_id, 'truncated': False}
-
- data = df_features.to_numpy()
-
- # Fill remaining NaN with column mean
- if data.dtype.kind == 'f':
- col_mean = np.nanmean(data, axis=0)
- col_mean = np.nan_to_num(col_mean, nan=0.0)
- data = np.where(np.isnan(data), col_mean, data)
-
- # Too-few-samples guard
- if len(data) < 3:
- return {
- 'cluster_result_id': _generate_id('clust'),
- 'n_clusters': 0,
- 'n_noise': 0,
- 'quality_metrics': {'note': 'too few samples'},
- 'filtered_dataset_id': filtered_dataset_id,
- 'truncated': False,
- }
-
- # Low memory check
- low_memory = False
- try:
- import psutil
- mem = psutil.virtual_memory()
- low_memory = mem.available < 2 * 1024 ** 3
- except ImportError:
- pass
-
- if low_memory and len(data) > 100000:
- rng = np.random.default_rng(42)
- idx = rng.choice(len(data), 50000, replace=False)
- data = data[idx]
-
- # Downsample to 50K max
- _ds_idx = None
- if len(data) > 50000:
- rng = np.random.default_rng(42)
- _ds_idx = rng.choice(len(data), 50000, replace=False)
- data = data[_ds_idx]
-
- # Variance filtering
- if data.shape[1] > 1:
- variances = np.var(data, axis=0)
- keep_var = np.where(variances >= 1e-10)[0]
- if len(keep_var) == 0:
- keep_var = np.arange(data.shape[1])
- if len(keep_var) < data.shape[1]:
- data = data[:, keep_var]
- feature_cols = [feature_cols[i] for i in keep_var]
-
- # Standard scale
- from sklearn.preprocessing import StandardScaler
- data_scaled = StandardScaler().fit_transform(data)
-
- # Correlation filtering
- if data_scaled.shape[1] > 1:
- corr = np.corrcoef(data_scaled.T)
- upper = np.triu(np.abs(corr), 1)
- to_drop = [j for j in range(upper.shape[1]) if any(upper[:, j] > 0.95)]
- if to_drop:
- keep = [j for j in range(data_scaled.shape[1]) if j not in to_drop]
- data_scaled = data_scaled[:, keep]
- feature_cols = [feature_cols[j] for j in keep]
-
- # SVD dimensionality reduction (if >50 features)
- n_features = data_scaled.shape[1]
- if n_features > 50:
- from sklearn.decomposition import TruncatedSVD
- data_scaled = TruncatedSVD(n_components=50, random_state=42).fit_transform(data_scaled)
-
- # Hyperparameter auto-tuning
- n = len(data_scaled)
- if algorithm == 'hdbscan':
- min_cluster = p.get('min_cluster_size', max(3, min(50, n // 20)))
- p['min_cluster_size'] = min_cluster
- p['min_samples'] = p.get('min_samples', min_cluster)
- p['cluster_selection_epsilon'] = p.get('cluster_selection_epsilon', 0.0)
- else:
- p['min_cluster_size'] = min(
- p.get('min_cluster_size', 5),
- max(n // 2, 2),
- )
-
- # Fit model
- from sklearn.cluster import HDBSCAN, MiniBatchKMeans
- if algorithm == 'kmeans':
- n_clusters_param = p.get('n_clusters', 3)
- model = MiniBatchKMeans(
- n_clusters=n_clusters_param, random_state=42,
- batch_size=1024, n_init='auto',
- )
- else:
- model = HDBSCAN(
- min_cluster_size=p['min_cluster_size'],
- min_samples=p.get('min_samples', None),
- metric=p.get('metric', 'euclidean'),
- cluster_selection_epsilon=p.get('cluster_selection_epsilon', 0.0),
- )
-
- labels_array = model.fit_predict(data_scaled)
- n_clusters_found = int(len(set(labels_array)) - (1 if -1 in labels_array else 0))
- noise_count = int((labels_array == -1).sum()) if algorithm == 'hdbscan' else 0
- noise_ratio = round(noise_count / len(labels_array), 4) if algorithm == 'hdbscan' else 0.0
-
- # Quality metrics
- quality = {
- 'n_clusters': n_clusters_found,
- 'n_noise': noise_count,
- 'noise_ratio': noise_ratio,
- 'algorithm': algorithm,
- }
-
- if n_clusters_found > 1:
- try:
- from sklearn.metrics import silhouette_score
- score = silhouette_score(data_scaled, labels_array, random_state=42)
- quality['silhouette_score'] = round(float(score), 4)
- except Exception:
- quality['silhouette_score'] = None
- try:
- from sklearn.metrics import davies_bouldin_score
- dbs = davies_bouldin_score(data_scaled, labels_array)
- quality['davies_bouldin_score'] = round(float(dbs), 4)
- except Exception:
- quality['davies_bouldin_score'] = None
- try:
- from sklearn.metrics import calinski_harabasz_score
- chs = calinski_harabasz_score(data_scaled, labels_array)
- quality['calinski_harabasz_score'] = round(float(chs), 4)
- except Exception:
- quality['calinski_harabasz_score'] = None
-
- # Cluster sizes
- cluster_sizes = {}
- for label in np.unique(labels_array):
- cluster_sizes[int(label)] = int((labels_array == label).sum())
- quality['cluster_sizes'] = cluster_sizes
-
- except Exception:
- raise
-
- result_id = _generate_id('clust')
- store.store_cluster_result(
- result_id=result_id,
- labels=labels_array.tolist(),
- n_clusters=n_clusters_found,
- params={
- 'algorithm': algorithm,
- 'params': p,
- 'random_state': 42,
- 'feature_columns': feature_cols,
- '_ds_idx': _ds_idx.tolist() if _ds_idx is not None else None,
- },
- parent_dataset_id=filtered_dataset_id,
- )
-
- return {
- 'cluster_result_id': result_id,
- 'filtered_dataset_id': filtered_dataset_id,
- 'n_clusters': n_clusters_found,
- 'n_noise': noise_count,
- 'labels_sample': labels_array[:50].tolist(),
- 'quality_metrics': quality,
- 'feature_columns': feature_cols,
- }
-
-
-# ── 14. detect_anomalies ──────────────────────────────────────────────────
-
-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
- store = SessionStore()
- entry = store.get_dataset(dataset_id)
- if entry is None:
- return {'error': f'Dataset not found: {dataset_id}', 'truncated': False}
-
- lf: pl.LazyFrame = entry['lazyframe']
- schema = entry.get('schema', {})
- numeric_types = {'Int8', 'Int16', 'Int32', 'Int64', 'UInt8', 'UInt16', 'UInt32', 'UInt64', 'Float32', 'Float64'}
- num_cols = [c for c in schema if schema[c].split('(')[0].strip() in numeric_types and not c.startswith('_')]
- if not num_cols:
- return {'error': 'No numeric columns for anomaly detection', 'truncated': False}
-
- # Collect scored data (already sampled by pipeline head param)
- df = lf.select(num_cols).collect(streaming=True)
- mat = df.to_numpy()
- mat = np.nan_to_num(mat, nan=0.0)
-
- # Isolation Forest in chunks
- n = len(mat)
- chunk_size = min(50000, n)
- labels = np.zeros(n, dtype=int)
- for start in range(0, n, chunk_size):
- end = min(start + chunk_size, n)
- iso = IsolationForest(n_estimators=100, max_samples=min(256, chunk_size),
- contamination=contamination, random_state=42, n_jobs=-1)
- labels[start:end] = iso.fit_predict(mat[start:end])
-
- anomaly_count = int((labels == -1).sum())
- return {
- 'status': 'completed',
- 'total_entities': n,
- 'anomaly_count': anomaly_count,
- 'anomaly_rate': round(anomaly_count / max(n, 1), 4),
- 'dataset_id': dataset_id,
- }
-
-
-# ── 15. visualize_anomalies ───────────────────────────────────────────────
-
-async def _handle_visualize_anomalies(dataset_id: str) -> dict:
- """Generate UMAP-2D embedding for anomaly visualization."""
- from sklearn.preprocessing import StandardScaler
- import umap
- store = SessionStore()
- entry = store.get_dataset(dataset_id)
- if entry is None:
- return {'error': f'Dataset not found: {dataset_id}', 'truncated': False}
-
- lf: pl.LazyFrame = entry['lazyframe']
- schema = entry.get('schema', {})
- numeric_types = {'Int8', 'Int16', 'Int32', 'Int64', 'UInt8', 'UInt16', 'UInt32', 'UInt64', 'Float32', 'Float64'}
- num_cols = [c for c in schema if schema[c].split('(')[0].strip() in numeric_types and not c.startswith('_')]
- if len(num_cols) < 2:
- return {'error': 'Need at least 2 numeric columns for UMAP', 'truncated': False}
-
- # Sample for UMAP visualisation (max 10K)
- try:
- n_total = lf.select(pl.len()).collect(streaming=True).item()
- except Exception:
- n_total = 0
- sample_lf = lf if n_total <= 10000 else lf.collect(streaming=True).sample(n=10000, seed=42).lazy()
- df = sample_lf.select(num_cols).collect(streaming=True)
- mat = StandardScaler().fit_transform(df.to_numpy())
- mat = np.nan_to_num(mat, nan=0.0)
- reducer = umap.UMAP(n_components=2, random_state=42,
- n_neighbors=15, min_dist=0.1, metric='euclidean')
- coords = reducer.fit_transform(mat)
-
- risk_col = next((c for c in schema if 'risk' in c.lower()), None)
- score_col = next((c for c in schema if 'proxy_score' in c.lower() or 'threat' in c.lower()), None)
-
- non_num = [c for c in df.columns if c not in num_cols and not c.startswith('_')]
- ent_col = non_num[0] if non_num else df.columns[0]
- entities = df.select(ent_col).to_series().to_list() if ent_col in df.columns else []
-
- scatter = [
- {'x': float(coords[i, 0]), 'y': float(coords[i, 1]),
- 'entity': str(entities[i]) if i < len(entities) else '',
- 'score': round(float(mat[i].mean()), 4)}
- for i in range(len(coords))
- ]
- return {
- 'status': 'completed',
- 'scatter_points': len(scatter),
- 'scatter_data': scatter[:200], # cap for LLM context
- 'dataset_id': dataset_id,
- }
-
-
-# ── 16. validate_data ──────────────────────────────────────────────────
-
-async def _handle_validate_data(dataset_id: str, fix_issues: bool = False) -> dict:
- """Check data quality: schema consistency, missing values, type conflicts."""
- store = SessionStore()
- entry = store.get_dataset(dataset_id)
- if entry is None:
- return {'error': f'Dataset not found: {dataset_id}', 'truncated': False}
- lf = entry['lazyframe']
- schema = entry.get('schema', {})
- issues = []
- try:
- df_sample = lf.head(1000).collect(streaming=True)
- for col in schema:
- null_count = df_sample[col].is_null().sum()
- if null_count > 500:
- issues.append(f'{col}: {null_count/10:.0f}% null')
- try:
- unique = df_sample[col].n_unique()
- if unique <= 1 and null_count == 0:
- issues.append(f'{col}: constant value (no variation)')
- except Exception:
- pass # n_unique not supported for this column type
- except Exception as exc:
- raise
- total_rows = lf.select(pl.len()).collect(streaming=True).item()
- return {
- 'status': 'ok',
- 'total_rows': total_rows,
- 'columns': len(schema),
- 'issues_found': len(issues),
- 'issues': issues[:20],
- 'recommendation': 'Try explore_distributions for detailed column analysis' if issues else 'No issues found',
- }
-
-
-# ── 17. explore_distributions ──────────────────────────────────────────
-
-async def _handle_explore_distributions(dataset_id: str, columns: list[str] = None,
- max_sample: int = 10000) -> dict:
- """Per-column distribution statistics."""
- store = SessionStore()
- entry = store.get_dataset(dataset_id)
- if entry is None:
- return {'error': f'Dataset not found: {dataset_id}', 'truncated': False}
- lf = entry['lazyframe']
- schema = entry.get('schema', {})
- numeric_types = {'Int8', 'Int16', 'Int32', 'Int64', 'UInt8', 'UInt16', 'UInt32', 'UInt64', 'Float32', 'Float64'}
-
- if not columns:
- columns = [c for c in schema if schema[c].split('(')[0].strip() in numeric_types][:10]
- # Limit columns to prevent excessive computation
- if len(columns) > 20:
- columns = columns[:20]
-
- try:
- n = lf.select(pl.len()).collect(streaming=True).item()
- if n > max_sample:
- # Use LazyFrame.sample() on the limited columns to avoid full-materialize
- sample_lf = lf.select(columns).sample(n=max_sample, seed=42)
- else:
- sample_lf = lf.select(columns)
- df = sample_lf.collect(streaming=True)
- except Exception as exc:
- return {'error': f'explore_distributions failed: {exc}', 'truncated': False}
-
- stats = {}
- for col in columns:
- try:
- s = df[col]
- stats[col] = {
- 'dtype': str(s.dtype),
- 'null_pct': round(float(s.is_null().mean() * 100), 2),
- 'unique': int(s.n_unique()),
- 'top_values': [str(v) for v in s.head(5).to_list()],
- }
- if s.dtype in (pl.Float32, pl.Float64, pl.Int32, pl.Int64):
- stats[col]['min'] = float(s.min()) if s.min() is not None else None
- stats[col]['max'] = float(s.max()) if s.max() is not None else None
- stats[col]['mean'] = round(float(s.mean()), 4) if s.mean() is not None else None
- stats[col]['std'] = round(float(s.std()), 4) if s.std() is not None else None
- except Exception:
- stats[col] = {'error': 'failed to compute'}
- return {'columns': stats, 'total_rows': n, 'sampled': min(n, max_sample)}
-
-
-# ── 18. find_outliers ─────────────────────────────────────────────────
-
-async def _handle_find_outliers(dataset_id: str, columns: list[str] = None,
- threshold: float = 3.0) -> dict:
- """Find statistical outliers using IQR method."""
- store = SessionStore()
- entry = store.get_dataset(dataset_id)
- if entry is None:
- return {'error': f'Dataset not found: {dataset_id}', 'truncated': False}
- lf = entry['lazyframe']
- schema = entry.get('schema', {})
- numeric_types = {'Int8', 'Int16', 'Int32', 'Int64', 'UInt8', 'UInt16', 'UInt32', 'UInt64', 'Float32', 'Float64'}
- if not columns:
- columns = [c for c in schema if schema[c].split('(')[0].strip() in numeric_types]
- try:
- n = lf.select(pl.len()).collect(streaming=True).item()
- sample_lf = lf if n <= 100000 else lf.collect(streaming=True).sample(n=100000, seed=42).lazy()
- df = sample_lf.select(columns).collect(streaming=True)
- except Exception as exc:
- raise
-
- outlier_counts = {}
- for col in columns:
- try:
- s = df[col]
- q1, q3 = s.quantile(0.25), s.quantile(0.75)
- iqr = q3 - q1
- lo = q1 - threshold * iqr
- hi = q3 + threshold * iqr
- n_out = ((s < lo) | (s > hi)).sum()
- if n_out > 0:
- outlier_counts[col] = int(n_out)
- except Exception:
- pass # quantile not supported for this column type
- return {'outlier_counts': outlier_counts, 'total_sampled': len(df), 'threshold_iqr': threshold}
-
-
-# ── 19. diagnose_clustering ───────────────────────────────────────────
-
-async def _handle_diagnose_clustering(dataset_id: str, cluster_result_id: str) -> dict:
- """Diagnose clustering quality issues."""
- from sklearn.decomposition import TruncatedSVD
- store = SessionStore()
- entry = store.get_dataset(dataset_id)
- cluster_entry = store.get_cluster_result(cluster_result_id)
- if entry is None:
- return {'error': f'Dataset not found: {dataset_id}', 'truncated': False}
- if cluster_entry is None:
- return {'error': f'Cluster result not found: {cluster_result_id}', 'truncated': False}
- lf = entry['lazyframe']
- schema = entry.get('schema', {})
- numeric_types = {'Int8', 'Int16', 'Int32', 'Int64', 'UInt8', 'UInt16', 'UInt32', 'UInt64', 'Float32', 'Float64'}
- num_cols = [c for c in schema if schema[c].split('(')[0].strip() in numeric_types and not c.startswith('_')]
-
- n_clusters = cluster_entry.get('n_clusters', 0)
- n_noise = cluster_entry.get('n_noise', 0)
- quality = cluster_entry.get('quality_metrics', {})
- labels = cluster_entry.get('labels', [])
- params = cluster_entry.get('params', {})
- algo = params.get('algorithm', 'unknown')
- feature_cols = params.get('feature_columns', [])
-
- diagnosis = []
- if n_clusters <= 1:
- diagnosis.append('Only 1 cluster — data may lack structure or need feature scaling')
- if n_noise > 0.5 * len(labels) if labels else False:
- diagnosis.append(f'High noise ratio ({n_noise}/{len(labels)}) — try HDBSCAN with larger min_cluster_size')
- sil = quality.get('silhouette_score')
- if sil and sil < 0.2:
- diagnosis.append(f'Low silhouette ({sil:.3f}) — clusters overlap, try KMeans or feature selection')
- if len(feature_cols) < 3:
- diagnosis.append(f'Only {len(feature_cols)} features — add more columns for meaningful clusters')
-
- # SVD variance check
- try:
- df_sample = lf.select(num_cols).head(5000).collect(streaming=True)
- mat = df_sample.to_numpy()
- n_comp = min(mat.shape)
- svd = TruncatedSVD(n_components=n_comp, random_state=42).fit(mat)
- var_explained = svd.explained_variance_ratio_
- if var_explained[0] > 0.95:
- diagnosis.append(f'First SVD component explains {var_explained[0]:.0%} variance — data nearly 1D')
- except Exception:
- pass # SVD fit failed — likely constant or all-null columns
-
- return {
- 'diagnosis': diagnosis,
- 'n_clusters': n_clusters,
- 'n_noise': n_noise,
- 'silhouette': sil,
- 'algorithm': algo,
- 'n_features': len(feature_cols),
- 'suggestions': [
- f'Try algorithm={"kmeans" if algo == "hdbscan" else "hdbscan"}',
- 'Increase min_cluster_size for fewer, larger clusters',
- 'Use explore_distributions to check feature quality',
- ],
- }
-
-
-# ── 20. compare_datasets ─────────────────────────────────────────────
-
-async def _handle_compare_datasets(dataset_id_a: str, dataset_id_b: str) -> dict:
- """Compare two datasets."""
- store = SessionStore()
- entry_a, entry_b = store.get_dataset(dataset_id_a), store.get_dataset(dataset_id_b)
- if entry_a is None or entry_b is None:
- return {'error': 'One or both datasets not found', 'truncated': False}
-
- def _get_info(entry):
- schema = entry.get('schema', {})
- lf = entry['lazyframe']
- try:
- n = lf.select(pl.len()).collect(streaming=True).item()
- except Exception:
- n = -1
- return {'schema': set(schema.keys()), 'row_count': n, 'columns': len(schema)}
-
- info_a, info_b = _get_info(entry_a), _get_info(entry_b)
- common_cols = info_a['schema'] & info_b['schema']
- only_a = info_a['schema'] - info_b['schema']
- only_b = info_b['schema'] - info_a['schema']
- return {
- 'dataset_a': {'id': dataset_id_a, **info_a},
- 'dataset_b': {'id': dataset_id_b, **info_b},
- 'common_columns': len(common_cols),
- 'columns_only_in_a': list(only_a)[:10] if only_a else [],
- 'columns_only_in_b': list(only_b)[:10] if only_b else [],
- 'row_diff': info_a['row_count'] - info_b['row_count'],
- 'identical_schema': len(only_a) == 0 and len(only_b) == 0,
- }
-
-
-# ── 21. export_debug_sample ───────────────────────────────────────────
-
-async def _handle_export_debug_sample(dataset_id: str, max_rows: int = 50) -> dict:
- """Export raw data sample as JSON for debugging."""
- store = SessionStore()
- entry = store.get_dataset(dataset_id)
- if entry is None:
- return {'error': f'Dataset not found: {dataset_id}', 'truncated': False}
- try:
- df = entry['lazyframe'].head(max_rows).collect(streaming=True)
- rows = df.to_dict(as_series=False)
- # Convert to list-of-dicts, serialize safely
- result = []
- for i in range(len(df)):
- row = {}
- for col in df.columns:
- val = rows[col][i]
- if isinstance(val, (float, int, str, bool)):
- row[col] = val
- elif val is None:
- row[col] = None
- else:
- row[col] = str(val)
- result.append(row)
- return {'rows': result, 'count': len(result), 'columns': df.columns}
- except Exception as exc:
- raise
-
-
-# ── 22. repair_schema ─────────────────────────────────────────────────
-
-async def _handle_repair_schema(dataset_id: str) -> dict:
- """Attempt to repair schema mismatches by normalising column names."""
- store = SessionStore()
- entry = store.get_dataset(dataset_id)
- if entry is None:
- return {'error': f'Dataset not found: {dataset_id}', 'truncated': False}
- lf = entry['lazyframe']
- schema = entry.get('schema', {})
- # Normalise: replace hyphens/underscores/dots, lowercase
- col_map = {}
- for c in schema:
- norm = c.lower().replace('-', '_').replace('.', '_')
- col_map[c] = norm
- if len(set(col_map.values())) < len(col_map):
- # Conflicts exist — can't auto-repair
- return {'status': 'conflict', 'message': 'Normalised names conflict', 'column_map': col_map}
- # Rename columns
- renames = {old: new for old, new in col_map.items() if old != new}
- if renames:
- lf = lf.rename(renames)
- new_schema = {col_map.get(c, c): dtype for c, dtype in schema.items()}
- store.store_dataset(dataset_id, lf, schema=new_schema, metadata=entry.get('metadata', {}))
- return {'status': 'repaired', 'renamed': len(renames), 'column_map': col_map}
- return {'status': 'no_change', 'message': 'Column names already normalised'}
-
-
-# ── 23. analyze_patterns ──────────────────────────────────────────────
-
-async def _handle_analyze_patterns(dataset_id: str, top_n: int = 10) -> dict:
- """Read-only: traffic pattern analysis."""
- store = SessionStore()
- entry = store.get_dataset(dataset_id)
- if entry is None:
- return {'error': 'Dataset not found', 'truncated': False}
- lf = entry['lazyframe']
- schema = entry.get('schema', {})
- n = lf.select(pl.len()).collect(streaming=True).item()
- df = lf.head(100000).collect(streaming=True)
-
- result = {'total_rows': n, 'sampled': min(n, 100000)}
-
- # Top source IPs
- for col_name, label in [(':ips', 'src_ips'), (':ipd', 'dst_ips'),
- ('snam', 'snis'), ('cnam', 'cert_names')]:
- if col_name in df.columns:
- try:
- top = df[col_name].value_counts(sort=True).head(top_n)
- result[label] = {
- 'top': [{'value': str(r[col_name]), 'count': int(r['count'])}
- for r in top.iter_rows(named=True)],
- 'unique': int(df[col_name].n_unique()),
- }
- except Exception:
- pass # value_counts not supported for column, skip
-
- # Port distribution
- for port_col in [':prs', ':prd']:
- if port_col in df.columns:
- try:
- top = df[port_col].value_counts(sort=True).head(top_n)
- result[f'{port_col}_dist'] = {
- 'top': [{'port': int(r[port_col]), 'count': int(r['count'])}
- for r in top.iter_rows(named=True)],
- 'unique': int(df[port_col].n_unique()),
- }
- except Exception:
- pass # port distribution extraction failed — skip
-
- # TLS version breakdown
- for tls_col in ['0ver']:
- if tls_col in df.columns:
- try:
- vc = df[tls_col].value_counts(sort=True)
- result['tls_versions'] = [{'version': str(r[tls_col]), 'count': int(r['count'])}
- for r in vc.iter_rows(named=True)]
- except Exception:
- pass # TLS version value_counts not supported — skip
-
- return result
-
-
-# ── 24. analyze_temporal ─────────────────────────────────────────────
-
-async def _handle_analyze_temporal(dataset_id: str, timestamp_column: str = None) -> dict:
- """Read-only: temporal traffic pattern analysis."""
- store = SessionStore()
- entry = store.get_dataset(dataset_id)
- if entry is None:
- return {'error': 'Dataset not found', 'truncated': False}
- lf = entry['lazyframe']
- schema = entry.get('schema', {})
-
- if not timestamp_column:
- for cand in ['timestamp', 'time', '8dbd', '8did']:
- if cand in schema:
- timestamp_column = cand
- break
- if not timestamp_column:
- return {'error': 'No timestamp column found', 'truncated': False}
-
- n = lf.select(pl.len()).collect(streaming=True).item()
- return {'total_rows': n, 'timestamp_column': timestamp_column,
- 'note': 'Temporal analysis requires parsed timestamps; raw values shown in explore_distributions'}
-
-
-# ── 24b. analyze_fft ─────────────────────────────────────────────────
-
-async def _handle_analyze_fft(
- dataset_id: str,
- timestamp_column: str,
- top_n: int = 5,
-) -> dict:
- """FFT spectrum analysis on a timestamp column to detect periodic patterns.
-
- Computes the Fast Fourier Transform of sorted timestamp-derived values,
- returning dominant frequencies, spectral centroid, and a periodicity score.
- """
- store = SessionStore()
- entry = store.get_dataset(dataset_id)
- if entry is None:
- return {'error': f'Dataset not found: {dataset_id}', 'truncated': False}
-
- lf: pl.LazyFrame = entry['lazyframe']
- schema = entry.get('schema', {})
-
- # ── Resolve timestamp column ──────────────────────────────────────
- col = None
- # Try exact match first
- if timestamp_column in schema:
- col = timestamp_column
- else:
- # Try case-insensitive match
- col_lower = timestamp_column.lower()
- for c in schema:
- if c.lower() == col_lower:
- col = c
- break
- if col is None:
- return {'error': f"Timestamp column '{timestamp_column}' not found in dataset. Available: {list(schema.keys())[:20]}",
- 'truncated': False}
-
- # ── Extract and sort values ───────────────────────────────────────
- df = lf.select(pl.col(col).alias('__ts__')).collect(streaming=True)
-
- n_raw = len(df)
- if n_raw < 4:
- return {'error': f'Need at least 4 samples for FFT; got {n_raw}', 'truncated': False}
-
- # Determine dtype and convert to numeric values
- ts_series = df['__ts__']
- dtype = ts_series.dtype
- str_dtypes = (pl.Utf8, pl.String)
-
- if dtype in str_dtypes:
- # Try parsing as datetime string, then convert to epoch seconds
- try:
- ts_parsed = ts_series.str.strptime(pl.Datetime, format=None, strict=False)
- ts_numeric = ts_parsed.cast(pl.Int64) / 1_000_000_000.0 # ns → seconds
- except Exception:
- # Fallback: try to cast to float directly
- try:
- ts_numeric = ts_series.cast(pl.Float64)
- except Exception as e:
- return {'error': f'Cannot convert string column to numeric or datetime: {e}',
- 'truncated': False}
- elif dtype in (pl.Datetime, pl.Date):
- ts_numeric = ts_series.cast(pl.Int64) / 1_000_000_000.0
- else:
- # Float, Int types — cast to float
- try:
- ts_numeric = ts_series.cast(pl.Float64)
- except Exception as e:
- return {'error': f'Cannot cast column to float: {e}', 'truncated': False}
-
- # Build DataFrame with values, drop nulls, sort
- df_sorted = pl.DataFrame({'__val__': ts_numeric}).drop_nulls().sort('__val__')
- n = len(df_sorted)
- if n < 4:
- return {'error': f'After removing nulls, only {n} samples remain (need ≥4)', 'truncated': False}
-
- values = df_sorted['__val__'].to_numpy().astype(np.float64)
-
- # ── FFT computation ────────────────────────────────────────────────
- # Center by subtracting mean (DC offset removal)
- values_centered = values - np.mean(values)
- fft_complex = np.fft.fft(values_centered)
- fft_magnitudes = np.abs(fft_complex)
- freqs = np.fft.fftfreq(n)
-
- # Take positive half (excluding DC at index 0)
- pos_mask = freqs > 0
- freqs_pos = freqs[pos_mask]
- mags_pos = fft_magnitudes[pos_mask]
-
- if len(freqs_pos) == 0:
- return {'error': 'Insufficient positive frequencies for FFT analysis', 'truncated': False}
-
- # ── Find top-N peaks ──────────────────────────────────────────────
- peak_indices = np.argsort(mags_pos)[::-1][:min(top_n, len(mags_pos))]
- top_periods = []
- for idx in peak_indices:
- freq = float(freqs_pos[idx])
- mag = float(mags_pos[idx])
- # Convert frequency to period (in sample-index units)
- period = float(1.0 / freq) if freq > 1e-10 else float('inf')
- # Map to time-domain period if data is temporal
- if n > 1:
- time_span = float(values[-1] - values[0])
- sample_period = time_span / max(n - 1, 1)
- time_period = period * sample_period
- else:
- time_period = float('inf')
- top_periods.append({
- 'period': round(period, 2),
- 'magnitude': round(mag, 4),
- 'frequency': round(freq, 6),
- 'time_domain_period_seconds': round(time_period, 2) if time_period != float('inf') else None,
- })
-
- # ── Spectral centroid (weighted mean frequency) ────────────────────
- mag_sum = float(np.sum(mags_pos))
- if mag_sum > 0:
- spectral_centroid = float(np.sum(freqs_pos * mags_pos) / mag_sum)
- else:
- spectral_centroid = 0.0
-
- # ── Periodicity score: ratio of top peak to mean magnitude ────────
- mean_mag = float(np.mean(mags_pos)) if len(mags_pos) > 0 else 1.0
- max_mag = float(mags_pos[peak_indices[0]]) if len(peak_indices) > 0 else 0.0
- periodicity_score = round(max_mag / max(mean_mag, 1e-10), 4)
-
- # ── Return decimated data for LLM context window ──────────────────
- # Decimate to at most 200 frequency-magnitude pairs
- decimate_step = max(1, len(freqs_pos) // 200)
- freqs_decimated = freqs_pos[::decimate_step].tolist()
- mags_decimated = mags_pos[::decimate_step].tolist()
-
- return {
- 'frequencies': [round(float(f), 6) for f in freqs_decimated],
- 'magnitudes': [round(float(m), 4) for m in mags_decimated],
- 'top_periods': top_periods,
- 'spectral_centroid': round(spectral_centroid, 6),
- 'periodicity_score': periodicity_score,
- 'n_samples': n,
- 'n_raw': n_raw,
- 'nan_dropped': n_raw - n,
- 'timestamp_column': col,
- }
-
-
-# ── 25. analyze_tls_health ────────────────────────────────────────────
-
-async def _handle_analyze_tls_health(dataset_id: str) -> dict:
- """Read-only: TLS security assessment."""
- store = SessionStore()
- entry = store.get_dataset(dataset_id)
- if entry is None:
- return {'error': 'Dataset not found', 'truncated': False}
- lf = entry['lazyframe']
- schema = entry.get('schema', {})
- n = lf.select(pl.len()).collect(streaming=True).item()
- df = lf.head(100000).collect(streaming=True)
-
- result = {'total_rows': n, 'sampled': min(n, 100000)}
-
- if '0ver' in df.columns:
- vc = df['0ver'].value_counts(sort=True)
- versions = {}
- for r in vc.iter_rows(named=True):
- v = str(r['0ver']).replace(' ', '')
- if v in ('0303', '0304'):
- versions['tls_1_2_plus'] = versions.get('tls_1_2_plus', 0) + int(r['count'])
- else:
- versions['tls_legacy'] = versions.get('tls_legacy', 0) + int(r['count'])
- total = sum(versions.values())
- result.update({
- 'tls_1_2_plus_pct': round(versions.get('tls_1_2_plus', 0) / max(total, 1) * 100, 1),
- 'tls_legacy_pct': round(versions.get('tls_legacy', 0) / max(total, 1) * 100, 1),
- 'tls_legacy_versions': [{'version': str(r['0ver']), 'count': int(r['count'])}
- for r in vc.iter_rows(named=True)
- if str(r['0ver']).replace(' ', '') not in ('0303', '0304')],
- })
-
- if 'snam' in df.columns:
- empty_sni = df['snam'].is_null().sum() + df['snam'].cast(pl.Utf8).is_in(['', 'null', 'None']).sum()
- result['sni_missing_pct'] = round(float(empty_sni) / max(len(df), 1) * 100, 1)
-
- if 'cipher-suite' in df.columns:
- weak_ciphers = ['rc4', 'cbc', 'null', 'export']
- total_ciphers = len(df)
- weak_count = 0
- for w in weak_ciphers:
- try:
- weak_count += df['cipher-suite'].str.to_lowercase().str.contains(w).sum()
- except Exception:
- pass # str.contains not supported for cipher-suite column — skip
- result['weak_cipher_pct'] = round(float(weak_count) / max(total_ciphers, 1) * 100, 1) if total_ciphers else 0
-
- if result.get('tls_legacy_pct', 0) > 20:
- result['warning'] = 'High proportion of legacy TLS — possible downgrade attacks'
- if result.get('sni_missing_pct', 0) > 50:
- result['warning'] = 'Most flows lack SNI — possible IP-direct connections (proxy/VPN)'
- return result
-
-
-# ── 26. analyze_geo_distribution ─────────────────────────────────────
-
-async def _handle_analyze_geo_distribution(dataset_id: str) -> dict:
- """Read-only: geographic traffic analysis."""
- store = SessionStore()
- entry = store.get_dataset(dataset_id)
- if entry is None:
- return {'error': 'Dataset not found', 'truncated': False}
- lf = entry['lazyframe']
- schema = entry.get('schema', {})
- n = lf.select(pl.len()).collect(streaming=True).item()
- df = lf.head(100000).collect(streaming=True)
-
- result = {'total_rows': n, 'sampled': min(n, 100000)}
-
- for geo_col, label in [('scnt', 'src_countries'), ('dcnt', 'dst_countries')]:
- if geo_col in df.columns:
- try:
- vc = df[geo_col].value_counts(sort=True).head(15)
- result[label] = [{'country': str(r[geo_col]), 'count': int(r['count'])}
- for r in vc.iter_rows(named=True)]
- result[f'{label}_unique'] = int(df[geo_col].n_unique())
- except Exception:
- pass # country distribution not computable — skip
-
- if 'scnt' in df.columns and 'dcnt' in df.columns:
- try:
- same = (df['scnt'] == df['dcnt']).sum()
- result['domestic_pct'] = round(float(same) / max(len(df), 1) * 100, 1)
- result['international_pct'] = round(100.0 - result['domestic_pct'], 1)
- except Exception:
- pass # domestic/international comparison failed — skip
-
- return result
-
-
-# ── 27. analyze_entity_detail ────────────────────────────────────────
-
-async def _handle_analyze_entity_detail(dataset_id: str, entity_value: str) -> dict:
- """Read-only: deep-dive into a single entity."""
- store = SessionStore()
- entry = store.get_dataset(dataset_id)
- if entry is None:
- return {'error': 'Dataset not found', 'truncated': False}
- lf = entry['lazyframe']
- schema = entry.get('schema', {})
-
- # Find entity column (first non-numeric, non-internal column)
- numeric_types = {'Int8', 'Int16', 'Int32', 'Int64', 'UInt8', 'UInt16', 'UInt32', 'UInt64', 'Float32', 'Float64'}
- entity_col = None
- for c in schema:
- if c.startswith('_') or schema[c].split('(')[0].strip() in numeric_types or '.' in c:
- continue
- entity_col = c
- break
- if not entity_col:
- entity_col = list(schema.keys())[0]
-
- try:
- filtered = lf.filter(pl.col(entity_col).cast(pl.Utf8).str.contains(entity_value, literal=True))
- n = filtered.select(pl.len()).collect(streaming=True).item()
- if n == 0:
- return {'error': f"Entity '{entity_value}' not found in column '{entity_col}'", 'truncated': False}
- df = filtered.collect(streaming=True)
- except Exception as exc:
- raise
-
- result = {
- 'entity_value': entity_value,
- 'entity_column': entity_col,
- 'flow_count': n,
- 'columns': list(df.columns[:15]),
- }
-
- # Numeric summary
- for c in df.columns:
- if df[c].dtype in (pl.Float32, pl.Float64, pl.Int32, pl.Int64):
- try:
- result[f'{c}_stats'] = {
- 'mean': round(float(df[c].mean()), 2),
- 'min': float(df[c].min()) if df[c].min() is not None else None,
- 'max': float(df[c].max()) if df[c].max() is not None else None,
- }
- except Exception:
- pass # numeric stats not computable for this column — skip
-
- return result
-
-
-# ── 28. compute_distance_matrix ──────────────────────────────────────────
-
-def _indent_function_body(body: str, indent: str = ' ') -> str:
- """Indent each non-empty line of a code block for wrapping in a def."""
- return '\n'.join(
- indent + line if line.strip() else line
- for line in body.split('\n')
- )
-
-
-async def _handle_compute_distance_matrix(
- dataset_id: str,
- python_function: str,
- columns: Optional[list[str]] = None,
- max_sample: int = 10000,
-) -> dict:
- """Evaluate a user-provided Python function on each row of a dataset.
-
- Wraps the function body as ``def distance_fn(row): ``, executes
- it per-row in a restricted namespace (``math``, ``numpy`` only), and
- returns a distance-score summary plus a sample of scored rows.
- """
- store = SessionStore()
- entry = store.get_dataset(dataset_id)
- if entry is None:
- return {'error': f'Dataset not found: {dataset_id}', 'truncated': False}
-
- lf: pl.LazyFrame = entry['lazyframe']
-
- # Collect data (streaming for large datasets)
- df = lf.collect(streaming=True)
-
- # Determine columns to pass to the function
- if columns is None:
- numeric_dtypes = (pl.Int8, pl.Int16, pl.Int32, pl.Int64,
- pl.UInt8, pl.UInt16, pl.UInt32, pl.UInt64,
- pl.Float32, pl.Float64)
- columns = [c for c in df.columns
- if df[c].dtype in numeric_dtypes]
-
- # Filter to columns that actually exist
- available = set(df.columns)
- columns = [c for c in columns if c in available]
- if not columns:
- return {'error': 'No valid columns found in dataset',
- 'truncated': False}
-
- n_total = len(df)
-
- # Sample if larger than max_sample
- if n_total > max_sample:
- df_sampled = df.sample(n=max_sample, seed=42)
- n_evaluated = max_sample
- else:
- df_sampled = df
- n_evaluated = n_total
-
- # Build list-of-dicts for the selected columns
- rows = df_sampled.select(columns).to_dicts()
-
- # ── Safe execution ────────────────────────────────────────────────────
- safe_builtins = {
- 'abs': abs, 'len': len, 'range': range,
- 'min': min, 'max': max, 'sum': sum, 'round': round,
- 'int': int, 'float': float, 'str': str, 'bool': bool,
- 'list': list, 'dict': dict, 'tuple': tuple,
- 'isinstance': isinstance, 'type': type,
- 'True': True, 'False': False, 'None': None,
- 'enumerate': enumerate, 'zip': zip, 'map': map,
- 'filter': filter, 'sorted': sorted, 'reversed': reversed,
- }
-
- safe_ns = {
- '__builtins__': safe_builtins,
- 'math': __import__('math'),
- 'np': __import__('numpy'),
- }
-
- # Wrap the user's function body in a def statement
- body = python_function.strip()
- if body.startswith('def '):
- # User provided the full function definition
- full_code = body
- else:
- indented = _indent_function_body(body)
- full_code = f'def distance_fn(row):\n{indented}'
-
- try:
- exec(full_code, safe_ns)
- # Resolve: the function name might be 'distance_fn' or whatever the user named it
- distance_fn = safe_ns.get('distance_fn')
- if distance_fn is None:
- # Fallback: try to find any callable in safe_ns
- for _name, _obj in safe_ns.items():
- if callable(_obj) and _name not in ('math', 'np', '__builtins__'):
- distance_fn = _obj
- break
- if distance_fn is None:
- return {'error': 'Could not locate the compiled function',
- 'truncated': False}
- except SyntaxError as e:
- return {'error': f'Function syntax error: {e}', 'truncated': False}
- except Exception as e:
- return {'error': f'Function compilation failed: {e}',
- 'truncated': False}
-
- # ── Apply function per row ────────────────────────────────────────────
- scores: list[float | None] = []
- errors = 0
- for row in rows:
- try:
- score = distance_fn(row)
- if isinstance(score, (int, float)):
- scores.append(float(score))
- else:
- scores.append(None)
- errors += 1
- except Exception:
- scores.append(None)
- errors += 1
-
- valid_scores = [s for s in scores if s is not None]
- if not valid_scores:
- return {'error': 'No valid scores computed — all rows raised errors',
- 'truncated': False}
-
- # ── Distance summary ──────────────────────────────────────────────────
- arr = np.array(valid_scores)
- summary = {
- 'min': float(np.min(arr)),
- 'max': float(np.max(arr)),
- 'mean': float(np.mean(arr)),
- 'std': float(np.std(arr)),
- 'count': len(arr),
- 'errors': errors,
- 'total_rows_in_sample': len(rows),
- 'matrix_shape': [len(rows), len(columns)],
- }
-
- # Sample of scored rows (first 30, keep score + first 3 values)
- scored_sample = []
- for i, row in enumerate(rows[:30]):
- if i < len(scores):
- scored_sample.append({
- 'index': i,
- 'values': {c: row.get(c) for c in columns[:3]},
- 'score': scores[i],
- })
-
- return {
- 'summary': summary,
- 'scored_sample': scored_sample,
- 'columns_used': columns,
- 'total_rows_in_dataset': n_total,
- 'rows_evaluated': n_evaluated,
- }
-
-
-# Auto-profile all public functions in this module
-from analysis.profile_util import auto_profile_module # noqa: E402
-auto_profile_module(__name__)
diff --git a/analysis/views/clustering.py b/analysis/views/clustering.py
index 3dcd195..b4f6c47 100644
--- a/analysis/views/clustering.py
+++ b/analysis/views/clustering.py
@@ -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
@@ -227,7 +227,6 @@ def _run_clustering_pipeline(run, store, ds_id, feature_columns, algorithm,
Operates directly on raw data rows (no entity aggregation).
Per-row results stored in EntityProfile with row index as identifier.
-<<<<<<< HEAD
Args:
run: AnalysisRun ORM object.
store: SessionStore instance.
diff --git a/analysis/views_backup.py b/analysis/views_backup.py
deleted file mode 100644
index e7bd0af..0000000
--- a/analysis/views_backup.py
+++ /dev/null
@@ -1,2308 +0,0 @@
-import json
-import time
-import urllib.request
-import urllib.error
-import threading
-import os
-from pathlib import Path
-
-from analysis.appdata import ensure_appdata_dir
-
-from django.shortcuts import render, get_object_or_404, redirect
-from django.http import HttpResponse, JsonResponse
-from django.views.decorators.csrf import csrf_exempt
-
-from config import get_config, save_config, Config
-from .models import AnalysisRun, ClusterResult, EntityProfile
-from . import nl_describe
-from .profile_util import profile
-
-
-def dashboard(request):
- """Home page: recent runs overview."""
- runs = AnalysisRun.objects.all()[:20]
- return render(request, 'analysis/dashboard.html', {'runs': runs})
-
-
-def run_list(request):
- """List all analysis runs with pagination and optional ?type= filter."""
- run_type_filter = request.GET.get('type')
- page = int(request.GET.get('page', 1))
- per_page = 20
-
- qs = AnalysisRun.objects.all()
- if run_type_filter in dict(AnalysisRun.RUN_TYPE_CHOICES):
- qs = qs.filter(run_type=run_type_filter)
-
- total = qs.count()
- runs = qs[(page - 1) * per_page: page * per_page]
- return render(request, 'analysis/run_list.html', {
- 'runs': runs,
- 'page': page,
- 'total_pages': (total + per_page - 1) // per_page if total else 1,
- 'current_type': run_type_filter or '',
- })
-
-
-def run_detail(request, display_id):
- """Details for a single analysis run."""
- run = get_object_or_404(AnalysisRun, display_id=display_id)
- clusters = run.clusters.all().order_by('-size')
- return render(request, 'analysis/run_detail.html', {
- 'run': run,
- 'clusters': clusters,
- })
-
-
-def _extract_lat(val):
- """Try to extract a latitude from *val* (number or str)."""
- if val is None:
- return None
- try:
- v = float(val)
- return None if v < -90 or v > 90 else v
- except (ValueError, TypeError):
- return None
-
-
-def _extract_lon(val):
- """Try to extract a longitude from *val* (number or str)."""
- if val is None:
- return None
- try:
- v = float(val)
- return None if v < -180 or v > 180 else v
- except (ValueError, TypeError):
- return None
-
-
-def cluster_overview(request, display_id):
- """Overview of all clusters for a run, with UMAP scatter data and geo scatter data."""
- run = get_object_or_404(AnalysisRun, display_id=display_id)
- clusters = run.clusters.exclude(cluster_label=-1).order_by('-size').prefetch_related('features')
- noise = run.clusters.filter(cluster_label=-1).first()
-
- # Build UMAP scatter data for Canvas / Three.js 3D
- entities = run.entities.exclude(embedding_x=None).exclude(embedding_y=None).only(
- 'embedding_x', 'embedding_y', 'embedding_z', 'cluster_label', 'entity_value'
- )
- has_z = any(e.embedding_z is not None for e in entities)
- scatter_data = [
- {'x': e.embedding_x, 'y': e.embedding_y, 'z': e.embedding_z if has_z else 0,
- 'label': e.cluster_label if e.cluster_label is not None else -1, 'entity': e.entity_value}
- for e in entities
- ]
-
- # Noise cluster: features + entity detail
- noise_features = []
- noise_summary = ''
- noise_entity_count = 0
- if noise:
- noise_features_qs = noise.features.all().order_by('-distinguishing_score')[:15]
- noise_features = [
- {'feature_name': f.feature_name, 'score': f.distinguishing_score, 'mean': f.mean,
- 'std': f.std, 'p25': f.p25, 'p75': f.p75}
- for f in noise_features_qs
- ]
- if noise_features:
- noise_summary = nl_describe.describe_cluster(noise_features)
- noise_entity_count = noise.size
-
- # LLM workflow timeline for auto runs
- 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,
- })
-
- # Build geo scatter data from feature_json (lat/lon)
- geo_data = []
- geo_skipped = 0
- for e in run.entities.all().only('feature_json', 'cluster_label', 'entity_value'):
- fj = e.feature_json
- if not fj:
- continue
- lat = _extract_lat(fj.get('avg_latitude'))
- lon = _extract_lon(fj.get('avg_longitude'))
- if lat is not None and lon is not None:
- geo_data.append({
- 'x': lon,
- 'y': lat,
- 'label': e.cluster_label if e.cluster_label is not None else -1,
- 'entity': e.entity_value,
- })
- else:
- geo_skipped += 1
-
- # Cluster sizes for bar chart
- cluster_sizes = [{'label': c.cluster_label, 'size': c.size, 'silhouette': c.silhouette_score}
- for c in clusters]
-
- # Top features per cluster from ClusterFeature + NL summaries
- top_features = {}
- for c in clusters:
- features_qs = c.features.all().order_by('-distinguishing_score')[:10]
- feats = [
- {'feature_name': f.feature_name, 'score': f.distinguishing_score, 'mean': f.mean,
- 'std': f.std}
- for f in features_qs
- ]
- top_features[str(c.cluster_label)] = feats
- # Attach features + NL summary directly to cluster object for template
- c._top_features = feats
- c.nl_summary = nl_describe.describe_cluster(feats) if feats else ''
-
- return render(request, 'analysis/cluster_overview.html', {
- 'run': run,
- 'clusters': clusters,
- 'noise': noise,
- 'scatter_data_json': json.dumps(scatter_data),
- 'has_z': has_z,
- 'geo_data_json': json.dumps(geo_data),
- 'geo_skipped': geo_skipped,
- 'geo_count': len(geo_data),
- 'cluster_sizes_json': json.dumps(cluster_sizes),
- 'top_features_json': json.dumps(top_features),
- 'noise_features': noise_features,
- 'noise_summary': noise_summary,
- 'noise_entity_count': noise_entity_count,
- 'llm_timeline_json': llm_timeline_json,
- # Globe flow data for the sidebar
- 'globe_flows_json': json.dumps(_get_globe_flows(run)),
- })
-
-
-def _get_globe_flows(run, max_rows=500):
- """Extract flow data for 3D globe visualization from a run."""
- import json
- from analysis.data_loader import load_csv_directory, load_from_db
- try:
- 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 None:
- return []
- df = lf.head(max_rows).collect()
- from analysis.geoip import lookup as geo_lookup
- flows = []
- TLS_HEX_MAP = {'0303': 'TLSv1.2', '0304': 'TLSv1.3', '0302': 'TLSv1.1', '0301': 'TLSv1.0'}
- src_ip_col = next((c for c in df.columns if 'src_ip' in c.lower() or ':ips' in c.lower()), None)
- dst_ip_col = next((c for c in df.columns if 'dst_ip' in c.lower() or ':ipd' in c.lower()), None)
- tls_col = next((c for c in df.columns if c.lower() in ('0ver', 'tls_version', '_tlsver', 'version')), None)
- bytes_col = next((c for c in df.columns if c.lower() in ('8ack', '8pak', '8byt', 'bytes_sent', 'bytes')), None)
- for row in df.iter_rows(named=True):
- sip = str(row.get(src_ip_col, '')) if src_ip_col else ''
- dip = str(row.get(dst_ip_col, '')) if dst_ip_col else ''
- if not sip or not dip:
- continue
- sg = geo_lookup(sip)
- dg = geo_lookup(dip)
- if not sg or not dg:
- continue
- tls_val = str(row.get(tls_col, '') or '') if tls_col else ''
- tls_ver = TLS_HEX_MAP.get(tls_val.replace(' ', ''), tls_val)
- flows.append({
- 'slat': sg['lat'], 'slon': sg['lon'],
- 'dlat': dg['lat'], 'dlon': dg['lon'],
- 'tls': tls_ver,
- 'bytes': float(row.get(bytes_col, 0) or 0) if bytes_col else 0,
- })
- return flows
- except Exception:
- return []
-
-
-def cluster_detail(request, display_id, cluster_label):
- """Detailed view of a single cluster with entity list, SVD features, and NL summary."""
- run = get_object_or_404(AnalysisRun, display_id=display_id)
- try:
- cluster_label = int(cluster_label)
- except (ValueError, TypeError):
- 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]
- 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),
- 'nl_summary': nl_summary,
- })
-
-
-def entity_profile(request, entity_id):
- """Detailed profile for a single entity."""
- entity = get_object_or_404(EntityProfile, id=entity_id)
- run = entity.run
-
- # Compute deviation from cluster mean
- deviations = {}
- if entity.feature_json and entity.cluster:
- cluster_features = {
- f.feature_name: {'mean': f.mean, 'std': f.std}
- for f in entity.cluster.features.all()
- }
- for col, val in entity.feature_json.items():
- if col in cluster_features and cluster_features[col].get('std') and cluster_features[col]['std'] > 1e-10:
- deviations[col] = {
- 'value': val,
- 'cluster_mean': cluster_features[col]['mean'],
- 'zscore': (val - cluster_features[col]['mean']) / cluster_features[col]['std'],
- }
-
- return render(request, 'analysis/entity_profile.html', {
- 'entity': entity,
- 'run': run,
- 'deviations': deviations,
- })
-
-
-def start_analysis(request):
- """Placeholder: API endpoint to trigger analysis via MCP."""
- return JsonResponse({'status': 'not_implemented', 'message': 'Use MCP tools to trigger analysis'})
-
-
-def config_view(request):
- """Config page: GET renders current config, POST saves updates."""
- cfg = get_config()
-
- if request.method == 'POST':
- # Build a new Config from POST data (fall back to current values)
- server = Config.Server(
- host=request.POST.get('server_host', cfg.server.host),
- port=int(request.POST.get('server_port', cfg.server.port)),
- debug=request.POST.get('server_debug') == 'true',
- )
- data = Config.Data(
- schema_strict=request.POST.get('data_schema_strict') == 'true',
- recursive=request.POST.get('data_recursive') == 'true',
- )
- clustering = Config.Clustering(
- algorithm=request.POST.get('clustering_algorithm', cfg.clustering.algorithm),
- min_cluster_size=int(request.POST.get('clustering_min_cluster_size', cfg.clustering.min_cluster_size)),
- random_state=cfg.clustering.random_state,
- )
- llm = Config.LLM(
- enabled=request.POST.get('llm_enabled') == 'true',
- base_url=request.POST.get('llm_base_url', cfg.llm.base_url),
- api_key=request.POST.get('llm_api_key', cfg.llm.api_key),
- model=request.POST.get('llm_model', cfg.llm.model),
- )
- cfg = Config(server=server, data=data, clustering=clustering, llm=llm)
- save_config(cfg)
- # Re-render with saved flag
- return render(request, 'tianxuan/config.html', {'config': cfg, 'saved': True})
-
- return render(request, 'tianxuan/config.html', {'config': cfg})
-
-
-@csrf_exempt
-def llm_test(request):
- """LLM connectivity test: POST JSON {base_url, api_key, model} → chat/completions."""
- if request.method != 'POST':
- return JsonResponse({'success': False, 'message': '仅支持 POST 请求', 'latency_ms': 0}, status=405)
-
- try:
- body = json.loads(request.body)
- except json.JSONDecodeError:
- return JsonResponse({'success': False, 'message': '无效的 JSON 请求体', 'latency_ms': 0})
-
- base_url = (body.get('base_url') or '').rstrip('/')
- api_key = body.get('api_key') or ''
- model = body.get('model') or ''
-
- if not base_url:
- return JsonResponse({'success': False, 'message': 'Base URL 不能为空', 'latency_ms': 0})
- if not api_key:
- return JsonResponse({'success': False, 'message': 'API Key 不能为空', 'latency_ms': 0})
- if not model:
- return JsonResponse({'success': False, 'message': 'Model 不能为空', 'latency_ms': 0})
-
- url = f'{base_url}/chat/completions'
- payload = json.dumps({
- 'model': model,
- 'messages': [{'role': 'user', 'content': 'respond with ok'}],
- 'max_tokens': 5,
- }).encode('utf-8')
-
- req = urllib.request.Request(
- url,
- data=payload,
- headers={
- 'Content-Type': 'application/json',
- 'Authorization': f'Bearer {api_key}',
- },
- method='POST',
- )
-
- start = time.monotonic()
- try:
- with urllib.request.urlopen(req, timeout=5) as resp:
- elapsed = int((time.monotonic() - start) * 1000)
- data = json.loads(resp.read().decode('utf-8'))
- if 'choices' in data and len(data['choices']) > 0:
- logger.info(f'[LLM_TEST] base_url={base_url} model={model} success=True latency={elapsed}ms')
- return JsonResponse({
- 'success': True,
- 'message': f'连接成功,模型 {model} 返回正常',
- 'latency_ms': elapsed,
- })
- else:
- logger.info(f'[LLM_TEST] base_url={base_url} model={model} success=True latency={elapsed}ms')
- return JsonResponse({
- 'success': True,
- 'message': f'连接成功,但响应中无 choices(原始响应已记录)',
- 'latency_ms': elapsed,
- })
- except urllib.error.HTTPError as e:
- elapsed = int((time.monotonic() - start) * 1000)
- try:
- detail = json.loads(e.read().decode('utf-8', errors='replace'))
- msg = detail.get('error', {}).get('message', str(e))
- except Exception:
- msg = str(e)
- logger.info(f'[LLM_TEST] base_url={base_url} model={model} success=False latency={elapsed}ms')
- return JsonResponse({'success': False, 'message': f'HTTP {e.code}: {msg}', 'latency_ms': elapsed})
- except urllib.error.URLError as e:
- elapsed = int((time.monotonic() - start) * 1000)
- reason = str(e.reason) if e.reason else '连接失败'
- logger.info(f'[LLM_TEST] base_url={base_url} model={model} success=False latency={elapsed}ms')
- return JsonResponse({'success': False, 'message': f'连接失败: {reason}', 'latency_ms': elapsed})
- except TimeoutError:
- elapsed = int((time.monotonic() - start) * 1000)
- logger.info(f'[LLM_TEST] base_url={base_url} model={model} success=False latency={elapsed}ms')
- return JsonResponse({'success': False, 'message': '请求超时(5秒)', 'latency_ms': elapsed})
- except Exception as e:
- elapsed = int((time.monotonic() - start) * 1000)
- logger.info(f'[LLM_TEST] base_url={base_url} model={model} success=False latency={elapsed}ms')
- return JsonResponse({'success': False, 'message': f'未知错误: {str(e)}', 'latency_ms': elapsed})
-
-
-# ── Upload page ─────────────────────────────────────────────────────────
-
-def upload_page(request):
- """CSV upload page."""
- runs = AnalysisRun.objects.all().order_by('-created_at')[:20]
- return render(request, 'tianxuan/upload.html', {'runs': runs})
-
-
-@csrf_exempt
-def upload_csv(request):
- """Handle CSV upload AJAX POST."""
- if request.method != 'POST':
- return JsonResponse({'error': 'POST required'}, status=405)
- files = request.FILES.getlist('files')
- if not files:
- return JsonResponse({'error': 'No files uploaded'})
- logger.info('[UPLOAD] Received %d files', len(files))
- for f in files:
- if f.size > 500 * 1024 * 1024:
- return JsonResponse({'warning': f'文件 {f.name} 超过 500MB,建议分割后上传'})
- total_size = sum(f.size for f in files)
- if total_size > 50 * 1024 * 1024 * 1024:
- return JsonResponse({'error': '总大小超过 50GB 限制'})
- from django.utils import timezone
- ts = timezone.now().strftime('%Y%m%d_%H%M%S')
- from analysis.appdata import ensure_appdata_dir
- base = ensure_appdata_dir() / 'data' / 'uploads'
- upload_dir = base / ts
- upload_dir.mkdir(parents=True, exist_ok=True)
- saved = []
- for f in files:
- path = upload_dir / f.name
- with open(path, 'wb+') as dst:
- for chunk in f.chunks():
- dst.write(chunk)
- saved.append(str(path))
- # Create run with status='pending' — background processing deferred to finalize endpoint
- run = AnalysisRun.objects.create(
- csv_glob=str(upload_dir / '*.csv'),
- status='pending',
- run_type='upload',
- )
- # Deferred: _background_process will be started by finalize_upload endpoint
- return JsonResponse({'run_id': run.display_id, 'files': saved, 'status': 'pending'})
-
-
-@csrf_exempt
-def upload_csv_batch(request, display_id):
- """Append files to existing upload run. Does NOT re-trigger _background_process."""
- run = get_object_or_404(AnalysisRun, display_id=display_id)
- files = request.FILES.getlist('files')
- if not files:
- return JsonResponse({'error': 'No files uploaded'})
- upload_dir = Path(run.csv_glob).parent if run.csv_glob else None
- if upload_dir:
- expected_base = ensure_appdata_dir() / 'data' / 'uploads'
- if not str(upload_dir.resolve()).startswith(str(expected_base.resolve())):
- return JsonResponse({'error': 'Invalid upload path'}, status=400)
- if not upload_dir or not upload_dir.exists():
- return JsonResponse({'error': 'Upload directory not found'})
- saved = []
- for f in files:
- path = upload_dir / f.name
- with open(path, 'wb+') as dst:
- for chunk in f.chunks():
- dst.write(chunk)
- saved.append(str(path))
- # NO background process re-trigger — first batch handles it
- return JsonResponse({'run_id': run.display_id, 'files': len(saved), 'status': 'ok'})
-
-
-@csrf_exempt
-def finalize_upload(request, display_id):
- """Finalize batch upload: validate upload_dir, set status='loading', start background processing."""
- if request.method != 'POST':
- return JsonResponse({'error': 'POST required'}, status=405)
- run = get_object_or_404(AnalysisRun, display_id=display_id)
- if run.status != 'pending':
- return JsonResponse({'error': f'Run status is {run.status}, expected pending'}, status=400)
- upload_dir = Path(run.csv_glob).parent if run.csv_glob else None
- if not upload_dir or not upload_dir.exists():
- return JsonResponse({'error': 'Upload directory not found'}, status=400)
- run.status = 'loading'
- run.save(update_fields=['status'])
- t = threading.Thread(target=_background_process, args=(run.id, upload_dir), daemon=True)
- t.start()
- return JsonResponse({'run_id': run.display_id, 'status': 'loading'})
-
-
-@csrf_exempt
-def delete_upload(request, display_id):
- """Delete uploaded files, SQLite data table, and AnalysisRun.
-
- Handles all run statuses (loading, profiling, clustering, completed, failed).
- Gracefully tolerates null csv_glob and missing/errored SQLite tables.
- """
- run = get_object_or_404(AnalysisRun, display_id=display_id)
- import logging
- import shutil
-
- _logger = logging.getLogger(__name__)
-
- # 1. Drop the SQLite data table if one exists
- if run.sqlite_table:
- try:
- from analysis.data_loader import drop_sqlite_table # fmt: skip
- drop_sqlite_table(run.sqlite_table)
- except Exception as exc:
- _logger.warning('[DELETE] failed to drop SQLite table %s: %s', run.sqlite_table, exc)
-
- # 2. Remove the upload directory if csv_glob is set
- if run.csv_glob:
- p = Path(run.csv_glob).parent
- expected_base = ensure_appdata_dir() / 'data' / 'uploads'
- if not str(p.resolve()).startswith(str(expected_base.resolve())):
- return JsonResponse({'error': 'Invalid upload path'}, status=400)
- if p.exists():
- shutil.rmtree(p, ignore_errors=True)
-
- # 3. Delete the ORM record
- run.delete()
- return JsonResponse({'deleted': True})
-
-
-import traceback, logging
-logger = logging.getLogger(__name__)
-
-
-@profile
-def _background_process(run_id, upload_dir):
- """Background: stream-load CSVs → save to SQLite → ready for analysis."""
- import django
- os.environ['DJANGO_SETTINGS_MODULE'] = 'tianxuan.settings'
- django.setup()
- from analysis.models import AnalysisRun
- from analysis.session_store import SessionStore
-
- run = AnalysisRun.objects.get(id=run_id)
- store = SessionStore()
- try:
- # Check upload directory still exists (may have been temp-cleaned)
- if not upload_dir.is_dir():
- msg = '上传数据目录已不存在,请重新上传'
- logger.error('[BACKGROUND] %s (run_id=%s, upload_dir=%s)', msg, run_id, upload_dir)
- run.status = 'failed'
- run.error_message = msg
- run.save(update_fields=['status', 'error_message'])
- return
-
- import glob
- import polars as pl
- csv_paths = sorted(glob.glob(str(upload_dir / '*.csv')))
- if not csv_paths:
- msg = '没有找到 CSV 文件'
- logger.error('[BACKGROUND] %s (run_id=%s)', msg, run_id)
- run.status = 'failed'
- run.error_message = msg
- run.save(update_fields=['status', 'error_message'])
- return
-
- # ── Detect lat/lon columns for schema override ──
- import csv as _csv
- _LAT_LON_KW = ('latd', 'lond', 'latitude', 'longitude')
- lat_lon_overrides = {}
- try:
- with open(csv_paths[0], 'r', encoding='utf-8') as _f:
- _reader = _csv.reader(_f)
- _headers = next(_reader, [])
- for _col in _headers:
- _norm = _col.lower().replace('-', '_').replace('.', '_').replace(' ', '_')
- if any(kw in _norm for kw in _LAT_LON_KW):
- lat_lon_overrides[_col] = pl.Utf8
- logger.info('[BACKGROUND] Forcing lat/lon column %r to Utf8 for safe parsing', _col)
- except Exception:
- logger.warning('[BACKGROUND] Failed to detect lat/lon columns', exc_info=True)
-
- total_files = len(csv_paths)
- logger.info('[BACKGROUND] Found %d CSV files in %s', total_files, upload_dir)
- # Adaptive batch size: aim for ~20 batches total, cap at 500 files per batch
- batch_size = max(1, min(500, total_files // 20 or 1))
- total_batches = (total_files + batch_size - 1) // batch_size
-
- run.status = 'loading'; run.progress_pct = 10
- run.progress_msg = f'正在流式加载 {total_files} 个文件(共 {total_batches} 批)...'
- run.save(update_fields=['status', 'progress_pct', 'progress_msg'])
-
- from analysis.data_loader import save_to_db, drop_sqlite_table, load_from_db, _dtype_to_sqlite
-
- table_name = f'_data_{run_id}'
-
- # ── Phase 1: Pre-scan all files for full union column set ──
- all_columns = []
- for _path in csv_paths:
- with open(_path, 'r', encoding='utf-8') as _f:
- _reader = _csv.reader(_f)
- _headers = next(_reader, [])
- for _h in _headers:
- if _h not in all_columns:
- all_columns.append(_h)
- reference_columns = sorted(all_columns, key=str.lower)
- schema = {col: 'Utf8' for col in all_columns}
-
- # ── Phase 2: Create SQLite table once with all columns as TEXT ──
- import sqlite3 as _sqlite3
- from django.conf import settings as _dj_settings
- _db_path = _dj_settings.DATABASES['default']['NAME']
- _conn = _sqlite3.connect(_db_path)
- try:
- _col_defs = [f'"{_col}" TEXT' for _col in reference_columns]
- _conn.execute(f'DROP TABLE IF EXISTS "{table_name}"')
- _conn.execute(f'CREATE TABLE "{table_name}" ({", ".join(_col_defs)})')
- _conn.commit()
- finally:
- _conn.close()
-
- # ── Phase 3: Batched collection → SQLite append ──
- schema_overrides = {col: pl.Utf8 for col in reference_columns}
- completed_batches = 0
- total_rows = 0
- for i in range(0, total_files, batch_size):
- batch = csv_paths[i:i+batch_size]
- # Scan each file INDIVIDUALLY — batch scan fails when files have different schemas
- dfs = []
- for fp in batch:
- lf = pl.scan_csv(fp, infer_schema_length=0,
- schema_overrides=schema_overrides)
- df = lf.collect(streaming=True)
- # Fill missing columns with NULL
- for col in reference_columns:
- if col not in df.columns:
- df = df.with_columns(pl.lit(None).alias(col))
- # Select all reference_columns in canonical order
- df = df.select(reference_columns)
- dfs.append(df)
- df_batch = pl.concat(dfs, how='diagonal_relaxed')
-
- # Append to pre-created SQLite table
- save_to_db(run.id, df_batch.lazy(), mode='append')
-
- # Delete temp files after successful SQLite write
- for f in batch:
- try:
- os.unlink(f)
- except OSError:
- pass
-
- completed_batches += 1
- run.progress_pct = int((completed_batches / total_batches) * 80 + 10)
- run.progress_msg = f'正在加载数据... ({completed_batches}/{total_batches} 批)'
- run.save(update_fields=['progress_pct', 'progress_msg'])
-
- # ── Phase 3: Reload from SQLite with correct types ──
- lf = load_from_db(table_name, schema_overrides=schema)
- if lf is None:
- raise RuntimeError(f'SQLite 表 {table_name} 加载失败')
-
- # Apply _coerce_to_float on lat/lon columns (restore Float64 from Utf8 storage)
- from analysis.data_loader import _coerce_to_float as _coerce_to_float_fn
- for lat_lon_col in lat_lon_overrides:
- if lat_lon_col in lf.columns:
- lf = lf.with_columns(
- 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:
- 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])
-
- # ── SVD dimensionality reduction ──
- svd_components = 0
- # Only operate on columns with known numeric types in the schema
- numeric_dtypes = {'Int64', 'Float64', 'Int32', 'Float32',
- 'Int8', 'Int16', 'UInt8', 'UInt16', 'UInt32', 'UInt64'}
- numeric_cols = [c for c, dt in schema.items() if dt in numeric_dtypes]
- n_numeric = len(numeric_cols)
-
- if n_numeric >= 2:
- n_components = min(50, n_numeric - 1)
- try:
- from sklearn.decomposition import TruncatedSVD # fmt: skip
- import numpy as np # fmt: skip
-
- # Collect numeric data for SVD (full materialise → transform → add back)
- df_full = lf.select(numeric_cols).collect(streaming=True)
- X = df_full.to_numpy()
- X = np.nan_to_num(X, nan=0.0, posinf=0.0, neginf=0.0)
-
- svd = TruncatedSVD(n_components=n_components, random_state=42)
- X_svd = svd.fit_transform(X)
- svd_components = n_components
-
- # Build a new LazyFrame that includes _svd_* columns
- # Strategy: collect full LazyFrame once, add SVD columns, re-wrap as lazy
- df_all = lf.collect(streaming=True)
- for i in range(n_components):
- col_name = f'_svd_{i}'
- schema[col_name] = 'Float64'
- df_all = df_all.with_columns(
- pl.Series(col_name, X_svd[:, i])
- )
- lf = df_all.lazy()
-
- logger.info('[BACKGROUND] SVD: %d components from %d numeric columns '
- '(explained variance ratio sum=%.4f)',
- svd_components, n_numeric,
- float(svd.explained_variance_ratio_.sum()))
- except Exception as e:
- logger.warning('[BACKGROUND] SVD failed (non-fatal): %s', e)
- elif n_numeric == 1:
- logger.info('[BACKGROUND] SVD skipped: only 1 numeric column (%s)',
- numeric_cols[0])
-
- # Get row count
- df_count = lf.select(pl.len()).collect(streaming=True)
- total_rows = df_count[0, 0] if df_count.height > 0 else 0
-
- run.sqlite_table = table_name
- run.total_flows = total_rows
- run.save(update_fields=['sqlite_table', 'total_flows'])
-
- ds_id = f'upload_{run.display_id}'
- store.store_dataset(ds_id, lf, schema=schema, metadata={
- 'row_count': total_rows, 'file_count': total_files, 'upload_dir': str(upload_dir),
- 'csv_glob': str(upload_dir / '*.csv'),
- 'sqlite_table': table_name,
- 'svd_components': svd_components,
- })
-
- # ── Phase 3: Data ready for filtering/clustering via MCP tools ──
- run.status = 'ready'; run.progress_pct = 60; run.progress_msg = '数据准备就绪'
- run.save(update_fields=['status', 'progress_pct', 'progress_msg'])
- except Exception:
- tb = traceback.format_exc()
- logger.error(tb)
- # Drop partial SQLite table on failure (idempotent)
- try:
- from analysis.data_loader import drop_sqlite_table # fmt: skip
- drop_sqlite_table(f'_data_{run_id}')
- except Exception:
- pass # Expected: drop_sqlite_table may fail if table already gone
- run.status = 'failed'
- run.error_message = tb
- run.run_log += f'\n[ERROR] {tb}'
- run.save(update_fields=['status', 'error_message', 'run_log'])
-
-
-# ── Manual analysis page ───────────────────────────────────────────────
-
-def manual_page(request):
- """Workflow builder: select dataset, add MCP tool steps, execute."""
- from analysis.session_store import SessionStore
- from analysis.tool_registry import get_tools_meta
- import json as _json
-
- # 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'}
- diag_names = {'validate_data', 'explore_distributions', 'find_outliers',
- 'diagnose_clustering', 'compare_datasets', 'export_debug_sample',
- 'repair_schema'}
- analysis_names = {'analyze_patterns', 'analyze_temporal', 'analyze_tls_health',
- 'analyze_geo_distribution', 'analyze_entity_detail'}
- core_tools, diag_tools, analysis_tools, other_tools = [], [], [], []
- for t in tools:
- if t.name in core_names: core_tools.append(t)
- elif t.name in diag_names: diag_tools.append(t)
- elif t.name in analysis_names: analysis_tools.append(t)
- else: other_tools.append(t)
-
- meta_list = [{'name': t.name, 'description': t.description, 'inputSchema': t.inputSchema} for t in tools]
-
- # Get datasets from SessionStore
- store = SessionStore()
- session_datasets = store.list_datasets() if hasattr(store, 'list_datasets') else []
- session_ds_map = {ds['dataset_id']: ds for ds in session_datasets}
-
- # Build dataset entries from ALL AnalysisRun records (upload/manual/auto, any status)
- from analysis.models import AnalysisRun
- all_runs = AnalysisRun.objects.all().order_by('-created_at')[:100]
- datasets_with_status = []
-
- for run in all_runs:
- # Determine how the dataset was keyed in SessionStore
- if run.run_type == 'upload':
- ds_id = f'upload_{run.display_id}'
- else:
- ds_id = f'run_{run.display_id}'
-
- session_entry = session_ds_map.get(ds_id)
-
- if session_entry:
- # Full data available in SessionStore — merge with run status
- datasets_with_status.append({
- **session_entry,
- 'dataset_id': ds_id,
- 'run_status': run.status,
- 'total_flows': run.total_flows,
- 'display_id': run.display_id,
- 'needs_reload': False,
- })
- elif run.csv_glob or run.sqlite_table:
- # Data not in SessionStore but recoverable from disk/DB
- datasets_with_status.append({
- 'dataset_id': ds_id,
- 'run_status': run.status,
- 'display_id': run.display_id,
- 'total_flows': run.total_flows,
- 'row_count': run.total_flows,
- 'file_count': None,
- 'column_count': 0,
- 'columns': [],
- 'svd_components': 0,
- 'needs_reload': True,
- 'csv_glob': run.csv_glob,
- 'sqlite_table': run.sqlite_table or '',
- })
-
- # Map statuses to user-facing labels for the filter tabs
- # ready → 待分析, completed → 已分析, failed → 错误
- # Everything else (pending/loading/profiling/aggregating/clustering/extracting) → analyzing → 分析中
- for ds_info in datasets_with_status:
- s = ds_info['run_status']
- if s in ('completed',):
- ds_info['status_label'] = '已分析'
- ds_info['badge_class'] = 'completed'
- elif s in ('failed',):
- ds_info['status_label'] = '错误'
- ds_info['badge_class'] = 'failed'
- elif s in ('pending', 'loading', 'profiling', 'aggregating', 'clustering', 'extracting'):
- ds_info['status_label'] = '分析中'
- ds_info['badge_class'] = 'analyzing'
- else:
- ds_info['status_label'] = '待分析'
- ds_info['badge_class'] = 'ready'
-
- return render(request, 'tianxuan/manual.html', {
- 'core_tools': core_tools,
- 'diag_tools': diag_tools,
- 'analysis_tools': analysis_tools,
- 'other_tools': other_tools,
- 'tools_meta': _json.dumps(meta_list, ensure_ascii=False),
- 'datasets': datasets_with_status,
- 'datasets_with_status_json': _json.dumps(datasets_with_status, ensure_ascii=False),
- })
-
-
-# ── Pipeline worker ─────────────────────────────────────────────────────────
-
-
-def _run_pipeline_worker(run_id, analysis_fn, **ctx):
- """Unified background worker: setup / ORM / progress / error handling."""
- import django
- os.environ['DJANGO_SETTINGS_MODULE'] = 'tianxuan.settings'
- os.environ['DJANGO_ALLOW_ASYNC_UNSAFE'] = 'true'
- django.setup()
-
- from analysis.db_utils import retry_on_lock
- from django.db import close_old_connections
-
- close_old_connections()
-
- @retry_on_lock()
- def _get_run():
- return AnalysisRun.objects.get(id=run_id)
-
- run = _get_run()
- try:
- analysis_fn(run, ctx)
- except Exception:
- import traceback
- tb = traceback.format_exc()
- logger.error(tb)
- run.status = 'failed'
- run.error_message = tb
- run.run_log += f'\n[FATAL] {tb}'
- retry_on_lock()(lambda: run.save(update_fields=['status', 'error_message', 'run_log']))()
-
-
-# ── Shared clustering pipeline ─────────────────────────────────────────
-
-@profile
-def _run_clustering_pipeline(run, store, entity_ds_id, feature_columns, algorithm,
- min_cluster_size, run_umap=True, head=None):
- """Unified clustering pipeline: clustering → feature extraction → UMAP → ORM save.
-
- Args:
- run: AnalysisRun ORM object.
- store: SessionStore instance.
- entity_ds_id: dataset ID in SessionStore pointing to entity-aggregated data.
- feature_columns: list of feature column names (None = auto-detect).
- algorithm: 'hdbscan' or 'kmeans'.
- min_cluster_size: int for HDBSCAN.
- run_umap: bool, whether to compute and save UMAP-2D embeddings.
- head: optional int, downsample to at most this many rows (min 100).
- """
- import warnings
- warnings.filterwarnings('ignore', category=RuntimeWarning, module='sklearn')
- import asyncio, traceback, logging
- logger = logging.getLogger(__name__)
-
- try:
- from analysis.tool_registry import _handle_run_clustering, _handle_extract_features
- import polars as pl
-
- entry = store.get_dataset(entity_ds_id)
- if entry is None:
- run.status = 'failed'
- run.error_message = 'Entity dataset not found in session store'
- run.save(update_fields=['status', 'error_message'])
- return
-
- schema = entry.get('schema', {})
-
- # ── Downsampling ──
- if head is not None and head > 0:
- lf = entry['lazyframe']
- try:
- row_count = lf.select(pl.len()).collect(streaming=True).item()
- except Exception:
- row_count = 0
-
- if row_count > head:
- head = max(head, 100) # ensure minimum rows for clustering
- run.progress_msg = f'正在采样 {head}/{row_count} 行...'
- run.save(update_fields=['progress_msg'])
- lf = lf.head(head)
- store.store_dataset(
- entity_ds_id, lf,
- schema=schema,
- metadata=entry.get('metadata', {}),
- )
- entry = store.get_dataset(entity_ds_id) # refresh
- row_count = head
-
- # Resolve feature columns — use actual LazyFrame dtypes (not stored schema dict
- # which may be stale after Utf8 coercion in _background_process).
- numeric_types = (pl.Int8, pl.Int16, pl.Int32, pl.Int64,
- pl.UInt8, pl.UInt16, pl.UInt32, pl.UInt64,
- pl.Float32, pl.Float64)
-
- lf = entry['lazyframe']
- live_schema = lf.collect_schema()
- live_names = live_schema.names()
- live_dtypes = list(live_schema.dtypes())
-
- if feature_columns:
- feature_cols = [
- c for c in feature_columns
- if c in live_names and live_dtypes[live_names.index(c)] in numeric_types
- ]
- else:
- feature_cols = [
- live_names[i] for i, dt in enumerate(live_dtypes)
- if dt in numeric_types and not live_names[i].startswith('_')
- ][:10]
-
- # ── Step 1: Clustering ────────────────────────────────────────────────
- # If no numeric columns detected, pass empty list to let
- # _handle_run_clustering apply its Utf8→Float64 coercion fallback.
- run.status = 'clustering'
- run.progress_pct = 70
- run.progress_msg = '正在进行聚类分析...'
- run.save(update_fields=['status', 'progress_pct', 'progress_msg'])
-
- result = asyncio.run(_handle_run_clustering(
- dataset_id=entity_ds_id,
- cluster_columns=feature_cols,
- algorithm=algorithm,
- params={'min_cluster_size': min_cluster_size},
- random_state=42,
- ))
-
- if 'error' in result:
- err = result['error']
- if 'numeric' in err.lower():
- available = [
- f"{k}({v})" for k, v in schema.items()
- if v.split('(')[0].strip() in numeric_types and not k.startswith('_')
- ]
- err += f"。可用数值列: {available}"
- raise Exception(err)
-
- cluster_id = result.get('cluster_result_id', '')
- run.cluster_count = result.get('n_clusters', 0)
- run.save(update_fields=['cluster_count'])
-
- # ── Step 2: Feature extraction ──────────────────────────────────────
- run.status = 'extracting'
- run.progress_pct = 90
- run.progress_msg = '正在提取聚类特征...'
- run.save(update_fields=['status', 'progress_pct', 'progress_msg'])
-
- feat_result = asyncio.run(_handle_extract_features(
- dataset_id=entity_ds_id,
- cluster_result_id=cluster_id,
- top_k=10, method='zscore', save_to_db=True,
- run_id=run.id,
- ))
-
- if 'error' in feat_result:
- raise Exception(feat_result['error'])
-
- # ── Step 2b: Cluster SVD feature extraction ──────────────────────
- run.progress_msg = '正在进行聚类SVD特征提取...'
- run.save(update_fields=['progress_msg'])
- try:
- from analysis.distance import cluster_svd_extract
- lf = entry['lazyframe']
- cluster_entry = store.get_cluster_result(cluster_id)
- labels_list = cluster_entry.get('labels', []) if cluster_entry else []
-
- if labels_list and feature_cols:
- svd_results = cluster_svd_extract(lf, labels_list, feature_cols)
-
- # Persist SVD features to ClusterFeature (method='cluster_svd')
- from analysis.models import ClusterFeature as CF
- from analysis.models import ClusterResult as CR
- for label, svd_info in svd_results.items():
- cr = CR.objects.filter(run=run, cluster_label=label).first()
- if cr is None:
- continue
- # Delete old zscore entries for this cluster, replace with SVD
- CF.objects.filter(cluster=cr, distinguishing_method='zscore').delete()
- for feat_name in svd_info.get('features', [])[:10]:
- idx = svd_info['features'].index(feat_name) if feat_name in svd_info['features'] else -1
- strength = (svd_info.get('feature_strength', [])[idx]
- if idx >= 0 and idx < len(svd_info.get('feature_strength', []))
- else None)
- CF.objects.get_or_create(
- cluster=cr,
- feature_name=feat_name,
- defaults={
- 'mean': None, 'std': None, 'median': None,
- 'p25': None, 'p75': None, 'missing_rate': None,
- 'distinguishing_score': float(strength) if strength is not None else None,
- 'distinguishing_method': 'cluster_svd',
- },
- )
- except Exception as svd_err:
- logger.warning(f'Cluster SVD extraction skipped: {svd_err}')
-
- # ── Step 3: UMAP-3D embedding (2D fallback) ───────────────────────
- if run_umap:
- try:
- lf = entry['lazyframe']
- # UMAP: sample max 10K for training, batch-transform rest in 1K batches
- MAX_UMAP_TRAIN = 10000
- BATCH_SIZE = 1000
- try:
- n_total = lf.select(pl.len()).collect(streaming=True).item()
- except Exception:
- n_total = 0
- from sklearn.preprocessing import StandardScaler
- import umap
- import numpy as np
-
- live_schema = lf.collect_schema()
- num_cols = [name for name, dt in zip(live_schema.names(), live_schema.dtypes())
- if dt in (pl.Int8, pl.Int16, pl.Int32, pl.Int64,
- pl.UInt8, pl.UInt16, pl.UInt32, pl.UInt64,
- pl.Float32, pl.Float64) and not name.startswith('_')]
-
- if len(num_cols) >= 2:
- if n_total > MAX_UMAP_TRAIN:
- run.progress_msg = f'UMAP 采样 {MAX_UMAP_TRAIN}/{n_total} 实体训练...'
- run.save(update_fields=['progress_msg'])
- df_sample = lf.sample(n=MAX_UMAP_TRAIN, seed=42).collect(streaming=True)
- df_umap = lf.collect(streaming=True)
- else:
- df_umap = lf.collect(streaming=True)
-
- umap_components = 3 # try 3D first
- coords = None
- reducer = None
-
- if len(df_umap) > 2:
- for attempt_n in (3, 2):
- try:
- if n_total > MAX_UMAP_TRAIN:
- # Train UMAP on 10K sample, batch-transform full dataset
- mat_sample = df_sample.select(num_cols).to_numpy()
- scaler = StandardScaler().fit(mat_sample)
- mat_sample_scaled = np.nan_to_num(
- scaler.transform(mat_sample), nan=0.0)
- reducer = umap.UMAP(n_components=attempt_n,
- random_state=42,
- n_neighbors=15, min_dist=0.1,
- metric='euclidean')
- reducer.fit(mat_sample_scaled)
- # Batch transform in 1K-row batches
- coords_list = []
- for start in range(0, len(df_umap), BATCH_SIZE):
- end = min(start + BATCH_SIZE, len(df_umap))
- mat_batch = scaler.transform(
- df_umap[start:end].select(num_cols).to_numpy())
- mat_batch = np.nan_to_num(mat_batch, nan=0.0)
- coords_list.append(reducer.transform(mat_batch))
- coords = np.vstack(coords_list)
- else:
- mat = np.nan_to_num(StandardScaler().fit_transform(
- df_umap.select(num_cols).to_numpy()), nan=0.0)
- reducer = umap.UMAP(n_components=attempt_n,
- random_state=42,
- n_neighbors=15, min_dist=0.1,
- metric='euclidean')
- coords = reducer.fit_transform(mat)
- umap_components = attempt_n
- break
- except Exception as e_umap:
- if attempt_n == 2:
- raise
- logger.warning(f'UMAP 3D failed ({e_umap}), falling back to 2D')
-
- if coords is None:
- raise Exception('UMAP produced no output')
-
- 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()
- 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'])
- except Exception as umap_err:
- logger.warning(f'UMAP embedding skipped: {umap_err}')
-
- # Fallback: if entity_count not set (UMAP skipped), derive from cluster sizes
- if run.entity_count is None:
- from django.db.models import Sum
- total = run.clusters.aggregate(total=Sum('size'))['total'] or 0
- run.entity_count = total
- run.save(update_fields=['entity_count'])
-
- # ── Done ──
- run.status = 'completed'
- run.progress_pct = 100
- run.progress_msg = '分析完成'
- run.save(update_fields=['status', 'progress_pct', 'progress_msg', 'entity_count'])
-
- except Exception:
- tb = traceback.format_exc()
- logger.error(tb)
- try:
- run.status = 'failed'
- run.error_message = tb
- run.progress_msg = f'失败: {tb}'
- run.run_log += f'\n[ERROR] {tb}'
- run.save(update_fields=['status', 'error_message', 'progress_msg', 'run_log'])
- except Exception as e:
- logger.warning('save failed after pipeline error: %s', e)
-
-
-@csrf_exempt
-def manual_run_analysis(request):
- """AJAX POST: run clustering pipeline on the upload dataset."""
- if request.method != 'POST':
- return JsonResponse({'error': 'POST required'}, status=405)
- import json
- body = json.loads(request.body)
- display_id = body.get('run_id')
- run = get_object_or_404(AnalysisRun, display_id=display_id)
- pk = run.id
- run.run_type = 'manual'
- run.save(update_fields=['run_type'])
- feature_columns = body.get('feature_columns')
- algorithm = body.get('algorithm', 'agglomerative')
- min_cluster_size = int(body.get('min_cluster_size', 5))
- head = body.get('head')
- cluster_mode = body.get('cluster_mode', 'raw')
-
- def _analysis_fn(run, ctx):
- from analysis.session_store import SessionStore
-
- feature_columns = ctx['feature_columns']
- algorithm = ctx['algorithm']
- min_cluster_size = ctx['min_cluster_size']
- head = ctx.get('head')
- pk = ctx['pk']
- cluster_mode = ctx.get('cluster_mode', 'raw')
-
- store = SessionStore()
- try:
- # Use display_id for dataset key (consistent with _background_process)
- display_id = ctx.get('display_id', pk)
- upload_ds_id = f'upload_{display_id}'
- entry = store.get_dataset(upload_ds_id)
- if entry is None:
- # Fallback: try PK-based key (old pipeline)
- entry = store.get_dataset(f'upload_{pk}')
- if entry is None:
- # Fallback: try entity dataset (from older pipeline)
- entry = store.get_dataset(f'entity_{pk}')
- if entry is None:
- run.error_message = '请先上传并等待预处理完成'
- run.status = 'failed'
- run.save(update_fields=['status', 'error_message'])
- return
-
- ds_id = upload_ds_id if store.get_dataset(upload_ds_id) else f'upload_{pk}'
-
- # Use the unified clustering pipeline (clustering → extraction → UMAP)
- from analysis.views import _run_clustering_pipeline
- _run_clustering_pipeline(
- run=run, store=store, entity_ds_id=ds_id,
- feature_columns=feature_columns,
- algorithm=algorithm, min_cluster_size=min_cluster_size,
- run_umap=True, head=head,
- )
- except Exception:
- tb = traceback.format_exc()
- logger.error(tb)
- run.status = 'failed'
- run.error_message = tb
- run.progress_msg = f'失败: {tb}'
- run.run_log += f'\n[ERROR] {tb}'
- run.save(update_fields=['status', 'error_message', 'progress_msg', 'run_log'])
-
- t = threading.Thread(
- target=_run_pipeline_worker,
- args=(pk, _analysis_fn),
- kwargs=dict(feature_columns=feature_columns, algorithm=algorithm,
- min_cluster_size=min_cluster_size, head=head, pk=pk,
- display_id=run.display_id, cluster_mode=cluster_mode),
- daemon=True,
- )
- t.start()
- return JsonResponse({'status': 'started', 'redirect': f'/runs/{run.display_id}/'})
-
-
-
-
-
-
-
-# ── Filter endpoint for manual analysis page ──────────────────────────
-
-@csrf_exempt
-def apply_filter(request):
- """POST {dataset_id, filters, logic} → apply filter_data tool → return filtered_X id."""
- if request.method != 'POST':
- return JsonResponse({'error': 'POST required'}, status=405)
- import json as _json
- import asyncio
- body = _json.loads(request.body)
- dataset_id = body.get('dataset_id')
- filters = body.get('filters', [])
- logic = body.get('logic', 'and')
-
- if not dataset_id:
- return JsonResponse({'error': 'dataset_id is required'})
- if not filters:
- return JsonResponse({'error': 'filters is required'})
-
- try:
- from analysis.tool_registry import _handle_filter_data
- result = asyncio.run(_handle_filter_data(
- dataset_id=dataset_id,
- filters=filters,
- logic=logic,
- ))
- if 'error' in result:
- return JsonResponse({'error': result['error']})
-
- filtered_id = result.get('filtered_dataset_id', '')
- row_count = result.get('row_count', 0)
-
- # Use the unique flt_xxx ID from _handle_filter_data — never rename
- # to filtered_{num} which would collide on repeated filters of the same source.
- return JsonResponse({
- 'dataset_id': filtered_id,
- 'row_count': row_count,
- })
- except Exception as e:
- import traceback
- logger.error(f'[apply_filter] {traceback.format_exc()}')
- return JsonResponse({'error': str(e)})
-
-
-# ── LLM auto analysis page ─────────────────────────────────────────────
-
-
-@csrf_exempt
-def run_llm_analysis_view(request):
- """AJAX POST: run LLM-driven analysis pipeline."""
- if request.method != 'POST':
- return JsonResponse({'error': 'POST required'}, status=405)
- import json
- body = json.loads(request.body)
- display_id = body.get('run_id')
- run = get_object_or_404(AnalysisRun, display_id=display_id)
- pk = run.id
- run.run_type = 'auto'
- run.save(update_fields=['run_type'])
- head = body.get('head')
-
- from analysis.session_store import SessionStore
- store = SessionStore()
-
- # Determine dataset key format (consistent with manual_page)
- upload_ds_id = f'upload_{display_id}'
- run_ds_id = f'run_{display_id}'
- entry = store.get_dataset(upload_ds_id)
- if entry is None:
- entry = store.get_dataset(run_ds_id)
-
- if entry is None:
- # Try primary key fallback
- upload_pk_id = f'upload_{pk}'
- entity_pk_id = f'entity_{pk}'
- entry = store.get_dataset(upload_pk_id)
- if entry is None:
- entry = store.get_dataset(entity_pk_id)
-
- if entry is None:
- # Auto-reload from sqlite_table or csv_glob
- from analysis.data_loader import load_csv_directory, load_from_db
- lf = None
- schema = {}
- if run.sqlite_table:
- try:
- lf = load_from_db(run.sqlite_table)
- except Exception:
- pass
- if lf is None and run.csv_glob:
- try:
- lf, schema, _, _, _ = load_csv_directory(run.csv_glob)
- except Exception:
- pass
- if lf is None:
- return JsonResponse({'error': '数据集不存在,请先上传并等待预处理完成'})
- entry_key = upload_ds_id if run.run_type == 'upload' else run_ds_id
- store.store_dataset(entry_key, lf, schema=schema, metadata={
- 'row_count': run.total_flows,
- 'csv_glob': run.csv_glob or '',
- 'sqlite_table': run.sqlite_table or '',
- })
- # Re-fetch entry
- entry = store.get_dataset(entry_key)
-
- # Determine the active dataset_id
- for candidate in [upload_ds_id, run_ds_id, f'upload_{pk}', f'entity_{pk}']:
- if store.get_dataset(candidate):
- dataset_id = candidate
- break
- else:
- return JsonResponse({'error': '数据集不存在,请先上传并等待预处理完成'})
-
- # ── Optional: apply filter before LLM pipeline ──
- filters = body.get('filters', [])
- if filters and len(filters) > 0:
- logic = body.get('logic', 'and')
- try:
- from analysis.tool_registry import _handle_filter_data
- filter_result = asyncio.run(_handle_filter_data(
- dataset_id=dataset_id,
- filters=filters,
- logic=logic,
- ))
- if 'error' not in filter_result:
- filtered_id = filter_result.get('filtered_dataset_id', '')
- if filtered_id and store.get_dataset(filtered_id):
- dataset_id = filtered_id
- except Exception:
- import traceback
- logger.warning(f'[LLM auto] Filter failed (non-fatal): {traceback.format_exc()}')
-
- def _analysis_fn(run, ctx):
- from tianxuan.llm_orchestrator import run_llm_pipeline, LLMConfig
- dataset_id = ctx['dataset_id']
- pk = ctx['pk']
-
- def progress_callback(step, tool_name, result=None, meta=None):
- nonlocal run
- try:
- run = AnalysisRun.objects.get(id=pk)
- pct = min(10 + step * 6, 95)
- run.progress_pct = pct
- run.progress_msg = f'步骤 {step}: {tool_name}'
-
- 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}'
- save_fields = ['progress_pct', 'progress_msg', 'llm_thinking']
- elif tool_name == 'complete':
- pass # No extra tracking needed
- else:
- # Single tool call — always has result
- if isinstance(result, dict):
- import json as _json
- summary = _json.dumps(result, default=str, ensure_ascii=False)[:500]
- else:
- summary = str(result)[:500]
- run.run_log += f'[{step}] {tool_name}: {summary}...\n'
-
- # One entry per tool call
- if meta and isinstance(meta, dict) and 'args' in meta:
- tool_calls = list(run.tool_calls_json or [])
- tool_calls.append({
- 'step': step,
- 'name': tool_name,
- 'input': meta['args'],
- 'output': result,
- })
- run.tool_calls_json = tool_calls
- save_fields = ['progress_pct', 'progress_msg', 'run_log', 'tool_calls_json']
-
- run.save(update_fields=save_fields)
- except Exception as e:
- logger.warning('progress_callback error: %s', e)
-
- try:
- from config import get_config
- cfg = get_config()
- llm_cfg = LLMConfig(cfg.llm.base_url, cfg.llm.api_key, cfg.llm.model)
- run.run_log = '启动 LLM 分析...\n'
- run.status = 'loading'
- run.progress_pct = 5
- run.progress_msg = 'LLM 初始化中...'
- run.save(update_fields=['status', 'progress_pct', 'progress_msg', 'run_log'])
-
- result = run_llm_pipeline(dataset_id, config=llm_cfg, callback=progress_callback)
-
- if 'error' in result:
- run.status = 'failed'
- run.progress_msg = f'失败: {result["error"]}'
- run.run_log += f'\n[ERROR] {result["error"]}'
- run.save(update_fields=['status', 'progress_pct', 'progress_msg', 'run_log'])
- else:
- run.progress_pct = 55
- run.progress_msg = f'LLM分析完成: {result.get("result", "")[:200]}'
- run.run_log += f'\n[完成] {result.get("result", "")}\n[INFO] 开始自动聚类流程...'
- run.save(update_fields=['progress_pct', 'progress_msg', 'run_log'])
-
- # ── Auto-run clustering pipeline after LLM analysis ──
- entity_ds_id = None
- tool_calls = run.tool_calls_json or []
- for tc in tool_calls:
- tool_name = tc.get('name', '')
- tool_input = tc.get('input', {})
- if tool_name in ('build_entity_profiles', 'run_clustering'):
- entity_ds_id = tool_input.get('dataset_id', '')
- if entity_ds_id and store.get_dataset(entity_ds_id):
- break
- entity_ds_id = None
-
- # Fallback: try common dataset ID patterns
- if not entity_ds_id:
- candidates = [
- ctx.get('dataset_id', ''),
- f'entity_{pk}',
- f'entity_{run.display_id}',
- f'upload_{run.display_id}',
- ]
- for cid in candidates:
- if cid and store.get_dataset(cid):
- entity_ds_id = cid
- break
-
- if entity_ds_id:
- _run_clustering_pipeline(
- run=run,
- store=store,
- entity_ds_id=entity_ds_id,
- feature_columns=None,
- algorithm='agglomerative',
- min_cluster_size=5,
- run_umap=True,
- head=ctx.get('head'),
- )
- else:
- logger.warning(
- f'[LLM auto] No entity dataset found for clustering. '
- f'Tool calls: {[tc.get("name") for tc in tool_calls]}. '
- f'Marking run as completed without clustering.'
- )
- run.status = 'completed'
- run.progress_pct = 100
- run.progress_msg = 'LLM分析完成(无实体数据,跳过聚类)'
- run.save(update_fields=['status', 'progress_pct', 'progress_msg'])
-
- # ── Auto-save workflow as loadable plan ──
- try:
- tool_calls_data = run.tool_calls_json or []
- steps = []
- for tc in tool_calls_data:
- tc_name = tc.get('name', '')
- if tc_name == 'thinking':
- continue
- steps.append({
- 'tool_name': tc_name,
- 'params': tc.get('input', {}),
- })
- if steps:
- import json as _json
- plan_data = {
- 'name': f'_auto_{run.display_id}',
- 'steps': steps,
- 'auto': True,
- 'display_id': run.display_id,
- 'created_at': str(run.created_at) if run.created_at else '',
- }
- plan_path = _PLANS_DIR / f'_auto_{run.display_id}.json'
- plan_path.write_text(
- _json.dumps(plan_data, ensure_ascii=False, indent=2),
- encoding='utf-8',
- )
- _add_to_auto_index(f'_auto_{run.display_id}.json')
- logger.info(
- '[LLM auto] Saved auto plan %s (%d steps)',
- plan_path.name, len(steps),
- )
- except Exception as save_err:
- logger.warning('[LLM auto] Failed to save auto plan: %s', save_err)
-
- except Exception:
- tb = traceback.format_exc()
- logger.error(tb)
- try:
- run = AnalysisRun.objects.get(id=pk)
- run.status = 'failed'
- run.progress_msg = f'失败: {tb}'
- run.run_log += f'\n[ERROR] {tb}'
- run.error_message = tb
- run.save(update_fields=['status', 'progress_msg', 'run_log', 'error_message'])
- except Exception as e:
- logger.warning('save failed after pipeline error: %s', e)
-
- t = threading.Thread(
- target=_run_pipeline_worker,
- args=(pk, _analysis_fn),
- kwargs=dict(dataset_id=dataset_id, head=head, pk=pk),
- daemon=True,
- )
- t.start()
- return JsonResponse({'status': 'started', 'run_id': run.display_id})
-
-
-def auto_page(request):
- """LLM-driven auto analysis page."""
- from analysis.session_store import SessionStore
- import json as _json
-
- cfg = get_config()
-
- # Get datasets from SessionStore + ALL runs from DB
- store = SessionStore()
- session_datasets = store.list_datasets() if hasattr(store, 'list_datasets') else []
- session_ds_map = {ds['dataset_id']: ds for ds in session_datasets}
-
- # Build dataset entries from ALL AnalysisRun records
- all_runs = AnalysisRun.objects.all().order_by('-created_at')[:100]
- datasets_with_status = []
-
- for run in all_runs:
- if run.run_type == 'upload':
- ds_id = f'upload_{run.display_id}'
- else:
- ds_id = f'run_{run.display_id}'
-
- session_entry = session_ds_map.get(ds_id)
-
- if session_entry:
- datasets_with_status.append({
- **session_entry,
- 'dataset_id': ds_id,
- 'run_status': run.status,
- 'total_flows': run.total_flows,
- 'display_id': run.display_id,
- 'needs_reload': False,
- 'csv_glob': run.csv_glob or '',
- 'sqlite_table': run.sqlite_table or '',
- })
- elif run.csv_glob or run.sqlite_table:
- datasets_with_status.append({
- 'dataset_id': ds_id,
- 'run_status': run.status,
- 'display_id': run.display_id,
- 'total_flows': run.total_flows,
- 'row_count': run.total_flows,
- 'file_count': None,
- 'columns': [],
- 'column_count': 0,
- 'svd_components': 0,
- 'needs_reload': True,
- 'csv_glob': run.csv_glob or '',
- 'sqlite_table': run.sqlite_table or '',
- })
-
- for ds_info in datasets_with_status:
- s = ds_info['run_status']
- if s in ('completed',):
- ds_info['status_label'] = '已分析'
- ds_info['badge_class'] = 'completed'
- elif s in ('failed',):
- ds_info['status_label'] = '错误'
- ds_info['badge_class'] = 'failed'
- elif s in ('pending', 'loading', 'profiling', 'aggregating', 'clustering', 'extracting'):
- ds_info['status_label'] = '分析中'
- ds_info['badge_class'] = 'analyzing'
- else:
- ds_info['status_label'] = '待分析'
- ds_info['badge_class'] = 'ready'
-
- return render(request, 'tianxuan/auto.html', {
- 'llm_configured': bool(cfg.llm.base_url and cfg.llm.api_key),
- 'datasets_with_status_json': _json.dumps(datasets_with_status, ensure_ascii=False),
- })
-
-
-# ── Progress API ───────────────────────────────────────────────────────
-
-def run_status_api(request, display_id):
- """Return current run status as JSON."""
- run = get_object_or_404(AnalysisRun, display_id=display_id)
- return JsonResponse({
- 'id': run.display_id,
- 'status': run.status,
- 'entity_count': run.entity_count,
- 'cluster_count': run.cluster_count,
- 'error_message': run.error_message,
- '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
- 'tool_calls': run.tool_calls_json,
- })
-
-
-def log_viewer(request):
- """Display last 200 lines of the app log (DEBUG only).
- With ?pos=N, returns text: line_count\\nnew_content for AJAX polling.
- """
- from django.conf import settings
- if not settings.DEBUG:
- return HttpResponse('Log viewer only available in DEBUG mode', status=403)
- log_path = settings.BASE_DIR / 'logs' / 'tianxuan.log'
- pos = request.GET.get('pos')
- if pos is not None:
- # AJAX polling mode: return new content since byte position
- try:
- pos = int(pos)
- except ValueError:
- pos = 0
- if log_path.exists():
- with open(log_path, 'r', encoding='utf-8') as f:
- total = f.seek(0, 2) # seek to end
- if pos >= total:
- return HttpResponse(f'{total}\n', content_type='text/plain')
- f.seek(pos)
- new_content = f.read()
- return HttpResponse(f'{total}\n{new_content}', content_type='text/plain')
- return HttpResponse('0\n', content_type='text/plain')
-
- # HTML page mode
- lines = []
- if log_path.exists():
- with open(log_path, 'r', encoding='utf-8') as f:
- lines = f.readlines()[-200:]
- return render(request, 'tianxuan/log_viewer.html', {'lines': lines})
-
-# ── 3D Globe view ─────────────────────────────────────────────────────────
-
-def _extract_flows_from_df(df, MAX_ROWS):
- """Extract flow data (lat/lon/TLS/bytes) from a Polars DataFrame."""
- from analysis.geoip import lookup as geo_lookup
- TLS_HEX_MAP = {'0303': 'TLSv1.2', '0304': 'TLSv1.3', '0302': 'TLSv1.1', '0301': 'TLSv1.0'}
- # NOTE: Duplicated in type_classifier.py — keep both in sync.
-
- src_lat_col = next((c for c in df.columns if c.lower() in (
- ':ips.latd', ':ips_latd', ':ips_lat', 'src_latitude', 'src_lat', 'source_latitude', 'source_lat')), None)
- src_lon_col = next((c for c in df.columns if c.lower() in (
- ':ips.lond', ':ips_lond', ':ips_lon', 'src_longitude', 'src_lon', 'source_longitude', 'source_lon')), None)
- dst_lat_col = next((c for c in df.columns if c.lower() in (
- ':ipd.latd', ':ipd_latd', ':ipd_lat', 'dst_latitude', 'dst_lat', 'dest_latitude', 'dest_lat')), None)
- dst_lon_col = next((c for c in df.columns if c.lower() in (
- ':ipd.lond', ':ipd_lond', ':ipd_lon', 'dst_longitude', 'dst_lon', 'dest_longitude', 'dest_lon')), None)
- src_ip_col = next((c for c in df.columns if 'src_ip' in c.lower() or ':ips' in c.lower()), None)
- dst_ip_col = next((c for c in df.columns if 'dst_ip' in c.lower() or ':ipd' in c.lower()), None)
- tls_col = next((c for c in df.columns if c.lower() in ('0ver', 'tls_version', '_tlsver', 'version')), None)
- bytes_col = next((c for c in df.columns if c.lower() in ('8ack', '8pak', '8byt', 'bytes_sent', 'bytes')), None)
- time_col = next((c for c in df.columns if c.lower() in (
- 'timestamp', 'time', '_time', 'ts', 'datetime', 'epoch',
- 'created_at', 'created', 'event_time')), None)
-
- flows = []
- for row in df.iter_rows(named=True):
- slat = slon = dlat = dlon = None
-
- if src_lat_col and src_lon_col:
- slat = _extract_lat(row.get(src_lat_col))
- slon = _extract_lon(row.get(src_lon_col))
- if dst_lat_col and dst_lon_col:
- dlat = _extract_lat(row.get(dst_lat_col))
- dlon = _extract_lon(row.get(dst_lon_col))
-
- if (slat is None or slon is None) and src_ip_col:
- sg = geo_lookup(str(row.get(src_ip_col, '')))
- if sg:
- slat, slon = sg['lat'], sg['lon']
- if (dlat is None or dlon is None) and dst_ip_col:
- dg = geo_lookup(str(row.get(dst_ip_col, '')))
- if dg:
- dlat, dlon = dg['lat'], dg['lon']
-
- if slat is None or slon is None or dlat is None or dlon is None:
- continue
-
- tls_val = str(row.get(tls_col or '', ''))
- # Check hex format first (e.g. "03 03" or "0303")
- hex_key = tls_val.replace(' ', '').strip()
- if hex_key in TLS_HEX_MAP:
- tls_label = TLS_HEX_MAP[hex_key]
- elif '1.3' in tls_val:
- tls_label = 'TLSv1.3'
- elif '1.2' in tls_val:
- tls_label = 'TLSv1.2'
- else:
- tls_label = 'other'
-
- bs = float(row.get(bytes_col or '', 0) or 0)
-
- # Extract timestamp for animation ordering
- t_raw = row.get(time_col) if time_col else None
- t_val = None
- if t_raw is not None:
- if isinstance(t_raw, (int, float)):
- t_val = float(t_raw)
- elif hasattr(t_raw, 'timestamp'):
- t_val = t_raw.timestamp()
- else:
- try:
- t_val = float(t_raw)
- except (ValueError, TypeError):
- pass
-
- flows.append({
- 'slat': slat, 'slon': slon,
- 'dlat': dlat, 'dlon': dlon,
- 'tls': tls_label,
- 'bytes': bs,
- 'time': t_val,
- })
-
- # Normalize timestamps to 0-1 and sort flows by time
- timed = [f for f in flows if f['time'] is not None]
- if len(timed) > 1:
- timed.sort(key=lambda f: f['time'])
- t_min, t_max = timed[0]['time'], timed[-1]['time']
- t_range = max(t_max - t_min, 1)
- for f in timed:
- f['time'] = (f['time'] - t_min) / t_range
- # Untimed flows get evenly spaced after timed ones
- untimed = [f for f in flows if f['time'] is None]
- for i, f in enumerate(untimed):
- f['time'] = min(1.0, (len(timed) + i) / max(len(flows) - 1, 1))
- flows = timed + untimed
- else:
- for i, f in enumerate(flows):
- f['time'] = i / max(len(flows) - 1, 1)
-
- return flows
-
-
-def globe_view(request):
- import json
- from analysis.geoip import lookup as geo_lookup
- from analysis.data_loader import load_csv_directory, load_from_db
- from analysis.models import AnalysisRun
- from analysis.session_store import SessionStore
-
- MAX_ROWS_PER_RUN = 300 # Limit per run to prevent browser overload
-
- # Check for ?data=dataset_id parameter (direct data loading from session store)
- data_param = request.GET.get('data')
- if data_param:
- store = SessionStore()
- flows = []
- all_runs = list(AnalysisRun.objects.filter(status='completed').order_by('-id').values('display_id', 'csv_glob'))
- try:
- entry = store.get_dataset(data_param)
- if entry is not None:
- lf = entry['lazyframe']
- df = lf.head(MAX_ROWS_PER_RUN).collect()
- flows = _extract_flows_from_df(df, MAX_ROWS_PER_RUN)
- except Exception as exc:
- import logging
- logging.getLogger(__name__).warning(f'globe ?data= param failed: {exc}')
- return render(request, 'tianxuan/globe.html', {
- 'geo_flows': json.dumps(flows),
- 'all_runs': all_runs,
- 'selected_ids': [],
- })
-
- # Get all completed runs for the selector
- all_runs = list(AnalysisRun.objects.filter(status='completed').order_by('-id').values('display_id', 'csv_glob'))
-
- # Get selected run IDs from query params (multiple checkboxes)
- selected_ids_list = request.GET.getlist('runs')
- selected_runs = []
- if selected_ids_list:
- for sid in selected_ids_list:
- sid = sid.strip()
- if sid.isdigit():
- try:
- r = AnalysisRun.objects.get(display_id=int(sid), status='completed')
- selected_runs.append(r)
- except AnalysisRun.DoesNotExist:
- pass
-
- # Check if user-selected runs have valid files; if not, fall through
- all_selected_failed = bool(selected_ids_list)
- fallback_run = None
-
- if not selected_runs:
- all_selected_failed = False
-
- flows = []
-
- for run in selected_runs:
- try:
- lf = None
- if run.sqlite_table:
- 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()
- flows.extend(_extract_flows_from_df(df, MAX_ROWS_PER_RUN))
- all_selected_failed = False
- break # stop after first successful load
- except Exception as exc:
- logger.warning('[GLOBE] Failed to load run %s (%s): %s', run.display_id, run.csv_glob, exc)
- continue
-
- if all_selected_failed or not flows:
- # Fallback: latest completed run with valid data (SQLite or CSV)
- for latest in AnalysisRun.objects.filter(status='completed').order_by('-id'):
- if not latest.csv_glob and not latest.sqlite_table:
- continue
- try:
- lf = None
- if latest.sqlite_table:
- lf = load_from_db(latest.sqlite_table)
- if lf is None:
- 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()
- flows.extend(_extract_flows_from_df(df, MAX_ROWS_PER_RUN))
- selected_runs = [latest]
- break
- except Exception:
- continue
-
- resp = render(request, 'tianxuan/globe.html', {
- 'geo_flows': json.dumps(flows),
- 'all_runs': all_runs,
- 'selected_ids': [r.display_id for r in selected_runs],
- })
- resp['Cache-Control'] = 'no-cache, no-store, must-revalidate'
- resp['Pragma'] = 'no-cache'
- resp['Expires'] = '0'
- return resp
-
-
-# ── MCP Tool Lab — manual tool execution ─────────────────────────────
-
-
-def tool_lab(request):
- """List all available MCP tools with descriptions and input schemas."""
- from analysis.tool_registry import get_tools_meta
- tools = get_tools_meta()
- # Group tools by category
- core_names = {'profile_data', 'build_entity_profiles', 'compute_scores',
- 'run_clustering', 'extract_features', 'detect_anomalies',
- 'visualize_anomalies'}
- diag_names = {'validate_data', 'explore_distributions', 'find_outliers',
- 'diagnose_clustering', 'compare_datasets', 'export_debug_sample',
- 'repair_schema'}
- analysis_names = {'analyze_patterns', 'analyze_temporal', 'analyze_tls_health',
- 'analyze_geo_distribution', 'analyze_entity_detail'}
-
- core_tools, diag_tools, analysis_tools, other_tools = [], [], [], []
- for t in tools:
- if t.name in core_names:
- core_tools.append(t)
- elif t.name in diag_names:
- diag_tools.append(t)
- elif t.name in analysis_names:
- analysis_tools.append(t)
- else:
- other_tools.append(t)
-
- # Get list of datasets for parameter autofill
- from analysis.session_store import SessionStore
- store = SessionStore()
- ds_list = store.list_datasets() if hasattr(store, 'list_datasets') else []
-
- import json as _json
- # Serialize tools metadata for JS
- meta_list = []
- for t in tools:
- meta_list.append({
- 'name': t.name,
- 'description': t.description,
- 'inputSchema': t.inputSchema,
- })
- return render(request, 'analysis/tool_lab.html', {
- 'core_tools': core_tools,
- 'diag_tools': diag_tools,
- 'analysis_tools': analysis_tools,
- 'other_tools': other_tools,
- 'datasets': ds_list,
- 'tools_meta': _json.dumps(meta_list, ensure_ascii=False),
- })
-
-
-@csrf_exempt
-def tool_lab_run(request):
- """Execute a single MCP tool via AJAX POST."""
- if request.method != 'POST':
- return JsonResponse({'error': 'POST required'}, status=405)
- import json as _json
- try:
- body = _json.loads(request.body)
- except Exception:
- return JsonResponse({'error': 'Invalid JSON body'}, status=400)
-
- tool_name = body.get('tool')
- params = body.get('params', {})
- if not tool_name:
- return JsonResponse({'error': 'Missing tool name'}, status=400)
-
- # ── Backend validation: check required params + dataset existence ──
- from analysis.tool_registry import handle_call, get_tools_meta
- from analysis.session_store import SessionStore
- tools_meta = get_tools_meta()
- tool_schema = None
- for t in tools_meta:
- if t.name == tool_name:
- tool_schema = t.inputSchema
- break
- if tool_schema:
- required_fields = tool_schema.get('required', [])
- for field in required_fields:
- val = params.get(field)
- if val is None or (isinstance(val, str) and val.strip() == ''):
- return JsonResponse({'error': f'Missing required parameter: {field}'}, status=400)
- # If field is a dataset_id, verify it exists in the store
- if 'dataset' in field.lower():
- store = SessionStore()
- if not store.get_dataset(str(val)):
- return JsonResponse({'error': f'Dataset not found: {val}'}, status=400)
-
- import asyncio
- result = asyncio.run(handle_call(tool_name, params))
- return JsonResponse({'status': 'ok', 'result': result})
-
-
-@csrf_exempt
-def reload_run_data(request):
- """Reload a run's data from csv_glob or sqlite_table into SessionStore."""
- if request.method != 'POST':
- return JsonResponse({'error': 'POST required'}, status=405)
- import json as _json
- try:
- body = _json.loads(request.body)
- except Exception:
- return JsonResponse({'error': 'Invalid JSON body'}, status=400)
-
- display_id = body.get('display_id')
- if not display_id:
- return JsonResponse({'error': 'Missing display_id'}, status=400)
-
- from analysis.models import AnalysisRun
- from analysis.data_loader import load_csv_directory, load_from_db
-
- run = get_object_or_404(AnalysisRun, display_id=display_id)
-
- # Determine dataset_id (same keying as manual_page)
- ds_id = f'upload_{display_id}' if run.run_type == 'upload' else f'run_{display_id}'
-
- store = SessionStore()
- # Check if already loaded
- existing = store.get_dataset(ds_id)
- if existing is not None:
- return JsonResponse({'status': 'ok', 'dataset_id': ds_id, 'reloaded': False})
-
- # Try loading from sqlite_table first, then csv_glob
- lf = None
- schema = {}
- row_count = 0
- file_count = 0
-
- if run.sqlite_table:
- try:
- lf = load_from_db(run.sqlite_table)
- if lf is not None:
- row_count = lf.select(pl.len()).collect(streaming=True)[0, 0]
- except Exception as exc:
- import logging
- logging.getLogger(__name__).warning(f'Failed to load from sqlite_table: {exc}')
-
- if lf is None and run.csv_glob:
- try:
- from pathlib import Path
- csv_path = Path(run.csv_glob)
- if csv_path.is_file() or csv_path.is_dir() or csv_path.parent.is_dir():
- lf, schema, row_count, file_count, _ = load_csv_directory(run.csv_glob)
- except Exception as exc:
- import logging
- logging.getLogger(__name__).warning(f'Failed to load from csv_glob: {exc}')
-
- if lf is None:
- return JsonResponse({'error': 'No data source available (upload dir or sqlite table gone)'}, status=404)
-
- store.store_dataset(ds_id, lf, schema=schema, metadata={
- 'row_count': row_count,
- 'file_count': file_count,
- 'csv_glob': run.csv_glob or '',
- 'sqlite_table': run.sqlite_table or '',
- })
-
- return JsonResponse({
- 'status': 'ok',
- 'dataset_id': ds_id,
- 'reloaded': True,
- 'row_count': row_count,
- 'columns': list(schema.keys()),
- })
-
-
-# ── Save / Load / Delete analysis plans ─────────────────────────────
-
-from pathlib import Path
-_PLANS_DIR = Path(__file__).resolve().parent.parent / '.omo' / 'plans'
-_PLANS_DIR.mkdir(parents=True, exist_ok=True)
-
-_AUTO_INDEX_PATH = _PLANS_DIR / '_auto_index.json'
-_MAX_AUTO_PLANS = 10
-
-
-def _read_auto_index():
- """Read the auto index file, returning list of {file, pinned}."""
- import json as _j
- if not _AUTO_INDEX_PATH.exists():
- return []
- try:
- data = _j.loads(_AUTO_INDEX_PATH.read_text(encoding='utf-8'))
- if not isinstance(data, list):
- return []
- return data
- except Exception:
- return []
-
-
-def _write_auto_index(index):
- """Write the auto index list."""
- import json as _j
- _AUTO_INDEX_PATH.write_text(
- _j.dumps(index, ensure_ascii=False, indent=2),
- encoding='utf-8',
- )
-
-
-def _add_to_auto_index(filename):
- """Prepend filename to auto index, evict unpinned entries when >10."""
- index = _read_auto_index()
- # Remove if already present
- index = [entry for entry in index if entry.get('file') != filename]
- # Prepend
- index.insert(0, {'file': filename, 'pinned': False})
- # Evict unpinned entries beyond max
- pinned_entries = [e for e in index if e.get('pinned')]
- unpinned_entries = [e for e in index if not e.get('pinned')]
- # Keep all pinned + fill remaining slots with unpinned
- result = pinned_entries + unpinned_entries[:_MAX_AUTO_PLANS - len(pinned_entries)]
- _write_auto_index(result)
-
-
-@csrf_exempt
-def tool_plan(request):
- """Save, list, load, delete, or pin/unpin an analysis plan."""
- import json as _j
- if request.method == 'GET':
- name = request.GET.get('name')
- if name:
- # Auto plans stored with _auto_ prefix
- if name.startswith('_auto_'):
- path = _PLANS_DIR / f'{name}.json'
- else:
- path = _PLANS_DIR / f'{name}.json'
- if not path.exists():
- return JsonResponse({'error': f'Plan "{name}" not found'}, status=404)
- try:
- data = _j.loads(path.read_text(encoding='utf-8'))
- return JsonResponse(data)
- except Exception as e:
- return JsonResponse({'error': str(e)}, status=500)
-
- # List all plans (regular + auto)
- auto_index = _read_auto_index()
- auto_index_map = {e.get('file', ''): e.get('pinned', False) for e in auto_index}
- auto_names = set()
- plans = []
- for p in sorted(_PLANS_DIR.glob('*.json')):
- # Skip index file and auto plans (handled separately)
- if p.name == '_auto_index.json':
- continue
- if p.name.startswith('_auto_'):
- auto_names.add(p.name)
- continue
- try:
- content = _j.loads(p.read_text(encoding='utf-8'))
- steps = len(content.get('steps', []))
- plans.append({'name': p.stem, 'steps': steps})
- except Exception:
- plans.append({'name': p.stem, 'steps': 0})
-
- # Add auto plans with markers
- auto_plans = []
- for auto_name in sorted(auto_names):
- try:
- path = _PLANS_DIR / auto_name
- content = _j.loads(path.read_text(encoding='utf-8'))
- steps = len(content.get('steps', []))
- auto_plans.append({
- 'name': auto_name.replace('.json', ''),
- 'steps': steps,
- 'auto': True,
- 'pinned': auto_index_map.get(auto_name, False),
- })
- except Exception:
- auto_plans.append({
- 'name': auto_name.replace('.json', ''),
- 'steps': 0,
- 'auto': True,
- 'pinned': auto_index_map.get(auto_name, False),
- })
- # Interleave: auto plans first, then regular
- return JsonResponse({'plans': auto_plans + plans})
-
- elif request.method == 'POST':
- try:
- body = _j.loads(request.body)
- # Pin/unpin toggle for auto plans
- if 'pin' in body and body.get('name', '').startswith('_auto_'):
- filename = body['name'].replace('.json', '') + '.json' \
- if not body['name'].endswith('.json') else body['name']
- index = _read_auto_index()
- new_pinned = bool(body['pin'])
- found = False
- for entry in index:
- if entry.get('file') == filename:
- entry['pinned'] = new_pinned
- found = True
- break
- if not found and new_pinned:
- index.append({'file': filename, 'pinned': True})
- _write_auto_index(index)
- return JsonResponse({
- 'status': 'pinned' if new_pinned else 'unpinned',
- 'name': body['name'],
- 'pinned': new_pinned,
- })
-
- # Regular save
- name = body.get('name', '').strip()
- steps = body.get('steps', [])
- if not name:
- return JsonResponse({'error': 'Missing plan name'}, status=400)
- path = _PLANS_DIR / f'{name}.json'
- path.write_text(_j.dumps({'name': name, 'steps': steps}, ensure_ascii=False, indent=2), encoding='utf-8')
- return JsonResponse({'status': 'saved', 'name': name, 'steps': len(steps)})
- except Exception as e:
- return JsonResponse({'error': str(e)}, status=500)
-
- elif request.method == 'DELETE':
- name = request.GET.get('name', '').strip()
- if not name:
- return JsonResponse({'error': 'Missing plan name'}, status=400)
- path = _PLANS_DIR / f'{name}.json'
- if path.exists():
- path.unlink()
- # Also remove from auto index if applicable
- if name.startswith('_auto_'):
- filename = name + '.json' if not name.endswith('.json') else name
- index = _read_auto_index()
- index = [e for e in index if e.get('file') != filename]
- _write_auto_index(index)
- return JsonResponse({'status': 'deleted', 'name': name})
-
- return JsonResponse({'error': 'Method not allowed'}, status=405)
-
-
-@csrf_exempt
-def retry_run(request, display_id):
- """Reset a failed run and re-trigger based on run_type."""
- if request.method != 'POST':
- return JsonResponse({'error': 'POST required'}, status=405)
-
- run = get_object_or_404(AnalysisRun, display_id=display_id)
- if run.status != 'failed':
- return JsonResponse({'error': '只能重试失败的分析运行'}, status=400)
-
- run.status = 'pending'
- run.error_message = ''
- run.progress_pct = 0
- run.progress_msg = ''
- run.save(update_fields=['status', 'error_message', 'progress_pct', 'progress_msg'])
-
- if run.run_type == 'upload' and run.csv_glob:
- upload_dir = Path(run.csv_glob).parent
- expected_base = ensure_appdata_dir() / 'data' / 'uploads'
- if not str(upload_dir.resolve()).startswith(str(expected_base.resolve())):
- return JsonResponse({'error': 'Invalid upload path'}, status=400)
- if upload_dir.is_dir():
- t = threading.Thread(
- target=_background_process,
- args=(run.id, upload_dir),
- daemon=True,
- )
- t.start()
- else:
- run.error_message = '上传数据目录已不存在,请重新上传'
- run.status = 'failed'
- run.save(update_fields=['status', 'error_message'])
-
- return redirect('analysis:run_detail', display_id=run.display_id)
-
-
-# Auto-profile all public functions in this module
-from analysis.profile_util import auto_profile_module # noqa: E402
-auto_profile_module(__name__)
-
diff --git a/scripts/fix_merge_conflict.py b/scripts/fix_merge_conflict.py
new file mode 100644
index 0000000..3dfa9e7
--- /dev/null
+++ b/scripts/fix_merge_conflict.py
@@ -0,0 +1,11 @@
+"""Fix the unresolved merge conflict in views/clustering.py."""
+import re
+with open('analysis/views/clustering.py', 'r', encoding='utf-8-sig') as f:
+ lines = f.readlines()
+
+# Find and remove lines containing conflict markers
+new_lines = [l for l in lines if '<<<<<<<' not in l and '>>>>>>>' not in l and '=======' not in l]
+with open('analysis/views/clustering.py', 'w', encoding='utf-8') as f:
+ f.writelines(new_lines)
+print(f'Removed {len(lines) - len(new_lines)} conflict marker lines')
+
diff --git a/static/tianxuan/cluster.css b/static/tianxuan/cluster.css
deleted file mode 100644
index 51060b1..0000000
--- a/static/tianxuan/cluster.css
+++ /dev/null
@@ -1,133 +0,0 @@
-/**
- * Shared cluster page styles.
- * Extracted from templates/analysis/cluster_overview.html and cluster_detail.html
- */
-
-/* ── Legend pills ── */
-.legend-pills {
- display: flex;
- flex-wrap: wrap;
- gap: 4px 8px;
- padding: 6px 0;
-}
-.pill {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- padding: 2px 10px;
- border-radius: 12px;
- font-size: 0.75rem;
- cursor: pointer;
- border: 2px solid transparent;
- transition: all 0.15s;
- white-space: nowrap;
-}
-.pill:hover {
- opacity: 0.8;
-}
-.pill.active {
- border-color: #333;
- font-weight: 600;
-}
-
-/* ── Cluster card ── */
-.cluster-card {
- border: 1px solid #e0e0e0;
- border-radius: 8px;
- padding: 1rem;
- background: #fff;
- cursor: pointer;
- transition: box-shadow 0.15s;
-}
-.cluster-card:hover {
- box-shadow: 0 2px 8px rgba(0,0,0,0.1);
-}
-.cluster-card.selected {
- border-color: #4361ee;
- box-shadow: 0 0 0 2px #4361ee33;
-}
-
-/* ── NL summary ── */
-.nl-summary {
- background: #f8f9fa;
- border-left: 3px solid #4361ee;
- padding: 0.6rem;
- margin: 0.5rem 0;
- border-radius: 0 4px 4px 0;
- font-size: 0.85rem;
- line-height: 1.6;
- color: #333;
-}
-
-/* ── Feature table ── */
-.feat-table {
- width: 100%;
- border-collapse: collapse;
- font-size: 0.8rem;
-}
-.feat-table th {
- background: #f5f5f5;
- padding: 0.3rem 0.5rem;
- text-align: left;
- font-weight: 600;
- border-bottom: 2px solid #ddd;
- white-space: nowrap;
-}
-.feat-table td {
- padding: 0.25rem 0.5rem;
- border-bottom: 1px solid #eee;
-}
-.feat-table td code {
- font-size: 0.72rem;
-}
-
-/* ── Feature scores ── */
-.feat-positive {
- color: #2e7d32;
-}
-.feat-negative {
- color: #c62828;
-}
-
-/* ── Detail panel (overlay) ── */
-.detail-overlay {
- position: fixed;
- bottom: 0;
- left: 0;
- right: 0;
- z-index: 100;
- transform: translateY(100%);
- transition: transform 0.35s cubic-bezier(.4,0,.2,1);
-}
-.detail-overlay.open {
- transform: translateY(0);
-}
-.detail-content {
- background: #fff;
- border-radius: 16px 16px 0 0;
- box-shadow: 0 -4px 24px rgba(0,0,0,0.15);
- max-height: 45vh;
- overflow-y: auto;
- padding: 1rem 2rem 2rem;
-}
-.detail-handle {
- width: 40px;
- height: 4px;
- background: #ccc;
- border-radius: 2px;
- margin: 0.5rem auto;
- cursor: pointer;
-}
-.detail-handle:hover {
- background: #999;
-}
-.detail-close {
- float: right;
- cursor: pointer;
- font-size: 1.5rem;
- color: #999;
- line-height: 1;
-}
-.detail-close:hover {
- color: #333;
-}
diff --git a/static/tianxuan/globe_embed.js b/static/tianxuan/globe_embed.js
deleted file mode 100644
index 800637c..0000000
--- a/static/tianxuan/globe_embed.js
+++ /dev/null
@@ -1,129 +0,0 @@
-/**
- * Three.js 3D globe rendering for TLS flow analysis.
- * Extracted from templates/tianxuan/globe_embed.html
- *
- * Usage: initGlobe('globeContainer', geoFlows, options)
- */
-function initGlobe(containerId, geoFlows, options) {
- options = options || {};
- var W = window.innerWidth, H = window.innerHeight;
-
- var scene = new THREE.Scene();
- var camera = new THREE.PerspectiveCamera(45, W / H, 0.1, 1000);
- camera.position.set(0, 2, 12);
-
- var renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
- renderer.setSize(W, H);
- renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
- document.getElementById(containerId).appendChild(renderer.domElement);
-
- // Earth
- var earthGeo = new THREE.SphereGeometry(5, 64, 64);
- var earthMat = new THREE.MeshPhongMaterial({ color: 0x1a3a5c, emissive: 0x0a1a2a, specular: new THREE.Color(0x333333), shininess: 5 });
- var earth = new THREE.Mesh(earthGeo, earthMat);
- scene.add(earth);
-
- // Glow
- var glow = new THREE.Mesh(new THREE.SphereGeometry(5.1, 64, 64), new THREE.MeshBasicMaterial({ color: 0x224488, transparent: true, opacity: 0.1 }));
- scene.add(glow);
-
- // Lights
- scene.add(new THREE.AmbientLight(0x222244));
- var dl = new THREE.DirectionalLight(0xffffff, 1);
- dl.position.set(5, 10, 7);
- scene.add(dl);
- scene.add(new THREE.DirectionalLight(0x4488ff, 0.3));
-
- // Stars
- var starGeo = new THREE.BufferGeometry();
- var starPos = new Float32Array(1000 * 3);
- for (var i = 0; i < 1000 * 3; i++) starPos[i] = (Math.random() - 0.5) * 200;
- starGeo.setAttribute('position', new THREE.BufferAttribute(starPos, 3));
- scene.add(new THREE.Points(starGeo, new THREE.PointsMaterial({ color: 0xffffff, size: 0.15 })));
-
- // Lat/lon grid
- var gridMat = new THREE.LineBasicMaterial({ color: 0x6699cc, transparent: true, opacity: 0.1 });
- for (var lat = -80; lat <= 80; lat += 20) {
- var pts = [];
- for (var lon = 0; lon <= 360; lon += 5) {
- var phi = (90 - lat) * Math.PI / 180;
- var theta = (lon + 180) * Math.PI / 180;
- pts.push(new THREE.Vector3(-5.02 * Math.sin(phi) * Math.cos(theta), 5.02 * Math.cos(phi), 5.02 * Math.sin(phi) * Math.sin(theta)));
- }
- scene.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), gridMat));
- }
- for (var lon = 0; lon < 360; lon += 20) {
- var pts = [];
- for (var lat = -90; lat <= 90; lat += 5) {
- var phi = (90 - lat) * Math.PI / 180;
- var theta = (lon + 180) * Math.PI / 180;
- pts.push(new THREE.Vector3(-5.02 * Math.sin(phi) * Math.cos(theta), 5.02 * Math.cos(phi), 5.02 * Math.sin(phi) * Math.sin(theta)));
- }
- scene.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), gridMat));
- }
-
- // Flow arcs
- var tlsColors = { 'TLSv1.3': 0x4cc9f0, 'TLSv1.2': 0x43aa8b, 'TLSv1.1': 0xf8961e, 'TLSv1.0': 0xf94144 };
- var flowGroup = new THREE.Group();
- scene.add(flowGroup);
-
- function latLonToVec3(lat, lon, r) {
- if (lat == null || lon == null) return null;
- var phi = (90 - lat) * Math.PI / 180;
- var theta = (lon + 180) * Math.PI / 180;
- return new THREE.Vector3(-r * Math.sin(phi) * Math.cos(theta), r * Math.cos(phi), r * Math.sin(phi) * Math.sin(theta));
- }
-
- var arcMeshes = [];
- var flowData = Array.isArray(geoFlows) ? geoFlows : (typeof geoFlows === 'string' ? JSON.parse(geoFlows) : []);
- flowData.forEach(function (flow) {
- var from = latLonToVec3(flow.slat, flow.slon, 5);
- var to = latLonToVec3(flow.dlat, flow.dlon, 5);
- if (!from || !to) return;
- var mid = new THREE.Vector3().addVectors(from, to).multiplyScalar(0.5).normalize().multiplyScalar(7);
- var curve = new THREE.QuadraticBezierCurve3(from, mid, to);
- var color = tlsColors[flow.tls] || 0x888888;
- var mat = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.6 });
- var mesh = new THREE.Mesh(new THREE.TubeGeometry(curve, 20, 0.02, 4, false), mat);
- mesh.userData = flow;
- flowGroup.add(mesh);
- arcMeshes.push(mesh);
- });
-
- // Mouse rotation
- var isDragging = false, prevMouse = { x: 0, y: 0 };
- renderer.domElement.addEventListener('mousedown', function (e) { isDragging = true; prevMouse = { x: e.clientX, y: e.clientY }; });
- window.addEventListener('mouseup', function () { isDragging = false; });
- window.addEventListener('mousemove', function (e) {
- if (!isDragging) return;
- var dx = e.clientX - prevMouse.x, dy = e.clientY - prevMouse.y;
- earth.rotation.y += dx * 0.005;
- earth.rotation.z += dy * 0.005;
- flowGroup.rotation.y = earth.rotation.y;
- flowGroup.rotation.z = earth.rotation.z;
- prevMouse = { x: e.clientX, y: e.clientY };
- });
-
- // Listen for cluster filter from parent
- window.addEventListener('message', function (e) {
- if (e.data && e.data.type === 'globe_filter') {
- var label = e.data.cluster_label;
- arcMeshes.forEach(function (mesh) {
- if (label === null) {
- mesh.material.opacity = 0.6;
- mesh.material.color.setHex(tlsColors[mesh.userData.tls] || 0x888888);
- } else {
- mesh.material.opacity = 0.06;
- mesh.material.color.setHex(0x666666);
- }
- });
- }
- });
-
- // Auto-rotate
- (function animate() {
- requestAnimationFrame(animate);
- if (!isDragging) { earth.rotation.y += 0.003; flowGroup.rotation.y = earth.rotation.y; }
- renderer.render(scene, camera);
- })();
-}
diff --git a/static/tianxuan/markdown.js b/static/tianxuan/markdown.js
deleted file mode 100644
index 0e290eb..0000000
--- a/static/tianxuan/markdown.js
+++ /dev/null
@@ -1,43 +0,0 @@
-/**
- * Simple Markdown-to-HTML converter.
- * Extracted from templates/tianxuan/auto.html
- *
- * Usage: renderMarkdown(text) → HTML string
- */
-function renderMarkdown(text) {
- if (!text) return '';
- // Escape HTML first
- var html = escapeHtml(text);
- // Code blocks (```...```) — must be before inline code
- html = html.replace(/```(\w*)\n?([\s\S]*?)```/g, '$2
');
- // Inline code
- html = html.replace(/`([^`]+)`/g, '$1');
- // Bold (**text** or __text__)
- html = html.replace(/\*\*([^*]+)\*\*/g, '$1');
- html = html.replace(/__([^_]+)__/g, '$1');
- // Italic (*text* or _text_)
- html = html.replace(/\*([^*]+)\*/g, '$1');
- html = html.replace(/(?$1');
- // Links [text](url)
- html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1');
- // Headers (# ## ###)
- html = html.replace(/^### (.+)$/gm, '$1
');
- html = html.replace(/^## (.+)$/gm, '$1
');
- html = html.replace(/^# (.+)$/gm, '$1
');
- // Blockquotes
- html = html.replace(/^>\s?(.+)$/gm, '$1
');
- // Unordered lists
- html = html.replace(/^[\s]*[-*+]\s+(.+)$/gm, '$1');
- html = html.replace(/()\n(?!' + m + ''; });
- // Ordered lists
- html = html.replace(/^\d+\.\s+(.+)$/gm, '$1');
- // Horizontal rules
- html = html.replace(/^---$/gm, '
');
- // Paragraphs (double newlines → paragraphs)
- html = html.replace(/\n\n/g, '
');
- // Line breaks (single newlines → br)
- html = html.replace(/\n/g, '
');
- // Wrap in paragraph if not already wrapped
- if (!html.startsWith('<')) html = '
' + html + '
';
- return html;
-}
diff --git a/static/tianxuan/timeline.js b/static/tianxuan/timeline.js
deleted file mode 100644
index 627b11b..0000000
--- a/static/tianxuan/timeline.js
+++ /dev/null
@@ -1,162 +0,0 @@
-/**
- * LLM workflow timeline rendering.
- * Extracted from templates/tianxuan/auto.html and templates/analysis/run_detail.html
- *
- * Provides:
- * buildTimeline(llmThinking, toolCalls) → entries[]
- * renderTimeline(entries, containerId)
- * escapeHtml(str)
- * prettyJson(obj)
- * toggleStep(el)
- */
-
-/**
- * Escape HTML special characters.
- */
-function escapeHtml(str) {
- if (str == null) return '';
- return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
-}
-
-/**
- * Pretty-print object as JSON string.
- */
-function prettyJson(obj) {
- try { return JSON.stringify(obj, null, 2); }
- catch (e) { return String(obj); }
-}
-
-/**
- * Toggle a collapsible step card.
- */
-function toggleStep(el) {
- el.classList.toggle('open');
- var body = el.nextElementSibling;
- if (body) body.classList.toggle('open');
-}
-
-/**
- * Build timeline entries from LLM thinking text and tool calls.
- *
- * @param {string} llmThinking - Raw LLM thinking text with [step] markers
- * @param {Array} toolCalls - Array of { step, name, input, output }
- * @returns {Array} entries - Interleaved array of { step, type, text/name/input/output }
- */
-function buildTimeline(llmThinking, toolCalls) {
- var thinkingByStep = {};
- var thinkOrder = [];
- if (llmThinking) {
- var parts = llmThinking.split(/\n(?=\[\d+\])/);
- for (var pi = 0; pi < parts.length; pi++) {
- var m = parts[pi].match(/^\[(\d+)\]\s*(.*)/s);
- if (m) {
- var step = parseInt(m[1]);
- if (!(step in thinkingByStep)) {
- thinkOrder.push(step);
- }
- thinkingByStep[step] = (thinkingByStep[step] || '') + (thinkingByStep[step] ? '\n' : '') + m[2].trim();
- }
- }
- }
-
- var toolMap = {};
- (toolCalls || []).forEach(function (tc) {
- var s = tc.step;
- if (!toolMap[s]) toolMap[s] = [];
- toolMap[s].push({
- type: 'tool',
- name: tc.name,
- input: tc.input,
- output: tc.output,
- });
- });
-
- var entries = [];
- var allSteps = new Set(thinkOrder.concat(Object.keys(toolMap).map(Number)));
- var sortedSteps = Array.from(allSteps).sort(function (a, b) { return a - b; });
-
- for (var si = 0; si < sortedSteps.length; si++) {
- var step = sortedSteps[si];
- if (thinkingByStep[step] !== undefined) {
- var isLastStep = step === sortedSteps[sortedSteps.length - 1];
- var hasTool = toolMap[step] && toolMap[step].length > 0;
- if (isLastStep && !hasTool) {
- entries.push({ step: step, type: 'answer', text: thinkingByStep[step] });
- } else {
- entries.push({ step: step, type: 'thinking', text: thinkingByStep[step] });
- }
- }
-
- if (toolMap[step]) {
- for (var tj = 0; tj < toolMap[step].length; tj++) {
- var te = toolMap[step][tj];
- entries.push({ step: step, type: 'tool', name: te.name, input: te.input, output: te.output, thinking: '' });
- }
- }
- }
-
- return entries;
-}
-
-/**
- * Render timeline entries into a DOM container.
- *
- * @param {Array} entries - From buildTimeline()
- * @param {string} containerId - DOM element ID to render into
- */
-function renderTimeline(entries, containerId) {
- var container = document.getElementById(containerId);
- if (!container || !entries || entries.length === 0) return;
-
- container.innerHTML = '';
- var lastStep = -1;
-
- for (var ei = 0; ei < entries.length; ei++) {
- var entry = entries[ei];
-
- if (entry.step !== lastStep) {
- var header = document.createElement('div');
- header.style.cssText = 'font-weight:700;font-size:0.85rem;color:#4361ee;padding:0.6rem 0 0.3rem 0;border-top:1px solid #eee;margin-top:0.4rem;';
- header.textContent = '\u6b65\u9aa4 ' + entry.step;
- container.appendChild(header);
- lastStep = entry.step;
- }
-
- if (entry.type === 'thinking') {
- var div = document.createElement('div');
- div.style.cssText = 'background:#eef2ff;padding:0.6rem;border-radius:6px;font-size:0.78rem;line-height:1.6;color:#333;max-height:300px;overflow:auto;margin-bottom:0.3rem;border:1px solid #d0d8f0;';
- div.innerHTML =
- '\ud83d\udcad \u601d\u8003' +
- (typeof renderMarkdown === 'function' ? renderMarkdown(entry.text) : escapeHtml(entry.text).replace(/\n/g, '
'));
- container.appendChild(div);
- } else if (entry.type === 'answer') {
- var div = document.createElement('div');
- div.style.cssText = 'background:#e8f5e9;padding:0.6rem 0.8rem;border-radius:6px;font-size:0.82rem;line-height:1.7;color:#333;max-height:400px;overflow:auto;margin-bottom:0.3rem;border:1px solid #c8e6c9;';
- div.innerHTML =
- '\u2705 \u56de\u7b54' +
- (typeof renderMarkdown === 'function' ? renderMarkdown(entry.text) : escapeHtml(entry.text).replace(/\n/g, '
'));
- container.appendChild(div);
- } else if (entry.type === 'tool') {
- var card = document.createElement('div');
- card.style.cssText = 'border:1px solid #e0e0e0;border-radius:6px;margin-bottom:0.3rem;overflow:hidden;background:#fff;';
- var inputStr = prettyJson(entry.input);
- var outputStr = prettyJson(entry.output);
- var bodyInner = '';
- bodyInner +=
- '\u8f93\u5165 (INPUT):
' +
- '' + escapeHtml(inputStr) + '
' +
- '\u8f93\u51fa (OUTPUT):
' +
- '' + escapeHtml(outputStr) + '
';
- card.innerHTML =
- '' +
- '' +
- bodyInner +
- '
';
- container.appendChild(card);
- }
- }
-}