This view is limited to 50 files because it contains too many changes. See the raw diff here.
Files changed (50) hide show
  1. .gitignore +1 -191
  2. .streamlit/config.toml +40 -0
  3. .streamlit/credentials.toml +2 -0
  4. .streamlit/secrets.toml +14 -0
  5. CLAUDE.md +0 -115
  6. ENHANCED_PIPELINE_README.md +0 -287
  7. PLAN.md +0 -313
  8. README.md +1 -48
  9. STREAMLIT_HANDOFF.md +0 -545
  10. analyzing.py +194 -1003
  11. api/main.py +0 -1155
  12. api/services/__init__.py +0 -1
  13. api/services/analysis_service.py +0 -287
  14. api/services/map_service.py +0 -198
  15. api/services/parsing_service.py +0 -312
  16. api/services/rag_service.py +0 -51
  17. api/services/scu_service.py +0 -83
  18. api/utils/data_utils.py +0 -186
  19. app.py +115 -18
  20. app2.py +16 -11
  21. claude_app_analysis.md +0 -178
  22. codex_app_analysis.md +0 -148
  23. config.py +0 -0
  24. embed_csv.py +0 -91
  25. embeddings.py +0 -259
  26. final_ufoseti_dataset.h5 +3 -0
  27. frontend/.gitignore +0 -24
  28. frontend/DEPLOY_VERCEL.md +0 -74
  29. frontend/README.md +0 -73
  30. frontend/eslint.config.js +0 -23
  31. frontend/index.html +0 -16
  32. frontend/package-lock.json +0 -0
  33. frontend/package.json +0 -39
  34. frontend/src/App.tsx +0 -33
  35. frontend/src/api/client.ts +0 -264
  36. frontend/src/components/analysis/AnalysisPage.tsx +0 -322
  37. frontend/src/components/analysis/ClusterView.tsx +0 -21
  38. frontend/src/components/analysis/ClusterVisualization.tsx +0 -64
  39. frontend/src/components/analysis/CorrelationHeatmap.tsx +0 -70
  40. frontend/src/components/analysis/CramersVExplorer.tsx +0 -487
  41. frontend/src/components/analysis/DistributionChart.tsx +0 -43
  42. frontend/src/components/analysis/XGBoostResults.tsx +0 -79
  43. frontend/src/components/common/LoadingSpinner.tsx +0 -11
  44. frontend/src/components/common/Panel.tsx +0 -27
  45. frontend/src/components/common/StatusBadge.tsx +0 -22
  46. frontend/src/components/dashboard/Dashboard.tsx +0 -194
  47. frontend/src/components/dashboard/StatCard.tsx +0 -24
  48. frontend/src/components/data/DataExplorer.tsx +0 -152
  49. frontend/src/components/data/DataTable.tsx +0 -299
  50. frontend/src/components/data/FilterPanel.tsx +0 -310
.gitignore CHANGED
@@ -1,191 +1 @@
1
- # Byte-compiled / optimized / DLL files
2
- __pycache__/
3
- *.py[cod]
4
- *$py.class
5
-
6
- # C extensions
7
- *.so
8
-
9
- # Distribution / packaging
10
- .Python
11
- build/
12
- develop-eggs/
13
- dist/
14
- downloads/
15
- eggs/
16
- .eggs/
17
- lib/
18
- lib64/
19
- parts/
20
- sdist/
21
- var/
22
- wheels/
23
- share/python-wheels/
24
- *.egg-info/
25
- .installed.cfg
26
- *.egg
27
- MANIFEST
28
-
29
- # PyInstaller
30
- # Usually these files are written by a python script from a template
31
- # before PyInstaller builds the exe, so may need to be excluded from sync.
32
- *.manifest
33
- *.spec
34
-
35
- # Installer logs
36
- pip-log.txt
37
- pip-delete-this-directory.txt
38
-
39
- # Unit test / coverage reports
40
- htmlcov/
41
- .tox/
42
- .nox/
43
- .coverage
44
- .coverage.*
45
- .cache
46
- nosetests.xml
47
- coverage.xml
48
- *.cover
49
- *.py,cover
50
- .hypothesis/
51
- .pytest_cache/
52
- cover/
53
-
54
- # Translations
55
- *.mo
56
- *.pot
57
-
58
- # Django stuff:
59
- *.log
60
- local_settings.py
61
- db.sqlite3
62
- db.sqlite3-journal
63
-
64
- # Flask stuff:
65
- instance/
66
- .webassets-cache
67
-
68
- # Scrapy stuff:
69
- .scrapy
70
-
71
- # Sphinx documentation
72
- docs/_build/
73
-
74
- # PyBuilder
75
- .pybuilder/
76
- target/
77
-
78
- # Jupyter Notebook
79
- .ipynb_checkpoints
80
-
81
- # IPython
82
- profile_default/
83
- ipython_config.py
84
-
85
- # pyenv
86
- # For a library or binary, you wish to ignore these files, but for an app, you might want to check them in.
87
- # .python-version
88
-
89
- # pipenv
90
- # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
91
- # However, in case of collaboration, if having platform-specific dependencies or dependencies
92
- # having no cross-platform support, pipenv may install dependencies that don't work, or even
93
- # fail to install them.
94
- # Pipfile.lock
95
-
96
- # poetry
97
- # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
98
- # poetry.lock
99
-
100
- # pdm
101
- # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
102
- # pdm.lock
103
-
104
- # PEP 582; used by e.g. github.com/pdm-project/pdm
105
- __pypackages__/
106
-
107
- # Celery stuff
108
- celerybeat-schedule
109
- celerybeat.pid
110
-
111
- # SageMath parsed files
112
- *.sage.py
113
-
114
- # Environments
115
- .env
116
- .venv
117
- env/
118
- venv/
119
- ENV/
120
- env.bak/
121
- venv.bak/
122
-
123
- # Spyder project settings
124
- .spyderproject
125
- .spyproject
126
-
127
- # Rope project settings
128
- .ropeproject
129
-
130
- # mkdocs documentation
131
- /site
132
-
133
- # mypy
134
- .mypy_cache/
135
- .dmypy.json
136
- dmypy.json
137
-
138
- # Pyre type checker
139
- .pyre/
140
-
141
- # pytype static type analyzer
142
- .pytype/
143
-
144
- # Cython debug symbols
145
- cython_debug/
146
-
147
- # PyCharm
148
- .idea/
149
-
150
- # Streamlit
151
- .streamlit/
152
-
153
- # VS Code
154
- .vscode/
155
-
156
- # Node.js
157
- node_modules/
158
- npm-debug.log*
159
- yarn-debug.log*
160
- yarn-error.log*
161
- .npm/
162
-
163
- # Dataset Files (Usually too large for git)
164
- *.h5
165
- *.h5.1
166
- *.csv
167
- *.jpeg
168
- *.webp
169
- *.pkl
170
-
171
- # Dist
172
- frontend/dist/
173
- frontend2/dist/
174
-
175
- # Large source PDFs (kept local, too big for git / HF Space)
176
- UAP_PDFs/
177
-
178
- # Document Preprocessing working dir (PDF corpus + intermediates) — data, not code.
179
- # The pipeline *scripts* live in pipeline/ and ARE tracked; the data tree is not.
180
- pipeline_data/
181
-
182
- # Windows "downloaded from internet" metadata sidecar files
183
- *Zone.Identifier
184
-
185
- # Superseded frontend backup + large generated cluster-viz HTML (kept local,
186
- # excluded from the HF Space deploy to stay within its storage limit).
187
- frontend_backup/
188
- frontend/uap_clusters_llm.html
189
-
190
- # Scratch notebooks
191
- Untitled.ipynb
 
1
+ .streamlit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.streamlit/config.toml ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [browser]
2
+ pageTitle = "UAP ANALYTICS"
3
+ pageIcon = ":alien:"
4
+ layout = "wide"
5
+
6
+ [server]
7
+ enableXsrfProtection = false
8
+ maxUploadSize=5000
9
+ maxMessageSize=5000
10
+
11
+
12
+ [theme]
13
+ # Primary accent for interactive elements
14
+ primaryColor = "#FFA500"
15
+
16
+ # Background color for the main content area
17
+ #backgroundColor = "#273346"
18
+
19
+ # Background color for sidebar and most interactive widgets
20
+ #secondaryBackgroundColor = "#B9F1C0"
21
+
22
+ # Color used for almost all text
23
+ #textColor = "#FFFFFF"
24
+
25
+ # Font family for all text in the app, except code blocks
26
+ # Accepted values (serif | sans serif | monospace)
27
+ # Default: "sans serif"
28
+ font = "sans serif"
29
+
30
+ # Base theme (light or dark)
31
+ base = "dark"
32
+
33
+
34
+ [runner]
35
+ magicEnabled = true
36
+ fastReruns = false
37
+
38
+
39
+ [client]
40
+ toolbarMode = "auto"
.streamlit/credentials.toml ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [general]
2
+ email = ""
.streamlit/secrets.toml ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ testing_mode = false
2
+ payment_provider = "stripe"
3
+ stripe_api_key_test = 'rk_test_51PYs5M2LWM8BdEzK3ggKUMfcoVpwpmWABeiewJZp797aMvxWzlmaDe70svq5wajxiun4x98OhQZQB1lqP3AsQfDS009pbiZHSx'
4
+ #stripe_api_key = 'pk_live_51PYs5M2LWM8BdEzK2lhKscFJJ8l8Z0CxY4xiLUTOoRjQnri1Qcf47Fhf3SAXH9P8jyPKYxfo3xEpHpHD5n8jMbqE00E3gRdxPF'
5
+ stripe_api_key = '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'
6
+ stripe_link_test = 'https://buy.stripe.com/test_4gw0390KX0ojc8w7ss'
7
+ stripe_link = 'https://buy.stripe.com/bIYcP31PZ4Jwdgs288'
8
+ client_id = '628411883365-dfkuut4shontl77uge7mta9514hambkc.apps.googleusercontent.com'
9
+ client_secret = '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'
10
+ redirect_url_test = 'https://huggingface.co/spaces/Ashoka74/UFOSINT'
11
+ redirect_url = 'https://huggingface.co/spaces/UFOSINT/UAP-Data-Analysis-Tool/'
12
+ GEMINI_KEY = 'AIzaSyAEALAXiaE1HcD8qcN1duY4OtmUDfYqquk'
13
+ COHERE_KEY = 'UOGoge1RICTXAb710UrE0QQSftv6qZx8ysJKXY6j'
14
+ OPENAI_KEY = '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'
CLAUDE.md DELETED
@@ -1,115 +0,0 @@
1
- # CLAUDE.md
2
-
3
- This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
-
5
- ## Project Overview
6
-
7
- UAP-Data-Analysis-Tool (also known as UFOSINT) is a Streamlit-based data analysis application for UFO/UAP (Unidentified Aerial Phenomena) sighting reports. It provides text parsing, clustering, statistical analysis, geospatial visualization, and magnetic anomaly correlation capabilities.
8
-
9
- ## Commands
10
-
11
- ### Running the Application
12
-
13
- ```bash
14
- # Streamlit app (main interface)
15
- uv run streamlit run app.py
16
-
17
- # Gradio app (alternative interface)
18
- uv run gradio gradio_app.py
19
- ```
20
-
21
- ### Dependency Management
22
-
23
- ```bash
24
- # Install dependencies using uv (Python 3.12+)
25
- uv sync
26
-
27
- # Or using pip
28
- pip install -r requirements.txt
29
- ```
30
-
31
- ### Testing/Running Individual Pages
32
-
33
- Each Streamlit page can be tested individually:
34
- ```bash
35
- uv run streamlit run parsing.py
36
- uv run streamlit run analyzing.py
37
- uv run streamlit run rag_search.py
38
- uv run streamlit run magnetic.py
39
- uv run streamlit run map.py
40
- ```
41
-
42
- ## Architecture
43
-
44
- ### Core Analysis Pipeline
45
-
46
- The application follows a multi-step NLP analysis pipeline:
47
-
48
- 1. **Parsing (`parsing.py`)** - Uses OpenAI GPT-4o-mini to extract structured JSON features from raw UAP report text. The JSON schema is defined in `config.py` (`FORMAT_LONG`).
49
-
50
- 2. **Analysis (`analyzing.py`)** - Performs dimensionality reduction (UMAP) and clustering (HDBSCAN) on text embeddings, then trains XGBoost classifiers to identify feature correlations.
51
-
52
- 3. **RAG Search (`rag_search.py`)** - Implements semantic search using Cohere's rerank API to find relevant reports based on natural language queries.
53
-
54
- 4. **Magnetic Analysis (`magnetic.py`)** - Correlates UAP sightings with InterMagnet station data using Dynamic Time Warping (FastDTW).
55
-
56
- 5. **Map Visualization (`map.py`)** - Interactive Kepler.gl maps showing sighting locations with proximity analysis to military bases and nuclear facilities.
57
-
58
- ### Key Classes (`uap_analyzer.py`)
59
-
60
- - **`UAPParser`** - Concurrent OpenAI API calls for JSON extraction with exponential backoff
61
- - **`UAPAnalyzer`** - Text embedding (sentence-transformers/e5-large-v2), UMAP reduction, HDBSCAN clustering, TF-IDF cluster naming, cluster merging via cosine similarity
62
- - **`UAPVisualizer`** - XGBoost prediction plots, confusion matrices, Cramer's V heatmaps, treemaps
63
-
64
- ### Utils Module (`utils/`)
65
-
66
- Enhanced utilities providing:
67
- - `DataProcessor` - DataFrame filtering with Streamlit UI
68
- - `UAP_Visualizer` - Interactive Plotly visualizations
69
- - `SessionStateManager` - Streamlit session state handling
70
- - `EmbeddingCacheManager` - Caches sentence-transformer embeddings to avoid recomputation
71
- - `MemoryManager` - GPU memory management for CUDA operations
72
- - `UAP_Pipeline` - End-to-end analysis pipeline orchestration
73
-
74
- ### Data Flow
75
-
76
- ```
77
- Raw Text Reports (CSV/XLSX)
78
- └── parsing.py (GPT-4o-mini) → Structured JSON
79
- └── analyzing.py → Embeddings → UMAP → HDBSCAN clusters
80
- └── XGBoost predictions + Cramer's V correlations
81
- └── map.py (Kepler.gl visualization)
82
- ```
83
-
84
- ### Session State Keys
85
-
86
- The app uses Streamlit session state extensively. Key variables:
87
- - `parsed_responses` / `parsed_responses_df` - Parsed JSON data
88
- - `analyzers` / `clusters` / `col_names` - Analysis results
89
- - `stage` - UI workflow state
90
- - `api_key_valid` - OpenAI key validation status
91
-
92
- ## Configuration
93
-
94
- ### API Keys
95
-
96
- Secrets are stored in `.streamlit/secrets.toml` (not committed). Required keys:
97
- - `OPENAI_KEY` - For GPT-4o-mini parsing
98
- - `GEMINI_KEY` - For Gemini Pro summarization
99
- - `COHERE_KEY` - For rerank search
100
-
101
- ### Data Files
102
-
103
- - `final_ufoseti_dataset.h5` - Pre-parsed UAP dataset
104
- - `parsed_files_distance_embeds.h5` - Dataset with embeddings
105
- - `global_power_plant_database.csv` - Nuclear facility locations
106
- - `secret_bases.csv` - Military base locations
107
- - `*.kgl` - Kepler.gl map configuration files
108
-
109
- ## GPU Requirements
110
-
111
- The embedding model (`embaas/sentence-transformers-e5-large-v2`) and XGBoost run on CUDA by default. The code includes `torch.cuda.empty_cache()` calls for memory management.
112
-
113
- ## HuggingFace Deployment
114
-
115
- The app is configured for HuggingFace Spaces deployment (see `README.md` metadata). The `sdk_version: 1.36.0` specifies the Streamlit version.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ENHANCED_PIPELINE_README.md DELETED
@@ -1,287 +0,0 @@
1
- # Enhanced Dynamic Filtering and Visualization Pipeline
2
-
3
- ## Overview
4
-
5
- This document describes the major improvements made to the UAP Data Analysis Tool's dynamic filtering and visualization pipeline. The enhancements focus on performance, usability, and advanced interactive features.
6
-
7
- ## 🚀 Key Improvements
8
-
9
- ### 1. Enhanced Data Processing (`utils/data_processing.py`)
10
-
11
- #### Intelligent Data Profiling
12
- - **Automatic column type detection** with statistical analysis
13
- - **Memory usage optimization** and performance monitoring
14
- - **Smart categorization** of columns (categorical, numeric, datetime, text)
15
- - **High cardinality column detection** for special handling
16
-
17
- #### Advanced Filtering System
18
- - **Quick Filter Presets**: Pre-configured filters for common scenarios
19
- - Remove outliers using IQR method
20
- - Top categories only
21
- - Remove rare categories (< 1% frequency)
22
- - Recent data filtering
23
- - **Multi-modal Filtering**:
24
- - Range, percentile, and standard deviation filters for numeric data
25
- - Smart selection, top-N, and exclusion modes for categorical data
26
- - Advanced date filtering with relative periods
27
- - Text filtering with regex support and length-based filtering
28
-
29
- #### Performance Optimizations
30
- - **Intelligent caching** with dataframe hashing
31
- - **Performance monitoring** decorators
32
- - **Parallel processing** for data parsing operations
33
- - **Memory-efficient operations** with smart sampling
34
-
35
- ### 2. Enhanced Visualization System (`utils/visualization.py`)
36
-
37
- #### Interactive Visualizations
38
- - **Plotly-based interactive charts** replacing static matplotlib plots
39
- - **Smart sampling** for large datasets maintaining data distribution
40
- - **Progressive rendering** to handle datasets of any size
41
- - **Drill-down capabilities** in treemaps and other charts
42
-
43
- #### New Visualization Types
44
- 1. **Interactive Scatter Plots**
45
- - Color and size encoding
46
- - Intelligent sampling for performance
47
- - Hover data with context
48
- - Sampling indicators for transparency
49
-
50
- 2. **Enhanced Histograms**
51
- - Box plots integration
52
- - Interactive binning
53
- - Statistical overlays
54
-
55
- 3. **Interactive Treemaps**
56
- - Drill-down capabilities
57
- - Percentage and count displays
58
- - Color-coded hierarchies
59
-
60
- 4. **Correlation Matrices**
61
- - Interactive heatmaps
62
- - Multiple correlation methods (Pearson, Spearman, Kendall)
63
- - Hover tooltips with exact values
64
-
65
- 5. **Time Series Plots**
66
- - Range selectors and sliders
67
- - Multiple series support
68
- - Resampling options
69
- - Zoom and pan capabilities
70
-
71
- 6. **Dashboard Layouts**
72
- - Multi-chart dashboards
73
- - Configurable layouts (2x2, vertical)
74
- - Synchronized interactions
75
-
76
- #### Performance Features
77
- - **Smart sampling algorithms** preserving data distribution
78
- - **Stratified sampling** for categorical data
79
- - **Caching at multiple levels** (Streamlit cache + custom cache)
80
- - **Memory-aware rendering** with automatic optimization
81
-
82
- ### 3. Session State Management (`utils/session_manager.py`)
83
-
84
- #### Enhanced State Handling
85
- - **Centralized session state management**
86
- - **Visualization caching** for improved performance
87
- - **Filter state persistence** across app interactions
88
- - **Memory management** for large datasets
89
-
90
- ### 4. Application Integration
91
-
92
- #### Updated Applications
93
- All main applications now use the enhanced pipeline:
94
-
95
- 1. **Analyzing App** (`analyzing.py`)
96
- - Enhanced filtering with quick presets
97
- - Interactive visualization tabs
98
- - Performance monitoring
99
- - Re-analysis on filtered data
100
-
101
- 2. **Map App** (`map.py`)
102
- - Map-optimized filtering (datetime to string conversion)
103
- - Geographic data handling improvements
104
- - Enhanced coordinate detection
105
-
106
- 3. **Other Apps** can be easily updated using the same pattern
107
-
108
- ## 📊 Usage Examples
109
-
110
- ### Basic Enhanced Filtering
111
- ```python
112
- from utils.data_processing import DataProcessor
113
-
114
- # Enhanced filtering with all features
115
- filtered_df = DataProcessor.filter_dataframe_enhanced(
116
- df,
117
- enable_quick_filters=False,
118
- enable_advanced_filters=True
119
- )
120
- ```
121
-
122
- ### Interactive Visualizations
123
- ```python
124
- from utils.visualization import UAP_Visualizer
125
-
126
- # Interactive scatter plot with smart sampling
127
- fig = UAP_Visualizer.plot_interactive_scatter(
128
- df, 'latitude', 'longitude',
129
- color_col='shape',
130
- max_points=10000
131
- )
132
- st.plotly_chart(fig, use_container_width=True)
133
-
134
- # Correlation matrix
135
- fig = UAP_Visualizer.plot_correlation_matrix(df[numeric_columns])
136
- st.plotly_chart(fig, use_container_width=True)
137
- ```
138
-
139
- ### Data Profiling
140
- ```python
141
- # Get intelligent data profile
142
- profile = DataProcessor.profile_data(df)
143
- print(f"Categorical columns: {len(profile['categorical_columns'])}")
144
- print(f"Numeric columns: {len(profile['numeric_columns'])}")
145
- print(f"Memory usage: {profile['memory_usage'] / 1024**2:.1f} MB")
146
- ```
147
-
148
- ## 🎯 Performance Improvements
149
-
150
- ### Before vs After
151
- - **Filtering Speed**: 3-5x faster with intelligent caching
152
- - **Visualization Rendering**: 2-10x faster with smart sampling
153
- - **Memory Usage**: 30-50% reduction for large datasets
154
- - **User Experience**: Instant feedback with progressive loading
155
-
156
- ### Smart Sampling Benefits
157
- - Maintains statistical properties of data
158
- - Preserves category distributions
159
- - Transparent to users (shows sampling info)
160
- - Configurable sampling limits
161
-
162
- ### Caching Strategy
163
- - **Multi-level caching**: Streamlit + custom application cache
164
- - **Intelligent cache keys**: Based on data hashes
165
- - **Automatic cache invalidation**: When data changes
166
- - **Memory-aware caching**: Prevents memory overflow
167
-
168
- ## 🔧 Configuration Options
169
-
170
- ### Performance Tuning
171
- ```python
172
- # Adjust sampling limits
173
- UAP_Visualizer.plot_interactive_scatter(df, x, y, max_points=5000)
174
-
175
- # Enable/disable performance mode
176
- DataProcessor.filter_dataframe_enhanced(
177
- df,
178
- enable_quick_filters=False, # Quick preset filters
179
- enable_advanced_filters=True # Full filtering interface
180
- )
181
- ```
182
-
183
- ### Visualization Customization
184
- ```python
185
- # Custom color schemes
186
- fig = UAP_Visualizer.plot_correlation_matrix(df, method='spearman')
187
-
188
- # Time series with resampling
189
- fig = UAP_Visualizer.plot_time_series(
190
- df, 'date_column', ['value1', 'value2'],
191
- resample_freq='M' # Monthly resampling
192
- )
193
- ```
194
-
195
- ## 🚀 Getting Started
196
-
197
- ### 1. Install Dependencies
198
- The enhanced pipeline requires additional dependencies (already in requirements.txt):
199
- - `plotly` - Interactive visualizations
200
- - `pandas` - Enhanced data processing
201
- - `streamlit` - Caching and UI components
202
-
203
- ### 2. Update Existing Code
204
- Replace old filter functions:
205
- ```python
206
- # Old way
207
- filtered_df = filter_dataframe(df)
208
-
209
- # New way
210
- from utils.data_processing import DataProcessor
211
- filtered_df = DataProcessor.filter_dataframe_enhanced(df)
212
- ```
213
-
214
- ### 3. Use Enhanced Visualizations
215
- ```python
216
- # Replace matplotlib plots with interactive versions
217
- from utils.visualization import UAP_Visualizer
218
-
219
- # Interactive histogram instead of static
220
- fig = UAP_Visualizer.plot_interactive_histogram(df, 'column_name')
221
- st.plotly_chart(fig, use_container_width=True)
222
- ```
223
-
224
- ### 4. Try the Demo
225
- Run the enhanced example:
226
- ```bash
227
- streamlit run utils/enhanced_example.py
228
- ```
229
-
230
- ## 🔮 Future Enhancements
231
-
232
- ### Planned Features
233
- 1. **Real-time Filtering**: WebSocket-based live data filtering
234
- 2. **Advanced Analytics**: Statistical tests and ML model integration
235
- 3. **Export Capabilities**: Enhanced data export with filter preservation
236
- 4. **Custom Visualizations**: User-defined chart types
237
- 5. **Performance Profiling**: Built-in performance analytics dashboard
238
-
239
- ### Extensibility
240
- The new architecture is designed for easy extension:
241
- - Add new filter types in `DataProcessor`
242
- - Create custom visualizations in `UAP_Visualizer`
243
- - Extend session management for new use cases
244
-
245
- ## 📈 Impact
246
-
247
- ### User Experience
248
- - **Faster interactions**: Immediate feedback on all operations
249
- - **Better insights**: Interactive visualizations reveal patterns
250
- - **Easier exploration**: Quick filters and smart defaults
251
- - **Transparent performance**: Users see sampling and processing info
252
-
253
- ### Developer Experience
254
- - **Cleaner code**: Centralized utilities eliminate duplication
255
- - **Better maintainability**: Single source of truth for filtering/visualization
256
- - **Performance monitoring**: Built-in performance tracking
257
- - **Easy extension**: Modular architecture for new features
258
-
259
- ### System Performance
260
- - **Scalability**: Handles datasets from thousands to millions of rows
261
- - **Memory efficiency**: Smart sampling and caching prevent memory issues
262
- - **Response times**: Sub-second response for most operations
263
- - **Resource usage**: Optimized CPU and memory utilization
264
-
265
- ## 🛠️ Technical Details
266
-
267
- ### Architecture
268
- ```
269
- UAP Data Analysis Tool
270
- ├── utils/
271
- │ ├── data_processing.py # Enhanced filtering and data ops
272
- │ ├── visualization.py # Interactive visualizations
273
- │ ├── session_manager.py # State management
274
- │ └── enhanced_example.py # Complete demo
275
- ├── analyzing.py # Updated with enhanced features
276
- ├── map.py # Updated with enhanced features
277
- └── other apps... # Can be updated similarly
278
- ```
279
-
280
- ### Key Classes
281
- - `DataProcessor`: Centralized data operations with intelligent caching
282
- - `UAP_Visualizer`: Interactive visualization factory with performance optimization
283
- - `SessionStateManager`: Enhanced state management with visualization caching
284
-
285
- The enhanced pipeline represents a significant upgrade to the UAP Data Analysis Tool, providing better performance, richer interactivity, and a superior user experience while maintaining backward compatibility and extensibility for future enhancements.
286
-
287
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
PLAN.md DELETED
@@ -1,313 +0,0 @@
1
- # Implementation Plan: React + FastAPI Architecture
2
-
3
- ## Overview
4
-
5
- Convert the Streamlit-based UAP Data Analysis Tool to a modern React frontend with FastAPI backend, maintaining all existing functionality.
6
-
7
- ## Architecture
8
-
9
- ```
10
- ┌─────────────────────────────────────────────────────────────┐
11
- │ React Frontend │
12
- │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌───────┐ │
13
- │ │ Parsing │ │Analysis │ │ Search │ │Magnetic │ │ Map │ │
14
- │ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ └───┬───┘ │
15
- └───────┼──────────┼──────────┼──────────┼──────────┼───────┘
16
- │ │ │ │ │
17
- ▼ ▼ ▼ ▼ ▼
18
- ┌─────────────────────────────────────────────────────────────┐
19
- │ FastAPI Backend │
20
- │ /api/parse /api/analyze /api/search /api/magnetic /api/map │
21
- └─────────────────────────────────────────────────────────────┘
22
-
23
-
24
- ┌─────────────────────────────────────────────────────────────┐
25
- │ Existing Python Services │
26
- │ UAPParser UAPAnalyzer UAPVisualizer Cohere InterMagnet │
27
- └─────────────────────────────────────────────────────────────┘
28
- ```
29
-
30
- ## File Structure
31
-
32
- ```
33
- UAP-Data-Analysis-Tool/
34
- ├── api/ # FastAPI backend
35
- │ ├── __init__.py
36
- │ ├── main.py # FastAPI app entry point
37
- │ ├── config.py # Settings and API keys
38
- │ ├── routes/
39
- │ │ ├── __init__.py
40
- │ │ ├── upload.py # File upload endpoints
41
- │ │ ├── parse.py # OpenAI parsing endpoints
42
- │ │ ├── analyze.py # UMAP/HDBSCAN/XGBoost endpoints
43
- │ │ ├── search.py # Cohere rerank endpoints
44
- │ │ ├── magnetic.py # InterMagnet correlation endpoints
45
- │ │ └── map.py # Geospatial data endpoints
46
- │ ├── services/
47
- │ │ ├── __init__.py
48
- │ │ ├── parser_service.py # Wraps UAPParser
49
- │ │ ├── analyzer_service.py # Wraps UAPAnalyzer
50
- │ │ ├── visualizer_service.py # Wraps UAPVisualizer
51
- │ │ ├── search_service.py # Cohere rerank logic
52
- │ │ ├── magnetic_service.py # InterMagnet API + DTW
53
- │ │ └── map_service.py # Kepler.gl data prep
54
- │ ├── models/
55
- │ │ ├── __init__.py
56
- │ │ ├── schemas.py # Pydantic request/response models
57
- │ │ └── jobs.py # Background job tracking
58
- │ └── utils/
59
- │ ├── __init__.py
60
- │ ├── file_handler.py # CSV/Excel/HDF5 handling
61
- │ └── serialization.py # NumPy/DataFrame serialization
62
-
63
- ├── frontend/ # React frontend
64
- │ ├── package.json
65
- │ ├── tailwind.config.js
66
- │ ├── vite.config.js
67
- │ ├── index.html
68
- │ ├── src/
69
- │ │ ├── main.jsx
70
- │ │ ├── App.jsx
71
- │ │ ├── api/
72
- │ │ │ └── client.js # Axios/fetch wrapper
73
- │ │ ├── components/
74
- │ │ │ ├── layout/
75
- │ │ │ │ ├── Navbar.jsx
76
- │ │ │ │ ├── Sidebar.jsx
77
- │ │ │ │ └── Layout.jsx
78
- │ │ │ ├── common/
79
- │ │ │ │ ├── FileUpload.jsx
80
- │ │ │ │ ├── DataTable.jsx
81
- │ │ │ │ ├── LoadingSpinner.jsx
82
- │ │ │ │ └── ErrorBoundary.jsx
83
- │ │ │ ├── charts/
84
- │ │ │ │ ├── Treemap.jsx
85
- │ │ │ │ ├── Histogram.jsx
86
- │ │ │ │ ├── ScatterPlot.jsx
87
- │ │ │ │ ├── Heatmap.jsx
88
- │ │ │ │ └── ConfusionMatrix.jsx
89
- │ │ │ └── map/
90
- │ │ │ └── KeplerMap.jsx
91
- │ │ ├── pages/
92
- │ │ │ ├── Home.jsx
93
- │ │ │ ├── Parsing.jsx
94
- │ │ │ ├── Analysis.jsx
95
- │ │ │ ├── Search.jsx
96
- │ │ │ ├── Magnetic.jsx
97
- │ │ │ └── Map.jsx
98
- │ │ ├── hooks/
99
- │ │ │ ├── useFileUpload.js
100
- │ │ │ ├── useAnalysis.js
101
- │ │ │ └── useWebSocket.js
102
- │ │ ├── store/
103
- │ │ │ └── index.js # Zustand or React Context
104
- │ │ └── styles/
105
- │ │ └── globals.css
106
- │ └── public/
107
- │ └── assets/
108
- ```
109
-
110
- ## Implementation Steps
111
-
112
- ### Phase 1: FastAPI Backend Setup
113
-
114
- #### Step 1.1: Create API structure and main entry point
115
- - Create `api/` directory structure
116
- - Set up FastAPI app with CORS middleware
117
- - Configure settings from environment/secrets
118
- - Add health check endpoint
119
-
120
- #### Step 1.2: Create Pydantic schemas
121
- - Define request models for each endpoint
122
- - Define response models with proper typing
123
- - Create job status models for async operations
124
-
125
- #### Step 1.3: Implement file upload endpoint
126
- - POST `/api/upload` - Accept CSV/Excel files
127
- - Store uploaded files temporarily
128
- - Return file ID and column list
129
- - Support chunked uploads for large files
130
-
131
- #### Step 1.4: Implement parsing endpoints
132
- - POST `/api/parse/start` - Start async parsing job
133
- - GET `/api/parse/status/{job_id}` - Check job status
134
- - GET `/api/parse/result/{job_id}` - Get parsed results
135
- - Wrap existing UAPParser with proper error handling
136
-
137
- #### Step 1.5: Implement analysis endpoints
138
- - POST `/api/analyze/start` - Start clustering analysis
139
- - GET `/api/analyze/status/{job_id}` - Check status
140
- - GET `/api/analyze/clusters/{job_id}` - Get cluster data
141
- - GET `/api/analyze/embeddings/{job_id}` - Get 2D embeddings for visualization
142
- - GET `/api/analyze/predictions/{job_id}` - Get XGBoost results
143
-
144
- #### Step 1.6: Implement search endpoints
145
- - POST `/api/search/rerank` - Cohere rerank search
146
- - Return ranked results with relevance scores
147
-
148
- #### Step 1.7: Implement magnetic endpoints
149
- - GET `/api/magnetic/stations` - List InterMagnet stations
150
- - POST `/api/magnetic/correlate` - Run DTW correlation
151
- - Return correlation results and time series data
152
-
153
- #### Step 1.8: Implement map endpoints
154
- - GET `/api/map/sightings` - Get sighting GeoJSON
155
- - GET `/api/map/bases` - Get military bases data
156
- - GET `/api/map/plants` - Get nuclear facilities data
157
- - GET `/api/map/config` - Get Kepler.gl config
158
-
159
- ### Phase 2: React Frontend Setup
160
-
161
- #### Step 2.1: Initialize React project
162
- - Create Vite + React project in `frontend/`
163
- - Install dependencies: react-router, axios, recharts/plotly, kepler.gl
164
- - Configure Tailwind CSS
165
- - Set up project structure
166
-
167
- #### Step 2.2: Create layout components
168
- - Navbar with navigation links
169
- - Sidebar for feature options
170
- - Main layout wrapper
171
- - Responsive design
172
-
173
- #### Step 2.3: Create common components
174
- - FileUpload with drag-and-drop
175
- - DataTable with sorting/filtering
176
- - LoadingSpinner and progress indicators
177
- - Error boundary and toast notifications
178
-
179
- #### Step 2.4: Create chart components
180
- - Treemap using Plotly.js
181
- - Histogram using Recharts
182
- - ScatterPlot for embeddings visualization
183
- - Heatmap for Cramer's V and confusion matrix
184
- - Feature importance bar chart
185
-
186
- #### Step 2.5: Create Kepler.gl map component
187
- - Integrate kepler.gl React component
188
- - Handle data layers dynamically
189
- - Support filtering by attributes
190
-
191
- ### Phase 3: Feature Pages
192
-
193
- #### Step 3.1: Parsing page
194
- - File upload interface
195
- - Column selector
196
- - Custom JSON schema editor (optional)
197
- - Progress indicator for parsing
198
- - Results table with download option
199
-
200
- #### Step 3.2: Analysis page
201
- - Dataset loader (upload or use parsed data)
202
- - Column multi-selector for analysis
203
- - Visualization tabs: Embeddings, Clusters, Predictions, Correlations
204
- - Interactive charts with tooltips
205
-
206
- #### Step 3.3: Search page
207
- - Dataset display
208
- - Query input
209
- - Column selector for search scope
210
- - Ranked results with relevance scores
211
- - Click-to-expand details
212
-
213
- #### Step 3.4: Magnetic page
214
- - Date range selector
215
- - Location input (lat/lon or from dataset)
216
- - Station selector
217
- - Correlation results with time series chart
218
-
219
- #### Step 3.5: Map page
220
- - Full-screen Kepler.gl map
221
- - Layer toggles (sightings, bases, plants)
222
- - Filter controls
223
- - Export functionality
224
-
225
- ### Phase 4: Integration and Polish
226
-
227
- #### Step 4.1: State management
228
- - Set up Zustand store for global state
229
- - Persist uploaded data across pages
230
- - Handle authentication state (API keys)
231
-
232
- #### Step 4.2: WebSocket for long-running tasks
233
- - Add WebSocket endpoint for job progress
234
- - Real-time updates during parsing/analysis
235
-
236
- #### Step 4.3: Error handling
237
- - Consistent error responses from API
238
- - User-friendly error messages in frontend
239
- - Retry logic for failed requests
240
-
241
- #### Step 4.4: Testing
242
- - API endpoint tests with pytest
243
- - Component tests with React Testing Library
244
-
245
- ## Key API Endpoints Summary
246
-
247
- | Method | Endpoint | Description |
248
- |--------|----------|-------------|
249
- | POST | `/api/upload` | Upload CSV/Excel file |
250
- | POST | `/api/parse/start` | Start parsing job |
251
- | GET | `/api/parse/status/{job_id}` | Get parsing status |
252
- | GET | `/api/parse/result/{job_id}` | Get parsed data |
253
- | POST | `/api/analyze/start` | Start analysis job |
254
- | GET | `/api/analyze/clusters/{job_id}` | Get cluster results |
255
- | GET | `/api/analyze/embeddings/{job_id}` | Get 2D embeddings |
256
- | POST | `/api/search/rerank` | Semantic search |
257
- | GET | `/api/magnetic/stations` | List stations |
258
- | POST | `/api/magnetic/correlate` | Run correlation |
259
- | GET | `/api/map/sightings` | Get sighting GeoJSON |
260
- | GET | `/api/map/bases` | Get bases GeoJSON |
261
-
262
- ## Dependencies to Add
263
-
264
- ### Backend (add to pyproject.toml)
265
- ```
266
- fastapi
267
- uvicorn[standard]
268
- python-multipart
269
- aiofiles
270
- websockets
271
- ```
272
-
273
- ### Frontend (package.json)
274
- ```json
275
- {
276
- "dependencies": {
277
- "react": "^18.2.0",
278
- "react-dom": "^18.2.0",
279
- "react-router-dom": "^6.x",
280
- "axios": "^1.x",
281
- "plotly.js": "^2.x",
282
- "react-plotly.js": "^2.x",
283
- "kepler.gl": "^3.x",
284
- "react-dropzone": "^14.x",
285
- "@tanstack/react-table": "^8.x",
286
- "zustand": "^4.x",
287
- "react-hot-toast": "^2.x"
288
- },
289
- "devDependencies": {
290
- "vite": "^5.x",
291
- "tailwindcss": "^3.x",
292
- "autoprefixer": "^10.x",
293
- "postcss": "^8.x"
294
- }
295
- }
296
- ```
297
-
298
- ## Running the Application
299
-
300
- ```bash
301
- # Terminal 1: Start FastAPI backend
302
- cd api && uvicorn main:app --reload --port 8000
303
-
304
- # Terminal 2: Start React frontend
305
- cd frontend && npm run dev
306
- ```
307
-
308
- ## Notes
309
-
310
- - Long-running tasks (parsing, analysis) use background jobs with polling or WebSocket updates
311
- - Embeddings are stored server-side and referenced by job_id to avoid large payloads
312
- - Visualizations are generated as Plotly JSON for interactive frontend rendering
313
- - The existing `uap_analyzer.py` and `utils/` modules are reused as services
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -5,57 +5,10 @@ colorFrom: green
5
  colorTo: yellow
6
  sdk: streamlit
7
  sdk_version: 1.36.0
8
- python_version: "3.12"
9
  app_file: app.py
10
  pinned: false
11
  license: apache-2.0
12
  short_description: UFO/UAP AI Analyst
13
  ---
14
 
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
16
-
17
- ---
18
-
19
- ## Modern FrontEnd (React + FastAPI)
20
-
21
- The repo ships a decoupled web stack alongside the Streamlit app:
22
-
23
- - **Backend** — FastAPI service under `./api` (routes in `api/routes/`, services in `api/services/`, models in `api/models/`).
24
- - **Frontend** — Vite + React + TypeScript + Tailwind + Zustand under `./frontend`, talking to the backend through a Vite proxy.
25
-
26
- ### Prerequisites
27
- - Python 3.11+ with `fastapi` and `uvicorn` installed (covered by `requirements.txt` / `pyproject.toml`).
28
- - Node 20+ for the frontend.
29
-
30
- ### 1. Start the backend (FastAPI)
31
-
32
- From the repo root:
33
-
34
- ```bash
35
- uvicorn api.main:app --reload --port 8000
36
- ```
37
-
38
- The `--reload` flag picks up code changes automatically.
39
-
40
- ### 2. Start the frontend (Vite + React)
41
-
42
- ```bash
43
- cd frontend
44
- npm install # first time only
45
- npm run dev
46
- ```
47
-
48
- Vite starts on port **5173** and proxies every `/api/*` request to `http://localhost:8000` (see `frontend/vite.config.ts`), so the React app calls FastAPI transparently — no CORS configuration needed.
49
-
50
- ### 3. Open the app
51
-
52
- http://localhost:5173
53
-
54
- ### Production build
55
-
56
- ```bash
57
- cd frontend
58
- npm run build # output in frontend/dist
59
- ```
60
-
61
- Serve the `dist/` artifacts behind any static host and keep `uvicorn api.main:app` running for the API.
 
5
  colorTo: yellow
6
  sdk: streamlit
7
  sdk_version: 1.36.0
 
8
  app_file: app.py
9
  pinned: false
10
  license: apache-2.0
11
  short_description: UFO/UAP AI Analyst
12
  ---
13
 
14
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
STREAMLIT_HANDOFF.md DELETED
@@ -1,545 +0,0 @@
1
- # Handoff: UAP Embeddings → Streamlit Semantic Search
2
-
3
- Everything you need to build a Streamlit app that does semantic search over the
4
- UAP archive embeddings currently sitting in a Neon Postgres + pgvector database.
5
-
6
- ---
7
-
8
- ## 1. Context in one paragraph
9
-
10
- A previous session embedded all of **UAP Release 2 (5/22/26)** — 49 DoD UAP
11
- video clips and 7 NASA Apollo/Mercury audio recordings — into a Neon Postgres
12
- database using **Google Gemini `gemini-embedding-2-preview`** (768-dim, cosine
13
- similarity, indexed with HNSW). The pipeline lives in `embeddings_v2.py` at the
14
- repo root. Your job is a Streamlit UI that lets users type a query (or upload an
15
- image), embed it with the same model, and return ranked matches with playable
16
- media.
17
-
18
- ---
19
-
20
- ## 2. What's in the database right now
21
-
22
- ```
23
- source_type rows distinct assets
24
- video_chunk 154 49 DVIDS UAP video clips (Release 2)
25
- pdf_page 126 5 source documents (DOW-D017 [116p], DOE-D002 [4p],
26
- CIA-D001 [3p], DOE-D001 [2p], DOE-D003 [1p])
27
- audio_clip 27 7 NASA Apollo/Mercury audio recordings (Release 2)
28
- TOTAL 307 61 assets all release='PURSUE_2' release_date=2026-05-22
29
- ```
30
-
31
- - All current rows use `user_id = '00000000-0000-0000-0000-000000000001'` (a
32
- placeholder UUID — the schema is multi-tenant but this archive has one tenant).
33
- - `parent_id` is `dvids_{asset_id}` for media rows (e.g. `dvids_1007706`); doc
34
- slugs like `dow-uap-d017` for `pdf_page` rows.
35
- - `source_id` is `{parent_id}:{start_ms}-{end_ms}` for media chunks and
36
- `{parent_id}:p{NNNN}` for PDF pages (e.g. `dow-uap-d017:p0017`).
37
- - Vector dimension is **768**. Queries must be 768-dim too.
38
- - Every row carries the new `release` (`'PURSUE_2'`) and `release_date`
39
- (`2026-05-22`) columns — filter on these in the UI when more releases land.
40
- - One pending video (`1007708`, the 513 MB outlier) was not ingested; it can be
41
- added later — not a blocker for the UI.
42
- - Nothing from earlier releases (Release 1, NARA-CIA, FBI photos, etc.) is
43
- embedded yet. If you build the UI to filter on `release` / `parent_id`
44
- patterns or future source types, leave it open.
45
-
46
- ---
47
-
48
- ## 3. Schema reference
49
-
50
- ```sql
51
- CREATE TABLE embeddings (
52
- id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
53
- source_type TEXT NOT NULL, -- 'video_chunk' | 'audio_clip' | 'pdf_page' (more later)
54
- source_id TEXT NOT NULL, -- '{parent_id}:{start_ms}-{end_ms}' for chunks; '{slug}:p{NNNN}' for pages
55
- user_id UUID NOT NULL,
56
- organization_id UUID,
57
- embedding VECTOR(768) NOT NULL,
58
- embedded_image_url TEXT, -- video/audio: DVIDS page URL; pdf_page: whole-PDF war.gov URL
59
- embedded_text TEXT, -- caption used during embed (Title + Blurb; or metadata + OCR for pdf_page)
60
- start_seconds REAL, -- chunk start (NULL for pdf_page)
61
- end_seconds REAL, -- chunk end (NULL for pdf_page)
62
- parent_id TEXT, -- 'dvids_1007706' for media; doc slug like 'dow-uap-d017' for pages
63
- release TEXT NOT NULL DEFAULT 'PURSUE_2', -- campaign tag (filter on this in the UI)
64
- release_date DATE NOT NULL DEFAULT '2026-05-22', -- when the source documents were publicly released
65
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
66
- updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
67
- CONSTRAINT uq_embeddings_source UNIQUE (source_type, source_id)
68
- );
69
-
70
- -- Already created:
71
- CREATE INDEX idx_embeddings_embedding ON embeddings USING hnsw (embedding vector_cosine_ops);
72
- CREATE INDEX idx_embeddings_parent_id ON embeddings (parent_id) WHERE parent_id IS NOT NULL;
73
- CREATE INDEX idx_embeddings_user_id ON embeddings (user_id);
74
- ```
75
-
76
- Cosine search uses pgvector's `<=>` operator (distance, lower = closer).
77
- Convert to similarity with `1 - (embedding <=> query)`.
78
-
79
- ---
80
-
81
- ## 4. Secrets — required, not in this file
82
-
83
- Set as env vars (or Streamlit `secrets.toml`):
84
-
85
- ```bash
86
- DATABASE_URL = <Neon connection string, prefer the DIRECT endpoint over -pooler>
87
- GEMINI_API_KEY = <Google AI Studio key, same model that produced the rows>
88
- ```
89
-
90
- The Neon string must include `?sslmode=require`. Ask the user to paste the
91
- values from their Neon dashboard and Google AI Studio — they're not embedded
92
- here on purpose. The previous session ran against a Neon project owned by the
93
- user, and the password / key from that session should be considered exposed
94
- and rotated.
95
-
96
- **Streamlit secrets.toml** (recommended over raw env vars):
97
-
98
- ```toml
99
- # .streamlit/secrets.toml -- DO NOT COMMIT
100
- DATABASE_URL = "postgresql://USER:PASSWORD@ep-xxxx.REGION.aws.neon.tech/neondb?sslmode=require"
101
- GEMINI_API_KEY = "AIza..."
102
- ```
103
-
104
- Read in app with `st.secrets["DATABASE_URL"]`.
105
-
106
- ---
107
-
108
- ## 5. Dependencies
109
-
110
- ```bash
111
- pip install streamlit google-genai pillow requests "psycopg[binary]" pgvector
112
- ```
113
-
114
- The only file from this repo you need to copy alongside the Streamlit app is
115
- **`embeddings_v2.py`** (it's self-contained — no project-internal imports). Or
116
- you can inline the few functions you actually use (see §6/§7 for the bare
117
- minimum).
118
-
119
- ---
120
-
121
- ## 6. Embedding a user query
122
-
123
- The model and dimension must match what's already in the DB
124
- (`gemini-embedding-2-preview`, 768-d). **The contract is asymmetric and is
125
- expressed in the prompt, not the config**: queries get a `task: search result
126
- | query: …` prefix; documents go in as `title: … | text: …`. The
127
- `EmbedContentConfig.task_type` field is *silently ignored* by
128
- gemini-embedding-2 on the consumer API — don't set it. (Helper functions in
129
- `embeddings_v2.py` apply the wrapping for you.)
130
-
131
- ```python
132
- import embeddings_v2 as e
133
-
134
- # Queries — generate_text_embedding auto-wraps with format_query().
135
- vec_text = e.generate_text_embedding("UAP over the Aegean")
136
- vec_image = e.generate_image_embedding("./uploaded.jpg") # image-only: no text instruction
137
- vec_both = e.generate_multimodal_embedding(
138
- "./uploaded.jpg",
139
- e.format_query("what is this"), # pre-wrap when there IS a text part
140
- )
141
- ```
142
-
143
- `embeddings_v2` also exports:
144
-
145
- - `format_document_text(title, body)` → `"title: {title} | text: {body}"` (use when storing).
146
- - `format_query(query)` → `"task: search result | query: {query}"` (use when querying with a text part attached to media).
147
-
148
- Minimal inline version if you don't want to import `embeddings_v2`:
149
-
150
- ```python
151
- import os
152
- from google import genai
153
- from google.genai import types as gt
154
-
155
- client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
156
-
157
- def embed_text(text: str) -> list[float]:
158
- r = client.models.embed_content(
159
- model="gemini-embedding-2-preview",
160
- contents=f"task: search result | query: {text}", # wrap, not task_type=
161
- config=gt.EmbedContentConfig(output_dimensionality=768),
162
- )
163
- return list(r.embeddings[0].values)
164
- ```
165
-
166
- ---
167
-
168
- ## 7. Searching with pgvector
169
-
170
- `embeddings_v2.search_similar()` already does this and returns a list of
171
- `SimilarityHit` dataclasses. If you want raw SQL:
172
-
173
- ```sql
174
- SELECT source_type, source_id, parent_id, start_seconds, end_seconds,
175
- embedded_image_url, embedded_text,
176
- 1 - (embedding <=> %s) AS similarity
177
- FROM embeddings
178
- WHERE user_id = %s::uuid
179
- AND (%s::text IS NULL OR source_type = %s)
180
- AND (embedding <=> %s) <= %s -- distance <= 1 - threshold
181
- ORDER BY embedding <=> %s
182
- LIMIT %s;
183
- ```
184
-
185
- Params, in order: `query_vec, user_id, source_type_or_null, source_type_or_null, query_vec, (1 - threshold), query_vec, limit`.
186
-
187
- Don't forget `register_vector(conn)` from `pgvector.psycopg` after connecting —
188
- without it psycopg can't bind `list[float]` to the `vector` type.
189
-
190
- ---
191
-
192
- ## 8. Result interpretation (per source_type)
193
-
194
- ### `video_chunk`
195
- - `parent_id` → e.g. `dvids_1007706`. Strip the prefix to get the DVIDS asset id.
196
- - `embedded_image_url` → the human DVIDS page, e.g. `https://www.dvidshub.net/video/1007706`.
197
- - `start_seconds`, `end_seconds` → the chunk's offsets within the source video
198
- (one video typically has multiple chunks; show the timestamp to the user).
199
- - `embedded_text` → the caption that was attached at embed time: the
200
- `Video Title` + `Description Blurb` from `uap-data_v2.csv`.
201
- - DVIDS deep-link with timestamp: append `?t={int(start_seconds)}` to the page
202
- URL (or use the local file with `st.video(local_path, start_time=int(start_seconds))`).
203
-
204
- ### `audio_clip`
205
- - Same `parent_id` shape but with audio DVIDS ids (1007870–1007879 range for
206
- Release 2).
207
- - `embedded_image_url` is set even though the asset is audio (it's the DVIDS
208
- page URL — the column was reused as the canonical media URL for any kind).
209
- - For long recordings (>80s — the model's audio input cap), the asset is
210
- segmented into ≤75s pieces; one row per piece with its own start/end.
211
-
212
- ### `pdf_page`
213
- - `parent_id` is the doc slug (e.g. `dow-uap-d017`, `cia-uap-d001`,
214
- `doe-uap-d001`, `doe-uap-d002`, `doe-uap-d003`).
215
- - `source_id` is `{parent_id}:p{NNNN}` with the page number zero-padded to
216
- 4 digits (e.g. `dow-uap-d017:p0017`). Parse with a tiny regex to surface
217
- the page number in the UI.
218
- - `embedded_image_url` is the whole-PDF URL on war.gov — there's no per-page
219
- URL on the source site, so deep-linking to a specific page means opening
220
- the PDF and scrolling.
221
- - `embedded_text` is composed at embed time as: `{Agency} - {Title}` /
222
- `Date: ... Location: ...` / `Page N of M.` / `Document context: {blurb}` /
223
- `Page OCR: {ocr}`, capped at 8000 chars. The same string was paired with
224
- the rendered page image in the multimodal embed call.
225
- - `start_seconds` / `end_seconds` are NULL.
226
- - A rendered page image lives locally at
227
- `D:\divided\release_2\UAP_Release_2\pages\{slug}\page_NNNN.png` (150 dpi).
228
- Display it directly with `st.image(local_path)`; link to
229
- `embedded_image_url` to open the whole PDF on war.gov.
230
-
231
- ---
232
-
233
- ## 9. Where the media files live
234
-
235
- The previous session saved every downloaded media file under the user's local
236
- drive (set by them as the persistence target):
237
-
238
- ```
239
- D:\divided\release_2\UAP_Release_2\
240
- ├── videos\dvids_{id}.mp4 (49 files, normalized originals from DVIDS)
241
- ├── audio\dvids_{id}.{ext} (7 source MP4 wrappers + extracted .m4a tracks)
242
- └── pages\{slug}\page_NNNN.png (PDF page renders at 150 dpi, e.g.
243
- pages\dow-uap-d017\page_0017.png)
244
- ```
245
-
246
- The page PNGs are generated by `ingest_pdf_pages.py` and are safe to delete and
247
- re-generate from the source `release_2\{doc}\page_NNNN\page_NNNN.pdf` files.
248
-
249
- This matters for the Streamlit UI:
250
-
251
- - If the app runs on the same machine, you can pass the local path straight
252
- into `st.video(path, start_time=...)` / `st.audio(...)` — that's the smoothest
253
- playback experience and supports seeking.
254
- - If the app runs elsewhere, link out to the DVIDS page (`embedded_image_url`).
255
- Direct CloudFront URLs work for download but seeking via HTTP from the
256
- browser is hit-or-miss.
257
- - A third option: upload the local files to S3/R2/Vercel Blob and rewrite URLs.
258
- Not done.
259
-
260
- If the file isn't found locally and the URL is the DVIDS page, **don't try to
261
- embed the CloudFront MP4 directly in `st.video()`** — DVIDS' `/download/asset/`
262
- endpoint is 403-gated, and the CloudFront URLs aren't stored in the DB. You'd
263
- need to re-scrape the page (see the `scrape_media_url` helper in
264
- `retry_release_2.py` if you want that pattern).
265
-
266
- ---
267
-
268
- ## 10. Minimal working Streamlit app
269
-
270
- Drop this at `app.py` next to `embeddings_v2.py`, set the secrets, and run
271
- `streamlit run app.py`. It covers text query, source-type filter, threshold
272
- slider, and inline media playback with timestamp seeking.
273
-
274
- ```python
275
- import os
276
- import re
277
- from pathlib import Path
278
-
279
- import psycopg
280
- import streamlit as st
281
-
282
- import embeddings_v2 as e
283
-
284
- USER_ID = "00000000-0000-0000-0000-000000000001"
285
- MEDIA_ROOT = Path(r"D:\divided\release_2\UAP_Release_2") # change if elsewhere
286
- SOURCE_TYPES = ("video_chunk", "audio_clip", "pdf_page")
287
-
288
- st.set_page_config(page_title="UAP Archive Semantic Search", layout="wide")
289
-
290
- # --- bootstrap ---------------------------------------------------------------
291
- for k in ("DATABASE_URL", "GEMINI_API_KEY"):
292
- if k in st.secrets:
293
- os.environ.setdefault(k, st.secrets[k])
294
- if not os.environ.get(k):
295
- st.error(f"Missing {k} — add it to .streamlit/secrets.toml")
296
- st.stop()
297
-
298
- @st.cache_resource
299
- def get_conn():
300
- return psycopg.connect(os.environ["DATABASE_URL"])
301
-
302
- @st.cache_data(ttl=3600, show_spinner=False)
303
- def embed_query_text(text: str) -> list[float]:
304
- # generate_text_embedding auto-wraps with format_query() and drops task_type.
305
- return e.generate_text_embedding(text)
306
-
307
- @st.cache_data(ttl=3600, show_spinner=False)
308
- def embed_query_image(image_bytes: bytes, mime: str) -> list[float]:
309
- import tempfile
310
- suffix = "." + mime.split("/", 1)[1]
311
- with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as f:
312
- f.write(image_bytes)
313
- path = f.name
314
- try:
315
- # image-only embed: same call for query and document side.
316
- return e.generate_image_embedding(path)
317
- finally:
318
- os.unlink(path)
319
-
320
- def search(vec, *, source_type=None, release=None, limit=20, threshold=0.30):
321
- # pgvector's psycopg adapter doesn't auto-cast list[float] to vector ---
322
- # serialise to the textual '[a,b,c]' form and let Postgres cast.
323
- vec_str = "[" + ",".join(f"{x:.6f}" for x in vec) + "]"
324
- clauses = ["user_id = %s::uuid", "(embedding <=> %s::vector) <= %s"]
325
- params = [USER_ID, vec_str, 1 - threshold]
326
- if source_type:
327
- clauses.append("source_type = %s")
328
- params.append(source_type)
329
- if release:
330
- clauses.append("release = %s")
331
- params.append(release)
332
- sql = f"""
333
- SELECT source_type, source_id, parent_id, start_seconds, end_seconds,
334
- embedded_image_url, embedded_text, release, release_date,
335
- 1 - (embedding <=> %s::vector) AS similarity
336
- FROM embeddings
337
- WHERE {' AND '.join(clauses)}
338
- ORDER BY embedding <=> %s::vector
339
- LIMIT %s
340
- """
341
- ordered = [vec_str, *params, vec_str, limit]
342
- with get_conn().cursor() as cur:
343
- cur.execute(sql, ordered)
344
- cols = [d.name for d in cur.description]
345
- return [dict(zip(cols, r)) for r in cur.fetchall()]
346
-
347
- _PAGE_RE = re.compile(r"^(.+):p(\d+)$")
348
-
349
- def local_media_path(row: dict) -> Path | None:
350
- st_type = row["source_type"]
351
- if st_type == "video_chunk":
352
- asset_id = row["parent_id"].removeprefix("dvids_")
353
- p = MEDIA_ROOT / "videos" / f"dvids_{asset_id}.mp4"
354
- return p if p.exists() else None
355
- if st_type == "audio_clip":
356
- asset_id = row["parent_id"].removeprefix("dvids_")
357
- for ext in ("m4a", "mp3", "mp4", "wav", "aac", "ogg"):
358
- p = MEDIA_ROOT / "audio" / f"dvids_{asset_id}.{ext}"
359
- if p.exists():
360
- return p
361
- return None
362
- if st_type == "pdf_page":
363
- m = _PAGE_RE.match(row["source_id"])
364
- if not m:
365
- return None
366
- slug, page_num = m.group(1), int(m.group(2))
367
- p = MEDIA_ROOT / "pages" / slug / f"page_{page_num:04d}.png"
368
- return p if p.exists() else None
369
- return None
370
-
371
- def page_number(row: dict) -> int | None:
372
- if row["source_type"] != "pdf_page":
373
- return None
374
- m = _PAGE_RE.match(row["source_id"])
375
- return int(m.group(2)) if m else None
376
-
377
- # --- UI ----------------------------------------------------------------------
378
- st.title("UAP Archive — Semantic Search")
379
- st.caption("Gemini 768-d embeddings, cosine similarity over Neon + pgvector.")
380
-
381
- with st.sidebar:
382
- mode = st.radio("Query type", ["Text", "Image"], horizontal=True)
383
- st_filter = st.selectbox("Source type", ["all", *SOURCE_TYPES])
384
- release_filter = st.selectbox("Release", ["all", "PURSUE_2"])
385
- threshold = st.slider("Min similarity", 0.0, 0.9, 0.30, 0.05)
386
- limit = st.slider("Max results", 5, 50, 20)
387
-
388
- vec = None
389
- if mode == "Text":
390
- q = st.text_input("Search query", placeholder="e.g. spherical UAP over water")
391
- if q:
392
- with st.spinner("Embedding query…"):
393
- vec = embed_query_text(q)
394
- else:
395
- up = st.file_uploader("Upload an image", type=["jpg", "jpeg", "png", "webp"])
396
- if up:
397
- st.image(up, width=240)
398
- with st.spinner("Embedding image…"):
399
- vec = embed_query_image(up.getvalue(), up.type)
400
-
401
- if vec is None:
402
- st.info("Enter a query or upload an image.")
403
- st.stop()
404
-
405
- with st.spinner("Searching Neon…"):
406
- rows = search(
407
- vec,
408
- source_type=None if st_filter == "all" else st_filter,
409
- release=None if release_filter == "all" else release_filter,
410
- limit=limit,
411
- threshold=threshold,
412
- )
413
-
414
- if not rows:
415
- st.warning("No matches above the similarity threshold. Try lowering it.")
416
- st.stop()
417
-
418
- st.subheader(f"{len(rows)} result(s)")
419
- for r in rows:
420
- with st.container(border=True):
421
- c1, c2 = st.columns([4, 1])
422
- with c1:
423
- header = f"**[{r['parent_id']}]({r['embedded_image_url']})** · `{r['source_type']}` · sim **{r['similarity']:.3f}**"
424
- page = page_number(r)
425
- if page is not None:
426
- header += f" · page {page}"
427
- elif r["start_seconds"] is not None:
428
- header += f" · {r['start_seconds']:.1f}s → {r['end_seconds']:.1f}s"
429
- st.markdown(header)
430
- if r["embedded_text"]:
431
- st.write(r["embedded_text"][:600] + ("…" if len(r["embedded_text"]) > 600 else ""))
432
- local = local_media_path(r)
433
- if local and r["source_type"] == "video_chunk":
434
- st.video(str(local), start_time=int(r["start_seconds"] or 0))
435
- elif local and r["source_type"] == "audio_clip":
436
- st.audio(str(local), start_time=int(r["start_seconds"] or 0))
437
- elif local and r["source_type"] == "pdf_page":
438
- st.image(str(local), use_container_width=True)
439
- if r["embedded_image_url"]:
440
- st.link_button("Open full PDF on war.gov", r["embedded_image_url"])
441
- elif r["embedded_image_url"]:
442
- st.link_button("Open source", r["embedded_image_url"])
443
- with c2:
444
- st.metric("similarity", f"{r['similarity']:.3f}")
445
- st.caption(f"{r['release']} · {r['release_date']}")
446
- ```
447
-
448
- ---
449
-
450
- ## 11. Gotchas / things that will trip you up
451
-
452
- - **Pooled vs direct Neon endpoint.** The user's connection string in the
453
- earlier session was the `-pooler` host. For a long-lived Streamlit process
454
- that reuses one connection across many queries, psycopg3 will eventually
455
- promote a statement to a *named* prepared statement (default
456
- `prepare_threshold=5`), which PgBouncer in transaction-pooling mode cannot
457
- hold across transactions. Use the **direct** endpoint (host without
458
- `-pooler`) or set `prepare_threshold=None` on the connection.
459
- - **Dimension must match.** The column is `VECTOR(768)`. Don't pass a 1536-dim
460
- vector — it'll fail on the cast. If you ever switch to a different
461
- `output_dimensionality`, you'll need to migrate the column.
462
- - **Instruction-in-prompt, not `task_type=`.** gemini-embedding-2 silently
463
- ignores `EmbedContentConfig.task_type` on the consumer API and instead
464
- expects the task to be expressed *inside the content*. Wrap documents as
465
- `title: {title} | text: {body}` (via `e.format_document_text(...)`) and
466
- queries as `task: search result | query: {q}` (via `e.format_query(...)`,
467
- applied automatically by `e.generate_text_embedding`). Skipping this
468
- produces noticeably worse ranking — the previous version of this corpus
469
- ranked NASA audio narratives above DOW UAP video clips on the query
470
- "instantaneous acceleration" because the asymmetric format wasn't applied;
471
- the re-embed with proper wrapping put `dvids_1007707` at ranks 1–4.
472
-
473
- - **Vertex-only config options.** Three `EmbedContentConfig` fields exist in
474
- the SDK but are rejected by the consumer Gemini API
475
- (`"<option> parameter is not supported in Gemini API"`):
476
- `document_ocr` (server-side PDF OCR), `audio_track_extraction` (pull audio
477
- from video for the embed), and `auto_truncate`. They're only available via
478
- Vertex AI. If you migrate to Vertex (`genai.Client(vertexai=True, project=...,
479
- location=...)`), all three become usable and would let us simplify the
480
- pipeline (no manual ffmpeg audio extraction, no manual OCR pre-step).
481
- - **`<=>` is distance, not similarity.** Lower = more similar. Always do
482
- `1 - (embedding <=> query)` for a similarity score.
483
- - **HNSW recall.** The HNSW index is approximate. For exact ranking on small
484
- result sets, you can `SET LOCAL hnsw.ef_search = 100;` before the query.
485
- - **First Neon query after idle is slow.** Neon auto-suspends idle databases;
486
- expect ~500ms cold-start latency on the first request.
487
- - **Don't ship secrets.** `secrets.toml` should be `.gitignore`d. The keys from
488
- the previous session are exposed in that chat transcript and should be
489
- rotated.
490
- - **Streamlit `st.video` URL playback.** Local file paths work great and
491
- support `start_time` seeking. Remote HTTP URLs are flaky for seeking —
492
- prefer local files where possible.
493
- - **Audio for the NASA recordings.** The source assets on DVIDS are MP4
494
- wrappers (large, ~200 MB each). The previous session extracted the audio
495
- track to `.m4a` (a few MB each) and embedded *that*. Use the `.m4a` for
496
- playback; ignore the source `.mp4` unless you want visual.
497
- - **`pgvector` + `psycopg3`: don't pass `list[float]` bare.** The pgvector
498
- adapter doesn't auto-cast Python lists to the `vector` type. Either bind a
499
- `numpy.ndarray`, or (what the example does) serialise the vector to the
500
- textual form `'[a,b,c,…]'` and use `%s::vector` in the SQL. Forgetting this
501
- fails with `operator does not exist: vector <=> double precision[]`.
502
- - **Text queries are biased toward text-rich modalities.** In this corpus,
503
- any plain text query crowds the top with `audio_clip` and `pdf_page` rows
504
- because their `embedded_text` is long (multi-sentence NASA narratives /
505
- multi-line OCR), and because video chunks' multimodal vectors are pulled
506
- toward visual neighborhoods that short text queries can't reach. Concrete
507
- example: the query "instantaneous acceleration" returns 12 NASA Apollo /
508
- Mercury audio rows in the top 12 — and **does not surface** the DVIDS clip
509
- `dvids_1007707` whose title literally contains "instant acceleration". To
510
- let video chunks compete: default to a `source_type` filter, present
511
- **faceted results** (top-N per type side by side), or steer users toward
512
- **image queries** (same-modality alignment with video frames).
513
-
514
- ---
515
-
516
- ## 12. Quick test: does the database actually have what this doc claims?
517
-
518
- Run once before you start coding the UI:
519
-
520
- ```python
521
- import os, psycopg
522
- with psycopg.connect(os.environ["DATABASE_URL"]) as c:
523
- for row in c.execute(
524
- "SELECT source_type, COUNT(*) AS rows, COUNT(DISTINCT parent_id) AS assets "
525
- "FROM embeddings GROUP BY source_type ORDER BY source_type"
526
- ).fetchall():
527
- print(row)
528
- ```
529
-
530
- Expected (as of the handoff): `('audio_clip', 27, 7)`, `('pdf_page', 126, 5)`, and `('video_chunk', 154, 49)` — total **307 rows** across **61 distinct parent_ids**, all `release='PURSUE_2'` / `release_date='2026-05-22'`.
531
-
532
- ---
533
-
534
- ## 13. Suggested next steps for the Streamlit session
535
-
536
- 1. Drop `embeddings_v2.py` and the `app.py` from §10 into a fresh folder.
537
- 2. Create `.streamlit/secrets.toml` with `DATABASE_URL` and `GEMINI_API_KEY`.
538
- 3. Run §12 to confirm DB connectivity.
539
- 4. `streamlit run app.py` and test a few queries: `"spherical UAP over water"`,
540
- `"high-speed maneuver"`, `"Apollo astronaut"`.
541
- 5. Polish UI: result cards, thumbnails (DVIDS pages have poster images in
542
- `og:image` if you want to scrape), pagination, multimodal query (already
543
- stubbed in the example), per-result "show all chunks of this video" drilldown.
544
- 6. Optional: add an admin tab that ingests new assets (re-uses `embeddings_v2`
545
- plus the `retry_release_2.py` patterns).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
analyzing.py CHANGED
@@ -5,15 +5,14 @@ import pandas as pd
5
  import numpy as np
6
  import matplotlib.pyplot as plt
7
  import seaborn as sns
8
- from uap_analyzer import UAPParser, UAPAnalyzer, UAPVisualizer, cramers_v
9
  # import ChartGen
10
  # from ChartGen import ChartGPT
11
  from Levenshtein import distance
12
  from sklearn.model_selection import train_test_split
13
  from sklearn.metrics import confusion_matrix
14
- from scipy.stats import chi2_contingency
15
- import plotly.graph_objects as go
16
- from tqdm import tqdm
17
  import streamlit.components.v1 as components
18
  from dateutil import parser
19
  from sentence_transformers import SentenceTransformer
@@ -23,12 +22,7 @@ import matplotlib.colors as mcolors
23
  import textwrap
24
  import datamapplot
25
 
26
- # Import enhanced utilities
27
- from utils.data_processing import DataProcessor
28
- from utils.visualization import UAP_Visualizer as Enhanced_Visualizer
29
- from utils.session_manager import SessionStateManager
30
-
31
- # st.set_option('deprecation.showPyplotGlobalUse', False)
32
 
33
  from pandas.api.types import (
34
  is_categorical_dtype,
@@ -43,7 +37,35 @@ def load_data(file_path, key='df'):
43
  return pd.read_hdf(file_path, key=key)
44
 
45
 
46
- # Gemini Q&A moved to rag_search.py (the RAG search page) — see gemini_query there.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
 
48
  def plot_treemap(df, column, top_n=32):
49
  # Get the value counts and the top N labels
@@ -100,69 +122,23 @@ def plot_treemap(df, column, top_n=32):
100
  ax.patch.set_alpha(0)
101
  return fig
102
 
103
- def plot_hist(df, column, bins=10, kde=True, figsize=(12, 6), color='orange', title=None):
104
- """
105
- Create a histogram with improved styling and error handling.
106
-
107
- Args:
108
- df (pd.DataFrame): DataFrame containing the data
109
- column (str): Column name to plot
110
- bins (int): Number of bins for histogram
111
- kde (bool): Whether to show kernel density estimation
112
- figsize (tuple): Figure size
113
- color (str): Color for the plot
114
- title (str): Custom title for the plot
115
-
116
- Returns:
117
- matplotlib.figure.Figure: The figure object
118
- """
119
- try:
120
- fig, ax = plt.subplots(figsize=figsize, dpi=150)
121
-
122
- # Check if column exists and has data
123
- if column not in df.columns:
124
- ax.text(0.5, 0.5, f'Column "{column}" not found', ha='center', va='center',
125
- transform=ax.transAxes, fontsize=12, color='red')
126
- return fig
127
-
128
- data_series = df[column].dropna()
129
- if len(data_series) == 0:
130
- ax.text(0.5, 0.5, 'No data to plot', ha='center', va='center',
131
- transform=ax.transAxes, fontsize=12, color='red')
132
- return fig
133
-
134
- # Create histogram with improved bins calculation
135
- if bins == 'auto':
136
- bins = min(50, max(10, int(np.sqrt(len(data_series)))))
137
-
138
- sns.histplot(data=df, x=column, kde=kde, bins=bins, color=color, ax=ax, alpha=0.7)
139
-
140
- # Set title
141
- ax.set_title(title or f'Distribution of {column}', color=color, fontweight='bold', fontsize=14)
142
- ax.set_xlabel(column, color=color, fontsize=12)
143
- ax.set_ylabel('Count', color=color, fontsize=12)
144
-
145
- # Style the plot
146
- for spine in ax.spines.values():
147
- spine.set_color(color)
148
-
149
- ax.tick_params(axis='both', colors=color)
150
- ax.grid(True, alpha=0.3, color=color)
151
-
152
  # Set transparent background
153
  fig.patch.set_alpha(0)
154
  ax.patch.set_alpha(0)
155
-
156
- plt.tight_layout()
157
- return fig
158
-
159
- except Exception as e:
160
- fig, ax = plt.subplots(figsize=figsize)
161
- ax.text(0.5, 0.5, f'Error creating histogram:\n{str(e)}',
162
- ha='center', va='center', transform=ax.transAxes, fontsize=12, color='red')
163
- ax.set_title('Histogram Error', color='red')
164
- fig.patch.set_alpha(0)
165
- ax.patch.set_alpha(0)
166
  return fig
167
 
168
 
@@ -283,12 +259,6 @@ def plot_grouped_bar(df, x_columns, y_column, figsize=(12, 10), colors=None, tit
283
 
284
 
285
  def filter_dataframe(df: pd.DataFrame) -> pd.DataFrame:
286
- """
287
- Enhanced filtering interface using the improved DataProcessor
288
- """
289
- return DataProcessor.filter_dataframe_enhanced(df, enable_quick_filters=False, enable_advanced_filters=True)
290
-
291
- def filter_dataframe_legacy(df: pd.DataFrame) -> pd.DataFrame:
292
  """
293
  Adds a UI on top of a dataframe to let viewers filter columns
294
 
@@ -380,104 +350,102 @@ def filter_dataframe_legacy(df: pd.DataFrame) -> pd.DataFrame:
380
  st.pyplot(plot_hist(df_, column, bins=int(round(len(df_[column].unique())-1)/2)))
381
 
382
  elif is_object_dtype(df_[column]):
383
- _orig_col = df_[column].copy()
384
- _is_valid_date = False
385
  try:
386
- df_[column] = pd.to_datetime(df_[column], errors='coerce')
387
  except Exception:
388
  try:
389
- df_[column] = df_[column].apply(lambda x: parser.parse(str(x)) if pd.notna(x) else pd.NaT)
390
  except Exception:
391
  pass
392
 
393
  if is_datetime64_any_dtype(df_[column]):
394
- try:
395
- df_[column] = df_[column].dt.tz_localize(None)
396
- except TypeError:
397
- df_[column] = df_[column].dt.tz_convert(None)
398
- _valid = df_[column].dropna()
399
- if not _valid.empty:
400
- min_date = _valid.min().date()
401
- max_date = _valid.max().date()
402
- if min_date != max_date:
403
- _is_valid_date = True
404
- user_date_input = right.date_input(
405
- f"Values for {column}",
406
- value=(min_date, max_date),
407
- min_value=min_date,
408
- max_value=max_date,
409
- )
410
- if len(user_date_input) == 2:
411
- user_date_input = tuple(map(pd.to_datetime, user_date_input))
412
- start_date, end_date = user_date_input
413
-
414
- time_units = {
415
- 'year': df_[column].dt.year,
416
- 'month': df_[column].dt.to_period('M'),
417
- 'day': df_[column].dt.date
418
- }
419
- unique_counts = {unit: col.nunique() for unit, col in time_units.items()}
420
- closest_to_36 = min(unique_counts, key=lambda k: abs(unique_counts[k] - 36))
421
-
422
- grouped = df_.groupby(time_units[closest_to_36]).size().reset_index(name='count')
423
- grouped.columns = [column, 'count']
424
-
425
- if closest_to_36 == 'year':
426
- date_range = pd.date_range(start=f"{start_date.year}-01-01", end=f"{end_date.year}-12-31", freq='YS')
427
- elif closest_to_36 == 'month':
428
- date_range = pd.date_range(start=start_date.replace(day=1), end=end_date + pd.offsets.MonthEnd(0), freq='MS')
429
- else:
430
- date_range = pd.date_range(start=start_date, end=end_date, freq='D')
431
-
432
- complete_range = pd.DataFrame({column: date_range})
433
- if closest_to_36 == 'year':
434
- complete_range[column] = complete_range[column].dt.year
435
- elif closest_to_36 == 'month':
436
- complete_range[column] = complete_range[column].dt.to_period('M')
437
-
438
- final_data = pd.merge(complete_range, grouped, on=column, how='left').fillna(0)
439
- with st.status(f"Date Distributions: {column}", expanded=False) as stat:
440
- try:
441
- st.pyplot(plot_bar(final_data, column, 'count'))
442
- except Exception as e:
443
- st.error(f"Error plotting bar chart: {e}")
444
- df_ = df_.loc[df_[column].between(start_date, end_date)]
445
-
446
- date_column = column
447
- if date_column and filtered_columns:
448
- numeric_columns = [col for col in filtered_columns if is_numeric_dtype(df_[col])]
449
- categorical_columns = [col for col in filtered_columns if is_categorical_dtype(df_[col])]
450
- with st.status(f"Date Distribution: {column}", expanded=False) as stat:
451
- if numeric_columns:
452
- try:
453
- st.pyplot(plot_line(df_, date_column, numeric_columns))
454
- except Exception as e:
455
- st.error(f"Error plotting line chart: {e}")
456
- if categorical_columns:
457
- try:
458
- st.pyplot(plot_bar(df_, date_column, categorical_columns[0]))
459
- except Exception as e:
460
- st.error(f"Error plotting bar chart: {e}")
461
-
462
- if not _is_valid_date:
463
- df_[column] = _orig_col
464
- _txt_col, _btn_col = right.columns([5, 1])
465
- user_text_input = _txt_col.text_input(
466
- f"Substring or regex in {column}",
467
- key=f"regex_{column}",
468
  )
469
- _drop_null = _btn_col.checkbox("Drop nulls", key=f"dropnull_{column}")
470
- if user_text_input:
471
- try:
472
- mask = df_[column].astype(str).str.contains(user_text_input, case=False, na=False, regex=True)
473
- except Exception:
474
- mask = df_[column].astype(str).str.contains(user_text_input, case=False, na=False, regex=False)
475
- df_ = df_.loc[mask]
476
- filtered_columns.append(column)
477
- if _drop_null:
478
- df_ = df_.loc[df_[column].notna() & (df_[column].astype(str).str.strip() != '')]
479
- if column not in filtered_columns:
480
- filtered_columns.append(column)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
481
  # write len of df after filtering with % of original
482
  st.write(f"{len(df_)} rows ({len(df_) / len(df) * 100:.2f}%)")
483
  return df_
@@ -506,658 +474,6 @@ def merge_clusters(df, column):
506
  df.__dict__['string_labels'] = updated_string_labels
507
  return updated_string_labels
508
 
509
- # ── Categorical Association Explorer (Cramér's V) ───────────────────────────
510
- # Bands columns by unique-value count so genuinely categorical columns are
511
- # auto-selected for a fast first-pass Cramér's V, while high-cardinality
512
- # columns (free-text, IDs, numerics) are excluded by default.
513
-
514
- def _safe_nunique(series):
515
- """nunique that tolerates unhashable cells (lists/dicts) by stringifying."""
516
- try:
517
- return int(series.nunique(dropna=True))
518
- except TypeError:
519
- return int(series.astype(str).nunique(dropna=True))
520
-
521
-
522
- def band_columns(df, high_threshold=30):
523
- """Bucket columns into categorical bands by cardinality.
524
-
525
- Returns (bands, nunique_map):
526
- bands = {"binary": [...], "low": [...], "medium": [...], "high": [...],
527
- "constant": [...]}
528
- - binary : exactly 2 distinct values
529
- - low : 3 .. 9
530
- - medium : 10 .. high_threshold-1
531
- - high : >= high_threshold (treated as non-categorical, excluded)
532
- - constant : <= 1 distinct value (useless for association)
533
- """
534
- bands = {"binary": [], "low": [], "medium": [], "high": [], "constant": []}
535
- nunique_map = {}
536
- for c in df.columns:
537
- nu = _safe_nunique(df[c])
538
- nunique_map[c] = nu
539
- if nu <= 1:
540
- bands["constant"].append(c)
541
- elif nu == 2:
542
- bands["binary"].append(c)
543
- elif nu <= 9:
544
- bands["low"].append(c)
545
- elif nu < high_threshold:
546
- bands["medium"].append(c)
547
- else:
548
- bands["high"].append(c)
549
- return bands, nunique_map
550
-
551
-
552
- # Single canonical label for every flavour of "absent" so missingness isn't
553
- # fragmented into nan / None / <NA> / NaT / "" as separate categories.
554
- _MISSING_LABEL = "(missing)"
555
- _NULL_STR_TOKENS = {"nan", "none", "null", "<na>", "nat", ""}
556
-
557
-
558
- def _coalesce(series):
559
- """Stringify a column and fold all null representations into one
560
- `(missing)` category (fixes fragmentation). Never mutates the source."""
561
- s = series.astype(str).str.strip()
562
- return s.mask(s.str.lower().isin(_NULL_STR_TOKENS), _MISSING_LABEL)
563
-
564
-
565
- def _pair_series(df, c1, c2, drop_missing):
566
- """Coalesced (and optionally complete-case) aligned pair of columns."""
567
- a, b = _coalesce(df[c1]), _coalesce(df[c2])
568
- if drop_missing:
569
- keep = (a != _MISSING_LABEL) & (b != _MISSING_LABEL)
570
- a, b = a[keep], b[keep]
571
- return a, b
572
-
573
-
574
- def compute_cramers_v_df(df, cols, drop_missing=False):
575
- """Symmetric Cramér's V matrix over `cols` (diagonal = 1.0). Each cell is
576
- computed once and mirrored. Nulls are coalesced to a single `(missing)`
577
- category; when `drop_missing` is set, each pair is reduced to its
578
- complete cases (note: per-cell N then varies across the matrix)."""
579
- cv = pd.DataFrame(index=cols, columns=cols, data=np.nan, dtype=float)
580
- cache = {c: _coalesce(df[c]) for c in cols}
581
- for i, c1 in enumerate(cols):
582
- cv.at[c1, c1] = 1.0
583
- for c2 in cols[i + 1:]:
584
- a, b = cache[c1], cache[c2]
585
- if drop_missing:
586
- keep = (a != _MISSING_LABEL) & (b != _MISSING_LABEL)
587
- a, b = a[keep], b[keep]
588
- v = 0.0 if len(a) == 0 else cramers_v(pd.crosstab(a, b))
589
- cv.at[c1, c2] = v
590
- cv.at[c2, c1] = v
591
- return cv
592
-
593
-
594
- _CV_TOL = 1e-6 # treat values within this of 0 / 1 as trivial
595
-
596
-
597
- def _is_trivial_v(v, tol=_CV_TOL):
598
- """True for a Cramér's V that's effectively 0 (no association — likely
599
- null/constant) or 1 (perfect association — likely a duplicate column)."""
600
- return (v <= tol) or (v >= 1.0 - tol)
601
-
602
-
603
- def high_correlation_columns(cv_df, strong_threshold=0.30, exclude_trivial=True):
604
- """Columns reaching Cramér's V ≥ strong_threshold with ≥1 other column.
605
-
606
- Mirrors the pass-2 pre-fill logic in render_cramers_v_explorer so the
607
- analysis form can reuse whatever the explorer last computed. Trivial pairs
608
- (V≈0 null/constant, V≈1 duplicate) are skipped when exclude_trivial is set.
609
- Returns [] if cv_df is None or empty.
610
- """
611
- if cv_df is None or getattr(cv_df, "empty", True):
612
- return []
613
- out = []
614
- for col in cv_df.columns:
615
- others = cv_df[col].drop(labels=[col], errors="ignore")
616
- for v in others:
617
- if pd.isna(v):
618
- continue
619
- v = float(v)
620
- if exclude_trivial and _is_trivial_v(v):
621
- continue
622
- if v >= strong_threshold:
623
- out.append(col)
624
- break
625
- return out
626
-
627
-
628
- def _cramers_pairs_table(cv_df, exclude_trivial=True):
629
- """Ranked long-form table of unique off-diagonal pairs, strongest first.
630
-
631
- When `exclude_trivial` is set, pairs with Cramér's V ≈ 0 (no association,
632
- likely null/constant) or ≈ 1 (perfect association, likely duplicate
633
- columns) are dropped. Returns (table, n_excluded)."""
634
- rows, n_excluded = [], 0
635
- cols = list(cv_df.columns)
636
- for i, c1 in enumerate(cols):
637
- for c2 in cols[i + 1:]:
638
- v = cv_df.at[c1, c2]
639
- if pd.isna(v):
640
- continue
641
- v = float(v)
642
- if exclude_trivial and _is_trivial_v(v):
643
- n_excluded += 1
644
- continue
645
- rows.append({"Variable A": c1, "Variable B": c2,
646
- "Cramér's V": round(v, 3)})
647
- out = pd.DataFrame(rows)
648
- if not out.empty:
649
- out = out.sort_values("Cramér's V", ascending=False).reset_index(drop=True)
650
- return out, n_excluded
651
-
652
-
653
- def _interactive_cv_heatmap(cv_df, title, key):
654
- """Clickable lower-triangle Cramér's V heatmap. Returns the (row, col)
655
- column names of the most recently clicked cell, or None.
656
-
657
- Clicking a cell drives the contingency drill-down below the chart.
658
- """
659
- cols = list(cv_df.columns)
660
- n = len(cols)
661
- z = cv_df.astype(float).values.copy()
662
- # Mask the strict upper triangle so each pair shows once (lower triangle).
663
- z[np.triu(np.ones_like(z, dtype=bool), k=1)] = np.nan
664
- annotate = n <= 25
665
- fig = go.Figure(go.Heatmap(
666
- z=z, x=cols, y=cols, colorscale="RdBu_r", zmin=0, zmax=1,
667
- colorbar=dict(title="Cramér's V"),
668
- hovertemplate="A (row): %{y}<br>B (col): %{x}<br>V: %{z:.3f}"
669
- "<extra></extra>",
670
- texttemplate="%{z:.2f}" if annotate else None,
671
- textfont={"size": 8},
672
- ))
673
- fig.update_layout(
674
- title=f"{title} — click a cell (or pick a pair below) to inspect categories",
675
- height=max(450, 24 * n + 160),
676
- yaxis=dict(autorange="reversed", scaleanchor="x", constrain="domain"),
677
- template="plotly_dark",
678
- paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
679
- margin={"l": 10, "r": 10, "t": 60, "b": 10},
680
- )
681
- try:
682
- event = st.plotly_chart(fig, use_container_width=True,
683
- on_select="rerun", key=key)
684
- except TypeError:
685
- # Older Streamlit without on_select — render non-interactive.
686
- st.plotly_chart(fig, use_container_width=True, key=key)
687
- return None
688
- # Selection payload shape varies across Streamlit versions; dig defensively.
689
- sel = {}
690
- if event is not None:
691
- sel = (event.get("selection") if hasattr(event, "get") else None) or {}
692
- pts = sel.get("points", []) if hasattr(sel, "get") else []
693
- if pts:
694
- p = pts[-1]
695
- c_row, c_col = p.get("y"), p.get("x")
696
- if c_row in cv_df.columns and c_col in cv_df.columns and c_row != c_col:
697
- return c_row, c_col
698
- return None
699
-
700
-
701
- def _pair_drilldown(df, cv_df, pairs_df, key_prefix, clicked, drop_missing=False):
702
- """Render Variable-A / Variable-B selectors (defaulting to the strongest
703
- pair, or to a clicked heatmap cell) and the contingency drill-down. Works
704
- even when heatmap click events don't fire — the selectors are the reliable
705
- path; a click just pre-sets them."""
706
- cols = list(cv_df.columns)
707
- if len(cols) < 2:
708
- return
709
- ka, kb = f"{key_prefix}_a", f"{key_prefix}_b"
710
-
711
- # Strongest pair from the (already filtered) ranked table → sensible default.
712
- if pairs_df is not None and not pairs_df.empty:
713
- default_a = pairs_df.iloc[0]["Variable A"]
714
- default_b = pairs_df.iloc[0]["Variable B"]
715
- else:
716
- default_a, default_b = cols[0], cols[1]
717
-
718
- # Seed defaults once; reset if a stale value isn't in the current matrix.
719
- if ka not in st.session_state or st.session_state[ka] not in cols:
720
- st.session_state[ka] = default_a
721
- if kb not in st.session_state or st.session_state[kb] not in cols:
722
- st.session_state[kb] = default_b
723
- # A heatmap click overrides the current selection.
724
- if clicked:
725
- st.session_state[ka], st.session_state[kb] = clicked[0], clicked[1]
726
-
727
- cA, cB = st.columns(2)
728
- with cA:
729
- a = st.selectbox("Variable A (row)", cols, key=ka)
730
- with cB:
731
- b = st.selectbox("Variable B (col)", cols, key=kb)
732
- if a == b:
733
- st.info("Pick two different columns to see their co-occurring categories.")
734
- return
735
- _render_contingency(df, a, b, float(cv_df.at[a, b]), key_prefix, drop_missing)
736
-
737
-
738
- def _render_contingency(df, c1, c2, v, key_prefix, drop_missing=False):
739
- """Show which category VALUES co-vary for the chosen column pair: a
740
- crosstab the user can view as counts, row/column %, or standardized
741
- residuals (the cells that drive the association). Nulls are coalesced to a
742
- single `(missing)` category, or dropped (complete cases) when requested."""
743
- st.markdown(f"#### 🔬 `{c1}` ✕ `{c2}` — Cramér's V = **{v:.3f}**")
744
- a, b = _pair_series(df, c1, c2, drop_missing)
745
- n_used = len(a)
746
- n_total = len(df)
747
- if drop_missing:
748
- st.caption(f"Complete cases: **{n_used:,}** of {n_total:,} rows "
749
- f"({n_used / n_total * 100:.1f}%) — rows missing either "
750
- "column dropped.")
751
- else:
752
- st.caption(f"All **{n_total:,}** rows; nulls folded into one "
753
- "`(missing)` category.")
754
- if n_used == 0:
755
- st.info("No rows left for this pair after dropping missing values.")
756
- return
757
- ct = pd.crosstab(a, b)
758
- if ct.size == 0:
759
- st.info("Empty crosstab for this pair.")
760
- return
761
-
762
- # Cap very large crosstabs to the most frequent values per axis.
763
- MAXC = 30
764
- note = ""
765
- r, k = ct.shape
766
- if r > MAXC or k > MAXC:
767
- top_r = a.value_counts().nlargest(MAXC).index
768
- top_k = b.value_counts().nlargest(MAXC).index
769
- ct = ct.loc[ct.index.isin(top_r), ct.columns.isin(top_k)]
770
- note = f" · showing top {MAXC} values per axis"
771
-
772
- # χ²-expected-count health check (the residual approximation wants ≥5).
773
- if min(ct.shape) >= 2:
774
- try:
775
- _, _, _, _exp = chi2_contingency(ct)
776
- _low = float((_exp < 5).mean()) * 100
777
- if _low > 20:
778
- st.warning(
779
- f"⚠️ {_low:.0f}% of cells have an expected count < 5 — the "
780
- "χ² / standardized-residual approximation is unstable here. "
781
- "Treat residuals as indicative, not exact."
782
- )
783
- except ValueError:
784
- pass
785
-
786
- view = st.radio(
787
- "Cell values", ["Counts", "Row %", "Column %", "Std. residuals"],
788
- horizontal=True, key=f"{key_prefix}_ctview",
789
- help="Std. residual = (observed − expected) / √expected. |residual| > 2 "
790
- "marks a value-pair that co-occurs far more (blue) or less (red) "
791
- "than chance — these are what make the two columns covariable.",
792
- )
793
-
794
- if view == "Counts":
795
- mat, fmt, cs, zmid = ct, ".0f", "Blues", None
796
- elif view == "Row %":
797
- mat = ct.div(ct.sum(axis=1).replace(0, np.nan), axis=0) * 100
798
- fmt, cs, zmid = ".0f", "Blues", None
799
- elif view == "Column %":
800
- mat = ct.div(ct.sum(axis=0).replace(0, np.nan), axis=1) * 100
801
- fmt, cs, zmid = ".0f", "Blues", None
802
- else: # Std. residuals
803
- chi2, p, dof, expected = chi2_contingency(ct)
804
- with np.errstate(divide="ignore", invalid="ignore"):
805
- resid = (ct.values - expected) / np.sqrt(expected)
806
- mat = pd.DataFrame(np.nan_to_num(resid), index=ct.index, columns=ct.columns)
807
- fmt, cs, zmid = ".1f", "RdBu", 0
808
-
809
- rows, kcols = mat.shape
810
- show_text = rows <= 25 and kcols <= 25
811
- fig = go.Figure(go.Heatmap(
812
- z=mat.values,
813
- x=[str(c) for c in mat.columns], y=[str(i) for i in mat.index],
814
- colorscale=cs, zmid=zmid,
815
- texttemplate=f"%{{z:{fmt}}}" if show_text else None,
816
- textfont={"size": 8},
817
- hovertemplate=f"{c1}=%{{y}}<br>{c2}=%{{x}}<br>%{{z:{fmt}}}<extra></extra>",
818
- ))
819
- fig.update_layout(
820
- title=f"{c1} ✕ {c2} — {view}{note}",
821
- height=max(380, 22 * rows + 160),
822
- xaxis_title=c2, yaxis_title=c1,
823
- yaxis=dict(autorange="reversed"),
824
- template="plotly_dark",
825
- paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
826
- )
827
- st.plotly_chart(fig, use_container_width=True, key=f"{key_prefix}_ctfig")
828
-
829
- if view == "Std. residuals":
830
- # Surface the strongest covariable value-pairs as a ranked table.
831
- rl = [
832
- {f"{c1}": i, f"{c2}": j, "std_residual": round(float(mat.at[i, j]), 2),
833
- "count": int(ct.at[i, j])}
834
- for i in mat.index for j in mat.columns
835
- if abs(mat.at[i, j]) >= 2
836
- ]
837
- if rl:
838
- rl_df = pd.DataFrame(rl)
839
- rl_df = (rl_df.assign(_abs=rl_df["std_residual"].abs())
840
- .sort_values("_abs", ascending=False)
841
- .drop(columns="_abs")
842
- .reset_index(drop=True))
843
- st.caption("Value-pairs that co-occur more/less than chance "
844
- "(|std. residual| ≥ 2):")
845
- st.dataframe(rl_df.head(30), hide_index=True, use_container_width=True)
846
- else:
847
- st.caption("No value-pair exceeds |std. residual| ≥ 2 — the "
848
- "association is spread evenly rather than driven by "
849
- "specific value combinations.")
850
-
851
-
852
- def render_cramers_v_explorer(df):
853
- """Cardinality-banded, two-pass Cramér's V association explorer.
854
-
855
- Pass 1 auto-runs over every column the cardinality heuristic flags as
856
- categorical (binary + low + medium bands). Pass 2 lets the user refine the
857
- set — pre-filled with whichever columns carried a strong association.
858
- """
859
- st.markdown("### 🎲 Categorical Association Explorer (Cramér's V)")
860
- st.caption(
861
- "Columns are banded by their number of distinct values. Genuinely "
862
- "categorical columns are auto-selected for a first-pass association "
863
- "heatmap; high-cardinality columns (free-text, IDs, numerics) are "
864
- "treated as non-categorical and excluded by default."
865
- )
866
-
867
- cc1, cc2 = st.columns(2)
868
- with cc1:
869
- high_threshold = int(st.number_input(
870
- "Non-categorical threshold (distinct values)",
871
- min_value=3, max_value=1000, value=30, step=1,
872
- key="cv_high_threshold",
873
- help="Columns with this many or more distinct values are treated "
874
- "as non-categorical and land in the (opt-in) High band.",
875
- ))
876
- with cc2:
877
- strong_threshold = float(st.slider(
878
- "Strong-association threshold (for pass 2 pre-fill)",
879
- 0.0, 1.0, 0.30, 0.05, key="cv_strong_threshold",
880
- help="In pass 2, columns are pre-selected if they reach at least "
881
- "this Cramér's V with any other column in pass 1.",
882
- ))
883
-
884
- fc1, fc2 = st.columns([2, 3])
885
- with fc1:
886
- exclude_trivial = st.checkbox(
887
- "Filter out V≈0 and V≈1 pairs (likely null / duplicate)",
888
- value=True, key="cv_exclude_trivial",
889
- help="Drops pairs with no association (V≈0 — often constant/null "
890
- "columns) and perfect association (V≈1 — often duplicate "
891
- "columns) from the ranked tables and the pass-2 pre-fill.",
892
- )
893
- with fc2:
894
- missing_mode = st.radio(
895
- "Missing values",
896
- ["Treat as “(missing)” category", "Drop missing (complete cases)"],
897
- horizontal=True, key="cv_missing_mode",
898
- help="Coalesce: all nulls (nan/None/<NA>) become one “(missing)” "
899
- "level — keeps every row, lets you study co-missingness. "
900
- "Drop: each pair uses only rows where both are present "
901
- "(unbiased only if data is missing-completely-at-random).",
902
- )
903
- drop_missing = missing_mode.startswith("Drop")
904
-
905
- bands, nunique_map = band_columns(df, high_threshold=high_threshold)
906
-
907
- band_meta = [
908
- ("binary", "Binary (2 values)", True),
909
- ("low", "Low (3–9 values)", True),
910
- ("medium", f"Medium (10–{high_threshold - 1} values)", True),
911
- ("high", f"High (≥{high_threshold} — non-categorical)", False),
912
- ]
913
-
914
- selected = []
915
- for key, label, prefill in band_meta:
916
- opts = bands[key]
917
- if not opts:
918
- continue
919
- # Sort options by cardinality so the most-categorical appear first.
920
- opts = sorted(opts, key=lambda c: nunique_map[c])
921
- default = opts if prefill else []
922
- picked = st.multiselect(
923
- f"{label} — {len(opts)} column(s)",
924
- options=opts,
925
- default=default,
926
- key=f"cv_band_{key}",
927
- help="Pre-filled and included in pass 1." if prefill
928
- else "Not pre-filled — tick to opt in (crosstabs can be large).",
929
- )
930
- selected.extend(picked)
931
-
932
- if bands["constant"]:
933
- st.caption(
934
- f":grey[Skipped {len(bands['constant'])} constant/empty column(s): "
935
- f"{', '.join(bands['constant'][:8])}"
936
- f"{'…' if len(bands['constant']) > 8 else ''}]"
937
- )
938
-
939
- # De-dup while preserving order.
940
- selected = list(dict.fromkeys(selected))
941
- n_sel = len(selected)
942
- n_pairs = n_sel * (n_sel - 1) // 2
943
- st.caption(f"**{n_sel}** column(s) selected → **{n_pairs:,}** pairwise "
944
- "Cramér's V computations.")
945
- if n_sel > 40:
946
- st.warning(
947
- f"{n_sel} columns selected — pass 1 computes {n_pairs:,} crosstabs "
948
- "and may be slow. Consider trimming the Medium band."
949
- )
950
-
951
- if st.button("▶️ Run Cramér's V — Pass 1", type="primary",
952
- disabled=n_sel < 2, key="cv_run_pass1"):
953
- with st.spinner(f"Computing Cramér's V over {n_sel} columns…"):
954
- cv_df = compute_cramers_v_df(df, selected, drop_missing=drop_missing)
955
- st.session_state["cv_pass1_df"] = cv_df
956
- st.session_state.pop("cv_pass2_df", None)
957
-
958
- cv1 = st.session_state.get("cv_pass1_df")
959
- if cv1 is not None:
960
- st.markdown("#### Pass 1 — auto-selected categoricals")
961
- clicked1 = _interactive_cv_heatmap(cv1, "Cramér's V — Pass 1", "cv_hm1")
962
- pairs1, n_excl1 = _cramers_pairs_table(cv1, exclude_trivial=exclude_trivial)
963
- # Keep only the download button in the main flow; tuck the pair
964
- # comparison drill-down and the ranked table behind a dropdown.
965
- if not pairs1.empty:
966
- st.download_button(
967
- "⬇️ Download pass-1 pairs (CSV)", pairs1.to_csv(index=False),
968
- "cramers_v_pass1.csv", "text/csv", key="cv_dl_pass1",
969
- )
970
- # Popover, not an expander: this whole explorer is rendered inside the
971
- # "Categorical Association Explorer" expander, and Streamlit forbids
972
- # nesting an expander inside another expander.
973
- with st.popover("🔎 Pair comparison & ranked table", use_container_width=True):
974
- _pair_drilldown(df, cv1, pairs1, "cv_dd1", clicked1, drop_missing)
975
- if n_excl1:
976
- st.caption(f":grey[Filtered {n_excl1} trivial pair(s) "
977
- "(V≈0 null/constant or V≈1 duplicate).]")
978
- if not pairs1.empty:
979
- st.caption("Strongest associations (pass 1):")
980
- st.dataframe(pairs1.head(25), hide_index=True, use_container_width=True)
981
- else:
982
- st.info("No non-trivial associations found in pass 1.")
983
-
984
- # ── Pass 2 — refine ────────────────────────────────────────────────
985
- st.divider()
986
- st.markdown("#### Pass 2 — refine")
987
- # A column qualifies if it reaches the strong threshold with another
988
- # column — but trivial values (≈1 duplicates, ≈0) are skipped when the
989
- # filter is on, so duplicate-driven columns don't auto-fill pass 2.
990
- def _qualifies(col):
991
- others = cv1[col].drop(labels=[col])
992
- for v in others:
993
- if pd.isna(v):
994
- continue
995
- v = float(v)
996
- if exclude_trivial and _is_trivial_v(v):
997
- continue
998
- if v >= strong_threshold:
999
- return True
1000
- return False
1001
- strong_cols = [c for c in cv1.columns if _qualifies(c)]
1002
- st.caption(
1003
- f"Pre-filled with the **{len(strong_cols)}** column(s) reaching "
1004
- f"Cramér's V ≥ {strong_threshold:.2f} with any other column in "
1005
- "pass 1. Edit the set and re-run for a focused heatmap."
1006
- )
1007
- refine_sel = st.multiselect(
1008
- "Columns for pass 2",
1009
- options=list(cv1.columns),
1010
- default=strong_cols or list(cv1.columns),
1011
- key="cv_refine_sel",
1012
- )
1013
- refine_sel = list(dict.fromkeys(refine_sel))
1014
- if st.button("🔬 Run Cramér's V — Pass 2 (refined)",
1015
- disabled=len(refine_sel) < 2, key="cv_run_pass2"):
1016
- with st.spinner(f"Re-computing over {len(refine_sel)} columns…"):
1017
- # Sub-select from the pass-1 matrix when possible; recompute
1018
- # only if the user somehow added columns not in pass 1.
1019
- if set(refine_sel).issubset(set(cv1.columns)):
1020
- cv2 = cv1.loc[refine_sel, refine_sel]
1021
- else:
1022
- cv2 = compute_cramers_v_df(df, refine_sel, drop_missing=drop_missing)
1023
- st.session_state["cv_pass2_df"] = cv2
1024
-
1025
- cv2 = st.session_state.get("cv_pass2_df")
1026
- if cv2 is not None:
1027
- clicked2 = _interactive_cv_heatmap(cv2, "Cramér's V — Pass 2 (refined)", "cv_hm2")
1028
- pairs2, n_excl2 = _cramers_pairs_table(cv2, exclude_trivial=exclude_trivial)
1029
- # Same as pass 1: only the download button stays in the main flow.
1030
- if not pairs2.empty:
1031
- st.download_button(
1032
- "⬇️ Download pass-2 pairs (CSV)", pairs2.to_csv(index=False),
1033
- "cramers_v_pass2.csv", "text/csv", key="cv_dl_pass2",
1034
- )
1035
- with st.popover("🔎 Pair comparison & ranked table (refined)", use_container_width=True):
1036
- _pair_drilldown(df, cv2, pairs2, "cv_dd2", clicked2, drop_missing)
1037
- if n_excl2:
1038
- st.caption(f":grey[Filtered {n_excl2} trivial pair(s) "
1039
- "(V≈0 null/constant or V≈1 duplicate).]")
1040
- if not pairs2.empty:
1041
- st.dataframe(pairs2.head(40), hide_index=True,
1042
- use_container_width=True)
1043
- else:
1044
- st.info("No non-trivial associations in the refined set.")
1045
-
1046
-
1047
- def render_categorical_flow_sankey(df):
1048
- """All-with-all categorical flow Sankey for the Statistical Analysis section.
1049
-
1050
- Unlike the cross-DB matcher in rag_search.py (which pairs records by
1051
- similarity), this links every value of each chosen column to every
1052
- co-occurring value of the next column, with link width = the number of rows
1053
- sharing that combination (a chained crosstab). No pairwise matching — just
1054
- all-with-all co-occurrence counts.
1055
- """
1056
- st.markdown("### 🌊 Categorical Flow (Sankey)")
1057
- st.caption(
1058
- "Pick two or more categorical columns as ordered levels (left → right). "
1059
- "Every value is linked to every co-occurring value of the next level, "
1060
- "with link width = the number of rows sharing that combination — no "
1061
- "pairwise matching, just all-with-all co-occurrence counts."
1062
- )
1063
-
1064
- n = len(df)
1065
- if n == 0:
1066
- st.info("No rows to chart.")
1067
- return
1068
-
1069
- def _safe_nunique(s):
1070
- # Columns of dicts/lists are unhashable; fall back to string form.
1071
- try:
1072
- return s.nunique(dropna=False)
1073
- except TypeError:
1074
- return s.astype(str).nunique(dropna=False)
1075
-
1076
- cat_like = [
1077
- c for c in df.columns
1078
- if 1 < _safe_nunique(df[c]) <= max(50, int(n * 0.5))
1079
- ]
1080
- if len(cat_like) < 2:
1081
- st.info("Need at least two categorical-like columns for a flow diagram.")
1082
- return
1083
-
1084
- sc1, sc2 = st.columns([3, 1])
1085
- with sc1:
1086
- cols = st.multiselect(
1087
- "Levels (left → right, in order)",
1088
- options=list(df.columns),
1089
- default=cat_like[:3],
1090
- key="sankey_flow_cols",
1091
- )
1092
- with sc2:
1093
- top_n = int(st.number_input(
1094
- "Top-N / level", min_value=2, max_value=50, value=12, step=1,
1095
- key="sankey_flow_topn",
1096
- help="Keep only the most frequent N values per level; the rest are "
1097
- "lumped into “Other” so the diagram stays readable.",
1098
- ))
1099
-
1100
- if len(cols) < 2:
1101
- st.info("Select at least two columns.")
1102
- return
1103
-
1104
- # Normalise: string-cast, fill missing, cap to top-N per level (+ “Other”).
1105
- work = pd.DataFrame(index=df.index)
1106
- for c in cols:
1107
- s = df[c].astype(str).where(df[c].notna(), "(missing)")
1108
- keep = s.value_counts().head(top_n).index
1109
- work[c] = s.where(s.isin(keep), "Other")
1110
-
1111
- # Namespace nodes by (level, value) so identical values in different levels
1112
- # never merge into one node.
1113
- PALETTE = ["#f97316", "#22c55e", "#3b82f6", "#a855f7", "#ec4899",
1114
- "#14b8a6", "#eab308", "#ef4444"]
1115
- index, node_label, node_color = {}, [], []
1116
- L = len(cols)
1117
- max_per_level = 1
1118
- for level, c in enumerate(cols):
1119
- vc = work[c].value_counts()
1120
- max_per_level = max(max_per_level, len(vc))
1121
- for v in vc.index:
1122
- key = (level, v)
1123
- if key not in index:
1124
- index[key] = len(node_label)
1125
- node_label.append(str(v))
1126
- node_color.append(PALETTE[level % len(PALETTE)])
1127
-
1128
- # Links: co-occurrence counts between consecutive levels (the "all with all").
1129
- srcs, tgts, vals = [], [], []
1130
- for level in range(L - 1):
1131
- a, b = cols[level], cols[level + 1]
1132
- counts = work.groupby([a, b]).size().reset_index(name="value")
1133
- for _, row in counts.iterrows():
1134
- srcs.append(index[(level, row[a])])
1135
- tgts.append(index[(level + 1, row[b])])
1136
- vals.append(int(row["value"]))
1137
-
1138
- def _rgba(hex_color, alpha=0.35):
1139
- h = hex_color.lstrip("#")
1140
- return f"rgba({int(h[0:2], 16)},{int(h[2:4], 16)},{int(h[4:6], 16)},{alpha})"
1141
-
1142
- fig = go.Figure(go.Sankey(
1143
- arrangement="snap",
1144
- node=dict(label=node_label, color=node_color, pad=14, thickness=18),
1145
- link=dict(source=srcs, target=tgts, value=vals,
1146
- color=[_rgba(node_color[s]) for s in srcs]),
1147
- ))
1148
- fig.update_layout(
1149
- template="plotly_dark",
1150
- paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
1151
- font_size=12, height=max(420, 60 + 24 * max_per_level),
1152
- margin=dict(l=10, r=10, t=20, b=10),
1153
- )
1154
- st.plotly_chart(fig, use_container_width=True)
1155
- st.caption(
1156
- f"{' → '.join(cols)} · {len(node_label)} nodes · {len(srcs)} links "
1157
- "(all-with-all co-occurrence, no matching)."
1158
- )
1159
-
1160
-
1161
  def analyze_and_predict(data, analyzers, col_names, clusters):
1162
  visualizer = UAPVisualizer()
1163
  new_data = pd.DataFrame()
@@ -1231,6 +547,9 @@ data_path = 'parsed_files_distance_embeds.h5'
1231
 
1232
  my_dataset = st.file_uploader("Upload Parsed DataFrame", type=["csv", "xlsx"])
1233
  if my_dataset is not None:
 
 
 
1234
  try:
1235
  if my_dataset.type == "text/csv":
1236
  data = pd.read_csv(my_dataset)
@@ -1239,9 +558,9 @@ if my_dataset is not None:
1239
  else:
1240
  st.error("Unsupported file type. Please upload a CSV, Excel or HD5 file.")
1241
  st.stop()
1242
- filtered = filter_dataframe(data)
1243
- st.session_state['parsed_responses'] = filtered
1244
- st.dataframe(filtered)
1245
  st.success(f"Successfully loaded and displayed data from {my_dataset.name}")
1246
  except Exception as e:
1247
  st.error(f"An error occurred while reading the file: {e}")
@@ -1250,85 +569,27 @@ else:
1250
  parsed_responses = filter_dataframe(parsed)
1251
  st.session_state['parsed_responses'] = parsed_responses
1252
  st.dataframe(parsed_responses)
1253
- # Gemini Q&A over a column moved to the RAG search page (rag_search.py).
 
 
 
 
 
 
 
 
 
 
1254
  st.session_state['stage'] = 1
1255
 
1256
- # Add enhanced visualization toggle
1257
- with st.expander("🚀 Enhanced Visualization Options", expanded=False):
1258
- use_interactive_viz = st.checkbox("Use Interactive Visualizations", value=True, help="Enable modern interactive charts with better performance")
1259
- show_data_profile = st.checkbox("Show Data Profile", value=True, help="Display intelligent data analysis before filtering")
1260
- enable_performance_mode = st.checkbox("Performance Mode", value=True, help="Enable smart sampling for large datasets")
1261
-
1262
- # TF-IDF cluster naming — default OFF. When ON, clusters get human-readable
1263
- # names derived from their top TF-IDF terms and near-duplicate names are
1264
- # merged. When OFF, clusters keep their numeric HDBSCAN ids and XGBoost
1265
- # downstream runs against the raw labels.
1266
- enable_tfidf_clusters = st.toggle(
1267
- "Enable TF-IDF cluster naming + merging",
1268
- value=False,
1269
- key="enable_tfidf_clusters",
1270
- help="When off, clusters are labeled 'Cluster N' and HDBSCAN labels are "
1271
- "passed straight to XGBoost. When on, top TF-IDF terms name each "
1272
- "cluster and near-duplicate names are merged (slower).",
1273
- )
1274
-
1275
- # Categorical association explorer — runs directly on the parsed DataFrame,
1276
- # independent of the embedding/cluster pipeline below.
1277
- if st.session_state['stage'] > 0 and st.session_state.get('parsed_responses') is not None:
1278
- with st.expander("🎲 Categorical Association Explorer (Cramér's V)", expanded=False):
1279
- render_cramers_v_explorer(st.session_state['parsed_responses'])
1280
- with st.expander("🌊 Categorical Flow (Sankey)", expanded=False):
1281
- render_categorical_flow_sankey(st.session_state['parsed_responses'])
1282
 
1283
  if st.session_state['stage'] > 0 :
1284
- # ── High-correlation shortcut ──────────────────────────────────────────────
1285
- # Pre-fill the column selector with whatever the Cramér's V explorer above
1286
- # last flagged as strongly associated. Kept OUTSIDE the form so toggling it
1287
- # reruns immediately and updates the (keyless) multiselect default; a
1288
- # checkbox inside the form would only take effect on submit.
1289
- _valid_cols = list(st.session_state['parsed_responses'].columns)
1290
- _cv_df = st.session_state.get("cv_pass2_df")
1291
- if _cv_df is None:
1292
- _cv_df = st.session_state.get("cv_pass1_df")
1293
- _cv_strong = float(st.session_state.get("cv_strong_threshold", 0.30))
1294
- _cv_excl = bool(st.session_state.get("cv_exclude_trivial", True))
1295
- _high_corr_cols = [
1296
- c for c in high_correlation_columns(_cv_df, _cv_strong, _cv_excl)
1297
- if c in _valid_cols
1298
- ]
1299
-
1300
- pass_high_corr = st.checkbox(
1301
- "Pass all high-correlated columns from Cramér's V",
1302
- value=False,
1303
- key="analyze_pass_high_corr",
1304
- disabled=not _high_corr_cols,
1305
- help=(
1306
- f"Pre-select every column reaching Cramér's V ≥ {_cv_strong:.2f} with "
1307
- "another column in the latest Cramér's V pass. Run the Categorical "
1308
- "Association Explorer above first to populate this."
1309
- ),
1310
- )
1311
- if not _high_corr_cols:
1312
- st.caption(
1313
- ":grey[No Cramér's V results yet — run the Categorical Association "
1314
- "Explorer above to enable the high-correlation shortcut.]"
1315
- )
1316
- elif pass_high_corr:
1317
- st.caption(
1318
- f":green[{len(_high_corr_cols)} high-correlation column(s) pre-selected:] "
1319
- f"{', '.join(_high_corr_cols[:12])}"
1320
- f"{'…' if len(_high_corr_cols) > 12 else ''}"
1321
- )
1322
-
1323
- _default_cols = _high_corr_cols if (pass_high_corr and _high_corr_cols) else []
1324
-
1325
  with st.form(border=True, key='Select Columns for Analysis'):
1326
  columns_to_analyze = st.multiselect(
1327
  label='Select columns to analyze',
1328
- options=st.session_state['parsed_responses'].columns,
1329
- default=_default_cols,
1330
  )
1331
- if st.form_submit_button("Process Data"):
1332
  if columns_to_analyze:
1333
  analyzers = []
1334
  col_names = []
@@ -1343,22 +604,11 @@ if st.session_state['stage'] > 0 :
1343
  analyzer.reduce_dimensionality(method='UMAP', n_components=2, n_neighbors=15, min_dist=0.1)
1344
  st.write("Clustering data...")
1345
  analyzer.cluster_data(method='HDBSCAN', min_cluster_size=15)
1346
- if enable_tfidf_clusters:
1347
- analyzer.get_tf_idf_clusters(top_n=3)
1348
- st.write("Naming clusters...")
1349
- clusters[column] = analyzer.merge_similar_clusters(
1350
- cluster_terms=analyzer.__dict__['cluster_terms'],
1351
- cluster_labels=analyzer.__dict__['cluster_labels'],
1352
- )
1353
- else:
1354
- # TF-IDF disabled: numeric placeholder names + raw HDBSCAN labels.
1355
- _labels = analyzer.__dict__['cluster_labels']
1356
- analyzer.cluster_terms = pd.Categorical(
1357
- [f"Cluster {cid}" for cid in np.unique(_labels) if cid != -1]
1358
- )
1359
- clusters[column] = list(_labels)
1360
  analyzers.append(analyzer)
1361
  col_names.append(column)
 
1362
 
1363
  # Run the visualization
1364
  # fig = datamapplot.create_plot(
@@ -1420,101 +670,42 @@ if st.session_state['stage'] > 0 :
1420
  st.session_state['analysis_complete'] = True
1421
 
1422
 
1423
- # Gemini Q&A over processed/analyzed data moved to the RAG search page (rag_search.py).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1424
 
1425
- # Enhanced visualization section
1426
  if 'analysis_complete' in st.session_state and st.session_state['analysis_complete']:
1427
- st.header("📊 Enhanced Interactive Visualizations")
1428
-
1429
- # Check if enhanced visualization is enabled
1430
- use_interactive_viz = st.session_state.get('use_interactive_viz', True)
1431
- show_data_profile = st.session_state.get('show_data_profile', True)
1432
- enable_performance_mode = st.session_state.get('enable_performance_mode', True)
1433
-
1434
- if use_interactive_viz and 'parsed_responses' in st.session_state:
1435
-
1436
- # Get the parsed responses for visualization
1437
- df_for_viz = st.session_state['parsed_responses']
1438
-
1439
- # Interactive visualization options
1440
- viz_tab1, viz_tab2, viz_tab3 = st.tabs(["📈 Interactive Charts", "🎯 Analysis Results", "📋 Data Explorer"])
1441
-
1442
- with viz_tab1:
1443
- st.subheader("Interactive Data Exploration")
1444
-
1445
- # Get numeric and categorical columns
1446
- numeric_cols = df_for_viz.select_dtypes(include=[np.number]).columns.tolist()
1447
- categorical_cols = df_for_viz.select_dtypes(include=['object', 'category']).columns.tolist()
1448
-
1449
- if len(numeric_cols) >= 2:
1450
- col1, col2, col3 = st.columns(3)
1451
-
1452
- with col1:
1453
- x_col = st.selectbox("X-axis", numeric_cols, key="enhanced_x")
1454
- with col2:
1455
- y_col = st.selectbox("Y-axis", [col for col in numeric_cols if col != x_col], key="enhanced_y")
1456
- with col3:
1457
- color_col = st.selectbox("Color by", ["None"] + categorical_cols, key="enhanced_color")
1458
- color_col = None if color_col == "None" else color_col
1459
-
1460
- if st.button("Generate Interactive Scatter Plot", key="enhanced_scatter"):
1461
- fig = Enhanced_Visualizer.plot_interactive_scatter(
1462
- df_for_viz, x_col, y_col, color_col=color_col,
1463
- max_points=10000 if enable_performance_mode else len(df_for_viz)
1464
- )
1465
- st.plotly_chart(fig, use_container_width=True)
1466
-
1467
- # Interactive correlation matrix
1468
- if len(numeric_cols) >= 3:
1469
- st.subheader("Correlation Analysis")
1470
- selected_cols = st.multiselect("Select columns for correlation", numeric_cols, default=numeric_cols[:8])
1471
-
1472
- if selected_cols and len(selected_cols) >= 2:
1473
- if st.button("Generate Correlation Matrix", key="enhanced_corr"):
1474
- fig = Enhanced_Visualizer.plot_correlation_matrix(df_for_viz[selected_cols])
1475
- st.plotly_chart(fig, use_container_width=True)
1476
-
1477
- with viz_tab2:
1478
- st.subheader("Analysis Results Visualization")
1479
-
1480
- # Show cluster analysis results if available
1481
- if st.session_state.get('clusters'):
1482
- for column, cluster_info in st.session_state['clusters'].items():
1483
- st.write(f"### Cluster Analysis: {column}")
1484
-
1485
- # Create interactive treemap for clusters
1486
- if len(cluster_info) > 0:
1487
- cluster_df = pd.DataFrame({'cluster': cluster_info})
1488
- fig = Enhanced_Visualizer.plot_interactive_treemap(cluster_df, 'cluster', top_n=15)
1489
- st.plotly_chart(fig, use_container_width=True)
1490
-
1491
- with viz_tab3:
1492
- st.subheader("Enhanced Data Explorer")
1493
-
1494
- # Show data profile if enabled
1495
- if show_data_profile:
1496
- with st.expander("📊 Intelligent Data Profile", expanded=False):
1497
- profile = DataProcessor.profile_data(df_for_viz)
1498
-
1499
- col1, col2, col3, col4 = st.columns(4)
1500
- with col1:
1501
- st.metric("Total Rows", f"{len(df_for_viz):,}")
1502
- with col2:
1503
- st.metric("Categorical Cols", len(profile['categorical_columns']))
1504
- with col3:
1505
- st.metric("Numeric Cols", len(profile['numeric_columns']))
1506
- with col4:
1507
- st.metric("Memory Usage", f"{profile['memory_usage'] / 1024**2:.1f} MB")
1508
-
1509
- # Enhanced filtering
1510
- st.write("### Advanced Data Filtering")
1511
- filtered_df = DataProcessor.filter_dataframe_enhanced(df_for_viz, enable_quick_filters=False, enable_advanced_filters=True)
1512
-
1513
- if len(filtered_df) != len(df_for_viz):
1514
- st.success(f"✅ Filtered data: {len(filtered_df):,} rows (from {len(df_for_viz):,})")
1515
-
1516
- # Option to re-run analysis on filtered data
1517
- if st.button("🔄 Re-run Analysis on Filtered Data"):
1518
- st.session_state['parsed_responses'] = filtered_df
1519
- st.session_state['analysis_complete'] = False
1520
- st.rerun()
 
5
  import numpy as np
6
  import matplotlib.pyplot as plt
7
  import seaborn as sns
8
+ from uap_analyzer import UAPParser, UAPAnalyzer, UAPVisualizer
9
  # import ChartGen
10
  # from ChartGen import ChartGPT
11
  from Levenshtein import distance
12
  from sklearn.model_selection import train_test_split
13
  from sklearn.metrics import confusion_matrix
14
+ from stqdm import stqdm
15
+ stqdm.pandas()
 
16
  import streamlit.components.v1 as components
17
  from dateutil import parser
18
  from sentence_transformers import SentenceTransformer
 
22
  import textwrap
23
  import datamapplot
24
 
25
+ st.set_option('deprecation.showPyplotGlobalUse', False)
 
 
 
 
 
26
 
27
  from pandas.api.types import (
28
  is_categorical_dtype,
 
37
  return pd.read_hdf(file_path, key=key)
38
 
39
 
40
+ def gemini_query(question, selected_data, gemini_key):
41
+
42
+ if question == "":
43
+ question = "Summarize the following data in relevant bullet points"
44
+
45
+ import pathlib
46
+ import textwrap
47
+
48
+ import google.generativeai as genai
49
+
50
+ from IPython.display import display
51
+ from IPython.display import Markdown
52
+
53
+
54
+ def to_markdown(text):
55
+ text = text.replace('•', ' *')
56
+ return Markdown(textwrap.indent(text, '> ', predicate=lambda _: True))
57
+
58
+ # selected_data is a list
59
+ # remove empty
60
+
61
+ filtered = [str(x) for x in selected_data if str(x) != '' and x is not None]
62
+ # make a string
63
+ context = '\n'.join(filtered)
64
+
65
+ genai.configure(api_key=gemini_key)
66
+ query_model = genai.GenerativeModel('models/gemini-1.5-pro-latest')
67
+ response = query_model.generate_content([f"{question}\n Answer based on this context: {context}\n\n"])
68
+ return(response.text)
69
 
70
  def plot_treemap(df, column, top_n=32):
71
  # Get the value counts and the top N labels
 
122
  ax.patch.set_alpha(0)
123
  return fig
124
 
125
+ def plot_hist(df, column, bins=10, kde=True):
126
+ fig, ax = plt.subplots(figsize=(12, 6))
127
+ sns.histplot(data=df, x=column, kde=True, bins=bins,color='orange')
128
+ # set the ticks and frame in orange
129
+ ax.spines['bottom'].set_color('orange')
130
+ ax.spines['top'].set_color('orange')
131
+ ax.spines['right'].set_color('orange')
132
+ ax.spines['left'].set_color('orange')
133
+ ax.xaxis.label.set_color('orange')
134
+ ax.yaxis.label.set_color('orange')
135
+ ax.tick_params(axis='x', colors='orange')
136
+ ax.tick_params(axis='y', colors='orange')
137
+ ax.title.set_color('orange')
138
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  # Set transparent background
140
  fig.patch.set_alpha(0)
141
  ax.patch.set_alpha(0)
 
 
 
 
 
 
 
 
 
 
 
142
  return fig
143
 
144
 
 
259
 
260
 
261
  def filter_dataframe(df: pd.DataFrame) -> pd.DataFrame:
 
 
 
 
 
 
262
  """
263
  Adds a UI on top of a dataframe to let viewers filter columns
264
 
 
350
  st.pyplot(plot_hist(df_, column, bins=int(round(len(df_[column].unique())-1)/2)))
351
 
352
  elif is_object_dtype(df_[column]):
 
 
353
  try:
354
+ df_[column] = pd.to_datetime(df_[column], infer_datetime_format=True, errors='coerce')
355
  except Exception:
356
  try:
357
+ df_[column] = df_[column].apply(parser.parse)
358
  except Exception:
359
  pass
360
 
361
  if is_datetime64_any_dtype(df_[column]):
362
+ df_[column] = df_[column].dt.tz_localize(None)
363
+ min_date = df_[column].min().date()
364
+ max_date = df_[column].max().date()
365
+ user_date_input = right.date_input(
366
+ f"Values for {column}",
367
+ value=(min_date, max_date),
368
+ min_value=min_date,
369
+ max_value=max_date,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
370
  )
371
+ # if len(user_date_input) == 2:
372
+ # start_date, end_date = user_date_input
373
+ # df_ = df_.loc[df_[column].dt.date.between(start_date, end_date)]
374
+ if len(user_date_input) == 2:
375
+ user_date_input = tuple(map(pd.to_datetime, user_date_input))
376
+ start_date, end_date = user_date_input
377
+
378
+ # Determine the most appropriate time unit for plot
379
+ time_units = {
380
+ 'year': df_[column].dt.year,
381
+ 'month': df_[column].dt.to_period('M'),
382
+ 'day': df_[column].dt.date
383
+ }
384
+ unique_counts = {unit: col.nunique() for unit, col in time_units.items()}
385
+ closest_to_36 = min(unique_counts, key=lambda k: abs(unique_counts[k] - 36))
386
+
387
+ # Group by the most appropriate time unit and count occurrences
388
+ grouped = df_.groupby(time_units[closest_to_36]).size().reset_index(name='count')
389
+ grouped.columns = [column, 'count']
390
+
391
+ # Create a complete date range
392
+ if closest_to_36 == 'year':
393
+ date_range = pd.date_range(start=f"{start_date.year}-01-01", end=f"{end_date.year}-12-31", freq='YS')
394
+ elif closest_to_36 == 'month':
395
+ date_range = pd.date_range(start=start_date.replace(day=1), end=end_date + pd.offsets.MonthEnd(0), freq='MS')
396
+ else: # day
397
+ date_range = pd.date_range(start=start_date, end=end_date, freq='D')
398
+
399
+ # Create a DataFrame with the complete date range
400
+ complete_range = pd.DataFrame({column: date_range})
401
+
402
+ # Convert the date column to the appropriate format based on closest_to_36
403
+ if closest_to_36 == 'year':
404
+ complete_range[column] = complete_range[column].dt.year
405
+ elif closest_to_36 == 'month':
406
+ complete_range[column] = complete_range[column].dt.to_period('M')
407
+
408
+ # Merge the complete range with the grouped data
409
+ final_data = pd.merge(complete_range, grouped, on=column, how='left').fillna(0)
410
+
411
+ with st.status(f"Date Distributions: {column}", expanded=False) as stat:
412
+ try:
413
+ st.pyplot(plot_bar(final_data, column, 'count'))
414
+ except Exception as e:
415
+ st.error(f"Error plotting bar chart: {e}")
416
+
417
+ df_ = df_.loc[df_[column].between(start_date, end_date)]
418
+
419
+ date_column = column
420
+
421
+ if date_column and filtered_columns:
422
+ numeric_columns = [col for col in filtered_columns if is_numeric_dtype(df_[col])]
423
+ if numeric_columns:
424
+ fig = plot_line(df_, date_column, numeric_columns)
425
+ #st.pyplot(fig)
426
+ # now to deal with categorical columns
427
+ categorical_columns = [col for col in filtered_columns if is_categorical_dtype(df_[col])]
428
+ if categorical_columns:
429
+ fig2 = plot_bar(df_, date_column, categorical_columns[0])
430
+ #st.pyplot(fig2)
431
+ with st.status(f"Date Distribution: {column}", expanded=False) as stat:
432
+ try:
433
+ st.pyplot(fig)
434
+ except Exception as e:
435
+ st.error(f"Error plotting line chart: {e}")
436
+ pass
437
+ try:
438
+ st.pyplot(fig2)
439
+ except Exception as e:
440
+ st.error(f"Error plotting bar chart: {e}")
441
+
442
+
443
+ else:
444
+ user_text_input = right.text_input(
445
+ f"Substring or regex in {column}",
446
+ )
447
+ if user_text_input:
448
+ df_ = df_[df_[column].astype(str).str.contains(user_text_input)]
449
  # write len of df after filtering with % of original
450
  st.write(f"{len(df_)} rows ({len(df_) / len(df) * 100:.2f}%)")
451
  return df_
 
474
  df.__dict__['string_labels'] = updated_string_labels
475
  return updated_string_labels
476
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
477
  def analyze_and_predict(data, analyzers, col_names, clusters):
478
  visualizer = UAPVisualizer()
479
  new_data = pd.DataFrame()
 
547
 
548
  my_dataset = st.file_uploader("Upload Parsed DataFrame", type=["csv", "xlsx"])
549
  if my_dataset is not None:
550
+
551
+ if parsed: # save space by cleaning default dataset
552
+ parsed = None
553
  try:
554
  if my_dataset.type == "text/csv":
555
  data = pd.read_csv(my_dataset)
 
558
  else:
559
  st.error("Unsupported file type. Please upload a CSV, Excel or HD5 file.")
560
  st.stop()
561
+ parser = filter_dataframe(data)
562
+ st.session_state['parsed_responses'] = parser
563
+ st.dataframe(parser)
564
  st.success(f"Successfully loaded and displayed data from {my_dataset.name}")
565
  except Exception as e:
566
  st.error(f"An error occurred while reading the file: {e}")
 
569
  parsed_responses = filter_dataframe(parsed)
570
  st.session_state['parsed_responses'] = parsed_responses
571
  st.dataframe(parsed_responses)
572
+ col1, col2 = st.columns(2)
573
+ with col1:
574
+ col_parsed = st.selectbox("Which column do you want to query?", st.session_state['parsed_responses'].columns)
575
+ with col2:
576
+ GEMINI_KEY = st.text_input('Gemini API Key', value=GEMINI_KEY, type='password', help="Enter your Gemini API key")
577
+
578
+ if col_parsed and GEMINI_KEY:
579
+ selected_column_data = st.session_state['parsed_responses'][col_parsed].tolist()
580
+ question = st.text_input("Ask a question or leave empty for summarization")
581
+ if st.button("Generate Query") and selected_column_data:
582
+ st.write(gemini_query(question, selected_column_data, GEMINI_KEY))
583
  st.session_state['stage'] = 1
584
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
585
 
586
  if st.session_state['stage'] > 0 :
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
587
  with st.form(border=True, key='Select Columns for Analysis'):
588
  columns_to_analyze = st.multiselect(
589
  label='Select columns to analyze',
590
+ options=st.session_state['parsed_responses'].columns
 
591
  )
592
+ if st.form_submit_button("Process Data"):
593
  if columns_to_analyze:
594
  analyzers = []
595
  col_names = []
 
604
  analyzer.reduce_dimensionality(method='UMAP', n_components=2, n_neighbors=15, min_dist=0.1)
605
  st.write("Clustering data...")
606
  analyzer.cluster_data(method='HDBSCAN', min_cluster_size=15)
607
+ analyzer.get_tf_idf_clusters(top_n=3)
608
+ st.write("Naming clusters...")
 
 
 
 
 
 
 
 
 
 
 
 
609
  analyzers.append(analyzer)
610
  col_names.append(column)
611
+ clusters[column] = analyzer.merge_similar_clusters(cluster_terms=analyzer.__dict__['cluster_terms'], cluster_labels=analyzer.__dict__['cluster_labels'])
612
 
613
  # Run the visualization
614
  # fig = datamapplot.create_plot(
 
670
  st.session_state['analysis_complete'] = True
671
 
672
 
673
+ # this will check if the dataframe is not empty
674
+ # if st.session_state['new_data'] is not None:
675
+ # parsed2 = st.session_state.get('dataset', pd.DataFrame())
676
+ # parsed2 = filter_dataframe(parsed2)
677
+ # col1, col2 = st.columns(2)
678
+ # st.dataframe(parsed2)
679
+ # with col1:
680
+ # col_parsed2 = st.selectbox("Which columns do you want to query?", parsed2.columns)
681
+ # with col2:
682
+ # GEMINI_KEY = st.text_input('Gemini APIs Key', GEMINI_KEY, type='password', help="Enter your Gemini API key")
683
+ # if col_parsed and GEMINI_KEY:
684
+ # selected_column_data2 = parsed2[col_parsed2].tolist()
685
+ # question2 = st.text_input("Ask a questions or leave empty for summarization")
686
+ # if st.button("Generate Query") and selected_column_data2:
687
+ # with st.status(f"Generating Query", expanded=True) as status:
688
+ # gemini_answer = gemini_query(question2, selected_column_data2, GEMINI_KEY)
689
+ # st.write(gemini_answer)
690
+ # st.session_state['gemini_answer'] = gemini_answer
691
 
 
692
  if 'analysis_complete' in st.session_state and st.session_state['analysis_complete']:
693
+ ticked_analysis = st.checkbox('Query Processed Data')
694
+ if ticked_analysis:
695
+ if st.session_state['new_data'] is not None:
696
+ parsed2 = st.session_state.get('dataset', pd.DataFrame()).copy()
697
+ parsed2 = filter_dataframe(parsed2)
698
+ col1, col2 = st.columns(2)
699
+ st.dataframe(parsed2)
700
+ with col1:
701
+ col_parsed2 = st.selectbox("Which columns do you want to query?", parsed2.columns)
702
+ with col2:
703
+ GEMINI_KEY = st.text_input('Gemini APIs Key', value=GEMINI_KEY, type='password', help="Enter your Gemini API key")
704
+ if col_parsed2 and GEMINI_KEY:
705
+ selected_column_data2 = parsed2[col_parsed2].tolist()
706
+ question2 = st.text_input("Ask a questions or leave empty for summarization")
707
+ if st.button("Generate Queries") and selected_column_data2:
708
+ with st.status(f"Generating Query", expanded=True) as status:
709
+ gemini_answer = gemini_query(question2, selected_column_data2, GEMINI_KEY)
710
+ st.write(gemini_answer)
711
+ st.session_state['gemini_answer'] = gemini_answer
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
api/main.py DELETED
@@ -1,1155 +0,0 @@
1
- import sys
2
- import os
3
- import io
4
- import time
5
- import logging
6
- import traceback
7
- from datetime import datetime, timezone
8
- import uuid
9
-
10
- import numpy as np
11
- import pandas as pd
12
- from fastapi import FastAPI, UploadFile, File, HTTPException, Query, Header, Depends
13
- from fastapi.middleware.cors import CORSMiddleware
14
- from pydantic import BaseModel
15
-
16
- # Add parent directory to path for importing uap_analyzer
17
- sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
18
-
19
- # Suppress expected outputs to only have clean API routes
20
- logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
21
- logger = logging.getLogger(__name__)
22
-
23
- app = FastAPI(title="UAP Analysis API", version="1.0.0")
24
-
25
- # CORS Improvement (Codex #6): Explicit origin allowlist, credentials disabled unless required
26
- origins = os.getenv("UAP_API_CORS_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173")
27
- allow_origins = [origin.strip() for origin in origins.split(",") if origin.strip()]
28
-
29
- app.add_middleware(
30
- CORSMiddleware,
31
- allow_origins=allow_origins,
32
- allow_credentials=False,
33
- allow_methods=["*"],
34
- allow_headers=["*"],
35
- )
36
-
37
- # ---------------------------------------------------------------------------
38
- # State Management (Codex #1): Session isolation
39
- # ---------------------------------------------------------------------------
40
- class SessionState:
41
- def __init__(self):
42
- self.dataset = None
43
- self.filtered_data = None
44
- self.analyzers = []
45
- self.col_names = []
46
- self.new_data = None
47
- self.data_processed = False
48
- self.analysis_results = {}
49
- self.cluster_viz = {}
50
- self.cramers_v = None
51
- self.analysis_runs = 0
52
- self.last_analysis_at = None
53
- self.analysis_mode = os.getenv("UAP_ANALYSIS_MODE", "production") # demo vs production
54
- # Parsing / SCU state (mirrors the Streamlit parsing.py session keys)
55
- self.parse_source_df = None # raw uploaded reports awaiting extraction
56
- self.parsed_responses = None # {description: parsed JSON dict}
57
- self.parsed_df = None # flat parsed-responses DataFrame
58
- self.scu_normalized_df = None # scu_normalizer.normalize() output
59
- self.last_accessed = time.time()
60
-
61
- sessions = {}
62
- SESSION_TTL = 3600 * 24 # 24 hours
63
-
64
- def get_session(session_id: str = Header(None, alias="X-Session-ID")) -> SessionState:
65
- if not session_id:
66
- session_id = "default"
67
-
68
- # Cleanup expired sessions
69
- current_time = time.time()
70
- expired = [k for k, v in sessions.items() if current_time - v.last_accessed > SESSION_TTL]
71
- for k in expired:
72
- del sessions[k]
73
-
74
- if session_id not in sessions:
75
- sessions[session_id] = SessionState()
76
-
77
- sessions[session_id].last_accessed = current_time
78
- return sessions[session_id]
79
-
80
- # Dataset paths
81
- DATA_PATH_WEST = os.path.join(os.path.dirname(__file__), "..", "parsed_files_distance_embeds.h5")
82
- DATA_PATH_EAST = os.path.join(os.path.dirname(__file__), "..", "final_ufoseti_dataset.h5")
83
- # Default for backward compatibility
84
- DATA_PATH = DATA_PATH_WEST
85
- MAX_QUERY_CONTEXT_ROWS = int(os.getenv("UAP_QUERY_MAX_ROWS", "250"))
86
- MAX_QUERY_CONTEXT_CHARS = int(os.getenv("UAP_QUERY_MAX_CHARS", "24000"))
87
-
88
- # ---------------------------------------------------------------------------
89
- # Pydantic models
90
- # ---------------------------------------------------------------------------
91
- class FilterSpec(BaseModel):
92
- column: str
93
- type: str # "categorical", "numeric", "text"
94
- values: list = None
95
- min_val: float = None
96
- max_val: float = None
97
- pattern: str = None
98
-
99
-
100
- class AnalysisRequest(BaseModel):
101
- columns: list[str]
102
- # Cluster pipeline tuning (mirrors analyzing.py controls). When enable_tfidf
103
- # is False (the analyzing.py default), clusters keep numeric "Cluster N"
104
- # names and raw HDBSCAN labels are passed straight to XGBoost.
105
- enable_tfidf: bool = False
106
- min_cluster_size: int = 15
107
- n_neighbors: int = 15
108
- min_dist: float = 0.1
109
- top_n: int = 32
110
-
111
-
112
- class QueryRequest(BaseModel):
113
- question: str
114
- columns: list[str]
115
- gemini_key: str
116
-
117
-
118
- # ── Parsing ────────────────────────────────────────────────────────────────
119
- class SchemaMergeRequest(BaseModel):
120
- labels: list[str]
121
- custom_fields: dict | None = None
122
-
123
-
124
- class SchemaCoverageRequest(BaseModel):
125
- labels: list[str]
126
- custom_fields: dict | None = None
127
- # Dataset columns to diff against; defaults to the uploaded parse source.
128
- columns: list[str] | None = None
129
-
130
-
131
- class ParseEstimateRequest(BaseModel):
132
- columns: list[str]
133
- format_json: str # merged schema template (JSON string)
134
- model: str = "gpt-4o-mini"
135
- use_cache: bool = True
136
- use_batch: bool = False
137
-
138
-
139
- class ParseRunRequest(BaseModel):
140
- columns: list[str]
141
- format_json: str # merged schema template (JSON string)
142
- provider: str = "openai" # "openai" | "deepseek"
143
- model: str = "gpt-4o-mini"
144
- api_key: str
145
- max_workers: int = 10
146
- keep_columns: list[str] = [] # carry-through source columns
147
-
148
-
149
- # ── SCU ────────────────────────────────────────────────────────────────────
150
- class ScuFilterRequest(BaseModel):
151
- criterion_keys: list[str]
152
-
153
-
154
- # ── RAG ────────────────────────────────────────────────────────────────────
155
- class RagSearchRequest(BaseModel):
156
- columns: list[str]
157
- question: str
158
- cohere_key: str
159
- top_n: int = 50
160
-
161
-
162
- # ── Cramér's V explorer ────────────────────────────────────────────────────
163
- class CramersVRequest(BaseModel):
164
- columns: list[str] | None = None
165
- drop_missing: bool = False
166
- exclude_trivial: bool = True
167
- strong_threshold: float = 0.30
168
- high_threshold: int = 30
169
- source: str = "dataset" # "dataset" | "parsed"
170
-
171
-
172
- class ContingencyRequest(BaseModel):
173
- col1: str
174
- col2: str
175
- drop_missing: bool = False
176
- source: str = "dataset"
177
-
178
-
179
- class ColumnGroupsRequest(BaseModel):
180
- source: str = "dataset" # "dataset" | "parsed"
181
- high_threshold: int = 30
182
-
183
-
184
- class XgboostRequest(BaseModel):
185
- columns: list[str]
186
- source: str = "dataset" # "dataset" | "parsed"
187
-
188
- # ---------------------------------------------------------------------------
189
- # Helpers
190
- # ---------------------------------------------------------------------------
191
- def df_to_json(df: pd.DataFrame, max_rows: int = 50000, total_rows: int | None = None) -> dict:
192
- df_subset = df.head(max_rows).copy()
193
- for col in df_subset.columns:
194
- # Convert categories and datetimes to strings
195
- if df_subset[col].dtype.name == "category" or pd.api.types.is_datetime64_any_dtype(df_subset[col]):
196
- df_subset[col] = df_subset[col].astype(str)
197
- else:
198
- # For object columns, we need to be careful with fillna if they contain dicts
199
- # We use a custom fillna for objects
200
- if df_subset[col].dtype == object:
201
- # Fill missing values without triggering hash checks on the content
202
- mask = df_subset[col].isna()
203
- df_subset.loc[mask, col] = ""
204
- else:
205
- df_subset[col] = df_subset[col].fillna("")
206
- return {
207
- "columns": list(df_subset.columns),
208
- "rows": df_subset.to_dict(orient="records"),
209
- # total_rows reflects the full dataset; pass it explicitly when df has
210
- # already been truncated upstream so the count is not misreported.
211
- "total_rows": total_rows if total_rows is not None else len(df),
212
- "returned_rows": len(df_subset),
213
- }
214
-
215
- def get_column_stats(df: pd.DataFrame) -> list[dict]:
216
- stats = []
217
- for col in df.columns:
218
- # Standard info
219
- info = {
220
- "name": col,
221
- "dtype": str(df[col].dtype),
222
- "non_null": int(df[col].notna().sum())
223
- }
224
-
225
- # Safe nunique
226
- try:
227
- info["unique"] = int(df[col].nunique())
228
- except (TypeError, ValueError):
229
- # For unhashable types (dicts/lists), we count unique by converting to string first
230
- try:
231
- info["unique"] = int(df[col].astype(str).nunique())
232
- except Exception:
233
- info["unique"] = 0
234
-
235
- # Numerical stats
236
- if pd.api.types.is_numeric_dtype(df[col]):
237
- try:
238
- if df[col].notna().any():
239
- info["min"] = float(df[col].min())
240
- info["max"] = float(df[col].max())
241
- info["mean"] = float(df[col].mean())
242
- else:
243
- info["min"] = info["max"] = info["mean"] = None
244
- except Exception:
245
- pass
246
- # Categorical / Object stats
247
- elif pd.api.types.is_object_dtype(df[col]) or df[col].dtype.name == "category":
248
- try:
249
- top = df[col].value_counts().head(10)
250
- info["top_values"] = [{"value": str(k), "count": int(v)} for k, v in top.items()]
251
- except (TypeError, ValueError):
252
- # Handle unhashable types in value_counts by converting to string
253
- try:
254
- top = df[col].astype(str).value_counts().head(10)
255
- info["top_values"] = [{"value": str(k), "count": int(v)} for k, v in top.items()]
256
- except Exception:
257
- info["top_values"] = []
258
- except Exception:
259
- info["top_values"] = []
260
-
261
- stats.append(info)
262
- return stats
263
-
264
- def truncate_context(items: list[str], max_chars: int) -> tuple[str, int]:
265
- context_parts = []
266
- used = 0
267
- chars = 0
268
- for item in items:
269
- to_add = item if not context_parts else f"\\n{item}"
270
- if chars + len(to_add) > max_chars:
271
- break
272
- context_parts.append(item)
273
- chars += len(to_add)
274
- used += 1
275
- return "\\n".join(context_parts), used
276
-
277
- # ---------------------------------------------------------------------------
278
- # Data endpoints
279
- # ---------------------------------------------------------------------------
280
- @app.get("/api/data/load")
281
- def load_data(
282
- rows: int = Query(default=15000, le=50000),
283
- type: str = Query(default="west"),
284
- state: SessionState = Depends(get_session)
285
- ):
286
- path = DATA_PATH_EAST if type == "east" else DATA_PATH_WEST
287
-
288
- if not os.path.exists(path):
289
- raise HTTPException(status_code=404, detail=f"Dataset file not found: {os.path.basename(path)}")
290
- try:
291
- df = pd.read_hdf(path, key="df")
292
- if "embeddings" in df.columns:
293
- df = df.drop(columns=["embeddings"])
294
- full_row_count = len(df)
295
- df = df.head(rows)
296
- state.dataset = df
297
- state.filtered_data = df
298
- state.data_processed = False
299
- state.col_names = []
300
- return {
301
- "status": "ok",
302
- "data": df_to_json(df, total_rows=full_row_count),
303
- "column_stats": get_column_stats(df),
304
- }
305
- except Exception as e:
306
- logger.error(traceback.format_exc())
307
- raise HTTPException(status_code=500, detail=f"Error loading {type} dataset: {e}")
308
-
309
- @app.get("/api/analysis/clusters")
310
- def get_clusters_viz():
311
- from fastapi.responses import FileResponse
312
- path = os.path.join(os.path.dirname(__file__), "..", "frontend", "uap_clusters_llm.html")
313
- if not os.path.exists(path):
314
- # Fallback to current dir or project root
315
- path = os.path.join(os.path.dirname(__file__), "..", "uap_clusters_llm.html")
316
-
317
- if not os.path.exists(path):
318
- raise HTTPException(status_code=404, detail="Cluster visualization not found")
319
- return FileResponse(path)
320
-
321
- @app.post("/api/data/upload")
322
- async def upload_data(file: UploadFile = File(...), state: SessionState = Depends(get_session)):
323
- try:
324
- contents = await file.read()
325
- name = (file.filename or "").lower()
326
- if name.endswith(".csv"):
327
- df = pd.read_csv(io.BytesIO(contents))
328
- elif name.endswith((".xlsx", ".xls")):
329
- df = pd.read_excel(io.BytesIO(contents))
330
- elif name.endswith(".json"):
331
- import json as _json
332
- obj = _json.loads(contents.decode("utf-8"))
333
- df = pd.json_normalize(obj if isinstance(obj, list) else [obj])
334
- else:
335
- raise HTTPException(status_code=400, detail="Unsupported file type (CSV / XLSX / JSON).")
336
- state.dataset = df
337
- state.filtered_data = df
338
- state.data_processed = False
339
- return {"status": "ok", "filename": file.filename, "data": df_to_json(df), "column_stats": get_column_stats(df)}
340
- except HTTPException:
341
- raise
342
- except Exception as e:
343
- raise HTTPException(status_code=500, detail=str(e))
344
-
345
- @app.post("/api/data/filter")
346
- def filter_data(filters: list[FilterSpec], state: SessionState = Depends(get_session)):
347
- if state.dataset is None:
348
- raise HTTPException(status_code=400, detail="No dataset loaded")
349
- df = state.dataset.copy()
350
- try:
351
- for f in filters:
352
- if f.column not in df.columns:
353
- continue
354
- if f.type == "categorical" and f.values:
355
- df = df[df[f.column].astype(str).isin([str(v) for v in f.values])]
356
- elif f.type == "numeric":
357
- if f.min_val is not None and f.max_val is not None:
358
- if f.min_val > f.max_val:
359
- raise ValueError(f"Invalid range: min {f.min_val} > max {f.max_val}")
360
- df = df[df[f.column].between(f.min_val, f.max_val)]
361
- elif f.min_val is not None:
362
- df = df[df[f.column] >= f.min_val]
363
- elif f.max_val is not None:
364
- df = df[df[f.column] <= f.max_val]
365
- elif f.type == "text" and f.pattern:
366
- import re
367
- df = df[df[f.column].astype(str).str.contains(re.escape(f.pattern), case=False, na=False)]
368
- state.filtered_data = df
369
- return {"status": "ok", "data": df_to_json(df), "column_stats": get_column_stats(df)}
370
- except Exception as e:
371
- raise HTTPException(status_code=500, detail=f"Filtering failed: {e}")
372
-
373
- @app.get("/api/data/columns")
374
- def get_columns(state: SessionState = Depends(get_session)):
375
- if state.dataset is None:
376
- raise HTTPException(status_code=400, detail="No dataset loaded")
377
- df = state.dataset
378
- columns = []
379
- for col in df.columns:
380
- columns.append({
381
- "name": col, "dtype": str(df[col].dtype),
382
- "unique": int(df[col].nunique()), "non_null": int(df[col].notna().sum()),
383
- })
384
- return {"columns": columns}
385
-
386
- @app.get("/api/data/column-values")
387
- def get_column_values(
388
- column: str = Query(...),
389
- search: str = Query(default=""),
390
- limit: int = Query(default=50, le=500),
391
- state: SessionState = Depends(get_session),
392
- ):
393
- """Return unique values of a column, optionally filtered by a search term.
394
-
395
- Backs the searchable categorical filter so any value can be selected,
396
- not just the precomputed top-N from column statistics.
397
- """
398
- if state.dataset is None:
399
- raise HTTPException(status_code=400, detail="No dataset loaded")
400
- if column not in state.dataset.columns:
401
- raise HTTPException(status_code=404, detail=f"Column '{column}' not found")
402
-
403
- import re
404
- series = state.dataset[column].astype(str)
405
- counts = series.value_counts()
406
- if search:
407
- mask = counts.index.str.contains(re.escape(search), case=False, na=False)
408
- counts = counts[mask]
409
- total_matches = int(len(counts))
410
- counts = counts.head(limit)
411
- return {
412
- "column": column,
413
- "values": [{"value": str(k), "count": int(v)} for k, v in counts.items()],
414
- "total_matches": total_matches,
415
- }
416
-
417
- @app.post("/api/analyze/run")
418
- def run_analysis(req: AnalysisRequest, state: SessionState = Depends(get_session)):
419
- if state.filtered_data is None or state.filtered_data.empty:
420
- raise HTTPException(status_code=400, detail="No data available")
421
-
422
- df = state.filtered_data
423
- valid_columns = [c for c in req.columns if c in df.columns]
424
- if not valid_columns:
425
- raise HTTPException(status_code=400, detail="No valid columns selected")
426
-
427
- results, cluster_viz, xgboost_results = {}, {}, {}
428
- state.analyzers = []
429
- new_data = pd.DataFrame()
430
-
431
- if state.analysis_mode == "production":
432
- # Codex #7: Run real embedding, UMAP, HDBSCAN cluster pipeline instead of generic mock
433
- from sklearn.model_selection import train_test_split
434
- from uap_analyzer import UAPAnalyzer, train_xgboost
435
-
436
- for col in valid_columns:
437
- analyzer = UAPAnalyzer(df, column=col)
438
- try:
439
- analyzer.preprocess_data(top_n=req.top_n)
440
- analyzer.reduce_dimensionality(
441
- method="UMAP", n_components=2,
442
- n_neighbors=req.n_neighbors, min_dist=req.min_dist,
443
- )
444
- analyzer.cluster_data(method="HDBSCAN", min_cluster_size=req.min_cluster_size)
445
- _labels = analyzer.__dict__["cluster_labels"]
446
- row_terms = None
447
- if req.enable_tfidf:
448
- # TF-IDF naming + near-duplicate cluster merging. The merge
449
- # path re-encodes on GPU and is best-effort; fall back to
450
- # numeric labels if anything in it fails.
451
- try:
452
- analyzer.get_tf_idf_clusters(top_n=3)
453
- row_terms = analyzer.merge_similar_clusters(
454
- cluster_terms=analyzer.__dict__["cluster_terms"],
455
- cluster_labels=analyzer.__dict__["cluster_labels"],
456
- )
457
- except Exception as merge_err:
458
- logger.warning(f"TF-IDF naming failed for {col}, using raw labels: {merge_err}")
459
- row_terms = None
460
- if row_terms is None:
461
- # Numeric placeholder names; raw HDBSCAN labels flow to XGBoost.
462
- row_terms = [f"Cluster {cid}" for cid in _labels]
463
- # cluster_terms is per-row here (length == len(df)) so the
464
- # cluster_viz masks below index reduced_embeddings correctly.
465
- analyzer.cluster_terms = row_terms
466
-
467
- new_data[f"Analyzer_{col}"] = analyzer.cluster_terms
468
- state.analyzers.append(analyzer)
469
-
470
- traces = []
471
- unique_terms = np.unique(analyzer.cluster_terms)
472
- for term in unique_terms:
473
- mask = (np.array(analyzer.cluster_terms) == term)
474
- if not mask.any(): continue
475
- traces.append({
476
- "name": term,
477
- "x": analyzer.reduced_embeddings[mask, 0].tolist(),
478
- "y": analyzer.reduced_embeddings[mask, 1].tolist(),
479
- "text": df[col].iloc[mask].astype(str).tolist(),
480
- "count": int(mask.sum()),
481
- })
482
- cluster_viz[col] = {"traces": traces, "title": f"{col} Clusters (HDBSCAN)"}
483
- _dist = pd.Series(analyzer.cluster_terms).astype(str).value_counts().head(20)
484
- results[col] = {
485
- "cluster_count": len(unique_terms),
486
- "total_points": len(df),
487
- "distribution": [{"label": str(k), "count": int(v)} for k, v in _dist.items()],
488
- }
489
- except Exception as e:
490
- logger.error(f"Error analyzing {col}: {e}")
491
-
492
- if len(state.analyzers) >= 2:
493
- new_data_cat = new_data.fillna('null').astype('category')
494
- data_nums = new_data_cat.apply(lambda x: x.cat.codes)
495
- for col in data_nums.columns:
496
- try:
497
- categories = new_data_cat[col].cat.categories
498
- x_train, x_test, y_train, y_test = train_test_split(data_nums.drop(columns=[col]), data_nums[col], test_size=0.2, random_state=42)
499
- bst, accuracy, preds = train_xgboost(x_train, y_train, x_test, y_test, len(categories))
500
-
501
- importances_dict = bst.get_score(importance_type="gain")
502
- importances = {k.replace("Analyzer_", ""): float(v) for k, v in importances_dict.items()}
503
- importances = dict(sorted(importances.items(), key=lambda x: x[1], reverse=True))
504
- xgboost_results[col.replace("Analyzer_", "")] = {
505
- "feature_importance": importances,
506
- "accuracy": round(accuracy, 3),
507
- }
508
- except Exception as e:
509
- logger.error(f"Error in xgboost for {col}: {e}")
510
-
511
- else:
512
- # Mock mode
513
- for col in valid_columns:
514
- col_data = df[col].fillna("").astype(str)
515
- vc = col_data.value_counts()
516
- top_labels = vc.head(32).index.tolist()
517
- cmap = {l: i for i, l in enumerate(top_labels)}
518
- clabels = col_data.apply(lambda x: cmap.get(x, -1)).values
519
-
520
- n = len(col_data)
521
- np.random.seed(42)
522
- red_embeds = np.random.randn(n, 2)
523
-
524
- cluster_terms = []
525
- for i, label in enumerate(top_labels[:20]):
526
- mask = col_data == label
527
- red_embeds[mask.values] = (np.random.randn(2) * 3) + np.random.randn(mask.sum(), 2) * 0.5
528
-
529
- new_data[f"Analyzer_{col}"] = [top_labels[c] if 0 <= c < len(top_labels) else "Other" for c in clabels]
530
-
531
- traces = []
532
- for i, term in enumerate(top_labels[:20]):
533
- mask = (clabels == i)
534
- if mask.sum() == 0: continue
535
- traces.append({
536
- "name": term,
537
- "x": red_embeds[mask, 0].tolist(),
538
- "y": red_embeds[mask, 1].tolist(),
539
- "text": col_data[mask].tolist(),
540
- "count": int(mask.sum()),
541
- })
542
- cluster_viz[col] = {"traces": traces, "title": f"{col} Clusters (Mock)"}
543
- results[col] = {"cluster_count": len(top_labels), "distribution": [{"label": str(k), "count": int(v)} for k, v in vc.head(20).items()], "total_points": n}
544
-
545
- cramers_data = None
546
- if len(valid_columns) >= 2:
547
- new_data_cat = new_data.fillna("null").astype("category")
548
- cols = list(new_data_cat.columns)
549
- from scipy.stats import chi2_contingency
550
- matrix = []
551
- for c1 in cols:
552
- row = []
553
- for c2 in cols:
554
- if c1 == c2:
555
- row.append(1.0)
556
- else:
557
- try:
558
- ct = pd.crosstab(new_data_cat[c1], new_data_cat[c2])
559
- chi2 = chi2_contingency(ct)[0]
560
- n_obs = ct.sum().sum()
561
- phi2 = chi2 / n_obs
562
- r, k = ct.shape
563
- phi2corr = max(0, phi2 - ((k-1)*(r-1))/(n_obs-1))
564
- rcorr, kcorr = r - ((r-1)**2)/(n_obs-1), k - ((k-1)**2)/(n_obs-1)
565
- denom = min(kcorr-1, rcorr-1)
566
- v = np.sqrt(phi2corr / denom) if denom > 0 else 0.0
567
- row.append(round(float(v), 3))
568
- except Exception: row.append(0.0)
569
- matrix.append(row)
570
- cramers_data = {"labels": [c.replace("Analyzer_", "") for c in cols], "matrix": matrix}
571
-
572
- state.new_data = new_data
573
- state.data_processed = True
574
- state.analysis_results = results
575
- state.cluster_viz = cluster_viz
576
- state.cramers_v = cramers_data
577
- state.col_names = valid_columns
578
- state.analysis_runs += 1
579
- state.last_analysis_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
580
-
581
- return {
582
- "status": "ok",
583
- "analysis_mode": state.analysis_mode,
584
- "mock_mode": state.analysis_mode == "mock",
585
- "warnings": ["Results generated by mock pipeline for demo purposes."] if state.analysis_mode == "mock" else [],
586
- "results": results,
587
- "cluster_viz": cluster_viz,
588
- "cramers_v": cramers_data,
589
- "xgboost": xgboost_results if xgboost_results else {},
590
- "processed_data": df_to_json(new_data),
591
- }
592
-
593
- @app.get("/api/analyze/results")
594
- def get_analysis_results(state: SessionState = Depends(get_session)):
595
- if not state.data_processed:
596
- raise HTTPException(status_code=400, detail="No analysis run yet")
597
- return {"results": state.analysis_results, "cluster_viz": state.cluster_viz, "cramers_v": state.cramers_v}
598
-
599
- @app.post("/api/query/gemini")
600
- def query_gemini(req: QueryRequest, state: SessionState = Depends(get_session)):
601
- if state.filtered_data is None:
602
- raise HTTPException(status_code=400, detail="No data")
603
- valid_cols = [c for c in req.columns if c in state.filtered_data.columns]
604
- if not valid_cols:
605
- raise HTTPException(status_code=400, detail="No valid columns selected")
606
- try:
607
- import google.generativeai as genai
608
- # Combine the selected columns per row with " - " (mirrors rag_search.py
609
- # and the parsing _build_text_series), then drop empty rows.
610
- text_series = _build_text_series(state.filtered_data, valid_cols)
611
- filtered = [t for t in text_series.dropna().tolist() if t.strip()]
612
- context_seed = filtered[:MAX_QUERY_CONTEXT_ROWS]
613
- # Token aware chunking
614
- context, rows_used = truncate_context(context_seed, MAX_QUERY_CONTEXT_CHARS)
615
- if not context:
616
- raise HTTPException(status_code=400, detail="No text available for querying")
617
-
618
- genai.configure(api_key=req.gemini_key)
619
- model = genai.GenerativeModel("models/gemini-3.1-pro-preview")
620
- response = model.generate_content([f"{req.question or 'Summarize'}\\nContext: {context}\\n\\n"])
621
- return {"status": "ok", "response": response.text,
622
- "context_rows_used": rows_used, "columns_used": valid_cols}
623
- except HTTPException:
624
- raise
625
- except Exception as e:
626
- raise HTTPException(status_code=500, detail=str(e))
627
-
628
- @app.get("/api/dashboard/summary")
629
- def get_dashboard_summary(state: SessionState = Depends(get_session)):
630
- if state.dataset is None:
631
- return {"loaded": False, "analysis_runs": state.analysis_runs, "last_analysis_at": state.last_analysis_at}
632
- df = state.dataset
633
- return {
634
- "loaded": True,
635
- "total_rows": len(df),
636
- "total_columns": len(df.columns),
637
- "columns": list(df.columns),
638
- "analyzed": state.data_processed,
639
- "analyzed_columns": len(state.col_names),
640
- "analysis_runs": state.analysis_runs,
641
- "last_analysis_at": state.last_analysis_at,
642
- "analysis_mode": state.analysis_mode,
643
- "null_counts": {col: int(df[col].isna().sum()) for col in df.columns}
644
- }
645
-
646
- # ---------------------------------------------------------------------------
647
- # Parsing — LLM feature extraction (parsing.py parity, core tier)
648
- # ---------------------------------------------------------------------------
649
- from api.services import parsing_service, scu_service, rag_service, analysis_service
650
-
651
-
652
- def _build_text_series(df: pd.DataFrame, columns: list[str]) -> pd.Series:
653
- """Concatenate the selected raw-text columns per row with ' - ' (mirrors
654
- parsing.py's _build_text), dropping empty cells."""
655
- def _row(row):
656
- parts = [
657
- str(row[c]).strip() for c in columns
658
- if c in row and pd.notna(row[c]) and str(row[c]).strip()
659
- ]
660
- return " - ".join(parts) if parts else None
661
- return df.apply(_row, axis=1)
662
-
663
-
664
- @app.get("/api/parse/schemas")
665
- def parse_schemas():
666
- try:
667
- return {**parsing_service.list_schemas(), "models": parsing_service.available_models()}
668
- except Exception as e:
669
- logger.error(traceback.format_exc())
670
- raise HTTPException(status_code=500, detail=f"Could not load schemas: {e}")
671
-
672
-
673
- @app.post("/api/parse/schema-merge")
674
- def parse_schema_merge(req: SchemaMergeRequest):
675
- try:
676
- return parsing_service.merge_schema(req.labels, req.custom_fields)
677
- except ValueError as e:
678
- raise HTTPException(status_code=400, detail=str(e))
679
- except Exception as e:
680
- raise HTTPException(status_code=500, detail=str(e))
681
-
682
-
683
- @app.post("/api/parse/schema-coverage")
684
- def parse_schema_coverage(req: SchemaCoverageRequest, state: SessionState = Depends(get_session)):
685
- """Diff the merged schema's leaf fields against the dataset columns (🟢/🔴
686
- coverage) and return per-mode extraction schemas (all / missing / database)."""
687
- cols = req.columns
688
- if cols is None:
689
- if state.parse_source_df is None:
690
- raise HTTPException(
691
- status_code=400,
692
- detail="No raw dataset uploaded and no columns provided.",
693
- )
694
- cols = list(state.parse_source_df.columns)
695
- try:
696
- return parsing_service.schema_coverage_report(req.labels, cols, req.custom_fields)
697
- except ValueError as e:
698
- raise HTTPException(status_code=400, detail=str(e))
699
- except Exception as e:
700
- logger.error(traceback.format_exc())
701
- raise HTTPException(status_code=500, detail=f"Coverage diff failed: {e}")
702
-
703
-
704
- @app.post("/api/parse/upload")
705
- async def parse_upload(file: UploadFile = File(...), state: SessionState = Depends(get_session)):
706
- """Upload a raw report dataset to be fed through the LLM extractor."""
707
- try:
708
- contents = await file.read()
709
- name = (file.filename or "").lower()
710
- if name.endswith(".csv"):
711
- df = pd.read_csv(io.BytesIO(contents))
712
- elif name.endswith((".xlsx", ".xls")):
713
- df = pd.read_excel(io.BytesIO(contents))
714
- elif name.endswith(".json"):
715
- import json as _json
716
- raw = contents.decode("utf-8")
717
- obj = _json.loads(raw)
718
- df = pd.json_normalize(obj if isinstance(obj, list) else [obj])
719
- else:
720
- raise HTTPException(status_code=400, detail="Unsupported file type (CSV / XLSX / JSON).")
721
- state.parse_source_df = df
722
- return {
723
- "status": "ok",
724
- "filename": file.filename,
725
- "data": df_to_json(df, max_rows=200),
726
- "columns": list(df.columns),
727
- "total_rows": len(df),
728
- }
729
- except HTTPException:
730
- raise
731
- except Exception as e:
732
- raise HTTPException(status_code=500, detail=str(e))
733
-
734
-
735
- @app.post("/api/parse/estimate")
736
- def parse_estimate(req: ParseEstimateRequest, state: SessionState = Depends(get_session)):
737
- if state.parse_source_df is None:
738
- raise HTTPException(status_code=400, detail="No raw dataset uploaded. Upload reports first.")
739
- valid = [c for c in req.columns if c in state.parse_source_df.columns]
740
- if not valid:
741
- raise HTTPException(status_code=400, detail="No valid text columns selected.")
742
- texts = _build_text_series(state.parse_source_df, valid).dropna().tolist()
743
- if not texts:
744
- raise HTTPException(status_code=400, detail="Selected columns produced no text.")
745
- try:
746
- return parsing_service.estimate(texts, req.format_json, req.model,
747
- use_cache=req.use_cache, use_batch=req.use_batch)
748
- except Exception as e:
749
- raise HTTPException(status_code=400, detail=f"Could not estimate cost: {e}")
750
-
751
-
752
- @app.post("/api/parse/run")
753
- def parse_run(req: ParseRunRequest, state: SessionState = Depends(get_session)):
754
- if state.parse_source_df is None:
755
- raise HTTPException(status_code=400, detail="No raw dataset uploaded. Upload reports first.")
756
- if not req.api_key:
757
- raise HTTPException(status_code=400, detail="An API key is required to run extraction.")
758
- src = state.parse_source_df
759
- valid = [c for c in req.columns if c in src.columns]
760
- if not valid:
761
- raise HTTPException(status_code=400, detail="No valid text columns selected.")
762
-
763
- text_series = _build_text_series(src, valid)
764
- texts = text_series.dropna().tolist()
765
- if not texts:
766
- raise HTTPException(status_code=400, detail="Selected columns produced no text to parse.")
767
-
768
- try:
769
- result = parsing_service.run_parse(
770
- texts, req.format_json, provider=req.provider, model=req.model,
771
- api_key=req.api_key, max_workers=req.max_workers,
772
- )
773
- except ValueError as e:
774
- raise HTTPException(status_code=400, detail=str(e))
775
- except Exception as e:
776
- logger.error(traceback.format_exc())
777
- raise HTTPException(status_code=500, detail=f"Parsing failed: {e}")
778
-
779
- df = result["df"]
780
- # Carry-through columns: align on the raw-text input, like parsing.py.
781
- if req.keep_columns and not df.empty:
782
- keep = [c for c in req.keep_columns if c in src.columns]
783
- if keep:
784
- keep_src = src[keep].copy()
785
- keep_src.index = text_series.values
786
- keep_src = keep_src[keep_src.index.notna()]
787
- keep_src = keep_src[~keep_src.index.duplicated(keep="first")]
788
- df = df.join(keep_src, how="left")
789
-
790
- state.parsed_responses = result["parsed_responses"]
791
- state.parsed_df = df
792
- return {
793
- "status": "ok",
794
- "n_ok": result["n_ok"],
795
- "n_total": result["n_total"],
796
- "n_failed": len(result["errors"]),
797
- "errors": result["errors"][:10],
798
- "data": df_to_json(df, max_rows=2000),
799
- }
800
-
801
-
802
- @app.get("/api/parse/result")
803
- def parse_result(state: SessionState = Depends(get_session)):
804
- if state.parsed_df is None:
805
- raise HTTPException(status_code=400, detail="No parsed data in session.")
806
- return {"status": "ok", "data": df_to_json(state.parsed_df, max_rows=2000),
807
- "n_records": len(state.parsed_df)}
808
-
809
-
810
- # ---------------------------------------------------------------------------
811
- # SCU normalization (scu_normalizer parity)
812
- # ---------------------------------------------------------------------------
813
- @app.get("/api/scu/criteria")
814
- def scu_criteria():
815
- try:
816
- return scu_service.criteria_info()
817
- except Exception as e:
818
- raise HTTPException(status_code=500, detail=str(e))
819
-
820
-
821
- @app.post("/api/scu/normalize")
822
- def scu_normalize(state: SessionState = Depends(get_session)):
823
- if not state.parsed_responses:
824
- raise HTTPException(
825
- status_code=400,
826
- detail="No parsed responses in session. Run the Parsing step first.",
827
- )
828
- try:
829
- result = scu_service.normalize(state.parsed_responses)
830
- except ValueError as e:
831
- raise HTTPException(status_code=400, detail=str(e))
832
- except Exception as e:
833
- logger.error(traceback.format_exc())
834
- raise HTTPException(status_code=500, detail=f"Normalization failed: {e}")
835
- state.scu_normalized_df = result["df"]
836
- return {
837
- "status": "ok",
838
- "metrics": result["metrics"],
839
- "audit_markdown": result["audit_markdown"],
840
- "data": df_to_json(result["df"], max_rows=5000),
841
- }
842
-
843
-
844
- @app.post("/api/scu/filter")
845
- def scu_filter(req: ScuFilterRequest, state: SessionState = Depends(get_session)):
846
- if state.scu_normalized_df is None:
847
- raise HTTPException(status_code=400, detail="No normalized data. Run SCU normalization first.")
848
- try:
849
- result = scu_service.filter_eligibility(state.scu_normalized_df, req.criterion_keys)
850
- except Exception as e:
851
- raise HTTPException(status_code=500, detail=f"Filter failed: {e}")
852
- return {
853
- "status": "ok",
854
- "funnel": result["funnel"],
855
- "n_passed": result["n_passed"],
856
- "data": df_to_json(result["df"], max_rows=5000),
857
- }
858
-
859
-
860
- # ---------------------------------------------------------------------------
861
- # RAG search — Cohere rerank (rag_search.py Dataset RAG parity)
862
- # ---------------------------------------------------------------------------
863
- @app.post("/api/rag/search")
864
- def rag_search(req: RagSearchRequest, state: SessionState = Depends(get_session)):
865
- df = state.filtered_data if state.filtered_data is not None else state.dataset
866
- if df is None or df.empty:
867
- raise HTTPException(status_code=400, detail="No dataset loaded. Load data first.")
868
- if not req.cohere_key:
869
- raise HTTPException(status_code=400, detail="A Cohere API key is required.")
870
- try:
871
- result = rag_service.rerank(df, req.columns, req.question, req.cohere_key, top_n=req.top_n)
872
- except ValueError as e:
873
- raise HTTPException(status_code=400, detail=str(e))
874
- except Exception as e:
875
- logger.error(traceback.format_exc())
876
- raise HTTPException(status_code=502, detail=f"Cohere rerank failed: {e}")
877
- return {
878
- "status": "ok",
879
- "n_results": result["n_results"],
880
- "searched_columns": result["searched_columns"],
881
- "data": df_to_json(result["df"], max_rows=req.top_n),
882
- }
883
-
884
-
885
- # ---------------------------------------------------------------------------
886
- # Cramér's V Categorical Association Explorer (analyzing.py parity)
887
- # ---------------------------------------------------------------------------
888
- def _assoc_source(state: SessionState, source: str) -> pd.DataFrame:
889
- if source == "parsed":
890
- if state.parsed_df is None:
891
- raise HTTPException(status_code=400, detail="No parsed data in session.")
892
- return state.parsed_df
893
- df = state.filtered_data if state.filtered_data is not None else state.dataset
894
- if df is None or df.empty:
895
- raise HTTPException(status_code=400, detail="No dataset loaded.")
896
- return df
897
-
898
-
899
- @app.post("/api/analysis/column-groups")
900
- def analysis_column_groups(req: ColumnGroupsRequest, state: SessionState = Depends(get_session)):
901
- """Eligible categorical columns grouped by dotted parent, for the explorer's
902
- group selector. Cheap (cardinality only) so it loads before any matrix compute."""
903
- df = _assoc_source(state, req.source)
904
- try:
905
- return analysis_service.column_groups(df, high_threshold=req.high_threshold)
906
- except Exception as e:
907
- logger.error(traceback.format_exc())
908
- raise HTTPException(status_code=500, detail=f"Column grouping failed: {e}")
909
-
910
-
911
- @app.post("/api/analysis/xgboost")
912
- def analysis_xgboost(req: XgboostRequest, state: SessionState = Depends(get_session)):
913
- """XGBoost feature importance computed directly on the selected categorical
914
- columns (no cluster pipeline) — fed by the Cramér's V explorer selection."""
915
- df = _assoc_source(state, req.source)
916
- try:
917
- return analysis_service.xgboost_importance(df, req.columns)
918
- except Exception as e:
919
- logger.error(traceback.format_exc())
920
- raise HTTPException(status_code=500, detail=f"XGBoost feature importance failed: {e}")
921
-
922
-
923
- @app.post("/api/analysis/cramers-v")
924
- def analysis_cramers_v(req: CramersVRequest, state: SessionState = Depends(get_session)):
925
- df = _assoc_source(state, req.source)
926
- try:
927
- return analysis_service.cramers_v_report(
928
- df, req.columns, drop_missing=req.drop_missing,
929
- exclude_trivial=req.exclude_trivial, strong_threshold=req.strong_threshold,
930
- high_threshold=req.high_threshold,
931
- )
932
- except Exception as e:
933
- logger.error(traceback.format_exc())
934
- raise HTTPException(status_code=500, detail=f"Cramér's V failed: {e}")
935
-
936
-
937
- @app.post("/api/analysis/contingency")
938
- def analysis_contingency(req: ContingencyRequest, state: SessionState = Depends(get_session)):
939
- df = _assoc_source(state, req.source)
940
- try:
941
- return analysis_service.contingency(df, req.col1, req.col2, drop_missing=req.drop_missing)
942
- except ValueError as e:
943
- raise HTTPException(status_code=400, detail=str(e))
944
- except Exception as e:
945
- raise HTTPException(status_code=500, detail=f"Contingency failed: {e}")
946
-
947
-
948
- # ---------------------------------------------------------------------------
949
- # Map and Magnetic Features
950
- # ---------------------------------------------------------------------------
951
- from fastapi.responses import HTMLResponse
952
- from api.services.map_service import MapService
953
-
954
- @app.get("/api/map/html")
955
- def get_map_html(state: SessionState = Depends(get_session)):
956
- if state.filtered_data is None or state.filtered_data.empty:
957
- return HTMLResponse(
958
- "<html><body style='background:#121212;color:white;display:flex;"
959
- "justify-content:center;align-items:center;height:100vh;font-family:sans-serif;'>"
960
- "<h3>No data to display. Please load and filter your dataset first.</h3>"
961
- "</body></html>"
962
- )
963
- try:
964
- html, from_cache = MapService.get_or_generate(state.filtered_data)
965
- headers = {"X-Map-Cache": "HIT" if from_cache else "MISS"}
966
- return HTMLResponse(content=html, headers=headers)
967
- except Exception as e:
968
- logger.error(f"Map Error: {traceback.format_exc()}")
969
- return HTMLResponse(
970
- f"<html><body style='background:#121212;color:red;'>"
971
- f"<h3>Map generation failed: {e}</h3></body></html>"
972
- )
973
-
974
-
975
- class MagneticRequest(BaseModel):
976
- lat_col: str
977
- lon_col: str
978
- date_col: str
979
- distance: int = 100
980
-
981
- def _fig_to_data_uri(fig, dpi: int = 80) -> str:
982
- """Render a matplotlib figure to a base64-encoded PNG data URI on a dark background."""
983
- import io, base64
984
- import matplotlib.pyplot as plt
985
- buf = io.BytesIO()
986
- fig.savefig(buf, format="png", dpi=dpi, bbox_inches="tight", facecolor="#0d0d0d")
987
- plt.close(fig)
988
- buf.seek(0)
989
- return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode("ascii")
990
-
991
-
992
- # Max events scanned per request (each match is a live BGS API call). Synchronous
993
- # endpoint, so this is kept small to bound request duration.
994
- MAGNETIC_MAX_EVENTS = 25
995
- MAGNETIC_TIME_BUDGET_S = 240
996
-
997
-
998
- @app.post("/api/magnetic/run")
999
- def run_magnetic(req: MagneticRequest, state: SessionState = Depends(get_session)):
1000
- """Cross-reference UAP events with geomagnetic observatory data.
1001
-
1002
- For each event with valid coordinates and a post-1995 date, find the nearest
1003
- INTERMAGNET/BGS observatory within ``distance`` km, fetch its X/Y/Z/S minute
1004
- data around the event, and render a time-series graph. Also produces a
1005
- FastDTW-aligned aggregate across every matched event.
1006
- """
1007
- if state.filtered_data is None or state.filtered_data.empty:
1008
- raise HTTPException(status_code=400, detail="No data loaded. Load a dataset in the Data Explorer first.")
1009
-
1010
- df = state.filtered_data
1011
- for label, col in (("Latitude", req.lat_col), ("Longitude", req.lon_col), ("Date", req.date_col)):
1012
- if col not in df.columns:
1013
- raise HTTPException(status_code=400, detail=f"{label} column '{col}' not found in dataset.")
1014
-
1015
- import sys
1016
- proj_root = os.path.dirname(os.path.dirname(__file__))
1017
- if proj_root not in sys.path:
1018
- sys.path.insert(0, proj_root)
1019
- try:
1020
- import matplotlib
1021
- matplotlib.use("Agg")
1022
- except Exception:
1023
- pass
1024
- import magnetic
1025
-
1026
- started = time.time()
1027
-
1028
- # 1. Observatory list from the BGS API
1029
- try:
1030
- stations = magnetic.get_stations()
1031
- st_lat = pd.to_numeric(stations["Latitude"], errors="coerce").to_numpy()
1032
- st_lon = pd.to_numeric(stations["Longitude"], errors="coerce").to_numpy()
1033
- except Exception as e:
1034
- logger.error(f"Magnetic: station fetch failed: {traceback.format_exc()}")
1035
- raise HTTPException(status_code=502, detail=f"Could not reach the BGS observatory API: {e}")
1036
-
1037
- # 2. Candidate events: numeric coordinates + parseable, post-1995 dates
1038
- work = pd.DataFrame({
1039
- "lat": pd.to_numeric(df[req.lat_col], errors="coerce"),
1040
- "lon": pd.to_numeric(df[req.lon_col], errors="coerce"),
1041
- "date": df[req.date_col].apply(magnetic.parse_uap_date),
1042
- }).dropna(subset=["lat", "lon", "date"])
1043
- work = work[work["lat"].between(-90, 90) & work["lon"].between(-180, 180)]
1044
- work = work[work["date"].dt.year >= 1995]
1045
- candidates = len(work)
1046
- if candidates == 0:
1047
- raise HTTPException(
1048
- status_code=400,
1049
- detail="No events with valid coordinates and a post-1995 date. Check the selected columns.",
1050
- )
1051
-
1052
- # 3. Per-event: nearest observatory -> fetch data -> render graph
1053
- graphs, agg_data, agg_times = [], [], []
1054
- scanned = skipped_no_station = skipped_no_data = 0
1055
-
1056
- for lat_, lon_, date_ in work[["lat", "lon", "date"]].itertuples(index=False):
1057
- if scanned >= MAGNETIC_MAX_EVENTS or (time.time() - started) > MAGNETIC_TIME_BUDGET_S:
1058
- break
1059
- scanned += 1
1060
-
1061
- dists = np.array([
1062
- magnetic.get_haversine_distance(lat_, lon_, a, b)
1063
- for a, b in zip(st_lat, st_lon)
1064
- ])
1065
- nearest = int(np.nanargmin(dists))
1066
- if not np.isfinite(dists[nearest]) or dists[nearest] > req.distance:
1067
- skipped_no_station += 1
1068
- continue
1069
- station = stations.iloc[nearest]
1070
- iaga = str(station["IagaCode"])
1071
-
1072
- d = pd.Timestamp(date_)
1073
- if d.tz is not None:
1074
- d = d.tz_localize(None)
1075
- start = (d - pd.Timedelta(hours=12)).strftime("%Y-%m-%d")
1076
- end = (d + pd.Timedelta(hours=48)).strftime("%Y-%m-%d")
1077
-
1078
- try:
1079
- res = magnetic.get_data(iaga, start, end)
1080
- except Exception:
1081
- res = None
1082
- dt = (res or {}).get("datetime") or []
1083
- if not dt:
1084
- skipped_no_data += 1
1085
- continue
1086
-
1087
- n = len(dt)
1088
- comp = {}
1089
- for k in ("X", "Y", "Z", "S"):
1090
- vals = res.get(k) or []
1091
- comp[k] = (
1092
- pd.to_numeric(pd.Series(vals), errors="coerce").to_numpy()
1093
- if len(vals) == n else np.full(n, np.nan)
1094
- )
1095
- if not any(np.isfinite(v).any() for v in comp.values()):
1096
- skipped_no_data += 1
1097
- continue
1098
-
1099
- dt_naive = pd.to_datetime(pd.Series(dt), utc=True, errors="coerce").dt.tz_localize(None)
1100
- plotted = pd.DataFrame({"datetime": dt_naive, **comp})
1101
-
1102
- subtitle = (
1103
- f"{d.date()} | {lat_:.3f}, {lon_:.3f} | "
1104
- f"{station['Name']} ({iaga}) | {dists[nearest]:.0f} km"
1105
- )
1106
- try:
1107
- fig = magnetic.plot_data_custom(plotted.copy(), date=d, save_path=None, subtitle=subtitle)
1108
- graphs.append({
1109
- "title": f"{d.date()} — {station['Name']}",
1110
- "station": str(station["Name"]),
1111
- "iaga": iaga,
1112
- "distance_km": round(float(dists[nearest]), 1),
1113
- "event_date": str(d),
1114
- "image": _fig_to_data_uri(fig),
1115
- })
1116
- except Exception:
1117
- logger.error(f"Magnetic: plot failed for {iaga} {d}: {traceback.format_exc()}")
1118
- skipped_no_data += 1
1119
- continue
1120
-
1121
- agg = plotted.copy()
1122
- agg["datetime"] = agg["datetime"].dt.tz_localize("UTC")
1123
- agg_data.append(agg)
1124
- agg_times.append(d.tz_localize("UTC"))
1125
-
1126
- # 4. FastDTW-aligned aggregate across every matched event
1127
- aggregate = None
1128
- if len(agg_data) >= 2:
1129
- try:
1130
- fig = magnetic.plot_average_timeseries_with_dtw(agg_data, agg_times, window_hours=12, save_path=None)
1131
- aggregate = {
1132
- "title": f"FastDTW-aligned average across {len(agg_data)} events",
1133
- "image": _fig_to_data_uri(fig),
1134
- }
1135
- except Exception:
1136
- logger.error(f"Magnetic: DTW aggregate failed: {traceback.format_exc()}")
1137
-
1138
- return {
1139
- "status": "ok",
1140
- "candidates": candidates,
1141
- "scanned": scanned,
1142
- "matched": len(graphs),
1143
- "skipped_no_station": skipped_no_station,
1144
- "skipped_no_data": skipped_no_data,
1145
- "distance_km": req.distance,
1146
- "elapsed_s": round(time.time() - started, 1),
1147
- "graphs": graphs,
1148
- "aggregate": aggregate,
1149
- "events": len(graphs),
1150
- "message": (
1151
- f"Scanned {scanned} of {candidates} candidate events within {req.distance} km — "
1152
- f"{len(graphs)} produced graphs, {skipped_no_station} had no observatory in range, "
1153
- f"{skipped_no_data} returned no usable data."
1154
- ),
1155
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
api/services/__init__.py DELETED
@@ -1 +0,0 @@
1
- # api/services package
 
 
api/services/analysis_service.py DELETED
@@ -1,287 +0,0 @@
1
- """Analysis service — the Categorical Association Explorer (Cramér's V) that runs
2
- directly on raw dataset columns, ported from analyzing.py. Reuses
3
- ``uap_analyzer.cramers_v`` for the per-pair statistic.
4
- """
5
- from __future__ import annotations
6
-
7
- import numpy as np
8
- import pandas as pd
9
-
10
- # Canonical label so missingness isn't fragmented into nan / None / "" etc.
11
- _MISSING_LABEL = "(missing)"
12
- _NULL_STR_TOKENS = {"nan", "none", "null", "<na>", "nat", ""}
13
- _CV_TOL = 1e-6 # values within this of 0 / 1 are "trivial"
14
-
15
-
16
- def _safe_nunique(series: pd.Series) -> int:
17
- try:
18
- return int(series.nunique(dropna=True))
19
- except TypeError:
20
- return int(series.astype(str).nunique(dropna=True))
21
-
22
-
23
- def band_columns(df: pd.DataFrame, high_threshold: int = 30) -> tuple[dict, dict]:
24
- """Bucket columns into categorical bands by cardinality (see analyzing.py)."""
25
- bands: dict[str, list[str]] = {
26
- "binary": [], "low": [], "medium": [], "high": [], "constant": [],
27
- }
28
- nunique_map: dict[str, int] = {}
29
- for c in df.columns:
30
- nu = _safe_nunique(df[c])
31
- nunique_map[c] = nu
32
- if nu <= 1:
33
- bands["constant"].append(c)
34
- elif nu == 2:
35
- bands["binary"].append(c)
36
- elif nu <= 9:
37
- bands["low"].append(c)
38
- elif nu < high_threshold:
39
- bands["medium"].append(c)
40
- else:
41
- bands["high"].append(c)
42
- return bands, nunique_map
43
-
44
-
45
- def _eligible_categorical(bands: dict) -> list[str]:
46
- """Columns the explorer scores by default: binary + low + medium cardinality
47
- (high-cardinality / free-text and constant columns are unsuitable for Cramér's V)."""
48
- return bands["binary"] + bands["low"] + bands["medium"]
49
-
50
-
51
- def group_by_parent(columns: list[str], sep: str = ".") -> list[dict]:
52
- """Group nested, ``sep``-separated column names by their top-level parent
53
- segment (the part before the first separator), preserving first-seen order.
54
-
55
- e.g. ['craft.shape', 'craft.color', 'state'] ->
56
- [{"parent": "craft", "columns": ["craft.shape", "craft.color"],
57
- "leaves": ["shape", "color"], "nested": True},
58
- {"parent": "state", "columns": ["state"], "leaves": ["state"],
59
- "nested": False}]
60
-
61
- Columns without the separator form their own single-member, non-nested group
62
- so the frontend can render them as standalone chips.
63
- """
64
- order: list[str] = []
65
- groups: dict[str, list[str]] = {}
66
- for c in columns:
67
- name = str(c)
68
- parent = name.split(sep, 1)[0] if sep in name else name
69
- if parent not in groups:
70
- groups[parent] = []
71
- order.append(parent)
72
- groups[parent].append(c)
73
- out = []
74
- for parent in order:
75
- members = groups[parent]
76
- nested = len(members) > 1 or (sep in str(members[0]))
77
- leaves = [str(m).split(sep, 1)[1] if sep in str(m) else str(m) for m in members]
78
- out.append({"parent": parent, "columns": members,
79
- "leaves": leaves, "nested": nested})
80
- return out
81
-
82
-
83
- def column_groups(df: pd.DataFrame, *, high_threshold: int = 30) -> dict:
84
- """Eligible categorical columns for the explorer, grouped by dotted parent.
85
-
86
- Cheap (only cardinality counting) so the frontend can render the parent-group
87
- selector before computing the full Cramér's V matrix.
88
- """
89
- bands, nunique_map = band_columns(df, high_threshold=high_threshold)
90
- eligible = _eligible_categorical(bands)
91
- return {
92
- "eligible": eligible,
93
- "groups": group_by_parent(eligible),
94
- "bands": bands,
95
- "nunique": nunique_map,
96
- }
97
-
98
-
99
- def _coalesce(series: pd.Series) -> pd.Series:
100
- s = series.astype(str).str.strip()
101
- return s.mask(s.str.lower().isin(_NULL_STR_TOKENS), _MISSING_LABEL)
102
-
103
-
104
- def compute_cramers_v_df(df: pd.DataFrame, cols: list[str],
105
- drop_missing: bool = False) -> pd.DataFrame:
106
- from uap_analyzer import cramers_v
107
-
108
- cv = pd.DataFrame(index=cols, columns=cols, data=np.nan, dtype=float)
109
- cache = {c: _coalesce(df[c]) for c in cols}
110
- for i, c1 in enumerate(cols):
111
- cv.at[c1, c1] = 1.0
112
- for c2 in cols[i + 1:]:
113
- a, b = cache[c1], cache[c2]
114
- if drop_missing:
115
- keep = (a != _MISSING_LABEL) & (b != _MISSING_LABEL)
116
- a, b = a[keep], b[keep]
117
- v = 0.0 if len(a) == 0 else float(cramers_v(pd.crosstab(a, b)))
118
- cv.at[c1, c2] = v
119
- cv.at[c2, c1] = v
120
- return cv
121
-
122
-
123
- def _is_trivial_v(v: float, tol: float = _CV_TOL) -> bool:
124
- return (v <= tol) or (v >= 1.0 - tol)
125
-
126
-
127
- def pairs_table(cv_df: pd.DataFrame, exclude_trivial: bool = True) -> tuple[list[dict], int]:
128
- rows, n_excluded = [], 0
129
- cols = list(cv_df.columns)
130
- for i, c1 in enumerate(cols):
131
- for c2 in cols[i + 1:]:
132
- v = cv_df.at[c1, c2]
133
- if pd.isna(v):
134
- continue
135
- v = float(v)
136
- if exclude_trivial and _is_trivial_v(v):
137
- n_excluded += 1
138
- continue
139
- rows.append({"a": c1, "b": c2, "v": round(v, 3)})
140
- rows.sort(key=lambda r: r["v"], reverse=True)
141
- return rows, n_excluded
142
-
143
-
144
- def high_correlation_columns(cv_df: pd.DataFrame, strong_threshold: float = 0.30,
145
- exclude_trivial: bool = True) -> list[str]:
146
- if cv_df is None or getattr(cv_df, "empty", True):
147
- return []
148
- out = []
149
- for col in cv_df.columns:
150
- others = cv_df[col].drop(labels=[col], errors="ignore")
151
- for v in others:
152
- if pd.isna(v):
153
- continue
154
- v = float(v)
155
- if exclude_trivial and _is_trivial_v(v):
156
- continue
157
- if v >= strong_threshold:
158
- out.append(col)
159
- break
160
- return out
161
-
162
-
163
- def cramers_v_report(df: pd.DataFrame, columns: list[str] | None = None, *,
164
- drop_missing: bool = False, exclude_trivial: bool = True,
165
- strong_threshold: float = 0.30, high_threshold: int = 30) -> dict:
166
- """Full explorer payload: column bands, the Cramér's V matrix, the ranked
167
- pair table, and the high-correlation column shortlist."""
168
- bands, nunique_map = band_columns(df, high_threshold=high_threshold)
169
-
170
- if columns:
171
- cols = [c for c in columns if c in df.columns]
172
- else:
173
- # Default selection mirrors the explorer: binary + low + medium cardinality.
174
- cols = _eligible_categorical(bands)
175
-
176
- if len(cols) < 2:
177
- return {
178
- "labels": [], "matrix": [], "pairs": [], "n_excluded": 0,
179
- "high_correlation_columns": [],
180
- "bands": bands, "nunique": nunique_map, "selected_columns": cols,
181
- "groups": group_by_parent(cols),
182
- }
183
-
184
- cv = compute_cramers_v_df(df, cols, drop_missing=drop_missing)
185
- pairs, n_excluded = pairs_table(cv, exclude_trivial=exclude_trivial)
186
- high = high_correlation_columns(cv, strong_threshold, exclude_trivial)
187
-
188
- matrix = [[None if pd.isna(v) else round(float(v), 3) for v in cv.loc[r]] for r in cols]
189
- return {
190
- "labels": cols,
191
- "matrix": matrix,
192
- "pairs": pairs,
193
- "n_excluded": n_excluded,
194
- "high_correlation_columns": high,
195
- "bands": bands,
196
- "nunique": nunique_map,
197
- "selected_columns": cols,
198
- "groups": group_by_parent(cols),
199
- }
200
-
201
-
202
- def contingency(df: pd.DataFrame, c1: str, c2: str, drop_missing: bool = False,
203
- top_n: int = 15) -> dict:
204
- """Crosstab + Cramér's V for a single pair, for the heatmap drill-down."""
205
- from uap_analyzer import cramers_v
206
-
207
- if c1 not in df.columns or c2 not in df.columns:
208
- raise ValueError("Both columns must exist in the dataset.")
209
- a, b = _coalesce(df[c1]), _coalesce(df[c2])
210
- if drop_missing:
211
- keep = (a != _MISSING_LABEL) & (b != _MISSING_LABEL)
212
- a, b = a[keep], b[keep]
213
- if len(a) == 0:
214
- return {"row_labels": [], "col_labels": [], "matrix": [], "v": 0.0, "n": 0}
215
-
216
- ct = pd.crosstab(a, b)
217
- v = float(cramers_v(ct))
218
- # Trim to the top_n most frequent categories on each axis for display.
219
- row_order = ct.sum(axis=1).sort_values(ascending=False).index[:top_n]
220
- col_order = ct.sum(axis=0).sort_values(ascending=False).index[:top_n]
221
- ct = ct.loc[row_order, col_order]
222
- return {
223
- "row_labels": [str(x) for x in ct.index.tolist()],
224
- "col_labels": [str(x) for x in ct.columns.tolist()],
225
- "matrix": ct.values.astype(int).tolist(),
226
- "v": round(v, 3),
227
- "n": int(len(a)),
228
- }
229
-
230
-
231
- # ── XGBoost feature importance on raw categorical columns ───────────────────
232
- # Cap on a target column's class count — XGBoost multi:softmax with hundreds of
233
- # classes is slow and the importances are meaningless. The explorer only feeds
234
- # binary/low/medium-cardinality columns, so this is just a safety net.
235
- _XGB_MAX_TARGET_CLASSES = 50
236
-
237
-
238
- def xgboost_importance(df: pd.DataFrame, columns: list[str], *,
239
- test_size: float = 0.2, random_state: int = 42) -> dict:
240
- """Per-column XGBoost feature importance computed *directly* on the selected
241
- raw categorical columns — predict each column from the others and report the
242
- gain-based importance of every other column, plus the test accuracy.
243
-
244
- This mirrors ``analyzing.py``'s ``analyze_and_predict`` loop but runs on the
245
- raw values (the same set used by the Cramér's V explorer) instead of cluster
246
- labels, so feature importance is available without the embedding/cluster
247
- pipeline. Returns ``{results: {col: {feature_importance, accuracy}}, ...}``.
248
- """
249
- from sklearn.model_selection import train_test_split
250
- from uap_analyzer import train_xgboost
251
-
252
- cols = [c for c in columns if c in df.columns]
253
- if len(cols) < 2:
254
- return {
255
- "results": {}, "columns": cols, "skipped": {},
256
- "message": "Select at least two categorical columns for feature importance.",
257
- }
258
-
259
- # Coalesce missingness the same way Cramér's V does, then category-encode.
260
- new_data = pd.DataFrame({c: _coalesce(df[c]) for c in cols}).astype("category")
261
- data_nums = new_data.apply(lambda s: s.cat.codes)
262
-
263
- results: dict[str, dict] = {}
264
- skipped: dict[str, str] = {}
265
- for col in cols:
266
- n_classes = len(new_data[col].cat.categories)
267
- if n_classes < 2:
268
- skipped[col] = "constant column (one class)"
269
- continue
270
- if n_classes > _XGB_MAX_TARGET_CLASSES:
271
- skipped[col] = f"too many classes ({n_classes}) to predict"
272
- continue
273
- try:
274
- x = data_nums.drop(columns=[col])
275
- y = data_nums[col]
276
- x_train, x_test, y_train, y_test = train_test_split(
277
- x, y, test_size=test_size, random_state=random_state,
278
- )
279
- bst, accuracy, _ = train_xgboost(x_train, y_train, x_test, y_test, n_classes)
280
- # Gain-based importance; only features used in a split appear.
281
- imp = {k: float(v) for k, v in bst.get_score(importance_type="gain").items()}
282
- imp = dict(sorted(imp.items(), key=lambda kv: kv[1], reverse=True))
283
- results[col] = {"feature_importance": imp, "accuracy": round(float(accuracy), 3)}
284
- except Exception as e: # noqa: BLE001 — one bad target shouldn't sink the rest
285
- skipped[col] = str(e)
286
-
287
- return {"results": results, "columns": cols, "skipped": skipped}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
api/services/map_service.py DELETED
@@ -1,198 +0,0 @@
1
- """
2
- map_service.py
3
- --------------
4
- Encapsulates all Kepler.gl HTML generation logic.
5
-
6
- Key design decisions:
7
- - Results are cached by a *data fingerprint* (hash of the DataFrame shape +
8
- first/last row content). When the user changes filters the fingerprint
9
- changes, a new HTML payload is generated, and the old entry is evicted.
10
- - We keep **at most `_MAX_CACHE_ENTRIES`** cached payloads to bound memory
11
- use when many sessions are active.
12
- - The heavy `df.copy()` that previously lived inside the endpoint is
13
- eliminated: we only copy the minimal columns we actually need for the map.
14
- """
15
- from __future__ import annotations
16
-
17
- import hashlib
18
- import logging
19
- import os
20
- import sys
21
- import traceback
22
- from collections import OrderedDict
23
- from typing import Optional
24
-
25
- import pandas as pd
26
-
27
- logger = logging.getLogger(__name__)
28
-
29
- _MAX_CACHE_ENTRIES = 20
30
-
31
- # ---------------------------------------------------------------------------
32
- # Internal helpers
33
- # ---------------------------------------------------------------------------
34
-
35
- def _fingerprint(df: pd.DataFrame) -> str:
36
- """
37
- Fast, deterministic fingerprint for a DataFrame.
38
-
39
- Uses shape + a hash of a small sample (first & last 5 rows rendered as
40
- CSV) so the cost is O(1) with respect to the total number of rows.
41
- """
42
- sample = pd.concat([df.head(5), df.tail(5)])
43
- raw = f"{df.shape}|{sample.to_csv(index=False)}"
44
- return hashlib.md5(raw.encode("utf-8", errors="replace")).hexdigest()
45
-
46
-
47
- def _build_html(df: pd.DataFrame) -> str:
48
- """
49
- Pure function: receive a (possibly large) DataFrame, return a Kepler.gl
50
- HTML string.
51
- """
52
- import json
53
- from keplergl import KeplerGl # noqa: PLC0415
54
-
55
- # DECOUPLED: Use the new lightweight utils instead of map.py
56
- from api.utils.data_utils import auto_create_date_column, sanitize_dataframe_for_json # noqa: PLC0415
57
-
58
- # Path setup
59
- base_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
60
- config_path = os.path.join(base_dir, "military_config.kgl")
61
- bases_path = os.path.join(base_dir, "secret_bases.csv")
62
- power_path = os.path.join(base_dir, "global_power_plant_database.csv")
63
-
64
- kmap = KeplerGl(height=800)
65
-
66
- # 1. Load configuration if available
67
- config = None
68
- if os.path.exists(config_path):
69
- try:
70
- with open(config_path, "r", encoding="utf-8") as f:
71
- config = json.load(f)
72
- except Exception as e:
73
- logger.error(f"Failed to load military config: {e}")
74
-
75
- # 2. Add Auxiliary Data (Power Plants & Secret Bases)
76
- if os.path.exists(power_path):
77
- try:
78
- pp_df = pd.read_csv(power_path)
79
- # Filter for nuclear as per config multiSelect
80
- nuke_df = pp_df[pp_df["primary_fuel"] == "Nuclear"].copy()
81
- nuke_df["icon"] = "control-on" # Required for icon layer
82
- kmap.add_data(data=nuke_df, name="nuclear_powerplants")
83
- except Exception as e:
84
- logger.error(f"Failed to load power plant data: {e}")
85
-
86
- if os.path.exists(bases_path):
87
- try:
88
- bases_df = pd.read_csv(bases_path)
89
- bases_df["icon"] = "draw-shape"
90
- kmap.add_data(data=bases_df, name="secret_bases")
91
- except Exception as e:
92
- logger.error(f"Failed to load secret bases: {e}")
93
-
94
- # 3. Add user's UAP data
95
- df = auto_create_date_column(df)
96
- df = sanitize_dataframe_for_json(df)
97
-
98
- map_cols = list(df.columns)
99
- lat_candidates = [c for c in map_cols if str(c).lower() in {"lat", "latitude", "city_latitude"}]
100
- lon_candidates = [c for c in map_cols if str(c).lower() in {"lon", "lng", "longitude", "city_longitude"}]
101
-
102
- if lat_candidates and lon_candidates:
103
- lat_col = lat_candidates[0]
104
- lon_col = lon_candidates[0]
105
- needed_cols = list(set(map_cols) & set(df.columns))
106
- df_map = df[needed_cols].copy()
107
- df_map[lat_col] = pd.to_numeric(df_map[lat_col], errors="coerce")
108
- df_map[lon_col] = pd.to_numeric(df_map[lon_col], errors="coerce")
109
- df_map = df_map.dropna(subset=[lat_col, lon_col])
110
-
111
- # 4. DYNAMIC VIEWPORT (Phase 14)
112
- # Calculate center and zoom based on the current sightings data
113
- if not df_map.empty:
114
- lat_mean = float(df_map[lat_col].mean())
115
- lon_mean = float(df_map[lon_col].mean())
116
-
117
- # Simple zoom heuristic based on spread
118
- lat_range = df_map[lat_col].max() - df_map[lat_col].min()
119
- lon_range = df_map[lon_col].max() - df_map[lon_col].min()
120
- max_range = max(lat_range, lon_range)
121
-
122
- # log-based zoom approximation: 0 is whole world (~360), 10-12 is city
123
- if max_range > 100: zoom = 2
124
- elif max_range > 30: zoom = 3
125
- elif max_range > 10: zoom = 4
126
- elif max_range > 5: zoom = 5
127
- elif max_range > 2: zoom = 6
128
- elif max_range > 1: zoom = 7
129
- else: zoom = 8
130
-
131
- if config:
132
- if "mapState" not in config:
133
- config["mapState"] = {}
134
- config["mapState"]["latitude"] = lat_mean
135
- config["mapState"]["longitude"] = lon_mean
136
- config["mapState"]["zoom"] = zoom
137
- logger.info(f"Dynamically centering map at {lat_mean:.2f}, {lon_mean:.2f} (zoom={zoom})")
138
-
139
- # Data ID aligned with military_config.kgl
140
- kmap.add_data(data=df_map, name="uap_sightings")
141
- else:
142
- kmap.add_data(data=df, name="uap_sightings")
143
-
144
- if config:
145
- kmap.config = config
146
-
147
- html_bytes = kmap._repr_html_()
148
- return html_bytes.decode("utf-8") if isinstance(html_bytes, bytes) else html_bytes
149
-
150
-
151
- # ---------------------------------------------------------------------------
152
- # Public service
153
- # ---------------------------------------------------------------------------
154
-
155
- class MapService:
156
- """
157
- Singleton-style service that generates and caches Kepler.gl HTML payloads.
158
- """
159
- _cache: OrderedDict[str, str] = OrderedDict()
160
-
161
- @classmethod
162
- def get_or_generate(cls, df: pd.DataFrame) -> tuple[str, bool]:
163
- """
164
- Return (html_string, cache_hit).
165
- """
166
- # Fingerprint logic remains the same, but we could add config mtime if it changes often
167
- key = _fingerprint(df)
168
-
169
- if key in cls._cache:
170
- # Promote to most-recently-used
171
- cls._cache.move_to_end(key)
172
- logger.info("MapService: cache HIT (key=%s…)", key[:8])
173
- return cls._cache[key], True
174
-
175
- logger.info("MapService: cache MISS — generating HTML (key=%s…)", key[:8])
176
- html = _build_html(df)
177
-
178
- # Evict oldest entry when the cache is full
179
- if len(cls._cache) >= _MAX_CACHE_ENTRIES:
180
- evicted = next(iter(cls._cache))
181
- cls._cache.pop(evicted)
182
- logger.debug("MapService: evicted cache entry %s…", evicted[:8])
183
-
184
- cls._cache[key] = html
185
- return html, False
186
-
187
- @classmethod
188
- def invalidate(cls, df: Optional[pd.DataFrame] = None) -> None:
189
- """
190
- Invalidate a specific entry (if *df* is provided) or the whole cache.
191
- """
192
- if df is None:
193
- cls._cache.clear()
194
- logger.info("MapService: full cache cleared")
195
- else:
196
- key = _fingerprint(df)
197
- cls._cache.pop(key, None)
198
- logger.info("MapService: cache entry %s… invalidated", key[:8])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
api/services/parsing_service.py DELETED
@@ -1,312 +0,0 @@
1
- """Parsing service — LLM feature extraction of raw UAP report text into
2
- structured JSON, mirroring the core of the Streamlit ``parsing.py`` page.
3
-
4
- Scope (core tier): schema registry + deep-merge + custom fields, cost
5
- estimation, and the *client-parallel* run mode (OpenAI / DeepSeek). The
6
- OpenAI server-batch path, SCU xlsx export and embeddings→HDF5 are intentionally
7
- left out of this first pass.
8
-
9
- Heavy/optional imports (``uap_analyzer``, ``config``) are pulled in lazily so
10
- importing this module never drags in torch or validates secrets.
11
- """
12
- from __future__ import annotations
13
-
14
- import json
15
- import re
16
- from typing import Any, Callable
17
-
18
-
19
- # ── Schema registry ────────────────────────────────────────────────────────
20
- # Rebuilt directly from config.py (no Streamlit dependency) so it stays in
21
- # sync with parsing.py's SCHEMA_FORMATS / SCHEMA_FORMAT_GROUPS mapping.
22
- _SCHEMA_SPECS: list[tuple[str, str]] = [
23
- ("SCU_v1", "FORMAT_SCU_V1"),
24
- ("Default UAP Format", "FORMAT_LONG"),
25
- ("SCU Spreadsheet", "FORMAT_LONG_XLSX"),
26
- ("SCU_v2", "FORMAT_SCU_V2"),
27
- ("SCU_v3", "FORMAT_SCU_V3"),
28
- ("UFOSETI (RU)", "FORMAT_UFOSETI_RU"),
29
- ("NUFORC", "FORMAT_NUFORC"),
30
- ("Blue Book (USAF)", "FORMAT_BLUE_BOOK"),
31
- ("UK National Archives", "FORMAT_UK_NATIONAL_ARCHIVES"),
32
- ("COBEPS — PAN Notifications (BE)", "FORMAT_COBEPS_NOTIFICATIONS_PAN"),
33
- ("COBEPS — COB 2021 (BE)", "FORMAT_COBEPS_COB_2021"),
34
- ("GEP (DE)", "FORMAT_GEP"),
35
- ("UPDB / NICAP", "FORMAT_UPDB_NICAP"),
36
- ("OVNIBASE (FR)", "FORMAT_OVNIBASE"),
37
- ("UFOSETI (raw)", "FORMAT_UFOSETI"),
38
- ("UAP Sightings GitHub", "FORMAT_UAP_SIGHTINGS_GITHUB"),
39
- ("Weinstein Pilot Catalog", "FORMAT_WEINSTEIN_PILOT_CATALOG"),
40
- ("Petrowitsch LATAM", "FORMAT_PETROWITSCH_LATAM"),
41
- ("UFOCAT 2023", "FORMAT_UFOCAT"),
42
- ("CISU / CISUCAT (IT)", "FORMAT_CISU_CISUCAT"),
43
- ("CUFOC 1977 Italy-France", "FORMAT_CUFOC_1977_ITALY_FRANCE"),
44
- ("UAPCHECK Registry", "FORMAT_UAPCHECK"),
45
- ]
46
-
47
- SCHEMA_FORMAT_GROUPS: dict[str, list[str]] = {
48
- "Canonical & SCU": ["SCU_v1", "Default UAP Format", "SCU Spreadsheet", "SCU_v2", "SCU_v3"],
49
- "Government & official archives": ["Blue Book (USAF)", "UK National Archives"],
50
- "European national databases": [
51
- "COBEPS — PAN Notifications (BE)", "COBEPS — COB 2021 (BE)",
52
- "GEP (DE)", "OVNIBASE (FR)", "CISU / CISUCAT (IT)", "CUFOC 1977 Italy-France",
53
- ],
54
- "Research catalogs": [
55
- "UPDB / NICAP", "UFOCAT 2023", "Weinstein Pilot Catalog", "Petrowitsch LATAM",
56
- ],
57
- "Public & crowd-sourced": ["NUFORC", "UAP Sightings GitHub", "UAPCHECK Registry"],
58
- "UFOSETI / SETI": ["UFOSETI (RU)", "UFOSETI (raw)"],
59
- }
60
-
61
-
62
- def _schema_registry() -> dict[str, Any]:
63
- """Map each human label to its schema (dict or JSON string), loaded from config."""
64
- import config
65
-
66
- registry: dict[str, Any] = {}
67
- for label, attr in _SCHEMA_SPECS:
68
- if hasattr(config, attr):
69
- registry[label] = getattr(config, attr)
70
- return registry
71
-
72
-
73
- # ── Schema merge helpers (ported from parsing.py) ──────────────────────────
74
- def _fmt_as_dict(v: Any) -> dict:
75
- """Coerce a schema entry (dict or JSON string) to a dict; {} on failure."""
76
- if isinstance(v, dict):
77
- return v
78
- try:
79
- return json.loads(v)
80
- except Exception:
81
- return {}
82
-
83
-
84
- def _deep_merge(base: dict, override: dict) -> dict:
85
- """Recursively merge ``override`` onto ``base`` without mutating either."""
86
- result = base.copy()
87
- for k, v in override.items():
88
- if k in result and isinstance(result[k], dict) and isinstance(v, dict):
89
- result[k] = _deep_merge(result[k], v)
90
- else:
91
- result[k] = v
92
- return result
93
-
94
-
95
- def _flatten_dotted(d: dict, prefix: str = "") -> dict:
96
- """Flatten a nested dict to {dotted_key: leaf_value}."""
97
- flat: dict[str, Any] = {}
98
- for k, v in d.items():
99
- key = f"{prefix}.{k}" if prefix else str(k)
100
- if isinstance(v, dict) and v:
101
- flat.update(_flatten_dotted(v, key))
102
- else:
103
- flat[key] = v
104
- return flat
105
-
106
-
107
- def list_schemas() -> dict:
108
- """Public: schema labels + logical groupings for the picker."""
109
- registry = _schema_registry()
110
- labels = list(registry.keys())
111
- groups = {g: [c for c in cols if c in registry] for g, cols in SCHEMA_FORMAT_GROUPS.items()}
112
- grouped = {c for cols in groups.values() for c in cols}
113
- ungrouped = [c for c in labels if c not in grouped]
114
- if ungrouped:
115
- groups["Other"] = ungrouped
116
- return {"labels": labels, "groups": groups}
117
-
118
-
119
- def merge_schema(labels: list[str], custom_fields: dict | None = None) -> dict:
120
- """Deep-merge the selected schema labels (plus optional dotted custom fields)
121
- into a single JSON template. Returns the merged dict, its pretty JSON string,
122
- the flattened leaf-field list, and the extraction key for FORMAT_LONG."""
123
- registry = _schema_registry()
124
- merged: dict = {}
125
- for label in labels:
126
- if label not in registry:
127
- raise ValueError(f"Unknown schema: {label!r}")
128
- merged = _deep_merge(merged, _fmt_as_dict(registry[label]))
129
-
130
- if custom_fields:
131
- merged = _deep_merge(merged, custom_fields)
132
-
133
- top_keys = set(merged.keys())
134
- # FORMAT_LONG is the only schema whose sole top-level key is sightingDetails.
135
- extract_key = "sightingDetails" if top_keys == {"sightingDetails"} else None
136
-
137
- flat = _flatten_dotted(merged)
138
- fields = [
139
- {"path": k, "description": v if isinstance(v, str) else json.dumps(v, ensure_ascii=False)}
140
- for k, v in flat.items()
141
- ]
142
- return {
143
- "schema": merged,
144
- "schema_json": json.dumps(merged, indent=2),
145
- "fields": fields,
146
- "extract_key": extract_key,
147
- }
148
-
149
-
150
- # ── Schema ↔ dataset coverage diff ─────────────────────────────────────────
151
- def _norm_token(s: Any) -> str:
152
- """Normalize a name for fuzzy matching: lowercase, alphanumerics only."""
153
- return re.sub(r"[^a-z0-9]", "", str(s).lower())
154
-
155
-
156
- def _leaf(path: str) -> str:
157
- """Last dotted segment of a (possibly nested) field/column name."""
158
- return str(path).split(".")[-1]
159
-
160
-
161
- def schema_coverage(merged: dict, dataset_columns: list[str]) -> dict:
162
- """Compare the merged schema's leaf fields against ``dataset_columns``.
163
-
164
- A schema field counts as *present* when some dataset column shares its
165
- normalized leaf name (last dotted segment, case/punctuation-insensitive) —
166
- e.g. schema ``sightingDetails.objectDescription.shape`` matches a dataset
167
- column ``shape`` or ``object.shape``. Returns per-field present flags, the
168
- dataset columns that matched, and the dataset-only (unmatched) columns.
169
- """
170
- flat = _flatten_dotted(merged)
171
- col_by_token: dict[str, str] = {}
172
- for c in dataset_columns:
173
- col_by_token.setdefault(_norm_token(_leaf(c)), c)
174
-
175
- coverage = []
176
- matched_cols: set[str] = set()
177
- for path in flat:
178
- match = col_by_token.get(_norm_token(_leaf(path)))
179
- if match is not None:
180
- matched_cols.add(match)
181
- coverage.append({
182
- "path": path,
183
- "leaf": _leaf(path),
184
- "present": match is not None,
185
- "matched_column": match,
186
- })
187
-
188
- schema_tokens = {_norm_token(_leaf(p)) for p in flat}
189
- db_only = [c for c in dataset_columns if _norm_token(_leaf(c)) not in schema_tokens]
190
- n_present = sum(1 for c in coverage if c["present"])
191
- return {
192
- "coverage": coverage,
193
- "summary": {
194
- "present": n_present,
195
- "missing": len(coverage) - n_present,
196
- "total": len(coverage),
197
- "db_only": len(db_only),
198
- },
199
- "matched_columns": sorted(matched_cols),
200
- "db_only_columns": db_only,
201
- }
202
-
203
-
204
- def prune_schema_to_paths(merged: dict, paths: list[str]) -> dict:
205
- """Rebuild a nested schema dict holding only the given dotted leaf paths,
206
- preserving each leaf's original description/value and the original nesting
207
- (so a FORMAT_LONG ``sightingDetails`` wrapper is kept)."""
208
- flat = _flatten_dotted(merged)
209
- keep = set(paths)
210
- out: dict = {}
211
- for path, val in flat.items():
212
- if path not in keep:
213
- continue
214
- parts = path.split(".")
215
- node = out
216
- for p in parts[:-1]:
217
- node = node.setdefault(p, {})
218
- node[parts[-1]] = val
219
- return out
220
-
221
-
222
- def schema_coverage_report(labels: list[str], dataset_columns: list[str],
223
- custom_fields: dict | None = None) -> dict:
224
- """Coverage diff plus ready-to-use extraction-schema variants for the three
225
- modes the parsing UI offers:
226
-
227
- - ``all`` → the full merged schema (extract every field).
228
- - ``missing`` → schema pruned to fields absent from the dataset (🔴 only).
229
- - ``database`` → empty schema; keep the dataset columns as-is (no extraction).
230
- """
231
- merged = merge_schema(labels, custom_fields)["schema"]
232
- cov = schema_coverage(merged, dataset_columns)
233
-
234
- missing_paths = [c["path"] for c in cov["coverage"] if not c["present"]]
235
- all_paths = [c["path"] for c in cov["coverage"]]
236
- missing_schema = prune_schema_to_paths(merged, missing_paths)
237
-
238
- variants = {
239
- "all": {"schema_json": json.dumps(merged, indent=2), "n_fields": len(all_paths)},
240
- "missing": {"schema_json": json.dumps(missing_schema, indent=2),
241
- "n_fields": len(missing_paths)},
242
- "database": {"schema_json": "{}", "n_fields": 0},
243
- }
244
- return {**cov, "variants": variants}
245
-
246
-
247
- # ── Cost estimation ────────────────────────────────────────────────────────
248
- def estimate(descriptions: list[str], schema_json: str, model: str,
249
- use_cache: bool = True, use_batch: bool = False) -> dict:
250
- from uap_analyzer import estimate_cost
251
- return estimate_cost(descriptions, schema_json, model=model,
252
- use_cache=use_cache, use_batch=use_batch)
253
-
254
-
255
- def available_models() -> dict:
256
- from uap_analyzer import OPENAI_MODELS, DEEPSEEK_MODELS
257
- return {"openai": list(OPENAI_MODELS), "deepseek": list(DEEPSEEK_MODELS)}
258
-
259
-
260
- # ── Parsed-response → flat DataFrame (ported from convert_cached_data_to_df) ─
261
- def parsed_responses_to_df(parsed_responses: dict):
262
- import pandas as pd
263
-
264
- if not parsed_responses:
265
- return pd.DataFrame()
266
- parsed_df_raw = pd.DataFrame(parsed_responses).T
267
- if set(parsed_df_raw.columns) == {"sightingDetails"}:
268
- df = pd.json_normalize(parsed_df_raw["sightingDetails"].tolist())
269
- df.index = parsed_df_raw.index
270
- else:
271
- df = pd.json_normalize(list(parsed_responses.values()))
272
- df.index = parsed_df_raw.index
273
- for col in df.columns:
274
- if df[col].dtype == "object":
275
- df[col] = df[col].astype(str)
276
- return df
277
-
278
-
279
- # ── Run the client-parallel parse ──────────────────────────────────────────
280
- def run_parse(descriptions: list[str], schema_json: str, *, provider: str,
281
- model: str, api_key: str, max_workers: int = 10,
282
- progress_callback: Callable[[int, int, int], None] | None = None) -> dict:
283
- """Parse a list of raw report texts into structured JSON via OpenAI/DeepSeek.
284
-
285
- Returns a dict with ``parsed_responses`` (keyed by description), the flat
286
- ``df`` (pandas DataFrame), and any ``errors``.
287
- """
288
- from uap_analyzer import UAPParser
289
-
290
- texts = [str(d) for d in descriptions if d is not None and str(d).strip()]
291
- if not texts:
292
- raise ValueError("No non-empty descriptions to parse.")
293
-
294
- parser = UAPParser(
295
- api_key=api_key,
296
- model=model,
297
- provider=("deepseek" if provider == "deepseek" else "openai"),
298
- use_batch=False,
299
- col=texts,
300
- )
301
- parser.process_descriptions(
302
- texts, schema_json, max_workers=max_workers, progress_callback=progress_callback,
303
- )
304
- parsed = parser.parse_responses()
305
- df = parsed_responses_to_df(parsed)
306
- return {
307
- "parsed_responses": parsed,
308
- "df": df,
309
- "errors": list(parser.last_errors),
310
- "n_ok": len(parsed),
311
- "n_total": len(texts),
312
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
api/services/rag_service.py DELETED
@@ -1,51 +0,0 @@
1
- """RAG search service — Cohere ``rerank`` over selected columns of a dataset,
2
- mirroring the "Dataset RAG (Cohere)" mode of the Streamlit rag_search.py page.
3
-
4
- Each row's selected columns are serialized to a JSON document, reranked against
5
- the natural-language query, and returned in relevance order with scores.
6
- """
7
- from __future__ import annotations
8
-
9
- import json
10
- from typing import Any
11
-
12
- # Cohere caps documents per rerank request; we chunk above this and merge.
13
- _RERANK_MODEL = "rerank-english-v3.0"
14
-
15
-
16
- def rerank(df, columns: list[str], question: str, cohere_key: str,
17
- top_n: int = 50) -> dict:
18
- import cohere
19
- import pandas as pd # noqa: F401 (ensures pandas present for callers)
20
-
21
- if not columns:
22
- raise ValueError("Select at least one column to search.")
23
- if not question.strip():
24
- raise ValueError("Enter a question to search for.")
25
- missing = [c for c in columns if c not in df.columns]
26
- if missing:
27
- raise ValueError(f"Columns not found: {', '.join(missing)}")
28
-
29
- documents = [
30
- json.dumps(doc, default=str)
31
- for doc in df[columns].to_dict("records")
32
- ]
33
- if not documents:
34
- raise ValueError("No rows available to search.")
35
-
36
- co = cohere.Client(api_key=cohere_key)
37
- n = min(top_n, len(documents))
38
- results = co.rerank(
39
- model=_RERANK_MODEL,
40
- query=question,
41
- documents=documents,
42
- top_n=n,
43
- return_documents=False,
44
- )
45
- ranked_indices = [r.index for r in results.results]
46
- ranked_scores = [float(r.relevance_score) for r in results.results]
47
-
48
- out = df.iloc[ranked_indices].copy()
49
- out.insert(0, "relevance_score", [round(s, 4) for s in ranked_scores])
50
- out.insert(0, "rank", range(1, len(ranked_indices) + 1))
51
- return {"df": out, "n_results": len(ranked_indices), "searched_columns": columns}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
api/services/scu_service.py DELETED
@@ -1,83 +0,0 @@
1
- """SCU normalization service — wraps ``scu_normalizer`` to canonicalise parsed
2
- UAP responses (country/US-state codes, witness roles, craft shape/size bands)
3
- and derive the SCU five-criterion eligibility gate, plus an eligibility filter
4
- with the same presets as the Streamlit page.
5
- """
6
- from __future__ import annotations
7
-
8
- from typing import Any
9
-
10
-
11
- # Preset → ordered list of criterion keys, mirroring render_scu_filter() in
12
- # parsing.py. Built lazily so importing this module doesn't import the
13
- # normalizer (and its data tables) until a request actually needs it.
14
- def _presets(criteria_keys: list[str]) -> dict[str, list[str] | None]:
15
- post_1975_keys = ["post_1975_window"] + [k for k in criteria_keys if k != "in_scu_window"]
16
- return {
17
- "Full five-criterion gate (1945-1975, recommended)": criteria_keys,
18
- "Post-1975 five-criterion gate (1975 onwards)": post_1975_keys,
19
- "Phase-3 analog — no window / anomaly / credibility gates": [
20
- "has_core_fields", "has_investigation_channel",
21
- "has_engagement_signal", "day_night_resolved", "military_public_known",
22
- ],
23
- "SCU window only (1945-1975)": ["in_scu_window"],
24
- "Post-1975 window only (1975 onwards)": ["post_1975_window"],
25
- "Data quality only — Criterion 2": ["has_core_fields"],
26
- }
27
-
28
-
29
- def criteria_info() -> dict:
30
- """Criterion keys/labels and named presets for the filter UI."""
31
- import scu_normalizer
32
-
33
- criteria = [
34
- {"key": k, "column": c, "label": lbl} for k, c, lbl in scu_normalizer.SCU_CRITERIA
35
- ]
36
- extra = [
37
- {"key": k, "column": c, "label": lbl} for k, c, lbl in scu_normalizer.SCU_EXTRA_CRITERIA
38
- ]
39
- keys = [k for k, _c, _l in scu_normalizer.SCU_CRITERIA]
40
- return {"criteria": criteria, "extra_criteria": extra, "presets": _presets(keys)}
41
-
42
-
43
- def _raw_df_from_parsed(parsed_responses: dict):
44
- import pandas as pd
45
-
46
- return pd.json_normalize(list(parsed_responses.values()))
47
-
48
-
49
- def normalize(parsed_responses: dict) -> dict:
50
- """Run scu_normalizer.normalize() on parsed responses.
51
-
52
- Returns the normalized DataFrame plus the audit dict, its markdown render,
53
- and headline eligibility metrics.
54
- """
55
- import scu_normalizer
56
-
57
- if not parsed_responses:
58
- raise ValueError("No parsed responses to normalize.")
59
- raw_df = _raw_df_from_parsed(parsed_responses)
60
- if raw_df.empty:
61
- raise ValueError("Parsed responses produced an empty DataFrame.")
62
-
63
- norm_df, audit = scu_normalizer.normalize(raw_df)
64
- audit_md = scu_normalizer.audit_to_markdown(audit)
65
-
66
- metrics = {
67
- "rows": int(audit.get("output_rows", len(norm_df))),
68
- "scu_eligible": int(audit.get("scu_eligible_count", 0)),
69
- "in_scu_window": int(audit.get("in_scu_window_count", 0)),
70
- "has_credible_witness": int(audit.get("has_credible_witness_count", 0)),
71
- }
72
- return {"df": norm_df, "audit": audit, "audit_markdown": audit_md, "metrics": metrics}
73
-
74
-
75
- def filter_eligibility(norm_df, criterion_keys: list[str]) -> dict:
76
- """AND the selected SCU criterion columns and return the surviving rows plus
77
- a cumulative funnel (one stage per criterion)."""
78
- import scu_normalizer
79
-
80
- stages, values, mask = scu_normalizer.incremental_funnel(norm_df, criterion_keys)
81
- filtered = norm_df[mask.values] if len(mask) == len(norm_df) else norm_df[mask]
82
- funnel = [{"stage": s, "count": v} for s, v in zip(stages, values)]
83
- return {"df": filtered, "funnel": funnel, "n_passed": int(mask.sum())}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
api/utils/data_utils.py DELETED
@@ -1,186 +0,0 @@
1
- import pandas as pd
2
- import numpy as np
3
- import datetime
4
-
5
- def sanitize_dataframe_for_json(df: pd.DataFrame) -> pd.DataFrame:
6
- """Convert non-JSON-serializable values (datetime, date, time, period, timedelta) to strings."""
7
- df_safe = df.copy()
8
-
9
- # Ensure simple integer index and string column names
10
- df_safe = df_safe.reset_index(drop=True)
11
- df_safe.columns = df_safe.columns.map(str)
12
-
13
- # Datetime columns: export as ISO 8601 strings with millisecond precision, null-safe
14
- for col in df_safe.select_dtypes(include=["datetime64[ns]"]).columns:
15
- try:
16
- dt = pd.to_datetime(df_safe[col], errors='coerce')
17
- # format to ISO with milliseconds (slice microseconds to 3 digits)
18
- iso = dt.dt.strftime('%Y-%m-%dT%H:%M:%S.%f').str.slice(0, 23)
19
- # set None where NaT
20
- iso = iso.where(dt.notna(), None)
21
- df_safe[col] = iso
22
- except Exception:
23
- # Fallback to plain string
24
- df_safe[col] = df_safe[col].astype(object).where(~df_safe[col].isna(), None)
25
-
26
- # Object columns: convert date/time-like objects to strings
27
- def _to_serializable(x):
28
- try:
29
- # Safe null check using a list contact for unhashable types
30
- if x is None or (not isinstance(x, (dict, list)) and pd.isna(x)):
31
- return None
32
- except Exception:
33
- # Fallback if the check itself fails
34
- if x is None:
35
- return None
36
-
37
- if isinstance(x, pd.Timestamp):
38
- try:
39
- return x.strftime('%Y-%m-%d %H:%M:%S')
40
- except Exception:
41
- return None
42
- if isinstance(x, (datetime.datetime, datetime.date)):
43
- return x.isoformat()
44
- if isinstance(x, datetime.time):
45
- return x.strftime('%H:%M:%S')
46
- # If it's a dict or list, it's already serializable by json.dumps if types match
47
- # but we might want to ensure it's clean. For now we leave it for the JSON serializer.
48
- return x
49
-
50
- for col in df_safe.select_dtypes(include=['object']).columns:
51
- try:
52
- # Try efficient mapping
53
- df_safe[col] = df_safe[col].map(_to_serializable)
54
- except (TypeError, Exception):
55
- # Fallback to slower apply or manual loop
56
- try:
57
- df_safe[col] = df_safe[col].apply(_to_serializable)
58
- except Exception:
59
- # Last resort: convert everything to string if mapping fails entirely
60
- df_safe[col] = df_safe[col].astype(str).where(df_safe[col].notna(), None)
61
-
62
- return df_safe
63
-
64
- def detect_and_combine_date_columns(df: pd.DataFrame) -> pd.DataFrame:
65
- """
66
- Detect separate year, month, day, hour columns and combine them into datetime columns.
67
- Removed streamlit logging for API safety.
68
- """
69
- df_combined = df.copy()
70
-
71
- year_cols = [col for col in df.columns if any(pattern in col.lower() for pattern in ['year', 'yr', 'yyyy'])]
72
- month_cols = [col for col in df.columns if any(pattern in col.lower() for pattern in ['month', 'mon', 'mm'])]
73
- day_cols = [col for col in df.columns if any(pattern in col.lower() for pattern in ['day', 'dd', 'date'])]
74
- hour_cols = [col for col in df.columns if any(pattern in col.lower() for pattern in ['hour', 'hr', 'hh', 'time'])]
75
-
76
- date_combinations = []
77
-
78
- for year_col in year_cols:
79
- for month_col in month_cols:
80
- for day_col in day_cols:
81
- try:
82
- sample_size = min(10, len(df))
83
- test_sample = df.iloc[:sample_size]
84
-
85
- years = pd.to_numeric(test_sample[year_col], errors='coerce')
86
- months = pd.to_numeric(test_sample[month_col], errors='coerce')
87
- days = pd.to_numeric(test_sample[day_col], errors='coerce')
88
-
89
- valid_years = years.between(1900, 2100).all()
90
- valid_months = months.between(1, 12).all()
91
- valid_days = days.between(1, 31).all()
92
-
93
- if valid_years and valid_months and valid_days:
94
- combination = {'year': year_col, 'month': month_col, 'day': day_col, 'hour': None}
95
- for hour_col in hour_cols:
96
- try:
97
- hours = pd.to_numeric(test_sample[hour_col], errors='coerce')
98
- if hours.between(0, 23).all():
99
- combination['hour'] = hour_col
100
- break
101
- except:
102
- continue
103
- date_combinations.append(combination)
104
- except Exception:
105
- continue
106
-
107
- for i, combo in enumerate(date_combinations):
108
- try:
109
- datetime_col_name = f"datetime_combined_{i+1}"
110
- year = pd.to_numeric(df_combined[combo['year']], errors='coerce')
111
- month = pd.to_numeric(df_combined[combo['month']], errors='coerce')
112
- day = pd.to_numeric(df_combined[combo['day']], errors='coerce')
113
-
114
- if combo['hour']:
115
- hour = pd.to_numeric(df_combined[combo['hour']], errors='coerce')
116
- df_combined[datetime_col_name] = pd.to_datetime({'year': year, 'month': month, 'day': day, 'hour': hour}, errors='coerce')
117
- else:
118
- df_combined[datetime_col_name] = pd.to_datetime({'year': year, 'month': month, 'day': day}, errors='coerce')
119
- except Exception:
120
- continue
121
-
122
- return df_combined
123
-
124
- def auto_create_date_column(df: pd.DataFrame) -> pd.DataFrame:
125
- """Automatically create a unified datetime column `date_x` for mapping."""
126
- df_auto = df.copy()
127
-
128
- if 'date_x' in df_auto.columns:
129
- try:
130
- parsed = pd.to_datetime(df_auto['date_x'], errors='coerce')
131
- if parsed.notna().sum() > 0:
132
- df_auto['date_x'] = parsed
133
- return df_auto
134
- except Exception:
135
- pass
136
-
137
- candidate_cols = [c for c in df_auto.columns if any(k in c.lower() for k in ['date', 'datetime', 'timestamp'])]
138
- candidate_cols.extend([c for c in df_auto.select_dtypes(include=["datetime64[ns]"]).columns if c not in candidate_cols])
139
-
140
- best_col = None
141
- best_valid = -1
142
- for col in candidate_cols:
143
- try:
144
- parsed = pd.to_datetime(df_auto[col], errors='coerce')
145
- valid = parsed.notna().sum()
146
- if valid > best_valid:
147
- best_valid = valid
148
- best_col = col
149
- except Exception:
150
- continue
151
-
152
- if best_col is not None and best_valid > 0:
153
- try:
154
- df_auto['date_x'] = pd.to_datetime(df_auto[best_col], errors='coerce')
155
- try:
156
- df_auto['date_x'] = df_auto['date_x'].dt.tz_localize(None)
157
- except Exception:
158
- pass
159
- df_auto['date_x'] = df_auto['date_x'].dt.floor('ms')
160
- return df_auto
161
- except Exception:
162
- pass
163
-
164
- df_combined = detect_and_combine_date_columns(df_auto)
165
- combined_cols = [c for c in df_combined.columns if c.startswith('datetime_combined_')]
166
- best_combined = None
167
- best_combined_valid = -1
168
- for col in combined_cols:
169
- try:
170
- valid = df_combined[col].notna().sum()
171
- if valid > best_combined_valid:
172
- best_combined_valid = valid
173
- best_combined = col
174
- except Exception:
175
- continue
176
-
177
- if best_combined is not None and best_combined_valid > 0:
178
- df_combined['date_x'] = pd.to_datetime(df_combined[best_combined], errors='coerce')
179
- try:
180
- df_combined['date_x'] = df_combined['date_x'].dt.tz_localize(None)
181
- except Exception:
182
- pass
183
- df_combined['date_x'] = df_combined['date_x'].dt.floor('ms')
184
- return df_combined
185
-
186
- return df_auto
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app.py CHANGED
@@ -1,4 +1,108 @@
1
  import streamlit as st
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  st.set_page_config(
4
  page_title="UAP Analytics",
@@ -7,6 +111,15 @@ st.set_page_config(
7
  initial_sidebar_state="expanded",
8
  )
9
 
 
 
 
 
 
 
 
 
 
10
  from PIL import Image
11
  import base64
12
 
@@ -28,30 +141,14 @@ def set_png_as_page_bg(png_file):
28
  st.markdown(page_bg_img, unsafe_allow_html=True)
29
 
30
 
31
- # if st.toggle('Set background image', True):
32
- # set_png_as_page_bg('saucer.webp') # Replace with your background image path
33
-
34
- # Global pipeline option — read by parsing.py to optionally post-process
35
- # parsed UAP data through the SCU v2 normalizer.
36
- st.sidebar.toggle(
37
- 'Apply SCU normalization to parsed data',
38
- value=False,
39
- key='scu_normalize_enabled',
40
- help=(
41
- 'When enabled, the UAP Feature Extraction page runs the SCU v2 '
42
- 'normalizer on parsed results — canonicalising countries, states, '
43
- 'witness roles and craft fields, and deriving the SCU five-criterion '
44
- 'eligibility gate. Adds a normalized CSV and audit report to download.'
45
- ),
46
- )
47
 
48
  pg = st.navigation([
49
- st.Page("preprocessing.py", title="Document Preprocessing (Scrape → OCR → Reports → Table)", icon="🧪"),
50
  st.Page("rag_search.py", title="Smart-Search (Retrieval Augmented Generations)", icon="🔍"),
51
  st.Page("parsing.py", title="UAP Feature Extraction (Shape, Speed, Color)", icon="📄"),
52
  st.Page("analyzing.py", title="Statistical Analysis (UMAP+HDBSCAN, XGBoost, V-Cramer)", icon="🧠"),
53
  st.Page("magnetic.py", title="Magnetic Anomaly Detection (InterMagnet Stations)", icon="🧲"),
54
  st.Page("map.py", title="Interactive Map (Tracking variations, Proximity with Military Bases, Nuclear Facilities)", icon="🗺️"),
55
- st.Page("pdf_ocr.py", title="PDF OCR — Table Extractor (LightOnOCR)", icon="📑"),
56
  ])
57
  pg.run()
 
1
  import streamlit as st
2
+ from st_paywall import add_auth
3
+ from st_paywall.google_auth import get_logged_in_user_email, show_login_button
4
+ from st_paywall.stripe_auth import is_active_subscriber, redirect_button
5
+ from st_paywall.buymeacoffee_auth import get_bmac_payers
6
+ import st_paywall.google_auth as google_auth
7
+ import st_paywall.stripe_auth as stripe_auth
8
+
9
+ payment_provider = st.secrets.get("payment_provider", "stripe")
10
+
11
+ def add_auth(
12
+ required: bool = True,
13
+ login_button_text: str = "Login with Google",
14
+ login_button_color: str = "#FD504D",
15
+ login_sidebar: bool = True,
16
+ subscribe_color_button = "#FFA500",
17
+
18
+ ):
19
+ if required:
20
+ require_auth(
21
+ login_button_text=login_button_text,
22
+ login_sidebar=login_sidebar,
23
+ login_button_color=login_button_color,
24
+ )
25
+ else:
26
+ optional_auth(
27
+ login_button_text=login_button_text,
28
+ login_sidebar=login_sidebar,
29
+ login_button_color=login_button_color,
30
+ )
31
+
32
+ def require_auth(
33
+ login_button_text: str = "Login with Google",
34
+ login_button_color: str = "#FD504D",
35
+ subscribe_button_color: str = "#FFA500",
36
+ login_sidebar: bool = True,):
37
+
38
+ user_email = get_logged_in_user_email()
39
+
40
+ if not user_email:
41
+ show_login_button(
42
+ text=login_button_text, color=login_button_color, sidebar=login_sidebar
43
+ )
44
+ st.stop()
45
+ if payment_provider == "stripe":
46
+ is_subscriber = user_email and is_active_subscriber(user_email)
47
+ elif payment_provider == "bmac":
48
+ is_subscriber = user_email and user_email in get_bmac_payers()
49
+ else:
50
+ raise ValueError("payment_provider must be 'stripe' or 'bmac'")
51
+
52
+ if not is_subscriber:
53
+ redirect_button(
54
+ text="Make a Donation",
55
+ customer_email=user_email,
56
+ payment_provider=payment_provider,
57
+ )
58
+ st.session_state.user_subscribed = False
59
+ st.stop()
60
+ elif is_subscriber:
61
+ st.session_state.user_subscribed = True
62
+
63
+ if st.sidebar.button("Logout", type="primary"):
64
+ del st.session_state.email
65
+ del st.session_state.user_subscribed
66
+ st.rerun()
67
+
68
+ def optional_auth(
69
+ login_button_text: str = "Login with Google",
70
+ login_button_color: str = "#FD504D",
71
+ login_sidebar: bool = True,
72
+ subscribe_button_color: str = "#FFA500", # Add this line
73
+ ):
74
+ user_email = get_logged_in_user_email()
75
+ if payment_provider == "stripe":
76
+ is_subscriber = user_email and is_active_subscriber(user_email)
77
+ elif payment_provider == "bmac":
78
+ is_subscriber = user_email and user_email in get_bmac_payers()
79
+ else:
80
+ raise ValueError("payment_provider must be 'stripe' or 'bmac'")
81
+
82
+ if not user_email:
83
+ show_login_button(
84
+ text=login_button_text, color=login_button_color, sidebar=login_sidebar
85
+ )
86
+ st.session_state.email = ""
87
+ st.sidebar.markdown("")
88
+
89
+ if not is_subscriber:
90
+ redirect_button(
91
+ text="Subscribe now!", customer_email="", payment_provider=payment_provider, color=subscribe_button_color
92
+ )
93
+ st.sidebar.markdown("")
94
+ st.session_state.user_subscribed = False
95
+
96
+ elif is_subscriber:
97
+ st.session_state.user_subscribed = True
98
+
99
+ if st.session_state.email != "":
100
+ if st.sidebar.button("Logout", type="primary"):
101
+ del st.session_state.email
102
+ del st.session_state.user_subscribed
103
+ st.rerun()
104
+
105
+
106
 
107
  st.set_page_config(
108
  page_title="UAP Analytics",
 
111
  initial_sidebar_state="expanded",
112
  )
113
 
114
+ add_auth(required=False, login_button_color="#FFA500",subscribe_color_button="#FFA500")
115
+
116
+ if st.session_state.email is not '':
117
+ st.write('')
118
+ st.write(f'User: {st.session_state.email}')
119
+
120
+ if "buttons" in st.session_state:
121
+ st.session_state.buttons = st.session_state.buttons
122
+
123
  from PIL import Image
124
  import base64
125
 
 
141
  st.markdown(page_bg_img, unsafe_allow_html=True)
142
 
143
 
144
+ if st.toggle('Set background image', True):
145
+ set_png_as_page_bg('saucer.webp') # Replace with your background image path
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
 
147
  pg = st.navigation([
 
148
  st.Page("rag_search.py", title="Smart-Search (Retrieval Augmented Generations)", icon="🔍"),
149
  st.Page("parsing.py", title="UAP Feature Extraction (Shape, Speed, Color)", icon="📄"),
150
  st.Page("analyzing.py", title="Statistical Analysis (UMAP+HDBSCAN, XGBoost, V-Cramer)", icon="🧠"),
151
  st.Page("magnetic.py", title="Magnetic Anomaly Detection (InterMagnet Stations)", icon="🧲"),
152
  st.Page("map.py", title="Interactive Map (Tracking variations, Proximity with Military Bases, Nuclear Facilities)", icon="🗺️"),
 
153
  ])
154
  pg.run()
app2.py CHANGED
@@ -6,7 +6,9 @@ import umap
6
  import plotly.graph_objects as go
7
  from sentence_transformers import SentenceTransformer
8
  import torch
9
- from uap_analyzer import get_embed_model
 
 
10
  from sentence_transformers.util import pytorch_cos_sim, pairwise_cos_sim
11
  #from stqdm.notebook import stqdm
12
  #stqdm.pandas()
@@ -53,7 +55,8 @@ import os
53
  import time
54
  import concurrent.futures
55
  from requests.exceptions import HTTPError
56
- from tqdm import tqdm
 
57
  import json
58
  import pandas as pd
59
  from openai import OpenAI
@@ -116,7 +119,8 @@ class UAPAnalyzer:
116
  self.y_test = None
117
  self.preds = None
118
  self.new_dataset = None
119
- self.model = None # lazy-loaded via get_embed_model() on first use
 
120
  #self.cluster_names_ = pd.DataFrame()
121
 
122
  logging.info("UAPAnalyzer initialized")
@@ -160,7 +164,7 @@ class UAPAnalyzer:
160
  """
161
  logging.info("Extracting embeddings")
162
  # convert to str
163
- return get_embed_model().encode(data_column.tolist(), show_progress_bar=True)
164
 
165
  @spaces.GPU
166
  def reduce_dimensionality(self, method='UMAP', n_components=2, **kwargs):
@@ -283,7 +287,7 @@ class UAPAnalyzer:
283
  merge_mapping[name1].add(name2)
284
 
285
  elif distance == 'cosine':
286
- self.cluster_terms_embeddings = get_embed_model().encode(self.cluster_terms)
287
  cos_sim_matrix = pytorch_cos_sim(self.cluster_terms_embeddings, self.cluster_terms_embeddings)
288
  for i, name1 in enumerate(self.cluster_terms):
289
  for j, name2 in enumerate(self.cluster_terms[i + 1:], start=i + 1):
@@ -343,7 +347,7 @@ class UAPAnalyzer:
343
 
344
  elif distance == 'cosine':
345
  if self.cluster_terms_embeddings is None:
346
- self.cluster_terms_embeddings = get_embed_model().encode(self.cluster_terms)
347
  cos_sim_matrix = pytorch_cos_sim(self.cluster_terms_embeddings, self.cluster_terms_embeddings)
348
  for i in range(len(self.cluster_terms)):
349
  for j in range(i + 1, len(self.cluster_terms)):
@@ -391,7 +395,7 @@ class UAPAnalyzer:
391
  def cluster_cosine(self, cluster_terms, cluster_labels, similarity_threshold):
392
  from sklearn.metrics.pairwise import cosine_similarity
393
 
394
- cluster_terms_embeddings = get_embed_model().encode(cluster_terms)
395
  # Compute cosine similarity matrix in a vectorized form
396
  cos_sim_matrix = cosine_similarity(cluster_terms_embeddings, cluster_terms_embeddings)
397
 
@@ -968,7 +972,7 @@ class UAPParser:
968
  with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
969
  future_to_desc = {executor.submit(self.fetch_response, desc, format_long): desc for desc in descriptions}
970
 
971
- for future in tqdm(concurrent.futures.as_completed(future_to_desc), total=len(descriptions)):
972
  desc = future_to_desc[future]
973
  try:
974
  response = future.result()
@@ -1016,12 +1020,13 @@ import seaborn as sns
1016
  from Levenshtein import distance
1017
  from sklearn.model_selection import train_test_split
1018
  from sklearn.metrics import confusion_matrix
1019
- from tqdm import tqdm
 
1020
  import streamlit.components.v1 as components
1021
  from dateutil import parser
1022
  from sentence_transformers import SentenceTransformer
1023
  import torch
1024
- # st.set_option('deprecation.showPyplotGlobalUse', False)
1025
 
1026
 
1027
  from pandas.api.types import (
@@ -1063,7 +1068,7 @@ def gemini_query(question, selected_data, gemini_key):
1063
  context = '\n'.join(filtered)
1064
 
1065
  genai.configure(api_key=gemini_key)
1066
- query_model = genai.GenerativeModel('models/gemini-3.1-pro-preview')
1067
  response = query_model.generate_content([f"{question}\n Answer based on this context: {context}\n\n"])
1068
  return(response.text)
1069
 
 
6
  import plotly.graph_objects as go
7
  from sentence_transformers import SentenceTransformer
8
  import torch
9
+ with torch.no_grad():
10
+ embed_model = SentenceTransformer('embaas/sentence-transformers-e5-large-v2')
11
+ embed_model.to('cuda')
12
  from sentence_transformers.util import pytorch_cos_sim, pairwise_cos_sim
13
  #from stqdm.notebook import stqdm
14
  #stqdm.pandas()
 
55
  import time
56
  import concurrent.futures
57
  from requests.exceptions import HTTPError
58
+ from stqdm import stqdm
59
+ stqdm.pandas()
60
  import json
61
  import pandas as pd
62
  from openai import OpenAI
 
119
  self.y_test = None
120
  self.preds = None
121
  self.new_dataset = None
122
+ self.model = SentenceTransformer('embaas/sentence-transformers-e5-large-v2')
123
+ self.model = self.model.to('cuda')
124
  #self.cluster_names_ = pd.DataFrame()
125
 
126
  logging.info("UAPAnalyzer initialized")
 
164
  """
165
  logging.info("Extracting embeddings")
166
  # convert to str
167
+ return embed_model.encode(data_column.tolist(), show_progress_bar=True)
168
 
169
  @spaces.GPU
170
  def reduce_dimensionality(self, method='UMAP', n_components=2, **kwargs):
 
287
  merge_mapping[name1].add(name2)
288
 
289
  elif distance == 'cosine':
290
+ self.cluster_terms_embeddings = embed_model.encode(self.cluster_terms)
291
  cos_sim_matrix = pytorch_cos_sim(self.cluster_terms_embeddings, self.cluster_terms_embeddings)
292
  for i, name1 in enumerate(self.cluster_terms):
293
  for j, name2 in enumerate(self.cluster_terms[i + 1:], start=i + 1):
 
347
 
348
  elif distance == 'cosine':
349
  if self.cluster_terms_embeddings is None:
350
+ self.cluster_terms_embeddings = embed_model.encode(self.cluster_terms)
351
  cos_sim_matrix = pytorch_cos_sim(self.cluster_terms_embeddings, self.cluster_terms_embeddings)
352
  for i in range(len(self.cluster_terms)):
353
  for j in range(i + 1, len(self.cluster_terms)):
 
395
  def cluster_cosine(self, cluster_terms, cluster_labels, similarity_threshold):
396
  from sklearn.metrics.pairwise import cosine_similarity
397
 
398
+ cluster_terms_embeddings = embed_model.encode(cluster_terms)
399
  # Compute cosine similarity matrix in a vectorized form
400
  cos_sim_matrix = cosine_similarity(cluster_terms_embeddings, cluster_terms_embeddings)
401
 
 
972
  with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
973
  future_to_desc = {executor.submit(self.fetch_response, desc, format_long): desc for desc in descriptions}
974
 
975
+ for future in stqdm(concurrent.futures.as_completed(future_to_desc), total=len(descriptions)):
976
  desc = future_to_desc[future]
977
  try:
978
  response = future.result()
 
1020
  from Levenshtein import distance
1021
  from sklearn.model_selection import train_test_split
1022
  from sklearn.metrics import confusion_matrix
1023
+ from stqdm import stqdm
1024
+ stqdm.pandas()
1025
  import streamlit.components.v1 as components
1026
  from dateutil import parser
1027
  from sentence_transformers import SentenceTransformer
1028
  import torch
1029
+ st.set_option('deprecation.showPyplotGlobalUse', False)
1030
 
1031
 
1032
  from pandas.api.types import (
 
1068
  context = '\n'.join(filtered)
1069
 
1070
  genai.configure(api_key=gemini_key)
1071
+ query_model = genai.GenerativeModel('models/gemini-1.5-pro-latest')
1072
  response = query_model.generate_content([f"{question}\n Answer based on this context: {context}\n\n"])
1073
  return(response.text)
1074
 
claude_app_analysis.md DELETED
@@ -1,178 +0,0 @@
1
- # UAP Analysis Tool — Improvement & Debugging Report
2
-
3
- ## 1. Critical Bugs
4
-
5
- ### 1.1 Variable Name Typo in Clustering (`uap_analyzer.py:316`)
6
- `self.clusters_labels` is assigned instead of `self.cluster_labels`, creating a new attribute and silently breaking downstream cluster references.
7
-
8
- ### 1.2 Bare `except:` Clauses (`uap_analyzer.py:987-990, 792`)
9
- Multiple bare `except:` blocks swallow all exceptions including `KeyboardInterrupt` and `SystemExit`. These should catch specific exceptions (`json.JSONDecodeError`, `ValueError`, etc.).
10
-
11
- ### 1.3 Race Condition in Concurrent Parsing (`uap_analyzer.py:966-978`)
12
- `ThreadPoolExecutor(max_workers=32)` writes to a shared `self.responses` dict without any locking mechanism. This can corrupt data under load.
13
-
14
- ### 1.4 Division by Zero in Cramér's V (`uap_analyzer.py:758-761`)
15
- ```python
16
- return np.sqrt(phi2corr / min((k_corr-1), (r_corr-1)))
17
- ```
18
- If either dimension is 1, the denominator is 0. No guard exists.
19
-
20
- ---
21
-
22
- ## 2. Error Handling Gaps
23
-
24
- | Area | Issue |
25
- |------|-------|
26
- | **GPU fallback** | No `try/except` around CUDA operations; crashes if GPU unavailable |
27
- | **Empty DataFrames** | No validation before passing to UMAP, HDBSCAN, or XGBoost |
28
- | **Single-valued columns** | Clustering and correlation break on columns with only one unique value |
29
- | **API timeouts** | No connection/read timeouts on Gemini, OpenAI, or INTERMAGNET calls |
30
- | **HDF5 loading** | Backend assumes the HDF5 file exists at a fixed path with no fallback |
31
- | **File uploads** | No size limits enforced; no validation of CSV/Excel structure |
32
- | **Regex injection** | User text input passed directly to `str.contains()` without `re.escape()` |
33
-
34
- ---
35
-
36
- ## 3. Architecture & Code Quality
37
-
38
- ### 3.1 God Object Anti-Pattern
39
- `UAPAnalyzer` handles embedding, dimensionality reduction, clustering, TF-IDF naming, XGBoost classification, and Cramér's V correlation all in one class. Consider splitting into:
40
- - `EmbeddingService`
41
- - `ClusteringPipeline`
42
- - `StatisticalAnalyzer`
43
- - `FeatureImportanceAnalyzer`
44
-
45
- ### 3.2 Duplicate Methods
46
- `merge_similar_clusters()` and `merge_similar_clusters2()` (lines 263-356) are near-identical. Consolidate into a single parameterized method.
47
-
48
- ### 3.3 In-Memory State Management (backend/main.py)
49
- The backend stores all session state in a plain Python dict. This means:
50
- - No persistence across server restarts
51
- - No multi-user isolation
52
- - Memory grows unbounded with concurrent sessions
53
-
54
- ### 3.4 Hardcoded Data Truncation (`streamlit_uap_clean.py:239`)
55
- `.head(10000)` silently drops rows beyond 10k. Users are not warned about data loss.
56
-
57
- ### 3.5 Wildcard CORS (`backend/main.py:30`)
58
- `allow_origins=["*"]` allows any domain to call the API. Should be restricted to the frontend origin.
59
-
60
- ---
61
-
62
- ## 4. Data Analysis Feature Improvements
63
-
64
- ### 4.1 Temporal Analysis
65
- - **Time-series decomposition**: Detect seasonal and trend components in sighting frequency over time (e.g., monthly/yearly cycles).
66
- - **Change-point detection**: Identify statistically significant shifts in sighting patterns using algorithms like PELT or Bayesian Online Change Point Detection.
67
- - **Temporal clustering**: Group sightings by time windows and compare feature distributions across eras.
68
-
69
- ### 4.2 Enhanced Geospatial Analysis
70
- - **Spatial autocorrelation** (Moran's I): Quantify whether sightings cluster geographically beyond random chance.
71
- - **Kernel density estimation**: Generate continuous heatmaps instead of discrete point maps.
72
- - **Proximity analysis**: Correlate sighting density with distance to military bases, nuclear plants, airports, and flight corridors.
73
- - **Voronoi tessellation**: Partition geography into regions of influence per cluster.
74
-
75
- ### 4.3 Advanced Clustering
76
- - **Silhouette score / Davies-Bouldin index**: Automatically evaluate cluster quality and suggest optimal `min_cluster_size`.
77
- - **Hierarchical HDBSCAN tree**: Expose the cluster hierarchy for interactive drill-down.
78
- - **Ensemble clustering**: Combine HDBSCAN + KMeans + spectral clustering via consensus for more robust assignments.
79
- - **Outlier analysis**: Surface and profile noise points (HDBSCAN label `-1`) instead of discarding them.
80
-
81
- ### 4.4 Natural Language & Text Mining
82
- - **Topic modeling** (BERTopic / LDA): Extract latent themes from witness descriptions beyond TF-IDF keywords.
83
- - **Sentiment analysis**: Score witness reports for emotional intensity, fear, certainty, etc.
84
- - **Named entity extraction**: Pull out specific aircraft types, locations, agencies, and dates from free text.
85
- - **Cross-report similarity network**: Build a graph of similar reports and detect communities.
86
-
87
- ### 4.5 Statistical Rigor
88
- - **Multiple hypothesis correction**: Apply Bonferroni or FDR correction to chi-squared tests across many column pairs.
89
- - **Effect size reporting**: Report Cohen's w alongside p-values for contingency tests.
90
- - **Confidence intervals**: Add bootstrap CIs to feature importance scores and cluster statistics.
91
- - **Bayesian alternatives**: Offer Bayesian correlation and classification as alternatives to frequentist methods.
92
-
93
- ### 4.6 Interactive Exploration
94
- - **Linked brushing**: Selecting points on a scatter plot should filter the data table, map, and histograms simultaneously.
95
- - **Drill-down from clusters**: Click a cluster to view its members, top features, and representative reports.
96
- - **Comparison mode**: Side-by-side analysis of two clusters or two time periods.
97
- - **Custom derived columns**: Let users create calculated fields (e.g., `duration_minutes / distance_km`).
98
-
99
- ### 4.7 Export & Reporting
100
- - **PDF/HTML report generation**: One-click export of the full analysis pipeline with charts and summary text.
101
- - **Reproducibility logs**: Record all parameter choices (UMAP neighbors, cluster size, etc.) so analyses can be replicated.
102
- - **Data export**: Export filtered/clustered data as CSV with cluster labels and embeddings.
103
-
104
- ---
105
-
106
- ## 5. Performance Improvements
107
-
108
- | Bottleneck | Current | Suggested |
109
- |------------|---------|-----------|
110
- | Embedding computation | CPU fallback is slow for >5k rows | Batch with `encode(batch_size=256)`, cache embeddings to disk |
111
- | UMAP on large datasets | O(n log n), no progress feedback | Use `umap.parametric_umap` or pre-reduce with PCA to 50 dims first |
112
- | XGBoost training | Single-threaded default | Set `nthread=-1`, use `early_stopping_rounds` |
113
- | TF-IDF vectorization | Rebuilds on every run | Cache vectorizer and matrix in session state |
114
- | HDF5 loading | Loads full 1.8GB file into memory | Use `pd.read_hdf()` with `where` clause for lazy loading |
115
- | Frontend re-renders | Full data sent on every filter | Implement server-side pagination and send only visible rows |
116
-
117
- ---
118
-
119
- ## 6. Testing (Currently Zero Coverage)
120
-
121
- ### Priority Test Targets
122
- 1. **Clustering pipeline**: empty input, single row, all-null columns, single-valued features
123
- 2. **API endpoints**: request validation, error responses, concurrent requests
124
- 3. **Statistical functions**: known-answer tests for Cramér's V, chi-squared, feature importance
125
- 4. **Data loading**: corrupted files, missing columns, encoding issues, oversized uploads
126
- 5. **Frontend components**: render tests, API error states, filter interactions
127
-
128
- ### Suggested Setup
129
- - `pytest` + `pytest-cov` for backend
130
- - `vitest` + `@testing-library/react` for frontend
131
- - CI pipeline via GitHub Actions
132
-
133
- ---
134
-
135
- ## 7. Security
136
-
137
- - **API keys**: Entered via text input and stored in session state in plaintext. Use environment variables or a secrets manager.
138
- - **CORS**: Wildcard `*` origin should be replaced with explicit frontend URL.
139
- - **Input sanitization**: User-provided regex and column names should be escaped before use in queries.
140
- - **Rate limiting**: No rate limiting on API endpoints; vulnerable to abuse.
141
- - **Dependency pinning**: All requirements use `>=` with no upper bounds, risking breaking changes on install.
142
-
143
- ---
144
-
145
- ## 8. Dependency Cleanup
146
-
147
- | Package | Status |
148
- |---------|--------|
149
- | `st-paywall>=0.1.8` | Unused — remove |
150
- | `cohere>=5.5.8` | Imported but never called — remove or integrate |
151
- | `protobuf>=4.25.3` | Transitive dependency conflict risk — pin version |
152
- | `sentence_transformers` | Two different models loaded (`all-mpnet-base-v2` and `e5-large-v2`) — standardize on one |
153
-
154
- ---
155
-
156
- ## 9. Logging & Observability
157
-
158
- - Replace all `print()` statements with Python `logging` module.
159
- - Add structured logging with context (user session, operation, duration).
160
- - Instrument key operations (embedding time, clustering time, API latency) with timing metrics.
161
- - Add health check endpoint that validates dependencies (GPU, model files, HDF5 availability).
162
-
163
- ---
164
-
165
- ## Summary: Top 10 Action Items
166
-
167
- | # | Priority | Action |
168
- |---|----------|--------|
169
- | 1 | Critical | Fix `clusters_labels` typo → `cluster_labels` |
170
- | 2 | Critical | Replace bare `except:` with specific exception types |
171
- | 3 | Critical | Add thread-safe locking for concurrent parsing |
172
- | 4 | High | Add division-by-zero guard in Cramér's V |
173
- | 5 | High | Restrict CORS to frontend origin |
174
- | 6 | High | Add unit tests for core pipeline |
175
- | 7 | Medium | Split `UAPAnalyzer` into focused services |
176
- | 8 | Medium | Implement temporal and geospatial analysis features |
177
- | 9 | Medium | Add proper logging and performance instrumentation |
178
- | 10 | Low | Clean up unused dependencies |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
codex_app_analysis.md DELETED
@@ -1,148 +0,0 @@
1
- # UAP Analysis App: Improvement and Debugging Notes
2
-
3
- ## Scope Reviewed
4
- - Backend API: `backend/main.py`
5
- - Frontend app/store/pages: `frontend/src/**`
6
- - Project positioning/features: `README.md`
7
-
8
- ## High-Priority Debugging and Reliability Issues
9
-
10
- ### 1) Global in-memory backend state is shared across all users/sessions
11
- - File: `backend/main.py`
12
- - Current behavior: `state` is a single process-level dictionary storing dataset, filtered data, analysis results, and query context.
13
- - Risk:
14
- - Cross-user data leakage.
15
- - Race conditions (one user can overwrite another user’s data mid-session).
16
- - Non-deterministic behavior in multi-worker deployment.
17
- - Improvement:
18
- - Introduce per-session/project IDs and isolate state in Redis or a DB cache keyed by session.
19
- - Add dataset ownership + TTL cleanup.
20
-
21
- ### 2) Dashboard “Analysis Runs” counter is effectively broken
22
- - File: `backend/main.py`
23
- - Current behavior: `/api/dashboard/summary` reports `analyzed_columns` from `state["col_names"]`, but `col_names` is never populated in `run_analysis`.
24
- - Impact: dashboard can show incorrect/always-zero run stats.
25
- - Improvement:
26
- - Set `state["col_names"] = req.columns` (validated columns only) during analysis.
27
- - Consider storing analysis timestamp and run count.
28
-
29
- ### 3) Numeric filtering ignores partial ranges
30
- - Files: `backend/main.py`, `frontend/src/components/data/FilterPanel.tsx`
31
- - Current behavior: numeric filter only applies when both `min_val` and `max_val` are provided.
32
- - Impact: user-provided minimum-only or maximum-only constraints do nothing.
33
- - Improvement:
34
- - Support all combinations: min-only, max-only, and bounded range.
35
-
36
- ### 4) Filtering error handling is silent on frontend
37
- - File: `frontend/src/components/data/DataExplorer.tsx`
38
- - Current behavior: `handleFilter` catches errors and suppresses them.
39
- - Impact: user cannot tell whether filtering failed or returned no matches.
40
- - Improvement:
41
- - Surface backend error in UI (same pattern used by upload/load errors).
42
-
43
- ### 5) Backend query endpoint has expensive prompt construction and token-risk
44
- - File: `backend/main.py`
45
- - Current behavior: concatenates up to 500 rows of full text into one prompt string.
46
- - Risks:
47
- - Large latency/cost spikes.
48
- - Prompt truncation or model failure on long text columns.
49
- - Improvement:
50
- - Add token-aware chunking/sampling and optional map-reduce summarization.
51
- - Enforce max characters/tokens per request with clear user feedback.
52
-
53
- ### 6) CORS config is overly permissive and potentially invalid for credentials
54
- - File: `backend/main.py`
55
- - Current behavior: `allow_origins=["*"]` with `allow_credentials=True`.
56
- - Risk: browser credential behavior can be inconsistent; security posture is weak for production.
57
- - Improvement:
58
- - Use explicit origin allowlist per environment.
59
- - Keep credentials disabled unless required.
60
-
61
- ## Data-Analysis Quality Gaps (Core Product)
62
-
63
- ### 7) Analysis pipeline is currently mock/simulated, not aligned with README claims
64
- - File: `backend/main.py`
65
- - Current behavior: analysis uses value counts + random 2D points + correlation proxy for “XGBoost-like” output.
66
- - Impact: users may interpret synthetic outputs as real model outputs.
67
- - Improvement:
68
- - Label this mode explicitly as `demo/mock` in API/UI.
69
- - Add a production pipeline path using real embeddings + UMAP/HDBSCAN + trained model artifacts.
70
-
71
- ### 8) Cluster assignment is too coarse for high-cardinality text fields
72
- - File: `backend/main.py`
73
- - Current behavior: top 32 frequent values become “clusters”; all others mapped to `Other`.
74
- - Impact: weak signal extraction for long-tail UAP narratives.
75
- - Improvement:
76
- - Use embedding-based similarity clustering with min-cluster-size tuning.
77
- - Keep top terms as labels only, not as cluster definitions.
78
-
79
- ### 9) “XGBoost” results are correlation-based placeholders
80
- - File: `backend/main.py`
81
- - Current behavior: feature importance derived from absolute correlation among category codes, with random “accuracy”.
82
- - Impact: misleading ML interpretation.
83
- - Improvement:
84
- - Either rename section (`Association Importance`) or run real train/validation with metrics and confidence intervals.
85
-
86
- ### 10) Cramer’s V stability safeguards are minimal
87
- - File: `backend/main.py`
88
- - Current behavior: exceptions are swallowed to `0.0` values.
89
- - Impact: matrix can hide data-quality problems.
90
- - Improvement:
91
- - Return diagnostics (insufficient contingency size, sparse table warning, low sample counts).
92
-
93
- ## UX and Feature Improvements for Analysis Workflows
94
-
95
- ### 11) Add reproducibility controls
96
- - Current gap: random projections are generated without surfaced seed controls; pipeline details are hidden.
97
- - Improvement:
98
- - UI inputs for random seed and analysis config profile.
99
- - Persist configuration alongside results.
100
-
101
- ### 12) Add time/location-first analysis modules
102
- - Context: UAP datasets are usually spatiotemporal.
103
- - Improvement ideas:
104
- - Temporal anomaly detection (daily/weekly trend breaks).
105
- - Geo heatmaps + hotspot evolution over time.
106
- - Co-occurrence matrices for shape/light/motion features.
107
-
108
- ### 13) Add model/result provenance panel
109
- - Improvement:
110
- - Track dataset hash, row count after filters, analysis timestamp, selected columns, pipeline version.
111
- - Show this metadata in Analysis and export payloads.
112
-
113
- ### 14) Improve filter capabilities for real EDA
114
- - Current gap: categorical filter relies on top-values list and cannot easily search rare categories.
115
- - Improvement:
116
- - Add searchable categorical picker and “include nulls/exclude nulls”.
117
- - Add reusable saved filter presets.
118
-
119
- ### 15) Add export/reporting features
120
- - Improvement:
121
- - Export filtered dataset, correlation matrix, and feature-importance JSON/CSV.
122
- - One-click markdown/PDF report with charts and configuration metadata.
123
-
124
- ## Engineering Quality and Maintainability
125
-
126
- ### 16) Add automated tests for core API behaviors
127
- - Suggested minimal suite:
128
- - `/api/data/load`, `/api/data/filter`, `/api/analyze/run`, `/api/dashboard/summary`, `/api/query/gemini` failure paths.
129
- - Numeric filter edge cases (min-only/max-only).
130
- - State isolation once sessionized.
131
-
132
- ### 17) Add request/analysis observability
133
- - Improvement:
134
- - Structured logging + request IDs.
135
- - Timing metrics per stage (load/filter/analyze/query).
136
- - Distinguish user errors (4xx) from pipeline errors (5xx).
137
-
138
- ### 18) Clarify mode separation: demo vs production
139
- - Improvement:
140
- - Feature flags/environment variable to select mock vs full analysis backend.
141
- - UI badges and warning copy to prevent scientific misinterpretation.
142
-
143
- ## Suggested Implementation Order
144
- 1. Fix state isolation and dashboard run-count correctness.
145
- 2. Fix filtering behavior + frontend error surfacing.
146
- 3. Mark current analysis mode as mock and rename misleading outputs.
147
- 4. Add reproducibility/provenance metadata and exports.
148
- 5. Introduce real embedding + clustering + model pipeline behind a feature flag.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
config.py CHANGED
The diff for this file is too large to render. See raw diff
 
embed_csv.py DELETED
@@ -1,91 +0,0 @@
1
- """
2
- embed_csv.py — batch-embed a CSV column and save to HDF5
3
-
4
- Usage:
5
- uv run python embed_csv.py --input data.csv --column description
6
- uv run python embed_csv.py --input data.csv --column description --output out.h5 --batch-size 128 --prompt web_search_query
7
- """
8
-
9
- import argparse
10
- import sys
11
- import time
12
-
13
- import numpy as np
14
- import pandas as pd
15
- import torch
16
- from sentence_transformers import SentenceTransformer
17
- from tqdm import tqdm
18
-
19
- MODEL_ID = "microsoft/harrier-oss-v1-0.6b"
20
-
21
-
22
- def load_model(device: str) -> SentenceTransformer:
23
- print(f"Loading {MODEL_ID} on {device}…")
24
- t0 = time.time()
25
- model_kwargs = {"dtype": "auto"}
26
- if device == "cuda":
27
- model_kwargs["device_map"] = "cuda" # load directly into VRAM, skip CPU copy
28
- model = SentenceTransformer(MODEL_ID, model_kwargs=model_kwargs)
29
- if device != "cuda":
30
- model.to(device)
31
- print(f"Model ready in {time.time() - t0:.1f}s")
32
- return model
33
-
34
-
35
- def embed(model: SentenceTransformer, texts: list[str], batch_size: int,
36
- prompt_name: str | None) -> np.ndarray:
37
- batches = [texts[i:i + batch_size] for i in range(0, len(texts), batch_size)]
38
- kwargs = {"prompt_name": prompt_name} if prompt_name else {}
39
- all_embs = []
40
- for batch in tqdm(batches, desc="Encoding", unit="batch"):
41
- with torch.no_grad():
42
- all_embs.append(model.encode(batch, show_progress_bar=False, **kwargs))
43
- return np.vstack(all_embs)
44
-
45
-
46
- def main():
47
- parser = argparse.ArgumentParser(description="Embed a CSV column with harrier-oss-v1-0.6b")
48
- parser.add_argument("--input", required=True, help="Input CSV file")
49
- parser.add_argument("--column", required=True, help="Column to embed")
50
- parser.add_argument("--output", default=None, help="Output .h5 file (default: <input>_embeddings.h5)")
51
- parser.add_argument("--key", default="df", help="HDF5 key (default: df)")
52
- parser.add_argument("--batch-size", default=256, type=int, help="Batch size (default: 256)")
53
- parser.add_argument("--prompt", default=None, choices=["web_search_query"],
54
- help="Prompt name for query encoding (omit for documents)")
55
- args = parser.parse_args()
56
-
57
- # Output path
58
- out_path = args.output or args.input.rsplit(".", 1)[0] + "_embeddings.h5"
59
-
60
- # Load CSV
61
- print(f"Reading {args.input}…")
62
- df = pd.read_csv(args.input)
63
- if args.column not in df.columns:
64
- print(f"ERROR: column '{args.column}' not found. Available: {list(df.columns)}")
65
- sys.exit(1)
66
- print(f"{len(df):,} rows loaded. Embedding column: '{args.column}'")
67
-
68
- texts = df[args.column].fillna("").astype(str).tolist()
69
-
70
- # Device
71
- device = "cuda" if torch.cuda.is_available() else "cpu"
72
- if device == "cuda":
73
- print(f"GPU: {torch.cuda.get_device_name(0)}")
74
-
75
- model = load_model(device)
76
-
77
- # Embed
78
- t0 = time.time()
79
- embeddings = embed(model, texts, batch_size=args.batch_size, prompt_name=args.prompt)
80
- elapsed = time.time() - t0
81
- print(f"Done in {elapsed:.1f}s — shape {embeddings.shape} "
82
- f"({len(texts)/elapsed:.0f} texts/s)")
83
-
84
- # Save
85
- df["embeddings"] = embeddings.tolist()
86
- df.to_hdf(out_path, key=args.key, mode="w")
87
- print(f"Saved → {out_path} (key='{args.key}')")
88
-
89
-
90
- if __name__ == "__main__":
91
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
embeddings.py DELETED
@@ -1,259 +0,0 @@
1
- """
2
- Self-contained multimodal embedding helper — Python port of utils/vertex-embeddings.ts.
3
-
4
- Generates embeddings via Google Gemini (gemini-embedding-2-preview, 768-dim).
5
- Mirrors the image / text / multimodal entry points used by app/api/embeddings/.
6
- Storage / pgvector search / clustering are intentionally NOT included — this
7
- module is pure embedding generation. Drop into any Python project.
8
-
9
- Setup:
10
- pip install google-genai pillow requests
11
- export GEMINI_API_KEY=...
12
-
13
- Usage:
14
- from embeddings import (
15
- generate_image_embedding,
16
- generate_text_embedding,
17
- generate_multimodal_embedding,
18
- cosine_similarity,
19
- )
20
-
21
- vec_img = generate_image_embedding("https://example.com/chair.jpg")
22
- vec_txt = generate_text_embedding("modern walnut dining chair")
23
- vec_mm = generate_multimodal_embedding(
24
- "https://example.com/chair.jpg", "modern walnut dining chair"
25
- )
26
- print(cosine_similarity(vec_img, vec_mm))
27
-
28
- CLI:
29
- python embeddings.py text "modern walnut dining chair"
30
- python embeddings.py image https://example.com/chair.jpg
31
- python embeddings.py image ./local/chair.jpg
32
- python embeddings.py multimodal ./local/chair.jpg "modern walnut dining chair"
33
- """
34
-
35
- from __future__ import annotations
36
-
37
- import argparse
38
- import base64
39
- import io
40
- import json
41
- import math
42
- import os
43
- import sys
44
- from dataclasses import dataclass
45
- from pathlib import Path
46
- from typing import Literal, Optional
47
-
48
- import requests
49
- from google import genai
50
- from google.genai import types as genai_types
51
- from PIL import Image
52
-
53
- EMBEDDING_MODEL = "gemini-embedding-2-preview"
54
- EMBEDDING_DIMENSIONS = 768
55
-
56
- # Pillow can decode these but Gemini may reject them — normalise to JPEG.
57
- UNSUPPORTED_MIME = {"image/webp", "image/tiff", "image/bmp", "image/avif"}
58
-
59
- TaskType = Literal["RETRIEVAL_DOCUMENT", "RETRIEVAL_QUERY"]
60
-
61
- _client: Optional[genai.Client] = None
62
-
63
-
64
- def _get_client() -> genai.Client:
65
- """Lazily-cached Gemini client. Reads GEMINI_API_KEY from environment."""
66
- global _client
67
- if _client is not None:
68
- return _client
69
- api_key = os.environ.get("GEMINI_API_KEY")
70
- if not api_key:
71
- raise RuntimeError("GEMINI_API_KEY environment variable is not set")
72
- _client = genai.Client(api_key=api_key)
73
- return _client
74
-
75
-
76
- @dataclass
77
- class _ImageBytes:
78
- data: bytes
79
- mime_type: str
80
-
81
-
82
- def _ensure_jpeg(raw: bytes, mime: str) -> _ImageBytes:
83
- """Convert webp/tiff/bmp/avif → jpeg. Mirrors ensureJpeg() in the TS module."""
84
- if mime.lower() not in UNSUPPORTED_MIME:
85
- return _ImageBytes(raw, mime)
86
- img = Image.open(io.BytesIO(raw))
87
- if img.mode in ("RGBA", "LA", "P"):
88
- img = img.convert("RGB")
89
- buf = io.BytesIO()
90
- img.save(buf, format="JPEG", quality=90)
91
- return _ImageBytes(buf.getvalue(), "image/jpeg")
92
-
93
-
94
- def _load_image(source: str) -> _ImageBytes:
95
- """Load image bytes from a URL, local path, or `data:` URL."""
96
- if source.startswith("data:"):
97
- return _parse_data_url(source)
98
- if source.startswith(("http://", "https://")):
99
- resp = requests.get(source, timeout=30)
100
- resp.raise_for_status()
101
- mime = resp.headers.get("content-type", "image/jpeg").split(";", 1)[0]
102
- return _ensure_jpeg(resp.content, mime)
103
- p = Path(source).expanduser()
104
- if not p.is_file():
105
- raise FileNotFoundError(f"Image not found: {source}")
106
- suffix = p.suffix.lower().lstrip(".")
107
- mime = {
108
- "jpg": "image/jpeg",
109
- "jpeg": "image/jpeg",
110
- "png": "image/png",
111
- "gif": "image/gif",
112
- "webp": "image/webp",
113
- "tiff": "image/tiff",
114
- "tif": "image/tiff",
115
- "bmp": "image/bmp",
116
- "avif": "image/avif",
117
- }.get(suffix, "image/jpeg")
118
- return _ensure_jpeg(p.read_bytes(), mime)
119
-
120
-
121
- def _parse_data_url(data_url: str) -> _ImageBytes:
122
- """Parse `data:image/...;base64,...` into raw bytes + mime."""
123
- if not data_url.startswith("data:"):
124
- raise ValueError("Not a data URL")
125
- header, _, b64 = data_url.partition(",")
126
- if not b64:
127
- raise ValueError("Malformed data URL")
128
- mime = header[5 : header.index(";")] if ";" in header else header[5:]
129
- return _ensure_jpeg(base64.b64decode(b64), mime)
130
-
131
-
132
- def _embed(parts: list, task_type: TaskType) -> list[float]:
133
- """Single-call wrapper around client.models.embed_content."""
134
- client = _get_client()
135
- if len(parts) == 1 and isinstance(parts[0], str):
136
- contents = parts[0]
137
- else:
138
- contents = genai_types.Content(parts=parts)
139
- result = client.models.embed_content(
140
- model=EMBEDDING_MODEL,
141
- contents=contents,
142
- config=genai_types.EmbedContentConfig(
143
- output_dimensionality=EMBEDDING_DIMENSIONS,
144
- task_type=task_type,
145
- ),
146
- )
147
- if not result.embeddings or not result.embeddings[0].values:
148
- raise RuntimeError("No embedding returned from model")
149
- return list(result.embeddings[0].values)
150
-
151
-
152
- def generate_image_embedding(
153
- source: str, *, task_type: TaskType = "RETRIEVAL_DOCUMENT"
154
- ) -> list[float]:
155
- """Embed an image given its URL, local path, or `data:` URL."""
156
- img = _load_image(source)
157
- part = genai_types.Part(
158
- inline_data=genai_types.Blob(mime_type=img.mime_type, data=img.data)
159
- )
160
- return _embed([part], task_type)
161
-
162
-
163
- def generate_text_embedding(
164
- text: str, *, task_type: TaskType = "RETRIEVAL_QUERY"
165
- ) -> list[float]:
166
- """Embed a piece of text. Default task_type matches the TS search path."""
167
- if not text:
168
- raise ValueError("text must be non-empty")
169
- return _embed([text], task_type)
170
-
171
-
172
- def generate_multimodal_embedding(
173
- image_source: str,
174
- text: str,
175
- *,
176
- task_type: TaskType = "RETRIEVAL_DOCUMENT",
177
- ) -> list[float]:
178
- """Embed an image + text pair as a single 768-dim vector."""
179
- if not text:
180
- raise ValueError("text must be non-empty for multimodal embedding")
181
- img = _load_image(image_source)
182
- parts = [
183
- genai_types.Part(
184
- inline_data=genai_types.Blob(mime_type=img.mime_type, data=img.data)
185
- ),
186
- genai_types.Part(text=text),
187
- ]
188
- return _embed(parts, task_type)
189
-
190
-
191
- def cosine_similarity(a: list[float], b: list[float]) -> float:
192
- """Cosine similarity in [-1, 1]. Returns 0.0 if either vector is zero."""
193
- if len(a) != len(b):
194
- raise ValueError(f"vector length mismatch: {len(a)} vs {len(b)}")
195
- dot = mag_a = mag_b = 0.0
196
- for x, y in zip(a, b):
197
- dot += x * y
198
- mag_a += x * x
199
- mag_b += y * y
200
- if mag_a == 0.0 or mag_b == 0.0:
201
- return 0.0
202
- return dot / (math.sqrt(mag_a) * math.sqrt(mag_b))
203
-
204
-
205
- def _cli() -> int:
206
- parser = argparse.ArgumentParser(
207
- description="Generate Gemini multimodal embeddings."
208
- )
209
- sub = parser.add_subparsers(dest="cmd", required=True)
210
-
211
- p_text = sub.add_parser("text", help="Embed a text string.")
212
- p_text.add_argument("text")
213
-
214
- p_img = sub.add_parser("image", help="Embed an image (URL, path, or data URL).")
215
- p_img.add_argument("source")
216
- p_img.add_argument(
217
- "--query",
218
- action="store_true",
219
- help="Use RETRIEVAL_QUERY task type (default is RETRIEVAL_DOCUMENT).",
220
- )
221
-
222
- p_mm = sub.add_parser("multimodal", help="Embed image + text together.")
223
- p_mm.add_argument("source")
224
- p_mm.add_argument("text")
225
-
226
- p_sim = sub.add_parser(
227
- "similarity",
228
- help="Compute cosine similarity between two embeddings (read JSON arrays from stdin or file).",
229
- )
230
- p_sim.add_argument("a")
231
- p_sim.add_argument("b")
232
-
233
- args = parser.parse_args()
234
-
235
- def _print_vec(vec: list[float]) -> None:
236
- print(json.dumps(vec))
237
-
238
- if args.cmd == "text":
239
- _print_vec(generate_text_embedding(args.text))
240
- return 0
241
- if args.cmd == "image":
242
- task: TaskType = "RETRIEVAL_QUERY" if args.query else "RETRIEVAL_DOCUMENT"
243
- _print_vec(generate_image_embedding(args.source, task_type=task))
244
- return 0
245
- if args.cmd == "multimodal":
246
- _print_vec(generate_multimodal_embedding(args.source, args.text))
247
- return 0
248
- if args.cmd == "similarity":
249
- a = json.loads(Path(args.a).read_text() if Path(args.a).is_file() else args.a)
250
- b = json.loads(Path(args.b).read_text() if Path(args.b).is_file() else args.b)
251
- print(cosine_similarity(a, b))
252
- return 0
253
-
254
- parser.print_help()
255
- return 2
256
-
257
-
258
- if __name__ == "__main__":
259
- sys.exit(_cli())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
final_ufoseti_dataset.h5 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:829fb6660b24626eb5db39952783c6e17dc17c7c4636df0dfc8b641d0c84efe5
3
+ size 39219544
frontend/.gitignore DELETED
@@ -1,24 +0,0 @@
1
- # Logs
2
- logs
3
- *.log
4
- npm-debug.log*
5
- yarn-debug.log*
6
- yarn-error.log*
7
- pnpm-debug.log*
8
- lerna-debug.log*
9
-
10
- node_modules
11
- dist
12
- dist-ssr
13
- *.local
14
-
15
- # Editor directories and files
16
- .vscode/*
17
- !.vscode/extensions.json
18
- .idea
19
- .DS_Store
20
- *.suo
21
- *.ntvs*
22
- *.njsproj
23
- *.sln
24
- *.sw?
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/DEPLOY_VERCEL.md DELETED
@@ -1,74 +0,0 @@
1
- # Deploying the frontend to Vercel
2
-
3
- The React/Vite frontend deploys cleanly to Vercel. The FastAPI backend (`api/`)
4
- does **not** — it needs a persistent, GPU-capable container (torch,
5
- sentence-transformers, in-memory session state, 240 s analysis jobs), which
6
- Vercel's serverless functions can't host. So this is a **split deploy**:
7
-
8
- ```
9
- Browser ──▶ Vercel (static React SPA)
10
- │ /api/* calls
11
-
12
- Backend host (Hugging Face Spaces / Render / Railway)
13
- FastAPI + ML stack, reads the .h5 datasets
14
- ```
15
-
16
- ## 1. Deploy the backend first
17
-
18
- The app is already configured for **Hugging Face Spaces** (see `README.md`
19
- metadata) — that's the recommended host because it runs the full ML stack and
20
- offers GPU tiers for the real embedding pipeline (`UAP_ANALYSIS_MODE=production`).
21
- Render or Railway also work for CPU/mock mode.
22
-
23
- Whichever you pick, set these env vars on the backend:
24
-
25
- | Var | Value |
26
- |-----|-------|
27
- | `UAP_API_CORS_ORIGINS` | Your Vercel URL, e.g. `https://uap.vercel.app` (comma-separated for multiple) |
28
- | `UAP_ANALYSIS_MODE` | `production` (real clustering) or `mock` (fast, no GPU) |
29
-
30
- Note the backend's public URL — e.g. `https://<user>-uap.hf.space`.
31
-
32
- ## 2. Deploy the frontend to Vercel
33
-
34
- 1. **New Project** → import this repo.
35
- 2. Set **Root Directory** to `frontend` (the repo root is a Python project).
36
- Vercel auto-detects Vite from there; `vercel.json` pins the build.
37
- 3. Add an **Environment Variable**:
38
-
39
- | Name | Value |
40
- |------|-------|
41
- | `VITE_API_BASE` | The backend origin, e.g. `https://<user>-uap.hf.space` |
42
-
43
- `VITE_*` vars are inlined at build time, so changing it later needs a redeploy.
44
- 4. **Deploy.**
45
-
46
- The client reads `VITE_API_BASE` (`src/api/client.ts`) and calls
47
- `<VITE_API_BASE>/api/...`. When the var is unset it falls back to a same-origin
48
- `/api`, so **local dev is unchanged** — `npm run dev` still proxies `/api` to
49
- `localhost:8000` via `vite.config.ts`.
50
-
51
- ### Alternative: same-origin proxy (no CORS)
52
-
53
- Instead of `VITE_API_BASE` + backend CORS, you can leave the env var unset and
54
- proxy `/api` through Vercel. Add this to `vercel.json` **above** the SPA
55
- fallback rewrite and you won't touch the backend's CORS at all:
56
-
57
- ```json
58
- { "source": "/api/:path*", "destination": "https://<user>-uap.hf.space/api/:path*" }
59
- ```
60
-
61
- ## CLI deploy (optional)
62
-
63
- ```bash
64
- cd frontend
65
- npx vercel # preview deploy, prompts for login + project link
66
- npx vercel --prod # production deploy
67
- ```
68
-
69
- ## What does NOT go to Vercel
70
-
71
- - `api/` (FastAPI) — backend host only.
72
- - The `.h5` datasets and the 124 MB `uap_clusters_llm.html` — these stay with
73
- the backend, which serves the cluster viz via `/api/analysis/clusters`. They
74
- are not bundled into the 5 MB Vercel build.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/README.md DELETED
@@ -1,73 +0,0 @@
1
- # React + TypeScript + Vite
2
-
3
- This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4
-
5
- Currently, two official plugins are available:
6
-
7
- - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
8
- - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
9
-
10
- ## React Compiler
11
-
12
- The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
13
-
14
- ## Expanding the ESLint configuration
15
-
16
- If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
17
-
18
- ```js
19
- export default defineConfig([
20
- globalIgnores(['dist']),
21
- {
22
- files: ['**/*.{ts,tsx}'],
23
- extends: [
24
- // Other configs...
25
-
26
- // Remove tseslint.configs.recommended and replace with this
27
- tseslint.configs.recommendedTypeChecked,
28
- // Alternatively, use this for stricter rules
29
- tseslint.configs.strictTypeChecked,
30
- // Optionally, add this for stylistic rules
31
- tseslint.configs.stylisticTypeChecked,
32
-
33
- // Other configs...
34
- ],
35
- languageOptions: {
36
- parserOptions: {
37
- project: ['./tsconfig.node.json', './tsconfig.app.json'],
38
- tsconfigRootDir: import.meta.dirname,
39
- },
40
- // other options...
41
- },
42
- },
43
- ])
44
- ```
45
-
46
- You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
47
-
48
- ```js
49
- // eslint.config.js
50
- import reactX from 'eslint-plugin-react-x'
51
- import reactDom from 'eslint-plugin-react-dom'
52
-
53
- export default defineConfig([
54
- globalIgnores(['dist']),
55
- {
56
- files: ['**/*.{ts,tsx}'],
57
- extends: [
58
- // Other configs...
59
- // Enable lint rules for React
60
- reactX.configs['recommended-typescript'],
61
- // Enable lint rules for React DOM
62
- reactDom.configs.recommended,
63
- ],
64
- languageOptions: {
65
- parserOptions: {
66
- project: ['./tsconfig.node.json', './tsconfig.app.json'],
67
- tsconfigRootDir: import.meta.dirname,
68
- },
69
- // other options...
70
- },
71
- },
72
- ])
73
- ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/eslint.config.js DELETED
@@ -1,23 +0,0 @@
1
- import js from '@eslint/js'
2
- import globals from 'globals'
3
- import reactHooks from 'eslint-plugin-react-hooks'
4
- import reactRefresh from 'eslint-plugin-react-refresh'
5
- import tseslint from 'typescript-eslint'
6
- import { defineConfig, globalIgnores } from 'eslint/config'
7
-
8
- export default defineConfig([
9
- globalIgnores(['dist']),
10
- {
11
- files: ['**/*.{ts,tsx}'],
12
- extends: [
13
- js.configs.recommended,
14
- tseslint.configs.recommended,
15
- reactHooks.configs.flat.recommended,
16
- reactRefresh.configs.vite,
17
- ],
18
- languageOptions: {
19
- ecmaVersion: 2020,
20
- globals: globals.browser,
21
- },
22
- },
23
- ])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/index.html DELETED
@@ -1,16 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>&#x2B22;</text></svg>" />
6
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
- <title>UAP Foundry | Analysis Platform</title>
8
- <link rel="preconnect" href="https://fonts.googleapis.com" />
9
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
10
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
11
- </head>
12
- <body>
13
- <div id="root"></div>
14
- <script type="module" src="/src/main.tsx"></script>
15
- </body>
16
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/package-lock.json DELETED
The diff for this file is too large to render. See raw diff
 
frontend/package.json DELETED
@@ -1,39 +0,0 @@
1
- {
2
- "name": "frontend",
3
- "private": true,
4
- "version": "0.0.0",
5
- "type": "module",
6
- "scripts": {
7
- "dev": "vite",
8
- "build": "tsc -b && vite build",
9
- "lint": "eslint .",
10
- "preview": "vite preview"
11
- },
12
- "dependencies": {
13
- "@tailwindcss/vite": "^4.1.18",
14
- "@types/react-plotly.js": "^2.6.4",
15
- "lucide-react": "^0.564.0",
16
- "plotly.js": "^3.3.1",
17
- "react": "^19.2.0",
18
- "react-dom": "^19.2.0",
19
- "react-plotly.js": "^2.6.0",
20
- "react-router-dom": "^7.13.0",
21
- "statsmodels": "^0.0.1-security",
22
- "tailwindcss": "^4.1.18",
23
- "zustand": "^5.0.11"
24
- },
25
- "devDependencies": {
26
- "@eslint/js": "^9.39.1",
27
- "@types/node": "^24.10.1",
28
- "@types/react": "^19.2.7",
29
- "@types/react-dom": "^19.2.3",
30
- "@vitejs/plugin-react": "^5.1.1",
31
- "eslint": "^9.39.1",
32
- "eslint-plugin-react-hooks": "^7.0.1",
33
- "eslint-plugin-react-refresh": "^0.4.24",
34
- "globals": "^16.5.0",
35
- "typescript": "~5.9.3",
36
- "typescript-eslint": "^8.48.0",
37
- "vite": "^7.3.1"
38
- }
39
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/App.tsx DELETED
@@ -1,33 +0,0 @@
1
- import { AppShell } from './components/layout/AppShell';
2
- import { Dashboard } from './components/dashboard/Dashboard';
3
- import { DataExplorer } from './components/data/DataExplorer';
4
- import { ParsingPage } from './components/parsing/ParsingPage';
5
- import { AnalysisPage } from './components/analysis/AnalysisPage';
6
- import { QueryPage } from './components/query/QueryPage';
7
- import { RagSearchPage } from './components/rag/RagSearchPage';
8
- import { ScuPage } from './components/scu/ScuPage';
9
- import { MapPage } from './components/map/MapPage';
10
- import { MagneticPage } from './components/magnetic/MagneticPage';
11
- import { ClusterView } from './components/analysis/ClusterView';
12
- import { useStore } from './store/useStore';
13
-
14
- function App() {
15
- const { currentPage } = useStore();
16
-
17
- return (
18
- <AppShell>
19
- {currentPage === 'dashboard' && <Dashboard />}
20
- {currentPage === 'data' && <DataExplorer />}
21
- {currentPage === 'parsing' && <ParsingPage />}
22
- {currentPage === 'analysis' && <AnalysisPage />}
23
- {currentPage === 'query' && <QueryPage />}
24
- {currentPage === 'rag' && <RagSearchPage />}
25
- {currentPage === 'scu' && <ScuPage />}
26
- {currentPage === 'map' && <MapPage />}
27
- {currentPage === 'magnetic' && <MagneticPage />}
28
- {currentPage === 'clusters' && <ClusterView />}
29
- </AppShell>
30
- );
31
- }
32
-
33
- export default App;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/api/client.ts DELETED
@@ -1,264 +0,0 @@
1
- import type {
2
- LoadDataResponse,
3
- AnalysisResponse,
4
- DashboardSummary,
5
- SchemaListResponse,
6
- SchemaMergeResponse,
7
- SchemaCoverageResponse,
8
- ParseUploadResponse,
9
- CostEstimate,
10
- ParseRunResponse,
11
- ScuCriteriaResponse,
12
- ScuNormalizeResponse,
13
- ScuFilterResponse,
14
- RagSearchResponse,
15
- CramersVResponse,
16
- ContingencyResponse,
17
- ColumnGroupsResponse,
18
- XgboostImportanceResponse,
19
- } from '../types';
20
-
21
- // API origin is configurable for split deployments (e.g. frontend on Vercel,
22
- // backend on Hugging Face Spaces / Render). Set VITE_API_BASE to the backend
23
- // origin at build time, e.g. "https://user-uap.hf.space". When unset it falls
24
- // back to a same-origin "/api", which the Vite dev proxy (vite.config.ts) and
25
- // a Vercel `/api` rewrite both handle transparently.
26
- const API_ORIGIN = (import.meta.env.VITE_API_BASE ?? '').replace(/\/+$/, '');
27
- const BASE = `${API_ORIGIN}/api`;
28
-
29
- async function request<T>(url: string, init?: RequestInit): Promise<T> {
30
- const res = await fetch(`${BASE}${url}`, {
31
- headers: { 'Content-Type': 'application/json' },
32
- ...init,
33
- });
34
- if (!res.ok) {
35
- const body = await res.json().catch(() => ({ detail: res.statusText }));
36
- throw new Error(body.detail || `Request failed: ${res.status}`);
37
- }
38
- return res.json();
39
- }
40
-
41
- export const api = {
42
- loadData(type = 'west', rows = 15000): Promise<LoadDataResponse> {
43
- return request(`/data/load?type=${type}&rows=${rows}`);
44
- },
45
-
46
- uploadFile(file: File): Promise<LoadDataResponse> {
47
- const form = new FormData();
48
- form.append('file', file);
49
- return fetch(`${BASE}/data/upload`, { method: 'POST', body: form }).then(
50
- async (res) => {
51
- if (!res.ok) {
52
- const body = await res.json().catch(() => ({ detail: res.statusText }));
53
- throw new Error(body.detail || `Upload failed: ${res.status}`);
54
- }
55
- return res.json();
56
- }
57
- );
58
- },
59
-
60
- filterData(
61
- filters: {
62
- column: string;
63
- type: string;
64
- values?: string[];
65
- min_val?: number;
66
- max_val?: number;
67
- pattern?: string;
68
- }[]
69
- ): Promise<LoadDataResponse> {
70
- return request('/data/filter', {
71
- method: 'POST',
72
- body: JSON.stringify(filters),
73
- });
74
- },
75
-
76
- getColumns(): Promise<{ columns: { name: string; dtype: string; unique: number; non_null: number }[] }> {
77
- return request('/data/columns');
78
- },
79
-
80
- getColumnValues(
81
- column: string,
82
- search = '',
83
- limit = 50
84
- ): Promise<{
85
- column: string;
86
- values: { value: string; count: number }[];
87
- total_matches: number;
88
- }> {
89
- return request(
90
- `/data/column-values?column=${encodeURIComponent(column)}&search=${encodeURIComponent(search)}&limit=${limit}`
91
- );
92
- },
93
-
94
- runAnalysis(
95
- columns: string[],
96
- opts: {
97
- enable_tfidf?: boolean;
98
- min_cluster_size?: number;
99
- n_neighbors?: number;
100
- min_dist?: number;
101
- top_n?: number;
102
- } = {}
103
- ): Promise<AnalysisResponse> {
104
- return request('/analyze/run', {
105
- method: 'POST',
106
- body: JSON.stringify({ columns, ...opts }),
107
- });
108
- },
109
-
110
- // ── Parsing ───────────────────────────────────────────────────────────
111
- getSchemas(): Promise<SchemaListResponse> {
112
- return request('/parse/schemas');
113
- },
114
-
115
- mergeSchema(
116
- labels: string[],
117
- customFields?: Record<string, unknown>
118
- ): Promise<SchemaMergeResponse> {
119
- return request('/parse/schema-merge', {
120
- method: 'POST',
121
- body: JSON.stringify({ labels, custom_fields: customFields ?? null }),
122
- });
123
- },
124
-
125
- schemaCoverage(
126
- labels: string[],
127
- columns: string[],
128
- customFields?: Record<string, unknown>
129
- ): Promise<SchemaCoverageResponse> {
130
- return request('/parse/schema-coverage', {
131
- method: 'POST',
132
- body: JSON.stringify({ labels, columns, custom_fields: customFields ?? null }),
133
- });
134
- },
135
-
136
- uploadParseFile(file: File): Promise<ParseUploadResponse> {
137
- const form = new FormData();
138
- form.append('file', file);
139
- return fetch(`${BASE}/parse/upload`, { method: 'POST', body: form }).then(
140
- async (res) => {
141
- if (!res.ok) {
142
- const body = await res.json().catch(() => ({ detail: res.statusText }));
143
- throw new Error(body.detail || `Upload failed: ${res.status}`);
144
- }
145
- return res.json();
146
- }
147
- );
148
- },
149
-
150
- estimateParse(
151
- columns: string[],
152
- formatJson: string,
153
- model: string,
154
- useBatch = false
155
- ): Promise<CostEstimate> {
156
- return request('/parse/estimate', {
157
- method: 'POST',
158
- body: JSON.stringify({ columns, format_json: formatJson, model, use_batch: useBatch }),
159
- });
160
- },
161
-
162
- runParse(payload: {
163
- columns: string[];
164
- format_json: string;
165
- provider: string;
166
- model: string;
167
- api_key: string;
168
- max_workers?: number;
169
- keep_columns?: string[];
170
- }): Promise<ParseRunResponse> {
171
- return request('/parse/run', {
172
- method: 'POST',
173
- body: JSON.stringify(payload),
174
- });
175
- },
176
-
177
- // ── SCU normalization ─────────────────────────────────────────────────
178
- getScuCriteria(): Promise<ScuCriteriaResponse> {
179
- return request('/scu/criteria');
180
- },
181
-
182
- scuNormalize(): Promise<ScuNormalizeResponse> {
183
- return request('/scu/normalize', { method: 'POST', body: '{}' });
184
- },
185
-
186
- scuFilter(criterionKeys: string[]): Promise<ScuFilterResponse> {
187
- return request('/scu/filter', {
188
- method: 'POST',
189
- body: JSON.stringify({ criterion_keys: criterionKeys }),
190
- });
191
- },
192
-
193
- // ── RAG search (Cohere) ───────────────────────────────────────────────
194
- ragSearch(
195
- columns: string[],
196
- question: string,
197
- cohereKey: string,
198
- topN = 50
199
- ): Promise<RagSearchResponse> {
200
- return request('/rag/search', {
201
- method: 'POST',
202
- body: JSON.stringify({ columns, question, cohere_key: cohereKey, top_n: topN }),
203
- });
204
- },
205
-
206
- // ── Cramér's V explorer ───────────────────────────────────────────────
207
- cramersV(payload: {
208
- columns?: string[] | null;
209
- drop_missing?: boolean;
210
- exclude_trivial?: boolean;
211
- strong_threshold?: number;
212
- high_threshold?: number;
213
- source?: string;
214
- }): Promise<CramersVResponse> {
215
- return request('/analysis/cramers-v', {
216
- method: 'POST',
217
- body: JSON.stringify(payload),
218
- });
219
- },
220
-
221
- contingency(payload: {
222
- col1: string;
223
- col2: string;
224
- drop_missing?: boolean;
225
- source?: string;
226
- }): Promise<ContingencyResponse> {
227
- return request('/analysis/contingency', {
228
- method: 'POST',
229
- body: JSON.stringify(payload),
230
- });
231
- },
232
-
233
- columnGroups(payload: {
234
- source?: string;
235
- high_threshold?: number;
236
- }): Promise<ColumnGroupsResponse> {
237
- return request('/analysis/column-groups', {
238
- method: 'POST',
239
- body: JSON.stringify(payload),
240
- });
241
- },
242
-
243
- xgboostImportance(columns: string[], source = 'dataset'): Promise<XgboostImportanceResponse> {
244
- return request('/analysis/xgboost', {
245
- method: 'POST',
246
- body: JSON.stringify({ columns, source }),
247
- });
248
- },
249
-
250
- queryGemini(question: string, columns: string[], geminiKey: string): Promise<{ status: string; response: string; context_rows_used?: number; columns_used?: string[] }> {
251
- return request('/query/gemini', {
252
- method: 'POST',
253
- body: JSON.stringify({ question, columns, gemini_key: geminiKey }),
254
- });
255
- },
256
-
257
- getDashboardSummary(): Promise<DashboardSummary> {
258
- return request('/dashboard/summary');
259
- },
260
-
261
- healthCheck(): Promise<{ status: string; version: string }> {
262
- return request('/health');
263
- },
264
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/components/analysis/AnalysisPage.tsx DELETED
@@ -1,322 +0,0 @@
1
- import { useState } from 'react';
2
- import { Play, AlertTriangle, CheckCircle2, Layers, BarChart3, Grid3x3, Settings2, Network } from 'lucide-react';
3
- import { api } from '../../api/client';
4
- import { useStore } from '../../store/useStore';
5
- import { Panel } from '../common/Panel';
6
- import { LoadingSpinner } from '../common/LoadingSpinner';
7
- import { ClusterVisualization } from './ClusterVisualization';
8
- import { CorrelationHeatmap } from './CorrelationHeatmap';
9
- import { XGBoostResults } from './XGBoostResults';
10
- import { DistributionChart } from './DistributionChart';
11
- import { CramersVExplorer } from './CramersVExplorer';
12
- import type { AnalysisResponse, XGBoostResult } from '../../types';
13
-
14
- type TabId = 'clusters' | 'correlation' | 'xgboost' | 'distribution' | 'association';
15
-
16
- export function AnalysisPage() {
17
- const { data, dataLoaded, analysisResults, setAnalysisResults, analysisRunning, setAnalysisRunning, setPage } = useStore();
18
- const [selected, setSelected] = useState<string[]>([]);
19
- const [error, setError] = useState<string | null>(null);
20
- const [activeTab, setActiveTab] = useState<TabId>('clusters');
21
- // XGBoost feature importance handed over from the Cramér's V explorer.
22
- const [assocXgboost, setAssocXgboost] = useState<Record<string, XGBoostResult> | null>(null);
23
-
24
- const handleAssocXgboost = (r: Record<string, XGBoostResult>) => {
25
- setAssocXgboost(r);
26
- setActiveTab('xgboost');
27
- };
28
-
29
- // Cluster pipeline tuning (mirrors analyzing.py controls)
30
- const [showParams, setShowParams] = useState(false);
31
- const [enableTfidf, setEnableTfidf] = useState(false);
32
- const [minClusterSize, setMinClusterSize] = useState(15);
33
- const [nNeighbors, setNNeighbors] = useState(15);
34
- const [minDist, setMinDist] = useState(0.1);
35
-
36
- const columns = data?.columns ?? [];
37
- const results: AnalysisResponse | null = analysisResults;
38
-
39
- const toggleColumn = (col: string) => {
40
- setSelected((prev) =>
41
- prev.includes(col) ? prev.filter((c) => c !== col) : [...prev, col]
42
- );
43
- };
44
-
45
- const runAnalysis = async () => {
46
- if (selected.length === 0) return;
47
- setError(null);
48
- setAnalysisRunning(true);
49
- try {
50
- const res = await api.runAnalysis(selected, {
51
- enable_tfidf: enableTfidf,
52
- min_cluster_size: minClusterSize,
53
- n_neighbors: nNeighbors,
54
- min_dist: minDist,
55
- });
56
- setAnalysisResults(res);
57
- } catch (e: unknown) {
58
- setError(e instanceof Error ? e.message : 'Analysis failed');
59
- setAnalysisRunning(false);
60
- }
61
- };
62
-
63
- if (!dataLoaded) {
64
- return (
65
- <Panel title="No Data Loaded">
66
- <p className="text-sm text-text-muted">
67
- Load a dataset first from the{' '}
68
- <button onClick={() => setPage('dashboard')} className="text-accent hover:underline">
69
- Dashboard
70
- </button>{' '}
71
- or{' '}
72
- <button onClick={() => setPage('data')} className="text-accent hover:underline">
73
- Data Explorer
74
- </button>.
75
- </p>
76
- </Panel>
77
- );
78
- }
79
-
80
- const tabs: { id: TabId; label: string; icon: typeof Layers; needsResults: boolean }[] = [
81
- { id: 'clusters', label: 'Clusters', icon: Layers, needsResults: true },
82
- { id: 'correlation', label: 'Correlation', icon: Grid3x3, needsResults: true },
83
- { id: 'xgboost', label: 'Feature Importance', icon: BarChart3, needsResults: true },
84
- { id: 'distribution', label: 'Distribution', icon: BarChart3, needsResults: true },
85
- { id: 'association', label: "Cramér's V Explorer", icon: Network, needsResults: false },
86
- ];
87
-
88
- return (
89
- <div className="space-y-4">
90
- {/* Column selector */}
91
- <Panel
92
- title="Select Columns for Analysis"
93
- subtitle="Choose columns to run through the clustering and ML pipeline"
94
- actions={
95
- <div className="flex items-center gap-2">
96
- <button
97
- onClick={() => setShowParams((s) => !s)}
98
- className={`flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs transition-colors ${
99
- showParams ? 'border-accent text-accent' : 'border-border text-text-secondary hover:text-text-primary'
100
- }`}
101
- >
102
- <Settings2 className="h-3.5 w-3.5" /> Params
103
- </button>
104
- <button
105
- onClick={runAnalysis}
106
- disabled={selected.length === 0 || analysisRunning}
107
- className="flex items-center gap-2 rounded-md bg-accent-dim px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-accent disabled:opacity-50"
108
- >
109
- <Play className="h-3.5 w-3.5" />
110
- {analysisRunning ? 'Running...' : 'Run Analysis'}
111
- </button>
112
- </div>
113
- }
114
- >
115
- <div className="flex flex-wrap gap-2">
116
- {columns.map((col) => (
117
- <button
118
- key={col}
119
- onClick={() => toggleColumn(col)}
120
- className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
121
- selected.includes(col)
122
- ? 'border-accent bg-accent-dim/30 text-accent-bright'
123
- : 'border-border bg-raised text-text-secondary hover:border-border-bright'
124
- }`}
125
- >
126
- {col}
127
- </button>
128
- ))}
129
- </div>
130
- {selected.length > 0 && (
131
- <p className="mt-2 text-xs text-text-muted">
132
- {selected.length} column{selected.length > 1 ? 's' : ''} selected
133
- </p>
134
- )}
135
-
136
- {showParams && (
137
- <div className="mt-3 grid grid-cols-1 gap-4 rounded-md border border-border/50 bg-raised/50 p-3 sm:grid-cols-2 lg:grid-cols-4">
138
- <label className="flex items-center gap-2 text-xs text-text-secondary">
139
- <input
140
- type="checkbox"
141
- checked={enableTfidf}
142
- onChange={(e) => setEnableTfidf(e.target.checked)}
143
- className="accent-accent"
144
- />
145
- TF-IDF cluster naming + merging
146
- </label>
147
- <div>
148
- <label className="mb-1 block text-[11px] text-text-muted">
149
- Min cluster size: {minClusterSize}
150
- </label>
151
- <input
152
- type="range" min={2} max={50} value={minClusterSize}
153
- onChange={(e) => setMinClusterSize(Number(e.target.value))}
154
- className="w-full accent-accent"
155
- />
156
- </div>
157
- <div>
158
- <label className="mb-1 block text-[11px] text-text-muted">
159
- UMAP neighbors: {nNeighbors}
160
- </label>
161
- <input
162
- type="range" min={2} max={100} value={nNeighbors}
163
- onChange={(e) => setNNeighbors(Number(e.target.value))}
164
- className="w-full accent-accent"
165
- />
166
- </div>
167
- <div>
168
- <label className="mb-1 block text-[11px] text-text-muted">
169
- UMAP min dist: {minDist.toFixed(2)}
170
- </label>
171
- <input
172
- type="range" min={0} max={1} step={0.05} value={minDist}
173
- onChange={(e) => setMinDist(Number(e.target.value))}
174
- className="w-full accent-accent"
175
- />
176
- </div>
177
- </div>
178
- )}
179
- </Panel>
180
-
181
- {analysisRunning && <LoadingSpinner text="Running analysis pipeline..." />}
182
-
183
- {error && (
184
- <div className="flex items-center gap-2 rounded-md border border-danger/30 bg-danger/10 px-4 py-2.5 text-sm text-danger">
185
- <AlertTriangle className="h-4 w-4" /> {error}
186
- </div>
187
- )}
188
-
189
- {/* Success message */}
190
- {results && (
191
- <>
192
- <div className="flex items-center gap-2 rounded-md border border-success/30 bg-success/10 px-4 py-2.5 text-sm text-success">
193
- <CheckCircle2 className="h-4 w-4" />
194
- Analysis complete for {Object.keys(results.results).length} column(s)
195
- </div>
196
- {results.mock_mode && (
197
- <div className="flex items-center gap-2 rounded-md border border-warning/30 bg-warning/10 px-4 py-2.5 text-sm text-warning">
198
- <AlertTriangle className="h-4 w-4" />
199
- Mock analysis mode is enabled. Outputs are for demo/debugging, not scientific conclusions.
200
- </div>
201
- )}
202
- </>
203
- )}
204
-
205
- {/* Tab bar — always available; the Cramér's V explorer needs no analysis run */}
206
- <div className="flex flex-wrap gap-1 border-b border-border">
207
- {tabs.map(({ id, label, icon: Icon }) => (
208
- <button
209
- key={id}
210
- onClick={() => setActiveTab(id)}
211
- className={`flex items-center gap-1.5 border-b-2 px-4 py-2.5 text-xs font-medium transition-colors ${
212
- activeTab === id
213
- ? 'border-accent text-accent'
214
- : 'border-transparent text-text-muted hover:text-text-secondary'
215
- }`}
216
- >
217
- <Icon className="h-3.5 w-3.5" />
218
- {label}
219
- </button>
220
- ))}
221
- </div>
222
-
223
- {/* Association explorer — independent of the cluster pipeline. Its XGBoost
224
- run is handed to the Feature Importance tab via handleAssocXgboost. */}
225
- {activeTab === 'association' && (
226
- <CramersVExplorer source="dataset" onXgboost={handleAssocXgboost} />
227
- )}
228
-
229
- {/* Feature importance — independent of the cluster pipeline: it shows the
230
- explorer-driven results when present, otherwise the pipeline's. */}
231
- {activeTab === 'xgboost' && (() => {
232
- const xgb =
233
- assocXgboost && Object.keys(assocXgboost).length > 0
234
- ? assocXgboost
235
- : results?.xgboost ?? null;
236
- if (xgb && Object.keys(xgb).length > 0) {
237
- return (
238
- <div className="space-y-3">
239
- {assocXgboost && Object.keys(assocXgboost).length > 0 && (
240
- <div className="flex items-center gap-2 rounded-md border border-purple/30 bg-purple/10 px-4 py-2.5 text-xs text-text-secondary">
241
- <Network className="h-4 w-4 text-purple" />
242
- Computed directly from your Cramér's V column selection — each column predicted
243
- from the others.
244
- </div>
245
- )}
246
- <XGBoostResults results={xgb} />
247
- </div>
248
- );
249
- }
250
- return (
251
- <Panel title="Feature Importance">
252
- <p className="text-sm text-text-muted">
253
- No feature-importance results yet. Run the cluster pipeline above, or open the{' '}
254
- <button onClick={() => setActiveTab('association')} className="text-accent hover:underline">
255
- Cramér's V Explorer
256
- </button>
257
- , select columns, and click <span className="text-accent">Feature Importance →</span>.
258
- </p>
259
- </Panel>
260
- );
261
- })()}
262
-
263
- {/* Result-dependent tabs */}
264
- {activeTab !== 'association' && activeTab !== 'xgboost' && !results && (
265
- <Panel title="Run the analysis pipeline">
266
- <p className="text-sm text-text-muted">
267
- Select columns above and click <span className="text-accent">Run Analysis</span> to
268
- populate clustering, correlation, feature-importance and distribution views. The{' '}
269
- <button onClick={() => setActiveTab('association')} className="text-accent hover:underline">
270
- Cramér's V Explorer
271
- </button>{' '}
272
- works directly on raw columns without running the pipeline.
273
- </p>
274
- </Panel>
275
- )}
276
-
277
- {results && (
278
- <>
279
- {/* Cluster visualizations */}
280
- {activeTab === 'clusters' && (
281
- <div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
282
- {Object.entries(results.cluster_viz).map(([col, viz]) => (
283
- <Panel key={col} title={viz.title}>
284
- <ClusterVisualization viz={viz} />
285
- </Panel>
286
- ))}
287
- </div>
288
- )}
289
-
290
- {/* Correlation heatmap */}
291
- {activeTab === 'correlation' && results.cramers_v && (
292
- <Panel title="Cramer's V Correlation Matrix">
293
- <CorrelationHeatmap data={results.cramers_v} height={500} />
294
- </Panel>
295
- )}
296
- {activeTab === 'correlation' && !results.cramers_v && (
297
- <Panel title="Correlation">
298
- <p className="text-sm text-text-muted">
299
- Select at least 2 columns to compute correlation analysis.
300
- </p>
301
- </Panel>
302
- )}
303
-
304
- {/* Distribution */}
305
- {activeTab === 'distribution' && (
306
- <div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3">
307
- {Object.entries(results.results).map(([col, analysis]) => (
308
- <Panel key={col} title={`${col} (${analysis.cluster_count} clusters)`}>
309
- <DistributionChart
310
- data={analysis.distribution}
311
- title={col}
312
- height={280}
313
- />
314
- </Panel>
315
- ))}
316
- </div>
317
- )}
318
- </>
319
- )}
320
- </div>
321
- );
322
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/components/analysis/ClusterView.tsx DELETED
@@ -1,21 +0,0 @@
1
- import { Panel } from '../common/Panel';
2
-
3
- export function ClusterView() {
4
- return (
5
- <div className="h-full space-y-4">
6
- <Panel
7
- title="EDA Clusters (LLM Analysis)"
8
- subtitle="Advanced clustering of UAP sightings using SentenceTransformers and HDBSCAN"
9
- noPad
10
- >
11
- <div className="relative h-[calc(100vh-180px)] w-full overflow-hidden rounded-b-lg">
12
- <iframe
13
- src="/api/analysis/clusters"
14
- className="h-full w-full border-0"
15
- title="UAP Clusters LLM"
16
- />
17
- </div>
18
- </Panel>
19
- </div>
20
- );
21
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/components/analysis/ClusterVisualization.tsx DELETED
@@ -1,64 +0,0 @@
1
- import Plot from 'react-plotly.js';
2
- import type { ClusterViz } from '../../types';
3
-
4
- const COLORS = [
5
- '#58a6ff', '#3fb950', '#f0883e', '#bc8cff', '#39d2c0',
6
- '#f85149', '#d29922', '#79c0ff', '#56d364', '#ffa657',
7
- '#d2a8ff', '#a5d6ff', '#7ee787', '#ffd8b5', '#e2c5ff',
8
- '#76e3ea', '#ff7b72', '#e3b341', '#87ceeb', '#ff69b4',
9
- ];
10
-
11
- interface Props {
12
- viz: ClusterViz;
13
- height?: number;
14
- }
15
-
16
- export function ClusterVisualization({ viz, height = 450 }: Props) {
17
- const traces = viz.traces.map((t, i) => ({
18
- x: t.x,
19
- y: t.y,
20
- text: t.text,
21
- name: `${t.name} (${t.count})`,
22
- type: 'scatter' as const,
23
- mode: 'markers' as const,
24
- marker: {
25
- color: COLORS[i % COLORS.length],
26
- size: 5,
27
- opacity: 0.8,
28
- },
29
- hoverinfo: 'text' as const,
30
- }));
31
-
32
- return (
33
- <Plot
34
- data={traces}
35
- layout={{
36
- title: { text: viz.title, font: { color: '#e6edf3', size: 14 } },
37
- paper_bgcolor: 'transparent',
38
- plot_bgcolor: '#111820',
39
- font: { color: '#8b949e', size: 10 },
40
- margin: { l: 40, r: 20, t: 40, b: 40 },
41
- xaxis: {
42
- gridcolor: '#21283b',
43
- zerolinecolor: '#30363d',
44
- showticklabels: false,
45
- },
46
- yaxis: {
47
- gridcolor: '#21283b',
48
- zerolinecolor: '#30363d',
49
- showticklabels: false,
50
- },
51
- legend: {
52
- font: { size: 9, color: '#8b949e' },
53
- bgcolor: 'transparent',
54
- x: 1.02,
55
- y: 1,
56
- },
57
- height,
58
- showlegend: true,
59
- }}
60
- config={{ responsive: true, displayModeBar: true, displaylogo: false }}
61
- style={{ width: '100%' }}
62
- />
63
- );
64
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/components/analysis/CorrelationHeatmap.tsx DELETED
@@ -1,70 +0,0 @@
1
- import Plot from 'react-plotly.js';
2
- import type { CramersVData } from '../../types';
3
-
4
- interface Props {
5
- data: CramersVData;
6
- height?: number;
7
- }
8
-
9
- export function CorrelationHeatmap({ data, height = 400 }: Props) {
10
- // Mask upper triangle
11
- const masked = data.matrix.map((row, i) =>
12
- row.map((val, j) => (j > i ? null : val))
13
- );
14
-
15
- // Annotation text
16
- const annotations = [];
17
- for (let i = 0; i < data.labels.length; i++) {
18
- for (let j = 0; j <= i; j++) {
19
- annotations.push({
20
- x: data.labels[j],
21
- y: data.labels[i],
22
- text: masked[i][j] != null ? masked[i][j]!.toFixed(2) : '',
23
- font: { color: '#e6edf3', size: 10 },
24
- showarrow: false,
25
- });
26
- }
27
- }
28
-
29
- return (
30
- <Plot
31
- data={[
32
- {
33
- z: masked,
34
- x: data.labels,
35
- y: data.labels,
36
- type: 'heatmap',
37
- colorscale: [
38
- [0, '#0d1117'],
39
- [0.25, '#1f3a5f'],
40
- [0.5, '#3d6098'],
41
- [0.75, '#d29922'],
42
- [1, '#f85149'],
43
- ],
44
- zmin: 0,
45
- zmax: 1,
46
- hoverongaps: false,
47
- colorbar: {
48
- title: { text: "Cramer's V", font: { color: '#8b949e', size: 10 } },
49
- tickfont: { color: '#8b949e', size: 9 },
50
- },
51
- },
52
- ]}
53
- layout={{
54
- title: {
55
- text: "Cramer's V Correlation Matrix",
56
- font: { color: '#e6edf3', size: 14 },
57
- },
58
- paper_bgcolor: 'transparent',
59
- plot_bgcolor: 'transparent',
60
- font: { color: '#8b949e', size: 10 },
61
- margin: { l: 100, r: 40, t: 40, b: 100 },
62
- xaxis: { tickangle: -45 },
63
- annotations,
64
- height,
65
- }}
66
- config={{ responsive: true, displayModeBar: false }}
67
- style={{ width: '100%' }}
68
- />
69
- );
70
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/components/analysis/CramersVExplorer.tsx DELETED
@@ -1,487 +0,0 @@
1
- import { useState, useEffect, useMemo } from 'react';
2
- import Plot from 'react-plotly.js';
3
- import { Play, AlertTriangle, Grid3x3, Layers, BarChart3 } from 'lucide-react';
4
- import { api } from '../../api/client';
5
- import { Panel } from '../common/Panel';
6
- import { LoadingSpinner } from '../common/LoadingSpinner';
7
- import { XGBoostResults } from './XGBoostResults';
8
- import type { CramersVResponse, ContingencyResponse, ColumnGroup, XGBoostResult } from '../../types';
9
-
10
- interface Props {
11
- source: 'dataset' | 'parsed';
12
- // When provided, XGBoost feature importance computed from the selected columns
13
- // is handed off to the parent (e.g. the Feature Importance tab) instead of
14
- // rendering inline.
15
- onXgboost?: (results: Record<string, XGBoostResult>) => void;
16
- }
17
-
18
- export function CramersVExplorer({ source, onXgboost }: Props) {
19
- const [report, setReport] = useState<CramersVResponse | null>(null);
20
- const [contingency, setContingency] = useState<ContingencyResponse | null>(null);
21
- const [pair, setPair] = useState<{ a: string; b: string } | null>(null);
22
-
23
- const [dropMissing, setDropMissing] = useState(false);
24
- const [excludeTrivial, setExcludeTrivial] = useState(true);
25
- const [strong, setStrong] = useState(0.3);
26
-
27
- const [loading, setLoading] = useState(false);
28
- const [error, setError] = useState<string | null>(null);
29
-
30
- // XGBoost feature importance run directly on the selected columns.
31
- const [xgbLoading, setXgbLoading] = useState(false);
32
- const [localXgb, setLocalXgb] = useState<Record<string, XGBoostResult> | null>(null);
33
-
34
- // Eligible categorical columns, grouped by their dotted parent (e.g. craft.*).
35
- const [groups, setGroups] = useState<ColumnGroup[]>([]);
36
- const [selected, setSelected] = useState<Set<string>>(new Set());
37
- const [groupsError, setGroupsError] = useState<string | null>(null);
38
-
39
- // Load the parent groups up front — cheap (cardinality only, no matrix), so the
40
- // selector is usable before the first Compute. Defaults to all eligible columns,
41
- // which matches the explorer's prior "compute everything" behavior.
42
- useEffect(() => {
43
- let cancelled = false;
44
- setGroupsError(null);
45
- api
46
- .columnGroups({ source })
47
- .then((res) => {
48
- if (cancelled) return;
49
- setGroups(res.groups);
50
- setSelected(new Set(res.eligible));
51
- })
52
- .catch((e) => {
53
- if (!cancelled) setGroupsError(e instanceof Error ? e.message : 'Could not load columns');
54
- });
55
- return () => {
56
- cancelled = true;
57
- };
58
- }, [source]);
59
-
60
- // Flattened in grouped order so the matrix keeps related columns adjacent.
61
- const orderedEligible = useMemo(() => groups.flatMap((g) => g.columns), [groups]);
62
- const nestedGroups = groups.filter((g) => g.nested);
63
- const standaloneCols = groups.filter((g) => !g.nested).flatMap((g) => g.columns);
64
-
65
- const toggleCol = (c: string) =>
66
- setSelected((prev) => {
67
- const next = new Set(prev);
68
- if (next.has(c)) next.delete(c);
69
- else next.add(c);
70
- return next;
71
- });
72
-
73
- const toggleGroup = (g: ColumnGroup) =>
74
- setSelected((prev) => {
75
- const next = new Set(prev);
76
- const allOn = g.columns.every((c) => next.has(c));
77
- for (const c of g.columns) {
78
- if (allOn) next.delete(c);
79
- else next.add(c);
80
- }
81
- return next;
82
- });
83
-
84
- const selectAll = () => setSelected(new Set(orderedEligible));
85
- const clearAll = () => setSelected(new Set());
86
-
87
- const run = async () => {
88
- if (orderedEligible.length && selected.size < 2) {
89
- setError('Select at least two columns (or whole parent groups) to compute associations.');
90
- return;
91
- }
92
- setLoading(true);
93
- setError(null);
94
- setContingency(null);
95
- setPair(null);
96
- try {
97
- const cols = orderedEligible.filter((c) => selected.has(c));
98
- const res = await api.cramersV({
99
- source,
100
- columns: cols.length ? cols : undefined,
101
- drop_missing: dropMissing,
102
- exclude_trivial: excludeTrivial,
103
- strong_threshold: strong,
104
- });
105
- setReport(res);
106
- } catch (e) {
107
- setError(e instanceof Error ? e.message : 'Cramér’s V failed');
108
- } finally {
109
- setLoading(false);
110
- }
111
- };
112
-
113
- const runXgboost = async () => {
114
- const cols = orderedEligible.filter((c) => selected.has(c));
115
- if (cols.length < 2) {
116
- setError('Select at least two columns to run feature importance.');
117
- return;
118
- }
119
- setXgbLoading(true);
120
- setError(null);
121
- try {
122
- const res = await api.xgboostImportance(cols, source);
123
- if (!Object.keys(res.results).length) {
124
- setError(res.message || 'No feature-importance results (need ≥2 non-constant columns).');
125
- return;
126
- }
127
- if (onXgboost) onXgboost(res.results);
128
- else setLocalXgb(res.results);
129
- } catch (e) {
130
- setError(e instanceof Error ? e.message : 'Feature importance failed');
131
- } finally {
132
- setXgbLoading(false);
133
- }
134
- };
135
-
136
- const loadContingency = async (a: string, b: string) => {
137
- setPair({ a, b });
138
- try {
139
- const res = await api.contingency({ col1: a, col2: b, drop_missing: dropMissing, source });
140
- setContingency(res);
141
- } catch (e) {
142
- setError(e instanceof Error ? e.message : 'Contingency failed');
143
- }
144
- };
145
-
146
- // Lower-triangle masked matrix for the heatmap
147
- const masked = report
148
- ? report.matrix.map((row, i) => row.map((val, j) => (j > i ? null : val)))
149
- : [];
150
-
151
- return (
152
- <div className="space-y-4">
153
- <Panel
154
- title="Categorical Association Explorer (Cramér's V)"
155
- subtitle="Pairwise association across the selected categorical columns"
156
- actions={
157
- <div className="flex items-center gap-2">
158
- <button
159
- onClick={runXgboost}
160
- disabled={xgbLoading || selected.size < 2}
161
- title="Train XGBoost on the selected columns and send the result to Feature Importance"
162
- className="flex items-center gap-1.5 rounded-md border border-border bg-raised px-3 py-1.5 text-xs font-medium text-text-secondary transition-colors hover:border-accent hover:text-accent disabled:opacity-50"
163
- >
164
- <BarChart3 className="h-3.5 w-3.5" />
165
- {xgbLoading ? 'Training…' : 'Feature Importance →'}
166
- </button>
167
- <button
168
- onClick={run}
169
- disabled={loading}
170
- className="flex items-center gap-2 rounded-md bg-accent-dim px-4 py-1.5 text-xs font-medium text-white transition-colors hover:bg-accent disabled:opacity-50"
171
- >
172
- <Play className="h-3.5 w-3.5" />
173
- {loading ? 'Computing…' : 'Compute'}
174
- </button>
175
- </div>
176
- }
177
- >
178
- <div className="flex flex-wrap items-center gap-4">
179
- <label className="flex items-center gap-2 text-xs text-text-secondary">
180
- <input
181
- type="checkbox"
182
- checked={dropMissing}
183
- onChange={(e) => setDropMissing(e.target.checked)}
184
- className="accent-accent"
185
- />
186
- Drop missing (complete-case pairs)
187
- </label>
188
- <label className="flex items-center gap-2 text-xs text-text-secondary">
189
- <input
190
- type="checkbox"
191
- checked={excludeTrivial}
192
- onChange={(e) => setExcludeTrivial(e.target.checked)}
193
- className="accent-accent"
194
- />
195
- Exclude trivial (V≈0 / V≈1)
196
- </label>
197
- <label className="flex items-center gap-2 text-xs text-text-secondary">
198
- Strong ≥ {strong.toFixed(2)}
199
- <input
200
- type="range"
201
- min={0.1}
202
- max={0.9}
203
- step={0.05}
204
- value={strong}
205
- onChange={(e) => setStrong(Number(e.target.value))}
206
- className="w-28 accent-accent"
207
- />
208
- </label>
209
- </div>
210
- </Panel>
211
-
212
- {/* Column / parent-group selector */}
213
- <Panel
214
- title="Columns"
215
- subtitle="Add whole parent groups (nested dot-separated names) or individual columns"
216
- actions={
217
- <div className="flex items-center gap-2 text-[11px]">
218
- <span className="text-text-muted">
219
- {selected.size}/{orderedEligible.length} selected
220
- </span>
221
- <button
222
- onClick={selectAll}
223
- className="rounded border border-border px-2 py-0.5 text-text-secondary transition-colors hover:border-accent hover:text-accent"
224
- >
225
- All
226
- </button>
227
- <button
228
- onClick={clearAll}
229
- className="rounded border border-border px-2 py-0.5 text-text-secondary transition-colors hover:border-accent hover:text-accent"
230
- >
231
- Clear
232
- </button>
233
- </div>
234
- }
235
- >
236
- {groupsError && <p className="text-xs text-danger">{groupsError}</p>}
237
- {!groupsError && orderedEligible.length === 0 && (
238
- <p className="text-xs text-text-muted">
239
- No categorical-eligible columns found for this source (binary/low/medium cardinality).
240
- </p>
241
- )}
242
-
243
- <div className="space-y-3">
244
- {nestedGroups.map((g) => {
245
- const sel = g.columns.filter((c) => selected.has(c)).length;
246
- const all = sel === g.columns.length;
247
- return (
248
- <div key={g.parent} className="rounded-md border border-border/50 bg-raised/40 p-2">
249
- <button
250
- onClick={() => toggleGroup(g)}
251
- title={all ? 'Remove whole group' : 'Add whole group'}
252
- className={`mb-1.5 flex items-center gap-1.5 rounded px-1.5 py-0.5 text-xs font-semibold transition-colors ${
253
- all ? 'text-accent-bright' : sel ? 'text-accent' : 'text-text-secondary hover:text-text-primary'
254
- }`}
255
- >
256
- <Layers className="h-3.5 w-3.5" />
257
- {g.parent}
258
- <span className="font-normal text-text-muted">
259
- ({sel}/{g.columns.length})
260
- </span>
261
- </button>
262
- <div className="flex flex-wrap gap-1.5 pl-1">
263
- {g.columns.map((c, i) => (
264
- <button
265
- key={c}
266
- onClick={() => toggleCol(c)}
267
- title={c}
268
- className={`rounded-md border px-2 py-1 text-[11px] transition-colors ${
269
- selected.has(c)
270
- ? 'border-accent bg-accent-dim/30 text-accent-bright'
271
- : 'border-border bg-raised text-text-secondary hover:border-border-bright'
272
- }`}
273
- >
274
- {g.leaves[i]}
275
- </button>
276
- ))}
277
- </div>
278
- </div>
279
- );
280
- })}
281
-
282
- {standaloneCols.length > 0 && (
283
- <div>
284
- {nestedGroups.length > 0 && (
285
- <p className="mb-1.5 text-[11px] font-medium text-text-muted">Ungrouped columns</p>
286
- )}
287
- <div className="flex flex-wrap gap-1.5">
288
- {standaloneCols.map((c) => (
289
- <button
290
- key={c}
291
- onClick={() => toggleCol(c)}
292
- className={`rounded-md border px-2 py-1 text-[11px] transition-colors ${
293
- selected.has(c)
294
- ? 'border-accent bg-accent-dim/30 text-accent-bright'
295
- : 'border-border bg-raised text-text-secondary hover:border-border-bright'
296
- }`}
297
- >
298
- {c}
299
- </button>
300
- ))}
301
- </div>
302
- </div>
303
- )}
304
- </div>
305
- </Panel>
306
-
307
- {error && (
308
- <div className="flex items-center gap-2 rounded-md border border-danger/30 bg-danger/10 px-4 py-2.5 text-sm text-danger">
309
- <AlertTriangle className="h-4 w-4" /> {error}
310
- </div>
311
- )}
312
-
313
- {loading && <LoadingSpinner text="Computing Cramér's V matrix..." />}
314
- {xgbLoading && <LoadingSpinner text="Training XGBoost on the selected columns..." />}
315
-
316
- {localXgb && (
317
- <Panel
318
- title="Feature Importance (XGBoost)"
319
- subtitle="Each selected column predicted from the others — gain-based importance"
320
- >
321
- <XGBoostResults results={localXgb} />
322
- </Panel>
323
- )}
324
-
325
- {report && report.labels.length < 2 && (
326
- <Panel title="Not enough categorical columns">
327
- <p className="text-sm text-text-muted">
328
- Fewer than two suitable categorical columns were selected (binary/low/medium cardinality).
329
- High-cardinality, free-text and constant columns are excluded automatically.
330
- </p>
331
- </Panel>
332
- )}
333
-
334
- {report && report.labels.length >= 2 && (
335
- <div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
336
- {/* Heatmap */}
337
- <div className="xl:col-span-2">
338
- <Panel title="Association Matrix" subtitle="Click a cell to drill into the contingency table" noPad>
339
- <div className="p-2">
340
- <Plot
341
- data={[
342
- {
343
- z: masked,
344
- x: report.labels,
345
- y: report.labels,
346
- type: 'heatmap',
347
- colorscale: [
348
- [0, '#0d1117'],
349
- [0.25, '#1f3a5f'],
350
- [0.5, '#3d6098'],
351
- [0.75, '#d29922'],
352
- [1, '#f85149'],
353
- ],
354
- zmin: 0,
355
- zmax: 1,
356
- hoverongaps: false,
357
- colorbar: {
358
- title: { text: "Cramér's V", font: { color: '#8b949e', size: 10 } },
359
- tickfont: { color: '#8b949e', size: 9 },
360
- },
361
- },
362
- ]}
363
- layout={{
364
- paper_bgcolor: 'transparent',
365
- plot_bgcolor: 'transparent',
366
- font: { color: '#8b949e', size: 9 },
367
- margin: { l: 130, r: 30, t: 20, b: 130 },
368
- xaxis: { tickangle: -45, automargin: true },
369
- yaxis: { automargin: true },
370
- height: Math.max(360, report.labels.length * 26),
371
- }}
372
- config={{ responsive: true, displayModeBar: false }}
373
- style={{ width: '100%' }}
374
- onClick={(e: Readonly<{ points?: Array<{ x?: unknown; y?: unknown }> }>) => {
375
- const pt = e.points?.[0];
376
- if (pt && pt.x != null && pt.y != null) {
377
- loadContingency(String(pt.y), String(pt.x));
378
- }
379
- }}
380
- />
381
- </div>
382
- </Panel>
383
-
384
- {/* Contingency drilldown */}
385
- {pair && contingency && (
386
- <Panel
387
- title={`Contingency: ${pair.a} × ${pair.b}`}
388
- subtitle={`Cramér's V = ${contingency.v} · N = ${contingency.n.toLocaleString()}`}
389
- className="mt-4"
390
- noPad
391
- >
392
- <div className="overflow-auto p-2" style={{ maxHeight: 360 }}>
393
- <table className="border-collapse text-[11px]">
394
- <thead>
395
- <tr>
396
- <th className="sticky left-0 bg-deep px-2 py-1 text-left text-text-muted">
397
- {pair.a} \ {pair.b}
398
- </th>
399
- {contingency.col_labels.map((c) => (
400
- <th key={c} className="px-2 py-1 text-text-muted" title={c}>
401
- <div className="max-w-24 truncate">{c}</div>
402
- </th>
403
- ))}
404
- </tr>
405
- </thead>
406
- <tbody>
407
- {contingency.row_labels.map((r, i) => (
408
- <tr key={r} className="border-t border-border/30">
409
- <td className="sticky left-0 bg-surface px-2 py-1 font-medium text-text-secondary" title={r}>
410
- <div className="max-w-32 truncate">{r}</div>
411
- </td>
412
- {contingency.matrix[i].map((v, j) => (
413
- <td key={j} className="px-2 py-1 text-center text-text-secondary">
414
- {v || ''}
415
- </td>
416
- ))}
417
- </tr>
418
- ))}
419
- </tbody>
420
- </table>
421
- </div>
422
- </Panel>
423
- )}
424
- </div>
425
-
426
- {/* Pairs + high-corr columns */}
427
- <div className="space-y-4">
428
- <Panel
429
- title="Strongest Pairs"
430
- subtitle={report.n_excluded ? `${report.n_excluded} trivial pairs hidden` : undefined}
431
- >
432
- <div className="max-h-96 space-y-1 overflow-y-auto">
433
- {report.pairs.slice(0, 40).map((p, i) => (
434
- <button
435
- key={i}
436
- onClick={() => loadContingency(p.a, p.b)}
437
- className="flex w-full items-center justify-between gap-2 rounded border border-border/40 bg-raised px-2.5 py-1.5 text-left text-[11px] hover:border-accent"
438
- >
439
- <span className="truncate text-text-secondary">
440
- {p.a} <span className="text-text-muted">×</span> {p.b}
441
- </span>
442
- <span
443
- className={`shrink-0 font-mono font-semibold ${
444
- p.v >= strong ? 'text-accent-bright' : 'text-text-muted'
445
- }`}
446
- >
447
- {p.v.toFixed(3)}
448
- </span>
449
- </button>
450
- ))}
451
- {report.pairs.length === 0 && (
452
- <p className="text-xs text-text-muted">No non-trivial pairs found.</p>
453
- )}
454
- </div>
455
- </Panel>
456
-
457
- {report.high_correlation_columns.length > 0 && (
458
- <Panel title={`High-Correlation Columns (≥ ${strong.toFixed(2)})`}>
459
- <div className="flex flex-wrap gap-1.5">
460
- {report.high_correlation_columns.map((c) => (
461
- <span
462
- key={c}
463
- className="rounded-md border border-purple/40 bg-purple/10 px-2 py-1 text-[11px] text-text-primary"
464
- >
465
- {c}
466
- </span>
467
- ))}
468
- </div>
469
- </Panel>
470
- )}
471
- </div>
472
- </div>
473
- )}
474
-
475
- {!report && !loading && (
476
- <Panel title="Cramér's V">
477
- <p className="flex items-center gap-2 text-sm text-text-muted">
478
- <Grid3x3 className="h-4 w-4" />
479
- Pick parent groups / columns above, then click{' '}
480
- <span className="text-accent">Compute</span> to score categorical associations
481
- across the {source === 'parsed' ? 'parsed' : 'loaded'} dataset.
482
- </p>
483
- </Panel>
484
- )}
485
- </div>
486
- );
487
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/components/analysis/DistributionChart.tsx DELETED
@@ -1,43 +0,0 @@
1
- import Plot from 'react-plotly.js';
2
- import type { DistributionItem } from '../../types';
3
-
4
- interface Props {
5
- data: DistributionItem[];
6
- title: string;
7
- height?: number;
8
- }
9
-
10
- const COLORS = [
11
- '#58a6ff', '#3fb950', '#f0883e', '#bc8cff', '#39d2c0',
12
- '#f85149', '#d29922', '#79c0ff', '#56d364', '#ffa657',
13
- ];
14
-
15
- export function DistributionChart({ data, title, height = 300 }: Props) {
16
- return (
17
- <Plot
18
- data={[
19
- {
20
- labels: data.map((d) => d.label),
21
- values: data.map((d) => d.count),
22
- type: 'pie',
23
- hole: 0.5,
24
- marker: { colors: COLORS },
25
- textfont: { color: '#e6edf3', size: 9 },
26
- hovertemplate: '%{label}<br>Count: %{value}<br>%{percent}<extra></extra>',
27
- },
28
- ]}
29
- layout={{
30
- title: { text: title, font: { color: '#e6edf3', size: 13 } },
31
- paper_bgcolor: 'transparent',
32
- plot_bgcolor: 'transparent',
33
- font: { color: '#8b949e', size: 9 },
34
- margin: { l: 10, r: 10, t: 40, b: 10 },
35
- showlegend: true,
36
- legend: { font: { size: 8, color: '#8b949e' }, bgcolor: 'transparent' },
37
- height,
38
- }}
39
- config={{ responsive: true, displayModeBar: false }}
40
- style={{ width: '100%' }}
41
- />
42
- );
43
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/components/analysis/XGBoostResults.tsx DELETED
@@ -1,79 +0,0 @@
1
- import Plot from 'react-plotly.js';
2
- import type { XGBoostResult } from '../../types';
3
-
4
- interface Props {
5
- results: Record<string, XGBoostResult>;
6
- }
7
-
8
- export function XGBoostResults({ results }: Props) {
9
- const columns = Object.keys(results);
10
-
11
- return (
12
- <div className="space-y-4">
13
- {columns.map((col) => {
14
- const r = results[col];
15
- const features = Object.keys(r.feature_importance);
16
- const importances = Object.values(r.feature_importance);
17
-
18
- return (
19
- <div key={col} className="rounded-lg border border-border bg-surface">
20
- <div className="flex items-center justify-between border-b border-border px-4 py-3">
21
- <div>
22
- <h4 className="text-sm font-semibold text-text-primary">{col}</h4>
23
- <p className="text-xs text-text-muted">Feature importance analysis</p>
24
- </div>
25
- <div className="flex items-center gap-2">
26
- <span className="text-xs text-text-muted">Accuracy:</span>
27
- <span
28
- className={`rounded-full px-2.5 py-0.5 text-xs font-bold ${
29
- r.accuracy >= 0.8
30
- ? 'bg-success/20 text-success'
31
- : r.accuracy >= 0.6
32
- ? 'bg-warning/20 text-warning'
33
- : 'bg-danger/20 text-danger'
34
- }`}
35
- >
36
- {(r.accuracy * 100).toFixed(1)}%
37
- </span>
38
- </div>
39
- </div>
40
- <div className="p-4">
41
- <Plot
42
- data={[
43
- {
44
- y: features,
45
- x: importances,
46
- type: 'bar',
47
- orientation: 'h',
48
- marker: {
49
- color: importances.map((v) => {
50
- const t = v / Math.max(...importances, 0.001);
51
- return `rgba(88, 166, 255, ${0.3 + t * 0.7})`;
52
- }),
53
- },
54
- hovertemplate: '%{y}: %{x:.3f}<extra></extra>',
55
- },
56
- ]}
57
- layout={{
58
- paper_bgcolor: 'transparent',
59
- plot_bgcolor: 'transparent',
60
- font: { color: '#8b949e', size: 10 },
61
- margin: { l: 120, r: 20, t: 10, b: 30 },
62
- xaxis: {
63
- title: { text: 'Importance (Gain)', font: { size: 10 } },
64
- gridcolor: '#21283b',
65
- zerolinecolor: '#30363d',
66
- },
67
- yaxis: { autorange: 'reversed' },
68
- height: 200,
69
- }}
70
- config={{ responsive: true, displayModeBar: false }}
71
- style={{ width: '100%' }}
72
- />
73
- </div>
74
- </div>
75
- );
76
- })}
77
- </div>
78
- );
79
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/components/common/LoadingSpinner.tsx DELETED
@@ -1,11 +0,0 @@
1
- export function LoadingSpinner({ text = 'Loading...' }: { text?: string }) {
2
- return (
3
- <div className="flex flex-col items-center justify-center gap-3 py-12">
4
- <div className="relative h-10 w-10">
5
- <div className="absolute inset-0 rounded-full border-2 border-border opacity-30" />
6
- <div className="absolute inset-0 animate-spin rounded-full border-2 border-transparent border-t-accent" />
7
- </div>
8
- <span className="text-sm text-text-secondary">{text}</span>
9
- </div>
10
- );
11
- }
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/components/common/Panel.tsx DELETED
@@ -1,27 +0,0 @@
1
- import type { ReactNode } from 'react';
2
-
3
- interface PanelProps {
4
- title?: string;
5
- subtitle?: string;
6
- children: ReactNode;
7
- className?: string;
8
- actions?: ReactNode;
9
- noPad?: boolean;
10
- }
11
-
12
- export function Panel({ title, subtitle, children, className = '', actions, noPad }: PanelProps) {
13
- return (
14
- <div className={`rounded-lg border border-border bg-surface ${className}`}>
15
- {(title || actions) && (
16
- <div className="flex items-center justify-between border-b border-border px-4 py-3">
17
- <div>
18
- {title && <h3 className="text-sm font-semibold text-text-primary">{title}</h3>}
19
- {subtitle && <p className="mt-0.5 text-xs text-text-muted">{subtitle}</p>}
20
- </div>
21
- {actions && <div className="flex items-center gap-2">{actions}</div>}
22
- </div>
23
- )}
24
- <div className={noPad ? '' : 'p-4'}>{children}</div>
25
- </div>
26
- );
27
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/components/common/StatusBadge.tsx DELETED
@@ -1,22 +0,0 @@
1
- interface StatusBadgeProps {
2
- status: 'success' | 'warning' | 'danger' | 'info' | 'idle';
3
- label: string;
4
- pulse?: boolean;
5
- }
6
-
7
- const colorMap = {
8
- success: 'bg-success/20 text-success border-success/30',
9
- warning: 'bg-warning/20 text-warning border-warning/30',
10
- danger: 'bg-danger/20 text-danger border-danger/30',
11
- info: 'bg-accent/20 text-accent border-accent/30',
12
- idle: 'bg-border/50 text-text-muted border-border',
13
- };
14
-
15
- export function StatusBadge({ status, label, pulse }: StatusBadgeProps) {
16
- return (
17
- <span className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium ${colorMap[status]}`}>
18
- <span className={`h-1.5 w-1.5 rounded-full bg-current ${pulse ? 'animate-pulse' : ''}`} />
19
- {label}
20
- </span>
21
- );
22
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/components/dashboard/Dashboard.tsx DELETED
@@ -1,194 +0,0 @@
1
- import { useEffect, useState, useCallback } from 'react';
2
- import {
3
- Database,
4
- Columns3,
5
- BrainCircuit,
6
- HardDrive,
7
- Upload,
8
- Play,
9
- AlertTriangle,
10
- } from 'lucide-react';
11
- import { api } from '../../api/client';
12
- import { useStore } from '../../store/useStore';
13
- import { StatCard } from './StatCard';
14
- import { Panel } from '../common/Panel';
15
- import { LoadingSpinner } from '../common/LoadingSpinner';
16
-
17
- export function Dashboard() {
18
- const { summary, setSummary, setPage, dataLoaded, setData } = useStore();
19
- const [loading, setLoading] = useState(false);
20
- const [error, setError] = useState<string | null>(null);
21
-
22
- const fetchSummary = useCallback(async () => {
23
- try {
24
- const s = await api.getDashboardSummary();
25
- setSummary(s);
26
- } catch {
27
- // Summary endpoint might not have data yet
28
- }
29
- }, [setSummary]);
30
-
31
- useEffect(() => {
32
- fetchSummary();
33
- }, [fetchSummary]);
34
-
35
- const handleLoadData = async (type: 'west' | 'east' = 'west') => {
36
- setLoading(true);
37
- setError(null);
38
- try {
39
- const res = await api.loadData(type, 15000);
40
- setData(res.data, res.column_stats);
41
- await fetchSummary();
42
- } catch (e: unknown) {
43
- setError(e instanceof Error ? e.message : 'Failed to load data');
44
- } finally {
45
- setLoading(false);
46
- }
47
- };
48
-
49
- return (
50
- <div className="space-y-6">
51
- {/* Stat cards */}
52
- <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
53
- <StatCard
54
- label="Total Records"
55
- value={summary?.total_rows?.toLocaleString() ?? '---'}
56
- icon={<Database className="h-5 w-5" />}
57
- color="text-accent"
58
- />
59
- <StatCard
60
- label="Columns"
61
- value={summary?.total_columns ?? '---'}
62
- icon={<Columns3 className="h-5 w-5" />}
63
- color="text-purple"
64
- />
65
- <StatCard
66
- label="Analysis Runs"
67
- value={summary?.analysis_runs ?? 0}
68
- icon={<BrainCircuit className="h-5 w-5" />}
69
- color="text-cyan"
70
- />
71
- <StatCard
72
- label="Memory Usage"
73
- value={summary?.memory_mb ? `${summary.memory_mb} MB` : '---'}
74
- icon={<HardDrive className="h-5 w-5" />}
75
- color="text-orange"
76
- />
77
- </div>
78
-
79
- {/* Quick actions & status */}
80
- <div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
81
- {/* Quick Actions */}
82
- <Panel title="Quick Actions" className="lg:col-span-1">
83
- <div className="space-y-3">
84
- <div className="overflow-hidden rounded-md border border-border bg-raised">
85
- <div className="flex items-center gap-2 border-b border-border/50 bg-surface/30 px-3 py-2 text-xs font-semibold uppercase tracking-wider text-text-muted">
86
- <Upload className="h-3.5 w-3.5" />
87
- Load Parsed Dataset
88
- </div>
89
- <div className="flex divide-x divide-border/50">
90
- <button
91
- onClick={() => handleLoadData('west')}
92
- disabled={loading}
93
- className="flex-1 px-4 py-3 text-sm font-medium transition-colors hover:bg-accent/10 hover:text-accent disabled:opacity-50"
94
- >
95
- West
96
- </button>
97
- <button
98
- onClick={() => handleLoadData('east')}
99
- disabled={loading}
100
- className="flex-1 px-4 py-3 text-sm font-medium transition-colors hover:bg-purple/10 hover:text-purple disabled:opacity-50"
101
- >
102
- East
103
- </button>
104
- </div>
105
- </div>
106
-
107
- <button
108
- onClick={() => setPage('data')}
109
- className="flex w-full items-center gap-3 rounded-md border border-border bg-raised px-4 py-3 text-sm text-text-primary transition-colors hover:border-accent hover:bg-elevated"
110
- >
111
- <Database className="h-4 w-4 text-purple" />
112
- Open Data Explorer
113
- </button>
114
- <button
115
- onClick={() => setPage('analysis')}
116
- disabled={!dataLoaded}
117
- className="flex w-full items-center gap-3 rounded-md border border-border bg-raised px-4 py-3 text-sm text-text-primary transition-colors hover:border-accent hover:bg-elevated disabled:opacity-50"
118
- >
119
- <Play className="h-4 w-4 text-cyan" />
120
- Run Analysis Pipeline
121
- </button>
122
- </div>
123
- {loading && <LoadingSpinner text="Loading dataset..." />}
124
- {error && (
125
- <div className="mt-3 flex items-center gap-2 rounded-md bg-danger/10 px-3 py-2 text-sm text-danger">
126
- <AlertTriangle className="h-4 w-4" /> {error}
127
- </div>
128
- )}
129
- </Panel>
130
-
131
- {/* Data schema overview */}
132
- <Panel title="Data Schema" subtitle={summary?.loaded ? `${summary.total_columns} columns detected` : 'Load data to view schema'} className="lg:col-span-2">
133
- {summary?.columns && summary.columns.length > 0 ? (
134
- <div className="max-h-72 overflow-y-auto">
135
- <table className="w-full text-sm">
136
- <thead>
137
- <tr className="border-b border-border text-left text-xs uppercase text-text-muted">
138
- <th className="pb-2 pr-4">Column</th>
139
- <th className="pb-2 pr-4">Type</th>
140
- <th className="pb-2">Nulls</th>
141
- </tr>
142
- </thead>
143
- <tbody>
144
- {summary.columns.map((col) => (
145
- <tr key={col} className="border-b border-border/50">
146
- <td className="py-1.5 pr-4 font-mono text-xs text-text-primary">{col}</td>
147
- <td className="py-1.5 pr-4">
148
- <span className="rounded bg-elevated px-1.5 py-0.5 text-xs text-text-secondary">
149
- {summary.dtypes?.[col] ?? '?'}
150
- </span>
151
- </td>
152
- <td className="py-1.5 text-xs text-text-muted">
153
- {summary.null_counts?.[col] ?? 0}
154
- </td>
155
- </tr>
156
- ))}
157
- </tbody>
158
- </table>
159
- </div>
160
- ) : (
161
- <p className="text-sm text-text-muted">No data loaded. Use &quot;Load Parsed Dataset&quot; to begin.</p>
162
- )}
163
- </Panel>
164
- </div>
165
-
166
- {/* System status */}
167
- <Panel title="System Pipeline">
168
- <div className="mb-3 flex flex-wrap items-center gap-3 text-xs text-text-muted">
169
- <span>Mode: {summary?.analysis_mode ?? 'mock'}</span>
170
- <span>Last run: {summary?.last_analysis_at ? new Date(summary.last_analysis_at).toLocaleString() : 'N/A'}</span>
171
- </div>
172
- <div className="flex items-center gap-2">
173
- {['Data Ingestion', 'Embedding', 'Dimensionality Reduction', 'Clustering', 'TF-IDF Naming', 'Merge Clusters', 'XGBoost', 'Visualization'].map(
174
- (step, i) => (
175
- <div key={step} className="flex items-center gap-2">
176
- <div
177
- className={`rounded-md border px-3 py-1.5 text-xs font-medium ${i === 0 && dataLoaded
178
- ? 'border-success/30 bg-success/10 text-success'
179
- : 'border-border bg-raised text-text-muted'
180
- }`}
181
- >
182
- {step}
183
- </div>
184
- {i < 7 && (
185
- <div className="h-px w-4 bg-border" />
186
- )}
187
- </div>
188
- )
189
- )}
190
- </div>
191
- </Panel>
192
- </div>
193
- );
194
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/components/dashboard/StatCard.tsx DELETED
@@ -1,24 +0,0 @@
1
- import type { ReactNode } from 'react';
2
-
3
- interface StatCardProps {
4
- label: string;
5
- value: string | number;
6
- icon: ReactNode;
7
- trend?: string;
8
- color?: string;
9
- }
10
-
11
- export function StatCard({ label, value, icon, trend, color = 'text-accent' }: StatCardProps) {
12
- return (
13
- <div className="rounded-lg border border-border bg-surface p-4">
14
- <div className="flex items-start justify-between">
15
- <div>
16
- <p className="text-xs font-medium uppercase tracking-wider text-text-muted">{label}</p>
17
- <p className={`mt-1 text-2xl font-bold ${color}`}>{value}</p>
18
- {trend && <p className="mt-1 text-xs text-text-secondary">{trend}</p>}
19
- </div>
20
- <div className="rounded-md bg-elevated p-2 text-text-muted">{icon}</div>
21
- </div>
22
- </div>
23
- );
24
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/components/data/DataExplorer.tsx DELETED
@@ -1,152 +0,0 @@
1
- import { useState, useCallback } from 'react';
2
- import { Upload, FileSpreadsheet, AlertTriangle } from 'lucide-react';
3
- import { api } from '../../api/client';
4
- import { useStore } from '../../store/useStore';
5
- import { Panel } from '../common/Panel';
6
- import { LoadingSpinner } from '../common/LoadingSpinner';
7
- import { DataTable } from './DataTable';
8
- import { FilterPanel } from './FilterPanel';
9
- import type { ColumnStat, DataResponse } from '../../types';
10
-
11
- export function DataExplorer() {
12
- const { data, columnStats, setData, dataLoaded } = useStore();
13
- const [loading, setLoading] = useState(false);
14
- const [error, setError] = useState<string | null>(null);
15
- const [activeData, setActiveData] = useState<DataResponse | null>(null);
16
- const [activeStats, setActiveStats] = useState<ColumnStat[]>([]);
17
-
18
- const displayData = activeData ?? data;
19
- const displayStats = activeStats.length > 0 ? activeStats : columnStats;
20
-
21
-
22
- const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
23
- const file = e.target.files?.[0];
24
- if (!file) return;
25
- setLoading(true);
26
- setError(null);
27
- try {
28
- const res = await api.uploadFile(file);
29
- setData(res.data, res.column_stats);
30
- setActiveData(null);
31
- setActiveStats([]);
32
- } catch (err: unknown) {
33
- setError(err instanceof Error ? err.message : 'Upload failed');
34
- } finally {
35
- setLoading(false);
36
- }
37
- };
38
-
39
- const handleFilter = useCallback(
40
- async (
41
- filters: {
42
- column: string;
43
- type: string;
44
- values?: string[];
45
- min_val?: number;
46
- max_val?: number;
47
- pattern?: string;
48
- }[]
49
- ) => {
50
- if (filters.length === 0) {
51
- setActiveData(null);
52
- setActiveStats([]);
53
- setError(null);
54
- return;
55
- }
56
- setLoading(true);
57
- setError(null);
58
- try {
59
- const res = await api.filterData(filters);
60
- setActiveData(res.data);
61
- setActiveStats(res.column_stats);
62
- } catch (err: unknown) {
63
- setError(err instanceof Error ? err.message : 'Filtering failed');
64
- } finally {
65
- setLoading(false);
66
- }
67
- },
68
- []
69
- );
70
-
71
- return (
72
- <div className="space-y-4">
73
- <div className="flex flex-wrap items-center gap-3">
74
-
75
-
76
- <label className="flex cursor-pointer items-center gap-2 rounded-md border border-border bg-surface px-4 py-2 text-sm text-text-primary transition-colors hover:border-purple hover:bg-elevated">
77
- <Upload className="h-4 w-4 text-purple" />
78
- Upload Files
79
- <input type="file" accept=".csv,.xlsx,.xls,.json" onChange={handleUpload} className="hidden" />
80
- </label>
81
-
82
- {dataLoaded && displayData && (
83
- <div className="ml-auto flex items-center gap-3 text-xs text-text-muted">
84
- <FileSpreadsheet className="h-4 w-4" />
85
- <span>
86
- {displayData.returned_rows.toLocaleString()} / {displayData.total_rows.toLocaleString()} rows
87
- </span>
88
- <span>{displayData.columns.length} columns</span>
89
- </div>
90
- )}
91
- </div>
92
-
93
- {error && (
94
- <div className="flex items-center gap-2 rounded-md border border-danger/30 bg-danger/10 px-4 py-2.5 text-sm text-danger">
95
- <AlertTriangle className="h-4 w-4" /> {error}
96
- </div>
97
- )}
98
-
99
- {loading && <LoadingSpinner text="Processing data..." />}
100
-
101
- {/* Filters */}
102
- {dataLoaded && displayStats.length > 0 && (
103
- <FilterPanel columns={displayStats} onApply={handleFilter} />
104
- )}
105
-
106
- {/* Column Stats */}
107
- {dataLoaded && displayStats.length > 0 && (
108
- <Panel title="Column Statistics" subtitle={`${displayStats.length} columns`}>
109
- <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6">
110
- {displayStats.map((col) => (
111
- <div
112
- key={col.name}
113
- className="rounded border border-border/50 bg-raised p-2.5"
114
- >
115
- <p className="truncate text-xs font-medium text-text-primary" title={col.name}>
116
- {col.name}
117
- </p>
118
- <p className="mt-0.5 text-[10px] text-text-muted">
119
- {col.dtype} | {col.unique} unique
120
- </p>
121
- {col.top_values && col.top_values.length > 0 && (
122
- <div className="mt-1.5 space-y-0.5">
123
- {col.top_values.slice(0, 3).map((tv) => (
124
- <div key={tv.value} className="flex items-center gap-1">
125
- <div
126
- className="h-1 rounded-full bg-accent/60"
127
- style={{
128
- width: `${Math.min(100, (tv.count / (col.non_null || 1)) * 100)}%`,
129
- }}
130
- />
131
- <span className="whitespace-nowrap text-[9px] text-text-muted">
132
- {tv.value.slice(0, 15)}
133
- </span>
134
- </div>
135
- ))}
136
- </div>
137
- )}
138
- </div>
139
- ))}
140
- </div>
141
- </Panel>
142
- )}
143
-
144
- {/* Data Table */}
145
- {dataLoaded && displayData && (
146
- <Panel title="Data View" noPad>
147
- <DataTable data={displayData} maxHeight="calc(100vh - 420px)" />
148
- </Panel>
149
- )}
150
- </div>
151
- );
152
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/components/data/DataTable.tsx DELETED
@@ -1,299 +0,0 @@
1
- import { useState, useMemo, useEffect } from 'react';
2
- import {
3
- ChevronUp,
4
- ChevronDown,
5
- ChevronsUpDown,
6
- Maximize2,
7
- Minimize2,
8
- Download,
9
- Eye,
10
- Search,
11
- X,
12
- FileText
13
- } from 'lucide-react';
14
- import type { DataResponse } from '../../types';
15
-
16
- interface DataTableProps {
17
- data: DataResponse;
18
- maxHeight?: string;
19
- }
20
-
21
- type SortDir = 'asc' | 'desc' | null;
22
-
23
- export function DataTable({ data, maxHeight = '500px' }: DataTableProps) {
24
- const [sortCol, setSortCol] = useState<string | null>(null);
25
- const [sortDir, setSortDir] = useState<SortDir>(null);
26
- const [page, setPage] = useState(0);
27
- const [isFullScreen, setIsFullScreen] = useState(false);
28
- const [searchTerm, setSearchTerm] = useState('');
29
- const [selectedRow, setSelectedRow] = useState<any | null>(null);
30
- const pageSize = 100;
31
-
32
- // Handle ESC key for full screen
33
- useEffect(() => {
34
- const handleEsc = (e: KeyboardEvent) => {
35
- if (e.key === 'Escape' && isFullScreen) setIsFullScreen(false);
36
- };
37
- window.addEventListener('keydown', handleEsc);
38
- return () => window.removeEventListener('keydown', handleEsc);
39
- }, [isFullScreen]);
40
-
41
- const handleSort = (col: string) => {
42
- if (sortCol === col) {
43
- setSortDir((d) => (d === 'asc' ? 'desc' : d === 'desc' ? null : 'asc'));
44
- if (sortDir === 'desc') setSortCol(null);
45
- } else {
46
- setSortCol(col);
47
- setSortDir('asc');
48
- }
49
- setPage(0);
50
- };
51
-
52
- const filtered = useMemo(() => {
53
- if (!searchTerm) return data.rows;
54
- const lower = searchTerm.toLowerCase();
55
- return data.rows.filter(row =>
56
- Object.values(row).some(val => String(val).toLowerCase().includes(lower))
57
- );
58
- }, [data.rows, searchTerm]);
59
-
60
- const sorted = useMemo(() => {
61
- if (!sortCol || !sortDir) return filtered;
62
- return [...filtered].sort((a, b) => {
63
- const va = a[sortCol];
64
- const vb = b[sortCol];
65
- if (va == null && vb == null) return 0;
66
- if (va == null) return 1;
67
- if (vb == null) return -1;
68
- if (typeof va === 'number' && typeof vb === 'number') {
69
- return sortDir === 'asc' ? va - vb : vb - va;
70
- }
71
- const sa = String(va);
72
- const sb = String(vb);
73
- return sortDir === 'asc' ? sa.localeCompare(sb) : sb.localeCompare(sa);
74
- });
75
- }, [filtered, sortCol, sortDir]);
76
-
77
- const paged = sorted.slice(page * pageSize, (page + 1) * pageSize);
78
- const totalPages = Math.ceil(sorted.length / pageSize);
79
-
80
- const exportToCSV = () => {
81
- const headers = data.columns.join(',');
82
- const rows = sorted.map(row =>
83
- data.columns.map(col => {
84
- const val = row[col] == null ? '' : String(row[col]);
85
- return `"${val.replace(/"/g, '""')}"`;
86
- }).join(',')
87
- );
88
- const csvContent = [headers, ...rows].join('\n');
89
- // Use a Blob URL, not a data: URI — browsers cap data: URIs at a few MB,
90
- // which silently blocks large exports. A leading BOM keeps Excel in UTF-8.
91
- const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
92
- const url = URL.createObjectURL(blob);
93
- const link = document.createElement('a');
94
- link.href = url;
95
- link.download = `uap_data_export_${new Date().getTime()}.csv`;
96
- document.body.appendChild(link);
97
- link.click();
98
- document.body.removeChild(link);
99
- URL.revokeObjectURL(url);
100
- };
101
-
102
- const TableContent = (
103
- <div className={`flex flex-col bg-surface ${isFullScreen ? 'h-full w-full fixed inset-0 z-[100] p-6 animate-in fade-in zoom-in duration-200' : 'relative'}`}>
104
-
105
- {/* Table Header / Controls */}
106
- <div className="flex items-center justify-between gap-4 mb-3">
107
- <div className="flex items-center gap-2 flex-1 max-w-md relative">
108
- <Search className="h-4 w-4 absolute left-3 text-text-muted" />
109
- <input
110
- type="text"
111
- placeholder="Search current data..."
112
- value={searchTerm}
113
- onChange={(e) => { setSearchTerm(e.target.value); setPage(0); }}
114
- className="w-full bg-elevated border border-border rounded-md pl-9 pr-4 py-1.5 text-xs focus:ring-1 focus:ring-accent outline-none"
115
- />
116
- </div>
117
-
118
- <div className="flex items-center gap-2">
119
- <button
120
- onClick={exportToCSV}
121
- title="Export to CSV"
122
- className="flex items-center gap-2 px-3 py-1.5 rounded-md bg-elevated border border-border text-xs text-text-secondary hover:bg-accent hover:text-white transition-all shadow-sm"
123
- >
124
- <Download className="h-3.5 w-3.5" />
125
- Export
126
- </button>
127
- <button
128
- onClick={() => setIsFullScreen(!isFullScreen)}
129
- title={isFullScreen ? "Exit Full Screen" : "Full Screen"}
130
- className="p-1.5 rounded-md bg-elevated border border-border text-text-secondary hover:bg-purple hover:text-white transition-all shadow-sm"
131
- >
132
- {isFullScreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
133
- </button>
134
- {isFullScreen && (
135
- <button
136
- onClick={() => setIsFullScreen(false)}
137
- className="p-1.5 rounded-md bg-danger/10 text-danger hover:bg-danger hover:text-white transition-all"
138
- >
139
- <X className="h-4 w-4" />
140
- </button>
141
- )}
142
- </div>
143
- </div>
144
-
145
- <div className="flex-1 overflow-auto border border-border rounded-lg bg-deep/50 shadow-inner" style={{ maxHeight: isFullScreen ? 'calc(100vh - 180px)' : maxHeight }}>
146
- <table className="w-full border-collapse text-xs">
147
- <thead className="sticky top-0 z-10 bg-deep border-b border-border shadow-sm">
148
- <tr>
149
- <th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-text-muted bg-deep">
150
- #
151
- </th>
152
- <th className="px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-text-muted bg-deep">
153
- Actions
154
- </th>
155
- {data.columns.map((col) => (
156
- <th
157
- key={col}
158
- onClick={() => handleSort(col)}
159
- className="cursor-pointer select-none px-3 py-2.5 text-left text-[10px] font-bold uppercase tracking-wider text-text-muted hover:text-accent transition-colors bg-deep"
160
- >
161
- <div className="flex items-center gap-1.5">
162
- <span className="truncate">{col}</span>
163
- {sortCol === col ? (
164
- sortDir === 'asc' ? (
165
- <ChevronUp className="h-3 w-3 text-accent" />
166
- ) : (
167
- <ChevronDown className="h-3 w-3 text-accent" />
168
- )
169
- ) : (
170
- <ChevronsUpDown className="h-3 w-3 opacity-20" />
171
- )}
172
- </div>
173
- </th>
174
- ))}
175
- </tr>
176
- </thead>
177
- <tbody className="divide-y divide-border/20">
178
- {paged.map((row, idx) => (
179
- <tr
180
- key={idx}
181
- className="group border-b border-border/30 transition-colors hover:bg-accent/5"
182
- >
183
- <td className="px-3 py-2 text-text-muted/60 font-mono text-[10px]">{page * pageSize + idx + 1}</td>
184
- <td className="px-3 py-2 text-center">
185
- <button
186
- onClick={() => setSelectedRow(row)}
187
- className="p-1 rounded bg-elevated border border-border text-text-muted hover:text-purple hover:border-purple/50 transition-all opacity-0 group-hover:opacity-100"
188
- title="View Details"
189
- >
190
- <Eye className="h-3.5 w-3.5" />
191
- </button>
192
- </td>
193
- {data.columns.map((col) => {
194
- const val = row[col] != null ? String(row[col]) : '';
195
- return (
196
- <td
197
- key={col}
198
- className="max-w-48 truncate px-3 py-2 text-text-secondary font-medium tracking-tight"
199
- title={val.length > 30 ? val : ''}
200
- >
201
- {val}
202
- </td>
203
- );
204
- })}
205
- </tr>
206
- ))}
207
- </tbody>
208
- </table>
209
-
210
- {paged.length === 0 && (
211
- <div className="py-20 text-center flex flex-col items-center gap-3">
212
- <div className="p-4 rounded-full bg-elevated border border-border">
213
- <Search className="h-8 w-8 text-text-muted" />
214
- </div>
215
- <div>
216
- <p className="text-text-primary font-medium">No results found</p>
217
- <p className="text-xs text-text-muted">Try adjusting your search terms</p>
218
- </div>
219
- </div>
220
- )}
221
- </div>
222
-
223
- {/* Pagination */}
224
- <div className="flex items-center justify-between border-t border-border mt-3 pt-3">
225
- <div className="flex items-center gap-4">
226
- <span className="text-[11px] text-text-muted font-medium bg-elevated px-2 py-1 rounded border border-border/50">
227
- PAGE {page + 1} OF {totalPages || 1}
228
- </span>
229
- <span className="text-[11px] text-text-muted">
230
- Showing <span className="text-text-primary">{page * pageSize + 1}–{Math.min((page + 1) * pageSize, sorted.length)}</span> of{' '}
231
- <span className="text-text-primary font-bold">{sorted.length}</span> rows
232
- </span>
233
- </div>
234
- <div className="flex items-center gap-2">
235
- <button
236
- disabled={page === 0}
237
- onClick={() => setPage(page - 1)}
238
- className="flex items-center gap-1 rounded-md border border-border px-3 py-1.5 text-xs font-semibold text-text-secondary bg-surface hover:bg-elevated transition-colors disabled:opacity-30 disabled:hover:bg-surface"
239
- >
240
- Previous
241
- </button>
242
- <button
243
- disabled={page >= totalPages - 1}
244
- onClick={() => setPage(page + 1)}
245
- className="flex items-center gap-1 rounded-md border border-border px-3 py-1.5 text-xs font-semibold text-text-secondary bg-surface hover:bg-elevated transition-colors disabled:opacity-30 disabled:hover:bg-surface"
246
- >
247
- Next
248
- </button>
249
- </div>
250
- </div>
251
-
252
- {/* Row Detail Modal */}
253
- {selectedRow && (
254
- <div className="fixed inset-0 z-[110] flex items-center justify-center p-4 bg-background/80 backdrop-blur-sm animate-in fade-in duration-200">
255
- <div className="w-full max-w-2xl bg-surface border border-border rounded-xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200">
256
- <div className="flex items-center justify-between px-6 py-4 border-b border-border bg-deep/50">
257
- <div className="flex items-center gap-3">
258
- <div className="p-2 rounded-lg bg-purple/20 text-purple">
259
- <FileText className="h-5 w-5" />
260
- </div>
261
- <h3 className="font-bold text-text-primary">Row Details</h3>
262
- </div>
263
- <button
264
- onClick={() => setSelectedRow(null)}
265
- className="p-1.5 rounded-full hover:bg-elevated text-text-muted hover:text-text-primary transition-colors"
266
- >
267
- <X className="h-5 w-5" />
268
- </button>
269
- </div>
270
- <div className="max-h-[70vh] overflow-auto p-6">
271
- <div className="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
272
- {data.columns.map(col => (
273
- <div key={col} className="space-y-1 group">
274
- <label className="text-[10px] font-bold uppercase tracking-widest text-text-muted group-hover:text-accent transition-colors">
275
- {col}
276
- </label>
277
- <div className="p-3 bg-elevated rounded-lg border border-border/50 text-sm text-text-secondary break-words font-medium">
278
- {selectedRow[col] != null ? String(selectedRow[col]) : <span className="italic opacity-30">null</span>}
279
- </div>
280
- </div>
281
- ))}
282
- </div>
283
- </div>
284
- <div className="px-6 py-4 bg-deep/30 border-t border-border flex justify-end">
285
- <button
286
- onClick={() => setSelectedRow(null)}
287
- className="px-6 py-2 rounded-lg bg-accent text-white font-bold text-sm hover:scale-[1.02] transform transition-all shadow-lg active:scale-95"
288
- >
289
- Close
290
- </button>
291
- </div>
292
- </div>
293
- </div>
294
- )}
295
- </div>
296
- );
297
-
298
- return TableContent;
299
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/components/data/FilterPanel.tsx DELETED
@@ -1,310 +0,0 @@
1
- import { useState, useEffect, useRef } from 'react';
2
- import { Filter, X, Plus } from 'lucide-react';
3
- import { api } from '../../api/client';
4
- import type { ColumnStat } from '../../types';
5
-
6
- interface ActiveFilter {
7
- id: number;
8
- column: string;
9
- type: string;
10
- values?: string[];
11
- min_val?: number;
12
- max_val?: number;
13
- pattern?: string;
14
- }
15
-
16
- interface FilterPanelProps {
17
- columns: ColumnStat[];
18
- onApply: (filters: ActiveFilter[]) => void;
19
- }
20
-
21
- type ValueCount = { value: string; count: number };
22
-
23
- let filterId = 0;
24
-
25
- /**
26
- * Searchable value picker for categorical filters.
27
- *
28
- * Shows the column's precomputed top values (from client-side column stats)
29
- * immediately, so values are always visible the moment the field is clicked.
30
- * Typing queries the server for the full set of matching values; if the
31
- * server is unavailable the picker falls back to filtering the local list.
32
- */
33
- function CategoricalValuePicker({
34
- column,
35
- selected,
36
- onChange,
37
- fallbackValues,
38
- }: {
39
- column: string;
40
- selected: string[];
41
- onChange: (values: string[]) => void;
42
- fallbackValues: ValueCount[];
43
- }) {
44
- const [query, setQuery] = useState('');
45
- const [results, setResults] = useState<ValueCount[]>(fallbackValues);
46
- const [totalMatches, setTotalMatches] = useState(fallbackValues.length);
47
- const [loading, setLoading] = useState(false);
48
- const [serverBacked, setServerBacked] = useState(true);
49
- const [open, setOpen] = useState(false);
50
- const ref = useRef<HTMLDivElement>(null);
51
-
52
- const localFilter = (q: string): ValueCount[] => {
53
- const t = q.trim().toLowerCase();
54
- return t ? fallbackValues.filter((v) => v.value.toLowerCase().includes(t)) : fallbackValues;
55
- };
56
-
57
- // Reset to the new column's local values when the column changes.
58
- useEffect(() => {
59
- setQuery('');
60
- setResults(fallbackValues);
61
- setTotalMatches(fallbackValues.length);
62
- setServerBacked(true);
63
- // eslint-disable-next-line react-hooks/exhaustive-deps
64
- }, [column]);
65
-
66
- // Debounced server search; falls back to the local list on failure.
67
- useEffect(() => {
68
- let cancelled = false;
69
- setLoading(true);
70
- const t = setTimeout(() => {
71
- api
72
- .getColumnValues(column, query, 50)
73
- .then((res) => {
74
- if (cancelled) return;
75
- setServerBacked(true);
76
- setResults(res.values);
77
- setTotalMatches(res.total_matches);
78
- })
79
- .catch(() => {
80
- if (cancelled) return;
81
- // Server (or its session data) unavailable — use the local list.
82
- setServerBacked(false);
83
- const local = localFilter(query);
84
- setResults(local);
85
- setTotalMatches(local.length);
86
- })
87
- .finally(() => {
88
- if (!cancelled) setLoading(false);
89
- });
90
- }, 250);
91
- return () => {
92
- cancelled = true;
93
- clearTimeout(t);
94
- };
95
- // eslint-disable-next-line react-hooks/exhaustive-deps
96
- }, [column, query]);
97
-
98
- // Close the dropdown when clicking outside.
99
- useEffect(() => {
100
- if (!open) return;
101
- const handler = (e: MouseEvent) => {
102
- if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
103
- };
104
- document.addEventListener('mousedown', handler);
105
- return () => document.removeEventListener('mousedown', handler);
106
- }, [open]);
107
-
108
- const toggle = (value: string) => {
109
- if (selected.includes(value)) onChange(selected.filter((v) => v !== value));
110
- else onChange([...selected, value]);
111
- };
112
-
113
- return (
114
- <div ref={ref} className="relative min-w-0 flex-1">
115
- {selected.length > 0 && (
116
- <div className="mb-1 flex flex-wrap gap-1">
117
- {selected.map((v) => (
118
- <span
119
- key={v}
120
- className="flex items-center gap-1 rounded bg-accent-dim px-1.5 py-0.5 text-[10px] text-white"
121
- >
122
- <span className="max-w-[140px] truncate" title={v}>
123
- {v}
124
- </span>
125
- <button onClick={() => toggle(v)} className="hover:text-danger">
126
- <X className="h-2.5 w-2.5" />
127
- </button>
128
- </span>
129
- ))}
130
- </div>
131
- )}
132
-
133
- <input
134
- type="text"
135
- placeholder={selected.length ? 'Add another value...' : 'Search values...'}
136
- value={query}
137
- onChange={(e) => setQuery(e.target.value)}
138
- onFocus={() => setOpen(true)}
139
- className="w-full rounded border border-border bg-deep px-2 py-1 text-xs text-text-primary placeholder:text-text-muted"
140
- />
141
-
142
- {open && (
143
- <div className="absolute left-0 right-0 top-full z-20 mt-1 max-h-48 overflow-auto rounded border border-border bg-deep shadow-xl">
144
- {results.length === 0 && loading && (
145
- <div className="px-2 py-1.5 text-[10px] text-text-muted">Searching...</div>
146
- )}
147
- {results.length === 0 && !loading && (
148
- <div className="px-2 py-1.5 text-[10px] text-text-muted">No matching values</div>
149
- )}
150
- {results.map((r) => {
151
- const isSel = selected.includes(r.value);
152
- return (
153
- <button
154
- key={r.value}
155
- onClick={() => toggle(r.value)}
156
- className={`flex w-full items-center justify-between gap-2 px-2 py-1 text-left text-[11px] hover:bg-elevated ${
157
- isSel ? 'text-accent' : 'text-text-secondary'
158
- }`}
159
- >
160
- <span className="truncate" title={r.value}>
161
- {isSel ? '✓ ' : ''}
162
- {r.value}
163
- </span>
164
- <span className="shrink-0 text-text-muted">{r.count}</span>
165
- </button>
166
- );
167
- })}
168
- {totalMatches > results.length && (
169
- <div className="px-2 py-1.5 text-[10px] text-text-muted">
170
- +{totalMatches - results.length} more — refine your search
171
- </div>
172
- )}
173
- {!serverBacked && (
174
- <div className="border-t border-border px-2 py-1.5 text-[10px] text-amber-400/80">
175
- Showing cached top values — reload the dataset for full search.
176
- </div>
177
- )}
178
- </div>
179
- )}
180
- </div>
181
- );
182
- }
183
-
184
- export function FilterPanel({ columns, onApply }: FilterPanelProps) {
185
- const [filters, setFilters] = useState<ActiveFilter[]>([]);
186
- const [open, setOpen] = useState(false);
187
-
188
- const addFilter = () => {
189
- if (columns.length === 0) return;
190
- const col = columns[0];
191
- const type = col.top_values ? 'categorical' : col.min != null ? 'numeric' : 'text';
192
- setFilters([...filters, { id: ++filterId, column: col.name, type }]);
193
- };
194
-
195
- const removeFilter = (id: number) => {
196
- const next = filters.filter((f) => f.id !== id);
197
- setFilters(next);
198
- onApply(next);
199
- };
200
-
201
- const updateFilter = (id: number, patch: Partial<ActiveFilter>) => {
202
- setFilters((prev) => prev.map((f) => (f.id === id ? { ...f, ...patch } : f)));
203
- };
204
-
205
- const handleApply = () => {
206
- onApply(filters);
207
- };
208
-
209
- return (
210
- <div className="rounded-lg border border-border bg-surface">
211
- <button
212
- onClick={() => setOpen(!open)}
213
- className="flex w-full items-center gap-2 px-4 py-2.5 text-sm text-text-secondary hover:text-text-primary"
214
- >
215
- <Filter className="h-4 w-4" />
216
- Filters {filters.length > 0 && `(${filters.length})`}
217
- </button>
218
-
219
- {open && (
220
- <div className="border-t border-border px-4 py-3">
221
- <div className="space-y-2">
222
- {filters.map((f) => {
223
- const colInfo = columns.find((c) => c.name === f.column);
224
- return (
225
- <div key={f.id} className="flex items-start gap-2 rounded bg-raised p-2">
226
- <select
227
- value={f.column}
228
- onChange={(e) => {
229
- const newCol = columns.find((c) => c.name === e.target.value);
230
- const type = newCol?.top_values ? 'categorical' : newCol?.min != null ? 'numeric' : 'text';
231
- updateFilter(f.id, { column: e.target.value, type, values: undefined, pattern: undefined, min_val: undefined, max_val: undefined });
232
- }}
233
- className="mt-0.5 rounded border border-border bg-deep px-2 py-1 text-xs text-text-primary"
234
- >
235
- {columns.map((c) => (
236
- <option key={c.name} value={c.name}>
237
- {c.name}
238
- </option>
239
- ))}
240
- </select>
241
-
242
- {f.type === 'text' && (
243
- <input
244
- type="text"
245
- placeholder="Search pattern..."
246
- value={f.pattern ?? ''}
247
- onChange={(e) => updateFilter(f.id, { pattern: e.target.value })}
248
- className="mt-0.5 flex-1 rounded border border-border bg-deep px-2 py-1 text-xs text-text-primary placeholder:text-text-muted"
249
- />
250
- )}
251
-
252
- {f.type === 'numeric' && (
253
- <div className="mt-0.5 flex items-center gap-1">
254
- <input
255
- type="number"
256
- placeholder="Min"
257
- value={f.min_val ?? ''}
258
- onChange={(e) => updateFilter(f.id, { min_val: e.target.value ? Number(e.target.value) : undefined })}
259
- className="w-20 rounded border border-border bg-deep px-2 py-1 text-xs text-text-primary"
260
- />
261
- <span className="text-text-muted">-</span>
262
- <input
263
- type="number"
264
- placeholder="Max"
265
- value={f.max_val ?? ''}
266
- onChange={(e) => updateFilter(f.id, { max_val: e.target.value ? Number(e.target.value) : undefined })}
267
- className="w-20 rounded border border-border bg-deep px-2 py-1 text-xs text-text-primary"
268
- />
269
- </div>
270
- )}
271
-
272
- {f.type === 'categorical' && (
273
- <CategoricalValuePicker
274
- column={f.column}
275
- selected={f.values ?? []}
276
- onChange={(values) => updateFilter(f.id, { values })}
277
- fallbackValues={colInfo?.top_values ?? []}
278
- />
279
- )}
280
-
281
- <button
282
- onClick={() => removeFilter(f.id)}
283
- className="mt-1.5 text-text-muted hover:text-danger"
284
- >
285
- <X className="h-3.5 w-3.5" />
286
- </button>
287
- </div>
288
- );
289
- })}
290
- </div>
291
-
292
- <div className="mt-3 flex gap-2">
293
- <button
294
- onClick={addFilter}
295
- className="flex items-center gap-1.5 rounded border border-border px-3 py-1.5 text-xs text-text-secondary hover:bg-elevated"
296
- >
297
- <Plus className="h-3 w-3" /> Add Filter
298
- </button>
299
- <button
300
- onClick={handleApply}
301
- className="rounded bg-accent-dim px-3 py-1.5 text-xs font-medium text-white hover:bg-accent"
302
- >
303
- Apply Filters
304
- </button>
305
- </div>
306
- </div>
307
- )}
308
- </div>
309
- );
310
- }