Skip to content

Commit 8096361

Browse files
authored
Merge pull request #82 from halcyon-past/feature/dev/sandbox-preview-mode
feat: Build a sandbox preview mode for uploads
2 parents 6f3a69a + 3fa7691 commit 8096361

35 files changed

Lines changed: 618 additions & 161 deletions

ARCHITECTURE.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,10 +136,11 @@ sequenceDiagram
136136
- **Map:** Each chunk is mapped over Gemini concurrently via Pub/Sub. If a chunk fails extraction, a LangGraph state machine catches the error and loops back for up to 3 self-correction retries.
137137
- **Reduce:** A Firestore transactional counter tracks chunk completions and eventually merges them into a unified `.xlsx` file.
138138

139-
### 3. Strict Schema Enforcement & Auto-Clean
140-
**Problem:** LLMs are prone to hallucinating formats or omitting columns.
139+
### 3. Strict Schema Enforcement, Auto-Clean, & Preview Mode
140+
**Problem:** LLMs are prone to hallucinating formats or omitting columns. Running a full pipeline on an invalid schema is costly.
141141
**Solution:**
142-
- If a target schema is provided, Gemini is forced to map the data directly to a JSON Schema object (`response_schema`).
142+
- **Sandbox Preview Mode:** The system can be triggered in a preview mode where the split phase instantly cuts the input to exactly 10 rows. This allows rapid validation of the extraction quality without wasting excessive compute tokens on dead runs.
143+
- **Schema Mapping:** If a target schema is provided, Gemini is forced to map the data directly to a JSON Schema object (`response_schema`).
143144
- **Auto-Clean Mode:** If no target schema is provided, Structurify dynamically infers the schema, cleans up the mess (capitalization, whitespaces, date formats), and returns the entire spreadsheet as a valid JSON array.
144145

145146
### 4. Asynchronous Email Notifications

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [2.4.0] - 2026-08-18
9+
### Added
10+
- **Sandbox Preview Mode**: Added a preview-only mode that processes just the first 10 rows of a file so users can validate schema fit and transformation quality before committing to a full long-running job. This significantly reduces wasted tokens and improves trust for first-time users.
11+
- The UI now features a "Run Preview" action.
12+
- Preview jobs are flagged in the pipeline UI, skipping heavy processing.
13+
- The completion screen for preview mode prompts users to launch a full job if the results are satisfactory.
14+
- **Custom Toast Notifications**: Replaced all native browser `alert()` and `confirm()` dialogues across the application with non-blocking, stylish `react-hot-toast` notifications.
15+
- **Admin Dashboard UI Update**: Moved the Admin button out of the main header and into the profile dropdown menu to streamline navigation and keep administrative functions discreetly accessible to verified admins and owners.
16+
817
## [2.3.0] - 2026-08-17
918
### Added
1019
- **Enterprise SSO Support**: Introduced support for Enterprise SSO via SAML and OIDC through Firebase Identity Platform.

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@ Built on a completely decoupled **Serverless Fan-Out Architecture** on Google Cl
1111
## <img src="./docs/icons/architecture.svg" width="28" align="absbottom" alt="architecture" /> Core Features
1212

1313
- **Strict Schema Enforcement**: Define exactly the JSON/Excel schema you need, and Structurify will enforce strict type-casting and structure.
14+
- **Sandbox Preview Mode**: Process just the first 10 rows of a dataset to validate schema fit and AI transformation quality before committing to a full long-running job.
1415
- **Auto-Clean Mode**: Don't know the schema? Structurify will automatically infer the schema from the file headers and repair capitalization, trim whitespace, and standardize date formats across the board.
1516
- **Email Notifications**: Upload a massive dataset (over 1MB), and Structurify will immediately email you a tracking link to watch the live progress, followed by a final success email with your secure download URL.
1617
- **Enterprise SSO & Multi-Tenant Authentication**: Provides robust authentication via Firebase Identity Platform, supporting Google OAuth, SAML, and OIDC enterprise SSO. Features automatic account linking for identity conflict resolution and maps users to isolated multi-tenant workspaces based on their provider `tenantId`.
1718
- **Massive Scalability**: The backend acts as a lightweight router while heavy data processing is handled by scalable workers via Cloud Pub/Sub, preventing Gateway Timeouts on long jobs.
1819
- **Dynamic Configuration & Prompt Management**: An integrated Admin UI backed by a real-time Firestore synchronization engine allows operators to hot-swap Gemini LLM models, tune chunk sizes, and edit system AI prompts entirely on the fly without ever redeploying code.
1920
- **Graceful Job Cancellation**: Safely halt massive in-flight jobs via a UI cancel button. In-memory TTL caching on workers ensures instant cancellation without generating "ghost jobs" or burning Firestore read quotas.
21+
- **Custom Toast Notifications**: Uses non-blocking `react-hot-toast` popups instead of native browser alerts to provide users with a clean, modern experience when editing settings or executing administrative actions.
2022
- **Enterprise Observability & Billing**: Logs rich telemetry into Firestore (`job_audits`), tracking LLM Token Usage via atomic transactions, File Sizes, IP Addresses, and exact Job Runtimes to power strict rate limits and future billing models.
2123

2224
---

backend/src/api/routers/jobs.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ async def create_job(
3030
try:
3131
firestore_svc.create_job(
3232
job_id, request.file_path, request.file_name, request.target_schema, now,
33-
request.email, request.role, request.plan, user_id, ip_address
33+
request.email, request.role, request.plan, user_id, ip_address, request.is_preview
3434
)
3535
except Exception as e:
3636
raise HTTPException(status_code=500, detail=f"Failed to create job document: {str(e)}")
@@ -39,7 +39,7 @@ async def create_job(
3939
try:
4040
pubsub_svc.publish_job(
4141
job_id, request.file_path, request.target_schema,
42-
request.email, request.role, request.plan, user_id, ip_address
42+
request.email, request.role, request.plan, user_id, ip_address, request.is_preview
4343
)
4444
except Exception as e:
4545
# Mark as failed if publish fails
@@ -54,7 +54,8 @@ async def create_job(
5454
return {
5555
"job_id": job_id,
5656
"status": "queued",
57-
"message": "Job accepted and queued for processing."
57+
"message": "Job accepted and queued for processing.",
58+
"is_preview": request.is_preview
5859
}
5960

6061
@router.get("/{job_id}", response_model=JobStatusResponse)
@@ -77,7 +78,8 @@ async def get_job_status(
7778
processed_rows=data.get("processed_rows"),
7879
error_message=data.get("error_message"),
7980
total_chunks=data.get("total_chunks"),
80-
completed_chunks=data.get("completed_chunks")
81+
completed_chunks=data.get("completed_chunks"),
82+
is_preview=data.get("is_preview", False)
8183
)
8284

8385
@router.post("/{job_id}/cancel")

backend/src/models/schemas.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,13 @@ class JobRequest(BaseModel):
1717
role: Optional[str] = "guest"
1818
plan: Optional[str] = "free"
1919
user_id: Optional[str] = None
20+
is_preview: bool = False
2021

2122
class JobResponse(BaseModel):
2223
job_id: str
2324
status: str
2425
message: str
26+
is_preview: bool = False
2527

2628
class JobStatusResponse(BaseModel):
2729
job_id: str
@@ -32,3 +34,4 @@ class JobStatusResponse(BaseModel):
3234
error_message: Optional[str] = None
3335
total_chunks: Optional[int] = None
3436
completed_chunks: Optional[int] = None
37+
is_preview: Optional[bool] = False

backend/src/services/firestore.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ class FirestoreService:
88
def __init__(self, client: firestore.Client = None):
99
self.db = client or get_firestore_client()
1010

11-
def create_job(self, job_id: str, file_path: str, file_name: str, target_schema: dict, created_at: str, email: str = None, role: str = "guest", plan: str = "free", user_id: str = None, ip_address: str = None):
11+
def create_job(self, job_id: str, file_path: str, file_name: str, target_schema: dict, created_at: str, email: str = None, role: str = "guest", plan: str = "free", user_id: str = None, ip_address: str = None, is_preview: bool = False):
1212
job_ref = self.db.collection("jobs").document(job_id)
1313
job_data = {
1414
"job_id": job_id,
@@ -21,7 +21,8 @@ def create_job(self, job_id: str, file_path: str, file_name: str, target_schema:
2121
"role": role,
2222
"plan": plan,
2323
"user_id": user_id,
24-
"ip_address": ip_address
24+
"ip_address": ip_address,
25+
"is_preview": is_preview
2526
}
2627
if email:
2728
job_data["email"] = email

backend/src/services/pubsub.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ def __init__(self, publisher: pubsub_v1.PublisherClient = None):
1010
self.publisher = publisher or get_pubsub_publisher()
1111
self.topic_path = self.publisher.topic_path(settings.GOOGLE_CLOUD_PROJECT, settings.PUBSUB_TOPIC_ID)
1212

13-
def publish_job(self, job_id: str, file_path: str, target_schema: dict, email: str = None, role: str = "guest", plan: str = "free", user_id: str = None, ip_address: str = None):
13+
def publish_job(self, job_id: str, file_path: str, target_schema: dict, email: str = None, role: str = "guest", plan: str = "free", user_id: str = None, ip_address: str = None, is_preview: bool = False):
1414
message_data = json.dumps({
1515
"job_id": job_id,
1616
"file_path": file_path,
@@ -19,7 +19,8 @@ def publish_job(self, job_id: str, file_path: str, target_schema: dict, email: s
1919
"role": role,
2020
"plan": plan,
2121
"user_id": user_id,
22-
"ip_address": ip_address
22+
"ip_address": ip_address,
23+
"is_preview": is_preview
2324
}).encode("utf-8")
2425

2526
future = self.publisher.publish(self.topic_path, data=message_data)

backend/test_firestore.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import sys
2+
import json
3+
from dotenv import load_dotenv
4+
load_dotenv('.env')
5+
6+
from google.cloud import firestore
7+
8+
try:
9+
db = firestore.Client(project="structurify-504821")
10+
doc = db.collection('settings').document('system').get()
11+
if doc.exists:
12+
print(json.dumps(doc.to_dict(), indent=2))
13+
else:
14+
print("Document does not exist.")
15+
except Exception as e:
16+
print(f"Error: {e}")

backend/tests/conftest.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,14 @@ def generate_upload_url(self, file_path: str, content_type: str, expiration_minu
1111
return f"https://mock-storage.com/{file_path}"
1212

1313
class MockPubSubService:
14-
def publish_job(self, job_id: str, file_path: str, target_schema: dict, email: str = None, role: str = "guest", plan: str = "free", user_id: str = None, ip_address: str = None):
14+
def publish_job(self, job_id: str, file_path: str, target_schema: dict, email: str = None, role: str = "guest", plan: str = "free", user_id: str = None, ip_address: str = None, is_preview: bool = False):
1515
pass # mock success
1616

1717
class MockFirestoreService:
1818
def __init__(self):
1919
self.jobs = {}
2020

21-
def create_job(self, job_id: str, file_path: str, file_name: str, target_schema: dict, created_at: str, email: str = None, role: str = "guest", plan: str = "free", user_id: str = None, ip_address: str = None):
21+
def create_job(self, job_id: str, file_path: str, file_name: str, target_schema: dict, created_at: str, email: str = None, role: str = "guest", plan: str = "free", user_id: str = None, ip_address: str = None, is_preview: bool = False):
2222
self.jobs[job_id] = {
2323
"job_id": job_id,
2424
"status": "queued",
@@ -27,7 +27,7 @@ def create_job(self, job_id: str, file_path: str, file_name: str, target_schema:
2727
"role": role,
2828
"plan": plan,
2929
"user_id": user_id,
30-
"ip_address": ip_address
30+
"ip_address": ip_address, "is_preview": is_preview
3131
}
3232

3333
def update_job_status(self, job_id: str, status: str, error_message: str = None, updated_at: str = None):

backend/tests/test_jobs.py

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ def test_create_and_get_job(client, mock_firestore):
99
"plan": "pro"
1010
}
1111
response = client.post("/api/v1/jobs/", json=payload)
12-
assert response.status_code == 202
12+
assert response.status_code == 202, response.text
1313
data = response.json()
1414
assert "job_id" in data
1515
assert data["status"] == "queued"
@@ -22,6 +22,35 @@ def test_create_and_get_job(client, mock_firestore):
2222
job_data = response.json()
2323
assert job_data["job_id"] == job_id
2424
assert job_data["status"] == "queued"
25+
assert job_data["is_preview"] is False
26+
27+
def test_create_and_get_job_preview(client, mock_firestore):
28+
# Test Create Preview Job
29+
payload = {
30+
"file_path": "uploads/test.csv",
31+
"file_name": "test.csv",
32+
"target_schema": {"name": "String"},
33+
"email": "test@example.com",
34+
"role": "admin",
35+
"plan": "pro",
36+
"is_preview": True
37+
}
38+
response = client.post("/api/v1/jobs/", json=payload)
39+
assert response.status_code == 202, response.text
40+
data = response.json()
41+
assert "job_id" in data
42+
assert data["status"] == "queued"
43+
assert data["is_preview"] is True
44+
45+
job_id = data["job_id"]
46+
47+
# Test Get Job
48+
response = client.get(f"/api/v1/jobs/{job_id}")
49+
assert response.status_code == 200
50+
job_data = response.json()
51+
assert job_data["job_id"] == job_id
52+
assert job_data["status"] == "queued"
53+
assert job_data["is_preview"] is True
2554

2655
def test_get_nonexistent_job(client):
2756
response = client.get("/api/v1/jobs/invalid-id")

0 commit comments

Comments
 (0)