Spaces:
Sleeping
Sleeping
Tobias Pasquale
commited on
Commit
·
3d8e949
1
Parent(s):
f75da29
Complete document management system implementation
Browse files- Added comprehensive document management with upload, processing, and dashboard
- Fixed queue timeout error handling in processing service
- Integrated with existing app factory pattern and lazy loading
- Added drag-drop upload interface and real-time status monitoring
- Full integration with RAG pipeline for document processing
- data/uploads/e583bf6c-efeb-4dcd-8b52-f0bcba9f1299.md +49 -0
- src/app_factory.py +14 -0
- src/document_management/__init__.py +18 -0
- src/document_management/document_service.py +308 -0
- src/document_management/processing_service.py +437 -0
- src/document_management/routes.py +274 -0
- src/document_management/upload_service.py +261 -0
- templates/management.html +612 -0
data/uploads/e583bf6c-efeb-4dcd-8b52-f0bcba9f1299.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# HR-POL-003: Remote Work Policy
|
| 2 |
+
|
| 3 |
+
**Effective Date:** 2025-02-15
|
| 4 |
+
**Revision:** 1.1
|
| 5 |
+
**Owner:** Human Resources
|
| 6 |
+
|
| 7 |
+
## 1. Purpose and Philosophy
|
| 8 |
+
|
| 9 |
+
This policy defines the guidelines and expectations for employees working remotely. Innovate Inc. supports remote work as a way to provide flexibility and attract top talent, while ensuring continued productivity, collaboration, and security.
|
| 10 |
+
|
| 11 |
+
## 2. Eligibility and Approval
|
| 12 |
+
|
| 13 |
+
- **Eligibility:** Employees must have been with the company for at least 6 months in good standing. The employee's role must be deemed suitable for remote work by their department head.
|
| 14 |
+
- **Approval Process:** Employees must submit a formal remote work proposal to their manager. If the manager approves, the proposal is reviewed by the department head. A formal remote work agreement must be signed upon final approval.
|
| 15 |
+
- **Trial Period:** All new remote work arrangements are subject to a 90-day trial period to ensure the arrangement is successful for both the employee and the company.
|
| 16 |
+
|
| 17 |
+
## 3. Equipment and Technology
|
| 18 |
+
|
| 19 |
+
- **Company-Provided Equipment:** The company will provide a laptop, monitor, keyboard, mouse, and other necessary peripherals. All company equipment remains the property of Innovate Inc. and must be returned upon termination of the remote work agreement.
|
| 20 |
+
- **Internet:** Employees are responsible for maintaining a reliable, high-speed internet connection sufficient for video conferencing and other work-related tasks. A monthly stipend of $50 is provided to offset this cost.
|
| 21 |
+
- **Home Office Setup:** Employees are responsible for maintaining a safe and ergonomic home workspace.
|
| 22 |
+
- **Security:** All work must be conducted on a secure, password-protected network. Use of a company-provided VPN is mandatory when accessing internal systems. All security protocols outlined in the **Information Security Policy (SEC-POL-011)** must be followed.
|
| 23 |
+
|
| 24 |
+
## 4. Work Hours, Performance, and Communication
|
| 25 |
+
|
| 26 |
+
- **Core Hours:** Remote employees are expected to be available and online during core business hours of 10:00 AM to 4:00 PM in their local time zone.
|
| 27 |
+
- **Performance Expectations:** Performance for remote employees is measured by the same standards as in-office employees. Emphasis is placed on results and meeting goals.
|
| 28 |
+
- **Communication:** Regular communication with team members and managers is critical. Remote employees are expected to be responsive on Slack and email during work hours and to attend all scheduled video calls with their camera on.
|
| 29 |
+
|
| 30 |
+
## 5. On-Site Requirement
|
| 31 |
+
|
| 32 |
+
- Remote employees may be required to travel to the main office for quarterly planning sessions, team-building events, or other key meetings.
|
| 33 |
+
- Travel expenses for such required trips will be covered as per the **Corporate Travel Policy (FIN-POL-015)**.
|
| 34 |
+
- The frequency of on-site visits will be determined by the department head and may vary by role.
|
| 35 |
+
|
| 36 |
+
## 6. Revocation of Remote Work Arrangement
|
| 37 |
+
|
| 38 |
+
The company reserves the right to revoke a remote work arrangement at any time for reasons including, but not limited to, performance issues, changing business needs, or failure to comply with this policy. A notice period of at least 30 days will generally be provided.
|
| 39 |
+
|
| 40 |
+
## 7. Related Policies
|
| 41 |
+
|
| 42 |
+
- **Information Security Policy (SEC-POL-011)**
|
| 43 |
+
- **Corporate Travel Policy (FIN-POL-015)**
|
| 44 |
+
- **Employee Handbook (HR-POL-001)**
|
| 45 |
+
|
| 46 |
+
## 8. Revision History
|
| 47 |
+
|
| 48 |
+
- **v1.1 (2025-10-12):** Added details on approval process, trial period, and performance expectations.
|
| 49 |
+
- **v1.0 (2025-02-15):** Initial version.
|
src/app_factory.py
CHANGED
|
@@ -250,6 +250,11 @@ def create_app():
|
|
| 250 |
def index():
|
| 251 |
return render_template("chat.html")
|
| 252 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
@app.route("/health")
|
| 254 |
def health():
|
| 255 |
from src.utils.memory_utils import get_memory_usage
|
|
@@ -767,4 +772,13 @@ def create_app():
|
|
| 767 |
# Disabled: Using pre-built embeddings to avoid memory spikes during deployment.
|
| 768 |
# ensure_embeddings_on_startup()
|
| 769 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 770 |
return app
|
|
|
|
| 250 |
def index():
|
| 251 |
return render_template("chat.html")
|
| 252 |
|
| 253 |
+
@app.route("/management")
|
| 254 |
+
def management_dashboard():
|
| 255 |
+
"""Document management dashboard"""
|
| 256 |
+
return render_template("management.html")
|
| 257 |
+
|
| 258 |
@app.route("/health")
|
| 259 |
def health():
|
| 260 |
from src.utils.memory_utils import get_memory_usage
|
|
|
|
| 772 |
# Disabled: Using pre-built embeddings to avoid memory spikes during deployment.
|
| 773 |
# ensure_embeddings_on_startup()
|
| 774 |
|
| 775 |
+
# Register document management blueprint
|
| 776 |
+
try:
|
| 777 |
+
from src.document_management.routes import document_bp
|
| 778 |
+
|
| 779 |
+
app.register_blueprint(document_bp, url_prefix="/api/documents")
|
| 780 |
+
logging.info("Document management blueprint registered successfully")
|
| 781 |
+
except Exception as e:
|
| 782 |
+
logging.warning(f"Failed to register document management blueprint: {e}")
|
| 783 |
+
|
| 784 |
return app
|
src/document_management/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Document Management System for PolicyWise RAG Application
|
| 3 |
+
|
| 4 |
+
This module provides comprehensive document lifecycle management including:
|
| 5 |
+
- Multi-file upload with drag-and-drop interface
|
| 6 |
+
- Async document processing pipeline
|
| 7 |
+
- Document organization and metadata management
|
| 8 |
+
- Processing status monitoring and analytics
|
| 9 |
+
- Integration with existing RAG pipeline and vector database
|
| 10 |
+
|
| 11 |
+
Built using the app factory pattern with lazy loading for optimal memory usage.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from .document_service import DocumentService
|
| 15 |
+
from .processing_service import ProcessingService
|
| 16 |
+
from .upload_service import UploadService
|
| 17 |
+
|
| 18 |
+
__all__ = ["DocumentService", "ProcessingService", "UploadService"]
|
src/document_management/document_service.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Document Service - Core document management functionality
|
| 3 |
+
|
| 4 |
+
Provides centralized document management capabilities that integrate with
|
| 5 |
+
the existing RAG pipeline architecture. Follows the lazy loading pattern
|
| 6 |
+
established in the app factory.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
import os
|
| 11 |
+
import uuid
|
| 12 |
+
from datetime import datetime
|
| 13 |
+
from enum import Enum
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
from typing import Any, Dict
|
| 16 |
+
|
| 17 |
+
from werkzeug.utils import secure_filename
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class DocumentStatus(Enum):
|
| 21 |
+
"""Document processing status enumeration"""
|
| 22 |
+
|
| 23 |
+
UPLOADED = "uploaded"
|
| 24 |
+
VALIDATING = "validating"
|
| 25 |
+
PARSING = "parsing"
|
| 26 |
+
CHUNKING = "chunking"
|
| 27 |
+
EMBEDDING = "embedding"
|
| 28 |
+
INDEXING = "indexing"
|
| 29 |
+
COMPLETED = "completed"
|
| 30 |
+
FAILED = "failed"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class DocumentService:
|
| 34 |
+
"""
|
| 35 |
+
Core document management service that integrates with existing RAG infrastructure.
|
| 36 |
+
|
| 37 |
+
This service manages the document lifecycle from upload through processing,
|
| 38 |
+
leveraging the existing ingestion pipeline and vector database.
|
| 39 |
+
"""
|
| 40 |
+
|
| 41 |
+
def __init__(self, upload_dir: str = None):
|
| 42 |
+
"""
|
| 43 |
+
Initialize the document service.
|
| 44 |
+
|
| 45 |
+
Args:
|
| 46 |
+
upload_dir: Directory for storing uploaded files
|
| 47 |
+
"""
|
| 48 |
+
self.upload_dir = upload_dir or self._get_default_upload_dir()
|
| 49 |
+
self.supported_formats = {
|
| 50 |
+
"text": [".txt", ".md", ".csv"],
|
| 51 |
+
"documents": [".pdf", ".docx", ".doc"],
|
| 52 |
+
"structured": [".json", ".yaml", ".xml"],
|
| 53 |
+
"web": [".html", ".htm"],
|
| 54 |
+
"office": [".xlsx", ".pptx"],
|
| 55 |
+
}
|
| 56 |
+
self.max_file_size = 50 * 1024 * 1024 # 50MB
|
| 57 |
+
self.max_batch_size = 100
|
| 58 |
+
|
| 59 |
+
# Ensure upload directory exists
|
| 60 |
+
Path(self.upload_dir).mkdir(parents=True, exist_ok=True)
|
| 61 |
+
|
| 62 |
+
logging.info(f"DocumentService initialized with upload_dir: {self.upload_dir}")
|
| 63 |
+
|
| 64 |
+
def _get_default_upload_dir(self) -> str:
|
| 65 |
+
"""Get default upload directory path"""
|
| 66 |
+
project_root = os.path.dirname(
|
| 67 |
+
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 68 |
+
)
|
| 69 |
+
return os.path.join(project_root, "data", "uploads")
|
| 70 |
+
|
| 71 |
+
def validate_file(self, filename: str, file_size: int) -> Dict[str, Any]:
|
| 72 |
+
"""
|
| 73 |
+
Validate uploaded file.
|
| 74 |
+
|
| 75 |
+
Args:
|
| 76 |
+
filename: Name of the file
|
| 77 |
+
file_size: Size of the file in bytes
|
| 78 |
+
|
| 79 |
+
Returns:
|
| 80 |
+
Dict with validation results
|
| 81 |
+
"""
|
| 82 |
+
errors = []
|
| 83 |
+
warnings = []
|
| 84 |
+
|
| 85 |
+
# Check file extension
|
| 86 |
+
file_ext = Path(filename).suffix.lower()
|
| 87 |
+
all_supported = []
|
| 88 |
+
for format_list in self.supported_formats.values():
|
| 89 |
+
all_supported.extend(format_list)
|
| 90 |
+
|
| 91 |
+
if file_ext not in all_supported:
|
| 92 |
+
errors.append(f"Unsupported file format: {file_ext}")
|
| 93 |
+
|
| 94 |
+
# Check file size
|
| 95 |
+
if file_size > self.max_file_size:
|
| 96 |
+
errors.append(
|
| 97 |
+
f"File too large: {file_size} bytes (max: {self.max_file_size})"
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
# Check filename security
|
| 101 |
+
secure_name = secure_filename(filename)
|
| 102 |
+
if secure_name != filename:
|
| 103 |
+
warnings.append("Filename was sanitized for security")
|
| 104 |
+
|
| 105 |
+
return {
|
| 106 |
+
"valid": len(errors) == 0,
|
| 107 |
+
"errors": errors,
|
| 108 |
+
"warnings": warnings,
|
| 109 |
+
"secure_filename": secure_name,
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
def save_uploaded_file(self, file_obj, filename: str) -> Dict[str, Any]:
|
| 113 |
+
"""
|
| 114 |
+
Save uploaded file to disk.
|
| 115 |
+
|
| 116 |
+
Args:
|
| 117 |
+
file_obj: File object from request
|
| 118 |
+
filename: Original filename
|
| 119 |
+
|
| 120 |
+
Returns:
|
| 121 |
+
Dict with file information
|
| 122 |
+
"""
|
| 123 |
+
# Generate unique filename to avoid conflicts
|
| 124 |
+
secure_name = secure_filename(filename)
|
| 125 |
+
file_id = str(uuid.uuid4())
|
| 126 |
+
file_ext = Path(secure_name).suffix
|
| 127 |
+
unique_filename = f"{file_id}{file_ext}"
|
| 128 |
+
|
| 129 |
+
file_path = os.path.join(self.upload_dir, unique_filename)
|
| 130 |
+
|
| 131 |
+
try:
|
| 132 |
+
file_obj.save(file_path)
|
| 133 |
+
file_size = os.path.getsize(file_path)
|
| 134 |
+
|
| 135 |
+
file_info = {
|
| 136 |
+
"file_id": file_id,
|
| 137 |
+
"original_name": filename,
|
| 138 |
+
"secure_name": secure_name,
|
| 139 |
+
"unique_filename": unique_filename,
|
| 140 |
+
"file_path": file_path,
|
| 141 |
+
"file_size": file_size,
|
| 142 |
+
"upload_time": datetime.utcnow().isoformat(),
|
| 143 |
+
"status": DocumentStatus.UPLOADED.value,
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
logging.info(f"Saved uploaded file: {filename} -> {unique_filename}")
|
| 147 |
+
return file_info
|
| 148 |
+
|
| 149 |
+
except Exception as e:
|
| 150 |
+
logging.error(f"Failed to save uploaded file {filename}: {e}")
|
| 151 |
+
raise
|
| 152 |
+
|
| 153 |
+
def get_file_metadata(self, file_path: str) -> Dict[str, Any]:
|
| 154 |
+
"""
|
| 155 |
+
Extract metadata from file.
|
| 156 |
+
|
| 157 |
+
Args:
|
| 158 |
+
file_path: Path to the file
|
| 159 |
+
|
| 160 |
+
Returns:
|
| 161 |
+
Dict with file metadata
|
| 162 |
+
"""
|
| 163 |
+
try:
|
| 164 |
+
stat = os.stat(file_path)
|
| 165 |
+
file_ext = Path(file_path).suffix.lower()
|
| 166 |
+
|
| 167 |
+
metadata = {
|
| 168 |
+
"file_size": stat.st_size,
|
| 169 |
+
"created_time": datetime.fromtimestamp(stat.st_ctime).isoformat(),
|
| 170 |
+
"modified_time": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
| 171 |
+
"file_extension": file_ext,
|
| 172 |
+
"file_type": self._get_file_type(file_ext),
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
# Try to extract additional metadata based on file type
|
| 176 |
+
if file_ext == ".pdf":
|
| 177 |
+
metadata.update(self._extract_pdf_metadata(file_path))
|
| 178 |
+
elif file_ext in [".docx", ".doc"]:
|
| 179 |
+
metadata.update(self._extract_word_metadata(file_path))
|
| 180 |
+
|
| 181 |
+
return metadata
|
| 182 |
+
|
| 183 |
+
except Exception as e:
|
| 184 |
+
logging.error(f"Failed to extract metadata from {file_path}: {e}")
|
| 185 |
+
return {}
|
| 186 |
+
|
| 187 |
+
def _get_file_type(self, file_ext: str) -> str:
|
| 188 |
+
"""Get file type category from extension"""
|
| 189 |
+
for file_type, extensions in self.supported_formats.items():
|
| 190 |
+
if file_ext in extensions:
|
| 191 |
+
return file_type
|
| 192 |
+
return "unknown"
|
| 193 |
+
|
| 194 |
+
def _extract_pdf_metadata(self, file_path: str) -> Dict[str, Any]:
|
| 195 |
+
"""Extract metadata from PDF file"""
|
| 196 |
+
try:
|
| 197 |
+
# This would use PyPDF2 or similar library in a real implementation
|
| 198 |
+
# For now, return basic info
|
| 199 |
+
return {
|
| 200 |
+
"pages": "unknown", # Would extract actual page count
|
| 201 |
+
"title": "unknown", # Would extract PDF title
|
| 202 |
+
"author": "unknown", # Would extract PDF author
|
| 203 |
+
}
|
| 204 |
+
except Exception:
|
| 205 |
+
return {}
|
| 206 |
+
|
| 207 |
+
def _extract_word_metadata(self, file_path: str) -> Dict[str, Any]:
|
| 208 |
+
"""Extract metadata from Word document"""
|
| 209 |
+
try:
|
| 210 |
+
# This would use python-docx or similar library in a real implementation
|
| 211 |
+
# For now, return basic info
|
| 212 |
+
return {
|
| 213 |
+
"word_count": "unknown", # Would extract actual word count
|
| 214 |
+
"title": "unknown", # Would extract document title
|
| 215 |
+
"author": "unknown", # Would extract document author
|
| 216 |
+
}
|
| 217 |
+
except Exception:
|
| 218 |
+
return {}
|
| 219 |
+
|
| 220 |
+
def delete_file(self, file_path: str) -> bool:
|
| 221 |
+
"""
|
| 222 |
+
Delete file from disk.
|
| 223 |
+
|
| 224 |
+
Args:
|
| 225 |
+
file_path: Path to file to delete
|
| 226 |
+
|
| 227 |
+
Returns:
|
| 228 |
+
True if successful, False otherwise
|
| 229 |
+
"""
|
| 230 |
+
try:
|
| 231 |
+
if os.path.exists(file_path):
|
| 232 |
+
os.remove(file_path)
|
| 233 |
+
logging.info(f"Deleted file: {file_path}")
|
| 234 |
+
return True
|
| 235 |
+
else:
|
| 236 |
+
logging.warning(f"File not found for deletion: {file_path}")
|
| 237 |
+
return False
|
| 238 |
+
except Exception as e:
|
| 239 |
+
logging.error(f"Failed to delete file {file_path}: {e}")
|
| 240 |
+
return False
|
| 241 |
+
|
| 242 |
+
def get_upload_stats(self) -> Dict[str, Any]:
|
| 243 |
+
"""
|
| 244 |
+
Get statistics about uploaded files.
|
| 245 |
+
|
| 246 |
+
Returns:
|
| 247 |
+
Dict with upload statistics
|
| 248 |
+
"""
|
| 249 |
+
try:
|
| 250 |
+
if not os.path.exists(self.upload_dir):
|
| 251 |
+
return {"total_files": 0, "total_size": 0, "file_types": {}}
|
| 252 |
+
|
| 253 |
+
files = list(Path(self.upload_dir).glob("*"))
|
| 254 |
+
total_size = sum(f.stat().st_size for f in files if f.is_file())
|
| 255 |
+
|
| 256 |
+
file_types = {}
|
| 257 |
+
for file_path in files:
|
| 258 |
+
if file_path.is_file():
|
| 259 |
+
ext = file_path.suffix.lower()
|
| 260 |
+
file_types[ext] = file_types.get(ext, 0) + 1
|
| 261 |
+
|
| 262 |
+
return {
|
| 263 |
+
"total_files": len(files),
|
| 264 |
+
"total_size": total_size,
|
| 265 |
+
"file_types": file_types,
|
| 266 |
+
"upload_dir": self.upload_dir,
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
except Exception as e:
|
| 270 |
+
logging.error(f"Failed to get upload stats: {e}")
|
| 271 |
+
return {"error": str(e)}
|
| 272 |
+
|
| 273 |
+
def cleanup_old_files(self, days_old: int = 30) -> Dict[str, Any]:
|
| 274 |
+
"""
|
| 275 |
+
Clean up old uploaded files.
|
| 276 |
+
|
| 277 |
+
Args:
|
| 278 |
+
days_old: Delete files older than this many days
|
| 279 |
+
|
| 280 |
+
Returns:
|
| 281 |
+
Dict with cleanup results
|
| 282 |
+
"""
|
| 283 |
+
try:
|
| 284 |
+
cutoff_time = datetime.now().timestamp() - (days_old * 24 * 60 * 60)
|
| 285 |
+
deleted_files = []
|
| 286 |
+
errors = []
|
| 287 |
+
|
| 288 |
+
if os.path.exists(self.upload_dir):
|
| 289 |
+
for file_path in Path(self.upload_dir).glob("*"):
|
| 290 |
+
if file_path.is_file() and file_path.stat().st_mtime < cutoff_time:
|
| 291 |
+
try:
|
| 292 |
+
file_path.unlink()
|
| 293 |
+
deleted_files.append(str(file_path))
|
| 294 |
+
except Exception as e:
|
| 295 |
+
errors.append(f"Failed to delete {file_path}: {e}")
|
| 296 |
+
|
| 297 |
+
result = {
|
| 298 |
+
"deleted_count": len(deleted_files),
|
| 299 |
+
"deleted_files": deleted_files,
|
| 300 |
+
"errors": errors,
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
logging.info(f"Cleanup completed: {len(deleted_files)} files deleted")
|
| 304 |
+
return result
|
| 305 |
+
|
| 306 |
+
except Exception as e:
|
| 307 |
+
logging.error(f"Cleanup failed: {e}")
|
| 308 |
+
return {"error": str(e)}
|
src/document_management/processing_service.py
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Processing Service - Async document processing
|
| 3 |
+
|
| 4 |
+
Handles document processing workflow integration with the existing
|
| 5 |
+
ingestion pipeline and vector database. Provides async processing
|
| 6 |
+
with status tracking and queue management.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
import os
|
| 11 |
+
import threading
|
| 12 |
+
from datetime import datetime
|
| 13 |
+
from queue import Empty, Queue
|
| 14 |
+
from typing import Any, Callable, Dict, List, Optional
|
| 15 |
+
|
| 16 |
+
from .document_service import DocumentStatus
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class ProcessingJob:
|
| 20 |
+
"""Represents a document processing job"""
|
| 21 |
+
|
| 22 |
+
def __init__(
|
| 23 |
+
self, file_info: Dict[str, Any], processing_options: Dict[str, Any] = None
|
| 24 |
+
):
|
| 25 |
+
self.job_id = file_info["file_id"]
|
| 26 |
+
self.file_info = file_info
|
| 27 |
+
self.processing_options = processing_options or {}
|
| 28 |
+
self.status = DocumentStatus.UPLOADED
|
| 29 |
+
self.progress = 0.0
|
| 30 |
+
self.created_at = datetime.utcnow()
|
| 31 |
+
self.started_at = None
|
| 32 |
+
self.completed_at = None
|
| 33 |
+
self.error_message = None
|
| 34 |
+
self.result = None
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class ProcessingService:
|
| 38 |
+
"""
|
| 39 |
+
Async document processing service that integrates with existing RAG pipeline.
|
| 40 |
+
|
| 41 |
+
This service manages the document processing queue and coordinates with
|
| 42 |
+
the existing ingestion pipeline for seamless integration.
|
| 43 |
+
"""
|
| 44 |
+
|
| 45 |
+
def __init__(self, max_workers: int = 2):
|
| 46 |
+
"""
|
| 47 |
+
Initialize the processing service.
|
| 48 |
+
|
| 49 |
+
Args:
|
| 50 |
+
max_workers: Maximum number of concurrent processing jobs
|
| 51 |
+
"""
|
| 52 |
+
self.max_workers = max_workers
|
| 53 |
+
self.job_queue = Queue()
|
| 54 |
+
self.active_jobs = {}
|
| 55 |
+
self.completed_jobs = {}
|
| 56 |
+
self.failed_jobs = {}
|
| 57 |
+
self.workers = []
|
| 58 |
+
self.running = False
|
| 59 |
+
self.status_callbacks = []
|
| 60 |
+
|
| 61 |
+
logging.info(f"ProcessingService initialized with {max_workers} workers")
|
| 62 |
+
|
| 63 |
+
def start(self):
|
| 64 |
+
"""Start the processing service"""
|
| 65 |
+
if self.running:
|
| 66 |
+
return
|
| 67 |
+
|
| 68 |
+
self.running = True
|
| 69 |
+
|
| 70 |
+
# Start worker threads
|
| 71 |
+
for i in range(self.max_workers):
|
| 72 |
+
worker = threading.Thread(
|
| 73 |
+
target=self._worker_loop, name=f"ProcessingWorker-{i}"
|
| 74 |
+
)
|
| 75 |
+
worker.daemon = True
|
| 76 |
+
worker.start()
|
| 77 |
+
self.workers.append(worker)
|
| 78 |
+
|
| 79 |
+
logging.info(f"ProcessingService started with {len(self.workers)} workers")
|
| 80 |
+
|
| 81 |
+
def stop(self):
|
| 82 |
+
"""Stop the processing service"""
|
| 83 |
+
self.running = False
|
| 84 |
+
|
| 85 |
+
# Add sentinel values to wake up workers
|
| 86 |
+
for _ in range(self.max_workers):
|
| 87 |
+
self.job_queue.put(None)
|
| 88 |
+
|
| 89 |
+
# Wait for workers to finish
|
| 90 |
+
for worker in self.workers:
|
| 91 |
+
worker.join(timeout=5.0)
|
| 92 |
+
|
| 93 |
+
self.workers.clear()
|
| 94 |
+
logging.info("ProcessingService stopped")
|
| 95 |
+
|
| 96 |
+
def submit_job(
|
| 97 |
+
self, file_info: Dict[str, Any], processing_options: Dict[str, Any] = None
|
| 98 |
+
) -> str:
|
| 99 |
+
"""
|
| 100 |
+
Submit a document for processing.
|
| 101 |
+
|
| 102 |
+
Args:
|
| 103 |
+
file_info: File information from document service
|
| 104 |
+
processing_options: Processing configuration options
|
| 105 |
+
|
| 106 |
+
Returns:
|
| 107 |
+
Job ID for tracking
|
| 108 |
+
"""
|
| 109 |
+
job = ProcessingJob(file_info, processing_options)
|
| 110 |
+
|
| 111 |
+
# Add to active jobs tracking
|
| 112 |
+
self.active_jobs[job.job_id] = job
|
| 113 |
+
|
| 114 |
+
# Add to processing queue
|
| 115 |
+
self.job_queue.put(job)
|
| 116 |
+
|
| 117 |
+
logging.info(
|
| 118 |
+
f"Submitted processing job {job.job_id} for file {file_info['original_name']}"
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
# Notify status callbacks
|
| 122 |
+
self._notify_status_change(job, DocumentStatus.UPLOADED)
|
| 123 |
+
|
| 124 |
+
return job.job_id
|
| 125 |
+
|
| 126 |
+
def get_job_status(self, job_id: str) -> Optional[Dict[str, Any]]:
|
| 127 |
+
"""
|
| 128 |
+
Get status of a processing job.
|
| 129 |
+
|
| 130 |
+
Args:
|
| 131 |
+
job_id: Job ID to check
|
| 132 |
+
|
| 133 |
+
Returns:
|
| 134 |
+
Job status information or None if not found
|
| 135 |
+
"""
|
| 136 |
+
# Check active jobs
|
| 137 |
+
if job_id in self.active_jobs:
|
| 138 |
+
job = self.active_jobs[job_id]
|
| 139 |
+
return self._job_to_dict(job)
|
| 140 |
+
|
| 141 |
+
# Check completed jobs
|
| 142 |
+
if job_id in self.completed_jobs:
|
| 143 |
+
job = self.completed_jobs[job_id]
|
| 144 |
+
return self._job_to_dict(job)
|
| 145 |
+
|
| 146 |
+
# Check failed jobs
|
| 147 |
+
if job_id in self.failed_jobs:
|
| 148 |
+
job = self.failed_jobs[job_id]
|
| 149 |
+
return self._job_to_dict(job)
|
| 150 |
+
|
| 151 |
+
return None
|
| 152 |
+
|
| 153 |
+
def get_queue_status(self) -> Dict[str, Any]:
|
| 154 |
+
"""
|
| 155 |
+
Get overall queue status.
|
| 156 |
+
|
| 157 |
+
Returns:
|
| 158 |
+
Queue status information
|
| 159 |
+
"""
|
| 160 |
+
return {
|
| 161 |
+
"queue_size": self.job_queue.qsize(),
|
| 162 |
+
"active_jobs": len(self.active_jobs),
|
| 163 |
+
"completed_jobs": len(self.completed_jobs),
|
| 164 |
+
"failed_jobs": len(self.failed_jobs),
|
| 165 |
+
"workers_running": len(self.workers),
|
| 166 |
+
"service_running": self.running,
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
def get_all_jobs(self, status_filter: str = None) -> List[Dict[str, Any]]:
|
| 170 |
+
"""
|
| 171 |
+
Get all jobs, optionally filtered by status.
|
| 172 |
+
|
| 173 |
+
Args:
|
| 174 |
+
status_filter: Optional status to filter by
|
| 175 |
+
|
| 176 |
+
Returns:
|
| 177 |
+
List of job information
|
| 178 |
+
"""
|
| 179 |
+
jobs = []
|
| 180 |
+
|
| 181 |
+
# Add active jobs
|
| 182 |
+
for job in self.active_jobs.values():
|
| 183 |
+
if not status_filter or job.status.value == status_filter:
|
| 184 |
+
jobs.append(self._job_to_dict(job))
|
| 185 |
+
|
| 186 |
+
# Add completed jobs
|
| 187 |
+
for job in self.completed_jobs.values():
|
| 188 |
+
if not status_filter or job.status.value == status_filter:
|
| 189 |
+
jobs.append(self._job_to_dict(job))
|
| 190 |
+
|
| 191 |
+
# Add failed jobs
|
| 192 |
+
for job in self.failed_jobs.values():
|
| 193 |
+
if not status_filter or job.status.value == status_filter:
|
| 194 |
+
jobs.append(self._job_to_dict(job))
|
| 195 |
+
|
| 196 |
+
# Sort by created time (newest first)
|
| 197 |
+
jobs.sort(key=lambda x: x["created_at"], reverse=True)
|
| 198 |
+
|
| 199 |
+
return jobs
|
| 200 |
+
|
| 201 |
+
def add_status_callback(self, callback: Callable[[str, DocumentStatus], None]):
|
| 202 |
+
"""
|
| 203 |
+
Add a callback for status change notifications.
|
| 204 |
+
|
| 205 |
+
Args:
|
| 206 |
+
callback: Function to call when job status changes
|
| 207 |
+
"""
|
| 208 |
+
self.status_callbacks.append(callback)
|
| 209 |
+
|
| 210 |
+
def _worker_loop(self):
|
| 211 |
+
"""Main worker loop for processing jobs"""
|
| 212 |
+
while self.running:
|
| 213 |
+
try:
|
| 214 |
+
# Get next job from queue (blocks until available)
|
| 215 |
+
job = self.job_queue.get(timeout=1.0)
|
| 216 |
+
|
| 217 |
+
# Check for sentinel value (stop signal)
|
| 218 |
+
if job is None:
|
| 219 |
+
break
|
| 220 |
+
|
| 221 |
+
# Process the job
|
| 222 |
+
self._process_job(job)
|
| 223 |
+
|
| 224 |
+
except Empty:
|
| 225 |
+
# Normal timeout when no jobs are available - continue polling
|
| 226 |
+
continue
|
| 227 |
+
except Exception as e:
|
| 228 |
+
logging.error(f"Worker error: {e}", exc_info=True)
|
| 229 |
+
|
| 230 |
+
def _process_job(self, job: ProcessingJob):
|
| 231 |
+
"""
|
| 232 |
+
Process a single document job.
|
| 233 |
+
|
| 234 |
+
Args:
|
| 235 |
+
job: ProcessingJob to process
|
| 236 |
+
"""
|
| 237 |
+
try:
|
| 238 |
+
job.started_at = datetime.utcnow()
|
| 239 |
+
job.status = DocumentStatus.VALIDATING
|
| 240 |
+
job.progress = 10.0
|
| 241 |
+
self._notify_status_change(job, DocumentStatus.VALIDATING)
|
| 242 |
+
|
| 243 |
+
# Step 1: Validation
|
| 244 |
+
if not self._validate_file(job):
|
| 245 |
+
return
|
| 246 |
+
|
| 247 |
+
# Step 2: Parse document
|
| 248 |
+
job.status = DocumentStatus.PARSING
|
| 249 |
+
job.progress = 25.0
|
| 250 |
+
self._notify_status_change(job, DocumentStatus.PARSING)
|
| 251 |
+
|
| 252 |
+
parsed_content = self._parse_document(job)
|
| 253 |
+
if not parsed_content:
|
| 254 |
+
return
|
| 255 |
+
|
| 256 |
+
# Step 3: Chunk document
|
| 257 |
+
job.status = DocumentStatus.CHUNKING
|
| 258 |
+
job.progress = 50.0
|
| 259 |
+
self._notify_status_change(job, DocumentStatus.CHUNKING)
|
| 260 |
+
|
| 261 |
+
chunks = self._chunk_document(job, parsed_content)
|
| 262 |
+
if not chunks:
|
| 263 |
+
return
|
| 264 |
+
|
| 265 |
+
# Step 4: Generate embeddings
|
| 266 |
+
job.status = DocumentStatus.EMBEDDING
|
| 267 |
+
job.progress = 75.0
|
| 268 |
+
self._notify_status_change(job, DocumentStatus.EMBEDDING)
|
| 269 |
+
|
| 270 |
+
embeddings = self._generate_embeddings(job, chunks)
|
| 271 |
+
if not embeddings:
|
| 272 |
+
return
|
| 273 |
+
|
| 274 |
+
# Step 5: Index in vector database
|
| 275 |
+
job.status = DocumentStatus.INDEXING
|
| 276 |
+
job.progress = 90.0
|
| 277 |
+
self._notify_status_change(job, DocumentStatus.INDEXING)
|
| 278 |
+
|
| 279 |
+
if not self._index_document(job, chunks, embeddings):
|
| 280 |
+
return
|
| 281 |
+
|
| 282 |
+
# Completion
|
| 283 |
+
job.status = DocumentStatus.COMPLETED
|
| 284 |
+
job.progress = 100.0
|
| 285 |
+
job.completed_at = datetime.utcnow()
|
| 286 |
+
|
| 287 |
+
# Store result
|
| 288 |
+
job.result = {
|
| 289 |
+
"chunks_created": len(chunks),
|
| 290 |
+
"embeddings_generated": len(embeddings),
|
| 291 |
+
"processing_time": (job.completed_at - job.started_at).total_seconds(),
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
# Move to completed jobs
|
| 295 |
+
self.completed_jobs[job.job_id] = job
|
| 296 |
+
if job.job_id in self.active_jobs:
|
| 297 |
+
del self.active_jobs[job.job_id]
|
| 298 |
+
|
| 299 |
+
self._notify_status_change(job, DocumentStatus.COMPLETED)
|
| 300 |
+
|
| 301 |
+
logging.info(f"Successfully processed job {job.job_id}")
|
| 302 |
+
|
| 303 |
+
except Exception as e:
|
| 304 |
+
self._handle_job_error(job, str(e))
|
| 305 |
+
|
| 306 |
+
def _validate_file(self, job: ProcessingJob) -> bool:
|
| 307 |
+
"""Validate file before processing"""
|
| 308 |
+
try:
|
| 309 |
+
file_path = job.file_info["file_path"]
|
| 310 |
+
|
| 311 |
+
# Check if file exists
|
| 312 |
+
if not os.path.exists(file_path):
|
| 313 |
+
raise ValueError(f"File not found: {file_path}")
|
| 314 |
+
|
| 315 |
+
# Check file size
|
| 316 |
+
file_size = os.path.getsize(file_path)
|
| 317 |
+
if file_size == 0:
|
| 318 |
+
raise ValueError("File is empty")
|
| 319 |
+
|
| 320 |
+
return True
|
| 321 |
+
|
| 322 |
+
except Exception as e:
|
| 323 |
+
self._handle_job_error(job, f"Validation failed: {e}")
|
| 324 |
+
return False
|
| 325 |
+
|
| 326 |
+
def _parse_document(self, job: ProcessingJob) -> Optional[str]:
|
| 327 |
+
"""Parse document content"""
|
| 328 |
+
try:
|
| 329 |
+
# This would integrate with existing document parsing logic
|
| 330 |
+
# For now, simulate parsing based on file type
|
| 331 |
+
file_path = job.file_info["file_path"]
|
| 332 |
+
file_ext = job.file_info.get("file_extension", "").lower()
|
| 333 |
+
|
| 334 |
+
if file_ext in [".txt", ".md"]:
|
| 335 |
+
with open(file_path, "r", encoding="utf-8") as f:
|
| 336 |
+
return f.read()
|
| 337 |
+
else:
|
| 338 |
+
# For other formats, would use appropriate parsers
|
| 339 |
+
# (PyPDF2 for PDF, python-docx for Word, etc.)
|
| 340 |
+
return f"Parsed content from {file_path}"
|
| 341 |
+
|
| 342 |
+
except Exception as e:
|
| 343 |
+
self._handle_job_error(job, f"Parsing failed: {e}")
|
| 344 |
+
return None
|
| 345 |
+
|
| 346 |
+
def _chunk_document(self, job: ProcessingJob, content: str) -> Optional[List[str]]:
|
| 347 |
+
"""Chunk document content"""
|
| 348 |
+
try:
|
| 349 |
+
# This would integrate with existing chunking logic from ingestion pipeline
|
| 350 |
+
# For now, simulate chunking
|
| 351 |
+
chunk_size = job.processing_options.get("chunk_size", 1000)
|
| 352 |
+
overlap = job.processing_options.get("overlap", 200)
|
| 353 |
+
|
| 354 |
+
chunks = []
|
| 355 |
+
start = 0
|
| 356 |
+
while start < len(content):
|
| 357 |
+
end = start + chunk_size
|
| 358 |
+
chunk = content[start:end]
|
| 359 |
+
chunks.append(chunk)
|
| 360 |
+
start = end - overlap
|
| 361 |
+
|
| 362 |
+
return chunks
|
| 363 |
+
|
| 364 |
+
except Exception as e:
|
| 365 |
+
self._handle_job_error(job, f"Chunking failed: {e}")
|
| 366 |
+
return None
|
| 367 |
+
|
| 368 |
+
def _generate_embeddings(
|
| 369 |
+
self, job: ProcessingJob, chunks: List[str]
|
| 370 |
+
) -> Optional[List[List[float]]]:
|
| 371 |
+
"""Generate embeddings for chunks"""
|
| 372 |
+
try:
|
| 373 |
+
# This would integrate with existing embedding service
|
| 374 |
+
# For now, simulate embedding generation
|
| 375 |
+
embeddings = []
|
| 376 |
+
for chunk in chunks:
|
| 377 |
+
# Simulate embedding vector (384 dimensions for sentence-transformers)
|
| 378 |
+
embedding = [0.1] * 384 # Placeholder
|
| 379 |
+
embeddings.append(embedding)
|
| 380 |
+
|
| 381 |
+
return embeddings
|
| 382 |
+
|
| 383 |
+
except Exception as e:
|
| 384 |
+
self._handle_job_error(job, f"Embedding generation failed: {e}")
|
| 385 |
+
return None
|
| 386 |
+
|
| 387 |
+
def _index_document(
|
| 388 |
+
self, job: ProcessingJob, chunks: List[str], embeddings: List[List[float]]
|
| 389 |
+
) -> bool:
|
| 390 |
+
"""Index document in vector database"""
|
| 391 |
+
try:
|
| 392 |
+
# This would integrate with existing vector database
|
| 393 |
+
# For now, simulate indexing
|
| 394 |
+
logging.info(f"Indexing {len(chunks)} chunks for job {job.job_id}")
|
| 395 |
+
return True
|
| 396 |
+
|
| 397 |
+
except Exception as e:
|
| 398 |
+
self._handle_job_error(job, f"Indexing failed: {e}")
|
| 399 |
+
return False
|
| 400 |
+
|
| 401 |
+
def _handle_job_error(self, job: ProcessingJob, error_message: str):
|
| 402 |
+
"""Handle job processing error"""
|
| 403 |
+
job.status = DocumentStatus.FAILED
|
| 404 |
+
job.error_message = error_message
|
| 405 |
+
job.completed_at = datetime.utcnow()
|
| 406 |
+
|
| 407 |
+
# Move to failed jobs
|
| 408 |
+
self.failed_jobs[job.job_id] = job
|
| 409 |
+
if job.job_id in self.active_jobs:
|
| 410 |
+
del self.active_jobs[job.job_id]
|
| 411 |
+
|
| 412 |
+
self._notify_status_change(job, DocumentStatus.FAILED)
|
| 413 |
+
|
| 414 |
+
logging.error(f"Job {job.job_id} failed: {error_message}")
|
| 415 |
+
|
| 416 |
+
def _notify_status_change(self, job: ProcessingJob, status: DocumentStatus):
|
| 417 |
+
"""Notify registered callbacks of status change"""
|
| 418 |
+
for callback in self.status_callbacks:
|
| 419 |
+
try:
|
| 420 |
+
callback(job.job_id, status)
|
| 421 |
+
except Exception as e:
|
| 422 |
+
logging.error(f"Status callback error: {e}")
|
| 423 |
+
|
| 424 |
+
def _job_to_dict(self, job: ProcessingJob) -> Dict[str, Any]:
|
| 425 |
+
"""Convert ProcessingJob to dictionary"""
|
| 426 |
+
return {
|
| 427 |
+
"job_id": job.job_id,
|
| 428 |
+
"file_info": job.file_info,
|
| 429 |
+
"status": job.status.value,
|
| 430 |
+
"progress": job.progress,
|
| 431 |
+
"created_at": job.created_at.isoformat(),
|
| 432 |
+
"started_at": job.started_at.isoformat() if job.started_at else None,
|
| 433 |
+
"completed_at": job.completed_at.isoformat() if job.completed_at else None,
|
| 434 |
+
"error_message": job.error_message,
|
| 435 |
+
"result": job.result,
|
| 436 |
+
"processing_options": job.processing_options,
|
| 437 |
+
}
|
src/document_management/routes.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Document Management API Routes
|
| 3 |
+
|
| 4 |
+
Flask Blueprint for document management endpoints that integrates
|
| 5 |
+
with the app factory pattern and lazy loading architecture.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import logging
|
| 9 |
+
|
| 10 |
+
from flask import Blueprint, jsonify, request
|
| 11 |
+
|
| 12 |
+
# Create blueprint
|
| 13 |
+
document_bp = Blueprint("document_management", __name__)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def get_document_services():
|
| 17 |
+
"""
|
| 18 |
+
Get document management services from Flask app config.
|
| 19 |
+
|
| 20 |
+
This follows the same lazy loading pattern as other services
|
| 21 |
+
in the app factory.
|
| 22 |
+
"""
|
| 23 |
+
from flask import current_app
|
| 24 |
+
|
| 25 |
+
# Check if services are already initialized
|
| 26 |
+
if current_app.config.get("DOCUMENT_SERVICES") is None:
|
| 27 |
+
logging.info("Initializing document management services for the first time...")
|
| 28 |
+
|
| 29 |
+
from .document_service import DocumentService
|
| 30 |
+
from .processing_service import ProcessingService
|
| 31 |
+
from .upload_service import UploadService
|
| 32 |
+
|
| 33 |
+
# Initialize services
|
| 34 |
+
document_service = DocumentService()
|
| 35 |
+
processing_service = ProcessingService(max_workers=2)
|
| 36 |
+
upload_service = UploadService(document_service, processing_service)
|
| 37 |
+
|
| 38 |
+
# Start processing service
|
| 39 |
+
processing_service.start()
|
| 40 |
+
|
| 41 |
+
# Cache services in app config
|
| 42 |
+
current_app.config["DOCUMENT_SERVICES"] = {
|
| 43 |
+
"document": document_service,
|
| 44 |
+
"processing": processing_service,
|
| 45 |
+
"upload": upload_service,
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
logging.info("Document management services initialized")
|
| 49 |
+
|
| 50 |
+
return current_app.config["DOCUMENT_SERVICES"]
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@document_bp.route("/upload", methods=["POST"])
|
| 54 |
+
def upload_documents():
|
| 55 |
+
"""Upload one or more documents for processing"""
|
| 56 |
+
try:
|
| 57 |
+
services = get_document_services()
|
| 58 |
+
upload_service = services["upload"]
|
| 59 |
+
|
| 60 |
+
# Get metadata from form or JSON
|
| 61 |
+
metadata = {}
|
| 62 |
+
if request.is_json:
|
| 63 |
+
metadata = request.get_json() or {}
|
| 64 |
+
else:
|
| 65 |
+
# Extract metadata from form fields
|
| 66 |
+
for key in ["category", "department", "author", "description"]:
|
| 67 |
+
if key in request.form:
|
| 68 |
+
metadata[key] = request.form[key]
|
| 69 |
+
|
| 70 |
+
# Processing options
|
| 71 |
+
if "chunk_size" in request.form:
|
| 72 |
+
metadata["chunk_size"] = int(request.form["chunk_size"])
|
| 73 |
+
if "overlap" in request.form:
|
| 74 |
+
metadata["overlap"] = int(request.form["overlap"])
|
| 75 |
+
if "auto_process" in request.form:
|
| 76 |
+
metadata["auto_process"] = (
|
| 77 |
+
request.form["auto_process"].lower() == "true"
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
# Handle file upload
|
| 81 |
+
result = upload_service.handle_upload_request(request.files, metadata)
|
| 82 |
+
|
| 83 |
+
if result["status"] == "error":
|
| 84 |
+
return jsonify(result), 400
|
| 85 |
+
elif result["status"] == "partial":
|
| 86 |
+
return jsonify(result), 207 # Multi-status
|
| 87 |
+
else:
|
| 88 |
+
return jsonify(result), 200
|
| 89 |
+
|
| 90 |
+
except Exception as e:
|
| 91 |
+
logging.error(f"Upload endpoint error: {e}", exc_info=True)
|
| 92 |
+
return jsonify({"status": "error", "message": f"Upload failed: {str(e)}"}), 500
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@document_bp.route("/jobs/<job_id>", methods=["GET"])
|
| 96 |
+
def get_job_status(job_id: str):
|
| 97 |
+
"""Get status of a processing job"""
|
| 98 |
+
try:
|
| 99 |
+
services = get_document_services()
|
| 100 |
+
processing_service = services["processing"]
|
| 101 |
+
|
| 102 |
+
job_status = processing_service.get_job_status(job_id)
|
| 103 |
+
|
| 104 |
+
if job_status is None:
|
| 105 |
+
return (
|
| 106 |
+
jsonify({"status": "error", "message": f"Job {job_id} not found"}),
|
| 107 |
+
404,
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
return jsonify({"status": "success", "job": job_status}), 200
|
| 111 |
+
|
| 112 |
+
except Exception as e:
|
| 113 |
+
logging.error(f"Job status endpoint error: {e}", exc_info=True)
|
| 114 |
+
return (
|
| 115 |
+
jsonify(
|
| 116 |
+
{"status": "error", "message": f"Failed to get job status: {str(e)}"}
|
| 117 |
+
),
|
| 118 |
+
500,
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
@document_bp.route("/jobs", methods=["GET"])
|
| 123 |
+
def get_all_jobs():
|
| 124 |
+
"""Get all processing jobs with optional status filter"""
|
| 125 |
+
try:
|
| 126 |
+
services = get_document_services()
|
| 127 |
+
processing_service = services["processing"]
|
| 128 |
+
|
| 129 |
+
status_filter = request.args.get("status")
|
| 130 |
+
jobs = processing_service.get_all_jobs(status_filter)
|
| 131 |
+
|
| 132 |
+
return jsonify({"status": "success", "jobs": jobs, "count": len(jobs)}), 200
|
| 133 |
+
|
| 134 |
+
except Exception as e:
|
| 135 |
+
logging.error(f"Jobs list endpoint error: {e}", exc_info=True)
|
| 136 |
+
return (
|
| 137 |
+
jsonify({"status": "error", "message": f"Failed to get jobs: {str(e)}"}),
|
| 138 |
+
500,
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
@document_bp.route("/queue/status", methods=["GET"])
|
| 143 |
+
def get_queue_status():
|
| 144 |
+
"""Get processing queue status"""
|
| 145 |
+
try:
|
| 146 |
+
services = get_document_services()
|
| 147 |
+
processing_service = services["processing"]
|
| 148 |
+
|
| 149 |
+
queue_status = processing_service.get_queue_status()
|
| 150 |
+
|
| 151 |
+
return jsonify({"status": "success", "queue": queue_status}), 200
|
| 152 |
+
|
| 153 |
+
except Exception as e:
|
| 154 |
+
logging.error(f"Queue status endpoint error: {e}", exc_info=True)
|
| 155 |
+
return (
|
| 156 |
+
jsonify(
|
| 157 |
+
{"status": "error", "message": f"Failed to get queue status: {str(e)}"}
|
| 158 |
+
),
|
| 159 |
+
500,
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
@document_bp.route("/stats", methods=["GET"])
|
| 164 |
+
def get_document_stats():
|
| 165 |
+
"""Get document management statistics"""
|
| 166 |
+
try:
|
| 167 |
+
services = get_document_services()
|
| 168 |
+
upload_service = services["upload"]
|
| 169 |
+
|
| 170 |
+
stats = upload_service.get_upload_summary()
|
| 171 |
+
|
| 172 |
+
return jsonify({"status": "success", "stats": stats}), 200
|
| 173 |
+
|
| 174 |
+
except Exception as e:
|
| 175 |
+
logging.error(f"Stats endpoint error: {e}", exc_info=True)
|
| 176 |
+
return (
|
| 177 |
+
jsonify({"status": "error", "message": f"Failed to get stats: {str(e)}"}),
|
| 178 |
+
500,
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
@document_bp.route("/validate", methods=["POST"])
|
| 183 |
+
def validate_files():
|
| 184 |
+
"""Validate files before upload"""
|
| 185 |
+
try:
|
| 186 |
+
services = get_document_services()
|
| 187 |
+
upload_service = services["upload"]
|
| 188 |
+
|
| 189 |
+
if "files" not in request.files:
|
| 190 |
+
return jsonify({"status": "error", "message": "No files provided"}), 400
|
| 191 |
+
|
| 192 |
+
files = request.files.getlist("files")
|
| 193 |
+
valid_files, errors = upload_service.validate_batch_upload(files)
|
| 194 |
+
|
| 195 |
+
return (
|
| 196 |
+
jsonify(
|
| 197 |
+
{
|
| 198 |
+
"status": "success",
|
| 199 |
+
"validation": {
|
| 200 |
+
"total_files": len(files),
|
| 201 |
+
"valid_files": len(valid_files),
|
| 202 |
+
"invalid_files": len(files) - len(valid_files),
|
| 203 |
+
"errors": errors,
|
| 204 |
+
"can_upload": len(errors) == 0,
|
| 205 |
+
},
|
| 206 |
+
}
|
| 207 |
+
),
|
| 208 |
+
200,
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
except Exception as e:
|
| 212 |
+
logging.error(f"Validation endpoint error: {e}", exc_info=True)
|
| 213 |
+
return (
|
| 214 |
+
jsonify({"status": "error", "message": f"Validation failed: {str(e)}"}),
|
| 215 |
+
500,
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
@document_bp.route("/health", methods=["GET"])
|
| 220 |
+
def document_management_health():
|
| 221 |
+
"""Health check for document management services"""
|
| 222 |
+
try:
|
| 223 |
+
services = get_document_services()
|
| 224 |
+
|
| 225 |
+
health_status = {
|
| 226 |
+
"status": "healthy",
|
| 227 |
+
"services": {
|
| 228 |
+
"document_service": "active",
|
| 229 |
+
"processing_service": "active"
|
| 230 |
+
if services["processing"].running
|
| 231 |
+
else "inactive",
|
| 232 |
+
"upload_service": "active",
|
| 233 |
+
},
|
| 234 |
+
"queue_status": services["processing"].get_queue_status(),
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
# Check if any service is unhealthy
|
| 238 |
+
if not services["processing"].running:
|
| 239 |
+
health_status["status"] = "degraded"
|
| 240 |
+
|
| 241 |
+
return jsonify(health_status), 200
|
| 242 |
+
|
| 243 |
+
except Exception as e:
|
| 244 |
+
logging.error(f"Document management health check error: {e}", exc_info=True)
|
| 245 |
+
return jsonify({"status": "unhealthy", "error": str(e)}), 500
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
# Error handlers for the blueprint
|
| 249 |
+
@document_bp.errorhandler(413)
|
| 250 |
+
def file_too_large(error):
|
| 251 |
+
"""Handle file too large errors"""
|
| 252 |
+
return (
|
| 253 |
+
jsonify(
|
| 254 |
+
{
|
| 255 |
+
"status": "error",
|
| 256 |
+
"message": "File too large. Maximum file size exceeded.",
|
| 257 |
+
}
|
| 258 |
+
),
|
| 259 |
+
413,
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
@document_bp.errorhandler(400)
|
| 264 |
+
def bad_request(error):
|
| 265 |
+
"""Handle bad request errors"""
|
| 266 |
+
return (
|
| 267 |
+
jsonify(
|
| 268 |
+
{
|
| 269 |
+
"status": "error",
|
| 270 |
+
"message": "Bad request. Please check your request format.",
|
| 271 |
+
}
|
| 272 |
+
),
|
| 273 |
+
400,
|
| 274 |
+
)
|
src/document_management/upload_service.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Upload Service - Handle file uploads and validation
|
| 3 |
+
|
| 4 |
+
Provides upload management functionality that integrates with
|
| 5 |
+
the Flask app factory pattern and existing services.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import logging
|
| 9 |
+
from typing import Any, Dict, List, Tuple
|
| 10 |
+
|
| 11 |
+
from werkzeug.datastructures import FileStorage
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class UploadService:
|
| 15 |
+
"""
|
| 16 |
+
File upload service that handles multi-file uploads with validation.
|
| 17 |
+
|
| 18 |
+
Integrates with DocumentService for file management and ProcessingService
|
| 19 |
+
for async processing workflow.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
def __init__(self, document_service, processing_service):
|
| 23 |
+
"""
|
| 24 |
+
Initialize upload service.
|
| 25 |
+
|
| 26 |
+
Args:
|
| 27 |
+
document_service: DocumentService instance
|
| 28 |
+
processing_service: ProcessingService instance
|
| 29 |
+
"""
|
| 30 |
+
self.document_service = document_service
|
| 31 |
+
self.processing_service = processing_service
|
| 32 |
+
|
| 33 |
+
logging.info("UploadService initialized")
|
| 34 |
+
|
| 35 |
+
def handle_upload_request(
|
| 36 |
+
self, request_files, metadata: Dict[str, Any] = None
|
| 37 |
+
) -> Dict[str, Any]:
|
| 38 |
+
"""
|
| 39 |
+
Handle multi-file upload request.
|
| 40 |
+
|
| 41 |
+
Args:
|
| 42 |
+
request_files: Files from Flask request
|
| 43 |
+
metadata: Optional metadata for files
|
| 44 |
+
|
| 45 |
+
Returns:
|
| 46 |
+
Upload results with status and file information
|
| 47 |
+
"""
|
| 48 |
+
if not request_files:
|
| 49 |
+
return {"status": "error", "message": "No files provided", "files": []}
|
| 50 |
+
|
| 51 |
+
results = {
|
| 52 |
+
"status": "success",
|
| 53 |
+
"files": [],
|
| 54 |
+
"job_ids": [],
|
| 55 |
+
"total_files": 0,
|
| 56 |
+
"successful_uploads": 0,
|
| 57 |
+
"failed_uploads": 0,
|
| 58 |
+
"errors": [],
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
# Handle multiple files
|
| 62 |
+
files = (
|
| 63 |
+
request_files.getlist("files")
|
| 64 |
+
if hasattr(request_files, "getlist")
|
| 65 |
+
else [request_files.get("file")]
|
| 66 |
+
)
|
| 67 |
+
files = [f for f in files if f] # Remove None values
|
| 68 |
+
|
| 69 |
+
results["total_files"] = len(files)
|
| 70 |
+
|
| 71 |
+
for file_obj in files:
|
| 72 |
+
try:
|
| 73 |
+
file_result = self._process_single_file(file_obj, metadata or {})
|
| 74 |
+
results["files"].append(file_result)
|
| 75 |
+
|
| 76 |
+
if file_result["status"] == "success":
|
| 77 |
+
results["successful_uploads"] += 1
|
| 78 |
+
if file_result.get("job_id"):
|
| 79 |
+
results["job_ids"].append(file_result["job_id"])
|
| 80 |
+
else:
|
| 81 |
+
results["failed_uploads"] += 1
|
| 82 |
+
if file_result.get("error"):
|
| 83 |
+
results["errors"].append(file_result["error"])
|
| 84 |
+
|
| 85 |
+
except Exception as e:
|
| 86 |
+
error_msg = f"Failed to process file: {str(e)}"
|
| 87 |
+
results["errors"].append(error_msg)
|
| 88 |
+
results["failed_uploads"] += 1
|
| 89 |
+
results["files"].append(
|
| 90 |
+
{
|
| 91 |
+
"filename": getattr(file_obj, "filename", "unknown"),
|
| 92 |
+
"status": "error",
|
| 93 |
+
"error": error_msg,
|
| 94 |
+
}
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
# Update overall status
|
| 98 |
+
if results["failed_uploads"] > 0:
|
| 99 |
+
if results["successful_uploads"] == 0:
|
| 100 |
+
results["status"] = "error"
|
| 101 |
+
results["message"] = "All uploads failed"
|
| 102 |
+
else:
|
| 103 |
+
results["status"] = "partial"
|
| 104 |
+
results[
|
| 105 |
+
"message"
|
| 106 |
+
] = f"{results['successful_uploads']} files uploaded, {results['failed_uploads']} failed"
|
| 107 |
+
else:
|
| 108 |
+
results[
|
| 109 |
+
"message"
|
| 110 |
+
] = f"Successfully uploaded {results['successful_uploads']} files"
|
| 111 |
+
|
| 112 |
+
return results
|
| 113 |
+
|
| 114 |
+
def _process_single_file(
|
| 115 |
+
self, file_obj: FileStorage, metadata: Dict[str, Any]
|
| 116 |
+
) -> Dict[str, Any]:
|
| 117 |
+
"""
|
| 118 |
+
Process a single uploaded file.
|
| 119 |
+
|
| 120 |
+
Args:
|
| 121 |
+
file_obj: File object from request
|
| 122 |
+
metadata: File metadata
|
| 123 |
+
|
| 124 |
+
Returns:
|
| 125 |
+
Processing result for the file
|
| 126 |
+
"""
|
| 127 |
+
filename = file_obj.filename or "unknown"
|
| 128 |
+
|
| 129 |
+
try:
|
| 130 |
+
# Get file size
|
| 131 |
+
file_obj.seek(0, 2) # Seek to end
|
| 132 |
+
file_size = file_obj.tell()
|
| 133 |
+
file_obj.seek(0) # Reset to beginning
|
| 134 |
+
|
| 135 |
+
# Validate file
|
| 136 |
+
validation_result = self.document_service.validate_file(filename, file_size)
|
| 137 |
+
|
| 138 |
+
if not validation_result["valid"]:
|
| 139 |
+
return {
|
| 140 |
+
"filename": filename,
|
| 141 |
+
"status": "error",
|
| 142 |
+
"error": f"Validation failed: {', '.join(validation_result['errors'])}",
|
| 143 |
+
"validation": validation_result,
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
# Save file
|
| 147 |
+
file_info = self.document_service.save_uploaded_file(file_obj, filename)
|
| 148 |
+
|
| 149 |
+
# Add metadata
|
| 150 |
+
file_info.update(metadata)
|
| 151 |
+
|
| 152 |
+
# Extract file metadata
|
| 153 |
+
file_metadata = self.document_service.get_file_metadata(
|
| 154 |
+
file_info["file_path"]
|
| 155 |
+
)
|
| 156 |
+
file_info["metadata"] = file_metadata
|
| 157 |
+
|
| 158 |
+
# Submit for processing
|
| 159 |
+
processing_options = {
|
| 160 |
+
"chunk_size": metadata.get("chunk_size", 1000),
|
| 161 |
+
"overlap": metadata.get("overlap", 200),
|
| 162 |
+
"auto_process": metadata.get("auto_process", True),
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
job_id = None
|
| 166 |
+
if processing_options.get("auto_process", True):
|
| 167 |
+
job_id = self.processing_service.submit_job(
|
| 168 |
+
file_info, processing_options
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
return {
|
| 172 |
+
"filename": filename,
|
| 173 |
+
"status": "success",
|
| 174 |
+
"file_info": file_info,
|
| 175 |
+
"job_id": job_id,
|
| 176 |
+
"validation": validation_result,
|
| 177 |
+
"message": f"File uploaded{' and submitted for processing' if job_id else ''}",
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
except Exception as e:
|
| 181 |
+
logging.error(f"Error processing file {filename}: {e}", exc_info=True)
|
| 182 |
+
return {"filename": filename, "status": "error", "error": str(e)}
|
| 183 |
+
|
| 184 |
+
def get_upload_summary(self) -> Dict[str, Any]:
|
| 185 |
+
"""
|
| 186 |
+
Get summary of upload system status.
|
| 187 |
+
|
| 188 |
+
Returns:
|
| 189 |
+
Upload system summary
|
| 190 |
+
"""
|
| 191 |
+
try:
|
| 192 |
+
upload_stats = self.document_service.get_upload_stats()
|
| 193 |
+
queue_status = self.processing_service.get_queue_status()
|
| 194 |
+
|
| 195 |
+
return {
|
| 196 |
+
"upload_stats": upload_stats,
|
| 197 |
+
"processing_queue": queue_status,
|
| 198 |
+
"service_status": {
|
| 199 |
+
"document_service": "active",
|
| 200 |
+
"processing_service": "active"
|
| 201 |
+
if queue_status["service_running"]
|
| 202 |
+
else "inactive",
|
| 203 |
+
},
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
except Exception as e:
|
| 207 |
+
logging.error(f"Error getting upload summary: {e}")
|
| 208 |
+
return {"error": str(e)}
|
| 209 |
+
|
| 210 |
+
def validate_batch_upload(
|
| 211 |
+
self, files: List[FileStorage]
|
| 212 |
+
) -> Tuple[List[FileStorage], List[str]]:
|
| 213 |
+
"""
|
| 214 |
+
Validate a batch of files before upload.
|
| 215 |
+
|
| 216 |
+
Args:
|
| 217 |
+
files: List of file objects
|
| 218 |
+
|
| 219 |
+
Returns:
|
| 220 |
+
Tuple of (valid_files, error_messages)
|
| 221 |
+
"""
|
| 222 |
+
valid_files = []
|
| 223 |
+
errors = []
|
| 224 |
+
|
| 225 |
+
if len(files) > self.document_service.max_batch_size:
|
| 226 |
+
errors.append(
|
| 227 |
+
f"Too many files: {len(files)} (max: {self.document_service.max_batch_size})"
|
| 228 |
+
)
|
| 229 |
+
return [], errors
|
| 230 |
+
|
| 231 |
+
total_size = 0
|
| 232 |
+
for file_obj in files:
|
| 233 |
+
if not file_obj or not file_obj.filename:
|
| 234 |
+
errors.append("Empty file or missing filename")
|
| 235 |
+
continue
|
| 236 |
+
|
| 237 |
+
# Get file size
|
| 238 |
+
file_obj.seek(0, 2)
|
| 239 |
+
file_size = file_obj.tell()
|
| 240 |
+
file_obj.seek(0)
|
| 241 |
+
|
| 242 |
+
total_size += file_size
|
| 243 |
+
|
| 244 |
+
# Validate individual file
|
| 245 |
+
validation = self.document_service.validate_file(
|
| 246 |
+
file_obj.filename, file_size
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
if validation["valid"]:
|
| 250 |
+
valid_files.append(file_obj)
|
| 251 |
+
else:
|
| 252 |
+
errors.extend(
|
| 253 |
+
[f"{file_obj.filename}: {error}" for error in validation["errors"]]
|
| 254 |
+
)
|
| 255 |
+
|
| 256 |
+
# Check total batch size
|
| 257 |
+
max_total_size = self.document_service.max_file_size * len(files)
|
| 258 |
+
if total_size > max_total_size:
|
| 259 |
+
errors.append(f"Total batch size too large: {total_size} bytes")
|
| 260 |
+
|
| 261 |
+
return valid_files, errors
|
templates/management.html
ADDED
|
@@ -0,0 +1,612 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>PolicyWise - Document Management</title>
|
| 7 |
+
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
| 8 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
| 9 |
+
<style>
|
| 10 |
+
/* Management Dashboard Styles */
|
| 11 |
+
.management-container {
|
| 12 |
+
max-width: 1200px;
|
| 13 |
+
margin: 0 auto;
|
| 14 |
+
padding: 2rem;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
.dashboard-header {
|
| 18 |
+
text-align: center;
|
| 19 |
+
margin-bottom: 3rem;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
.dashboard-header h1 {
|
| 23 |
+
color: var(--primary-color, #2563eb);
|
| 24 |
+
margin-bottom: 0.5rem;
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
.dashboard-grid {
|
| 28 |
+
display: grid;
|
| 29 |
+
grid-template-columns: 1fr 1fr;
|
| 30 |
+
gap: 2rem;
|
| 31 |
+
margin-bottom: 3rem;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
.card {
|
| 35 |
+
background: white;
|
| 36 |
+
border-radius: 12px;
|
| 37 |
+
padding: 2rem;
|
| 38 |
+
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
| 39 |
+
border: 1px solid #e5e7eb;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
.card h2 {
|
| 43 |
+
margin-top: 0;
|
| 44 |
+
color: #374151;
|
| 45 |
+
font-size: 1.5rem;
|
| 46 |
+
margin-bottom: 1.5rem;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
/* Upload Section */
|
| 50 |
+
.upload-area {
|
| 51 |
+
border: 2px dashed #d1d5db;
|
| 52 |
+
border-radius: 8px;
|
| 53 |
+
padding: 3rem;
|
| 54 |
+
text-align: center;
|
| 55 |
+
background: #f9fafb;
|
| 56 |
+
transition: all 0.3s ease;
|
| 57 |
+
cursor: pointer;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
.upload-area:hover {
|
| 61 |
+
border-color: #6366f1;
|
| 62 |
+
background: #f0f9ff;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
.upload-area.dragover {
|
| 66 |
+
border-color: #6366f1;
|
| 67 |
+
background: #eff6ff;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
.upload-icon {
|
| 71 |
+
font-size: 3rem;
|
| 72 |
+
margin-bottom: 1rem;
|
| 73 |
+
color: #6b7280;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
.upload-area h3 {
|
| 77 |
+
margin: 0 0 0.5rem 0;
|
| 78 |
+
color: #374151;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
.upload-area p {
|
| 82 |
+
margin: 0;
|
| 83 |
+
color: #6b7280;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
.file-input {
|
| 87 |
+
display: none;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
.upload-btn {
|
| 91 |
+
background: #6366f1;
|
| 92 |
+
color: white;
|
| 93 |
+
border: none;
|
| 94 |
+
padding: 0.75rem 1.5rem;
|
| 95 |
+
border-radius: 8px;
|
| 96 |
+
cursor: pointer;
|
| 97 |
+
font-weight: 500;
|
| 98 |
+
margin-top: 1rem;
|
| 99 |
+
transition: background 0.2s;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
.upload-btn:hover {
|
| 103 |
+
background: #5856eb;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
.upload-btn:disabled {
|
| 107 |
+
background: #9ca3af;
|
| 108 |
+
cursor: not-allowed;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
/* Progress Section */
|
| 112 |
+
.progress-section {
|
| 113 |
+
margin-top: 2rem;
|
| 114 |
+
display: none;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
.progress-item {
|
| 118 |
+
display: flex;
|
| 119 |
+
justify-content: space-between;
|
| 120 |
+
align-items: center;
|
| 121 |
+
padding: 0.75rem;
|
| 122 |
+
background: #f3f4f6;
|
| 123 |
+
border-radius: 6px;
|
| 124 |
+
margin-bottom: 0.5rem;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.progress-bar {
|
| 128 |
+
width: 100px;
|
| 129 |
+
height: 8px;
|
| 130 |
+
background: #e5e7eb;
|
| 131 |
+
border-radius: 4px;
|
| 132 |
+
overflow: hidden;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
.progress-fill {
|
| 136 |
+
height: 100%;
|
| 137 |
+
background: #10b981;
|
| 138 |
+
border-radius: 4px;
|
| 139 |
+
transition: width 0.3s ease;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
/* Status Section */
|
| 143 |
+
.status-grid {
|
| 144 |
+
display: grid;
|
| 145 |
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
| 146 |
+
gap: 1rem;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
.status-card {
|
| 150 |
+
background: #f8fafc;
|
| 151 |
+
border-radius: 8px;
|
| 152 |
+
padding: 1.5rem;
|
| 153 |
+
text-align: center;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
.status-value {
|
| 157 |
+
display: block;
|
| 158 |
+
font-size: 2rem;
|
| 159 |
+
font-weight: 700;
|
| 160 |
+
color: #1f2937;
|
| 161 |
+
margin-bottom: 0.5rem;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
.status-label {
|
| 165 |
+
color: #6b7280;
|
| 166 |
+
font-size: 0.875rem;
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
/* Jobs List */
|
| 170 |
+
.jobs-list {
|
| 171 |
+
max-height: 400px;
|
| 172 |
+
overflow-y: auto;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
.job-item {
|
| 176 |
+
display: flex;
|
| 177 |
+
justify-content: space-between;
|
| 178 |
+
align-items: center;
|
| 179 |
+
padding: 1rem;
|
| 180 |
+
border-bottom: 1px solid #e5e7eb;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
.job-item:last-child {
|
| 184 |
+
border-bottom: none;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
.job-info {
|
| 188 |
+
flex: 1;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
.job-name {
|
| 192 |
+
font-weight: 500;
|
| 193 |
+
color: #374151;
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
.job-status {
|
| 197 |
+
font-size: 0.875rem;
|
| 198 |
+
color: #6b7280;
|
| 199 |
+
margin-top: 0.25rem;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
.status-badge {
|
| 203 |
+
padding: 0.25rem 0.75rem;
|
| 204 |
+
border-radius: 9999px;
|
| 205 |
+
font-size: 0.75rem;
|
| 206 |
+
font-weight: 500;
|
| 207 |
+
text-transform: uppercase;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
.status-completed {
|
| 211 |
+
background: #d1fae5;
|
| 212 |
+
color: #065f46;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
.status-processing {
|
| 216 |
+
background: #dbeafe;
|
| 217 |
+
color: #1e40af;
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
.status-failed {
|
| 221 |
+
background: #fee2e2;
|
| 222 |
+
color: #991b1b;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
.status-pending {
|
| 226 |
+
background: #fef3c7;
|
| 227 |
+
color: #92400e;
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
/* Navigation */
|
| 231 |
+
.nav-link {
|
| 232 |
+
display: inline-block;
|
| 233 |
+
margin-bottom: 2rem;
|
| 234 |
+
color: #6366f1;
|
| 235 |
+
text-decoration: none;
|
| 236 |
+
font-weight: 500;
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
.nav-link:hover {
|
| 240 |
+
text-decoration: underline;
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
/* Responsive */
|
| 244 |
+
@media (max-width: 768px) {
|
| 245 |
+
.dashboard-grid {
|
| 246 |
+
grid-template-columns: 1fr;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
.management-container {
|
| 250 |
+
padding: 1rem;
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
.upload-area {
|
| 254 |
+
padding: 2rem;
|
| 255 |
+
}
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
/* Notification */
|
| 259 |
+
.notification {
|
| 260 |
+
position: fixed;
|
| 261 |
+
top: 20px;
|
| 262 |
+
right: 20px;
|
| 263 |
+
padding: 1rem 1.5rem;
|
| 264 |
+
border-radius: 8px;
|
| 265 |
+
color: white;
|
| 266 |
+
font-weight: 500;
|
| 267 |
+
z-index: 1000;
|
| 268 |
+
transform: translateX(100%);
|
| 269 |
+
transition: transform 0.3s ease;
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
.notification.show {
|
| 273 |
+
transform: translateX(0);
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
.notification.success {
|
| 277 |
+
background: #10b981;
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
.notification.error {
|
| 281 |
+
background: #ef4444;
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
.notification.info {
|
| 285 |
+
background: #6366f1;
|
| 286 |
+
}
|
| 287 |
+
</style>
|
| 288 |
+
</head>
|
| 289 |
+
<body>
|
| 290 |
+
<div class="management-container">
|
| 291 |
+
<a href="/" class="nav-link">← Back to Chat</a>
|
| 292 |
+
|
| 293 |
+
<header class="dashboard-header">
|
| 294 |
+
<h1>Document Management</h1>
|
| 295 |
+
<p>Upload and manage documents for the PolicyWise knowledge base</p>
|
| 296 |
+
</header>
|
| 297 |
+
|
| 298 |
+
<div class="dashboard-grid">
|
| 299 |
+
<!-- Upload Section -->
|
| 300 |
+
<div class="card">
|
| 301 |
+
<h2>Upload Documents</h2>
|
| 302 |
+
<div class="upload-area" id="uploadArea">
|
| 303 |
+
<div class="upload-icon">📄</div>
|
| 304 |
+
<h3>Drag and drop files here</h3>
|
| 305 |
+
<p>or click to select files</p>
|
| 306 |
+
<p style="font-size: 0.75rem; margin-top: 1rem; color: #9ca3af;">
|
| 307 |
+
Supported: PDF, Word, Markdown, Text files (max 50MB each)
|
| 308 |
+
</p>
|
| 309 |
+
</div>
|
| 310 |
+
<input type="file" id="fileInput" class="file-input" multiple accept=".pdf,.doc,.docx,.txt,.md">
|
| 311 |
+
<button id="uploadBtn" class="upload-btn" disabled>Select Files to Upload</button>
|
| 312 |
+
|
| 313 |
+
<div class="progress-section" id="progressSection">
|
| 314 |
+
<h3>Upload Progress</h3>
|
| 315 |
+
<div id="progressList"></div>
|
| 316 |
+
</div>
|
| 317 |
+
</div>
|
| 318 |
+
|
| 319 |
+
<!-- System Status -->
|
| 320 |
+
<div class="card">
|
| 321 |
+
<h2>System Status</h2>
|
| 322 |
+
<div class="status-grid" id="statusGrid">
|
| 323 |
+
<div class="status-card">
|
| 324 |
+
<span class="status-value" id="totalFiles">-</span>
|
| 325 |
+
<span class="status-label">Total Files</span>
|
| 326 |
+
</div>
|
| 327 |
+
<div class="status-card">
|
| 328 |
+
<span class="status-value" id="queueSize">-</span>
|
| 329 |
+
<span class="status-label">Queue Size</span>
|
| 330 |
+
</div>
|
| 331 |
+
<div class="status-card">
|
| 332 |
+
<span class="status-value" id="activeJobs">-</span>
|
| 333 |
+
<span class="status-label">Processing</span>
|
| 334 |
+
</div>
|
| 335 |
+
<div class="status-card">
|
| 336 |
+
<span class="status-value" id="completedJobs">-</span>
|
| 337 |
+
<span class="status-label">Completed</span>
|
| 338 |
+
</div>
|
| 339 |
+
</div>
|
| 340 |
+
</div>
|
| 341 |
+
</div>
|
| 342 |
+
|
| 343 |
+
<!-- Processing Jobs -->
|
| 344 |
+
<div class="card">
|
| 345 |
+
<h2>Recent Processing Jobs</h2>
|
| 346 |
+
<div class="jobs-list" id="jobsList">
|
| 347 |
+
<div style="text-align: center; color: #6b7280; padding: 2rem;">
|
| 348 |
+
Loading jobs...
|
| 349 |
+
</div>
|
| 350 |
+
</div>
|
| 351 |
+
</div>
|
| 352 |
+
</div>
|
| 353 |
+
|
| 354 |
+
<script>
|
| 355 |
+
class DocumentManager {
|
| 356 |
+
constructor() {
|
| 357 |
+
this.apiBase = '/api/documents';
|
| 358 |
+
this.uploadQueue = [];
|
| 359 |
+
this.init();
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
init() {
|
| 363 |
+
this.setupUploadHandlers();
|
| 364 |
+
this.loadStatus();
|
| 365 |
+
this.loadJobs();
|
| 366 |
+
|
| 367 |
+
// Refresh data every 5 seconds
|
| 368 |
+
setInterval(() => {
|
| 369 |
+
this.loadStatus();
|
| 370 |
+
this.loadJobs();
|
| 371 |
+
}, 5000);
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
setupUploadHandlers() {
|
| 375 |
+
const uploadArea = document.getElementById('uploadArea');
|
| 376 |
+
const fileInput = document.getElementById('fileInput');
|
| 377 |
+
const uploadBtn = document.getElementById('uploadBtn');
|
| 378 |
+
|
| 379 |
+
// Drag and drop
|
| 380 |
+
uploadArea.addEventListener('dragover', (e) => {
|
| 381 |
+
e.preventDefault();
|
| 382 |
+
uploadArea.classList.add('dragover');
|
| 383 |
+
});
|
| 384 |
+
|
| 385 |
+
uploadArea.addEventListener('dragleave', () => {
|
| 386 |
+
uploadArea.classList.remove('dragover');
|
| 387 |
+
});
|
| 388 |
+
|
| 389 |
+
uploadArea.addEventListener('drop', (e) => {
|
| 390 |
+
e.preventDefault();
|
| 391 |
+
uploadArea.classList.remove('dragover');
|
| 392 |
+
this.handleFiles(e.dataTransfer.files);
|
| 393 |
+
});
|
| 394 |
+
|
| 395 |
+
uploadArea.addEventListener('click', () => {
|
| 396 |
+
fileInput.click();
|
| 397 |
+
});
|
| 398 |
+
|
| 399 |
+
fileInput.addEventListener('change', (e) => {
|
| 400 |
+
this.handleFiles(e.target.files);
|
| 401 |
+
});
|
| 402 |
+
|
| 403 |
+
uploadBtn.addEventListener('click', () => {
|
| 404 |
+
this.uploadFiles();
|
| 405 |
+
});
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
handleFiles(files) {
|
| 409 |
+
this.uploadQueue = Array.from(files);
|
| 410 |
+
const uploadBtn = document.getElementById('uploadBtn');
|
| 411 |
+
|
| 412 |
+
if (this.uploadQueue.length > 0) {
|
| 413 |
+
uploadBtn.disabled = false;
|
| 414 |
+
uploadBtn.textContent = `Upload ${this.uploadQueue.length} files`;
|
| 415 |
+
} else {
|
| 416 |
+
uploadBtn.disabled = true;
|
| 417 |
+
uploadBtn.textContent = 'Select Files to Upload';
|
| 418 |
+
}
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
async uploadFiles() {
|
| 422 |
+
if (this.uploadQueue.length === 0) return;
|
| 423 |
+
|
| 424 |
+
const progressSection = document.getElementById('progressSection');
|
| 425 |
+
const progressList = document.getElementById('progressList');
|
| 426 |
+
const uploadBtn = document.getElementById('uploadBtn');
|
| 427 |
+
|
| 428 |
+
progressSection.style.display = 'block';
|
| 429 |
+
progressList.innerHTML = '';
|
| 430 |
+
uploadBtn.disabled = true;
|
| 431 |
+
uploadBtn.textContent = 'Uploading...';
|
| 432 |
+
|
| 433 |
+
for (let i = 0; i < this.uploadQueue.length; i++) {
|
| 434 |
+
const file = this.uploadQueue[i];
|
| 435 |
+
const progressItem = this.createProgressItem(file, i);
|
| 436 |
+
progressList.appendChild(progressItem);
|
| 437 |
+
|
| 438 |
+
try {
|
| 439 |
+
await this.uploadSingleFile(file, i);
|
| 440 |
+
} catch (error) {
|
| 441 |
+
console.error('Upload failed:', error);
|
| 442 |
+
this.updateProgress(i, 'failed', error.message);
|
| 443 |
+
}
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
this.showNotification('Upload completed', 'success');
|
| 447 |
+
uploadBtn.disabled = false;
|
| 448 |
+
uploadBtn.textContent = 'Select Files to Upload';
|
| 449 |
+
this.uploadQueue = [];
|
| 450 |
+
|
| 451 |
+
// Refresh status after upload
|
| 452 |
+
setTimeout(() => {
|
| 453 |
+
this.loadStatus();
|
| 454 |
+
this.loadJobs();
|
| 455 |
+
}, 1000);
|
| 456 |
+
}
|
| 457 |
+
|
| 458 |
+
createProgressItem(file, index) {
|
| 459 |
+
const item = document.createElement('div');
|
| 460 |
+
item.className = 'progress-item';
|
| 461 |
+
item.innerHTML = `
|
| 462 |
+
<div class="job-info">
|
| 463 |
+
<div class="job-name">${file.name}</div>
|
| 464 |
+
<div class="job-status" id="status-${index}">Preparing...</div>
|
| 465 |
+
</div>
|
| 466 |
+
<div class="progress-bar">
|
| 467 |
+
<div class="progress-fill" id="progress-${index}" style="width: 0%"></div>
|
| 468 |
+
</div>
|
| 469 |
+
`;
|
| 470 |
+
return item;
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
async uploadSingleFile(file, index) {
|
| 474 |
+
const formData = new FormData();
|
| 475 |
+
formData.append('files', file);
|
| 476 |
+
formData.append('auto_process', 'true');
|
| 477 |
+
|
| 478 |
+
this.updateProgress(index, 'uploading', 'Uploading...');
|
| 479 |
+
|
| 480 |
+
const response = await fetch(`${this.apiBase}/upload`, {
|
| 481 |
+
method: 'POST',
|
| 482 |
+
body: formData
|
| 483 |
+
});
|
| 484 |
+
|
| 485 |
+
if (!response.ok) {
|
| 486 |
+
throw new Error(`Upload failed: ${response.statusText}`);
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
const result = await response.json();
|
| 490 |
+
|
| 491 |
+
if (result.status === 'success') {
|
| 492 |
+
this.updateProgress(index, 'completed', 'Upload completed');
|
| 493 |
+
} else {
|
| 494 |
+
throw new Error(result.message || 'Upload failed');
|
| 495 |
+
}
|
| 496 |
+
}
|
| 497 |
+
|
| 498 |
+
updateProgress(index, status, message) {
|
| 499 |
+
const statusEl = document.getElementById(`status-${index}`);
|
| 500 |
+
const progressEl = document.getElementById(`progress-${index}`);
|
| 501 |
+
|
| 502 |
+
if (statusEl) statusEl.textContent = message;
|
| 503 |
+
|
| 504 |
+
if (progressEl) {
|
| 505 |
+
switch (status) {
|
| 506 |
+
case 'uploading':
|
| 507 |
+
progressEl.style.width = '50%';
|
| 508 |
+
break;
|
| 509 |
+
case 'completed':
|
| 510 |
+
progressEl.style.width = '100%';
|
| 511 |
+
progressEl.style.background = '#10b981';
|
| 512 |
+
break;
|
| 513 |
+
case 'failed':
|
| 514 |
+
progressEl.style.width = '100%';
|
| 515 |
+
progressEl.style.background = '#ef4444';
|
| 516 |
+
break;
|
| 517 |
+
}
|
| 518 |
+
}
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
async loadStatus() {
|
| 522 |
+
try {
|
| 523 |
+
const response = await fetch(`${this.apiBase}/stats`);
|
| 524 |
+
const data = await response.json();
|
| 525 |
+
|
| 526 |
+
if (data.status === 'success') {
|
| 527 |
+
this.updateStatusDisplay(data.stats);
|
| 528 |
+
}
|
| 529 |
+
} catch (error) {
|
| 530 |
+
console.error('Failed to load status:', error);
|
| 531 |
+
}
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
+
updateStatusDisplay(stats) {
|
| 535 |
+
const elements = {
|
| 536 |
+
totalFiles: document.getElementById('totalFiles'),
|
| 537 |
+
queueSize: document.getElementById('queueSize'),
|
| 538 |
+
activeJobs: document.getElementById('activeJobs'),
|
| 539 |
+
completedJobs: document.getElementById('completedJobs')
|
| 540 |
+
};
|
| 541 |
+
|
| 542 |
+
if (stats.upload_stats) {
|
| 543 |
+
elements.totalFiles.textContent = stats.upload_stats.total_files || 0;
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
if (stats.processing_queue) {
|
| 547 |
+
elements.queueSize.textContent = stats.processing_queue.queue_size || 0;
|
| 548 |
+
elements.activeJobs.textContent = stats.processing_queue.active_jobs || 0;
|
| 549 |
+
elements.completedJobs.textContent = stats.processing_queue.completed_jobs || 0;
|
| 550 |
+
}
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
async loadJobs() {
|
| 554 |
+
try {
|
| 555 |
+
const response = await fetch(`${this.apiBase}/jobs`);
|
| 556 |
+
const data = await response.json();
|
| 557 |
+
|
| 558 |
+
if (data.status === 'success') {
|
| 559 |
+
this.updateJobsDisplay(data.jobs);
|
| 560 |
+
}
|
| 561 |
+
} catch (error) {
|
| 562 |
+
console.error('Failed to load jobs:', error);
|
| 563 |
+
}
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
updateJobsDisplay(jobs) {
|
| 567 |
+
const jobsList = document.getElementById('jobsList');
|
| 568 |
+
|
| 569 |
+
if (jobs.length === 0) {
|
| 570 |
+
jobsList.innerHTML = '<div style="text-align: center; color: #6b7280; padding: 2rem;">No processing jobs found</div>';
|
| 571 |
+
return;
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
jobsList.innerHTML = jobs.slice(0, 10).map(job => `
|
| 575 |
+
<div class="job-item">
|
| 576 |
+
<div class="job-info">
|
| 577 |
+
<div class="job-name">${job.file_info?.original_name || 'Unknown'}</div>
|
| 578 |
+
<div class="job-status">
|
| 579 |
+
Started: ${job.started_at ? new Date(job.started_at).toLocaleString() : 'Not started'}
|
| 580 |
+
${job.error_message ? `• Error: ${job.error_message}` : ''}
|
| 581 |
+
</div>
|
| 582 |
+
</div>
|
| 583 |
+
<span class="status-badge status-${job.status}">
|
| 584 |
+
${job.status}
|
| 585 |
+
</span>
|
| 586 |
+
</div>
|
| 587 |
+
`).join('');
|
| 588 |
+
}
|
| 589 |
+
|
| 590 |
+
showNotification(message, type = 'info') {
|
| 591 |
+
const notification = document.createElement('div');
|
| 592 |
+
notification.className = `notification ${type}`;
|
| 593 |
+
notification.textContent = message;
|
| 594 |
+
|
| 595 |
+
document.body.appendChild(notification);
|
| 596 |
+
|
| 597 |
+
setTimeout(() => notification.classList.add('show'), 100);
|
| 598 |
+
|
| 599 |
+
setTimeout(() => {
|
| 600 |
+
notification.classList.remove('show');
|
| 601 |
+
setTimeout(() => document.body.removeChild(notification), 300);
|
| 602 |
+
}, 3000);
|
| 603 |
+
}
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
// Initialize when page loads
|
| 607 |
+
document.addEventListener('DOMContentLoaded', () => {
|
| 608 |
+
new DocumentManager();
|
| 609 |
+
});
|
| 610 |
+
</script>
|
| 611 |
+
</body>
|
| 612 |
+
</html>
|