Files
tianxuan/analysis/tool_registry.py
T
PM-pinou 8fa36b774e v7: 19 issues fixed — SQLite storage, new clustering pipeline, display_id, globe rewrite, Chinese tools
Key changes:
- New: data_loader.py SQLite persistence with drop_sqlite_table
- New: db_utils.py retry_on_lock decorator (3 retries, exponential backoff)
- New: tool_registry.py with 27 MCP tools (filter_and_cluster, compute_scores, etc.)
- New: tls_ref.py for TLS cipher/reference data
- New: import_tlsdb.py management command
- New: scripts/start_server.py for portable runtime
- New: migrations 0003-0007 for SQLite table, display_id, llm fields
- Changed: views.py unified pipeline worker, retry_run, display_id everywhere
- Changed: models.py with display_id auto-assignment, run_type, sqlite_table, llm_thinking, tool_calls_json
- Changed: urls.py added retry_run route
- Changed: session_store.py robust JSON persistence
- Changed: AGENTS.md v7 fix summary added
- Changed: templates — globe rewrite (inertia/polar flip), auto.html (thinking/tool accordions), base.html (toast/config), all pages use display_id
- Changed: run.bat PYTHONUTF8=1
- Deleted: entity_detector.py, entity_aggregator.py (replaced by filter_and_cluster clustering pipeline)
- Test: 92/92 unit tests passing
2026-07-20 13:33:13 +08:00

2954 lines
121 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tool definitions and handler functions for the TLS Analyzer MCP server.
Each tool is defined by a :class:`Tool <mcp.types.Tool>` metadata object and
an async handler function. Handlers interact with the :class:`SessionStore`
singleton and the Django ORM.
All 11 tools:
1. load_data 7. filter_and_cluster
2. profile_data 8. export_results
3. filter_data 9. list_datasets
4. preprocess_data 10. drop_dataset
5. run_clustering 11. clone_dataset
6. evaluate_clustering
"""
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
# ═══════════════════════════════════════════════════════════════════════
# 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": ["hdbscan", "kmeans"],
"description": "Clustering algorithm",
"default": "hdbscan",
},
"params": {
"type": "object",
"description": (
"Algorithm parameters. 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 ORMClusterFeature模型)。"
"使用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": ["hdbscan", "kmeans"],
"description": "Clustering algorithm",
"default": "hdbscan",
},
"params": {
"type": "object",
"description": (
"Algorithm parameters. HDBSCAN: min_cluster_size (5), "
"min_samples (None), metric ('euclidean'). "
"KMeans: n_clusters (3), random_state (42)."
),
},
},
"required": ["dataset_id", "filters"],
},
),
Tool(
name="compute_scores",
description=(
"在实体数据上计算自适应代理/异常/威胁评分。使用PCA从数据自动学习"
"特征权重,计算加权代理评分(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=(
"为高风险实体生成PCA-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=(
"诊断工具:当聚类产生太少/太多聚类、全部为噪声或轮廓系数低时——诊断原因。"
"检查:特征方差、相关矩阵、PCA解释方差、数据稀疏性,并建议参数调整"
"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_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,
'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_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']
try:
profile = _profile_dataset(lf, sample_size=sample_size, columns=columns)
except Exception as e:
return {'error': f'Profiling failed: {e}', 'truncated': False}
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', {})
try:
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.lit(1).count()).collect(streaming=True).item()
except Exception:
row_count = None
except Exception as e:
return {'error': f'Filter failed: {e}', 'truncated': False}
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] = {}
try:
df = lf.collect(streaming=True)
except Exception as e:
return {'error': f'Data collection failed: {e}', 'truncated': False}
# 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'<dropped:{c}>'
# 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'<onehot:{c}>'
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 = 'hdbscan',
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:
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
data_scaled = StandardScaler().fit_transform(data)
# ── 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: PCA dimensionality reduction (if >50 features) ───────────
n_features = data_scaled.shape[1]
if n_features > 50:
from sklearn.decomposition import PCA
pca = PCA(n_components=50, random_state=random_state)
data_scaled = pca.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
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',
)
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 as e:
return {'error': f'Clustering failed: {e}', 'truncated': False}
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 as e:
return {'error': f'Evaluation failed: {e}', 'truncated': False}
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 PCA-2D embedding ──────────────────────────────────────
if len(numeric_cols) >= 2 and len(df) > 2:
try:
data_matrix = df.select(numeric_cols).to_numpy()
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA
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)
pca_model = PCA(n_components=2, random_state=42)
coords = pca_model.fit_transform(data_scaled)
non_numeric = [c for c in df.columns if c not in numeric_cols and not c.startswith('_')]
entity_col_pca = 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_pca, ''))
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'[PCA_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 as e:
return {'error': f'Feature extraction failed: {e}', 'truncated': False}
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,
}
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 features (not from cluster_entry — avoids scope issues)
label_counts = {}
for feat in features:
lbl = feat['cluster_label']
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 as e:
return {'error': f'Export failed: {e}', 'truncated': False}
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}
# ── 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',
]
def _add_adaptive_scores(lf: pl.LazyFrame, feature_names: list[str]) -> pl.LazyFrame:
"""Add anomaly scores using data-driven weights (PCA + IQR + percentile).
Steps:
1. Find which anomaly feature columns exist in the aggregated data
2. Collect a sample (max 10K rows) for PCA weight learning
3. Learn feature weights from PCA 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: PCA weight learning
from sklearn.decomposition import PCA
n_comp = min(1, min(mat.shape) - 1)
if n_comp < 1 or mat.shape[1] < 2:
return lf
pca = PCA(n_components=1, random_state=42).fit(mat)
raw_weights = np.abs(pca.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 PCA: %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, [])
store.store_dataset(dataset_id, lf, schema=entry.get('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.lit(1).count()).collect(streaming=True).item()
except Exception:
row_count = None
except Exception as e:
return {'error': f'Filter failed: {e}', 'truncated': False}
# ── 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 as e:
return {'error': f'Feature detection failed: {e}', 'truncated': False}
# ── 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]
# PCA dimensionality reduction (if >50 features)
n_features = data_scaled.shape[1]
if n_features > 50:
from sklearn.decomposition import PCA
data_scaled = PCA(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 as e:
return {'error': f'Clustering failed: {e}',
'filtered_dataset_id': filtered_dataset_id, 'truncated': False}
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 PCA-2D embedding for anomaly visualization."""
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA
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 PCA', 'truncated': False}
# Sample for PCA visualisation
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)
coords = PCA(n_components=2, random_state=42).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
total_rows = lf.select(pl.len()).collect(streaming=True).item()
except Exception as exc:
return {'error': f'Validation failed: {exc}', 'truncated': False}
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]
try:
n = lf.select(pl.len()).collect(streaming=True).item()
sample_lf = lf if n <= max_sample else lf.collect(streaming=True).sample(n=max_sample, seed=42).lazy()
df = sample_lf.select(columns).collect(streaming=True)
except Exception as exc:
return {'error': f'Failed to collect sample: {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:
return {'error': f'Failed: {exc}', 'truncated': False}
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
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 PCA
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')
# PCA variance check
try:
df_sample = lf.select(num_cols).head(5000).collect(streaming=True)
mat = df_sample.to_numpy()
pca = PCA().fit(mat)
var_explained = pca.explained_variance_ratio_
if var_explained[0] > 0.95:
diagnosis.append(f'First PCA component explains {var_explained[0]:.0%} variance — data nearly 1D')
except Exception:
pass
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:
return {'error': f'Export failed: {exc}', 'truncated': False}
# ── 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
# 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
# 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
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'}
# ── 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
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
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
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:
return {'error': f'Failed to query: {exc}', 'truncated': False}
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
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): <body>``, 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)
try:
df = lf.collect(streaming=True)
except Exception as e:
return {'error': f'Data collection failed: {e}', 'truncated': False}
# 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,
}