prepro-auto 1.0.0b1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. app/__init__.py +0 -0
  2. app/api/__init__.py +0 -0
  3. app/api/router.py +16 -0
  4. app/api/routes/__init__.py +0 -0
  5. app/api/routes/datasets.py +463 -0
  6. app/api/routes/decisions.py +302 -0
  7. app/api/routes/execution.py +116 -0
  8. app/api/routes/export.py +69 -0
  9. app/api/routes/health.py +159 -0
  10. app/api/routes/transform.py +338 -0
  11. app/core/__init__.py +0 -0
  12. app/core/config.py +129 -0
  13. app/core/logging.py +47 -0
  14. app/core/memory.py +143 -0
  15. app/db/__init__.py +0 -0
  16. app/db/session.py +73 -0
  17. app/main.py +108 -0
  18. app/models/__init__.py +16 -0
  19. app/models/decision.py +78 -0
  20. app/models/job.py +92 -0
  21. app/models/snapshot.py +81 -0
  22. app/notebook.py +208 -0
  23. app/preprocessing/__init__.py +0 -0
  24. app/preprocessing/abbreviations.py +200 -0
  25. app/preprocessing/correlation.py +152 -0
  26. app/preprocessing/encoding.py +127 -0
  27. app/preprocessing/imputation.py +328 -0
  28. app/preprocessing/missing_values.py +293 -0
  29. app/preprocessing/outliers.py +216 -0
  30. app/preprocessing/profiler.py +169 -0
  31. app/preprocessing/scaling.py +175 -0
  32. app/preprocessing/semantics.py +190 -0
  33. app/preprocessing/transforms.py +520 -0
  34. app/preprocessing/type_inference.py +223 -0
  35. app/schemas/__init__.py +0 -0
  36. app/schemas/decision.py +52 -0
  37. app/schemas/execution.py +50 -0
  38. app/schemas/job.py +77 -0
  39. app/services/__init__.py +0 -0
  40. app/services/dashboard_service.py +112 -0
  41. app/services/dataset_inspector.py +78 -0
  42. app/services/drift_service.py +181 -0
  43. app/services/encoding_service.py +98 -0
  44. app/services/execution_service.py +176 -0
  45. app/services/export_service.py +311 -0
  46. app/services/file_reader.py +163 -0
  47. app/services/llm_client.py +292 -0
  48. app/services/llm_transform_service.py +496 -0
  49. app/services/missing_value_service.py +109 -0
  50. app/services/outlier_service.py +166 -0
  51. app/services/profiling_service.py +62 -0
  52. app/services/scaling_service.py +186 -0
  53. app/services/storage.py +129 -0
  54. app/services/transform_service.py +111 -0
  55. app/services/versioning_service.py +173 -0
  56. app/services/visualization_service.py +171 -0
  57. app/web/__init__.py +1 -0
  58. app/web/review.html +270 -0
  59. app/web/workbench.html +1675 -0
  60. app/workers/__init__.py +0 -0
  61. app/workers/celery_app.py +33 -0
  62. app/workers/tasks.py +48 -0
  63. prepro_auto/__init__.py +57 -0
  64. prepro_auto/cli.py +38 -0
  65. prepro_auto-1.0.0b1.dist-info/METADATA +270 -0
  66. prepro_auto-1.0.0b1.dist-info/RECORD +70 -0
  67. prepro_auto-1.0.0b1.dist-info/WHEEL +5 -0
  68. prepro_auto-1.0.0b1.dist-info/entry_points.txt +2 -0
  69. prepro_auto-1.0.0b1.dist-info/licenses/LICENSE +21 -0
  70. prepro_auto-1.0.0b1.dist-info/top_level.txt +2 -0
app/__init__.py ADDED
File without changes
app/api/__init__.py ADDED
File without changes
app/api/router.py ADDED
@@ -0,0 +1,16 @@
1
+ """
2
+ Aggregates all route modules into a single APIRouter that main.py mounts
3
+ under the API_PREFIX (e.g. /api/v1).
4
+ """
5
+
6
+ from fastapi import APIRouter
7
+
8
+ from app.api.routes import health, datasets, decisions, execution, export, transform
9
+
10
+ api_router = APIRouter()
11
+ api_router.include_router(health.router)
12
+ api_router.include_router(datasets.router)
13
+ api_router.include_router(decisions.router)
14
+ api_router.include_router(execution.router)
15
+ api_router.include_router(export.router)
16
+ api_router.include_router(transform.router)
File without changes
@@ -0,0 +1,463 @@
1
+ """
2
+ Dataset routes: upload a file and inspect a job.
3
+
4
+ POST /api/v1/datasets/upload
5
+ Accepts a multipart file + optional domain. Validates extension and size,
6
+ stores the raw bytes, creates a PipelineJob row, returns the job_id.
7
+
8
+ GET /api/v1/datasets/{job_id}
9
+ Returns the full job detail.
10
+ """
11
+
12
+ import uuid
13
+
14
+ from fastapi import APIRouter, UploadFile, File, Form, Depends, HTTPException, status
15
+ from sqlalchemy import select
16
+ from sqlalchemy.orm import Session
17
+
18
+ from app.core.config import settings
19
+ from app.core.logging import get_logger
20
+ from app.db.session import get_db
21
+ from app.models.job import PipelineJob, JobStatus, Domain
22
+ from app.schemas.job import JobCreateResponse, JobDetail, DatasetPreview, ProfileResponse
23
+ from app.services.storage import storage
24
+ from app.services.file_reader import read_dataframe, FileReadError
25
+ from app.services.dataset_inspector import inspect_dataframe
26
+ from app.services.profiling_service import run_profiling_sync
27
+
28
+ log = get_logger(__name__)
29
+ router = APIRouter(prefix="/datasets", tags=["datasets"])
30
+
31
+
32
+ def _extract_extension(filename: str) -> str:
33
+ if "." not in filename:
34
+ return ""
35
+ return filename.rsplit(".", 1)[-1].lower()
36
+
37
+
38
+ @router.post(
39
+ "/upload",
40
+ response_model=JobCreateResponse,
41
+ status_code=status.HTTP_201_CREATED,
42
+ )
43
+ async def upload_dataset(
44
+ file: UploadFile = File(...),
45
+ domain: str = Form(default="general"),
46
+ db: Session = Depends(get_db),
47
+ ):
48
+ # ── Validate extension ─────────────────────────────────
49
+ ext = _extract_extension(file.filename or "")
50
+ if ext not in settings.allowed_extensions_set:
51
+ raise HTTPException(
52
+ status_code=status.HTTP_400_BAD_REQUEST,
53
+ detail=(
54
+ f"Unsupported file type '.{ext}'. "
55
+ f"Allowed: {sorted(settings.allowed_extensions_set)}"
56
+ ),
57
+ )
58
+
59
+ # ── Validate domain ────────────────────────────────────
60
+ try:
61
+ domain_enum = Domain(domain.lower())
62
+ except ValueError:
63
+ raise HTTPException(
64
+ status_code=status.HTTP_400_BAD_REQUEST,
65
+ detail=f"Invalid domain '{domain}'. Use 'general' or 'finance'.",
66
+ )
67
+
68
+ # ── Read bytes and check size ──────────────────────────
69
+ data = await file.read()
70
+ size = len(data)
71
+ if size == 0:
72
+ raise HTTPException(
73
+ status_code=status.HTTP_400_BAD_REQUEST,
74
+ detail="Uploaded file is empty.",
75
+ )
76
+ if size > settings.effective_max_upload_bytes:
77
+ eff = settings.effective_max_upload_mb
78
+ raise HTTPException(
79
+ status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
80
+ detail=(
81
+ f"File is {size / (1024 * 1024):.0f} MB, which exceeds the current "
82
+ f"{eff} MB limit. This limit is based on available memory because the "
83
+ f"dataset is processed in RAM. For larger files, free up memory, run on "
84
+ f"a machine with more RAM, or pre-sample the data."
85
+ ),
86
+ )
87
+
88
+ # ── Store raw file ─────────────────────────────────────
89
+ job_id = str(uuid.uuid4())
90
+ storage_key = f"raw/{job_id}.{ext}"
91
+ storage.save_bytes(storage_key, data)
92
+
93
+ # ── Create job record ──────────────────────────────────
94
+ job = PipelineJob(
95
+ id=job_id,
96
+ original_filename=file.filename or f"upload.{ext}",
97
+ file_extension=ext,
98
+ storage_key=storage_key,
99
+ file_size_bytes=size,
100
+ status=JobStatus.UPLOADED,
101
+ domain=domain_enum,
102
+ )
103
+ db.add(job)
104
+ db.commit()
105
+ db.refresh(job)
106
+
107
+ # If the file is within the streamable limit but too big for the heavy
108
+ # in-memory steps on this machine, note it (the UI also warns the user).
109
+ from app.core.memory import fits_heavy_ops
110
+ heavy = fits_heavy_ops(size)
111
+ if not heavy["ok"]:
112
+ log.warning(
113
+ f"Job {job_id[:8]}: {heavy['file_mb']} MB exceeds heavy-op limit "
114
+ f"({heavy['heavy_max_mb']} MB) — KNN/MICE/IsolationForest may not fit in RAM."
115
+ )
116
+
117
+ log.info(
118
+ f"Upload stored: job={job_id[:8]} file={file.filename!r} "
119
+ f"size={size} domain={domain_enum.value}"
120
+ )
121
+
122
+ # NOTE: In Week 3 we will trigger the profiler Celery task here:
123
+ # from app.workers.tasks import profile_dataset
124
+ # profile_dataset.delay(job_id)
125
+
126
+ return JobCreateResponse(
127
+ job_id=job.id,
128
+ original_filename=job.original_filename,
129
+ file_extension=job.file_extension,
130
+ file_size_bytes=job.file_size_bytes,
131
+ status=job.status,
132
+ domain=job.domain,
133
+ )
134
+
135
+
136
+ @router.get("/{job_id}", response_model=JobDetail)
137
+ def get_dataset(job_id: str, db: Session = Depends(get_db)):
138
+ job = db.get(PipelineJob, job_id)
139
+ if job is None:
140
+ raise HTTPException(
141
+ status_code=status.HTTP_404_NOT_FOUND,
142
+ detail=f"Job {job_id} not found.",
143
+ )
144
+ return job
145
+
146
+
147
+ @router.get("/{job_id}/preview", response_model=DatasetPreview)
148
+ def preview_dataset(
149
+ job_id: str,
150
+ rows: int = 10,
151
+ db: Session = Depends(get_db),
152
+ ):
153
+ """
154
+ Load the stored raw file, parse it into a DataFrame, and return its shape
155
+ plus a preview of the first `rows` rows.
156
+
157
+ As a side effect, persists row_count and column_count onto the job record
158
+ (this is the first time we actually learn the dataset's shape).
159
+ """
160
+ job = db.get(PipelineJob, job_id)
161
+ if job is None:
162
+ raise HTTPException(
163
+ status_code=status.HTTP_404_NOT_FOUND,
164
+ detail=f"Job {job_id} not found.",
165
+ )
166
+
167
+ # Clamp preview rows to a sane range.
168
+ rows = max(1, min(rows, 100))
169
+
170
+ # Load the raw bytes from storage.
171
+ try:
172
+ data = storage.load_bytes(job.storage_key)
173
+ except Exception as e: # noqa: BLE001
174
+ raise HTTPException(
175
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
176
+ detail=f"Could not load stored file: {e}",
177
+ )
178
+
179
+ # Parse into a DataFrame.
180
+ try:
181
+ df = read_dataframe(data, job.file_extension)
182
+ except FileReadError as e:
183
+ # Record the failure on the job so the developer can see why.
184
+ job.status = JobStatus.FAILED
185
+ job.error_message = str(e)
186
+ db.commit()
187
+ raise HTTPException(
188
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
189
+ detail=str(e),
190
+ )
191
+
192
+ summary = inspect_dataframe(df, preview_rows=rows)
193
+
194
+ # Persist the shape onto the job record.
195
+ job.row_count = summary["row_count"]
196
+ job.column_count = summary["column_count"]
197
+ db.commit()
198
+
199
+ log.info(
200
+ f"Preview generated: job={job_id[:8]} "
201
+ f"shape=({summary['row_count']}, {summary['column_count']})"
202
+ )
203
+
204
+ return DatasetPreview(job_id=job_id, **summary)
205
+
206
+
207
+ @router.post("/{job_id}/profile", response_model=ProfileResponse)
208
+ def profile_dataset_endpoint(
209
+ job_id: str,
210
+ background: bool = False,
211
+ db: Session = Depends(get_db),
212
+ ):
213
+ """
214
+ Run the statistical profiler on a dataset.
215
+
216
+ By default this runs SYNCHRONOUSLY (background=false) — no Redis/Celery
217
+ needed, perfect for local development. The response includes the full
218
+ profile.
219
+
220
+ Set background=true to dispatch the work to a Celery worker instead (needs
221
+ Redis + a running worker). In that case the response returns immediately
222
+ with status=profiling and you poll GET .../profile for the result.
223
+ """
224
+ job = db.get(PipelineJob, job_id)
225
+ if job is None:
226
+ raise HTTPException(
227
+ status_code=status.HTTP_404_NOT_FOUND,
228
+ detail=f"Job {job_id} not found.",
229
+ )
230
+
231
+ if background:
232
+ # Dispatch to Celery. Import here so local sync mode never needs Redis.
233
+ from app.workers.tasks import profile_dataset as profile_task
234
+ profile_task.delay(job_id)
235
+ job.status = JobStatus.PROFILING
236
+ db.commit()
237
+ return ProfileResponse(
238
+ job_id=job_id,
239
+ status=JobStatus.PROFILING,
240
+ quality_score=None,
241
+ row_count=job.row_count,
242
+ column_count=job.column_count,
243
+ )
244
+
245
+ # Synchronous path (default).
246
+ try:
247
+ job = run_profiling_sync(job_id, db)
248
+ except FileReadError as e:
249
+ raise HTTPException(
250
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
251
+ detail=str(e),
252
+ )
253
+
254
+ profile = job.profile_json or {}
255
+ return ProfileResponse(
256
+ job_id=job_id,
257
+ status=job.status,
258
+ quality_score=job.quality_score,
259
+ row_count=job.row_count,
260
+ column_count=job.column_count,
261
+ type_breakdown=profile.get("type_breakdown"),
262
+ columns=profile.get("columns"),
263
+ )
264
+
265
+
266
+ @router.get("/{job_id}/profile", response_model=ProfileResponse)
267
+ def get_profile(job_id: str, db: Session = Depends(get_db)):
268
+ """Retrieve a previously-computed profile."""
269
+ job = db.get(PipelineJob, job_id)
270
+ if job is None:
271
+ raise HTTPException(
272
+ status_code=status.HTTP_404_NOT_FOUND,
273
+ detail=f"Job {job_id} not found.",
274
+ )
275
+
276
+ profile = job.profile_json or {}
277
+ return ProfileResponse(
278
+ job_id=job_id,
279
+ status=job.status,
280
+ quality_score=job.quality_score,
281
+ row_count=job.row_count,
282
+ column_count=job.column_count,
283
+ type_breakdown=profile.get("type_breakdown"),
284
+ columns=profile.get("columns"),
285
+ )
286
+
287
+
288
+ @router.get("/{job_id}/comparison")
289
+ def dataset_comparison(job_id: str, db: Session = Depends(get_db)):
290
+ """
291
+ Before/after comparison for the workbench.
292
+
293
+ Returns the original (raw) data's quality and per-column types alongside the
294
+ cleaned data's quality and types, plus a small sample of each. Lets a user
295
+ see exactly what preprocessing changed: type conversions, dropped/added
296
+ columns, and the overall quality-score movement.
297
+ """
298
+ import io as _io
299
+ import pandas as _pd
300
+ from app.preprocessing.profiler import profile_dataframe
301
+ from app.models.snapshot import PipelineSnapshot
302
+
303
+ job = db.get(PipelineJob, job_id)
304
+ if job is None:
305
+ raise HTTPException(status_code=404, detail=f"Job {job_id} not found.")
306
+
307
+ # ── BEFORE: the raw uploaded file ──────────────────────
308
+ raw_df = read_dataframe(storage.load_bytes(job.storage_key), job.file_extension)
309
+ before = profile_dataframe(raw_df, use_llm=False)
310
+
311
+ # ── AFTER: the ACTIVE snapshot version (respects undo/redo) ──
312
+ from app.services.versioning_service import load_active_dataframe
313
+ active = job.active_version or 0
314
+ if active > 0:
315
+ clean_df = load_active_dataframe(job, db)
316
+ after = profile_dataframe(clean_df, use_llm=False)
317
+ cleaned = True
318
+ version = active
319
+ else:
320
+ clean_df = raw_df
321
+ after = before
322
+ cleaned = False
323
+ version = 0
324
+
325
+ def _types(profile):
326
+ return {
327
+ c["name"]: (c.get("refined_type") or c.get("semantic_type", "unknown"))
328
+ for c in profile["columns"]
329
+ }
330
+
331
+ def _sample(df, n=5):
332
+ head = df.head(n)
333
+ return {
334
+ "columns": [str(c) for c in df.columns],
335
+ "rows": [
336
+ {str(c): (None if _pd.isna(r[c]) else _json_cell(r[c])) for c in df.columns}
337
+ for _, r in head.iterrows()
338
+ ],
339
+ }
340
+
341
+ return {
342
+ "job_id": job_id,
343
+ "cleaned": cleaned,
344
+ "snapshot_version": version,
345
+ "before": {
346
+ "quality_score": before["quality_score"],
347
+ "row_count": before["row_count"],
348
+ "column_count": before["column_count"],
349
+ "types": _types(before),
350
+ "sample": _sample(raw_df),
351
+ },
352
+ "after": {
353
+ "quality_score": after["quality_score"],
354
+ "row_count": after["row_count"],
355
+ "column_count": after["column_count"],
356
+ "types": _types(after),
357
+ "sample": _sample(clean_df),
358
+ },
359
+ }
360
+
361
+
362
+ def _json_cell(v):
363
+ """Make a single cell JSON-safe (numpy scalars -> python)."""
364
+ try:
365
+ import numpy as _np
366
+ if isinstance(v, (_np.integer,)):
367
+ return int(v)
368
+ if isinstance(v, (_np.floating,)):
369
+ f = float(v)
370
+ return None if f != f else f # NaN guard
371
+ if isinstance(v, (_np.bool_,)):
372
+ return bool(v)
373
+ except Exception:
374
+ pass
375
+ return v
376
+
377
+
378
+ @router.get("/{job_id}/view")
379
+ def view_dataset(
380
+ job_id: str,
381
+ rows: int = 20,
382
+ db: Session = Depends(get_db),
383
+ ):
384
+ """
385
+ Live view of the dataset at the job's ACTIVE version (respects undo/redo).
386
+ Returns columns (with dtypes), a row sample, shape, and the version label so
387
+ the UI's View section always reflects the current state after any change.
388
+ """
389
+ import pandas as _pd
390
+ from app.services.versioning_service import load_active_dataframe, history as _history
391
+
392
+ job = db.get(PipelineJob, job_id)
393
+ if job is None:
394
+ raise HTTPException(status_code=404, detail=f"Job {job_id} not found.")
395
+
396
+ rows = max(1, min(rows, 200))
397
+ df = load_active_dataframe(job, db)
398
+ head = df.head(rows)
399
+
400
+ cols = [{"name": str(c), "dtype": str(df[c].dtype)} for c in df.columns]
401
+ sample = [
402
+ {str(c): (None if _pd.isna(r[c]) else _json_cell(r[c])) for c in df.columns}
403
+ for _, r in head.iterrows()
404
+ ]
405
+ h = _history(job, db)
406
+
407
+ # Live quality score of the CURRENT version + the raw baseline, so the UI can
408
+ # show the score and turn green when the data has improved over raw.
409
+ from app.preprocessing.profiler import profile_dataframe
410
+ try:
411
+ current_quality = profile_dataframe(df, use_llm=False)["quality_score"]
412
+ except Exception: # noqa: BLE001 - quality is best-effort, never blocks the view
413
+ current_quality = None
414
+ baseline_quality = job.quality_score # set at profile time on the raw data
415
+
416
+ return {
417
+ "job_id": job_id,
418
+ "active_version": h["active_version"],
419
+ "version_label": next(
420
+ (v["label"] for v in h["versions"] if v["version"] == h["active_version"]),
421
+ "Raw data",
422
+ ),
423
+ "row_count": int(df.shape[0]),
424
+ "column_count": int(df.shape[1]),
425
+ "quality_score": current_quality,
426
+ "baseline_quality": baseline_quality,
427
+ "improved": (
428
+ current_quality is not None and baseline_quality is not None
429
+ and current_quality > baseline_quality
430
+ ),
431
+ "columns": cols,
432
+ "sample": sample,
433
+ }
434
+
435
+
436
+ @router.get("/{job_id}/history")
437
+ def get_history(job_id: str, db: Session = Depends(get_db)):
438
+ """Snapshot history + undo/redo availability for the job."""
439
+ from app.services.versioning_service import history as _history
440
+ job = db.get(PipelineJob, job_id)
441
+ if job is None:
442
+ raise HTTPException(status_code=404, detail=f"Job {job_id} not found.")
443
+ return _history(job, db)
444
+
445
+
446
+ @router.post("/{job_id}/undo")
447
+ def undo_step(job_id: str, db: Session = Depends(get_db)):
448
+ """Move the active version back one step (undo)."""
449
+ from app.services.versioning_service import undo as _undo
450
+ job = db.get(PipelineJob, job_id)
451
+ if job is None:
452
+ raise HTTPException(status_code=404, detail=f"Job {job_id} not found.")
453
+ return _undo(job, db)
454
+
455
+
456
+ @router.post("/{job_id}/redo")
457
+ def redo_step(job_id: str, db: Session = Depends(get_db)):
458
+ """Move the active version forward one step (redo)."""
459
+ from app.services.versioning_service import redo as _redo
460
+ job = db.get(PipelineJob, job_id)
461
+ if job is None:
462
+ raise HTTPException(status_code=404, detail=f"Job {job_id} not found.")
463
+ return _redo(job, db)