dineshb commited on
Commit
9c1c0ef
·
verified ·
1 Parent(s): 43f63b8

Deploy DataPilot AI production Docker Space

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.dockerignore ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .github
3
+ .venv
4
+ venv
5
+ __pycache__
6
+ *.py[cod]
7
+ .pytest_cache
8
+ .ruff_cache
9
+ .coverage
10
+ htmlcov
11
+ artifacts
12
+ tests
13
+ docs
14
+ scripts
15
+ worker
16
+ api
17
+ *.db
18
+ *.sqlite
19
+ *.sqlite3
20
+ .env
21
+ .streamlit/secrets.toml
22
+ Dockerfile.api
23
+ Dockerfile.worker
24
+ docker-compose.yml
25
+ render.yaml
.env.example ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ENVIRONMENT=development
2
+ ARTIFACT_ROOT=artifacts
3
+ DATABASE_URL=sqlite:///artifacts/datapilot.db
4
+ MAX_UPLOAD_MB=25
5
+ MAX_ROWS=100000
6
+ MAX_COLUMNS=250
7
+ MAX_CATEGORIES_PER_FEATURE=100
8
+ MAX_ENCODED_FEATURES=5000
9
+ API_KEY=
10
+ REQUESTS_PER_MINUTE=30
11
+ MAX_CRITIC_RETRIES=1
12
+ ENABLE_MLFLOW=false
13
+ MLFLOW_TRACKING_URI=file:./artifacts/mlruns
14
+ # Optional: the deterministic application works without an LLM key.
15
+ GEMINI_API_KEY=
16
+ GEMINI_MODEL=gemini-2.5-flash
17
+ CORS_ORIGINS=http://localhost:8501
18
+
.github/CODEOWNERS ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ * @dineshbarri
2
+ /.github/ @dineshbarri
3
+ /datapilot/ @dineshbarri
4
+ /api/ @dineshbarri
5
+ /docs/ @dineshbarri
.github/ISSUE_TEMPLATE/bug_report.yml ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Bug report
2
+ description: Report a reproducible DataPilot defect
3
+ title: "[Bug]: "
4
+ labels: [bug]
5
+ body:
6
+ - type: textarea
7
+ id: description
8
+ attributes: {label: Description, description: What happened?}
9
+ validations: {required: true}
10
+ - type: textarea
11
+ id: reproduce
12
+ attributes: {label: Reproduction, description: Provide minimal safe reproduction steps.}
13
+ validations: {required: true}
14
+ - type: input
15
+ id: version
16
+ attributes: {label: Version or commit}
17
+ validations: {required: true}
18
+ - type: textarea
19
+ id: logs
20
+ attributes: {label: Sanitized logs, description: Remove API keys and private data.}
.github/ISSUE_TEMPLATE/feature_request.yml ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Feature request
2
+ description: Propose a product, ML, platform, or evaluation improvement
3
+ title: "[Feature]: "
4
+ labels: [enhancement]
5
+ body:
6
+ - type: textarea
7
+ id: problem
8
+ attributes: {label: Problem, description: Which user or engineering problem should this solve?}
9
+ validations: {required: true}
10
+ - type: textarea
11
+ id: proposal
12
+ attributes: {label: Proposed approach}
13
+ validations: {required: true}
14
+ - type: textarea
15
+ id: risks
16
+ attributes: {label: Privacy, security, and ML risks}
.github/dependabot.yml ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: pip
4
+ directory: /
5
+ schedule: {interval: weekly}
6
+ groups:
7
+ python-dependencies: {patterns: ["*"]}
8
+ - package-ecosystem: docker
9
+ directory: /
10
+ schedule: {interval: weekly}
11
+ - package-ecosystem: github-actions
12
+ directory: /
13
+ schedule: {interval: weekly}
.github/pull_request_template.md ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ## Summary
2
+
3
+ ## Why this change
4
+
5
+ ## Validation
6
+
7
+ - [ ] Tests added or updated
8
+ - [ ] `ruff check .` passes
9
+ - [ ] `pytest --cov` passes
10
+ - [ ] Security/privacy impact reviewed
11
+ - [ ] Documentation updated
12
+
13
+ ## ML/AI impact
14
+
15
+ Describe changes to data handling, evaluation methodology, prompts, models, or metrics.
.github/workflows/ci.yml ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_dispatch:
8
+
9
+ permissions:
10
+ contents: read
11
+ security-events: write
12
+
13
+ concurrency:
14
+ group: ci-${{ github.ref }}
15
+ cancel-in-progress: true
16
+
17
+ jobs:
18
+ test:
19
+ name: Python ${{ matrix.python-version }}
20
+ runs-on: ubuntu-latest
21
+ timeout-minutes: 25
22
+ strategy:
23
+ fail-fast: false
24
+ matrix:
25
+ python-version: ["3.11", "3.12", "3.13"]
26
+ steps:
27
+ - uses: actions/checkout@v4
28
+ - uses: actions/setup-python@v5
29
+ with:
30
+ python-version: ${{ matrix.python-version }}
31
+ cache: pip
32
+ - run: python -m pip install --upgrade pip && pip install -e ".[dev]"
33
+ - run: ruff check .
34
+ - run: ruff format --check .
35
+ - run: pytest --cov=datapilot --cov=api --cov-report=term-missing --cov-report=xml --cov-fail-under=75
36
+ - run: python -m compileall -q datapilot api worker app.py streamlit_app.py
37
+ - name: Generate example model card
38
+ if: matrix.python-version == '3.12'
39
+ run: python scripts/generate_example_model_card.py
40
+ - uses: actions/upload-artifact@v4
41
+ if: matrix.python-version == '3.12'
42
+ with:
43
+ name: evaluation-evidence
44
+ path: |
45
+ coverage.xml
46
+ build/example-model-card.md
47
+
48
+ package:
49
+ runs-on: ubuntu-latest
50
+ steps:
51
+ - uses: actions/checkout@v4
52
+ - uses: actions/setup-python@v5
53
+ with:
54
+ python-version: "3.12"
55
+ - run: pip install build twine
56
+ - run: python -m build
57
+ - run: twine check dist/*
58
+ - uses: actions/upload-artifact@v4
59
+ with:
60
+ name: python-package
61
+ path: dist/
62
+
63
+ dependency-audit:
64
+ runs-on: ubuntu-latest
65
+ steps:
66
+ - uses: actions/checkout@v4
67
+ - uses: actions/setup-python@v5
68
+ with:
69
+ python-version: "3.12"
70
+ cache: pip
71
+ - run: python -m pip install --upgrade pip pip-audit
72
+ - run: pip-audit --requirement requirements.txt
73
+
74
+ containers:
75
+ runs-on: ubuntu-latest
76
+ strategy:
77
+ fail-fast: false
78
+ matrix:
79
+ include:
80
+ - file: Dockerfile
81
+ image: datapilot-ui
82
+ - file: Dockerfile.api
83
+ image: datapilot-api
84
+ - file: Dockerfile.worker
85
+ image: datapilot-worker
86
+ steps:
87
+ - uses: actions/checkout@v4
88
+ - uses: docker/setup-buildx-action@v3
89
+ - uses: docker/build-push-action@v6
90
+ with:
91
+ context: .
92
+ file: ${{ matrix.file }}
93
+ push: false
94
+ load: true
95
+ tags: ${{ matrix.image }}:ci
96
+ - uses: aquasecurity/trivy-action@v0.36.0
97
+ with:
98
+ image-ref: ${{ matrix.image }}:ci
99
+ format: sarif
100
+ output: "trivy-${{ matrix.image }}.sarif"
101
+ severity: HIGH,CRITICAL
102
+ ignore-unfixed: true
103
+ - uses: github/codeql-action/upload-sarif@v4
104
+ if: github.actor != 'dependabot[bot]'
105
+ with:
106
+ sarif_file: "trivy-${{ matrix.image }}.sarif"
107
+ - uses: actions/upload-artifact@v4
108
+ if: always()
109
+ with:
110
+ name: "trivy-${{ matrix.image }}"
111
+ path: "trivy-${{ matrix.image }}.sarif"
112
+
113
+ secrets:
114
+ runs-on: ubuntu-latest
115
+ steps:
116
+ - uses: actions/checkout@v4
117
+ with:
118
+ fetch-depth: 0
119
+ - uses: gitleaks/gitleaks-action@v2
120
+ env:
121
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
.github/workflows/release.yml ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ permissions:
8
+ contents: write
9
+
10
+ jobs:
11
+ release:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: actions/setup-python@v5
16
+ with: {python-version: "3.12"}
17
+ - run: pip install build
18
+ - run: python -m build
19
+ - run: git archive --format=zip --output=dist/DataPilot-AI-${GITHUB_REF_NAME}.zip HEAD
20
+ - uses: softprops/action-gh-release@v2
21
+ with:
22
+ generate_release_notes: true
23
+ files: dist/*
.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ .coverage
6
+ htmlcov/
7
+ build/
8
+ dist/
9
+ .venv/
10
+ venv/
11
+ .env
12
+ artifacts/
13
+ mlruns/
14
+ *.joblib
15
+ *.parquet
16
+ .DS_Store
17
+ .idea/
18
+ .vscode/
19
+
20
+ .streamlit/secrets.toml
21
+ *.db
22
+ *.sqlite
23
+ *.sqlite3
24
+
.pre-commit-config.yaml ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ repos:
2
+ - repo: https://github.com/astral-sh/ruff-pre-commit
3
+ rev: v0.12.8
4
+ hooks:
5
+ - id: ruff-check
6
+ args: [--fix]
7
+ - id: ruff-format
8
+ - repo: https://github.com/pre-commit/pre-commit-hooks
9
+ rev: v5.0.0
10
+ hooks:
11
+ - id: trailing-whitespace
12
+ - id: end-of-file-fixer
13
+ - id: check-yaml
14
+ - id: check-toml
15
+ - id: check-json
16
+ - id: check-added-large-files
17
+ args: [--maxkb=5000]
18
+ - id: detect-private-key
19
+ - repo: https://github.com/gitleaks/gitleaks
20
+ rev: v8.28.0
21
+ hooks:
22
+ - id: gitleaks
.streamlit/config.toml ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [theme]
2
+ primaryColor = "#6045D8"
3
+ backgroundColor = "#FBFAFF"
4
+ secondaryBackgroundColor = "#F4F1FF"
5
+ textColor = "#172033"
6
+ font = "sans serif"
7
+
8
+ [server]
9
+ headless = true
10
+ maxUploadSize = 25
11
+
CHANGELOG.md ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Changelog
2
+
3
+ All notable changes follow [Keep a Changelog](https://keepachangelog.com/) and semantic versioning.
4
+
5
+ ## [2.0.0] - 2026-08-10
6
+
7
+ ### Added
8
+
9
+ - Training-only cross-validation selection and one-time untouched test evaluation.
10
+ - Sparse, cardinality-bounded categorical encoding and encoded-width guardrails.
11
+ - Structured candidate failure records.
12
+ - Asynchronous API job contract, cancellation, authentication, rate limiting, and correlation IDs.
13
+ - CI, Dependabot, pre-commit, Docker context controls, templates, threat model, privacy, and evaluation docs.
14
+
15
+ ### Changed
16
+
17
+ - Consolidated Streamlit deployment onto the `app.py` implementation.
18
+ - Sanitized API and job errors returned to clients.
CONTRIBUTING.md ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Contributing
2
+
3
+ 1. Create a feature branch.
4
+ 2. Install `pip install -e ".[dev]"`.
5
+ 3. Run `ruff check .`.
6
+ 4. Run `pytest --cov=datapilot --cov=api`.
7
+ 5. Keep computed metrics deterministic and source-backed.
8
+ 6. Add a test for every new quality rule, workflow route or API behavior.
9
+ 7. Never commit datasets containing personal data, credentials or generated artifacts.
10
+
11
+ Pull requests should explain the user problem, architecture impact, validation performed and
12
+ any new security or model-risk considerations.
13
+
Dockerfile ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim AS runtime
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1 \
5
+ PIP_NO_CACHE_DIR=1 \
6
+ STREAMLIT_SERVER_HEADLESS=true \
7
+ STREAMLIT_BROWSER_GATHER_USAGE_STATS=false
8
+
9
+ WORKDIR /app
10
+
11
+ RUN addgroup --system datapilot \
12
+ && adduser --system --ingroup datapilot --home /home/datapilot datapilot
13
+
14
+ COPY requirements.txt ./requirements.txt
15
+ RUN pip install --no-cache-dir --upgrade pip \
16
+ && pip install --no-cache-dir -r requirements.txt
17
+
18
+ COPY app.py streamlit_app.py ./
19
+ COPY datapilot ./datapilot
20
+ COPY .streamlit ./.streamlit
21
+
22
+ RUN mkdir -p /app/artifacts /home/datapilot/.streamlit \
23
+ && chown -R datapilot:datapilot /app /home/datapilot
24
+
25
+ USER datapilot
26
+
27
+ EXPOSE 7860
28
+
29
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
30
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:7860/_stcore/health', timeout=3)"
31
+
32
+ CMD ["streamlit", "run", "streamlit_app.py", "--server.address=0.0.0.0", "--server.port=7860", "--server.headless=true", "--browser.gatherUsageStats=false"]
Dockerfile.api ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
4
+ WORKDIR /app
5
+ RUN addgroup --system datapilot && adduser --system --ingroup datapilot datapilot
6
+ COPY requirements.txt .
7
+ RUN pip install --no-cache-dir -r requirements.txt
8
+ COPY datapilot ./datapilot
9
+ COPY api ./api
10
+ RUN mkdir -p /app/artifacts && chown -R datapilot:datapilot /app
11
+ USER datapilot
12
+ EXPOSE 8000
13
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
14
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
15
+ CMD ["uvicorn", "api.main:app", "--host=0.0.0.0", "--port=8000"]
16
+
Dockerfile.worker ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1
4
+ WORKDIR /app
5
+ RUN addgroup --system datapilot && adduser --system --ingroup datapilot datapilot
6
+ RUN pip install --no-cache-dir fastapi uvicorn pydantic
7
+ COPY datapilot/safety.py ./datapilot/safety.py
8
+ COPY datapilot/__init__.py ./datapilot/__init__.py
9
+ COPY worker ./worker
10
+ USER datapilot
11
+ EXPOSE 8010
12
+ CMD ["uvicorn", "worker.main:app", "--host=0.0.0.0", "--port=8010"]
13
+
LICENSE ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dinesh Barri
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
Makefile ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .PHONY: install test lint format coverage build run api security
2
+
3
+ install:
4
+ python -m pip install -e ".[dev]"
5
+ test:
6
+ python -m pytest
7
+ lint:
8
+ python -m ruff check .
9
+ format:
10
+ python -m ruff format .
11
+ coverage:
12
+ python -m pytest --cov=datapilot --cov=api --cov-fail-under=75
13
+ build:
14
+ python -m build
15
+ run:
16
+ python -m streamlit run app.py
17
+ api:
18
+ python -m uvicorn api.main:app --reload
19
+ security:
20
+ python -m pip_audit
README.md CHANGED
@@ -1,11 +1,361 @@
1
  ---
2
  title: DataPilot AI Agent
3
- emoji: 📊
4
- colorFrom: red
5
  colorTo: green
6
  sdk: docker
7
- pinned: false
 
8
  license: mit
 
9
  ---
10
 
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: DataPilot AI Agent
3
+ emoji: "📊"
4
+ colorFrom: indigo
5
  colorTo: green
6
  sdk: docker
7
+ app_port: 7860
8
+ pinned: true
9
  license: mit
10
+ short_description: Evidence-grounded autonomous data science and ML copilot
11
  ---
12
 
13
+ <div align="center">
14
+
15
+ <img src="https://capsule-render.vercel.app/api?type=rect&color=0:21154F,48:6045D8,100:19A98E&height=190&section=header&text=DataPilot%20AI&fontSize=48&fontColor=ffffff&animation=fadeIn&desc=Autonomous%20Data%20Science%20%C2%B7%20Evidence-Grounded%20ML%20%C2%B7%20Explainable%20Decisions&descSize=17&descAlignY=72" width="100%" alt="DataPilot AI banner">
16
+
17
+ <p>
18
+ <img src="https://readme-typing-svg.herokuapp.com/?font=Fira+Code&size=16&duration=2600&pause=850&color=6D4AFF&center=true&vCenter=true&width=780&lines=CSV+or+Parquet+%E2%86%92+Quality+Audit+%E2%86%92+Validated+Model;8+Specialized+Agents+%7C+Stateful+LangGraph+Orchestration;Leakage-Safe+Pipelines+%7C+Critic+Retry+Loop+%7C+SHAP-Ready;Streamlit+%7C+FastAPI+%7C+DuckDB+%7C+scikit-learn" alt="DataPilot AI capabilities">
19
+ </p>
20
+
21
+ [![Python](https://img.shields.io/badge/Python-3.11--3.13-3776AB?style=for-the-badge&logo=python&logoColor=white)](https://www.python.org/)
22
+ [![LangGraph](https://img.shields.io/badge/LangGraph-Stateful_Agents-1C1C1C?style=for-the-badge)](https://langchain-ai.github.io/langgraph/)
23
+ [![Streamlit](https://img.shields.io/badge/Streamlit-Recruiter_Demo-FF4B4B?style=for-the-badge&logo=streamlit&logoColor=white)](https://datapilot-ai-agent.streamlit.app/)
24
+ [![FastAPI](https://img.shields.io/badge/FastAPI-Production_API-009688?style=for-the-badge&logo=fastapi&logoColor=white)](https://fastapi.tiangolo.com/)
25
+ [![CI](https://github.com/dineshbarri/DataPilot-AI/actions/workflows/ci.yml/badge.svg)](https://github.com/dineshbarri/DataPilot-AI/actions/workflows/ci.yml)
26
+ [![License](https://img.shields.io/badge/License-MIT-F5C518?style=for-the-badge)](LICENSE)
27
+
28
+ **[API Docs](#fastapi)** ·
29
+ **[Architecture](docs/ARCHITECTURE.md)** ·
30
+ **[Security](docs/SECURITY.md)** ·
31
+ **[Model Governance](docs/MODEL_GOVERNANCE.md)** ·
32
+ **[Benchmarks](docs/BENCHMARKS.md)** ·
33
+ **[API Examples](docs/API_EXAMPLES.md)**
34
+
35
+ </div>
36
+
37
+ ---
38
+
39
+ ## Why DataPilot AI?
40
+
41
+ Most “AI data scientist” demos upload a CSV, run preprocessing on the entire dataset, compare
42
+ a few models, and ask an LLM to write an impressive-sounding summary. That is fast—but it can
43
+ leak test information, exaggerate confidence, and produce insights with no numerical provenance.
44
+
45
+ **DataPilot AI treats trust as a feature.** It coordinates a stateful agent team that audits the
46
+ data, plans the experiment, builds leakage-safe pipelines, compares models, challenges the
47
+ winner, explains predictive signals, and exports reproducible artifacts. Every displayed metric
48
+ comes from deterministic computation. The optional LLM may improve wording; it cannot create
49
+ new numbers or execute code.
50
+
51
+ ### Recruiter five-minute test
52
+
53
+ 1. Open the app.
54
+ 2. Keep **Iris classification** selected.
55
+ 3. Click **Run autonomous analysis**.
56
+ 4. Inspect the model comparison, critic decision, feature importance and full agent trace.
57
+ 5. Download the fitted pipeline, model card and standalone report.
58
+
59
+ No account, upload or API key is required.
60
+
61
+ ---
62
+
63
+ ## Product capabilities
64
+
65
+ | Stage | What DataPilot does | Evidence produced |
66
+ |---|---|---|
67
+ | Data intake | Accepts bounded CSV, TSV, Excel, JSON, Parquet or packaged demos | row/column limits and validated schema |
68
+ | Data quality | Detects missingness, duplicates, target gaps, imbalance, outliers and leakage-like fields | evidence registry with IDs, source and method |
69
+ | EDA | Uses DuckDB and pandas for compact profiles, cardinality and correlations | dataset profile and statistical summary |
70
+ | Planning | Infers classification/regression, primary metric, validation strategy and risk controls | typed Pydantic analysis plan |
71
+ | Feature engineering | Builds numeric and categorical transformers inside the model pipeline | transformation plan and fitted pipeline |
72
+ | Modeling | Selects linear, forest, extra-trees and optional XGBoost models by training-only CV | CV mean/stability and one-time untouched test results |
73
+ | Evaluation | Applies thresholds and validation-consistency checks | critic approval, rejection reasons and retry count |
74
+ | Explainability | Uses SHAP only when the optional dependency and fitted estimator are compatible; otherwise permutation importance | ranked predictive signals and caveats |
75
+ | Reporting | Produces a dashboard, evidence-backed narrative and portable artifacts | HTML report, model card, JSON, joblib pipeline |
76
+ | Follow-up | Answers questions from persisted run evidence | bounded, non-hallucinatory responses |
77
+
78
+ ---
79
+
80
+ ## Real agentic orchestration
81
+
82
+ The “agents” are typed LangGraph nodes combining deterministic Python computation with optional,
83
+ evidence-bounded LLM narration. The critic controls a conditional edge:
84
+ weak or unstable analysis returns to the modeling node before explanation is allowed.
85
+
86
+ ```mermaid
87
+ flowchart LR
88
+ A["CSV / Parquet / Demo"] --> B["Data Quality Agent"]
89
+ B --> C["EDA Agent"]
90
+ C --> D["Statistical Agent"]
91
+ D --> E["Planning Agent"]
92
+ E --> F["Feature Engineering Agent"]
93
+ F --> G["Modeling Agent"]
94
+ G --> H{"Evaluation / Critic"}
95
+ H -->|"Reject + retry"| G
96
+ H -->|"Approve"| I["Explainability Agent"]
97
+ I --> J["Executive Insights Agent"]
98
+ J --> K["Report · Model Card · Pipeline · Evidence"]
99
+ ```
100
+
101
+ The Streamlit **Agent trace** tab shows every completed node, its duration and decision.
102
+
103
+ ---
104
+
105
+ ## Leakage-safe ML design
106
+
107
+ ```python
108
+ pipeline = Pipeline(
109
+ [
110
+ ("preprocessor", ColumnTransformer(...)),
111
+ ("model", candidate_model),
112
+ ]
113
+ )
114
+
115
+ # Imputers, encoders and scalers learn only from training folds.
116
+ pipeline.fit(x_train, y_train)
117
+ ```
118
+
119
+ - Split occurs before learned preprocessing.
120
+ - Cross-validation refits the complete pipeline in every fold.
121
+ - Classification defaults to balanced accuracy and stratification where possible.
122
+ - Candidates are selected only by training-partition CV; the selected model touches the test set once.
123
+ - Target-like names and identifier cardinality are flagged for human review.
124
+ - Predictive importance is never described as causality.
125
+
126
+ ---
127
+
128
+ ## Dashboard experience
129
+
130
+ The Streamlit application is summary-first and useful before any upload:
131
+
132
+ - **Built-in demos:** Iris, Breast Cancer and Diabetes Progression.
133
+ - **Executive overview:** findings, recommendations and quality risks.
134
+ - **Model laboratory:** candidate comparison, CV stability and critic gate.
135
+ - **Explainability:** interactive Plotly feature-importance view.
136
+ - **Agent trace:** visible orchestration and retry behavior.
137
+ - **Artifacts and Q&A:** downloads, evidence registry and run-specific questions.
138
+
139
+ ![DataPilot production architecture](docs/architecture.svg)
140
+
141
+ The public demo URL is intentionally not claimed until a monitored deployment exists. Add a
142
+ real browser capture under `assets/` together with the deployment URL after release validation.
143
+
144
+ ---
145
+
146
+ ## Technology stack
147
+
148
+ | Layer | Technology |
149
+ |---|---|
150
+ | Agent orchestration | LangGraph typed state and conditional routing |
151
+ | User interface | Streamlit and Plotly |
152
+ | API | FastAPI and Pydantic |
153
+ | Analytical engine | DuckDB, pandas and NumPy |
154
+ | ML | scikit-learn; optional XGBoost and Optuna |
155
+ | Explainability | optional SHAP with permutation fallback |
156
+ | Persistence | SQLAlchemy; SQLite locally, PostgreSQL in production |
157
+ | Experiment tracking | structured agent trace; optional MLflow/OpenTelemetry |
158
+ | Artifacts | local storage interface, ready for S3/MinIO replacement |
159
+ | Delivery | Docker Compose, Render Blueprint and Streamlit Cloud |
160
+ | Quality | Pytest, Ruff, coverage, compile checks and Docker builds in GitHub Actions |
161
+
162
+ ---
163
+
164
+ ## Repository structure
165
+
166
+ ```text
167
+ DataPilot-AI/
168
+ ├── datapilot/
169
+ │ ├── config.py # typed environment configuration and limits
170
+ │ ├── data.py # safe readers, DuckDB overview and sample datasets
171
+ │ ├── quality.py # quality, leakage, imbalance, outlier and drift checks
172
+ │ ├── modeling.py # leakage-safe pipelines and model comparison
173
+ │ ├── workflow.py # LangGraph agent graph and critic loop
174
+ │ ├── insights.py # deterministic + optional evidence-bounded narrative
175
+ │ ├── reports.py # HTML report, model card, pipeline and JSON export
176
+ │ ├── persistence.py # SQL run store and artifact interface
177
+ │ ├── observability.py # optional MLflow integration
178
+ │ └── safety.py # expression-only AST security policy
179
+ ├── api/main.py # versioned FastAPI application
180
+ ├── worker/main.py # optional isolated calculation worker
181
+ ├── tests/ # quality, safety, API and end-to-end workflow tests
182
+ ├── docs/ # architecture, deployment, security and governance
183
+ ├── app.py # canonical premium Streamlit implementation
184
+ ├── streamlit_app.py # Streamlit Cloud compatibility shim
185
+ ├── Dockerfile* # non-root UI, API and worker images
186
+ ├── docker-compose.yml # constrained local multi-service stack
187
+ ├── render.yaml # Render API deployment blueprint
188
+ └── .github/workflows/ci.yml # lint, tests, coverage, compile and image builds
189
+ ```
190
+
191
+ ---
192
+
193
+ ## Quick start
194
+
195
+ ### Local Streamlit demo
196
+
197
+ ```bash
198
+ git clone https://github.com/dineshbarri/DataPilot-AI.git
199
+ cd DataPilot-AI
200
+
201
+ python -m venv .venv
202
+ # Windows
203
+ .venv\Scripts\activate
204
+ # macOS/Linux
205
+ source .venv/bin/activate
206
+
207
+ pip install -e ".[dev]"
208
+ streamlit run app.py
209
+ ```
210
+
211
+ For the validated Python 3.12 reference environment, use `pip install -r requirements.lock`.
212
+ The lock snapshot is refreshed after dependency updates and tested across supported interpreters.
213
+
214
+ Open <http://localhost:8501>.
215
+
216
+ ### Full AI/AutoML/observability extras
217
+
218
+ ```bash
219
+ pip install -e ".[all,dev]"
220
+ ```
221
+
222
+ The default installation intentionally stays deployable on modest public-demo infrastructure.
223
+
224
+ ### FastAPI
225
+
226
+ ```bash
227
+ uvicorn api.main:app --reload --port 8000
228
+ ```
229
+
230
+ Open <http://localhost:8000/docs>.
231
+
232
+ Example:
233
+
234
+ ```bash
235
+ curl -X POST http://localhost:8000/v1/analyze/sample \
236
+ -H "Content-Type: application/json" \
237
+ -d "{\"sample\":\"iris\"}"
238
+ ```
239
+
240
+ The API returns `202 Accepted` with a job ID. Poll `GET /v1/jobs/{job_id}` and cancel queued
241
+ work with `DELETE /v1/jobs/{job_id}`. Set `API_KEY` in deployed environments and send it as
242
+ `X-API-Key`. The bundled in-process queue is for development; use the production topology in
243
+ `docs/ARCHITECTURE.md` for durable execution.
244
+
245
+ ### Docker
246
+
247
+ ```bash
248
+ docker compose up --build
249
+ ```
250
+
251
+ The optional worker runs with no network, a read-only filesystem, dropped capabilities,
252
+ memory/CPU/PID limits and expression-only AST validation.
253
+
254
+ ---
255
+
256
+ ## Configuration
257
+
258
+ Copy `.env.example` to `.env`.
259
+
260
+ | Variable | Default | Purpose |
261
+ |---|---|---|
262
+ | `DATABASE_URL` | SQLite | Set a PostgreSQL URL for persistent production runs |
263
+ | `ARTIFACT_ROOT` | `artifacts` | Root for reports, pipelines and model cards |
264
+ | `MAX_UPLOAD_MB` | `25` | Public upload protection |
265
+ | `MAX_ROWS` | `100000` | Maximum rows per analysis |
266
+ | `MAX_COLUMNS` | `250` | Maximum feature width |
267
+ | `MAX_CATEGORIES_PER_FEATURE` | `100` | Bound categorical expansion |
268
+ | `MAX_ENCODED_FEATURES` | `5000` | Refuse unsafe estimated encoded width |
269
+ | `API_KEY` | empty | Optional API authentication; required for public deployment |
270
+ | `REQUESTS_PER_MINUTE` | `30` | Per-client API rate limit |
271
+ | `MAX_CRITIC_RETRIES` | `1` | Conditional modeling retry budget |
272
+ | `ENABLE_MLFLOW` | `false` | Enable optional experiment tracking |
273
+ | `GEMINI_API_KEY` | empty | Optional narrative refinement only |
274
+
275
+ No credential is embedded in the repository.
276
+
277
+ ---
278
+
279
+ ## Testing and engineering quality
280
+
281
+ ```bash
282
+ pip install -e ".[dev]"
283
+ ruff check .
284
+ pytest --cov=datapilot --cov=api --cov-fail-under=75
285
+ python -m compileall datapilot api worker app.py streamlit_app.py
286
+ ```
287
+
288
+ CI runs Python 3.11, 3.12 and 3.13, enforces coverage, builds the package and all three images,
289
+ audits dependencies, scans containers with Trivy, and performs secret detection.
290
+
291
+ ---
292
+
293
+ ## Deployment
294
+
295
+ ### Streamlit Community Cloud
296
+
297
+ - Entrypoint: `streamlit_app.py`
298
+ - Python: 3.12
299
+ - Secrets: none required; `GEMINI_API_KEY` is optional
300
+ - Default recruiter path: bundled demo dataset
301
+
302
+ ### Render / Railway / Fly.io
303
+
304
+ Deploy `Dockerfile.api`, attach PostgreSQL, and configure durable object storage if artifacts
305
+ must survive container replacement. See [the deployment guide](docs/DEPLOYMENT.md).
306
+
307
+ ### Production recommendation
308
+
309
+ The included in-process job manager makes local requests non-blocking. For durable production
310
+ jobs, replace it with a queue and separate workers, PostgreSQL state, S3-compatible artifacts,
311
+ and short-lived sandboxed workers for any future code-execution capability.
312
+
313
+ ---
314
+
315
+ ## Responsible-use boundaries
316
+
317
+ DataPilot is an exploratory decision-support system, not an automatic production approval
318
+ authority. Before consequential use, complete:
319
+
320
+ - target and leakage review
321
+ - out-of-time and segment evaluation
322
+ - privacy and retention assessment
323
+ - fairness and disparate-impact evaluation
324
+ - domain and legal approval
325
+ - monitoring, rollback and retraining ownership
326
+
327
+ See [Model Governance](docs/MODEL_GOVERNANCE.md) and [Security](docs/SECURITY.md).
328
+
329
+ ---
330
+
331
+ ## Roadmap
332
+
333
+ - [ ] Background job queue and live progress streaming
334
+ - [ ] S3/MinIO artifact adapter with signed downloads
335
+ - [ ] Native PostgreSQL checkpoints for resumable LangGraph runs
336
+ - [ ] Optuna study dashboard and experiment comparison
337
+ - [ ] Time-series and clustering task families
338
+ - [ ] Fairness and segment-performance report
339
+ - [ ] Data-contract and schema-drift registry
340
+ - [ ] Authenticated multi-tenant workspace
341
+
342
+ ---
343
+
344
+ ## Creator
345
+
346
+ ### Dinesh Barri
347
+
348
+ AI Engineer building agentic systems, data products, RAG applications and production-oriented
349
+ machine-learning workflows.
350
+
351
+ [![GitHub](https://img.shields.io/badge/GitHub-dineshbarri-181717?style=for-the-badge&logo=github)](https://github.com/dineshbarri)
352
+ [![LinkedIn](https://img.shields.io/badge/LinkedIn-Dinesh_Barri-0A66C2?style=for-the-badge&logo=linkedin&logoColor=white)](https://www.linkedin.com/in/dinesh-barri-7654b010b)
353
+
354
+ ---
355
+
356
+ ## License
357
+
358
+ Released under the [MIT License](LICENSE).
359
+
360
+ If this project helps you, please star the repository and share the live demo.
361
+
SECURITY.md ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Security policy
2
+
3
+ Supported security fixes target the latest release and the `main` branch.
4
+
5
+ Do not open public issues for vulnerabilities or include datasets, credentials, API keys, or
6
+ database URLs in reports. Report vulnerabilities privately through GitHub Security Advisories:
7
+
8
+ <https://github.com/dineshbarri/DataPilot-AI/security/advisories/new>
9
+
10
+ Include affected version, reproduction steps, impact, and a suggested mitigation when possible.
11
+ See [docs/SECURITY.md](docs/SECURITY.md) and [docs/THREAT_MODEL.md](docs/THREAT_MODEL.md).
api/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """FastAPI service for DataPilot AI."""
api/main.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import time
5
+ from collections import defaultdict, deque
6
+ from pathlib import Path
7
+ from uuid import uuid4
8
+
9
+ from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+ from fastapi.responses import FileResponse
12
+ from pydantic import BaseModel
13
+
14
+ from datapilot.config import get_settings
15
+ from datapilot.data import load_sample, read_dataset
16
+ from datapilot.insights import answer_follow_up
17
+ from datapilot.jobs import JobManager
18
+ from datapilot.persistence import RunStore
19
+ from datapilot.workflow import run_analysis
20
+
21
+ settings = get_settings()
22
+ logger = logging.getLogger(__name__)
23
+ jobs = JobManager(workers=2)
24
+ request_windows: dict[str, deque[float]] = defaultdict(deque)
25
+ app = FastAPI(
26
+ title="DataPilot AI API",
27
+ version="1.0.0",
28
+ description="Evidence-grounded autonomous data science with LangGraph.",
29
+ )
30
+ app.add_middleware(
31
+ CORSMiddleware,
32
+ allow_origins=[item.strip() for item in settings.cors_origins.split(",")],
33
+ allow_methods=["GET", "POST"],
34
+ allow_headers=["*"],
35
+ )
36
+
37
+
38
+ @app.middleware("http")
39
+ async def security_middleware(request: Request, call_next):
40
+ correlation_id = request.headers.get("X-Correlation-ID") or uuid4().hex
41
+ request.state.correlation_id = correlation_id
42
+ if request.url.path.startswith("/v1/"):
43
+ if settings.api_key and request.headers.get("X-API-Key") != settings.api_key:
44
+ return _error_response(401, "Authentication required.", correlation_id)
45
+ client = request.client.host if request.client else "unknown"
46
+ now = time.monotonic()
47
+ window = request_windows[client]
48
+ while window and now - window[0] > 60:
49
+ window.popleft()
50
+ if len(window) >= settings.requests_per_minute:
51
+ return _error_response(429, "Rate limit exceeded.", correlation_id)
52
+ window.append(now)
53
+ response = await call_next(request)
54
+ response.headers["X-Correlation-ID"] = correlation_id
55
+ return response
56
+
57
+
58
+ def _error_response(status_code: int, message: str, correlation_id: str):
59
+ from fastapi.responses import JSONResponse
60
+
61
+ return JSONResponse(
62
+ status_code=status_code,
63
+ content={"detail": message, "correlation_id": correlation_id},
64
+ headers={"X-Correlation-ID": correlation_id},
65
+ )
66
+
67
+
68
+ def _safe_failure(exc: Exception, correlation_id: str) -> HTTPException:
69
+ logger.exception(
70
+ "Analysis failure correlation_id=%s category=%s", correlation_id, type(exc).__name__
71
+ )
72
+ return HTTPException(
73
+ status_code=500,
74
+ detail={
75
+ "message": "Analysis failed. Use the correlation ID when contacting support.",
76
+ "correlation_id": correlation_id,
77
+ },
78
+ )
79
+
80
+
81
+ class SampleRequest(BaseModel):
82
+ sample: str
83
+
84
+
85
+ class ChatRequest(BaseModel):
86
+ question: str
87
+
88
+
89
+ @app.get("/")
90
+ def root() -> dict[str, str]:
91
+ return {"name": settings.app_name, "status": "ready", "docs": "/docs"}
92
+
93
+
94
+ @app.get("/health")
95
+ def health() -> dict[str, object]:
96
+ return {
97
+ "status": "healthy",
98
+ "environment": settings.environment,
99
+ "limits": {
100
+ "max_upload_mb": settings.max_upload_mb,
101
+ "max_rows": settings.max_rows,
102
+ "max_columns": settings.max_columns,
103
+ },
104
+ }
105
+
106
+
107
+ @app.post("/v1/analyze/sample", status_code=202)
108
+ def analyze_sample(request: SampleRequest, http_request: Request):
109
+ try:
110
+ frame, target, dataset_name = load_sample(request.sample)
111
+ job = jobs.submit(lambda: run_analysis(frame, target, dataset_name, settings))
112
+ return {"job_id": job.job_id, "status": job.status, "status_url": f"/v1/jobs/{job.job_id}"}
113
+ except ValueError as exc:
114
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
115
+ except Exception as exc:
116
+ raise _safe_failure(exc, http_request.state.correlation_id) from exc
117
+
118
+
119
+ @app.post("/v1/analyze/upload")
120
+ async def analyze_upload(
121
+ request: Request,
122
+ file: UploadFile = File(...),
123
+ target: str = Form(...),
124
+ ):
125
+ try:
126
+ content = await file.read(settings.max_upload_mb * 1024 * 1024 + 1)
127
+ frame = read_dataset(content, file.filename or "dataset.csv", settings)
128
+ job = jobs.submit(
129
+ lambda: run_analysis(frame, target, file.filename or "uploaded_dataset", settings)
130
+ )
131
+ return {"job_id": job.job_id, "status": job.status, "status_url": f"/v1/jobs/{job.job_id}"}
132
+ except ValueError as exc:
133
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
134
+ except Exception as exc:
135
+ raise _safe_failure(exc, request.state.correlation_id) from exc
136
+
137
+
138
+ @app.get("/v1/jobs/{job_id}")
139
+ def get_job(job_id: str):
140
+ job = jobs.get(job_id)
141
+ if job is None:
142
+ raise HTTPException(status_code=404, detail="Job not found.")
143
+ return job.public()
144
+
145
+
146
+ @app.delete("/v1/jobs/{job_id}")
147
+ def cancel_job(job_id: str):
148
+ if jobs.get(job_id) is None:
149
+ raise HTTPException(status_code=404, detail="Job not found.")
150
+ return {"job_id": job_id, "cancelled": jobs.cancel(job_id)}
151
+
152
+
153
+ @app.get("/v1/runs")
154
+ def recent_runs():
155
+ return RunStore(settings).list_recent()
156
+
157
+
158
+ @app.get("/v1/runs/{run_id}")
159
+ def get_run(run_id: str):
160
+ run = RunStore(settings).get(run_id)
161
+ if run is None:
162
+ raise HTTPException(status_code=404, detail="Run not found.")
163
+ return run
164
+
165
+
166
+ @app.post("/v1/runs/{run_id}/chat")
167
+ def chat_with_run(run_id: str, request: ChatRequest):
168
+ run = RunStore(settings).get(run_id)
169
+ if run is None:
170
+ raise HTTPException(status_code=404, detail="Run not found.")
171
+ return {"answer": answer_follow_up(run, request.question)}
172
+
173
+
174
+ @app.get("/v1/runs/{run_id}/artifacts/{artifact_name}")
175
+ def download_artifact(run_id: str, artifact_name: str):
176
+ run = RunStore(settings).get(run_id)
177
+ if run is None:
178
+ raise HTTPException(status_code=404, detail="Run not found.")
179
+ path_string = run.get("artifacts", {}).get(artifact_name)
180
+ if not path_string:
181
+ raise HTTPException(status_code=404, detail="Artifact not found.")
182
+ path = Path(path_string).resolve()
183
+ artifact_root = settings.artifact_root.resolve()
184
+ if artifact_root not in path.parents or not path.is_file():
185
+ raise HTTPException(status_code=404, detail="Artifact not found.")
186
+ return FileResponse(path, filename=path.name)
app.py ADDED
@@ -0,0 +1,451 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ import os
5
+ from pathlib import Path
6
+
7
+ import pandas as pd
8
+ import plotly.express as px
9
+ import streamlit as st
10
+
11
+ from datapilot.analyst import dataframe_csv, gemini_dataset_summary, inspect_dataset
12
+ from datapilot.config import get_settings
13
+ from datapilot.data import SAMPLE_DATASETS, load_sample
14
+ from datapilot.workflow import run_analysis
15
+
16
+ st.set_page_config(
17
+ page_title="DataPilot · Autonomous Data Analyst",
18
+ page_icon="✦",
19
+ layout="wide",
20
+ initial_sidebar_state="expanded",
21
+ )
22
+
23
+ st.markdown(
24
+ """
25
+ <style>
26
+ @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Manrope:wght@600;700;800&display=swap');
27
+ :root{--navy:#07111f;--panel:#0e1b2c;--line:#203149;--cyan:#49d7c5;--blue:#6d8dff;--text:#edf4ff;--muted:#91a1b7}
28
+ .stApp{background:radial-gradient(circle at 75% -10%,#17355c 0,transparent 35%),#07111f;color:var(--text)}
29
+ html,body,[class*="css"]{font-family:"DM Sans",sans-serif}
30
+ h1,h2,h3{font-family:"Manrope",sans-serif;letter-spacing:-.03em}
31
+ header[data-testid="stHeader"]{background:transparent}
32
+ div[data-testid="stSidebar"]{background:#091522;border-right:1px solid var(--line)}
33
+ .block-container{max-width:1480px;padding-top:1.1rem;padding-bottom:4rem}
34
+ .brand{display:flex;gap:.75rem;align-items:center;font:800 1.2rem Manrope;color:white;margin:.2rem 0 1.3rem}
35
+ .brand-mark{display:grid;place-items:center;width:34px;height:34px;border-radius:10px;background:linear-gradient(135deg,var(--cyan),var(--blue));color:#07111f}
36
+ .hero{border:1px solid #29405d;background:linear-gradient(125deg,rgba(17,35,57,.96),rgba(9,22,38,.88));border-radius:24px;padding:2rem 2.2rem;margin-bottom:1rem;overflow:hidden;position:relative}
37
+ .hero:after{content:"";position:absolute;width:340px;height:340px;border-radius:50%;right:-100px;top:-190px;background:rgba(73,215,197,.10)}
38
+ .eyebrow{color:var(--cyan);font-size:.73rem;font-weight:700;letter-spacing:.18em;text-transform:uppercase}
39
+ .hero h1{font-size:clamp(2.1rem,4vw,4rem);line-height:1.02;margin:.45rem 0 .7rem;color:white}
40
+ .hero p{max-width:790px;color:#aebdd0;font-size:1.02rem;line-height:1.65;margin:0}
41
+ .stepbar{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:1.4rem}.step{border:1px solid #2d4664;border-radius:999px;padding:.4rem .72rem;color:#9eafc4;font-size:.75rem}.step.on{color:#07111f;background:var(--cyan);border-color:var(--cyan);font-weight:700}
42
+ .panel{background:rgba(14,27,44,.92);border:1px solid var(--line);border-radius:18px;padding:1.15rem 1.25rem;height:100%}
43
+ .kicker{color:var(--cyan);font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.12em}.muted{color:var(--muted);font-size:.87rem;line-height:1.55}
44
+ .signature{background:linear-gradient(145deg,#11263b,#0b1828);border:1px solid #29435f;border-radius:17px;padding:1rem;margin-top:1rem}.signature strong{color:white}.signature a{color:var(--cyan);text-decoration:none;font-size:.83rem}
45
+ div[data-testid="stMetric"]{background:#0d1b2c;border:1px solid var(--line);padding:15px 17px;border-radius:15px}div[data-testid="stMetric"] label{color:#91a1b7}div[data-testid="stMetricValue"]{color:white}
46
+ .stButton>button,.stDownloadButton>button{border:0;border-radius:11px;background:linear-gradient(135deg,#49d7c5,#6d8dff);color:#07111f;font-weight:800}
47
+ .stButton>button:hover,.stDownloadButton>button:hover{color:#07111f;filter:brightness(1.08)}
48
+ div[data-testid="stFileUploaderDropzone"]{background:#0c1a2b;border:1.5px dashed #3b617c;border-radius:16px;padding:1.3rem}
49
+ div[data-baseweb="tab-list"]{gap:.3rem;background:#0b1828;border:1px solid var(--line);border-radius:13px;padding:.3rem}
50
+ button[data-baseweb="tab"]{border-radius:9px;color:#9caec3}button[data-baseweb="tab"][aria-selected="true"]{background:#172b41;color:white}
51
+ .stDataFrame{border:1px solid var(--line);border-radius:13px;overflow:hidden}
52
+ [data-testid="stAlert"]{border-radius:13px}
53
+ </style>
54
+ """,
55
+ unsafe_allow_html=True,
56
+ )
57
+
58
+ settings = get_settings()
59
+ for key, default in {
60
+ "frame": None,
61
+ "dataset_name": "",
62
+ "profile": None,
63
+ "result": None,
64
+ "ai_summary": "",
65
+ "chat": [],
66
+ "target": None,
67
+ }.items():
68
+ if key not in st.session_state:
69
+ st.session_state[key] = default
70
+
71
+
72
+ def read_upload(uploaded) -> pd.DataFrame:
73
+ suffix = Path(uploaded.name).suffix.lower()
74
+ raw = uploaded.getvalue()
75
+ if len(raw) > settings.max_upload_mb * 1_048_576:
76
+ raise ValueError(f"File exceeds the {settings.max_upload_mb} MB limit.")
77
+ stream = io.BytesIO(raw)
78
+ if suffix in {".csv", ".tsv", ".txt"}:
79
+ return pd.read_csv(stream, sep="\t" if suffix == ".tsv" else None, engine="python")
80
+ if suffix in {".xlsx", ".xls"}:
81
+ return pd.read_excel(stream)
82
+ if suffix == ".parquet":
83
+ return pd.read_parquet(stream)
84
+ if suffix == ".json":
85
+ try:
86
+ return pd.read_json(stream)
87
+ except ValueError:
88
+ stream.seek(0)
89
+ return pd.read_json(stream, lines=True)
90
+ raise ValueError("Use CSV, TSV, Excel, JSON, or Parquet.")
91
+
92
+
93
+ with st.sidebar:
94
+ st.markdown(
95
+ '<div class="brand"><span class="brand-mark">✦</span>DataPilot</div>',
96
+ unsafe_allow_html=True,
97
+ )
98
+ st.caption("AUTONOMOUS ANALYSIS WORKSPACE")
99
+ st.markdown("##### Gemini intelligence")
100
+ default_key = os.getenv("GEMINI_API_KEY", os.getenv("GOOGLE_API_KEY", ""))
101
+ api_key = st.text_input(
102
+ "Gemini API key",
103
+ value=default_key,
104
+ type="password",
105
+ help="Masked in the interface and not intentionally written to files or logs.",
106
+ )
107
+ model = st.selectbox("Model", ["gemini-2.5-flash", "gemini-2.5-pro", "gemini-2.0-flash"])
108
+ st.caption("● AI ready" if api_key else "○ Local analysis mode")
109
+ st.divider()
110
+ st.markdown("##### Privacy controls")
111
+ metadata_only = st.toggle(
112
+ "Metadata-first AI",
113
+ value=True,
114
+ help="Send schema, aggregate statistics, and three redacted examples—not the full dataset.",
115
+ )
116
+ excluded = st.multiselect(
117
+ "Exclude columns from AI",
118
+ list(st.session_state.frame.columns) if st.session_state.frame is not None else [],
119
+ )
120
+ st.divider()
121
+ if st.button("Reset workspace", width="stretch"):
122
+ for key in ("frame", "profile", "result", "ai_summary", "chat", "target"):
123
+ st.session_state[key] = (
124
+ None
125
+ if key in {"frame", "profile", "result", "target"}
126
+ else ([] if key == "chat" else "")
127
+ )
128
+ st.rerun()
129
+ st.markdown(
130
+ """
131
+ <div class="signature">
132
+ <div class="kicker">Built & designed by</div>
133
+ <strong>Dinesh Barri</strong><br>
134
+ <span class="muted">AI Engineer · Data Scientist</span><br><br>
135
+ <a href="https://github.com/dineshbarri">GitHub ↗</a>&nbsp;&nbsp;
136
+ <a href="https://www.linkedin.com/in/dinesh-barri-7654b010b">LinkedIn ↗</a>
137
+ </div>""",
138
+ unsafe_allow_html=True,
139
+ )
140
+
141
+ loaded = st.session_state.frame is not None
142
+ st.markdown(
143
+ f"""
144
+ <section class="hero">
145
+ <div class="eyebrow">Evidence-first autonomous data science</div>
146
+ <h1>Your data. Explained.<br>Decisions, accelerated.</h1>
147
+ <p>Upload a dataset and DataPilot immediately inspects its structure, surfaces quality risks,
148
+ recommends analytical targets, creates interactive evidence, and prepares a leakage-safe
149
+ machine-learning study—with Gemini available for grounded interpretation.</p>
150
+ <div class="stepbar">
151
+ <span class="step {"on" if loaded else ""}">01 · Connect</span>
152
+ <span class="step {"on" if loaded else ""}">02 · Inspect</span>
153
+ <span class="step {"on" if st.session_state.ai_summary else ""}">03 · Interpret</span>
154
+ <span class="step {"on" if st.session_state.result else ""}">04 · Model</span>
155
+ <span class="step {"on" if st.session_state.result else ""}">05 · Deliver</span>
156
+ </div>
157
+ </section>""",
158
+ unsafe_allow_html=True,
159
+ )
160
+
161
+ if not loaded:
162
+ left, right = st.columns([1.35, 0.65], gap="large")
163
+ with left:
164
+ st.markdown('<div class="kicker">Start a new analysis</div>', unsafe_allow_html=True)
165
+ st.subheader("Drop in your dataset")
166
+ uploaded = st.file_uploader(
167
+ "Upload dataset",
168
+ type=["csv", "tsv", "txt", "xlsx", "xls", "json", "parquet"],
169
+ label_visibility="collapsed",
170
+ )
171
+ st.caption("CSV · TSV · Excel · JSON · Parquet | Raw data remains in this session.")
172
+ if uploaded:
173
+ try:
174
+ with st.status("DataPilot is inspecting your dataset…", expanded=True) as status:
175
+ st.write("Validating file structure")
176
+ frame = read_upload(uploaded)
177
+ st.write("Profiling columns, missingness, cardinality, and target candidates")
178
+ profile = inspect_dataset(frame)
179
+ st.session_state.frame = frame
180
+ st.session_state.profile = profile
181
+ st.session_state.dataset_name = uploaded.name
182
+ status.update(label="Dataset ready", state="complete")
183
+ st.rerun()
184
+ except Exception as exc:
185
+ st.error(f"Upload could not be processed: {exc}")
186
+ with right:
187
+ st.markdown(
188
+ '<div class="panel"><div class="kicker">Try it instantly</div><h3>Explore a trusted demo</h3><p class="muted">Load a complete classification or regression dataset and see the full analyst workflow.</p></div>',
189
+ unsafe_allow_html=True,
190
+ )
191
+ demo = st.selectbox("Demo dataset", list(SAMPLE_DATASETS))
192
+ if st.button("Load demo workspace", width="stretch"):
193
+ frame, target, name = load_sample(SAMPLE_DATASETS[demo])
194
+ st.session_state.frame, st.session_state.target = frame, target
195
+ st.session_state.dataset_name = name
196
+ st.session_state.profile = inspect_dataset(frame)
197
+ st.rerun()
198
+ st.stop()
199
+
200
+ frame: pd.DataFrame = st.session_state.frame
201
+ profile = st.session_state.profile or inspect_dataset(frame)
202
+ brief = profile["brief"]
203
+
204
+ metrics = st.columns(6)
205
+ metrics[0].metric("Rows", f"{brief.rows:,}")
206
+ metrics[1].metric("Columns", f"{brief.columns:,}")
207
+ metrics[2].metric("Numeric", brief.numeric)
208
+ metrics[3].metric("Categorical", brief.categorical)
209
+ metrics[4].metric("Missing cells", f"{brief.missing_cells:,}")
210
+ metrics[5].metric("Quality score", f"{profile['quality_score']}/100")
211
+
212
+ overview, quality, explore, ai_tab, model_tab, deliver = st.tabs(
213
+ ["Overview", "Data quality", "Explore", "AI insights", "Model lab", "Deliver"]
214
+ )
215
+
216
+ with overview:
217
+ st.subheader(st.session_state.dataset_name)
218
+ st.caption(f"Dataset fingerprint {brief.fingerprint} · {brief.memory_mb:.2f} MB in memory")
219
+ first, last, sample = st.tabs(["First 5 rows", "Last 5 rows", "Random sample"])
220
+ first.dataframe(frame.head(), width="stretch", hide_index=True)
221
+ last.dataframe(frame.tail(), width="stretch", hide_index=True)
222
+ sample.dataframe(
223
+ frame.sample(min(5, len(frame)), random_state=42), width="stretch", hide_index=True
224
+ )
225
+ st.markdown("#### Data dictionary")
226
+ st.dataframe(
227
+ profile["dictionary"].drop(columns=["issue_count"]), width="stretch", hide_index=True
228
+ )
229
+
230
+ with quality:
231
+ a, b = st.columns([0.75, 1.25])
232
+ with a:
233
+ st.markdown("#### Quality signals")
234
+ st.metric("Duplicate rows", f"{brief.duplicate_rows:,}")
235
+ st.metric("Completeness", f"{100 - brief.missing_cells / max(1, frame.size) * 100:.1f}%")
236
+ flagged = profile["dictionary"].query("issue_count > 0")
237
+ st.metric("Flagged columns", len(flagged))
238
+ st.info("DataPilot reports evidence first. No rows or values are changed without approval.")
239
+ with b:
240
+ missing = profile["missing"][profile["missing"] > 0].sort_values()
241
+ if len(missing):
242
+ fig = px.bar(
243
+ x=missing.values,
244
+ y=missing.index,
245
+ orientation="h",
246
+ labels={"x": "Missing values", "y": "Column"},
247
+ title="Missing values by column",
248
+ color=missing.values,
249
+ color_continuous_scale=["#49d7c5", "#6d8dff"],
250
+ )
251
+ fig.update_layout(
252
+ template="plotly_dark",
253
+ paper_bgcolor="#0e1b2c",
254
+ plot_bgcolor="#0e1b2c",
255
+ coloraxis_showscale=False,
256
+ )
257
+ st.plotly_chart(fig, width="stretch")
258
+ else:
259
+ st.success("No missing values detected.")
260
+ if len(flagged):
261
+ st.dataframe(flagged.drop(columns=["issue_count"]), width="stretch", hide_index=True)
262
+
263
+ with explore:
264
+ numeric = profile["numeric"]
265
+ if numeric:
266
+ selected = st.selectbox("Explore a numerical feature", numeric)
267
+ c1, c2 = st.columns(2)
268
+ fig = px.histogram(
269
+ frame,
270
+ x=selected,
271
+ marginal="box",
272
+ title=f"Distribution of {selected}",
273
+ color_discrete_sequence=["#49d7c5"],
274
+ )
275
+ fig.update_layout(template="plotly_dark", paper_bgcolor="#0e1b2c", plot_bgcolor="#0e1b2c")
276
+ c1.plotly_chart(fig, width="stretch")
277
+ if not profile["correlation"].empty:
278
+ heat = px.imshow(
279
+ profile["correlation"],
280
+ text_auto=".2f",
281
+ aspect="auto",
282
+ color_continuous_scale=["#1a2940", "#49d7c5", "#f4b860"],
283
+ title="Numeric correlation map",
284
+ )
285
+ heat.update_layout(template="plotly_dark", paper_bgcolor="#0e1b2c")
286
+ c2.plotly_chart(heat, width="stretch")
287
+ else:
288
+ c2.info("Add another numerical column to calculate correlations.")
289
+ st.dataframe(frame[numeric].describe().T, width="stretch")
290
+ else:
291
+ st.info("This dataset has no numerical columns. Use the categorical overview below.")
292
+ categories = profile["categorical"]
293
+ if categories:
294
+ selected_cat = st.selectbox("Explore a categorical feature", categories)
295
+ counts = frame[selected_cat].astype(str).value_counts().head(20).reset_index()
296
+ fig = px.bar(
297
+ counts,
298
+ x="count",
299
+ y=selected_cat,
300
+ orientation="h",
301
+ title=f"Top values · {selected_cat}",
302
+ color="count",
303
+ color_continuous_scale=["#49d7c5", "#6d8dff"],
304
+ )
305
+ fig.update_layout(
306
+ template="plotly_dark",
307
+ paper_bgcolor="#0e1b2c",
308
+ plot_bgcolor="#0e1b2c",
309
+ coloraxis_showscale=False,
310
+ )
311
+ st.plotly_chart(fig, width="stretch")
312
+
313
+ with ai_tab:
314
+ st.markdown("#### Ask Gemini to interpret the computed evidence")
315
+ st.caption(
316
+ "AI interpretation based on dataset metadata and limited redacted samples. Verify against source documentation."
317
+ )
318
+ if not api_key:
319
+ st.warning(
320
+ "Enter a Gemini API key in the sidebar. Deterministic profiling remains fully available without AI."
321
+ )
322
+ if st.button("Generate AI analyst brief", disabled=not bool(api_key)):
323
+ try:
324
+ with st.status("Gemini is reviewing the evidence package…", expanded=True) as status:
325
+ st.write("Redacting potential PII and excluded columns")
326
+ st.write("Sending schema, aggregate statistics, and three examples")
327
+ summary = gemini_dataset_summary(frame, profile, api_key, model, excluded)
328
+ st.session_state.ai_summary = summary
329
+ status.update(label="AI analyst brief ready", state="complete")
330
+ except ValueError as exc:
331
+ st.error(str(exc))
332
+ if st.session_state.ai_summary:
333
+ st.markdown(st.session_state.ai_summary)
334
+ with st.expander("What may be sent to Gemini"):
335
+ st.write(
336
+ "Column metadata, aggregate statistics, target candidates, quality score, and up to three redacted example rows."
337
+ )
338
+ st.write(
339
+ "Automatically excluded potential PII:",
340
+ [
341
+ c
342
+ for c in frame.columns
343
+ if any(
344
+ k in str(c).lower() for k in ("email", "phone", "address", "name", "account")
345
+ )
346
+ ]
347
+ or "None detected",
348
+ )
349
+
350
+ with model_tab:
351
+ st.markdown("#### Confirm the analytical target")
352
+ candidates = pd.DataFrame(profile["targets"])
353
+ st.dataframe(candidates, width="stretch", hide_index=True)
354
+ default_target = st.session_state.target or (
355
+ profile["targets"][0]["column"] if profile["targets"] else frame.columns[-1]
356
+ )
357
+ target = st.selectbox(
358
+ "Target column", list(frame.columns), index=list(frame.columns).index(default_target)
359
+ )
360
+ st.caption("DataPilot will not train supervised models until you confirm this selection.")
361
+ if frame[target].nunique(dropna=True) < 2:
362
+ st.error("The selected target has fewer than two observed values.")
363
+ run = st.button(
364
+ "Run autonomous model study",
365
+ type="primary",
366
+ disabled=frame[target].nunique(dropna=True) < 2,
367
+ )
368
+ if run:
369
+ try:
370
+ progress = st.progress(0, text="Preparing agent graph")
371
+ progress.progress(12, text="Data Quality Agent · auditing risks")
372
+ with st.spinner(
373
+ "LangGraph agents are profiling, planning, training, evaluating, and explaining…"
374
+ ):
375
+ result = run_analysis(frame, target, st.session_state.dataset_name, settings)
376
+ progress.progress(100, text="Analysis complete")
377
+ st.session_state.result = result.model_dump(mode="json")
378
+ st.success(
379
+ "Model study completed with leakage-safe preprocessing and cross-validation."
380
+ )
381
+ except Exception as exc:
382
+ st.error(f"Model study failed: {exc}")
383
+ result = st.session_state.result
384
+ if result:
385
+ best = result["model_results"][0]
386
+ c1, c2, c3 = st.columns(3)
387
+ c1.metric("Selected model", result["best_model"])
388
+ c2.metric(
389
+ "One-time test " + best["primary_metric"].replace("_", " ").title(),
390
+ f"{best['final_test_score']:.3f}",
391
+ )
392
+ c3.metric("CV mean", f"{best['cross_validation_mean']:.3f}")
393
+ results = pd.DataFrame(result["model_results"])
394
+ fig = px.bar(
395
+ results.sort_values("selection_score"),
396
+ x="selection_score",
397
+ y="name",
398
+ orientation="h",
399
+ color="selection_score",
400
+ title="Training-CV model selection",
401
+ color_continuous_scale=["#344b69", "#49d7c5"],
402
+ )
403
+ fig.update_layout(
404
+ template="plotly_dark",
405
+ paper_bgcolor="#0e1b2c",
406
+ plot_bgcolor="#0e1b2c",
407
+ coloraxis_showscale=False,
408
+ )
409
+ st.plotly_chart(fig, width="stretch")
410
+ st.dataframe(results, width="stretch", hide_index=True)
411
+ st.markdown("#### Agent execution trace")
412
+ st.dataframe(pd.DataFrame(result["trace"]), width="stretch", hide_index=True)
413
+
414
+ with deliver:
415
+ st.markdown("#### Export your evidence")
416
+ c1, c2 = st.columns(2)
417
+ c1.download_button(
418
+ "Download original dataset · CSV",
419
+ dataframe_csv(frame),
420
+ file_name=f"{Path(st.session_state.dataset_name).stem}_datapilot.csv",
421
+ mime="text/csv",
422
+ width="stretch",
423
+ )
424
+ c2.download_button(
425
+ "Download data dictionary · CSV",
426
+ dataframe_csv(profile["dictionary"].drop(columns=["issue_count"])),
427
+ file_name="datapilot_data_dictionary.csv",
428
+ mime="text/csv",
429
+ width="stretch",
430
+ )
431
+ result = st.session_state.result
432
+ if result:
433
+ st.markdown("#### Model and report artifacts")
434
+ columns = st.columns(min(4, len(result["artifacts"])))
435
+ for column, (name, raw_path) in zip(columns, result["artifacts"].items(), strict=False):
436
+ path = Path(raw_path)
437
+ if path.exists():
438
+ column.download_button(
439
+ name.replace("_", " ").title(),
440
+ path.read_bytes(),
441
+ file_name=path.name,
442
+ width="stretch",
443
+ )
444
+ else:
445
+ st.info(
446
+ "Run a model study to unlock the fitted pipeline, model card, metrics, and HTML report."
447
+ )
448
+
449
+ st.caption(
450
+ "DataPilot provides exploratory decision support. Predictive associations do not establish causality."
451
+ )
artifacts/run_f13555d6077f/MODEL_CARD.md ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Model Card — iris
2
+
3
+ ## Model details
4
+
5
+ - Run ID: `run_f13555d6077f`
6
+ - Task: classification
7
+ - Target: `target`
8
+ - Selected model: **Logistic Regression**
9
+ - Training-CV selection metric: `balanced_accuracy = 0.9583`
10
+ - One-time untouched test metric: `balanced_accuracy = 0.9333`
11
+ - Training rows before split: 150
12
+
13
+ ## Intended use
14
+
15
+ Exploratory decision support and portfolio demonstration. Validate with domain-specific,
16
+ out-of-time data before any consequential or production use.
17
+
18
+ ## Evaluation
19
+
20
+ ```json
21
+ {
22
+ "accuracy": 0.9333,
23
+ "balanced_accuracy": 0.9333,
24
+ "f1_weighted": 0.9333
25
+ }
26
+ ```
27
+
28
+ ## Data-quality observations
29
+
30
+ - 1 exact duplicate rows can bias validation.
31
+
32
+ ## Explainability
33
+
34
+ Method: **Permutation importance**. Importance values are predictive associations,
35
+ not evidence of causation.
36
+
37
+ ## Limitations
38
+
39
+ - Results depend on the uploaded dataset and chosen target.
40
+ - Automated task inference can be wrong; a domain owner should confirm the objective.
41
+ - Fairness, privacy, and legal review are outside the automatic approval gate.
artifacts/run_f13555d6077f/analysis_report.html ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
3
+ <title>DataPilot AI report</title>
4
+ <style>
5
+ body{font-family:Inter,system-ui,sans-serif;max-width:1000px;margin:40px auto;padding:0 24px;color:#172033}
6
+ h1{color:#5537d8} .hero{background:#f4f1ff;border:1px solid #d9d0ff;padding:24px;border-radius:18px}
7
+ .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px;margin:20px 0}
8
+ .card{border:1px solid #e2e6ef;border-radius:14px;padding:18px} table{border-collapse:collapse;width:100%}
9
+ td,th{border-bottom:1px solid #e2e6ef;padding:10px;text-align:left} small{color:#667085}
10
+ </style></head><body>
11
+ <div class="hero"><h1>DataPilot AI Analysis Report</h1>
12
+ <p>iris · Run run_f13555d6077f</p></div>
13
+ <div class="grid">
14
+ <div class="card"><small>Selected model</small><h2>Logistic Regression</h2></div>
15
+ <div class="card"><small>Test balanced_accuracy</small><h2>0.933</h2></div>
16
+ <div class="card"><small>Rows analyzed</small><h2>150</h2></div>
17
+ </div>
18
+ <h2>Executive findings</h2><ul><li>The analysis used 150 rows and 5 columns for a classification task targeting &#x27;target&#x27;.</li><li>Logistic Regression ranked first with training CV balanced_accuracy 0.958; its one-time test score was 0.933.</li><li>1 data-quality observations were recorded; 0 are critical.</li><li>The strongest predictive signals were petal_width_(cm), petal_length_(cm), sepal_length_(cm) according to permutation importance.</li></ul>
19
+ <h2>Evaluation</h2><table><tr><th>Metric</th><th>Value</th></tr><tr><td>accuracy</td><td>0.9333</td></tr><tr><td>balanced_accuracy</td><td>0.9333</td></tr><tr><td>f1_weighted</td><td>0.9333</td></tr></table>
20
+ <h2>Data quality</h2><table><tr><th>Severity</th><th>Code</th><th>Observation</th></tr><tr><td>warning</td><td>DUPLICATE_ROWS</td><td>1 exact duplicate rows can bias validation.</td></tr></table>
21
+ <h2>Recommendations</h2><ol><li>Validate performance on fresh, out-of-time data before production deployment.</li><li>Review suspected leakage and identifier columns with a domain owner.</li><li>Monitor input drift and the primary metric after deployment.</li></ol>
22
+ <p><small>Generated from computed evidence. Predictive findings do not establish causality.</small></p>
23
+ </body></html>
artifacts/run_f13555d6077f/metrics.json ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "run_id": "run_f13555d6077f",
3
+ "status": "completed",
4
+ "dataset_name": "iris",
5
+ "profile": {
6
+ "rows": 150,
7
+ "columns": 5,
8
+ "numeric_columns": [
9
+ "sepal_length_(cm)",
10
+ "sepal_width_(cm)",
11
+ "petal_length_(cm)",
12
+ "petal_width_(cm)",
13
+ "target"
14
+ ],
15
+ "categorical_columns": [],
16
+ "datetime_columns": [],
17
+ "duplicate_rows": 1,
18
+ "missing_cells": 0,
19
+ "missing_rate": 0.0,
20
+ "memory_mb": 0.006,
21
+ "target": "target",
22
+ "task_type": "classification",
23
+ "target_cardinality": 3
24
+ },
25
+ "plan": {
26
+ "objective": "Predict 'target' and produce reproducible, evidence-backed insights.",
27
+ "target": "target",
28
+ "task_type": "classification",
29
+ "primary_metric": "balanced_accuracy",
30
+ "validation_strategy": "Training-only stratified cross-validation; untouched final test evaluation",
31
+ "candidate_models": [
32
+ "Logistic Regression",
33
+ "Random Forest",
34
+ "Extra Trees",
35
+ "Histogram Gradient Boosting",
36
+ "XGBoost (when installed)"
37
+ ],
38
+ "risk_controls": [
39
+ "Drop rows with missing target before split",
40
+ "Fit imputers, encoders, and scalers on training folds only",
41
+ "Flag leakage-like names and identifier cardinality",
42
+ "Require critic quality gate before explanation"
43
+ ]
44
+ },
45
+ "quality_issues": [
46
+ {
47
+ "code": "DUPLICATE_ROWS",
48
+ "severity": "warning",
49
+ "column": null,
50
+ "message": "1 exact duplicate rows can bias validation.",
51
+ "evidence_ids": [
52
+ "EV-9000641D"
53
+ ]
54
+ }
55
+ ],
56
+ "evidence": [
57
+ {
58
+ "evidence_id": "EV-84BCE8AD",
59
+ "claim": "Dataset missingness was measured across all cells.",
60
+ "metric": "missing_rate",
61
+ "value": 0.0,
62
+ "source": "uploaded_dataset",
63
+ "method": "pandas.isna"
64
+ },
65
+ {
66
+ "evidence_id": "EV-9000641D",
67
+ "claim": "Exact duplicate rows were counted before splitting.",
68
+ "metric": "duplicate_rows",
69
+ "value": 1,
70
+ "source": "uploaded_dataset",
71
+ "method": "pandas.duplicated"
72
+ },
73
+ {
74
+ "evidence_id": "EV-A9BE42C5",
75
+ "claim": "Rows with missing labels cannot be used for supervised training.",
76
+ "metric": "missing_target_rows",
77
+ "value": 0,
78
+ "source": "column:target",
79
+ "method": "pandas.isna"
80
+ },
81
+ {
82
+ "evidence_id": "EV-76E9ED71",
83
+ "claim": "Class imbalance was measured using the minority-class share.",
84
+ "metric": "minority_class_share",
85
+ "value": 0.3333,
86
+ "source": "column:target",
87
+ "method": "normalized value counts"
88
+ }
89
+ ],
90
+ "model_results": [
91
+ {
92
+ "name": "Logistic Regression",
93
+ "primary_metric": "balanced_accuracy",
94
+ "primary_score": 0.9583333333333334,
95
+ "metrics": {
96
+ "accuracy": 0.9333,
97
+ "balanced_accuracy": 0.9333,
98
+ "f1_weighted": 0.9333
99
+ },
100
+ "cross_validation_mean": 0.9583333333333334,
101
+ "cross_validation_std": 0.026352313834736508,
102
+ "training_seconds": 0.228,
103
+ "selection_score": 0.9583333333333334,
104
+ "final_test_score": 0.9333,
105
+ "final_test_metrics": {
106
+ "accuracy": 0.9333,
107
+ "balanced_accuracy": 0.9333,
108
+ "f1_weighted": 0.9333
109
+ }
110
+ },
111
+ {
112
+ "name": "Extra Trees",
113
+ "primary_metric": "balanced_accuracy",
114
+ "primary_score": 0.9583333333333334,
115
+ "metrics": {},
116
+ "cross_validation_mean": 0.9583333333333334,
117
+ "cross_validation_std": 0.026352313834736508,
118
+ "training_seconds": 2.901,
119
+ "selection_score": 0.9583333333333334,
120
+ "final_test_score": null,
121
+ "final_test_metrics": {}
122
+ },
123
+ {
124
+ "name": "Random Forest",
125
+ "primary_metric": "balanced_accuracy",
126
+ "primary_score": 0.95,
127
+ "metrics": {},
128
+ "cross_validation_mean": 0.95,
129
+ "cross_validation_std": 0.0311804782231162,
130
+ "training_seconds": 2.641,
131
+ "selection_score": 0.95,
132
+ "final_test_score": null,
133
+ "final_test_metrics": {}
134
+ }
135
+ ],
136
+ "model_failures": [],
137
+ "best_model": "Logistic Regression",
138
+ "critic": {
139
+ "approved": true,
140
+ "score": 0.9583333333333334,
141
+ "threshold": 0.55,
142
+ "reasons": [
143
+ "Performance and validation consistency passed the configured quality gate."
144
+ ],
145
+ "retry_number": 0
146
+ },
147
+ "explainability": {
148
+ "method": "Permutation importance",
149
+ "feature_importance": {
150
+ "petal_width_(cm)": 0.213333,
151
+ "petal_length_(cm)": 0.2,
152
+ "sepal_length_(cm)": 0.06,
153
+ "sepal_width_(cm)": 0.046667
154
+ },
155
+ "caveats": [
156
+ "Permutation importance can dilute importance among correlated features.",
157
+ "Feature importance is predictive, not causal."
158
+ ]
159
+ },
160
+ "executive_summary": [
161
+ "The analysis used 150 rows and 5 columns for a classification task targeting 'target'.",
162
+ "Logistic Regression ranked first with training CV balanced_accuracy 0.958; its one-time test score was 0.933.",
163
+ "1 data-quality observations were recorded; 0 are critical.",
164
+ "The strongest predictive signals were petal_width_(cm), petal_length_(cm), sepal_length_(cm) according to permutation importance."
165
+ ],
166
+ "recommendations": [
167
+ "Validate performance on fresh, out-of-time data before production deployment.",
168
+ "Review suspected leakage and identifier columns with a domain owner.",
169
+ "Monitor input drift and the primary metric after deployment."
170
+ ],
171
+ "artifacts": {},
172
+ "trace": [
173
+ {
174
+ "agent": "Data Quality Agent",
175
+ "status": "completed",
176
+ "duration_seconds": 0.005,
177
+ "detail": "Recorded 1 quality observations."
178
+ },
179
+ {
180
+ "agent": "EDA Agent",
181
+ "status": "completed",
182
+ "duration_seconds": 0.058,
183
+ "detail": "Computed DuckDB-backed dataset overview."
184
+ },
185
+ {
186
+ "agent": "Statistical Analysis Agent",
187
+ "status": "completed",
188
+ "duration_seconds": 0.001,
189
+ "detail": "Measured target distribution and associations."
190
+ },
191
+ {
192
+ "agent": "Planning Agent",
193
+ "status": "completed",
194
+ "duration_seconds": 0.0,
195
+ "detail": "Selected balanced_accuracy as primary metric."
196
+ },
197
+ {
198
+ "agent": "Feature Engineering Agent",
199
+ "status": "completed",
200
+ "duration_seconds": 0.0,
201
+ "detail": "Created leakage-safe ColumnTransformer plan."
202
+ },
203
+ {
204
+ "agent": "Modeling Agent",
205
+ "status": "completed",
206
+ "duration_seconds": 5.916,
207
+ "detail": "Compared 3 models; Logistic Regression ranked first."
208
+ },
209
+ {
210
+ "agent": "Evaluation / Critic Agent",
211
+ "status": "completed",
212
+ "duration_seconds": 0.0,
213
+ "detail": "Approved analysis."
214
+ },
215
+ {
216
+ "agent": "Explainability Agent",
217
+ "status": "completed",
218
+ "duration_seconds": 0.331,
219
+ "detail": "Generated Permutation importance explanations."
220
+ },
221
+ {
222
+ "agent": "Executive Insights Agent",
223
+ "status": "completed",
224
+ "duration_seconds": 0.0,
225
+ "detail": "Created evidence-grounded narrative with deterministic metric provenance."
226
+ }
227
+ ]
228
+ }
artifacts/run_f13555d6077f/reproduction.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "run_id": "run_f13555d6077f",
3
+ "random_state": 42,
4
+ "test_size": 0.2,
5
+ "target": "target",
6
+ "task_type": "classification",
7
+ "best_model": "Logistic Regression"
8
+ }
datapilot/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """DataPilot AI: evidence-grounded autonomous data science."""
2
+
3
+ __version__ = "1.0.0"
datapilot/analyst.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import io
5
+ import json
6
+ import re
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+ import numpy as np
11
+ import pandas as pd
12
+
13
+ TARGET_WORDS = {
14
+ "target": 1.0,
15
+ "label": 1.0,
16
+ "outcome": 0.95,
17
+ "class": 0.9,
18
+ "churn": 0.95,
19
+ "fraud": 0.95,
20
+ "default": 0.9,
21
+ "price": 0.8,
22
+ "revenue": 0.75,
23
+ "sales": 0.75,
24
+ "diagnosis": 0.9,
25
+ "status": 0.7,
26
+ "response": 0.8,
27
+ "converted": 0.9,
28
+ }
29
+ PII_PATTERN = re.compile(r"(email|phone|mobile|address|ssn|passport|account|name)", re.I)
30
+
31
+
32
+ @dataclass
33
+ class DatasetBrief:
34
+ fingerprint: str
35
+ rows: int
36
+ columns: int
37
+ numeric: int
38
+ categorical: int
39
+ datetime: int
40
+ missing_cells: int
41
+ duplicate_rows: int
42
+ memory_mb: float
43
+
44
+
45
+ def inspect_dataset(frame: pd.DataFrame) -> dict[str, Any]:
46
+ """Compute an immediate analyst profile without requiring a target."""
47
+ numeric = list(frame.select_dtypes(include=np.number).columns)
48
+ datetime = list(frame.select_dtypes(include=["datetime", "datetimetz"]).columns)
49
+ categorical = [c for c in frame.columns if c not in numeric and c not in datetime]
50
+ missing = frame.isna().sum()
51
+ brief = DatasetBrief(
52
+ fingerprint=hashlib.sha256(
53
+ pd.util.hash_pandas_object(frame, index=True).values.tobytes()
54
+ ).hexdigest()[:12],
55
+ rows=len(frame),
56
+ columns=len(frame.columns),
57
+ numeric=len(numeric),
58
+ categorical=len(categorical),
59
+ datetime=len(datetime),
60
+ missing_cells=int(missing.sum()),
61
+ duplicate_rows=int(frame.duplicated().sum()),
62
+ memory_mb=round(float(frame.memory_usage(deep=True).sum() / 1_048_576), 2),
63
+ )
64
+ dictionary = build_data_dictionary(frame)
65
+ quality_score = max(
66
+ 0,
67
+ round(
68
+ 100
69
+ - (brief.missing_cells / max(1, frame.size)) * 45
70
+ - (brief.duplicate_rows / max(1, brief.rows)) * 25
71
+ - sum(dictionary["issue_count"].clip(upper=3)) / max(1, len(dictionary)) * 4
72
+ ),
73
+ )
74
+ return {
75
+ "brief": brief,
76
+ "dictionary": dictionary,
77
+ "targets": rank_target_candidates(frame),
78
+ "quality_score": quality_score,
79
+ "missing": missing.sort_values(ascending=False),
80
+ "numeric": numeric,
81
+ "categorical": categorical,
82
+ "datetime": datetime,
83
+ "correlation": frame[numeric].corr(numeric_only=True)
84
+ if len(numeric) > 1
85
+ else pd.DataFrame(),
86
+ }
87
+
88
+
89
+ def build_data_dictionary(frame: pd.DataFrame) -> pd.DataFrame:
90
+ """Create an evidence-based data dictionary for every column."""
91
+ rows: list[dict[str, Any]] = []
92
+ for column in frame.columns:
93
+ series = frame[column]
94
+ unique, missing = int(series.nunique(dropna=True)), int(series.isna().sum())
95
+ issues: list[str] = []
96
+ if missing:
97
+ issues.append("Missing values")
98
+ if unique <= 1:
99
+ issues.append("Constant")
100
+ if len(frame) and unique / len(frame) > 0.98:
101
+ issues.append("Identifier-like")
102
+ if PII_PATTERN.search(str(column)):
103
+ issues.append("Potential PII")
104
+ role = "Numeric feature" if pd.api.types.is_numeric_dtype(series) else "Categorical feature"
105
+ if pd.api.types.is_datetime64_any_dtype(series):
106
+ role = "Datetime"
107
+ elif unique == len(frame) and len(frame) > 10:
108
+ role = "Identifier"
109
+ rows.append(
110
+ {
111
+ "column": str(column),
112
+ "type": str(series.dtype),
113
+ "role": role,
114
+ "unique": unique,
115
+ "missing": missing,
116
+ "missing_%": round(missing / max(1, len(frame)) * 100, 2),
117
+ "example_values": ", ".join(map(str, series.dropna().astype(str).unique()[:3]))[
118
+ :90
119
+ ],
120
+ "issues": ", ".join(issues) or "None detected",
121
+ "issue_count": len(issues),
122
+ }
123
+ )
124
+ return pd.DataFrame(rows)
125
+
126
+
127
+ def rank_target_candidates(frame: pd.DataFrame) -> list[dict[str, Any]]:
128
+ """Rank targets while explicitly leaving confirmation to the user."""
129
+ ranked: list[dict[str, Any]] = []
130
+ for position, column in enumerate(frame.columns):
131
+ series, name = frame[column], str(column).lower().strip()
132
+ cardinality = int(series.nunique(dropna=True))
133
+ score = max((v for word, v in TARGET_WORDS.items() if word in name), default=0.0)
134
+ if 2 <= cardinality <= max(20, int(len(frame) * 0.1)):
135
+ score += 0.22
136
+ if position == len(frame.columns) - 1:
137
+ score += 0.12
138
+ if cardinality >= max(10, int(len(frame) * 0.95)):
139
+ score -= 0.45
140
+ if series.isna().mean() > 0.5 or cardinality < 2:
141
+ score -= 0.5
142
+ task = "classification" if cardinality <= max(20, int(len(frame) * 0.05)) else "regression"
143
+ if pd.api.types.is_numeric_dtype(series) and cardinality > 20:
144
+ task = "regression"
145
+ ranked.append(
146
+ {
147
+ "column": str(column),
148
+ "task": task,
149
+ "confidence": round(max(0, min(score, 0.99)), 2),
150
+ "reason": f"{cardinality:,} distinct values; "
151
+ + ("name/position signals detected" if score > 0.3 else "weak heuristic evidence"),
152
+ }
153
+ )
154
+ return sorted(ranked, key=lambda item: item["confidence"], reverse=True)[:5]
155
+
156
+
157
+ def ai_context(frame: pd.DataFrame, profile: dict[str, Any], excluded: list[str]) -> dict[str, Any]:
158
+ """Build a bounded, redacted payload suitable for AI interpretation."""
159
+ safe = frame.drop(columns=[c for c in excluded if c in frame], errors="ignore").copy()
160
+ pii = [c for c in safe.columns if PII_PATTERN.search(str(c))]
161
+ safe = safe.drop(columns=pii, errors="ignore")
162
+ return {
163
+ "shape": list(frame.shape),
164
+ "columns": profile["dictionary"].drop(columns=["issue_count"]).to_dict(orient="records"),
165
+ "numeric_summary": safe.select_dtypes(include=np.number).describe().round(3).to_dict(),
166
+ "sample": safe.head(3).replace({np.nan: None}).to_dict(orient="records"),
167
+ "excluded_columns": sorted(set(excluded + pii)),
168
+ "quality_score": profile["quality_score"],
169
+ "target_candidates": profile["targets"],
170
+ }
171
+
172
+
173
+ def gemini_dataset_summary(
174
+ frame: pd.DataFrame, profile: dict[str, Any], api_key: str, model: str, excluded: list[str]
175
+ ) -> str:
176
+ """Ask Gemini for an evidence-bounded analyst narrative."""
177
+ if not api_key:
178
+ raise ValueError("Enter a Gemini API key to generate AI interpretation.")
179
+ try:
180
+ from google import genai
181
+ except ImportError as exc:
182
+ raise ValueError("Install the AI extra: pip install google-genai") from exc
183
+ prompt = """You are DataPilot, a rigorous senior data analyst. Use ONLY the supplied JSON.
184
+ Return concise Markdown with exactly these headings: Finding, Evidence, Interpretation,
185
+ Limitation, Recommendation. Explain likely row grain and useful business questions, but label
186
+ uncertain semantics as assumptions. Never invent values, origin, or causal claims.
187
+ DATA:\n""" + json.dumps(ai_context(frame, profile, excluded), default=str)
188
+ try:
189
+ response = genai.Client(api_key=api_key).models.generate_content(
190
+ model=model, contents=prompt
191
+ )
192
+ return response.text
193
+ except Exception as exc:
194
+ raise ValueError(f"Gemini request failed: {str(exc)[:240]}") from exc
195
+
196
+
197
+ def dataframe_csv(frame: pd.DataFrame) -> bytes:
198
+ buffer = io.StringIO()
199
+ frame.to_csv(buffer, index=False)
200
+ return buffer.getvalue().encode("utf-8")
datapilot/config.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from functools import lru_cache
4
+ from pathlib import Path
5
+
6
+ from pydantic import Field
7
+ from pydantic_settings import BaseSettings, SettingsConfigDict
8
+
9
+
10
+ class Settings(BaseSettings):
11
+ model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
12
+
13
+ app_name: str = "DataPilot AI"
14
+ environment: str = "development"
15
+ artifact_root: Path = Path("artifacts")
16
+ database_url: str = "sqlite:///artifacts/datapilot.db"
17
+ max_upload_mb: int = Field(default=25, ge=1, le=250)
18
+ max_rows: int = Field(default=100_000, ge=100)
19
+ max_columns: int = Field(default=250, ge=2)
20
+ max_categories_per_feature: int = Field(default=100, ge=10, le=10_000)
21
+ max_encoded_features: int = Field(default=5_000, ge=100, le=100_000)
22
+ api_key: str | None = None
23
+ requests_per_minute: int = Field(default=30, ge=1, le=10_000)
24
+ random_state: int = 42
25
+ test_size: float = Field(default=0.2, gt=0.05, lt=0.5)
26
+ max_critic_retries: int = Field(default=1, ge=0, le=3)
27
+ min_classification_score: float = 0.55
28
+ min_regression_score: float = 0.15
29
+ optuna_trials: int = Field(default=8, ge=0, le=50)
30
+ enable_mlflow: bool = False
31
+ mlflow_tracking_uri: str = "file:./artifacts/mlruns"
32
+ gemini_api_key: str | None = None
33
+ gemini_model: str = "gemini-2.5-flash"
34
+ cors_origins: str = "http://localhost:8501"
35
+
36
+ def ensure_directories(self) -> None:
37
+ self.artifact_root.mkdir(parents=True, exist_ok=True)
38
+
39
+
40
+ @lru_cache
41
+ def get_settings() -> Settings:
42
+ settings = Settings()
43
+ settings.ensure_directories()
44
+ return settings
datapilot/data.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import io
4
+ from pathlib import Path
5
+ from typing import BinaryIO
6
+
7
+ import duckdb
8
+ import pandas as pd
9
+ from sklearn.datasets import load_breast_cancer, load_diabetes, load_iris
10
+
11
+ from datapilot.config import Settings
12
+
13
+ SAMPLE_DATASETS = {
14
+ "Iris classification": "iris",
15
+ "Breast cancer classification": "breast_cancer",
16
+ "Diabetes progression regression": "diabetes",
17
+ }
18
+
19
+
20
+ def load_sample(name: str) -> tuple[pd.DataFrame, str, str]:
21
+ loaders = {
22
+ "iris": load_iris,
23
+ "breast_cancer": load_breast_cancer,
24
+ "diabetes": load_diabetes,
25
+ }
26
+ if name not in loaders:
27
+ raise ValueError(f"Unknown sample dataset: {name}")
28
+ bundle = loaders[name](as_frame=True)
29
+ frame = bundle.frame.copy()
30
+ frame.columns = [str(column).replace(" ", "_") for column in frame.columns]
31
+ target = str(bundle.target.name).replace(" ", "_")
32
+ return frame, target, name
33
+
34
+
35
+ def read_dataset(
36
+ source: bytes | BinaryIO | str | Path,
37
+ filename: str,
38
+ settings: Settings,
39
+ ) -> pd.DataFrame:
40
+ suffix = Path(filename).suffix.lower()
41
+ if suffix not in {".csv", ".parquet"}:
42
+ raise ValueError("Only CSV and Parquet files are supported.")
43
+ if isinstance(source, bytes):
44
+ if len(source) > settings.max_upload_mb * 1024 * 1024:
45
+ raise ValueError(f"File exceeds the {settings.max_upload_mb} MB upload limit.")
46
+ stream: BinaryIO | str | Path = io.BytesIO(source)
47
+ else:
48
+ stream = source
49
+ frame = pd.read_csv(stream) if suffix == ".csv" else pd.read_parquet(stream)
50
+ validate_shape(frame, settings)
51
+ frame.columns = _unique_columns([str(column).strip() for column in frame.columns])
52
+ return frame
53
+
54
+
55
+ def validate_shape(frame: pd.DataFrame, settings: Settings) -> None:
56
+ if frame.empty:
57
+ raise ValueError("The dataset is empty.")
58
+ if len(frame) > settings.max_rows:
59
+ raise ValueError(f"Dataset has {len(frame):,} rows; limit is {settings.max_rows:,}.")
60
+ if len(frame.columns) > settings.max_columns:
61
+ raise ValueError(
62
+ f"Dataset has {len(frame.columns):,} columns; limit is {settings.max_columns:,}."
63
+ )
64
+ if len(frame.columns) < 2:
65
+ raise ValueError("At least one feature and one target column are required.")
66
+
67
+
68
+ def _unique_columns(columns: list[str]) -> list[str]:
69
+ seen: dict[str, int] = {}
70
+ result: list[str] = []
71
+ for raw in columns:
72
+ name = raw or "unnamed"
73
+ count = seen.get(name, 0)
74
+ seen[name] = count + 1
75
+ result.append(name if count == 0 else f"{name}_{count}")
76
+ return result
77
+
78
+
79
+ def duckdb_overview(frame: pd.DataFrame) -> dict[str, object]:
80
+ connection = duckdb.connect(database=":memory:")
81
+ try:
82
+ connection.register("dataset", frame)
83
+ row = connection.execute(
84
+ """
85
+ SELECT
86
+ COUNT(*) AS row_count
87
+ FROM dataset
88
+ """
89
+ ).fetchone()
90
+ numeric = frame.select_dtypes(include="number")
91
+ correlations = (
92
+ numeric.corr(numeric_only=True).round(4).fillna(0).to_dict()
93
+ if len(numeric.columns) > 1
94
+ else {}
95
+ )
96
+ return {
97
+ "row_count": int(row[0]),
98
+ "duplicate_rows": int(frame.duplicated().sum()),
99
+ "correlations": correlations,
100
+ }
101
+ finally:
102
+ connection.close()
datapilot/evaluation.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic quality gates for evidence-grounded LLM narratives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Iterable
7
+ from dataclasses import asdict, dataclass
8
+
9
+ EVIDENCE_ID = re.compile(r"\bEVD-[A-Z0-9-]+\b")
10
+ NUMBER = re.compile(r"(?<![A-Za-z])[-+]?\d+(?:\.\d+)?%?")
11
+ PII = re.compile(r"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}|(?:\+?\d[\d .()-]{7,}\d)", re.I)
12
+ INJECTION = re.compile(
13
+ r"ignore (?:all |the )?(?:previous|prior) instructions|system prompt|developer message",
14
+ re.I,
15
+ )
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class NarrativeEvaluation:
20
+ supported_evidence_ids: bool
21
+ unsupported_numbers: list[str]
22
+ leaked_pii: bool
23
+ prompt_injection_echo: bool
24
+ score: float
25
+
26
+ def to_dict(self) -> dict[str, object]:
27
+ return asdict(self)
28
+
29
+
30
+ def evaluate_narrative(
31
+ output: str,
32
+ allowed_evidence_ids: Iterable[str],
33
+ allowed_numbers: Iterable[str | float | int],
34
+ ) -> NarrativeEvaluation:
35
+ """Score an AI narrative for evidence citations, numeric faithfulness, and safety."""
36
+ allowed_ids = set(allowed_evidence_ids)
37
+ cited = set(EVIDENCE_ID.findall(output))
38
+ supported_ids = cited.issubset(allowed_ids) and bool(cited)
39
+ allowed_numeric = {_normalize_number(str(value)) for value in allowed_numbers}
40
+ narrative_without_ids = EVIDENCE_ID.sub("", output)
41
+ found = {_normalize_number(value) for value in NUMBER.findall(narrative_without_ids)}
42
+ unsupported = sorted(value for value in found if value not in allowed_numeric)
43
+ leaked_pii = bool(PII.search(output))
44
+ injection = bool(INJECTION.search(output))
45
+ penalties = (0 if supported_ids else 0.35) + min(0.35, len(unsupported) * 0.1)
46
+ penalties += 0.2 if leaked_pii else 0
47
+ penalties += 0.1 if injection else 0
48
+ return NarrativeEvaluation(
49
+ supported_evidence_ids=supported_ids,
50
+ unsupported_numbers=unsupported,
51
+ leaked_pii=leaked_pii,
52
+ prompt_injection_echo=injection,
53
+ score=round(max(0.0, 1.0 - penalties), 3),
54
+ )
55
+
56
+
57
+ def _normalize_number(value: str) -> str:
58
+ return value.replace(",", "").rstrip("%").lstrip("+")
datapilot/insights.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from datapilot.config import Settings
7
+ from datapilot.schemas import Evidence
8
+
9
+
10
+ def deterministic_insights(state: dict[str, Any]) -> tuple[list[str], list[str]]:
11
+ profile = state["profile"]
12
+ best = state["model_bundle"].results[0]
13
+ quality = state["quality_issues"]
14
+ explainability = state["explainability"]
15
+ important = list(explainability.feature_importance)[:3]
16
+ summary = [
17
+ (
18
+ f"The analysis used {profile.rows:,} rows and {profile.columns:,} columns for a "
19
+ f"{profile.task_type.value} task targeting '{profile.target}'."
20
+ ),
21
+ (
22
+ f"{best.name} ranked first with training CV {best.primary_metric} {best.primary_score:.3f}; "
23
+ f"its one-time test score was {best.final_test_score:.3f}."
24
+ ),
25
+ (
26
+ f"{len(quality)} data-quality observations were recorded; "
27
+ f"{sum(issue.severity.value == 'critical' for issue in quality)} are critical."
28
+ ),
29
+ ]
30
+ if important:
31
+ summary.append(
32
+ f"The strongest predictive signals were {', '.join(important)} "
33
+ f"according to {explainability.method.lower()}."
34
+ )
35
+ recommendations = [
36
+ "Validate performance on fresh, out-of-time data before production deployment.",
37
+ "Review suspected leakage and identifier columns with a domain owner.",
38
+ "Monitor input drift and the primary metric after deployment.",
39
+ ]
40
+ if profile.missing_rate > 0.1:
41
+ recommendations.insert(0, "Investigate upstream causes of missing data before retraining.")
42
+ return summary, recommendations
43
+
44
+
45
+ def optional_llm_narrative(
46
+ state: dict[str, Any], evidence: list[Evidence], settings: Settings
47
+ ) -> list[str] | None:
48
+ """Generate narrative only from bounded evidence; calculations remain deterministic."""
49
+ if not settings.gemini_api_key:
50
+ return None
51
+ try:
52
+ from google import genai
53
+
54
+ client = genai.Client(api_key=settings.gemini_api_key)
55
+ payload = {
56
+ "profile": state["profile"].model_dump(),
57
+ "best_model": state["model_bundle"].results[0].model_dump(),
58
+ "critic": state["critic"].model_dump(),
59
+ "evidence": [item.model_dump() for item in evidence[:25]],
60
+ }
61
+ prompt = (
62
+ "You are a senior data scientist. Return exactly four concise markdown bullet points. "
63
+ "Use only the JSON evidence below. Cite supporting evidence IDs in square brackets. "
64
+ "Do not add numbers, causal claims, or facts absent from the payload.\n"
65
+ + json.dumps(payload, default=str)
66
+ )
67
+ response = client.models.generate_content(model=settings.gemini_model, contents=prompt)
68
+ lines = [line.strip("- ").strip() for line in response.text.splitlines() if line.strip()]
69
+ return lines[:4] or None
70
+ except Exception:
71
+ return None
72
+
73
+
74
+ def answer_follow_up(run: dict[str, Any], question: str) -> str:
75
+ lowered = question.lower()
76
+ if any(token in lowered for token in {"best model", "which model", "winner"}):
77
+ top = run["model_results"][0]
78
+ return (
79
+ f"The best model was **{top['name']}**, with {top['primary_metric']} "
80
+ f"training-CV **{top['selection_score']:.3f}** and one-time test "
81
+ f"**{top['final_test_score']:.3f}**."
82
+ )
83
+ if any(token in lowered for token in {"feature", "important", "driver"}):
84
+ importance = run["explainability"]["feature_importance"]
85
+ top = list(importance.items())[:5]
86
+ return (
87
+ "Top predictive features: "
88
+ + ", ".join(f"**{name}** ({value:.4f})" for name, value in top)
89
+ + ". These are associations, not causal effects."
90
+ )
91
+ if any(token in lowered for token in {"quality", "missing", "leak", "risk"}):
92
+ issues = run["quality_issues"]
93
+ if not issues:
94
+ return "No material quality flags were detected by the configured checks."
95
+ return "Quality observations: " + "; ".join(item["message"] for item in issues[:6])
96
+ if any(token in lowered for token in {"metric", "performance", "score"}):
97
+ top = run["model_results"][0]
98
+ formatted = ", ".join(
99
+ f"{key}={value:.3f}" for key, value in top["final_test_metrics"].items()
100
+ )
101
+ return f"Selected-model one-time test metrics: {formatted}."
102
+ return (
103
+ "I can answer evidence-backed questions about the best model, performance metrics, "
104
+ "data quality, leakage risk, and feature importance for this run."
105
+ )
datapilot/jobs.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Bounded asynchronous analysis jobs with a production-queue compatible contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ from collections.abc import Callable
7
+ from concurrent.futures import Future, ThreadPoolExecutor
8
+ from dataclasses import dataclass, field
9
+ from datetime import UTC, datetime
10
+ from typing import Any
11
+ from uuid import uuid4
12
+
13
+
14
+ @dataclass
15
+ class Job:
16
+ job_id: str
17
+ status: str = "queued"
18
+ progress: int = 0
19
+ created_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
20
+ updated_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
21
+ result: Any | None = None
22
+ error: str | None = None
23
+ future: Future[Any] | None = field(default=None, repr=False)
24
+
25
+ def public(self) -> dict[str, Any]:
26
+ return {
27
+ "job_id": self.job_id,
28
+ "status": self.status,
29
+ "progress": self.progress,
30
+ "created_at": self.created_at,
31
+ "updated_at": self.updated_at,
32
+ "result": self.result if self.status == "completed" else None,
33
+ "error": self.error,
34
+ }
35
+
36
+
37
+ class JobManager:
38
+ """In-process development queue; replaceable by ARQ/Celery behind the same API."""
39
+
40
+ def __init__(self, workers: int = 2) -> None:
41
+ self._executor = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="datapilot")
42
+ self._jobs: dict[str, Job] = {}
43
+ self._lock = threading.Lock()
44
+
45
+ def submit(self, operation: Callable[[], Any]) -> Job:
46
+ job = Job(job_id=f"job_{uuid4().hex[:12]}")
47
+ with self._lock:
48
+ self._jobs[job.job_id] = job
49
+ job.future = self._executor.submit(self._run, job, operation)
50
+ return job
51
+
52
+ def get(self, job_id: str) -> Job | None:
53
+ with self._lock:
54
+ return self._jobs.get(job_id)
55
+
56
+ def cancel(self, job_id: str) -> bool:
57
+ job = self.get(job_id)
58
+ if job is None or job.future is None or job.status not in {"queued", "running"}:
59
+ return False
60
+ cancelled = job.future.cancel()
61
+ if cancelled:
62
+ job.status, job.progress = "cancelled", 0
63
+ job.updated_at = datetime.now(UTC).isoformat()
64
+ return cancelled
65
+
66
+ @staticmethod
67
+ def _run(job: Job, operation: Callable[[], Any]) -> None:
68
+ job.status, job.progress = "running", 10
69
+ job.updated_at = datetime.now(UTC).isoformat()
70
+ try:
71
+ value = operation()
72
+ job.result = value.model_dump(mode="json") if hasattr(value, "model_dump") else value
73
+ job.status, job.progress = "completed", 100
74
+ except Exception:
75
+ job.status, job.progress = "failed", 100
76
+ job.error = "Analysis failed. Use the correlation ID in server logs for support."
77
+ finally:
78
+ job.updated_at = datetime.now(UTC).isoformat()
datapilot/modeling.py ADDED
@@ -0,0 +1,389 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import time
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+ from sklearn.base import BaseEstimator
11
+ from sklearn.compose import ColumnTransformer
12
+ from sklearn.ensemble import (
13
+ ExtraTreesClassifier,
14
+ ExtraTreesRegressor,
15
+ RandomForestClassifier,
16
+ RandomForestRegressor,
17
+ )
18
+ from sklearn.impute import SimpleImputer
19
+ from sklearn.inspection import permutation_importance
20
+ from sklearn.linear_model import LinearRegression, LogisticRegression
21
+ from sklearn.metrics import (
22
+ accuracy_score,
23
+ balanced_accuracy_score,
24
+ f1_score,
25
+ mean_absolute_error,
26
+ mean_squared_error,
27
+ r2_score,
28
+ roc_auc_score,
29
+ )
30
+ from sklearn.model_selection import cross_val_score, train_test_split
31
+ from sklearn.pipeline import Pipeline
32
+ from sklearn.preprocessing import OneHotEncoder, StandardScaler
33
+
34
+ from datapilot.config import Settings
35
+ from datapilot.schemas import (
36
+ CriticDecision,
37
+ ExplainabilityResult,
38
+ ModelFailure,
39
+ ModelResult,
40
+ TaskType,
41
+ )
42
+ from datapilot.tuning import tune_random_forest
43
+
44
+
45
+ @dataclass
46
+ class TrainingBundle:
47
+ pipeline: Pipeline
48
+ results: list[ModelResult]
49
+ best_model: str
50
+ test_features: pd.DataFrame
51
+ test_target: pd.Series
52
+ retry_number: int
53
+ failures: list[ModelFailure]
54
+
55
+
56
+ logger = logging.getLogger(__name__)
57
+
58
+
59
+ def _preprocessor(features: pd.DataFrame, settings: Settings) -> ColumnTransformer:
60
+ numeric = features.select_dtypes(include=np.number).columns.tolist()
61
+ categorical = [column for column in features.columns if column not in numeric]
62
+ numeric_pipeline = Pipeline(
63
+ [
64
+ ("imputer", SimpleImputer(strategy="median")),
65
+ ("scaler", StandardScaler()),
66
+ ]
67
+ )
68
+ categorical_pipeline = Pipeline(
69
+ [
70
+ ("imputer", SimpleImputer(strategy="most_frequent")),
71
+ (
72
+ "encoder",
73
+ OneHotEncoder(
74
+ handle_unknown="infrequent_if_exist",
75
+ min_frequency=2,
76
+ max_categories=settings.max_categories_per_feature,
77
+ sparse_output=True,
78
+ ),
79
+ ),
80
+ ]
81
+ )
82
+ return ColumnTransformer(
83
+ [
84
+ ("numeric", numeric_pipeline, numeric),
85
+ ("categorical", categorical_pipeline, categorical),
86
+ ],
87
+ remainder="drop",
88
+ verbose_feature_names_out=False,
89
+ )
90
+
91
+
92
+ def _candidate_models(task: TaskType, settings: Settings, retry: int) -> dict[str, BaseEstimator]:
93
+ if task == TaskType.classification:
94
+ models: dict[str, BaseEstimator] = {
95
+ "Logistic Regression": LogisticRegression(
96
+ max_iter=1_000, class_weight="balanced", random_state=settings.random_state
97
+ ),
98
+ "Random Forest": RandomForestClassifier(
99
+ n_estimators=180 + retry * 80,
100
+ min_samples_leaf=max(1, 2 - retry),
101
+ class_weight="balanced",
102
+ n_jobs=-1,
103
+ random_state=settings.random_state,
104
+ ),
105
+ "Extra Trees": ExtraTreesClassifier(
106
+ n_estimators=180 + retry * 80,
107
+ class_weight="balanced",
108
+ n_jobs=-1,
109
+ random_state=settings.random_state,
110
+ ),
111
+ }
112
+ else:
113
+ models = {
114
+ "Linear Regression": LinearRegression(),
115
+ "Random Forest": RandomForestRegressor(
116
+ n_estimators=180 + retry * 80,
117
+ min_samples_leaf=max(1, 2 - retry),
118
+ n_jobs=-1,
119
+ random_state=settings.random_state,
120
+ ),
121
+ "Extra Trees": ExtraTreesRegressor(
122
+ n_estimators=180 + retry * 80,
123
+ n_jobs=-1,
124
+ random_state=settings.random_state,
125
+ ),
126
+ }
127
+ try:
128
+ if task == TaskType.classification:
129
+ from xgboost import XGBClassifier
130
+
131
+ models["XGBoost"] = XGBClassifier(
132
+ n_estimators=160 + retry * 60,
133
+ max_depth=4 + retry,
134
+ learning_rate=0.07,
135
+ eval_metric="logloss",
136
+ n_jobs=-1,
137
+ random_state=settings.random_state,
138
+ )
139
+ else:
140
+ from xgboost import XGBRegressor
141
+
142
+ models["XGBoost"] = XGBRegressor(
143
+ n_estimators=160 + retry * 60,
144
+ max_depth=4 + retry,
145
+ learning_rate=0.07,
146
+ n_jobs=-1,
147
+ random_state=settings.random_state,
148
+ )
149
+ except ImportError:
150
+ pass
151
+ return models
152
+
153
+
154
+ def train_models(
155
+ frame: pd.DataFrame,
156
+ target: str,
157
+ task: TaskType,
158
+ settings: Settings,
159
+ retry_number: int = 0,
160
+ ) -> TrainingBundle:
161
+ if target not in frame.columns:
162
+ raise ValueError(f"Target column '{target}' does not exist.")
163
+ clean = frame.dropna(subset=[target]).drop_duplicates().reset_index(drop=True)
164
+ features = clean.drop(columns=[target]).copy()
165
+ labels = clean[target].copy()
166
+ if features.empty:
167
+ raise ValueError("No usable feature columns remain after removing the target.")
168
+ if labels.nunique(dropna=True) < 2:
169
+ raise ValueError("The target must contain at least two distinct non-null values.")
170
+ categorical = features.select_dtypes(exclude=np.number)
171
+ estimated_width = len(features.select_dtypes(include=np.number).columns) + sum(
172
+ min(int(categorical[column].nunique(dropna=True)), settings.max_categories_per_feature)
173
+ for column in categorical.columns
174
+ )
175
+ if estimated_width > settings.max_encoded_features:
176
+ raise ValueError(
177
+ f"Estimated encoded width {estimated_width:,} exceeds the safe limit of "
178
+ f"{settings.max_encoded_features:,}. Reduce high-cardinality columns or increase "
179
+ "MAX_ENCODED_FEATURES after reviewing memory capacity."
180
+ )
181
+ stratify = (
182
+ labels if task == TaskType.classification and labels.value_counts().min() >= 2 else None
183
+ )
184
+ x_train, x_test, y_train, y_test = train_test_split(
185
+ features,
186
+ labels,
187
+ test_size=settings.test_size,
188
+ random_state=settings.random_state,
189
+ stratify=stratify,
190
+ )
191
+ results: list[ModelResult] = []
192
+ failures: list[ModelFailure] = []
193
+ cv = min(5, max(2, int(len(x_train) / 20)))
194
+ if task == TaskType.classification:
195
+ smallest_class = int(y_train.value_counts().min())
196
+ cv = min(cv, smallest_class) if smallest_class >= 2 else 0
197
+ scoring = "balanced_accuracy"
198
+ else:
199
+ scoring = "r2"
200
+ if cv < 2:
201
+ raise ValueError("The training partition is too small for reliable cross-validation.")
202
+
203
+ candidates = _candidate_models(task, settings, retry_number)
204
+ tuned_parameters = tune_random_forest(
205
+ task,
206
+ _preprocessor(x_train, settings),
207
+ x_train,
208
+ y_train,
209
+ settings,
210
+ cv,
211
+ )
212
+ if tuned_parameters:
213
+ candidates["Random Forest"].set_params(**tuned_parameters)
214
+
215
+ for name, estimator in candidates.items():
216
+ pipeline = Pipeline(
217
+ [("preprocessor", _preprocessor(x_train, settings)), ("model", estimator)]
218
+ )
219
+ started = time.perf_counter()
220
+ try:
221
+ cv_scores = (
222
+ cross_val_score(pipeline, x_train, y_train, scoring=scoring, cv=cv, n_jobs=1)
223
+ if cv >= 2
224
+ else np.array([])
225
+ )
226
+ result = ModelResult(
227
+ name=name,
228
+ primary_metric="balanced_accuracy" if task == TaskType.classification else "r2",
229
+ primary_score=float(cv_scores.mean()),
230
+ metrics={},
231
+ cross_validation_mean=float(cv_scores.mean()) if len(cv_scores) else None,
232
+ cross_validation_std=float(cv_scores.std()) if len(cv_scores) else None,
233
+ training_seconds=round(time.perf_counter() - started, 3),
234
+ selection_score=float(cv_scores.mean()) if len(cv_scores) else None,
235
+ )
236
+ results.append(result)
237
+ except Exception as exc:
238
+ logger.warning(
239
+ "Candidate %s failed during cross-validation: %s", name, type(exc).__name__
240
+ )
241
+ failures.append(
242
+ ModelFailure(
243
+ name=name,
244
+ stage="cross_validation",
245
+ exception_category=type(exc).__name__,
246
+ sanitized_error="Candidate failed during cross-validation; inspect structured logs.",
247
+ training_seconds=round(time.perf_counter() - started, 3),
248
+ expected=isinstance(exc, (ValueError, TypeError)),
249
+ )
250
+ )
251
+ if not results:
252
+ raise RuntimeError("Every candidate model failed; review the data types and target.")
253
+ results.sort(key=lambda item: item.selection_score or float("-inf"), reverse=True)
254
+ best = results[0]
255
+ final_pipeline = Pipeline(
256
+ [
257
+ ("preprocessor", _preprocessor(x_train, settings)),
258
+ ("model", candidates[best.name]),
259
+ ]
260
+ )
261
+ final_pipeline.fit(x_train, y_train)
262
+ predictions = final_pipeline.predict(x_test)
263
+ final_metrics = _metrics(task, y_test, predictions, final_pipeline, x_test)
264
+ final_score = (
265
+ final_metrics["balanced_accuracy"]
266
+ if task == TaskType.classification
267
+ else final_metrics["r2"]
268
+ )
269
+ best.final_test_score = float(final_score)
270
+ best.final_test_metrics = final_metrics
271
+ best.metrics = final_metrics
272
+ return TrainingBundle(
273
+ pipeline=final_pipeline,
274
+ results=results,
275
+ best_model=best.name,
276
+ test_features=x_test,
277
+ test_target=y_test,
278
+ retry_number=retry_number,
279
+ failures=failures,
280
+ )
281
+
282
+
283
+ def _metrics(
284
+ task: TaskType,
285
+ truth: pd.Series,
286
+ predictions: np.ndarray,
287
+ pipeline: Pipeline,
288
+ features: pd.DataFrame,
289
+ ) -> dict[str, float]:
290
+ if task == TaskType.classification:
291
+ metrics = {
292
+ "accuracy": round(float(accuracy_score(truth, predictions)), 4),
293
+ "balanced_accuracy": round(float(balanced_accuracy_score(truth, predictions)), 4),
294
+ "f1_weighted": round(float(f1_score(truth, predictions, average="weighted")), 4),
295
+ }
296
+ if truth.nunique() == 2 and hasattr(pipeline, "predict_proba"):
297
+ probabilities = pipeline.predict_proba(features)[:, 1]
298
+ metrics["roc_auc"] = round(float(roc_auc_score(truth, probabilities)), 4)
299
+ return metrics
300
+ return {
301
+ "r2": round(float(r2_score(truth, predictions)), 4),
302
+ "rmse": round(float(mean_squared_error(truth, predictions) ** 0.5), 4),
303
+ "mae": round(float(mean_absolute_error(truth, predictions)), 4),
304
+ }
305
+
306
+
307
+ def critic_decision(bundle: TrainingBundle, task: TaskType, settings: Settings) -> CriticDecision:
308
+ best = bundle.results[0]
309
+ threshold = (
310
+ settings.min_classification_score
311
+ if task == TaskType.classification
312
+ else settings.min_regression_score
313
+ )
314
+ reasons: list[str] = []
315
+ quality_score = best.cross_validation_mean if best.cross_validation_mean is not None else -1.0
316
+ if quality_score < threshold:
317
+ reasons.append(
318
+ f"Training CV {best.primary_metric} {quality_score:.3f} is below {threshold:.3f}."
319
+ )
320
+ if (
321
+ best.cross_validation_mean is not None
322
+ and best.final_test_score is not None
323
+ and abs(best.final_test_score - best.cross_validation_mean) > 0.2
324
+ ):
325
+ reasons.append("Holdout and cross-validation scores diverge by more than 0.20.")
326
+ approved = not reasons or bundle.retry_number >= settings.max_critic_retries
327
+ if not reasons:
328
+ reasons.append("Performance and validation consistency passed the configured quality gate.")
329
+ elif approved:
330
+ reasons.append("Retry budget exhausted; result is retained with an explicit limitation.")
331
+ return CriticDecision(
332
+ approved=approved,
333
+ score=quality_score,
334
+ threshold=threshold,
335
+ reasons=reasons,
336
+ retry_number=bundle.retry_number,
337
+ )
338
+
339
+
340
+ def explain_model(bundle: TrainingBundle) -> ExplainabilityResult:
341
+ sample_size = min(300, len(bundle.test_features))
342
+ features = bundle.test_features.iloc[:sample_size]
343
+ target = bundle.test_target.iloc[:sample_size]
344
+ try:
345
+ transformed = bundle.pipeline.named_steps["preprocessor"].transform(features)
346
+ transformed_names = bundle.pipeline.named_steps["preprocessor"].get_feature_names_out()
347
+ estimator = bundle.pipeline.named_steps["model"]
348
+ import shap
349
+
350
+ explainer = shap.Explainer(estimator, transformed)
351
+ values = explainer(transformed)
352
+ raw = np.asarray(values.values)
353
+ if raw.ndim == 3:
354
+ raw = np.abs(raw).mean(axis=(0, 2))
355
+ else:
356
+ raw = np.abs(raw).mean(axis=0)
357
+ importance = _top_importance(transformed_names, raw)
358
+ return ExplainabilityResult(
359
+ method="SHAP",
360
+ feature_importance=importance,
361
+ caveats=["SHAP values explain this fitted model, not causal effects."],
362
+ )
363
+ except Exception:
364
+ permutation = permutation_importance(
365
+ bundle.pipeline,
366
+ features,
367
+ target,
368
+ n_repeats=5,
369
+ random_state=42,
370
+ n_jobs=1,
371
+ )
372
+ importance = _top_importance(features.columns, np.abs(permutation.importances_mean))
373
+ return ExplainabilityResult(
374
+ method="Permutation importance",
375
+ feature_importance=importance,
376
+ caveats=[
377
+ "Permutation importance can dilute importance among correlated features.",
378
+ "Feature importance is predictive, not causal.",
379
+ ],
380
+ )
381
+
382
+
383
+ def _top_importance(names: Any, values: np.ndarray, limit: int = 15) -> dict[str, float]:
384
+ pairs = sorted(
385
+ zip([str(name) for name in names], values.tolist(), strict=False),
386
+ key=lambda item: item[1],
387
+ reverse=True,
388
+ )[:limit]
389
+ return {name: round(float(value), 6) for name, value in pairs}
datapilot/observability.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from datapilot.config import Settings
6
+
7
+
8
+ def log_to_mlflow(payload: dict[str, Any], settings: Settings) -> bool:
9
+ """Record a run when MLflow is enabled; local demos remain dependency-light."""
10
+ if not settings.enable_mlflow:
11
+ return False
12
+ try:
13
+ import mlflow
14
+
15
+ mlflow.set_tracking_uri(settings.mlflow_tracking_uri)
16
+ mlflow.set_experiment("datapilot-ai")
17
+ best = payload["model_results"][0]
18
+ with mlflow.start_run(run_name=payload["run_id"]):
19
+ mlflow.log_params(
20
+ {
21
+ "dataset": payload["dataset_name"],
22
+ "task_type": payload["profile"]["task_type"],
23
+ "target": payload["profile"]["target"],
24
+ "selected_model": payload["best_model"],
25
+ }
26
+ )
27
+ mlflow.log_metrics({key: float(value) for key, value in best["metrics"].items()})
28
+ return True
29
+ except Exception:
30
+ return False
datapilot/persistence.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import UTC, datetime
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from sqlalchemy import DateTime, String, Text, create_engine
9
+ from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker
10
+
11
+ from datapilot.config import Settings
12
+
13
+
14
+ class Base(DeclarativeBase):
15
+ pass
16
+
17
+
18
+ class AnalysisRun(Base):
19
+ __tablename__ = "analysis_runs"
20
+
21
+ run_id: Mapped[str] = mapped_column(String(64), primary_key=True)
22
+ dataset_name: Mapped[str] = mapped_column(String(255))
23
+ status: Mapped[str] = mapped_column(String(32), index=True)
24
+ created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
25
+ updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
26
+ payload_json: Mapped[str] = mapped_column(Text)
27
+
28
+
29
+ class RunStore:
30
+ """SQLite locally; set DATABASE_URL to PostgreSQL in production."""
31
+
32
+ def __init__(self, settings: Settings):
33
+ self.engine = create_engine(settings.database_url, future=True)
34
+ Base.metadata.create_all(self.engine)
35
+ self.sessions = sessionmaker(self.engine, expire_on_commit=False)
36
+
37
+ def save(self, run_id: str, dataset_name: str, status: str, payload: dict[str, Any]) -> None:
38
+ now = datetime.now(UTC)
39
+ with self.sessions.begin() as session:
40
+ record = session.get(AnalysisRun, run_id)
41
+ if record is None:
42
+ record = AnalysisRun(
43
+ run_id=run_id,
44
+ dataset_name=dataset_name,
45
+ status=status,
46
+ created_at=now,
47
+ updated_at=now,
48
+ payload_json=json.dumps(payload, default=str),
49
+ )
50
+ session.add(record)
51
+ else:
52
+ record.status = status
53
+ record.updated_at = now
54
+ record.payload_json = json.dumps(payload, default=str)
55
+
56
+ def get(self, run_id: str) -> dict[str, Any] | None:
57
+ with self.sessions() as session:
58
+ record = session.get(AnalysisRun, run_id)
59
+ return json.loads(record.payload_json) if record else None
60
+
61
+ def list_recent(self, limit: int = 20) -> list[dict[str, Any]]:
62
+ from sqlalchemy import select
63
+
64
+ with self.sessions() as session:
65
+ records = session.scalars(
66
+ select(AnalysisRun).order_by(AnalysisRun.created_at.desc()).limit(limit)
67
+ )
68
+ return [
69
+ {
70
+ "run_id": record.run_id,
71
+ "dataset_name": record.dataset_name,
72
+ "status": record.status,
73
+ "created_at": record.created_at.isoformat(),
74
+ }
75
+ for record in records
76
+ ]
77
+
78
+
79
+ class ArtifactStore:
80
+ """Local artifact storage with an interface that can be replaced by S3/MinIO."""
81
+
82
+ def __init__(self, root: Path):
83
+ self.root = root
84
+ self.root.mkdir(parents=True, exist_ok=True)
85
+
86
+ def run_directory(self, run_id: str) -> Path:
87
+ directory = (self.root / run_id).resolve()
88
+ if self.root.resolve() not in directory.parents:
89
+ raise ValueError("Invalid run identifier.")
90
+ directory.mkdir(parents=True, exist_ok=True)
91
+ return directory
datapilot/quality.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from uuid import uuid4
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+
9
+ from datapilot.schemas import (
10
+ DatasetProfile,
11
+ Evidence,
12
+ QualityIssue,
13
+ Severity,
14
+ TaskType,
15
+ )
16
+
17
+ LEAKAGE_PATTERNS = re.compile(
18
+ r"(target|label|outcome|result|prediction|predicted|probability|score)$",
19
+ re.IGNORECASE,
20
+ )
21
+
22
+
23
+ def infer_task_type(target: pd.Series) -> TaskType:
24
+ unique = int(target.nunique(dropna=True))
25
+ if (
26
+ not pd.api.types.is_numeric_dtype(target)
27
+ or pd.api.types.is_bool_dtype(target)
28
+ or unique <= 20
29
+ or unique / max(len(target), 1) < 0.05
30
+ ):
31
+ return TaskType.classification
32
+ return TaskType.regression
33
+
34
+
35
+ def build_profile(frame: pd.DataFrame, target: str) -> DatasetProfile:
36
+ if target not in frame.columns:
37
+ raise ValueError(f"Target column '{target}' is not present.")
38
+ numeric = frame.select_dtypes(include=np.number).columns.tolist()
39
+ categorical = frame.select_dtypes(include=["object", "category", "bool"]).columns.tolist()
40
+ datetime = frame.select_dtypes(include=["datetime", "datetimetz"]).columns.tolist()
41
+ missing_cells = int(frame.isna().sum().sum())
42
+ return DatasetProfile(
43
+ rows=len(frame),
44
+ columns=len(frame.columns),
45
+ numeric_columns=numeric,
46
+ categorical_columns=categorical,
47
+ datetime_columns=datetime,
48
+ duplicate_rows=int(frame.duplicated().sum()),
49
+ missing_cells=missing_cells,
50
+ missing_rate=round(missing_cells / max(frame.size, 1), 4),
51
+ memory_mb=round(frame.memory_usage(deep=True).sum() / 1_048_576, 3),
52
+ target=target,
53
+ task_type=infer_task_type(frame[target]),
54
+ target_cardinality=int(frame[target].nunique(dropna=True)),
55
+ )
56
+
57
+
58
+ def audit_quality(
59
+ frame: pd.DataFrame, profile: DatasetProfile
60
+ ) -> tuple[list[QualityIssue], list[Evidence]]:
61
+ issues: list[QualityIssue] = []
62
+ evidence: list[Evidence] = []
63
+
64
+ def add_evidence(claim: str, metric: str, value: object, source: str, method: str) -> str:
65
+ evidence_id = f"EV-{uuid4().hex[:8].upper()}"
66
+ evidence.append(
67
+ Evidence(
68
+ evidence_id=evidence_id,
69
+ claim=claim,
70
+ metric=metric,
71
+ value=value,
72
+ source=source,
73
+ method=method,
74
+ )
75
+ )
76
+ return evidence_id
77
+
78
+ missing_id = add_evidence(
79
+ "Dataset missingness was measured across all cells.",
80
+ "missing_rate",
81
+ profile.missing_rate,
82
+ "uploaded_dataset",
83
+ "pandas.isna",
84
+ )
85
+ if profile.missing_rate > 0.2:
86
+ issues.append(
87
+ QualityIssue(
88
+ code="HIGH_MISSINGNESS",
89
+ severity=Severity.critical,
90
+ message=f"{profile.missing_rate:.1%} of dataset cells are missing.",
91
+ evidence_ids=[missing_id],
92
+ )
93
+ )
94
+ elif profile.missing_rate > 0:
95
+ issues.append(
96
+ QualityIssue(
97
+ code="MISSING_VALUES",
98
+ severity=Severity.warning,
99
+ message=f"{profile.missing_rate:.1%} of dataset cells are missing.",
100
+ evidence_ids=[missing_id],
101
+ )
102
+ )
103
+
104
+ duplicate_id = add_evidence(
105
+ "Exact duplicate rows were counted before splitting.",
106
+ "duplicate_rows",
107
+ profile.duplicate_rows,
108
+ "uploaded_dataset",
109
+ "pandas.duplicated",
110
+ )
111
+ if profile.duplicate_rows:
112
+ issues.append(
113
+ QualityIssue(
114
+ code="DUPLICATE_ROWS",
115
+ severity=Severity.warning,
116
+ message=f"{profile.duplicate_rows:,} exact duplicate rows can bias validation.",
117
+ evidence_ids=[duplicate_id],
118
+ )
119
+ )
120
+
121
+ target = frame[profile.target]
122
+ target_missing = int(target.isna().sum())
123
+ target_missing_id = add_evidence(
124
+ "Rows with missing labels cannot be used for supervised training.",
125
+ "missing_target_rows",
126
+ target_missing,
127
+ f"column:{profile.target}",
128
+ "pandas.isna",
129
+ )
130
+ if target_missing:
131
+ issues.append(
132
+ QualityIssue(
133
+ code="MISSING_TARGET",
134
+ severity=Severity.critical,
135
+ column=profile.target,
136
+ message=f"{target_missing:,} rows have no target value and will be excluded.",
137
+ evidence_ids=[target_missing_id],
138
+ )
139
+ )
140
+
141
+ if profile.task_type == TaskType.classification:
142
+ distribution = target.value_counts(normalize=True, dropna=True)
143
+ minority_share = float(distribution.min()) if not distribution.empty else 0.0
144
+ imbalance_id = add_evidence(
145
+ "Class imbalance was measured using the minority-class share.",
146
+ "minority_class_share",
147
+ round(minority_share, 4),
148
+ f"column:{profile.target}",
149
+ "normalized value counts",
150
+ )
151
+ if minority_share < 0.1:
152
+ issues.append(
153
+ QualityIssue(
154
+ code="CLASS_IMBALANCE",
155
+ severity=Severity.warning,
156
+ column=profile.target,
157
+ message=f"Minority class represents only {minority_share:.1%} of labeled rows.",
158
+ evidence_ids=[imbalance_id],
159
+ )
160
+ )
161
+
162
+ feature_frame = frame.drop(columns=[profile.target])
163
+ for column in feature_frame.columns:
164
+ normalized = column.strip().lower()
165
+ leakage_risk = bool(LEAKAGE_PATTERNS.search(normalized))
166
+ if feature_frame[column].nunique(dropna=True) == len(feature_frame):
167
+ leakage_risk = leakage_risk or normalized.endswith(("_id", "id"))
168
+ if leakage_risk:
169
+ evidence_id = add_evidence(
170
+ "A feature name or cardinality pattern may reveal the target or row identity.",
171
+ "suspected_leakage_feature",
172
+ column,
173
+ f"column:{column}",
174
+ "name and cardinality heuristic",
175
+ )
176
+ issues.append(
177
+ QualityIssue(
178
+ code="LEAKAGE_RISK",
179
+ severity=Severity.warning,
180
+ column=column,
181
+ message=f"'{column}' may leak target or row identity; review before deployment.",
182
+ evidence_ids=[evidence_id],
183
+ )
184
+ )
185
+
186
+ numeric = feature_frame.select_dtypes(include=np.number)
187
+ for column in numeric.columns:
188
+ series = numeric[column].dropna()
189
+ if len(series) < 8:
190
+ continue
191
+ q1, q3 = series.quantile([0.25, 0.75])
192
+ iqr = q3 - q1
193
+ if iqr == 0:
194
+ continue
195
+ outlier_rate = float(((series < q1 - 1.5 * iqr) | (series > q3 + 1.5 * iqr)).mean())
196
+ if outlier_rate > 0.05:
197
+ evidence_id = add_evidence(
198
+ "Potential outliers were detected with the 1.5×IQR rule.",
199
+ "outlier_rate",
200
+ round(outlier_rate, 4),
201
+ f"column:{column}",
202
+ "Tukey IQR",
203
+ )
204
+ issues.append(
205
+ QualityIssue(
206
+ code="OUTLIER_RATE",
207
+ severity=Severity.info,
208
+ column=column,
209
+ message=f"'{column}' has {outlier_rate:.1%} potential outliers.",
210
+ evidence_ids=[evidence_id],
211
+ )
212
+ )
213
+
214
+ return issues, evidence
215
+
216
+
217
+ def drift_report(reference: pd.DataFrame, current: pd.DataFrame) -> list[dict[str, object]]:
218
+ """Population stability index for numeric columns shared by two datasets."""
219
+ reports: list[dict[str, object]] = []
220
+ shared = reference.select_dtypes(include=np.number).columns.intersection(
221
+ current.select_dtypes(include=np.number).columns
222
+ )
223
+ for column in shared:
224
+ baseline = reference[column].dropna()
225
+ observed = current[column].dropna()
226
+ if baseline.nunique() < 2 or observed.empty:
227
+ continue
228
+ edges = np.unique(baseline.quantile(np.linspace(0, 1, 11)).to_numpy())
229
+ if len(edges) < 3:
230
+ continue
231
+ expected_counts, _ = np.histogram(baseline, bins=edges)
232
+ actual_counts, _ = np.histogram(observed, bins=edges)
233
+ expected = np.clip(expected_counts / max(expected_counts.sum(), 1), 1e-6, None)
234
+ actual = np.clip(actual_counts / max(actual_counts.sum(), 1), 1e-6, None)
235
+ psi = float(np.sum((actual - expected) * np.log(actual / expected)))
236
+ reports.append(
237
+ {
238
+ "column": column,
239
+ "psi": round(psi, 4),
240
+ "status": "high" if psi >= 0.25 else "moderate" if psi >= 0.1 else "stable",
241
+ }
242
+ )
243
+ return reports
datapilot/reports.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import html
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import joblib
9
+
10
+
11
+ def export_artifacts(
12
+ run_id: str,
13
+ state: dict[str, Any],
14
+ run_directory: Path,
15
+ ) -> dict[str, str]:
16
+ run_directory.mkdir(parents=True, exist_ok=True)
17
+ bundle = state["model_bundle"]
18
+ summary = state["summary_payload"]
19
+
20
+ pipeline_path = run_directory / "model_pipeline.joblib"
21
+ joblib.dump(bundle.pipeline, pipeline_path)
22
+
23
+ metrics_path = run_directory / "metrics.json"
24
+ metrics_path.write_text(json.dumps(summary, indent=2, default=str), encoding="utf-8")
25
+
26
+ model_card_path = run_directory / "MODEL_CARD.md"
27
+ model_card_path.write_text(_model_card(summary), encoding="utf-8")
28
+
29
+ report_path = run_directory / "analysis_report.html"
30
+ report_path.write_text(_html_report(summary), encoding="utf-8")
31
+
32
+ requirements_path = run_directory / "reproduction.json"
33
+ requirements_path.write_text(
34
+ json.dumps(
35
+ {
36
+ "run_id": run_id,
37
+ "random_state": state["settings"].random_state,
38
+ "test_size": state["settings"].test_size,
39
+ "target": summary["profile"]["target"],
40
+ "task_type": summary["profile"]["task_type"],
41
+ "best_model": summary["best_model"],
42
+ },
43
+ indent=2,
44
+ ),
45
+ encoding="utf-8",
46
+ )
47
+ return {
48
+ "pipeline": str(pipeline_path),
49
+ "metrics": str(metrics_path),
50
+ "model_card": str(model_card_path),
51
+ "report": str(report_path),
52
+ "reproduction": str(requirements_path),
53
+ }
54
+
55
+
56
+ def _model_card(run: dict[str, Any]) -> str:
57
+ best = run["model_results"][0]
58
+ profile = run["profile"]
59
+ issues = (
60
+ "\n".join(f"- {item['message']}" for item in run["quality_issues"]) or "- None detected"
61
+ )
62
+ return f"""# Model Card — {run["dataset_name"]}
63
+
64
+ ## Model details
65
+
66
+ - Run ID: `{run["run_id"]}`
67
+ - Task: {profile["task_type"]}
68
+ - Target: `{profile["target"]}`
69
+ - Selected model: **{run["best_model"]}**
70
+ - Training-CV selection metric: `{best["primary_metric"]} = {best["selection_score"]:.4f}`
71
+ - One-time untouched test metric: `{best["primary_metric"]} = {best["final_test_score"]:.4f}`
72
+ - Training rows before split: {profile["rows"]:,}
73
+
74
+ ## Intended use
75
+
76
+ Exploratory decision support and portfolio demonstration. Validate with domain-specific,
77
+ out-of-time data before any consequential or production use.
78
+
79
+ ## Evaluation
80
+
81
+ ```json
82
+ {json.dumps(best["final_test_metrics"], indent=2)}
83
+ ```
84
+
85
+ ## Data-quality observations
86
+
87
+ {issues}
88
+
89
+ ## Explainability
90
+
91
+ Method: **{run["explainability"]["method"]}**. Importance values are predictive associations,
92
+ not evidence of causation.
93
+
94
+ ## Limitations
95
+
96
+ - Results depend on the uploaded dataset and chosen target.
97
+ - Automated task inference can be wrong; a domain owner should confirm the objective.
98
+ - Fairness, privacy, and legal review are outside the automatic approval gate.
99
+ """
100
+
101
+
102
+ def _html_report(run: dict[str, Any]) -> str:
103
+ best = run["model_results"][0]
104
+ summary_items = "".join(f"<li>{html.escape(item)}</li>" for item in run["executive_summary"])
105
+ recommendations = "".join(f"<li>{html.escape(item)}</li>" for item in run["recommendations"])
106
+ issues = (
107
+ "".join(
108
+ f"<tr><td>{html.escape(item['severity'])}</td><td>{html.escape(item['code'])}</td>"
109
+ f"<td>{html.escape(item['message'])}</td></tr>"
110
+ for item in run["quality_issues"]
111
+ )
112
+ or "<tr><td colspan='3'>No material flags</td></tr>"
113
+ )
114
+ metrics = "".join(
115
+ f"<tr><td>{html.escape(name)}</td><td>{value:.4f}</td></tr>"
116
+ for name, value in best["final_test_metrics"].items()
117
+ )
118
+ return f"""<!doctype html>
119
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">
120
+ <title>DataPilot AI report</title>
121
+ <style>
122
+ body{{font-family:Inter,system-ui,sans-serif;max-width:1000px;margin:40px auto;padding:0 24px;color:#172033}}
123
+ h1{{color:#5537d8}} .hero{{background:#f4f1ff;border:1px solid #d9d0ff;padding:24px;border-radius:18px}}
124
+ .grid{{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px;margin:20px 0}}
125
+ .card{{border:1px solid #e2e6ef;border-radius:14px;padding:18px}} table{{border-collapse:collapse;width:100%}}
126
+ td,th{{border-bottom:1px solid #e2e6ef;padding:10px;text-align:left}} small{{color:#667085}}
127
+ </style></head><body>
128
+ <div class="hero"><h1>DataPilot AI Analysis Report</h1>
129
+ <p>{html.escape(run["dataset_name"])} · Run {html.escape(run["run_id"])}</p></div>
130
+ <div class="grid">
131
+ <div class="card"><small>Selected model</small><h2>{html.escape(run["best_model"])}</h2></div>
132
+ <div class="card"><small>Test {html.escape(best["primary_metric"])}</small><h2>{best["final_test_score"]:.3f}</h2></div>
133
+ <div class="card"><small>Rows analyzed</small><h2>{run["profile"]["rows"]:,}</h2></div>
134
+ </div>
135
+ <h2>Executive findings</h2><ul>{summary_items}</ul>
136
+ <h2>Evaluation</h2><table><tr><th>Metric</th><th>Value</th></tr>{metrics}</table>
137
+ <h2>Data quality</h2><table><tr><th>Severity</th><th>Code</th><th>Observation</th></tr>{issues}</table>
138
+ <h2>Recommendations</h2><ol>{recommendations}</ol>
139
+ <p><small>Generated from computed evidence. Predictive findings do not establish causality.</small></p>
140
+ </body></html>"""
datapilot/safety.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+
5
+ BLOCKED_NODES = (
6
+ ast.Import,
7
+ ast.ImportFrom,
8
+ ast.Global,
9
+ ast.Nonlocal,
10
+ ast.With,
11
+ ast.AsyncWith,
12
+ ast.Try,
13
+ ast.Raise,
14
+ ast.ClassDef,
15
+ ast.FunctionDef,
16
+ ast.AsyncFunctionDef,
17
+ )
18
+ BLOCKED_CALLS = {
19
+ "eval",
20
+ "exec",
21
+ "compile",
22
+ "open",
23
+ "input",
24
+ "__import__",
25
+ "breakpoint",
26
+ "getattr",
27
+ "setattr",
28
+ "delattr",
29
+ }
30
+
31
+
32
+ class UnsafeCodeError(ValueError):
33
+ pass
34
+
35
+
36
+ def validate_generated_expression(expression: str, max_characters: int = 2_000) -> ast.Expression:
37
+ """Validate a calculation expression before it is sent to an isolated worker.
38
+
39
+ DataPilot's primary workflow does not execute LLM-generated Python. This validator
40
+ exists for an optional restricted calculation worker and accepts expressions only.
41
+ """
42
+ if len(expression) > max_characters:
43
+ raise UnsafeCodeError("Expression exceeds the configured size limit.")
44
+ try:
45
+ tree = ast.parse(expression, mode="eval")
46
+ except SyntaxError as exc:
47
+ raise UnsafeCodeError("Expression is not valid Python.") from exc
48
+ for node in ast.walk(tree):
49
+ if isinstance(node, BLOCKED_NODES):
50
+ raise UnsafeCodeError(f"Blocked syntax: {type(node).__name__}.")
51
+ if isinstance(node, ast.Attribute) and node.attr.startswith("__"):
52
+ raise UnsafeCodeError("Dunder attribute access is blocked.")
53
+ if isinstance(node, ast.Name) and node.id.startswith("__"):
54
+ raise UnsafeCodeError("Dunder names are blocked.")
55
+ if isinstance(node, ast.Call):
56
+ if isinstance(node.func, ast.Name) and node.func.id in BLOCKED_CALLS:
57
+ raise UnsafeCodeError(f"Blocked call: {node.func.id}.")
58
+ if isinstance(node.func, ast.Attribute) and node.func.attr in BLOCKED_CALLS:
59
+ raise UnsafeCodeError(f"Blocked call: {node.func.attr}.")
60
+ return tree
datapilot/schemas.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from enum import StrEnum
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class TaskType(StrEnum):
10
+ classification = "classification"
11
+ regression = "regression"
12
+
13
+
14
+ class Severity(StrEnum):
15
+ info = "info"
16
+ warning = "warning"
17
+ critical = "critical"
18
+
19
+
20
+ class Evidence(BaseModel):
21
+ evidence_id: str
22
+ claim: str
23
+ metric: str
24
+ value: float | int | str
25
+ source: str
26
+ method: str
27
+
28
+
29
+ class QualityIssue(BaseModel):
30
+ code: str
31
+ severity: Severity
32
+ column: str | None = None
33
+ message: str
34
+ evidence_ids: list[str] = Field(default_factory=list)
35
+
36
+
37
+ class DatasetProfile(BaseModel):
38
+ rows: int
39
+ columns: int
40
+ numeric_columns: list[str]
41
+ categorical_columns: list[str]
42
+ datetime_columns: list[str]
43
+ duplicate_rows: int
44
+ missing_cells: int
45
+ missing_rate: float
46
+ memory_mb: float
47
+ target: str
48
+ task_type: TaskType
49
+ target_cardinality: int
50
+
51
+
52
+ class AnalysisPlan(BaseModel):
53
+ objective: str
54
+ target: str
55
+ task_type: TaskType
56
+ primary_metric: str
57
+ validation_strategy: str
58
+ candidate_models: list[str]
59
+ risk_controls: list[str]
60
+
61
+
62
+ class ModelResult(BaseModel):
63
+ name: str
64
+ primary_metric: str
65
+ primary_score: float
66
+ metrics: dict[str, float]
67
+ cross_validation_mean: float | None = None
68
+ cross_validation_std: float | None = None
69
+ training_seconds: float
70
+ selection_score: float | None = None
71
+ final_test_score: float | None = None
72
+ final_test_metrics: dict[str, float] = Field(default_factory=dict)
73
+
74
+
75
+ class ModelFailure(BaseModel):
76
+ name: str
77
+ stage: str
78
+ exception_category: str
79
+ sanitized_error: str
80
+ training_seconds: float
81
+ expected: bool = False
82
+
83
+
84
+ class CriticDecision(BaseModel):
85
+ approved: bool
86
+ score: float
87
+ threshold: float
88
+ reasons: list[str]
89
+ retry_number: int
90
+
91
+
92
+ class ExplainabilityResult(BaseModel):
93
+ method: str
94
+ feature_importance: dict[str, float]
95
+ caveats: list[str]
96
+
97
+
98
+ class RunSummary(BaseModel):
99
+ run_id: str
100
+ status: str
101
+ dataset_name: str
102
+ profile: DatasetProfile
103
+ plan: AnalysisPlan
104
+ quality_issues: list[QualityIssue]
105
+ evidence: list[Evidence]
106
+ model_results: list[ModelResult]
107
+ model_failures: list[ModelFailure] = Field(default_factory=list)
108
+ best_model: str
109
+ critic: CriticDecision
110
+ explainability: ExplainabilityResult
111
+ executive_summary: list[str]
112
+ recommendations: list[str]
113
+ artifacts: dict[str, str]
114
+ trace: list[dict[str, Any]]
datapilot/tuning.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import pandas as pd
6
+ from sklearn.ensemble import RandomForestClassifier, RandomForestRegressor
7
+ from sklearn.model_selection import cross_val_score
8
+ from sklearn.pipeline import Pipeline
9
+
10
+ from datapilot.config import Settings
11
+ from datapilot.schemas import TaskType
12
+
13
+
14
+ def tune_random_forest(
15
+ task: TaskType,
16
+ preprocessor: Any,
17
+ features: pd.DataFrame,
18
+ target: pd.Series,
19
+ settings: Settings,
20
+ cv: int,
21
+ ) -> dict[str, Any]:
22
+ """Run a bounded Optuna study when the optional AutoML extra is installed."""
23
+ if settings.optuna_trials <= 0 or cv < 2:
24
+ return {}
25
+ try:
26
+ import optuna
27
+ except ImportError:
28
+ return {}
29
+
30
+ optuna.logging.set_verbosity(optuna.logging.WARNING)
31
+ scoring = "balanced_accuracy" if task == TaskType.classification else "r2"
32
+
33
+ def objective(trial):
34
+ parameters = {
35
+ "n_estimators": trial.suggest_int("n_estimators", 120, 360, step=60),
36
+ "max_depth": trial.suggest_int("max_depth", 3, 14),
37
+ "min_samples_leaf": trial.suggest_int("min_samples_leaf", 1, 6),
38
+ "max_features": trial.suggest_categorical("max_features", ["sqrt", "log2", 0.8]),
39
+ }
40
+ common = {
41
+ **parameters,
42
+ "n_jobs": -1,
43
+ "random_state": settings.random_state,
44
+ }
45
+ if task == TaskType.classification:
46
+ estimator = RandomForestClassifier(**common, class_weight="balanced")
47
+ else:
48
+ estimator = RandomForestRegressor(**common)
49
+ pipeline = Pipeline([("preprocessor", preprocessor), ("model", estimator)])
50
+ scores = cross_val_score(
51
+ pipeline,
52
+ features,
53
+ target,
54
+ cv=cv,
55
+ scoring=scoring,
56
+ n_jobs=1,
57
+ )
58
+ return float(scores.mean())
59
+
60
+ study = optuna.create_study(direction="maximize")
61
+ study.optimize(
62
+ objective,
63
+ n_trials=settings.optuna_trials,
64
+ timeout=90,
65
+ show_progress_bar=False,
66
+ )
67
+ return study.best_params
datapilot/workflow.py ADDED
@@ -0,0 +1,341 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Any, TypedDict
5
+ from uuid import uuid4
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+ from langgraph.graph import END, START, StateGraph
10
+
11
+ from datapilot.config import Settings, get_settings
12
+ from datapilot.data import duckdb_overview
13
+ from datapilot.insights import deterministic_insights, optional_llm_narrative
14
+ from datapilot.modeling import (
15
+ TrainingBundle,
16
+ critic_decision,
17
+ explain_model,
18
+ train_models,
19
+ )
20
+ from datapilot.observability import log_to_mlflow
21
+ from datapilot.persistence import ArtifactStore, RunStore
22
+ from datapilot.quality import audit_quality, build_profile
23
+ from datapilot.reports import export_artifacts
24
+ from datapilot.schemas import AnalysisPlan, RunSummary, TaskType
25
+
26
+
27
+ class AgentState(TypedDict, total=False):
28
+ run_id: str
29
+ dataset_name: str
30
+ frame: pd.DataFrame
31
+ target: str
32
+ settings: Settings
33
+ profile: Any
34
+ quality_issues: list[Any]
35
+ evidence: list[Any]
36
+ eda: dict[str, Any]
37
+ statistics: dict[str, Any]
38
+ plan: AnalysisPlan
39
+ feature_plan: dict[str, Any]
40
+ model_bundle: TrainingBundle
41
+ critic: Any
42
+ explainability: Any
43
+ executive_summary: list[str]
44
+ recommendations: list[str]
45
+ summary_payload: dict[str, Any]
46
+ artifacts: dict[str, str]
47
+ trace: list[dict[str, Any]]
48
+
49
+
50
+ def _trace(state: AgentState, agent: str, started: float, detail: str) -> list[dict[str, Any]]:
51
+ trace = list(state.get("trace", []))
52
+ trace.append(
53
+ {
54
+ "agent": agent,
55
+ "status": "completed",
56
+ "duration_seconds": round(time.perf_counter() - started, 3),
57
+ "detail": detail,
58
+ }
59
+ )
60
+ return trace
61
+
62
+
63
+ def data_quality_agent(state: AgentState) -> dict[str, Any]:
64
+ started = time.perf_counter()
65
+ profile = build_profile(state["frame"], state["target"])
66
+ issues, evidence = audit_quality(state["frame"], profile)
67
+ return {
68
+ "profile": profile,
69
+ "quality_issues": issues,
70
+ "evidence": evidence,
71
+ "trace": _trace(
72
+ state, "Data Quality Agent", started, f"Recorded {len(issues)} quality observations."
73
+ ),
74
+ }
75
+
76
+
77
+ def eda_agent(state: AgentState) -> dict[str, Any]:
78
+ started = time.perf_counter()
79
+ frame = state["frame"]
80
+ overview = duckdb_overview(frame)
81
+ overview["numeric_summary"] = (
82
+ frame.select_dtypes(include=np.number).describe().round(4).to_dict()
83
+ )
84
+ overview["categorical_cardinality"] = {
85
+ column: int(frame[column].nunique(dropna=True))
86
+ for column in frame.select_dtypes(exclude=np.number).columns
87
+ }
88
+ return {
89
+ "eda": overview,
90
+ "trace": _trace(state, "EDA Agent", started, "Computed DuckDB-backed dataset overview."),
91
+ }
92
+
93
+
94
+ def statistical_agent(state: AgentState) -> dict[str, Any]:
95
+ started = time.perf_counter()
96
+ frame = state["frame"]
97
+ target = state["target"]
98
+ numeric = frame.select_dtypes(include=np.number)
99
+ correlations: dict[str, float] = {}
100
+ if target in numeric.columns and len(numeric.columns) > 1:
101
+ correlations = (
102
+ numeric.corr(numeric_only=True)[target]
103
+ .drop(labels=[target])
104
+ .abs()
105
+ .sort_values(ascending=False)
106
+ .head(10)
107
+ .round(4)
108
+ .to_dict()
109
+ )
110
+ statistics = {
111
+ "top_absolute_target_correlations": correlations,
112
+ "target_distribution": frame[target].value_counts(dropna=False).head(20).to_dict(),
113
+ }
114
+ return {
115
+ "statistics": statistics,
116
+ "trace": _trace(
117
+ state,
118
+ "Statistical Analysis Agent",
119
+ started,
120
+ "Measured target distribution and associations.",
121
+ ),
122
+ }
123
+
124
+
125
+ def planning_agent(state: AgentState) -> dict[str, Any]:
126
+ started = time.perf_counter()
127
+ profile = state["profile"]
128
+ if profile.task_type == TaskType.classification:
129
+ metric = "balanced_accuracy"
130
+ candidates = [
131
+ "Logistic Regression",
132
+ "Random Forest",
133
+ "Extra Trees",
134
+ "Histogram Gradient Boosting",
135
+ "XGBoost (when installed)",
136
+ ]
137
+ else:
138
+ metric = "r2"
139
+ candidates = [
140
+ "Linear Regression",
141
+ "Random Forest",
142
+ "Extra Trees",
143
+ "Histogram Gradient Boosting",
144
+ "XGBoost (when installed)",
145
+ ]
146
+ plan = AnalysisPlan(
147
+ objective=f"Predict '{profile.target}' and produce reproducible, evidence-backed insights.",
148
+ target=profile.target,
149
+ task_type=profile.task_type,
150
+ primary_metric=metric,
151
+ validation_strategy="Training-only stratified cross-validation; untouched final test evaluation"
152
+ if profile.task_type == TaskType.classification
153
+ else "Training-only cross-validation; untouched final test evaluation",
154
+ candidate_models=candidates,
155
+ risk_controls=[
156
+ "Drop rows with missing target before split",
157
+ "Fit imputers, encoders, and scalers on training folds only",
158
+ "Flag leakage-like names and identifier cardinality",
159
+ "Require critic quality gate before explanation",
160
+ ],
161
+ )
162
+ return {
163
+ "plan": plan,
164
+ "trace": _trace(state, "Planning Agent", started, f"Selected {metric} as primary metric."),
165
+ }
166
+
167
+
168
+ def feature_engineering_agent(state: AgentState) -> dict[str, Any]:
169
+ started = time.perf_counter()
170
+ profile = state["profile"]
171
+ feature_plan = {
172
+ "numeric": "Median imputation followed by standard scaling",
173
+ "categorical": "Most-frequent imputation followed by unknown-safe one-hot encoding",
174
+ "fit_scope": "Preprocessing is fitted inside each sklearn Pipeline after splitting",
175
+ "dropped": ["exact duplicate rows", "rows with missing target"],
176
+ "feature_count": profile.columns - 1,
177
+ }
178
+ return {
179
+ "feature_plan": feature_plan,
180
+ "trace": _trace(
181
+ state,
182
+ "Feature Engineering Agent",
183
+ started,
184
+ "Created leakage-safe ColumnTransformer plan.",
185
+ ),
186
+ }
187
+
188
+
189
+ def modeling_agent(state: AgentState) -> dict[str, Any]:
190
+ started = time.perf_counter()
191
+ retry = state.get("model_bundle").retry_number + 1 if state.get("model_bundle") else 0
192
+ bundle = train_models(
193
+ state["frame"],
194
+ state["target"],
195
+ state["profile"].task_type,
196
+ state["settings"],
197
+ retry_number=retry,
198
+ )
199
+ return {
200
+ "model_bundle": bundle,
201
+ "trace": _trace(
202
+ state,
203
+ "Modeling Agent",
204
+ started,
205
+ f"Compared {len(bundle.results)} models; {bundle.best_model} ranked first.",
206
+ ),
207
+ }
208
+
209
+
210
+ def evaluation_critic_agent(state: AgentState) -> dict[str, Any]:
211
+ started = time.perf_counter()
212
+ decision = critic_decision(state["model_bundle"], state["profile"].task_type, state["settings"])
213
+ detail = "Approved analysis." if decision.approved else "Rejected analysis and requested retry."
214
+ return {
215
+ "critic": decision,
216
+ "trace": _trace(state, "Evaluation / Critic Agent", started, detail),
217
+ }
218
+
219
+
220
+ def critic_route(state: AgentState) -> str:
221
+ return "explainability" if state["critic"].approved else "retry_modeling"
222
+
223
+
224
+ def explainability_agent(state: AgentState) -> dict[str, Any]:
225
+ started = time.perf_counter()
226
+ result = explain_model(state["model_bundle"])
227
+ return {
228
+ "explainability": result,
229
+ "trace": _trace(
230
+ state, "Explainability Agent", started, f"Generated {result.method} explanations."
231
+ ),
232
+ }
233
+
234
+
235
+ def executive_insights_agent(state: AgentState) -> dict[str, Any]:
236
+ started = time.perf_counter()
237
+ summary, recommendations = deterministic_insights(state)
238
+ llm_summary = optional_llm_narrative(state, state["evidence"], state["settings"])
239
+ if llm_summary:
240
+ summary = llm_summary
241
+ return {
242
+ "executive_summary": summary,
243
+ "recommendations": recommendations,
244
+ "trace": _trace(
245
+ state,
246
+ "Executive Insights Agent",
247
+ started,
248
+ "Created evidence-grounded narrative with deterministic metric provenance.",
249
+ ),
250
+ }
251
+
252
+
253
+ def build_graph():
254
+ graph = StateGraph(AgentState)
255
+ graph.add_node("data_quality", data_quality_agent)
256
+ graph.add_node("eda", eda_agent)
257
+ graph.add_node("statistics", statistical_agent)
258
+ graph.add_node("planning", planning_agent)
259
+ graph.add_node("feature_engineering", feature_engineering_agent)
260
+ graph.add_node("modeling", modeling_agent)
261
+ graph.add_node("critic", evaluation_critic_agent)
262
+ graph.add_node("explainability", explainability_agent)
263
+ graph.add_node("executive_insights", executive_insights_agent)
264
+ graph.add_edge(START, "data_quality")
265
+ graph.add_edge("data_quality", "eda")
266
+ graph.add_edge("eda", "statistics")
267
+ graph.add_edge("statistics", "planning")
268
+ graph.add_edge("planning", "feature_engineering")
269
+ graph.add_edge("feature_engineering", "modeling")
270
+ graph.add_edge("modeling", "critic")
271
+ graph.add_conditional_edges(
272
+ "critic",
273
+ critic_route,
274
+ {"retry_modeling": "modeling", "explainability": "explainability"},
275
+ )
276
+ graph.add_edge("explainability", "executive_insights")
277
+ graph.add_edge("executive_insights", END)
278
+ return graph.compile()
279
+
280
+
281
+ def run_analysis(
282
+ frame: pd.DataFrame,
283
+ target: str,
284
+ dataset_name: str,
285
+ settings: Settings | None = None,
286
+ ) -> RunSummary:
287
+ settings = settings or get_settings()
288
+ run_id = f"run_{uuid4().hex[:12]}"
289
+ store = RunStore(settings)
290
+ artifact_store = ArtifactStore(settings.artifact_root)
291
+ store.save(run_id, dataset_name, "running", {"run_id": run_id, "status": "running"})
292
+ initial: AgentState = {
293
+ "run_id": run_id,
294
+ "dataset_name": dataset_name,
295
+ "frame": frame,
296
+ "target": target,
297
+ "settings": settings,
298
+ "trace": [],
299
+ }
300
+ try:
301
+ final = build_graph().invoke(initial)
302
+ payload = _summary_payload(final, run_id, dataset_name)
303
+ final["summary_payload"] = payload
304
+ log_to_mlflow(payload, settings)
305
+ artifacts = export_artifacts(run_id, final, artifact_store.run_directory(run_id))
306
+ payload["artifacts"] = artifacts
307
+ payload["status"] = "completed"
308
+ store.save(run_id, dataset_name, "completed", payload)
309
+ return RunSummary.model_validate(payload)
310
+ except Exception as exc:
311
+ store.save(
312
+ run_id,
313
+ dataset_name,
314
+ "failed",
315
+ {"run_id": run_id, "dataset_name": dataset_name, "status": "failed", "error": str(exc)},
316
+ )
317
+ raise
318
+ finally:
319
+ store.engine.dispose()
320
+
321
+
322
+ def _summary_payload(state: AgentState, run_id: str, dataset_name: str) -> dict[str, Any]:
323
+ bundle = state["model_bundle"]
324
+ return {
325
+ "run_id": run_id,
326
+ "status": "completed",
327
+ "dataset_name": dataset_name,
328
+ "profile": state["profile"].model_dump(mode="json"),
329
+ "plan": state["plan"].model_dump(mode="json"),
330
+ "quality_issues": [item.model_dump(mode="json") for item in state["quality_issues"]],
331
+ "evidence": [item.model_dump(mode="json") for item in state["evidence"]],
332
+ "model_results": [item.model_dump(mode="json") for item in bundle.results],
333
+ "model_failures": [item.model_dump(mode="json") for item in bundle.failures],
334
+ "best_model": bundle.best_model,
335
+ "critic": state["critic"].model_dump(mode="json"),
336
+ "explainability": state["explainability"].model_dump(mode="json"),
337
+ "executive_summary": state["executive_summary"],
338
+ "recommendations": state["recommendations"],
339
+ "artifacts": {},
340
+ "trace": state["trace"],
341
+ }
docker-compose.yml ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ ui:
3
+ build:
4
+ context: .
5
+ dockerfile: Dockerfile
6
+ ports:
7
+ - "8501:8501"
8
+ environment:
9
+ ARTIFACT_ROOT: /app/artifacts
10
+ DATABASE_URL: sqlite:////app/artifacts/datapilot.db
11
+ volumes:
12
+ - datapilot_artifacts:/app/artifacts
13
+ read_only: true
14
+ tmpfs:
15
+ - /tmp:size=128m
16
+ security_opt:
17
+ - no-new-privileges:true
18
+ cap_drop:
19
+ - ALL
20
+ mem_limit: 2g
21
+ cpus: 2
22
+
23
+ api:
24
+ build:
25
+ context: .
26
+ dockerfile: Dockerfile.api
27
+ ports:
28
+ - "8000:8000"
29
+ environment:
30
+ ARTIFACT_ROOT: /app/artifacts
31
+ DATABASE_URL: sqlite:////app/artifacts/datapilot.db
32
+ CORS_ORIGINS: http://localhost:8501
33
+ volumes:
34
+ - datapilot_artifacts:/app/artifacts
35
+ read_only: true
36
+ tmpfs:
37
+ - /tmp:size=128m
38
+ security_opt:
39
+ - no-new-privileges:true
40
+ cap_drop:
41
+ - ALL
42
+ mem_limit: 2g
43
+ cpus: 2
44
+
45
+ worker:
46
+ build:
47
+ context: .
48
+ dockerfile: Dockerfile.worker
49
+ network_mode: none
50
+ read_only: true
51
+ tmpfs:
52
+ - /tmp:size=32m,noexec,nosuid
53
+ security_opt:
54
+ - no-new-privileges:true
55
+ cap_drop:
56
+ - ALL
57
+ mem_limit: 256m
58
+ cpus: 0.5
59
+ pids_limit: 64
60
+
61
+ volumes:
62
+ datapilot_artifacts:
63
+
docs/API_EXAMPLES.md ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # API examples
2
+
3
+ Start the service with `uvicorn api.main:app --port 8000`. If `API_KEY` is configured, include
4
+ `X-API-Key: <key>` in every `/v1/` request.
5
+
6
+ ## Submit a bundled dataset
7
+
8
+ ```bash
9
+ curl -i -X POST http://localhost:8000/v1/analyze/sample \
10
+ -H "Content-Type: application/json" \
11
+ -H "Idempotency-Key: iris-demo-001" \
12
+ -d '{"sample":"iris"}'
13
+ ```
14
+
15
+ ```json
16
+ {"job_id":"job_abc123","status":"queued"}
17
+ ```
18
+
19
+ ## Submit an upload
20
+
21
+ ```bash
22
+ curl -i -X POST http://localhost:8000/v1/analyze/upload \
23
+ -F "file=@dataset.csv" \
24
+ -F "target=outcome"
25
+ ```
26
+
27
+ ## Poll or cancel
28
+
29
+ ```bash
30
+ curl http://localhost:8000/v1/jobs/job_abc123
31
+ curl -X DELETE http://localhost:8000/v1/jobs/job_abc123
32
+ ```
33
+
34
+ A completed job includes the validated run summary in `result`. Errors exposed to clients are
35
+ sanitized and carry an `X-Correlation-ID`; use that identifier to locate structured server logs.
36
+ The machine-readable contract is committed as [`openapi.json`](openapi.json).
37
+
docs/ARCHITECTURE.md ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DataPilot AI Architecture
2
+
3
+ ## Design goals
4
+
5
+ DataPilot AI separates the user experience, orchestration, analytics, model training,
6
+ persistence, and optional restricted computation. The default application remains useful
7
+ without a paid model API; every displayed number originates from deterministic computation.
8
+
9
+ ## Runtime components
10
+
11
+ | Component | Responsibility |
12
+ |---|---|
13
+ | Streamlit UI | Recruiter demo, upload/sample selection, charts, trace, downloads and Q&A |
14
+ | FastAPI | Versioned analysis, run, artifact and evidence-backed Q&A endpoints |
15
+ | LangGraph | Stateful agent ordering, state propagation and critic retry routing |
16
+ | Analytics core | DuckDB profiling, statistical summaries and data-quality evidence |
17
+ | ML core | Leakage-safe sklearn pipelines, model comparison and validation |
18
+ | Explainability | SHAP when compatible; permutation-importance fallback |
19
+ | Persistence | SQLAlchemy with SQLite locally and PostgreSQL via `DATABASE_URL` |
20
+ | Artifact store | Local filesystem interface, replaceable by S3/MinIO |
21
+ | Restricted worker | Expression-only AST validation in a networkless, resource-limited container |
22
+
23
+ ## Agent graph
24
+
25
+ ```mermaid
26
+ flowchart TD
27
+ A["Dataset + target"] --> B["Data Quality Agent"]
28
+ B --> C["EDA Agent (DuckDB)"]
29
+ C --> D["Statistical Analysis Agent"]
30
+ D --> E["Planning Agent"]
31
+ E --> F["Feature Engineering Agent"]
32
+ F --> G["Modeling Agent"]
33
+ G --> H{"Evaluation / Critic Agent"}
34
+ H -->|"Reject: weak or unstable"| G
35
+ H -->|"Approve"| I["Explainability Agent"]
36
+ I --> J["Executive Insights Agent"]
37
+ J --> K["Report + model card + pipeline + evidence"]
38
+ ```
39
+
40
+ ## Leakage controls
41
+
42
+ 1. Rows without labels and exact duplicates are removed before splitting.
43
+ 2. Train/test split occurs before any learned transformation.
44
+ 3. Imputation, scaling, and one-hot encoding are inside `sklearn.pipeline.Pipeline`.
45
+ 4. Cross-validation refits the complete pipeline in every fold.
46
+ 5. Identifier and target-like features are flagged for human review.
47
+ 6. Holdout metrics and cross-validation metrics remain distinct.
48
+
49
+ ## Graceful degradation
50
+
51
+ - Without Gemini: deterministic evidence-backed executive narrative.
52
+ - Without SHAP: permutation importance.
53
+ - Without XGBoost: sklearn candidate models.
54
+ - Without MLflow: structured agent trace plus persisted run JSON.
55
+ - Without PostgreSQL: SQLite.
56
+
docs/BENCHMARKS.md ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Reproducible smoke benchmarks
2
+
3
+ These results are correctness-oriented smoke benchmarks, not claims about production accuracy.
4
+ They were measured on a local Windows CPU with Python 3.13.1, `RANDOM_STATE=42`,
5
+ `OPTUNA_TRIALS=0`, and `MAX_CRITIC_RETRIES=0`.
6
+
7
+ | Dataset | Rows | Task | Selected model | Training CV score | Untouched test score | Wall time |
8
+ |---|---:|---|---|---:|---:|---:|
9
+ | scikit-learn Iris | 150 | multiclass classification | Logistic Regression | 0.9583 weighted F1 | 0.9333 weighted F1 | 4.29 s |
10
+ | scikit-learn Diabetes | 442 | regression | Linear Regression | 0.4493 R² | 0.4526 R² | 3.17 s |
11
+
12
+ Candidate selection uses cross-validation on the training partition only. The test score is
13
+ computed once after selection. Timings vary by hardware and installed optional dependencies.
14
+ Run the same path with `python scripts/benchmark.py` once optional benchmark automation is added.
15
+
docs/DATA_PRIVACY.md ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # Data privacy
2
+
3
+ DataPilot defaults to deterministic local analysis. Gemini is optional and receives a bounded
4
+ metadata package rather than the full dataset: schema, aggregates, target candidates, quality
5
+ signals, and a small redacted sample. Users can exclude columns, and likely PII columns are
6
+ removed automatically.
7
+
8
+ Production operators must define dataset retention, deletion SLAs, tenant isolation, regional
9
+ processing, encryption, access logging, data-subject request handling, and subprocessors. API
10
+ keys and database credentials belong in a secret manager and must never enter exports or logs.
docs/DEPLOYMENT.md ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Deployment Guide
2
+
3
+ ## Streamlit Community Cloud
4
+
5
+ 1. Push the repository to GitHub.
6
+ 2. Create a Streamlit app with `streamlit_app.py` as the entrypoint.
7
+ 3. Use Python 3.12.
8
+ 4. Add `GEMINI_API_KEY` only if optional LLM narration is desired.
9
+ 5. Keep the default SQLite/local artifact mode for a stateless public demo.
10
+
11
+ The app is fully functional without an LLM key.
12
+
13
+ ## Render API
14
+
15
+ `render.yaml` defines a Docker-backed FastAPI service. Configure:
16
+
17
+ - `DATABASE_URL`: Neon, Supabase or another PostgreSQL URL.
18
+ - `GEMINI_API_KEY`: optional.
19
+ - persistent/object storage if generated artifacts must survive redeployments.
20
+
21
+ ## Docker Compose
22
+
23
+ ```bash
24
+ docker compose up --build
25
+ ```
26
+
27
+ - Streamlit: <http://localhost:8501>
28
+ - FastAPI: <http://localhost:8000/docs>
29
+
30
+ The isolated worker has no exposed port and no network.
31
+
32
+ ## Production topology
33
+
34
+ For heavier datasets, make `/v1/analyze/*` enqueue jobs to a separate worker and return
35
+ `202 Accepted`. Persist state in PostgreSQL, place artifacts in S3-compatible storage, and
36
+ stream progress through server-sent events or polling.
37
+