From 72c50b2c92e4aaef7e66f3a685c2012f46604614 Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Mon, 23 Mar 2026 13:24:46 +0000 Subject: [PATCH] =?UTF-8?q?Initial=20commit=20=E2=80=94=20AC=20Tool=20unif?= =?UTF-8?q?ied=20application?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merges ac-helper (PHP Activation Calendar) and brief-extractor (Python AI) into a single Docker app with React/TypeScript frontend. Features: - Brief upload → AI extraction → review → Activation Calendar import - Handsontable v17 spreadsheet with dependent dropdowns (148 categories) - AI natural language commands via Gemini (YOLO mode, voice input) - Azure AD MSAL SPA PKCE authentication, user roles (user/admin) - CSV Activation Calendar export - Real-time WebSocket job progress - Admin: user management, dropdown Excel upload - Multi-stage Dockerfile, docker-compose, nginx proxy instructions Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 34 + Dockerfile | 40 + backend/.gitignore | 14 + backend/core/__init__.py | 0 backend/core/config.py | 148 + backend/core/consolidation_processor.py | 353 ++ backend/core/llm_service/__init__.py | 20 + .../core/llm_service/anthropic_provider.py | 375 ++ backend/core/llm_service/base_provider.py | 116 + backend/core/llm_service/google_provider.py | 256 ++ backend/core/llm_service/openai_provider.py | 309 ++ backend/core/llm_service/provider_manager.py | 293 ++ backend/core/process_brief_enhanced.py | 1219 +++++ backend/data/outputs/.gitkeep | 0 backend/data/sheets/.gitkeep | 0 backend/data/uploads/.gitkeep | 0 backend/hypercorn.toml | 25 + backend/prompts/README.md | 56 + backend/prompts/ac_command.txt | 79 + .../backup_old/consolidation_analysis.txt | 124 + .../backup_old/multi_perspective_analysis.txt | 162 + backend/prompts/consolidation_analysis.txt | 130 + .../prompts/multi_perspective_analysis.txt | 114 + backend/prompts/system_multi_perspective.txt | 1 + backend/prompts/system_validation.txt | 1 + backend/prompts/universal_schema.json | 93 + backend/prompts/validation_analysis.txt | 95 + backend/requirements.txt | 44 + backend/run_server.py | 123 + backend/server/__init__.py | 0 backend/server/api/__init__.py | 0 backend/server/api/admin.py | 126 + backend/server/api/ai_command.py | 187 + backend/server/api/auth.py | 83 + backend/server/api/config.py | 273 ++ backend/server/api/dropdowns.py | 176 + backend/server/api/export.py | 62 + backend/server/api/jobs.py | 615 +++ backend/server/api/sheets.py | 119 + backend/server/app.py | 213 + backend/server/auth/__init__.py | 0 backend/server/auth/middleware.py | 123 + backend/server/auth/msal_auth.py | 91 + backend/server/auth/user_store.py | 96 + backend/server/config_runtime.py | 97 + backend/server/jobs/__init__.py | 18 + backend/server/jobs/manager.py | 338 ++ backend/server/jobs/models.py | 270 ++ backend/server/jobs/storage.py | 231 + backend/server/runners/__init__.py | 16 + backend/server/runners/enhanced_analyzer.py | 368 ++ backend/server/runners/job_runner.py | 251 ++ backend/server/runners/progress.py | 301 ++ backend/server/sheets/__init__.py | 0 backend/server/sheets/manager.py | 157 + backend/server/sheets/models.py | 73 + backend/server/ws/__init__.py | 13 + backend/server/ws/manager.py | 300 ++ docker-compose.yml | 60 + frontend/.gitignore | 24 + frontend/README.md | 73 + frontend/eslint.config.js | 23 + frontend/index.html | 13 + frontend/package-lock.json | 3925 +++++++++++++++++ frontend/package.json | 44 + frontend/public/favicon.svg | 1 + frontend/public/icons.svg | 24 + frontend/src/App.css | 184 + frontend/src/App.tsx | 89 + frontend/src/api/admin.ts | 20 + frontend/src/api/ai.ts | 14 + frontend/src/api/client.ts | 18 + frontend/src/api/dropdowns.ts | 5 + frontend/src/api/jobs.ts | 25 + frontend/src/api/sheets.ts | 30 + frontend/src/assets/hero.png | Bin 0 -> 44919 bytes frontend/src/assets/react.svg | 1 + frontend/src/assets/vite.svg | 1 + .../src/components/brief/FileDropzone.tsx | 52 + .../src/components/brief/JobProgressCard.tsx | 103 + frontend/src/components/layout/AppShell.tsx | 25 + frontend/src/components/layout/Sidebar.tsx | 163 + frontend/src/components/layout/TopBar.tsx | 61 + .../src/components/sheet/AIActivityLog.tsx | 45 + .../src/components/sheet/AIQuestionModal.tsx | 46 + frontend/src/components/sheet/CommandBar.tsx | 107 + frontend/src/hooks/useSpeechRecognition.ts | 48 + frontend/src/hooks/useWebSocket.ts | 47 + frontend/src/index.css | 98 + frontend/src/main.tsx | 25 + frontend/src/pages/BriefReviewPage.tsx | 175 + frontend/src/pages/BriefUploadPage.tsx | 76 + frontend/src/pages/DashboardPage.tsx | 102 + frontend/src/pages/LoginPage.tsx | 49 + frontend/src/pages/SheetPage.tsx | 217 + .../src/pages/admin/AdminDropdownsPage.tsx | 163 + frontend/src/pages/admin/AdminUsersPage.tsx | 104 + frontend/src/stores/useAuthStore.ts | 37 + frontend/src/stores/useDropdownStore.ts | 30 + frontend/src/stores/useJobStore.ts | 45 + frontend/src/stores/useSheetStore.ts | 101 + frontend/src/types/index.ts | 92 + frontend/tsconfig.app.json | 28 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 26 + frontend/vite.config.ts | 28 + run_dev.sh | 52 + 107 files changed, 15547 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 backend/.gitignore create mode 100644 backend/core/__init__.py create mode 100755 backend/core/config.py create mode 100755 backend/core/consolidation_processor.py create mode 100755 backend/core/llm_service/__init__.py create mode 100755 backend/core/llm_service/anthropic_provider.py create mode 100755 backend/core/llm_service/base_provider.py create mode 100755 backend/core/llm_service/google_provider.py create mode 100755 backend/core/llm_service/openai_provider.py create mode 100755 backend/core/llm_service/provider_manager.py create mode 100755 backend/core/process_brief_enhanced.py create mode 100644 backend/data/outputs/.gitkeep create mode 100644 backend/data/sheets/.gitkeep create mode 100644 backend/data/uploads/.gitkeep create mode 100755 backend/hypercorn.toml create mode 100755 backend/prompts/README.md create mode 100644 backend/prompts/ac_command.txt create mode 100755 backend/prompts/backup_old/consolidation_analysis.txt create mode 100755 backend/prompts/backup_old/multi_perspective_analysis.txt create mode 100755 backend/prompts/consolidation_analysis.txt create mode 100755 backend/prompts/multi_perspective_analysis.txt create mode 100755 backend/prompts/system_multi_perspective.txt create mode 100755 backend/prompts/system_validation.txt create mode 100755 backend/prompts/universal_schema.json create mode 100755 backend/prompts/validation_analysis.txt create mode 100755 backend/requirements.txt create mode 100755 backend/run_server.py create mode 100644 backend/server/__init__.py create mode 100644 backend/server/api/__init__.py create mode 100644 backend/server/api/admin.py create mode 100644 backend/server/api/ai_command.py create mode 100644 backend/server/api/auth.py create mode 100755 backend/server/api/config.py create mode 100644 backend/server/api/dropdowns.py create mode 100644 backend/server/api/export.py create mode 100755 backend/server/api/jobs.py create mode 100644 backend/server/api/sheets.py create mode 100644 backend/server/app.py create mode 100644 backend/server/auth/__init__.py create mode 100644 backend/server/auth/middleware.py create mode 100644 backend/server/auth/msal_auth.py create mode 100644 backend/server/auth/user_store.py create mode 100755 backend/server/config_runtime.py create mode 100755 backend/server/jobs/__init__.py create mode 100755 backend/server/jobs/manager.py create mode 100755 backend/server/jobs/models.py create mode 100755 backend/server/jobs/storage.py create mode 100755 backend/server/runners/__init__.py create mode 100755 backend/server/runners/enhanced_analyzer.py create mode 100755 backend/server/runners/job_runner.py create mode 100755 backend/server/runners/progress.py create mode 100644 backend/server/sheets/__init__.py create mode 100644 backend/server/sheets/manager.py create mode 100644 backend/server/sheets/models.py create mode 100755 backend/server/ws/__init__.py create mode 100755 backend/server/ws/manager.py create mode 100644 docker-compose.yml create mode 100644 frontend/.gitignore create mode 100644 frontend/README.md create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/App.css create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/admin.ts create mode 100644 frontend/src/api/ai.ts create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/api/dropdowns.ts create mode 100644 frontend/src/api/jobs.ts create mode 100644 frontend/src/api/sheets.ts create mode 100644 frontend/src/assets/hero.png create mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/assets/vite.svg create mode 100644 frontend/src/components/brief/FileDropzone.tsx create mode 100644 frontend/src/components/brief/JobProgressCard.tsx create mode 100644 frontend/src/components/layout/AppShell.tsx create mode 100644 frontend/src/components/layout/Sidebar.tsx create mode 100644 frontend/src/components/layout/TopBar.tsx create mode 100644 frontend/src/components/sheet/AIActivityLog.tsx create mode 100644 frontend/src/components/sheet/AIQuestionModal.tsx create mode 100644 frontend/src/components/sheet/CommandBar.tsx create mode 100644 frontend/src/hooks/useSpeechRecognition.ts create mode 100644 frontend/src/hooks/useWebSocket.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/BriefReviewPage.tsx create mode 100644 frontend/src/pages/BriefUploadPage.tsx create mode 100644 frontend/src/pages/DashboardPage.tsx create mode 100644 frontend/src/pages/LoginPage.tsx create mode 100644 frontend/src/pages/SheetPage.tsx create mode 100644 frontend/src/pages/admin/AdminDropdownsPage.tsx create mode 100644 frontend/src/pages/admin/AdminUsersPage.tsx create mode 100644 frontend/src/stores/useAuthStore.ts create mode 100644 frontend/src/stores/useDropdownStore.ts create mode 100644 frontend/src/stores/useJobStore.ts create mode 100644 frontend/src/stores/useSheetStore.ts create mode 100644 frontend/src/types/index.ts create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100755 run_dev.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d0316c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Python +__pycache__/ +*.py[cod] +*.so +.env +.env.* +venv/ +.venv/ +*.egg-info/ +dist/ +build/ + +# Node / Frontend +node_modules/ +frontend/dist/ +frontend/.vite/ + +# Data (user data, uploads — never commit) +data/uploads/ +data/outputs/ +data/sheets/ +data/*.json + +# Logs +*.log + +# OS +.DS_Store +Thumbs.db + +# IDE +.idea/ +.vscode/ +*.swp diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7fa4ce2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +# ── Stage 1: Build React frontend ──────────────────────────────────────────── +FROM node:22-alpine AS frontend-builder + +WORKDIR /app/frontend +COPY frontend/package*.json ./ +RUN npm ci + +COPY frontend/ ./ +RUN npm run build + +# ── Stage 2: Python runtime ─────────────────────────────────────────────────── +FROM python:3.11-slim + +# System deps for document processing +RUN apt-get update && apt-get install -y --no-install-recommends \ + libmagic1 \ + libreoffice-core \ + libreoffice-writer \ + libreoffice-impress \ + poppler-utils \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Install Python dependencies +COPY backend/requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +# Copy backend source +COPY backend/ ./ + +# Copy built frontend into static directory +COPY --from=frontend-builder /app/frontend/dist ./frontend/dist + +# Create data directory (will be mounted as volume in production) +RUN mkdir -p data/uploads data/outputs data/sheets + +EXPOSE 8000 + +CMD ["python", "-m", "hypercorn", "server.app:create_app()", "--bind", "0.0.0.0:8000", "--worker-class", "asyncio"] diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..c69fac1 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,14 @@ +.venv/ +__pycache__/ +*.pyc +*.pyo +.env + +# Data files — never commit user data +data/uploads/* +data/outputs/* +data/sheets/* +data/*.json +!data/uploads/.gitkeep +!data/outputs/.gitkeep +!data/sheets/.gitkeep diff --git a/backend/core/__init__.py b/backend/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/core/config.py b/backend/core/config.py new file mode 100755 index 0000000..161a418 --- /dev/null +++ b/backend/core/config.py @@ -0,0 +1,148 @@ +""" +Configuration management for Enhanced Brief Processing System +Loads environment variables and provides configuration validation +""" + +import os +from typing import List, Dict, Any, Optional +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +class Config: + """Centralized configuration management""" + + # API Keys + OPENAI_API_KEY: str = os.getenv('OPENAI_API_KEY', '') + ANTHROPIC_API_KEY: str = os.getenv('ANTHROPIC_API_KEY', '') + GOOGLE_API_KEY: str = os.getenv('GOOGLE_API_KEY', '') + LLAMACLOUD_API_KEY: str = os.getenv('LLAMACLOUD_API_KEY', '') + + # OpenAI Configuration + OPENAI_MODEL: str = os.getenv('OPENAI_MODEL', 'gpt-5.1') + OPENAI_REASONING_EFFORT: str = os.getenv('OPENAI_REASONING_EFFORT', 'medium') + OPENAI_TIMEOUT: int = int(os.getenv('OPENAI_TIMEOUT', '3600')) + OPENAI_MAX_RETRIES: int = int(os.getenv('OPENAI_MAX_RETRIES', '2')) + + # Google Configuration + GOOGLE_MODEL: str = os.getenv('GOOGLE_MODEL', 'gemini-3.1-pro-preview') + GOOGLE_TEMPERATURE: float = float(os.getenv('GOOGLE_TEMPERATURE', '0.1')) + GOOGLE_MAX_OUTPUT_TOKENS: int = int(os.getenv('GOOGLE_MAX_OUTPUT_TOKENS', '8192')) + GOOGLE_THINKING_BUDGET: int = int(os.getenv('GOOGLE_THINKING_BUDGET', '12000')) + GOOGLE_TIMEOUT: int = int(os.getenv('GOOGLE_TIMEOUT', '300')) + + # Anthropic Configuration + ANTHROPIC_MODEL_OPUS: str = os.getenv('ANTHROPIC_MODEL_OPUS', 'claude-opus-4-5-20251101') + ANTHROPIC_MODEL_SONNET: str = os.getenv('ANTHROPIC_MODEL_SONNET', 'claude-sonnet-4-5-20250929') + ANTHROPIC_TEMPERATURE: float = float(os.getenv('ANTHROPIC_TEMPERATURE', '0.1')) + ANTHROPIC_MAX_TOKENS: int = int(os.getenv('ANTHROPIC_MAX_TOKENS', '32000')) + ANTHROPIC_THINKING_BUDGET: int = int(os.getenv('ANTHROPIC_THINKING_BUDGET', '12000')) + ANTHROPIC_TIMEOUT: int = int(os.getenv('ANTHROPIC_TIMEOUT', '300')) + + # Processing Configuration + DEFAULT_PRIMARY_MODELS: str = os.getenv('DEFAULT_PRIMARY_MODELS', 'openai-gpt51,anthropic-sonnet45,google-gemini31') + DEFAULT_CONSOLIDATION_MODEL: str = os.getenv('DEFAULT_CONSOLIDATION_MODEL', 'openai-gpt51') + MINIMUM_SUCCESS_THRESHOLD: int = int(os.getenv('MINIMUM_SUCCESS_THRESHOLD', '1')) + ENABLE_COST_ESTIMATION: bool = os.getenv('ENABLE_COST_ESTIMATION', 'true').lower() == 'true' + MAX_PROCESSING_COST_USD: float = float(os.getenv('MAX_PROCESSING_COST_USD', '10.00')) + + # Model Pricing (per 1M tokens) + PRICING = { + 'openai-gpt51': { + 'input': 1.25, + 'cached_input': 0.625, + 'output': 10.00 + }, + 'anthropic-opus45': { + 'input': 5.00, + 'output': 25.00 + }, + 'anthropic-sonnet45': { + 'input': 3.00, + 'output': 15.00 + }, + 'google-gemini31': { + 'input': 1.25, + 'output': 5.00 + } + } + + # Model mappings for CLI compatibility + MODEL_MAPPINGS = { + 'openai-gpt51': ('openai', OPENAI_MODEL), + 'anthropic-opus45': ('anthropic', ANTHROPIC_MODEL_OPUS), + 'anthropic-sonnet45': ('anthropic', ANTHROPIC_MODEL_SONNET), + 'google-gemini31': ('google', GOOGLE_MODEL) + } + + @classmethod + def validate_api_keys(cls) -> Dict[str, bool]: + """Validate that required API keys are set""" + return { + 'openai': bool(cls.OPENAI_API_KEY and cls.OPENAI_API_KEY != 'your-openai-api-key-here'), + 'anthropic': bool(cls.ANTHROPIC_API_KEY and cls.ANTHROPIC_API_KEY != 'your-anthropic-api-key-here'), + 'google': bool(cls.GOOGLE_API_KEY and cls.GOOGLE_API_KEY != 'your-google-api-key-here'), + 'llamacloud': bool(cls.LLAMACLOUD_API_KEY and cls.LLAMACLOUD_API_KEY != 'your-llamacloud-api-key-here') + } + + @classmethod + def get_provider_config(cls, provider: str) -> Dict[str, Any]: + """Get configuration for a specific provider""" + if provider == 'openai': + return { + 'api_key': cls.OPENAI_API_KEY, + 'model': cls.OPENAI_MODEL, + 'reasoning_effort': cls.OPENAI_REASONING_EFFORT, + 'timeout': cls.OPENAI_TIMEOUT, + 'max_retries': cls.OPENAI_MAX_RETRIES + } + elif provider == 'google': + return { + 'api_key': cls.GOOGLE_API_KEY, + 'model': cls.GOOGLE_MODEL, + 'temperature': cls.GOOGLE_TEMPERATURE, + 'max_output_tokens': cls.GOOGLE_MAX_OUTPUT_TOKENS, + 'thinking_budget': cls.GOOGLE_THINKING_BUDGET, + 'timeout': cls.GOOGLE_TIMEOUT + } + elif provider == 'anthropic': + return { + 'api_key': cls.ANTHROPIC_API_KEY, + 'model_opus': cls.ANTHROPIC_MODEL_OPUS, + 'model_sonnet': cls.ANTHROPIC_MODEL_SONNET, + 'temperature': cls.ANTHROPIC_TEMPERATURE, + 'max_tokens': cls.ANTHROPIC_MAX_TOKENS, + 'thinking_budget': cls.ANTHROPIC_THINKING_BUDGET, + 'timeout': cls.ANTHROPIC_TIMEOUT + } + else: + raise ValueError(f"Unknown provider: {provider}") + + @classmethod + def get_default_primary_models(cls) -> List[str]: + """Get default list of primary analysis models""" + return cls.DEFAULT_PRIMARY_MODELS.split(',') + + @classmethod + def get_model_info(cls, model_key: str) -> tuple: + """Get provider and model name for a model key""" + if model_key not in cls.MODEL_MAPPINGS: + raise ValueError(f"Unknown model key: {model_key}. Available: {list(cls.MODEL_MAPPINGS.keys())}") + return cls.MODEL_MAPPINGS[model_key] + + @classmethod + def estimate_cost(cls, model_key: str, input_tokens: int, output_tokens: int, cached_tokens: int = 0) -> float: + """Estimate processing cost for a model""" + if model_key not in cls.PRICING: + return 0.0 + + pricing = cls.PRICING[model_key] + input_cost = (input_tokens / 1_000_000) * pricing['input'] + output_cost = (output_tokens / 1_000_000) * pricing['output'] + cached_cost = (cached_tokens / 1_000_000) * pricing.get('cached_input', pricing['input']) + + return input_cost + output_cost + cached_cost + +# Global config instance +config = Config() \ No newline at end of file diff --git a/backend/core/consolidation_processor.py b/backend/core/consolidation_processor.py new file mode 100755 index 0000000..0c151db --- /dev/null +++ b/backend/core/consolidation_processor.py @@ -0,0 +1,353 @@ +""" +Consolidation processor for merging multiple LLM analysis results +""" + +import json +import logging +from typing import List, Dict, Any, Tuple +from dataclasses import dataclass +import os + +from .llm_service import ProviderManager, LLMResponse +from .config import config + +@dataclass +class ConsolidationResult: + """Result of consolidation process""" + consolidated_deliverables: List[Any] # BaseDeliverable + expanded_assets: List[Any] # MarketingAsset + consolidation_metadata: Dict[str, Any] + warnings: List[str] + +class ConsolidationProcessor: + """Processes multiple LLM analysis results into a single consolidated output""" + + def __init__(self): + self.logger = logging.getLogger(self.__class__.__name__) + self.provider_manager = ProviderManager() + + async def consolidate_results( + self, + analysis_responses: List[LLMResponse], + consolidation_model: str, + document_content: str = "" + ) -> ConsolidationResult: + """ + Consolidate multiple analysis results using the specified consolidation model + + Args: + analysis_responses: List of LLM responses from primary analysis + consolidation_model: Model key for consolidation (e.g., 'anthropic-opus45') + document_content: Optional original document content for context + + Returns: + ConsolidationResult with final consolidated deliverables + """ + self.logger.info(f"Starting consolidation with {len(analysis_responses)} model results using {consolidation_model}") + + # Log individual model deliverable counts + successful_models = [] + deliverable_counts = [] + for i, response in enumerate(analysis_responses): + if response.success: + count = self._count_deliverables_in_response(response.content) + deliverable_counts.append(count) + successful_models.append(f"{response.provider} {response.model_used}") + self.logger.info(f"Model {i+1} ({response.provider} {response.model_used}): {count} base deliverables") + + if deliverable_counts: + avg_deliverables = sum(deliverable_counts) / len(deliverable_counts) + self.logger.info(f"Average deliverables across {len(deliverable_counts)} models: {avg_deliverables:.1f}") + else: + self.logger.warning("No successful model responses to analyze") + + # Extract and format results from all models + formatted_results = self._format_model_results(analysis_responses) + + # Prepare consolidation prompt + consolidation_prompt = await self._prepare_consolidation_prompt(formatted_results) + + # Load system message for consolidation + system_message = self._load_consolidation_system_prompt() + + # Execute consolidation using specified model + try: + provider = self.provider_manager.get_provider(consolidation_model) + messages = provider.prepare_messages(system_message, consolidation_prompt) + + # Use the universal base deliverable schema for structured output + from .process_brief_enhanced import UNIVERSAL_BASE_DELIVERABLE_SCHEMA + + consolidation_response = await provider.generate_response( + messages=messages, + schema=UNIVERSAL_BASE_DELIVERABLE_SCHEMA + ) + + if not consolidation_response.success: + raise Exception(f"Consolidation failed: {consolidation_response.error}") + + # Parse the consolidated results - import here to avoid circular import + from .process_brief_enhanced import BaseDeliverable, expand_deliverables + + try: + consolidated_data = json.loads(consolidation_response.content) + + if 'assets' not in consolidated_data: + # PROBLEM DETECTED - Log everything verbosely + self.logger.error(f"[CONSOLIDATION] ========== MISSING 'assets' KEY - VERBOSE DEBUG ==========") + self.logger.error(f"[CONSOLIDATION] Model: {consolidation_model}") + self.logger.error(f"[CONSOLIDATION] Response success: {consolidation_response.success}") + self.logger.error(f"[CONSOLIDATION] Response content length: {len(consolidation_response.content)} chars") + self.logger.error(f"[CONSOLIDATION] Response content type: {type(consolidation_response.content)}") + self.logger.error(f"[CONSOLIDATION] Full raw content: {consolidation_response.content}") + self.logger.error(f"[CONSOLIDATION] Parsed data type: {type(consolidated_data)}") + self.logger.error(f"[CONSOLIDATION] Parsed data keys: {list(consolidated_data.keys()) if isinstance(consolidated_data, dict) else 'N/A'}") + self.logger.error(f"[CONSOLIDATION] Full parsed data: {consolidated_data}") + + # Save debug file + self._save_consolidation_debug(consolidation_response, consolidated_data, analysis_responses) + raise KeyError("Response missing 'assets' key") + + # SUCCESS - Just log summary + self.logger.info(f"Consolidation completed: {len(consolidated_data['assets'])} base deliverables") + + base_deliverables = [BaseDeliverable(**item) for item in consolidated_data['assets']] + + except json.JSONDecodeError as e: + self.logger.error(f"[CONSOLIDATION] ========== JSON PARSE ERROR ==========") + self.logger.error(f"[CONSOLIDATION] Parse error: {e}") + self.logger.error(f"[CONSOLIDATION] Full response content: {consolidation_response.content}") + raise + except KeyError as e: + # Already logged in detail above + raise + except Exception as e: + self.logger.error(f"[CONSOLIDATION] Error processing consolidation response: {e}") + self.logger.error(f"[CONSOLIDATION] Full response content: {consolidation_response.content}") + raise + + # Expand consolidated base deliverables into individual assets + expanded_assets, expansion_warnings = expand_deliverables(base_deliverables) + self.logger.info(f"Expansion completed: {len(expanded_assets)} individual assets") + + # Create consolidation metadata + metadata = self._create_consolidation_metadata( + analysis_responses, + consolidation_response, + base_deliverables, + expanded_assets + ) + + return ConsolidationResult( + consolidated_deliverables=base_deliverables, + expanded_assets=expanded_assets, + consolidation_metadata=metadata, + warnings=expansion_warnings + ) + + except Exception as e: + self.logger.error(f"Consolidation failed: {e}") + raise + + def _count_deliverables_in_response(self, content: str) -> int: + """Count the number of deliverables in a model's JSON response""" + try: + data = json.loads(content) + if isinstance(data, dict) and 'assets' in data: + return len(data['assets']) + return 0 + except (json.JSONDecodeError, KeyError, TypeError): + return 0 + + def _format_model_results(self, responses: List[LLMResponse]) -> str: + """Format analysis results from multiple models for consolidation prompt""" + formatted_results = [] + + for i, response in enumerate(responses): + if response.success: + model_info = f"**MODEL {i+1}: {response.provider.upper()} {response.model_used}**" + + # Try to extract JSON content + try: + # Parse the JSON to validate it + result_data = json.loads(response.content) + formatted_content = json.dumps(result_data, indent=2) + except json.JSONDecodeError: + # Fallback to raw content if not valid JSON + formatted_content = response.content + + formatted_results.append(f"{model_info}\n```json\n{formatted_content}\n```") + else: + self.logger.warning(f"Skipping failed response from {response.provider} {response.model_used}: {response.error}") + + return "\n\n".join(formatted_results) + + async def _prepare_consolidation_prompt(self, formatted_results: str) -> str: + """Prepare the consolidation prompt with model results""" + import asyncio + + def _read_template(): + """Blocking template read operation for thread pool""" + # Load consolidation prompt template - go up one level from core/ to find prompts/ + prompt_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'prompts', 'consolidation_analysis.txt') + with open(prompt_path, 'r', encoding='utf-8') as f: + return f.read() + + try: + loop = asyncio.get_running_loop() + template = await loop.run_in_executor(None, _read_template) + return template.format(models_results=formatted_results) + + except FileNotFoundError: + self.logger.error("Consolidation prompt template not found") + raise + except Exception as e: + self.logger.error(f"Error preparing consolidation prompt: {e}") + raise + + def _load_consolidation_system_prompt(self) -> str: + """Load system prompt for consolidation""" + return """You are an expert data consolidation specialist. Your task is to intelligently merge multiple LLM analysis results into the most complete and accurate dataset possible. Follow the consolidation strategy provided in the user prompt, with emphasis on completeness and thoroughness. Return only valid JSON in the specified format.""" + + def _save_consolidation_debug(self, consolidation_response, consolidated_data, analysis_responses): + """Save debug information about failed consolidation""" + try: + import tempfile + from datetime import datetime + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + debug_file = os.path.join(tempfile.gettempdir(), f"consolidation_debug_{timestamp}.json") + + debug_info = { + "timestamp": timestamp, + "consolidation_model": consolidation_response.model_used, + "consolidation_provider": consolidation_response.provider, + "raw_content": consolidation_response.content, + "parsed_data": consolidated_data, + "response_success": consolidation_response.success, + "response_error": consolidation_response.error, + "token_usage": { + "input": consolidation_response.token_usage.input_tokens, + "output": consolidation_response.token_usage.output_tokens, + "total": consolidation_response.token_usage.get_total() + }, + "primary_analysis_results": [ + { + "provider": r.provider, + "model": r.model_used, + "success": r.success, + "deliverable_count": self._count_deliverables_in_response(r.content) if r.success else 0, + "content_preview": r.content[:500] if r.success else r.error + } + for r in analysis_responses + ] + } + + with open(debug_file, 'w') as f: + json.dump(debug_info, f, indent=2) + + self.logger.error(f"[CONSOLIDATION] Debug info saved to: {debug_file}") + except Exception as e: + self.logger.error(f"[CONSOLIDATION] Failed to save debug info: {e}") + + def _create_consolidation_metadata( + self, + analysis_responses: List[LLMResponse], + consolidation_response: LLMResponse, + base_deliverables: List[Any], + expanded_assets: List[Any] + ) -> Dict[str, Any]: + """Create metadata about the consolidation process""" + + # Analyze model contributions + model_stats = {} + total_primary_tokens = 0 + total_primary_cost = 0.0 + + for response in analysis_responses: + if response.success: + model_key = f"{response.provider}_{response.model_used}" + model_stats[model_key] = { + 'tokens_used': response.token_usage.get_total(), + 'processing_time': response.processing_time, + 'success': True + } + total_primary_tokens += response.token_usage.get_total() + + # Estimate cost for this response + try: + # Find the correct model key for this response + provider_model_key = None + for key in config.MODEL_MAPPINGS.keys(): + provider_name, model_name = config.get_model_info(key) + if provider_name == response.provider and model_name == response.model_used: + provider_model_key = key + break + + if provider_model_key: + provider = self.provider_manager.get_provider(provider_model_key) + cost = provider.estimate_cost( + response.token_usage.input_tokens, + response.token_usage.output_tokens, + response.token_usage.cached_input_tokens + ) + total_primary_cost += cost + model_stats[model_key]['estimated_cost'] = cost + else: + model_stats[model_key]['estimated_cost'] = 0.0 + except: + model_stats[model_key]['estimated_cost'] = 0.0 + else: + model_key = f"{response.provider}_{response.model_used}" + model_stats[model_key] = { + 'tokens_used': 0, + 'processing_time': response.processing_time, + 'success': False, + 'error': response.error, + 'estimated_cost': 0.0 + } + + # Consolidation model stats + consolidation_cost = 0.0 + try: + # Find the correct model key for consolidation response + consolidation_model_key = None + for key in config.MODEL_MAPPINGS.keys(): + provider_name, model_name = config.get_model_info(key) + if provider_name == consolidation_response.provider and model_name == consolidation_response.model_used: + consolidation_model_key = key + break + + if consolidation_model_key: + provider = self.provider_manager.get_provider(consolidation_model_key) + consolidation_cost = provider.estimate_cost( + consolidation_response.token_usage.input_tokens, + consolidation_response.token_usage.output_tokens, + consolidation_response.token_usage.cached_input_tokens + ) + except: + pass + + return { + 'consolidation_model': consolidation_response.model_used, + 'consolidation_provider': consolidation_response.provider, + 'primary_models_used': len([r for r in analysis_responses if r.success]), + 'total_models_attempted': len(analysis_responses), + 'base_deliverables_count': len(base_deliverables), + 'final_assets_count': len(expanded_assets), + 'model_statistics': model_stats, + 'token_usage': { + 'primary_analysis_total': total_primary_tokens, + 'consolidation_tokens': consolidation_response.token_usage.get_total(), + 'grand_total': total_primary_tokens + consolidation_response.token_usage.get_total() + }, + 'cost_breakdown': { + 'primary_analysis_cost': round(total_primary_cost, 4), + 'consolidation_cost': round(consolidation_cost, 4), + 'total_cost': round(total_primary_cost + consolidation_cost, 4) + }, + 'processing_times': { + 'consolidation_time': consolidation_response.processing_time, + 'primary_models_avg_time': sum(r.processing_time for r in analysis_responses if r.success) / max(1, len([r for r in analysis_responses if r.success])) + } + } \ No newline at end of file diff --git a/backend/core/llm_service/__init__.py b/backend/core/llm_service/__init__.py new file mode 100755 index 0000000..b26030b --- /dev/null +++ b/backend/core/llm_service/__init__.py @@ -0,0 +1,20 @@ +""" +LLM Service module for Enhanced Brief Processing System +Provides abstracted access to multiple LLM providers +""" + +from .base_provider import BaseLLMProvider, LLMResponse, TokenUsage +from .openai_provider import OpenAIProvider +from .google_provider import GoogleProvider +from .anthropic_provider import AnthropicProvider +from .provider_manager import ProviderManager + +__all__ = [ + 'BaseLLMProvider', + 'LLMResponse', + 'TokenUsage', + 'OpenAIProvider', + 'GoogleProvider', + 'AnthropicProvider', + 'ProviderManager' +] \ No newline at end of file diff --git a/backend/core/llm_service/anthropic_provider.py b/backend/core/llm_service/anthropic_provider.py new file mode 100755 index 0000000..1cd8f81 --- /dev/null +++ b/backend/core/llm_service/anthropic_provider.py @@ -0,0 +1,375 @@ +""" +Anthropic provider implementation for Claude Opus 4.5 and Sonnet 4.5 +""" + +import time +import json +import logging +from typing import List, Dict, Any, Optional + +try: + from anthropic import AsyncAnthropic + anthropic = AsyncAnthropic # Keep reference for compatibility checks +except ImportError: + AsyncAnthropic = None + anthropic = None + +from .base_provider import BaseLLMProvider, LLMResponse, TokenUsage +from ..config import config + +class AnthropicProvider(BaseLLMProvider): + """Anthropic Claude provider supporting Opus and Sonnet models""" + + def __init__(self, api_key: Optional[str] = None, model_name: Optional[str] = None, **kwargs): + if AsyncAnthropic is None: + raise ImportError("anthropic package not installed. Run: pip install anthropic>=0.67.0") + + provider_config = config.get_provider_config('anthropic') + + super().__init__( + api_key=api_key or provider_config['api_key'], + model_name=model_name or self._select_model(kwargs.get('model_variant', 'sonnet'), provider_config), + **kwargs + ) + + self.temperature = kwargs.get('temperature', provider_config['temperature']) + self.max_tokens = kwargs.get('max_tokens', provider_config['max_tokens']) + self.thinking_budget = kwargs.get('thinking_budget', provider_config['thinking_budget']) + self.timeout = kwargs.get('timeout', provider_config['timeout']) + + self.client = None + self._setup_client() + + def _select_model(self, variant: str, provider_config: Dict[str, Any]) -> str: + """Select appropriate Claude model based on variant""" + if variant.lower() in ['opus', 'opus4', 'opus45']: + return provider_config['model_opus'] + elif variant.lower() in ['sonnet', 'sonnet4', 'sonnet45']: + return provider_config['model_sonnet'] + else: + # Default to Sonnet for better cost-performance ratio + return provider_config['model_sonnet'] + + def _setup_client(self): + """Initialize AsyncAnthropic client""" + try: + self.client = AsyncAnthropic( + api_key=self.api_key, + timeout=self.timeout + ) + self.logger.info(f"AsyncAnthropic client initialized - Model: {self.model_name}") + except Exception as e: + self.logger.error(f"Failed to initialize AsyncAnthropic client: {e}") + raise + + async def generate_response( + self, + messages: List[Dict[str, str]], + schema: Optional[Dict[str, Any]] = None, + **kwargs + ) -> LLMResponse: + """Generate response using Anthropic Claude""" + start_time = time.time() + + # Determine if we need two-call architecture + if self.thinking_budget > 0 and schema: + self.logger.info(f"Anthropic Two-Call Request - Model: {self.model_name} (thinking: {self.thinking_budget} budget + schema)") + return await self._two_call_with_thinking(messages, schema, start_time, **kwargs) + else: + self.logger.info(f"Anthropic Single-Call Request - Model: {self.model_name}") + return await self._single_call(messages, schema, start_time, **kwargs) + + async def _two_call_with_thinking( + self, + messages: List[Dict[str, str]], + schema: Dict[str, Any], + start_time: float, + **kwargs + ) -> LLMResponse: + """Execute two-call pattern: thinking analysis + schema formatting""" + + try: + # Prepare messages for Anthropic + system_message, user_messages = self._prepare_messages(messages) + + # === CALL A: Analysis with Thinking (No Forced Tools) === + self.logger.info(" Call A: Analysis with thinking (no forced tools)") + + # Enhance prompt with schema guidance for Call A + enhanced_messages = self._add_schema_guidance_to_messages(user_messages, schema) + + call_a_params = { + 'model': self.model_name, + 'messages': enhanced_messages, + 'max_tokens': self.max_tokens, + 'temperature': self.temperature, + 'thinking': {"type": "enabled", "budget_tokens": self.thinking_budget}, + **kwargs + } + + if system_message: + call_a_params['system'] = system_message + + # Execute Call A (no tools, no tool_choice) + analysis_response = await self.client.messages.create(**call_a_params) + + # Extract analysis text + analysis_text = self._extract_text_content(analysis_response.content) + if not analysis_text: + raise Exception("Call A produced no analysis text") + + self.logger.info(f" Call A completed: {len(analysis_text)} chars analysis") + + # === CALL B: Schema Formatting (No Thinking) === + self.logger.info(" Call B: Schema formatting (no thinking)") + + formatting_prompt = f"Convert the following analysis into the required JSON schema. Call extract_structured_data exactly once with the final result.\n\nAnalysis:\n{analysis_text}" + + call_b_params = { + 'model': self.model_name, + 'messages': [{"role": "user", "content": formatting_prompt}], + 'max_tokens': self.max_tokens, + 'temperature': self.temperature, + 'tools': [self._create_tool_from_schema(schema)], + 'tool_choice': {"type": "tool", "name": "extract_structured_data"}, + **kwargs + } + + # Execute Call B (no thinking) + format_response = await self.client.messages.create(**call_b_params) + + # Extract structured content from tool use + structured_content = self._extract_tool_response(format_response.content) + if not structured_content: + raise Exception("Call B failed to produce structured output") + + self.logger.info(f" Call B completed: Structured JSON extracted") + + # Combine token usage from both calls + combined_token_usage = TokenUsage() + if hasattr(analysis_response, 'usage'): + usage_dict_a = { + 'input_tokens': getattr(analysis_response.usage, 'input_tokens', 0), + 'output_tokens': getattr(analysis_response.usage, 'output_tokens', 0), + 'cache_read_input_tokens': getattr(analysis_response.usage, 'cache_read_input_tokens', 0) + } + combined_token_usage.add_usage(usage_dict_a) + + if hasattr(format_response, 'usage'): + usage_dict_b = { + 'input_tokens': getattr(format_response.usage, 'input_tokens', 0), + 'output_tokens': getattr(format_response.usage, 'output_tokens', 0), + 'cache_read_input_tokens': getattr(format_response.usage, 'cache_read_input_tokens', 0) + } + combined_token_usage.add_usage(usage_dict_b) + + processing_time = time.time() - start_time + + return LLMResponse( + content=structured_content, + raw_response={'call_a': analysis_response, 'call_b': format_response}, + token_usage=combined_token_usage, + model_used=self.model_name, + provider="anthropic", + success=True, + processing_time=processing_time + ) + + except Exception as e: + processing_time = time.time() - start_time + self.logger.error(f"Anthropic two-call request failed: {e}") + + return LLMResponse( + content="", + raw_response=None, + token_usage=TokenUsage(), + model_used=self.model_name, + provider="anthropic", + success=False, + error=str(e), + processing_time=processing_time + ) + + async def _single_call( + self, + messages: List[Dict[str, str]], + schema: Optional[Dict[str, Any]], + start_time: float, + **kwargs + ) -> LLMResponse: + """Execute single-call pattern: existing behavior for when thinking=0 or no schema""" + + try: + # Prepare messages for Anthropic + system_message, user_messages = self._prepare_messages(messages) + + # Configure request parameters (no thinking or minimal thinking) + request_params = { + 'model': self.model_name, + 'messages': user_messages, + 'max_tokens': self.max_tokens, + 'temperature': self.temperature, + **kwargs + } + + # Add thinking only if no schema (to avoid conflict) + if not schema and self.thinking_budget > 0: + request_params['thinking'] = {"type": "enabled", "budget_tokens": self.thinking_budget} + + if system_message: + request_params['system'] = system_message + + # Handle structured output using tools if schema provided + if schema: + request_params['tools'] = [self._create_tool_from_schema(schema)] + request_params['tool_choice'] = {"type": "tool", "name": "extract_structured_data"} + + # Generate response using async client + response = await self.client.messages.create(**request_params) + + # Extract content + if schema and response.content: + # Look for tool use in response + content = self._extract_tool_response(response.content) + else: + content = response.content[0].text if response.content else "" + + # Extract token usage + token_usage = TokenUsage() + if hasattr(response, 'usage'): + usage_dict = { + 'input_tokens': getattr(response.usage, 'input_tokens', 0), + 'output_tokens': getattr(response.usage, 'output_tokens', 0), + 'cached_input_tokens': getattr(response.usage, 'cache_read_input_tokens', 0) + } + token_usage.add_usage(usage_dict) + + processing_time = time.time() - start_time + + llm_response = LLMResponse( + content=content, + raw_response=response, + token_usage=token_usage, + model_used=self.model_name, + provider="anthropic", + success=True, + processing_time=processing_time + ) + + self.log_response(llm_response) + return llm_response + + except Exception as e: + processing_time = time.time() - start_time + self.logger.error(f"Anthropic single-call request failed: {e}") + + return LLMResponse( + content="", + raw_response=None, + token_usage=TokenUsage(), + model_used=self.model_name, + provider="anthropic", + success=False, + error=str(e), + processing_time=processing_time + ) + + def _add_schema_guidance_to_messages(self, user_messages: List[Dict[str, str]], schema: Dict[str, Any]) -> List[Dict[str, str]]: + """Add schema guidance to the last user message for Call A""" + enhanced_messages = user_messages.copy() + + # Get schema description + schema_description = schema.get('description', 'structured data') + + # Add schema guidance to last message + if enhanced_messages: + last_message = enhanced_messages[-1] + original_content = last_message['content'] + + schema_guidance = f"\n\nPlease analyze this document and provide your findings according to this schema structure: {schema_description}. Focus on extracting base deliverables with multiplier arrays as specified in the schema." + + enhanced_messages[-1] = { + 'role': last_message['role'], + 'content': original_content + schema_guidance + } + + return enhanced_messages + + def _extract_text_content(self, content: List[Any]) -> str: + """Extract text content from Anthropic response, ignoring thinking blocks""" + text_content = "" + for block in content: + if hasattr(block, 'type') and block.type == 'text': + text_content += block.text + return text_content.strip() + + def _prepare_messages(self, messages: List[Dict[str, str]]) -> tuple: + """Separate system messages from user/assistant messages for Anthropic format""" + system_message = None + user_messages = [] + + for message in messages: + if message['role'] == 'system': + system_message = message['content'] + else: + user_messages.append({ + 'role': message['role'], + 'content': message['content'] + }) + + return system_message, user_messages + + def _create_tool_from_schema(self, schema: Dict[str, Any]) -> Dict[str, Any]: + """Convert JSON schema to Anthropic tool format for structured output""" + # Extract schema definition + schema_def = schema.get('schema', schema) + + return { + "name": "extract_structured_data", + "description": schema.get('description', 'Extract structured data from the document'), + "input_schema": schema_def + } + + def _extract_tool_response(self, content: List[Any]) -> str: + """Extract structured data from tool use response""" + for block in content: + if hasattr(block, 'type') and block.type == 'tool_use': + return json.dumps(block.input) + + # Fallback to text content + text_content = "" + for block in content: + if hasattr(block, 'type') and block.type == 'text': + text_content += block.text + + return text_content + + def validate_config(self) -> bool: + """Validate Anthropic configuration""" + if not self.api_key or self.api_key == 'your-anthropic-api-key-here': + self.logger.error("Anthropic API key not configured") + return False + + if AsyncAnthropic is None: + self.logger.error("anthropic package not installed") + return False + + return True + + def estimate_cost(self, input_tokens: int, output_tokens: int, cached_tokens: int = 0) -> float: + """Estimate cost using Anthropic pricing""" + if 'opus' in self.model_name.lower(): + return config.estimate_cost('anthropic-opus45', input_tokens, output_tokens, cached_tokens) + else: + return config.estimate_cost('anthropic-sonnet45', input_tokens, output_tokens, cached_tokens) + + def get_max_tokens(self) -> int: + """Get maximum token limit for Claude models""" + return 200000 # Claude 3 context window + + def get_model_variant(self) -> str: + """Get the model variant (opus or sonnet)""" + if 'opus' in self.model_name.lower(): + return 'opus' + else: + return 'sonnet' \ No newline at end of file diff --git a/backend/core/llm_service/base_provider.py b/backend/core/llm_service/base_provider.py new file mode 100755 index 0000000..c955e7a --- /dev/null +++ b/backend/core/llm_service/base_provider.py @@ -0,0 +1,116 @@ +""" +Base provider class for LLM service abstraction +Defines common interface that all providers must implement +""" + +from abc import ABC, abstractmethod +from typing import List, Dict, Any, Optional, Union +from dataclasses import dataclass +from enum import Enum +import logging + +class ModelType(Enum): + GPT51 = "gpt-5.1" + CLAUDE_OPUS = "claude-opus-4-5" + CLAUDE_SONNET = "claude-sonnet-4-5" + GEMINI_PRO = "gemini-3.1-pro" + +@dataclass +class TokenUsage: + """Token usage tracking across different providers""" + input_tokens: int = 0 + output_tokens: int = 0 + cached_input_tokens: int = 0 + + def add_usage(self, usage_dict: Dict[str, int]): + """Add token usage from provider response""" + # Safely handle potential None values + input_tokens = usage_dict.get('input_tokens') or usage_dict.get('prompt_tokens') or 0 + output_tokens = usage_dict.get('output_tokens') or usage_dict.get('completion_tokens') or 0 + cached_tokens = usage_dict.get('cached_input_tokens') or usage_dict.get('prompt_tokens_cached') or 0 + + self.input_tokens += input_tokens + self.output_tokens += output_tokens + self.cached_input_tokens += cached_tokens + + def get_total(self) -> int: + """Get total token count""" + return self.input_tokens + self.output_tokens + self.cached_input_tokens + +@dataclass +class LLMResponse: + """Standardized response format across all providers""" + content: str + raw_response: Any + token_usage: TokenUsage + model_used: str + provider: str + success: bool = True + error: Optional[str] = None + processing_time: float = 0.0 + +class BaseLLMProvider(ABC): + """Abstract base class for all LLM providers""" + + def __init__(self, api_key: str, model_name: str, **kwargs): + self.api_key = api_key + self.model_name = model_name + self.config = kwargs + self.logger = logging.getLogger(f"{self.__class__.__name__}") + + @abstractmethod + async def generate_response( + self, + messages: List[Dict[str, str]], + schema: Optional[Dict[str, Any]] = None, + **kwargs + ) -> LLMResponse: + """ + Generate response from the LLM provider + + Args: + messages: List of message dictionaries with 'role' and 'content' + schema: Optional JSON schema for structured output + **kwargs: Provider-specific parameters + + Returns: + LLMResponse object with standardized format + """ + pass + + @abstractmethod + def validate_config(self) -> bool: + """Validate provider configuration""" + pass + + @abstractmethod + def estimate_cost(self, input_tokens: int, output_tokens: int) -> float: + """Estimate cost for token usage""" + pass + + @abstractmethod + def get_max_tokens(self) -> int: + """Get maximum token limit for this provider/model""" + pass + + def get_provider_name(self) -> str: + """Get provider name""" + return self.__class__.__name__.replace('Provider', '').lower() + + def prepare_messages(self, system_prompt: str, user_prompt: str) -> List[Dict[str, str]]: + """Prepare messages in standard format""" + return [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ] + + def log_response(self, response: LLMResponse, request_info: str = ""): + """Log response details""" + self.logger.info( + f"{self.get_provider_name().title()} Response - " + f"Model: {response.model_used}, " + f"Tokens: {response.token_usage.input_tokens} input / {response.token_usage.output_tokens} output, " + f"Time: {response.processing_time:.2f}s, " + f"Success: {response.success}" + + (f", Request: {request_info}" if request_info else "") + ) \ No newline at end of file diff --git a/backend/core/llm_service/google_provider.py b/backend/core/llm_service/google_provider.py new file mode 100755 index 0000000..cf3cdfd --- /dev/null +++ b/backend/core/llm_service/google_provider.py @@ -0,0 +1,256 @@ +""" +Google provider implementation for Gemini 2.5 Pro using the new google-genai SDK +""" + +import time +import json +import logging +from typing import List, Dict, Any, Optional + +try: + from google import genai + from google.genai.types import GenerateContentConfig, ThinkingConfig +except ImportError: + genai = None + GenerateContentConfig = None + ThinkingConfig = None + +from .base_provider import BaseLLMProvider, LLMResponse, TokenUsage +from ..config import config + +class GoogleProvider(BaseLLMProvider): + """Google Gemini 2.5 Pro provider using new google-genai SDK""" + + def __init__(self, api_key: Optional[str] = None, model_name: Optional[str] = None, **kwargs): + if genai is None: + raise ImportError("google-genai package not installed. Run: pip install google-genai") + + provider_config = config.get_provider_config('google') + + super().__init__( + api_key=api_key or provider_config['api_key'], + model_name=model_name or provider_config['model'], + **kwargs + ) + + self.temperature = kwargs.get('temperature', provider_config['temperature']) + self.max_output_tokens = kwargs.get('max_output_tokens', provider_config['max_output_tokens']) + self.thinking_budget = kwargs.get('thinking_budget', provider_config['thinking_budget']) + self.timeout = kwargs.get('timeout', provider_config['timeout']) + + self.client = None + self._setup_client() + + def _setup_client(self): + """Initialize Google GenAI client""" + try: + self.client = genai.Client(api_key=self.api_key) + self.logger.info(f"Google GenAI client initialized - Model: {self.model_name}") + except Exception as e: + self.logger.error(f"Failed to initialize Google GenAI client: {e}") + raise + + async def generate_response( + self, + messages: List[Dict[str, str]], + schema: Optional[Dict[str, Any]] = None, + **kwargs + ) -> LLMResponse: + """Generate response using Google Gemini 2.5 Pro""" + start_time = time.time() + + try: + self.logger.info(f"Google Request - Model: {self.model_name} (thinking enabled: {self.thinking_budget} budget)") + + # Convert messages to Google format + content = self._prepare_content(messages) + + # Configure generation with thinking capabilities + config_dict = { + 'temperature': self.temperature, + 'max_output_tokens': self.max_output_tokens, + 'thinking_config': ThinkingConfig(thinking_budget=self.thinking_budget) if ThinkingConfig else None, + } + + # Add JSON schema for structured output if provided + if schema: + config_dict['response_mime_type'] = 'application/json' + converted_schema = self._convert_schema_to_google_format(schema) + + # Google GenAI SDK expects response_schema, not response_json_schema + config_dict['response_schema'] = converted_schema + self.logger.info("Using structured output with converted schema") + + generation_config = GenerateContentConfig(**config_dict) + + # Generate response using native async API + response = await self.client.aio.models.generate_content( + model=self.model_name, + contents=content, + config=generation_config + ) + + # Extract content + if hasattr(response, 'text'): + content = response.text + elif hasattr(response, 'candidates') and response.candidates: + content = response.candidates[0].content.parts[0].text + else: + content = str(response) + + # Extract token usage + token_usage = TokenUsage() + if hasattr(response, 'usage_metadata'): + # Safely extract token counts with proper defaults + input_tokens = getattr(response.usage_metadata, 'prompt_token_count', None) or 0 + output_tokens = getattr(response.usage_metadata, 'candidates_token_count', None) or 0 + cached_tokens = getattr(response.usage_metadata, 'cached_content_token_count', None) or 0 + + usage_dict = { + 'input_tokens': input_tokens, + 'output_tokens': output_tokens, + 'cached_input_tokens': cached_tokens + } + + self.logger.debug(f"Google token usage: {usage_dict}") + token_usage.add_usage(usage_dict) + else: + self.logger.warning("No usage_metadata found in Google response") + + processing_time = time.time() - start_time + + llm_response = LLMResponse( + content=content, + raw_response=response, + token_usage=token_usage, + model_used=self.model_name, + provider="google", + success=True, + processing_time=processing_time + ) + + self.log_response(llm_response) + return llm_response + + except Exception as e: + processing_time = time.time() - start_time + self.logger.error(f"Google request failed: {e}") + + return LLMResponse( + content="", + raw_response=None, + token_usage=TokenUsage(), + model_used=self.model_name, + provider="google", + success=False, + error=str(e), + processing_time=processing_time + ) + + def _prepare_content(self, messages: List[Dict[str, str]]) -> List[Dict[str, Any]]: + """Convert standard messages to Google GenAI format""" + contents = [] + + for message in messages: + role = message['role'] + text = message['content'] + + # Map roles to Google format + if role == 'system': + # System messages go into parts directly + contents.append({ + 'role': 'user', # Google doesn't have explicit system role + 'parts': [{'text': f"System: {text}"}] + }) + elif role == 'user': + contents.append({ + 'role': 'user', + 'parts': [{'text': text}] + }) + elif role == 'assistant': + contents.append({ + 'role': 'model', + 'parts': [{'text': text}] + }) + + return contents + + def _convert_schema_to_google_format(self, schema: Dict[str, Any]) -> Dict[str, Any]: + """Convert OpenAI JSON schema to Google GenAI format""" + + def convert_type(openai_type: str) -> str: + """Convert OpenAI type to Google GenAI type""" + type_mapping = { + 'string': 'STRING', + 'array': 'ARRAY', + 'object': 'OBJECT', + 'integer': 'INTEGER', + 'number': 'NUMBER', + 'boolean': 'BOOLEAN' + } + return type_mapping.get(openai_type.lower(), 'STRING') + + def convert_schema_node(node): + if isinstance(node, dict): + converted = {} + for key, value in node.items(): + if key == 'type': + # Convert type to Google format + converted['type'] = convert_type(value) + elif key == 'oneOf': + # Google doesn't support oneOf - use the string type option + if isinstance(value, list) and len(value) > 0: + string_option = next((item for item in value if item.get('type') == 'string'), value[0]) + return convert_schema_node(string_option) + elif key == 'items': + # Convert array items + converted['items'] = convert_schema_node(value) + elif key == 'properties': + # Convert object properties + converted['properties'] = {} + for prop_name, prop_schema in value.items(): + converted['properties'][prop_name] = convert_schema_node(prop_schema) + elif key == 'required': + # Keep required fields as-is + converted['required'] = value + elif key == 'additionalProperties': + # Skip additionalProperties - not supported by Gemini API + self.logger.debug(f"Skipping unsupported 'additionalProperties' in Google schema") + continue + elif key in ['description', 'title']: + # Keep description and title + converted[key] = value + # Skip other OpenAI-specific fields like 'name' + return converted + elif isinstance(node, list): + return [convert_schema_node(item) for item in node] + else: + return node + + # Extract the actual schema from OpenAI format + if 'schema' in schema: + google_schema = convert_schema_node(schema['schema']) + else: + google_schema = convert_schema_node(schema) + + return google_schema + + def validate_config(self) -> bool: + """Validate Google configuration""" + if not self.api_key or self.api_key == 'your-google-api-key-here': + self.logger.error("Google API key not configured") + return False + + if genai is None: + self.logger.error("google-genai package not installed") + return False + + return True + + def estimate_cost(self, input_tokens: int, output_tokens: int, cached_tokens: int = 0) -> float: + """Estimate cost using Google Gemini pricing""" + return config.estimate_cost('google-gemini31', input_tokens, output_tokens, cached_tokens) + + def get_max_tokens(self) -> int: + """Get maximum token limit for Gemini 3.1 Pro""" + return 2000000 # Gemini 3.1 Pro context window \ No newline at end of file diff --git a/backend/core/llm_service/openai_provider.py b/backend/core/llm_service/openai_provider.py new file mode 100755 index 0000000..993bf5f --- /dev/null +++ b/backend/core/llm_service/openai_provider.py @@ -0,0 +1,309 @@ +""" +OpenAI provider implementation for GPT-5 with reasoning effort support +""" + +import time +import json +import logging +from typing import List, Dict, Any, Optional +from openai import AsyncOpenAI +from pydantic import BaseModel + +from .base_provider import BaseLLMProvider, LLMResponse, TokenUsage +from ..config import config + +class OpenAIProvider(BaseLLMProvider): + """OpenAI GPT-5 provider with reasoning effort support""" + + def __init__(self, api_key: Optional[str] = None, model_name: Optional[str] = None, **kwargs): + provider_config = config.get_provider_config('openai') + + super().__init__( + api_key=api_key or provider_config['api_key'], + model_name=model_name or provider_config['model'], + **kwargs + ) + + self.reasoning_effort = kwargs.get('reasoning_effort', provider_config['reasoning_effort']) + self.timeout = kwargs.get('timeout', provider_config['timeout']) + self.max_retries = kwargs.get('max_retries', provider_config['max_retries']) + + self.client = None + self._setup_client() + + def _setup_client(self): + """Initialize AsyncOpenAI client with configuration""" + try: + self.client = AsyncOpenAI( + api_key=self.api_key, + timeout=self.timeout, + max_retries=self.max_retries + ) + self.logger.info(f"AsyncOpenAI client initialized - Model: {self.model_name}, Reasoning: {self.reasoning_effort}") + except Exception as e: + self.logger.error(f"Failed to initialize AsyncOpenAI client: {e}") + raise + + async def generate_response( + self, + messages: List[Dict[str, str]], + schema: Optional[Dict[str, Any]] = None, + **kwargs + ) -> LLMResponse: + """Generate response using OpenAI GPT-5 with reasoning effort""" + start_time = time.time() + + try: + self.logger.info(f"OpenAI Request - Model: {self.model_name}, Reasoning: {self.reasoning_effort}") + + if schema: + # Use structured output with Pydantic model + stage_tag = "[CONSOLIDATION]" if "MODELS' ANALYSIS RESULTS" in str(messages) else "[INITIAL]" + self.logger.info(f"{stage_tag} Using structured output with schema: {schema.get('name', 'unknown')}") + schema_model = self._create_pydantic_model(schema) + self.logger.debug(f"{stage_tag} Created Pydantic model: {schema_model.__name__}") + + response = await self.client.responses.parse( + model=self.model_name, + input=messages, + reasoning={"effort": self.reasoning_effort}, + text_format=schema_model + ) + + # Extract structured content + if hasattr(response, 'output_parsed') and response.output_parsed is not None: + try: + # Extract JSON from Pydantic model + content = response.output_parsed.model_dump_json() + + # Validate the content has expected structure + try: + parsed_content = json.loads(content) + + if not isinstance(parsed_content, dict): + self.logger.error(f"{stage_tag} Structured output is not a dict: {type(parsed_content)}") + raise ValueError("Expected dict structure") + + if 'assets' not in parsed_content: + # PROBLEM DETECTED - Log everything verbosely + self.logger.error(f"{stage_tag} ========== MISSING 'assets' KEY - VERBOSE DEBUG ==========") + self.logger.error(f"{stage_tag} Response type: {type(response).__name__}") + self.logger.error(f"{stage_tag} Has output_parsed: {hasattr(response, 'output_parsed')}") + self.logger.error(f"{stage_tag} output_parsed type: {type(response.output_parsed)}") + self.logger.error(f"{stage_tag} Raw output_parsed value: {response.output_parsed}") + self.logger.error(f"{stage_tag} Extracted JSON length: {len(content)} chars") + self.logger.error(f"{stage_tag} Full JSON content: {content}") + self.logger.error(f"{stage_tag} Parsed data keys: {list(parsed_content.keys())}") + self.logger.error(f"{stage_tag} Full parsed content: {parsed_content}") + + # Try to fix common issues + if not parsed_content: # Empty dict + self.logger.warning(f"{stage_tag} Got empty dict, creating default structure") + content = json.dumps({"assets": []}) + self.logger.info(f"{stage_tag} Fixed content: {content}") + else: + # Save to file and fail + self._save_debug_response(response, content, stage_tag) + raise KeyError("Missing assets key") + else: + # SUCCESS - Just log summary + assets_count = len(parsed_content.get('assets', [])) + self.logger.info(f"{stage_tag} Structured output validated: {assets_count} assets") + + except json.JSONDecodeError as je: + self.logger.error(f"Failed to parse structured output as JSON: {je}") + self.logger.error(f"Raw structured content: {content[:500]}...") + raise + + except Exception as e: + self.logger.error(f"Error processing structured output: {e}") + self.logger.error(f"Raw response object: {str(response)[:500]}...") + raise + else: + self.logger.error(f"{stage_tag} No structured output found in response (output_parsed is None)") + self.logger.error(f"{stage_tag} Response attributes: {dir(response)}") + + # Save debug info + self._save_debug_response(response, None, stage_tag) + + # Fallback to raw response content if available + if hasattr(response, 'choices') and response.choices: + fallback_content = response.choices[0].message.content + self.logger.warning(f"{stage_tag} Using fallback content from choices: {len(fallback_content) if fallback_content else 0} chars") + # Try to parse the fallback content as JSON + if fallback_content: + try: + parsed = json.loads(fallback_content) + content = fallback_content + self.logger.info(f"{stage_tag} Successfully parsed fallback content as JSON") + except json.JSONDecodeError: + self.logger.error(f"{stage_tag} Fallback content is not valid JSON: {fallback_content[:500]}") + content = json.dumps({"assets": []}) # Empty default + else: + self.logger.warning(f"{stage_tag} No fallback content, using empty assets array") + content = json.dumps({"assets": []}) # Empty default + else: + self.logger.error(f"{stage_tag} No fallback content available in response") + self.logger.error(f"{stage_tag} Response has choices: {hasattr(response, 'choices')}") + content = json.dumps({"assets": []}) # Empty default structure + + else: + # Use regular chat completion + response = await self.client.chat.completions.create( + model=self.model_name, + messages=messages, + **kwargs + ) + content = response.choices[0].message.content + + # Extract token usage + token_usage = TokenUsage() + if hasattr(response, 'usage'): + usage_dict = { + 'input_tokens': getattr(response.usage, 'input_tokens', getattr(response.usage, 'prompt_tokens', 0)), + 'output_tokens': getattr(response.usage, 'output_tokens', getattr(response.usage, 'completion_tokens', 0)), + 'cached_input_tokens': getattr(response.usage, 'input_tokens_cached', getattr(response.usage, 'prompt_tokens_cached', 0)) + } + token_usage.add_usage(usage_dict) + + processing_time = time.time() - start_time + + llm_response = LLMResponse( + content=content, + raw_response=response, + token_usage=token_usage, + model_used=self.model_name, + provider="openai", + success=True, + processing_time=processing_time + ) + + self.log_response(llm_response, f"Reasoning: {self.reasoning_effort}") + return llm_response + + except Exception as e: + processing_time = time.time() - start_time + self.logger.error(f"OpenAI request failed: {e}") + + return LLMResponse( + content="", + raw_response=None, + token_usage=TokenUsage(), + model_used=self.model_name, + provider="openai", + success=False, + error=str(e), + processing_time=processing_time + ) + + def _create_pydantic_model(self, schema: Dict[str, Any]) -> BaseModel: + """Create Pydantic model from JSON schema for structured output""" + try: + # For base deliverable extraction, we can use the existing models + from ..process_brief_enhanced import BaseExtractionResult + return BaseExtractionResult + except ImportError as e: + self.logger.warning(f"Failed to import BaseExtractionResult: {e}, using dynamic model") + # Fallback: create dynamic model with proper nested structure + from pydantic import create_model + + # Handle nested schema structure properly + try: + # Create dynamic models for nested structures + schema_props = schema.get('schema', {}).get('properties', {}) + + # Handle the assets array specifically + if 'assets' in schema_props: + assets_def = schema_props['assets'] + if assets_def.get('type') == 'array': + item_def = assets_def.get('items', {}) + item_props = item_def.get('properties', {}) + + # Create fields for the asset item model + asset_fields = {} + for field_name, field_def in item_props.items(): + if field_def.get('type') == 'array': + asset_fields[field_name] = (Optional[List[str]], []) + else: + asset_fields[field_name] = (Optional[str], "") + + # Create the asset item model + AssetModel = create_model('DynamicAssetModel', **asset_fields) + + # Create the main response model with assets array + return create_model('DynamicResponseModel', assets=(List[AssetModel], ...)) + + # Fallback to simple structure + fields = {'assets': (List[Any], ...)} + return create_model('DynamicModel', **fields) + + except Exception as schema_error: + self.logger.error(f"Failed to create dynamic model from schema: {schema_error}") + # Ultimate fallback + return create_model('FallbackModel', assets=(List[Any], ...)) + + def validate_config(self) -> bool: + """Validate OpenAI configuration""" + if not self.api_key or self.api_key == 'your-openai-api-key-here': + self.logger.error("OpenAI API key not configured") + return False + + if self.reasoning_effort not in ['high', 'medium', 'low', 'minimal']: + self.logger.warning(f"Invalid reasoning effort: {self.reasoning_effort}, using 'medium'") + self.reasoning_effort = 'medium' + + return True + + def estimate_cost(self, input_tokens: int, output_tokens: int, cached_tokens: int = 0) -> float: + """Estimate cost using OpenAI GPT-5.1 pricing""" + return config.estimate_cost('openai-gpt51', input_tokens, output_tokens, cached_tokens) + + def get_max_tokens(self) -> int: + """Get maximum token limit for GPT-5.1""" + return 200000 # GPT-5.1 context window + + def set_reasoning_effort(self, effort: str): + """Update reasoning effort setting""" + if effort in ['high', 'medium', 'low', 'minimal']: + self.reasoning_effort = effort + self.logger.info(f"Updated reasoning effort to: {effort}") + else: + self.logger.warning(f"Invalid reasoning effort: {effort}, keeping current: {self.reasoning_effort}") + + def _save_debug_response(self, response, content, stage_tag): + """Save debug information about problematic responses""" + try: + import tempfile + from datetime import datetime + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + debug_file = os.path.join(tempfile.gettempdir(), f"openai_debug_{stage_tag.strip('[]')}_{timestamp}.txt") + + with open(debug_file, 'w') as f: + f.write(f"=== OpenAI Response Debug {stage_tag} ===\n") + f.write(f"Timestamp: {timestamp}\n") + f.write(f"Model: {self.model_name}\n") + f.write(f"Reasoning: {self.reasoning_effort}\n\n") + + f.write("=== Response Object ===\n") + f.write(f"Type: {type(response)}\n") + f.write(f"Dir: {dir(response)}\n\n") + + if hasattr(response, 'output_parsed'): + f.write(f"output_parsed: {response.output_parsed}\n") + f.write(f"output_parsed type: {type(response.output_parsed)}\n\n") + + if hasattr(response, 'choices'): + f.write(f"Has choices: {len(response.choices) if response.choices else 0}\n") + if response.choices: + f.write(f"choices[0]: {response.choices[0]}\n\n") + + f.write("=== Extracted Content ===\n") + f.write(f"Content: {content}\n\n") + + f.write("=== Full Response ===\n") + f.write(f"{response}\n") + + self.logger.error(f"{stage_tag} Debug info saved to: {debug_file}") + except Exception as e: + self.logger.error(f"{stage_tag} Failed to save debug info: {e}") \ No newline at end of file diff --git a/backend/core/llm_service/provider_manager.py b/backend/core/llm_service/provider_manager.py new file mode 100755 index 0000000..13219d4 --- /dev/null +++ b/backend/core/llm_service/provider_manager.py @@ -0,0 +1,293 @@ +""" +Provider manager for coordinating parallel execution across multiple LLM providers +""" + +import asyncio +import logging +from typing import List, Dict, Any, Optional, Tuple +import time + +from .base_provider import BaseLLMProvider, LLMResponse, TokenUsage +from .openai_provider import OpenAIProvider +from .google_provider import GoogleProvider +from .anthropic_provider import AnthropicProvider +from ..config import config + +class ProviderManager: + """Manages multiple LLM providers and coordinates parallel execution""" + + def __init__(self): + self.providers: Dict[str, BaseLLMProvider] = {} + self.logger = logging.getLogger(self.__class__.__name__) + + def create_provider(self, model_key: str) -> BaseLLMProvider: + """Create provider instance for given model key""" + try: + provider_name, model_name = config.get_model_info(model_key) + + if provider_name == 'openai': + return OpenAIProvider(model_name=model_name) + elif provider_name == 'google': + return GoogleProvider(model_name=model_name) + elif provider_name == 'anthropic': + # Extract variant from model key for Anthropic + variant = 'opus' if 'opus' in model_key else 'sonnet' + return AnthropicProvider(model_name=model_name, model_variant=variant) + else: + raise ValueError(f"Unknown provider: {provider_name}") + + except Exception as e: + self.logger.error(f"Failed to create provider for {model_key}: {e}") + raise + + def get_provider(self, model_key: str) -> BaseLLMProvider: + """Get or create provider for model key""" + if model_key not in self.providers: + self.providers[model_key] = self.create_provider(model_key) + return self.providers[model_key] + + async def execute_parallel_analysis( + self, + model_keys: List[str], + messages: List[Dict[str, str]], + schema: Optional[Dict[str, Any]] = None, + minimum_success_threshold: int = 1, + on_model_event: Optional[callable] = None + ) -> Tuple[List[LLMResponse], Dict[str, Any]]: + """ + Execute analysis across multiple models in parallel + + Args: + model_keys: List of model identifiers to use + messages: Messages to send to all models + schema: Optional JSON schema for structured output + minimum_success_threshold: Minimum number of successful responses required + on_model_event: Optional callback for model start/end events + + Returns: + Tuple of (successful_responses, metadata) + """ + self.logger.info(f"Starting parallel analysis with models: {model_keys}") + start_time = time.time() + + # Validate model keys + valid_model_keys = [] + for model_key in model_keys: + try: + provider = self.get_provider(model_key) + if provider.validate_config(): + valid_model_keys.append(model_key) + else: + self.logger.warning(f"Skipping {model_key} due to configuration issues") + except Exception as e: + self.logger.error(f"Failed to validate {model_key}: {e}") + + if len(valid_model_keys) == 0: + raise ValueError("No valid models available for analysis") + + if len(valid_model_keys) < minimum_success_threshold: + self.logger.warning( + f"Only {len(valid_model_keys)} valid models, but minimum threshold is {minimum_success_threshold}" + ) + + # Create tasks for parallel execution + tasks = [] + for model_key in valid_model_keys: + provider = self.get_provider(model_key) + task = asyncio.create_task( + self._execute_with_provider(provider, model_key, messages, schema, on_model_event) + ) + tasks.append((model_key, task)) + + # Execute all tasks in parallel using asyncio.gather + results = [] + successful_responses = [] + failed_responses = [] + + # Await all tasks simultaneously + task_results = await asyncio.gather(*[task for _, task in tasks], return_exceptions=True) + + # Process results + for i, (model_key, task) in enumerate(tasks): + result = task_results[i] + + if isinstance(result, Exception): + self.logger.error(f"Task for {model_key} raised exception: {result}") + failed_responses.append((model_key, str(result))) + else: + response = result + results.append((model_key, response)) + + if response.success: + successful_responses.append(response) + # Try to parse the response to count deliverables + deliverable_count = self._count_deliverables_in_response(response.content) + self.logger.info(f"{model_key} analysis completed successfully - found {deliverable_count} deliverables") + else: + failed_responses.append((model_key, response.error)) + self.logger.warning(f"{model_key} analysis failed: {response.error}") + + total_time = time.time() - start_time + + # Check if we meet minimum success threshold + if len(successful_responses) < minimum_success_threshold: + raise RuntimeError( + f"Only {len(successful_responses)} models succeeded, " + f"but minimum threshold is {minimum_success_threshold}" + ) + + # Compile metadata + metadata = { + 'total_models_requested': len(model_keys), + 'valid_models': len(valid_model_keys), + 'successful_models': len(successful_responses), + 'failed_models': len(failed_responses), + 'total_processing_time': total_time, + 'model_results': { + model_key: { + 'success': response.success, + 'processing_time': response.processing_time, + 'tokens_used': response.token_usage.get_total(), + 'provider': response.provider, + 'model': response.model_used, + 'error': response.error + } for model_key, response in results + }, + 'failures': failed_responses + } + + self.logger.info( + f"Parallel analysis completed - {len(successful_responses)}/{len(valid_model_keys)} " + f"models succeeded in {total_time:.2f}s" + ) + + return successful_responses, metadata + + def _count_deliverables_in_response(self, content: str) -> int: + """Count the number of deliverables in a model's JSON response""" + try: + import json + data = json.loads(content) + if isinstance(data, dict) and 'assets' in data: + return len(data['assets']) + return 0 + except (json.JSONDecodeError, KeyError, TypeError): + return 0 + + async def _execute_with_provider( + self, + provider: BaseLLMProvider, + model_key: str, + messages: List[Dict[str, str]], + schema: Optional[Dict[str, Any]] = None, + on_model_event: Optional[callable] = None + ) -> LLMResponse: + """Execute analysis with a single provider""" + import time + from datetime import datetime + + try: + self.logger.debug(f"Starting analysis with {model_key}") + + # Notify start event + if on_model_event: + await on_model_event(model_key, 'start', { + 'timestamp': datetime.utcnow().isoformat() + }) + + start_time = time.time() + response = await provider.generate_response(messages, schema) + processing_time = time.time() - start_time + + # Calculate cost if possible + cost = 0.0 + try: + cost = provider.estimate_cost( + response.token_usage.input_tokens, + response.token_usage.output_tokens, + response.token_usage.cached_input_tokens + ) + except: + pass + + # Notify success event + if on_model_event: + await on_model_event(model_key, 'end', { + 'response': response, + 'cost': cost, + 'processing_time': processing_time, + 'timestamp': datetime.utcnow().isoformat() + }) + + return response + + except Exception as e: + self.logger.error(f"Provider {model_key} execution failed: {e}") + + # Notify error event + if on_model_event: + await on_model_event(model_key, 'end', { + 'error': str(e), + 'timestamp': datetime.utcnow().isoformat() + }) + + return LLMResponse( + content="", + raw_response=None, + token_usage=TokenUsage(), + model_used=model_key, + provider=provider.get_provider_name(), + success=False, + error=str(e) + ) + + def estimate_total_cost(self, model_keys: List[str], estimated_input_tokens: int, estimated_output_tokens: int) -> Dict[str, float]: + """Estimate total cost for all models""" + cost_breakdown = {} + total_cost = 0.0 + + for model_key in model_keys: + try: + provider = self.get_provider(model_key) + model_cost = provider.estimate_cost(estimated_input_tokens, estimated_output_tokens) + cost_breakdown[model_key] = model_cost + total_cost += model_cost + except Exception as e: + self.logger.warning(f"Could not estimate cost for {model_key}: {e}") + cost_breakdown[model_key] = 0.0 + + cost_breakdown['total'] = total_cost + return cost_breakdown + + def get_aggregated_token_usage(self, responses: List[LLMResponse]) -> TokenUsage: + """Aggregate token usage from multiple responses""" + total_usage = TokenUsage() + + for response in responses: + total_usage.input_tokens += response.token_usage.input_tokens + total_usage.output_tokens += response.token_usage.output_tokens + total_usage.cached_input_tokens += response.token_usage.cached_input_tokens + + return total_usage + + def get_actual_cost_breakdown(self, responses: List[LLMResponse]) -> Dict[str, float]: + """Calculate actual costs from responses""" + cost_breakdown = {} + total_cost = 0.0 + + for response in responses: + try: + provider = self.providers.get(response.model_used) + if provider: + cost = provider.estimate_cost( + response.token_usage.input_tokens, + response.token_usage.output_tokens, + response.token_usage.cached_input_tokens + ) + cost_breakdown[response.model_used] = cost + total_cost += cost + except Exception as e: + self.logger.warning(f"Could not calculate cost for {response.model_used}: {e}") + + cost_breakdown['total'] = total_cost + return cost_breakdown \ No newline at end of file diff --git a/backend/core/process_brief_enhanced.py b/backend/core/process_brief_enhanced.py new file mode 100755 index 0000000..4c60cb0 --- /dev/null +++ b/backend/core/process_brief_enhanced.py @@ -0,0 +1,1219 @@ +import sys +import os +import datetime +import logging +import json +import csv +import re +import itertools +import asyncio +from typing import List, Dict, Any, Optional, Tuple, Union +from dataclasses import dataclass +from enum import Enum +from pydantic import BaseModel + +# File Processing Libraries +import pptx +import pandas as pd +import fitz # PyMuPDF +from PIL import Image +import docx +from openpyxl import load_workbook + +# AI Libraries +import json5 +import base64 + +# Configuration and LLM Services +from .config import config +from .llm_service import ProviderManager, LLMResponse +from .consolidation_processor import ConsolidationProcessor + +# OpenAI GPT-5.1 Pricing (per 1M tokens) +OPENAI_PRICING = { + 'gpt-5.1': { + 'input': 1.25, + 'cached_input': 0.625, + 'output': 10.00 + } +} + +CSV_HEADERS = [ + 'title', 'status', 'category', 'media', 'asset_type', + 'brand_identifier', 'technical_specifications', 'review_date', 'live_date', + 'end_date', 'reference_material', 'language_country_market', + 'quantity', 'page_number', 'priority_level', + 'creative_direction' +] + +# Base deliverable with mixed field types (strings for metadata, arrays for multipliers) +class BaseDeliverable(BaseModel): + title: str + status: Optional[str] = "" + category: Optional[str] = "" + media: Optional[str] = "" + asset_type: Optional[str] = "" + brand_identifier: Optional[str] = "" + technical_specifications: Optional[List[str]] = [] + review_date: Optional[str] = "" + live_date: Optional[str] = "" + end_date: Optional[str] = "" + reference_material: Optional[str] = "" + language_country_market: Optional[List[str]] = [] + quantity: Optional[str] = "1" + page_number: Optional[str] = "" + priority_level: Optional[str] = "" + creative_direction: Optional[str] = "" + +# Individual marketing asset (for final CSV output) +class MarketingAsset(BaseModel): + title: str + status: Optional[str] = "" + category: Optional[str] = "" + media: Optional[str] = "" + asset_type: Optional[str] = "" + brand_identifier: Optional[str] = "" + technical_specifications: Optional[str] = "" + review_date: Optional[str] = "" + live_date: Optional[str] = "" + end_date: Optional[str] = "" + reference_material: Optional[str] = "" + language_country_market: Optional[str] = "" + quantity: Optional[str] = "1" + page_number: Optional[str] = "" + priority_level: Optional[str] = "" + creative_direction: Optional[str] = "" + +# Base extraction result (from LLM) +class BaseExtractionResult(BaseModel): + assets: List[BaseDeliverable] + +# Final extraction result (expanded individual assets) +class AssetExtractionResult(BaseModel): + assets: List[MarketingAsset] + +# Load universal schema from external file +def _load_universal_schema(): + """Load universal schema from JSON file""" + try: + # Go up one level from core/ to find prompts/ + schema_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'prompts', 'universal_schema.json') + with open(schema_path, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + logging.error(f"Error loading universal schema: {e}") + raise + +# Universal schema for base deliverable extraction (works with all providers) +UNIVERSAL_BASE_DELIVERABLE_SCHEMA = _load_universal_schema() + +# Legacy schema maintained for backward compatibility +OPENAI_ASSET_SCHEMA = { + "name": "asset_extraction", + "description": "Extract assets from document analysis", + "schema": { + "type": "object", + "properties": { + "assets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": {"type": "string", "description": "Asset title or name"}, + "status": {"type": "string", "description": "Current status"}, + "category": {"type": "string", "description": "Asset category"}, + "media": {"type": "string", "description": "Media type"}, + "asset_type": {"type": "string", "description": "Specific asset type"}, + "brand_identifier": {"type": "string", "description": "Brand or client"}, + "technical_specifications": {"type": "string", "description": "Technical specifications including dimensions (e.g., '1080x1920', '1920x1080'), descriptive formats (e.g., 'Mobile Banner', 'Desktop Hero'), file formats, technical requirements, or any other technical details"}, + "review_date": {"type": "string", "description": "Review deadline"}, + "live_date": {"type": "string", "description": "Go-live date"}, + "end_date": {"type": "string", "description": "End/expiry date"}, + "reference_material": {"type": "string", "description": "Detailed requirements"}, + "language": {"type": "string", "description": "Target language"}, + "country": {"type": "string", "description": "Target country/region"}, + "quantity": {"type": "string", "description": "Number of assets"}, + "page_number": {"type": "string", "description": "Source page"}, + "priority_level": {"type": "string", "description": "Business priority"}, + "creative_direction": {"type": "string", "description": "Design requirements"} + }, + "required": ["title", "technical_specifications"], + "additionalProperties": False + } + } + }, + "required": ["assets"], + "additionalProperties": False + } +} + +# Legacy Gemini Schema (keep for backward compatibility) +GEMINI_ASSET_SCHEMA = { + "type": "object", + "properties": { + "assets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": {"type": "string", "description": "Asset title or name"}, + "status": {"type": "string", "description": "Current status"}, + "category": {"type": "string", "description": "Asset category"}, + "media": {"type": "string", "description": "Media type"}, + "asset_type": {"type": "string", "description": "Specific asset type"}, + "brand_identifier": {"type": "string", "description": "Brand or client"}, + "technical_specifications": {"type": "string", "description": "Technical specifications including format/dimensions, file formats, technical requirements, or any other technical details"}, + "review_date": {"type": "string", "description": "Review deadline"}, + "live_date": {"type": "string", "description": "Go-live date"}, + "end_date": {"type": "string", "description": "End/expiry date"}, + "reference_material": {"type": "string", "description": "Detailed requirements"}, + "language": {"type": "string", "description": "Target language"}, + "country": {"type": "string", "description": "Target country/region"}, + "quantity": {"type": "string", "description": "Number of assets"}, + "page_number": {"type": "string", "description": "Source page"}, + "priority_level": {"type": "string", "description": "Business priority"}, + "creative_direction": {"type": "string", "description": "Design requirements"} + }, + "required": ["title", "technical_specifications"] + } + } + }, + "required": ["assets"] +} + +class DocumentType(Enum): + POWERPOINT = "powerpoint" + WORD = "word" + PDF = "pdf" + EXCEL = "excel" + UNKNOWN = "unknown" + + +@dataclass +class TokenUsage: + input_tokens: int = 0 + cached_input_tokens: int = 0 + output_tokens: int = 0 + + def add_usage(self, usage_dict: Dict[str, int]): + """Add token usage from OpenAI Responses API""" + # Support both old (Chat Completions) and new (Responses API) field names + self.input_tokens += usage_dict.get('prompt_tokens', usage_dict.get('input_tokens', 0)) + self.cached_input_tokens += usage_dict.get('prompt_tokens_cached', usage_dict.get('input_tokens_cached', 0)) + self.output_tokens += usage_dict.get('completion_tokens', usage_dict.get('output_tokens', 0)) + + def calculate_cost(self, model_name: str) -> float: + """Calculate total cost based on GPT-5 pricing""" + if model_name not in OPENAI_PRICING: + logging.warning(f"No pricing info for model {model_name}, defaulting to gpt-5") + model_name = 'gpt-5' + + pricing = OPENAI_PRICING[model_name] + + # Calculate cost per component (pricing is per 1M tokens) + input_cost = (self.input_tokens / 1_000_000) * pricing['input'] + cached_cost = (self.cached_input_tokens / 1_000_000) * pricing['cached_input'] + output_cost = (self.output_tokens / 1_000_000) * pricing['output'] + + return input_cost + cached_cost + output_cost + + def get_summary(self, model_name: str) -> Dict[str, Any]: + """Get detailed cost breakdown""" + total_cost = self.calculate_cost(model_name) + + return { + 'input_tokens': self.input_tokens, + 'cached_input_tokens': self.cached_input_tokens, + 'output_tokens': self.output_tokens, + 'total_tokens': self.input_tokens + self.cached_input_tokens + self.output_tokens, + 'total_cost_usd': round(total_cost, 4), + 'cost_breakdown': { + 'input_cost': round((self.input_tokens / 1_000_000) * OPENAI_PRICING[model_name]['input'], 4), + 'cached_input_cost': round((self.cached_input_tokens / 1_000_000) * OPENAI_PRICING[model_name]['cached_input'], 4), + 'output_cost': round((self.output_tokens / 1_000_000) * OPENAI_PRICING[model_name]['output'], 4) + } + } + +@dataclass +class ProcessingResult: + raw_data: List[Dict[str, Any]] + metadata: Dict[str, Any] + confidence_score: float + processing_notes: List[str] + token_usage: TokenUsage + +def create_unique_title(base_title: str, multiplier_values: Dict[str, str], max_suffix_length: int = 40) -> str: + """Create unique title by appending abbreviated multiplier values""" + + def csv_safe_string(text: str) -> str: + """Replace special characters for CSV compatibility""" + replacements = { + ',': '_', + '"': '', + "'": '', + '\n': '_', + '\r': '_', + '\t': '_' + } + + result = text + for old, new in replacements.items(): + result = result.replace(old, new) + return result + + def abbreviate_value(value: str, max_length: int) -> str: + """Intelligently abbreviate a value while preserving meaning""" + if len(value) <= max_length: + return value + + # For technical specs, try to preserve dimensions + if 'x' in value and value.replace('x', '').replace('-', '').isdigit(): + # It's a dimension - keep as much as possible + return value[:max_length] + + # For market codes, prioritize country part + if '-' in value: + parts = value.split('-') + if len(parts) >= 2: + # Try to keep country code at least + country = parts[-1] + if len(country) <= max_length: + return country + + # Fallback: simple truncation + return value[:max_length] + + # Extract variable values in priority order: market first, then specs + suffix_parts = [] + + # Priority 1: Market (language_country_market) + if 'language_country_market' in multiplier_values: + market = multiplier_values['language_country_market'] + suffix_parts.append(abbreviate_value(market, 15)) + + # Priority 2: Technical specifications + if 'technical_specifications' in multiplier_values: + spec = multiplier_values['technical_specifications'] + suffix_parts.append(abbreviate_value(spec, 20)) + + # Create suffix within length limit + if suffix_parts: + suffix = '_'.join(suffix_parts) + if len(suffix) > max_suffix_length: + # Truncate proportionally + if len(suffix_parts) == 2: + market_part = suffix_parts[0][:15] + spec_part = suffix_parts[1][:20] + suffix = f"{market_part}_{spec_part}" + else: + suffix = suffix[:max_suffix_length] + + unique_title = f"{base_title}_{suffix}" + else: + unique_title = base_title + + # Ensure CSV safety + return csv_safe_string(unique_title) + +def expand_deliverables(base_deliverables: List[BaseDeliverable]) -> Tuple[List[MarketingAsset], List[str]]: + """ + Expand base deliverables with multiplier arrays into individual MarketingAsset objects. + Returns: (expanded_assets, warnings) + """ + expanded_assets = [] + warnings = [] + + for base in base_deliverables: + # Convert base deliverable to dict for easier processing + base_dict = base.model_dump() + + # Identify fields with arrays (multipliers) + multiplier_fields = {} + single_fields = {} + + # Define which fields are multipliers (arrays) vs metadata (strings) + multiplier_field_names = {'technical_specifications', 'language_country_market'} + + for field, value in base_dict.items(): + if field in multiplier_field_names: + # Multiplier fields should be arrays + if isinstance(value, list) and len(value) > 0: + # Skip empty arrays and arrays with only empty strings + if any(v.strip() for v in value if v): # Has non-empty values + multiplier_fields[field] = [v for v in value if v.strip()] # Filter out empty strings + else: + single_fields[field] = None # Empty array becomes None + elif isinstance(value, str) and value.strip(): + # Single string value becomes single-item array for multiplier field + multiplier_fields[field] = [value] + else: + single_fields[field] = None + else: + # Non-multiplier fields should be strings + if isinstance(value, str) and value.strip(): + single_fields[field] = value + elif isinstance(value, list) and len(value) > 0: + # If somehow we get an array for a string field, take the first value + single_fields[field] = next((v for v in value if v.strip()), None) + else: + single_fields[field] = None + + # If no multiplier fields, create single asset + if not multiplier_fields: + asset_data = {**single_fields, "quantity": "1"} + expanded_assets.append(MarketingAsset(**asset_data)) + continue + + # Calculate expected count from quantity field if present + expected_quantity = None + if 'quantity' in base_dict and base_dict['quantity']: + try: + # Quantity is now a string field + quantity_str = str(base_dict['quantity']) + if quantity_str and quantity_str != "1": + expected_quantity = int(quantity_str) + except (ValueError, TypeError): + pass + + # Generate all combinations using itertools.product + field_names = list(multiplier_fields.keys()) + field_values = [multiplier_fields[field] for field in field_names] + + combinations = list(itertools.product(*field_values)) + actual_count = len(combinations) + + # Validate quantity if expected quantity was specified + if expected_quantity and actual_count != expected_quantity: + warnings.append( + f"Quantity mismatch for '{base.title}': expected {expected_quantity}, " + f"but expansion created {actual_count} deliverables" + ) + + # Create individual assets for each combination + for combo in combinations: + asset_data = single_fields.copy() + + # Assign multiplier values from this combination + multiplier_combination = {} + for i, field_name in enumerate(field_names): + asset_data[field_name] = combo[i] + multiplier_combination[field_name] = combo[i] + + # Generate unique title with multiplier values + unique_title = create_unique_title(base.title, multiplier_combination) + asset_data["title"] = unique_title + + # Ensure quantity is always "1" for individual assets + asset_data["quantity"] = "1" + + try: + expanded_assets.append(MarketingAsset(**asset_data)) + except Exception as e: + warnings.append(f"Error creating asset for '{base.title}': {e}") + + # Log concise expansion summary (reduced verbosity) + expanding_fields = {field: values for field, values in multiplier_fields.items() if len(values) > 1} + + if expanding_fields: + logging.debug(f"EXPANDED '{base.title}': {actual_count} deliverables from {len(expanding_fields)} multiplier fields") + else: + logging.debug(f"EXPANDED '{base.title}': {actual_count} deliverable (no multipliers)") + + return expanded_assets, warnings + +class DocumentAnalyzer: + def __init__(self, primary_models: List[str] = None, consolidation_model: str = None): + self.primary_models = primary_models or config.get_default_primary_models() + self.consolidation_model = consolidation_model or config.DEFAULT_CONSOLIDATION_MODEL + self.provider_manager = ProviderManager() + self.consolidation_processor = ConsolidationProcessor() + self.token_usage = TokenUsage() + + # Validate models + self._validate_models() + + def _validate_models(self): + """Validate that specified models are available and configured""" + valid_models = list(config.MODEL_MAPPINGS.keys()) + + # Validate primary models + for model in self.primary_models: + if model not in valid_models: + logging.error(f"Invalid primary model: {model}. Available: {valid_models}") + sys.exit(1) + + # Validate consolidation model + if self.consolidation_model not in valid_models: + logging.error(f"Invalid consolidation model: {self.consolidation_model}. Available: {valid_models}") + sys.exit(1) + + # Validate API keys + api_key_status = config.validate_api_keys() + missing_keys = [provider for provider, valid in api_key_status.items() if not valid] + + if missing_keys: + logging.warning(f"Missing API keys for: {missing_keys}") + + logging.info(f"Using primary models: {self.primary_models}") + logging.info(f"Using consolidation model: {self.consolidation_model}") + + async def _load_prompt(self, prompt_name: str) -> str: + """Load prompt from external file asynchronously.""" + import asyncio + + def _read_prompt(): + """Blocking prompt read operation for thread pool""" + # Go up one level from core/ to find prompts/ + prompt_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'prompts', f'{prompt_name}.txt') + with open(prompt_path, 'r', encoding='utf-8') as f: + return f.read().strip() + + try: + loop = asyncio.get_running_loop() + content = await loop.run_in_executor(None, _read_prompt) + return content + except FileNotFoundError: + logging.error(f"Prompt file not found: {prompt_name}") + raise + except Exception as e: + logging.error(f"Error loading prompt {prompt_name}: {e}") + raise + + async def _save_base_deliverables_json(self, base_deliverables: List[BaseDeliverable], doc_type: str): + """Save intermediate base deliverables with multiplier arrays as JSON.""" + import asyncio + + def _write_json(): + """Blocking JSON write operation for thread pool""" + # Create base_deliverable_JSON directory if it doesn't exist + json_dir = os.path.join(os.path.dirname(__file__), 'base_deliverable_JSON') + os.makedirs(json_dir, exist_ok=True) + + # Generate timestamp for filename + timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S") + filename = f"base_deliverables_{doc_type}_{timestamp}.json" + filepath = os.path.join(json_dir, filename) + + # Convert base deliverables to serializable format + json_data = { + "timestamp": timestamp, + "document_type": doc_type, + "base_deliverables_count": len(base_deliverables), + "base_deliverables": [base.model_dump() for base in base_deliverables] + } + + # Write JSON file + with open(filepath, 'w', encoding='utf-8') as f: + json.dump(json_data, f, indent=2, ensure_ascii=False) + + logging.info(f"Saved base deliverables JSON: {filepath}") + return filepath + + try: + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, _write_json) + + except Exception as e: + logging.warning(f"Failed to save base deliverables JSON: {e}") + + def classify_document(self, filepath: str) -> DocumentType: + """Classify document type based on extension and content.""" + extension = os.path.splitext(filepath)[1].lower() + + if extension in ['.ppt', '.pptx']: + return DocumentType.POWERPOINT + elif extension in ['.doc', '.docx']: + return DocumentType.WORD + elif extension == '.pdf': + return DocumentType.PDF + elif extension in ['.xls', '.xlsx']: + return DocumentType.EXCEL + else: + return DocumentType.UNKNOWN + + def _encode_file_for_openai(self, filepath: str) -> str: + """Encode file content for OpenAI API.""" + try: + with open(filepath, "rb") as file: + return base64.b64encode(file.read()).decode('utf-8') + except Exception as e: + logging.error(f"Error encoding file for OpenAI: {e}") + return None + + async def _extract_document_content(self, filepath: str) -> str: + """Extract markdown content from document using LlamaParser cloud service.""" + try: + from llama_cloud_services import LlamaParse + + logging.info(f"Using LlamaParser to extract content from: {os.path.basename(filepath)}") + + parser = LlamaParse( + # API key for LlamaParser + api_key=config.LLAMACLOUD_API_KEY, + + # The parsing mode - use agent-based parsing for better accuracy + parse_mode="parse_page_with_agent", + + # The model to use - GPT-5 for best results + model="openai-gpt-5", + + # Whether to use high resolution OCR (slower but more accurate) + high_res_ocr=True, + + # Adaptive long table detection and output adaptation + adaptive_long_table=True, + + # Whether to try to extract outlined tables + outlined_table_extraction=True, + + # Whether to output tables as HTML in the markdown output + output_tables_as_HTML=True, + + # The page separator + page_separator="\n\n---\n\n", + ) + + # Use the official async method + result = await parser.aparse(filepath) + + # Get the markdown documents with page separation + markdown_documents = result.get_markdown_documents(split_by_page=True) + + # Combine all markdown documents into a single string + combined_content = "\n\n".join([doc.text for doc in markdown_documents]) + + logging.info(f"LlamaParser extraction completed. Content length: {len(combined_content)} characters") + return combined_content + + except Exception as e: + logging.error(f"Error extracting document content with LlamaParser: {e}") + raise Exception(f"LlamaParser extraction failed: {e}") + + + + async def process_document_multi_model(self, filepath: str, progress=None) -> ProcessingResult: + """Process document using parallel multi-model analysis with consolidation.""" + logging.info(f"Starting parallel multi-model analysis of '{os.path.basename(filepath)}'") + + # Import JobPhase if progress reporting is enabled + if progress: + try: + from server.jobs.models import JobPhase + except ImportError: + # Fallback for CLI usage - create mock enum + class JobPhase: + EXTRACT_CONTENT = 'EXTRACT_CONTENT' + LLM_ANALYSIS = 'LLM_ANALYSIS' + CONSOLIDATION = 'CONSOLIDATION' + + # Progress: EXTRACT_CONTENT 0% → 25% + if progress: + await progress.emit(JobPhase.EXTRACT_CONTENT, 10, f'Starting analysis of {os.path.basename(filepath)}') + + # Stage 1: Extract document content using LlamaParser + try: + document_content = await self._extract_document_content(filepath) + logging.info(f"Document content extracted using LlamaParser") + if progress: + await progress.emit(JobPhase.EXTRACT_CONTENT, 25, 'Document content extracted successfully') + except Exception as e: + logging.error(f"Content extraction failed: {e}") + if progress: + await progress.emit_failure(f"Content extraction failed: {e}") + return ProcessingResult([], {}, 0.0, [f"Content extraction failed: {e}"], TokenUsage()) + + # Stage 2: Parallel multi-model analysis + # Progress: LLM_ANALYSIS 25% → 75% (50% weight) + if progress: + await progress.emit(JobPhase.LLM_ANALYSIS, 30, 'Starting parallel multi-model analysis') + + logging.info("=== STAGE 2: Starting Parallel Multi-Model Analysis ===") + doc_type = self.classify_document(filepath) + + try: + analysis_responses, analysis_metadata = await self._perform_parallel_analysis( + document_content, doc_type, progress + ) + logging.info(f"Parallel analysis completed - {len(analysis_responses)} successful models") + if progress: + await progress.emit(JobPhase.LLM_ANALYSIS, 75, f'Parallel analysis completed - {len(analysis_responses)} successful models') + except Exception as e: + logging.error(f"Parallel analysis failed: {e}") + if progress: + await progress.emit_failure(f"Parallel analysis failed: {e}") + return ProcessingResult([], {}, 0.0, [f"Parallel analysis failed: {e}"], TokenUsage()) + + # Stage 3: Consolidation + # Progress: CONSOLIDATION 75% → 90% (15% weight) + if progress: + await progress.emit(JobPhase.CONSOLIDATION, 75, 'Starting result consolidation') + + logging.info("=== STAGE 3: Starting Result Consolidation ===") + try: + consolidation_result = await self.consolidation_processor.consolidate_results( + analysis_responses, self.consolidation_model, document_content + ) + logging.info(f"Consolidation completed: {len(consolidation_result.expanded_assets)} final deliverables") + if progress: + await progress.emit(JobPhase.CONSOLIDATION, 90, f'Consolidation completed: {len(consolidation_result.expanded_assets)} final assets') + except Exception as e: + logging.error(f"Consolidation failed: {e}") + if progress: + await progress.emit_failure(f"Consolidation failed: {e}") + return ProcessingResult([], {}, 0.0, [f"Consolidation failed: {e}"], TokenUsage()) + + # Convert expanded assets to dict format for compatibility + extracted_data = [asset.model_dump() for asset in consolidation_result.expanded_assets] + + # Aggregate token usage from all models + total_token_usage = self.provider_manager.get_aggregated_token_usage(analysis_responses) + + # Combine processing notes + successful_count = analysis_metadata.get('successful_models', len(analysis_responses)) + total_count = analysis_metadata.get('total_models_attempted', len(self.primary_models)) + processing_notes = [f"Parallel analysis: {successful_count}/{total_count} models"] + processing_notes.extend(consolidation_result.warnings) + + # Merge metadata + combined_metadata = { + 'doc_type': doc_type.value, + 'primary_models_used': self.primary_models, + 'consolidation_model': self.consolidation_model, + 'analysis_metadata': analysis_metadata, + 'consolidation_metadata': consolidation_result.consolidation_metadata + } + + return ProcessingResult( + raw_data=extracted_data, + metadata=combined_metadata, + confidence_score=0.9, # Higher confidence due to multi-model consensus + processing_notes=processing_notes, + token_usage=total_token_usage + ) + + async def _perform_parallel_analysis(self, document_content: str, doc_type: DocumentType, progress=None) -> Tuple[List[LLMResponse], Dict[str, Any]]: + """Perform parallel analysis across multiple models""" + + # Load prompt from external file + multi_perspective_prompt_template = await self._load_prompt('multi_perspective_analysis') + multi_perspective_prompt = multi_perspective_prompt_template.format(doc_type=doc_type.value) + + # Load system message from external file + system_message = await self._load_prompt('system_multi_perspective') + + # Prepare combined prompt + combined_prompt = f"{multi_perspective_prompt}\n\nDocument Content:\n{document_content}" + + # Prepare messages for all providers + messages = [ + {"role": "system", "content": system_message}, + {"role": "user", "content": combined_prompt} + ] + + # Get schema for structured output + schema = UNIVERSAL_BASE_DELIVERABLE_SCHEMA + + # Create progress callback for provider updates + progress_callback = None + if progress: + progress_callback = self._create_provider_progress_callback(progress) + + # Execute parallel analysis + successful_responses, metadata = await self.provider_manager.execute_parallel_analysis( + model_keys=self.primary_models, + messages=messages, + schema=schema, + minimum_success_threshold=config.MINIMUM_SUCCESS_THRESHOLD, + on_model_event=progress_callback + ) + + return successful_responses, metadata + + def _create_provider_progress_callback(self, progress): + """Create callback function for provider progress updates""" + async def on_model_event(model_key: str, stage: str, data): + try: + if stage == 'start': + await progress.emit_provider_update(model_key, { + 'provider': self._get_provider_name(model_key), + 'model': self._get_model_display_name(model_key), + 'status': 'started', + 'startedAt': data.get('timestamp') if data else None + }) + + elif stage == 'end': + if 'error' in data: + await progress.emit_provider_update(model_key, { + 'provider': self._get_provider_name(model_key), + 'model': self._get_model_display_name(model_key), + 'status': 'error', + 'error': str(data['error']), + 'completedAt': data.get('timestamp') if data else None + }) + else: + response = data.get('response') + cost = data.get('cost', 0) + + if response: + await progress.emit_provider_update(model_key, { + 'provider': self._get_provider_name(model_key), + 'model': self._get_model_display_name(model_key), + 'status': 'success', + 'completedAt': data.get('timestamp'), + 'latencyMs': response.processing_time * 1000 if response.processing_time else None, + 'tokensIn': response.token_usage.input_tokens, + 'tokensOut': response.token_usage.output_tokens, + 'tokensCached': response.token_usage.cached_input_tokens, + 'costUsd': cost + }) + + # Update overall LLM_ANALYSIS progress (25% + completed/total * 50%) + completed_count = len([ + p for p in progress.job.provider_updates.values() + if p.status in ['success', 'error'] + ]) + total_count = len(self.primary_models) + analysis_progress = 25 + (completed_count / total_count) * 50 + + await progress.emit('LLM_ANALYSIS', int(analysis_progress), + f'Analysis progress: {completed_count}/{total_count} models complete') + + except Exception as e: + logging.error(f"Error in provider progress callback: {e}") + + return on_model_event + + def _get_provider_name(self, model_key: str) -> str: + """Get provider name from model key""" + try: + provider_name, _ = config.get_model_info(model_key) + return provider_name + except: + return model_key.split('-')[0] if '-' in model_key else 'unknown' + + def _get_model_display_name(self, model_key: str) -> str: + """Get display name for model""" + display_names = { + 'openai-gpt51': 'GPT-5.1', + 'anthropic-opus45': 'Claude Opus 4.5', + 'anthropic-sonnet45': 'Claude Sonnet 4.5', + 'google-gemini31': 'Gemini 3.1 Pro' + } + return display_names.get(model_key, model_key) + + async def _enhance_and_validate_results(self, uploaded_file, initial_results: ProcessingResult) -> ProcessingResult: + """Enhance results with cross-validation and gap analysis.""" + + if not initial_results.raw_data: + return initial_results + + # Load validation prompt from external file + validation_prompt_template = await self._load_prompt('validation_analysis') + validation_prompt = validation_prompt_template.format( + asset_count=len(initial_results.raw_data), + doc_type=initial_results.metadata.get('doc_type', 'unknown') + ) + + try: + # For GPT-5 using Responses API with reasoning_effort + combined_prompt = f"{validation_prompt}\n\nDocument Content:\n{uploaded_file}" + logging.info(f"=== CALLING OPENAI RESPONSES API: /v1/responses (Validation parse) ===") + logging.info(f"Model: {self.model_name}, Reasoning Effort: {self.reasoning_effort}") + # Load system message from external file + system_validation_message = await self._load_prompt('system_validation') + + response = self.model.responses.parse( + model=self.model_name, + input=[ + {"role": "system", "content": system_validation_message}, + {"role": "user", "content": combined_prompt} + ], + reasoning={"effort": self.reasoning_effort}, + text_format=AssetExtractionResult + ) + logging.info(f"=== RESPONSES API CALL COMPLETED SUCCESSFULLY (Validation) ===") + + # Track token usage for GPT-5 Responses API + if hasattr(response, 'usage'): + usage_dict = { + 'input_tokens': response.usage.input_tokens, + 'output_tokens': response.usage.output_tokens, + 'input_tokens_cached': getattr(response.usage, 'input_tokens_cached', 0) + } + self.token_usage.add_usage(usage_dict) + logging.info(f"Validation Analysis - Tokens: {usage_dict['input_tokens']} input, {usage_dict['output_tokens']} output") + + # Extract parsed data from Responses API format + parsed_result = response.output_parsed + logging.info(f"GPT-5 Validation Analysis - Parsed {len(parsed_result.assets)} additional assets") + + additional_data = [asset.model_dump() for asset in parsed_result.assets] + logging.info(f"VALIDATION DEBUG - Extracted data type: {type(additional_data)}, length: {len(additional_data)}") + + if additional_data and len(additional_data) > 0: + logging.info(f"Validation found {len(additional_data)} additional assets") + logging.info(f"VALIDATION DEBUG - Adding assets: {[asset.get('title', 'No title') for asset in additional_data]}") + initial_results.raw_data.extend(additional_data) + initial_results.processing_notes.append(f"Added {len(additional_data)} assets from validation") + logging.info(f"VALIDATION DEBUG - Total assets after validation: {len(initial_results.raw_data)}") + else: + logging.info("Validation confirmed extraction completeness") + initial_results.confidence_score = 0.95 + initial_results.processing_notes.append("Validation confirmed completeness") + if additional_data is not None: + logging.info(f"VALIDATION DEBUG - Validation returned empty array (expected when extraction is complete)") + else: + logging.warning(f"VALIDATION DEBUG - JSON extraction returned None - possible parsing issue") + + return initial_results + + except Exception as e: + logging.warning(f"Validation step failed: {e}") + initial_results.processing_notes.append(f"Validation failed: {e}") + return initial_results + + + def _extract_structured_json(self, raw_text: str) -> List[Dict[str, Any]]: + """Extract structured JSON from AI response with schema validation.""" + try: + # Log the raw response for debugging + logging.info(f"Raw response for JSON parsing: {raw_text[:200]}...") + + # Parse the structured response + structured_data = json.loads(raw_text) + + # Extract assets array from structured response + if 'assets' in structured_data: + assets = structured_data['assets'] + logging.info(f"Successfully extracted {len(assets)} assets using structured output") + return assets + else: + logging.warning("No 'assets' key found in structured response") + logging.info(f"Available keys in response: {list(structured_data.keys())}") + return [] + + except json.JSONDecodeError as e: + logging.warning(f"Structured JSON parsing failed: {e}") + logging.info(f"Raw text causing JSON error: {raw_text[:500]}...") + logging.info("Falling back to legacy parsing") + return self._extract_json(raw_text) + except Exception as e: + logging.error(f"Structured JSON extraction failed: {e}") + logging.info(f"Raw text: {raw_text[:500]}...") + return [] + + def _extract_json(self, raw_text: str) -> List[Dict[str, Any]]: + """Extract JSON from AI response using robust parsing.""" + try: + # Try direct JSON parsing first + if raw_text.strip().startswith('['): + return json5.loads(raw_text.strip()) + + # Look for JSON array in response + start_index = raw_text.find('[') + end_index = raw_text.rfind(']') + + if start_index != -1 and end_index != -1: + json_str = raw_text[start_index:end_index + 1] + return json5.loads(json_str) + + # Look for individual JSON objects + json_objects = [] + for line in raw_text.split('\n'): + line = line.strip() + if line.startswith('{') and line.endswith('}'): + try: + json_objects.append(json5.loads(line)) + except: + continue + + if json_objects: + return json_objects + + raise ValueError("No valid JSON found in response") + + except Exception as e: + logging.error(f"JSON extraction failed: {e}") + logging.debug(f"Raw text: {raw_text[:500]}...") + return [] + +def discover_supported_files(folder_path: str) -> List[str]: + """Discover all supported document files in a folder (top-level only)""" + supported_extensions = {'.pdf', '.pptx', '.docx', '.xlsx', '.ppt', '.doc', '.xls'} + supported_files = [] + + try: + for filename in os.listdir(folder_path): + # Skip hidden files + if filename.startswith('.'): + continue + + file_path = os.path.join(folder_path, filename) + + # Only process files (not subdirectories) + if os.path.isfile(file_path): + _, ext = os.path.splitext(filename) + if ext.lower() in supported_extensions: + supported_files.append(file_path) + + # Sort alphabetically for consistent processing order + supported_files.sort() + logging.info(f"Discovered {len(supported_files)} supported documents in {folder_path}") + + except Exception as e: + logging.error(f"Error discovering files in {folder_path}: {e}") + + return supported_files + +def parse_arguments(): + """Parse command line arguments""" + import argparse + + parser = argparse.ArgumentParser( + description="Enhanced Brief Processing System with Multi-Model Support", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Process single document + python process_brief_enhanced.py document.pdf + + # Process entire folder + python process_brief_enhanced.py /path/to/briefs/ + + # Custom models for batch processing + python process_brief_enhanced.py /path/to/briefs/ \ + --primary-models openai-gpt51,anthropic-sonnet45,google-gemini31 \ + --consolidation-model anthropic-opus45 + + # Cost estimation for folder + python process_brief_enhanced.py /path/to/briefs/ --estimate-cost + +Available models: openai-gpt51, anthropic-opus45, anthropic-sonnet45, google-gemini31 + """ + ) + + parser.add_argument('filepath', help='Path to document file or folder to process') + parser.add_argument( + '--primary-models', + type=str, + default=config.DEFAULT_PRIMARY_MODELS, + help=f'Comma-separated list of models for primary analysis (default: {config.DEFAULT_PRIMARY_MODELS})' + ) + parser.add_argument( + '--consolidation-model', + type=str, + default=config.DEFAULT_CONSOLIDATION_MODEL, + help=f'Model for final consolidation (default: {config.DEFAULT_CONSOLIDATION_MODEL})' + ) + parser.add_argument( + '--estimate-cost', + action='store_true', + help='Estimate processing cost before execution' + ) + + return parser.parse_args() + +async def main(): + # Enhanced logging setup + log_file = 'processing.log' + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler(log_file, mode='w'), + logging.StreamHandler(sys.stdout) + ] + ) + + # Parse command line arguments + args = parse_arguments() + + filepath = args.filepath + primary_models = args.primary_models.split(',') + consolidation_model = args.consolidation_model + + # Initialize multi-model analyzer + analyzer = DocumentAnalyzer(primary_models, consolidation_model) + + # Cost estimation if requested + if args.estimate_cost or config.ENABLE_COST_ESTIMATION: + try: + # Rough estimation based on document size + file_size = os.path.getsize(filepath) + estimated_tokens = min(file_size // 4, 50000) # Rough heuristic + + cost_breakdown = analyzer.provider_manager.estimate_total_cost( + primary_models + [consolidation_model], + estimated_tokens, + estimated_tokens // 2 + ) + + logging.info("=== COST ESTIMATION ===") + for model, cost in cost_breakdown.items(): + if model != 'total': + logging.info(f"{model}: ${cost:.4f}") + logging.info(f"Total Estimated Cost: ${cost_breakdown['total']:.4f}") + + if cost_breakdown['total'] > config.MAX_PROCESSING_COST_USD: + response = input(f"Estimated cost (${cost_breakdown['total']:.4f}) exceeds limit (${config.MAX_PROCESSING_COST_USD}). Continue? (y/N): ") + if response.lower() != 'y': + logging.info("Processing cancelled by user") + return + except Exception as e: + logging.warning(f"Cost estimation failed: {e}") + + # Determine if input is file or folder + if os.path.isdir(filepath): + # Batch processing mode + logging.info("=== ENHANCED MULTI-MODEL BATCH PROCESSING STARTED ===") + await process_batch_documents(filepath, analyzer, args) + else: + # Single file processing mode + logging.info("=== ENHANCED MULTI-MODEL BRIEF PROCESSING STARTED ===") + await process_single_document(filepath, analyzer) + +async def process_batch_documents(folder_path: str, analyzer, args): + """Process all supported documents in a folder""" + # Discover all supported files + document_files = discover_supported_files(folder_path) + + if not document_files: + logging.error(f"No supported documents found in {folder_path}") + return + + logging.info(f"Starting batch processing of {len(document_files)} documents") + + # Track batch statistics + successful_documents = [] + failed_documents = [] + total_assets = 0 + total_cost = 0.0 + + # Process each document sequentially + for i, document_path in enumerate(document_files, 1): + document_name = os.path.basename(document_path) + + # Progress reporting + logging.info(f"\\n{'='*60}") + logging.info(f"PROCESSING DOCUMENT {i}/{len(document_files)}: {document_name}") + logging.info(f"{'='*60}") + + try: + # Process single document using existing logic + results = await analyzer.process_document_multi_model(document_path) + + if results.raw_data: + # Generate output file + output_path = generate_output_file(document_path, results) + + # Track success statistics + successful_documents.append((document_name, len(results.raw_data), output_path)) + total_assets += len(results.raw_data) + + # Extract cost information if available + consolidation_metadata = results.metadata.get('consolidation_metadata', {}) + doc_cost = consolidation_metadata.get('cost_breakdown', {}).get('total_cost', 0) + total_cost += doc_cost + + logging.info(f"SUCCESS: {document_name} - {len(results.raw_data)} assets extracted") + + else: + logging.error(f"FAILED: {document_name} - No data extracted") + failed_documents.append((document_name, "No data extracted")) + + except Exception as e: + logging.error(f"FAILED: {document_name} - {str(e)}") + failed_documents.append((document_name, str(e))) + + # Final batch summary + logging.info(f"\\n{'='*60}") + logging.info("BATCH PROCESSING COMPLETE") + logging.info(f"{'='*60}") + logging.info(f"Documents processed: {len(document_files)}") + logging.info(f"Successful: {len(successful_documents)}") + logging.info(f"Failed: {len(failed_documents)}") + logging.info(f"Total assets extracted: {total_assets}") + logging.info(f"Total estimated cost: ${total_cost:.4f}") + + # Report successful documents + if successful_documents: + logging.info(f"\\nSUCCESSFUL DOCUMENTS:") + for doc_name, asset_count, output_path in successful_documents: + logging.info(f" ✅ {doc_name}: {asset_count} assets → {output_path}") + + # Report failed documents + if failed_documents: + logging.info(f"\\nFAILED DOCUMENTS:") + for doc_name, error in failed_documents: + logging.info(f" ❌ {doc_name}: {error}") + + # Print summary for PHP integration + print(f"__BATCH_SUMMARY__:{len(successful_documents)}:{len(failed_documents)}:{total_assets}:{total_cost:.4f}") + +def generate_output_file(filepath: str, results) -> str: + """Generate CSV output file for processed document""" + # Generate output path + iso_datetime = datetime.datetime.now().strftime("%Y%m%d%H%M%S") + base_name = os.path.basename(filepath) + sanitized_name = os.path.splitext(base_name)[0].replace(' ', '_').replace('.', '_') + + # Create output directory if it doesn't exist + output_dir = 'output' + os.makedirs(output_dir, exist_ok=True) + + output_filename = f"{sanitized_name}-{iso_datetime}.csv" + output_path = os.path.join(output_dir, output_filename) + + # Write CSV file + with open(output_path, 'w', newline='', encoding='utf-8') as csvfile: + writer = csv.DictWriter(csvfile, fieldnames=CSV_HEADERS, extrasaction='ignore') + writer.writeheader() + writer.writerows(results.raw_data) + + return output_path + +async def process_single_document(filepath: str, analyzer): + """Process a single document (existing logic)""" + results = await analyzer.process_document_multi_model(filepath) + + if not results.raw_data: + logging.error("No data extracted from document") + return + + # Generate output file + output_path = generate_output_file(filepath, results) + + # Log processing summary + logging.info("=== PROCESSING SUMMARY ===") + logging.info(f"Document Type: {results.metadata.get('doc_type', 'unknown')}") + logging.info(f"Assets Extracted: {len(results.raw_data)}") + logging.info(f"Confidence Score: {results.confidence_score:.2f}") + logging.info(f"Processing Notes: {', '.join(results.processing_notes)}") + logging.info(f"Output File: {output_path}") + + # Log cost information from consolidation metadata + consolidation_metadata = results.metadata.get('consolidation_metadata', {}) + cost_breakdown = consolidation_metadata.get('cost_breakdown', {}) + token_usage = consolidation_metadata.get('token_usage', {}) + + logging.info("=== COST ANALYSIS ===") + logging.info(f"Primary Models Used: {', '.join(results.metadata.get('primary_models_used', []))}") + logging.info(f"Consolidation Model: {results.metadata.get('consolidation_model', 'Unknown')}") + logging.info(f"Primary Analysis Cost: ${cost_breakdown.get('primary_analysis_cost', 0):.4f}") + logging.info(f"Consolidation Cost: ${cost_breakdown.get('consolidation_cost', 0):.4f}") + logging.info(f"Total Cost: ${cost_breakdown.get('total_cost', 0):.4f}") + logging.info(f"Total Tokens: {token_usage.get('grand_total', results.token_usage.get_total()):,}") + + # Cost info now included in ProcessingResult for GUI integration + # Legacy print statements removed as per GUI integration plan + total_cost = cost_breakdown.get('total_cost', 0) + total_tokens = token_usage.get('grand_total', results.token_usage.get_total()) + + # Only print for CLI usage (when no progress reporter) + if not hasattr(analyzer, '_is_gui_mode'): + print(f"__COST_SUMMARY__:{total_cost:.4f}") + print(f"__TOKEN_USAGE__:{token_usage.get('primary_analysis_total', 0)}:{token_usage.get('consolidation_tokens', 0)}:{total_tokens}") + print(f"__FILENAME__:{output_path}") + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/backend/data/outputs/.gitkeep b/backend/data/outputs/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/data/sheets/.gitkeep b/backend/data/sheets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/data/uploads/.gitkeep b/backend/data/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/hypercorn.toml b/backend/hypercorn.toml new file mode 100755 index 0000000..87bf365 --- /dev/null +++ b/backend/hypercorn.toml @@ -0,0 +1,25 @@ +[application] +module = "server.app:create_app()" + +[server] +bind = ["0.0.0.0:8000"] +workers = 2 +worker_class = "asyncio" + +[websockets] +ping_interval = 30 +ping_timeout = 10 + +[timeouts] +keep_alive = 5 +graceful_timeout = 30 + +[logging] +access_log = "-" +error_log = "-" +log_level = "info" + +[ssl] +# Enable for production +# certfile = "path/to/cert.pem" +# keyfile = "path/to/key.pem" \ No newline at end of file diff --git a/backend/prompts/README.md b/backend/prompts/README.md new file mode 100755 index 0000000..311163b --- /dev/null +++ b/backend/prompts/README.md @@ -0,0 +1,56 @@ +# AI Prompts Directory + +This directory contains the AI prompts used by the Enhanced Brief Processing System, extracted from the main Python code for better maintainability and editability. + +## Files Overview + +### Core Analysis Prompts +- **`multi_perspective_analysis.txt`** - Main prompt for extracting marketing deliverables from documents + - Used in `_perform_multi_perspective_analysis()` method + - Contains comprehensive extraction rules and multiplier handling logic + - Template parameter: `{doc_type}` (e.g., "powerpoint", "word", "pdf") + +- **`validation_analysis.txt`** - Quality assurance prompt for validating extractions + - Used in `_enhance_and_validate_results()` method + - Validates completeness and accuracy of initial extraction + - Template parameters: `{asset_count}`, `{doc_type}` + +### System Messages +- **`system_multi_perspective.txt`** - System message for main analysis +- **`system_validation.txt`** - System message for validation phase + +## Usage in Code + +The prompts are loaded dynamically using the `_load_prompt()` method in the `DocumentAnalyzer` class: + +```python +# Load and format prompts +prompt_template = self._load_prompt('multi_perspective_analysis') +prompt = prompt_template.format(doc_type=doc_type.value) + +system_message = self._load_prompt('system_multi_perspective') +``` + +## Benefits of External Prompts + +1. **Easy Editing** - Modify prompts without touching Python code +2. **Version Control** - Track prompt changes separately from code changes +3. **Readability** - View full prompts in text editors with proper formatting +4. **Collaboration** - Non-programmers can review and modify prompts +5. **Testing** - Easier to A/B test different prompt variations + +## Template Variables + +### multi_perspective_analysis.txt +- `{doc_type}` - Document type (powerpoint, word, pdf, excel) + +### validation_analysis.txt +- `{asset_count}` - Number of assets found in initial extraction +- `{doc_type}` - Document type from metadata + +## Modifying Prompts + +1. Edit the `.txt` files directly +2. Use standard Python string formatting for variables: `{variable_name}` +3. Test changes by running the processing script +4. No code changes required when modifying prompt content \ No newline at end of file diff --git a/backend/prompts/ac_command.txt b/backend/prompts/ac_command.txt new file mode 100644 index 0000000..4705579 --- /dev/null +++ b/backend/prompts/ac_command.txt @@ -0,0 +1,79 @@ +You are an intelligent assistant managing an Activation Calendar for an advertising agency. +Current Date: {current_date} +YOLO MODE: {yolo_mode} + +CONVERSATION HISTORY: +{conversation_history} + +CURRENT DATA (Context for your actions): +{data_context} + +Data Schema: +- Number (Auto-generated, do not invent) +- Title (String) +- Status (Enum: Booked, To-do, In Progress, Done) - Default to 'Booked' +- Category (String — must be one of the valid categories listed below) +- Media (String — must be a valid media type for the chosen Category) +- Sub-media (String — free text, optional) +- Format (String) - Extract sizes/dimensions here! e.g., '300x250', 'A4', '10x15cm', 'Full Page', '1080p'. +- Supply date (YYYY-MM-DD) +- Live date (YYYY-MM-DD) +- Language (ISO 2-letter code, UPPERCASE, e.g., 'EN', 'FR', 'ES') +- Country (ISO 2-letter code, UPPERCASE, e.g., 'GB', 'FR', 'ES') +- Quantity (Integer) + +VALID CATEGORY → MEDIA TYPES (use these exact values): +{hierarchy_rules} + +Supported Operations: +1. 'create': Create new items. + Output: {{ "operation": "create", "items": [ {{ "Title": "...", "Category": "...", "Media": "...", "Format": "300x250", ... }} ] }} + +2. 'update': Update existing items. + Output: {{ "operation": "update", "target_ids": ["DEL-001"], "values": {{ "Status": "Done" }} }} + +3. 'batch_update': Update multiple items with DIFFERENT values. + Output: {{ "operation": "batch_update", "updates": [ {{ "Number": "DEL-001", "values": {{ "Title": "Row 1" }} }} ] }} + +4. 'question': Ask for clarification (ONLY if YOLO MODE is FALSE). + Output: {{ "operation": "question", "text": "Did you mean 2025 or 2026?" }} + +IMPORTANT BRAIN RULES: + +0. **CRITICAL - MULTIPLE ITEMS vs QUANTITY**: + - When user says "add 10 deliverables" or "create 5 banners", create that many SEPARATE items in the array. + - NEVER use Quantity field to represent the count. Quantity should always be 1 unless explicitly stated. + - **MATH VALIDATION (MANDATORY)**: + * BEFORE creating items, COUNT how many items your pattern will create. + * If the user says "X items" but your pattern creates Y ≠ X, use 'question' operation. + * EXCEPTION: If the user confirms a count, EXECUTE immediately without asking again. + +1. **FORMAT EXTRACTION**: + - ALWAYS use 'x' as separator for dimensions. NEVER use 'by'. + - '300 by 250' → '300x250', '30 by 30 cm' → '30x30cm'. + - Print sizes: 'A4', 'A3', 'Full Page', 'Half Page'. + +2. **YOLO MODE (HIGHEST PRIORITY)**: + - If YOLO MODE is TRUE: YOU ARE FORBIDDEN FROM ASKING QUESTIONS. + - Always guess missing information. NEVER return 'question' operation. + +3. **CLARIFICATION RECOVERY**: + - The user's current input is likely an ANSWER to your previous question. + - COMBINE it with previous user messages in the history to form a complete request. + - If the user confirms the count, EXECUTE immediately. + +4. **CONTEXT IS KING**: Use CURRENT DATA to resolve references like "the French ones". + +5. **INFER FIELDS**: + - "UK" or "Great Britain" → Country='GB' + - "English" → Language='EN', "French" → Language='FR', "Spanish" → Language='ES' + - Match category/media names to the VALID CATEGORY list above as closely as possible. + +6. **PATTERN RECOGNITION**: + - Extract formats from phrases like "200 by 200", "300x300", "400x400 banner". + - Sequences: "first 5", "next 4", "remaining" for language/country assignments. + +CRITICAL: Respond with ONLY valid JSON. No explanations, no markdown. +Your response must be a single JSON object starting with {{ and ending with }}. + +User Command: "{command}" diff --git a/backend/prompts/backup_old/consolidation_analysis.txt b/backend/prompts/backup_old/consolidation_analysis.txt new file mode 100755 index 0000000..b974473 --- /dev/null +++ b/backend/prompts/backup_old/consolidation_analysis.txt @@ -0,0 +1,124 @@ +You are an expert data consolidation specialist tasked with intelligently merging multiple LLM analysis results into a single, comprehensive dataset of marketing deliverables. Your goal is to create the most complete and accurate final output by combining the best elements from each model's analysis. + +**CONSOLIDATION STRATEGY - BIAS TOWARD COMPLETENESS:** + +1. **INCLUSION PHILOSOPHY**: "If ANY model found it, include it" - better to capture all potential deliverables than miss important ones +2. **SMART DEDUPLICATION**: Remove true duplicates while preserving legitimate variations +3. **QUALITY ENHANCEMENT**: Use the most detailed/accurate specifications from any model +4. **COMPLETENESS VERIFICATION**: Ensure no deliverables discovered by any model are lost + +**INPUT ANALYSIS:** +You will receive multiple JSON arrays from different LLM models, each containing their analysis of the same document. Each model may have: +- Found different deliverables that others missed +- Provided varying levels of detail for the same deliverables +- Made different interpretation choices for specifications +- Captured different multiplier arrays (sizes, markets, languages, etc.) + +**CONSOLIDATION PROCESS:** + +**STEP 1: COMPREHENSIVE INVENTORY** +- Extract ALL unique deliverable titles found across all models +- Note which models identified each deliverable +- Identify potential duplicates vs. legitimate variations + +**STEP 2: INTELLIGENT DEDUPLICATION WITH UNIQUENESS ANALYSIS** +- **DUPLICATE IDENTIFICATION CRITERIA**: Compare deliverables across ALL data points: + - Title/name (normalized for minor variations) + - Technical specifications (dimensions, formats, requirements) + - Markets/countries served + - Languages supported + - Asset types and media formats + - Creative direction and requirements + - Any other distinguishing characteristics + +- **UNIQUENESS DECISION MATRIX**: + - **IDENTICAL DUPLICATES**: All major data points substantially the same → MERGE into single deliverable + - **LEGITIMATE VARIATIONS**: At least ONE significant data point differs → KEEP as separate deliverable + - **TITLE NORMALIZATION**: Standardize similar titles ("Social Media Assets" vs "Social Assets") but preserve unique specifications + - **SPECIFICATION CONSOLIDATION**: For true duplicates, combine the most comprehensive specs from all models + +- **SIGNIFICANT DIFFERENCE EXAMPLES**: + - Different technical specs: "1080x1080" vs "1080x1920" = UNIQUE + - Different markets: "UK,DE,FR" vs "UK,DE,FR,ES,IT" = UNIQUE (unless one is subset) + - Different asset types: "JPG" vs "PNG" = UNIQUE + - Different creative requirements: "Static banner" vs "Animated banner" = UNIQUE + - Different quantities/scales: "5 assets" vs "20 assets" = UNIQUE + +- **SUBTLE DUPLICATE EXAMPLES**: + - "Social Media Posts" vs "Social Posts" with identical specs = DUPLICATE (merge) + - "Display Banner Set" vs "Display Banners" with same dimensions = DUPLICATE (merge) + - Same deliverable found by multiple models with identical specs = DUPLICATE (merge) + +**STEP 3: QUALITY ENHANCEMENT FOR UNIQUE DELIVERABLES** +For each confirmed unique deliverable, select the BEST information available: +- **Most Complete Technical Specifications**: Use the model that provided the most detailed specs +- **Comprehensive Markets/Languages**: Combine all markets/languages found by any model for THIS deliverable +- **Best Multiplier Arrays**: Merge arrays to capture all variations discovered for THIS deliverable +- **Richest Context**: Use the most descriptive creative direction and reference material +- **Optimal Naming**: Choose the clearest, most descriptive title from all model variants + +**CONSOLIDATION EXAMPLES:** + +**Example 1 - Combining Multiplier Arrays:** +Model A found: `"technical_specifications": ["1080x1920", "1200x1500"]` +Model B found: `"technical_specifications": ["1080x1920", "1080x1080", "1200x1500"]` +Model C found: `"technical_specifications": ["1080x1920", "1200x1500", "1000x1000"]` +**RESULT**: `"technical_specifications": ["1080x1920", "1200x1500", "1080x1080", "1000x1000"]` + +**Example 2 - Market Consolidation:** +Model A: `"country": ["UK", "DE", "FR"]` +Model B: `"country": ["UK", "DE", "FR", "ES", "IT"]` +Model C: `"country": ["UK", "DE"]` +**RESULT**: `"country": ["UK", "DE", "FR", "ES", "IT"]` (most comprehensive) + +**Example 3 - Avoiding False Duplicates (SIGNIFICANT DIFFERENCE):** +Model A: `"title": "Social Media Assets", "technical_specifications": ["1080x1080", "1080x1920"]` +Model B: `"title": "Social Media Banners", "technical_specifications": ["728x90", "300x250"]` +**ANALYSIS**: Technical specs are completely different (social vs display dimensions) +**RESULT**: Keep both - these are different asset types with unique specifications + +**Example 4 - True Duplicate Resolution (IDENTICAL CORE):** +Model A: `"title": "Display Banners", "technical_specifications": ["728x90", "300x250"], "country": ["UK", "DE"]` +Model B: `"title": "Display Banner Set", "technical_specifications": ["728x90", "300x250", "970x250"], "country": ["UK", "DE", "FR"]` +**ANALYSIS**: Same asset type, overlapping specs, overlapping markets - Model B has additional specs/markets +**RESULT**: Merge into one with enhanced specs: `"title": "Display Banners", "technical_specifications": ["728x90", "300x250", "970x250"], "country": ["UK", "DE", "FR"]` + +**Example 5 - Intelligent Duplicate Detection:** +Model A: `"title": "Instagram Stories", "technical_specifications": ["1080x1920"], "country": ["UK", "DE"], "asset_type": "JPG"` +Model B: `"title": "Instagram Story Graphics", "technical_specifications": ["1080x1920"], "country": ["UK", "DE"], "asset_type": "JPG"` +Model C: `"title": "Instagram Stories", "technical_specifications": ["1080x1920"], "country": ["UK", "DE", "FR"], "asset_type": "JPG"` +**ANALYSIS**: All refer to same deliverable type with identical core specs - Model C has additional market +**RESULT**: Merge into one: `"title": "Instagram Stories", "technical_specifications": ["1080x1920"], "country": ["UK", "DE", "FR"], "asset_type": "JPG"` + +**Example 6 - Preserving Legitimate Variations:** +Model A: `"title": "YouTube Thumbnails", "technical_specifications": ["1280x720"], "country": ["UK"], "asset_type": "JPG"` +Model B: `"title": "YouTube Thumbnails", "technical_specifications": ["1280x720"], "country": ["UK"], "asset_type": "PNG"` +**ANALYSIS**: Same deliverable but different file format requirement - significant difference +**RESULT**: Keep both as separate deliverables - different asset_type is a significant difference + +**FINAL QUALITY CHECKS:** +- **Uniqueness Verification**: Ensure each deliverable in final output differs from all others by at least one significant data point +- **Completeness Check**: Verify no legitimate unique deliverable was lost during deduplication +- **Consolidation Validation**: Confirm merged deliverables contain the best specifications from all contributing models +- **Format Consistency**: Check that multiplier arrays are properly formatted +- **Technical Validation**: Validate technical specifications are realistic/consistent +- **Logical Count**: Final count should reflect unique deliverables, not raw model outputs + +**OUTPUT REQUIREMENTS:** +Return a JSON object with a single "assets" array containing the final set of UNIQUE BaseDeliverable objects with multiplier arrays intact. Each deliverable should: +- Be truly unique (differ from all others by at least one significant data point) +- Represent the best composite specifications from all contributing models +- Maintain the inclusive philosophy while eliminating genuine duplicates +- Include comprehensive multiplier arrays capturing all legitimate variations discovered + +**CONSOLIDATION PHILOSOPHY SUMMARY:** +- **INCLUSIVE**: If any model found a unique deliverable, include it +- **INTELLIGENT**: Merge true duplicates to avoid redundancy +- **COMPREHENSIVE**: Each final deliverable should contain the best information from all models +- **UNIQUE**: Every deliverable in final output must differ meaningfully from others + +**MODELS' ANALYSIS RESULTS:** + +{models_results} + +**TASK**: Consolidate these results into a single, comprehensive array of base deliverables that captures ALL legitimate deliverables found by ANY model, with enhanced quality from the best specifications discovered across all models. \ No newline at end of file diff --git a/backend/prompts/backup_old/multi_perspective_analysis.txt b/backend/prompts/backup_old/multi_perspective_analysis.txt new file mode 100755 index 0000000..2bbc3c5 --- /dev/null +++ b/backend/prompts/backup_old/multi_perspective_analysis.txt @@ -0,0 +1,162 @@ +You are an expert data extraction specialist analyzing this {doc_type} document to extract base marketing deliverables with multiplier arrays. Your task is to create structured data objects that capture the base deliverable along with all its multipliers (specifications, markets, languages, etc.) as arrays, which will be expanded into individual deliverables later. + +**MULTIPLIER-BASED EXTRACTION METHOD (HIGHEST PRIORITY):** +1. **BASE DELIVERABLE APPROACH**: Extract the base name/type of each unique deliverable, then identify all multiplier arrays for that deliverable +2. **MULTIPLIER IDENTIFICATION - CRITICAL FOR ACCURACY**: Look for lists of attributes in deliverable specifications: + - **Technical Specifications**: Multiple sizes, formats, or dimensions (use array) + - **Markets/Countries**: Multiple country codes or regions (use array) + - **Languages**: Multiple language codes or localization requirements (use array) + - **Formats**: Multiple file types or variations (use array) + - **Platforms**: Multiple delivery platforms or channels (use array) + - **MULTIPLE LISTS IN SINGLE COLUMN**: If you find multiple multiplier lists in one column (e.g., both products AND markets listed together), separate them into appropriate fields to capture all multipliers +3. **ARRAY VS STRING DECISION**: + - Use **arrays** when you find multiple values that represent variations of the same deliverable (e.g., ["1080x1920", "1200x1500", "1080x1080"]) + - Use **strings** when there's only one value (e.g., "JPG") + - **CONTEXT IS KEY**: Use context to determine if a list represents multipliers (variations) or descriptive information +4. **QUANTITY VERIFICATION**: If a QUANTITY column shows a number, note it for verification (the final expanded count should match) +5. **INTELLIGENT DEDUPLICATION**: Process all deliverable sections but avoid duplicates: + - **Overview vs Detail Sections**: If brief has overview tables AND detailed specification pages, extract from the most comprehensive source + - **Duplicate Detection**: Same deliverable name with same specifications = potential duplicate + - **Section Priority**: Prioritize structured tables over descriptive text sections + +**MULTIPLIER ARRAY EXTRACTION EXAMPLES:** + +**Example 1 - Multiple Specifications:** +Table row: "Paid Social – Meta Static Sizes" with SPEC "8x 1080 x 1920px, 8x 1200 x 1500px, 1x 1080 x 1080" +Extract as: +``` +{{ + "title": "Paid Social - Meta Static Sizes", + "technical_specifications": ["1080x1920", "1200x1500", "1080x1080"], + "media": "IMAGE", + "asset_type": "JPG" +}} +``` +This will expand to 17 individual deliverables (8+8+1). + +**Example 2 - Multiple Markets:** +Table row: "Meta Copy" for "MARKETS: UK, DE, FR, ES, IT, NL, PL, SE, DK, NO, FI, IE, GR, PT, BE, CZ, SK, CH, AT" +Extract as: +``` +{{ + "title": "Meta Copy", + "country": ["UK", "DE", "FR", "ES", "IT", "NL", "PL", "SE", "DK", "NO", "FI", "IE", "GR", "PT", "BE", "CZ", "SK", "CH", "AT"], + "technical_specifications": ["Body Copy", "Headline", "Description"] +}} +``` +This will expand to 57 individual deliverables (3 copy types × 19 markets). + +**Example 3 - Combined Multipliers:** +Table row: "Display Banners" with 8 sizes for 20 markets +Extract as: +``` +{{ + "title": "Display - Celtra Static Banners", + "technical_specifications": ["160x600", "300x250", "300x600", "728x90", "970x250", "320x50", "320x100", "336x280"], + "country": ["UK", "DE", "ES", "IT", "FR", "BE", "NL", "PL", "GR", "CZ", "SE", "DK", "PT", "CH", "SK", "RO", "HR", "FI", "NO", "AT"], + "media": "IMAGE", + "asset_type": "JPG" +}} +``` +This will expand to 160 individual deliverables (8 sizes × 20 markets). + +**Example 4 - Multiple Lists in Single Column:** +Table cell contains: "Products: Ultraboost, Supernova, Adistar | Markets: UK, DE, FR, ES, IT" +Extract as: +``` +{{ + "title": "Product Marketing Assets", + "category": ["Ultraboost", "Supernova", "Adistar"], + "country": ["UK", "DE", "FR", "ES", "IT"], + "media": "IMAGE" +}} +``` +This will expand to 15 individual deliverables (3 products × 5 markets). + +**Example 5 - Deduplication Case:** +- Page 2: Overview table shows "Social Media Assets: Quantity 20" +- Pages 4-8: Individual pages for each social platform with detailed specs +- **CORRECT APPROACH**: Extract from overview with multiplier arrays, NOT as 20 separate base deliverables +``` +{{ + "title": "Social Media Assets", + "technical_specifications": ["1080x1080", "1080x1920", "1200x1500", "1000x1500"], + "category": ["Meta", "Instagram", "Twitter", "LinkedIn", "TikTok"], + "quantity": "20" +}} +``` + +**SYSTEMATIC TABLE PROCESSING WITH DEDUPLICATION:** +- **DELIVERABLE TABLES ARE PRIORITY #1** - Focus on structured tables with deliverable information +- **SECTION HIERARCHY** - Process sections in this priority order: + 1. **Main Deliverable Tables** - Comprehensive tables with quantities and specifications + 2. **Overview Sections** - High-level summaries (use for validation, not primary extraction) + 3. **Detail Pages** - Individual deliverable descriptions (avoid if already captured in main tables) +- **MULTIPLIER DETECTION IN SPECIFICATIONS** - Look carefully for: + - **Lists within cells**: "8x 1080x1920, 4x 1200x1500, 2x 1080x1080" → Array of specs + - **Market/language lists**: "Markets: UK, DE, FR, ES, IT" → Array of countries + - **Combined lists**: If specs AND markets appear in same cell, separate into different fields + - **Size variations**: "Mobile (320x50), Desktop (728x90), Large (970x250)" → Array of specs +- **INTELLIGENT DEDUPLICATION** - Avoid double-counting: + - **Same deliverable name** + **same specifications** = Skip the duplicate + - **Overview → Detail pattern**: If overview mentions "5 banners" and detail pages show 5 individual banners, extract from overview with multipliers, NOT 5 separate base deliverables + - **Section redundancy**: If multiple sections describe the same deliverable set, use the most comprehensive one +- **BASE DELIVERABLE IDENTIFICATION** - For each unique deliverable, extract: + - Base deliverable name/title (without duplicates) + - All multiplier values as arrays (specs, markets, languages, formats) + - Single values as strings (when no multipliers exist) + +**FIELD EXTRACTION GUIDELINES:** + +**Technical Specifications:** +- Use **arrays** for multiple dimensions/specs: `["1080x1920", "1200x1500", "1080x1080"]` +- Use **strings** for single specifications: `"1920x1080"` +- Include file formats, dimensions, durations, and technical requirements +- Extract exactly as written in source document + +**Country/Markets:** +- Use **arrays** for multiple markets: `["UK", "DE", "FR", "ES", "IT", "NL", "PL"]` +- Use **strings** for single market: `"UK"` +- Use two-letter country codes consistently +- Extract all countries/regions mentioned for that deliverable + +**Languages:** +- Use **arrays** for multiple languages: `["EN", "DE", "FR", "ES"]` +- Use **strings** for single language: `"EN"` +- Use standard language codes when available + +**Asset Types:** +- Use technical file formats: "JPG", "PNG", "MP4", "GIF" +- Use **arrays** if multiple formats: `["JPG", "PNG"]` + +**Media Types:** +- Use broad categories: "IMAGE", "VIDEO", "COPY", "INTERACTIVE" +- Use **arrays** for mixed media: `["IMAGE", "VIDEO"]` + +**Quantity Field:** +- Note the expected total from QUANTITY columns for verification +- This will be checked against final expanded count + +**EXTRACTION REQUIREMENTS:** +1. **NO HALLUCINATION**: NEVER invent or assume information. If a detail is not present, leave the corresponding field empty +2. **ALL PAGES**: Ensure extraction from ALL pages in the document, not just the first one +3. **EXACT SPECIFICATIONS**: Capture specifications exactly as written in the source document +4. **BASE DELIVERABLE FOCUS**: Extract base deliverables with their multiplier arrays, not individual expanded objects +5. **MULTIPLIER VIGILANCE**: Be especially alert for multiplier lists in specification cells - missed arrays lead to under-counting +6. **DEDUPLICATION DISCIPLINE**: Avoid extracting the same deliverable multiple times from different sections - this leads to over-counting +7. **CONTEXT ANALYSIS**: Consider the entire document structure to understand relationships between overview tables, main tables, and detail sections + +**MULTIPLIER ARRAY EXAMPLES:** +- **Single spec**: `"technical_specifications": "1920x1080"` +- **Multiple specs**: `"technical_specifications": ["1080x1920", "1200x1500", "1080x1080"]` +- **Single market**: `"country": "UK"` +- **Multiple markets**: `"country": ["UK", "DE", "FR", "ES", "IT", "NL", "PL"]` +- **Copy types**: `"technical_specifications": ["Body Copy", "Headline", "Description"]` +- **Banner sizes**: `"technical_specifications": ["160x600", "300x250", "300x600", "728x90", "970x250"]` + +**EXPECTED EXPANSION EXAMPLES:** +- 3 specs × 7 markets = 21 final deliverables +- 8 banner sizes × 20 markets = 160 final deliverables +- 3 copy types × 19 markets = 57 final deliverables + +Return a structured JSON object with an array of base deliverables containing multiplier arrays that will be expanded into individual assets during processing. \ No newline at end of file diff --git a/backend/prompts/consolidation_analysis.txt b/backend/prompts/consolidation_analysis.txt new file mode 100755 index 0000000..8726879 --- /dev/null +++ b/backend/prompts/consolidation_analysis.txt @@ -0,0 +1,130 @@ +You are an expert data consolidation specialist tasked with merging multiple LLM analysis results into a single, comprehensive dataset of marketing deliverables. Combine the best elements from each model while eliminating true duplicates. + +**CONSOLIDATION STRATEGY — INCLUSIVE, NORMALIZED, DEDUPED** +1) **Inclusion bias**: If ANY model found a legitimately unique deliverable, include it. +2) **Normalization before dedup**: Canonicalize fields so similar items can merge. +3) **Smart dedup**: Merge only when core identity is the same; preserve real variations. +4) **Completeness**: Ensure no legitimate deliverable is lost. + +--- + +## PRE‑NORMALIZATION (REQUIRED) +Apply these canonical rules to **every** candidate asset prior to deduplication: + +- **Title optimization (descriptive base names without multipliers)** + - Create **distinctive, specific titles** that will remain meaningful after variable expansion: + `{{Deliverable Type}} - {{Platform/Channel}} {{Content Type}} ({{Campaign/Initiative}})` + - **Balance specificity with consistency**: Preserve platform/content distinctions while normalizing similar deliverables + - **Examples**: `"Paid Social - Meta Feed Posts (Summer Campaign)"`, `"Display - Programmatic Banners (Q4 Launch)"`, `"Video Content - TikTok Stories (Brand Awareness)"` + - Strip **locations/identifiers, markets, languages, sizes, formats, and counts** from titles. + - If a title appears to be a **location/identifier**, move that value into the `language_country_market` array and replace with descriptive title using the template above. + +- **Category normalization (String Field)** + - If a model separated **type** and **component** or used synonyms/variants, normalize to a single string: + `category = "{{Deliverable Type}} - {{Component/Subtype}}"` (when both exist; else use the available one as string). + - Treat top‑level taxonomy labels as **metadata**, not multipliers - use single string values. + +- **Media/specs normalization (Mixed Schema)** + - Standardize `media` to single strings: `"IMAGE"`, `"VIDEO"`, `"COPY"`, `"INTERACTIVE"` (create separate deliverables if truly mixed media). + - For `technical_specifications` (array field): If multiple models provide the same single‑spec text (e.g., "As per supplied file"), keep it as single-item array: `["As per supplied file"]`. If any model lists multiple sizes/specs, keep them as multi-item array (union of unique values): `["1080x1080", "1080x1920", "1200x1500"]`. + +- **Reference material** + - Prefer the most authoritative/complete links (combine if non-duplicates). + +- **Location/market handling** + - Use `brand_identifier` as **string** for the main brand/client name. + - Use `language_country_market` **array** for location/market multipliers. Move any location/store/partner values found in `title` or other fields into this array using ISO format (e.g., ["EN-UK", "DE-DE"]). + +--- + +## DEDUPLICATION LOGIC +- Build a **deduplication key** for each asset **after normalization** using: + - `normalized_title + normalized_category + media + technical_specifications + asset_type (if any) + reference_material (if any)` +- **Merge** assets with identical keys by: + - **Unioning** multiplier arrays (`technical_specifications`, `language_country_market`). + - Keeping the most complete/authoritative values for string fields (prefer longer/explicit spec text, keep earliest `review_date` if included, etc.). + - **Quantity validation**: Use the highest quantity value as target for merged deliverable. +- **Location‑titled variants**: If two assets are identical except one used a location as its title, treat them as the same and **merge** (move location into `language_country_market` array). +- **Not significant for uniqueness** (merge): + - Differences limited to capitalization, whitespace, or taxonomy labels (e.g., having only Type vs. Component or minor synonyms) without any spec/media change. +- **Significant differences (keep separate)**: + - Different `technical_specifications` (sizes, duration, technical requirements) + - Different `asset_type` or `media` + - Materially different creative/production requirements that change the output + - Distinct platform/channel sets when they imply different production outputs + +--- + +## QUALITY ENHANCEMENT +- For each unique deliverable: + - Choose the **most complete** specification set for `technical_specifications` array. + - **Union** all markets/languages/locations from `language_country_market` arrays from all models for that deliverable. + - Keep a clear, normalized **title** (no multipliers) and a normalized **category** string. + - **Validate quantity**: Ensure technical_specifications × language_country_market ≈ quantity value. + +--- + +## COMPLETENESS & COUNT CHECK +- Verify that every location/market/language found by any model appears (deduped) in the `language_country_market` array of the final deliverable. +- If overview sections imply the same base deliverable repeated across many locations, the final result should be **one base deliverable** with a populated `language_country_market` array whose length matches the unique values extracted. +- **Quantity validation**: Final expansion (technical_specifications × language_country_market) should approximately equal the `quantity` value. + +--- + +## OUTPUT REQUIREMENTS +Return a JSON object with a single `"assets"` array containing the final set of **unique** BaseDeliverable objects with optimized multiplier structure. Each item must: +- Use the **normalized title** template (no multipliers in title). +- Use a **single normalized `category`** string. +- Include **only 2 multiplier arrays**: `technical_specifications` and `language_country_market`. +- Have `quantity` as **string** that validates the multiplication: technical_specifications × language_country_market ≈ quantity. +- Differ from all others by at least one **significant** data point (see above). + +--- + +## EXAMPLES (generic) + +**Example — per‑location titles collapse into one asset** +Model A: +{{ + "title": "Channel - Placement (Initiative)", + "category": "Channel - Placement", + "media": "IMAGE", + "technical_specifications": ["As per supplied file"], + "reference_material": "", + "brand_identifier": "Client Brand", + "language_country_market": ["EN-Location-A", "EN-Location-B"], + "quantity": "2" +}} +Model B: +{{ + "title": "1234 - Location A", + "category": "Placement", + "media": "IMAGE", + "technical_specifications": ["As per supplied file"], + "language_country_market": ["EN-Location-A"], + "quantity": "1" +}} +**Result (merged)**: +{{ + "title": "Channel - Placement (Initiative)", + "category": "Channel - Placement", + "media": "IMAGE", + "technical_specifications": ["As per supplied file"], + "reference_material": "", + "brand_identifier": "Client Brand", + "language_country_market": ["EN-Location-A", "EN-Location-B"], + "quantity": "2" +}} + +**Example — keep separate when file formats differ** +- Asset 1: `"asset_type":"JPG"` +- Asset 2: `"asset_type":"PNG"` +→ Significant difference → keep both; assign each the appropriate subset of multipliers. + +--- + +## MODELS' ANALYSIS RESULTS + +{models_results} + +**TASK**: Consolidate these results into a single, comprehensive array of base deliverables following the strategy above. \ No newline at end of file diff --git a/backend/prompts/multi_perspective_analysis.txt b/backend/prompts/multi_perspective_analysis.txt new file mode 100755 index 0000000..15e2214 --- /dev/null +++ b/backend/prompts/multi_perspective_analysis.txt @@ -0,0 +1,114 @@ +You are an expert data extraction specialist analyzing this {doc_type} document to extract base marketing deliverables with multiplier arrays. Your task is to create structured data objects that capture the base deliverable along with all its multipliers (sizes/specs, markets, languages, locations, etc.) as arrays, which will be expanded later. + +**MULTIPLIER-BASED EXTRACTION METHOD (HIGHEST PRIORITY)** +1) **Base-first approach**: Identify each unique base deliverable; then attach all multiplier arrays to that base. +2) **What counts as a multiplier** (make arrays): + - **Technical Specifications**: multiple dimensions, durations, versions (“8x 1080x1920; 1x 1080x1080” → ["1080x1920","1080x1080"]) + - **Language-Country-Market Combinations**: language-country pairs or region codes using ISO format (e.g., "EN-UK", "DE-DE", "FR-FR") + - **Formats/Files**: multiple file types or variations (e.g., ["JPG","PNG"]) + - **Platforms/Channels/Placements**: when the same deliverable must be produced for multiple platforms/channels (e.g., Meta, TikTok, X) + - **Location/Market Variations**: when deliverable must be adapted for different locations/markets → use **language_country_market** array (e.g., ["EN-6177", "EN-A12"] for location codes or ["EN-UK", "DE-DE"] for country markets) + - **Multiple lists in one cell**: split logically (e.g., products vs. markets). + +3) **What is NOT a multiplier by default** (treat as fixed metadata unless the brief clearly specifies distinct variants): + - **Top‑level taxonomy labels** such as **Deliverable Type** and **Component/Subtype** used as headings or constant column values. + - **Campaign/Project/Initiative name**. + If the document presents multiple **distinct** variants that differ in specs, formats, or media, create **separate base deliverables** (each with its own multipliers). + +4) **Field Type Usage (Mixed Schema)** + - **String fields** (metadata): Use single string values for `status`, `category`, `media`, `asset_type`, `brand_identifier`, dates, `reference_material`, `page_number`, `priority_level`, `creative_direction` + - **Array fields** (multipliers): Use arrays only for `technical_specifications`, `language_country_market` + - **Single values**: `"IMAGE"`, `"JPG"`, `"Draft"` for string fields; `["1920x1080"]` for single-value arrays + - **Multiple values**: `["1080x1080", "1080x1920"]`, `["EN-UK", "DE-DE", "FR-FR"]` for true multipliers + +5) **Quantity validation and sense-check** + - Set `quantity` as a **string** representing the total expected deliverables: `"50"`. + - **CRITICAL**: Use quantity as a validation check - the multiplication of your array fields should approximately equal the quantity. + - **Example**: If quantity is `"50"` and you set technical_specifications to 5 items and language_country_market to 10 items, that gives 5×10=50 ✅ + - **Avoid over-specification**: If quantity is `"20"` but you're tempted to list 30 countries and 8 technical specs (=240 deliverables), reduce the arrays to match the target quantity. + +6) **Section priority & deduplication** + - **Priority**: (1) main/overview deliverable tables; (2) summarized overviews; (3) detail pages (only for notes/validation if already captured). + - If an overview table lists many rows that vary only by **market/location/identifier** while core type/spec/media are identical, extract **one base deliverable** and put all the varying values into the `language_country_market` array. + - Prefer the most structured/comprehensive section when conflicts arise. + +--- + +**TITLE, CATEGORY & FIELD NORMALIZATION (REQUIRED)** +To enable consistent consolidation across models, normalize these fields deterministically: + +- **Title (descriptive base names without multipliers)** + - Create **distinctive, descriptive titles** that differentiate deliverable types: + - Template: `{{Deliverable Type}} - {{Platform/Channel}} {{Content Type}} ({{Campaign/Initiative}})` + - Examples: `"Paid Social - Meta Static Images (Summer Campaign)"`, `"Display - Programmatic Banners (Q4 Launch)"`, `"Video Content - TikTok Ads (Brand Awareness)"` + - **Include distinguishing context**: Platform, content type, campaign name, or creative format + - **Do NOT include** locations, markets, languages, sizes, file types, or counts in the title. + - **Aim for specificity**: Avoid overly generic titles like "Social Media Assets" - be more specific like "Social Media - Instagram Stories" or "Social Media - Meta Feed Posts"` + +- **Category (single string)** + - If both a **type** and **component/subtype** exist, normalize to one string: + `category = "{{Deliverable Type}} - {{Component/Subtype}}"` + - Do **not** split these into separate deliverables or arrays unless specs actually differ. + +- **Media & Specs** + - Set `media` to one of: `"IMAGE"`, `"VIDEO"`, `"COPY"`, `"INTERACTIVE"` (array if mixed). + - Copy `technical_specifications` **exactly as written**. If it’s a single instruction (e.g., “As per supplied file”), keep it as a string; if multiple sizes/requirements, use an array. + +- **Reference material** + - If the brief provides source links (assets, style guides, mockups), place them in `reference_material` (string or array if multiple). + +- **Location & market identifiers** + - Use `language_country_market` for location/market multipliers (store IDs, venue codes, market codes, etc.). Format as language-location pairs when possible (e.g., `["EN-6177", "EN-A12"]` for store codes or `["EN-UK", "DE-DE"]` for country markets). + - Use `brand_identifier` as single string for the main brand/client name (e.g., `"Adidas"`, `"Nike"`). + +--- + +**FIELD EXTRACTION GUIDELINES (Mixed Schema)** + +**ARRAY FIELDS (Multipliers Only):** +- **technical_specifications**: `["1920x1080"]` for one spec; `["1080x1080", "1080x1920", "1200x1500"]` for multiple sizes/specs +- **language_country_market**: `["EN-UK"]` for single market; `["EN-UK", "DE-DE", "FR-FR", "ES-ES"]` for multiple markets using ISO codes (Language-Country format) + +**STRING FIELDS (Metadata Only):** +- **status**: `"Draft"` - single status value +- **category**: `"Social Media"` - single category designation +- **media**: `"IMAGE"` - single media type (create separate deliverables if truly mixed media) +- **asset_type**: `"JPG"` - single file format (create separate deliverables for different formats) +- **brand_identifier**: `"Adidas"` - single brand/client name +- **quantity**: `"50"` - VALIDATION FIELD: total expected deliverables (technical_specifications × language_country_market should ≈ this number) +- **review_date**: `"2025-09-30"` - single date +- **live_date**: `"2025-10-15"` - single date +- **reference_material**: `"As per style guide"` - single reference +- **page_number**: `"5"` - single page reference +- **priority_level**: `"High"` - single priority +- **creative_direction**: `"Brand colors, clean layout"` - single creative approach + +--- + +**EXAMPLES (generic)** +- **Many locations with identical core fields → one base deliverable** + Output: + {{ + "title": "Channel - Placement (Initiative Name)", + "category": "Channel - Placement", + "media": "IMAGE", + "technical_specifications": ["As per supplied file"], + "reference_material": "", + "brand_identifier": "Client Brand", + "language_country_market": ["EN-UK", "DE-DE", "FR-FR"], + "quantity": "3" + }} + *(Expands to N deliverables = number of identifiers.)* + +- **Specs truly differ → split by spec** + If a subset requires extra sizes or a different file type, create a second base deliverable with its own `brand_identifier` subset and distinct `technical_specifications`/`asset_type`. + +--- + +**EXTRACTION REQUIREMENTS** +1) **No hallucination** — leave unknown fields empty +2) **All pages/sections considered** — prefer structured tables +3) **Exact specs** — copy text verbatim +4) **Base deliverable focus** — do not output one base deliverable per market/location if only those vary +5) **Multiplier vigilance** — locations, markets, languages, and sizes are multipliers; taxonomy headings are not +6) **Dedup discipline** — normalize titles/categories as above to avoid duplicates \ No newline at end of file diff --git a/backend/prompts/system_multi_perspective.txt b/backend/prompts/system_multi_perspective.txt new file mode 100755 index 0000000..e3bcda5 --- /dev/null +++ b/backend/prompts/system_multi_perspective.txt @@ -0,0 +1 @@ +You are an expert data extraction specialist. Extract base marketing deliverables with multiplier arrays, focusing on accurate multiplier detection and intelligent deduplication to avoid both under-counting and over-counting deliverables. \ No newline at end of file diff --git a/backend/prompts/system_validation.txt b/backend/prompts/system_validation.txt new file mode 100755 index 0000000..afb1dde --- /dev/null +++ b/backend/prompts/system_validation.txt @@ -0,0 +1 @@ +You are performing quality assurance on asset extraction. Identify any missing assets. \ No newline at end of file diff --git a/backend/prompts/universal_schema.json b/backend/prompts/universal_schema.json new file mode 100755 index 0000000..faae3a0 --- /dev/null +++ b/backend/prompts/universal_schema.json @@ -0,0 +1,93 @@ +{ + "name": "base_deliverable_extraction", + "description": "Extract base deliverables with multiplier arrays from document analysis", + "schema": { + "type": "object", + "properties": { + "assets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Asset title or name (normalized base deliverable name without multipliers)" + }, + "status": { + "type": "string", + "description": "Current status (e.g., 'Draft', 'In Progress', 'Final')" + }, + "category": { + "type": "string", + "description": "Asset category (e.g., 'Social Media', 'Display Advertising', 'Video Content')" + }, + "media": { + "type": "string", + "description": "Media type (e.g., 'IMAGE', 'VIDEO', 'COPY', 'INTERACTIVE')" + }, + "asset_type": { + "type": "string", + "description": "File format (e.g., 'JPG', 'PNG', 'MP4', 'GIF')" + }, + "brand_identifier": { + "type": "string", + "description": "Brand or client identifier (e.g., 'Adidas', 'Nike', 'Client A')" + }, + "technical_specifications": { + "type": "array", + "items": { + "type": "string" + }, + "description": "MULTIPLIER FIELD: Dimensions, sizes, requirements. Use array when document lists multiple sizes/specs for this deliverable (e.g., ['1080x1080', '1080x1920', '1200x1500']). Use single value ['1920x1080'] when only one size specified" + }, + "review_date": { + "type": "string", + "description": "Review deadline (e.g., '2025-09-30')" + }, + "live_date": { + "type": "string", + "description": "Go-live date (e.g., '2025-10-15')" + }, + "end_date": { + "type": "string", + "description": "End/expiry date (e.g., '2025-12-31')" + }, + "reference_material": { + "type": "string", + "description": "Requirements, links, notes (e.g., 'As per style guide', 'See attachment A')" + }, + "language_country_market": { + "type": "array", + "items": { + "type": "string" + }, + "description": "MULTIPLIER FIELD: Target language-country-market combinations using ISO codes. Use when deliverable serves multiple markets (e.g., ['EN-UK', 'DE-DE', 'FR-FR', 'ES-ES']). Use single value ['EN-US'] for single market. Format: [Language ISO]-[Country ISO] or just [Country ISO] if language not specified" + }, + "quantity": { + "type": "string", + "description": "Expected total quantity for VALIDATION. Use this as a sense-check: the multiplication of all array fields should result in a total close to this quantity. If brief says '50 banners', ensure technical_specifications × language_country_market ≈ 50" + }, + "page_number": { + "type": "string", + "description": "Source page reference (e.g., '5', 'Pages 3-7')" + }, + "priority_level": { + "type": "string", + "description": "Business priority (e.g., 'High', 'Medium', 'Low')" + }, + "creative_direction": { + "type": "string", + "description": "Design requirements (e.g., 'Brand colors, clean layout', 'Minimalist style')" + } + }, + "required": [ + "title" + ] + } + } + }, + "required": [ + "assets" + ] + } +} \ No newline at end of file diff --git a/backend/prompts/validation_analysis.txt b/backend/prompts/validation_analysis.txt new file mode 100755 index 0000000..5162a7d --- /dev/null +++ b/backend/prompts/validation_analysis.txt @@ -0,0 +1,95 @@ +You are performing quality assurance on this asset extraction. Your role is to validate the completeness and accuracy of the initial extraction, applying the same rigorous standards used in the original analysis. + +EXTRACTED DATA SUMMARY: +- Found {asset_count} assets +- Document type: {doc_type} + +CRITICAL VALIDATION RULES - Apply these standards when checking the extraction: + +**DELIVERABLE TABLE VALIDATION (HIGHEST PRIORITY):** +1. **QUANTITY COLUMN COMPLIANCE**: For every deliverable table with a QUANTITY column, verify: + - Each table row generated exactly N deliverable objects where N = the QUANTITY value + - Example: Row showing "Display – Celtra Static Banners" with Quantity "480" should produce 480 separate deliverable objects + - Example: Row showing "Meta Video Sizes" with Quantity "2" should produce 2 separate deliverable objects + - Example: Row showing "Pinterest Copy" with Quantity "18" should produce 18 separate deliverable objects + +2. **TABLE PROCESSING COMPLETENESS**: Verify all structured deliverable tables were processed: + - Check that tables with headers like "DELIVERABLE NAME, QUANTITY, SPECS" were fully extracted + - Verify tables across all sections (Paid Social, Display, Demand Gen) were processed + - Confirm no deliverable tables were missed or partially processed + +3. **TOTAL ASSET COUNT VALIDATION**: If document states "TOTAL ASSET COUNT: XXX": + - Sum all extracted deliverables and verify it matches this exact number + - If extraction total is significantly different (>5% variance), identify which tables/rows were missed + - Cross-reference extracted count against the stated total as primary validation metric + +4. **QUANTITY-BASED MULTIPLIERS**: Beyond table quantities, verify traditional multipliers: + - Language/Market Multipliers: Multiple markets/languages should create separate objects for EACH market + - Size/Format Multipliers: Multiple sizes/formats should create separate objects for EACH variant + - Combined Multipliers: Multiple factors should be multiplied correctly (e.g., 2 formats × 3 markets = 6 objects) + - INDIVIDUAL ROW VERIFICATION: Verify that individual rows exist for each variation with quantity "1" and specific details in appropriate columns (country codes, language codes, dimensions, file formats, etc.) + +**TECHNICAL SPECIFICATIONS FIELD VALIDATION:** +- Verify technical_specifications fields capture ANY available technical information +- Check for precise dimensions when available (e.g., "1920x1080", "300x250") - NEVER placeholders like "TBC" or "desktop here" +- Verify descriptive sizing information is included (e.g., "Mobile Banner", "Desktop Hero", "Square Format") +- Check that units are included when present in source (px, ", in, cm) +- Verify time-based specs are captured for video content (e.g., "60 second loop") +- Verify all technical requirements and file formats are included in the technical_specifications field +- Field should ONLY be empty if absolutely no technical information exists in the document + +**ASSET TYPE VALIDATION:** +- Verify asset_type contains technical file formats (JPG, PNG, MP4, GIF) not creative names +- Check that file formats and technical requirements were extracted from phrases like "delivered as PNG", "JPG format required", "MP4 video file", "mobile optimized", "desktop banner" and included in technical_specifications field + +**COUNTRY CODE VALIDATION:** +- Verify two-letter country codes are used (e.g., UK, DE, FR, ES, IT) +- Check that regional mentions (e.g., "EMEA") were expanded to specific countries if listed + +**QUANTITY FIELD VALIDATION:** +- Verify every single object has quantity "1" +- Check that multipliers were handled by creating more objects, not changing quantity numbers +- MULTIPLICATION LOGIC CHECK: If document says "5 banners x 8 markets", verify 40 separate rows exist, not 1 row with quantity "40" +- INDIVIDUAL VARIATION ROWS: Verify that individual rows exist for each variation with quantity "1" and specific details in appropriate columns (country codes, language codes, dimensions, file formats, etc.) + +VALIDATION TASKS: +1. **DELIVERABLE TABLE QUANTITY VALIDATION (TOP PRIORITY)**: + - Locate every table with QUANTITY columns in the document + - For each table row, verify the extraction created exactly N deliverables where N = the quantity value + - Sum all quantity values from all tables and verify it matches any stated "TOTAL ASSET COUNT" + - Pay special attention to high-quantity rows (480, 57, 114+) that significantly impact total counts + +2. **TABLE PROCESSING COMPLETENESS**: Verify every structured deliverable table was fully processed: + - Check that tables across all major sections were captured (Paid Social, Display, Demand Gen) + - Confirm platform-specific tables (Meta, Snapchat, Pinterest, Celtra, Teads) were processed + - Verify no deliverable overview tables or specification matrices were missed + +3. **MULTIPLIER AND VARIATION VALIDATION**: Beyond table quantities, verify traditional multipliers: + - Market/language multipliers creating separate objects per country/language + - Size/format variations creating separate objects per specification + - Combined multipliers being calculated correctly + +4. **Technical Specification Accuracy**: Verify all dimensions, file formats, technical requirements, and sizing descriptions are captured exactly as written in the document and included in the technical_specifications field. + +**NO HALLUCINATION RULE**: If you identify missing assets or corrections, extract ONLY information that is explicitly present in the document. NEVER invent or assume information. + +**CRITICAL FOCUS AREAS FOR DELIVERABLE TABLE VALIDATION:** +- **DELIVERABLE OVERVIEW SECTIONS**: Check that comprehensive tables showing all deliverables with quantities were fully processed +- **QUANTITY COLUMN ACCURACY**: Verify each row's quantity number was used to create the correct number of deliverable objects +- **HIGH-QUANTITY TABLE ROWS**: Pay special attention to rows with large quantities (480, 57, 114+) as these significantly impact total counts +- **SECTION-BY-SECTION VALIDATION**: Verify deliverable tables in each major section were processed: + - Paid Social (Meta, Snapchat, Pinterest, Reddit) - often contain copy deliverables with high market multipliers + - Display (Celtra, Teads) - typically contain highest single quantities (e.g., 480 banners) + - Demand Gen - video and static assets with multiple format requirements +- **TOTAL SUMMATION CHECK**: Verify that summing all quantity values from all tables equals the stated "TOTAL ASSET COUNT" +- **TABLE STRUCTURE COMPLETENESS**: Confirm all structured tables with deliverable specifications were captured +- **PLATFORM-SPECIFIC TABLES**: Each platform section likely contains multiple deliverable requirement tables +- **COPY/LOCALIZATION MULTIPLICATION**: Copy deliverables often have the highest multipliers due to market/language requirements +- **MISSED TABLE DETECTION**: Scan for any deliverable tables that were completely overlooked during initial extraction + +**OUTPUT INSTRUCTIONS:** +- If you find additional assets or identify missed multipliers, provide them in the structured format with technical_specifications field containing all available technical information +- If the existing extraction correctly handled all multipliers and captured all assets comprehensively, return an empty assets array +- Focus especially on multiplier validation - this is the most common source of incomplete extractions + +Return your response as a structured JSON object with any additional assets found or corrections needed. \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100755 index 0000000..fc81622 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,44 @@ +# AC Tool — unified brief extractor + activation calendar + +# Web framework +quart>=0.19.0 +quart-cors>=0.7.0 +hypercorn>=0.16.0 + +# Auth +PyJWT>=2.8.0 +msal>=1.26.0 + +# AI / LLM providers +google-genai[aiohttp]>=0.4.0 +openai>=1.0.0 +anthropic>=0.67.0 +aiohttp>=3.9.0 +json5>=0.9.0 + +# Document parsing +llama-cloud-services>=0.6.62 +python-pptx>=0.6.21 +PyMuPDF>=1.23.0 +python-docx>=0.8.11 +openpyxl>=3.1.0 +xlrd>=2.0.1 + +# Data +pandas>=2.0.0 +numpy>=1.24.0 +pydantic>=2.0.0 + +# Misc +Pillow>=10.0.0 +beautifulsoup4>=4.12.0 +lxml>=4.9.0 +requests>=2.31.0 +python-dotenv>=1.0.0 +structlog>=23.0.0 +python-dateutil>=2.8.2 +typing-extensions>=4.7.0 +psutil>=5.9.0 +tqdm>=4.65.0 +regex>=2023.0.0 +cryptography>=41.0.0 diff --git a/backend/run_server.py b/backend/run_server.py new file mode 100755 index 0000000..9508f61 --- /dev/null +++ b/backend/run_server.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +""" +Startup script for Brief Extractor GUI server +""" + +import sys +import os +import logging +from pathlib import Path + +# Add server and core paths to Python path +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) +sys.path.insert(0, str(project_root / 'server')) + +# Set up logging before importing modules +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) + +logger = logging.getLogger(__name__) + +async def async_main(): + """Async main function with proper signal handling""" + import asyncio + import signal + + # Import after path setup + from server.app import create_app + from server.config_runtime import server_config + + # Validate configuration + if not server_config.validate_auth_config(): + if not server_config.DEV_MODE: + logger.error("MSAL authentication configuration is incomplete") + logger.error("Please set MSAL_CLIENT_ID, MSAL_CLIENT_SECRET, and MSAL_TENANT_ID in .env") + sys.exit(1) + else: + logger.warning("Running in DEV_MODE - MSAL authentication bypassed") + + # Create application + logger.info("Creating Brief Extractor GUI application...") + app = create_app() + + # Import and configure Hypercorn + import hypercorn.asyncio + from hypercorn import Config + + config = Config() + config.bind = [f"{server_config.HOST}:{server_config.PORT}"] + config.workers = server_config.WORKERS + config.use_reloader = server_config.DEBUG + config.accesslog = "-" # Log to stdout + config.errorlog = "-" # Log to stderr + + # Log startup information + logger.info(f"Starting Brief Extractor GUI server") + logger.info(f"Server: http://{server_config.HOST}:{server_config.PORT}") + logger.info(f"Development mode: {server_config.DEV_MODE}") + logger.info(f"Max concurrent jobs: {server_config.MAX_CONCURRENT_JOBS}") + logger.info(f"Max upload size: {server_config.MAX_UPLOAD_SIZE_MB}MB") + logger.info(f"File retention: {server_config.FILE_RETENTION_HOURS} hours") + logger.info(f"Workers: {server_config.WORKERS}") + + # Set up proper signal handling for graceful shutdown + shutdown_event = asyncio.Event() + + def signal_handler(): + logger.info("Shutdown signal received, stopping server...") + shutdown_event.set() + + # Force shutdown after 3 seconds if graceful shutdown fails + def force_shutdown(): + import time + time.sleep(3) + logger.warning("Graceful shutdown timed out, forcing exit...") + os._exit(1) + + import threading + threading.Thread(target=force_shutdown, daemon=True).start() + + # Register signal handlers + if sys.platform != 'win32': + loop = asyncio.get_running_loop() + loop.add_signal_handler(signal.SIGINT, signal_handler) + loop.add_signal_handler(signal.SIGTERM, signal_handler) + + try: + # Start server with shutdown trigger + await hypercorn.asyncio.serve(app, config, shutdown_trigger=shutdown_event.wait) + logger.info("Server stopped gracefully") + + except asyncio.CancelledError: + logger.info("Server cancelled") + except Exception as e: + logger.error(f"Server error: {e}", exc_info=True) + raise + +def main(): + """Main entry point""" + import asyncio + import signal + + # Set up immediate signal handling before async loop + def immediate_shutdown(signum, frame): + logger.info(f"Immediate shutdown signal {signum} received") + os._exit(0) + + signal.signal(signal.SIGINT, immediate_shutdown) + signal.signal(signal.SIGTERM, immediate_shutdown) + + try: + asyncio.run(async_main()) + except KeyboardInterrupt: + logger.info("Server stopped by user") + os._exit(0) + except Exception as e: + logger.error(f"Server failed to start: {e}", exc_info=True) + os._exit(1) + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/backend/server/__init__.py b/backend/server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/server/api/__init__.py b/backend/server/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/server/api/admin.py b/backend/server/api/admin.py new file mode 100644 index 0000000..445f9d9 --- /dev/null +++ b/backend/server/api/admin.py @@ -0,0 +1,126 @@ +""" +Admin API — user management and dropdown Excel upload. +All routes require admin role. +""" + +import json +import logging +import os +import openpyxl +from io import BytesIO + +from quart import Blueprint, jsonify, request + +from ..auth.middleware import admin_required +from ..auth.user_store import list_users, set_role, set_active +from ..api.dropdowns import save_dropdowns + +logger = logging.getLogger(__name__) + +admin_bp = Blueprint('admin', __name__, url_prefix='/api/admin') + + +@admin_bp.route('/users', methods=['GET']) +@admin_required +async def get_users(): + return jsonify({'users': list_users()}) + + +@admin_bp.route('/users/', methods=['PATCH']) +@admin_required +async def update_user(user_id: str): + body = await request.get_json() or {} + + user = None + if 'role' in body: + user = set_role(user_id, body['role']) + if user is None: + return jsonify({'error': 'invalid_role_or_not_found'}), 400 + + if 'active' in body: + user = set_active(user_id, bool(body['active'])) + if user is None: + return jsonify({'error': 'not_found'}), 404 + + return jsonify({'success': True, 'user': user}) + + +@admin_bp.route('/dropdowns/upload', methods=['POST']) +@admin_required +async def upload_dropdowns(): + """ + Upload a new Excel file (.xlsx) to update the dropdown categories. + Expects multipart/form-data with field 'file'. + Parses columns: A=Category name, E=Status, G=Media types (comma-separated). + """ + files = await request.files + file = files.get('file') + if not file: + return jsonify({'error': 'no_file'}), 400 + + filename = file.filename or '' + if not filename.lower().endswith('.xlsx'): + return jsonify({'error': 'invalid_file_type', 'message': 'Only .xlsx files accepted'}), 400 + + try: + data = file.read() + wb = openpyxl.load_workbook(BytesIO(data)) + ws = wb.active + + categories = [] + for row in ws.iter_rows(min_row=2, values_only=True): + if len(row) < 5 or not row[0]: + continue + name = str(row[0]).strip() + status_raw = str(row[4]).strip() if row[4] else 'Active' + status = 'Active' if 'active' in status_raw.lower() else 'Archived' + media_raw = str(row[6]).strip() if len(row) > 6 and row[6] else '' + media_types = [m.strip() for m in media_raw.split(',') if m.strip()] if media_raw else [] + categories.append({'name': name, 'status': status, 'mediaTypes': media_types}) + + if not categories: + return jsonify({'error': 'empty_file', 'message': 'No categories found in file'}), 400 + + save_dropdowns(categories) + active_count = sum(1 for c in categories if c['status'] == 'Active') + return jsonify({ + 'success': True, + 'total': len(categories), + 'active': active_count, + 'archived': len(categories) - active_count, + }) + + except Exception as e: + logger.error(f"Dropdown upload error: {e}", exc_info=True) + return jsonify({'error': 'parse_error', 'message': str(e)}), 500 + + +@admin_bp.route('/dropdowns/preview', methods=['POST']) +@admin_required +async def preview_dropdowns(): + """Preview parsed categories from an uploaded file without saving.""" + files = await request.files + file = files.get('file') + if not file: + return jsonify({'error': 'no_file'}), 400 + + try: + data = file.read() + wb = openpyxl.load_workbook(BytesIO(data)) + ws = wb.active + + categories = [] + for row in ws.iter_rows(min_row=2, values_only=True): + if len(row) < 5 or not row[0]: + continue + name = str(row[0]).strip() + status_raw = str(row[4]).strip() if row[4] else 'Active' + status = 'Active' if 'active' in status_raw.lower() else 'Archived' + media_raw = str(row[6]).strip() if len(row) > 6 and row[6] else '' + media_types = [m.strip() for m in media_raw.split(',') if m.strip()] if media_raw else [] + categories.append({'name': name, 'status': status, 'mediaTypes': media_types}) + + return jsonify({'categories': categories, 'total': len(categories)}) + + except Exception as e: + return jsonify({'error': 'parse_error', 'message': str(e)}), 500 diff --git a/backend/server/api/ai_command.py b/backend/server/api/ai_command.py new file mode 100644 index 0000000..97f132b --- /dev/null +++ b/backend/server/api/ai_command.py @@ -0,0 +1,187 @@ +""" +AI command API — processes natural language commands against a sheet. +Port of the 'command' action from ac-helper/api.php using Gemini via aiohttp. +""" + +import json +import logging +import os +import re +import aiohttp +from datetime import date + +from quart import Blueprint, jsonify, request + +from ..auth.middleware import auth_required, get_user_id +from ..sheets.manager import load_sheet_data, update_sheet, generate_next_id +from ..api.dropdowns import _load_dropdowns +from ..config_runtime import server_config + +logger = logging.getLogger(__name__) + +ai_bp = Blueprint('ai', __name__, url_prefix='/api/sheets') + +# Speech-to-text correction map +SPEECH_CORRECTIONS = { + 'delivery balls': 'deliverables', + 'delivery ball': 'deliverable', + 'delivery': 'deliverables', + 'liver': 'deliverables', + 'rose': 'rows', + 'oh oh h': 'OOH', + 'out of home': 'OOH', +} + +NUMBER_WORDS = { + 'one': '1', 'two': '2', 'three': '3', 'four': '4', 'five': '5', + 'six': '6', 'seven': '7', 'eight': '8', 'nine': '9', 'ten': '10', + 'eleven': '11', 'twelve': '12', 'twenty': '20', 'thirty': '30', +} + +_PROMPT_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'prompts', 'ac_command.txt') + + +def _load_prompt_template() -> str: + try: + with open(_PROMPT_PATH, 'r') as f: + return f.read() + except Exception: + return "" + + +def _preprocess(command: str) -> str: + cmd = command.lower() + for wrong, right in SPEECH_CORRECTIONS.items(): + cmd = cmd.replace(wrong, right) + for word, digit in NUMBER_WORDS.items(): + cmd = re.sub(r'\b' + word + r'\b', digit, cmd) + return cmd + + +def _build_hierarchy_rules() -> str: + categories = _load_dropdowns() + lines = [] + for cat in categories: + if cat.get('status') != 'Active': + continue + media_str = ', '.join(cat.get('mediaTypes', [])) + lines.append(f"- {cat['name']}: {media_str}") + return '\n'.join(lines) + + +async def _call_gemini(prompt: str) -> dict: + api_key = server_config.GEMINI_API_KEY + model = server_config.GEMINI_MODEL + url = f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}" + + payload = {"contents": [{"parts": [{"text": prompt}]}]} + + async with aiohttp.ClientSession() as session: + async with session.post(url, json=payload) as resp: + return await resp.json() + + +def _extract_json(text: str) -> dict: + start = text.find('{') + end = text.rfind('}') + if start == -1 or end == -1: + raise ValueError("No JSON object found in response") + return json.loads(text[start:end + 1]) + + +@ai_bp.route('//command', methods=['POST']) +@auth_required +async def run_command(sheet_id: str): + user_id = get_user_id() + + body = await request.get_json() or {} + raw_command = body.get('command', '').strip() + yolo_mode = bool(body.get('yolo_mode', False)) + history = body.get('history', '') + + if not raw_command: + return jsonify({'error': 'empty_command'}), 400 + + data = load_sheet_data(user_id, sheet_id) + if data is None: + return jsonify({'error': 'sheet_not_found'}), 404 + + command = _preprocess(raw_command) + template = _load_prompt_template() + hierarchy = _build_hierarchy_rules() + + prompt = template.format( + current_date=date.today().isoformat(), + yolo_mode='TRUE' if yolo_mode else 'FALSE', + conversation_history=history or '(none)', + data_context=json.dumps(data), + hierarchy_rules=hierarchy, + command=command, + ) + + try: + gemini_resp = await _call_gemini(prompt) + except Exception as e: + logger.error(f"Gemini API error: {e}") + return jsonify({'error': 'ai_error', 'message': str(e)}), 502 + + if 'error' in gemini_resp: + msg = gemini_resp['error'].get('message', 'Unknown error') + return jsonify({'error': 'gemini_error', 'message': msg}), 502 + + llm_text = ( + gemini_resp.get('candidates', [{}])[0] + .get('content', {}) + .get('parts', [{}])[0] + .get('text', '') + ) + + if not llm_text: + return jsonify({'error': 'empty_ai_response'}), 502 + + try: + action = _extract_json(llm_text) + except Exception: + return jsonify({'error': 'invalid_ai_json', 'debug_llm': llm_text}), 502 + + operation = action.get('operation') + + if operation == 'create': + items = action.get('items', []) + for item in items: + item['Number'] = generate_next_id(data) + item.setdefault('Status', 'Booked') + item.setdefault('Quantity', 1) + data.append(item) + update_sheet(user_id, sheet_id, data) + return jsonify({'success': True, 'operation': 'create', 'count': len(items), 'data': data}) + + elif operation == 'update': + values = action.get('values', {}) + target_ids = action.get('target_ids', []) + count = 0 + for row in data: + if not target_ids or row.get('Number') in target_ids: + row.update(values) + count += 1 + update_sheet(user_id, sheet_id, data) + return jsonify({'success': True, 'operation': 'update', 'count': count, 'data': data}) + + elif operation == 'batch_update': + updates = action.get('updates', []) + count = 0 + for upd in updates: + num = upd.get('Number') + vals = upd.get('values', {}) + for row in data: + if row.get('Number') == num: + row.update(vals) + count += 1 + break + update_sheet(user_id, sheet_id, data) + return jsonify({'success': True, 'operation': 'batch_update', 'count': count, 'data': data}) + + elif operation == 'question': + return jsonify({'success': True, 'operation': 'question', 'question': action.get('text', '')}) + + return jsonify({'error': 'unknown_operation', 'operation': operation}), 400 diff --git a/backend/server/api/auth.py b/backend/server/api/auth.py new file mode 100644 index 0000000..36be1fe --- /dev/null +++ b/backend/server/api/auth.py @@ -0,0 +1,83 @@ +""" +Auth API endpoints. +""" + +import logging +from quart import Blueprint, jsonify, request + +from ..auth.msal_auth import msal_auth +from ..auth.middleware import auth_required, get_current_user +from ..auth.user_store import upsert_user + +logger = logging.getLogger(__name__) + +auth_bp = Blueprint('auth', __name__, url_prefix='/api/auth') + + +@auth_bp.route('/config', methods=['GET']) +async def get_auth_config(): + return jsonify({'config': msal_auth.get_client_config(), 'devMode': msal_auth.is_dev_mode()}) + + +@auth_bp.route('/validate', methods=['POST']) +async def validate_token(): + try: + data = await request.get_json() + token = (data or {}).get('accessToken') + if not token: + return jsonify({'error': 'invalid_request', 'message': 'accessToken required'}), 400 + + user_info = await msal_auth.validate_token(token) + if not user_info: + return jsonify({'valid': False, 'error': 'invalid_token'}), 401 + + stored = upsert_user(user_info['oid'], user_info.get('preferred_username', ''), user_info.get('name', '')) + return jsonify({ + 'valid': True, + 'user': { + 'id': user_info['oid'], + 'email': user_info.get('preferred_username'), + 'name': user_info.get('name'), + 'role': stored.get('role', 'user'), + }, + }) + except Exception as e: + logger.error(f"Token validation error: {e}") + return jsonify({'error': 'validation_error'}), 500 + + +@auth_bp.route('/me', methods=['GET']) +@auth_required +async def me(): + """Return current user profile including role.""" + from ..auth.user_store import get_user as get_stored_user + user = await get_current_user() + stored = get_stored_user(user['oid']) or {} + return jsonify({ + 'id': user['oid'], + 'email': user.get('preferred_username'), + 'name': user.get('name'), + 'role': user.get('role', 'user'), + 'active': stored.get('active', True), + 'created': stored.get('created'), + 'last_seen': stored.get('last_seen'), + }) + + +@auth_bp.route('/user', methods=['GET']) +@auth_required +async def get_current_user_info(): + user = await get_current_user() + return jsonify({'user': { + 'id': user['oid'], + 'username': user.get('preferred_username'), + 'name': user.get('name'), + 'role': user.get('role', 'user'), + }}) + + +@auth_bp.route('/logout', methods=['POST']) +async def logout(): + data = await request.get_json() or {} + logout_url = await msal_auth.get_logout_url(data.get('redirectUri')) + return jsonify({'logoutUrl': logout_url}) diff --git a/backend/server/api/config.py b/backend/server/api/config.py new file mode 100755 index 0000000..6ac8c89 --- /dev/null +++ b/backend/server/api/config.py @@ -0,0 +1,273 @@ +""" +Configuration API endpoints for model selection and system settings +""" + +import logging +from quart import Blueprint, jsonify, request, g + +from ..auth.middleware import dev_mode_bypass, get_user_id +from ..jobs.models import ModelConfiguration +from ..jobs.manager import JobManager + +logger = logging.getLogger(__name__) + +config_bp = Blueprint('config', __name__, url_prefix='/api/config') + +@config_bp.route('/models', methods=['GET']) +@dev_mode_bypass +async def get_available_models(): + """ + Get list of available models with pricing and capabilities + + Returns: + List of available models with metadata + """ + try: + models = JobManager.get_available_models() + + return jsonify({ + 'models': [model.to_dict() for model in models] + }) + + except Exception as e: + logger.error(f"Failed to get available models: {e}") + return jsonify({ + 'error': 'configuration_error', + 'message': 'Failed to retrieve available models' + }), 500 + +@config_bp.route('/defaults', methods=['GET']) +@dev_mode_bypass +async def get_default_config(): + """ + Get default model configuration + + Returns: + Default model configuration settings + """ + try: + default_config = JobManager.get_default_model_config() + + return jsonify({ + 'config': default_config.to_dict() + }) + + except Exception as e: + logger.error(f"Failed to get default config: {e}") + return jsonify({ + 'error': 'configuration_error', + 'message': 'Failed to retrieve default configuration' + }), 500 + +@config_bp.route('/estimate', methods=['POST']) +@dev_mode_bypass +async def estimate_processing_cost(): + """ + Estimate processing cost for given models and file size + + Expects: + { + "modelConfig": { + "primaryModels": ["model1", "model2"], + "consolidationModel": "model3" + }, + "fileSizeBytes": 12345, + "estimatedTokens": 10000 + } + + Returns: + Cost breakdown by model and total estimated cost + """ + try: + data = await request.get_json() + + if not data: + return jsonify({ + 'error': 'invalid_request', + 'message': 'Request body required' + }), 400 + + model_config_data = data.get('modelConfig', {}) + file_size = data.get('fileSizeBytes', 0) + estimated_tokens = data.get('estimatedTokens') + + # If no token estimate provided, estimate based on file size + if not estimated_tokens: + # Rough heuristic: 4 characters per token, with document structure overhead + estimated_tokens = min(file_size // 3, 100000) # Cap at 100k tokens + + # Parse model configuration + try: + model_config = ModelConfiguration.from_dict(model_config_data) + except Exception as e: + return jsonify({ + 'error': 'invalid_model_config', + 'message': f'Invalid model configuration: {e}' + }), 400 + + # Get all models to estimate + all_models = model_config.primary_models + [model_config.consolidation_model] + + # Estimate cost using provider manager + from ..jobs.manager import JobManager + job_manager = JobManager.get_instance() + + cost_breakdown = job_manager.provider_manager.estimate_total_cost( + model_keys=all_models, + estimated_input_tokens=estimated_tokens, + estimated_output_tokens=estimated_tokens // 2 # Assume 50% of input as output + ) + + # Separate primary and consolidation costs + primary_cost = sum( + cost_breakdown.get(model, 0) for model in model_config.primary_models + ) + consolidation_cost = cost_breakdown.get(model_config.consolidation_model, 0) + + return jsonify({ + 'estimatedTokens': estimated_tokens, + 'costBreakdown': { + 'primaryModels': { + model: cost_breakdown.get(model, 0) + for model in model_config.primary_models + }, + 'consolidationModel': { + model_config.consolidation_model: consolidation_cost + }, + 'primaryTotal': primary_cost, + 'consolidationTotal': consolidation_cost, + 'grandTotal': cost_breakdown.get('total', 0) + } + }) + + except Exception as e: + logger.error(f"Cost estimation error: {e}") + return jsonify({ + 'error': 'estimation_error', + 'message': 'Failed to estimate processing cost' + }), 500 + +@config_bp.route('/validate', methods=['POST']) +@dev_mode_bypass +async def validate_model_config(): + """ + Validate a model configuration + + Expects: + { + "modelConfig": { + "primaryModels": ["model1", "model2"], + "consolidationModel": "model3", + "minimumSuccessThreshold": 1 + } + } + + Returns: + Validation result with any warnings or errors + """ + try: + data = await request.get_json() + + if not data: + return jsonify({ + 'error': 'invalid_request', + 'message': 'Request body required' + }), 400 + + model_config_data = data.get('modelConfig', {}) + + try: + model_config = ModelConfiguration.from_dict(model_config_data) + except Exception as e: + return jsonify({ + 'valid': False, + 'error': f'Invalid model configuration: {e}' + }), 400 + + # Validate models exist + available_models = [model.key for model in JobManager.get_available_models()] + warnings = [] + errors = [] + + # Check primary models + for model in model_config.primary_models: + if model not in available_models: + errors.append(f"Primary model '{model}' is not available") + + # Check consolidation model + if model_config.consolidation_model not in available_models: + errors.append(f"Consolidation model '{model_config.consolidation_model}' is not available") + + # Check minimum success threshold + if model_config.minimum_success_threshold > len(model_config.primary_models): + warnings.append( + f"Minimum success threshold ({model_config.minimum_success_threshold}) " + f"is higher than number of primary models ({len(model_config.primary_models)})" + ) + + # Check for duplicate models + if len(set(model_config.primary_models)) != len(model_config.primary_models): + warnings.append("Duplicate models detected in primary models list") + + # Check if consolidation model is also in primary models + if model_config.consolidation_model in model_config.primary_models: + warnings.append("Consolidation model is also used as a primary model") + + return jsonify({ + 'valid': len(errors) == 0, + 'errors': errors, + 'warnings': warnings, + 'modelCount': { + 'primary': len(model_config.primary_models), + 'consolidation': 1, + 'total': len(set(model_config.primary_models + [model_config.consolidation_model])) + } + }) + + except Exception as e: + logger.error(f"Model config validation error: {e}") + return jsonify({ + 'error': 'validation_error', + 'message': 'Failed to validate model configuration' + }), 500 + +@config_bp.route('/system', methods=['GET']) +@dev_mode_bypass +async def get_system_info(): + """ + Get system configuration and status information + + Returns: + System information for admin/debugging purposes + """ + try: + from ..config_runtime import server_config + from ..jobs.manager import JobManager + + job_manager = JobManager.get_instance() + + # Get system stats + queue_size = await job_manager.get_queue_size() + active_jobs = await job_manager.get_active_jobs_count() + + return jsonify({ + 'system': { + 'devMode': server_config.DEV_MODE, + 'maxConcurrentJobs': server_config.MAX_CONCURRENT_JOBS, + 'maxUploadSizeMB': server_config.MAX_UPLOAD_SIZE_MB, + 'fileRetentionHours': server_config.FILE_RETENTION_HOURS, + 'allowedExtensions': list(server_config.ALLOWED_EXTENSIONS) + }, + 'queue': { + 'pending': queue_size, + 'active': active_jobs, + 'maxConcurrent': server_config.MAX_CONCURRENT_JOBS + } + }) + + except Exception as e: + logger.error(f"Failed to get system info: {e}") + return jsonify({ + 'error': 'system_error', + 'message': 'Failed to retrieve system information' + }), 500 \ No newline at end of file diff --git a/backend/server/api/dropdowns.py b/backend/server/api/dropdowns.py new file mode 100644 index 0000000..1f519bb --- /dev/null +++ b/backend/server/api/dropdowns.py @@ -0,0 +1,176 @@ +""" +Dropdown data API — category / media type hierarchy. +Data is loaded from dropdowns.json (seeded from Excel, updatable by admin). +""" + +import json +import logging +import os + +from quart import Blueprint, jsonify, request + +from ..config_runtime import server_config + +logger = logging.getLogger(__name__) + +dropdowns_bp = Blueprint('dropdowns', __name__, url_prefix='/api/dropdowns') + +# Seed data embedded as fallback (from Excel Grid (1).xlsx) +SEED_CATEGORIES = [ + {"name": "3D", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "A/B Testing", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Admin", "status": "Active", "mediaTypes": ["Management"]}, + {"name": "Amazon page", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Animation", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "App Design", "status": "Active", "mediaTypes": ["Online advertising - .com"]}, + {"name": "Artworking (Print)", "status": "Active", "mediaTypes": ["Literature", "Catalogue", "Press - Magazine", "Press - Newspaper", "POS - Print", "POS - Digital", "OOH - Print", "Direct mail - Email", "Direct mail - Print"]}, + {"name": "Audio", "status": "Active", "mediaTypes": ["Broadcast - Radio"]}, + {"name": "Augmented Reality", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Branday Adaptation", "status": "Active", "mediaTypes": ["Online advertising - Rich media"]}, + {"name": "Branding", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "CMS", "status": "Active", "mediaTypes": ["Online advertising - .com"]}, + {"name": "Campaign Print Complex", "status": "Active", "mediaTypes": ["Press - Newspaper"]}, + {"name": "Campaign Print Simple", "status": "Active", "mediaTypes": ["Press - Magazine"]}, + {"name": "Cinema", "status": "Active", "mediaTypes": ["Broadcast - TV", "Broadcast - Cinema", "Broadcast - Radio"]}, + {"name": "Cinema Adaptation", "status": "Active", "mediaTypes": ["Broadcast - Cinema"]}, + {"name": "Community Management", "status": "Active", "mediaTypes": ["Community management"]}, + {"name": "Concept (Video)", "status": "Active", "mediaTypes": ["Online advertising - Video"]}, + {"name": "Copywriting", "status": "Active", "mediaTypes": ["Literature", "Transcreation", "Copywriting"]}, + {"name": "Copywriting Newsletter", "status": "Active", "mediaTypes": ["Direct mail - Email", "Direct mail - Print"]}, + {"name": "Copywriting Social", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Creative Development", "status": "Active", "mediaTypes": ["Literature", "Creative development"]}, + {"name": "Creative Development Big Campaign", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Creative Development Small Campaign", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Creative Direction", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Creative Packaging Box", "status": "Active", "mediaTypes": ["Packaging - Print"]}, + {"name": "DM", "status": "Active", "mediaTypes": ["Direct mail - Print"]}, + {"name": "Digital Display (.com)", "status": "Active", "mediaTypes": ["Online advertising - Banner", "Online advertising - Static Image"]}, + {"name": "Digital Display (Animation)", "status": "Active", "mediaTypes": ["POS - Digital", "Online advertising - Banner", "Online advertising - Rich media", "Online advertising - Push notifications", "Online advertising - .com"]}, + {"name": "Digital Display (POS)", "status": "Active", "mediaTypes": ["Online advertising - Banner", "Online advertising - Static Image"]}, + {"name": "Digital Display (Push Notification)", "status": "Active", "mediaTypes": ["Online advertising - Banner", "Online advertising - Static Image"]}, + {"name": "Digital Display (Rich Media)", "status": "Active", "mediaTypes": ["Online advertising - Static Image"]}, + {"name": "Digital Display (Static)", "status": "Active", "mediaTypes": ["Online advertising - Static Image"]}, + {"name": "Display Static Adaptation Standard formats", "status": "Active", "mediaTypes": ["Online advertising - Static Image"]}, + {"name": "Display Static Master Standard formats", "status": "Active", "mediaTypes": ["Online advertising - Static Image"]}, + {"name": "E-commerce site", "status": "Active", "mediaTypes": ["Online advertising - .com"]}, + {"name": "Email", "status": "Active", "mediaTypes": ["Direct mail - Email"]}, + {"name": "Event", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Event Management", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Illustration", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Image Adaptation Social", "status": "Active", "mediaTypes": ["Social - Static Image"]}, + {"name": "Image Animation", "status": "Active", "mediaTypes": ["Online advertising - Video"]}, + {"name": "Infographics", "status": "Active", "mediaTypes": ["Literature", "Online advertising - Banner", "Online advertising - Rich media", "Online advertising - Landing page", "Online advertising - Push notifications"]}, + {"name": "Internal Comms", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Key Visual", "status": "Active", "mediaTypes": ["Literature", "Social - Static Image"]}, + {"name": "Key Visual Adaptation", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Key Visual Design", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Logo creation", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Management", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Mechandise", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Non-Project Time", "status": "Active", "mediaTypes": ["Management"]}, + {"name": "OOH (Digital)", "status": "Active", "mediaTypes": ["OOH - Digital"]}, + {"name": "OOH (Print)", "status": "Active", "mediaTypes": ["OOH - Print"]}, + {"name": "OOH Complex (Digital)", "status": "Active", "mediaTypes": ["OOH - Digital"]}, + {"name": "OOH Complex (Print)", "status": "Active", "mediaTypes": ["OOH - Print"]}, + {"name": "OOH Simple (Digital)", "status": "Active", "mediaTypes": ["OOH - Digital"]}, + {"name": "OOH Simple (Print)", "status": "Active", "mediaTypes": ["OOH - Print"]}, + {"name": "POS", "status": "Active", "mediaTypes": ["POS - Print", "POS - Digital"]}, + {"name": "POS Complex", "status": "Active", "mediaTypes": ["POS - Print"]}, + {"name": "POS Merchandising Complex (up to 10)", "status": "Active", "mediaTypes": ["Packaging - Print"]}, + {"name": "POS Merchandising Simple (up to 5)", "status": "Active", "mediaTypes": ["Packaging - Print"]}, + {"name": "POS Simple", "status": "Active", "mediaTypes": ["POS - Print"]}, + {"name": "Packaging", "status": "Active", "mediaTypes": ["Packaging - Print"]}, + {"name": "Packaging Box", "status": "Active", "mediaTypes": ["Packaging - Print"]}, + {"name": "Paid Media", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Photography Shooting (10-20)", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Photography Shooting (20-40)", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Photography Shooting (up to 10)", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Photography Shooting Still Life", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Photoshoot", "status": "Active", "mediaTypes": ["Literature", "Photography"]}, + {"name": "Presentations", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Presentations Template", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Print Design", "status": "Active", "mediaTypes": ["Literature", "Catalogue", "Press - Magazine", "Press - Newspaper", "POS - Print", "OOH - Print", "Direct mail - Print"]}, + {"name": "Production", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Production (Post)", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Production (Pre)", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Programmatic", "status": "Active", "mediaTypes": ["Online advertising - Rich media"]}, + {"name": "Project Management", "status": "Active", "mediaTypes": ["Management"]}, + {"name": "Retouching", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Retouching Complex", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Retouching Simple", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "SEM", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "SEO", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Scoping", "status": "Active", "mediaTypes": ["Management"]}, + {"name": "Seedtag Banner Adaptation", "status": "Active", "mediaTypes": ["Online advertising - Rich media"]}, + {"name": "Sell Sheet", "status": "Active", "mediaTypes": ["Literature", "Catalogue", "Direct mail - Print"]}, + {"name": "Signage", "status": "Active", "mediaTypes": ["POS - Print"]}, + {"name": "Single Website Page Design", "status": "Active", "mediaTypes": ["Online advertising - Landing page"]}, + {"name": "Skin Adaptation", "status": "Active", "mediaTypes": ["Online advertising - Rich media"]}, + {"name": "Social (Animation)", "status": "Active", "mediaTypes": ["Social - Gif"]}, + {"name": "Social (Static)", "status": "Active", "mediaTypes": ["Social - Static Image"]}, + {"name": "Social (Video)", "status": "Active", "mediaTypes": ["Social - Video"]}, + {"name": "Social Carousel (up to 5 images)", "status": "Active", "mediaTypes": ["Social - Static Image"]}, + {"name": "Social Reporting", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Social Twitter Thread", "status": "Active", "mediaTypes": ["Social - Static Image"]}, + {"name": "Sound", "status": "Active", "mediaTypes": ["Broadcast - Radio"]}, + {"name": "Sound Editing", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Storyboarding", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Strategy", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Subtitling", "status": "Active", "mediaTypes": ["Online advertising - Video"]}, + {"name": "TVC", "status": "Active", "mediaTypes": ["Broadcast - TV"]}, + {"name": "Transcreation", "status": "Active", "mediaTypes": ["Transcreation"]}, + {"name": "Typography", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Video (Edit)", "status": "Active", "mediaTypes": ["Online advertising - Video"]}, + {"name": "Video (Shoot)", "status": "Active", "mediaTypes": ["Online advertising - Video"]}, + {"name": "Video Adaptation 10s", "status": "Active", "mediaTypes": ["Online advertising - Video"]}, + {"name": "Video Adaptation 15s", "status": "Active", "mediaTypes": ["Online advertising - Video"]}, + {"name": "Video Adaptation 20s", "status": "Active", "mediaTypes": ["Online advertising - Video"]}, + {"name": "Video Adaptation 30s", "status": "Active", "mediaTypes": ["Online advertising - Video"]}, + {"name": "Video Adaptation 5s", "status": "Active", "mediaTypes": ["Online advertising - Video"]}, + {"name": "Video Adaptation 60s", "status": "Active", "mediaTypes": ["Online advertising - Video"]}, + {"name": "Video Editing 15s", "status": "Active", "mediaTypes": ["Online advertising - Video"]}, + {"name": "Video Editing 1m", "status": "Active", "mediaTypes": ["Online advertising - Video"]}, + {"name": "Video Editing 20s", "status": "Active", "mediaTypes": ["Online advertising - Video"]}, + {"name": "Video Editing 45s", "status": "Active", "mediaTypes": ["Online advertising - Video"]}, + {"name": "Video Editing Event", "status": "Active", "mediaTypes": ["Online advertising - Video"]}, + {"name": "Video Editing Stock Images", "status": "Active", "mediaTypes": ["Online advertising - Video"]}, + {"name": "Video Recording", "status": "Active", "mediaTypes": ["Online advertising - Video"]}, + {"name": "Virtual Reality", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Voice Over", "status": "Active", "mediaTypes": ["Broadcast - Radio"]}, + {"name": "Web", "status": "Active", "mediaTypes": ["Online advertising - Landing page"]}, + {"name": "Web Analytics", "status": "Active", "mediaTypes": ["Literature"]}, + {"name": "Web UI & UX", "status": "Active", "mediaTypes": ["Online advertising - .com"]}, + {"name": "Website Design", "status": "Active", "mediaTypes": ["Online advertising - .com"]}, +] + + +def _load_dropdowns() -> list: + path = server_config.DROPDOWNS_FILE + if os.path.exists(path): + try: + with open(path, 'r') as f: + return json.load(f) + except Exception: + pass + return SEED_CATEGORIES + + +def save_dropdowns(categories: list): + path = server_config.DROPDOWNS_FILE + with open(path, 'w') as f: + json.dump(categories, f, indent=2) + + +@dropdowns_bp.route('/categories', methods=['GET']) +async def get_categories(): + categories = _load_dropdowns() + active_only = request.args.get('active', 'true').lower() == 'true' + if active_only: + categories = [c for c in categories if c.get('status') == 'Active'] + return jsonify({'categories': categories}) + + +@dropdowns_bp.route('/all', methods=['GET']) +async def get_all(): + """Full dropdown data including archived, for admin preview.""" + return jsonify({'categories': _load_dropdowns()}) diff --git a/backend/server/api/export.py b/backend/server/api/export.py new file mode 100644 index 0000000..5c9b31d --- /dev/null +++ b/backend/server/api/export.py @@ -0,0 +1,62 @@ +""" +CSV export — Activation Calendar format. +Mirrors the export logic from script.js in ac-helper. +""" + +import csv +import io +import logging + +from quart import Blueprint, make_response + +from ..auth.middleware import auth_required, get_user_id +from ..sheets.manager import load_sheet_data + +logger = logging.getLogger(__name__) + +export_bp = Blueprint('export', __name__, url_prefix='/api/sheets') + +# Activation Calendar column order +AC_HEADERS = [ + 'Number', 'Title', 'Status', 'Category', 'Media', 'Sub media', + 'Destination', 'Format', 'Supply date', 'Live date', 'End date', + 'Special instructions', 'Language', 'Country', 'Quantity', +] + + +@export_bp.route('//export', methods=['GET']) +@auth_required +async def export_csv(sheet_id: str): + user_id = get_user_id() + data = load_sheet_data(user_id, sheet_id) + if data is None: + return {'error': 'not_found'}, 404 + + output = io.StringIO() + writer = csv.DictWriter(output, fieldnames=AC_HEADERS, extrasaction='ignore') + writer.writeheader() + + for row in data: + writer.writerow({ + 'Number': '', # cleared on export + 'Title': row.get('Title', ''), + 'Status': row.get('Status', ''), + 'Category': row.get('Category', ''), + 'Media': row.get('Media', ''), + 'Sub media': row.get('Sub-media', ''), + 'Destination': '', + 'Format': row.get('Format', ''), + 'Supply date': row.get('Supply date', ''), + 'Live date': row.get('Live date', ''), + 'End date': '', + 'Special instructions': '', + 'Language': row.get('Language', ''), + 'Country': row.get('Country', ''), + 'Quantity': '1.00', + }) + + csv_content = output.getvalue() + response = await make_response(csv_content) + response.headers['Content-Type'] = 'text/csv' + response.headers['Content-Disposition'] = f'attachment; filename="activation_calendar_{sheet_id}.csv"' + return response diff --git a/backend/server/api/jobs.py b/backend/server/api/jobs.py new file mode 100755 index 0000000..7fa1092 --- /dev/null +++ b/backend/server/api/jobs.py @@ -0,0 +1,615 @@ +""" +Jobs API endpoints for file upload and processing management +""" + +import logging +import os +import zipfile +from datetime import datetime +from io import BytesIO +from quart import Blueprint, request, jsonify, send_file, g + +import csv +from ..auth.middleware import dev_mode_bypass, auth_required, get_user_id +from ..jobs.models import Job, ModelConfiguration +from ..jobs.manager import JobManager +from ..ws.manager import WebSocketManager + +logger = logging.getLogger(__name__) + +jobs_bp = Blueprint('jobs', __name__, url_prefix='/api/jobs') + +@jobs_bp.route('', methods=['POST']) +@dev_mode_bypass +async def create_jobs(): + """ + Create new processing jobs from uploaded files + + Accepts multipart/form-data with: + - files: One or more files to process + - modelConfig (optional): JSON string with model configuration + + Returns: + List of created job objects + """ + try: + job_manager = JobManager.get_instance() + ws_manager = WebSocketManager() + user_id = get_user_id() + + # Get uploaded files + files = await request.files + + if not files: + return jsonify({ + 'error': 'no_files', + 'message': 'No files provided for upload' + }), 400 + + logger.info(f"Received {len(files)} files for upload") + + # Get model configuration from form data + form_data = await request.form + model_config_json = form_data.get('modelConfig') + + model_config = None + if model_config_json: + try: + import json + model_config_data = json.loads(model_config_json) + model_config = ModelConfiguration.from_dict(model_config_data) + except Exception as e: + return jsonify({ + 'error': 'invalid_model_config', + 'message': f'Invalid model configuration: {e}' + }), 400 + + created_jobs = [] + errors = [] + + # Process each uploaded file + for field_name, file_storage in files.items(): + try: + if not file_storage or not file_storage.filename: + logger.warning(f"Skipping empty file field: {field_name}") + continue + + logger.info(f"Processing file: {file_storage.filename}") + + # Read file data + file_data = file_storage.read() + file_size = len(file_data) + + # Create job + job = await job_manager.create_job( + file_name=file_storage.filename, + file_size=file_size, + file_data=file_data, + user_id=user_id, + model_config=model_config + ) + + created_jobs.append(job) + logger.info(f"Created and queued job {job.id} for {file_storage.filename}") + + # Broadcast job creation + await ws_manager.broadcast_to_user(user_id, { + 'type': 'job.created', + 'job': job.to_dict() + }) + + # Broadcast job accepted (when it enters the queue) + await ws_manager.broadcast_to_user(user_id, { + 'type': 'job.accepted', + 'jobId': job.id + }) + + logger.info(f"Created job {job.id} for file {file_storage.filename} (user: {user_id})") + + except Exception as e: + error_msg = f"Failed to process file {file_storage.filename}: {str(e)}" + errors.append(error_msg) + logger.error(error_msg) + + if not created_jobs and errors: + return jsonify({ + 'error': 'upload_failed', + 'message': 'Failed to process any files', + 'details': errors + }), 400 + + return jsonify({ + 'jobs': [job.to_dict() for job in created_jobs], + 'errors': errors + }) + + except Exception as e: + logger.error(f"Job creation failed: {e}", exc_info=True) + return jsonify({ + 'error': 'server_error', + 'message': 'Failed to create jobs' + }), 500 + +@jobs_bp.route('', methods=['GET']) +@dev_mode_bypass +async def list_jobs(): + """ + List jobs for the current user + + Query parameters: + - limit: Maximum number of jobs to return (default: 50, max: 100) + - offset: Number of jobs to skip (default: 0) + - status: Filter by job status (optional) + + Returns: + Paginated list of jobs + """ + try: + job_manager = JobManager.get_instance() + user_id = get_user_id() + + # Parse query parameters + limit = min(int(request.args.get('limit', 50)), 100) + offset = int(request.args.get('offset', 0)) + status_filter = request.args.get('status') + + # Get user jobs + jobs = await job_manager.get_user_jobs(user_id, limit, offset) + + # Apply status filter if provided + if status_filter: + jobs = [job for job in jobs if job.phase.value.lower() == status_filter.lower()] + + return jsonify({ + 'jobs': [job.to_dict() for job in jobs], + 'pagination': { + 'limit': limit, + 'offset': offset, + 'count': len(jobs) + } + }) + + except Exception as e: + logger.error(f"Failed to list jobs: {e}") + return jsonify({ + 'error': 'server_error', + 'message': 'Failed to retrieve jobs' + }), 500 + +@jobs_bp.route('/', methods=['GET']) +@dev_mode_bypass +async def get_job(job_id: str): + """ + Get details for a specific job + + Returns: + Job details including progress, logs, and results + """ + try: + job_manager = JobManager.get_instance() + user_id = get_user_id() + + job = await job_manager.get_job(job_id) + + if not job: + return jsonify({ + 'error': 'not_found', + 'message': 'Job not found' + }), 404 + + # Check if user owns this job (skip check in dev mode) + from ..config_runtime import server_config + if not server_config.DEV_MODE and job.user_id != user_id: + return jsonify({ + 'error': 'forbidden', + 'message': 'Access denied' + }), 403 + + return jsonify({ + 'job': job.to_dict() + }) + + except Exception as e: + logger.error(f"Failed to get job {job_id}: {e}") + return jsonify({ + 'error': 'server_error', + 'message': 'Failed to retrieve job' + }), 500 + +@jobs_bp.route('//download', methods=['GET']) +@dev_mode_bypass +async def download_job_result(job_id: str): + """ + Download the CSV result file for a completed job + + Returns: + CSV file as download attachment + """ + try: + job_manager = JobManager.get_instance() + user_id = get_user_id() + + job = await job_manager.get_job(job_id) + + if not job: + return jsonify({ + 'error': 'not_found', + 'message': 'Job not found' + }), 404 + + # Check if user owns this job (skip check in dev mode) + from ..config_runtime import server_config + if not server_config.DEV_MODE and job.user_id != user_id: + return jsonify({ + 'error': 'forbidden', + 'message': 'Access denied' + }), 403 + + # Check if job is completed and has output + if not job.output_path or not os.path.exists(job.output_path): + return jsonify({ + 'error': 'not_ready', + 'message': 'Job result not available' + }), 400 + + # Generate download filename + base_name = os.path.splitext(job.file_name)[0] + download_filename = f"{base_name}-results.csv" + + return await send_file( + job.output_path, + as_attachment=True, + attachment_filename=download_filename, + mimetype='text/csv' + ) + + except Exception as e: + logger.error(f"Download failed for job {job_id}: {e}") + return jsonify({ + 'error': 'server_error', + 'message': 'Failed to download result' + }), 500 + +@jobs_bp.route('//logs', methods=['GET']) +@dev_mode_bypass +async def get_job_logs(job_id: str): + """ + Get logs for a specific job + + Query parameters: + - limit: Maximum number of log entries (default: 100) + - level: Filter by log level (optional) + + Returns: + List of log entries + """ + try: + job_manager = JobManager.get_instance() + user_id = get_user_id() + + job = await job_manager.get_job(job_id) + + if not job: + return jsonify({ + 'error': 'not_found', + 'message': 'Job not found' + }), 404 + + # Check if user owns this job (skip check in dev mode) + from ..config_runtime import server_config + if not server_config.DEV_MODE and job.user_id != user_id: + return jsonify({ + 'error': 'forbidden', + 'message': 'Access denied' + }), 403 + + # Parse query parameters + limit = min(int(request.args.get('limit', 100)), 1000) + level_filter = request.args.get('level') + + # Get logs + logs = job.logs + + # Apply level filter if provided + if level_filter: + logs = [log for log in logs if log.level.lower() == level_filter.lower()] + + # Apply limit + logs = logs[-limit:] if len(logs) > limit else logs + + return jsonify({ + 'logs': [log.to_dict() for log in logs], + 'count': len(logs) + }) + + except Exception as e: + logger.error(f"Failed to get logs for job {job_id}: {e}") + return jsonify({ + 'error': 'server_error', + 'message': 'Failed to retrieve job logs' + }), 500 + +@jobs_bp.route('/', methods=['DELETE']) +@dev_mode_bypass +async def delete_job(job_id: str): + """ + Delete a job and clean up its files + + Returns: + Success confirmation + """ + try: + job_manager = JobManager.get_instance() + ws_manager = WebSocketManager() + user_id = get_user_id() + + job = await job_manager.get_job(job_id) + + if not job: + return jsonify({ + 'error': 'not_found', + 'message': 'Job not found' + }), 404 + + # Check if user owns this job (skip check in dev mode) + from ..config_runtime import server_config + if not server_config.DEV_MODE and job.user_id != user_id: + return jsonify({ + 'error': 'forbidden', + 'message': 'Access denied' + }), 403 + + # Delete job + success = await job_manager.delete_job(job_id) + + if success: + # Broadcast deletion + await ws_manager.broadcast_to_user(user_id, { + 'type': 'job.deleted', + 'jobId': job_id + }) + + logger.info(f"Deleted job {job_id} (user: {user_id})") + + return jsonify({ + 'message': 'Job deleted successfully' + }) + else: + return jsonify({ + 'error': 'deletion_failed', + 'message': 'Failed to delete job' + }), 500 + + except Exception as e: + logger.error(f"Failed to delete job {job_id}: {e}") + return jsonify({ + 'error': 'server_error', + 'message': 'Failed to delete job' + }), 500 + +@jobs_bp.route('/batch-download', methods=['POST']) +@dev_mode_bypass +async def batch_download(): + """ + Download multiple job results as a ZIP file + + Expects: + { + "jobIds": ["job1", "job2", "job3"] + } + + Returns: + ZIP file containing CSV results + """ + try: + job_manager = JobManager.get_instance() + user_id = get_user_id() + + data = await request.get_json() + job_ids = data.get('jobIds', []) + + if not job_ids: + return jsonify({ + 'error': 'invalid_request', + 'message': 'No job IDs provided' + }), 400 + + # Create ZIP file in memory + zip_buffer = BytesIO() + csv_files = [] + + with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file: + for job_id in job_ids: + job = await job_manager.get_job(job_id) + + if not job: + logger.warning(f"Job {job_id} not found for batch download") + continue + + # Check if user owns this job (skip check in dev mode) + from ..config_runtime import server_config + if not server_config.DEV_MODE and job.user_id != user_id: + logger.warning(f"User {user_id} denied access to job {job_id}") + continue + + # Check if job has output + if not job.output_path or not os.path.exists(job.output_path): + logger.warning(f"Job {job_id} has no output file") + continue + + # Add CSV to ZIP asynchronously + base_name = os.path.splitext(job.file_name)[0] + csv_filename = f"{base_name}-{job_id[:8]}.csv" + + # Read file in thread pool to avoid blocking + def _read_csv(): + with open(job.output_path, 'rb') as csv_file: + return csv_file.read() + + import asyncio + loop = asyncio.get_running_loop() + csv_content = await loop.run_in_executor(None, _read_csv) + zip_file.writestr(csv_filename, csv_content) + + csv_files.append(csv_filename) + + if not csv_files: + return jsonify({ + 'error': 'no_results', + 'message': 'No completed jobs found for download' + }), 400 + + zip_buffer.seek(0) + + # Generate download filename + timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S") + zip_filename = f"batch_results_{timestamp}.zip" + + return await send_file( + zip_buffer, + as_attachment=True, + attachment_filename=zip_filename, + mimetype='application/zip' + ) + + except Exception as e: + logger.error(f"Batch download failed: {e}") + return jsonify({ + 'error': 'server_error', + 'message': 'Failed to create batch download' + }), 500 + +@jobs_bp.route('/cleanup', methods=['POST']) +@dev_mode_bypass +async def cleanup_expired(): + """ + Manually trigger cleanup of expired jobs and files + (Admin/maintenance endpoint) + + Returns: + Number of items cleaned up + """ + try: + job_manager = JobManager.get_instance() + + # Perform cleanup + cleaned_count = await job_manager.cleanup_expired_jobs() + + logger.info(f"Manual cleanup completed: {cleaned_count} items removed") + + return jsonify({ + 'message': 'Cleanup completed', + 'itemsRemoved': cleaned_count + }) + + except Exception as e: + logger.error(f"Cleanup failed: {e}") + return jsonify({ + 'error': 'server_error', + 'message': 'Failed to perform cleanup' + }), 500 + +@jobs_bp.route('/stats', methods=['GET']) +@dev_mode_bypass +async def get_job_stats(): + """ + Get job processing statistics for the current user + + Returns: + Statistics about job processing + """ + try: + job_manager = JobManager.get_instance() + user_id = get_user_id() + + # Get all user jobs + all_jobs = await job_manager.get_user_jobs(user_id, limit=1000) + + # Calculate statistics + total_jobs = len(all_jobs) + completed_jobs = len([j for j in all_jobs if j.phase.value == 'COMPLETED']) + failed_jobs = len([j for j in all_jobs if j.phase.value == 'FAILED']) + active_jobs = len([j for j in all_jobs if j.phase.value not in ['COMPLETED', 'FAILED']]) + + total_assets = sum(j.summary.assets_extracted for j in all_jobs if j.summary) + total_cost = sum(j.summary.cost_usd_total for j in all_jobs if j.summary) + + return jsonify({ + 'stats': { + 'totalJobs': total_jobs, + 'completedJobs': completed_jobs, + 'failedJobs': failed_jobs, + 'activeJobs': active_jobs, + 'successRate': completed_jobs / total_jobs if total_jobs > 0 else 0, + 'totalAssetsExtracted': total_assets, + 'totalCostUsd': round(total_cost, 4) + } + }) + + except Exception as e: + logger.error(f"Failed to get job stats: {e}") + return jsonify({ + 'error': 'server_error', + 'message': 'Failed to retrieve statistics' + }), 500 + + +@jobs_bp.route('//deliverables', methods=['GET']) +@auth_required +async def get_job_deliverables(job_id: str): + """ + Return extracted deliverables from a completed job as JSON rows + ready for the Review → Import flow. + Reads the output CSV and maps columns to AC Deliverable schema. + """ + try: + job_manager = JobManager.get_instance() + user_id = get_user_id() + + job = await job_manager.get_job(job_id) + if not job: + return jsonify({'error': 'not_found'}), 404 + + from ..config_runtime import server_config + if not server_config.DEV_MODE and job.user_id != user_id: + return jsonify({'error': 'forbidden'}), 403 + + if not job.output_path or not os.path.exists(job.output_path): + return jsonify({'error': 'not_ready', 'message': 'Job not completed yet'}), 400 + + deliverables = [] + with open(job.output_path, newline='', encoding='utf-8') as f: + reader = csv.DictReader(f) + for row in reader: + # Map brief-extractor CSV columns → AC Deliverable schema + market = row.get('language_country_market', '') + lang, country = ('', '') + if '-' in market: + parts = market.split('-', 1) + lang = parts[0].strip().upper() + country = parts[1].strip().upper() + + deliverables.append({ + 'Number': '', + 'Title': row.get('title', ''), + 'Status': row.get('status', 'Booked') or 'Booked', + 'Category': row.get('category', ''), + 'Media': row.get('media', ''), + 'Sub-media': row.get('asset_type', ''), + 'Format': row.get('technical_specifications', ''), + 'Supply date': row.get('review_date', ''), + 'Live date': row.get('live_date', ''), + 'Language': lang, + 'Country': country, + 'Quantity': int(row.get('quantity', 1) or 1), + # Extra brief fields kept for review UI + '_brief_title': row.get('title', ''), + '_brand_identifier': row.get('brand_identifier', ''), + '_priority': row.get('priority_level', ''), + }) + + return jsonify({'deliverables': deliverables, 'count': len(deliverables)}) + + except Exception as e: + logger.error(f"Failed to get deliverables for job {job_id}: {e}") + return jsonify({'error': 'server_error'}), 500 \ No newline at end of file diff --git a/backend/server/api/sheets.py b/backend/server/api/sheets.py new file mode 100644 index 0000000..6c91fbb --- /dev/null +++ b/backend/server/api/sheets.py @@ -0,0 +1,119 @@ +""" +Sheet CRUD API — port of ac-helper api.php sheet management. +All routes scoped to the authenticated user. +""" + +import logging +from quart import Blueprint, jsonify, request + +from ..auth.middleware import auth_required, get_user_id +from ..sheets.manager import ( + get_user_sheets, create_sheet, load_sheet_data, + update_sheet, delete_sheet, rename_sheet, duplicate_sheet, + generate_next_id, +) + +logger = logging.getLogger(__name__) + +sheets_bp = Blueprint('sheets', __name__, url_prefix='/api/sheets') + + +@sheets_bp.route('', methods=['GET']) +@auth_required +async def list_sheets(): + user_id = get_user_id() + sheets = get_user_sheets(user_id) + return jsonify({'sheets': sheets}) + + +@sheets_bp.route('', methods=['POST']) +@auth_required +async def create_new_sheet(): + user_id = get_user_id() + body = await request.get_json() or {} + name = body.get('name', '') + data = body.get('data', []) + sheet = create_sheet(user_id, name, data) + return jsonify({'sheet': sheet}), 201 + + +@sheets_bp.route('/', methods=['GET']) +@auth_required +async def get_sheet(sheet_id: str): + user_id = get_user_id() + data = load_sheet_data(user_id, sheet_id) + if data is None: + return jsonify({'error': 'not_found'}), 404 + return jsonify({'data': data}) + + +@sheets_bp.route('/', methods=['PUT']) +@auth_required +async def update_sheet_data(sheet_id: str): + user_id = get_user_id() + body = await request.get_json() or {} + data = body.get('data', []) + update_sheet(user_id, sheet_id, data) + return jsonify({'success': True}) + + +@sheets_bp.route('/', methods=['DELETE']) +@auth_required +async def delete_sheet_route(sheet_id: str): + user_id = get_user_id() + delete_sheet(user_id, sheet_id) + return jsonify({'success': True}) + + +@sheets_bp.route('/', methods=['PATCH']) +@auth_required +async def rename_sheet_route(sheet_id: str): + user_id = get_user_id() + body = await request.get_json() or {} + name = body.get('name', '') + success = rename_sheet(user_id, sheet_id, name) + if not success: + return jsonify({'error': 'not_found'}), 404 + return jsonify({'success': True}) + + +@sheets_bp.route('//duplicate', methods=['POST']) +@auth_required +async def duplicate_sheet_route(sheet_id: str): + user_id = get_user_id() + sheet = duplicate_sheet(user_id, sheet_id) + if sheet is None: + return jsonify({'error': 'not_found'}), 404 + return jsonify({'sheet': sheet}), 201 + + +@sheets_bp.route('//import', methods=['POST']) +@auth_required +async def import_deliverables(sheet_id: str): + """ + Import a list of deliverables into an existing sheet. + Body: { "deliverables": [...], "mode": "append" | "replace" } + """ + user_id = get_user_id() + body = await request.get_json() or {} + incoming = body.get('deliverables', []) + mode = body.get('mode', 'append') + + existing = load_sheet_data(user_id, sheet_id) + if existing is None: + return jsonify({'error': 'not_found'}), 404 + + base = [] if mode == 'replace' else list(existing) + + for row in incoming: + row['Number'] = generate_next_id(base) + row.setdefault('Status', 'Booked') + row.setdefault('Quantity', 1) + # Strip internal brief metadata fields + for k in list(row.keys()): + if k.startswith('_'): + del row[k] + base.append(row) + + update_sheet(user_id, sheet_id, base) + return jsonify({'success': True, 'imported': len(incoming), 'total': len(base)}) diff --git a/backend/server/app.py b/backend/server/app.py new file mode 100644 index 0000000..dcfe729 --- /dev/null +++ b/backend/server/app.py @@ -0,0 +1,213 @@ +""" +Main Quart application — AC Tool (AC Helper + Brief Extractor unified) +""" + +import asyncio +import json +import logging +import os +import signal +from datetime import datetime +from typing import List + +from quart import Quart, websocket, jsonify +from quart_cors import cors +import structlog + +from .config_runtime import server_config +from .auth import msal_auth +from .jobs import JobManager +from .ws import ws_manager +from .runners.job_runner import start_background_workers, stop_background_workers + +# API blueprints +from .api.auth import auth_bp +from .api.jobs import jobs_bp +from .api.config import config_bp +from .api.sheets import sheets_bp +from .api.export import export_bp +from .api.ai_command import ai_bp +from .api.dropdowns import dropdowns_bp +from .api.admin import admin_bp + +structlog.configure( + processors=[ + structlog.stdlib.filter_by_level, + structlog.stdlib.add_logger_name, + structlog.stdlib.add_log_level, + structlog.stdlib.PositionalArgumentsFormatter(), + structlog.processors.TimeStamper(fmt="ISO"), + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + structlog.processors.JSONRenderer(), + ], + context_class=dict, + logger_factory=structlog.stdlib.LoggerFactory(), + wrapper_class=structlog.stdlib.BoundLogger, + cache_logger_on_first_use=True, +) + +logger = structlog.get_logger(__name__) +background_workers: List[asyncio.Task] = [] + + +def create_app() -> Quart: + app = Quart(__name__) + + cors_cfg = server_config.get_cors_config() + cors(app, **cors_cfg) + + app.config.update({ + 'MAX_CONTENT_LENGTH': server_config.MAX_CONTENT_LENGTH, + 'SECRET_KEY': server_config.SESSION_SECRET, + }) + + server_config.ensure_directories() + + # Seed dropdowns.json from embedded data if not present + _seed_dropdowns_if_needed() + + job_manager = JobManager.get_instance() + + # Register blueprints + for bp in [auth_bp, jobs_bp, config_bp, sheets_bp, export_bp, ai_bp, dropdowns_bp, admin_bp]: + app.register_blueprint(bp) + + # Serve React SPA static files (built by Vite into /app/frontend/dist) + _register_spa(app) + + @app.before_serving + async def startup(): + logger.info("Starting AC Tool server...") + await ws_manager.start_background_tasks() + global background_workers + background_workers = await start_background_workers( + job_manager, ws_manager, num_workers=server_config.MAX_CONCURRENT_JOBS + ) + background_workers.append(asyncio.create_task(periodic_cleanup(job_manager))) + logger.info("Server started", dev_mode=server_config.DEV_MODE) + + @app.after_serving + async def shutdown(): + logger.info("Shutting down AC Tool server...") + global background_workers + if background_workers: + await stop_background_workers(background_workers) + await ws_manager.stop_background_tasks() + + @app.route('/health') + async def health(): + queue_size = await job_manager.get_queue_size() + active_jobs = await job_manager.get_active_jobs_count() + ws_stats = await ws_manager.get_connection_stats() + return jsonify({ + 'status': 'healthy', + 'timestamp': datetime.utcnow().isoformat(), + 'queue': {'pending': queue_size, 'active': active_jobs}, + 'websockets': ws_stats, + 'devMode': server_config.DEV_MODE, + }) + + @app.websocket('/ws') + async def websocket_handler(): + client = None + try: + if server_config.DEV_MODE: + user_id = server_config.DEV_USER_ID + else: + user_id = None + token = websocket.args.get('token') or (websocket.headers.get('Authorization', '')[7:]) + if token: + from .auth.msal_auth import msal_auth as _msal + info = await _msal.validate_token(token) + if info: + user_id = info['oid'] + if not user_id: + await websocket.send(json.dumps({'error': 'unauthorized'})) + return + + client = await ws_manager.register_client(user_id) + jobs_data = job_manager.serialize_all() + await ws_manager.send_queue_snapshot(client, jobs_data) + + while True: + try: + msg = await websocket.receive() + if msg: + data = json.loads(msg) + if data.get('type') == 'ping': + await client.send({'type': 'pong'}) + except Exception: + break + except Exception as e: + logger.error(f"WebSocket error: {e}") + finally: + if client: + await ws_manager.unregister_client(client.client_id) + + # Error handlers + @app.errorhandler(401) + async def unauthorized(e): + return jsonify({'error': 'unauthorized'}), 401 + + @app.errorhandler(403) + async def forbidden(e): + return jsonify({'error': 'forbidden'}), 403 + + @app.errorhandler(404) + async def not_found(e): + return jsonify({'error': 'not_found'}), 404 + + @app.errorhandler(413) + async def too_large(e): + return jsonify({'error': 'file_too_large', 'message': f'Max {server_config.MAX_UPLOAD_SIZE_MB}MB'}), 413 + + @app.errorhandler(500) + async def internal(e): + return jsonify({'error': 'internal_error'}), 500 + + return app + + +def _register_spa(app: Quart): + """Serve the Vite-built React frontend for all non-API routes.""" + import os + from quart import send_from_directory, send_file + + dist = os.environ.get('FRONTEND_DIST', os.path.join(os.path.dirname(os.path.dirname(__file__)), '..', 'frontend', 'dist')) + dist = os.path.abspath(dist) + + if not os.path.isdir(dist): + logger.warning(f"Frontend dist not found at {dist} — API-only mode") + return + + @app.route('/', defaults={'path': ''}) + @app.route('/') + async def serve_spa(path): + full = os.path.join(dist, path) + if path and os.path.isfile(full): + return await send_from_directory(dist, path) + return await send_from_directory(dist, 'index.html') + + +def _seed_dropdowns_if_needed(): + """Write initial dropdowns.json from embedded seed data if file doesn't exist.""" + path = server_config.DROPDOWNS_FILE + if os.path.exists(path): + return + from .api.dropdowns import SEED_CATEGORIES, save_dropdowns + save_dropdowns(SEED_CATEGORIES) + logger.info(f"Seeded {len(SEED_CATEGORIES)} categories to {path}") + + +async def periodic_cleanup(job_manager: JobManager): + while True: + try: + await asyncio.sleep(3600) + cleaned = await job_manager.cleanup_expired_jobs() + if cleaned: + logger.info(f"Periodic cleanup: {cleaned} items removed") + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"Cleanup error: {e}") diff --git a/backend/server/auth/__init__.py b/backend/server/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/server/auth/middleware.py b/backend/server/auth/middleware.py new file mode 100644 index 0000000..e6579d0 --- /dev/null +++ b/backend/server/auth/middleware.py @@ -0,0 +1,123 @@ +""" +Authentication middleware — decorators for Quart routes. +Includes @auth_required, @admin_required, @dev_mode_bypass. +""" + +import logging +from functools import wraps +from typing import Optional, Dict, Any, Callable + +from quart import request, jsonify, g + +from .msal_auth import msal_auth +from .user_store import upsert_user, get_user +from ..config_runtime import server_config + +logger = logging.getLogger(__name__) + + +async def _extract_token_user() -> Optional[Dict[str, Any]]: + """Extract and validate Bearer token from Authorization header or ?_token= query param.""" + auth_header = request.headers.get('Authorization', '') + if auth_header.startswith('Bearer '): + token = auth_header[7:] + else: + # Fallback for browser download links (window.open) which can't set headers + token = request.args.get('_token', '') + if not token: + return None + return await msal_auth.validate_token(token) + + +async def _resolve_user(token_user: Dict) -> Dict: + """ + Merge token claims with our users.json store. + Creates the user record on first login; enriches token info with role. + """ + user_id = token_user['oid'] + email = token_user.get('preferred_username', '') + name = token_user.get('name', '') + + stored = upsert_user(user_id, email, name) + return {**token_user, 'role': stored.get('role', 'user'), 'active': stored.get('active', True)} + + +def auth_required(f: Callable) -> Callable: + """Require a valid Bearer token. Sets g.current_user.""" + @wraps(f) + async def wrapper(*args, **kwargs): + if server_config.DEV_MODE: + role = server_config.DEV_USER_ROLE + g.current_user = { + 'oid': server_config.DEV_USER_ID, + 'preferred_username': server_config.DEV_USER_EMAIL, + 'name': server_config.DEV_USER_NAME, + 'role': role, + 'active': True, + } + # Ensure dev user exists in store + upsert_user( + server_config.DEV_USER_ID, + server_config.DEV_USER_EMAIL, + server_config.DEV_USER_NAME, + role=role, + ) + else: + token_user = await _extract_token_user() + if not token_user: + return jsonify({'error': 'unauthorized', 'message': 'Authentication required'}), 401 + user = await _resolve_user(token_user) + if not user.get('active', True): + return jsonify({'error': 'forbidden', 'message': 'Account deactivated'}), 403 + g.current_user = user + + return await f(*args, **kwargs) + return wrapper + + +# Keep old name for compatibility with brief-extractor blueprints +dev_mode_bypass = auth_required + + +def admin_required(f: Callable) -> Callable: + """Require admin role. Must be used after @auth_required.""" + @wraps(f) + async def wrapper(*args, **kwargs): + if server_config.DEV_MODE: + role = server_config.DEV_USER_ROLE + g.current_user = { + 'oid': server_config.DEV_USER_ID, + 'preferred_username': server_config.DEV_USER_EMAIL, + 'name': server_config.DEV_USER_NAME, + 'role': role, + 'active': True, + } + upsert_user( + server_config.DEV_USER_ID, + server_config.DEV_USER_EMAIL, + server_config.DEV_USER_NAME, + role=role, + ) + else: + token_user = await _extract_token_user() + if not token_user: + return jsonify({'error': 'unauthorized', 'message': 'Authentication required'}), 401 + user = await _resolve_user(token_user) + if not user.get('active', True): + return jsonify({'error': 'forbidden', 'message': 'Account deactivated'}), 403 + g.current_user = user + + if g.current_user.get('role') != 'admin': + return jsonify({'error': 'forbidden', 'message': 'Admin access required'}), 403 + + return await f(*args, **kwargs) + return wrapper + + +def get_user_id() -> str: + user = getattr(g, 'current_user', None) + return user.get('oid', 'anonymous') if user else 'anonymous' + + +async def get_current_user() -> Optional[Dict[str, Any]]: + return getattr(g, 'current_user', None) diff --git a/backend/server/auth/msal_auth.py b/backend/server/auth/msal_auth.py new file mode 100644 index 0000000..933cda5 --- /dev/null +++ b/backend/server/auth/msal_auth.py @@ -0,0 +1,91 @@ +""" +MSAL / Azure AD token validator (SPA PKCE flow). +Backend only validates incoming Bearer JWTs — no server-side MSAL client needed. +""" + +import logging +import time +from typing import Optional, Dict, Any + +import jwt + +from ..config_runtime import server_config + +logger = logging.getLogger(__name__) + + +class MSALAuthenticator: + def __init__(self): + if server_config.DEV_MODE: + logger.info("Running in DEV_MODE — MSAL authentication bypassed") + + async def validate_token(self, access_token: str) -> Optional[Dict[str, Any]]: + if server_config.DEV_MODE: + return { + 'oid': server_config.DEV_USER_ID, + 'preferred_username': server_config.DEV_USER_EMAIL, + 'name': server_config.DEV_USER_NAME, + } + + if not access_token: + return None + + try: + # Decode without signature verification (PKCE SPA tokens may use + # audience = client_id; full sig verification requires fetching JWKS). + unverified = jwt.decode( + access_token, + options={"verify_signature": False, "verify_aud": False}, + ) + + user_id = unverified.get('oid') + if not user_id: + logger.warning("Token missing 'oid' claim") + return None + + exp = unverified.get('exp', 0) + if exp < time.time(): + logger.warning("Token expired") + return None + + return { + 'oid': user_id, + 'preferred_username': unverified.get('preferred_username') or unverified.get('upn', ''), + 'name': unverified.get('name', ''), + } + + except jwt.InvalidTokenError as e: + logger.warning(f"Invalid JWT: {e}") + return None + except Exception as e: + logger.error(f"Token validation error: {e}", exc_info=True) + return None + + async def get_logout_url(self, post_logout_redirect_uri: Optional[str] = None) -> str: + if server_config.DEV_MODE: + return post_logout_redirect_uri or 'http://localhost:5173' + base = f"{server_config.MSAL_AUTHORITY}/oauth2/v2.0/logout" + if post_logout_redirect_uri: + return f"{base}?post_logout_redirect_uri={post_logout_redirect_uri}" + return base + + def get_client_config(self) -> Dict[str, Any]: + if server_config.DEV_MODE: + return { + 'clientId': server_config.MSAL_CLIENT_ID, + 'authority': server_config.MSAL_AUTHORITY, + 'redirectUri': server_config.MSAL_REDIRECT_URI, + 'devMode': True, + } + return { + 'clientId': server_config.MSAL_CLIENT_ID, + 'authority': server_config.MSAL_AUTHORITY, + 'redirectUri': server_config.MSAL_REDIRECT_URI, + 'devMode': False, + } + + def is_dev_mode(self) -> bool: + return server_config.DEV_MODE + + +msal_auth = MSALAuthenticator() diff --git a/backend/server/auth/user_store.py b/backend/server/auth/user_store.py new file mode 100644 index 0000000..853551e --- /dev/null +++ b/backend/server/auth/user_store.py @@ -0,0 +1,96 @@ +""" +User store — manages users.json (roles, active status). +Keyed by Azure AD oid (object ID). +""" + +import json +import logging +import os +from datetime import datetime, timezone +from typing import Dict, Optional + +from ..config_runtime import server_config + +logger = logging.getLogger(__name__) + +_LOCK_FILE = server_config.USERS_FILE + '.lock' + + +def _load() -> Dict: + path = server_config.USERS_FILE + if not os.path.exists(path): + return {} + try: + with open(path, 'r') as f: + return json.load(f) + except Exception: + return {} + + +def _save(data: Dict): + path = server_config.USERS_FILE + with open(path, 'w') as f: + json.dump(data, f, indent=2) + + +def get_user(user_id: str) -> Optional[Dict]: + users = _load() + return users.get(user_id) + + +def upsert_user(user_id: str, email: str, name: str, role: Optional[str] = None) -> Dict: + """ + Create or update user. On creation defaults to 'user' role, + unless the email matches ADMIN_EMAIL env var (gets 'admin'). + """ + users = _load() + existing = users.get(user_id) + + if existing is None: + # First login — determine default role + default_role = 'admin' if email and email.lower() == server_config.ADMIN_EMAIL.lower() else 'user' + user = { + 'id': user_id, + 'email': email, + 'name': name, + 'role': role or default_role, + 'active': True, + 'created': datetime.now(timezone.utc).isoformat(), + 'last_seen': datetime.now(timezone.utc).isoformat(), + } + else: + user = {**existing} + user['email'] = email or existing.get('email', '') + user['name'] = name or existing.get('name', '') + user['last_seen'] = datetime.now(timezone.utc).isoformat() + if role is not None: + user['role'] = role + + users[user_id] = user + _save(users) + return user + + +def list_users() -> list: + users = _load() + return sorted(users.values(), key=lambda u: u.get('last_seen', ''), reverse=True) + + +def set_role(user_id: str, role: str) -> Optional[Dict]: + if role not in ('user', 'admin'): + return None + users = _load() + if user_id not in users: + return None + users[user_id]['role'] = role + _save(users) + return users[user_id] + + +def set_active(user_id: str, active: bool) -> Optional[Dict]: + users = _load() + if user_id not in users: + return None + users[user_id]['active'] = active + _save(users) + return users[user_id] diff --git a/backend/server/config_runtime.py b/backend/server/config_runtime.py new file mode 100755 index 0000000..61cf9ca --- /dev/null +++ b/backend/server/config_runtime.py @@ -0,0 +1,97 @@ +""" +Runtime configuration for AC Tool server +""" + +import os +from typing import List +from dotenv import load_dotenv + +load_dotenv() + + +class ServerConfig: + # Server + HOST: str = os.getenv('SERVER_HOST', '0.0.0.0') + PORT: int = int(os.getenv('SERVER_PORT', '8000')) + WORKERS: int = int(os.getenv('SERVER_WORKERS', '2')) + DEBUG: bool = os.getenv('DEBUG', 'false').lower() == 'true' + + # Development Mode + DEV_MODE: bool = os.getenv('DEV_MODE', 'true').lower() == 'true' + DEV_USER_ID: str = os.getenv('DEV_USER_ID', 'dev-user-id') + DEV_USER_EMAIL: str = os.getenv('DEV_USER_EMAIL', 'dev@localhost') + DEV_USER_NAME: str = os.getenv('DEV_USER_NAME', 'Dev User') + DEV_USER_ROLE: str = os.getenv('DEV_USER_ROLE', 'admin') # 'user' or 'admin' + + # CORS + ALLOWED_ORIGINS: List[str] = [ + origin.strip() + for origin in os.getenv( + 'ALLOWED_ORIGINS', + 'http://localhost:3000,http://localhost:5173,https://ai-sandbox.oliver.solutions' + ).split(',') + ] + + # Azure AD / MSAL (SPA PKCE flow — no client secret needed) + MSAL_CLIENT_ID: str = os.getenv('MSAL_CLIENT_ID', '9079054c-9620-4757-a256-23413042f1ef') + MSAL_TENANT_ID: str = os.getenv('MSAL_TENANT_ID', 'e519c2e6-bc6d-4fdf-8d9c-923c2f002385') + MSAL_REDIRECT_URI: str = os.getenv('MSAL_REDIRECT_URI', 'https://ai-sandbox.oliver.solutions/ac-helper/') + MSAL_AUTHORITY: str = f'https://login.microsoftonline.com/{os.getenv("MSAL_TENANT_ID", "e519c2e6-bc6d-4fdf-8d9c-923c2f002385")}' + + # Admin bootstrap + ADMIN_EMAIL: str = os.getenv('ADMIN_EMAIL', 'daveporter@oliver.agency') + + # Security + SESSION_SECRET: str = os.getenv('SESSION_SECRET', 'change-me-in-production') + SECURE_COOKIES: bool = os.getenv('SECURE_COOKIES', 'false').lower() == 'true' + HTTPS_ONLY: bool = os.getenv('HTTPS_ONLY', 'false').lower() == 'true' + + # File Upload + MAX_UPLOAD_SIZE_MB: int = int(os.getenv('MAX_UPLOAD_SIZE_MB', '200')) + MAX_CONTENT_LENGTH: int = MAX_UPLOAD_SIZE_MB * 1024 * 1024 + ALLOWED_EXTENSIONS: set = {'.pdf', '.pptx', '.docx', '.xlsx', '.ppt', '.doc', '.xls'} + + # Job Management + MAX_CONCURRENT_JOBS: int = int(os.getenv('MAX_CONCURRENT_JOBS', '2')) + FILE_RETENTION_HOURS: int = int(os.getenv('FILE_RETENTION_HOURS', '24')) + + # WebSocket + WS_PING_INTERVAL_SECONDS: int = int(os.getenv('WS_PING_INTERVAL_SECONDS', '30')) + + # AI + GEMINI_API_KEY: str = os.getenv('GEMINI_API_KEY', '') + GEMINI_MODEL: str = os.getenv('GEMINI_MODEL', 'gemini-2.0-flash-exp') + + # Data paths — mounted as Docker volume + DATA_DIR: str = os.getenv( + 'DATA_DIR', + os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'data') + ) + UPLOAD_DIR: str = os.path.join(DATA_DIR, 'uploads') + OUTPUT_DIR: str = os.path.join(DATA_DIR, 'outputs') + SHEETS_DIR: str = os.path.join(DATA_DIR, 'sheets') + USERS_FILE: str = os.path.join(DATA_DIR, 'users.json') + DROPDOWNS_FILE: str = os.path.join(DATA_DIR, 'dropdowns.json') + + @classmethod + def ensure_directories(cls): + for d in [cls.DATA_DIR, cls.UPLOAD_DIR, cls.OUTPUT_DIR, cls.SHEETS_DIR]: + os.makedirs(d, exist_ok=True) + + @classmethod + def validate_auth_config(cls) -> bool: + if cls.DEV_MODE: + return True + return bool(cls.MSAL_CLIENT_ID and cls.MSAL_TENANT_ID) + + @classmethod + def get_cors_config(cls) -> dict: + return { + 'allow_origin': cls.ALLOWED_ORIGINS, + 'allow_methods': ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], + 'allow_headers': ['Content-Type', 'Authorization', 'Accept'], + 'allow_credentials': True, + } + + +server_config = ServerConfig() diff --git a/backend/server/jobs/__init__.py b/backend/server/jobs/__init__.py new file mode 100755 index 0000000..f3b5813 --- /dev/null +++ b/backend/server/jobs/__init__.py @@ -0,0 +1,18 @@ +""" +Job management module for Brief Extractor GUI +""" + +from .models import Job, JobPhase, ProviderUpdate, JobSummary, ModelConfiguration, ModelInfo +from .manager import JobManager +from .storage import StorageManager + +__all__ = [ + 'Job', + 'JobPhase', + 'ProviderUpdate', + 'JobSummary', + 'ModelConfiguration', + 'ModelInfo', + 'JobManager', + 'StorageManager' +] \ No newline at end of file diff --git a/backend/server/jobs/manager.py b/backend/server/jobs/manager.py new file mode 100755 index 0000000..376a875 --- /dev/null +++ b/backend/server/jobs/manager.py @@ -0,0 +1,338 @@ +""" +Job manager for handling job queue, registry, and lifecycle +""" + +import asyncio +import logging +from datetime import datetime, timedelta +from typing import Dict, List, Optional +from threading import RLock + +from .models import Job, JobPhase, ModelConfiguration, ModelInfo +from .storage import StorageManager +from ..config_runtime import server_config + +logger = logging.getLogger(__name__) + +class JobManager: + """ + Manages job lifecycle, queue, and in-memory registry + Thread-safe singleton for job management + """ + + _instance: Optional['JobManager'] = None + _lock = RLock() + + def __new__(cls): + if cls._instance is None: + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + if hasattr(self, '_initialized'): + return + + self._initialized = True + self.jobs: Dict[str, Job] = {} + self.queue: asyncio.Queue = asyncio.Queue() + self.processing_semaphore = asyncio.Semaphore(server_config.MAX_CONCURRENT_JOBS) + self.storage = StorageManager() + self._lock = asyncio.Lock() + + logger.info(f"JobManager initialized with concurrency limit: {server_config.MAX_CONCURRENT_JOBS}") + + @classmethod + def get_instance(cls) -> 'JobManager': + """Get the singleton instance""" + return cls() + + async def create_job( + self, + file_name: str, + file_size: int, + file_data: bytes, + user_id: str, + model_config: Optional[ModelConfiguration] = None + ) -> Job: + """ + Create a new job from uploaded file + + Args: + file_name: Original filename + file_size: Size in bytes + file_data: Binary file content + user_id: User identifier + model_config: Model configuration for processing + + Returns: + Created job object + """ + # Validate file + is_valid, error_msg = self.storage.validate_file(file_name, file_size) + if not is_valid: + raise ValueError(f"File validation failed: {error_msg}") + + # Create job + job = Job.create( + file_name=file_name, + file_size=file_size, + user_id=user_id, + upload_path="", # Will be set after saving + model_config=model_config + ) + + try: + # Save uploaded file + upload_path = await self.storage.save_uploaded_file( + file_data=file_data, + filename=file_name, + job_id=job.id + ) + job.upload_path = upload_path + + # Add to registry + async with self._lock: + self.jobs[job.id] = job + + # Add to queue + await self.queue.put(job.id) + + logger.info(f"Created job {job.id} for file {file_name} (user: {user_id})") + return job + + except Exception as e: + logger.error(f"Failed to create job for {file_name}: {e}") + # Cleanup on failure + if job.upload_path: + await self.storage.cleanup_job_files(job.upload_path, None) + raise + + async def get_job(self, job_id: str) -> Optional[Job]: + """Get job by ID""" + async with self._lock: + return self.jobs.get(job_id) + + async def update_job(self, job_id: str, **updates) -> bool: + """ + Update job attributes + + Args: + job_id: Job identifier + **updates: Attributes to update + + Returns: + True if job was found and updated + """ + async with self._lock: + job = self.jobs.get(job_id) + if not job: + return False + + for attr, value in updates.items(): + if hasattr(job, attr): + setattr(job, attr, value) + + job.updated_at = datetime.utcnow() + return True + + async def get_user_jobs( + self, + user_id: str, + limit: int = 100, + offset: int = 0 + ) -> List[Job]: + """ + Get jobs for a specific user + + Args: + user_id: User identifier + limit: Maximum number of jobs to return + offset: Number of jobs to skip + + Returns: + List of user's jobs, newest first + """ + async with self._lock: + user_jobs = [ + job for job in self.jobs.values() + if job.user_id == user_id + ] + + # Sort by creation time, newest first + user_jobs.sort(key=lambda j: j.created_at, reverse=True) + + # Apply pagination + return user_jobs[offset:offset + limit] + + async def get_all_jobs(self, limit: int = 100, offset: int = 0) -> List[Job]: + """ + Get all jobs (admin function) + + Args: + limit: Maximum number of jobs to return + offset: Number of jobs to skip + + Returns: + List of all jobs, newest first + """ + async with self._lock: + all_jobs = list(self.jobs.values()) + + # Sort by creation time, newest first + all_jobs.sort(key=lambda j: j.created_at, reverse=True) + + # Apply pagination + return all_jobs[offset:offset + limit] + + async def delete_job(self, job_id: str) -> bool: + """ + Delete a job and clean up its files + + Args: + job_id: Job identifier + + Returns: + True if job was found and deleted + """ + async with self._lock: + job = self.jobs.get(job_id) + if not job: + return False + + # Clean up files + await self.storage.cleanup_job_files(job.upload_path, job.output_path) + + # Remove from registry + del self.jobs[job_id] + + logger.info(f"Deleted job {job_id}") + return True + + async def get_queue_size(self) -> int: + """Get current queue size""" + return self.queue.qsize() + + async def get_active_jobs_count(self) -> int: + """Get number of jobs currently being processed""" + async with self._lock: + return len([ + job for job in self.jobs.values() + if job.phase in [JobPhase.EXTRACT_CONTENT, JobPhase.LLM_ANALYSIS, + JobPhase.CONSOLIDATION, JobPhase.CSV_GENERATION] + ]) + + def serialize_all(self) -> List[Dict]: + """Serialize all jobs for WebSocket broadcast""" + return [job.to_dict() for job in self.jobs.values()] + + async def cleanup_expired_jobs(self) -> int: + """ + Clean up expired jobs and their files + + Returns: + Number of jobs cleaned up + """ + cutoff_time = datetime.utcnow() - timedelta(hours=server_config.FILE_RETENTION_HOURS) + cleanup_count = 0 + + # Get jobs to cleanup + jobs_to_cleanup = [] + async with self._lock: + for job_id, job in list(self.jobs.items()): + # Clean up completed/failed jobs older than retention period + if (job.phase in [JobPhase.COMPLETED, JobPhase.FAILED] and + job.updated_at < cutoff_time): + jobs_to_cleanup.append(job_id) + + # Clean up identified jobs + for job_id in jobs_to_cleanup: + if await self.delete_job(job_id): + cleanup_count += 1 + + # Also clean up orphaned files + orphaned_count = await self.storage.cleanup_expired_files() + + total_cleaned = cleanup_count + orphaned_count + if total_cleaned > 0: + logger.info(f"Cleaned up {cleanup_count} expired jobs and {orphaned_count} orphaned files") + + return total_cleaned + + @staticmethod + def get_available_models() -> List[ModelInfo]: + """ + Get list of available models with their information + + Returns: + List of available model information + """ + # Import here to avoid circular imports + from core.config import config as core_config + + models = [] + + # Define model information based on existing configuration + model_info_map = { + 'openai-gpt51': ModelInfo( + key='openai-gpt51', + name='GPT-5.1', + provider='OpenAI', + description='Latest OpenAI model with advanced reasoning capabilities', + cost_per_1m_input=1.25, + cost_per_1m_output=10.00, + can_be_primary=True, + can_be_consolidation=True + ), + 'anthropic-opus45': ModelInfo( + key='anthropic-opus45', + name='Claude Opus 4.5', + provider='Anthropic', + description='Highest quality model for complex analysis', + cost_per_1m_input=5.00, + cost_per_1m_output=25.00, + can_be_primary=True, + can_be_consolidation=True + ), + 'anthropic-sonnet45': ModelInfo( + key='anthropic-sonnet45', + name='Claude Sonnet 4.5', + provider='Anthropic', + description='Balanced performance and cost', + cost_per_1m_input=3.00, + cost_per_1m_output=15.00, + can_be_primary=True, + can_be_consolidation=True + ), + 'google-gemini31': ModelInfo( + key='google-gemini31', + name='Gemini 3.1 Pro', + provider='Google', + description='Cost-effective model with high context limit', + cost_per_1m_input=1.25, + cost_per_1m_output=5.00, + can_be_primary=True, + can_be_consolidation=True + ) + } + + # Return models that exist in the configuration + for model_key in core_config.MODEL_MAPPINGS.keys(): + if model_key in model_info_map: + models.append(model_info_map[model_key]) + + return models + + def get_default_model_config() -> ModelConfiguration: + """Get default model configuration""" + from core.config import config as core_config + + return ModelConfiguration( + primary_models=core_config.get_default_primary_models(), + consolidation_model=core_config.DEFAULT_CONSOLIDATION_MODEL, + minimum_success_threshold=core_config.MINIMUM_SUCCESS_THRESHOLD + ) + +# Global instance +job_manager = JobManager.get_instance() \ No newline at end of file diff --git a/backend/server/jobs/models.py b/backend/server/jobs/models.py new file mode 100755 index 0000000..f2bc3d0 --- /dev/null +++ b/backend/server/jobs/models.py @@ -0,0 +1,270 @@ +""" +Data models for job management and processing +""" + +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import Dict, List, Optional, Any +import uuid + +class JobPhase(Enum): + """Processing phases for a job""" + QUEUED = "QUEUED" + EXTRACT_CONTENT = "EXTRACT_CONTENT" + LLM_ANALYSIS = "LLM_ANALYSIS" + CONSOLIDATION = "CONSOLIDATION" + CSV_GENERATION = "CSV_GENERATION" + COMPLETED = "COMPLETED" + FAILED = "FAILED" + +@dataclass +class ProviderUpdate: + """Update information for a specific LLM provider during processing""" + provider: str # 'openai', 'anthropic', 'google' + model: str # e.g., "gpt-5.1", "claude-sonnet-4-5", "gemini-3.1-pro" + status: str # 'started', 'success', 'error' + started_at: Optional[str] = None + completed_at: Optional[str] = None + latency_ms: Optional[float] = None + tokens_in: Optional[int] = None + tokens_out: Optional[int] = None + tokens_cached: Optional[int] = None + cost_usd: Optional[float] = None + error: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for JSON serialization""" + return { + 'provider': self.provider, + 'model': self.model, + 'status': self.status, + 'startedAt': self.started_at, + 'completedAt': self.completed_at, + 'latencyMs': self.latency_ms, + 'tokensIn': self.tokens_in, + 'tokensOut': self.tokens_out, + 'tokensCached': self.tokens_cached, + 'costUsd': self.cost_usd, + 'error': self.error + } + +@dataclass +class LogEntry: + """Individual log entry for job processing""" + timestamp: str + level: str # 'DEBUG', 'INFO', 'WARNING', 'ERROR' + message: str + + def to_dict(self) -> Dict[str, Any]: + return { + 'timestamp': self.timestamp, + 'level': self.level, + 'message': self.message + } + +@dataclass +class JobSummary: + """Summary information for a completed job""" + doc_type: str + assets_extracted: int + confidence_score: float + notes: List[str] + cost_usd_total: float + tokens_total: int + primary_models: List[str] + consolidation_model: str + processing_time_seconds: Optional[float] = None + + def to_dict(self) -> Dict[str, Any]: + return { + 'docType': self.doc_type, + 'assetsExtracted': self.assets_extracted, + 'confidenceScore': self.confidence_score, + 'notes': self.notes, + 'costUsdTotal': self.cost_usd_total, + 'tokensTotal': self.tokens_total, + 'primaryModels': self.primary_models, + 'consolidationModel': self.consolidation_model, + 'processingTimeSeconds': self.processing_time_seconds + } + +@dataclass +class ModelInfo: + """Information about an available LLM model""" + key: str + name: str + provider: str + description: str + cost_per_1m_input: float + cost_per_1m_output: float + can_be_primary: bool = True + can_be_consolidation: bool = True + + def to_dict(self) -> Dict[str, Any]: + return { + 'key': self.key, + 'name': self.name, + 'provider': self.provider, + 'description': self.description, + 'costPer1mInput': self.cost_per_1m_input, + 'costPer1mOutput': self.cost_per_1m_output, + 'canBePrimary': self.can_be_primary, + 'canBeConsolidation': self.can_be_consolidation + } + +@dataclass +class ModelConfiguration: + """Model selection configuration for a job""" + primary_models: List[str] = field(default_factory=lambda: [ + 'openai-gpt51', 'anthropic-sonnet45', 'google-gemini31' + ]) + consolidation_model: str = 'openai-gpt51' + minimum_success_threshold: int = 1 + + def to_dict(self) -> Dict[str, Any]: + return { + 'primaryModels': self.primary_models, + 'consolidationModel': self.consolidation_model, + 'minimumSuccessThreshold': self.minimum_success_threshold + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'ModelConfiguration': + return cls( + primary_models=data.get('primaryModels', []), + consolidation_model=data.get('consolidationModel', 'openai-gpt51'), + minimum_success_threshold=data.get('minimumSuccessThreshold', 1) + ) + +@dataclass +class Job: + """Main job model representing a document processing job""" + id: str + file_name: str + file_size: int + created_at: datetime + updated_at: datetime + user_id: str + phase: JobPhase + progress_pct: int # 0-100 + step_label: str + provider_updates: Dict[str, ProviderUpdate] = field(default_factory=dict) + error: Optional[str] = None + result_csv_url: Optional[str] = None + summary: Optional[JobSummary] = None + logs: List[LogEntry] = field(default_factory=list) + upload_path: Optional[str] = None + output_path: Optional[str] = None + model_config: ModelConfiguration = field(default_factory=ModelConfiguration) + + @classmethod + def create( + cls, + file_name: str, + file_size: int, + user_id: str, + upload_path: str, + model_config: Optional[ModelConfiguration] = None + ) -> 'Job': + """Create a new job with default values""" + now = datetime.utcnow() + return cls( + id=str(uuid.uuid4()), + file_name=file_name, + file_size=file_size, + created_at=now, + updated_at=now, + user_id=user_id, + phase=JobPhase.QUEUED, + progress_pct=0, + step_label='Queued for processing', + upload_path=upload_path, + model_config=model_config or ModelConfiguration() + ) + + def update_progress( + self, + phase: JobPhase, + progress_pct: int, + step_label: str = "" + ): + """Update job progress""" + self.phase = phase + self.progress_pct = min(100, max(0, progress_pct)) # Clamp to [0, 100] + self.updated_at = datetime.utcnow() + + if step_label: + self.step_label = step_label + else: + # Default step labels based on phase + phase_labels = { + JobPhase.QUEUED: 'Queued for processing', + JobPhase.EXTRACT_CONTENT: 'Extracting document content', + JobPhase.LLM_ANALYSIS: 'Parallel LLM analysis', + JobPhase.CONSOLIDATION: 'Consolidating results', + JobPhase.CSV_GENERATION: 'Generating CSV output', + JobPhase.COMPLETED: 'Processing completed', + JobPhase.FAILED: 'Processing failed' + } + self.step_label = phase_labels.get(phase, 'Processing') + + def add_log(self, level: str, message: str): + """Add a log entry to this job""" + log_entry = LogEntry( + timestamp=datetime.utcnow().isoformat(), + level=level, + message=message + ) + self.logs.append(log_entry) + self.updated_at = datetime.utcnow() + + def update_provider(self, model_key: str, update: ProviderUpdate): + """Update status for a specific provider""" + self.provider_updates[model_key] = update + self.updated_at = datetime.utcnow() + + def mark_completed( + self, + result_csv_url: str, + summary: JobSummary, + output_path: str + ): + """Mark job as completed with results""" + self.phase = JobPhase.COMPLETED + self.progress_pct = 100 + self.step_label = 'Processing completed' + self.result_csv_url = result_csv_url + self.summary = summary + self.output_path = output_path + self.updated_at = datetime.utcnow() + + def mark_failed(self, error: str): + """Mark job as failed with error message""" + self.phase = JobPhase.FAILED + self.error = error + self.step_label = 'Processing failed' + self.updated_at = datetime.utcnow() + + def to_dict(self) -> Dict[str, Any]: + """Convert job to dictionary for JSON serialization""" + # Handle phase - might be string or enum + phase_value = self.phase.value if isinstance(self.phase, JobPhase) else self.phase + + return { + 'id': self.id, + 'fileName': self.file_name, + 'fileSize': self.file_size, + 'createdAt': self.created_at.isoformat(), + 'updatedAt': self.updated_at.isoformat(), + 'userId': self.user_id, + 'phase': phase_value, + 'progressPct': self.progress_pct, + 'stepLabel': self.step_label, + 'providerUpdates': {k: v.to_dict() for k, v in self.provider_updates.items()}, + 'error': self.error, + 'resultCsvUrl': self.result_csv_url, + 'summary': self.summary.to_dict() if self.summary else None, + 'logs': [log.to_dict() for log in self.logs], + 'modelConfig': self.model_config.to_dict() + } \ No newline at end of file diff --git a/backend/server/jobs/storage.py b/backend/server/jobs/storage.py new file mode 100755 index 0000000..94fc420 --- /dev/null +++ b/backend/server/jobs/storage.py @@ -0,0 +1,231 @@ +""" +File storage management for uploads and outputs +""" + +import os +import hashlib +import asyncio +from datetime import datetime, timedelta +from pathlib import Path +from typing import Optional, List +import logging +import uuid + +from ..config_runtime import server_config + +logger = logging.getLogger(__name__) + +class StorageManager: + """Manages file storage, cleanup, and safe file operations""" + + def __init__(self): + self.upload_dir = Path(server_config.UPLOAD_DIR) + self.output_dir = Path(server_config.OUTPUT_DIR) + + # Ensure directories exist + self.upload_dir.mkdir(parents=True, exist_ok=True) + self.output_dir.mkdir(parents=True, exist_ok=True) + + def generate_safe_filename(self, original_filename: str, job_id: str) -> str: + """ + Generate a safe filename for uploaded files + + Args: + original_filename: Original filename from upload + job_id: Unique job identifier + + Returns: + Safe filename with job ID prefix + """ + # Extract extension + name, ext = os.path.splitext(original_filename) + + # Sanitize the filename + safe_name = "".join(c for c in name if c.isalnum() or c in (' ', '-', '_')).strip() + safe_name = safe_name[:50] # Limit length + + # Generate unique filename + return f"{job_id}_{safe_name}{ext}" + + def get_upload_path(self, filename: str) -> str: + """Get full path for uploaded file""" + return str(self.upload_dir / filename) + + def get_output_path(self, job_id: str, original_filename: str) -> str: + """ + Generate output CSV path for a job + + Args: + job_id: Job identifier + original_filename: Original uploaded filename + + Returns: + Path for output CSV file + """ + # Generate timestamp + timestamp = datetime.utcnow().strftime("%Y%m%d%H%M%S") + + # Extract base name without extension + base_name = os.path.splitext(original_filename)[0] + safe_base = "".join(c for c in base_name if c.isalnum() or c in (' ', '-', '_')).strip() + safe_base = safe_base[:30] # Limit length + + # Generate output filename + output_filename = f"{safe_base}-{timestamp}.csv" + return str(self.output_dir / output_filename) + + async def save_uploaded_file(self, file_data: bytes, filename: str, job_id: str) -> str: + """ + Save uploaded file data to disk + + Args: + file_data: Binary file data + filename: Original filename + job_id: Job identifier + + Returns: + Path to saved file + """ + safe_filename = self.generate_safe_filename(filename, job_id) + file_path = self.get_upload_path(safe_filename) + + try: + # Write file asynchronously using thread pool + def _write_file(): + """Blocking file write operation for thread pool""" + with open(file_path, 'wb') as f: + f.write(file_data) + + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, _write_file) + + logger.info(f"Saved uploaded file: {file_path}") + return file_path + + except Exception as e: + logger.error(f"Failed to save uploaded file {filename}: {e}") + raise + + def validate_file(self, filename: str, file_size: int) -> tuple[bool, Optional[str]]: + """ + Validate uploaded file + + Args: + filename: Original filename + file_size: File size in bytes + + Returns: + Tuple of (is_valid, error_message) + """ + # Check file extension + _, ext = os.path.splitext(filename.lower()) + if ext not in server_config.ALLOWED_EXTENSIONS: + allowed = ', '.join(server_config.ALLOWED_EXTENSIONS) + return False, f"File type {ext} not allowed. Allowed types: {allowed}" + + # Check file size + max_size = server_config.MAX_CONTENT_LENGTH + if file_size > max_size: + max_mb = max_size / (1024 * 1024) + actual_mb = file_size / (1024 * 1024) + return False, f"File size {actual_mb:.1f}MB exceeds limit of {max_mb:.1f}MB" + + # Check filename length and characters + if len(filename) > 255: + return False, "Filename too long (max 255 characters)" + + return True, None + + async def cleanup_job_files(self, upload_path: Optional[str], output_path: Optional[str]): + """ + Clean up files associated with a job + + Args: + upload_path: Path to uploaded file + output_path: Path to output CSV file + """ + for file_path in [upload_path, output_path]: + if file_path and os.path.exists(file_path): + try: + os.remove(file_path) + logger.info(f"Cleaned up file: {file_path}") + except Exception as e: + logger.warning(f"Failed to clean up file {file_path}: {e}") + + async def cleanup_expired_files(self) -> int: + """ + Clean up files older than the retention period + + Returns: + Number of files cleaned up + """ + cutoff_time = datetime.utcnow() - timedelta(hours=server_config.FILE_RETENTION_HOURS) + cleanup_count = 0 + + # Clean upload directory + cleanup_count += await self._cleanup_directory(self.upload_dir, cutoff_time) + + # Clean output directory + cleanup_count += await self._cleanup_directory(self.output_dir, cutoff_time) + + if cleanup_count > 0: + logger.info(f"Cleaned up {cleanup_count} expired files") + + return cleanup_count + + async def _cleanup_directory(self, directory: Path, cutoff_time: datetime) -> int: + """Clean up files in a specific directory older than cutoff time""" + cleanup_count = 0 + + try: + for file_path in directory.iterdir(): + if file_path.is_file(): + # Get file modification time + mtime = datetime.fromtimestamp(file_path.stat().st_mtime) + + if mtime < cutoff_time: + try: + file_path.unlink() + cleanup_count += 1 + logger.debug(f"Cleaned up expired file: {file_path}") + except Exception as e: + logger.warning(f"Failed to delete expired file {file_path}: {e}") + + except Exception as e: + logger.error(f"Error during directory cleanup {directory}: {e}") + + return cleanup_count + + def get_file_info(self, file_path: str) -> Optional[dict]: + """ + Get information about a file + + Args: + file_path: Path to file + + Returns: + Dictionary with file info or None if file doesn't exist + """ + if not os.path.exists(file_path): + return None + + try: + stat = os.stat(file_path) + return { + 'path': file_path, + 'size': stat.st_size, + 'created': datetime.fromtimestamp(stat.st_ctime).isoformat(), + 'modified': datetime.fromtimestamp(stat.st_mtime).isoformat() + } + except Exception as e: + logger.error(f"Failed to get file info for {file_path}: {e}") + return None + + def ensure_directories(self): + """Ensure all required directories exist""" + self.upload_dir.mkdir(parents=True, exist_ok=True) + self.output_dir.mkdir(parents=True, exist_ok=True) + logger.info(f"Storage directories ready: {self.upload_dir}, {self.output_dir}") + +# Global instance +storage_manager = StorageManager() \ No newline at end of file diff --git a/backend/server/runners/__init__.py b/backend/server/runners/__init__.py new file mode 100755 index 0000000..edc662a --- /dev/null +++ b/backend/server/runners/__init__.py @@ -0,0 +1,16 @@ +""" +Job runners module for processing document analysis jobs +""" + +from .progress import ProgressReporter, JobLogHandler, create_job_logger +from .job_runner import run_job, process_job_queue, start_background_workers, stop_background_workers + +__all__ = [ + 'ProgressReporter', + 'JobLogHandler', + 'create_job_logger', + 'run_job', + 'process_job_queue', + 'start_background_workers', + 'stop_background_workers' +] \ No newline at end of file diff --git a/backend/server/runners/enhanced_analyzer.py b/backend/server/runners/enhanced_analyzer.py new file mode 100755 index 0000000..3e1769a --- /dev/null +++ b/backend/server/runners/enhanced_analyzer.py @@ -0,0 +1,368 @@ +""" +Enhanced DocumentAnalyzer with progress reporting for GUI integration +Extends the existing analyzer with progress hooks and WebSocket updates +""" + +import sys +import os +sys.path.append(os.path.join(os.path.dirname(__file__), '../../')) + +from typing import Optional, List, Dict, Any +import logging + +from core.process_brief_enhanced import DocumentAnalyzer, ProcessingResult +from ..jobs.models import JobPhase, ModelConfiguration, JobSummary +from .progress import ProgressReporter + +logger = logging.getLogger(__name__) + +class EnhancedDocumentAnalyzer(DocumentAnalyzer): + """ + Enhanced DocumentAnalyzer with progress reporting capabilities + Extends the base analyzer with WebSocket progress updates + """ + + def __init__( + self, + model_config: ModelConfiguration, + progress_reporter: Optional[ProgressReporter] = None + ): + # Initialize base analyzer with model configuration + primary_models = model_config.primary_models + consolidation_model = model_config.consolidation_model + + super().__init__(primary_models, consolidation_model) + + self.progress = progress_reporter + self.model_config = model_config + + async def process_document_with_progress(self, filepath: str) -> ProcessingResult: + """ + Process document with progress reporting integration + + Args: + filepath: Path to document file + + Returns: + ProcessingResult with extracted data + """ + try: + if self.progress: + await self.progress.emit( + JobPhase.EXTRACT_CONTENT, + 10, + f"Starting analysis of {os.path.basename(filepath)}" + ) + + # Stage 1: Extract document content + if self.progress: + await self.progress.emit_log('INFO', "=== STAGE 1: Document Content Extraction ===") + + try: + document_content = self._extract_document_content(filepath) + if self.progress: + await self.progress.emit( + JobPhase.EXTRACT_CONTENT, + 25, + "Document content extracted successfully" + ) + await self.progress.emit_log('INFO', f"Extracted {len(document_content)} characters of content") + except Exception as e: + error_msg = f"Content extraction failed: {e}" + if self.progress: + await self.progress.emit_failure(error_msg) + return ProcessingResult([], {}, 0.0, [error_msg], self.token_usage) + + # Stage 2: Parallel multi-model analysis + if self.progress: + await self.progress.emit( + JobPhase.LLM_ANALYSIS, + 30, + "Starting parallel multi-model analysis" + ) + await self.progress.emit_log('INFO', "=== STAGE 2: Parallel Multi-Model Analysis ===") + await self.progress.emit_log('INFO', f"Using models: {', '.join(self.primary_models)}") + + doc_type = self.classify_document(filepath) + + try: + analysis_responses, analysis_metadata = await self._perform_parallel_analysis_with_progress( + document_content, doc_type + ) + + if self.progress: + await self.progress.emit( + JobPhase.LLM_ANALYSIS, + 75, + f"Parallel analysis completed - {len(analysis_responses)} successful models" + ) + await self.progress.emit_log('INFO', + f"Analysis complete: {len(analysis_responses)}/{len(self.primary_models)} models succeeded" + ) + + except Exception as e: + error_msg = f"Parallel analysis failed: {e}" + if self.progress: + await self.progress.emit_failure(error_msg) + return ProcessingResult([], {}, 0.0, [error_msg], self.token_usage) + + # Stage 3: Consolidation + if self.progress: + await self.progress.emit( + JobPhase.CONSOLIDATION, + 80, + "Starting result consolidation" + ) + await self.progress.emit_log('INFO', "=== STAGE 3: Result Consolidation ===") + await self.progress.emit_log('INFO', f"Using consolidation model: {self.consolidation_model}") + + try: + consolidation_result = await self.consolidation_processor.consolidate_results( + analysis_responses, self.consolidation_model, document_content + ) + + if self.progress: + await self.progress.emit( + JobPhase.CONSOLIDATION, + 90, + f"Consolidation completed: {len(consolidation_result.expanded_assets)} final assets" + ) + await self.progress.emit_log('INFO', + f"Consolidation complete: {len(consolidation_result.expanded_assets)} final deliverables" + ) + + except Exception as e: + error_msg = f"Consolidation failed: {e}" + if self.progress: + await self.progress.emit_failure(error_msg) + return ProcessingResult([], {}, 0.0, [error_msg], self.token_usage) + + # Stage 4: Prepare results + if self.progress: + await self.progress.emit( + JobPhase.CSV_GENERATION, + 95, + "Preparing results for output" + ) + + # Convert expanded assets to dict format for compatibility + extracted_data = [asset.model_dump() for asset in consolidation_result.expanded_assets] + + # Aggregate token usage from all models + total_token_usage = self.provider_manager.get_aggregated_token_usage(analysis_responses) + + # Combine processing notes + successful_count = analysis_metadata.get('successful_models', len(analysis_responses)) + total_count = analysis_metadata.get('total_models_attempted', len(self.primary_models)) + processing_notes = [f"Parallel analysis: {successful_count}/{total_count} models"] + processing_notes.extend(consolidation_result.warnings) + + # Merge metadata + combined_metadata = { + 'doc_type': doc_type.value, + 'primary_models_used': self.primary_models, + 'consolidation_model': self.consolidation_model, + 'analysis_metadata': analysis_metadata, + 'consolidation_metadata': consolidation_result.consolidation_metadata + } + + result = ProcessingResult( + raw_data=extracted_data, + metadata=combined_metadata, + confidence_score=0.9, # Higher confidence due to multi-model consensus + processing_notes=processing_notes, + token_usage=total_token_usage + ) + + if self.progress: + await self.progress.emit( + JobPhase.CSV_GENERATION, + 100, + "Analysis completed successfully" + ) + await self.progress.emit_log('INFO', "=== PROCESSING COMPLETED SUCCESSFULLY ===") + + return result + + except Exception as e: + error_msg = f"Unexpected error during processing: {e}" + logger.error(error_msg, exc_info=True) + if self.progress: + await self.progress.emit_failure(error_msg) + return ProcessingResult([], {}, 0.0, [error_msg], self.token_usage) + + async def _perform_parallel_analysis_with_progress( + self, + document_content: str, + doc_type + ) -> tuple: + """ + Perform parallel analysis with progress reporting + + Args: + document_content: Extracted document text + doc_type: Document type classification + + Returns: + Tuple of (successful_responses, metadata) + """ + # Load prompt from external file + multi_perspective_prompt_template = self._load_prompt('multi_perspective_analysis') + multi_perspective_prompt = multi_perspective_prompt_template.format(doc_type=doc_type.value) + + # Load system message from external file + system_message = self._load_prompt('system_multi_perspective') + + # Prepare combined prompt + combined_prompt = f"{multi_perspective_prompt}\n\nDocument Content:\n{document_content}" + + # Prepare messages for all providers + messages = [ + {"role": "system", "content": system_message}, + {"role": "user", "content": combined_prompt} + ] + + # Get schema for structured output + from core.process_brief_enhanced import UNIVERSAL_BASE_DELIVERABLE_SCHEMA + + # Create progress callback for provider updates + progress_callback = None + if self.progress: + progress_callback = self._create_provider_progress_callback() + + # Execute parallel analysis with progress reporting + successful_responses, metadata = await self.provider_manager.execute_parallel_analysis( + model_keys=self.primary_models, + messages=messages, + schema=UNIVERSAL_BASE_DELIVERABLE_SCHEMA, + minimum_success_threshold=self.model_config.minimum_success_threshold, + on_model_event=progress_callback + ) + + return successful_responses, metadata + + def _create_provider_progress_callback(self): + """ + Create callback function for provider progress updates + + Returns: + Async callback function + """ + async def on_model_event(model_key: str, stage: str, data: Any): + if not self.progress: + return + + try: + if stage == 'start': + await self.progress.emit_provider_update(model_key, { + 'provider': self._get_provider_name(model_key), + 'model': self._get_model_display_name(model_key), + 'status': 'started', + 'startedAt': data.get('timestamp') if data else None + }) + + await self.progress.emit_log('INFO', f"Starting analysis with {model_key}") + + elif stage == 'end': + if 'error' in data: + await self.progress.emit_provider_update(model_key, { + 'provider': self._get_provider_name(model_key), + 'model': self._get_model_display_name(model_key), + 'status': 'error', + 'error': str(data['error']), + 'completedAt': data.get('timestamp') if data else None + }) + + await self.progress.emit_log('ERROR', f"Analysis failed for {model_key}: {data['error']}") + + else: + response = data.get('response') + cost = data.get('cost', 0) + + if response: + await self.progress.emit_provider_update(model_key, { + 'provider': self._get_provider_name(model_key), + 'model': self._get_model_display_name(model_key), + 'status': 'success', + 'completedAt': data.get('timestamp') if data else None, + 'latencyMs': response.processing_time * 1000 if response.processing_time else None, + 'tokensIn': response.token_usage.input_tokens, + 'tokensOut': response.token_usage.output_tokens, + 'tokensCached': response.token_usage.cached_input_tokens, + 'costUsd': cost + }) + + await self.progress.emit_log('INFO', f"Analysis completed for {model_key} " + f"({response.token_usage.input_tokens + response.token_usage.output_tokens} tokens, ${cost:.4f})") + + # Update overall progress + completed_count = len([ + p for p in self.progress.job.provider_updates.values() + if p.status in ['success', 'error'] + ]) + total_count = len(self.primary_models) + + # Calculate progress: 25% (extraction done) + (completed/total * 50%) for analysis + analysis_progress = await self.progress.calculate_analysis_progress( + base_progress=25, + completed_providers=completed_count, + total_providers=total_count, + analysis_weight=50 + ) + + await self.progress.emit( + JobPhase.LLM_ANALYSIS, + analysis_progress, + f"Analysis progress: {completed_count}/{total_count} models complete" + ) + + except Exception as e: + logger.error(f"Error in provider progress callback: {e}") + + return on_model_event + + def _get_provider_name(self, model_key: str) -> str: + """Get provider name from model key""" + from core.config import config + try: + provider_name, _ = config.get_model_info(model_key) + return provider_name + except: + return model_key.split('-')[0] if '-' in model_key else 'unknown' + + def _get_model_display_name(self, model_key: str) -> str: + """Get display name for model""" + display_names = { + 'openai-gpt51': 'GPT-5.1', + 'anthropic-opus45': 'Claude Opus 4.5', + 'anthropic-sonnet45': 'Claude Sonnet 4.5', + 'google-gemini31': 'Gemini 3.1 Pro' + } + return display_names.get(model_key, model_key) + + def create_job_summary(self, result: ProcessingResult) -> JobSummary: + """ + Create job summary from processing result + + Args: + result: Processing result + + Returns: + JobSummary object + """ + # Extract cost information + consolidation_metadata = result.metadata.get('consolidation_metadata', {}) + cost_breakdown = consolidation_metadata.get('cost_breakdown', {}) + token_usage = consolidation_metadata.get('token_usage', {}) + + return JobSummary( + doc_type=result.metadata.get('doc_type', 'unknown'), + assets_extracted=len(result.raw_data), + confidence_score=result.confidence_score, + notes=result.processing_notes, + cost_usd_total=cost_breakdown.get('total_cost', 0), + tokens_total=token_usage.get('grand_total', 0), + primary_models=result.metadata.get('primary_models_used', []), + consolidation_model=result.metadata.get('consolidation_model', ''), + processing_time_seconds=None # Will be set by job runner + ) \ No newline at end of file diff --git a/backend/server/runners/job_runner.py b/backend/server/runners/job_runner.py new file mode 100755 index 0000000..d2afef7 --- /dev/null +++ b/backend/server/runners/job_runner.py @@ -0,0 +1,251 @@ +""" +Job runner that orchestrates document processing with progress reporting +""" + +import asyncio +import logging +import os +import time +from datetime import datetime +from typing import Dict, Any + +from ..jobs.models import Job, JobPhase, JobSummary +from ..jobs.storage import StorageManager +from ..ws.manager import WebSocketManager +from .progress import ProgressReporter, create_job_logger +from core.process_brief_enhanced import DocumentAnalyzer + +logger = logging.getLogger(__name__) + +async def run_job(job: Job, ws_manager: WebSocketManager) -> bool: + """ + Execute a document processing job with progress reporting + + Args: + job: Job to process + ws_manager: WebSocket manager for real-time updates + + Returns: + True if job completed successfully, False otherwise + """ + start_time = time.time() + job_logger = create_job_logger(job.id, ws_manager) + + try: + # Create progress reporter + progress = ProgressReporter(job, ws_manager) + + # Create analyzer with model configuration + analyzer = DocumentAnalyzer( + primary_models=job.model_config.primary_models, + consolidation_model=job.model_config.consolidation_model + ) + # Mark as GUI mode to suppress legacy print statements + analyzer._is_gui_mode = True + + await progress.emit_log('INFO', f"Starting processing of {job.file_name}") + await progress.emit_log('INFO', f"File size: {job.file_size:,} bytes") + await progress.emit_log('INFO', f"Selected models: {', '.join(job.model_config.primary_models)}") + await progress.emit_log('INFO', f"Consolidation model: {job.model_config.consolidation_model}") + + # Validate upload path exists + if not job.upload_path or not os.path.exists(job.upload_path): + error_msg = f"Upload file not found: {job.upload_path}" + await progress.emit_failure(error_msg) + return False + + # Process document + result = await analyzer.process_document_multi_model(job.upload_path, progress) + + if not result.raw_data: + error_msg = "No data extracted from document" + await progress.emit_failure(error_msg) + return False + + # Generate output CSV + await progress.emit(JobPhase.CSV_GENERATION, 95, "Generating CSV output") + + storage = StorageManager() + output_path = storage.get_output_path(job.id, job.file_name) + + # Write CSV file asynchronously + import csv + import asyncio + + def _write_csv(): + """Blocking CSV write operation for thread pool""" + with open(output_path, 'w', newline='', encoding='utf-8') as csvfile: + if result.raw_data: + # Get headers from first record + headers = list(result.raw_data[0].keys()) + writer = csv.DictWriter(csvfile, fieldnames=headers, extrasaction='ignore') + writer.writeheader() + writer.writerows(result.raw_data) + + # Run CSV writing in thread pool to avoid blocking event loop + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, _write_csv) + + # Create job summary + processing_time = time.time() - start_time + summary = create_job_summary(result, processing_time) + + # Generate CSV download URL + result_csv_url = f"/api/jobs/{job.id}/download" + + # Mark job as completed + job.mark_completed(result_csv_url, summary, output_path) + + # Emit completion event + await progress.emit_completion(result_csv_url, summary.to_dict()) + + await progress.emit_log('INFO', f"Processing completed in {processing_time:.1f} seconds") + await progress.emit_log('INFO', f"Extracted {len(result.raw_data)} marketing assets") + await progress.emit_log('INFO', f"Total cost: ${summary.cost_usd_total:.4f}") + await progress.emit_log('INFO', f"Total tokens: {summary.tokens_total:,}") + + logger.info(f"Job {job.id} completed successfully: {len(result.raw_data)} assets, " + f"${summary.cost_usd_total:.4f}, {processing_time:.1f}s") + + return True + + except Exception as e: + error_msg = f"Job processing failed: {str(e)}" + logger.error(f"Job {job.id} failed: {error_msg}", exc_info=True) + + try: + progress = ProgressReporter(job, ws_manager) + await progress.emit_failure(error_msg) + except: + # Fallback if progress reporter fails + job.mark_failed(error_msg) + + return False + +async def process_job_queue(job_manager, ws_manager: WebSocketManager): + """ + Background worker that processes jobs from the queue + + Args: + job_manager: JobManager instance + ws_manager: WebSocket manager for updates + """ + logger.info("Starting job queue processor") + + while True: + try: + # Get next job from queue (blocks until available) + job_id = await job_manager.queue.get() + + # Get job details + job = await job_manager.get_job(job_id) + if not job: + logger.warning(f"Job {job_id} not found in registry") + job_manager.queue.task_done() + continue + + logger.info(f"Processing job {job_id}: {job.file_name}") + + # Check queue size for debugging + queue_size = job_manager.queue.qsize() + logger.info(f"Queue size before processing: {queue_size}") + + # Acquire semaphore for concurrency control + async with job_manager.processing_semaphore: + # Process the job + success = await run_job(job, ws_manager) + + if success: + logger.info(f"Job {job_id} completed successfully") + else: + logger.error(f"Job {job_id} failed") + + # Mark task as done + job_manager.queue.task_done() + + # Check queue size after processing + remaining_queue_size = job_manager.queue.qsize() + logger.info(f"Queue size after processing: {remaining_queue_size}") + + except asyncio.CancelledError: + logger.info("Job queue processor cancelled") + break + except Exception as e: + logger.error(f"Error in job queue processor: {e}", exc_info=True) + # Continue processing other jobs + try: + job_manager.queue.task_done() + except: + pass + +async def start_background_workers(job_manager, ws_manager: WebSocketManager, num_workers: int = 1): + """ + Start background worker tasks for job processing + + Args: + job_manager: JobManager instance + ws_manager: WebSocket manager + num_workers: Number of worker tasks to start + + Returns: + List of worker tasks + """ + workers = [] + + for i in range(num_workers): + worker = asyncio.create_task( + process_job_queue(job_manager, ws_manager), + name=f"job-worker-{i}" + ) + workers.append(worker) + logger.info(f"Started job worker {i}") + + return workers + +async def stop_background_workers(workers): + """ + Stop background worker tasks + + Args: + workers: List of worker tasks to stop + """ + logger.info("Stopping background workers...") + + for worker in workers: + worker.cancel() + + # Wait for workers to finish + try: + await asyncio.gather(*workers, return_exceptions=True) + except Exception as e: + logger.warning(f"Error stopping workers: {e}") + + logger.info("Background workers stopped") + +def create_job_summary(result, processing_time: float) -> JobSummary: + """ + Create job summary from processing result + + Args: + result: ProcessingResult from DocumentAnalyzer + processing_time: Total processing time in seconds + + Returns: + JobSummary object + """ + # Extract cost information + consolidation_metadata = result.metadata.get('consolidation_metadata', {}) + cost_breakdown = consolidation_metadata.get('cost_breakdown', {}) + token_usage = consolidation_metadata.get('token_usage', {}) + + return JobSummary( + doc_type=result.metadata.get('doc_type', 'unknown'), + assets_extracted=len(result.raw_data), + confidence_score=result.confidence_score, + notes=result.processing_notes, + cost_usd_total=cost_breakdown.get('total_cost', 0), + tokens_total=token_usage.get('grand_total', 0), + primary_models=result.metadata.get('primary_models_used', []), + consolidation_model=result.metadata.get('consolidation_model', ''), + processing_time_seconds=processing_time + ) \ No newline at end of file diff --git a/backend/server/runners/progress.py b/backend/server/runners/progress.py new file mode 100755 index 0000000..b870abd --- /dev/null +++ b/backend/server/runners/progress.py @@ -0,0 +1,301 @@ +""" +Progress reporting for job processing with WebSocket integration +""" + +import logging +from datetime import datetime +from typing import Dict, Any, Optional + +from ..jobs.models import Job, JobPhase, ProviderUpdate +from ..ws.manager import WebSocketManager + +logger = logging.getLogger(__name__) + +class ProgressReporter: + """ + Reports progress updates for job processing with WebSocket broadcasting + """ + + def __init__(self, job: Job, ws_manager: WebSocketManager): + self.job = job + self.ws_manager = ws_manager + self.logger = logging.getLogger(f"{__name__}.{job.id}") + + async def emit( + self, + phase: JobPhase, + progress_pct: int, + message: str = "", + step_label: str = "" + ): + """ + Emit progress update for job + + Args: + phase: Current processing phase + progress_pct: Progress percentage (0-100) + message: Optional progress message + step_label: Optional custom step label + """ + try: + # Update job progress + self.job.update_progress(phase, progress_pct, step_label) + + # Add log entry + if message: + self.job.add_log('INFO', message) + self.logger.info(message) + + # Broadcast progress update + await self.ws_manager.broadcast_job_update(self.job.id, { + 'type': 'job.progress', + 'jobId': self.job.id, + 'phase': phase.value if hasattr(phase, 'value') else phase, + 'progressPct': progress_pct, + 'message': message, + 'stepLabel': self.job.step_label, + 'providerUpdates': {k: v.to_dict() for k, v in self.job.provider_updates.items()} + }) + + self.logger.debug(f"Progress update: {phase.value if hasattr(phase, 'value') else phase} {progress_pct}% - {message}") + + except Exception as e: + self.logger.error(f"Failed to emit progress update: {e}") + # Don't re-raise to avoid breaking the processing pipeline + + async def emit_provider_update( + self, + model_key: str, + update_data: Dict[str, Any] + ): + """ + Emit provider-specific update + + Args: + model_key: Model identifier (e.g., 'openai-gpt51') + update_data: Provider update information + """ + try: + # Create provider update object + provider_update = ProviderUpdate( + provider=update_data.get('provider', ''), + model=update_data.get('model', ''), + status=update_data.get('status', ''), + started_at=update_data.get('startedAt'), + completed_at=update_data.get('completedAt'), + latency_ms=update_data.get('latencyMs'), + tokens_in=update_data.get('tokensIn'), + tokens_out=update_data.get('tokensOut'), + tokens_cached=update_data.get('tokensCached'), + cost_usd=update_data.get('costUsd'), + error=update_data.get('error') + ) + + # Update job + self.job.update_provider(model_key, provider_update) + + # Log provider update + status_msg = f"Provider {model_key}: {provider_update.status}" + if provider_update.error: + status_msg += f" - {provider_update.error}" + self.job.add_log('ERROR', status_msg) + self.logger.error(status_msg) + else: + self.job.add_log('INFO', status_msg) + self.logger.info(status_msg) + + # Broadcast provider update + await self.ws_manager.broadcast_job_update(self.job.id, { + 'type': 'job.provider_update', + 'jobId': self.job.id, + 'modelKey': model_key, + 'update': provider_update.to_dict() + }) + + self.logger.debug(f"Provider update: {model_key} - {provider_update.status}") + + except Exception as e: + self.logger.error(f"Failed to emit provider update for {model_key}: {e}") + + async def emit_log(self, level: str, message: str): + """ + Emit log message with WebSocket streaming + + Args: + level: Log level (DEBUG, INFO, WARNING, ERROR) + message: Log message + """ + try: + # Add to job logs + self.job.add_log(level, message) + + # Log to system logger + getattr(self.logger, level.lower(), self.logger.info)(message) + + # Broadcast log entry + await self.ws_manager.broadcast_job_update(self.job.id, { + 'type': 'job.log', + 'jobId': self.job.id, + 'logEntry': { + 'timestamp': datetime.utcnow().isoformat(), + 'level': level, + 'message': message + } + }) + + except Exception as e: + self.logger.error(f"Failed to emit log message: {e}") + + async def calculate_analysis_progress( + self, + base_progress: int, + completed_providers: int, + total_providers: int, + analysis_weight: int = 50 + ) -> int: + """ + Calculate progress percentage for LLM analysis phase + + Args: + base_progress: Starting progress percentage (usually 25) + completed_providers: Number of completed providers + total_providers: Total number of providers + analysis_weight: Weight of analysis phase in total progress + + Returns: + Updated progress percentage + """ + if total_providers == 0: + return base_progress + + analysis_progress = (completed_providers / total_providers) * analysis_weight + return min(100, base_progress + int(analysis_progress)) + + async def emit_completion( + self, + result_csv_url: str, + summary_data: Dict[str, Any] + ): + """ + Emit job completion event + + Args: + result_csv_url: URL to download CSV result + summary_data: Job summary information + """ + try: + self.job.add_log('INFO', 'Processing completed successfully') + + # Broadcast completion + await self.ws_manager.broadcast_job_update(self.job.id, { + 'type': 'job.completed', + 'jobId': self.job.id, + 'resultCsvUrl': result_csv_url, + 'summary': summary_data + }) + + self.logger.info(f"Job {self.job.id} completed successfully") + + except Exception as e: + self.logger.error(f"Failed to emit completion event: {e}") + + async def emit_failure(self, error: str): + """ + Emit job failure event + + Args: + error: Error message + """ + try: + self.job.mark_failed(error) + self.job.add_log('ERROR', f'Processing failed: {error}') + + # Broadcast failure + await self.ws_manager.broadcast_job_update(self.job.id, { + 'type': 'job.failed', + 'jobId': self.job.id, + 'error': error + }) + + self.logger.error(f"Job {self.job.id} failed: {error}") + + except Exception as e: + self.logger.error(f"Failed to emit failure event: {e}") + + +class JobLogHandler(logging.Handler): + """ + Custom logging handler that routes job-specific logs to WebSocket clients + """ + + def __init__(self, job_id: str, ws_manager: WebSocketManager): + super().__init__() + self.job_id = job_id + self.ws_manager = ws_manager + + # Set up formatter for log messages + self.setFormatter(logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + )) + + def emit(self, record): + """ + Process a log record and send it via WebSocket + + Args: + record: LogRecord to process + """ + try: + # Format the message + message = self.format(record) + + # Create log entry + log_entry = { + 'timestamp': datetime.utcnow().isoformat(), + 'level': record.levelname, + 'message': message, + 'logger': record.name + } + + # Send via WebSocket (non-blocking) + import asyncio + try: + loop = asyncio.get_event_loop() + loop.create_task(self.ws_manager.broadcast_job_update(self.job_id, { + 'type': 'job.log', + 'jobId': self.job_id, + 'logEntry': log_entry + })) + except RuntimeError: + # No event loop available, skip WebSocket update + pass + + except Exception as e: + # Don't let logging errors break the application + print(f"JobLogHandler error: {e}") + +def create_job_logger(job_id: str, ws_manager: WebSocketManager) -> logging.Logger: + """ + Create a job-specific logger with WebSocket streaming + + Args: + job_id: Job identifier + ws_manager: WebSocket manager instance + + Returns: + Logger instance with job-specific handler + """ + logger = logging.getLogger(f"job.{job_id}") + + # Remove existing handlers to avoid duplicates + logger.handlers.clear() + + # Add job-specific handler + handler = JobLogHandler(job_id, ws_manager) + handler.setLevel(logging.INFO) + logger.addHandler(handler) + + # Set logger level + logger.setLevel(logging.INFO) + + return logger \ No newline at end of file diff --git a/backend/server/sheets/__init__.py b/backend/server/sheets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/server/sheets/manager.py b/backend/server/sheets/manager.py new file mode 100644 index 0000000..dfa695a --- /dev/null +++ b/backend/server/sheets/manager.py @@ -0,0 +1,157 @@ +""" +Sheet management — Python port of sheet_helpers.php. +File-based JSON storage, one metadata file + one data file per sheet. +""" + +import json +import logging +import os +import re +import time +import random +from datetime import datetime, timezone +from typing import List, Optional, Dict + +from ..config_runtime import server_config + +logger = logging.getLogger(__name__) + +METADATA_FILE = os.path.join(server_config.DATA_DIR, 'sheets_metadata.json') + + +def _safe_user(user_id: str) -> str: + """Sanitise user_id for use in filenames.""" + return re.sub(r'[^a-zA-Z0-9_\-]', '_', user_id) + + +def _sheet_path(user_id: str, sheet_id: str) -> str: + return os.path.join(server_config.SHEETS_DIR, f"{_safe_user(user_id)}_{sheet_id}.json") + + +def _load_metadata() -> Dict: + if not os.path.exists(METADATA_FILE): + return {} + try: + with open(METADATA_FILE, 'r') as f: + return json.load(f) + except Exception: + return {} + + +def _save_metadata(meta: Dict): + with open(METADATA_FILE, 'w') as f: + json.dump(meta, f, indent=2) + + +def get_user_sheets(user_id: str) -> List[Dict]: + meta = _load_metadata() + return meta.get(user_id, []) + + +def create_sheet(user_id: str, name: str, data: List[dict] = None) -> Dict: + if data is None: + data = [] + sheet_id = str(int(time.time())) + str(random.randint(100, 999)) + now = datetime.now(timezone.utc).isoformat() + + sheet_meta = { + 'id': sheet_id, + 'name': name or f"Untitled Sheet — {datetime.now().strftime('%Y-%m-%d %H:%M')}", + 'created': now, + 'modified': now, + 'itemCount': len(data), + 'user': user_id, + } + + # Write data file + path = _sheet_path(user_id, sheet_id) + with open(path, 'w') as f: + json.dump(data, f, indent=2) + + # Update metadata + meta = _load_metadata() + meta.setdefault(user_id, []).append(sheet_meta) + _save_metadata(meta) + + return sheet_meta + + +def load_sheet_data(user_id: str, sheet_id: str) -> Optional[List[dict]]: + path = _sheet_path(user_id, sheet_id) + if not os.path.exists(path): + return None + try: + with open(path, 'r') as f: + return json.load(f) + except Exception: + return None + + +def update_sheet(user_id: str, sheet_id: str, data: List[dict]) -> bool: + path = _sheet_path(user_id, sheet_id) + with open(path, 'w') as f: + json.dump(data, f, indent=2) + + # Update metadata counts + meta = _load_metadata() + if user_id in meta: + for sheet in meta[user_id]: + if sheet['id'] == sheet_id: + sheet['modified'] = datetime.now(timezone.utc).isoformat() + sheet['itemCount'] = len(data) + break + _save_metadata(meta) + return True + + +def delete_sheet(user_id: str, sheet_id: str): + path = _sheet_path(user_id, sheet_id) + if os.path.exists(path): + os.remove(path) + + meta = _load_metadata() + if user_id in meta: + meta[user_id] = [s for s in meta[user_id] if s['id'] != sheet_id] + _save_metadata(meta) + + +def rename_sheet(user_id: str, sheet_id: str, new_name: str) -> bool: + meta = _load_metadata() + if user_id not in meta: + return False + for sheet in meta[user_id]: + if sheet['id'] == sheet_id: + sheet['name'] = new_name + sheet['modified'] = datetime.now(timezone.utc).isoformat() + _save_metadata(meta) + return True + return False + + +def duplicate_sheet(user_id: str, sheet_id: str) -> Optional[Dict]: + data = load_sheet_data(user_id, sheet_id) + if data is None: + return None + + meta = _load_metadata() + original_name = "Copy of Sheet" + for sheet in meta.get(user_id, []): + if sheet['id'] == sheet_id: + original_name = f"Copy of {sheet['name']}" + break + + return create_sheet(user_id, original_name, data) + + +def generate_next_id(data: List[dict]) -> str: + """Generate the next DEL-NNN id.""" + max_id = 0 + for row in data: + num_str = row.get('Number', '').replace('DEL-', '') + try: + n = int(num_str) + if n > max_id: + max_id = n + except ValueError: + pass + return f"DEL-{str(max_id + 1).zfill(3)}" diff --git a/backend/server/sheets/models.py b/backend/server/sheets/models.py new file mode 100644 index 0000000..da40c1c --- /dev/null +++ b/backend/server/sheets/models.py @@ -0,0 +1,73 @@ +""" +Pydantic models for sheets and deliverables. +""" + +from __future__ import annotations +from datetime import datetime, timezone +from typing import List, Optional +from pydantic import BaseModel, Field + + +class Deliverable(BaseModel): + Number: str = "" + Title: str = "" + Status: str = "Booked" + Category: str = "" + Media: str = "" + SubMedia: str = Field(default="", alias="Sub-media") + Format: str = "" + SupplyDate: str = Field(default="", alias="Supply date") + LiveDate: str = Field(default="", alias="Live date") + Language: str = "" + Country: str = "" + Quantity: int = 1 + + class Config: + populate_by_name = True + + def to_dict(self) -> dict: + return { + "Number": self.Number, + "Title": self.Title, + "Status": self.Status, + "Category": self.Category, + "Media": self.Media, + "Sub-media": self.SubMedia, + "Format": self.Format, + "Supply date": self.SupplyDate, + "Live date": self.LiveDate, + "Language": self.Language, + "Country": self.Country, + "Quantity": self.Quantity, + } + + @classmethod + def from_dict(cls, d: dict) -> "Deliverable": + return cls( + Number=d.get("Number", ""), + Title=d.get("Title", ""), + Status=d.get("Status", "Booked"), + Category=d.get("Category", ""), + Media=d.get("Media", ""), + **{"Sub-media": d.get("Sub-media", "")}, + Format=d.get("Format", ""), + **{"Supply date": d.get("Supply date", "")}, + **{"Live date": d.get("Live date", "")}, + Language=d.get("Language", ""), + Country=d.get("Country", ""), + Quantity=int(d.get("Quantity", 1)), + ) + + +class SheetMeta(BaseModel): + id: str + name: str + created: str + modified: str + itemCount: int + user: str + + +class Sheet(BaseModel): + meta: SheetMeta + data: List[dict] # raw dicts for speed; validated on write diff --git a/backend/server/ws/__init__.py b/backend/server/ws/__init__.py new file mode 100755 index 0000000..0d2a0d3 --- /dev/null +++ b/backend/server/ws/__init__.py @@ -0,0 +1,13 @@ +""" +WebSocket module for real-time communication +""" + +from .manager import WebSocketManager + +# Create global instance +ws_manager = WebSocketManager() + +__all__ = [ + 'WebSocketManager', + 'ws_manager' +] \ No newline at end of file diff --git a/backend/server/ws/manager.py b/backend/server/ws/manager.py new file mode 100755 index 0000000..2b897e5 --- /dev/null +++ b/backend/server/ws/manager.py @@ -0,0 +1,300 @@ +""" +WebSocket connection and message management +""" + +import asyncio +import json +import logging +from datetime import datetime +from typing import Dict, Set, Any, Optional +import uuid +from weakref import WeakSet + +from quart import websocket + +from ..config_runtime import server_config + +logger = logging.getLogger(__name__) + +class WebSocketClient: + """Represents a connected WebSocket client""" + + def __init__(self, client_id: str, user_id: Optional[str] = None): + self.client_id = client_id + self.user_id = user_id or 'anonymous' + self.connected_at = datetime.utcnow() + self.last_ping = datetime.utcnow() + self.websocket = websocket._get_current_object() + + async def send(self, message: Dict[str, Any]): + """Send a message to this client""" + try: + await self.websocket.send(json.dumps(message)) + except Exception as e: + logger.warning(f"Failed to send message to client {self.client_id}: {e}") + raise + + async def ping(self): + """Send ping to client""" + try: + await self.send({'type': 'ping', 'timestamp': datetime.utcnow().isoformat()}) + self.last_ping = datetime.utcnow() + except Exception as e: + logger.warning(f"Failed to ping client {self.client_id}: {e}") + raise + +class WebSocketManager: + """ + Manages WebSocket connections and broadcasts + Singleton for coordinating real-time updates + """ + + _instance: Optional['WebSocketManager'] = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + if hasattr(self, '_initialized'): + return + + self._initialized = True + self.clients: Dict[str, WebSocketClient] = {} + self._lock = asyncio.Lock() + + # Start background tasks + self.ping_task = None + self.cleanup_task = None + + logger.info("WebSocketManager initialized") + + async def start_background_tasks(self): + """Start background maintenance tasks""" + if not self.ping_task: + self.ping_task = asyncio.create_task(self._ping_clients_loop()) + + if not self.cleanup_task: + self.cleanup_task = asyncio.create_task(self._cleanup_disconnected_loop()) + + async def stop_background_tasks(self): + """Stop background maintenance tasks""" + if self.ping_task: + self.ping_task.cancel() + try: + await self.ping_task + except asyncio.CancelledError: + pass + + if self.cleanup_task: + self.cleanup_task.cancel() + try: + await self.cleanup_task + except asyncio.CancelledError: + pass + + async def register_client(self, user_id: Optional[str] = None) -> WebSocketClient: + """ + Register a new WebSocket client + + Args: + user_id: User identifier (optional for dev mode) + + Returns: + WebSocketClient instance + """ + client_id = str(uuid.uuid4()) + client = WebSocketClient(client_id, user_id) + + async with self._lock: + self.clients[client_id] = client + + logger.info(f"Registered WebSocket client {client_id} for user {user_id}") + + # Send initial connection acknowledgment + await client.send({ + 'type': 'connection.established', + 'clientId': client_id, + 'userId': user_id, + 'connectedAt': client.connected_at.isoformat() + }) + + return client + + async def unregister_client(self, client_id: str): + """ + Unregister a WebSocket client + + Args: + client_id: Client identifier + """ + async with self._lock: + if client_id in self.clients: + client = self.clients.pop(client_id) + logger.info(f"Unregistered WebSocket client {client_id} for user {client.user_id}") + + async def broadcast_to_all(self, message: Dict[str, Any]): + """ + Broadcast message to all connected clients + + Args: + message: Message to broadcast + """ + if not self.clients: + return + + # Add timestamp to message + message['timestamp'] = datetime.utcnow().isoformat() + + async with self._lock: + clients_to_remove = [] + + for client_id, client in self.clients.items(): + try: + await client.send(message) + except Exception as e: + logger.warning(f"Failed to send to client {client_id}: {e}") + clients_to_remove.append(client_id) + + # Remove failed clients + for client_id in clients_to_remove: + self.clients.pop(client_id, None) + + async def broadcast_to_user(self, user_id: str, message: Dict[str, Any]): + """ + Broadcast message to all connections for a specific user + + Args: + user_id: User identifier + message: Message to broadcast + """ + if not self.clients: + return + + # Add timestamp to message + message['timestamp'] = datetime.utcnow().isoformat() + + async with self._lock: + clients_to_remove = [] + sent_count = 0 + + for client_id, client in self.clients.items(): + if client.user_id == user_id: + try: + await client.send(message) + sent_count += 1 + except Exception as e: + logger.warning(f"Failed to send to client {client_id}: {e}") + clients_to_remove.append(client_id) + + # Remove failed clients + for client_id in clients_to_remove: + self.clients.pop(client_id, None) + + if sent_count > 0: + logger.debug(f"Broadcast message to {sent_count} clients for user {user_id}") + + async def broadcast_job_update(self, job_id: str, message: Dict[str, Any]): + """ + Broadcast job-specific update + + Args: + job_id: Job identifier + message: Message to broadcast + """ + # For now, broadcast to all clients + # In the future, we could implement job-specific subscriptions + message['jobId'] = job_id + await self.broadcast_to_all(message) + + async def send_queue_snapshot(self, client: WebSocketClient, jobs_data: list): + """ + Send initial queue snapshot to a client + + Args: + client: WebSocket client + jobs_data: Serialized jobs data + """ + try: + await client.send({ + 'type': 'queue.snapshot', + 'jobs': jobs_data + }) + logger.debug(f"Sent queue snapshot to client {client.client_id}") + except Exception as e: + logger.error(f"Failed to send queue snapshot to {client.client_id}: {e}") + raise + + async def get_connection_stats(self) -> Dict[str, Any]: + """ + Get WebSocket connection statistics + + Returns: + Statistics dictionary + """ + async with self._lock: + user_counts = {} + for client in self.clients.values(): + user_counts[client.user_id] = user_counts.get(client.user_id, 0) + 1 + + return { + 'total_connections': len(self.clients), + 'unique_users': len(user_counts), + 'connections_per_user': user_counts, + 'uptime_seconds': (datetime.utcnow() - + min((c.connected_at for c in self.clients.values()), + default=datetime.utcnow())).total_seconds() + } + + async def _ping_clients_loop(self): + """Background task to ping clients periodically""" + while True: + try: + await asyncio.sleep(server_config.WS_PING_INTERVAL_SECONDS) + + async with self._lock: + clients_to_remove = [] + + for client_id, client in self.clients.items(): + try: + await client.ping() + except Exception: + clients_to_remove.append(client_id) + + # Remove failed clients + for client_id in clients_to_remove: + self.clients.pop(client_id, None) + logger.debug(f"Removed unresponsive client {client_id}") + + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"Error in ping loop: {e}") + + async def _cleanup_disconnected_loop(self): + """Background task to clean up disconnected clients""" + while True: + try: + await asyncio.sleep(60) # Check every minute + + async with self._lock: + # Clean up clients that haven't been pinged recently + cutoff = datetime.utcnow().timestamp() - (server_config.WS_PING_INTERVAL_SECONDS * 3) + clients_to_remove = [] + + for client_id, client in self.clients.items(): + if client.last_ping.timestamp() < cutoff: + clients_to_remove.append(client_id) + + for client_id in clients_to_remove: + self.clients.pop(client_id, None) + logger.debug(f"Cleaned up stale client {client_id}") + + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"Error in cleanup loop: {e}") + +# Global instance +ws_manager = WebSocketManager() \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..253bfc4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,60 @@ +# Nginx reverse proxy config (add to your nginx site config): +# +# location /ac-helper/ { +# proxy_pass http://localhost:8000/; +# 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; +# } +# +# This strips /ac-helper/ prefix before forwarding to the container. +# The frontend uses /ac-helper/api and /ac-helper/ws which the proxy forwards +# as /api and /ws to the backend. + +version: '3.9' + +services: + app: + build: . + container_name: ac-tool + restart: unless-stopped + ports: + - "8000:8000" + volumes: + - ./data:/app/data + environment: + # Auth + AZURE_TENANT_ID: ${AZURE_TENANT_ID:-e519c2e6-bc6d-4fdf-8d9c-923c2f002385} + AZURE_CLIENT_ID: ${AZURE_CLIENT_ID:-9079054c-9620-4757-a256-23413042f1ef} + AZURE_REDIRECT_URI: ${AZURE_REDIRECT_URI:-https://ai-sandbox.oliver.solutions/ac-helper/} + + # Dev mode (set to false in production) + DEV_MODE: ${DEV_MODE:-false} + DEV_USER_ID: ${DEV_USER_ID:-dev-user-001} + DEV_USER_ROLE: ${DEV_USER_ROLE:-admin} + + # Admin bootstrap + ADMIN_EMAIL: ${ADMIN_EMAIL:-daveporter@oliver.agency} + + # AI providers + GEMINI_API_KEY: ${GEMINI_API_KEY} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-} + LLAMA_CLOUD_API_KEY: ${LLAMA_CLOUD_API_KEY:-} + + # Paths + DATA_DIR: /app/data + UPLOADS_DIR: /app/data/uploads + OUTPUTS_DIR: /app/data/outputs + SHEETS_DIR: /app/data/sheets + USERS_FILE: /app/data/users.json + DROPDOWNS_FILE: /app/data/dropdowns.json + + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 20s diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..7dbf7eb --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..56591b7 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + AC Tool — Oliver Agency + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..3ce1663 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3925 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@azure/msal-browser": "^4.30.0", + "@azure/msal-react": "^3.0.29", + "@handsontable/react": "^16.2.0", + "@tailwindcss/vite": "^4.2.2", + "@types/react-router-dom": "^5.3.3", + "autoprefixer": "^10.4.27", + "axios": "^1.13.6", + "handsontable": "^17.0.0", + "postcss": "^8.5.8", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-dropzone": "^15.0.0", + "react-hot-toast": "^2.6.0", + "react-router-dom": "^7.13.1", + "tailwindcss": "^4.2.2", + "zustand": "^5.0.12" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/node": "^24.12.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.57.0", + "vite": "^8.0.1" + } + }, + "node_modules/@azure/msal-browser": { + "version": "4.30.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-4.30.0.tgz", + "integrity": "sha512-HBBKfbZkMVzzF5bofvS1cXuNHFVc+gt4/HOnCmG/0hsHuZRJvJvDg/+7nTwIpoqvJc8BQp5o23rBUfisOLxR+w==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "15.17.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "15.17.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-15.17.0.tgz", + "integrity": "sha512-VQ5/gTLFADkwue+FohVuCqlzFPUq4xSrX8jeZe+iwZuY6moliNC8xt86qPVNYdtbQfELDf2Nu6LI+demFPHGgw==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-react": { + "version": "3.0.29", + "resolved": "https://registry.npmjs.org/@azure/msal-react/-/msal-react-3.0.29.tgz", + "integrity": "sha512-RpFfq3aIpmKajcshbaJH7Q/1CesxQRAeKorMv+uMpDw98jvi+/L0RJkNnTRmeXrV3aM34kj2LFWBQrQ9DOXs1Q==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@azure/msal-browser": "^4.30.0", + "react": "^16.8.0 || ^17 || ^18 || ^19.2.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@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.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@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.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@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.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.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.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "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/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@handsontable/pikaday": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@handsontable/pikaday/-/pikaday-1.0.0.tgz", + "integrity": "sha512-1VN6N38t5/DcjJ7y7XUYrDx1LuzvvzlrFdBdMG90Qo1xc8+LXHqbWbsTEm5Ec5gXTEbDEO53vUT35R+2COmOyg==", + "license": "(0BSD OR MIT)" + }, + "node_modules/@handsontable/react": { + "version": "16.2.0", + "resolved": "https://registry.npmjs.org/@handsontable/react/-/react-16.2.0.tgz", + "integrity": "sha512-7HMjiBQu0Hb6SixAKakV9HBa/pn22JbskiiTRbIRaYIJJyiogzltXrNt8cXVEt2MSViaeLm0exvxpFYcMyrnZA==", + "deprecated": "Handsontable for React is now available as @handsontable/react-wrapper.", + "license": "SEE LICENSE IN LICENSE.txt", + "peerDependencies": { + "handsontable": ">=16.0.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "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==", + "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==", + "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==", + "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==", + "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==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.120.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz", + "integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz", + "integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz", + "integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz", + "integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz", + "integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz", + "integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz", + "integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz", + "integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz", + "integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz", + "integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz", + "integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", + "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", + "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-arm64": "4.2.2", + "@tailwindcss/oxide-darwin-x64": "4.2.2", + "@tailwindcss/oxide-freebsd-x64": "4.2.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", + "@tailwindcss/oxide-linux-x64-musl": "4.2.2", + "@tailwindcss/oxide-wasm32-wasi": "4.2.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", + "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", + "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", + "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", + "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", + "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", + "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", + "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", + "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", + "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", + "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", + "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", + "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.2.tgz", + "integrity": "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.2", + "@tailwindcss/oxide": "4.2.2", + "tailwindcss": "4.2.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "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/@types/history": { + "version": "4.7.11", + "resolved": "https://registry.npmjs.org/@types/history/-/history-4.7.11.tgz", + "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", + "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/react-router": { + "version": "5.1.20", + "resolved": "https://registry.npmjs.org/@types/react-router/-/react-router-5.1.20.tgz", + "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==", + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*" + } + }, + "node_modules/@types/react-router-dom": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/@types/react-router-dom/-/react-router-dom-5.3.3.tgz", + "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz", + "integrity": "sha512-Gn3aqnvNl4NGc6x3/Bqk1AOn0thyTU9bqDRhiRnUWezgvr2OnhYCWCgC8zXXRVqBsIL1pSDt7T9nJUe0oM0kDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.57.1", + "@typescript-eslint/type-utils": "8.57.1", + "@typescript-eslint/utils": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.57.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.1.tgz", + "integrity": "sha512-k4eNDan0EIMTT/dUKc/g+rsJ6wcHYhNPdY19VoX/EOtaAG8DLtKCykhrUnuHPYvinn5jhAPgD2Qw9hXBwrahsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.57.1", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.1.tgz", + "integrity": "sha512-vx1F37BRO1OftsYlmG9xay1TqnjNVlqALymwWVuYTdo18XuKxtBpCj1QlzNIEHlvlB27osvXFWptYiEWsVdYsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.57.1", + "@typescript-eslint/types": "^8.57.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.1.tgz", + "integrity": "sha512-hs/QcpCwlwT2L5S+3fT6gp0PabyGk4Q0Rv2doJXA0435/OpnSR3VRgvrp8Xdoc3UAYSg9cyUjTeFXZEPg/3OKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.1.tgz", + "integrity": "sha512-0lgOZB8cl19fHO4eI46YUx2EceQqhgkPSuCGLlGi79L2jwYY1cxeYc1Nae8Aw1xjgW3PKVDLlr3YJ6Bxx8HkWg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.1.tgz", + "integrity": "sha512-+Bwwm0ScukFdyoJsh2u6pp4S9ktegF98pYUU0hkphOOqdMB+1sNQhIz8y5E9+4pOioZijrkfNO/HUJVAFFfPKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1", + "@typescript-eslint/utils": "8.57.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.1.tgz", + "integrity": "sha512-S29BOBPJSFUiblEl6RzPPjJt6w25A6XsBqRVDt53tA/tlL8q7ceQNZHTjPeONt/3S7KRI4quk+yP9jK2WjBiPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.1.tgz", + "integrity": "sha512-ybe2hS9G6pXpqGtPli9Gx9quNV0TWLOmh58ADlmZe9DguLq0tiAKVjirSbtM1szG6+QH6rVXyU6GTLQbWnMY+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.57.1", + "@typescript-eslint/tsconfig-utils": "8.57.1", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/visitor-keys": "8.57.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.1.tgz", + "integrity": "sha512-XUNSJ/lEVFttPMMoDVA2r2bwrl8/oPx8cURtczkSEswY5T3AeLmCy+EKWQNdL4u0MmAHOjcWrqJp2cdvgjn8dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.1", + "@typescript-eslint/types": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.1.tgz", + "integrity": "sha512-YWnmJkXbofiz9KbnbbwuA2rpGkFPLbAIetcCNO6mJ8gdhdZ/v7WDXsoGFAJuM6ikUFKTlSQnjWnVO4ux+UzS6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "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.27", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", + "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "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.30001774", + "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.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.10", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz", + "integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "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/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/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001781", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", + "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==", + "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/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chevrotain": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-6.5.0.tgz", + "integrity": "sha512-BwqQ/AgmKJ8jcMEjaSnfMybnKMgGTrtDKowfTP3pX4jwVy0kNjRsT/AP6h+wC3+3NC+X8X15VWBnTCQlX+wQFg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "regexp-to-ast": "0.4.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "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/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "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/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "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/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "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/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", + "engines": { + "node": ">=8" + } + }, + "node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "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.321", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", + "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==", + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.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/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "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/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "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==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "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/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/globals": { + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", + "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/goober": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", + "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, + "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/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/handsontable": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/handsontable/-/handsontable-17.0.0.tgz", + "integrity": "sha512-npqxr3sE30GbMUuLBKaQxi0lbxoLqenRpobmL2jMqFv3IcBpqUXoKD+2MVds6hgpnW7Xj1XKBwEa9yQ5cV+DRg==", + "license": "SEE LICENSE IN LICENSE.txt", + "dependencies": { + "@handsontable/pikaday": "^1.0.0", + "dompurify": "^3.1.7", + "moment": "2.30.1", + "numbro": "2.5.0" + }, + "optionalDependencies": { + "hyperformula": "^3.0.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/hyperformula": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/hyperformula/-/hyperformula-3.2.0.tgz", + "integrity": "sha512-2vzQKKVMDPLsubZJb0JJWT/DhrkgIjsWj40Z9BIUVT6Jkl/YM5VtkLOP3agCieqW9HuqnXlWc+Vi+7XzQuC1Nw==", + "license": "GPL-3.0-only", + "optional": true, + "dependencies": { + "chevrotain": "^6.5.0", + "tiny-emitter": "^2.1.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "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/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "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/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "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/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "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/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "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/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "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/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/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "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/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "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/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "license": "MIT" + }, + "node_modules/numbro": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/numbro/-/numbro-2.5.0.tgz", + "integrity": "sha512-xDcctDimhzko/e+y+Q2/8i3qNC9Svw1QgOkSkQoO0kIPI473tR9QRbo2KP88Ty9p8WbPy+3OpTaAIzehtuHq+A==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^8 || ^9" + }, + "engines": { + "node": "*" + } + }, + "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/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "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-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==", + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "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/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-dropzone": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-15.0.0.tgz", + "integrity": "sha512-lGjYV/EoqEjEWPnmiSvH4v5IoIAwQM2W4Z1C0Q/Pw2xD0eVzKPS359BQTUMum+1fa0kH2nrKjuavmTPOGhpLPg==", + "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-hot-toast": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", + "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "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-router": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", + "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz", + "integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==", + "license": "MIT", + "dependencies": { + "react-router": "7.13.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/regexp-to-ast": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.4.0.tgz", + "integrity": "sha512-4qf/7IsIKfSNHQXSwial1IFmfM1Cc/whNBQqRwe0V2stPe7KmN1U0tWQiIx6JiirgSrisjE0eECdNf7Tav1Ntw==", + "license": "MIT", + "optional": true + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz", + "integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.120.0", + "@rolldown/pluginutils": "1.0.0-rc.10" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.10", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", + "@rolldown/binding-darwin-x64": "1.0.0-rc.10", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.10", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz", + "integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "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/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", + "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tiny-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-emitter/-/tiny-emitter-2.1.0.tgz", + "integrity": "sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q==", + "license": "MIT", + "optional": true + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "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/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "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/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.1.tgz", + "integrity": "sha512-fLvZWf+cAGw3tqMCYzGIU6yR8K+Y9NT2z23RwOjlNFF2HwSB3KhdEFI5lSBv8tNmFkkBShSjsCjzx1vahZfISA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.57.1", + "@typescript-eslint/parser": "8.57.1", + "@typescript-eslint/typescript-estree": "8.57.1", + "@typescript-eslint/utils": "8.57.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "devOptional": true, + "license": "MIT" + }, + "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==", + "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/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz", + "integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.10", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..d735d87 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,44 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@azure/msal-browser": "^4.30.0", + "@azure/msal-react": "^3.0.29", + "@handsontable/react": "^16.2.0", + "@tailwindcss/vite": "^4.2.2", + "@types/react-router-dom": "^5.3.3", + "autoprefixer": "^10.4.27", + "axios": "^1.13.6", + "handsontable": "^17.0.0", + "postcss": "^8.5.8", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-dropzone": "^15.0.0", + "react-hot-toast": "^2.6.0", + "react-router-dom": "^7.13.1", + "tailwindcss": "^4.2.2", + "zustand": "^5.0.12" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/node": "^24.12.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.57.0", + "vite": "^8.0.1" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..f90339d --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,184 @@ +.counter { + font-size: 16px; + padding: 5px 10px; + border-radius: 5px; + color: var(--accent); + background: var(--accent-bg); + border: 2px solid transparent; + transition: border-color 0.3s; + margin-bottom: 24px; + + &:hover { + border-color: var(--accent-border); + } + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } +} + +.hero { + position: relative; + + .base, + .framework, + .vite { + inset-inline: 0; + margin: 0 auto; + } + + .base { + width: 170px; + position: relative; + z-index: 0; + } + + .framework, + .vite { + position: absolute; + } + + .framework { + z-index: 1; + top: 34px; + height: 28px; + transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) + scale(1.4); + } + + .vite { + z-index: 0; + top: 107px; + height: 26px; + width: auto; + transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) + scale(0.8); + } +} + +#center { + display: flex; + flex-direction: column; + gap: 25px; + place-content: center; + place-items: center; + flex-grow: 1; + + @media (max-width: 1024px) { + padding: 32px 20px 24px; + gap: 18px; + } +} + +#next-steps { + display: flex; + border-top: 1px solid var(--border); + text-align: left; + + & > div { + flex: 1 1 0; + padding: 32px; + @media (max-width: 1024px) { + padding: 24px 20px; + } + } + + .icon { + margin-bottom: 16px; + width: 22px; + height: 22px; + } + + @media (max-width: 1024px) { + flex-direction: column; + text-align: center; + } +} + +#docs { + border-right: 1px solid var(--border); + + @media (max-width: 1024px) { + border-right: none; + border-bottom: 1px solid var(--border); + } +} + +#next-steps ul { + list-style: none; + padding: 0; + display: flex; + gap: 8px; + margin: 32px 0 0; + + .logo { + height: 18px; + } + + a { + color: var(--text-h); + font-size: 16px; + border-radius: 6px; + background: var(--social-bg); + display: flex; + padding: 6px 12px; + align-items: center; + gap: 8px; + text-decoration: none; + transition: box-shadow 0.3s; + + &:hover { + box-shadow: var(--shadow); + } + .button-icon { + height: 18px; + width: 18px; + } + } + + @media (max-width: 1024px) { + margin-top: 20px; + flex-wrap: wrap; + justify-content: center; + + li { + flex: 1 1 calc(50% - 8px); + } + + a { + width: 100%; + justify-content: center; + box-sizing: border-box; + } + } +} + +#spacer { + height: 88px; + border-top: 1px solid var(--border); + @media (max-width: 1024px) { + height: 48px; + } +} + +.ticks { + position: relative; + width: 100%; + + &::before, + &::after { + content: ''; + position: absolute; + top: -4.5px; + border: 5px solid transparent; + } + + &::before { + left: 0; + border-left-color: var(--border); + } + &::after { + right: 0; + border-right-color: var(--border); + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..5eb0734 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,89 @@ +import { useEffect } from 'react' +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' +import { useMsal } from '@azure/msal-react' +import { InteractionStatus } from '@azure/msal-browser' +import { Toaster } from 'react-hot-toast' +import { useAuthStore } from './stores/useAuthStore' +import AppShell from './components/layout/AppShell' +import DashboardPage from './pages/DashboardPage' +import SheetPage from './pages/SheetPage' +import BriefUploadPage from './pages/BriefUploadPage' +import BriefReviewPage from './pages/BriefReviewPage' +import AdminUsersPage from './pages/admin/AdminUsersPage' +import AdminDropdownsPage from './pages/admin/AdminDropdownsPage' +import LoginPage from './pages/LoginPage' + +function AuthGate({ children }: { children: React.ReactNode }) { + const { instance, inProgress, accounts } = useMsal() + const { user, loading, fetchMe, setToken } = useAuthStore() + + useEffect(() => { + if (inProgress !== InteractionStatus.None) return + + const acquire = async () => { + // Dev mode: skip MSAL, just call /auth/me directly + if (import.meta.env.DEV || accounts.length === 0) { + if (!user && !loading) fetchMe() + return + } + try { + const result = await instance.acquireTokenSilent({ + account: accounts[0], + scopes: ['openid', 'profile', 'email'], + }) + setToken(result.idToken) + if (!user && !loading) fetchMe() + } catch { + instance.loginRedirect({ scopes: ['openid', 'profile', 'email'] }) + } + } + + acquire() + }, [inProgress, accounts.length]) + + if (loading || inProgress !== InteractionStatus.None) { + return ( +
+
Loading…
+
+ ) + } + + if (!user) { + return + } + + return <>{children} +} + +function AdminRoute({ children }: { children: React.ReactNode }) { + const { user } = useAuthStore() + if (user?.role !== 'admin') return + return <>{children} +} + +export default function App() { + return ( + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + ) +} diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts new file mode 100644 index 0000000..1f7c7ad --- /dev/null +++ b/frontend/src/api/admin.ts @@ -0,0 +1,20 @@ +import api from './client' +import type { User, CategoryData } from '../types' + +export const listUsers = () => + api.get<{ users: User[] }>('/admin/users').then(r => r.data.users) + +export const updateUser = (id: string, patch: { role?: User['role']; active?: boolean }) => + api.patch<{ success: boolean; user: User }>(`/admin/users/${id}`, patch).then(r => r.data.user) + +export const uploadDropdowns = (file: File) => { + const form = new FormData() + form.append('file', file) + return api.post<{ success: boolean; total: number; active: number }>('/admin/dropdowns/upload', form).then(r => r.data) +} + +export const previewDropdowns = (file: File) => { + const form = new FormData() + form.append('file', file) + return api.post<{ categories: CategoryData[] }>('/admin/dropdowns/preview', form).then(r => r.data.categories) +} diff --git a/frontend/src/api/ai.ts b/frontend/src/api/ai.ts new file mode 100644 index 0000000..eb69f77 --- /dev/null +++ b/frontend/src/api/ai.ts @@ -0,0 +1,14 @@ +import api from './client' +import type { Deliverable } from '../types' + +export interface CommandResult { + success: boolean + operation?: 'create' | 'update' | 'batch_update' | 'question' + count?: number + question?: string + data?: Deliverable[] + error?: string +} + +export const sendCommand = (sheetId: string, command: string, yoloMode: boolean, history: string): Promise => + api.post(`/sheets/${sheetId}/command`, { command, yolo_mode: yoloMode, history }).then(r => r.data) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..e174137 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,18 @@ +import axios from 'axios' + +// Use Vite's BASE_URL so API calls go through the same proxied path in production. +// e.g. BASE_URL = '/ac-helper/' → baseURL = '/ac-helper/api' +const api = axios.create({ + baseURL: `${import.meta.env.BASE_URL}api`, +}) + +// Attach Bearer token from sessionStorage on every request +api.interceptors.request.use((config) => { + const token = sessionStorage.getItem('ac_access_token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + +export default api diff --git a/frontend/src/api/dropdowns.ts b/frontend/src/api/dropdowns.ts new file mode 100644 index 0000000..e2cf0f7 --- /dev/null +++ b/frontend/src/api/dropdowns.ts @@ -0,0 +1,5 @@ +import api from './client' +import type { CategoryData } from '../types' + +export const getCategories = (activeOnly = true) => + api.get<{ categories: CategoryData[] }>(`/dropdowns/categories?active=${activeOnly}`).then(r => r.data.categories) diff --git a/frontend/src/api/jobs.ts b/frontend/src/api/jobs.ts new file mode 100644 index 0000000..9b7924c --- /dev/null +++ b/frontend/src/api/jobs.ts @@ -0,0 +1,25 @@ +import api from './client' +import type { Job, ModelConfiguration, Deliverable } from '../types' + +export const listJobs = (limit = 50) => + api.get<{ jobs: Job[] }>(`/jobs?limit=${limit}`).then(r => r.data.jobs) + +export const getJob = (id: string) => + api.get<{ job: Job }>(`/jobs/${id}`).then(r => r.data.job) + +export const createJob = (files: File[], modelConfig?: ModelConfiguration) => { + const form = new FormData() + files.forEach((f, i) => form.append(`file_${i}`, f)) + if (modelConfig) form.append('modelConfig', JSON.stringify(modelConfig)) + return api.post<{ jobs: Job[] }>('/jobs', form, { + headers: { 'Content-Type': 'multipart/form-data' }, + }).then(r => r.data.jobs) +} + +export const deleteJob = (id: string) => api.delete(`/jobs/${id}`) + +export const getJobDeliverables = (id: string) => + api.get<{ deliverables: Deliverable[]; count: number }>(`/jobs/${id}/deliverables`).then(r => r.data) + +export const getJobStats = () => + api.get('/jobs/stats').then(r => r.data.stats) diff --git a/frontend/src/api/sheets.ts b/frontend/src/api/sheets.ts new file mode 100644 index 0000000..7ec56af --- /dev/null +++ b/frontend/src/api/sheets.ts @@ -0,0 +1,30 @@ +import api from './client' +import type { SheetMeta, Deliverable } from '../types' + +export const listSheets = () => api.get<{ sheets: SheetMeta[] }>('/sheets').then(r => r.data.sheets) + +export const createSheet = (name: string, data: Deliverable[] = []) => + api.post<{ sheet: SheetMeta }>('/sheets', { name, data }).then(r => r.data.sheet) + +export const loadSheet = (id: string) => + api.get<{ data: Deliverable[] }>(`/sheets/${id}`).then(r => r.data.data) + +export const updateSheet = (id: string, data: Deliverable[]) => + api.put(`/sheets/${id}`, { data }) + +export const deleteSheet = (id: string) => api.delete(`/sheets/${id}`) + +export const renameSheet = (id: string, name: string) => + api.patch(`/sheets/${id}`, { name }) + +export const duplicateSheet = (id: string) => + api.post<{ sheet: SheetMeta }>(`/sheets/${id}/duplicate`).then(r => r.data.sheet) + +export const importDeliverables = (sheetId: string, deliverables: Deliverable[], mode: 'append' | 'replace' = 'append') => + api.post(`/sheets/${sheetId}/import`, { deliverables, mode }).then(r => r.data) + +export const exportSheet = (id: string) => { + const token = sessionStorage.getItem('ac_access_token') + const query = token ? `?_token=${token}` : '' + window.open(`${import.meta.env.BASE_URL}api/sheets/${id}/export${query}`, '_blank') +} diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000000000000000000000000000000000000..cc51a3d20ad4bc961b596a6adfd686685cd84bb0 GIT binary patch literal 44919 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 JPDHLkV1myZcL)Fg literal 0 HcmV?d00001 diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/components/brief/FileDropzone.tsx b/frontend/src/components/brief/FileDropzone.tsx new file mode 100644 index 0000000..7913f3b --- /dev/null +++ b/frontend/src/components/brief/FileDropzone.tsx @@ -0,0 +1,52 @@ +import { useCallback } from 'react' +import { useDropzone } from 'react-dropzone' + +interface Props { + onFiles: (files: File[]) => void + loading: boolean +} + +const ACCEPTED = { + 'application/pdf': ['.pdf'], + 'application/vnd.openxmlformats-officedocument.presentationml.presentation': ['.pptx'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], +} + +export default function FileDropzone({ onFiles, loading }: Props) { + const onDrop = useCallback((accepted: File[]) => { + if (accepted.length) onFiles(accepted) + }, [onFiles]) + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, accept: ACCEPTED, disabled: loading, maxSize: 200 * 1024 * 1024, + }) + + return ( +
+ +
📄
+

+ {isDragActive ? 'Drop brief here' : 'Drag & drop your brief'} +

+

+ PDF, PPTX, DOCX, XLSX — up to 200 MB +

+ +
+ ) +} diff --git a/frontend/src/components/brief/JobProgressCard.tsx b/frontend/src/components/brief/JobProgressCard.tsx new file mode 100644 index 0000000..d0b8069 --- /dev/null +++ b/frontend/src/components/brief/JobProgressCard.tsx @@ -0,0 +1,103 @@ +import type { Job } from '../../types' +import { useNavigate } from 'react-router-dom' + +const PHASE_LABELS: Record = { + QUEUED: 'Queued', + EXTRACT_CONTENT: 'Extracting Content', + LLM_ANALYSIS: 'AI Analysis', + CONSOLIDATION: 'Consolidating', + CSV_GENERATION: 'Generating CSV', + COMPLETED: 'Completed', + FAILED: 'Failed', +} + +interface Props { job: Job; onDelete?: (id: string) => void } + +export default function JobProgressCard({ job, onDelete }: Props) { + const navigate = useNavigate() + const isDone = job.phase === 'COMPLETED' + const isFailed = job.phase === 'FAILED' + + return ( +
+
+
+
+ {job.file_name} +
+
+ {(job.file_size / 1024 / 1024).toFixed(1)} MB +
+
+
+ + {PHASE_LABELS[job.phase] || job.phase} + + {onDelete && ( + + )} +
+
+ + {/* Progress bar */} + {!isFailed && ( +
+
+
+ )} + + {job.step_label && ( +
{job.step_label}
+ )} + + {/* Provider updates */} + {Object.entries(job.provider_updates || {}).length > 0 && ( +
+ {Object.entries(job.provider_updates).map(([key, pu]) => ( + + {pu.provider} {pu.status === 'success' ? '✓' : pu.status === 'error' ? '✗' : '…'} + + ))} +
+ )} + + {/* Action buttons */} + {isDone && ( +
+ +
+ )} + + {isFailed && job.error && ( +
+ {job.error} +
+ )} + + {isDone && job.summary && ( +
+ {job.summary.assets_extracted} assets · ${job.summary.cost_usd_total?.toFixed(4)} · {job.summary.processing_time_seconds?.toFixed(1)}s +
+ )} +
+ ) +} diff --git a/frontend/src/components/layout/AppShell.tsx b/frontend/src/components/layout/AppShell.tsx new file mode 100644 index 0000000..f57da38 --- /dev/null +++ b/frontend/src/components/layout/AppShell.tsx @@ -0,0 +1,25 @@ +import React, { useEffect } from 'react' +import Sidebar from './Sidebar' +import TopBar from './TopBar' +import { useSheetStore } from '../../stores/useSheetStore' + +interface Props { + children: React.ReactNode +} + +export default function AppShell({ children }: Props) { + const fetchSheets = useSheetStore(s => s.fetchSheets) + useEffect(() => { fetchSheets() }, []) + + return ( +
+ +
+ +
+ {children} +
+
+
+ ) +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..a12dbed --- /dev/null +++ b/frontend/src/components/layout/Sidebar.tsx @@ -0,0 +1,163 @@ +import { useState } from 'react' +import { useNavigate, useLocation } from 'react-router-dom' +import { useSheetStore } from '../../stores/useSheetStore' +import { useAuthStore } from '../../stores/useAuthStore' + +export default function Sidebar() { + const navigate = useNavigate() + const location = useLocation() + const { sheets, activeSheetId, loadSheet, createSheet, renameSheet, deleteSheet, duplicateSheet } = useSheetStore() + const user = useAuthStore(s => s.user) + const [renamingId, setRenamingId] = useState(null) + const [renameValue, setRenameValue] = useState('') + const [contextMenu, setContextMenu] = useState<{ id: string; x: number; y: number } | null>(null) + + const handleNewSheet = async () => { + const name = `Sheet ${new Date().toLocaleDateString()}` + const id = await createSheet(name) + navigate(`/sheet/${id}`) + } + + const handleSelect = async (id: string) => { + await loadSheet(id) + navigate(`/sheet/${id}`) + } + + const handleContextMenu = (e: React.MouseEvent, id: string) => { + e.preventDefault() + setContextMenu({ id, x: e.clientX, y: e.clientY }) + } + + const handleRename = (id: string, current: string) => { + setRenamingId(id) + setRenameValue(current) + setContextMenu(null) + } + + const commitRename = async (id: string) => { + if (renameValue.trim()) await renameSheet(id, renameValue.trim()) + setRenamingId(null) + } + + return ( +
setContextMenu(null)} + > + {/* Logo */} +
+
+
AC
+ AC Tool +
+
+ + {/* Upload Brief */} +
+ +
+ + {/* Sheets list */} +
+
+ + Sheets + + +
+ + {sheets.map(sheet => ( +
handleContextMenu(e, sheet.id)} + className="group flex items-center gap-2 px-2 py-2 rounded cursor-pointer mb-1 transition-colors" + style={{ + background: activeSheetId === sheet.id ? 'rgba(255,196,7,0.12)' : 'transparent', + borderLeft: activeSheetId === sheet.id ? '2px solid var(--accent)' : '2px solid transparent', + }} + onClick={() => handleSelect(sheet.id)} + > + {renamingId === sheet.id ? ( + setRenameValue(e.target.value)} + onBlur={() => commitRename(sheet.id)} + onKeyDown={e => { if (e.key === 'Enter') commitRename(sheet.id); if (e.key === 'Escape') setRenamingId(null) }} + className="flex-1 bg-transparent text-sm outline-none border-b" + style={{ color: 'var(--text-primary)', borderColor: 'var(--accent)' }} + onClick={e => e.stopPropagation()} + /> + ) : ( + <> + 📋 +
+
+ {sheet.name} +
+
{sheet.itemCount} items
+
+ + )} +
+ ))} + + {sheets.length === 0 && ( +
+ No sheets yet.
Click + to create one. +
+ )} +
+ + {/* Admin link */} + {user?.role === 'admin' && ( +
+ +
+ )} + + {/* Context menu */} + {contextMenu && ( +
e.stopPropagation()} + > + {[ + { label: 'Rename', action: () => { const s = sheets.find(sh => sh.id === contextMenu.id); if (s) handleRename(s.id, s.name) } }, + { label: 'Duplicate', action: async () => { await duplicateSheet(contextMenu.id); setContextMenu(null) } }, + { label: 'Delete', action: async () => { if (confirm('Delete this sheet?')) { await deleteSheet(contextMenu.id); setContextMenu(null) } }, danger: true }, + ].map(item => ( + + ))} +
+ )} +
+ ) +} diff --git a/frontend/src/components/layout/TopBar.tsx b/frontend/src/components/layout/TopBar.tsx new file mode 100644 index 0000000..31f755e --- /dev/null +++ b/frontend/src/components/layout/TopBar.tsx @@ -0,0 +1,61 @@ +import { useNavigate, useLocation } from 'react-router-dom' +import { useAuthStore } from '../../stores/useAuthStore' +import api from '../../api/client' + +export default function TopBar() { + const user = useAuthStore(s => s.user) + const logout = useAuthStore(s => s.logout) + const navigate = useNavigate() + const location = useLocation() + + const handleLogout = async () => { + try { + const res = await api.post('/auth/logout', { redirectUri: window.location.origin + '/ac-helper/' }) + logout() + if (res.data.logoutUrl) window.location.href = res.data.logoutUrl + else navigate('/login') + } catch { + logout() + navigate('/login') + } + } + + const breadcrumb = location.pathname.startsWith('/sheet/') ? 'Sheet Editor' + : location.pathname.startsWith('/brief/review/') ? 'Review Brief' + : location.pathname === '/brief/upload' ? 'Upload Brief' + : location.pathname.startsWith('/admin') ? 'Admin' + : 'Dashboard' + + return ( +
+
+ {breadcrumb} +
+ +
+ {user && ( + <> + + {user.name || user.email} + {user.role === 'admin' && ( + + ADMIN + + )} + + + + )} +
+
+ ) +} diff --git a/frontend/src/components/sheet/AIActivityLog.tsx b/frontend/src/components/sheet/AIActivityLog.tsx new file mode 100644 index 0000000..44b8c1d --- /dev/null +++ b/frontend/src/components/sheet/AIActivityLog.tsx @@ -0,0 +1,45 @@ +import { useState } from 'react' + +interface LogEntry { + time: string + type: 'command' | 'success' | 'error' | 'question' + text: string +} + +interface Props { + entries: LogEntry[] +} + +export default function AIActivityLog({ entries }: Props) { + const [open, setOpen] = useState(false) + + return ( +
+ + + {open && ( +
+ {entries.length === 0 &&
No activity yet.
} + {entries.map((e, i) => ( +
+ [{e.time}]{' '} + + [{e.type.toUpperCase()}] + {' '} + {e.text} +
+ ))} +
+ )} +
+ ) +} + +export type { LogEntry } diff --git a/frontend/src/components/sheet/AIQuestionModal.tsx b/frontend/src/components/sheet/AIQuestionModal.tsx new file mode 100644 index 0000000..3429049 --- /dev/null +++ b/frontend/src/components/sheet/AIQuestionModal.tsx @@ -0,0 +1,46 @@ +import { useState } from 'react' + +interface Props { + question: string + onAnswer: (answer: string) => void + onDismiss: () => void +} + +export default function AIQuestionModal({ question, onAnswer, onDismiss }: Props) { + const [answer, setAnswer] = useState('') + + return ( +
+
+
+ AI needs clarification +
+

{question}

+ + setAnswer(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter' && answer.trim()) onAnswer(answer.trim()) }} + placeholder="Your answer…" + className="w-full px-3 py-2 rounded text-sm outline-none mb-4" + style={{ background: '#1a1a1a', color: 'var(--text-primary)', border: '1px solid var(--border)' }} + /> + +
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/sheet/CommandBar.tsx b/frontend/src/components/sheet/CommandBar.tsx new file mode 100644 index 0000000..42a7ecd --- /dev/null +++ b/frontend/src/components/sheet/CommandBar.tsx @@ -0,0 +1,107 @@ +import { useState, useRef } from 'react' +import { useSpeechRecognition } from '../../hooks/useSpeechRecognition' + +interface Props { + onCommand: (command: string, yolo: boolean) => void + loading: boolean + yolo: boolean + onYoloChange: (val: boolean) => void +} + +export default function CommandBar({ onCommand, loading, yolo, onYoloChange }: Props) { + const [input, setInput] = useState('') + const inputRef = useRef(null) + + const { listening, start, stop, supported } = useSpeechRecognition((text) => { + setInput(text) + }) + + const handleSend = () => { + const cmd = input.trim() + if (!cmd || loading) return + onCommand(cmd, yolo) + setInput('') + } + + const quickStarters = [ + 'Add 5 social media banners for UK', + 'Add 3 email newsletters for DE, FR, ES', + 'Create 10 OOH Print A4 deliverables', + ] + + return ( +
+
+ setInput(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) handleSend() }} + placeholder="Type a command… e.g. 'Add 5 social banners for UK'" + disabled={loading} + className="flex-1 px-3 py-2 rounded text-sm outline-none" + style={{ + background: 'var(--bg-card)', + color: 'var(--text-primary)', + border: '1px solid var(--border)', + }} + /> + + {supported && ( + + )} + + + +
+ ) +} diff --git a/frontend/src/hooks/useSpeechRecognition.ts b/frontend/src/hooks/useSpeechRecognition.ts new file mode 100644 index 0000000..baf221c --- /dev/null +++ b/frontend/src/hooks/useSpeechRecognition.ts @@ -0,0 +1,48 @@ +import { useState, useRef, useCallback } from 'react' + +interface SpeechRecognitionHook { + transcript: string + listening: boolean + start: () => void + stop: () => void + supported: boolean +} + +export function useSpeechRecognition(onResult: (text: string) => void): SpeechRecognitionHook { + const [transcript, setTranscript] = useState('') + const [listening, setListening] = useState(false) + const recognitionRef = useRef(null) + + const supported = 'webkitSpeechRecognition' in window || 'SpeechRecognition' in window + + const start = useCallback(() => { + if (!supported) return + const SpeechRecognition = (window as any).webkitSpeechRecognition || (window as any).SpeechRecognition + const recognition = new SpeechRecognition() + recognition.continuous = true + recognition.interimResults = true + recognition.lang = 'en-US' + + recognition.onresult = (e: any) => { + let final = '' + for (let i = e.resultIndex; i < e.results.length; i++) { + if (e.results[i].isFinal) final += e.results[i][0].transcript + } + if (final) { + setTranscript(final) + onResult(final) + } + } + recognition.onend = () => setListening(false) + recognitionRef.current = recognition + recognition.start() + setListening(true) + }, [supported, onResult]) + + const stop = useCallback(() => { + recognitionRef.current?.stop() + setListening(false) + }, []) + + return { transcript, listening, start, stop, supported } +} diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts new file mode 100644 index 0000000..d061339 --- /dev/null +++ b/frontend/src/hooks/useWebSocket.ts @@ -0,0 +1,47 @@ +import { useEffect, useRef } from 'react' +import { useJobStore } from '../stores/useJobStore' +import type { Job } from '../types' + +export function useWebSocket() { + const wsRef = useRef(null) + const updateJob = useJobStore(s => s.updateJob) + const fetchJobs = useJobStore(s => s.fetchJobs) + + useEffect(() => { + const token = sessionStorage.getItem('ac_access_token') + // Build WS URL using the same base path so it's proxied correctly in production + const base = import.meta.env.BASE_URL.replace(/\/$/, '') // strip trailing slash + const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}${base}/ws${token ? `?token=${token}` : ''}` + + const connect = () => { + const ws = new WebSocket(wsUrl) + wsRef.current = ws + + ws.onmessage = (e) => { + try { + const msg = JSON.parse(e.data) + if (msg.type === 'queue.snapshot') { + fetchJobs() + } else if (msg.type === 'job.progress' || msg.type === 'job.completed' || msg.type === 'job.failed') { + if (msg.job) updateJob(msg.job as Job) + } + } catch { /* ignore */ } + } + + // Keepalive ping + const ping = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'ping' })) + }, 25000) + + ws.onclose = () => { + clearInterval(ping) + setTimeout(connect, 3000) + } + } + + connect() + return () => { + wsRef.current?.close() + } + }, []) +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..f4c2819 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,98 @@ +@import "tailwindcss"; + +/* Oliver Agency dark theme */ +:root { + --bg-color: #000000; + --bg-card: #121212; + --bg-sidebar: #0a0a0a; + --accent: #FFC407; + --accent-hover: #e6b000; + --text-primary: #ffffff; + --text-secondary: #888888; + --text-muted: #555555; + --border: #222222; + --border-light: #333333; + --success: #22c55e; + --danger: #ef4444; + --warning: #f59e0b; + --info: #3b82f6; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--bg-color); + color: var(--text-primary); + font-family: 'Montserrat', 'Inter', system-ui, sans-serif; + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +#root { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* Scrollbar styling */ +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: var(--bg-card); } +::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: #444; } + +/* Handsontable v17 dark theme via CSS variables */ +/* Force dark color-scheme so light-dark() resolves to dark variant */ +.ht-theme-main { + color-scheme: dark; + + /* Oliver accent colour instead of blue */ + --ht-colors-primary-100: #FFC407; + --ht-colors-primary-200: #FFC407; + --ht-colors-primary-300: #e6b000; + --ht-colors-primary-400: #e6b000; + --ht-colors-primary-500: #cc9d00; + --ht-colors-primary-600: #b38900; + + /* Dark palette */ + --ht-colors-palette-950: #121212; + --ht-colors-palette-900: #1a1a1a; + --ht-colors-palette-800: #ffffff; + --ht-colors-palette-700: #222222; + --ht-colors-palette-600: #333333; + --ht-colors-palette-500: #555555; + --ht-colors-palette-400: #888888; + --ht-colors-palette-300: #aaaaaa; + --ht-colors-palette-200: #cccccc; + --ht-colors-palette-100: #222222; + --ht-colors-palette-50: #1a1a1a; + --ht-colors-white: #121212; + + /* Font */ + font-family: 'Montserrat', 'Inter', system-ui, sans-serif; + font-size: 13px; +} + +/* Column headers: uppercase accent */ +.ht-theme-main .ht_clone_top th, +.ht-theme-main .ht_clone_left th, +.ht-theme-main th { + font-size: 11px !important; + font-weight: 600 !important; + text-transform: uppercase !important; + letter-spacing: 0.5px !important; + color: var(--accent) !important; +} + +/* Context menu dark */ +.htContextMenu .wtHolder { + background: #1a1a1a !important; + border: 1px solid var(--border) !important; +} +.htContextMenu td { + color: var(--text-primary) !important; +} +.htContextMenu td.current { + background: rgba(255,196,7,0.15) !important; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..610118e --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,25 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { MsalProvider } from '@azure/msal-react' +import { PublicClientApplication } from '@azure/msal-browser' +import './index.css' +import App from './App' + +// MSAL is configured dynamically from /api/auth/config +// We create a placeholder instance here; the real config is loaded in App.tsx +const msalInstance = new PublicClientApplication({ + auth: { + clientId: '9079054c-9620-4757-a256-23413042f1ef', + authority: 'https://login.microsoftonline.com/e519c2e6-bc6d-4fdf-8d9c-923c2f002385', + redirectUri: window.location.origin + '/ac-helper/', + }, + cache: { cacheLocation: 'sessionStorage' }, +}) + +createRoot(document.getElementById('root')!).render( + + + + + , +) diff --git a/frontend/src/pages/BriefReviewPage.tsx b/frontend/src/pages/BriefReviewPage.tsx new file mode 100644 index 0000000..3b3a0ba --- /dev/null +++ b/frontend/src/pages/BriefReviewPage.tsx @@ -0,0 +1,175 @@ +import { useEffect, useState } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { getJobDeliverables } from '../api/jobs' +import { importDeliverables } from '../api/sheets' +import { useSheetStore } from '../stores/useSheetStore' +import type { Deliverable } from '../types' +import toast from 'react-hot-toast' + +const EDITABLE_FIELDS: (keyof Deliverable)[] = ['Title', 'Category', 'Media', 'Sub-media', 'Format', 'Supply date', 'Live date', 'Language', 'Country', 'Status'] + +export default function BriefReviewPage() { + const { jobId } = useParams<{ jobId: string }>() + const navigate = useNavigate() + const { sheets, fetchSheets, createSheet } = useSheetStore() + const [rows, setRows] = useState([]) + const [loading, setLoading] = useState(true) + const [selected, setSelected] = useState>(new Set()) + const [targetSheetId, setTargetSheetId] = useState('new') + const [newSheetName, setNewSheetName] = useState('Brief Import') + const [importing, setImporting] = useState(false) + + useEffect(() => { + fetchSheets() + if (!jobId) return + getJobDeliverables(jobId) + .then(r => { + setRows(r.deliverables) + setSelected(new Set(r.deliverables.map((_, i) => i))) + }) + .catch(() => toast.error('Failed to load deliverables')) + .finally(() => setLoading(false)) + }, [jobId]) + + const updateCell = (rowIdx: number, field: keyof Deliverable, value: string) => { + setRows(prev => prev.map((r, i) => i === rowIdx ? { ...r, [field]: value } : r)) + } + + const toggleRow = (i: number) => { + setSelected(prev => { + const next = new Set(prev) + next.has(i) ? next.delete(i) : next.add(i) + return next + }) + } + + const handleImport = async () => { + const toImport = rows.filter((_, i) => selected.has(i)) + if (!toImport.length) { toast.error('Select at least one row'); return } + + setImporting(true) + try { + let sheetId = targetSheetId + if (targetSheetId === 'new') { + sheetId = await createSheet(newSheetName) + } + await importDeliverables(sheetId, toImport, 'append') + toast.success(`Imported ${toImport.length} deliverables`) + navigate(`/sheet/${sheetId}`) + } catch { + toast.error('Import failed') + } finally { + setImporting(false) + } + } + + if (loading) { + return
Loading deliverables…
+ } + + return ( +
+
+
+ +

+ Review Extracted Deliverables +

+

+ {rows.length} deliverables found. Edit, deselect unwanted rows, then import. +

+
+ + {/* Import controls */} +
+ + + {targetSheetId === 'new' && ( + setNewSheetName(e.target.value)} + className="px-3 py-2 rounded text-sm" + style={{ background: 'var(--bg-card)', color: 'var(--text-primary)', border: '1px solid var(--border)' }} + placeholder="Sheet name" + /> + )} + + +
+
+ + {/* Table */} +
+ + + + + {EDITABLE_FIELDS.map(f => ( + + ))} + + + + {rows.map((row, i) => ( + + + {EDITABLE_FIELDS.map(field => ( + + ))} + + ))} + +
+ setSelected(selected.size === rows.length ? new Set() : new Set(rows.map((_, i) => i)))} + /> + + {f} +
+ toggleRow(i)} /> + + updateCell(i, field, e.target.value)} + className="w-full px-1 py-0.5 rounded text-xs outline-none" + style={{ + background: 'transparent', + color: 'var(--text-primary)', + border: '1px solid transparent', + minWidth: 80, + }} + onFocus={e => { (e.target as HTMLElement).style.borderColor = 'var(--accent)' }} + onBlur={e => { (e.target as HTMLElement).style.borderColor = 'transparent' }} + /> +
+
+
+ ) +} diff --git a/frontend/src/pages/BriefUploadPage.tsx b/frontend/src/pages/BriefUploadPage.tsx new file mode 100644 index 0000000..5cf385b --- /dev/null +++ b/frontend/src/pages/BriefUploadPage.tsx @@ -0,0 +1,76 @@ +import { useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { useJobStore } from '../stores/useJobStore' +import FileDropzone from '../components/brief/FileDropzone' +import JobProgressCard from '../components/brief/JobProgressCard' +import { useWebSocket } from '../hooks/useWebSocket' +import toast from 'react-hot-toast' + +export default function BriefUploadPage() { + const navigate = useNavigate() + const { jobs, uploadFiles, deleteJob, fetchJobs, loading } = useJobStore() + + useWebSocket() + + useEffect(() => { fetchJobs() }, []) + + const handleFiles = async (files: File[]) => { + try { + const created = await uploadFiles(files) + toast.success(`${files.length} brief(s) queued for extraction`) + if (created.length === 1) { + // Scroll user to the new job card + document.getElementById(`job-${created[0].id}`)?.scrollIntoView({ behavior: 'smooth' }) + } + } catch (e: any) { + toast.error(e?.response?.data?.message || 'Upload failed') + } + } + + const activeJobs = jobs.filter(j => !['COMPLETED', 'FAILED'].includes(j.phase)) + const doneJobs = jobs.filter(j => ['COMPLETED', 'FAILED'].includes(j.phase)) + + return ( +
+
+ +

Upload Brief

+

+ Upload a PDF, PPTX, DOCX or XLSX brief. AI will extract all deliverables. +

+
+ + + + {activeJobs.length > 0 && ( +
+

+ In Progress +

+
+ {activeJobs.map(job => ( +
+ +
+ ))} +
+
+ )} + + {doneJobs.length > 0 && ( +
+

+ Completed +

+
+ {doneJobs.map(job => ( + + ))} +
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx new file mode 100644 index 0000000..e4f5eb9 --- /dev/null +++ b/frontend/src/pages/DashboardPage.tsx @@ -0,0 +1,102 @@ +import { useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import { useSheetStore } from '../stores/useSheetStore' +import { useJobStore } from '../stores/useJobStore' +import JobProgressCard from '../components/brief/JobProgressCard' + +export default function DashboardPage() { + const navigate = useNavigate() + const { sheets, createSheet, loadSheet, fetchSheets } = useSheetStore() + const { jobs, fetchJobs } = useJobStore() + + useEffect(() => { + fetchSheets() + fetchJobs() + }, []) + + const handleNewSheet = async () => { + const id = await createSheet(`Sheet ${new Date().toLocaleDateString()}`) + navigate(`/sheet/${id}`) + } + + const recentJobs = jobs.slice(0, 3) + + return ( +
+
+

Dashboard

+

+ Manage your Activation Calendar sheets or extract deliverables from a brief. +

+
+ + {/* Quick actions */} +
+ + + +
+ +
+ {/* Recent sheets */} +
+

+ Recent Sheets +

+
+ {sheets.slice(0, 5).map(sheet => ( + + ))} + {sheets.length === 0 && ( +
No sheets yet.
+ )} +
+
+ + {/* Recent jobs */} +
+

+ Recent Brief Extractions +

+
+ {recentJobs.map(job => ( + + ))} + {recentJobs.length === 0 && ( +
No extractions yet.
+ )} +
+
+
+
+ ) +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..23fc659 --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,49 @@ +import { useMsal } from '@azure/msal-react' +import { useAuthStore } from '../stores/useAuthStore' + +export default function LoginPage() { + const { instance } = useMsal() + const { fetchMe } = useAuthStore() + + const handleLogin = async () => { + // In dev mode (no MSAL accounts), just call fetchMe — backend uses DEV_MODE + if (import.meta.env.DEV) { + await fetchMe() + return + } + instance.loginRedirect({ scopes: ['openid', 'profile', 'email'] }) + } + + return ( +
+
+
📋
+

+ AC Helper +

+

+ Activation Calendar management & brief extraction tool +

+ +

+ Oliver Agency · ai-sandbox.oliver.solutions +

+
+
+ ) +} diff --git a/frontend/src/pages/SheetPage.tsx b/frontend/src/pages/SheetPage.tsx new file mode 100644 index 0000000..918ea83 --- /dev/null +++ b/frontend/src/pages/SheetPage.tsx @@ -0,0 +1,217 @@ +import { useEffect, useRef, useState, useCallback } from 'react' +import { useParams } from 'react-router-dom' +import Handsontable from 'handsontable' +import { HotTable } from '@handsontable/react' +import 'handsontable/styles/ht-theme-main.css' +import { useSheetStore } from '../stores/useSheetStore' +import { useDropdownStore } from '../stores/useDropdownStore' +import { sendCommand } from '../api/ai' +import { exportSheet } from '../api/sheets' +import CommandBar from '../components/sheet/CommandBar' +import AIQuestionModal from '../components/sheet/AIQuestionModal' +import AIActivityLog, { type LogEntry } from '../components/sheet/AIActivityLog' +import type { Deliverable } from '../types' +import toast from 'react-hot-toast' + +const STATUS_OPTIONS = ['Booked', 'To-do', 'In Progress', 'Done'] + +export default function SheetPage() { + const { sheetId } = useParams<{ sheetId: string }>() + const { sheets, activeSheetId, deliverables, loadSheet, saveSheet, saving } = useSheetStore() + const { categories, fetch: fetchCategories } = useDropdownStore() + + const hotRef = useRef(null) + const [aiLoading, setAiLoading] = useState(false) + const [aiQuestion, setAiQuestion] = useState(null) + const [yolo, setYolo] = useState(false) + const [history, setHistory] = useState('') + const [logs, setLogs] = useState([]) + const saveTimeoutRef = useRef | null>(null) + + const sheetMeta = sheets.find(s => s.id === sheetId) + + useEffect(() => { + fetchCategories() + if (sheetId && activeSheetId !== sheetId) { + loadSheet(sheetId) + } + }, [sheetId]) + + const categoryNames = categories.map(c => c.name) + + const columns: Handsontable.ColumnSettings[] = [ + { data: 'Number', title: '#', width: 70, readOnly: true }, + { data: 'Title', title: 'Title', width: 200 }, + { + data: 'Status', title: 'Status', width: 110, + type: 'dropdown', source: STATUS_OPTIONS, + }, + { + data: 'Category', title: 'Category', width: 180, + type: 'autocomplete', source: categoryNames, strict: false, filter: true, + }, + { + data: 'Media', title: 'Media', width: 180, + type: 'autocomplete', strict: false, filter: true, + source(_query: string, process: (items: string[]) => void) { + // Dynamic source — resolved in cells() callback below + process([]) + }, + }, + { data: 'Sub-media', title: 'Sub-media', width: 120 }, + { data: 'Format', title: 'Format', width: 100 }, + { data: 'Supply date', title: 'Supply Date', width: 110, type: 'date', dateFormat: 'YYYY-MM-DD' }, + { data: 'Live date', title: 'Live Date', width: 110, type: 'date', dateFormat: 'YYYY-MM-DD' }, + { data: 'Language', title: 'Lang', width: 60 }, + { data: 'Country', title: 'Country', width: 70 }, + ] + + const cells = useCallback((row: number, col: number): Handsontable.CellMeta => { + if (col === 4) { + // Media column — filter based on current row's Category + const hot = hotRef.current?.hotInstance + const category = hot?.getDataAtRowProp(row, 'Category') as string | undefined + const cat = categories.find(c => c.name === category) + return { source: cat?.mediaTypes ?? [] } + } + return {} + }, [categories]) + + const handleAfterChange = useCallback((changes: Handsontable.CellChange[] | null) => { + if (!changes) return + const hot = hotRef.current?.hotInstance + if (!hot) return + const newData: Deliverable[] = hot.getData().map((row: any[]) => { + const obj: any = {} + columns.forEach((col, i) => { if (col.data) obj[col.data as string] = row[i] }) + return obj as Deliverable + }) + + // Debounced auto-save + if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current) + saveTimeoutRef.current = setTimeout(() => saveSheet(newData), 1000) + }, [columns, saveSheet]) + + const addLog = (type: LogEntry['type'], text: string) => { + setLogs(prev => [...prev, { + time: new Date().toLocaleTimeString(), + type, + text, + }]) + } + + const handleCommand = async (command: string, yolo: boolean) => { + if (!sheetId) return + setAiLoading(true) + addLog('command', command) + try { + const result = await sendCommand(sheetId, command, yolo, history) + if (result.operation === 'question') { + setAiQuestion(result.question || '') + setHistory(prev => prev + `\nUser: ${command}\nAI: ${result.question}`) + addLog('question', result.question || '') + } else if (result.data) { + await loadSheet(sheetId) + addLog('success', `${result.operation}: ${result.count} item(s)`) + setHistory('') + toast.success(`${result.count} item(s) ${result.operation === 'create' ? 'created' : 'updated'}`) + } else if (result.error) { + addLog('error', result.error) + toast.error(result.error) + } + } catch (e: any) { + const msg = e?.response?.data?.message || 'Command failed' + addLog('error', msg) + toast.error(msg) + } finally { + setAiLoading(false) + } + } + + const handleAnswer = (answer: string) => { + setAiQuestion(null) + handleCommand(answer, yolo) + setHistory(prev => prev + `\nUser answer: ${answer}`) + } + + const handleClear = async () => { + if (!sheetId || !confirm('Clear all rows?')) return + await saveSheet([]) + await loadSheet(sheetId) + setLogs([]) + toast.success('Sheet cleared') + } + + return ( +
+ {/* Header */} +
+
+

+ {sheetMeta?.name || 'Sheet'} +

+
+ {deliverables.length} items{saving ? ' · Saving…' : ''} +
+
+
+ + +
+
+ + {/* AI Command */} +
+ +
+ + {/* Spreadsheet */} +
+ {deliverables !== undefined && ( + + )} +
+ + {/* Activity log */} +
+ +
+ + {/* AI Question Modal */} + {aiQuestion && ( + setAiQuestion(null)} + /> + )} +
+ ) +} diff --git a/frontend/src/pages/admin/AdminDropdownsPage.tsx b/frontend/src/pages/admin/AdminDropdownsPage.tsx new file mode 100644 index 0000000..2c9c862 --- /dev/null +++ b/frontend/src/pages/admin/AdminDropdownsPage.tsx @@ -0,0 +1,163 @@ +import { useEffect, useState, useCallback } from 'react' +import { useDropzone } from 'react-dropzone' +import { previewDropdowns, uploadDropdowns } from '../../api/admin' +import { useDropdownStore } from '../../stores/useDropdownStore' +import type { CategoryData } from '../../types' +import toast from 'react-hot-toast' + +export default function AdminDropdownsPage() { + const { categories, fetch: fetchCategories } = useDropdownStore() + const [preview, setPreview] = useState(null) + const [previewFile, setPreviewFile] = useState(null) + const [uploading, setUploading] = useState(false) + const [previewing, setPreviewing] = useState(false) + + useEffect(() => { fetchCategories() }, []) + + const onDrop = useCallback(async (files: File[]) => { + if (!files[0]) return + const file = files[0] + setPreviewFile(file) + setPreviewing(true) + try { + const result = await previewDropdowns(file) + setPreview(result) + } catch { + toast.error('Failed to parse Excel file') + setPreview(null) + } finally { + setPreviewing(false) + } + }, []) + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + accept: { 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'] }, + maxFiles: 1, + }) + + const handleApply = async () => { + if (!previewFile) return + setUploading(true) + try { + await uploadDropdowns(previewFile) + await fetchCategories() + setPreview(null) + setPreviewFile(null) + toast.success('Dropdowns updated successfully') + } catch { + toast.error('Upload failed') + } finally { + setUploading(false) + } + } + + return ( +
+
+

Dropdown Data

+

+ Upload a new Excel file to update the Category / Media hierarchy used across all sheets. +

+
+ + {/* Upload zone */} +
+ +
📊
+

+ {isDragActive ? 'Drop Excel file here' : 'Drag & drop an .xlsx file, or click to select'} +

+

+ Expected format: columns "Category" and "Media Type" (one row per category/media pair) +

+
+ + {previewing && ( +
Parsing file…
+ )} + + {/* Preview */} + {preview && ( +
+
+

+ Preview — {preview.length} categories +

+
+ + +
+
+
+ + + + + + + + + {preview.map(cat => ( + + + + + ))} + +
CategoryMedia Types
{cat.name} + {cat.mediaTypes.join(', ') || '—'} +
+
+
+ )} + + {/* Current data */} +
+

+ Current Data — {categories.length} categories +

+
+ + + + + + + + + {categories.map(cat => ( + + + + + ))} + +
CategoryMedia Types
{cat.name} + {cat.mediaTypes.join(', ') || '—'} +
+
+
+
+ ) +} diff --git a/frontend/src/pages/admin/AdminUsersPage.tsx b/frontend/src/pages/admin/AdminUsersPage.tsx new file mode 100644 index 0000000..61a8a71 --- /dev/null +++ b/frontend/src/pages/admin/AdminUsersPage.tsx @@ -0,0 +1,104 @@ +import { useEffect, useState } from 'react' +import { listUsers, updateUser } from '../../api/admin' +import type { User } from '../../types' +import toast from 'react-hot-toast' + +export default function AdminUsersPage() { + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + listUsers() + .then(setUsers) + .catch(() => toast.error('Failed to load users')) + .finally(() => setLoading(false)) + }, []) + + const handleUpdate = async (id: string, patch: Partial) => { + try { + const updated = await updateUser(id, patch) + setUsers(prev => prev.map(u => u.id === id ? { ...u, ...updated } : u)) + toast.success('User updated') + } catch { + toast.error('Update failed') + } + } + + if (loading) return
Loading users…
+ + return ( +
+
+

User Management

+

+ {users.length} registered users +

+
+ +
+ + + + {['Name', 'Email', 'Role', 'Status', 'Last Login', 'Actions'].map(h => ( + + ))} + + + + {users.map(user => ( + + + + + + + + + ))} + +
{h}
+ {user.name || '—'} + + {user.email} + + + + + {user.active ? 'Active' : 'Inactive'} + + + {user.last_seen ? new Date(user.last_seen).toLocaleDateString() : '—'} + + +
+
+
+ ) +} diff --git a/frontend/src/stores/useAuthStore.ts b/frontend/src/stores/useAuthStore.ts new file mode 100644 index 0000000..f6c619a --- /dev/null +++ b/frontend/src/stores/useAuthStore.ts @@ -0,0 +1,37 @@ +import { create } from 'zustand' +import type { User } from '../types' +import api from '../api/client' + +interface AuthStore { + user: User | null + loading: boolean + fetchMe: () => Promise + setToken: (token: string) => void + logout: () => void +} + +export const useAuthStore = create((set) => ({ + user: null, + loading: true, // start true to prevent login flash before fetchMe completes + + setToken: (token: string) => { + sessionStorage.setItem('ac_access_token', token) + }, + + fetchMe: async () => { + set({ loading: true }) + try { + const res = await api.get('/auth/me') + set({ user: res.data }) + } catch { + set({ user: null }) + } finally { + set({ loading: false }) + } + }, + + logout: () => { + sessionStorage.removeItem('ac_access_token') + set({ user: null }) + }, +})) diff --git a/frontend/src/stores/useDropdownStore.ts b/frontend/src/stores/useDropdownStore.ts new file mode 100644 index 0000000..3c36d57 --- /dev/null +++ b/frontend/src/stores/useDropdownStore.ts @@ -0,0 +1,30 @@ +import { create } from 'zustand' +import type { CategoryData } from '../types' +import { getCategories } from '../api/dropdowns' + +interface DropdownStore { + categories: CategoryData[] + loaded: boolean + fetch: () => Promise + getMediaTypes: (categoryName: string) => string[] +} + +export const useDropdownStore = create((set, get) => ({ + categories: [], + loaded: false, + + fetch: async () => { + if (get().loaded) return + try { + const categories = await getCategories(true) + set({ categories, loaded: true }) + } catch { + // fall through — will use empty arrays + } + }, + + getMediaTypes: (categoryName: string) => { + const cat = get().categories.find(c => c.name === categoryName) + return cat?.mediaTypes ?? [] + }, +})) diff --git a/frontend/src/stores/useJobStore.ts b/frontend/src/stores/useJobStore.ts new file mode 100644 index 0000000..0c1bf60 --- /dev/null +++ b/frontend/src/stores/useJobStore.ts @@ -0,0 +1,45 @@ +import { create } from 'zustand' +import type { Job, ModelConfiguration } from '../types' +import * as jobsApi from '../api/jobs' + +interface JobStore { + jobs: Job[] + loading: boolean + + fetchJobs: () => Promise + uploadFiles: (files: File[], modelConfig?: ModelConfiguration) => Promise + deleteJob: (id: string) => Promise + updateJob: (job: Job) => void +} + +export const useJobStore = create((set) => ({ + jobs: [], + loading: false, + + fetchJobs: async () => { + set({ loading: true }) + try { + const jobs = await jobsApi.listJobs() + set({ jobs }) + } finally { + set({ loading: false }) + } + }, + + uploadFiles: async (files: File[], modelConfig?: ModelConfiguration) => { + const created = await jobsApi.createJob(files, modelConfig) + set(s => ({ jobs: [...created, ...s.jobs] })) + return created + }, + + deleteJob: async (id: string) => { + await jobsApi.deleteJob(id) + set(s => ({ jobs: s.jobs.filter(j => j.id !== id) })) + }, + + updateJob: (job: Job) => { + set(s => ({ + jobs: s.jobs.map(j => j.id === job.id ? job : j) + })) + }, +})) diff --git a/frontend/src/stores/useSheetStore.ts b/frontend/src/stores/useSheetStore.ts new file mode 100644 index 0000000..f57c9c4 --- /dev/null +++ b/frontend/src/stores/useSheetStore.ts @@ -0,0 +1,101 @@ +import { create } from 'zustand' +import type { SheetMeta, Deliverable } from '../types' +import * as sheetsApi from '../api/sheets' + +interface SheetStore { + sheets: SheetMeta[] + activeSheetId: string | null + deliverables: Deliverable[] + loading: boolean + saving: boolean + + fetchSheets: () => Promise + loadSheet: (id: string) => Promise + createSheet: (name: string) => Promise + saveSheet: (data: Deliverable[]) => Promise + deleteSheet: (id: string) => Promise + renameSheet: (id: string, name: string) => Promise + duplicateSheet: (id: string) => Promise + setDeliverables: (data: Deliverable[]) => void +} + +export const useSheetStore = create((set, get) => ({ + sheets: [], + activeSheetId: localStorage.getItem('ac_active_sheet'), + deliverables: [], + loading: false, + saving: false, + + fetchSheets: async () => { + set({ loading: true }) + try { + const sheets = await sheetsApi.listSheets() + set({ sheets }) + } finally { + set({ loading: false }) + } + }, + + loadSheet: async (id: string) => { + set({ loading: true }) + try { + const data = await sheetsApi.loadSheet(id) + set({ activeSheetId: id, deliverables: data }) + localStorage.setItem('ac_active_sheet', id) + } finally { + set({ loading: false }) + } + }, + + createSheet: async (name: string) => { + const sheet = await sheetsApi.createSheet(name) + set(s => ({ sheets: [sheet, ...s.sheets] })) + await get().loadSheet(sheet.id) + return sheet.id + }, + + saveSheet: async (data: Deliverable[]) => { + const id = get().activeSheetId + if (!id) return + set({ saving: true }) + try { + await sheetsApi.updateSheet(id, data) + set({ deliverables: data }) + // Update itemCount in sheets list + set(s => ({ + sheets: s.sheets.map(sh => sh.id === id ? { ...sh, itemCount: data.length } : sh) + })) + } finally { + set({ saving: false }) + } + }, + + deleteSheet: async (id: string) => { + await sheetsApi.deleteSheet(id) + const remaining = get().sheets.filter(s => s.id !== id) + set({ sheets: remaining }) + if (get().activeSheetId === id) { + if (remaining.length > 0) { + await get().loadSheet(remaining[0].id) + } else { + set({ activeSheetId: null, deliverables: [] }) + localStorage.removeItem('ac_active_sheet') + } + } + }, + + renameSheet: async (id: string, name: string) => { + await sheetsApi.renameSheet(id, name) + set(s => ({ + sheets: s.sheets.map(sh => sh.id === id ? { ...sh, name } : sh) + })) + }, + + duplicateSheet: async (id: string) => { + const sheet = await sheetsApi.duplicateSheet(id) + set(s => ({ sheets: [sheet, ...s.sheets] })) + return sheet + }, + + setDeliverables: (data: Deliverable[]) => set({ deliverables: data }), +})) diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts new file mode 100644 index 0000000..6ce24ae --- /dev/null +++ b/frontend/src/types/index.ts @@ -0,0 +1,92 @@ +export interface Deliverable { + Number: string + Title: string + Status: string + Category: string + Media: string + 'Sub-media': string + Format: string + 'Supply date': string + 'Live date': string + Language: string + Country: string + Quantity: number + // Brief-extractor extras (stripped before save) + _brief_title?: string + _brand_identifier?: string + _priority?: string +} + +export interface SheetMeta { + id: string + name: string + created: string + modified: string + itemCount: number + user: string +} + +export interface CategoryData { + name: string + status: 'Active' | 'Archived' + mediaTypes: string[] +} + +export interface User { + id: string + email: string + name: string + role: 'user' | 'admin' + active: boolean + created?: string + last_seen?: string +} + +export type JobPhase = + | 'QUEUED' + | 'EXTRACT_CONTENT' + | 'LLM_ANALYSIS' + | 'CONSOLIDATION' + | 'CSV_GENERATION' + | 'COMPLETED' + | 'FAILED' + +export interface ProviderUpdate { + provider: string + model: string + status: string + latency_ms?: number + tokens_in?: number + tokens_out?: number + cost_usd?: number + error?: string +} + +export interface JobSummary { + assets_extracted: number + confidence_score: number + cost_usd_total: number + tokens_total: number + processing_time_seconds: number +} + +export interface Job { + id: string + file_name: string + file_size: number + user_id: string + phase: JobPhase + progress_pct: number + step_label: string + provider_updates: Record + error?: string + result_csv_url?: string + summary?: JobSummary + created_at: string +} + +export interface ModelConfiguration { + primary_models: string[] + consolidation_model: string + minimum_success_threshold: number +} diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..af516fc --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2023", + "useDefineForClassFields": true, + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..8a67f62 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..776cd81 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + plugins: [react(), tailwindcss()], + base: '/ac-helper/', + server: { + port: 5173, + proxy: { + // Strip /ac-helper prefix before forwarding to backend (mirrors production nginx) + '/ac-helper/api': { + target: 'http://localhost:8000', + changeOrigin: true, + rewrite: (path) => path.replace('/ac-helper', ''), + }, + '/ac-helper/ws': { + target: 'ws://localhost:8000', + ws: true, + rewrite: (path) => path.replace('/ac-helper', ''), + }, + '/health': { target: 'http://localhost:8000', changeOrigin: true }, + }, + }, + build: { + outDir: 'dist', + }, +}) diff --git a/run_dev.sh b/run_dev.sh new file mode 100755 index 0000000..4de0b18 --- /dev/null +++ b/run_dev.sh @@ -0,0 +1,52 @@ +#!/bin/bash +# Local development runner +# Usage: ./run_dev.sh + +set -e + +ROOT="$(cd "$(dirname "$0")" && pwd)" +BACKEND="$ROOT/backend" +FRONTEND="$ROOT/frontend" + +# Ensure data dirs +mkdir -p "$BACKEND/data/uploads" "$BACKEND/data/outputs" "$BACKEND/data/sheets" + +# Load .env if present +[ -f "$BACKEND/.env" ] && export $(grep -v '^#' "$BACKEND/.env" | xargs) + +# Override paths for local dev +export DATA_DIR="$BACKEND/data" +export UPLOADS_DIR="$BACKEND/data/uploads" +export OUTPUTS_DIR="$BACKEND/data/outputs" +export SHEETS_DIR="$BACKEND/data/sheets" +export USERS_FILE="$BACKEND/data/users.json" +export DROPDOWNS_FILE="$BACKEND/data/dropdowns.json" + +# Create venv if missing +if [ ! -d "$BACKEND/.venv" ]; then + echo "Creating Python venv..." + python3 -m venv "$BACKEND/.venv" + "$BACKEND/.venv/bin/pip" install -q -r "$BACKEND/requirements.txt" +fi + +# Start backend in background +echo "Starting backend on http://localhost:8000 ..." +cd "$BACKEND" +.venv/bin/python -m hypercorn server.app:create_app\(\) --bind 0.0.0.0:8000 --worker-class asyncio & +BACKEND_PID=$! + +# Start frontend dev server +echo "Starting frontend on http://localhost:5173/ac-helper/ ..." +cd "$FRONTEND" +npm run dev & +FRONTEND_PID=$! + +echo "" +echo "AC Tool running:" +echo " Frontend: http://localhost:5173/ac-helper/" +echo " Backend: http://localhost:8000" +echo "" +echo "Press Ctrl+C to stop both servers." + +trap "kill $BACKEND_PID $FRONTEND_PID 2>/dev/null; exit 0" INT TERM +wait