diff --git a/sample_solutions/DocQvision/.env.example b/sample_solutions/DocQvision/.env.example new file mode 100644 index 00000000..53b79a8e --- /dev/null +++ b/sample_solutions/DocQvision/.env.example @@ -0,0 +1,9 @@ +# Docker Compose Configuration + +# Local URL Endpoint (only needed for non-public domains) +# If using a local domain like api.example.com mapped to localhost, set to the domain without https:// +# Otherwise, set to: not-needed +LOCAL_URL_ENDPOINT=not-needed + +BACKEND_PORT=5001 +FRONTEND_PORT=3000 diff --git a/sample_solutions/DocQvision/.gitignore b/sample_solutions/DocQvision/.gitignore new file mode 100644 index 00000000..0bae1ed2 --- /dev/null +++ b/sample_solutions/DocQvision/.gitignore @@ -0,0 +1,70 @@ +# Environment variables and secrets +.env +.env.local +.env.*.local +*.env + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store +Thumbs.db + +# Logs +*.log +logs/ + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +tests/ + +# Node modules +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Docker +*.pid + +# Database files +*.db +*.sqlite +*.sqlite3 + +# Temporary files +*.tmp +*.bak +nul +tmp/ +temp/ diff --git a/sample_solutions/DocQvision/README.md b/sample_solutions/DocQvision/README.md new file mode 100644 index 00000000..3ccd384e --- /dev/null +++ b/sample_solutions/DocQvision/README.md @@ -0,0 +1,425 @@ +# DocQvision + +AI-powered document processing platform that extracts structured data from PDF documents using vision-language models. The system features conversational schema configuration through vision AI assistance, template management, and a multi-stage extraction pipeline that combines traditional pattern matching with vision model extraction as an intelligent fallback. + +## Table of Contents + +- [Project Overview](#project-overview) +- [Features](#features) +- [Architecture](#architecture) +- [Prerequisites](#prerequisites) +- [Quick Start Deployment](#quick-start-deployment) +- [User Interface](#user-interface) +- [Troubleshooting](#troubleshooting) + +--- + +## Project Overview + +DocQvision demonstrates how AI can automatically extract structured data from various document types including invoices, prescriptions, contracts, and forms. Users configure extraction templates through natural language conversation with vision AI, upload documents for batch processing, and export extracted data in multiple formats. The application supports GenAI Gateway and APISIX Gateway for enterprise deployments. + +--- + +## Features + +**Backend** + +- Multi-stage extraction pipeline with intelligent fallback (Traditional → Vision AI) +- Document type validation using vision models +- Conversational schema configuration with vision AI assistance +- Template system for reusable extraction configurations +- Batch document processing (up to 5 files simultaneously) +- Multiple export formats (JSON, CSV) +- Field coverage analysis and quality scoring +- Background job processing with real-time status updates +- Enterprise authentication support (GenAI Gateway, APISIX Gateway) +- SQLite database for persistence +- Document deduplication using SHA-256 hashing +- Comprehensive error handling and logging + +**Frontend** + +- Clean, intuitive interface with multi-page navigation +- Interactive PDF viewer with zoom and page navigation +- Drag-and-drop file upload with validation +- Conversational template configuration interface +- Real-time extraction progress monitoring +- Extraction history with filtering and search +- Template management dashboard +- Re-extraction capability for failed jobs +- Mobile-responsive design with Tailwind CSS + +--- + +## Architecture + +```mermaid +graph TB + subgraph "Client Layer" + A[React Web UI
Nginx
Port 3000] + end + + subgraph "Backend Service" + B[FastAPI Backend
Port 5001] + B1[Extraction Pipeline] + B2[Vision Service] + B3[SQLite Database] + B --> B1 + B --> B2 + B --> B3 + end + + subgraph "External Services" + C[GenAI Gateway / APISIX Gateway
Vision Models] + end + + A -->|Upload PDF| B + A -->|Configure Template Chat| B + A -->|Request Extraction| B + B -->|Chat Processing| B2 + B2 -->|Vision API Call| C + C -->|Schema Suggestions| B2 + B -->|Start Extraction Job| B1 + B1 -->|Traditional Pattern Match| B1 + B1 -->|Fallback if Coverage Low| B2 + C -->|Extracted Data| B2 + B1 -->|Store Templates & Results| B3 + B -->|JSON/CSV Response| A + + style A fill:#e1f5ff + style B fill:#fff4e1 + style B1 fill:#ffe1f5 + style B2 fill:#ffe1f5 + style B3 fill:#f5e1ff + style C fill:#e1ffe1 +``` + +**Service Components** + +1. **React Web UI (Port 3000)** + - Modern React application with responsive styling + - Handles user interactions and file uploads + - Interactive PDF preview with zoom controls + - Real-time extraction status monitoring + - Served via Nginx + +2. **Backend Service (Port 5001)** + - FastAPI-based modular architecture + - Multi-stage extraction pipeline (Traditional → Vision AI) + - Document type validation before extraction + - Template and document management with SQLite persistence + - Background job processing + - Field coverage scoring system + +3. **External Services** + - GenAI Gateway or APISIX Gateway with API key authentication + - Vision-language models for extraction and validation + +**Typical Flow:** + +1. User configures extraction template through conversational interface with vision AI +2. AI assistant guides user to define fields and data types +3. User tests extraction on sample document +4. Template is saved for reuse +5. User selects template and uploads documents for batch processing +6. Backend validates document type matches template +7. Extraction pipeline processes documents page-by-page (traditional first, vision AI fallback) +8. Results are displayed with coverage metrics and export options +9. User can view history and re-run failed extractions + +--- + +## Prerequisites + +### System Requirements + +Before you begin, ensure you have the following installed: + +- **Docker and Docker Compose** +- **GenAI Gateway** or **APISIX Gateway** access configured + +### Verify Docker Installation + +```bash +# Check Docker version +docker --version + +# Check Docker Compose version +docker compose version + +# Verify Docker is running +docker ps +``` + +### Required API Configuration + +**For Inference Service (Document Extraction):** + +This application supports multiple inference deployment patterns: + +**GenAI Gateway**: Provide your GenAI Gateway URL and API key +- **URL format**: `https://api.example.com` +- To generate the GenAI Gateway API key, use the [generate-vault-secrets.sh](https://github.com/opea-project/Enterprise-Inference/blob/main/core/scripts/generate-vault-secrets.sh) script +- The API key is the `litellm_master_key` value from the generated `vault.yml` file + +**APISIX Gateway**: Provide your APISIX Gateway URL and authentication token +- **URL format**: `https://api.example.com/Qwen2.5-VL-7B-Instruct` +- **Note**: APISIX requires the model name in the URL path (without company/family prefixes) +- To generate the APISIX authentication token, use the [generate-token.sh](https://github.com/opea-project/Enterprise-Inference/blob/main/core/scripts/generate-token.sh) script +- The token is generated using Keycloak client credentials + +**Configuration requirements:** +- **INFERENCE_API_ENDPOINT**: URL to your inference service (example: `https://api.example.com`) +- **INFERENCE_API_TOKEN**: Authentication token/API key for your chosen service + +--- + +## Quick Start Deployment + +### Clone the Repository + +```bash +git clone https://github.com/cld2labs/GenAISamples.git +cd GenAISamples/DocQvision +``` + +### Set up the Environment + +This application requires **two `.env` files** for proper configuration: + +1. **Root `.env` file** (for Docker Compose variables) +2. **`api/.env` file** (for backend application configuration) + +#### Step 1: Create Root `.env` File + +```bash +# From the DocQvision directory +cat > .env << EOF +# Docker Compose Configuration +LOCAL_URL_ENDPOINT=not-needed +EOF +``` + +**Note:** If using a local domain (e.g., `api.example.com` mapped to localhost), replace `not-needed` with your domain name (without `https://`). + +#### Step 2: Create `api/.env` File + +Copy from the example file and edit with your actual credentials: + +```bash +cp api/.env.example api/.env +``` + +Then edit `api/.env` to set your `INFERENCE_API_ENDPOINT` and `INFERENCE_API_TOKEN`. + +Or manually create `api/.env` with: + +```bash +# ============================================================================= +# DocQvision Configuration +# ============================================================================= + +# Inference API Configuration +# INFERENCE_API_ENDPOINT: URL to your inference service (without /v1 suffix) +# +# **GenAI Gateway**: Provide your GenAI Gateway URL and API key +# - URL format: https://api.example.com +# - To generate the GenAI Gateway API key, use the [generate-vault-secrets.sh] script +# - The API key is the litellm_master_key value from the generated vault.yml file +# +# **APISIX Gateway**: Provide your APISIX Gateway URL and authentication token +# - URL format: https://api.example.com/Qwen2.5-VL-7B-Instruct +# - Note: APISIX requires the model name in the URL path (without company/family prefixes) +# - To generate the APISIX authentication token, use the [generate-token.sh] script +# - The token is generated using Keycloak client credentials +# +# INFERENCE_API_TOKEN: Authentication token/API key for the inference service +INFERENCE_API_ENDPOINT=https://api.example.com +INFERENCE_API_TOKEN=your-pre-generated-token-here + +# Docker Network Configuration +# LOCAL_URL_ENDPOINT: Required if using local domain mapping (e.g., api.example.com -> localhost) +# Set to your domain name (without https://) or leave as "not-needed" if using public URLs +LOCAL_URL_ENDPOINT=not-needed + +# Vision Model Configuration +VISION_MODEL=Qwen/Qwen2.5-VL-7B-Instruct +DETECTION_MODEL=Qwen/Qwen2.5-VL-7B-Instruct +VISION_MAX_TOKENS=4000 +VISION_TEMPERATURE=0.1 + +# File Upload Limits +MAX_UPLOAD_MB=10 +MAX_PDF_PAGES=50 +MAX_BATCH_UPLOAD=5 + +# Extraction Pipeline Configuration +EXTRACTION_COVERAGE_THRESHOLD=0.8 +VISION_MAX_PAGES=5 + +# Service Configuration +LOG_LEVEL=INFO +ENVIRONMENT=production + +# CORS Settings +CORS_ORIGINS=http://localhost:3000,http://localhost:5173 + +# Security Configuration +# SSL Verification: Set to false only for development with self-signed certificates +VERIFY_SSL=true +``` + +**Important Configuration Notes:** + +- **INFERENCE_API_ENDPOINT**: Your actual inference service URL (replace `https://api.example.com`) + - For APISIX/Keycloak deployments, the model name must be included in the endpoint URL (e.g., `https://api.example.com/Qwen2.5-VL-7B-Instruct`) +- **INFERENCE_API_TOKEN**: Your actual pre-generated authentication token +- **VISION_MODEL** and **DETECTION_MODEL**: Use the exact model names from your inference service +- **LOCAL_URL_ENDPOINT**: Only needed if using local domain mapping + +**Note**: The docker-compose.yml file automatically loads environment variables from both `.env` (root) and `./api/.env` (backend) files. + +### Running the Application + +Start both API and UI services together with Docker Compose: + +```bash +# From the DocQvision directory +docker compose up --build + +# Or run in detached mode (background) +docker compose up -d --build +``` + +What happens during deployment: +- Docker builds images for frontend and backend (first time: 3-5 minutes) +- Creates containers for both services +- Sets up networking between services +- Initializes SQLite database + +### Verify Deployment + +Check that all containers are running: + +```bash +docker compose ps +``` + +Expected output - You should see 2 containers with status "Up": + +| Container Name | Port | Status | +|----------------|------|--------| +| `DocQvision-backend` | 5001 | Up (healthy) | +| `DocQvision-frontend` | 3000 | Up (healthy) | + +If any container shows "Restarting" or "Exited", check logs: + +```bash +docker compose logs -f +``` + +**View logs:** + +```bash +# All services +docker compose logs -f + +# Backend only +docker compose logs -f DocQvision-backend + +# Frontend only +docker compose logs -f DocQvision-frontend +``` + +**Verify the services are running:** + +```bash +# Check API health +curl http://localhost:5001/health + +# Check if containers are running +docker compose ps +``` + +### Local Development (Without Docker) + +For local development with hot reload: + +**Backend:** +```bash +cd api +pip install -r requirements.txt +python main.py +``` + +**Frontend:** +```bash +cd ui +npm install +npm run dev +``` + +The backend will run on http://localhost:5001 and frontend on http://localhost:5173 + +## User Interface + +**Using the Application** + +Access the application at http://localhost:3000 + +### Configure Template Page + +Define extraction fields through conversational vision AI interface. + +![Configure Template - Initial View](./assets/configure-initial.png) + +![Configure Template - Chat Interface](./assets/configure-chat.png) + +**Steps:** +1. Upload a sample PDF document. +2. Chat with vision AI to define fields (e.g., "client Company name, client representative name, patient name, invoice number, etc.") +3. Review configured schema. +4. Test extraction on sample document. +5. Save template for reuse. + +![Configure Template - Test Results](./assets/configure-test-results.png) + +### Upload Documents Page + +Process single or multiple documents with selected template. + +![Upload Page - Batch Processing](./assets/upload-page.png) + +**Steps:** +1. Select document template from dropdown +2. Upload 1-5 PDF files (drag-and-drop or file picker) +3. Click "Upload & Extract" +4. Monitor real-time extraction progress +5. View results or download JSON/CSV + + +### History Page + +Browse past extraction jobs with filtering and search. + +![History Page - Extraction History](./assets/history-page.png) + +**Features:** +- Filter by template or status +- View extraction metadata (coverage, processing time) +- Re-run failed extractions +- Export results to JSON or CSV +- Delete old extraction records +- Bulk operations on multiple records + +### API Documentation + +Interactive API documentation available at: +- **Swagger UI**: http://localhost:5001/docs +- **ReDoc**: http://localhost:5001/redoc + +## Troubleshooting + +For comprehensive troubleshooting guidance, common issues, and solutions, refer to: + +[TROUBLESHOOTING.md](./TROUBLESHOOTING.md) diff --git a/sample_solutions/DocQvision/TROUBLESHOOTING.md b/sample_solutions/DocQvision/TROUBLESHOOTING.md new file mode 100644 index 00000000..480e8c14 --- /dev/null +++ b/sample_solutions/DocQvision/TROUBLESHOOTING.md @@ -0,0 +1,468 @@ +# Troubleshooting Guide + +## Common Issues + +### 1. Containers Not Starting + +**Symptom**: Containers fail to start or exit immediately + +**Check container status:** +```bash +docker compose ps +``` + +**View error logs:** +```bash +docker compose logs backend +docker compose logs frontend +``` + +**Solution:** +```bash +# Rebuild containers +docker compose down +docker compose up -d --build +``` + +### 2. Backend Connection Errors + +**Symptom**: Frontend shows "Failed to connect" or network errors + +**Check backend health:** +```bash +curl http://localhost:5001/health +``` + +**Expected response:** +```json +{"status": "healthy", "auth_mode": "genai_gateway"} +``` + +**Solution:** +- Verify backend container is running: `docker compose ps` +- Check backend logs: `docker compose logs backend -f` +- Restart backend: `docker compose restart backend` + +### 3. Authentication Errors + +**Symptom**: Extraction fails with authentication errors + +**Error**: `AuthenticationError` or `Invalid API key` + +**Solution:** +- Check `GENAI_GATEWAY_API_KEY` in `api/.env` +- Verify API key is active and has proper permissions +- For Keycloak: Verify client secret is correct + +**Error**: `Connection refused` or `Connection timeout` + +**Solution:** +- Verify `GENAI_GATEWAY_URL` is correct in `api/.env` +- For Keycloak: Ensure `KEYCLOAK_BASE_URL` points to correct realm +- Test endpoint directly: +```bash +curl https://your-gateway-url.com/v1/models +``` + +### 4. Vision Model Errors + +**Symptom**: Extraction fails at vision stage + +**Error**: `Model not found` or `Model unavailable` + +**Solution:** +- Verify `VISION_MODEL` is deployed and accessible +- Check model name spelling in `api/.env` +- Confirm GenAI Gateway has vision model deployed +- For Keycloak: Verify inference endpoint supports vision models + +**Error**: `Rate limit exceeded` or `Quota exceeded` + +**Solution:** +- Wait a few minutes and retry +- Check API usage limits with administrator +- Reduce `MAX_BATCH_UPLOAD` to process fewer files at once + +### 5. PDF Upload Errors + +**Symptom**: PDF upload fails or returns validation errors + +**Error**: `Invalid PDF file` or `File validation failed` + +**Causes:** +- Corrupted PDF file +- Password-protected PDF +- Exceeds 10MB size limit +- Exceeds 50 pages limit + +**Solution:** +- Verify PDF opens in standard PDF viewer +- Remove password protection +- Compress large PDF files +- Split multi-page documents +- Check backend logs for specific error + +### 6. Document Type Mismatch + +**Symptom**: Extraction fails with document type validation error + +**Error**: `Document type mismatch detected` + +**Causes:** +- Selected wrong template for document +- Document type doesn't match template configuration +- Low confidence in document type detection + +**Solution:** +- Verify correct template is selected +- Check document matches expected type (invoice, prescription, etc.) +- Upload correct document type or create new template +- For test/debugging: Use template with doc_type "test" to skip validation + +### 7. Low Extraction Coverage + +**Symptom**: Extraction completes but many fields are empty + +**Possible causes:** +- Document quality is poor +- Fields not present in document +- Traditional extraction failed, vision model struggled +- Document layout significantly different from template + +**Solution:** +- Verify document contains all expected fields +- Use high-quality, clear PDF documents +- Check if document type matches template +- Review template schema matches document structure +- Test extraction on sample document in Configure page + +### 8. Frontend Not Loading + +**Symptom**: Browser shows blank page or cannot connect + +**Check frontend status:** +```bash +docker compose ps frontend +``` + +**Check frontend logs:** +```bash +docker compose logs frontend -f +``` + +**Solution:** +- Clear browser cache and hard refresh (Ctrl+F5) +- Verify port 3000 is not in use by another application +- Restart frontend: `docker compose restart frontend` +- Check firewall settings +- Try accessing from different browser + +### 9. PDF Preview Not Working + +**Symptom**: PDF preview shows error or doesn't load + +**Error**: `Failed to load PDF file` + +**Solution:** +- Verify PDF file is valid and not corrupted +- Check file size is under 10MB +- Ensure browser supports PDF.js +- Clear browser cache +- Check browser console for errors + +### 10. Template Configuration Fails + +**Symptom**: Chat-based configuration not responding or returning errors + +**Error**: `Failed to process message` or `Chat response failed` + +**Solution:** +- Check vision model is configured correctly +- Verify authentication is working +- Review backend logs for specific errors +- Try simpler field descriptions +- Ensure PDF is uploaded before chatting + +### 11. Test Extraction Fails with 422 Error + +**Symptom**: Test extraction button returns 422 error + +**Error**: `Failed to test extraction: Request failed with status code 422` + +**Causes:** +- Invalid schema structure +- Missing required fields in template +- Backend validation error + +**Solution:** +- Check backend logs for validation details +- Ensure all fields have proper type definitions +- Restart backend: `docker compose restart backend` +- Try creating a simpler template first + +### 12. Batch Upload Failures + +**Symptom**: Some files in batch fail while others succeed + +**Check error details:** +- Review individual file error messages in UI +- Check backend logs for specific file failures + +**Common causes:** +- Individual file validation errors +- Mixed document types in batch +- One or more files corrupted +- Size or page limit exceeded on specific files + +**Solution:** +- Upload failed files individually to see specific errors +- Ensure all files are same document type +- Verify each file meets requirements (size, format, pages) +- Process problem files separately + +### 13. Port Already in Use + +**Error**: `Port 3000 is already allocated` or `Port 5001 is already allocated` + +**Find process using port:** +```bash +# Windows +netstat -ano | findstr :3000 +netstat -ano | findstr :5001 + +# Linux/Mac +lsof -i :3000 +lsof -i :5001 +``` + +**Solution:** +- Stop the conflicting process +- Or change ports in `docker-compose.yml` + +### 14. Database Errors + +**Symptom**: Backend fails with database operation errors + +**Error**: `Database is locked` or `OperationalError` + +**Solution:** +```bash +# Stop containers +docker compose down + +# Remove database volume +docker volume rm DocQvision_db_data + +# Restart +docker compose up -d --build +``` + +**Warning**: This will delete all templates and extraction history + +### 15. Out of Memory Errors + +**Symptom**: Container crashes or backend becomes unresponsive + +**Check logs:** +```bash +docker compose logs backend | grep -i "memory\|killed" +``` + +**Solution:** +- Reduce `MAX_BATCH_UPLOAD` from 5 to 2-3 files +- Process smaller PDF files +- Reduce `VISION_MAX_PAGES` from 5 to 2-3 pages +- Increase Docker memory limit in Docker Desktop settings +- Reduce `VISION_MAX_TOKENS` if using large context + +### 16. CORS Errors + +**Symptom**: Browser console shows CORS policy errors + +**Error**: `Access to fetch has been blocked by CORS policy` + +**Solution:** +- Verify backend is running on port 5001 +- Check `CORS_ORIGINS` in `api/.env` includes frontend URL +- Ensure frontend is accessing correct backend URL +- Restart both containers after configuration changes + +### 17. Session Lost on Refresh + +**Symptom**: Template configuration session lost when page refreshes + +**Explanation:** +- Configure page saves session to browser localStorage +- Data persists across page refreshes +- Intentional design for resume capability + +**Solution:** +- Use "New Template" button to start fresh session +- Browser prompts to continue or start new when previous session found +- Clear browser localStorage to reset completely + +### 18. Extraction Returns Incomplete Data + +**Symptom**: Some fields extracted correctly, others missing + +**Common causes:** +- Fields located in different sections of document +- Vision model only processed first few pages +- Field names don't match document structure + +**Solution:** +- Increase `VISION_MAX_PAGES` to process more pages +- Verify field names match actual document headers +- Use more specific field descriptions in template +- Check if missing fields are on later pages + +## Configuration Issues + +### Invalid .env Configuration + +**Symptom**: Backend fails to start with configuration errors + +**Check required variables in `api/.env`:** +```bash +AUTH_MODE=genai_gateway +GENAI_GATEWAY_URL=https://your-gateway-url.com/v1 +GENAI_GATEWAY_API_KEY=your-api-key-here +VISION_MODEL=Qwen/Qwen2.5-VL-7B-Instruct +``` + +**Common mistakes:** +- Missing required variables +- Extra spaces in variable names +- Wrong endpoint format (missing /v1) +- Quotes around values (not needed) +- Wrong AUTH_MODE value + +### Authentication Mode Configuration + +**For GenAI Gateway:** +```bash +AUTH_MODE=genai_gateway +GENAI_GATEWAY_URL=https://your-gateway-url.com/v1 +GENAI_GATEWAY_API_KEY=your-api-key-here +VISION_MODEL=Qwen/Qwen2.5-VL-7B-Instruct +DETECTION_MODEL=Qwen/Qwen2.5-VL-7B-Instruct +``` + +**For Keycloak:** +```bash +AUTH_MODE=keycloak +KEYCLOAK_BASE_URL=https://your-keycloak-url.com/realms/master/protocol/openid-connect +KEYCLOAK_REALM=master +KEYCLOAK_CLIENT_ID=api +KEYCLOAK_CLIENT_SECRET=your-client-secret-here +VISION_MODEL=Qwen/Qwen2.5-VL-7B-Instruct +DETECTION_MODEL=Qwen/Qwen2.5-VL-7B-Instruct +``` + +## Advanced Troubleshooting + +### Enable Debug Logging + +Edit `api/.env`: +```bash +LOG_LEVEL=DEBUG +``` + +Restart backend: +```bash +docker compose restart backend +docker compose logs backend -f +``` + +### Test Backend Directly + +**Test health endpoint:** +```bash +curl http://localhost:5001/health +``` + +**Test template creation:** +```bash +curl -X POST http://localhost:5001/api/templates \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Test Template", + "doc_type": "test", + "schema_json": { + "field1": {"type": "string", "required": true}, + "field2": {"type": "number", "required": false} + } + }' +``` + +**Test document upload:** +```bash +curl -X POST http://localhost:5001/api/documents/upload \ + -F "file=@test.pdf" +``` + +### Inspect Container + +**Access backend container shell:** +```bash +docker compose exec backend /bin/bash +``` + +**Check Python environment:** +```bash +docker compose exec backend pip list +docker compose exec backend python -c "import pypdf; print(pypdf.__version__)" +``` + +**Check database:** +```bash +docker compose exec backend python -c " +from database import engine +from sqlalchemy import inspect +inspector = inspect(engine) +print('Tables:', inspector.get_table_names()) +" +``` + +### Clean Docker Environment + +If issues persist, clean Docker completely: + +```bash +# Stop and remove containers +docker compose down -v + +# Remove unused images +docker system prune -a + +# Rebuild from scratch +docker compose up -d --build +``` + +## Getting Help + +If issues persist after following this guide: + +1. **Collect Information:** + - Docker logs: `docker compose logs > logs.txt` + - Docker status: `docker compose ps` + - Environment: `docker compose config` + - Backend health: `curl http://localhost:5001/health` + +2. **Check Configuration:** + - Review `api/.env` file + - Verify API keys/credentials are valid + - Test authentication endpoint independently + - Check vision model is deployed + +3. **Try Minimal Setup:** + - Use fresh `.env` configuration + - Test with simple document (single page invoice) + - Verify extraction works on known good document + - Check if issue persists with minimal config + +4. **System Information:** + - Docker version: `docker --version` + - Docker Compose version: `docker compose version` + - Operating system and version + - Available memory and disk space diff --git a/sample_solutions/DocQvision/api/.dockerignore b/sample_solutions/DocQvision/api/.dockerignore new file mode 100644 index 00000000..719e5044 --- /dev/null +++ b/sample_solutions/DocQvision/api/.dockerignore @@ -0,0 +1,76 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual environments +venv/ +env/ +ENV/ +.venv + +# Environment variables +.env +.env.local +.env.*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ + +# Database +*.db +*.sqlite +*.sqlite3 +data/ + +# Logs +*.log +logs/ + +# Documentation +*.md +docs/ + +# Git +.git/ +.gitignore +.gitattributes + +# Docker +Dockerfile* +docker-compose*.yml +.dockerignore + +# Temporary files +*.tmp +*.bak +*.swp +.cache/ diff --git a/sample_solutions/DocQvision/api/.env.example b/sample_solutions/DocQvision/api/.env.example new file mode 100644 index 00000000..6396937d --- /dev/null +++ b/sample_solutions/DocQvision/api/.env.example @@ -0,0 +1,52 @@ +# ============================================================================= +# DocQvision Configuration +# ============================================================================= + +# Inference API Configuration +# INFERENCE_API_ENDPOINT: URL to your inference service (without /v1 suffix) +# +# **GenAI Gateway**: Provide your GenAI Gateway URL and API key +# - URL format: https://api.example.com +# - To generate the GenAI Gateway API key, use the [generate-vault-secrets.sh](https://github.com/opea-project/Enterprise-Inference/blob/main/core/scripts/generate-vault-secrets.sh) script +# - The API key is the litellm_master_key value from the generated vault.yml file +# +# **APISIX Gateway**: Provide your APISIX Gateway URL and authentication token +# - URL format: https://api.example.com/Qwen2.5-VL-7B-Instruct +# - Note: APISIX requires the model name in the URL path (without company/family prefixes) +# - To generate the APISIX authentication token, use the [generate-token.sh](https://github.com/opea-project/Enterprise-Inference/blob/main/core/scripts/generate-token.sh) script +# - The token is generated using Keycloak client credentials +# +# INFERENCE_API_TOKEN: Authentication token/API key for the inference service +INFERENCE_API_ENDPOINT=https://api.example.com +INFERENCE_API_TOKEN=your-pre-generated-token-here + +# Docker Network Configuration +# LOCAL_URL_ENDPOINT: Required if using local domain mapping (e.g., api.example.com -> localhost) +# Set to your domain name (without https://) or leave as "not-needed" if using public URLs +LOCAL_URL_ENDPOINT=not-needed + +# Vision Model Configuration +VISION_MODEL=Qwen/Qwen2.5-VL-7B-Instruct +DETECTION_MODEL=Qwen/Qwen2.5-VL-7B-Instruct +VISION_MAX_TOKENS=4000 +VISION_TEMPERATURE=0.1 + +# File Upload Limits +MAX_UPLOAD_MB=10 +MAX_PDF_PAGES=50 +MAX_BATCH_UPLOAD=5 + +# Extraction Pipeline Configuration +EXTRACTION_COVERAGE_THRESHOLD=0.8 +VISION_MAX_PAGES=5 + +# Service Configuration +LOG_LEVEL=INFO +ENVIRONMENT=production + +# CORS Settings +CORS_ORIGINS=http://localhost:3000,http://localhost:5173 + +# Security Configuration +# SSL Verification: Set to false only for development with self-signed certificates +VERIFY_SSL=true diff --git a/sample_solutions/DocQvision/api/Dockerfile b/sample_solutions/DocQvision/api/Dockerfile new file mode 100644 index 00000000..3160f6fb --- /dev/null +++ b/sample_solutions/DocQvision/api/Dockerfile @@ -0,0 +1,73 @@ +# ============================================================================= +# DocQvision API - Production Dockerfile +# ============================================================================= + +FROM python:3.11-slim as base + +# Set environment variables +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app + +# ============================================================================= +# Builder stage - Install dependencies +# ============================================================================= +FROM base as builder + +# Install system dependencies required for PDF processing and Python packages +RUN apt-get update && apt-get install -y --no-install-recommends \ + # Required for PDF processing + poppler-utils \ + # Required for building Python packages + gcc \ + g++ \ + # Required for healthcheck + curl \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements first for better layer caching +COPY requirements.txt . + +# Install Python dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# ============================================================================= +# Runtime stage - Create minimal production image +# ============================================================================= +FROM base as runtime + +# Install only runtime system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + poppler-utils \ + curl \ + && rm -rf /var/lib/apt/lists/* \ + && apt-get clean + +# Create non-root user for security +RUN groupadd -r appuser && useradd -r -g appuser appuser + +# Copy Python dependencies from builder +COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages +COPY --from=builder /usr/local/bin /usr/local/bin + +# Create directory for SQLite database with proper permissions +RUN mkdir -p /app/data && chown -R appuser:appuser /app/data + +# Copy application code +COPY --chown=appuser:appuser . . + +# Switch to non-root user +USER appuser + +# Expose application port +EXPOSE 5001 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \ + CMD curl -f http://localhost:5001/health || exit 1 + +# Run the application +CMD ["python", "main.py"] diff --git a/sample_solutions/DocQvision/api/config.py b/sample_solutions/DocQvision/api/config.py new file mode 100644 index 00000000..fd3835bc --- /dev/null +++ b/sample_solutions/DocQvision/api/config.py @@ -0,0 +1,61 @@ +""" +Application configuration management. + +Supports GenAI Gateway, APISIX Gateway, and any OpenAI-compatible inference endpoint. +""" + +import os +from typing import Optional +from dotenv import load_dotenv + +load_dotenv() + + +# Inference API Configuration +# Supports multiple inference deployment patterns: +# - GenAI Gateway: Provide your GenAI Gateway URL and API key +# - APISIX Gateway: Provide your APISIX Gateway URL and authentication token +INFERENCE_API_ENDPOINT: Optional[str] = os.getenv("INFERENCE_API_ENDPOINT") +INFERENCE_API_TOKEN: Optional[str] = os.getenv("INFERENCE_API_TOKEN") + +# Security Configuration +VERIFY_SSL = os.getenv("VERIFY_SSL", "true").lower() in ("true", "1", "yes") + +# Database Configuration +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./DocQvision.db") + +# Application Settings +LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") +ENVIRONMENT = os.getenv("ENVIRONMENT", "development") +CORS_ORIGINS = os.getenv("CORS_ORIGINS", "*") + +# Vision Model Configuration +VISION_MODEL = os.getenv("VISION_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct") +VISION_MAX_TOKENS = int(os.getenv("VISION_MAX_TOKENS", "4000")) +VISION_TEMPERATURE = float(os.getenv("VISION_TEMPERATURE", "0.1")) + +# Document Type Detection Model +DETECTION_MODEL = os.getenv("DETECTION_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct") + +# File Upload Limits +MAX_FILE_SIZE = int(os.getenv("MAX_UPLOAD_MB", "10")) * 1024 * 1024 +MAX_PDF_PAGES = int(os.getenv("MAX_PDF_PAGES", "50")) +MAX_BATCH_UPLOAD = int(os.getenv("MAX_BATCH_UPLOAD", "5")) +ALLOWED_EXTENSIONS = {".pdf"} + +# Extraction Pipeline Configuration +EXTRACTION_COVERAGE_THRESHOLD = float(os.getenv("EXTRACTION_COVERAGE_THRESHOLD", "0.8")) +VISION_MAX_PAGES = int(os.getenv("VISION_MAX_PAGES", "5")) # Increased to handle multi-page documents + + +def validate_auth_config() -> None: + """ + Validate authentication configuration on startup + + Raises: + ValueError: If required configuration is missing + """ + if not INFERENCE_API_ENDPOINT: + raise ValueError("INFERENCE_API_ENDPOINT is required") + if not INFERENCE_API_TOKEN: + raise ValueError("INFERENCE_API_TOKEN is required") diff --git a/sample_solutions/DocQvision/api/crud.py b/sample_solutions/DocQvision/api/crud.py new file mode 100644 index 00000000..3dce7b3e --- /dev/null +++ b/sample_solutions/DocQvision/api/crud.py @@ -0,0 +1,334 @@ +""" +CRUD operations for database models. + +Provides create, read, update, delete operations for templates, documents, +and extraction results. +""" + +from sqlalchemy.orm import Session +from typing import Optional, List, Dict, Any +import models +import schemas +import uuid +import hashlib +from datetime import datetime + + +# Template CRUD operations + +def create_template(db: Session, template: schemas.TemplateCreate) -> models.Template: + """ + Create a new template in database. + + Args: + db: Database session + template: Template creation data + + Returns: + Created template model instance + """ + template_id = str(uuid.uuid4()) + db_template = models.Template( + id=template_id, + name=template.name, + doc_type=template.doc_type, + schema_json={k: v.model_dump() for k, v in template.schema_json.items()} + ) + db.add(db_template) + db.commit() + db.refresh(db_template) + return db_template + + +def get_template(db: Session, template_id: str) -> Optional[models.Template]: + """ + Retrieve template by ID. + + Args: + db: Database session + template_id: Template identifier + + Returns: + Template model instance or None if not found + """ + return db.query(models.Template).filter(models.Template.id == template_id).first() + + +def get_templates(db: Session, skip: int = 0, limit: int = 100) -> List[models.Template]: + """ + Retrieve all templates with pagination. + + Args: + db: Database session + skip: Number of records to skip + limit: Maximum number of records to return + + Returns: + List of template model instances + """ + return db.query(models.Template).order_by(models.Template.created_at.desc()).offset(skip).limit(limit).all() + + +def get_templates_by_doc_type(db: Session, doc_type: str) -> List[models.Template]: + """ + Retrieve templates matching a specific document type. + + Args: + db: Database session + doc_type: Document type to filter by + + Returns: + List of template model instances matching the document type + """ + normalized_doc_type = doc_type.lower().replace("_", "").replace("-", "") + + all_templates = db.query(models.Template).all() + matching_templates = [] + + for template in all_templates: + if template.doc_type: + normalized_template_type = template.doc_type.lower().replace("_", "").replace("-", "") + if normalized_template_type == normalized_doc_type: + matching_templates.append(template) + + return sorted(matching_templates, key=lambda t: t.created_at, reverse=True) + + +def update_template(db: Session, template_id: str, template_update: schemas.TemplateUpdate) -> Optional[models.Template]: + """ + Update existing template. + + Args: + db: Database session + template_id: Template identifier + template_update: Fields to update + + Returns: + Updated template model instance or None if not found + """ + db_template = get_template(db, template_id) + if not db_template: + return None + + update_data = template_update.model_dump(exclude_unset=True) + if 'schema_json' in update_data and update_data['schema_json']: + update_data['schema_json'] = {k: v.model_dump() for k, v in update_data['schema_json'].items()} + + for field, value in update_data.items(): + setattr(db_template, field, value) + + db.commit() + db.refresh(db_template) + return db_template + + +def delete_template(db: Session, template_id: str) -> bool: + """ + Delete template by ID. + + Args: + db: Database session + template_id: Template identifier + + Returns: + True if deleted, False if not found + """ + db_template = get_template(db, template_id) + if not db_template: + return False + + db.delete(db_template) + db.commit() + return True + + +# Document CRUD operations + +def create_document(db: Session, filename: str, file_content: bytes, page_count: Optional[int] = None) -> models.Document: + """ + Create a new document in database. + + Args: + db: Database session + filename: Original filename + file_content: Binary PDF content + page_count: Number of pages in PDF + + Returns: + Created document model instance + """ + document_id = str(uuid.uuid4()) + sha256_hash = hashlib.sha256(file_content).hexdigest() + + db_document = models.Document( + id=document_id, + filename=filename, + file_size=len(file_content), + page_count=page_count, + sha256_hash=sha256_hash, + file_content=file_content + ) + db.add(db_document) + db.commit() + db.refresh(db_document) + return db_document + + +def get_document(db: Session, document_id: str) -> Optional[models.Document]: + """ + Retrieve document by ID. + + Args: + db: Database session + document_id: Document identifier + + Returns: + Document model instance or None if not found + """ + return db.query(models.Document).filter(models.Document.id == document_id).first() + + +def get_document_by_hash(db: Session, sha256_hash: str) -> Optional[models.Document]: + """ + Retrieve document by SHA-256 hash for deduplication. + + Args: + db: Database session + sha256_hash: SHA-256 hash of file content + + Returns: + Document model instance or None if not found + """ + return db.query(models.Document).filter(models.Document.sha256_hash == sha256_hash).first() + + +# Extraction Result CRUD operations + +def create_extraction_result( + db: Session, + document_id: str, + template_id: str +) -> models.ExtractionResult: + """ + Create a new extraction result record with pending status. + + Args: + db: Database session + document_id: Document identifier + template_id: Template identifier + + Returns: + Created extraction result model instance + """ + result_id = str(uuid.uuid4()) + db_result = models.ExtractionResult( + id=result_id, + document_id=document_id, + template_id=template_id, + status=models.ExtractionStatus.PENDING + ) + db.add(db_result) + db.commit() + db.refresh(db_result) + return db_result + + +def update_extraction_result( + db: Session, + result_id: str, + status: Optional[models.ExtractionStatus] = None, + stage_used: Optional[models.ExtractionStage] = None, + extracted_data: Optional[Dict[str, Any]] = None, + field_coverage_percent: Optional[float] = None, + processing_time_ms: Optional[int] = None, + model_used: Optional[str] = None, + error_message: Optional[str] = None +) -> Optional[models.ExtractionResult]: + """ + Update extraction result with processing results. + + Args: + db: Database session + result_id: Extraction result identifier + status: Job status + stage_used: Pipeline stage that produced result + extracted_data: Extracted field values + field_coverage_percent: Coverage percentage + processing_time_ms: Processing time + model_used: Model identifier + error_message: Error details + + Returns: + Updated extraction result model instance or None if not found + """ + db_result = db.query(models.ExtractionResult).filter(models.ExtractionResult.id == result_id).first() + if not db_result: + return None + + if status is not None: + db_result.status = status + if stage_used is not None: + db_result.stage_used = stage_used + if extracted_data is not None: + db_result.extracted_data = extracted_data + if field_coverage_percent is not None: + db_result.field_coverage_percent = field_coverage_percent + if processing_time_ms is not None: + db_result.processing_time_ms = processing_time_ms + if model_used is not None: + db_result.model_used = model_used + if error_message is not None: + db_result.error_message = error_message + + db.commit() + db.refresh(db_result) + return db_result + + +def get_extraction_result(db: Session, result_id: str) -> Optional[models.ExtractionResult]: + """ + Retrieve extraction result by ID. + + Args: + db: Database session + result_id: Extraction result identifier + + Returns: + Extraction result model instance or None if not found + """ + return db.query(models.ExtractionResult).filter(models.ExtractionResult.id == result_id).first() + + +def get_extraction_history( + db: Session, + template_id: Optional[str] = None, + status: Optional[models.ExtractionStatus] = None, + skip: int = 0, + limit: int = 50 +) -> List[models.ExtractionResult]: + """ + Retrieve extraction history with optional filtering. + + Args: + db: Database session + template_id: Filter by template ID + status: Filter by status + skip: Number of records to skip + limit: Maximum number of records to return + + Returns: + List of extraction result model instances with document and template relationships loaded + """ + from sqlalchemy.orm import joinedload + + query = db.query(models.ExtractionResult).options( + joinedload(models.ExtractionResult.document), + joinedload(models.ExtractionResult.template) + ) + + if template_id: + query = query.filter(models.ExtractionResult.template_id == template_id) + if status: + query = query.filter(models.ExtractionResult.status == status) + + return query.order_by(models.ExtractionResult.created_at.desc()).offset(skip).limit(limit).all() diff --git a/sample_solutions/DocQvision/api/database.py b/sample_solutions/DocQvision/api/database.py new file mode 100644 index 00000000..54e0be95 --- /dev/null +++ b/sample_solutions/DocQvision/api/database.py @@ -0,0 +1,60 @@ +""" +Database configuration and session management. + +This module sets up SQLAlchemy engine, session factory, and base class +for all database models. Provides dependency injection for FastAPI endpoints. +""" + +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker, Session +from typing import Generator +import config + +# Database URL from configuration +DATABASE_URL = getattr(config, 'DATABASE_URL', 'sqlite:///./DocQvision.db') + +# Create SQLAlchemy engine +# connect_args for SQLite only - allows multi-threaded access +engine = create_engine( + DATABASE_URL, + connect_args={"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}, + pool_pre_ping=True, + echo=False +) + +# Session factory for creating database sessions +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# Base class for all ORM models +Base = declarative_base() + + +def get_db() -> Generator[Session, None, None]: + """ + Dependency function for FastAPI endpoints to get database session. + + Yields database session and ensures proper cleanup after request completion. + + Yields: + Session: SQLAlchemy database session + + Example: + @app.get("/items") + def read_items(db: Session = Depends(get_db)): + return db.query(Item).all() + """ + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def init_db() -> None: + """ + Initialize database by creating all tables. + + Should be called on application startup to ensure database schema exists. + """ + Base.metadata.create_all(bind=engine) diff --git a/sample_solutions/DocQvision/api/main.py b/sample_solutions/DocQvision/api/main.py new file mode 100644 index 00000000..7227938d --- /dev/null +++ b/sample_solutions/DocQvision/api/main.py @@ -0,0 +1,665 @@ +""" +DocQvision API - Document Intelligence Platform + +FastAPI application providing REST endpoints for template-based document extraction +using AI vision models. Supports chat-based schema configuration, PDF upload, +data extraction, and result export. +""" + +import logging +from fastapi import FastAPI, UploadFile, File, Form, HTTPException, Depends, BackgroundTasks +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from sqlalchemy.orm import Session +from typing import Optional, List +import uvicorn +import json + +import config +from database import get_db, init_db +from services.extraction_service import ExtractionService +from utils.validators import validate_pdf_file, sanitize_chat_message +import crud +import schemas + +logging.basicConfig( + level=getattr(logging, config.LOG_LEVEL), + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +app = FastAPI( + title="DocQvision API", + description="Document Intelligence Platform API - Extract structured data from PDF documents using AI vision models", + version="1.0.0" +) + +origins = config.CORS_ORIGINS.split(",") if config.CORS_ORIGINS != "*" else ["*"] + +app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +extraction_service = ExtractionService() + + +@app.exception_handler(Exception) +async def global_exception_handler(request, exc): + """Global exception handler for unhandled errors.""" + logger.error(f"Global exception: {str(exc)}", exc_info=True) + return JSONResponse( + status_code=500, + content={"error": "Internal server error", "detail": str(exc)} + ) + + +@app.get("/") +def root(): + """Root endpoint with service information.""" + return { + "message": "DocQvision API is running", + "version": "1.0.0", + "status": "healthy", + "docs": "/docs", + "inference_endpoint": config.INFERENCE_API_ENDPOINT, + "vision_model": config.VISION_MODEL + } + + +@app.get("/health") +def health_check(): + """Health check endpoint for monitoring.""" + return { + "status": "healthy", + "service": "DocQvision", + "version": "1.0.0" + } + + +@app.post("/api/configure") +async def configure_schema( + message: str = Form(...), + session_id: Optional[str] = Form(None), + db: Session = Depends(get_db) +): + """ + Process chat message to build extraction schema with session isolation. + + Uses conversational AI to help users define extraction fields interactively. + Each session maintains independent chat history and schema. + + Args: + message: User's natural language message + session_id: Optional session identifier for chat isolation + db: Database session + + Returns: + AI reply with updated schema definition and session_id + """ + try: + if not message or not message.strip(): + raise HTTPException(status_code=400, detail="Message cannot be empty") + + sanitized_message = sanitize_chat_message(message) + result = extraction_service.process_chat_message(sanitized_message, session_id) + return result + except HTTPException: + raise + except Exception as e: + logger.error(f"Error in configure endpoint: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/configure/clear") +async def clear_chat_session(session_id: str = Form(...)): + """ + Clear/reset a chat session. + + Args: + session_id: Session identifier to clear + + Returns: + Success confirmation + """ + try: + result = extraction_service.clear_session(session_id) + return result + except Exception as e: + logger.error(f"Error clearing session: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/templates", response_model=schemas.TemplateResponse) +def create_template( + template: schemas.TemplateCreate, + db: Session = Depends(get_db) +): + """ + Create a new extraction template. + + Args: + template: Template creation data with name, type, and schema + db: Database session + + Returns: + Created template with ID and timestamps + """ + try: + db_template = crud.create_template(db, template) + logger.info(f"Template created: {db_template.id}") + return db_template + except Exception as e: + logger.error(f"Error creating template: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/templates", response_model=List[schemas.TemplateListItem]) +def list_templates( + skip: int = 0, + limit: int = 100, + db: Session = Depends(get_db) +): + """ + Retrieve all templates with pagination. + + Args: + skip: Number of records to skip + limit: Maximum number of records to return + db: Database session + + Returns: + List of template summaries + """ + try: + templates = crud.get_templates(db, skip=skip, limit=limit) + return templates + except Exception as e: + logger.error(f"Error listing templates: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/templates/{template_id}", response_model=schemas.TemplateResponse) +def get_template( + template_id: str, + db: Session = Depends(get_db) +): + """ + Retrieve template by ID. + + Args: + template_id: Template identifier + db: Database session + + Returns: + Template details with full schema + """ + template = crud.get_template(db, template_id) + if not template: + raise HTTPException(status_code=404, detail="Template not found") + return template + + +@app.put("/api/templates/{template_id}", response_model=schemas.TemplateResponse) +def update_template( + template_id: str, + template_update: schemas.TemplateUpdate, + db: Session = Depends(get_db) +): + """ + Update existing template. + + Args: + template_id: Template identifier + template_update: Fields to update + db: Database session + + Returns: + Updated template + """ + updated_template = crud.update_template(db, template_id, template_update) + if not updated_template: + raise HTTPException(status_code=404, detail="Template not found") + + logger.info(f"Template updated: {template_id}") + return updated_template + + +@app.delete("/api/templates/{template_id}") +def delete_template( + template_id: str, + db: Session = Depends(get_db) +): + """ + Delete template by ID. + + Args: + template_id: Template identifier + db: Database session + + Returns: + Success confirmation + """ + deleted = crud.delete_template(db, template_id) + if not deleted: + raise HTTPException(status_code=404, detail="Template not found") + + logger.info(f"Template deleted: {template_id}") + return {"success": True, "message": "Template deleted successfully"} + + +@app.post("/api/templates/save") +async def save_template_from_chat( + name: str = Form(...), + template_type: str = Form(...), + schema: str = Form(...), + db: Session = Depends(get_db) +): + """ + Save template from chat configuration. + + Converts flat schema format to nested format and saves to database. + + Args: + name: Template name + template_type: Document type + schema: JSON string with schema definition + db: Database session + + Returns: + Success confirmation with template ID + """ + try: + schema_dict = json.loads(schema) + + def convert_to_field_schema(field_type): + """Convert field type string to FieldSchema object.""" + if isinstance(field_type, dict): + return field_type + return { + "type": field_type, + "required": True + } + + nested_schema = {} + for field_name, field_type in schema_dict.items(): + nested_schema[field_name] = convert_to_field_schema(field_type) + + template_create = schemas.TemplateCreate( + name=name, + doc_type=template_type, + schema_json=nested_schema + ) + + db_template = crud.create_template(db, template_create) + logger.info(f"Template saved: {db_template.id}") + + return { + "success": True, + "template_id": db_template.id, + "message": f"Template '{name}' saved successfully" + } + except json.JSONDecodeError: + raise HTTPException(status_code=400, detail="Invalid schema format") + except Exception as e: + logger.error(f"Error saving template: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/documents/upload", response_model=schemas.DocumentUploadResponse) +async def upload_document( + file: UploadFile = File(...), + db: Session = Depends(get_db) +): + """ + Upload PDF document to database. + + Args: + file: PDF file upload + db: Database session + + Returns: + Document metadata with ID + """ + try: + content = await file.read() + + is_valid, error_msg = validate_pdf_file(content, file.filename) + if not is_valid: + raise HTTPException(status_code=400, detail=error_msg) + + if len(content) > config.MAX_FILE_SIZE: + raise HTTPException( + status_code=400, + detail=f"File size exceeds {config.MAX_FILE_SIZE // (1024*1024)}MB limit" + ) + + db_document = crud.create_document(db, file.filename, content) + logger.info(f"Document uploaded: {db_document.id} ({file.filename})") + + return schemas.DocumentUploadResponse( + document_id=db_document.id, + filename=db_document.filename, + file_size=db_document.file_size, + page_count=db_document.page_count, + uploaded_at=db_document.uploaded_at + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error uploading document: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/api/documents/batch-upload") +async def batch_upload_documents( + files: List[UploadFile] = File(...), + db: Session = Depends(get_db) +): + """ + Upload multiple PDF documents in batch. + + Args: + files: List of PDF file uploads + db: Database session + + Returns: + List of document upload responses with success/error status + """ + if len(files) > config.MAX_BATCH_UPLOAD: + raise HTTPException( + status_code=400, + detail=f"Maximum {config.MAX_BATCH_UPLOAD} files allowed per batch upload" + ) + + results = [] + for file in files: + try: + content = await file.read() + + is_valid, error_msg = validate_pdf_file(content, file.filename) + if not is_valid: + results.append({ + "filename": file.filename, + "success": False, + "error": error_msg + }) + continue + + if len(content) > config.MAX_FILE_SIZE: + results.append({ + "filename": file.filename, + "success": False, + "error": f"File size exceeds {config.MAX_FILE_SIZE // (1024*1024)}MB limit" + }) + continue + + db_document = crud.create_document(db, file.filename, content) + logger.info(f"Batch upload - Document uploaded: {db_document.id} ({file.filename})") + + results.append({ + "filename": file.filename, + "success": True, + "document_id": db_document.id, + "file_size": db_document.file_size, + "page_count": db_document.page_count + }) + + except Exception as e: + logger.error(f"Error uploading {file.filename}: {str(e)}") + results.append({ + "filename": file.filename, + "success": False, + "error": str(e) + }) + + successful_uploads = sum(1 for r in results if r["success"]) + logger.info(f"Batch upload completed: {successful_uploads}/{len(files)} files uploaded successfully") + + return { + "total_files": len(files), + "successful": successful_uploads, + "failed": len(files) - successful_uploads, + "results": results + } + + +@app.post("/api/extract", response_model=schemas.ExtractionResponse) +async def create_extraction_job( + extraction: schemas.ExtractionCreate, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db) +): + """ + Create extraction job and process in background. + + Args: + extraction: Extraction job parameters (document_id, template_id) + background_tasks: FastAPI background task handler + db: Database session + + Returns: + Extraction job with initial status (pending) + """ + try: + document = crud.get_document(db, extraction.document_id) + if not document: + raise HTTPException(status_code=404, detail="Document not found") + + template = crud.get_template(db, extraction.template_id) + if not template: + raise HTTPException(status_code=404, detail="Template not found") + + db_result = crud.create_extraction_result(db, extraction.document_id, extraction.template_id) + + background_tasks.add_task( + extraction_service.process_extraction_job, + db_result.id, + document.file_content, + template.schema_json, + template.doc_type + ) + + logger.info(f"Extraction job created: {db_result.id} (template type: {template.doc_type})") + return db_result + except HTTPException: + raise + except Exception as e: + logger.error(f"Error creating extraction job: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.get("/api/extract/{job_id}", response_model=schemas.ExtractionResponse) +def get_extraction_result( + job_id: str, + db: Session = Depends(get_db) +): + """ + Retrieve extraction job status and results. + + Poll this endpoint to check job completion status. + + Args: + job_id: Extraction job identifier + db: Database session + + Returns: + Extraction job details with status and results + """ + result = crud.get_extraction_result(db, job_id) + if not result: + raise HTTPException(status_code=404, detail="Extraction job not found") + return result + + +@app.get("/api/history", response_model=List[schemas.ExtractionResponse]) +def get_extraction_history( + template_id: Optional[str] = None, + status: Optional[schemas.ExtractionStatusEnum] = None, + skip: int = 0, + limit: int = 50, + db: Session = Depends(get_db) +): + """ + Retrieve extraction history with optional filtering. + + Args: + template_id: Filter by template ID + status: Filter by extraction status + skip: Number of records to skip + limit: Maximum number of records to return + db: Database session + + Returns: + List of extraction results ordered by creation time (newest first) + """ + try: + status_enum = None + if status: + from models import ExtractionStatus + status_enum = ExtractionStatus[status.value.upper()] + + results = crud.get_extraction_history( + db, + template_id=template_id, + status=status_enum, + skip=skip, + limit=limit + ) + + # Enrich results with document and template info + enriched_results = [] + for result in results: + result_dict = { + "id": result.id, + "document_id": result.document_id, + "template_id": result.template_id, + "status": result.status.value, + "stage_used": result.stage_used.value if result.stage_used else None, + "extracted_data": result.extracted_data, + "field_coverage_percent": result.field_coverage_percent, + "processing_time_ms": result.processing_time_ms, + "model_used": result.model_used, + "error_message": result.error_message, + "created_at": result.created_at, + "document_filename": result.document.filename if result.document else None, + "template_name": result.template.name if result.template else None, + "document_page_count": result.document.page_count if result.document else None + } + enriched_results.append(result_dict) + + return enriched_results + except Exception as e: + logger.error(f"Error retrieving extraction history: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.delete("/api/extract/{job_id}") +def delete_extraction( + job_id: str, + db: Session = Depends(get_db) +): + """ + Delete extraction result by ID. + + Args: + job_id: Extraction job identifier + db: Database session + + Returns: + Success confirmation + """ + result = crud.get_extraction_result(db, job_id) + if not result: + raise HTTPException(status_code=404, detail="Extraction job not found") + + db.delete(result) + db.commit() + logger.info(f"Extraction job deleted: {job_id}") + return {"success": True, "message": "Extraction deleted successfully"} + + +@app.post("/api/extract/{job_id}/re-extract", response_model=schemas.ExtractionResponse) +async def re_extract_document( + job_id: str, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db) +): + """ + Re-run extraction on an existing extraction result. + + Args: + job_id: Existing extraction job identifier + background_tasks: FastAPI background task handler + db: Database session + + Returns: + New extraction job with initial status (pending) + """ + try: + original_result = crud.get_extraction_result(db, job_id) + if not original_result: + raise HTTPException(status_code=404, detail="Original extraction job not found") + + document = crud.get_document(db, original_result.document_id) + if not document: + raise HTTPException(status_code=404, detail="Document not found") + + template = crud.get_template(db, original_result.template_id) + if not template: + raise HTTPException(status_code=404, detail="Template not found") + + # Create new extraction result + db_result = crud.create_extraction_result(db, document.id, template.id) + + background_tasks.add_task( + extraction_service.process_extraction_job, + db_result.id, + document.file_content, + template.schema_json, + template.doc_type + ) + + logger.info(f"Re-extraction job created: {db_result.id} from original: {job_id}") + return db_result + except HTTPException: + raise + except Exception as e: + logger.error(f"Error creating re-extraction job: {str(e)}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.on_event("startup") +async def startup_event(): + """Initialize database and log startup information.""" + logger.info("=" * 60) + logger.info("DocQvision API Starting") + logger.info(f"Environment: {config.ENVIRONMENT}") + logger.info(f"Database: {config.DATABASE_URL}") + logger.info(f"Inference endpoint: {config.INFERENCE_API_ENDPOINT}") + logger.info(f"Vision Model: {config.VISION_MODEL}") + logger.info(f"Detection Model: {config.DETECTION_MODEL}") + + try: + config.validate_auth_config() + logger.info("Authentication configuration validated") + except ValueError as e: + logger.error(f"Authentication configuration error: {str(e)}") + raise + + logger.info("Initializing database...") + try: + init_db() + logger.info("Database initialized successfully") + except Exception as e: + logger.error(f"Database initialization failed: {str(e)}") + raise + + logger.info("=" * 60) + + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=5001) diff --git a/sample_solutions/DocQvision/api/models.py b/sample_solutions/DocQvision/api/models.py new file mode 100644 index 00000000..67c32fca --- /dev/null +++ b/sample_solutions/DocQvision/api/models.py @@ -0,0 +1,122 @@ +""" +SQLAlchemy ORM models for DocQvision database. + +Defines database schema for templates, documents, extraction results, +and chat sessions with proper relationships and constraints. +""" + +from sqlalchemy import Column, String, Integer, Text, LargeBinary, DateTime, ForeignKey, JSON, Float, Enum +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from database import Base +import enum + + +class ExtractionStatus(enum.Enum): + """Enumeration for extraction job status.""" + PENDING = "pending" + RUNNING = "running" + SUCCESS = "success" + FAILED = "failed" + + +class ExtractionStage(enum.Enum): + """Enumeration for extraction pipeline stage used.""" + TRADITIONAL = "traditional" + VISION = "vision" + MOCK = "mock" + + +class Template(Base): + """ + Template model for storing document extraction schemas. + + Templates define what fields to extract from documents and their types. + Users create templates via chat interface and reuse them for similar documents. + + Attributes: + id: Unique template identifier + name: User-provided template name + doc_type: Document type category (invoice, prescription, contract, etc.) + schema_json: JSON object defining extraction fields and their properties + created_at: Timestamp when template was created + updated_at: Timestamp when template was last modified + """ + __tablename__ = "templates" + + id = Column(String(50), primary_key=True, index=True) + name = Column(String(255), nullable=False) + doc_type = Column(String(100), nullable=False) + schema_json = Column(JSON, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) + + # Relationships + extractions = relationship("ExtractionResult", back_populates="template", cascade="all, delete-orphan") + + +class Document(Base): + """ + Document model for storing uploaded PDF files. + + Stores PDF binary content and metadata for extraction processing. + + Attributes: + id: Unique document identifier + filename: Original filename from upload + file_size: File size in bytes + page_count: Number of pages in PDF + sha256_hash: SHA-256 hash for deduplication and integrity + file_content: Binary PDF content + uploaded_at: Timestamp when document was uploaded + """ + __tablename__ = "documents" + + id = Column(String(50), primary_key=True, index=True) + filename = Column(String(255), nullable=False) + file_size = Column(Integer, nullable=False) + page_count = Column(Integer, nullable=True) + sha256_hash = Column(String(64), nullable=True, index=True) + file_content = Column(LargeBinary, nullable=False) + uploaded_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + # Relationships + extractions = relationship("ExtractionResult", back_populates="document", cascade="all, delete-orphan") + + +class ExtractionResult(Base): + """ + Extraction result model for storing extraction job history and results. + + Each extraction run creates a record with status, extracted data, and metadata. + + Attributes: + id: Unique extraction result identifier + document_id: Foreign key to document + template_id: Foreign key to template used + status: Current job status (pending, running, success, failed) + stage_used: Pipeline stage that produced result (traditional, vision) + extracted_data: JSON object with extracted field values + field_coverage_percent: Percentage of required fields successfully extracted + processing_time_ms: Total processing time in milliseconds + model_used: Vision model identifier if vision stage was used + error_message: Error details if extraction failed + created_at: Timestamp when extraction job was created + """ + __tablename__ = "extraction_results" + + id = Column(String(50), primary_key=True, index=True) + document_id = Column(String(50), ForeignKey("documents.id"), nullable=False, index=True) + template_id = Column(String(50), ForeignKey("templates.id"), nullable=False, index=True) + status = Column(Enum(ExtractionStatus), nullable=False, default=ExtractionStatus.PENDING, index=True) + stage_used = Column(Enum(ExtractionStage), nullable=True) + extracted_data = Column(JSON, nullable=True) + field_coverage_percent = Column(Float, nullable=True) + processing_time_ms = Column(Integer, nullable=True) + model_used = Column(String(100), nullable=True) + error_message = Column(Text, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True) + + # Relationships + document = relationship("Document", back_populates="extractions") + template = relationship("Template", back_populates="extractions") diff --git a/sample_solutions/DocQvision/api/requirements.txt b/sample_solutions/DocQvision/api/requirements.txt new file mode 100644 index 00000000..82f8e9da --- /dev/null +++ b/sample_solutions/DocQvision/api/requirements.txt @@ -0,0 +1,33 @@ +# ============================================================================= +# DocQvision API - Python Dependencies +# ============================================================================= + +# Web Framework & Server +fastapi==0.115.4 +uvicorn==0.32.0 +python-multipart>=0.0.18 + +# Data Validation & Configuration +pydantic==2.9.2 +python-dotenv==1.0.1 + +# AI/ML APIs +openai>=1.54.0,<2.0.0 + +# PDF Processing +pypdf==5.1.0 # PDF text extraction +PyMuPDF==1.24.14 # PDF to image conversion (provides 'fitz' module) + +# Image Processing +Pillow==12.1.1 + +# PDF Generation +reportlab==4.2.5 + +# Database +sqlalchemy==2.0.43 +alembic==1.18.0 + +# HTTP Clients +requests==2.32.3 +httpx==0.28.1 diff --git a/sample_solutions/DocQvision/api/schemas.py b/sample_solutions/DocQvision/api/schemas.py new file mode 100644 index 00000000..4ea90a4a --- /dev/null +++ b/sample_solutions/DocQvision/api/schemas.py @@ -0,0 +1,159 @@ +""" +Pydantic schemas for request/response validation. + +Defines data validation models for API endpoints, ensuring type safety +and proper data structure throughout the application. +""" + +from pydantic import BaseModel, Field, field_validator +from typing import Optional, Dict, Any, List +from datetime import datetime +from enum import Enum + + +class ExtractionStatusEnum(str, Enum): + """Extraction job status enumeration.""" + PENDING = "pending" + RUNNING = "running" + SUCCESS = "success" + FAILED = "failed" + + +class ExtractionStageEnum(str, Enum): + """Extraction pipeline stage enumeration.""" + TRADITIONAL = "traditional" + VISION = "vision" + MOCK = "mock" + + +class FieldSchema(BaseModel): + """ + Schema definition for a single extraction field. + + Attributes: + type: Field data type (string, number, date, boolean) + required: Whether field must be extracted + description: Optional field description for UI + enum: Optional list of allowed values + """ + type: str = Field(..., description="Field data type") + required: bool = Field(default=True, description="Whether field is required") + description: Optional[str] = Field(default=None, description="Field description") + enum: Optional[List[str]] = Field(default=None, description="Allowed values for enum fields") + + @field_validator('type') + @classmethod + def validate_type(cls, v: str) -> str: + """Validate field type is one of allowed types.""" + allowed_types = {'string', 'number', 'date', 'boolean', 'array', 'object'} + if v not in allowed_types: + raise ValueError(f"Field type must be one of {allowed_types}") + return v + + +class TemplateCreate(BaseModel): + """Schema for creating a new template.""" + model_config = {"protected_namespaces": ()} + + name: str = Field(..., min_length=1, max_length=255, description="Template name") + doc_type: str = Field(..., min_length=1, max_length=100, description="Document type") + schema_json: Dict[str, FieldSchema] = Field(..., description="Extraction schema definition") + + +class TemplateUpdate(BaseModel): + """Schema for updating an existing template.""" + model_config = {"protected_namespaces": ()} + + name: Optional[str] = Field(None, min_length=1, max_length=255) + doc_type: Optional[str] = Field(None, min_length=1, max_length=100) + schema_json: Optional[Dict[str, FieldSchema]] = None + + +class TemplateResponse(BaseModel): + """Schema for template response.""" + model_config = {"from_attributes": True, "protected_namespaces": ()} + + id: str + name: str + doc_type: str + schema_json: Dict[str, Any] + created_at: datetime + updated_at: datetime + + +class TemplateListItem(BaseModel): + """Schema for template list item (without full schema).""" + id: str + name: str + doc_type: str + created_at: datetime + + class Config: + from_attributes = True + + +class DocumentUploadResponse(BaseModel): + """Schema for document upload response.""" + document_id: str + filename: str + file_size: int + page_count: Optional[int] + uploaded_at: datetime + + +class ExtractionCreate(BaseModel): + """Schema for creating extraction job.""" + document_id: str = Field(..., description="Document ID to extract from") + template_id: str = Field(..., description="Template ID to use for extraction") + + +class ExtractionResponse(BaseModel): + """Schema for extraction job response.""" + model_config = {"from_attributes": True, "protected_namespaces": ()} + + id: str + document_id: str + template_id: str + status: ExtractionStatusEnum + stage_used: Optional[ExtractionStageEnum] + extracted_data: Optional[Dict[str, Any]] + field_coverage_percent: Optional[float] + processing_time_ms: Optional[int] + model_used: Optional[str] + error_message: Optional[str] + created_at: datetime + + # Additional fields for UI + document_filename: Optional[str] = None + template_name: Optional[str] = None + document_page_count: Optional[int] = None + + +class ChatMessage(BaseModel): + """Schema for chat message.""" + role: str = Field(..., pattern="^(user|assistant|system)$") + content: str = Field(..., min_length=1) + + +class ChatRequest(BaseModel): + """Schema for chat configuration request.""" + message: str = Field(..., min_length=1, description="User message") + session_id: Optional[str] = Field(default=None, description="Existing session ID to continue") + + +class ChatResponse(BaseModel): + """Schema for chat configuration response.""" + model_config = {"protected_namespaces": ()} + + session_id: str + reply: str + schema: Dict[str, Any] + messages: List[ChatMessage] + + +class ExtractionHistoryFilter(BaseModel): + """Schema for extraction history filtering.""" + template_id: Optional[str] = None + status: Optional[ExtractionStatusEnum] = None + limit: int = Field(default=50, ge=1, le=100) + offset: int = Field(default=0, ge=0) diff --git a/sample_solutions/DocQvision/api/services/__init__.py b/sample_solutions/DocQvision/api/services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sample_solutions/DocQvision/api/services/api_client.py b/sample_solutions/DocQvision/api/services/api_client.py new file mode 100644 index 00000000..49661155 --- /dev/null +++ b/sample_solutions/DocQvision/api/services/api_client.py @@ -0,0 +1,94 @@ +""" +API client abstraction layer. + +Supports GenAI Gateway, APISIX Gateway, and any OpenAI-compatible inference endpoint. +""" + +import logging +import httpx +from typing import Optional +from openai import OpenAI + +import config + +logger = logging.getLogger(__name__) + + +class AuthenticationError(Exception): + """Raised when authentication fails""" + pass + + +class APIClient: + """Unified API client for inference endpoints""" + + def __init__(self): + if not config.INFERENCE_API_ENDPOINT: + raise AuthenticationError("INFERENCE_API_ENDPOINT is required") + + if not config.INFERENCE_API_TOKEN: + raise AuthenticationError("INFERENCE_API_TOKEN is required") + + self.base_url = config.INFERENCE_API_ENDPOINT + self.api_key = config.INFERENCE_API_TOKEN + + # Configure httpx clients with extended timeouts for slower inference servers + # Default httpx timeout is 5 seconds, which is too short for VL model inference + timeout_config = httpx.Timeout(300.0, connect=60.0) # 5 minutes read, 1 minute connect + self.http_client = httpx.Client(verify=config.VERIFY_SSL, timeout=timeout_config) + self.async_http_client = httpx.AsyncClient(verify=config.VERIFY_SSL, timeout=timeout_config) + + logger.info(f"Inference endpoint configured: {self.base_url}") + + def get_openai_client(self) -> OpenAI: + return OpenAI( + api_key=self.api_key, + base_url=f"{self.base_url}/v1", + http_client=self.http_client + ) + + def is_authenticated(self) -> bool: + return bool(self.api_key) + + def __del__(self): + """Cleanup HTTP clients on deletion""" + if hasattr(self, 'http_client') and self.http_client: + try: + self.http_client.close() + except Exception: + pass + if hasattr(self, 'async_http_client') and self.async_http_client: + try: + import asyncio + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + loop.create_task(self.async_http_client.aclose()) + else: + loop.run_until_complete(self.async_http_client.aclose()) + except RuntimeError: + pass + except Exception: + pass + + +_api_client: Optional[APIClient] = None + + +def get_api_client() -> APIClient: + """ + Get or create global API client instance + + Returns: + Configured API client + + Raises: + AuthenticationError: If authentication configuration is invalid + """ + global _api_client + + if _api_client is None: + logger.info("Initializing API client") + _api_client = APIClient() + + return _api_client diff --git a/sample_solutions/DocQvision/api/services/extraction_pipeline.py b/sample_solutions/DocQvision/api/services/extraction_pipeline.py new file mode 100644 index 00000000..5662b093 --- /dev/null +++ b/sample_solutions/DocQvision/api/services/extraction_pipeline.py @@ -0,0 +1,202 @@ +""" +Vision-based extraction pipeline. + +Extracts structured data from documents using AI vision model. +""" + +import logging +import time +from typing import Dict, Any, Optional + +import config +from models import ExtractionStage +from services.extractors import calculate_field_coverage +from services.pdf_utils import extract_text_by_page, select_relevant_pages +from services.vision_service import VisionService + +logger = logging.getLogger(__name__) + + +class PipelineResult: + """Container for pipeline execution results.""" + + def __init__( + self, + extracted_data: Dict[str, Any], + stage_used: ExtractionStage, + field_coverage: float, + processing_time_ms: int, + model_used: Optional[str] = None + ): + self.extracted_data = extracted_data + self.stage_used = stage_used + self.field_coverage = field_coverage + self.processing_time_ms = processing_time_ms + self.model_used = model_used + + +class ExtractionPipeline: + """ + Vision-based extraction pipeline. + + Extracts structured data from PDF documents using AI vision model, + with intelligent page selection for multi-page documents. + """ + + def __init__(self): + self.vision_service = VisionService() + self.vision_max_pages = config.VISION_MAX_PAGES + + logger.info(f"Vision-only pipeline initialized (max pages: {self.vision_max_pages})") + + def extract( + self, + pdf_content: bytes, + schema: Dict[str, Any], + template_doc_type: str = None, + validate_type: bool = True + ) -> PipelineResult: + """ + Execute vision-based extraction. + + Args: + pdf_content: Binary PDF content + schema: Extraction schema definition + template_doc_type: Expected document type from template (e.g., "invoice", "prescription") + validate_type: Whether to validate document type before extraction + + Returns: + PipelineResult with extracted data and metadata + + Raises: + ValueError: If extraction fails or document type mismatch + """ + start_time = time.time() + + try: + # Skip validation for test templates or if validation is disabled + if validate_type and template_doc_type and template_doc_type.lower() != 'test': + self._validate_document_type(pdf_content, template_doc_type) + + # Extract using vision model + logger.info("Starting vision extraction") + + page_texts = extract_text_by_page(pdf_content) + total_pages = len(page_texts) + + if total_pages > self.vision_max_pages: + selected_pages = select_relevant_pages( + page_texts, + schema, + max_pages=self.vision_max_pages + ) + logger.info( + f"Selected {len(selected_pages)} pages from {total_pages} total pages" + ) + else: + selected_pages = None + + extracted_data = self.vision_service.extract_with_schema( + pdf_content, + schema, + page_numbers=selected_pages + ) + + coverage, field_status = calculate_field_coverage(extracted_data, schema) + + # Log missing fields for debugging + missing_fields = [field for field, found in field_status.items() if not found] + if missing_fields: + logger.warning( + f"Vision extraction: {len(missing_fields)} fields not extracted: {missing_fields}" + ) + + logger.info( + f"Vision extraction completed: coverage={coverage:.2%} " + f"(extracted {len(extracted_data) - len(missing_fields)}/{len(schema)} fields)" + ) + + # Accept any coverage > 0 + if coverage == 0: + error_msg = ( + "Failed to extract data from document.\n\n" + "Possible reasons:\n" + "• Document type may not match the template\n" + "• Document quality or format may be incompatible\n" + "• Required fields may not be present in the document\n\n" + "Please verify:\n" + "1. You selected the correct template for this document type\n" + "2. The document contains the expected fields\n" + "3. The document is clear and readable" + ) + raise ValueError(error_msg) + + processing_time = int((time.time() - start_time) * 1000) + + return PipelineResult( + extracted_data=extracted_data, + stage_used=ExtractionStage.VISION, + field_coverage=coverage, + processing_time_ms=processing_time, + model_used=config.VISION_MODEL + ) + + except Exception as e: + logger.error(f"Vision extraction failed: {str(e)}") + raise ValueError(str(e)) + + def _validate_document_type(self, pdf_content: bytes, expected_type: str): + """ + Validate that document type matches expected template type. + + Args: + pdf_content: Binary PDF content + expected_type: Expected document type (e.g., "invoice", "prescription") + + Raises: + ValueError: If document type doesn't match expected type + """ + try: + logger.info(f"Validating document type (expected: {expected_type})") + + detection_result = self.vision_service.detect_document_type(pdf_content) + detected_type = detection_result.get("document_type", "unknown") + confidence = detection_result.get("confidence", 0.0) + reasoning = detection_result.get("reasoning", "") + + logger.info( + f"Document type detection: {detected_type} " + f"(confidence: {confidence:.1%}, expected: {expected_type})" + ) + + # Normalize types for comparison + detected_normalized = detected_type.lower().replace("_", "").replace("-", "") + expected_normalized = expected_type.lower().replace("_", "").replace("-", "") + + # Allow if confidence is low (uncertain detection) + if confidence < 0.5: + logger.warning( + f"Low confidence ({confidence:.1%}) in type detection. Proceeding with extraction." + ) + return + + # Check for mismatch + if detected_normalized != expected_normalized and confidence >= 0.7: + error_msg = ( + f"Document type mismatch detected!\n\n" + f"Expected: {expected_type.upper()}\n" + f"Detected: {detected_type.upper()} (confidence: {confidence:.0%})\n\n" + f"Reason: {reasoning}\n\n" + f"Please select the correct template for this document type, " + f"or upload a {expected_type} document." + ) + logger.error(error_msg) + raise ValueError(error_msg) + + logger.info("Document type validation passed") + + except ValueError: + raise # Re-raise validation errors + except Exception as e: + # Don't fail extraction if validation itself fails + logger.warning(f"Document type validation failed: {str(e)}. Proceeding with extraction.") diff --git a/sample_solutions/DocQvision/api/services/extraction_service.py b/sample_solutions/DocQvision/api/services/extraction_service.py new file mode 100644 index 00000000..e90cb3eb --- /dev/null +++ b/sample_solutions/DocQvision/api/services/extraction_service.py @@ -0,0 +1,293 @@ +""" +Extraction service for document processing. + +Handles template management, document upload, and data extraction workflows. +Coordinates multi-stage extraction pipeline and database persistence. +""" + +import uuid +import logging +import time +from datetime import datetime +from typing import Dict, Any +from services.vision_service import VisionService +from services.extraction_pipeline import ExtractionPipeline +from database import SessionLocal +import crud +from models import ExtractionStatus, ExtractionStage + +logger = logging.getLogger(__name__) + + +class ExtractionService: + def __init__(self): + self.templates = self._initialize_templates() + self.documents = {} + self.chat_sessions = {} + self.vision_service = VisionService() + self.extraction_pipeline = ExtractionPipeline() + + def _initialize_templates(self): + """Initialize with default templates""" + return { + "invoice": { + "id": "invoice", + "name": "Invoice Template", + "type": "invoice", + "schema": { + "invoice_number": "string", + "date": "date", + "vendor": "string", + "total": "number" + } + }, + "prescription": { + "id": "prescription", + "name": "Prescription Template", + "type": "prescription", + "schema": { + "patient_name": "string", + "medication": "string", + "dosage": "string", + "date": "date" + } + }, + "contract": { + "id": "contract", + "name": "Contract Template", + "type": "contract", + "schema": { + "party_1": "string", + "party_2": "string", + "date": "date", + "terms": "string" + } + } + } + + def _get_or_create_session(self, session_id: str): + """Get or create chat session by ID""" + if session_id not in self.chat_sessions: + self.chat_sessions[session_id] = { + "schema": {}, + "chat_history": [], + "created_at": datetime.now().isoformat() + } + logger.info(f"Created new chat session: {session_id}") + return self.chat_sessions[session_id] + + def process_chat_message(self, message: str, session_id: str = None): + """Process chat message with AI assistance in isolated session""" + try: + if not session_id: + session_id = str(uuid.uuid4()) + + session = self._get_or_create_session(session_id) + + session["chat_history"].append({"role": "user", "content": message}) + + reply, updated_schema = self.vision_service.process_chat_message( + message, session["schema"] + ) + + session["schema"] = updated_schema + session["chat_history"].append({"role": "assistant", "content": reply}) + + logger.info(f"Chat processed for session {session_id}. Schema has {len(session['schema'])} fields") + + return { + "reply": reply, + "schema": session["schema"], + "chat_history": session["chat_history"], + "session_id": session_id + } + except Exception as e: + logger.error(f"Error processing chat message: {str(e)}") + error_reply = "I encountered an error processing your message. Please try again." + session = self._get_or_create_session(session_id or str(uuid.uuid4())) + session["chat_history"].append({"role": "assistant", "content": error_reply}) + return { + "reply": error_reply, + "schema": session["schema"], + "chat_history": session["chat_history"], + "session_id": session_id + } + + def clear_session(self, session_id: str): + """Clear/delete a chat session""" + if session_id in self.chat_sessions: + del self.chat_sessions[session_id] + logger.info(f"Cleared chat session: {session_id}") + return {"success": True, "message": "Session cleared"} + return {"success": False, "message": "Session not found"} + + def save_template(self, name: str, template_type: str, schema: Dict[str, str]) -> Dict[str, Any]: + """Save a new template""" + try: + template_id = template_type.lower().replace(" ", "_") + + template = { + "id": template_id, + "name": name, + "type": template_type, + "schema": schema, + "created_at": datetime.now().isoformat() + } + + self.templates[template_id] = template + logger.info(f"Template saved: {template_id}") + + return { + "success": True, + "template_id": template_id, + "message": f"Template '{name}' saved successfully" + } + except Exception as e: + logger.error(f"Error saving template: {str(e)}") + return { + "success": False, + "error": str(e) + } + + def upload_file(self, filename: str, content: bytes): + """Upload and store document""" + try: + if len(content) > 10 * 1024 * 1024: + raise ValueError("File size exceeds 10MB limit") + + document_id = str(uuid.uuid4()) + self.documents[document_id] = { + "id": document_id, + "filename": filename, + "content": content, + "size": len(content), + "uploaded_at": datetime.now().isoformat() + } + + logger.info(f"Document uploaded: {document_id} ({filename})") + + return { + "document_id": document_id, + "filename": filename, + "size": len(content), + "status": "uploaded" + } + except Exception as e: + logger.error(f"Error uploading file: {str(e)}") + raise + + def extract_document(self, document_id: str, template_id: str): + """Extract data from document using vision AI""" + start_time = time.time() + + try: + if document_id not in self.documents: + raise ValueError(f"Document not found: {document_id}") + + if template_id not in self.templates: + raise ValueError(f"Template not found: {template_id}") + + document = self.documents[document_id] + template = self.templates[template_id] + schema = template["schema"] + + logger.info(f"Extracting document {document_id} with template {template_id}") + + extracted_data = self.vision_service.extract_with_schema( + document["content"], + schema + ) + + processing_time = int((time.time() - start_time) * 1000) + + result = { + "result_id": str(uuid.uuid4()), + "document_id": document_id, + "template_id": template_id, + "extracted_data": extracted_data, + "status": "completed", + "processing_time_ms": processing_time + } + + logger.info(f"Extraction completed in {processing_time}ms") + + return result + + except Exception as e: + logger.error(f"Error extracting document: {str(e)}") + raise + + def get_templates(self): + """Get all available templates""" + return { + "templates": [ + { + "id": t["id"], + "name": t["name"], + "type": t["type"] + } + for t in self.templates.values() + ] + } + + def process_extraction_job(self, job_id: str, pdf_content: bytes, schema: Dict[str, Any], template_doc_type: str = None) -> None: + """ + Process extraction job through vision-based pipeline in background. + + Executes vision extraction pipeline with intelligent page selection + and document type validation. Updates database with status and results + throughout execution. + + Args: + job_id: Extraction result ID in database + pdf_content: Binary PDF content + schema: Template schema definition + template_doc_type: Expected document type for validation + """ + db = SessionLocal() + + try: + crud.update_extraction_result( + db, + job_id, + status=ExtractionStatus.RUNNING + ) + + logger.info(f"Starting extraction job: {job_id} (template type: {template_doc_type})") + + result = self.extraction_pipeline.extract( + pdf_content, + schema, + template_doc_type=template_doc_type, + validate_type=True + ) + + crud.update_extraction_result( + db, + job_id, + status=ExtractionStatus.SUCCESS, + stage_used=result.stage_used, + extracted_data=result.extracted_data, + field_coverage_percent=result.field_coverage, + processing_time_ms=result.processing_time_ms, + model_used=result.model_used + ) + + logger.info( + f"Extraction job completed: {job_id} " + f"(stage={result.stage_used.value}, coverage={result.field_coverage:.2%}, " + f"time={result.processing_time_ms}ms)" + ) + + except Exception as e: + logger.error(f"Extraction job failed: {job_id} - {str(e)}") + + crud.update_extraction_result( + db, + job_id, + status=ExtractionStatus.FAILED, + error_message=str(e) + ) + + finally: + db.close() diff --git a/sample_solutions/DocQvision/api/services/extractors/__init__.py b/sample_solutions/DocQvision/api/services/extractors/__init__.py new file mode 100644 index 00000000..7a7665b4 --- /dev/null +++ b/sample_solutions/DocQvision/api/services/extractors/__init__.py @@ -0,0 +1,9 @@ +""" +Extraction utility modules. + +Provides coverage calculation and validation utilities for extraction quality assessment. +""" + +from .coverage import calculate_field_coverage, validate_field_types + +__all__ = ['calculate_field_coverage', 'validate_field_types'] diff --git a/sample_solutions/DocQvision/api/services/extractors/coverage.py b/sample_solutions/DocQvision/api/services/extractors/coverage.py new file mode 100644 index 00000000..af2989ce --- /dev/null +++ b/sample_solutions/DocQvision/api/services/extractors/coverage.py @@ -0,0 +1,118 @@ +""" +Field coverage scoring for extraction quality assessment. + +Evaluates extraction completeness to determine pipeline stage progression. +""" + +import logging +from typing import Dict, Any, Tuple + +logger = logging.getLogger(__name__) + + +def calculate_field_coverage( + extracted_data: Dict[str, Any], + schema: Dict[str, Any] +) -> Tuple[float, Dict[str, bool]]: + """ + Calculate field coverage percentage based on schema requirements. + + Args: + extracted_data: Extracted field values + schema: Schema definition with required fields + + Returns: + Tuple of (coverage_percentage, field_status_map) + coverage_percentage: Percentage of required fields successfully extracted (0.0-1.0) + field_status_map: Dict mapping field names to extraction success boolean + """ + if not schema: + return 1.0, {} + + total_required = 0 + extracted_required = 0 + field_status = {} + + for field_name, field_def in schema.items(): + is_required = True + if isinstance(field_def, dict): + is_required = field_def.get('required', True) + + if is_required: + total_required += 1 + value = extracted_data.get(field_name) + is_extracted = value is not None and value != "" + + if is_extracted: + extracted_required += 1 + field_status[field_name] = True + else: + field_status[field_name] = False + else: + value = extracted_data.get(field_name) + field_status[field_name] = value is not None and value != "" + + coverage = extracted_required / total_required if total_required > 0 else 1.0 + + logger.debug( + f"Coverage: {coverage:.2%} ({extracted_required}/{total_required} required fields)" + ) + + return coverage, field_status + + +def validate_field_types( + extracted_data: Dict[str, Any], + schema: Dict[str, Any] +) -> Tuple[bool, Dict[str, str]]: + """ + Validate extracted field values against schema type constraints. + + Args: + extracted_data: Extracted field values + schema: Schema definition with field types + + Returns: + Tuple of (is_valid, validation_errors) + is_valid: True if all extracted fields match schema types + validation_errors: Dict mapping field names to error messages + """ + errors = {} + + for field_name, value in extracted_data.items(): + if value is None: + continue + + field_def = schema.get(field_name) + if not field_def: + continue + + expected_type = field_def.get('type', 'string') if isinstance(field_def, dict) else field_def + + if expected_type == 'number': + if not isinstance(value, (int, float)): + errors[field_name] = f"Expected number, got {type(value).__name__}" + + elif expected_type == 'boolean': + if not isinstance(value, bool): + errors[field_name] = f"Expected boolean, got {type(value).__name__}" + + elif expected_type == 'date': + if not isinstance(value, str): + errors[field_name] = f"Expected date string, got {type(value).__name__}" + + elif expected_type == 'string': + if not isinstance(value, str): + errors[field_name] = f"Expected string, got {type(value).__name__}" + + if isinstance(field_def, dict) and 'enum' in field_def: + allowed_values = field_def['enum'] + if value not in allowed_values: + errors[field_name] = f"Value '{value}' not in allowed values: {allowed_values}" + + is_valid = len(errors) == 0 + + if not is_valid: + logger.warning(f"Type validation failed: {errors}") + + return is_valid, errors diff --git a/sample_solutions/DocQvision/api/services/pdf_utils.py b/sample_solutions/DocQvision/api/services/pdf_utils.py new file mode 100644 index 00000000..e0324c40 --- /dev/null +++ b/sample_solutions/DocQvision/api/services/pdf_utils.py @@ -0,0 +1,135 @@ +""" +PDF processing utilities for text extraction and page selection. + +Provides multi-page text extraction and intelligent page selection for +token-constrained vision model fallback. +""" + +import logging +from typing import List, Tuple, Optional +from io import BytesIO +from pypdf import PdfReader + +logger = logging.getLogger(__name__) + + +def extract_text_from_pdf(pdf_content: bytes, max_pages: Optional[int] = None) -> str: + """ + Extract text from all pages of PDF document. + + Args: + pdf_content: Binary PDF content + max_pages: Maximum number of pages to process (None for all pages) + + Returns: + Concatenated text from all pages with page boundaries preserved + """ + try: + pdf_reader = PdfReader(BytesIO(pdf_content)) + total_pages = len(pdf_reader.pages) + + pages_to_process = min(total_pages, max_pages) if max_pages else total_pages + + text_parts = [] + for page_num in range(pages_to_process): + page = pdf_reader.pages[page_num] + page_text = page.extract_text() + + if page_text.strip(): + text_parts.append(f"--- Page {page_num + 1} ---\n{page_text}") + + combined_text = "\n\n".join(text_parts) + logger.info(f"Extracted text from {pages_to_process} pages ({len(combined_text)} chars)") + + return combined_text + + except Exception as e: + logger.error(f"PDF text extraction failed: {str(e)}") + raise ValueError(f"Failed to extract text from PDF: {str(e)}") + + +def extract_text_by_page(pdf_content: bytes) -> List[Tuple[int, str]]: + """ + Extract text from PDF with per-page granularity. + + Args: + pdf_content: Binary PDF content + + Returns: + List of (page_number, page_text) tuples + """ + try: + pdf_reader = PdfReader(BytesIO(pdf_content)) + page_texts = [] + + for page_num, page in enumerate(pdf_reader.pages): + page_text = page.extract_text() + page_texts.append((page_num + 1, page_text)) + + logger.info(f"Extracted {len(page_texts)} pages individually") + return page_texts + + except Exception as e: + logger.error(f"Per-page text extraction failed: {str(e)}") + raise ValueError(f"Failed to extract text by page: {str(e)}") + + +def select_relevant_pages( + page_texts: List[Tuple[int, str]], + schema: dict, + max_pages: int = 3 +) -> List[int]: + """ + Select most relevant pages for vision model processing based on schema fields. + + Uses keyword matching to identify pages likely containing target fields. + + Args: + page_texts: List of (page_number, page_text) tuples + schema: Schema definition with field names + max_pages: Maximum number of pages to select + + Returns: + List of selected page numbers (1-indexed) + """ + if len(page_texts) <= max_pages: + return [page_num for page_num, _ in page_texts] + + field_keywords = [] + for field_name in schema.keys(): + keywords = field_name.lower().replace('_', ' ').split() + field_keywords.extend(keywords) + + page_scores = [] + for page_num, page_text in page_texts: + page_text_lower = page_text.lower() + score = sum(1 for keyword in field_keywords if keyword in page_text_lower) + + text_density = len(page_text.strip()) + combined_score = score * 10 + (text_density / 1000) + + page_scores.append((page_num, combined_score)) + + page_scores.sort(key=lambda x: x[1], reverse=True) + selected_pages = sorted([page_num for page_num, _ in page_scores[:max_pages]]) + + logger.info(f"Selected pages {selected_pages} from {len(page_texts)} total pages") + return selected_pages + + +def get_page_count(pdf_content: bytes) -> int: + """ + Get total page count from PDF document. + + Args: + pdf_content: Binary PDF content + + Returns: + Number of pages in PDF + """ + try: + pdf_reader = PdfReader(BytesIO(pdf_content)) + return len(pdf_reader.pages) + except Exception as e: + logger.error(f"Failed to get page count: {str(e)}") + return 0 diff --git a/sample_solutions/DocQvision/api/services/vision_service.py b/sample_solutions/DocQvision/api/services/vision_service.py new file mode 100644 index 00000000..75244306 --- /dev/null +++ b/sample_solutions/DocQvision/api/services/vision_service.py @@ -0,0 +1,1159 @@ +import base64 +import logging +from io import BytesIO +from typing import Dict, Any, List, Optional +from PIL import Image +from pypdf import PdfReader +import fitz + +import config +from services.api_client import get_api_client, AuthenticationError + +logger = logging.getLogger(__name__) + + +class VisionService: + def __init__(self): + try: + api_client = get_api_client() + self.client = api_client.get_openai_client() + logger.info(f"Vision service initialized with endpoint: {config.INFERENCE_API_ENDPOINT}") + except AuthenticationError as e: + logger.warning(f"Authentication failed: {e}. Using mock responses.") + self.client = None + + def pdf_to_text(self, pdf_content: bytes) -> str: + """Extract text from first page of PDF""" + try: + pdf_file = BytesIO(pdf_content) + reader = PdfReader(pdf_file) + + text = "" + page = reader.pages[0] + text = page.extract_text() + + logger.info(f"Extracted {len(text)} characters from PDF") + return text + + except Exception as e: + logger.error(f"Error extracting text from PDF: {str(e)}") + return "" + + def _pdf_to_images( + self, + pdf_content: bytes, + page_numbers: Optional[List[int]] = None, + zoom: float = 2.0 + ) -> List[Image.Image]: + """ + Convert PDF pages to PIL images for vision model processing using PyMuPDF. + + Args: + pdf_content: Binary PDF content + page_numbers: Optional list of page numbers to convert (1-indexed) + zoom: Zoom factor for rendering (2.0 = 200% = ~144 DPI) + + Returns: + List of PIL Image objects + """ + try: + # Open PDF with PyMuPDF + pdf_document = fitz.open(stream=pdf_content, filetype="pdf") + total_pages = pdf_document.page_count + + # Determine which pages to convert + if page_numbers: + pages_to_convert = [p - 1 for p in page_numbers if 0 < p <= total_pages] + else: + pages_to_convert = range(total_pages) + + images = [] + mat = fitz.Matrix(zoom, zoom) # Create transformation matrix for zoom + + for page_idx in pages_to_convert: + page = pdf_document[page_idx] + pix = page.get_pixmap(matrix=mat) # Render page to pixmap + + # Convert pixmap to PIL Image + img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples) + images.append(img) + + pdf_document.close() + + logger.info(f"Converted {len(images)} PDF pages to images (zoom: {zoom}x)") + return images + + except Exception as e: + logger.error(f"PDF to image conversion failed: {str(e)}") + return [] + + def _extract_text_from_pages( + self, + pdf_content: bytes, + page_numbers: Optional[List[int]] = None + ) -> str: + """ + Extract text from specific pages or all pages of PDF. + + Args: + pdf_content: Binary PDF content + page_numbers: List of page numbers to extract (1-indexed), None for all pages + + Returns: + Concatenated text from specified pages + """ + try: + pdf_file = BytesIO(pdf_content) + reader = PdfReader(pdf_file) + total_pages = len(reader.pages) + + if page_numbers: + pages_to_extract = [p - 1 for p in page_numbers if 0 < p <= total_pages] + else: + pages_to_extract = range(total_pages) + + text_parts = [] + for page_idx in pages_to_extract: + page_text = reader.pages[page_idx].extract_text() + if page_text.strip(): + text_parts.append(page_text) + + combined_text = "\n\n".join(text_parts) + logger.info( + f"Extracted {len(combined_text)} characters from " + f"{len(pages_to_extract)} pages" + ) + return combined_text + + except Exception as e: + logger.error(f"Error extracting text from pages: {str(e)}") + return "" + + def text_to_image(self, text: str) -> str: + """Convert text to image for vision model (alternative when PDF-to-image fails)""" + try: + img = Image.new('RGB', (800, 1000), color='white') + draw = ImageDraw.Draw(img) + + try: + font = ImageFont.truetype("arial.ttf", 12) + except: + font = ImageFont.load_default() + + y_text = 10 + lines = text.split('\n')[:50] + for line in lines: + draw.text((10, y_text), line[:100], fill='black', font=font) + y_text += 15 + + buffered = BytesIO() + img.save(buffered, format="PNG") + img_str = base64.b64encode(buffered.getvalue()).decode() + + logger.info("Converted text to image successfully") + return img_str + + except Exception as e: + logger.error(f"Error converting text to image: {str(e)}") + return "" + + def extract_with_schema( + self, + pdf_content: bytes, + schema: Dict[str, str], + page_numbers: Optional[List[int]] = None + ) -> Dict[str, Any]: + """ + Extract data from PDF using vision model with defined schema. + + Processes pages sequentially (one image per request) and merges results. + This works around vision model limitations that only support 1 image per request. + + Args: + pdf_content: Binary PDF content + schema: Extraction schema definition + page_numbers: Optional list of specific page numbers to process (1-indexed) + + Returns: + Merged extracted data dictionary + """ + if not self.client: + logger.warning("No vision client available. Returning mock data.") + return self._mock_extraction(schema) + + try: + # Convert PDF pages to images + page_images = self._pdf_to_images(pdf_content, page_numbers) + + if not page_images: + logger.error("Failed to convert PDF to images") + raise ValueError("Could not convert PDF pages to images") + + logger.info(f"Processing {len(page_images)} pages sequentially for vision extraction") + + # Process each page sequentially and merge results + merged_data = {} + + for page_idx, page_image in enumerate(page_images, start=1): + page_num = page_numbers[page_idx - 1] if page_numbers else page_idx + logger.info(f"Processing page {page_num} ({page_idx}/{len(page_images)})") + + # Extract data from this single page + page_data = self._extract_from_single_page(page_image, schema, page_num) + + # Merge results intelligently + merged_data = self._merge_extraction_results(merged_data, page_data, schema) + + logger.info(f"Sequential extraction complete. Final merged data: {list(merged_data.keys())}") + return merged_data + + except Exception as e: + logger.error(f"Vision extraction error: {str(e)}") + raise ValueError(f"Vision extraction failed: {str(e)}") + + def _extract_from_single_page( + self, + page_image: Image.Image, + schema: Dict[str, str], + page_number: int + ) -> Dict[str, Any]: + """ + Extract data from a single page image. + + Args: + page_image: PIL Image of the page + schema: Extraction schema definition + page_number: Page number for logging + + Returns: + Extracted data dictionary for this page + """ + import time + + try: + # Timing: Image encoding + t_encode_start = time.time() + buffered = BytesIO() + page_image.save(buffered, format="PNG") + img_base64 = base64.b64encode(buffered.getvalue()).decode() + img_size_kb = len(buffered.getvalue()) / 1024 + t_encode_end = time.time() + + logger.info(f"Page {page_number}: Image encoded in {(t_encode_end - t_encode_start)*1000:.0f}ms (size: {img_size_kb:.1f}KB)") + + # Build prompt with schema + schema_description = self._format_schema_for_prompt(schema) + + prompt = f"""Extract data from this document as JSON. + +FIELDS TO EXTRACT: +{schema_description} + +CRITICAL RULES: +1. Section Header Matching (HIGHEST PRIORITY): + - ONLY extract from sections with matching numbered headers + - "warranty" field → ONLY from "8. WARRANTY" or "WARRANTY" section header + - "deliverables" field → ONLY from "4. DELIVERABLES" or "DELIVERABLES" section header + - "scope_of_service" field → ONLY from "1. SCOPE OF SERVICES" section header + - DO NOT extract a field from a bullet point list UNLESS that list is under the matching section header + - If you see "60 days of support" in a deliverables LIST, it is NOT warranty - ignore it for warranty field + +2. Field vs Section Distinction: + - If "warranty" appears in a numbered list under "4. DELIVERABLES", ignore it (it's a deliverable item) + - If "warranty" has its own section header "8. WARRANTY", extract from there + - Section headers are usually numbered (1., 2., 8.) and bold/underlined + +3. Output Format: + - Return ONLY valid JSON with exact field names above + - No markdown, no code fences, no commentary + - Use null for missing fields or if section not found on this page + +4. Array Fields: + - Extract ALL items from the section (if 5 bullets, extract all 5) + - Keep complete sentences together as single array items + +5. Text Fields: + - For email/name fields, look for labels like "Email:", "Representative:" + +Example - WRONG extraction: +Under "4. DELIVERABLES": "5. 60 days warranty support" +{{"warranty": "60 days"}} ← WRONG! This is from deliverables section, not warranty section! + +Example - CORRECT extraction: +Under "8. WARRANTY": "Contractor warrants work will be free from defects for 90 days" +{{"warranty": "90 days following final delivery"}} ← CORRECT! Extracted from warranty section header""" + + # Build message content with single page image + message_content = [ + {"type": "text", "text": prompt}, + { + "type": "image_url", + "image_url": { + "url": f"data:image/png;base64,{img_base64}", + "detail": "high" + } + } + ] + + # Timing: Model inference + t_request_start = time.time() + logger.info(f"Page {page_number}: Starting VL model inference (timeout=180s)...") + + # Call vision model with extended timeout for slower inference servers + try: + response = self.client.chat.completions.create( + model=config.VISION_MODEL, + messages=[{"role": "user", "content": message_content}], + max_tokens=config.VISION_MAX_TOKENS, + temperature=config.VISION_TEMPERATURE, + timeout=180.0 # 3 minutes per page for slower inference servers + ) + t_request_end = time.time() + + # Calculate timing metrics + total_latency_ms = (t_request_end - t_request_start) * 1000 + + # Extract token usage if available + usage = getattr(response, 'usage', None) + if usage: + prompt_tokens = getattr(usage, 'prompt_tokens', 0) + completion_tokens = getattr(usage, 'completion_tokens', 0) + total_tokens = getattr(usage, 'total_tokens', 0) + + # Calculate tokens per second + tokens_per_second = completion_tokens / (total_latency_ms / 1000) if total_latency_ms > 0 else 0 + + logger.info( + f"Page {page_number}: VL model response received | " + f"Latency: {total_latency_ms:.0f}ms ({total_latency_ms/1000:.1f}s) | " + f"Tokens: {completion_tokens} output / {prompt_tokens} input = {total_tokens} total | " + f"Speed: {tokens_per_second:.2f} tokens/sec" + ) + else: + logger.info( + f"Page {page_number}: VL model response received | " + f"Latency: {total_latency_ms:.0f}ms ({total_latency_ms/1000:.1f}s) | " + f"Token usage not available" + ) + + result_text = response.choices[0].message.content + extracted_data = self._parse_json_response(result_text) + + except Exception as api_error: + t_error_end = time.time() + error_time_ms = (t_error_end - t_request_start) * 1000 + logger.error( + f"Page {page_number}: VL model API error after {error_time_ms:.0f}ms ({error_time_ms/1000:.1f}s) | " + f"Error: {str(api_error)}" + ) + raise + + if extracted_data: + # Filter out any extra fields that aren't in the schema + expected_fields = set(schema.keys()) + filtered_data = {k: v for k, v in extracted_data.items() if k in expected_fields} + + # Log if any unexpected fields were removed + extra_fields = set(extracted_data.keys()) - expected_fields + if extra_fields: + logger.warning(f"Page {page_number}: Removed unexpected fields: {extra_fields}") + + # Log array lengths for debugging + array_info = [] + for field, value in filtered_data.items(): + if isinstance(value, list): + array_info.append(f"{field}={len(value)} items") + + if array_info: + logger.info(f"Page {page_number} extraction successful: {list(filtered_data.keys())} ({', '.join(array_info)})") + else: + logger.info(f"Page {page_number} extraction successful: {list(filtered_data.keys())}") + return filtered_data + else: + logger.warning(f"Page {page_number} returned no data") + return {} + + except Exception as e: + logger.error(f"Error extracting from page {page_number}: {str(e)}") + return {} + + def _merge_extraction_results( + self, + existing_data: Dict[str, Any], + new_data: Dict[str, Any], + schema: Dict[str, str] + ) -> Dict[str, Any]: + """ + Intelligently merge extraction results from multiple pages. + + - For arrays: Concatenate items from both results + - For scalars: Prefer non-null values, keep first found value + - For objects: Merge recursively + + Args: + existing_data: Previously merged data + new_data: New data from current page + schema: Schema definition to determine field types + + Returns: + Merged data dictionary + """ + merged = dict(existing_data) + + for field, new_value in new_data.items(): + field_type = schema.get(field, "string") + + # Determine if this is an array field + is_array_field = ( + field_type == "array" or + isinstance(field_type, dict) and field_type.get("type") == "array" + ) + + if field not in merged: + # Field doesn't exist in merged data yet, add it + merged[field] = new_value + elif new_value is None or new_value == "": + # New value is null/empty, keep existing + continue + elif merged[field] is None or merged[field] == "": + # Existing is null/empty, replace with new value + merged[field] = new_value + elif is_array_field: + # Merge arrays - prefer longer/more complete arrays + if isinstance(merged[field], list) and isinstance(new_value, list): + existing_len = len(merged[field]) + new_len = len(new_value) + + # If new array is significantly larger, it's likely more complete - replace + if new_len > existing_len * 1.5: # 50% more items + logger.info(f"Array field '{field}': Replacing {existing_len} items with {new_len} items (more complete)") + merged[field] = new_value + elif new_len > existing_len: # New has more items but not significantly + # Prefer the larger array as it's more complete + logger.info(f"Array field '{field}': Using larger array ({new_len} vs {existing_len} items)") + merged[field] = new_value + elif existing_len > new_len * 1.5: # Existing is significantly larger + logger.info(f"Array field '{field}': Keeping existing {existing_len} items (more complete than {new_len})") + # Keep existing + else: + # Similar lengths - concatenate and deduplicate + if all(isinstance(item, (str, int, float, bool)) for item in merged[field] + new_value): + # Simple types - deduplicate + merged[field] = list(dict.fromkeys(merged[field] + new_value)) + else: + # Complex types (objects) - just concatenate + merged[field] = merged[field] + new_value + logger.info(f"Merged array field '{field}': Concatenated to {len(merged[field])} total items") + elif isinstance(new_value, list): + # Existing was not an array but should be, replace + logger.info(f"Array field '{field}': Setting initial value with {len(new_value)} items") + merged[field] = new_value + else: + # Scalar field - choose most valid value + if merged[field] != new_value: + existing_val = str(merged[field]) if merged[field] else "" + new_val = str(new_value) if new_value else "" + + # For email fields, ONLY accept values with @ symbol + if 'email' in field.lower(): + has_existing_at = '@' in existing_val + has_new_at = '@' in new_val + + if has_new_at and not has_existing_at: + # New value is valid email, existing is not + logger.info(f"Field '{field}': replacing '{existing_val}' with valid email '{new_val}'") + merged[field] = new_value + elif has_existing_at and not has_new_at: + # Keep existing valid email, reject invalid new value + logger.info(f"Field '{field}': keeping valid email '{existing_val}', rejecting '{new_val}'") + elif has_new_at and has_existing_at: + # Both are emails, keep first one + logger.info(f"Field '{field}': keeping first email '{existing_val}'") + else: + # Neither is valid email, keep existing + logger.warning(f"Field '{field}': no valid email found ('{existing_val}' vs '{new_val}'), keeping first") + else: + # For non-email fields, prefer longer/more detailed values + if len(new_val) > len(existing_val): + logger.info(f"Field '{field}': using more detailed value '{new_val}' over '{existing_val}'") + merged[field] = new_value + else: + logger.debug(f"Field '{field}': keeping '{existing_val}'") + + return merged + + def _extract_from_text_only(self, text: str, schema: Dict[str, str]) -> Dict[str, Any]: + """Extract using text-only vision model (fallback) with section-aware parsing""" + try: + schema_description = self._format_schema_for_prompt(schema) + + prompt = f"""Extract data from this document text as JSON. + +FIELDS TO EXTRACT: +{schema_description} + +CRITICAL RULES: +1. Section Header Matching (HIGHEST PRIORITY): + - ONLY extract from sections with matching numbered headers + - Look for numbered section headers like "1.", "2.", "4.", "8." followed by section names + - "warranty" field → ONLY from "8. WARRANTY" or "WARRANTY" section header + - "deliverables" field → ONLY from "4. DELIVERABLES" or "DELIVERABLES" section header + - "scope_of_service" field → ONLY from "1. SCOPE OF SERVICES" or "SCOPE OF SERVICE" section header + - DO NOT extract a field from a bullet point list UNLESS that list is under the matching section header + - If you see warranty-related text in a deliverables bullet list, it is NOT warranty - ignore it + +2. Field vs Section Distinction: + - If "warranty" appears in a numbered list under "4. DELIVERABLES", ignore it (it's a deliverable item) + - If "warranty" has its own section header "8. WARRANTY", extract from there + - Section headers are usually numbered (1., 2., 8.) and may be in CAPS or bold + +3. Output Format: + - Return ONLY valid JSON with exact field names above + - No markdown, no code fences, no commentary + - Use null for missing fields or if section not found in text + +4. Array Fields: + - Extract ALL bullet points from the matched section ONLY + - If "scope_of_service" is array, extract bullets ONLY from "1. SCOPE OF SERVICES" section + - If "deliverables" is array, extract bullets ONLY from "4. DELIVERABLES" section + - Keep complete sentences together as single array items + +5. Text Structure: + - Sections are separated by numbered headers (1., 2., etc.) + - Content under a section belongs to that section until next numbered header + - Bullet points (•, -, numbered lists) belong to their parent section + +Example - WRONG extraction: +Text shows: +"4. DELIVERABLES +- Complete source code +- 60 days warranty support" + +{{"warranty": "60 days"}} ← WRONG! This is from deliverables section, not warranty section! + +Example - CORRECT extraction: +Text shows: +"4. DELIVERABLES +- Complete source code +- Technical documentation" +"8. WARRANTY +Contractor warrants work will be free from defects for 90 days" + +{{"deliverables": ["Complete source code", "Technical documentation"], "warranty": "90 days"}} ← CORRECT! + +Document text: +{text[:4000]} + +Return ONLY valid JSON with the exact field names specified.""" + + response = self.client.chat.completions.create( + model=config.VISION_MODEL, + messages=[ + {"role": "user", "content": prompt} + ], + max_tokens=config.VISION_MAX_TOKENS, + temperature=config.VISION_TEMPERATURE, + timeout=180.0 # 3 minutes for slower inference servers + ) + + result_text = response.choices[0].message.content + extracted_data = self._parse_json_response(result_text) + + return extracted_data if extracted_data else self._mock_extraction(schema) + + except Exception as e: + logger.error(f"Text-only extraction error: {str(e)}") + return self._mock_extraction(schema) + + def _parse_json_response(self, response_text: str) -> Dict[str, Any]: + """Parse JSON from model response with smart extraction and sanitization""" + import json + import re + + def extract_json_object(text: str) -> str: + """Extract JSON object by finding matching braces (first { to last })""" + first_brace = text.find('{') + if first_brace == -1: + return "" + + # Find matching closing brace by counting + brace_count = 0 + last_brace = -1 + + for i in range(first_brace, len(text)): + if text[i] == '{': + brace_count += 1 + elif text[i] == '}': + brace_count -= 1 + if brace_count == 0: + last_brace = i + break + + if last_brace == -1: + # No matching brace found, find last } in text + last_brace = text.rfind('}') + if last_brace == -1: + return text[first_brace:] # Return from first { to end + + return text[first_brace:last_brace + 1] + + def sanitize_json_strings(json_str: str) -> str: + """Sanitize control characters and fix corrupted string values""" + # Remove NULL bytes + sanitized = json_str.replace('\x00', '') + + # Fix pattern: "field": "value that may span + # multiple lines or have garbage" + # We need to handle cases where value contains unescaped newlines or quotes + + def fix_field_value(match): + field_name = match.group(1) + # Everything after the colon and opening quote until we hit valid JSON again + rest = match.group(2) + + # Find where the value should end (next field or closing brace) + # Look for patterns like: ",\n "field": or }\n + next_field = re.search(r'[,\}]\s*(?:\n\s*)?"', rest) + if next_field: + value = rest[:next_field.start()] + else: + value = rest + + # Clean the value: remove literal newlines, escape special chars, remove garbage + value = re.sub(r'\s*\n\s*', ' ', value) # Replace newlines with space + value = re.sub(r'\s+', ' ', value).strip() # Normalize whitespace + # Remove random garbage words that shouldn't be there + value = re.sub(r'\s*addCriterion\s*', '', value) + # Escape any remaining control characters + value = value.replace('\r', ' ').replace('\t', ' ') + + return f'"{field_name}": "{value}"' + + # Match "field": " and capture everything after + sanitized = re.sub(r'"([^"]+)":\s*"([^}]+?)(?="[^"]*":|$)', fix_field_value, sanitized, flags=re.DOTALL) + + return sanitized + + def fix_truncated_json(json_str: str) -> str: + """Attempt to fix truncated JSON by closing structures""" + fixed = json_str.strip() + + # Remove trailing comma + if fixed.endswith(','): + fixed = fixed[:-1] + + # If string count is odd, we have an unclosed string + if fixed.count('"') % 2 != 0: + # Find the last complete field and truncate there + last_comma = fixed.rfind('",') + if last_comma != -1: + fixed = fixed[:last_comma + 1] + else: + # Find last colon and set to null + last_colon = fixed.rfind('":') + if last_colon != -1: + fixed = fixed[:last_colon + 1] + ' null' + + # Close any unclosed brackets and braces + open_brackets = fixed.count('[') - fixed.count(']') + open_braces = fixed.count('{') - fixed.count('}') + + fixed += ']' * open_brackets + fixed += '}' * open_braces + + return fixed + + # Step 1: Try direct parse (best case) + try: + return json.loads(response_text) + except json.JSONDecodeError: + pass + + # Step 2: Extract JSON object (from first { to matching }) + json_str = extract_json_object(response_text) + + if not json_str: + logger.error(f"Could not find JSON object in response: {response_text[:200]}") + return {} + + # Step 3: Try parsing extracted JSON + try: + return json.loads(json_str) + except json.JSONDecodeError as e: + logger.debug(f"Extracted JSON parse failed: {str(e)}, attempting repair...") + + # Step 4: Sanitize control characters in string values + try: + sanitized = sanitize_json_strings(json_str) + return json.loads(sanitized) + except json.JSONDecodeError as e: + logger.debug(f"Sanitized JSON parse failed: {str(e)}, attempting truncation fix...") + + # Step 5: Try fixing truncation + try: + fixed = fix_truncated_json(json_str) + result = json.loads(fixed) + logger.warning("Successfully repaired truncated/malformed JSON - some data may be incomplete") + return result + except json.JSONDecodeError as e: + logger.error(f"All JSON repair attempts failed: {str(e)}") + logger.error(f"Failed JSON preview: {json_str[:300]}") + return {} + + def _format_schema_for_prompt(self, schema: Dict[str, str]) -> str: + """Format schema for prompt with helpful descriptions""" + lines = [] + + # Field-specific extraction hints to improve accuracy + field_hints = { + # Section-based array fields + "deliverables": "array from section '4. DELIVERABLES'", + "deliverable": "array from 'DELIVERABLES' section", + "scope_of_work": "array from section '1. SCOPE OF WORK' or '1. SCOPE OF SERVICES'", + "scope_of_services": "array from section '1. SCOPE OF SERVICES'", + "scope_of_service": "array from section '1. SCOPE OF SERVICES'", + + # Representative fields + "contractor_representative": "text following 'Representative:' label in AND/contractor section", + "contractor_representative_name": "text following 'Representative:' label in contractor section", + "client_representative": "text following 'Representative:' label in BETWEEN/client section", + "client_representative_name": "text following 'Representative:' label in client section", + + # Email fields + "contractor_email": "email address following 'Email:' label in AND/contractor section", + "client_email": "email address following 'Email:' label in BETWEEN/client section", + + # Company fields + "contractor_name": "text following 'Company Name:' in contractor section", + "client_company_name": "text following 'Company Name:' in client section", + + # Medical/invoice common fields + "medications": "array of prescribed medicines", + "line_items": "array of items with details" + } + + for field, field_type in schema.items(): + # Check if field_type is a dict (nested schema) or string + if isinstance(field_type, dict): + type_str = field_type.get("type", "string") + description = field_type.get("description", "") + else: + type_str = field_type + description = "" + + # Use custom hint if available, otherwise use description from schema + hint = field_hints.get(field.lower(), description) + + if hint: + lines.append(f"- {field} ({type_str}): {hint}") + else: + lines.append(f"- {field} ({type_str})") + + return "\n".join(lines) + + def _classify_by_keywords(self, text: str) -> Dict[str, Any]: + """ + Fast rule-based classification using keyword patterns. + Returns classification result or None if no confident match. + """ + text_lower = text.lower() + + # Prescription indicators (high confidence) + prescription_keywords = ['prescription', 'rx ', 'medication', 'dosage', 'sig:', 'pharmacy', 'prescriber', 'refills'] + prescription_count = sum(1 for kw in prescription_keywords if kw in text_lower) + if prescription_count >= 2: + return { + "document_type": "prescription", + "confidence": 0.85, + "reasoning": f"Contains {prescription_count} prescription-specific keywords" + } + + # Invoice indicators + invoice_keywords = ['invoice', 'invoice number', 'invoice #', 'bill to', 'payment terms', 'net ', 'due date'] + invoice_count = sum(1 for kw in invoice_keywords if kw in text_lower) + if invoice_count >= 2: + return { + "document_type": "invoice", + "confidence": 0.85, + "reasoning": f"Contains {invoice_count} invoice-specific keywords" + } + + # Contract indicators + contract_keywords = ['agreement', 'hereby', 'whereas', 'party', 'contract', 'terms and conditions', 'signed'] + contract_count = sum(1 for kw in contract_keywords if kw in text_lower) + if contract_count >= 3: + return { + "document_type": "contract", + "confidence": 0.85, + "reasoning": f"Contains {contract_count} contract-specific keywords" + } + + # Bank statement indicators + bank_keywords = ['account number', 'statement period', 'beginning balance', 'ending balance', 'transaction'] + bank_count = sum(1 for kw in bank_keywords if kw in text_lower) + if bank_count >= 2: + return { + "document_type": "bank_statement", + "confidence": 0.85, + "reasoning": f"Contains {bank_count} banking-specific keywords" + } + + # Receipt indicators + receipt_keywords = ['receipt', 'thank you', 'payment method', 'subtotal', 'tax', 'change due'] + receipt_count = sum(1 for kw in receipt_keywords if kw in text_lower) + if receipt_count >= 2: + return { + "document_type": "receipt", + "confidence": 0.80, + "reasoning": f"Contains {receipt_count} receipt-specific keywords" + } + + return None + + def _build_types_description(self, available_types: List[str]) -> str: + """ + Build a formatted description of available document types with common indicators. + + Args: + available_types: List of document types available in the database + + Returns: + Formatted string describing each type + """ + type_indicators = { + "invoice": "vendor name, invoice number, line items with prices, total amount, payment terms", + "prescription": "patient info, doctor/prescriber name, medication names, dosage instructions, pharmacy", + "bank_statement": "account number, statement period dates, transaction list, balances", + "receipt": "store/business name, items purchased, subtotal, tax, total, payment method", + "contract": "legal language (\"hereby\", \"whereas\"), party names, signatures, terms/conditions, sections", + "service_contract": "service agreement, deliverables, scope of work, payment terms, signatures", + "report": "title, sections, analysis, data tables, conclusions, recommendations" + } + + descriptions = [] + for doc_type in available_types: + # Normalize type for matching (remove underscores, lowercase) + normalized_type = doc_type.lower().replace("_", " ").replace("-", " ") + type_key = doc_type.lower().replace("_", "").replace("-", "") + + # Try to find matching indicators + indicators = None + for key, value in type_indicators.items(): + if key.replace("_", "").replace("-", "") == type_key: + indicators = value + break + + if indicators: + descriptions.append(f"• {doc_type} - Typical indicators: {indicators}") + else: + # Generic description for unknown types + descriptions.append(f"• {doc_type} - Custom document type") + + return "\n".join(descriptions) + + def detect_document_type(self, pdf_content: bytes, available_types: List[str] = None) -> Dict[str, Any]: + """ + Detect the type of document using hybrid approach against available template types. + 1. Fast keyword-based classification (50ms) + 2. VL model fallback for unclear cases (5-10 seconds) + + Args: + pdf_content: Binary PDF content + available_types: List of document types that have templates in the database + + Returns dict with: + - document_type: str (e.g., "invoice", "prescription", "bank_statement") + - confidence: float (0.0-1.0) + - reasoning: str (explanation) + """ + if not self.client: + logger.warning("No vision client. Returning mock document type.") + return {"document_type": "unknown", "confidence": 0.0, "reasoning": "Vision service unavailable"} + + if not available_types: + logger.warning("No available document types provided for classification") + return {"document_type": "unknown", "confidence": 0.0, "reasoning": "No templates available in the system"} + + try: + # STEP 1: Extract first page text for fast classification + text = self._extract_text_from_pages(pdf_content, page_numbers=[1]) + + # STEP 2: Try fast keyword-based classification first + if len(text.strip()) >= 50: + keyword_result = self._classify_by_keywords(text) + if keyword_result and keyword_result['confidence'] >= 0.80: + logger.info(f"Fast classification: {keyword_result['document_type']} ({keyword_result['confidence']:.0%})") + return keyword_result + + # STEP 3: If no text or low confidence, use VL model with optimized image + if len(text.strip()) < 50: + logger.info("Limited text found, using image for document type detection") + page_images = self._pdf_to_images(pdf_content, page_numbers=[1], zoom=1.0) # Reduced zoom + if not page_images: + raise ValueError("Cannot extract text or images from PDF") + + # Optimize image size before sending + img = page_images[0] + max_width = 800 # Reduced from potentially 2000+ + if img.width > max_width: + ratio = max_width / img.width + new_size = (max_width, int(img.height * ratio)) + img = img.resize(new_size, Image.Resampling.LANCZOS) + logger.info(f"Resized image to {new_size[0]}x{new_size[1]} for faster processing") + + # Compress as JPEG for smaller size + buffered = BytesIO() + img.convert('RGB').save(buffered, format="JPEG", quality=85, optimize=True) + img_base64 = base64.b64encode(buffered.getvalue()).decode() + img_size_kb = len(buffered.getvalue()) / 1024 + logger.info(f"Optimized image size: {img_size_kb:.1f}KB") + + # Build dynamic prompt with available types + types_description = self._build_types_description(available_types) + + message_content = [ + {"type": "text", "text": f"""Analyze this document image and classify its type. + +AVAILABLE DOCUMENT TYPES IN SYSTEM (choose ONE from this list ONLY): +{types_description} + +IMPORTANT: +- You MUST choose ONE type from the list above +- If the document doesn't clearly match any type, choose the closest match with lower confidence +- DO NOT suggest types that are not in the list above + +RESPONSE FORMAT (JSON only): +{{ + "document_type": "", + "confidence": <0.0 to 1.0>, + "reasoning": "<2-3 key visual indicators you see>" +}} + +Look at the document carefully and identify the strongest visual indicators."""}, + { + "type": "image_url", + "image_url": { + "url": f"data:image/jpeg;base64,{img_base64}", + "detail": "low" + } + } + ] + else: + # Text-based classification with dynamic types + types_description = self._build_types_description(available_types) + + message_content = f"""Analyze this document text and classify its type. + +Document text excerpt: +{text[:800]} + +AVAILABLE DOCUMENT TYPES IN SYSTEM (choose ONE from this list ONLY): +{types_description} + +IMPORTANT: +- You MUST choose ONE type from the list above +- If the document doesn't clearly match any type, choose the closest match with lower confidence +- DO NOT suggest types that are not in the list above + +RESPONSE FORMAT (JSON only): +{{ + "document_type": "", + "confidence": <0.0 to 1.0>, + "reasoning": "<2-3 key text patterns you found>" +}} + +Return ONLY valid JSON, no other text.""" + + # Call VL model with extended timeout for slower inference servers + response = self.client.chat.completions.create( + model=config.DETECTION_MODEL, + messages=[{"role": "user", "content": message_content}], + max_tokens=300, # Increased for better reasoning + temperature=0.2, # Slightly higher for flexibility + timeout=120.0 # 2 minutes for classification on slower servers + ) + + result = self._parse_json_response(response.choices[0].message.content) + + if not result or not result.get('document_type'): + logger.warning("VL model returned empty result") + return {"document_type": "undetected", "confidence": 0.0, "reasoning": "Could not determine document type"} + + logger.info(f"VL classification: {result.get('document_type')} ({result.get('confidence', 0):.0%})") + return result + + except TimeoutError: + logger.error("Document classification timed out after 2 minutes") + return { + "document_type": "undetected", + "confidence": 0.0, + "reasoning": "Classification timed out. Please select a template manually." + } + except Exception as e: + logger.error(f"Document type detection failed: {str(e)}") + return { + "document_type": "undetected", + "confidence": 0.0, + "reasoning": f"Classification failed: {str(e)}" + } + + def _mock_extraction(self, schema: Dict[str, str]) -> Dict[str, Any]: + """Generate mock data based on schema""" + mock_data = {} + for field, field_type in schema.items(): + if "invoice" in field.lower() or "number" in field.lower(): + mock_data[field] = "INV-2024-001" + elif "date" in field.lower(): + mock_data[field] = "2024-12-29" + elif "vendor" in field.lower() or "company" in field.lower(): + mock_data[field] = "Acme Corporation" + elif "total" in field.lower() or "amount" in field.lower(): + mock_data[field] = 1250.00 + elif field_type == "array": + mock_data[field] = [ + {"description": "Consulting Services", "amount": 1000.00}, + {"description": "Software License", "amount": 250.00} + ] + else: + mock_data[field] = f"Mock {field}" + + return mock_data + + def process_chat_message(self, message: str, current_schema: Dict[str, str]) -> tuple[str, Dict[str, str]]: + """Process chat message to build extraction schema""" + + if not self.client: + return self._mock_chat_response(message, current_schema) + + try: + schema_str = "\n".join([f"- {k}: {v}" for k, v in current_schema.items()]) if current_schema else "None yet" + + system_prompt = """You are a helpful assistant that helps users build document extraction schemas. + +When users greet you or ask questions: Respond conversationally and ask what fields they want to extract. +When users request fields: Update the schema and confirm. + +FIELD NAME RULES: +- Use snake_case +- Use EXACT names user specifies +- Plural words (deliverables, medications, items) = array type +- NO typos, NO extra letters, NO suffix additions like "_list" + +DATA TYPES: +- string: text, names, emails +- number: integers, floats +- date: dates +- array: lists, multiple items, plural nouns +- object: nested structures + +USER REQUEST PATTERNS: +"extract X" or "add X" → ADD field X to schema (determine type from name) +"extract X as array" → ADD field X with type=array +"remove X" → DELETE field X from schema +"replace X with Y" → DELETE X, ADD Y +"X and Y as well" → ADD both X and Y to schema +"X, Y, Z" → ADD all three to schema +"hi", "hello", "help" → Greet and ask what to extract + +RESPONSE FORMAT (JSON only): +{ + "reply": "Your conversational response", + "schema": {"field1": "type1", "field2": "type2"} +} + +EXAMPLES: +User: "hi" +Response: {"reply": "Hello! I'll help you configure document extraction. What fields would you like to extract from your documents?", "schema": {}} + +User: "extract deliverables" +Response: {"reply": "Added deliverables as an array field. What else would you like to extract?", "schema": {"deliverables": "array"}} + +User: "add client_email and contractor_email" +Response: {"reply": "Added client_email and contractor_email fields.", "schema": {"deliverables": "array", "client_email": "string", "contractor_email": "string"}}""" + + user_prompt = f"""Current schema: +{schema_str} + +User request: {message} + +Process this request and update the schema accordingly.""" + + response = self.client.chat.completions.create( + model=config.VISION_MODEL, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ], + max_tokens=800, + temperature=0.3, + timeout=120.0 # 2 minutes for chat processing on slower servers + ) + + result = self._parse_json_response(response.choices[0].message.content) + + reply = result.get("reply", "I'm here to help! What would you like to extract?") + updated_schema = result.get("schema", current_schema) + + logger.info(f"Chat processed: {len(updated_schema)} fields in schema") + + return reply, updated_schema + + except Exception as e: + logger.error(f"Chat processing error: {str(e)}") + return self._mock_chat_response(message, current_schema) + + def _mock_chat_response(self, message: str, current_schema: Dict[str, str]) -> tuple[str, Dict[str, str]]: + """Mock chat response when vision client is not available""" + message_lower = message.lower() + + if not current_schema: + if "invoice" in message_lower: + reply = "I'll help you extract invoice data. What fields do you need? Common invoice fields include: invoice number, date, vendor, line items, and total amount." + return reply, {} + else: + reply = "I can help you define what data to extract from your documents. What type of document are you working with?" + return reply, {} + + new_schema = dict(current_schema) + + if "invoice number" in message_lower or "number" in message_lower: + new_schema["invoice_number"] = "string" + if "date" in message_lower: + new_schema["date"] = "date" + if "vendor" in message_lower or "company" in message_lower: + new_schema["vendor"] = "string" + if "total" in message_lower or "amount" in message_lower: + new_schema["total"] = "number" + if "line item" in message_lower or "items" in message_lower: + new_schema["line_items"] = "array" + + if new_schema != current_schema: + field_list = ", ".join(new_schema.keys()) + reply = f"Got it! I'll extract these fields: {field_list}. You can test this extraction or add more fields." + else: + reply = "What other fields would you like to extract?" + + return reply, new_schema + + def _extract_schema_from_response(self, message: str, current_schema: Dict[str, str]) -> Dict[str, str]: + """Extract schema updates from user message""" + new_schema = dict(current_schema) + message_lower = message.lower() + + field_mappings = { + "invoice number": ("invoice_number", "string"), + "invoice_number": ("invoice_number", "string"), + "date": ("date", "date"), + "vendor": ("vendor", "string"), + "company": ("vendor", "string"), + "total": ("total", "number"), + "amount": ("total", "number"), + "line items": ("line_items", "array"), + "items": ("line_items", "array"), + } + + for keyword, (field, field_type) in field_mappings.items(): + if keyword in message_lower: + new_schema[field] = field_type + + return new_schema diff --git a/sample_solutions/DocQvision/api/utils/__init__.py b/sample_solutions/DocQvision/api/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sample_solutions/DocQvision/api/utils/validators.py b/sample_solutions/DocQvision/api/utils/validators.py new file mode 100644 index 00000000..5a7ae58a --- /dev/null +++ b/sample_solutions/DocQvision/api/utils/validators.py @@ -0,0 +1,66 @@ +"""Validation utilities for file uploads and data""" +import logging + +logger = logging.getLogger(__name__) + + +def validate_pdf_file(content: bytes, filename: str) -> tuple[bool, str]: + """ + Validate that uploaded file is a legitimate PDF + + Args: + content: File bytes + filename: Original filename + + Returns: + (is_valid, error_message) + """ + if not content: + return False, "Empty file" + + if not filename.lower().endswith('.pdf'): + return False, "Only PDF files are supported" + + if not content.startswith(b'%PDF-'): + return False, "Invalid PDF file format (magic bytes mismatch)" + + if len(content) > 10 * 1024 * 1024: + return False, "File size exceeds 10MB limit" + + return True, "" + + +def sanitize_chat_message(message: str) -> str: + """ + Sanitize user chat input to prevent prompt injection + + Args: + message: User chat message + + Returns: + Sanitized message + """ + if not message: + return "" + + message = message.strip() + + if len(message) > 2000: + message = message[:2000] + + suspicious_patterns = [ + "ignore previous", + "ignore all previous", + "disregard previous", + "forget previous", + "new instructions:", + "system:", + "assistant:", + ] + + message_lower = message.lower() + for pattern in suspicious_patterns: + if pattern in message_lower: + logger.warning(f"Potential prompt injection detected: {pattern}") + + return message diff --git a/sample_solutions/DocQvision/assets/configure-chat.png b/sample_solutions/DocQvision/assets/configure-chat.png new file mode 100644 index 00000000..0691ed17 Binary files /dev/null and b/sample_solutions/DocQvision/assets/configure-chat.png differ diff --git a/sample_solutions/DocQvision/assets/configure-initial.png b/sample_solutions/DocQvision/assets/configure-initial.png new file mode 100644 index 00000000..4098cb0c Binary files /dev/null and b/sample_solutions/DocQvision/assets/configure-initial.png differ diff --git a/sample_solutions/DocQvision/assets/configure-test-results.png b/sample_solutions/DocQvision/assets/configure-test-results.png new file mode 100644 index 00000000..5eb690ab Binary files /dev/null and b/sample_solutions/DocQvision/assets/configure-test-results.png differ diff --git a/sample_solutions/DocQvision/assets/history-page.png b/sample_solutions/DocQvision/assets/history-page.png new file mode 100644 index 00000000..074f9101 Binary files /dev/null and b/sample_solutions/DocQvision/assets/history-page.png differ diff --git a/sample_solutions/DocQvision/assets/results-view.png b/sample_solutions/DocQvision/assets/results-view.png new file mode 100644 index 00000000..5eb690ab Binary files /dev/null and b/sample_solutions/DocQvision/assets/results-view.png differ diff --git a/sample_solutions/DocQvision/assets/upload-page.png b/sample_solutions/DocQvision/assets/upload-page.png new file mode 100644 index 00000000..4a23c34c Binary files /dev/null and b/sample_solutions/DocQvision/assets/upload-page.png differ diff --git a/sample_solutions/DocQvision/docker-compose.yml b/sample_solutions/DocQvision/docker-compose.yml new file mode 100644 index 00000000..98656677 --- /dev/null +++ b/sample_solutions/DocQvision/docker-compose.yml @@ -0,0 +1,109 @@ +# ============================================================================= +# DocQvision - Docker Compose Configuration +# ============================================================================= +# This file orchestrates the DocQvision application stack consisting of: +# - Backend API (FastAPI) +# - Frontend UI (React + Nginx) +# ============================================================================= + +services: + # =========================================================================== + # Backend API Service + # =========================================================================== + backend: + build: + context: ./api + dockerfile: Dockerfile + args: + - BUILDKIT_INLINE_CACHE=1 + image: DocQvision-backend:latest + container_name: DocQvision-backend + ports: + - "${BACKEND_PORT:-5001}:5001" + env_file: + - ./api/.env + environment: + - DATABASE_URL=sqlite:////app/data/DocQvision.db + - PYTHONUNBUFFERED=1 + volumes: + # Persistent database storage + - db_data:/app/data + extra_hosts: + - "${LOCAL_URL_ENDPOINT}:host-gateway" + networks: + - DocQvision_network + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5001/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + deploy: + resources: + limits: + cpus: '2' + memory: 2G + reservations: + cpus: '0.5' + memory: 512M + labels: + - "com.DocQvision.service=backend" + - "com.DocQvision.version=1.0" + + # =========================================================================== + # Frontend UI Service + # =========================================================================== + frontend: + build: + context: ./ui + dockerfile: Dockerfile + args: + - BUILDKIT_INLINE_CACHE=1 + image: DocQvision-frontend:latest + container_name: DocQvision-frontend + ports: + - "${FRONTEND_PORT:-3000}:80" + depends_on: + backend: + condition: service_healthy + networks: + - DocQvision_network + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost/"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 10s + deploy: + resources: + limits: + cpus: '1' + memory: 512M + reservations: + cpus: '0.25' + memory: 128M + labels: + - "com.DocQvision.service=frontend" + - "com.DocQvision.version=1.0" + +# ============================================================================= +# Networks +# ============================================================================= +networks: + DocQvision_network: + driver: bridge + name: DocQvision_network + labels: + - "com.DocQvision.network=main" + +# ============================================================================= +# Volumes +# ============================================================================= +volumes: + db_data: + driver: local + name: DocQvision_db_data + labels: + - "com.DocQvision.volume=database" diff --git a/sample_solutions/DocQvision/sample-documents/invoice1.pdf b/sample_solutions/DocQvision/sample-documents/invoice1.pdf new file mode 100644 index 00000000..80ef2fa7 Binary files /dev/null and b/sample_solutions/DocQvision/sample-documents/invoice1.pdf differ diff --git a/sample_solutions/DocQvision/sample-documents/invoice2.pdf b/sample_solutions/DocQvision/sample-documents/invoice2.pdf new file mode 100644 index 00000000..79d63e65 Binary files /dev/null and b/sample_solutions/DocQvision/sample-documents/invoice2.pdf differ diff --git a/sample_solutions/DocQvision/sample-documents/prescription.pdf b/sample_solutions/DocQvision/sample-documents/prescription.pdf new file mode 100644 index 00000000..d8a936d3 Binary files /dev/null and b/sample_solutions/DocQvision/sample-documents/prescription.pdf differ diff --git a/sample_solutions/DocQvision/sample-documents/service-contract.pdf b/sample_solutions/DocQvision/sample-documents/service-contract.pdf new file mode 100644 index 00000000..066001db Binary files /dev/null and b/sample_solutions/DocQvision/sample-documents/service-contract.pdf differ diff --git a/sample_solutions/DocQvision/ui/.dockerignore b/sample_solutions/DocQvision/ui/.dockerignore new file mode 100644 index 00000000..1fa4d037 --- /dev/null +++ b/sample_solutions/DocQvision/ui/.dockerignore @@ -0,0 +1,56 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Production build +dist/ +build/ +.cache/ + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Testing +coverage/ +.nyc_output/ + +# Logs +logs/ +*.log + +# Documentation +*.md +docs/ + +# Git +.git/ +.gitignore +.gitattributes + +# Docker +Dockerfile* +docker-compose*.yml +.dockerignore + +# Temporary files +*.tmp +*.bak +.cache/ + +# OS +Thumbs.db diff --git a/sample_solutions/DocQvision/ui/Dockerfile b/sample_solutions/DocQvision/ui/Dockerfile new file mode 100644 index 00000000..230b0b98 --- /dev/null +++ b/sample_solutions/DocQvision/ui/Dockerfile @@ -0,0 +1,64 @@ +# ============================================================================= +# DocQvision UI - Production Dockerfile +# ============================================================================= + +# ============================================================================= +# Builder stage - Build React application +# ============================================================================= +FROM node:20-alpine as builder + +WORKDIR /app + +# Copy package files for dependency installation +COPY package.json ./ + +# Install dependencies +RUN npm install + +# Copy source code +COPY . . + +# Build the application +RUN npm run build + +# ============================================================================= +# Runtime stage - Serve with nginx +# ============================================================================= +FROM nginx:1.25-alpine as runtime + +# Install curl for healthcheck +RUN apk add --no-cache curl + +# Remove default nginx config +RUN rm -rf /etc/nginx/conf.d/* + +# Copy custom nginx configuration +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Copy built application from builder +COPY --from=builder /app/dist /usr/share/nginx/html + +# Create non-root user for security +RUN addgroup -g 1001 -S appuser && \ + adduser -S -D -H -u 1001 -h /var/cache/nginx -s /sbin/nologin -G appuser -g appuser appuser + +# Change ownership of nginx directories +RUN chown -R appuser:appuser /var/cache/nginx && \ + chown -R appuser:appuser /var/log/nginx && \ + chown -R appuser:appuser /etc/nginx/conf.d && \ + touch /var/run/nginx.pid && \ + chown -R appuser:appuser /var/run/nginx.pid && \ + chown -R appuser:appuser /usr/share/nginx/html + +# Switch to non-root user +USER appuser + +# Expose HTTP port +EXPOSE 80 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost/ || exit 1 + +# Start nginx +CMD ["nginx", "-g", "daemon off;"] diff --git a/sample_solutions/DocQvision/ui/index.html b/sample_solutions/DocQvision/ui/index.html new file mode 100644 index 00000000..9da79c86 --- /dev/null +++ b/sample_solutions/DocQvision/ui/index.html @@ -0,0 +1,12 @@ + + + + + + DocQvision - Document Intelligence Platform + + +
+ + + diff --git a/sample_solutions/DocQvision/ui/nginx.conf b/sample_solutions/DocQvision/ui/nginx.conf new file mode 100644 index 00000000..0194eb93 --- /dev/null +++ b/sample_solutions/DocQvision/ui/nginx.conf @@ -0,0 +1,26 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Frontend routes - React Router + location / { + try_files $uri $uri/ /index.html; + } + + # Backend API proxy + location /api { + proxy_pass http://DocQvision-backend:5001; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 300s; + proxy_connect_timeout 75s; + } +} diff --git a/sample_solutions/DocQvision/ui/package-lock.json b/sample_solutions/DocQvision/ui/package-lock.json new file mode 100644 index 00000000..06c2b01b --- /dev/null +++ b/sample_solutions/DocQvision/ui/package-lock.json @@ -0,0 +1,3529 @@ +{ + "name": "docqvision-ui", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "docqvision-ui", + "version": "1.0.0", + "dependencies": { + "axios": "^1.13.5", + "lucide-react": "^0.454.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-dropzone": "^14.2.10", + "react-pdf": "^9.1.1", + "react-router-dom": "^6.29.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.3", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.14", + "vite": "^5.4.10" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz", + "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz", + "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz", + "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz", + "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz", + "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz", + "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz", + "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz", + "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz", + "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz", + "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz", + "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz", + "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz", + "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz", + "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz", + "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz", + "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz", + "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz", + "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz", + "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz", + "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz", + "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz", + "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.23", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.23.tgz", + "integrity": "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001760", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.11", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz", + "integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001761", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001761.tgz", + "integrity": "sha512-JF9ptu1vP2coz98+5051jZ4PwQgd2ni8A+gYSN7EA7dPKIMf0pDlSUxhdmVOaV3/fYK5uWBkgSXJaRLr4+3A6g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/canvas": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/canvas/-/canvas-3.2.0.tgz", + "integrity": "sha512-jk0GxrLtUEmW/TmFsk2WghvgHe8B0pxGilqCL21y8lHkPUGa6FTsnCNtHPOzT8O3y+N+m3espawV80bbBlgfTA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^7.0.0", + "prebuild-install": "^7.1.3" + }, + "engines": { + "node": "^18.12.0 || >= 20.9.0" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC", + "optional": true + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-selector": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "license": "MIT", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT", + "optional": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT", + "optional": true + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC", + "optional": true + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC", + "optional": true + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.454.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.454.0.tgz", + "integrity": "sha512-hw7zMDwykCLnEzgncEEjHeA6+45aeEzRYuKHuyRSOPkhko+J3ySGjGIzu+mmMfDFG1vazHepMaYFYHbTFAZAAQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/make-cancellable-promise": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-1.3.2.tgz", + "integrity": "sha512-GCXh3bq/WuMbS+Ky4JBPW1hYTOU+znU+Q5m9Pu+pI8EoUqIHk9+tviOKC6/qhHh8C4/As3tzJ69IF32kdz85ww==", + "license": "MIT", + "funding": { + "url": "https://github.com/wojtekmaj/make-cancellable-promise?sponsor=1" + } + }, + "node_modules/make-event-props": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-1.6.2.tgz", + "integrity": "sha512-iDwf7mA03WPiR8QxvcVHmVWEPfMY1RZXerDVNCRYW7dUr2ppH3J58Rwb39/WG39yTZdRSxr3x+2v22tvI0VEvA==", + "license": "MIT", + "funding": { + "url": "https://github.com/wojtekmaj/make-event-props?sponsor=1" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge-refs": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-1.3.0.tgz", + "integrity": "sha512-nqXPXbso+1dcKDpPCXvwZyJILz+vSLqGGOnDrYHQYE+B8n9JTCekVLC65AfCpR4ggVyA/45Y0iR9LDyS2iI+zA==", + "license": "MIT", + "funding": { + "url": "https://github.com/wojtekmaj/merge-refs?sponsor=1" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT", + "optional": true + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT", + "optional": true + }, + "node_modules/node-abi": { + "version": "3.85.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", + "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-abi/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT", + "optional": true + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "optional": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path2d": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/path2d/-/path2d-0.2.2.tgz", + "integrity": "sha512-+vnG6S4dYcYxZd+CZxzXCNKdELYZSKfohrk98yajCo1PtRoDgCTrrwOvK1GT0UoAdVszagDVllQc0U1vaX4NUQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/pdfjs-dist": { + "version": "4.8.69", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-4.8.69.tgz", + "integrity": "sha512-IHZsA4T7YElCKNNXtiLgqScw4zPd3pG9do8UrznC757gMd7UPeHSL2qwNNMJo4r79fl8oj1Xx+1nh2YkzdMpLQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "canvas": "^3.0.0-rc2", + "path2d": "^0.2.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-dropzone": { + "version": "14.3.8", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz", + "integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==", + "license": "MIT", + "dependencies": { + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-pdf": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-9.2.1.tgz", + "integrity": "sha512-AJt0lAIkItWEZRA5d/mO+Om4nPCuTiQ0saA+qItO967DTjmGjnhmF+Bi2tL286mOTfBlF5CyLzJ35KTMaDoH+A==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "dequal": "^2.0.3", + "make-cancellable-promise": "^1.3.1", + "make-event-props": "^1.6.0", + "merge-refs": "^1.3.0", + "pdfjs-dist": "4.8.69", + "tiny-invariant": "^1.0.0", + "warning": "^4.0.0" + }, + "funding": { + "url": "https://github.com/wojtekmaj/react-pdf?sponsor=1" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.54.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", + "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.54.0", + "@rollup/rollup-android-arm64": "4.54.0", + "@rollup/rollup-darwin-arm64": "4.54.0", + "@rollup/rollup-darwin-x64": "4.54.0", + "@rollup/rollup-freebsd-arm64": "4.54.0", + "@rollup/rollup-freebsd-x64": "4.54.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", + "@rollup/rollup-linux-arm-musleabihf": "4.54.0", + "@rollup/rollup-linux-arm64-gnu": "4.54.0", + "@rollup/rollup-linux-arm64-musl": "4.54.0", + "@rollup/rollup-linux-loong64-gnu": "4.54.0", + "@rollup/rollup-linux-ppc64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-gnu": "4.54.0", + "@rollup/rollup-linux-riscv64-musl": "4.54.0", + "@rollup/rollup-linux-s390x-gnu": "4.54.0", + "@rollup/rollup-linux-x64-gnu": "4.54.0", + "@rollup/rollup-linux-x64-musl": "4.54.0", + "@rollup/rollup-openharmony-arm64": "4.54.0", + "@rollup/rollup-win32-arm64-msvc": "4.54.0", + "@rollup/rollup-win32-ia32-msvc": "4.54.0", + "@rollup/rollup-win32-x64-gnu": "4.54.0", + "@rollup/rollup-win32-x64-msvc": "4.54.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC", + "optional": true + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/sample_solutions/DocQvision/ui/package.json b/sample_solutions/DocQvision/ui/package.json new file mode 100644 index 00000000..d95b0544 --- /dev/null +++ b/sample_solutions/DocQvision/ui/package.json @@ -0,0 +1,27 @@ +{ + "name": "DocQvision-ui", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.29.0", + "axios": "^1.13.5", + "react-pdf": "^9.1.1", + "react-dropzone": "^14.2.10", + "lucide-react": "^0.454.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.3", + "vite": "^5.4.10", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.14" + } +} diff --git a/sample_solutions/DocQvision/ui/postcss.config.js b/sample_solutions/DocQvision/ui/postcss.config.js new file mode 100644 index 00000000..2e7af2b7 --- /dev/null +++ b/sample_solutions/DocQvision/ui/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/sample_solutions/DocQvision/ui/src/App.jsx b/sample_solutions/DocQvision/ui/src/App.jsx new file mode 100644 index 00000000..d53c819a --- /dev/null +++ b/sample_solutions/DocQvision/ui/src/App.jsx @@ -0,0 +1,23 @@ +import { BrowserRouter as Router, Routes, Route } from 'react-router-dom' +import Layout from './components/Layout' +import ConfigurePage from './pages/ConfigurePage' +import UploadPage from './pages/UploadPage' +import ResultsPage from './pages/ResultsPage' +import HistoryPage from './pages/HistoryPage' + +function App() { + return ( + + + + } /> + } /> + } /> + } /> + + + + ) +} + +export default App diff --git a/sample_solutions/DocQvision/ui/src/components/ChatInterface.jsx b/sample_solutions/DocQvision/ui/src/components/ChatInterface.jsx new file mode 100644 index 00000000..b6f693b0 --- /dev/null +++ b/sample_solutions/DocQvision/ui/src/components/ChatInterface.jsx @@ -0,0 +1,74 @@ +import { useState } from 'react' +import { Send } from 'lucide-react' + +const ChatInterface = ({ onSendMessage, chatHistory, isLoading }) => { + const [message, setMessage] = useState('') + + const handleSubmit = (e) => { + e.preventDefault() + if (message.trim() && !isLoading) { + onSendMessage(message) + setMessage('') + } + } + + return ( +
+
+ {chatHistory.length === 0 ? ( +
+

Chat with AI to define extraction fields

+
+

Examples:

+

• "I want to extract invoice data"

+

• "Extract patient name, medication, and dosage from prescriptions"

+

• "Get company name, date, and terms from contracts"

+
+

+ AI will ask follow-up questions to build your extraction template +

+
+ ) : ( + chatHistory.map((msg, idx) => ( +
+
+

{msg.content}

+
+
+ )) + )} +
+ +
+
+ setMessage(e.target.value)} + placeholder="Type your message..." + className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" + disabled={isLoading} + /> + +
+
+
+ ) +} + +export default ChatInterface diff --git a/sample_solutions/DocQvision/ui/src/components/ErrorMessage.jsx b/sample_solutions/DocQvision/ui/src/components/ErrorMessage.jsx new file mode 100644 index 00000000..3368ce6b --- /dev/null +++ b/sample_solutions/DocQvision/ui/src/components/ErrorMessage.jsx @@ -0,0 +1,39 @@ +import { AlertCircle, X } from 'lucide-react' + +const ErrorMessage = ({ message, onClose, type = 'error' }) => { + if (!message) return null + + const styles = { + error: 'bg-red-50 border-red-200 text-red-800', + warning: 'bg-amber-50 border-amber-200 text-amber-800', + info: 'bg-blue-50 border-blue-200 text-blue-800', + success: 'bg-green-50 border-green-200 text-green-800' + } + + const iconColors = { + error: 'text-red-600', + warning: 'text-amber-600', + info: 'text-blue-600', + success: 'text-green-600' + } + + return ( +
+ +
+

{message}

+
+ {onClose && ( + + )} +
+ ) +} + +export default ErrorMessage diff --git a/sample_solutions/DocQvision/ui/src/components/ExtractionStatus.jsx b/sample_solutions/DocQvision/ui/src/components/ExtractionStatus.jsx new file mode 100644 index 00000000..2643e655 --- /dev/null +++ b/sample_solutions/DocQvision/ui/src/components/ExtractionStatus.jsx @@ -0,0 +1,202 @@ +import { useState, useEffect } from 'react' +import { Loader, CheckCircle, XCircle, Clock } from 'lucide-react' +import { getExtractionResult } from '../services/api' + +const ExtractionStatus = ({ jobId, onComplete, onError, compact = false }) => { + const [status, setStatus] = useState('pending') + const [result, setResult] = useState(null) + const [progress, setProgress] = useState(0) + const [startTime] = useState(Date.now()) + const [, setTick] = useState(0) // Force re-render for elapsed time display + + useEffect(() => { + if (!jobId) return + + const pollInterval = setInterval(async () => { + try { + const data = await getExtractionResult(jobId) + setStatus(data.status) + + if (data.status === 'success') { + setProgress(100) + setResult(data) + clearInterval(pollInterval) + onComplete?.(data) + } else if (data.status === 'failed') { + clearInterval(pollInterval) + onError?.(data.error_message || 'Extraction failed') + } else if (data.status === 'running') { + // Slow, realistic progress for Xeon machine (30-75 seconds per page) + // Expected total time: 60-180 seconds for typical 2-3 page documents + const elapsedSeconds = (Date.now() - startTime) / 1000 + + // Logarithmic progress curve - slower as it approaches completion + // Designed for 60-180 second processing time + let calculatedProgress = 0 + if (elapsedSeconds < 30) { + // First 30 seconds: 0% -> 20% (fast initial progress) + calculatedProgress = (elapsedSeconds / 30) * 20 + } else if (elapsedSeconds < 60) { + // 30-60 seconds: 20% -> 40% + calculatedProgress = 20 + ((elapsedSeconds - 30) / 30) * 20 + } else if (elapsedSeconds < 120) { + // 60-120 seconds: 40% -> 65% + calculatedProgress = 40 + ((elapsedSeconds - 60) / 60) * 25 + } else if (elapsedSeconds < 180) { + // 120-180 seconds: 65% -> 80% + calculatedProgress = 65 + ((elapsedSeconds - 120) / 60) * 15 + } else { + // After 180 seconds: cap at 85% (never reach 100% until backend confirms) + calculatedProgress = 80 + Math.min((elapsedSeconds - 180) / 60 * 5, 5) + } + + setProgress(Math.min(Math.floor(calculatedProgress), 85)) + } + } catch (error) { + console.error('Polling error:', error) + clearInterval(pollInterval) + onError?.(error.message) + } + }, 2000) + + return () => clearInterval(pollInterval) + }, [jobId, onComplete, onError, startTime]) + + // Update elapsed time display every second when processing + useEffect(() => { + if (status !== 'running') return + + const tickInterval = setInterval(() => { + setTick(t => t + 1) // Force re-render to update elapsed time + }, 1000) + + return () => clearInterval(tickInterval) + }, [status]) + + const getStatusIcon = () => { + const iconClass = "w-6 h-6" + switch (status) { + case 'success': + return + case 'failed': + return + case 'running': + return + default: + return + } + } + + const getStatusColor = () => { + switch (status) { + case 'success': return 'text-green-600' + case 'failed': return 'text-red-600' + case 'running': return 'text-blue-600' + default: return 'text-gray-600' + } + } + + if (compact) { + const elapsedSeconds = Math.floor((Date.now() - startTime) / 1000) + const minutes = Math.floor(elapsedSeconds / 60) + const seconds = elapsedSeconds % 60 + const timeDisplay = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s` + + return ( +
+
+ {getStatusIcon()} + {status} + {status === 'running' && ( + ({timeDisplay} elapsed) + )} +
+ {status === 'running' && ( +
+
+
+
+

+ Processing on inference server... {progress}% +

+
+ )} +
+ ) + } + + return ( +
+
+ {getStatusIcon()} +
+

{status}

+

Job ID: {jobId}

+
+
+ + {status === 'running' && ( +
+
+
+
+
+

+ Processing on inference server... {progress}% +

+

+ {(() => { + const elapsedSeconds = Math.floor((Date.now() - startTime) / 1000) + const minutes = Math.floor(elapsedSeconds / 60) + const seconds = elapsedSeconds % 60 + return minutes > 0 ? `${minutes}m ${seconds}s elapsed` : `${seconds}s elapsed` + })()} +

+
+

+ Note: Processing may take 1-3 minutes depending on document complexity +

+
+ )} + + {result && result.status === 'success' && ( +
+
+
+ Stage Used: + {result.stage_used} +
+
+ Coverage: + + {result.field_coverage_percent + ? `${(result.field_coverage_percent * 100).toFixed(1)}%` + : 'N/A'} + +
+
+ Processing Time: + + {result.processing_time_ms ? `${result.processing_time_ms}ms` : 'N/A'} + +
+ {result.model_used && ( +
+ Model: + {result.model_used} +
+ )} +
+
+ )} +
+ ) +} + +export default ExtractionStatus diff --git a/sample_solutions/DocQvision/ui/src/components/FileUpload.jsx b/sample_solutions/DocQvision/ui/src/components/FileUpload.jsx new file mode 100644 index 00000000..788b9b5c --- /dev/null +++ b/sample_solutions/DocQvision/ui/src/components/FileUpload.jsx @@ -0,0 +1,113 @@ +import { useState } from 'react' +import { useDropzone } from 'react-dropzone' +import { Upload, FileText, X } from 'lucide-react' + +const FileUpload = ({ onFileSelect, multiple = false, maxFiles = 5 }) => { + const [files, setFiles] = useState([]) + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + accept: { 'application/pdf': ['.pdf'] }, + maxSize: 10 * 1024 * 1024, + multiple: multiple, + maxFiles: multiple ? maxFiles : 1, + onDrop: (acceptedFiles) => { + if (acceptedFiles.length > 0) { + if (multiple) { + const newFiles = [...files, ...acceptedFiles].slice(0, maxFiles) + setFiles(newFiles) + onFileSelect(newFiles) + } else { + const selectedFile = acceptedFiles[0] + setFiles([selectedFile]) + onFileSelect(selectedFile) + } + } + }, + onDropRejected: (rejections) => { + const rejection = rejections[0] + if (rejection.errors[0].code === 'file-too-large') { + alert('File size must be less than 10MB') + } else if (rejection.errors[0].code === 'too-many-files') { + alert(`Maximum ${maxFiles} files allowed`) + } else { + alert('Please upload PDF files only') + } + }, + }) + + const handleRemove = (indexToRemove) => { + if (multiple) { + const newFiles = files.filter((_, index) => index !== indexToRemove) + setFiles(newFiles) + onFileSelect(newFiles) + } else { + setFiles([]) + onFileSelect(null) + } + } + + return ( +
+
+ + {files.length === 0 ? ( + <> + +

+ {multiple ? `Drop your files here or click to browse` : `Drop your file here or click to browse`} +

+

Supported formats: PDF

+

+ Maximum file size: 10MB{multiple ? ` • Maximum ${maxFiles} files` : ''} +

+ + ) : ( +
+

+ {files.length} file{files.length > 1 ? 's' : ''} selected + {multiple && ` (max ${maxFiles})`} +

+
+ )} +
+ + {files.length > 0 && ( +
+ {files.map((file, index) => ( +
+
+ +
+

{file.name}

+

+ {(file.size / 1024 / 1024).toFixed(2)} MB +

+
+
+ +
+ ))} +
+ )} +
+ ) +} + +export default FileUpload diff --git a/sample_solutions/DocQvision/ui/src/components/Layout.jsx b/sample_solutions/DocQvision/ui/src/components/Layout.jsx new file mode 100644 index 00000000..ce71ff8b --- /dev/null +++ b/sample_solutions/DocQvision/ui/src/components/Layout.jsx @@ -0,0 +1,59 @@ +import { Link, useLocation } from 'react-router-dom' +import { FileSearch } from 'lucide-react' + +const Layout = ({ children }) => { + const location = useLocation() + + const navigation = [ + { name: 'Configure', href: '/' }, + { name: 'Upload', href: '/upload' }, + { name: 'History', href: '/history' }, + ] + + return ( +
+
+ +
+ +
+ {location.pathname === '/' && ( +
+

+ DocQvision uses AI vision models to extract structured data from PDF documents. + Configure extraction templates once, then reuse them for processing multiple documents. +

+
+ )} + {children} +
+
+ ) +} + +export default Layout diff --git a/sample_solutions/DocQvision/ui/src/components/PDFPreview.jsx b/sample_solutions/DocQvision/ui/src/components/PDFPreview.jsx new file mode 100644 index 00000000..43b8fc62 --- /dev/null +++ b/sample_solutions/DocQvision/ui/src/components/PDFPreview.jsx @@ -0,0 +1,183 @@ +import { useState, useRef, useEffect } from 'react' +import { FileText, ZoomIn, ZoomOut, RotateCw, AlertCircle } from 'lucide-react' +import { Document, Page, pdfjs } from 'react-pdf' +import 'react-pdf/dist/Page/AnnotationLayer.css' +import 'react-pdf/dist/Page/TextLayer.css' + +pdfjs.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs` + +const PDFPreview = ({ file }) => { + const [numPages, setNumPages] = useState(null) + const [pageNumber, setPageNumber] = useState(1) + const [fileUrl, setFileUrl] = useState(null) + const [scale, setScale] = useState(1.0) + const [rotation, setRotation] = useState(0) + const [error, setError] = useState(null) + const containerRef = useRef(null) + const [containerWidth, setContainerWidth] = useState(0) + + useEffect(() => { + if (containerRef.current) { + const updateWidth = () => { + setContainerWidth(containerRef.current.offsetWidth - 32) + } + updateWidth() + window.addEventListener('resize', updateWidth) + return () => window.removeEventListener('resize', updateWidth) + } + }, []) + + const onDocumentLoadSuccess = ({ numPages }) => { + setNumPages(numPages) + setPageNumber(1) + setError(null) + } + + const onDocumentLoadError = (error) => { + console.error('PDF load error:', error) + setError('Failed to load PDF file. Please try again.') + setNumPages(null) + } + + useEffect(() => { + if (file) { + setError(null) + const url = URL.createObjectURL(file) + setFileUrl(url) + + return () => { + if (url) { + URL.revokeObjectURL(url) + } + } + } else { + setFileUrl(null) + setNumPages(null) + setPageNumber(1) + setScale(1.0) + setRotation(0) + setError(null) + } + }, [file]) + + const handleZoomIn = () => { + setScale((prev) => { + const newScale = Math.min(prev + 0.2, 3.0) + return newScale + }) + } + + const handleZoomOut = () => { + setScale((prev) => { + const newScale = Math.max(prev - 0.2, 0.5) + return newScale + }) + } + + const handleRotate = () => { + setRotation((prev) => (prev + 90) % 360) + } + + if (!file || !fileUrl) { + return ( +
+
+

No document selected

+
+
+
+ +

Upload a PDF to preview

+
+
+
+ ) + } + + return ( +
+
+

{file.name}

+
+ + {Math.round(scale * 100)}% + + + {numPages && ( + + Page {pageNumber} of {numPages} + + )} +
+
+
+ {error ? ( +
+ +

{error}

+

+ The PDF file may be corrupted or incompatible. Please try uploading a different file. +

+
+ ) : ( + +
+
+ } + > + + + )} +
+ {numPages && numPages > 1 && ( +
+ + +
+ )} +
+ ) +} + +export default PDFPreview diff --git a/sample_solutions/DocQvision/ui/src/index.css b/sample_solutions/DocQvision/ui/src/index.css new file mode 100644 index 00000000..1393534e --- /dev/null +++ b/sample_solutions/DocQvision/ui/src/index.css @@ -0,0 +1,77 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + line-height: 1.5; + font-weight: 400; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background-color: #f9fafb; +} + +#root { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #f1f1f1; +} + +::-webkit-scrollbar-thumb { + background: #888; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #555; +} + +@layer components { + .card { + @apply bg-white rounded-xl p-6 shadow-lg hover:shadow-xl transition-shadow; + } + + .btn-primary { + @apply bg-primary-600 hover:bg-primary-700 text-white font-medium px-6 py-2.5 rounded-lg transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed; + } + + .btn-secondary { + @apply bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium px-6 py-2.5 rounded-lg transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed; + } + + .file-drop-zone { + @apply border-2 border-dashed rounded-lg p-12 text-center transition-colors duration-200; + } + + .file-drop-zone-active { + @apply border-primary-500 bg-primary-50; + } + + .file-drop-zone-inactive { + @apply border-gray-300 bg-gray-50; + } +} diff --git a/sample_solutions/DocQvision/ui/src/main.jsx b/sample_solutions/DocQvision/ui/src/main.jsx new file mode 100644 index 00000000..54b39dd1 --- /dev/null +++ b/sample_solutions/DocQvision/ui/src/main.jsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App.jsx' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + , +) diff --git a/sample_solutions/DocQvision/ui/src/pages/ConfigurePage.jsx b/sample_solutions/DocQvision/ui/src/pages/ConfigurePage.jsx new file mode 100644 index 00000000..50ae768d --- /dev/null +++ b/sample_solutions/DocQvision/ui/src/pages/ConfigurePage.jsx @@ -0,0 +1,486 @@ +import { useState, useEffect } from 'react' +import { Upload, Save, PlayCircle, RefreshCw, X, AlertCircle } from 'lucide-react' +import PDFPreview from '../components/PDFPreview' +import ChatInterface from '../components/ChatInterface' +import ErrorMessage from '../components/ErrorMessage' +import { configureSchema, saveTemplate, uploadDocument, createTemplate, extractData, getExtractionResult, deleteTemplate } from '../services/api' + +const ConfigurePage = () => { + const [file, setFile] = useState(null) + const [chatHistory, setChatHistory] = useState([]) + const [schema, setSchema] = useState({}) + const [isLoading, setIsLoading] = useState(false) + const [sessionId, setSessionId] = useState(null) + const [testResults, setTestResults] = useState(null) + const [testingStatus, setTestingStatus] = useState('') + const [error, setError] = useState(null) + const [successMessage, setSuccessMessage] = useState(null) + + useEffect(() => { + // Load saved session from localStorage + const savedSession = localStorage.getItem('configure_session') + if (savedSession) { + try { + const { sessionId: savedSessionId, schema: savedSchema, chatHistory: savedHistory } = JSON.parse(savedSession) + + // If there's a saved session with fields, ask user if they want to continue or start fresh + if (savedSchema && Object.keys(savedSchema).length > 0) { + const continueSession = window.confirm( + `📋 Previous session found with ${Object.keys(savedSchema).length} configured fields.\n\n` + + `Do you want to continue where you left off?\n\n` + + `Click "OK" to continue, or "Cancel" to start a new template.` + ) + + if (!continueSession) { + localStorage.removeItem('configure_session') + const newSessionId = crypto.randomUUID() + setSessionId(newSessionId) + return + } + } + + setSessionId(savedSessionId) + setSchema(savedSchema || {}) + setChatHistory(savedHistory || []) + } catch (e) { + console.error('Failed to load saved session:', e) + localStorage.removeItem('configure_session') + const newSessionId = crypto.randomUUID() + setSessionId(newSessionId) + } + } else { + const newSessionId = crypto.randomUUID() + setSessionId(newSessionId) + } + }, []) + + useEffect(() => { + // Save session to localStorage whenever it changes + if (sessionId) { + localStorage.setItem('configure_session', JSON.stringify({ + sessionId, + schema, + chatHistory + })) + } + }, [sessionId, schema, chatHistory]) + + const handleFileUpload = (e) => { + // Clear previous errors + setError(null) + setSuccessMessage(null) + + // Prevent changing PDF if session has schema configured + if (Object.keys(schema).length > 0 && file) { + const confirm = window.confirm( + '⚠️ Uploading a new PDF will require starting a new session.\n\n' + + 'Your current schema configuration will be cleared.\n\n' + + 'Click "New Template" to start fresh, or cancel to keep current session.' + ) + if (!confirm) { + e.target.value = '' // Reset file input + return + } + // User confirmed - clear session + handleNewTemplate() + return + } + + const selectedFile = e.target.files[0] + if (selectedFile && selectedFile.type === 'application/pdf') { + if (selectedFile.size <= 10 * 1024 * 1024) { + setFile(selectedFile) + } else { + setError('File size must be less than 10MB') + e.target.value = '' + } + } else { + setError('Please upload a PDF file') + e.target.value = '' + } + } + + const handleSendMessage = async (message) => { + // Add user message immediately to chat history + const userMessage = { role: 'user', content: message } + setChatHistory(prev => [...prev, userMessage]) + + setIsLoading(true) + try { + const response = await configureSchema(message, sessionId) + setChatHistory(response.chat_history || []) + setSchema(response.schema || {}) + if (response.session_id && !sessionId) { + setSessionId(response.session_id) + } + } catch (error) { + console.error('Error sending message:', error) + setError('Failed to process message') + // Remove the user message if API call failed + setChatHistory(prev => prev.filter(msg => msg !== userMessage)) + } finally { + setIsLoading(false) + } + } + + const handleNewTemplate = () => { + if (Object.keys(schema).length > 0) { + const confirm = window.confirm('This will clear your current configuration. Continue?') + if (!confirm) return + } + const newSessionId = crypto.randomUUID() + setSessionId(newSessionId) + setSchema({}) + setChatHistory([]) + setFile(null) + setTestResults(null) + localStorage.removeItem('configure_session') + } + + const handleTestExtraction = async () => { + if (!file) { + setError('Please upload a document first') + return + } + if (Object.keys(schema).length === 0) { + setError('Please configure extraction fields first') + return + } + + setIsLoading(true) + setTestResults(null) + let tempTemplateId = null + + try { + // 1. Upload the document + setTestingStatus('Uploading document...') + const uploadResponse = await uploadDocument(file) + const documentId = uploadResponse.document_id + + // 2. Create a temporary template + setTestingStatus('Creating temporary template...') + + // Convert schema to proper FieldSchema format + const convertToFieldSchema = (fieldValue) => { + // If it's already a proper object with 'type' field + if (typeof fieldValue === 'object' && fieldValue !== null && 'type' in fieldValue) { + const result = { + type: fieldValue.type, + required: fieldValue.required !== undefined ? fieldValue.required : true + } + // Only add description if it exists and is not empty + if (fieldValue.description && fieldValue.description.trim()) { + result.description = fieldValue.description + } + return result + } + // If it's a simple string type + if (typeof fieldValue === 'string') { + return { type: fieldValue, required: true } + } + // Fallback to string + return { type: 'string', required: true } + } + + const tempTemplate = await createTemplate({ + name: `Test_${Date.now()}`, + doc_type: 'test', + schema_json: Object.fromEntries( + Object.entries(schema).map(([field, fieldValue]) => [ + field, + convertToFieldSchema(fieldValue) + ]) + ) + }) + tempTemplateId = tempTemplate.id + + // 3. Run extraction + setTestingStatus('Running extraction...') + const extractionJob = await extractData(documentId, tempTemplateId) + + // 4. Poll for results + let attempts = 0 + const maxAttempts = 30 + while (attempts < maxAttempts) { + const progress = Math.round(((attempts + 1) / maxAttempts) * 100) + setTestingStatus(`Processing extraction... ${progress}%`) + await new Promise(resolve => setTimeout(resolve, 2000)) + const result = await getExtractionResult(extractionJob.id) + + if (result.status === 'success') { + setTestResults(result) + break + } else if (result.status === 'failed') { + setError(`Extraction Failed: ${result.error_message || 'Unknown error'}`) + break + } + + attempts++ + } + + if (attempts >= maxAttempts) { + setError('Test extraction timed out. Please try again.') + } + + } catch (error) { + console.error('Error testing extraction:', error) + setError(`Failed to test extraction: ${error.message}`) + } finally { + // Clean up: Delete the temporary template + if (tempTemplateId) { + try { + setTestingStatus('Cleaning up...') + await deleteTemplate(tempTemplateId) + } catch (err) { + console.error('Failed to delete temporary template:', err) + } + } + setTestingStatus('') + setIsLoading(false) + } + } + + const handleSaveTemplate = async () => { + if (Object.keys(schema).length === 0) { + setError('Please configure extraction fields first') + return + } + + const templateName = prompt('Enter template name:') + if (!templateName || !templateName.trim()) { + setError('Template name is required. Please provide a name for your template.') + return + } + + const templateType = prompt('Enter document type (e.g., invoice, prescription, contract):') + if (!templateType || !templateType.trim()) { + setError('Document type is required. This helps validate documents during extraction.') + return + } + + setIsLoading(true) + try { + // Check for duplicate template names + const { getTemplates } = await import('../services/api') + const existingTemplates = await getTemplates() + const templateList = Array.isArray(existingTemplates) ? existingTemplates : (existingTemplates.templates || []) + + const duplicate = templateList.find(t => t.name.toLowerCase() === templateName.trim().toLowerCase()) + if (duplicate) { + const overwrite = window.confirm( + `⚠️ Template "${templateName}" already exists!\n\n` + + `Do you want to overwrite it?` + ) + if (!overwrite) { + setIsLoading(false) + return + } + } + + await saveTemplate(templateName.trim(), templateType.trim(), schema) + setSuccessMessage('Template saved successfully!') + localStorage.removeItem('configure_session') // Clear session after successful save + } catch (error) { + console.error('Error saving template:', error) + if (error.response?.data?.detail) { + setError(`Failed to save template: ${error.response.data.detail}`) + } else { + setError('Failed to save template. Please try again.') + } + } finally { + setIsLoading(false) + } + } + + return ( +
+
+
+

Configure Document Type

+

+ Upload a sample document and chat with AI to define extraction fields +

+ {Object.keys(schema).length > 0 && ( +
+ + Active session with {Object.keys(schema).length} configured fields +
+ )} +
+ +
+ + {/* Error Messages */} + setError(null)} /> + setSuccessMessage(null)} /> + +
+
+
+
+

Document Preview

+ +
+

Supports PDF up to 10MB

+
+
+ +
+
+ +
+

Chat

+
+ +
+
+
+ + {Object.keys(schema).length > 0 && ( +
+

+ Configured Fields ({Object.keys(schema).length}) +

+
    + {Object.entries(schema).map(([field, type]) => { + const displayType = typeof type === 'string' ? type : (type?.type || 'unknown') + return ( +
  • + +
    + {field} + ({displayType}) +
    +
  • + ) + })} +
+
+ )} + + {/* Loading modal during test extraction */} + {isLoading && testingStatus && ( +
+
+
+
+

Testing Extraction

+

{testingStatus}

+
+
+
+ )} + + {/* Test results section */} + {testResults && ( +
+
+
+

Test Extraction Results

+

+ Coverage: {testResults.field_coverage_percent ? (testResults.field_coverage_percent * 100).toFixed(0) : 0}% | + Stage: {testResults.stage_used || 'N/A'} | + Time: {testResults.processing_time_ms || 0}ms +

+
+ +
+
+ {Object.entries(testResults.extracted_data || {}).map(([field, value]) => ( +
+
{field}
+
+ {value !== null && value !== undefined && value !== '' ? ( + Array.isArray(value) ? ( +
    + {value.map((item, idx) => ( +
  • + +
    + {typeof item === 'object' && item !== null ? ( +
    + {Object.entries(item).map(([key, val]) => ( +
    + + {key.replace(/_/g, ' ')}: + + + {val !== null && val !== undefined ? String(val) : '-'} + +
    + ))} +
    + ) : ( + {String(item)} + )} +
    +
  • + ))} +
+ ) : ( + {String(value)} + ) + ) : ( + + + Not found + + )} +
+
+ ))} +
+
+ )} + +
+ + +
+
+ ) +} + +export default ConfigurePage diff --git a/sample_solutions/DocQvision/ui/src/pages/HistoryPage.jsx b/sample_solutions/DocQvision/ui/src/pages/HistoryPage.jsx new file mode 100644 index 00000000..dc3edc07 --- /dev/null +++ b/sample_solutions/DocQvision/ui/src/pages/HistoryPage.jsx @@ -0,0 +1,573 @@ +import { useState, useEffect } from 'react' +import { FileText, Eye, Download, RefreshCw, Trash2, FileJson, FileSpreadsheet, CheckSquare, Square, Archive, X, AlertCircle, Clock, CheckCircle, XCircle } from 'lucide-react' +import { getExtractionHistory, getTemplates, deleteExtraction, reExtract, getExtractionResult } from '../services/api' + +const HistoryPage = () => { + const [history, setHistory] = useState([]) + const [templates, setTemplates] = useState([]) + const [loading, setLoading] = useState(true) + const [selectedItems, setSelectedItems] = useState([]) + const [showResultsModal, setShowResultsModal] = useState(false) + const [selectedResult, setSelectedResult] = useState(null) + const [pollingJobs, setPollingJobs] = useState(new Set()) + const [filters, setFilters] = useState({ + template_id: '', + status: '', + limit: 50 + }) + + useEffect(() => { + loadTemplates() + loadHistory() + + // Auto-refresh every 3 seconds to poll for running jobs + const interval = setInterval(() => { + if (history.some(item => item.status === 'running' || item.status === 'pending')) { + loadHistory() + } + }, 3000) + + return () => clearInterval(interval) + }, [filters]) + + const loadTemplates = async () => { + try { + const data = await getTemplates() + setTemplates(data) + } catch (error) { + console.error('Failed to load templates:', error) + } + } + + const loadHistory = async () => { + setLoading(true) + try { + const data = await getExtractionHistory(filters) + setHistory(data) + } catch (error) { + console.error('Failed to load history:', error) + } finally { + setLoading(false) + } + } + + const handleSelectItem = (id) => { + setSelectedItems(prev => + prev.includes(id) + ? prev.filter(item => item !== id) + : [...prev, id] + ) + } + + const handleSelectAll = () => { + if (selectedItems.length === history.length) { + setSelectedItems([]) + } else { + setSelectedItems(history.map(item => item.id)) + } + } + + const handleViewResults = (item) => { + setSelectedResult(item) + setShowResultsModal(true) + } + + const handleDownloadJSON = (item) => { + const dataStr = JSON.stringify(item.extracted_data, null, 2) + const dataBlob = new Blob([dataStr], { type: 'application/json' }) + const url = URL.createObjectURL(dataBlob) + const link = document.createElement('a') + link.href = url + link.download = `${item.document_filename.replace('.pdf', '')}_extraction.json` + link.click() + URL.revokeObjectURL(url) + } + + const handleDownloadCSV = (item) => { + if (!item.extracted_data) return + + const headers = Object.keys(item.extracted_data) + const values = Object.values(item.extracted_data).map(v => { + if (v === null || v === undefined) return '' + if (Array.isArray(v) || typeof v === 'object') { + // Serialize arrays/objects as JSON + return JSON.stringify(v) + } + return String(v) + }) + const csv = `${headers.join(',')}\n${values.map(v => `"${String(v).replace(/"/g, '""')}"`).join(',')}` + + const dataBlob = new Blob([csv], { type: 'text/csv' }) + const url = URL.createObjectURL(dataBlob) + const link = document.createElement('a') + link.href = url + link.download = `${item.document_filename.replace('.pdf', '')}_extraction.csv` + link.click() + URL.revokeObjectURL(url) + } + + const handleReExtract = async (item) => { + try { + const newJob = await reExtract(item.id) + alert(`✅ Re-extraction started! Job ID: ${newJob.id.substring(0, 8)}...`) + loadHistory() + } catch (error) { + console.error('Failed to re-extract:', error) + alert(`❌ Re-extraction failed: ${error.response?.data?.detail || error.message}`) + } + } + + const handleDelete = async (id) => { + if (!window.confirm('Are you sure you want to delete this extraction result?')) return + + try { + await deleteExtraction(id) + loadHistory() + setSelectedItems(prev => prev.filter(item => item !== id)) + } catch (error) { + console.error('Failed to delete:', error) + alert(`Failed to delete: ${error.response?.data?.detail || error.message}`) + } + } + + const handleBulkDelete = async () => { + if (selectedItems.length === 0) return + if (!window.confirm(`Delete ${selectedItems.length} selected items?`)) return + + try { + await Promise.all(selectedItems.map(id => deleteExtraction(id))) + loadHistory() + setSelectedItems([]) + } catch (error) { + console.error('Failed to bulk delete:', error) + alert('Failed to delete some items') + } + } + + const handleBulkExportJSON = () => { + if (selectedItems.length === 0) return + + const selectedData = history.filter(item => selectedItems.includes(item.id)) + const exportData = selectedData.map(item => ({ + filename: item.document_filename, + template: item.template_name, + extracted_data: item.extracted_data, + metadata: { + status: item.status, + coverage: item.field_coverage_percent, + processing_time_ms: item.processing_time_ms, + created_at: item.created_at + } + })) + + const dataStr = JSON.stringify(exportData, null, 2) + const dataBlob = new Blob([dataStr], { type: 'application/json' }) + const url = URL.createObjectURL(dataBlob) + const link = document.createElement('a') + link.href = url + link.download = `bulk_export_${Date.now()}.json` + link.click() + URL.revokeObjectURL(url) + } + + const getStatusIcon = (status) => { + switch (status) { + case 'success': + return + case 'failed': + return + case 'running': + return + case 'pending': + return + default: + return + } + } + + const getStatusBadge = (status) => { + const colors = { + success: 'bg-green-100 text-green-800', + failed: 'bg-red-100 text-red-800', + running: 'bg-blue-100 text-blue-800', + pending: 'bg-gray-100 text-gray-800' + } + return ( + + {status} + + ) + } + + const getStageBadge = (stage) => { + const colors = { + traditional: 'bg-purple-100 text-purple-800', + vision: 'bg-indigo-100 text-indigo-800', + mock: 'bg-gray-100 text-gray-800' + } + return stage ? ( + + {stage} + + ) : null + } + + return ( +
+
+

Extraction History

+

View, manage, and export document extraction results

+
+ + {/* Filters */} +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + {/* Bulk Actions */} + {selectedItems.length > 0 && ( +
+
+ + {selectedItems.length} items selected +
+
+ + +
+
+ )} + + {loading ? ( +
+
+

Loading history...

+
+ ) : history.length === 0 ? ( +
+ +

No extraction history found

+

Run your first extraction to see results here

+
+ ) : ( +
+ {/* Table Header */} +
+
+
+ +
+
Document
+
Template
+
Status
+
Coverage
+
Created
+
Actions
+
+
+ + {/* Table Body */} +
+ {history.map((item) => ( +
+
+ {/* Checkbox */} +
+ +
+ + {/* Document */} +
+
+ +
+
+ {item.document_filename || 'Unknown'} +
+ {item.document_page_count && ( +
+ {item.document_page_count} pages +
+ )} +
+
+
+ + {/* Template */} +
+
{item.template_name || 'N/A'}
+ {item.stage_used && ( +
{getStageBadge(item.stage_used)}
+ )} +
+ + {/* Status */} +
+
+ {getStatusIcon(item.status)} +
+
+ + {/* Coverage */} +
+ {item.field_coverage_percent !== null && item.field_coverage_percent !== undefined ? ( +
+
+ {(item.field_coverage_percent * 100).toFixed(0)}% +
+ {item.processing_time_ms && ( +
+ {item.processing_time_ms}ms +
+ )} +
+ ) : ( + - + )} +
+ + {/* Created */} +
+
+ {new Date(item.created_at).toLocaleDateString()} +
+
+ {new Date(item.created_at).toLocaleTimeString()} +
+
+ + {/* Actions */} +
+
+ {item.status === 'success' && item.extracted_data && ( + <> + + + + + )} + {(item.status === 'success' || item.status === 'failed') && ( + + )} + +
+
+
+ + {/* Error message row */} + {item.status === 'failed' && item.error_message && ( +
+
+ +
{item.error_message}
+
+
+ )} +
+ ))} +
+
+ )} + + {/* View Results Modal */} + {showResultsModal && selectedResult && ( +
+
+
+
+

Extraction Results

+

+ {selectedResult.document_filename} • {selectedResult.template_name} +

+
+ {getStatusBadge(selectedResult.status)} + {selectedResult.stage_used && getStageBadge(selectedResult.stage_used)} + {selectedResult.field_coverage_percent !== null && ( + + Coverage: {(selectedResult.field_coverage_percent * 100).toFixed(0)}% + + )} + {selectedResult.processing_time_ms && ( + + Time: {selectedResult.processing_time_ms}ms + + )} +
+
+ +
+
+
+ {Object.entries(selectedResult.extracted_data || {}).map(([field, value]) => ( +
+
{field}
+
+ {value !== null && value !== undefined && value !== '' ? ( + Array.isArray(value) ? ( +
    + {value.map((item, idx) => ( +
  • + +
    + {typeof item === 'object' && item !== null ? ( +
    + {Object.entries(item).map(([key, val]) => ( +
    + + {key.replace(/_/g, ' ')}: + + + {val !== null && val !== undefined ? String(val) : '-'} + +
    + ))} +
    + ) : ( + {String(item)} + )} +
    +
  • + ))} +
+ ) : ( + {String(value)} + ) + ) : ( + + + Not found + + )} +
+
+ ))} +
+
+
+
+ )} +
+ ) +} + +export default HistoryPage diff --git a/sample_solutions/DocQvision/ui/src/pages/ResultsPage.jsx b/sample_solutions/DocQvision/ui/src/pages/ResultsPage.jsx new file mode 100644 index 00000000..1f46106f --- /dev/null +++ b/sample_solutions/DocQvision/ui/src/pages/ResultsPage.jsx @@ -0,0 +1,141 @@ +import { useLocation, useNavigate } from 'react-router-dom' +import { Download, FileJson, FileSpreadsheet, Upload } from 'lucide-react' + +const ResultsPage = () => { + const location = useLocation() + const navigate = useNavigate() + const result = location.state?.result + + if (!result) { + return ( +
+

No extraction results available

+ +
+ ) + } + + const { extracted_data, processing_time_ms } = result + + const handleExportJSON = () => { + const dataStr = JSON.stringify(extracted_data, null, 2) + const dataBlob = new Blob([dataStr], { type: 'application/json' }) + const url = URL.createObjectURL(dataBlob) + const link = document.createElement('a') + link.href = url + link.download = 'extracted_data.json' + link.click() + } + + const handleExportCSV = () => { + const headers = Object.keys(extracted_data).filter( + (key) => typeof extracted_data[key] !== 'object' + ) + const values = headers.map((key) => extracted_data[key]) + + const csvContent = [ + headers.join(','), + values.map((v) => `"${v}"`).join(','), + ].join('\n') + + const dataBlob = new Blob([csvContent], { type: 'text/csv' }) + const url = URL.createObjectURL(dataBlob) + const link = document.createElement('a') + link.href = url + link.download = 'extracted_data.csv' + link.click() + } + + const renderValue = (value) => { + if (Array.isArray(value)) { + return ( +
+ {value.map((item, idx) => ( +
+ {JSON.stringify(item, null, 2)} +
+ ))} +
+ ) + } + if (typeof value === 'object') { + return
{JSON.stringify(value, null, 2)}
+ } + return value + } + + return ( +
+
+
+

Extraction Complete

+

+ Processed in {processing_time_ms}ms +

+
+
+ +
+

+ Extracted Data +

+
+ + + + + + + + + {Object.entries(extracted_data).map(([field, value]) => ( + + + + + ))} + +
+ Field Name + + Extracted Value +
+ {field} + + {renderValue(value)} +
+
+
+ +
+ +
+ + +
+
+
+ ) +} + +export default ResultsPage diff --git a/sample_solutions/DocQvision/ui/src/pages/UploadPage.jsx b/sample_solutions/DocQvision/ui/src/pages/UploadPage.jsx new file mode 100644 index 00000000..d6cc1653 --- /dev/null +++ b/sample_solutions/DocQvision/ui/src/pages/UploadPage.jsx @@ -0,0 +1,587 @@ +import { useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { Upload as UploadIcon, XCircle, AlertCircle, CheckCircle, Loader, FileText, Eye, Download, FileJson, FileSpreadsheet } from 'lucide-react' +import FileUpload from '../components/FileUpload' +import ExtractionStatus from '../components/ExtractionStatus' +import ErrorMessage from '../components/ErrorMessage' +import { uploadDocument, batchUploadDocuments, extractData, getTemplates } from '../services/api' + +const UploadPage = () => { + const navigate = useNavigate() + const [files, setFiles] = useState([]) + const [templates, setTemplates] = useState([]) + const [selectedTemplate, setSelectedTemplate] = useState('') + const [isLoading, setIsLoading] = useState(false) + const [batchJobs, setBatchJobs] = useState([]) + const [errorMessage, setErrorMessage] = useState(null) + const [error, setError] = useState(null) + const [viewModalResult, setViewModalResult] = useState(null) + + useEffect(() => { + loadTemplates() + }, []) + + const loadTemplates = async () => { + try { + const response = await getTemplates() + console.log('Templates response:', response) + + // Handle both array and object responses + const templateList = Array.isArray(response) ? response : (response.templates || []) + + setTemplates(templateList) + if (templateList.length > 0) { + setSelectedTemplate(templateList[0].id) + } + } catch (error) { + console.error('Error loading templates:', error) + setError('Failed to load templates. Please try refreshing the page.') + } + } + + const handleFileSelect = (selectedFiles) => { + setError(null) + setFiles(Array.isArray(selectedFiles) ? selectedFiles : [selectedFiles]) + } + + const handleUploadAndExtract = async () => { + if (!files || files.length === 0) { + setError('Please select at least one file') + return + } + if (!selectedTemplate) { + setError('Please select a document type') + return + } + + setIsLoading(true) + setErrorMessage(null) + setBatchJobs([]) + + try { + // Single file - use single upload + if (files.length === 1) { + const uploadResponse = await uploadDocument(files[0]) + const extractResponse = await extractData(uploadResponse.document_id, selectedTemplate) + + // Track as single job + setBatchJobs([{ + filename: files[0].name, + success: true, + documentId: uploadResponse.document_id, + error: null, + jobId: extractResponse.id, + status: 'processing', + extractionResult: null + }]) + } else { + // Multiple files - use batch upload + const uploadResponse = await batchUploadDocuments(files) + + // Initialize batch jobs tracking + const jobs = uploadResponse.results.map(result => ({ + filename: result.filename, + success: result.success, + documentId: result.document_id, + error: result.error, + jobId: null, + status: result.success ? 'pending' : 'failed', + extractionResult: null + })) + + setBatchJobs(jobs) + + // Start extraction for successful uploads + const successfulUploads = jobs.filter(job => job.success) + + for (let i = 0; i < successfulUploads.length; i++) { + const job = successfulUploads[i] + try { + const extractResponse = await extractData(job.documentId, selectedTemplate) + + // Update job with extraction ID + setBatchJobs(prev => prev.map(j => + j.filename === job.filename + ? { ...j, jobId: extractResponse.id, status: 'processing' } + : j + )) + } catch (error) { + console.error(`Error starting extraction for ${job.filename}:`, error) + setBatchJobs(prev => prev.map(j => + j.filename === job.filename + ? { ...j, status: 'failed', error: error.message } + : j + )) + } + } + } + } catch (error) { + console.error('Error processing documents:', error) + setErrorMessage(error.response?.data?.detail || error.message || 'Failed to process documents') + setIsLoading(false) + } + } + + const handleBatchJobComplete = (jobId, result) => { + setBatchJobs(prev => prev.map(j => + j.jobId === jobId + ? { ...j, status: 'completed', extractionResult: result } + : j + )) + + // Check if all jobs are complete + const updatedJobs = batchJobs.map(j => + j.jobId === jobId ? { ...j, status: 'completed' } : j + ) + + const allComplete = updatedJobs.every(j => + j.status === 'completed' || j.status === 'failed' + ) + + if (allComplete) { + setIsLoading(false) + } + } + + const handleBatchJobError = (jobId, error) => { + setBatchJobs(prev => prev.map(j => + j.jobId === jobId + ? { ...j, status: 'failed', error } + : j + )) + } + + const handleViewResults = (job) => { + console.log('handleViewResults called with job:', job) + if (job && job.extractionResult) { + console.log('Setting viewModalResult:', job.extractionResult) + setViewModalResult(job.extractionResult) + } else { + console.error('No extraction result found in job') + setError('Unable to view results - extraction data not available') + } + } + + const handleDownloadJSON = (job) => { + if (!job.extractionResult) return + + const dataStr = JSON.stringify(job.extractionResult.extracted_data, null, 2) + const dataBlob = new Blob([dataStr], { type: 'application/json' }) + const url = URL.createObjectURL(dataBlob) + const link = document.createElement('a') + link.href = url + link.download = `${job.filename.replace('.pdf', '')}_extraction.json` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + } + + const handleDownloadCSV = (job) => { + if (!job.extractionResult || !job.extractionResult.extracted_data) return + + const data = job.extractionResult.extracted_data + const headers = Object.keys(data) + const values = Object.values(data).map(v => { + if (v === null || v === undefined) return '' + if (Array.isArray(v) || typeof v === 'object') { + return JSON.stringify(v) + } + return String(v) + }) + + let csvContent = headers.join(',') + '\n' + csvContent += values.map(v => `"${String(v).replace(/"/g, '""')}"`).join(',') + + const dataBlob = new Blob([csvContent], { type: 'text/csv' }) + const url = URL.createObjectURL(dataBlob) + const link = document.createElement('a') + link.href = url + link.download = `${job.filename.replace('.pdf', '')}_extraction.csv` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + } + + const handleBatchExportJSON = () => { + const completedJobs = batchJobs.filter(j => j.status === 'completed' && j.extractionResult) + + if (completedJobs.length === 0) { + setError('No completed extractions to export') + return + } + + const batchData = completedJobs.map(job => ({ + filename: job.filename, + extracted_data: job.extractionResult.extracted_data, + metadata: { + coverage: job.extractionResult.field_coverage_percent, + stage_used: job.extractionResult.stage_used, + processing_time_ms: job.extractionResult.processing_time_ms + } + })) + + const dataStr = JSON.stringify(batchData, null, 2) + const dataBlob = new Blob([dataStr], { type: 'application/json' }) + const url = URL.createObjectURL(dataBlob) + const link = document.createElement('a') + link.href = url + link.download = `batch_extraction_${Date.now()}.json` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + } + + const handleBatchExportCSV = () => { + const completedJobs = batchJobs.filter(j => j.status === 'completed' && j.extractionResult) + + if (completedJobs.length === 0) { + setError('No completed extractions to export') + return + } + + // Get all unique field names across all extractions + const allFields = new Set() + completedJobs.forEach(job => { + Object.keys(job.extractionResult.extracted_data).forEach(field => allFields.add(field)) + }) + + const fieldNames = ['filename', ...Array.from(allFields)] + + // Build CSV + let csvContent = fieldNames.join(',') + '\n' + + completedJobs.forEach(job => { + const data = job.extractionResult.extracted_data + const row = [ + `"${job.filename}"`, + ...Array.from(allFields).map(field => { + const value = data[field] + if (value === undefined || value === null) return '""' + + // Serialize arrays/objects as JSON + if (Array.isArray(value) || typeof value === 'object') { + return `"${JSON.stringify(value).replace(/"/g, '""')}"` + } + + return `"${String(value).replace(/"/g, '""')}"` + }) + ] + csvContent += row.join(',') + '\n' + }) + + const dataBlob = new Blob([csvContent], { type: 'text/csv' }) + const url = URL.createObjectURL(dataBlob) + const link = document.createElement('a') + link.href = url + link.download = `batch_extraction_${Date.now()}.csv` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + } + + return ( +
+
+

+ Extract Data from Documents +

+

+ Upload up to 5 documents at once to extract structured data +

+
+ + {/* Error Messages */} + setError(null)} /> + +
+
+
+
+ + +
+ + + +
+

+ Document Requirements: +

+
    +
  • • PDF only
  • +
  • • Maximum file size: 10MB per file
  • +
  • • Upload 1-5 documents at once
  • +
+
+ + + + {errorMessage && ( +
+
+ +
+

Extraction Failed

+
+ {errorMessage.split('\n\n').map((section, idx) => { + const lines = section.split('\n').filter(line => line.trim()) + if (lines.length === 0) return null + + const header = lines[0] + const items = lines.slice(1) + + return ( +
+

{header}

+ {items.length > 0 && ( +
    + {items.map((item, i) => ( +
  • {item}
  • + ))} +
+ )} +
+ ) + })} +
+
+ +
+
+ )} + + {batchJobs.length > 0 && ( +
+
+

+ Processing Documents ({batchJobs.filter(j => j.status === 'completed').length}/{batchJobs.length} completed) +

+ {batchJobs.filter(j => j.status === 'completed').length > 1 && ( +
+ + +
+ )} +
+
+ {batchJobs.map((job, index) => ( +
+
+
+ + {job.filename} +
+
+ {job.status === 'completed' && ( + + )} + {job.status === 'processing' && ( + + )} + {job.status === 'pending' && ( +
+ )} + {job.status === 'failed' && ( + + )} +
+
+ + {job.status === 'processing' && job.jobId && ( +
+ handleBatchJobComplete(job.jobId, result)} + onError={(error) => handleBatchJobError(job.jobId, error)} + compact={true} + /> +
+ )} + + {job.status === 'failed' && job.error && ( +
+ Error: {job.error} +
+ )} + + {job.status === 'completed' && job.extractionResult && ( +
+
+
+ Coverage: {job.extractionResult.field_coverage_percent ? (job.extractionResult.field_coverage_percent * 100).toFixed(0) : 'N/A'}% • + Stage: {job.extractionResult.stage_used || 'N/A'} +
+
+ + + +
+
+
+ )} +
+ ))} +
+
+ )} +
+
+
+ + {/* View Results Modal */} + {viewModalResult && ( +
+
+
+
+

Extraction Results

+

+ Coverage: {viewModalResult.field_coverage_percent ? (viewModalResult.field_coverage_percent * 100).toFixed(0) : 'N/A'}% • + Stage: {viewModalResult.stage_used || 'N/A'} • + Time: {viewModalResult.processing_time_ms || 'N/A'}ms +

+
+ +
+
+ {viewModalResult.extracted_data && Object.keys(viewModalResult.extracted_data).length > 0 ? ( +
+ {Object.entries(viewModalResult.extracted_data).map(([field, value]) => ( +
+
{field}
+
+ {value !== null && value !== undefined && value !== '' ? ( + Array.isArray(value) ? ( +
    + {value.map((item, idx) => ( +
  • + +
    + {typeof item === 'object' && item !== null ? ( +
    + {Object.entries(item).map(([key, val]) => ( +
    + + {key.replace(/_/g, ' ')}: + + + {val !== null && val !== undefined ? String(val) : '-'} + +
    + ))} +
    + ) : ( + {String(item)} + )} +
    +
  • + ))} +
+ ) : ( + {String(value)} + ) + ) : ( + + + Not found + + )} +
+
+ ))} +
+ ) : ( +
+

No extraction data available

+
+ )} +
+
+
+ )} +
+ ) +} + +export default UploadPage diff --git a/sample_solutions/DocQvision/ui/src/services/api.js b/sample_solutions/DocQvision/ui/src/services/api.js new file mode 100644 index 00000000..b6f7ce9f --- /dev/null +++ b/sample_solutions/DocQvision/ui/src/services/api.js @@ -0,0 +1,117 @@ +import axios from 'axios' + +// In production (Docker), nginx proxies /api to backend, so use relative path +// In development (npm run dev), directly call backend on port 5001 +const API_BASE_URL = import.meta.env.VITE_API_URL || + (import.meta.env.DEV ? 'http://localhost:5001' : '') + +const api = axios.create({ + baseURL: API_BASE_URL, + headers: { + 'Content-Type': 'application/json', + }, +}) + +export const configureSchema = async (message, sessionId = null) => { + const formData = new FormData() + formData.append('message', message) + if (sessionId) { + formData.append('session_id', sessionId) + } + const response = await api.post('/api/configure', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + return response.data +} + +export const uploadDocument = async (file) => { + const formData = new FormData() + formData.append('file', file) + const response = await api.post('/api/documents/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + return response.data +} + +export const batchUploadDocuments = async (files) => { + const formData = new FormData() + files.forEach((file) => { + formData.append('files', file) + }) + const response = await api.post('/api/documents/batch-upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + return response.data +} + +export const extractData = async (documentId, templateId) => { + const response = await api.post('/api/extract', { + document_id: documentId, + template_id: templateId + }) + return response.data +} + +export const getExtractionResult = async (jobId) => { + const response = await api.get(`/api/extract/${jobId}`) + return response.data +} + +export const saveTemplate = async (name, templateType, schema) => { + const formData = new FormData() + formData.append('name', name) + formData.append('template_type', templateType) + formData.append('schema', JSON.stringify(schema)) + const response = await api.post('/api/templates/save', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }) + return response.data +} + +export const createTemplate = async (templateData) => { + const response = await api.post('/api/templates', templateData) + return response.data +} + +export const getTemplate = async (templateId) => { + const response = await api.get(`/api/templates/${templateId}`) + return response.data +} + +export const updateTemplate = async (templateId, updateData) => { + const response = await api.put(`/api/templates/${templateId}`, updateData) + return response.data +} + +export const deleteTemplate = async (templateId) => { + const response = await api.delete(`/api/templates/${templateId}`) + return response.data +} + +export const getExtractionHistory = async (filters = {}) => { + const params = new URLSearchParams() + if (filters.template_id) params.append('template_id', filters.template_id) + if (filters.status) params.append('status', filters.status) + if (filters.skip) params.append('skip', filters.skip) + if (filters.limit) params.append('limit', filters.limit) + + const response = await api.get(`/api/history?${params.toString()}`) + return response.data +} + +export const getTemplates = async () => { + const response = await api.get('/api/templates') + return response.data +} + +export const deleteExtraction = async (jobId) => { + const response = await api.delete(`/api/extract/${jobId}`) + return response.data +} + +export const reExtract = async (jobId) => { + const response = await api.post(`/api/extract/${jobId}/re-extract`) + return response.data +} + +export default api diff --git a/sample_solutions/DocQvision/ui/tailwind.config.js b/sample_solutions/DocQvision/ui/tailwind.config.js new file mode 100644 index 00000000..e0f5aa6a --- /dev/null +++ b/sample_solutions/DocQvision/ui/tailwind.config.js @@ -0,0 +1,37 @@ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: { + colors: { + primary: { + 50: '#f0f9ff', + 100: '#e0f2fe', + 200: '#bae6fd', + 300: '#7dd3fc', + 400: '#38bdf8', + 500: '#0ea5e9', + 600: '#0284c7', + 700: '#0369a1', + 800: '#075985', + 900: '#0c4a6e', + }, + secondary: { + 50: '#fdf4ff', + 100: '#fae8ff', + 200: '#f5d0fe', + 300: '#f0abfc', + 400: '#e879f9', + 500: '#d946ef', + 600: '#c026d3', + 700: '#a21caf', + 800: '#86198f', + 900: '#701a75', + }, + }, + }, + }, + plugins: [], +} diff --git a/sample_solutions/DocQvision/ui/vite.config.js b/sample_solutions/DocQvision/ui/vite.config.js new file mode 100644 index 00000000..80d84674 --- /dev/null +++ b/sample_solutions/DocQvision/ui/vite.config.js @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + port: 3000, + host: true + } +})