1708 lines
66 KiB
Python
1708 lines
66 KiB
Python
"""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. extract_features
|
|
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 .entity_detector import detect_entity_column
|
|
from .entity_aggregator import aggregate_by_entity
|
|
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=(
|
|
"Load CSV files matching a glob pattern. Detects BOM per file, "
|
|
"validates schema consistency, merges vertically, and stores the "
|
|
"result as a lazy dataset in the session store."
|
|
),
|
|
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=(
|
|
"Compute per-column statistics and a numeric correlation matrix "
|
|
"for a dataset. Result is automatically truncated to ~16 KB for "
|
|
"LLM context-window efficiency."
|
|
),
|
|
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=(
|
|
"Apply column-level filters to a dataset and store the result as "
|
|
"a new derived dataset. Supports eq/neq/gt/gte/lt/lte/contains/"
|
|
"not_contains/in/not_in/is_null/is_not_null operators."
|
|
),
|
|
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=(
|
|
"Preprocess selected columns: scaling (standardize), one-hot encoding, "
|
|
"missing-value imputation, and column dropping."
|
|
),
|
|
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=(
|
|
"Run clustering on selected columns. Uses HDBSCAN (default) or "
|
|
"KMeans. Features are standard-scaled before clustering. Returns "
|
|
"cluster labels and quality metrics."
|
|
),
|
|
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=(
|
|
"Evaluate a clustering result on a dataset. Currently supports "
|
|
"silhouette, davies_bouldin, calinski_harabasz, noise_ratio, and "
|
|
"cluster_sizes metrics."
|
|
),
|
|
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=(
|
|
"Extract distinguishing features per cluster and auto-save to "
|
|
"the Django ORM (ClusterFeature model). Uses z-score or ANOVA "
|
|
"to rank feature importance per cluster."
|
|
),
|
|
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=(
|
|
"Export a dataset or cluster result to disk as CSV or Parquet. "
|
|
"Datasets are materialised and written; cluster results are "
|
|
"serialised as 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="List all active datasets and results in the session store.",
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {},
|
|
},
|
|
),
|
|
Tool(
|
|
name="drop_dataset",
|
|
description=(
|
|
"Remove a dataset (or result) from the session store and release "
|
|
"memory via garbage collection."
|
|
),
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"dataset_id": {
|
|
"type": "string",
|
|
"description": "Dataset or result identifier to remove",
|
|
},
|
|
},
|
|
"required": ["dataset_id"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="clone_dataset",
|
|
description=(
|
|
"Shallow-clone a dataset. Since LazyFrames are query plans, "
|
|
"no data is copied. Useful for branching analyses."
|
|
),
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"dataset_id": {
|
|
"type": "string",
|
|
"description": "Source dataset identifier",
|
|
},
|
|
},
|
|
"required": ["dataset_id"],
|
|
},
|
|
),
|
|
Tool(
|
|
name="build_entity_profiles",
|
|
description=(
|
|
"Auto-detect or manually specify entity column(s) (e.g. src_ip, "
|
|
"sni, user) and aggregate flow-level data into per-entity profiles. "
|
|
"Computes flow stats, destination diversity, TLS features, protocol "
|
|
"features, and time patterns. Results are persisted to the Django "
|
|
"ORM (EntityProfile model) and stored as a new dataset in the session."
|
|
),
|
|
inputSchema={
|
|
"type": "object",
|
|
"properties": {
|
|
"dataset_id": {
|
|
"type": "string",
|
|
"description": "Source dataset identifier from load_data",
|
|
},
|
|
"entity_column": {
|
|
"type": "string",
|
|
"description": "Column to group by (optional; auto-detected if omitted)",
|
|
},
|
|
"entity_columns": {
|
|
"type": "array",
|
|
"items": {"type": "string"},
|
|
"description": "Multiple columns to group by (optional; overrides entity_column)",
|
|
},
|
|
"auto_detect": {
|
|
"type": "boolean",
|
|
"description": "Auto-detect entity column when entity_column is not given",
|
|
"default": True,
|
|
},
|
|
},
|
|
"required": ["dataset_id"],
|
|
},
|
|
),
|
|
]
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════
|
|
# 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,
|
|
}
|
|
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 ───────────────────────────────────────────────────
|
|
|
|
def _cluster_sync(
|
|
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 (synchronous core)."""
|
|
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
|
|
}
|
|
|
|
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: General downsampling for big data (>50K rows → 50K) ───────
|
|
if len(data) > 50000:
|
|
rng = np.random.default_rng(42)
|
|
idx = rng.choice(len(data), 50000, replace=False)
|
|
data = data[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:
|
|
return {
|
|
'cluster_result_id': _generate_id('clust'),
|
|
'n_clusters': 0,
|
|
'n_noise': 0,
|
|
'quality_metrics': {'note': 'all features have zero variance'},
|
|
'truncated': False,
|
|
}
|
|
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,
|
|
},
|
|
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,
|
|
}
|
|
|
|
|
|
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 (async wrapper — delegates to _cluster_sync)."""
|
|
return _cluster_sync(
|
|
dataset_id=dataset_id,
|
|
cluster_columns=cluster_columns,
|
|
algorithm=algorithm,
|
|
params=params,
|
|
random_state=random_state,
|
|
)
|
|
|
|
|
|
# ── 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,
|
|
) -> 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']
|
|
df = lf.collect(streaming=True)
|
|
labels = cluster_entry['labels']
|
|
|
|
# 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
|
|
csv_glob = dataset_entry.get('metadata', {}).get('csv_glob', 'manual')
|
|
run_obj = AR.objects.filter(csv_glob=csv_glob).order_by('-id').first()
|
|
if run_obj:
|
|
run_id_for_ep = run_obj.id
|
|
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
|
|
cr = CR.objects.filter(run_id=run_id_for_ep, cluster_label=lbl).first()
|
|
EP.objects.update_or_create(entity_value=ev, defaults={
|
|
'run_id': run_id_for_ep, 'cluster_label': lbl, 'cluster': cr,
|
|
'embedding_x': float(coords[i,0]), 'embedding_y': float(coords[i,1])})
|
|
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):
|
|
run, _ = await sync_to_async(AnalysisRunModel.objects.get_or_create, thread_sensitive=True)(
|
|
csv_glob=dataset_entry.get('metadata', {}).get('csv_glob', 'manual'),
|
|
defaults={'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}
|
|
|
|
|
|
# ── 12. build_entity_profiles ─────────────────────────────────────────────
|
|
|
|
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:
|
|
"""Auto-detect entity column(s), aggregate flows → entity profiles, persist.
|
|
|
|
Steps:
|
|
1. Load dataset from session store.
|
|
2. Resolve entity column(s): *entity_columns* takes precedence,
|
|
then *entity_column*, then auto-detection.
|
|
3. Run :func:`aggregate_by_entity` to build per-entity features.
|
|
4. Store aggregated result as a new dataset in the session.
|
|
5. Persist each entity's feature dict to the Django ORM
|
|
(:class:`EntityProfile`).
|
|
6. Return summary (capped at ~8 KB).
|
|
"""
|
|
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: dict[str, str] = entry.get('schema', {})
|
|
|
|
# ── Step 1: resolve entity column(s) ─────────────────────────────────
|
|
used_columns: list[str] = []
|
|
detection_confidence: Optional[float] = None
|
|
candidates: list[dict] = []
|
|
|
|
if entity_columns:
|
|
# Multi-column support: use specified list directly
|
|
missing = [c for c in entity_columns if c not in schema]
|
|
if missing:
|
|
return {
|
|
'error': f"Entity column(s) '{missing}' not found in schema. "
|
|
f"Available: {list(schema.keys())[:20]}",
|
|
'truncated': False,
|
|
}
|
|
used_columns = entity_columns
|
|
elif entity_column:
|
|
if entity_column not in schema:
|
|
return {
|
|
'error': f"Entity column '{entity_column}' not found in schema. "
|
|
f"Available: {list(schema.keys())[:20]}",
|
|
'truncated': False,
|
|
}
|
|
used_columns = [entity_column]
|
|
elif auto_detect:
|
|
det_result = detect_entity_column(dataset_id)
|
|
if det_result.get('error'):
|
|
return {'error': f'Entity detection failed: {det_result["error"]}',
|
|
'truncated': False}
|
|
candidates = det_result.get('candidates', [])
|
|
recommended = det_result.get('recommended', '')
|
|
used_columns = [recommended] if recommended else []
|
|
if candidates:
|
|
detection_confidence = candidates[0]['score']
|
|
if not used_columns:
|
|
return {
|
|
'error': 'No suitable entity column auto-detected. '
|
|
'Provide entity_column manually.',
|
|
'truncated': False,
|
|
}
|
|
else:
|
|
return {
|
|
'error': 'No entity_column provided and auto_detect is False.',
|
|
'truncated': False,
|
|
}
|
|
|
|
# ── Step 2: aggregate ────────────────────────────────────────────────
|
|
try:
|
|
agg_lf, feature_cols = aggregate_by_entity(lf, used_columns, schema)
|
|
except Exception as exc:
|
|
return {'error': f'Aggregation failed: {exc}', 'truncated': False}
|
|
|
|
# Collect to compute entity count and preview
|
|
try:
|
|
df_agg = agg_lf.collect(streaming=True)
|
|
entity_count = len(df_agg)
|
|
feature_columns = [c for c in df_agg.columns if not c.startswith('_')]
|
|
except Exception as exc:
|
|
return {'error': f'Failed to collect aggregated data: {exc}',
|
|
'truncated': False}
|
|
|
|
# Build feature column schema
|
|
agg_schema = {name: str(dtype) for name, dtype in
|
|
zip(df_agg.columns, [df_agg[c].dtype for c in df_agg.columns])}
|
|
|
|
# Primary entity column for metadata / DB persistence
|
|
primary_entity_col = used_columns[0]
|
|
|
|
# ── Step 3: store as new dataset in session ──────────────────────────
|
|
entity_dataset_id = _generate_id('entity')
|
|
store.store_dataset(
|
|
dataset_id=entity_dataset_id,
|
|
lazyframe=df_agg.lazy(),
|
|
schema=agg_schema,
|
|
metadata={
|
|
'row_count': entity_count,
|
|
'entity_column': primary_entity_col,
|
|
'entity_columns': used_columns,
|
|
'parent_dataset_id': dataset_id,
|
|
'feature_columns': feature_columns,
|
|
},
|
|
parent_id=dataset_id,
|
|
)
|
|
|
|
# ── Step 4: persist to Django ORM (EntityProfile) ────────────────────
|
|
db_saved = False
|
|
try:
|
|
db_saved = _save_entity_profiles(
|
|
dataset_id=dataset_id,
|
|
entity_column=primary_entity_col,
|
|
df_agg=df_agg,
|
|
entity_count=entity_count,
|
|
)
|
|
except Exception:
|
|
db_saved = False
|
|
|
|
# ── Step 5: build response (compact, ≤ 8 KB friendly) ────────────────
|
|
# Take a small sample of rows for the response
|
|
sample_rows = df_agg.head(20).to_dict(as_series=False) if entity_count > 0 else {}
|
|
|
|
result = {
|
|
'entity_dataset_id': entity_dataset_id,
|
|
'entity_count': entity_count,
|
|
'entity_columns_used': used_columns,
|
|
'feature_columns': feature_columns,
|
|
'detection_confidence': detection_confidence,
|
|
'detection_candidates': candidates[:5] if candidates else [],
|
|
'db_saved': db_saved,
|
|
'sample': sample_rows,
|
|
}
|
|
|
|
# Ensure truncation within ~8 KB (the global _truncate_response uses 64 KB
|
|
# by default, but we cap sample here for this tool)
|
|
import json
|
|
body = json.dumps(result, default=str, ensure_ascii=False)
|
|
if len(body) > 8 * 1024:
|
|
# Drop sample and candidates, keep only counts
|
|
result.pop('sample', None)
|
|
result.pop('detection_candidates', None)
|
|
result['truncated'] = True
|
|
else:
|
|
result['truncated'] = False
|
|
|
|
return result
|
|
|
|
|
|
def _save_entity_profiles(
|
|
dataset_id: str,
|
|
entity_column: str,
|
|
df_agg: pl.DataFrame,
|
|
entity_count: int,
|
|
) -> bool:
|
|
"""Persist aggregated entity profiles to the Django ORM.
|
|
|
|
Creates/updates :class:`EntityProfile` records linked to the
|
|
:class:`AnalysisRun` associated with *dataset_id*.
|
|
"""
|
|
import django
|
|
import os
|
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tianxuan.settings')
|
|
django.setup()
|
|
|
|
from analysis.models import AnalysisRun, EntityProfile
|
|
|
|
store = SessionStore()
|
|
ds_entry = store.get_dataset(dataset_id)
|
|
if ds_entry is None:
|
|
return False
|
|
|
|
csv_glob = ds_entry.get('metadata', {}).get('csv_glob', '')
|
|
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='aggregating',
|
|
entity_count=entity_count,
|
|
entity_column=entity_column,
|
|
)
|
|
except Exception:
|
|
return False
|
|
|
|
# Build EntityProfile records in bulk
|
|
entity_values = df_agg[entity_column].to_list()
|
|
feature_cols = [c for c in df_agg.columns if not c.startswith('_') and c != entity_column]
|
|
|
|
profiles = []
|
|
for row_idx in range(len(df_agg)):
|
|
entity_val = str(entity_values[row_idx])
|
|
feature_dict = {
|
|
col: _serialize_value(df_agg[col][row_idx])
|
|
for col in feature_cols
|
|
}
|
|
profiles.append(EntityProfile(
|
|
run=run,
|
|
entity_value=entity_val,
|
|
feature_json=feature_dict,
|
|
))
|
|
|
|
try:
|
|
EntityProfile.objects.bulk_create(profiles, ignore_conflicts=True)
|
|
except Exception:
|
|
return False
|
|
|
|
# Update run metadata
|
|
run.status = 'completed'
|
|
run.entity_count = entity_count
|
|
run.entity_column = entity_column
|
|
run.save(update_fields=['status', 'entity_count', 'entity_column'])
|
|
|
|
return True
|
|
|
|
|
|
def _serialize_value(val) -> object:
|
|
"""Convert a Polars/Numpy scalar to a JSON-safe Python value."""
|
|
if val is None:
|
|
return None
|
|
if isinstance(val, (int, float, str, bool)):
|
|
# Handle NaN / Inf
|
|
if isinstance(val, float):
|
|
import math
|
|
if math.isnan(val) or math.isinf(val):
|
|
return None
|
|
return val
|
|
if isinstance(val, (list, tuple)):
|
|
return [_serialize_value(v) for v in val]
|
|
return str(val)
|