Spaces:
Sleeping
Sleeping
UAP Improvements
#3
by Ashoka74 - opened
This view is limited to 50 files because it contains too many changes. See the raw diff here.
- .gitignore +1 -191
- .streamlit/config.toml +40 -0
- .streamlit/credentials.toml +2 -0
- .streamlit/secrets.toml +14 -0
- CLAUDE.md +0 -115
- ENHANCED_PIPELINE_README.md +0 -287
- PLAN.md +0 -313
- README.md +1 -48
- STREAMLIT_HANDOFF.md +0 -545
- analyzing.py +194 -1003
- api/main.py +0 -1155
- api/services/__init__.py +0 -1
- api/services/analysis_service.py +0 -287
- api/services/map_service.py +0 -198
- api/services/parsing_service.py +0 -312
- api/services/rag_service.py +0 -51
- api/services/scu_service.py +0 -83
- api/utils/data_utils.py +0 -186
- app.py +115 -18
- app2.py +16 -11
- claude_app_analysis.md +0 -178
- codex_app_analysis.md +0 -148
- config.py +0 -0
- embed_csv.py +0 -91
- embeddings.py +0 -259
- final_ufoseti_dataset.h5 +3 -0
- frontend/.gitignore +0 -24
- frontend/DEPLOY_VERCEL.md +0 -74
- frontend/README.md +0 -73
- frontend/eslint.config.js +0 -23
- frontend/index.html +0 -16
- frontend/package-lock.json +0 -0
- frontend/package.json +0 -39
- frontend/src/App.tsx +0 -33
- frontend/src/api/client.ts +0 -264
- frontend/src/components/analysis/AnalysisPage.tsx +0 -322
- frontend/src/components/analysis/ClusterView.tsx +0 -21
- frontend/src/components/analysis/ClusterVisualization.tsx +0 -64
- frontend/src/components/analysis/CorrelationHeatmap.tsx +0 -70
- frontend/src/components/analysis/CramersVExplorer.tsx +0 -487
- frontend/src/components/analysis/DistributionChart.tsx +0 -43
- frontend/src/components/analysis/XGBoostResults.tsx +0 -79
- frontend/src/components/common/LoadingSpinner.tsx +0 -11
- frontend/src/components/common/Panel.tsx +0 -27
- frontend/src/components/common/StatusBadge.tsx +0 -22
- frontend/src/components/dashboard/Dashboard.tsx +0 -194
- frontend/src/components/dashboard/StatCard.tsx +0 -24
- frontend/src/components/data/DataExplorer.tsx +0 -152
- frontend/src/components/data/DataTable.tsx +0 -299
- frontend/src/components/data/FilterPanel.tsx +0 -310
.gitignore
CHANGED
|
@@ -1,191 +1 @@
|
|
| 1 |
-
|
| 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
|
| 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
|
| 15 |
-
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 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(
|
| 390 |
except Exception:
|
| 391 |
pass
|
| 392 |
|
| 393 |
if is_datetime64_any_dtype(df_[column]):
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 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 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1243 |
-
st.session_state['parsed_responses'] =
|
| 1244 |
-
st.dataframe(
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1347 |
-
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1424 |
|
| 1425 |
-
# Enhanced visualization section
|
| 1426 |
if 'analysis_complete' in st.session_state and st.session_state['analysis_complete']:
|
| 1427 |
-
st.
|
| 1428 |
-
|
| 1429 |
-
|
| 1430 |
-
|
| 1431 |
-
|
| 1432 |
-
|
| 1433 |
-
|
| 1434 |
-
|
| 1435 |
-
|
| 1436 |
-
|
| 1437 |
-
|
| 1438 |
-
|
| 1439 |
-
|
| 1440 |
-
|
| 1441 |
-
|
| 1442 |
-
|
| 1443 |
-
|
| 1444 |
-
|
| 1445 |
-
|
| 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 |
-
|
| 32 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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 =
|
|
|
|
| 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
|
| 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 =
|
| 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 =
|
| 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 =
|
| 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
|
| 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
|
|
|
|
| 1020 |
import streamlit.components.v1 as components
|
| 1021 |
from dateutil import parser
|
| 1022 |
from sentence_transformers import SentenceTransformer
|
| 1023 |
import torch
|
| 1024 |
-
|
| 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-
|
| 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'>⬢</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 "Load Parsed Dataset" 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 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|