feat: dataset export endpoint + SQLite dev-mode (geometry decoupling)

Dataset export (source data for model training):
- GET /api/v1/export?format=jsonl|csv|geojson with category/data_source
  filters; streams a labeled dataset (signal params + identified device +
  routed category) suitable for training a Sub-GHz classifier.

SQLite dev-mode (corrects FABLE brief: SQLite was NOT a drop-in swap):
- models.py made dialect-aware — JSONB->JSON, ARRAY(Text)->JSON, TSVECTOR
  ->Text via .with_variant(); PostGIS Geometry column + GiST index only
  defined when not on SQLite (lat/lon + haversine bbox used instead).
- config/database.py honors DATABASE_URL / a full-URL override and builds
  a SQLite engine (check_same_thread=False, no server pool) when the URL
  is sqlite; PostgreSQL keeps pooling + UTC session.

Verified: create_all + Capture/CaptureMatch/Device CRUD + JSON round-trip
+ bbox query all work on sqlite; postgres mode still defines geom + gist
index; 52/52 unit tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-07-18 13:15:46 -07:00
parent 78c28fe43f
commit b1a3e2a11d
3 changed files with 171 additions and 30 deletions
+30 -5
View File
@@ -24,7 +24,8 @@ class DatabaseConfig:
password: str = "giglez_secure_password_2026",
pool_size: int = 10,
max_overflow: int = 20,
echo: bool = False
echo: bool = False,
url: Optional[str] = None
):
"""
Initialize database configuration
@@ -38,6 +39,9 @@ class DatabaseConfig:
pool_size: Connection pool size (Wigle pattern: moderate pooling)
max_overflow: Max overflow connections
echo: Echo SQL queries (debug mode)
url: Full SQLAlchemy URL override (e.g. sqlite:///./giglez.db).
When set, it takes precedence over the host/port/user fields.
Falls back to the DATABASE_URL env var.
"""
self.host = host
self.port = port
@@ -47,29 +51,50 @@ class DatabaseConfig:
self.pool_size = pool_size
self.max_overflow = max_overflow
self.echo = echo
self.url = url or os.getenv("DATABASE_URL")
self._engine: Optional = None
self._session_factory: Optional[sessionmaker] = None
@property
def is_sqlite(self) -> bool:
return bool(self.url) and self.url.startswith("sqlite")
@property
def connection_string(self) -> str:
"""Generate PostgreSQL connection string"""
"""SQLAlchemy connection string (URL override wins, else PostgreSQL)"""
if self.url:
return self.url
return f"postgresql://{self.user}:{self.password}@{self.host}:{self.port}/{self.database}"
@property
def connection_string_safe(self) -> str:
"""Generate connection string without password (for logging)"""
"""Connection string without password (for logging)"""
if self.url:
# sqlite URLs carry no password; postgres URLs would — mask them
if self.is_sqlite:
return self.url
return "postgresql://****"
return f"postgresql://{self.user}:****@{self.host}:{self.port}/{self.database}"
def get_engine(self):
"""
Get or create SQLAlchemy engine
Get or create SQLAlchemy engine.
Uses connection pooling for performance (Wigle pattern)
PostgreSQL uses connection pooling + UTC session (Wigle pattern).
SQLite (dev/MVP) uses a simple engine with cross-thread access enabled.
"""
if self._engine is None:
logger.info(f"Creating database engine: {self.connection_string_safe}")
if self.is_sqlite:
# SQLite dev mode: no server-side pool, allow use across threads
self._engine = create_engine(
self.connection_string,
echo=self.echo,
connect_args={"check_same_thread": False},
)
else:
self._engine = create_engine(
self.connection_string,
poolclass=QueuePool,
+92 -2
View File
@@ -4,15 +4,17 @@ GigLez FastAPI Application - Simplified Version
Runs without database requirement for testing web interface
"""
import csv
import io
import json
import sys
from pathlib import Path
from typing import List
from typing import List, Optional
from datetime import datetime
from fastapi import FastAPI, Request, UploadFile, File, Form
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
@@ -240,6 +242,94 @@ async def get_stats():
}
# =============================================================================
# DATASET EXPORT (for model training)
# =============================================================================
# Columns exported for training datasets. Order is stable across formats.
EXPORT_FIELDS = [
"id", "filename", "frequency", "protocol", "preset",
"device_name", "device_category", "match_confidence", "match_method",
"latitude", "longitude", "gps_source", "timestamp",
"data_source", "session_id",
]
def _iter_export_rows(category: Optional[str], data_source: Optional[str]):
"""Yield capture dicts filtered by category / data_source."""
for cap in captures_storage:
if category and cap.get("device_category") != category:
continue
if data_source and cap.get("data_source") != data_source:
continue
yield cap
@app.get("/api/v1/export")
async def export_dataset(
format: str = "jsonl",
category: Optional[str] = None,
data_source: Optional[str] = None,
):
"""
Export captures as a labeled dataset for model training.
Query params:
- format: jsonl (default) | csv | geojson
- category: filter to one device category (e.g. "Weather Sensor")
- data_source: filter to one data source (e.g. "production")
Each row carries the signal parameters + the auto-identified device
label + category, suitable as training data for a Sub-GHz classifier.
Note: this JSON-backed store does not retain raw pulse timing arrays;
for RAW-pulse feature training, use the DB-backed path (Capture.raw_data).
"""
fmt = format.lower()
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
if fmt == "jsonl":
def gen():
for cap in _iter_export_rows(category, data_source):
yield json.dumps({k: cap.get(k) for k in EXPORT_FIELDS}) + "\n"
return StreamingResponse(
gen(),
media_type="application/x-ndjson",
headers={"Content-Disposition": f'attachment; filename="giglez_dataset_{ts}.jsonl"'},
)
if fmt == "csv":
def gen():
buf = io.StringIO()
writer = csv.DictWriter(buf, fieldnames=EXPORT_FIELDS, extrasaction="ignore")
writer.writeheader()
yield buf.getvalue()
for cap in _iter_export_rows(category, data_source):
buf.seek(0); buf.truncate(0)
writer.writerow({k: cap.get(k) for k in EXPORT_FIELDS})
yield buf.getvalue()
return StreamingResponse(
gen(),
media_type="text/csv",
headers={"Content-Disposition": f'attachment; filename="giglez_dataset_{ts}.csv"'},
)
if fmt == "geojson":
features = []
for cap in _iter_export_rows(category, data_source):
lat, lon = cap.get("latitude"), cap.get("longitude")
if lat is None or lon is None:
continue
features.append({
"type": "Feature",
"geometry": {"type": "Point", "coordinates": [lon, lat]},
"properties": {k: cap.get(k) for k in EXPORT_FIELDS if k not in ("latitude", "longitude")},
})
return {"type": "FeatureCollection", "features": features}
return {"error": f"Unsupported format '{format}'. Use jsonl, csv, or geojson."}
@app.delete("/api/v1/admin/cleanup")
async def cleanup_test_data(data_source: str = "test"):
"""
+35 -9
View File
@@ -5,15 +5,36 @@ Database models matching the PostgreSQL + PostGIS schema
Based on Wigle wardriving patterns adapted for IoT RF device mapping
"""
import os
from datetime import datetime
from typing import Optional, List
from sqlalchemy import (
Column, String, Integer, Float, DateTime, Text, Boolean,
DECIMAL, ARRAY, ForeignKey, CheckConstraint, UniqueConstraint,
Index, LargeBinary
Index, LargeBinary, JSON
)
from sqlalchemy.orm import declarative_base, relationship
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR
# =============================================================================
# DIALECT AWARENESS (PostgreSQL/PostGIS in prod, SQLite in dev)
# =============================================================================
# The production schema targets PostgreSQL + PostGIS, but dev/MVP must run on
# SQLite with no external services. We keep PostgreSQL-native behaviour on
# postgres via `.with_variant()` while degrading gracefully on SQLite.
_DATABASE_URL = os.getenv("DATABASE_URL", "")
IS_SQLITE = _DATABASE_URL.startswith("sqlite")
# JSON: JSONB on postgres, generic JSON (stored as TEXT) on sqlite
JSON_TYPE = JSON().with_variant(JSONB(), "postgresql")
# Text arrays: native ARRAY on postgres, JSON list on sqlite
STR_ARRAY_TYPE = JSON().with_variant(ARRAY(Text), "postgresql")
# Full-text search vector: TSVECTOR on postgres (trigger-populated), Text on sqlite
SEARCH_VECTOR_TYPE = Text().with_variant(TSVECTOR(), "postgresql")
if not IS_SQLITE:
# geoalchemy2 is only importable/usable against a spatial backend
from geoalchemy2 import Geometry
from geoalchemy2.functions import ST_SetSRID, ST_MakePoint
@@ -154,7 +175,7 @@ class Device(Base):
typical_frequency = Column(Integer, index=True)
frequency_range_low = Column(Integer)
frequency_range_high = Column(Integer)
modulation_types = Column(ARRAY(Text))
modulation_types = Column(STR_ARRAY_TYPE)
# Protocol Information
protocol = Column(String(100), index=True)
@@ -174,8 +195,8 @@ class Device(Base):
source = Column(String(50), index=True) # 'flipper', 'rtl433', 'urh', 'community'
source_url = Column(Text)
# Full-text search (auto-updated by trigger)
search_vector = Column('search_vector', nullable=True)
# Full-text search (auto-updated by trigger on postgres)
search_vector = Column('search_vector', SEARCH_VECTOR_TYPE, nullable=True)
# Relationships
signatures = relationship("Signature", back_populates="device")
@@ -218,6 +239,8 @@ class Capture(Base):
longitude = Column(DECIMAL(11, 8), nullable=False)
altitude = Column(DECIMAL(8, 2))
gps_accuracy = Column(DECIMAL(6, 2))
# Spatial geometry only exists on PostGIS; SQLite uses lat/lon + haversine
if not IS_SQLITE:
geom = Column(Geometry('POINT', srid=4326)) # Auto-populated by trigger
# Timestamps
@@ -254,7 +277,10 @@ class Capture(Base):
CheckConstraint('longitude >= -180 AND longitude <= 180', name='valid_longitude'),
CheckConstraint('match_confidence >= 0 AND match_confidence <= 1', name='valid_confidence'),
CheckConstraint('frequency >= 300000000 AND frequency <= 928000000', name='valid_frequency'),
Index('idx_captures_geom', 'geom', postgresql_using='gist'),
) + (
# GiST spatial index only applies to the PostGIS geometry column
(Index('idx_captures_geom', 'geom', postgresql_using='gist'),)
if not IS_SQLITE else ()
)
# Relationships
@@ -350,7 +376,7 @@ class CaptureMatch(Base):
# Match Details
confidence = Column(DECIMAL(5, 4), nullable=False, index=True)
match_method = Column(String(50), nullable=False)
match_details = Column(JSONB)
match_details = Column(JSON_TYPE)
# Timestamp
matched_at = Column(DateTime, default=datetime.utcnow)
@@ -386,7 +412,7 @@ class Identification(Base):
notes = Column(Text)
# Visual Evidence
photo_urls = Column(ARRAY(Text))
photo_urls = Column(STR_ARRAY_TYPE)
# Community Validation
upvotes = Column(Integer, default=0)
@@ -540,7 +566,7 @@ class RTL433Protocol(Base):
bit_count = Column(Integer)
# JSON Fields Mapping
json_fields = Column(JSONB)
json_fields = Column(JSON_TYPE)
# Source
source_file = Column(String(500))