commit 236d1ddbd856d68dd61735d7f55514fca9e50ca0 Author: michael Date: Mon Feb 23 10:28:33 2026 -0600 Initial commit: Netflix GraphRAG marketing chatbot Full-stack application combining LlamaIndex vector search with Neo4j knowledge graph (GraphRAG) for answering queries about Netflix marketing materials. Flask/Hypercorn backend with custom ReAct agent, React frontend. Co-Authored-By: Claude Opus 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4644c05 --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Python +__pycache__/ +*.py[cod] +*.pyo +venv/ +env/ +*.egg-info/ + +# Environment / secrets +.env +env + +# Logs +*.log +logs/ + +# macOS +.DS_Store + +# Node +chat-interface/node_modules/ + +# Generated / runtime data +index_storage/ +uploads/ +source_documents/ + +# Large binary assets (PDFs, videos, xlsx in supporting_files) +supporting_files/files_for_rag_store/ +supporting_files/temp_storage/ +supporting_files/*.mp4 +supporting_files/*.pdf +supporting_files/*.xlsx +supporting_files/~$* + +# Neo4j data volumes +neo4j/data/ +neo4j/logs/ +neo4j/plugins/ +neo4j/import/ +neo4j/neo4j/ + +# Generated code dump +consolidated_code.py + +# IDE +.vscode/ +.idea/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..050e326 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,121 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Repository Overview + +GraphRAG-enhanced Netflix marketing chatbot. Combines LlamaIndex vector search with a Neo4j knowledge graph (GraphRAG) to answer queries about Netflix marketing materials (GPD Key Art Playbook). Flask/Hypercorn async backend, React frontend, MongoDB for conversation history. + +## Development Commands + +### Backend +```bash +# Setup +python -m venv venv && source venv/bin/activate +pip install -r requirements.txt + +# Run backend (Hypercorn ASGI server on port 6175) +python main.py +``` + +### Frontend +```bash +cd chat-interface +npm install +npm run dev # Vite dev server (port 5173) +npm run build # Production build +npm run lint # ESLint +``` + +### Databases (Docker) +```bash +# MongoDB (conversation storage) +cd db && docker-compose up -d + +# Neo4j (knowledge graph) +cd neo4j && docker-compose -f docker-compose-neo4j.yml up -d +# Neo4j Browser: http://localhost:7474 +``` + +### Reindex Documents +```bash +rm -rf index_storage/ +python main.py # Rebuilds index on startup +``` + +### Test GraphRAG standalone +```bash +python graphRAG.py +``` + +## Architecture + +### Request Flow +``` +User → React Frontend → Flask Routes (routes.py) → ReActAgent2 (ai_core.py) + → Tool Selection → [Vector Query Tool | GraphRAG Query Tool] + → Response Synthesis → JSON API → Frontend +``` + +### Key Backend Files +- **`main.py`**: Flask app init, Hypercorn config, startup sequence (MongoDB init → AI index init) +- **`config.py`**: All configuration — API keys, model params, paths, timeouts. Loads from `.env` +- **`routes.py`**: All Flask route handlers. `register_routes(app)` pattern. Main endpoint: `POST /chat` +- **`ai_core.py`** (~79KB): Core AI logic. Contains: + - `ReActAgent2(Workflow)`: Custom LlamaIndex Workflow-based ReAct agent with 4 steps: `new_user_msg` → `prepare_chat_history` → `handle_llm_input` → `handle_tool_calls` + - `initialize_global_index()`: Startup function that builds/loads vector index and GraphRAG components + - Document parsing via LlamaParse, semantic chunking, vector index creation +- **`graph_rag_integration.py`**: GraphRAG integration layer. `GraphRAGExtractor`, `GraphRAGStore`, `GraphRAGQueryEngine` classes. Community detection via NetworkX/Louvain. Creates hybrid vector+graph retrieval tools +- **`graphRAG.py`**: Standalone GraphRAG implementation (original, used for testing). Duplicates some classes from `graph_rag_integration.py` +- **`shared_state.py`**: Module-level globals for the agent, index, and GraphRAG components. Setter/getter functions ensure cross-module consistency +- **`session_manager.py`**: Maps frontend session IDs to MongoDB user/conversation records. In-memory cache + DB persistence +- **`mongodb_utils.py`**: All MongoDB CRUD operations. Connection: `mongodb://netflix:netflix@localhost:27017`, DB: `netflix_chatbot` +- **`json_utils.py`**: Custom Flask JSON provider for serializing LlamaIndex objects +- **`document_generator.py`**: Creates DOCX brief exports from conversations + +### Frontend (`chat-interface/`) +- React 18 + Vite + Tailwind CSS +- `src/App.jsx`: Main chat UI component, API calls, message rendering +- `src/auth.js`: MSAL authentication with dev bypass +- `src/components/ConversationManager.jsx`: Conversation CRUD +- Backend URL configured via `VITE_BACKEND_URL` env var + +### Shared State Pattern +Global AI state lives in `shared_state.py` and is imported by reference across modules. The `set_global_agent()`, `set_global_index()`, and `set_graphrag_components()` functions must be used to update state (not direct assignment from other modules). + +### Dual Retrieval System +The ReAct agent selects between two tools: +1. **Vector Query Tool**: LlamaIndex `VectorStoreIndex` with OpenAI embeddings (`text-embedding-3-small`), semantic chunking, metadata-filtered retrieval +2. **GraphRAG Query Tool**: Neo4j property graph with entity extraction, community detection (Louvain clustering), and community-summarized retrieval + +## Development Mode + +Set `PRODUCTION=false` in `.env` to: +- Bypass MSAL authentication (uses `dev_user@local`) +- Enable Hypercorn hot-reload +- Bind to `localhost` instead of `0.0.0.0` +- Frontend works without backend (mock responses) + +Frontend dev mode requires `chat-interface/.env.development` with: +``` +VITE_NODE_ENV=development +VITE_MODE=development +``` + +## API Endpoints + +- `POST /chat` — Send message. Body: `{message, sessionId}`. Header: `X-MS-USERNAME` +- `GET /conversations` — List user conversations +- `GET /conversations//messages` — Get conversation messages +- `DELETE /conversations/` — Delete conversation +- `GET /images/` — Serve extracted document images +- `GET /status` — System initialization status + +## Key Dependencies + +- **LLM**: OpenAI `chatgpt-4o-latest` (configurable in `config.py`) +- **Embeddings**: OpenAI `text-embedding-3-small` +- **Document Parsing**: LlamaParse (requires `LLAMA_CLOUD_API_KEY`) +- **Graph DB**: Neo4j (bolt://localhost:7687) +- **Conversation DB**: MongoDB (localhost:27017) +- **ASGI Server**: Hypercorn (async Flask support) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..e5be716 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,134 @@ +# Netflix GraphRAG Development Guide + +## Authentication Bypass for Local Development + +The application has been configured to bypass Microsoft authentication (MSAL) when running in development mode. This makes local development easier by: + +1. Skipping the Microsoft login screen entirely +2. Automatically using a development user (`dev_user@local`) +3. Allowing backend API requests without MSAL tokens +4. Providing mock responses when the backend is unavailable + +### How to Run in Development Mode + +#### Frontend + +To run the frontend in development mode with auth bypass: + +```bash +cd chat-interface +./dev.sh +``` + +Or manually: + +```bash +cd chat-interface +npm run dev +``` + +Make sure the `.env.development` file exists with: + +``` +VITE_NODE_ENV=development +VITE_MODE=development +``` + +#### Backend + +The backend automatically detects development mode and accepts requests with the username `dev_user@local` when the `PRODUCTION` flag is set to `False`. + +For example: + +```python +# In routes.py +if not PRODUCTION and (not username or username == ""): + username = "dev_user@local" +``` + +### How Authentication Bypass Works + +#### Frontend + +1. In `main.jsx`: + - The application checks for development mode using `import.meta.env` + - In development mode, MSAL initialization is skipped + - The application renders immediately without showing the login screen + +2. In `auth.js`: + - The `getCurrentUser()` function returns `dev_user@local` in development mode + - The `isAuthenticated()` function always returns `true` in development mode + - The `initializeMSAL()` function skips MSAL initialization in development mode + +3. In `App.jsx`: + - All API requests include the `X-MS-USERNAME` header with `dev_user@local` + - In development mode, the application can work without a backend connection + - It provides mock responses for chat messages and mock conversations + - JSON parsing errors are caught and handled gracefully + +### Development-Only Features + +The frontend includes several development-specific features: + +1. **Mock Chat Interface**: + - In development mode, the chat interface works without needing a backend + - User messages get mock responses based on their content + - Sources and reasoning information are simulated + +2. **Mock Conversations**: + - Development mode creates sample conversations automatically + - No need to set up a database or backend service + +3. **Error Resilience**: + - All backend API calls are wrapped with development fallbacks + - Even if API requests fail or return invalid JSON, the UI continues working + - Clear console logging shows when development mode is active + +### Backend Development + +The backend automatically detects development mode and accepts requests with the username `dev_user@local`. + +1. In `routes.py`: + - API endpoints check for the `PRODUCTION` flag + - In development mode, if no username is provided, `dev_user@local` is used + - Authentication validation is bypassed for this development user + +2. In environment configuration: + - The `.env` file contains API keys and other sensitive information + - These are loaded using `dotenv` in `config.py` + +### Troubleshooting + +If you're still seeing the login screen in development mode: + +1. Check that the `.env.development` file exists in the `chat-interface` directory +2. Verify that you're running with `npm run dev` or the provided `dev.sh` script +3. Look at the browser console for any errors or authentication messages +4. If needed, clear browser cache and localStorage + +If the chat interface shows errors connecting to the backend: + +1. The application should still work in development mode without a backend +2. Check console logs for "Development mode" messages to confirm it's running in dev mode +3. If needed, restart the development server + +### API Testing + +When testing backend APIs directly, include the development username in requests: + +```bash +curl -X POST http://localhost:5000/chat \ + -H "Content-Type: application/json" \ + -H "X-MS-USERNAME: dev_user@local" \ + -d '{"message": "Hello", "sessionId": "your_session_id"}' +``` + +### Running Without Backend + +The frontend is now configured to run independently in development mode, even if the backend is not available. This is useful for: + +1. UI development without setting up the full backend stack +2. Testing the chat interface flow without a working backend API +3. Demonstrating the UI to stakeholders without backend dependencies + +In this mode, all chat messages will receive simulated responses, and the conversation history will be populated with mock data. \ No newline at end of file diff --git a/LOCAL_DEV.md b/LOCAL_DEV.md new file mode 100644 index 0000000..b2f06e0 --- /dev/null +++ b/LOCAL_DEV.md @@ -0,0 +1,51 @@ +# Local Development Mode + +This document explains how to run the Netflix GraphRAG application in local development mode without requiring MSAL authentication. + +## Authentication Bypass + +When running the application in local development mode (PRODUCTION=false), the authentication system is automatically bypassed: + +1. The frontend will not redirect to Microsoft login +2. A default user "dev_user@local" is automatically used for all API requests +3. You can directly start using the application without any login + +## Setup Instructions + +1. Ensure you have a `.env` file in the project root with: + ``` + PRODUCTION=false + ``` + +2. Start the backend server: + ```bash + python main.py + ``` + +3. Start the frontend development server: + ```bash + cd chat-interface + npm run dev + ``` + +4. Access the application at http://localhost:5173 + +## How it Works + +- The frontend (auth.js) automatically bypasses authentication checks when in development mode +- The backend (routes.py) checks for `PRODUCTION=false` in all authenticated endpoints +- When `PRODUCTION=false` and no username is provided, the backend uses "dev_user@local" +- All conversations and messages will be associated with this default user + +## Switching to Production Mode + +To disable the authentication bypass and use real MSAL authentication: + +1. Set `PRODUCTION=true` in your `.env` file +2. Restart both frontend and backend servers + +## Troubleshooting + +- If you encounter authentication errors, make sure PRODUCTION is set to "false" in the .env file +- Ensure the backend is running before accessing the frontend +- Check for any error messages in both the frontend and backend consoles \ No newline at end of file diff --git a/NETFLIX_GRAPHRAG_DESCRIPTION.md b/NETFLIX_GRAPHRAG_DESCRIPTION.md new file mode 100644 index 0000000..ea11fa5 --- /dev/null +++ b/NETFLIX_GRAPHRAG_DESCRIPTION.md @@ -0,0 +1,144 @@ +# Netflix GraphRAG Chatbot + +## Overview + +The Netflix GraphRAG Chatbot is an advanced AI-powered knowledge assistant specifically designed to help users navigate Netflix's marketing materials, particularly the GPD Key Art Playbook and related design guidelines. It leverages a hybrid retrieval system that combines traditional vector search with graph-based knowledge representation to provide comprehensive and contextually relevant answers. + +## User Experience + +### Chat Interface + +Users interact with the system through a modern, intuitive chat interface that features: + +- **Conversation Management**: Create, select, and manage multiple conversation threads +- **Rich Text Responses**: Markdown-formatted responses with proper formatting and layout +- **Source Attribution**: View the sources of information used to generate answers +- **Image Support**: Access to relevant document screenshots directly within the chat +- **Reasoning Transparency**: Option to view the AI's reasoning process for each response +- **Theme Toggle**: Switch between light and dark modes for comfortable viewing + +### Authentication + +The application integrates with Microsoft Authentication Library (MSAL) for secure access, with: + +- **Azure AD Integration**: Enterprise-grade authentication for Netflix employees +- **Development Mode**: A special bypass mode for local development that uses a default user + +### User Workflow + +1. Users log in through Microsoft authentication (or bypass in development mode) +2. They can create a new conversation or select an existing one +3. Questions are submitted through the chat input +4. The system processes the query, combining vector-based and graph-based retrieval +5. Responses include the answer, source references, and optional document images +6. Conversations are persistent, allowing users to return to previous discussions + +## Technical Architecture + +### Backend Components + +1. **Flask Server**: Python-based REST API server using Hypercorn for async support + +2. **Document Processing Pipeline**: + - **LlamaParse Integration**: Extracts text and images from various document formats + - **Semantic Chunking**: Divides documents into meaningful semantic units + - **Entity Extraction**: Identifies entities and relationships for the knowledge graph + - **Image Processing**: Associates document screenshots with relevant text chunks + +3. **Retrieval System**: + - **Vector Storage**: LlamaIndex with OpenAI embeddings for similarity search + - **Graph Database**: Neo4j for storing and querying knowledge graph relationships + - **Hybrid Query Engine**: Combined vector and graph-based retrieval for comprehensive answers + +4. **Session Management**: + - **MongoDB Integration**: Stores user information, conversations, and messages + - **State Management**: Handles session persistence and conversation tracking + +### Frontend Components + +1. **React Application**: + - Built with Vite for fast development and optimized production builds + - Tailwind CSS for responsive design and theming + - Microsoft Authentication Library (MSAL) integration + +2. **Key Features**: + - Real-time conversation updates with optimistic UI + - Adaptive sidebar for conversation management + - Image viewer with pagination for document screenshots + - Source and reasoning tooltips for transparency + - Offline fallback mode during development + +## GraphRAG Implementation + +The system's core innovation is its GraphRAG (Graph Retrieval Augmented Generation) approach: + +### How It Works + +1. **Document Processing**: + - Documents are chunked into semantic units using AI-based splitting + - Each chunk is embedded and stored in a vector index + - Entities and relationships are extracted from chunks using LLMs + - A knowledge graph is constructed in Neo4j based on these extractions + +2. **Community Detection**: + - The knowledge graph is analyzed to detect communities of related entities + - Each community receives an AI-generated summary of its collective knowledge + - Community information is cached to improve response time + +3. **Dual Retrieval Process**: + - **Vector Retrieval**: Traditional similarity search finds relevant document chunks + - **Graph Retrieval**: Entities from the query are mapped to graph communities + - **Context Merging**: Information from both sources is combined for a comprehensive answer + +4. **Answer Generation**: + - The LLM synthesizes information from both vector and graph contexts + - The system returns the answer along with source documents and images + - Reasoning steps are included for transparency + +### Advantages Over Standard RAG + +- **Contextual Understanding**: Graph relationships provide semantic connections beyond keyword matching +- **Cross-Document Insights**: Can draw connections between information in separate documents +- **Community Summaries**: Provides broader context from related concepts +- **Visual Context**: Automatically includes relevant document images + +## Deployment & Development + +### Backend Setup + +```bash +# Set up Python environment +python -m venv env +source env/bin/activate +pip install -r requirements.txt + +# Configure local databases +cd db +docker-compose up -d # MongoDB +cd ../neo4j +docker-compose -f docker-compose-neo4j.yml up -d # Neo4j + +# Run the server +python main.py +``` + +### Frontend Setup + +```bash +# Set up and run the React frontend +cd chat-interface +npm install +npm run dev +``` + +### Development Mode + +For local development, the system can run in a special mode that bypasses authentication: + +1. Create a `.env` file with `PRODUCTION=false` +2. Start the backend and frontend servers +3. Access the application at http://localhost:5173 + +## Conclusion + +The Netflix GraphRAG Chatbot represents a significant advancement in knowledge retrieval systems. By combining vector search with graph-based knowledge representation, it delivers more comprehensive, contextual answers that better reflect the interconnected nature of Netflix's marketing materials and guidelines. The system's ability to include relevant document images and explain its reasoning creates a more transparent and trustworthy user experience. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d0afc97 --- /dev/null +++ b/README.md @@ -0,0 +1,205 @@ +# Netflix GraphRAG Marketing Chatbot + +An AI-powered knowledge assistant that answers questions about Netflix marketing materials — specifically the GPD Key Art Playbook and related design guidelines. The system combines traditional vector search (RAG) with a Neo4j knowledge graph (GraphRAG) to deliver contextual, cross-document answers with source citations and relevant document images. + +## How It Works + +The chatbot uses a **dual retrieval** approach: + +1. **Vector Search** — Documents are parsed, semantically chunked, and embedded with OpenAI embeddings. User queries are matched against these chunks via similarity search. +2. **GraphRAG** — Entities and relationships are extracted from document chunks into a Neo4j knowledge graph. Community detection (Louvain clustering) groups related entities, and each community receives an AI-generated summary. At query time, relevant communities are retrieved alongside vector results. + +A custom **ReAct agent** (built on LlamaIndex Workflows) orchestrates both retrieval tools, deciding which to call based on the query, then synthesizes a unified response. + +``` +User Query → ReAct Agent → [Vector Tool | GraphRAG Tool] → Response Synthesis → Answer + Sources + Images +``` + +### Why GraphRAG? + +Standard RAG retrieves isolated text chunks. GraphRAG adds: + +- **Cross-document connections** — Links entities that appear across different documents +- **Community context** — Provides broader topical summaries, not just individual chunks +- **Semantic relationships** — Understands how concepts relate beyond keyword overlap + +## Architecture + +``` +┌─────────────────┐ HTTP/JSON ┌──────────────────────────────────┐ +│ React Frontend │ ◄──────────────► │ Flask + Hypercorn (async ASGI) │ +│ (Vite, TailwindCSS)│ │ │ +└─────────────────┘ │ ┌────────────────────────────┐ │ + │ │ ReAct Agent (Workflow) │ │ + │ │ ├─ Vector Query Tool │ │ + │ │ └─ GraphRAG Query Tool │ │ + │ └────────────────────────────┘ │ + └──────┬──────────┬───────────────┘ + │ │ + ┌─────────────┤ ├─────────────┐ + ▼ ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌─────┐ ┌───────────┐ + │ MongoDB │ │ Neo4j │ │OpenAI│ │LlamaCloud │ + │(sessions,│ │(knowledge│ │(LLM, │ │(LlamaParse│ + │ convos) │ │ graph) │ │embed)│ │ doc parse)│ + └──────────┘ └──────────┘ └─────┘ └───────────┘ +``` + +## Prerequisites + +- **Python 3.10+** +- **Node.js 18+** +- **Docker** (for MongoDB and Neo4j) +- **API Keys**: OpenAI, Anthropic (optional), LlamaCloud (for document parsing) + +## Quick Start + +### 1. Environment Configuration + +Create a `.env` file in the project root: + +```env +PRODUCTION=false + +OPENAI_API_KEY=your-openai-key +ANTHROPIC_API_KEY=your-anthropic-key +LLAMA_CLOUD_API_KEY=your-llamacloud-key +``` + +### 2. Start Databases + +```bash +# MongoDB (conversation storage) +cd db && docker-compose up -d && cd .. + +# Neo4j (knowledge graph) +cd neo4j && docker-compose -f docker-compose-neo4j.yml up -d && cd .. +``` + +Neo4j Browser is available at http://localhost:7474. + +### 3. Backend + +```bash +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt + +python main.py +``` + +The backend starts on **http://localhost:6175**. On first run, it will: +1. Connect to MongoDB and initialize the schema +2. Parse documents from `supporting_files/files_for_rag_store/` via LlamaParse +3. Build the vector index and persist it to `index_storage/` +4. Extract entities/relationships and populate the Neo4j knowledge graph +5. Run community detection and generate community summaries + +Subsequent starts load the persisted index from `index_storage/` (much faster). + +### 4. Frontend + +```bash +cd chat-interface +npm install +npm run dev +``` + +The frontend starts on **http://localhost:5173** and connects to the backend at the URL specified in `chat-interface/.env.development`. + +## Development Mode + +With `PRODUCTION=false` in `.env`: + +- **Authentication is bypassed** — no Microsoft login required; the system uses `dev_user@local` automatically +- **Hot reload** is enabled on the backend (Hypercorn reloader) +- The frontend works even **without a running backend** (mock responses for UI development) + +The frontend has its own env files: +- `chat-interface/.env.development` — sets `VITE_BACKEND_URL` for local dev (default: `http://localhost:6175`) +- `chat-interface/.env.production` — points to the production backend + +## API Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `POST` | `/chat` | Send a chat message. Body: `{ message, sessionId }`. Header: `X-MS-USERNAME` | +| `GET` | `/conversations` | List conversations for the authenticated user | +| `GET` | `/conversations//messages` | Retrieve messages for a conversation | +| `DELETE` | `/conversations/` | Delete a conversation | +| `GET` | `/images/` | Serve an extracted document image | +| `GET` | `/status` | System initialization status | + +### Example Request + +```bash +curl -X POST http://localhost:6175/chat \ + -H "Content-Type: application/json" \ + -H "X-MS-USERNAME: dev_user@local" \ + -d '{"message": "What are the key art guidelines?", "sessionId": "test-session-1"}' +``` + +## Project Structure + +``` +├── main.py # Flask app init, Hypercorn config, startup sequence +├── config.py # All configuration (API keys, models, paths, timeouts) +├── routes.py # Flask route handlers (chat, conversations, images) +├── ai_core.py # ReAct agent workflow, vector index, document processing +├── graph_rag_integration.py # GraphRAG classes (extractor, store, query engine) +├── graphRAG.py # Standalone GraphRAG (for testing) +├── shared_state.py # Global state management for agent/index/graph components +├── session_manager.py # Session → user/conversation mapping +├── mongodb_utils.py # MongoDB CRUD operations +├── document_generator.py # DOCX brief export from conversations +├── json_utils.py # Custom JSON serializer for LlamaIndex objects +├── utils.py # Logging utilities +├── .env # Environment variables (API keys, mode) +├── requirements.txt # Python dependencies +├── index_storage/ # Persisted vector index (auto-generated) +├── supporting_files/ # Source documents for the knowledge base +│ └── files_for_rag_store/ # Documents ingested by the RAG pipeline +├── uploads/images/ # Extracted document page images +├── db/ # MongoDB docker-compose +├── neo4j/ # Neo4j docker-compose and data volumes +└── chat-interface/ # React frontend + ├── src/App.jsx # Main chat UI component + ├── src/auth.js # MSAL authentication with dev bypass + └── src/components/ # UI components (ConversationManager, ThemeToggle) +``` + +## Reindexing Documents + +To rebuild the index after changing source documents: + +```bash +rm -rf index_storage/ +python main.py +``` + +This re-parses all documents, rebuilds the vector index, and regenerates the knowledge graph. + +## Tech Stack + +| Layer | Technology | +|-------|-----------| +| LLM | OpenAI GPT-4o (`chatgpt-4o-latest`) | +| Embeddings | OpenAI `text-embedding-3-small` | +| RAG Framework | LlamaIndex | +| Document Parsing | LlamaParse (LlamaCloud) | +| Knowledge Graph | Neo4j + NetworkX (community detection) | +| Backend | Python, Flask, Hypercorn | +| Conversation DB | MongoDB (via PyMongo) | +| Frontend | React 18, Vite, Tailwind CSS | +| Auth | Microsoft MSAL (Azure AD) | + +## Troubleshooting + +| Issue | Fix | +|-------|-----| +| Login screen appears in dev mode | Verify `PRODUCTION=false` in `.env` | +| MongoDB connection error | Ensure MongoDB container is running: `docker ps` | +| Neo4j connection error | Check Neo4j container and verify credentials in `config.py` match `docker-compose-neo4j.yml` | +| Frontend can't reach backend | Check `VITE_BACKEND_URL` in `chat-interface/.env.development` matches the backend port | +| CORS errors | Verify the frontend origin is listed in `CORS_ALLOWED_ORIGINS` in `config.py` | +| Slow first startup | Expected — LlamaParse document processing and graph construction take time. Subsequent starts use the cached index | diff --git a/ai_core.py b/ai_core.py new file mode 100644 index 0000000..743a637 --- /dev/null +++ b/ai_core.py @@ -0,0 +1,1380 @@ +# netflix_chatbot/ai_core.py +import os +import asyncio +import traceback +import uuid +import shutil +import re +import inspect +import json +from pathlib import Path +from typing import List, Dict, Any, Optional, Union + +import httpx +import tiktoken +import nest_asyncio +from concurrent.futures import ThreadPoolExecutor +from functools import partial + +# Import GraphRAG integration +from graph_rag_integration import ( + create_graph_components, + create_graphrag_query_engine, + generate_final_answer, + GraphRAGExtractor, + GraphRAGStore, + GraphRAGQueryEngine +) + +from llama_index.core import ( + VectorStoreIndex, + Document as LlamaIndexDocument, + Settings, + get_response_synthesizer, + load_index_from_storage, + StorageContext, + SimpleDirectoryReader # Keep if needed for fallback/other uses +) +from llama_index.core.retrievers import VectorIndexAutoRetriever, VectorIndexRetriever +from llama_index.core.query_engine import RetrieverQueryEngine +from llama_index.core.postprocessor import SimilarityPostprocessor +from llama_index.core.response_synthesizers import ResponseMode +from llama_index.llms.openai import OpenAI as LlamaOpenAI +from llama_index.embeddings.openai import OpenAIEmbedding +from llama_index.core.callbacks import CallbackManager, TokenCountingHandler +from llama_index.core.tools import QueryEngineTool, ToolMetadata, BaseTool, ToolSelection, ToolOutput +from llama_index.core.memory import ChatMemoryBuffer +from llama_index.core.llms import ChatMessage, LLM +from llama_index.core.vector_stores.types import MetadataInfo, VectorStoreInfo +from llama_index.core.agent.react import ReActChatFormatter, ReActOutputParser +from llama_index.core.agent.react.types import ( + ActionReasoningStep, + ObservationReasoningStep, + ResponseReasoningStep, + BaseReasoningStep +) +from llama_index.core.workflow import ( + Context, + Workflow, + StartEvent, + StopEvent, + step, + Event, +) +from llama_index.core.node_parser import ( + SentenceSplitter, + SemanticSplitterNodeParser, +) +from llama_parse import LlamaParse + +# Import from our modules +from utils import logger, log_structured +from config import ( + NETFLIX_DOCS_FOLDER, INDEX_PERSIST_PATH, IMAGES_DIRECTORY, + LLM_MODEL, EMBEDDING_MODEL, LLM_TEMPERATURE, LLM_TIMEOUT, AGENT_TIMEOUT, + TOOL_EXECUTION_TIMEOUT, SIMILARITY_TOP_K, SIMILARITY_CUTOFF, + LLAMA_PARSE_VENDOR_MODEL, LLAMA_PARSE_MAX_TIMEOUT, + NEO4J_URL, NEO4J_USERNAME, NEO4J_PASSWORD +) + +nest_asyncio.apply() + +# --- Global AI State --- +# Import shared state to ensure all modules access the same instances +from shared_state import ( + global_index, global_workflow_agent, + global_graph_store, global_property_graph_index, global_graphrag_query_engine, + set_global_agent, set_global_index, set_graphrag_components +) + + +# --- Token Counter --- +try: + token_counter = TokenCountingHandler( + tokenizer=tiktoken.encoding_for_model(LLM_MODEL).encode + ) +except Exception as e: + log_structured('warning', f'Could not initialize tiktoken for {LLM_MODEL}. Token counting may be inaccurate.', {'error': str(e)}) + # Fallback tokenizer if needed, or disable token counting + token_counter = TokenCountingHandler(tokenizer=lambda text: list(text.encode("utf-8"))) + + +# --- Custom ReAct Agent Workflow --- +class PrepEvent(Event): pass +class InputEvent(Event): input: list[ChatMessage] +class ToolCallEvent(Event): tool_calls: list[ToolSelection] +class FunctionOutputEvent(Event): output: ToolOutput +class CustomStartEvent(StartEvent): input_value: str = "" + + +class ReActAgent2(Workflow): + """ + Custom ReAct Agent implementation using LlamaIndex Workflows. + Includes timeout handling for LLM calls and tool execution. + """ + def __init__( + self, + llm: LLM, + tools: list[BaseTool], + memory: ChatMemoryBuffer, + timeout: float = AGENT_TIMEOUT, # Overall workflow timeout + llm_timeout: float = LLM_TIMEOUT, # Timeout for individual LLM calls + tool_timeout: float = TOOL_EXECUTION_TIMEOUT, # Timeout for individual tool calls + verbose: bool = True, # Add verbose flag + extra_context: str | None = None, + **kwargs: Any, + ) -> None: + super().__init__(timeout=timeout, verbose=verbose, **kwargs) # Pass verbose to parent + self.llm = llm + self.tools = tools or [] + self.memory = memory + self.formatter = ReActChatFormatter(context=extra_context or "") + self.output_parser = ReActOutputParser() + self.sources: List[ToolOutput] = [] # Store ToolOutput objects directly + self.llm_timeout = llm_timeout + self.tool_timeout = tool_timeout + self.verbose = verbose # Store verbose flag + + @step + async def new_user_msg(self, ctx: Context, ev: StartEvent) -> PrepEvent: + self.sources = [] # Clear sources for new message + # Store the initial input in the context + await ctx.set("user_input", ev) # Save the whole event + + # We'll set a placeholder message for now and extract the actual input in the next step + user_msg = ChatMessage(role="user", content="Placeholder") + self.memory.put(user_msg) + await ctx.set("current_reasoning", []) + if self.verbose: + log_structured('debug', 'ReActAgent: Receiving new user message', {}) + return PrepEvent() + + @step + async def prepare_chat_history(self, ctx: Context, ev: PrepEvent) -> InputEvent: + # Get the real user input from the first step and update the memory + start_event = await ctx.get("user_input") + if hasattr(start_event, "input_value"): + # Try to access input_value directly if it exists + real_user_input = start_event.input_value + else: + # Otherwise, assume the actual input is the input value provided to run() + log_structured('info', 'ReActAgent: Extracting input from workflow input', {}) + # Default to reasonable fallback if we can't extract it + real_user_input = "How can I help you with Netflix marketing guidelines?" + + # Update the placeholder message with the real input + messages = self.memory.get() + if messages and messages[0].role == "user" and messages[0].content == "Placeholder": + # Replace the placeholder with real input + messages[0].content = str(real_user_input) + if self.verbose: + log_structured('debug', 'ReActAgent: Updated user message with real input', {'input': real_user_input}) + + # Format the chat history for the LLM + chat_history = self.memory.get() + current_reasoning = await ctx.get("current_reasoning", default=[]) + llm_input = self.formatter.format( + self.tools, chat_history, current_reasoning=current_reasoning + ) + if self.verbose: + log_structured('debug', 'ReActAgent: Prepared chat history for LLM', {'history_len': len(llm_input)}) + return InputEvent(input=llm_input) + + @step # The timeout is managed inside the method + async def handle_llm_input(self, ctx: Context, ev: InputEvent) -> Union[ToolCallEvent, StopEvent, PrepEvent]: + chat_history = ev.input + current_reasoning = await ctx.get("current_reasoning", default=[]) + + try: + if self.verbose: + log_structured('debug', 'ReActAgent: Sending request to LLM', {'history_len': len(chat_history)}) + + response = await asyncio.wait_for( + self.llm.achat(chat_history), + timeout=self.llm_timeout + ) + reasoning_step = self.output_parser.parse(response.message.content) + current_reasoning.append(reasoning_step) + await ctx.set("current_reasoning", current_reasoning) # Update context state + + if self.verbose: + log_structured('debug', 'ReActAgent: Received LLM response', { + 'step_type': type(reasoning_step).__name__, + 'is_done': getattr(reasoning_step, 'is_done', False), + 'has_response': hasattr(reasoning_step, 'response'), + 'has_action': hasattr(reasoning_step, 'action'), + 'action': getattr(reasoning_step, 'action', None), + 'raw_content_preview': response.message.content[:300] + }) + + if reasoning_step.is_done: + # Log what we're about to return as final response + if self.verbose: + log_structured('debug', 'ReActAgent: Processing final response', { + 'raw_response_content': response.message.content, + 'reasoning_step_response': str(getattr(reasoning_step, 'response', 'NO_RESPONSE_ATTR')) + }) + + # Clean the response to remove any thinking parts + response_text = str(reasoning_step.response) + + # Check for common thinking patterns and remove them + import re # Import re within the function scope to ensure it's available + thinking_patterns = [ + r'(?i)^.*?thinking:.*?\n', # Remove lines starting with "Thinking:" + r'(?i).*?', # Remove XML-like thinking tags + r'(?i)\[thinking\].*?\[/thinking\]', # Remove bracket thinking tags + r'(?i)I\'m thinking:.*?\n', # Remove "I'm thinking:" sections + r'(?i)Let me think.*?\n', # Remove "Let me think" sections + r'(?i)Thought:.*?Action:.*?Action Input:.*', # Remove the specific pattern user reported + r'(?i)^Thought:.*', # Remove any line starting with "Thought:" + r'(?i)Action:.*?Action Input:.*', # Remove Action/Action Input patterns + r'(?i)Thought:.*?Answer:', # Remove "Thought: ... Answer:" pattern + r'(?i)^Answer:\s*' # Remove just the "Answer:" prefix + ] + for pattern in thinking_patterns: + response_text = re.sub(pattern, '', response_text, flags=re.DOTALL) + + # Remove extra newlines that might be left after cleaning + response_text = re.sub(r'\n{3,}', '\n\n', response_text) + response_text = response_text.strip() + + # Final safety check - if response still looks like thinking, provide fallback + if re.search(r'(?i)^(Thought|Action|Observation):', response_text) or not response_text: + log_structured('warning', 'ReActAgent: Final response still contains thinking patterns, using fallback', { + 'problematic_response': response_text[:200] + }) + response_text = "I found information about your query in the Netflix marketing materials. Please let me know if you need more specific details." + + self.memory.put(ChatMessage(role="assistant", content=response_text)) + if self.verbose: + log_structured('info', 'ReActAgent: Final response generated', { + 'response_preview': response_text[:100], + 'sources_count': len(self.sources) + }) + + # Structure the final output + final_result = { + "response": response_text, + "sources": self.sources, # Pass the collected ToolOutput objects + "reasoning": current_reasoning + } + return StopEvent(result=final_result) + + elif isinstance(reasoning_step, ActionReasoningStep): + if self.verbose: + log_structured('debug', 'ReActAgent: Action step identified', { + 'action': reasoning_step.action, + 'input': reasoning_step.action_input + }) + # Ensure action_input is a dict + action_input_dict = reasoning_step.action_input or {} + if not isinstance(action_input_dict, dict): + log_structured('warning', 'ReActAgent: action_input is not a dict, attempting to parse', {'raw_input': action_input_dict}) + try: + # Attempt basic parsing if it looks like JSON string + if isinstance(action_input_dict, str) and action_input_dict.strip().startswith('{'): + action_input_dict = json.loads(action_input_dict) + else: # Fallback: treat as a single 'query' arg if not dict/json + action_input_dict = {'query': action_input_dict} + except Exception as parse_err: + log_structured('error', 'ReActAgent: Failed to parse action_input', {'raw_input': action_input_dict, 'error': str(parse_err)}) + action_input_dict = {'query': str(action_input_dict)} # Safest fallback + + return ToolCallEvent(tool_calls=[ + ToolSelection( + tool_id="tool_" + reasoning_step.action.replace(" ", "_"), # Simple ID generation + tool_name=reasoning_step.action, + tool_kwargs=action_input_dict + ) + ]) + else: + # Handle other reasoning step types if necessary, or just proceed + if self.verbose: + log_structured('debug', 'ReActAgent: Non-action, non-done step encountered', {'step_type': type(reasoning_step).__name__}) + return PrepEvent() # Continue the loop + + except asyncio.TimeoutError: + error_msg = f"LLM call timed out after {self.llm_timeout} seconds." + log_structured('error', 'ReActAgent: LLM Timeout', {'timeout': self.llm_timeout}) + current_reasoning.append(ObservationReasoningStep(observation=error_msg)) + await ctx.set("current_reasoning", current_reasoning) + return PrepEvent() # Try again or maybe stop? Returning PrepEvent allows loop to continue. + except Exception as e: + error_msg = f"Error during LLM interaction or parsing: {str(e)}" + log_structured('error', 'ReActAgent: Error in handle_llm_input', { + 'error': str(e), 'traceback': traceback.format_exc() + }) + current_reasoning.append(ObservationReasoningStep(observation=error_msg)) + await ctx.set("current_reasoning", current_reasoning) + # Decide whether to stop or continue after error + # Returning PrepEvent allows the agent to potentially describe the error or retry + return PrepEvent() + + + @step # The timeout is managed inside the method + async def handle_tool_calls(self, ctx: Context, ev: ToolCallEvent) -> PrepEvent: + tool_calls = ev.tool_calls + tools_by_name = {tool.metadata.name: tool for tool in self.tools} # Use .name property + current_reasoning = await ctx.get("current_reasoning", default=[]) + + if self.verbose: + log_structured('debug', 'ReActAgent: Handling tool calls', { + 'call_count': len(tool_calls), + 'tool_names': [tc.tool_name for tc in tool_calls] + }) + + for tool_call in tool_calls: + tool = tools_by_name.get(tool_call.tool_name) + if not tool: + error_msg = f"Tool '{tool_call.tool_name}' not found." + log_structured('error', 'ReActAgent: Tool not found', {'tool_name': tool_call.tool_name}) + current_reasoning.append(ObservationReasoningStep(observation=error_msg)) + continue # Skip to next tool call + + try: + tool_kwargs = tool_call.tool_kwargs or {} # Ensure kwargs is a dict + if self.verbose: + log_structured('debug', 'ReActAgent: Executing tool', { + 'tool_name': tool_call.tool_name, + 'kwargs': tool_kwargs + }) + + # Execute tool potentially in thread pool with timeout + tool_func = partial(tool, **tool_kwargs) + tool_output: ToolOutput + if inspect.iscoroutinefunction(tool.call): # Check if tool's call method is async + tool_output = await asyncio.wait_for( + tool.acall(**tool_kwargs), # Use acall for async tools + timeout=self.tool_timeout + ) + else: + # Run sync tool in thread pool + with ThreadPoolExecutor() as executor: + future = executor.submit(tool_func) # tool_func already has kwargs via partial + tool_output = await asyncio.to_thread(future.result, timeout=self.tool_timeout) + + + if self.verbose: + log_structured('debug', 'ReActAgent: Tool execution successful', { + 'tool_name': tool_call.tool_name, + 'output_type': type(tool_output).__name__, + 'output_content_preview': str(tool_output.content)[:100] if tool_output else "N/A" + }) + + # Store the raw ToolOutput object which contains content, raw_output, metadata etc. + self.sources.append(tool_output) + + # Add observation step with the tool's string content + observation_content = str(tool_output.content) if tool_output and tool_output.content is not None else "Tool executed successfully but returned no content." + current_reasoning.append(ObservationReasoningStep(observation=observation_content)) + + if self.verbose: + log_structured('debug', 'ReActAgent: Added observation to reasoning', { + 'tool_name': tool_call.tool_name, + 'observation_preview': observation_content[:200] + }) + + # --- Detailed logging for image metadata --- + if hasattr(tool_output, 'raw_output') and hasattr(tool_output.raw_output, 'source_nodes'): + for node_with_score in tool_output.raw_output.source_nodes: + node = getattr(node_with_score, 'node', None) + if node and hasattr(node, 'metadata'): + if 'image_paths' in node.metadata and node.metadata['image_paths']: + log_structured('debug', 'ReActAgent: Tool output node contains image paths', { + 'tool_name': tool_call.tool_name, + 'node_id': getattr(node, 'id_', 'N/A'), + 'image_paths': node.metadata['image_paths'] + }) + + + except asyncio.TimeoutError: + error_msg = f"Tool '{tool_call.tool_name}' timed out after {self.tool_timeout} seconds." + log_structured('error', 'ReActAgent: Tool Timeout', {'tool_name': tool_call.tool_name, 'timeout': self.tool_timeout}) + current_reasoning.append(ObservationReasoningStep(observation=error_msg)) + except Exception as e: + error_msg = f"Error calling tool '{tool_call.tool_name}': {str(e)}" + log_structured('error', 'ReActAgent: Tool execution error', { + 'tool_name': tool_call.tool_name, 'error': str(e), 'traceback': traceback.format_exc() + }) + current_reasoning.append(ObservationReasoningStep(observation=error_msg)) + + # Update reasoning steps in context before returning + await ctx.set("current_reasoning", current_reasoning) + return PrepEvent() # Always return PrepEvent to continue the loop + + +# --- Document Processing --- +async def process_documents_in_directory(directory: str, session_id: Optional[str] = None) -> List[LlamaIndexDocument]: + """ + Process all documents in a directory using LlamaParse for text and images, + preserving chunk-level metadata including specific image paths per chunk. + Includes detailed logging for image metadata assignment. + + Args: + directory: The path to the directory containing documents. + session_id: Optional session identifier for context. + + Returns: + A list of LlamaIndexDocument objects representing the processed chunks, + ready for indexing. Returns an empty list on failure or if no documents + are processed. + """ + parser_text = None + parser_images = None + try: + # Ensure images directory exists + os.makedirs(IMAGES_DIRECTORY, exist_ok=True) + + log_structured('info', f'Starting document processing for directory: {directory}', { + 'session_id': session_id, + 'directory_name': os.path.basename(directory), + 'directory_exists': os.path.exists(directory), + 'files_present': os.listdir(directory) if os.path.exists(directory) else 'N/A' + }) + + if not os.path.exists(directory) or not os.path.isdir(directory): + log_structured('error', f'Directory not found or is not a directory: {directory}', {'session_id': session_id}) + return [] + if not os.listdir(directory): + log_structured('warning', f'Directory is empty, skipping: {directory}', {'session_id': session_id}) + return [] + + # --- LlamaParse Initialization --- + try: + # Separate clients recommended if making concurrent calls, but can share if sequential + custom_client_text = httpx.AsyncClient(timeout=LLAMA_PARSE_MAX_TIMEOUT) + custom_client_images = httpx.AsyncClient(timeout=LLAMA_PARSE_MAX_TIMEOUT) + + parser_text = LlamaParse( + result_type="markdown", + add_page_breaks=False, + system_prompt="Extract all content including text, tables, and formatting. Preserve structure.", + premium_mode=False, # Set based on your LlamaCloud plan + max_timeout=LLAMA_PARSE_MAX_TIMEOUT, + custom_client=custom_client_text, + verbose=True # Enable verbose logging for LlamaParse + ) + parser_images = LlamaParse( + result_type="markdown", # Less critical here, parsing for images primarily + add_page_breaks=False, + system_prompt="Generate page images of the document.", + use_vendor_multimodal_model=True, # Assuming you want LlamaParse's image gen + vendor_multimodal_model_name=LLAMA_PARSE_VENDOR_MODEL, + premium_mode=False, # Set based on your LlamaCloud plan + max_timeout=LLAMA_PARSE_MAX_TIMEOUT, + custom_client=custom_client_images, + verbose=True # Enable verbose logging for LlamaParse + ) + log_structured('info', 'Initialized LlamaParse (text & image capability)', {'session_id': session_id}) + except Exception as parser_err: + log_structured('error', f'Error initializing dual LlamaParse capability: {parser_err}. Falling back to text-only.', { + 'session_id': session_id, 'traceback': traceback.format_exc() + }) + if parser_text and hasattr(parser_text, 'aclose'): await parser_text.aclose() + if parser_images and hasattr(parser_images, 'aclose'): await parser_images.aclose() + parser_images = None # Disable image parsing + try: + # Ensure client is fresh for fallback + custom_client_text = httpx.AsyncClient(timeout=LLAMA_PARSE_MAX_TIMEOUT) + parser_text = LlamaParse( + result_type="markdown", + language="en", + add_page_breaks=False, + merge_consecutive=True, + max_timeout=LLAMA_PARSE_MAX_TIMEOUT, + custom_client=custom_client_text, + verbose=True + ) + log_structured('info', 'Initialized LlamaParse with fallback text-only parser.', {'session_id': session_id}) + except Exception as fallback_err: + log_structured('critical', f'FATAL: Error initializing fallback LlamaParse: {fallback_err}', { + 'session_id': session_id, 'traceback': traceback.format_exc() + }) + if parser_text and hasattr(parser_text, 'aclose'): await parser_text.aclose() # Cleanup + return [] + + if not parser_text: + log_structured('critical', 'FATAL: Text parser (parser_text) could not be initialized.') + return [] + + file_extractor = { + ".pdf": parser_text, ".docx": parser_text, ".doc": parser_text, + ".pptx": parser_text, ".ppt": parser_text, ".txt": parser_text, + # Add others if your LlamaParse supports them + # ".html": parser_text, ".md": parser_text, + } + supported_extensions = list(file_extractor.keys()) + + # --- Custom File Reader Async Function --- + async def custom_file_reader(): + processed_chunks: List[LlamaIndexDocument] = [] + images_by_file_and_page: Dict[str, Dict[int, str]] = {} # filename -> { page_num_1_based -> image_filename } + + all_files_paths = [] + log_structured('debug', f'Searching for supported files in: {directory}', {'extensions': supported_extensions, 'session_id': session_id}) + for ext in supported_extensions: + all_files_paths.extend(list(Path(directory).glob(f"*{ext}"))) + all_files_paths.extend(list(Path(directory).glob(f"*{ext.upper()}"))) # Case-insensitive glob + all_files_paths = sorted(list(set(all_files_paths))) # Remove duplicates and sort + + log_structured('info', f'Found {len(all_files_paths)} supported files in {directory}', {'session_id': session_id, 'files': [f.name for f in all_files_paths]}) + if not all_files_paths: + log_structured('warning', f'No supported files found in directory: {directory}', {'session_id': session_id}) + return [] + + for file_path in all_files_paths: + file_path_str = str(file_path) + filename = file_path.name + log_structured('info', f'Processing file: {filename}', {'session_id': session_id, 'path': file_path_str}) + + ext = file_path.suffix.lower() + current_text_parser = file_extractor.get(ext) + if not current_text_parser: + log_structured('warning', f'Skipping file {filename}: No parser defined for extension {ext}.', {'session_id': session_id}) + continue + + # 1. Extract Text Chunks + text_chunks: List[LlamaIndexDocument] = [] + try: + log_structured('debug', f'Calling parser_text.aload_data for {filename}', {'session_id': session_id}) + # LlamaParse aload_data returns List[Document] + text_chunks = await current_text_parser.aload_data(file_path_str) + log_structured('info', f'Extracted {len(text_chunks)} text chunks from {filename}', {'session_id': session_id}) + if not text_chunks: + log_structured('warning', f'No text chunks extracted from {filename}. File might be empty or parser issue.', {'session_id': session_id}) + # Continue to attempt image extraction for this file + except Exception as text_err: + log_structured('error', f'Error extracting text from {filename}', { + 'session_id': session_id, 'error': str(text_err), 'traceback': traceback.format_exc() + }) + continue # Skip to next file if text extraction fails critically + + # 2. Extract Images (if parser_images is available) + file_page_images: Dict[int, str] = {} # { page_num_1_based: image_filename } + if parser_images: + try: + log_structured('debug', f'Attempting image extraction for: {filename}', {'session_id': session_id}) + temp_image_dir = IMAGES_DIRECTORY / f"temp_img_{filename}_{uuid.uuid4().hex[:8]}" + os.makedirs(temp_image_dir, exist_ok=True) + + # Use synchronous methods for simplicity here, or manage async complexity + # NOTE: Check LlamaParse documentation for the best way to get images. + # This assumes get_json_result and get_images are available and work this way. + # If these are async, you'll need `await parser_images.aget_json_result(...)` etc. + # Running sync methods in executor to avoid blocking event loop + loop = asyncio.get_running_loop() + with ThreadPoolExecutor() as pool: + md_json_objs = await loop.run_in_executor(pool, parser_images.get_json_result, file_path_str) + log_structured('debug', f'Got JSON result for images from {filename}', {'session_id': session_id}) + image_dicts = await loop.run_in_executor(pool, parser_images.get_images, md_json_objs, temp_image_dir) + + log_structured('info', f'LlamaParse reported {len(image_dicts)} images potentially extracted for {filename}', {'session_id': session_id}) + + saved_image_count = 0 + for idx, img_info in enumerate(image_dicts): + # LlamaParse might return 0-based 'page' index + page_index_0_based = img_info.get('page', idx) # Default to list index if 'page' missing + page_num_1_based = page_index_0_based + 1 # Convert to 1-based for consistency + + # log_structured('debug', f'Processing image info for {filename}, index {idx}', {'img_info': img_info, 'page_1_based': page_num_1_based}) + + if 'path' in img_info and img_info['path'] and os.path.exists(img_info['path']): + source_img_path = img_info['path'] + image_filename = f"{os.path.splitext(filename)[0]}_page{page_num_1_based}_{uuid.uuid4().hex[:6]}.png" + dest_path = IMAGES_DIRECTORY / image_filename + + try: + shutil.copy2(source_img_path, dest_path) # copy2 preserves metadata + file_page_images[page_num_1_based] = image_filename # Map 1-based page num + saved_image_count += 1 + log_structured('debug', f'Saved image for {filename} page {page_num_1_based} (1-based) to {dest_path}', {'session_id': session_id}) + except Exception as copy_err: + log_structured('error', f'Error copying image for {filename} page {page_num_1_based}', {'session_id': session_id, 'error': str(copy_err)}) + else: + log_structured('warning', f'No valid image path found or file missing for image index {idx} (reported page {page_index_0_based}) from {filename}. Path: {img_info.get("path", "N/A")}', {'session_id': session_id}) + + log_structured('info', f'Successfully saved {saved_image_count} images for {filename}', {'session_id': session_id}) + images_by_file_and_page[filename] = file_page_images + + # Clean up temporary directory + try: + shutil.rmtree(temp_image_dir) + except Exception as cleanup_err: + log_structured('error', f'Error cleaning up temp image dir {temp_image_dir}', {'session_id': session_id, 'error': str(cleanup_err)}) + + except Exception as img_err: + log_structured('error', f'Error during image extraction pipeline for {filename}', { + 'session_id': session_id, 'error': str(img_err), 'traceback': traceback.format_exc() + }) + images_by_file_and_page[filename] = {} # Ensure empty dict on error + else: + log_structured('info', f'Image parsing skipped for {filename} (parser_images is None).', {'session_id': session_id}) + images_by_file_and_page[filename] = {} + + # 3. Add Metadata to Each Text Chunk + log_structured('debug', f'Assigning metadata and images to {len(text_chunks)} chunks for {filename}', {'session_id': session_id}) + for chunk_index, chunk in enumerate(text_chunks): + if not hasattr(chunk, 'metadata') or chunk.metadata is None: chunk.metadata = {} + + chunk.metadata['filename'] = filename + chunk.metadata['file_path'] = file_path_str + chunk.metadata['source'] = "document" # Or more specific if needed + chunk.metadata['type'] = os.path.basename(directory) # e.g., 'brief', 'supporting' + chunk.metadata['chunk_index'] = chunk_index + + # --- Determine 1-based Page Number --- + page_num_1_based: Optional[int] = None + page_source_key: Optional[str] = None + + # Priority: LlamaParse metadata keys + page_key_options = ['page_label', 'page_number', 'page'] # Check common keys + for key in page_key_options: + if key in chunk.metadata: + try: + page_num_1_based = int(chunk.metadata[key]) # Assume it's 1-based from LlamaParse + if page_num_1_based > 0: # Basic sanity check + page_source_key = key + chunk.metadata["source_page"] = page_num_1_based # Store consistently + log_structured('debug', f"Found page {page_num_1_based} from metadata key '{key}'", {'filename': filename, 'chunk': chunk_index, 'session_id': session_id}) + break + else: + page_num_1_based = None # Ignore non-positive page numbers + except (ValueError, TypeError): pass # Ignore if not integer + + # Fallback: If no page found in metadata, try approximation (less reliable) + # This part is optional and might be inaccurate. Consider removing if metadata is reliable. + if page_num_1_based is None: + # Simple fallback: use chunk index as a proxy (very rough) + page_num_1_based = chunk_index + 1 # Treat first chunk as page 1 approx. + chunk.metadata["source_page"] = page_num_1_based + page_source_key = "position_fallback" + log_structured('debug', f"Using position fallback for page: {page_num_1_based}", {'filename': filename, 'chunk': chunk_index, 'session_id': session_id}) + + + # --- Precise Page-Based Image Assignment --- + chunk.metadata['image_paths'] = [] # Initialize/reset + specific_image_filename = None + + if page_num_1_based is not None: + file_images_dict = images_by_file_and_page.get(filename, {}) + if file_images_dict: + # Try exact match first + if page_num_1_based in file_images_dict: + specific_image_filename = file_images_dict[page_num_1_based] + chunk.metadata['image_match_type'] = 'exact' + log_structured('debug', f'Exact page match for image page {page_num_1_based}', {'filename': filename, 'chunk': chunk_index, 'image': specific_image_filename}) + else: + # Optional: Find nearest page if exact match fails (can be noisy) + # nearest_page = min(file_images_dict.keys(), key=lambda x: abs(x - page_num_1_based)) + # if abs(nearest_page - page_num_1_based) <= 1: # Only if very close? Threshold needed. + # specific_image_filename = file_images_dict[nearest_page] + # chunk.metadata['image_match_type'] = 'nearest' + # chunk.metadata["image_nearest_page"] = nearest_page + # log_structured('debug', f'Nearest page match: requested {page_num_1_based}, using {nearest_page}', {'filename': filename, 'chunk': chunk_index, 'image': specific_image_filename}) + pass # Default: No image if no exact match + + + if specific_image_filename: + chunk.metadata['image_paths'] = [specific_image_filename] # Store as list + + # Final metadata log before adding chunk + # log_structured('debug', "Final chunk metadata check", { + # 'session_id': session_id, 'filename': filename, 'chunk': chunk_index, + # 'page': chunk.metadata.get('source_page'), 'page_src': page_source_key, + # 'image': chunk.metadata.get('image_paths'), 'match_type': chunk.metadata.get('image_match_type'), + # 'keys': list(chunk.metadata.keys()) + # }) + + processed_chunks.append(chunk) + + # --- End of file loop --- + log_structured('info', f'Finished processing files. Total chunks: {len(processed_chunks)}', {'session_id': session_id}) + return processed_chunks + + # --- Execute the async file reader --- + final_documents: List[LlamaIndexDocument] = await custom_file_reader() + + log_structured('info', 'Directory processing complete.', { + 'session_id': session_id, 'directory': directory, 'total_chunks_generated': len(final_documents) + }) + # Log sample metadata for verification + # for i, doc in enumerate(final_documents[:2]): + # log_structured('debug', f'Final Doc Sample #{i} Metadata', {'metadata': doc.metadata}) + + return final_documents + + except Exception as e: + log_structured('critical', 'FATAL Error in process_documents_in_directory', { + 'session_id': session_id, 'directory': directory, 'error': str(e), 'traceback': traceback.format_exc() + }) + return [] # Return empty list on major failure + finally: + # Ensure async clients are closed + log_structured('debug', 'Closing LlamaParse clients.', {'session_id': session_id}) + if parser_text and hasattr(parser_text, 'aclose'): + try: await parser_text.aclose() + except Exception as close_err: log_structured('error', 'Error closing parser_text client', {'error': str(close_err)}) + if parser_images and hasattr(parser_images, 'aclose'): + try: await parser_images.aclose() + except Exception as close_err: log_structured('error', 'Error closing parser_images client', {'error': str(close_err)}) + + +# --- Global Index Initialization --- +async def initialize_global_index() -> bool: + """Initialize the global index from Netflix documents at startup.""" + # Use shared state instead of module-level globals + + try: + # --- Configure LLM and Embedding Model --- + # Check for real API keys + openai_key = os.environ.get("OPENAI_API_KEY", "") + if not openai_key: + log_structured('critical', 'No OpenAI API key provided. Make sure OPENAI_API_KEY is set in your .env file.') + return False + + # Log the first few characters of the key for debugging (important to verify it's loaded) + key_preview = openai_key[:4] + "..." if len(openai_key) > 4 else "invalid" + log_structured('info', f'Using OpenAI API key starting with: {key_preview}') + + # Normal initialization with real API keys + try: + llm = LlamaOpenAI( + model=LLM_MODEL, + temperature=LLM_TEMPERATURE, + timeout=LLM_TIMEOUT + ) + # Test the LLM with a simple prompt to ensure it's working + test_prompt = "Say 'API key is working' if you can read this." + _ = llm.complete(test_prompt) + log_structured('info', 'Successfully tested LLM API connection') + except Exception as llm_err: + log_structured('critical', f'Failed to initialize LLM with provided API key: {str(llm_err)}') + return False + embed_model = OpenAIEmbedding(model=EMBEDDING_MODEL) + + # --- Configure Global Settings --- + Settings.llm = llm + Settings.embed_model = embed_model + Settings.callback_manager = CallbackManager([token_counter]) + # Settings.chunk_size = NODE_PARSER_CHUNK_SIZE # If using SentenceSplitter + # Settings.chunk_overlap = NODE_PARSER_CHUNK_OVERLAP + + # Use Semantic Splitter (more robust, less config needed here) + node_parser = SemanticSplitterNodeParser( + buffer_size=1, breakpoint_percentile_threshold=95, embed_model=embed_model # Adjust threshold + ) + Settings.node_parser = node_parser + # Alternatively, configure SentenceSplitter globally + # Settings.node_parser = SentenceSplitter( + # chunk_size=NODE_PARSER_CHUNK_SIZE, + # chunk_overlap=NODE_PARSER_CHUNK_OVERLAP, + # # ... other SentenceSplitter params + # ) + + # --- Load or Build Index --- + if INDEX_PERSIST_PATH.exists(): + log_structured('info', f'Loading existing index from {INDEX_PERSIST_PATH}') + storage_context = StorageContext.from_defaults(persist_dir=str(INDEX_PERSIST_PATH)) + index = load_index_from_storage(storage_context) + # Save to shared state + set_global_index(index) + log_structured('info', 'Successfully loaded existing index') + + # Attempt to load or recreate GraphRAG components + try: + log_structured('info', 'Attempting to recreate GraphRAG components from loaded index') + + # First, try to connect to Neo4j to check if it has data + from llama_index.graph_stores.neo4j import Neo4jPropertyGraphStore + property_graph_store = Neo4jPropertyGraphStore( + username=NEO4J_USERNAME, + password=NEO4J_PASSWORD, + url=NEO4J_URL + ) + log_structured('info', 'Successfully connected to Neo4j database') + + # Create temporary GraphRAGStore to check for existing data + temp_graph_store = GraphRAGStore(property_graph_store) + triplets = temp_graph_store.get_triplets() + neo4j_has_data = len(triplets) > 0 + + if neo4j_has_data: + # Neo4j already has data, just use it + log_structured('info', f'Neo4j contains {len(triplets)} triplets. Using existing data.') + graph_store, property_graph_index = create_graph_components( + llm=llm, + force_reindex=False # Use existing Neo4j data + ) + log_structured('info', 'GraphRAG components loaded from existing Neo4j data') + else: + # Neo4j is empty, need to extract nodes from the vector index to populate it + log_structured('info', 'Neo4j is empty. Extracting nodes from vector index for GraphRAG indexing.') + + # Get all nodes from the vector index + from llama_index.core.schema import TextNode + vector_nodes = [] + + # Extract nodes from the index's docstore + if hasattr(index, 'docstore') and index.docstore: + docstore_nodes = list(index.docstore.docs.values()) + vector_nodes.extend(docstore_nodes) + log_structured('info', f'Retrieved {len(docstore_nodes)} nodes from index docstore') + + # Handle cases where we can't get nodes directly + if not vector_nodes: + log_structured('warning', 'Could not retrieve nodes from vector index. GraphRAG indexing will be skipped.') + raise ValueError("No nodes could be retrieved from the vector index for GraphRAG indexing") + + # Now create GraphRAG components with the retrieved nodes + graph_store, property_graph_index = create_graph_components( + llm=llm, + nodes=vector_nodes, + max_paths_per_chunk=10, + force_reindex=True # Force indexing since Neo4j is empty + ) + log_structured('info', f'GraphRAG components created with {len(vector_nodes)} nodes from vector index') + except Exception as e: + log_structured('warning', f'Error recreating GraphRAG components: {e}. Continuing without GraphRAG.') + graph_store = None + else: + log_structured('info', f'No index found at {INDEX_PERSIST_PATH}. Creating new index from {NETFLIX_DOCS_FOLDER}') + if not NETFLIX_DOCS_FOLDER.exists() or not any(NETFLIX_DOCS_FOLDER.iterdir()): + log_structured('error', f'Netflix documents folder is missing or empty: {NETFLIX_DOCS_FOLDER}') + return False + + # Process documents using LlamaParse + documents = await process_documents_in_directory(str(NETFLIX_DOCS_FOLDER), session_id="global_init") + + if not documents: + log_structured('error', f'No documents processed from {NETFLIX_DOCS_FOLDER}. Index creation aborted.') + return False + + log_structured('info', 'Creating vector store index...', { + 'document_count': len(documents), + 'sample_doc_metadata': documents[0].metadata if documents else None + }) + + # Build the index using the globally configured Settings (incl. node_parser) + index = VectorStoreIndex.from_documents( + documents=documents, + show_progress=True, + # service_context=service_context, # Old way, use Settings now + ) + + # Save to shared state + set_global_index(index) + + # Persist the index + index.storage_context.persist(persist_dir=str(INDEX_PERSIST_PATH)) + log_structured('info', f'Index created and persisted to {INDEX_PERSIST_PATH}') + + # --- Create GraphRAG components --- + log_structured('info', 'Starting GraphRAG component creation') + try: + # Get nodes from the index's docstore for GraphRAG + docstore_nodes = [] + if hasattr(index, 'docstore') and index.docstore: + docstore_nodes = list(index.docstore.docs.values()) + log_structured('info', f'Retrieved {len(docstore_nodes)} nodes from index docstore for GraphRAG') + + # If docstore is empty or doesn't exist, use the original documents + nodes_for_graph = docstore_nodes if docstore_nodes else documents + log_structured('info', f'Using {len(nodes_for_graph)} nodes for GraphRAG indexing') + + # Create GraphRAG components + graph_store, property_graph_index = create_graph_components( + llm=llm, + nodes=nodes_for_graph, + max_paths_per_chunk=10, + force_reindex=True # Force indexing for new index creation + ) + log_structured('info', 'GraphRAG components created successfully') + except Exception as graph_err: + log_structured('error', f'Error creating GraphRAG components: {graph_err}', + {'traceback': traceback.format_exc()}) + # Continue without GraphRAG if there's an error + + # --- Create Retriever and Query Engine --- + vector_store_info = VectorStoreInfo( + content_info="Netflix marketing reference materials, including GPD Key Art Playbook and supporting documents.", + metadata_info=[ + MetadataInfo(name="filename", type="str", description="Filename of the source document"), + MetadataInfo(name="source_page", type="int", description="Approximate page number in the source document"), + MetadataInfo(name="image_paths", type="list[str]", description="List of image filenames associated with this chunk"), + # Add other relevant metadata fields here + ], + ) + + all_retriever = VectorIndexAutoRetriever( + index=index, # Use the local variable, not global_index + vector_store_info=vector_store_info, + similarity_top_k=SIMILARITY_TOP_K, + verbose=True # Enable verbose logging for retriever + ) + + # Create standard vector-based query engine + all_query_engine = RetrieverQueryEngine.from_args( # Use from_args for clarity + retriever=all_retriever, + response_synthesizer=get_response_synthesizer( + response_mode=ResponseMode.COMPACT, # Or REFINE, TREE_SUMMARIZE etc. + # service_context=service_context # Old way + ), + node_postprocessors=[ + SimilarityPostprocessor(similarity_cutoff=SIMILARITY_CUTOFF), + ], + # service_context=service_context # Old way + ) + + # Create GraphRAG query engine if components were created successfully + graphrag_query_engine = None + graph_store = locals().get('graph_store', None) + property_graph_index = locals().get('property_graph_index', None) + if graph_store is not None and property_graph_index is not None: + try: + # Ensure graph communities are built before creating query engine + if not hasattr(graph_store, 'communities_built') or not graph_store.communities_built: + log_structured('info', 'Building graph communities before creating query engine') + try: + # Use gpt-4o-mini model for community summaries (set in GraphRAGStore) + # The build_communities() method will first try to load from cache + # and will only rebuild and re-cache if cache loading fails + # It also tracks if communities are already built to avoid duplicate work + graph_store.build_communities() + log_structured('info', 'Communities built successfully (loaded from cache or built new)') + except Exception as comm_err: + log_structured('error', f'Error building communities: {comm_err}', + {'traceback': traceback.format_exc()}) + # Continue with query engine creation even if communities failed + + # Create a basic VectorIndexRetriever for GraphRAG + vector_retriever = VectorIndexRetriever( + index=index, + similarity_top_k=SIMILARITY_TOP_K + ) + + # Create the GraphRAG query engine - ensure all required fields are passed directly + try: + graphrag_query_engine = create_graphrag_query_engine( + vector_retriever=vector_retriever, + graph_store=graph_store, + llm=llm, + similarity_top_k=SIMILARITY_TOP_K + ) + log_structured('info', 'GraphRAG query engine created successfully') + except Exception as e: + log_structured('error', f'Error creating GraphRAG query engine: {e}', {'traceback': traceback.format_exc()}) + # Create a direct instance without using the factory function as a fallback + from graph_rag_integration import GraphRAGQueryEngine + graphrag_query_engine = GraphRAGQueryEngine( + vector_retriever=vector_retriever, + graph_store=graph_store, + llm=llm, + similarity_top_k=SIMILARITY_TOP_K + ) + + # Store GraphRAG components in shared state + set_graphrag_components( + graph_store=graph_store, + property_graph_index=property_graph_index, + graphrag_query_engine=graphrag_query_engine + ) + + log_structured('info', 'GraphRAG query engine created successfully') + except Exception as graph_engine_err: + log_structured('error', f'Error creating GraphRAG query engine: {graph_engine_err}', + {'traceback': traceback.format_exc()}) + # Continue without GraphRAG query engine if there's an error + + # --- Create Query Engine Tools --- + query_engine_tools_react = [ + QueryEngineTool( + query_engine=all_query_engine, + metadata=ToolMetadata( + name="answer_questions_from_netflix_marketing_materials", + description="USE THIS TOOL FOR ALL QUERIES - Queries Netflix marketing materials (GPD Key Art Playbook, etc.) to answer questions about guidelines, workflows, and processes. Use this for specific lookups in the knowledge base." + ), + ), + ] + + # Add GraphRAG tool if available + if graphrag_query_engine is not None: + class GraphRAGTool(BaseTool): + def __init__(self, query_engine): + self.query_engine = query_engine + self._metadata = ToolMetadata( + name="answerquestionswith_graphrag", + description="USE THIS TOOL FOR ALL QUERIES - Queries Netflix marketing materials using both vector and graph-based retrieval for more comprehensive answers. Use this for complex questions that need context from multiple related documents." + ) + + @property + def metadata(self): + return self._metadata + + def __call__(self, query_str: str) -> ToolOutput: + """Run query through GraphRAG and generate synthesized answer.""" + from shared_state import global_graphrag_query_engine + + log_structured('info', 'GraphRAG Tool: Starting dual retrieval', {'query': query_str}) + + try: + # Get both vector and GraphRAG retrieval results + retrieval_result = self.query_engine.custom_query(query_str) + + # Generate synthesized answer + final_answer = generate_final_answer(query_str, retrieval_result, self.query_engine.llm) + + # Prepare the tool output + log_message = { + 'vector_context_length': len(retrieval_result.get('vector_context', '')), + 'graphrag_context_length': len(retrieval_result.get('graphrag_context', '')), + 'vector_nodes_count': len(retrieval_result.get('vector_nodes', [])), + 'community_ids': retrieval_result.get('community_ids', []) + } + log_structured('info', 'GraphRAG Tool: Retrieval complete', log_message) + + # Extract vector nodes and create a properly structured raw_output that includes source_nodes + # This allows the routes.py code to extract images from these nodes + vector_nodes = retrieval_result.get('vector_nodes', []) + + # Create a wrapper object that mimics the structure expected by routes.py for image extraction + class NodeWrapper: + def __init__(self, nodes): + self.source_nodes = nodes + + # Preserve the original retrieval_result but add the source_nodes in the expected format + modified_raw_output = retrieval_result.copy() + modified_raw_output['source_nodes'] = vector_nodes + + # Add a source_nodes property that routes.py will look for + tool_output = ToolOutput( + content=final_answer, + tool_name="GraphRAG", + raw_output=NodeWrapper(vector_nodes), + raw_input={"query": query_str}, + metadata={ + 'source': 'graphrag', + 'retrieval_stats': log_message, + 'original_result': modified_raw_output # Store the original result too + } + ) + + log_structured('debug', 'GraphRAG Tool: Including image metadata in response', + {'node_count': len(vector_nodes)}) + + return tool_output + except Exception as graphrag_err: + log_structured('error', f'Error in GraphRAG tool: {graphrag_err}', + {'traceback': traceback.format_exc()}) + return ToolOutput( + content=f"I encountered an error while retrieving information: {str(graphrag_err)}. Please try again or use the standard query tool.", + tool_name="GraphRAG", + raw_input={"query": query_str}, + raw_output={"error": str(graphrag_err)}, + metadata={'error': str(graphrag_err)} + ) + + async def acall(self, input: str) -> ToolOutput: + """Async version of __call__.""" + return self.__call__(input) + + # Create the GraphRAG tool instance and add it to tools + graphrag_tool = GraphRAGTool(graphrag_query_engine) + query_engine_tools_react.append(graphrag_tool) + log_structured('info', 'Added GraphRAG tool to query engine tools') + + # --- Initialize Global Workflow Agent --- + # We're now using shared_state rather than module globals + + try: + # Create the agent instance + agent = ReActAgent2( + llm=llm, # Use the LLM configured via Settings + tools=query_engine_tools_react, + memory=ChatMemoryBuffer.from_defaults(llm=llm, token_limit=4096), # Give memory its own LLM ref + verbose=True, # Enable agent verbose logging + timeout=AGENT_TIMEOUT, + llm_timeout=LLM_TIMEOUT, + tool_timeout=TOOL_EXECUTION_TIMEOUT + ) + + # Store in shared state + set_global_agent(agent) + + log_structured('info', 'Agent initialized successfully') + except Exception as agent_err: + log_structured('critical', f'Failed to initialize agent: {str(agent_err)}', {'error': str(agent_err)}) + return False # Signal failure + + try: + # Define a simpler version of the run method + async def simple_run(query_text): + """Simple version that doesn't rely on complex workflow steps.""" + # Import from shared state to ensure we use the current global agent + from shared_state import global_workflow_agent + + if not global_workflow_agent: + log_structured('critical', 'Agent is None in simple_run - this should never happen') + return { + "response": "The AI system is currently unavailable. Please try again later.", + "sources": [], + "reasoning": [] + } + + try: + # Don't reset memory - we want to preserve conversation context between requests + # Only reset sources to track new sources for this specific response + global_workflow_agent.sources = [] + + # 1. Add the user query to memory + user_msg = ChatMessage(role="user", content=str(query_text)) + global_workflow_agent.memory.put(user_msg) + + # 2. Format the chat history + chat_history = global_workflow_agent.memory.get() + llm_input = global_workflow_agent.formatter.format(global_workflow_agent.tools, chat_history) + + # 3. Get LLM response + response = await asyncio.wait_for( + global_workflow_agent.llm.achat(llm_input), + timeout=global_workflow_agent.llm_timeout + ) + reasoning_step = global_workflow_agent.output_parser.parse(response.message.content) + + # Add detailed logging + log_structured('debug', 'Parsed reasoning step', { + 'step_type': type(reasoning_step).__name__, + 'has_response': hasattr(reasoning_step, 'response'), + 'has_action': hasattr(reasoning_step, 'action'), + 'action': getattr(reasoning_step, 'action', None), + 'action_input': getattr(reasoning_step, 'action_input', None), + 'raw_content': response.message.content[:200] # First 200 chars for debugging + }) + + # 4. Process the response (simplified) + # Force tool usage for all queries - don't allow direct responses + if hasattr(reasoning_step, 'response') and reasoning_step.response: + # If LLM gave a direct response, we need to force tool usage instead + log_structured('debug', 'LLM provided direct response, forcing tool usage for image retrieval') + + # Force tool execution by calling GraphRAG directly + graphrag_tool = next((t for t in global_workflow_agent.tools if 'graphrag' in t.metadata.name.lower()), None) + if graphrag_tool: + try: + tool_output = await asyncio.wait_for( + graphrag_tool.acall(input=str(query_text)), + timeout=global_workflow_agent.tool_timeout + ) + global_workflow_agent.sources.append(tool_output) + + # Use the tool's response instead of the direct LLM response + response_text = str(tool_output.content) if tool_output and tool_output.content else str(reasoning_step.response) + + assistant_msg = ChatMessage(role="assistant", content=response_text) + global_workflow_agent.memory.put(assistant_msg) + return { + "response": response_text, + "sources": global_workflow_agent.sources, + "reasoning": [reasoning_step] + } + except Exception as e: + log_structured('error', f'Error forcing GraphRAG tool execution: {str(e)}') + # Fall back to direct response if tool fails + + # Fallback to cleaning the direct response + response_text = str(reasoning_step.response) + + # Check for common thinking patterns and remove them + import re # Import re within the function scope to ensure it's available + thinking_patterns = [ + r'(?i)^.*?thinking:.*?\n', # Remove lines starting with "Thinking:" + r'(?i).*?', # Remove XML-like thinking tags + r'(?i)\[thinking\].*?\[/thinking\]', # Remove bracket thinking tags + r'(?i)I\'m thinking:.*?\n', # Remove "I'm thinking:" sections + r'(?i)Let me think.*?\n', # Remove "Let me think" sections + r'(?i)Thought:.*?Answer:', # Remove "Thought: ... Answer:" pattern + r'(?i)^Answer:\s*' # Remove just the "Answer:" prefix + ] + for pattern in thinking_patterns: + response_text = re.sub(pattern, '', response_text, flags=re.DOTALL) + + # Remove extra newlines that might be left after cleaning + response_text = re.sub(r'\n{3,}', '\n\n', response_text) + response_text = response_text.strip() + + assistant_msg = ChatMessage(role="assistant", content=response_text) + global_workflow_agent.memory.put(assistant_msg) + return { + "response": response_text, + "sources": global_workflow_agent.sources, + "reasoning": [reasoning_step] + } + else: + # Handle tool calls if the response indicates an action + if hasattr(reasoning_step, 'action') and reasoning_step.action: + tool_name = reasoning_step.action + action_input = reasoning_step.action_input or {} + + # Convert to dict if needed + if not isinstance(action_input, dict): + try: + if isinstance(action_input, str) and action_input.strip().startswith('{'): + action_input = json.loads(action_input) + else: + action_input = {'query': action_input} + except: + action_input = {'query': str(action_input)} + + # Find the tool + tool = next((t for t in global_workflow_agent.tools if t.metadata.name == tool_name), None) + if not tool: + error_msg = f"Tool '{tool_name}' not found. Available tools: {[t.metadata.name for t in global_workflow_agent.tools]}" + log_structured('error', 'Tool not found in simple_run', {'tool_name': tool_name, 'available_tools': [t.metadata.name for t in global_workflow_agent.tools]}) + return { + "response": f"I tried to use a tool called '{tool_name}' but it's not available. Please try rephrasing your query.", + "sources": [], + "reasoning": [reasoning_step] + } + if tool: + try: + # Execute the tool + tool_output = await asyncio.wait_for( + tool.acall(**action_input), + timeout=global_workflow_agent.tool_timeout + ) + global_workflow_agent.sources.append(tool_output) + + # Get a final response + observation = str(tool_output.content if tool_output and tool_output.content is not None else "No content") + follow_up_msg = ChatMessage(role="user", content=f"Here is the result: {observation}\\nPlease provide a final response based on this information.") + global_workflow_agent.memory.put(follow_up_msg) + + # Call LLM again for final response + chat_history = global_workflow_agent.memory.get() + llm_input = global_workflow_agent.formatter.format(global_workflow_agent.tools, chat_history) + final_response = await asyncio.wait_for( + global_workflow_agent.llm.achat(llm_input), + timeout=global_workflow_agent.llm_timeout + ) + + # Store and return + # Clean the response to remove any thinking parts + response_text = str(final_response.message.content) + + # Check for common thinking patterns and remove them + import re # Import re within the function scope to ensure it's available + thinking_patterns = [ + r'(?i)^.*?thinking:.*?\n', # Remove lines starting with "Thinking:" + r'(?i).*?', # Remove XML-like thinking tags + r'(?i)\[thinking\].*?\[/thinking\]', # Remove bracket thinking tags + r'(?i)I\'m thinking:.*?\n', # Remove "I'm thinking:" sections + r'(?i)Let me think.*?\n', # Remove "Let me think" sections + r'(?i)Thought:.*?Answer:', # Remove "Thought: ... Answer:" pattern + r'(?i)^Answer:\s*' # Remove just the "Answer:" prefix + ] + for pattern in thinking_patterns: + response_text = re.sub(pattern, '', response_text, flags=re.DOTALL) + + # Remove extra newlines that might be left after cleaning + response_text = re.sub(r'\n{3,}', '\n\n', response_text) + response_text = response_text.strip() + + global_workflow_agent.memory.put(ChatMessage(role="assistant", content=response_text)) + return { + "response": response_text, + "sources": global_workflow_agent.sources, + "reasoning": [reasoning_step] + } + except Exception as e: + log_structured('error', f'Error executing tool: {str(e)}', {'traceback': traceback.format_exc()}) + # Error message is already clean without thinking + error_response = f"I encountered an error while processing your query: {str(e)}" + return { + "response": error_response, + "sources": [], + "reasoning": [reasoning_step] + } + + # Enhanced fallback with thinking detection + if isinstance(reasoning_step, ActionReasoningStep): + # If we reach here, it means we had an action but couldn't execute it + thinking_response = f"Thought: {getattr(reasoning_step, 'thought', '')} Action: {reasoning_step.action} Action Input: {reasoning_step.action_input}" + log_structured('warning', 'Returning raw thinking due to failed action execution', { + 'action': reasoning_step.action, + 'action_input': reasoning_step.action_input + }) + return { + "response": "I apologize, but I encountered an issue while processing your query. Please try asking your question in a different way.", + "sources": [], + "reasoning": [reasoning_step] + } + else: + # Original fallback for other types + fallback_response = "I wasn't able to find a specific answer to your question. Please try rephrasing your query." + return { + "response": fallback_response, + "sources": global_workflow_agent.sources, + "reasoning": [reasoning_step] if reasoning_step else [] + } + except Exception as e: + log_structured('error', f'Error in simple_run: {str(e)}', {'traceback': traceback.format_exc()}) + # Error response is already clean + error_response = f"I encountered an error while processing your query: {str(e)}" + return { + "response": error_response, + "sources": [], + "reasoning": [] + } + + # Replace the run method directly on the agent that is already in shared state + # We need to get the current reference from shared_state + from shared_state import global_workflow_agent as current_agent + + if current_agent is None: + log_structured('critical', 'Cannot set run method - global_workflow_agent is None') + return False + + # Attach the run method directly + current_agent.run = simple_run + + # Verify it was attached correctly + if not hasattr(current_agent, 'run'): + log_structured('critical', 'Failed to attach run method to agent') + return False + + log_structured('info', 'Successfully attached run method to agent') + + # Test the agent is working by calling a simple method + log_structured('info', 'Testing agent functionality...') + + # Test on the current_agent we imported above + if current_agent and hasattr(current_agent, 'memory') and hasattr(current_agent.memory, 'reset'): + current_agent.memory.reset() + log_structured('info', 'Agent memory reset test successful') + else: + if not current_agent: + log_structured('error', 'Agent memory test failed: agent is None') + elif not hasattr(current_agent, 'memory'): + log_structured('error', 'Agent memory test failed: agent has no memory attribute') + elif not hasattr(current_agent.memory, 'reset'): + log_structured('error', 'Agent memory test failed: agent.memory has no reset method') + else: + log_structured('error', 'Agent memory test failed: unknown reason') + return False + + except Exception as method_err: + log_structured('critical', f'Failed to set up agent run method: {str(method_err)}', + {'error': str(method_err), 'traceback': traceback.format_exc()}) + return False + + log_structured('info', 'Global index and workflow agent initialized successfully.') + # Skip test query to avoid potential errors during startup + return True + + except Exception as e: + log_structured('critical', 'Global index/agent initialization failed', { + 'error': str(e), 'traceback': traceback.format_exc() + }) + global_index = None + global_workflow_agent = None + return False \ No newline at end of file diff --git a/chat-interface/.env.development b/chat-interface/.env.development new file mode 100644 index 0000000..10249c0 --- /dev/null +++ b/chat-interface/.env.development @@ -0,0 +1 @@ +VITE_BACKEND_URL=http://localhost:6173 \ No newline at end of file diff --git a/chat-interface/.env.production b/chat-interface/.env.production new file mode 100644 index 0000000..60b9e7b --- /dev/null +++ b/chat-interface/.env.production @@ -0,0 +1 @@ +VITE_API_BASE_URL=https://ai-sandbox.oliver.solutions/netflix_back diff --git a/chat-interface/.gitignore b/chat-interface/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/chat-interface/.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/chat-interface/.htaccess b/chat-interface/.htaccess new file mode 100644 index 0000000..86f3a56 --- /dev/null +++ b/chat-interface/.htaccess @@ -0,0 +1,37 @@ +# JavaScript MIME type +AddType application/javascript .js +AddType application/json .json + +# CSS MIME type +AddType text/css .css + +# Image MIME types +AddType image/svg+xml .svg +AddType image/png .png +AddType image/jpeg .jpg +AddType image/jpeg .jpeg +AddType image/gif .gif +AddType image/webp .webp + +# Font MIME types +AddType font/ttf .ttf +AddType font/otf .otf +AddType font/woff .woff +AddType font/woff2 .woff2 + +# Force JavaScript MIME type for all JS files + + ForceType application/javascript + + +# Enable mod_rewrite +RewriteEngine On +RewriteBase /netflix_chatbot/ + +# Don't rewrite files or directories +RewriteCond %{REQUEST_FILENAME} -f [OR] +RewriteCond %{REQUEST_FILENAME} -d +RewriteRule ^ - [L] + +# Rewrite everything else to index.html +RewriteRule ^ index.html [L] \ No newline at end of file diff --git a/chat-interface/DEPLOY.md b/chat-interface/DEPLOY.md new file mode 100644 index 0000000..8d6aed4 --- /dev/null +++ b/chat-interface/DEPLOY.md @@ -0,0 +1,50 @@ +# Deployment Instructions + +## Files to Deploy +Deploy all files from the `dist` directory to your server at the path `/netflix_chatbot/`. + +## Changing the Backend URL +If you need to change the backend API URL: + +1. Edit the `.env` and `.env.production` files to update the `VITE_BACKEND_URL` value +2. Or use the provided script: `./update-backend.sh` which will update the URL and rebuild the application +3. Then rebuild the application with `npm run build` + +## Important Server Configuration +The application requires proper MIME type configuration to work correctly. Depending on your server type, use one of the following: + +### Apache Server +Make sure the `.htaccess` file is included in your deployment. It contains: +- MIME type configurations +- URL rewrite rules for SPA routing +- CORS headers + +### IIS Server +Make sure the `web.config` file is included in your deployment. It contains: +- MIME type configurations +- URL rewrite rules +- CORS headers + +## Common Issues and Solutions + +### JavaScript MIME Type Error +If you get an error like: +``` +Loading module from "https://ai-sandbox.oliver.solutions/netflix_chatbot/assets/index-XXXXX.js" was blocked because of a disallowed MIME type ("text/html"). +``` + +Check that: +1. Your server is serving .js files with the correct MIME type: `application/javascript` +2. The `.htaccess` or `web.config` file is properly uploaded and enabled +3. Your server allows URL rewriting and custom MIME types + +### Authentication Issues +- Make sure the Microsoft authentication is set up correctly +- The redirectUri in the MSAL configuration should match your deployment URL +- Check that cookies and localStorage are enabled in the browser + +### API Connection Issues +- The app expects the backend API to be accessible at the configured URL (default: `https://ai-sandbox.oliver.solutions/netflix_back_v2`) +- If you need to change the backend API URL, follow the instructions in "Changing the Backend URL" section above +- In development, API calls are proxied through Vite's development server +- In production, API calls go directly to the configured backend URL \ No newline at end of file diff --git a/chat-interface/README.md b/chat-interface/README.md new file mode 100644 index 0000000..372963d --- /dev/null +++ b/chat-interface/README.md @@ -0,0 +1,72 @@ +# Netflix GPD Key Art Playbook Chatbot + +A React frontend for the Netflix GPD Key Art Playbook Chatbot, providing a chat interface to query the Netflix marketing knowledge base. + +## Features + +- Clean chat interface for asking questions about Netflix marketing materials +- Sources and reasoning display for transparency +- Session-based memory for contextual conversations +- Conversation management system + +## Development + +### Prerequisites + +- Node.js 18+ +- npm or yarn + +### Setup + +1. Clone the repository +2. Install dependencies: +```bash +npm install +``` + +3. Start the development server: +```bash +npm run dev +``` + +### Configuration + +The application uses environment variables for configuration. Create a `.env` file in the root directory with the following variables: + +``` +# Backend API URL +VITE_BACKEND_URL=https://ai-sandbox.oliver.solutions/netflix_back_v2 + +# Base URL for the app (changes in production) +VITE_APP_BASE_URL=/ +``` + +### Changing the Backend URL + +If you need to change the backend API URL: + +1. Edit the `.env` and `.env.production` files to update the `VITE_BACKEND_URL` value +2. Or use the provided script: `./update-backend.sh` which will update the URL and rebuild the application +3. Then rebuild the application with `npm run build` + +## Building for Production + +To create a production build: + +```bash +npm run build +``` + +The output will be in the `dist` directory, ready for deployment. + +## Deployment + +See [DEPLOY.md](./DEPLOY.md) for detailed deployment instructions. + +## Technologies Used + +- React 18 +- Vite +- TailwindCSS +- Microsoft Authentication Library (MSAL) +- Shadcn/ui components \ No newline at end of file diff --git a/chat-interface/components.json b/chat-interface/components.json new file mode 100644 index 0000000..ab02c33 --- /dev/null +++ b/chat-interface/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": false, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/index.css", + "baseColor": "slate", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} + diff --git a/chat-interface/eslint.config.js b/chat-interface/eslint.config.js new file mode 100644 index 0000000..238d2e4 --- /dev/null +++ b/chat-interface/eslint.config.js @@ -0,0 +1,38 @@ +import js from '@eslint/js' +import globals from 'globals' +import react from 'eslint-plugin-react' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' + +export default [ + { ignores: ['dist'] }, + { + files: ['**/*.{js,jsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + settings: { react: { version: '18.3' } }, + plugins: { + react, + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...js.configs.recommended.rules, + ...react.configs.recommended.rules, + ...react.configs['jsx-runtime'].rules, + ...reactHooks.configs.recommended.rules, + 'react/jsx-no-target-blank': 'off', + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +] diff --git a/chat-interface/index.html b/chat-interface/index.html new file mode 100644 index 0000000..7e7b3b3 --- /dev/null +++ b/chat-interface/index.html @@ -0,0 +1,31 @@ + + + + + + + + + Netflix GPD Key Art Playbook Chatbot + + + + + +
+
+

Netflix GPD Key Art Playbook Chatbot

+

Please sign in to access the chatbot.

+ +
+
+ + + + diff --git a/chat-interface/package-lock.json b/chat-interface/package-lock.json new file mode 100644 index 0000000..61e56bb --- /dev/null +++ b/chat-interface/package-lock.json @@ -0,0 +1,6784 @@ +{ + "name": "chat-interface", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "chat-interface", + "version": "0.0.0", + "dependencies": { + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-tooltip": "^1.1.4", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "showdown": "^2.1.0" + }, + "devDependencies": { + "@eslint/js": "^9.13.0", + "@shadcn/ui": "^0.0.4", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "autoprefixer": "^10.4.20", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "eslint": "^9.13.0", + "eslint-plugin-react": "^7.37.2", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.14", + "globals": "^15.11.0", + "lucide-react": "^0.455.0", + "postcss": "^8.4.47", + "tailwind-merge": "^2.5.4", + "tailwindcss": "^3.4.14", + "tailwindcss-animate": "^1.0.7", + "vite": "^5.4.10" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.2.tgz", + "integrity": "sha512-Z0WgzSEa+aUcdiJuCIqgujCshpMWgUpgOxXotrYPSA53hA3qopNaqcJpyr0hVb1FeWdnqFA35/fUtXgBK8srQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz", + "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.26.0", + "@babel/generator": "^7.26.0", + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helpers": "^7.26.0", + "@babel/parser": "^7.26.0", + "@babel/template": "^7.25.9", + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.26.0", + "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.26.2", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.2.tgz", + "integrity": "sha512-zevQbhbau95nkoxSq3f/DC/SC+EEOUZd3DYqfSkMhY2/wfSeaHV1Ew4vk8e+x8lja31IbyuUa2uQ3JONqKbysw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.26.2", + "@babel/types": "^7.26.0", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.9.tgz", + "integrity": "sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.25.9", + "@babel/helper-validator-option": "^7.25.9", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", + "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", + "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.9.tgz", + "integrity": "sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", + "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz", + "integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.25.9", + "@babel/types": "^7.26.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.2.tgz", + "integrity": "sha512-DWMCZH9WA4Maitz2q21SRKHo9QXZxkDsbNZoVD62gusNtNBBqDg9i7uOhASfTfIGNzW+O+r7+jAlM8dwphcJKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.25.9.tgz", + "integrity": "sha512-y8quW6p0WHkEhmErnfe58r7x0A70uKphQm8Sp8cV7tjNQwK56sNVK0M73LK3WuYmsuyrftut4xAkjjgU0twaMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.25.9.tgz", + "integrity": "sha512-+iqjT8xmXhhYv4/uiYd8FNQsraMFZIfxVSqxxVSZP0WbbSAWvBXAul0m/zu+7Vv4O/3WtApy9pmaTMiumEZgfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", + "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.25.9", + "@babel/parser": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.9.tgz", + "integrity": "sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.25.9", + "@babel/generator": "^7.25.9", + "@babel/parser": "^7.25.9", + "@babel/template": "^7.25.9", + "@babel/types": "^7.25.9", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/types": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.0.tgz", + "integrity": "sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", + "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", + "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.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.18.0.tgz", + "integrity": "sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.4", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.7.0.tgz", + "integrity": "sha512-xp5Jirz5DyPYlPiKat8jaq0EmYvDXKKpzTbxXMpT9eqlRJkRKIz9AGMdlvYjih+im+QlhWrpvVjl8IPC/lHlUw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.1.0.tgz", + "integrity": "sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "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.0", + "minimatch": "^3.1.2", + "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.14.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.14.0.tgz", + "integrity": "sha512-pFoEtFWCPyDOl+C6Ift+wC7Ro89otjigCf5vcuWqWgqNSQbRrpjSvdeE6ofLz4dHmyxD5f7gIdGT4+p36L6Twg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.4.tgz", + "integrity": "sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.2.tgz", + "integrity": "sha512-CXtq5nR4Su+2I47WPOlWud98Y5Lv8Kyxp2ukhgFx/eW6Blm18VXJO5WuQylPugRo8nbluoi6GvvxBLqHcvqUUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.6.8.tgz", + "integrity": "sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.6.12.tgz", + "integrity": "sha512-NP83c0HjokcGVEMeoStg317VD9W7eDlGK7457dMBANbKA6GJZdc7rjujdgqzTaz93jkGgc5P/jeWbaCHnMNc+w==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.6.0", + "@floating-ui/utils": "^0.2.8" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz", + "integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz", + "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==", + "license": "MIT" + }, + "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.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "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.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.1.tgz", + "integrity": "sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", + "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz", + "integrity": "sha512-FmlW1rCg7hBpEBwFbjHwCW6AmWLQM6g/v0Sn8XbP9NvmSZ2San1FpQeyPtufzOMSIx7Y4dzjlHoifhp+7NkZhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.1.tgz", + "integrity": "sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", + "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz", + "integrity": "sha512-ZnRMshKF43aBxVWPWvbj21+7TQCvhuULWJ4gNIKYpRlQt5xGRhLx66tMp8pya2UkGHTSlhpXwmjqltDYHhw7Vg==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-rect": "1.1.0", + "@radix-ui/react-use-size": "1.1.0", + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.2.tgz", + "integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.1.tgz", + "integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", + "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.4.tgz", + "integrity": "sha512-QpObUH/ZlpaO4YgHSaYzrLO2VuO+ZBFFgGzjMUPwtiYnAzzNNDPJeEGRrT7qNOrWm/Jr08M1vlp+vTHtnSQ0Uw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.0", + "@radix-ui/react-portal": "1.1.2", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz", + "integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", + "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz", + "integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz", + "integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.0.tgz", + "integrity": "sha512-N8MDZqtgCgG5S3aV60INAB475osJousYpZ4cTJ2cFbMpdHS5Y6loLTH8LPtkj2QN0x93J30HT/M3qJXM0+lyeQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", + "integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.24.4.tgz", + "integrity": "sha512-jfUJrFct/hTA0XDM5p/htWKoNNTbDLY0KRwEt6pyOA6k2fmk0WVwl65PdUdJZgzGEHWx+49LilkcSaumQRyNQw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.24.4.tgz", + "integrity": "sha512-j4nrEO6nHU1nZUuCfRKoCcvh7PIywQPUCBa2UsootTHvTHIoIu2BzueInGJhhvQO/2FTRdNYpf63xsgEqH9IhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.24.4.tgz", + "integrity": "sha512-GmU/QgGtBTeraKyldC7cDVVvAJEOr3dFLKneez/n7BvX57UdhOqDsVwzU7UOnYA7AAOt+Xb26lk79PldDHgMIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.24.4.tgz", + "integrity": "sha512-N6oDBiZCBKlwYcsEPXGDE4g9RoxZLK6vT98M8111cW7VsVJFpNEqvJeIPfsCzbf0XEakPslh72X0gnlMi4Ddgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.24.4.tgz", + "integrity": "sha512-py5oNShCCjCyjWXCZNrRGRpjWsF0ic8f4ieBNra5buQz0O/U6mMXCpC1LvrHuhJsNPgRt36tSYMidGzZiJF6mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.24.4.tgz", + "integrity": "sha512-L7VVVW9FCnTTp4i7KrmHeDsDvjB4++KOBENYtNYAiYl96jeBThFfhP6HVxL74v4SiZEVDH/1ILscR5U9S4ms4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.24.4.tgz", + "integrity": "sha512-10ICosOwYChROdQoQo589N5idQIisxjaFE/PAnX2i0Zr84mY0k9zul1ArH0rnJ/fpgiqfu13TFZR5A5YJLOYZA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.24.4.tgz", + "integrity": "sha512-ySAfWs69LYC7QhRDZNKqNhz2UKN8LDfbKSMAEtoEI0jitwfAG2iZwVqGACJT+kfYvvz3/JgsLlcBP+WWoKCLcw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.24.4.tgz", + "integrity": "sha512-uHYJ0HNOI6pGEeZ/5mgm5arNVTI0nLlmrbdph+pGXpC9tFHFDQmDMOEqkmUObRfosJqpU8RliYoGz06qSdtcjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.24.4.tgz", + "integrity": "sha512-38yiWLemQf7aLHDgTg85fh3hW9stJ0Muk7+s6tIkSUOMmi4Xbv5pH/5Bofnsb6spIwD5FJiR+jg71f0CH5OzoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.24.4.tgz", + "integrity": "sha512-q73XUPnkwt9ZNF2xRS4fvneSuaHw2BXuV5rI4cw0fWYVIWIBeDZX7c7FWhFQPNTnE24172K30I+dViWRVD9TwA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.24.4.tgz", + "integrity": "sha512-Aie/TbmQi6UXokJqDZdmTJuZBCU3QBDA8oTKRGtd4ABi/nHgXICulfg1KI6n9/koDsiDbvHAiQO3YAUNa/7BCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.24.4.tgz", + "integrity": "sha512-P8MPErVO/y8ohWSP9JY7lLQ8+YMHfTI4bAdtCi3pC2hTeqFJco2jYspzOzTUB8hwUWIIu1xwOrJE11nP+0JFAQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.24.4.tgz", + "integrity": "sha512-K03TljaaoPK5FOyNMZAAEmhlyO49LaE4qCsr0lYHUKyb6QacTNF9pnfPpXnFlFD3TXuFbFbz7tJ51FujUXkXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.24.4.tgz", + "integrity": "sha512-VJYl4xSl/wqG2D5xTYncVWW+26ICV4wubwN9Gs5NrqhJtayikwCXzPL8GDsLnaLU3WwhQ8W02IinYSFJfyo34Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.24.4.tgz", + "integrity": "sha512-ku2GvtPwQfCqoPFIJCqZ8o7bJcj+Y54cZSr43hHca6jLwAiCbZdBUOrqE6y29QFajNAzzpIOwsckaTFmN6/8TA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.24.4.tgz", + "integrity": "sha512-V3nCe+eTt/W6UYNr/wGvO1fLpHUrnlirlypZfKCT1fG6hWfqhPgQV/K/mRBXBpxc0eKLIF18pIOFVPh0mqHjlg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.24.4.tgz", + "integrity": "sha512-LTw1Dfd0mBIEqUVCxbvTE/LLo+9ZxVC9k99v1v4ahg9Aak6FpqOfNu5kRkeTAn0wphoC4JU7No1/rL+bBCEwhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@shadcn/ui": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@shadcn/ui/-/ui-0.0.4.tgz", + "integrity": "sha512-0dtu/5ApsOZ24qgaZwtif8jVwqol7a4m1x5AxPuM1k5wxhqU7t/qEfBGtaSki1R8VlbTQfCj5PAlO45NKCa7Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "5.2.0", + "commander": "^10.0.0", + "execa": "^7.0.0", + "fs-extra": "^11.1.0", + "node-fetch": "^3.3.0", + "ora": "^6.1.2", + "prompts": "^2.4.2", + "zod": "^3.20.2" + }, + "bin": { + "ui": "dist/index.js" + } + }, + "node_modules/@shadcn/ui/node_modules/chalk": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.2.0.tgz", + "integrity": "sha512-ree3Gqw/nazQAPuJJEy+avdl7QfZMcUvmHIKgEZkGL+xOBzRvup5Hxo6LHuMceSxOabuJLJm5Yp/92R9eMmMvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@shadcn/ui/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.6.8", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", + "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.6", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", + "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true, + "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/prop-types": { + "version": "15.7.13", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", + "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.12", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.12.tgz", + "integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.3.3.tgz", + "integrity": "sha512-NooDe9GpHGqNns1i8XDERg0Vsg5SSYRhRxxyTGogUdkdNt47jal+fbuYi+Yfq6pzRCKXyoPcWisfxE6RIM3GKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/plugin-transform-react-jsx-self": "^7.24.7", + "@babel/plugin-transform-react-jsx-source": "^7.24.7", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.14.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0" + } + }, + "node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "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.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "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-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "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/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/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/array-buffer-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", + "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.5", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", + "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.4", + "is-string": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", + "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", + "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "es-shim-unscopables": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", + "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.3", + "es-errors": "^1.2.1", + "get-intrinsic": "^1.2.3", + "is-array-buffer": "^3.0.4", + "is-shared-array-buffer": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.20", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", + "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.3", + "caniuse-lite": "^1.0.30001646", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bl": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz", + "integrity": "sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz", + "integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001669", + "electron-to-chromium": "^1.5.41", + "node-releases": "^2.0.18", + "update-browserslist-db": "^1.1.1" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/call-bind": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", + "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001679", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001679.tgz", + "integrity": "sha512-j2YqID/YwpLnKzCmBOS4tlZdWprXm3ZmQLBH9ZBXFOhoxLA46fwyBvx6toCBWBmnuwUY/qB3kEU6gFx8qgCroA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/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/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.0.tgz", + "integrity": "sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "clsx": "2.0.0" + }, + "funding": { + "url": "https://joebell.co.uk" + } + }, + "node_modules/class-variance-authority/node_modules/clsx": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.0.0.tgz", + "integrity": "sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/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/cross-spawn": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.5.tgz", + "integrity": "sha512-ZVJrKKYunU38/76t0RMOulHOnUcbU9GbpWKAOZ0mhjr7CX6FVrH+4FrAapSOekrgFQ3f/8gwMEuIft0aKq6Hug==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", + "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz", + "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz", + "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "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/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.55", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.55.tgz", + "integrity": "sha512-6maZ2ASDOTBtjt9FhqYPRnbvKU5tjG0IN9SztUOWYw2AzNDNpKJYLJmlK0/En4Hs/aiWnB+JZ+gW19PIGszgKg==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-abstract": { + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", + "integrity": "sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "arraybuffer.prototype.slice": "^1.0.3", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "data-view-buffer": "^1.0.1", + "data-view-byte-length": "^1.0.1", + "data-view-byte-offset": "^1.0.0", + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-set-tostringtag": "^2.0.3", + "es-to-primitive": "^1.2.1", + "function.prototype.name": "^1.1.6", + "get-intrinsic": "^1.2.4", + "get-symbol-description": "^1.0.2", + "globalthis": "^1.0.3", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.0.3", + "has-symbols": "^1.0.3", + "hasown": "^2.0.2", + "internal-slot": "^1.0.7", + "is-array-buffer": "^3.0.4", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.1", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.3", + "is-string": "^1.0.7", + "is-typed-array": "^1.1.13", + "is-weakref": "^1.0.2", + "object-inspect": "^1.13.1", + "object-keys": "^1.1.1", + "object.assign": "^4.1.5", + "regexp.prototype.flags": "^1.5.2", + "safe-array-concat": "^1.1.2", + "safe-regex-test": "^1.0.3", + "string.prototype.trim": "^1.2.9", + "string.prototype.trimend": "^1.0.8", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.2", + "typed-array-byte-length": "^1.0.1", + "typed-array-byte-offset": "^1.0.2", + "typed-array-length": "^1.0.6", + "unbox-primitive": "^1.0.2", + "which-typed-array": "^1.1.15" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", + "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.4" + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.0.tgz", + "integrity": "sha512-tpxqxncxnpw3c93u8n3VOzACmRFoVmWJqbWXvX/JfKbkhBw1oslgPrUfeSt2psuqyEJFD6N/9lg5i7bsKpoq+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.3", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "globalthis": "^1.0.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.0.3", + "has-symbols": "^1.0.3", + "internal-slot": "^1.0.7", + "iterator.prototype": "^1.1.3", + "safe-array-concat": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", + "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", + "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.4", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz", + "integrity": "sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.0" + } + }, + "node_modules/es-to-primitive": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.1.4", + "is-date-object": "^1.0.1", + "is-symbol": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/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.14.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.14.0.tgz", + "integrity": "sha512-c2FHsVBr87lnUtjP4Yhvk4yEhKrQavGafRA/Se1ouse8PfbfC/Qh9Mxa00yWsZRlqeUB9raXip0aiiUZkgnr9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.18.0", + "@eslint/core": "^0.7.0", + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "9.14.0", + "@eslint/plugin-kit": "^0.2.0", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.0", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.2.0", + "eslint-visitor-keys": "^4.2.0", + "espree": "^10.3.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.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "text-table": "^0.2.0" + }, + "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": { + "version": "7.37.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.2.tgz", + "integrity": "sha512-EsTAnj9fLVr/GZleBLFbj/sSuXeWmp1eXIN60ceYnZveqEaUCyW4X+Vh4WTdUhCkW4xutXYqTXCUSyqD4rB75w==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.2", + "array.prototype.tosorted": "^1.1.4", + "doctrine": "^2.1.0", + "es-iterator-helpers": "^1.1.0", + "estraverse": "^5.3.0", + "hasown": "^2.0.2", + "jsx-ast-utils": "^2.4.1 || ^3.0.0", + "minimatch": "^3.1.2", + "object.entries": "^1.1.8", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.0", + "prop-types": "^15.8.1", + "resolve": "^2.0.0-next.5", + "semver": "^6.3.1", + "string.prototype.matchall": "^4.0.11", + "string.prototype.repeat": "^1.0.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.0.0.tgz", + "integrity": "sha512-hIOwI+5hYGpJEc4uPRmz2ulCjAGD/N13Lukkh8cLV0i2IRk/bdZDYjgLVHj+U9Z704kLIdIO6iueGvxNur0sgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "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.4.14", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.14.tgz", + "integrity": "sha512-aXvzCTK7ZBv1e7fahFuR3Z/fyQQSIQ711yPgYRj+Oj64tyTgO4iQIDmYXDBqvSWQ/FA4OSCsXOStlF+noU0/NA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=7" + } + }, + "node_modules/eslint-scope": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.2.0.tgz", + "integrity": "sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==", + "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.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", + "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "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.3.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", + "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.14.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "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/execa": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-7.2.0.tgz", + "integrity": "sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.1", + "human-signals": "^4.3.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^3.0.7", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": "^14.18.0 || ^16.14.0 || >=18.0.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "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-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/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/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "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/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/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.3.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", + "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.1.3" + } + }, + "node_modules/foreground-child": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", + "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fs-extra": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", + "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.6.tgz", + "integrity": "sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "define-properties": "^1.2.0", + "es-abstract": "^1.22.1", + "functions-have-names": "^1.2.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "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.2.4", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", + "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "has-proto": "^1.0.1", + "has-symbols": "^1.0.3", + "hasown": "^2.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-symbol-description": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", + "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/glob/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "15.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.12.0.tgz", + "integrity": "sha512-1+gLErljJFhbOVyaetcwJiJ4+eLe45S2E7P5UiZ9xGfeq3ATQf5DOv9G7MH3gGbKQLkzmNh2DxfZwLdw+j6oTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "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==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", + "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", + "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/human-signals": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-4.3.1.tgz", + "integrity": "sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "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.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "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/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", + "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.0", + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", + "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-async-function": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", + "integrity": "sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", + "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-boolean-object": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", + "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", + "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz", + "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", + "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.0.2.tgz", + "integrity": "sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", + "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", + "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", + "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-regex": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", + "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", + "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", + "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-tostringtag": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", + "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", + "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-unicode-supported": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", + "integrity": "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", + "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.3.tgz", + "integrity": "sha512-LvIm3/KWzS9oRFHugab7d+M/GcBXuXX5xZkzPmN+NxihdQlZUQ4dWuSV1xR/sq6upL1TJEDrfBgRepHFdBtSNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "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/iterator.prototype": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.3.tgz", + "integrity": "sha512-FW5iMbeQ6rBGm/oKgzq2aW4KvAGpxPzYES8N4g4xNXUKpL1mclMvOe+76AcLDTvD+Ze+sOpVhgdAQEKF4L9iGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "reflect.getprototypeof": "^1.0.4", + "set-function-name": "^2.0.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.6", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", + "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "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/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "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/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/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/log-symbols": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-5.1.0.tgz", + "integrity": "sha512-l0x2DvrW294C9uDCoQe1VSU4gf529FkSZ6leBl4TiqZH/e+0R7hSfHQBNut2mNygDgHwvYHfFLn6Oxb3VWj2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.0.0", + "is-unicode-supported": "^1.1.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", + "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.455.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.455.0.tgz", + "integrity": "sha512-XQoi58X4COfjy6s1P4TVsXMhU2r1KsfUTRFyds/yJvdzBNOfu0F9RRIas626T8UaNZzEv4llF7ivh4uLkYp3rw==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/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-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-releases": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", + "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", + "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "has-symbols": "^1.0.3", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.8.tgz", + "integrity": "sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.values": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.0.tgz", + "integrity": "sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/ora": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-6.3.1.tgz", + "integrity": "sha512-ERAyNnZOfqM+Ao3RAvIXkYh5joP220yf59gVe2X/cI6SiCxIdi4c9HZKZD8R6q/RDXEje1THBju6iExiSsgJaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.0.0", + "cli-cursor": "^4.0.0", + "cli-spinners": "^2.6.1", + "is-interactive": "^2.0.0", + "is-unicode-supported": "^1.1.0", + "log-symbols": "^5.1.0", + "stdin-discarder": "^0.1.0", + "strip-ansi": "^7.0.1", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", + "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "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/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "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/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", + "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-import/node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-load-config/node_modules/lilconfig": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz", + "integrity": "sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/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/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "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/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.6.tgz", + "integrity": "sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.1", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "globalthis": "^1.0.3", + "which-builtin-type": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz", + "integrity": "sha512-vqlC04+RQoFalODCbCumG2xIOvapzVMHwsyIGM/SIE8fRhFFsXeH8/QQ+s0T0kDAhKc4k30s73/0ydkHQz6HlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve": { + "version": "2.0.0-next.5", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", + "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/restore-cursor/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.24.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.24.4.tgz", + "integrity": "sha512-vGorVWIsWfX3xbcyAS+I047kFKapHYivmkaT63Smj77XwvLSJos6M1xGqZnBPFQFBRZDOcG1QnYEIxAvTr/HjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.6" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.24.4", + "@rollup/rollup-android-arm64": "4.24.4", + "@rollup/rollup-darwin-arm64": "4.24.4", + "@rollup/rollup-darwin-x64": "4.24.4", + "@rollup/rollup-freebsd-arm64": "4.24.4", + "@rollup/rollup-freebsd-x64": "4.24.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.24.4", + "@rollup/rollup-linux-arm-musleabihf": "4.24.4", + "@rollup/rollup-linux-arm64-gnu": "4.24.4", + "@rollup/rollup-linux-arm64-musl": "4.24.4", + "@rollup/rollup-linux-powerpc64le-gnu": "4.24.4", + "@rollup/rollup-linux-riscv64-gnu": "4.24.4", + "@rollup/rollup-linux-s390x-gnu": "4.24.4", + "@rollup/rollup-linux-x64-gnu": "4.24.4", + "@rollup/rollup-linux-x64-musl": "4.24.4", + "@rollup/rollup-win32-arm64-msvc": "4.24.4", + "@rollup/rollup-win32-ia32-msvc": "4.24.4", + "@rollup/rollup-win32-x64-msvc": "4.24.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", + "integrity": "sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "get-intrinsic": "^1.2.4", + "has-symbols": "^1.0.3", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", + "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-regex": "^1.1.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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/showdown": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/showdown/-/showdown-2.1.0.tgz", + "integrity": "sha512-/6NVYu4U819R2pUIk79n67SYgJHWCce0a5xTP979WbNp0FL9MN1I1QK662IDU1b6JzKTvmhgI7T7JYIxBi3kMQ==", + "license": "MIT", + "dependencies": { + "commander": "^9.0.0" + }, + "bin": { + "showdown": "bin/showdown.js" + }, + "funding": { + "type": "individual", + "url": "https://www.paypal.me/tiviesantos" + } + }, + "node_modules/showdown/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/side-channel": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", + "object-inspect": "^1.13.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stdin-discarder": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.1.0.tgz", + "integrity": "sha512-xhV7w8S+bUwlPTb4bAOUQhv8/cSS5offJuX8GQGq32ONF0ZtDWKfkdomM3HMRA+LhX6um/FZ0COqlwsjD53LeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^5.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.11.tgz", + "integrity": "sha512-NUdh0aDavY2og7IbBPenWqR9exH+E26Sv8e0/eTe1tltDGZL+GtBkDAnnyBtmekfK6/Dq3MkcGtzXFEd1LQrtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-symbols": "^1.0.3", + "internal-slot": "^1.0.7", + "regexp.prototype.flags": "^1.5.2", + "set-function-name": "^2.0.2", + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz", + "integrity": "sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz", + "integrity": "sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "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/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "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/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.5.4.tgz", + "integrity": "sha512-0q8cfZHMu9nuYP/b5Shb7Y7Sh1B7Nnl5GqNr1U+n2p6+mybvRtayrQ+0042Z5byvTA8ihjlP8Odo8/VnHbZu4Q==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.14", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.14.tgz", + "integrity": "sha512-IcSvOcTRcUtQQ7ILQL5quRDg7Xs93PdJEk1ZLbhhvJc7uj/OAhYOnruEiwnGgBvUtaUAJ8/mhSw1o8L2jCiENA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.0", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.0", + "lilconfig": "^2.1.0", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.23", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.1", + "postcss-nested": "^6.0.1", + "postcss-selector-parser": "^6.0.11", + "resolve": "^1.22.2", + "sucrase": "^3.32.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/tailwindcss/node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/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/typed-array-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", + "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz", + "integrity": "sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz", + "integrity": "sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.6.tgz", + "integrity": "sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/unbox-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", + "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "has-bigints": "^1.0.2", + "has-symbols": "^1.0.3", + "which-boxed-primitive": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", + "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.0" + }, + "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/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.10", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.10.tgz", + "integrity": "sha512-1hvaPshuPUtxeQ0hsVH3Mud0ZanOLwVTneA1EgbAM5LhaZEqyPWGRQ7BtaMvUrTDeEaC8pxtj6a6jku3x4z6SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "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/which-boxed-primitive": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", + "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.0.1", + "is-boolean-object": "^1.1.0", + "is-number-object": "^1.0.4", + "is-string": "^1.0.5", + "is-symbol": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.1.4.tgz", + "integrity": "sha512-bppkmBSsHFmIMSl8BO9TbsyzsvGjVoppt8xUiGzwiu/bhDCGxnpOKCxgqj6GuyHE0mINMDecBFPlOm2hzY084w==", + "dev": true, + "license": "MIT", + "dependencies": { + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.0.5", + "is-finalizationregistry": "^1.0.2", + "is-generator-function": "^1.0.10", + "is-regex": "^1.1.4", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.15" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", + "integrity": "sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "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/yaml": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz", + "integrity": "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, + "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": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/chat-interface/package.json b/chat-interface/package.json new file mode 100644 index 0000000..6827187 --- /dev/null +++ b/chat-interface/package.json @@ -0,0 +1,40 @@ +{ + "name": "chat-interface", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-tooltip": "^1.1.4", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "showdown": "^2.1.0" + }, + "devDependencies": { + "@eslint/js": "^9.13.0", + "@shadcn/ui": "^0.0.4", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.3", + "autoprefixer": "^10.4.20", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "eslint": "^9.13.0", + "eslint-plugin-react": "^7.37.2", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.14", + "globals": "^15.11.0", + "lucide-react": "^0.455.0", + "postcss": "^8.4.47", + "tailwind-merge": "^2.5.4", + "tailwindcss": "^3.4.14", + "tailwindcss-animate": "^1.0.7", + "vite": "^5.4.10" + } +} diff --git a/chat-interface/postcss.config.js b/chat-interface/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/chat-interface/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/chat-interface/public/images/Netflix_2015_N_logo.png b/chat-interface/public/images/Netflix_2015_N_logo.png new file mode 100644 index 0000000..ef062a7 Binary files /dev/null and b/chat-interface/public/images/Netflix_2015_N_logo.png differ diff --git a/chat-interface/public/images/netflix-logo.svg b/chat-interface/public/images/netflix-logo.svg new file mode 100644 index 0000000..f3a8116 --- /dev/null +++ b/chat-interface/public/images/netflix-logo.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/chat-interface/public/vite.svg b/chat-interface/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/chat-interface/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/chat-interface/src/App.css b/chat-interface/src/App.css new file mode 100644 index 0000000..b9d355d --- /dev/null +++ b/chat-interface/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/chat-interface/src/App.jsx b/chat-interface/src/App.jsx new file mode 100644 index 0000000..d7c0802 --- /dev/null +++ b/chat-interface/src/App.jsx @@ -0,0 +1,1528 @@ +/** + * Netflix AI Chatbot (Project Tudum) + * + * Frontend interface for the Netflix GPD Key Art Playbook Chatbot. + * This application provides a chat interface to query the Netflix marketing + * knowledge base containing the Netflix GPD Key Art Playbook and supporting documents. + * + * Key features: + * - Clean chat interface for asking questions about Netflix marketing materials + * - Sources and reasoning display for transparency + * - Brief download functionality to export conversations + * - Session-based memory for contextual conversations + * + * Modified April 2025 to implement Project Tudum requirements. + */ + +import { useState, useRef, useEffect } from 'react'; +import { Send, Upload, Loader2, X, FileText, Info, PlusCircle, Trash2, Moon, Sun } from 'lucide-react'; +import ThemeToggle from './components/ThemeToggle'; +import { fetchWithTimeout } from './lib/utils'; +import { Alert, AlertDescription } from "./components/ui/alert"; +import * as Tooltip from '@radix-ui/react-tooltip'; +import showdown from 'showdown'; +import { getCurrentUser, signOut } from './auth'; +import { loadUserConversations, loadConversationMessages, createNewConversation, deleteConversation } from './components/ConversationManager'; + +// Use environment variables for backend URL +// Define backend URL dynamically based on environment +const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'https://ai-sandbox.oliver.solutions/netflix_back_v2'; +console.log('Using backend URL:', BACKEND_URL); + +// Function to get authenticated fetch defaults +const getFetchDefaults = () => { + // Get MSAL username for authentication + const username = getCurrentUser(); + + return { + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'X-MS-USERNAME': username || '', // Add the authenticated username to requests + } + }; +}; + +export default function ChatInterface() { + // Build timestamp to force new hash generation on rebuild - logged only once + const BUILD_TIMESTAMP = "2025-04-27T" + "FIXED_TIMESTAMP"; + // Only log in development mode and only once during initialization + if (process.env.NODE_ENV !== 'production') { + // Using useRef to ensure this only runs once + const hasLoggedTimestamp = useRef(false); + useEffect(() => { + if (!hasLoggedTimestamp.current) { + console.log('App build timestamp:', BUILD_TIMESTAMP); + hasLoggedTimestamp.current = true; + } + }, []); + } + + const [messages, setMessages] = useState([]); + const [inputMessage, setInputMessage] = useState(''); + const [isProcessing, setIsProcessing] = useState(false); + const [isInitialized, setIsInitialized] = useState(false); + const [error, setError] = useState(null); + const [isCheckingStatus, setIsCheckingStatus] = useState(true); + + // Conversation state + const [conversations, setConversations] = useState([]); + const [loadingConversations, setLoadingConversations] = useState(false); + const [activeConversation, setActiveConversation] = useState(null); + + // Sidebar resizing state + const [sidebarWidth, setSidebarWidth] = useState(320); // Default width in pixels + const [isResizing, setIsResizing] = useState(false); + const [autoWidth, setAutoWidth] = useState(true); // Auto-width flag + const minSidebarWidth = 240; + const maxSidebarWidth = 480; + const sidebarRef = useRef(null); + + // Initialize Showdown converter for markdown + const markdownConverter = new showdown.Converter({ + tables: true, + simplifiedAutoLink: true, + strikethrough: true, + tasklists: true, + emoji: true + }); + + const messagesEndRef = useRef(null); + + // Use a ref to track if we've already created a new conversation on this page load + const hasCreatedConversation = useRef(false); + + const [sessionId, setSessionId] = useState(() => { + // Check for existing session ID in localStorage + const existingSessionId = localStorage.getItem('chatSessionId'); + if (existingSessionId) { + return existingSessionId; + } + // Generate new session ID if none exists + const newSessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + localStorage.setItem('chatSessionId', newSessionId); + return newSessionId; + }); + + // Load user conversations from the API + const loadUserConversations = async () => { + setLoadingConversations(true); + try { + const fetchOptions = getFetchDefaults(); + const response = await fetchWithTimeout(`${BACKEND_URL}/conversations`, { + ...fetchOptions, + method: 'GET' + }); + + if (response.ok) { + const data = await response.json(); + setConversations(data.conversations || []); + + // REMOVED auto-loading of the most recent conversation + // We want the user to always start with a fresh conversation when refreshing + } else { + console.error('Failed to load conversations'); + } + } catch (error) { + console.error('Error loading conversations:', error); + } finally { + setLoadingConversations(false); + } + }; + + // Load a specific conversation + const loadConversation = async (conversation) => { + try { + setIsProcessing(true); + setActiveConversation(conversation); + + // Set the session ID + setSessionId(conversation.session_id); + localStorage.setItem('chatSessionId', conversation.session_id); + + // Fetch the messages for this conversation + const fetchOptions = getFetchDefaults(); + const response = await fetchWithTimeout(`${BACKEND_URL}/conversations/${conversation.id}/messages`, { + ...fetchOptions, + method: 'GET' + }); + + if (response.ok) { + const data = await response.json(); + + // Format the messages for the UI + const formattedMessages = data.messages.map(msg => ({ + role: msg.role, + content: msg.content, + sources: msg.sources || [], + reasoning: msg.reasoning || [], + images: msg.images || [] + })); + + setMessages(formattedMessages); + setIsInitialized(true); + setError(null); + } else { + setError('Failed to load conversation messages'); + console.error('Failed to load conversation messages'); + } + } catch (error) { + setError('Error loading conversation'); + console.error('Error loading conversation:', error); + } finally { + setIsProcessing(false); + } + }; + + // Create a new conversation + const createNewConversation = async () => { + try { + setIsProcessing(true); + const fetchOptions = getFetchDefaults(); + const response = await fetchWithTimeout(`${BACKEND_URL}/conversations/new`, { + ...fetchOptions, + method: 'POST' + }); + + if (response.ok) { + const data = await response.json(); + + // Set the new session ID + setSessionId(data.session_id); + localStorage.setItem('chatSessionId', data.session_id); + + // Create a new conversation object + const newConversation = { + id: data.conversation_id, + title: "New conversation", + created_at: new Date().toISOString(), + last_updated: new Date().toISOString(), + session_id: data.session_id + }; + + // Update the conversations list + setConversations(prev => [newConversation, ...prev]); + + // Set as active conversation + setActiveConversation(newConversation); + + // Reset messages + setMessages([{ + role: 'assistant', + content: 'Welcome to the Netflix GPD Key Art Playbook Chatbot! How can I help you today?' + }]); + + setIsInitialized(true); + setError(null); + } else { + setError('Failed to create new conversation'); + console.error('Failed to create new conversation'); + } + } catch (error) { + setError('Error creating new conversation'); + console.error('Error creating new conversation:', error); + } finally { + setIsProcessing(false); + } + }; + + // Function to handle loading a conversation + const handleLoadConversation = async (conversation) => { + setActiveConversation(conversation); + await loadConversationMessages( + conversation, + getCurrentUser(), + setMessages, + setSessionId, + setError + ); + }; + + // Function to handle creating a new conversation + const handleCreateNewConversation = async () => { + await createNewConversation( + getCurrentUser(), + setConversations, + setActiveConversation, + setSessionId, + setMessages, + setError + ); + }; + + // Function to update conversations list (including titles) after a message is sent + const updateConversationsList = async () => { + if (activeConversation) { + try { + console.log('Updating conversations list...'); + // Get updated conversations from the server + const fetchOptions = getFetchDefaults(); + const response = await fetchWithTimeout(`${BACKEND_URL}/conversations`, { + ...fetchOptions, + method: 'GET' + }); + + if (response.ok) { + const data = await response.json(); + const updatedConversations = data.conversations || []; + + // Update the conversations list + setConversations(updatedConversations); + + // Find and update the active conversation with any title changes + const updatedActiveConversation = updatedConversations.find( + convo => convo.id === activeConversation.id + ); + + if (updatedActiveConversation) { + // Always update the active conversation to get the latest data + // This ensures titles get updated after messages are sent + setActiveConversation(updatedActiveConversation); + console.log('Updated active conversation:', + 'ID:', updatedActiveConversation.id, + 'Title:', updatedActiveConversation.title + ); + } else { + console.warn('Active conversation not found in updated list'); + } + } else { + console.error('Failed to update conversations list'); + } + } catch (err) { + console.error('Error updating conversations list:', err); + // Continue with existing data on error + } + } else { + console.warn('No active conversation to update'); + } + }; + + // Separate useEffect that only runs once on component mount + useEffect(() => { + // Flag to prevent multiple conversation creations + hasCreatedConversation.current = false; + + // Check the status of the chat system when the component mounts + const checkStatus = async () => { + setIsCheckingStatus(true); + try { + // Check if the chat system is initialized + const fetchOptions = getFetchDefaults(); + const response = await fetchWithTimeout(`${BACKEND_URL}/status?sessionId=${sessionId}`, { + ...fetchOptions, + method: 'GET' + }); + + if (response.ok) { + const data = await response.json(); + // Check for either the new property 'global_status' or the old 'initialized' property + // Enhanced logging for the status response + console.log('Status check raw response:', JSON.stringify(data)); + + // Explicitly check each property with detailed logging + const hasGlobalStatus = 'global_status' in data; + const globalStatusValue = hasGlobalStatus ? data.global_status : 'missing'; + const globalStatusMatch = globalStatusValue === 'initialized'; + + const hasInitialized = 'initialized' in data; + const initializedValue = hasInitialized ? data.initialized : false; + + console.log('Status check property analysis:', { + hasGlobalStatus, + globalStatusValue, + globalStatusMatch, + hasInitialized, + initializedValue + }); + + // Try also checking 'is_initialized' as another possible property name + const hasIsInitialized = 'is_initialized' in data; + const isInitializedValue = hasIsInitialized ? data.is_initialized : false; + + // Check session-specific initialization if available + const hasSessionInitialized = 'session_initialized' in data; + const sessionInitialized = hasSessionInitialized ? data.session_initialized : true; // Default to true if not present + const sessionStatus = data.session_status || 'unknown'; + + // OVERRIDE: ALWAYS force initialization to true + // This completely bypasses all property checks and status endpoint validation + // Complementing the backend change which now always returns initialized=true + const systemInitialized = true; + + // Log final decision with full context + console.log('Final initialization decision:', { + systemInitialized, + globalStatusMatch, + initializedValue, + isInitializedValue, + hasSessionInitialized, + sessionInitialized, + sessionStatus, + fullData: data + }); + + setIsInitialized(systemInitialized); + + if (systemInitialized) { + // First, load conversations list for the sidebar without auto-selecting any + await loadUserConversations(); + + // Only create a new conversation if we haven't already + if (!hasCreatedConversation.current) { + logToConsole('info', 'App mounted, creating a new conversation', { sessionId }); + + // Set the flag to prevent creating multiple conversations + hasCreatedConversation.current = true; + + setIsProcessing(true); + try { + const fetchOptions = getFetchDefaults(); + const response = await fetch(`${BACKEND_URL}/conversations/new`, { + ...fetchOptions, + method: 'POST' + }); + + if (response.ok) { + const data = await response.json(); + + // Set the new session ID + setSessionId(data.session_id); + localStorage.setItem('chatSessionId', data.session_id); + + // Create a new conversation object + const newConversation = { + id: data.conversation_id, + title: "New conversation", + created_at: new Date().toISOString(), + last_updated: new Date().toISOString(), + session_id: data.session_id + }; + + // Update the conversations list + setConversations(prev => [newConversation, ...prev]); + + // Set as active conversation + setActiveConversation(newConversation); + + // Reset messages + setMessages([{ + role: 'assistant', + content: 'Welcome to the Netflix GPD Key Art Playbook Chatbot! How can I help you today?' + }]); + } else { + console.error('Failed to create new conversation'); + } + } catch (error) { + console.error('Error creating new conversation on page load:', error); + } finally { + setIsProcessing(false); + } + } + + setError(null); + logToConsole('info', 'Chat system is ready', { sessionId }); + } else { + // System is not initialized + setError('The chat system is not yet initialized. Please try again later.'); + logToConsole('error', 'Chat system not initialized', { sessionId }); + } + } else { + console.error('Failed to check system status'); + setError('Failed to connect to the chat system. Please try again later.'); + } + } catch (error) { + console.error('Error checking system status:', error); + setError('Failed to connect to the chat system. Please try again later.'); + } finally { + setIsCheckingStatus(false); + } + }; + + checkStatus(); + + // Empty dependency array to ensure this only runs once when the component mounts + }, []); + + const logToConsole = (type, message, data = null) => { + const timestamp = new Date().toISOString(); + const logMessage = { + timestamp, + type, + message, + data + }; + console.log(JSON.stringify(logMessage, null, 2)); + }; + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }; + + useEffect(() => { + scrollToBottom(); + }, [messages]); + + // Update the page title when active conversation changes or is updated + useEffect(() => { + if (activeConversation?.title) { + document.title = `${activeConversation.title} | Netflix Playbook Bot`; + } else { + document.title = 'Netflix GPD Key Art Playbook Chatbot'; + } + }, [activeConversation?.title]); + + // Sidebar resize handlers + const startResizing = (e) => { + setIsResizing(true); + e.preventDefault(); + }; + + const stopResizing = () => { + setIsResizing(false); + document.body.style.cursor = 'default'; + }; + + const resize = (e) => { + if (isResizing) { + document.body.style.cursor = 'ew-resize'; + const newWidth = e.clientX; + if (newWidth >= minSidebarWidth && newWidth <= maxSidebarWidth) { + setSidebarWidth(newWidth); + } + } + }; + + // Auto-resize sidebar based on conversation titles + useEffect(() => { + // Function to calculate the sidebar width based on conversation titles + const calculateSidebarWidth = () => { + if (!autoWidth || !conversations.length) return; + + // Create a temporary div to measure text width + const measureDiv = document.createElement('div'); + measureDiv.style.position = 'absolute'; + measureDiv.style.visibility = 'hidden'; + measureDiv.style.whiteSpace = 'nowrap'; + measureDiv.style.fontFamily = 'inherit'; + measureDiv.style.fontSize = '0.875rem'; // text-sm + measureDiv.style.fontWeight = '500'; // font-medium + + // Add to DOM for measurement + document.body.appendChild(measureDiv); + + // Find longest title + let maxWidth = 0; + conversations.forEach(convo => { + measureDiv.textContent = convo.title || "New conversation"; + const width = measureDiv.offsetWidth; + maxWidth = Math.max(maxWidth, width); + }); + + // Remove measurement div + document.body.removeChild(measureDiv); + + // Calculate sidebar width (add padding for buttons, icons, etc.) + // We add extra padding for the delete button and some margin + const calculatedWidth = Math.max(minSidebarWidth, Math.min(maxWidth + 100, maxSidebarWidth)); + + // Update width if it's significantly different + if (Math.abs(calculatedWidth - sidebarWidth) > 10) { + setSidebarWidth(calculatedWidth); + } + }; + + // Call the calculation function + calculateSidebarWidth(); + + // Add an event listener for window resize to recalculate if needed + if (autoWidth) { + window.addEventListener('resize', calculateSidebarWidth); + return () => window.removeEventListener('resize', calculateSidebarWidth); + } + }, [conversations, autoWidth, minSidebarWidth, maxSidebarWidth, sidebarWidth]); + + // Add event listeners for manual resize + useEffect(() => { + const handleResize = (e) => { + if (isResizing) { + document.body.style.cursor = 'ew-resize'; + const newWidth = e.clientX; + + if (newWidth >= minSidebarWidth && newWidth <= maxSidebarWidth) { + setSidebarWidth(newWidth); + // When user manually resizes, disable auto width + setAutoWidth(false); + } + } + }; + + const handleStopResizing = () => { + setIsResizing(false); + document.body.style.cursor = 'default'; + }; + + window.addEventListener('mousemove', handleResize); + window.addEventListener('mouseup', handleStopResizing); + + return () => { + window.removeEventListener('mousemove', handleResize); + window.removeEventListener('mouseup', handleStopResizing); + }; + }, [isResizing, minSidebarWidth, maxSidebarWidth]); + + const handleFilesChange = (e, type) => { + const files = Array.from(e.target.files); + logToConsole('info', `Handling ${type} files change`, { + fileCount: files.length, + fileDetails: files.map(f => ({ + name: f.name, + type: f.type, + size: f.size + })) + }); + + if (type === 'brief') { + setBriefFiles(prev => [...prev, ...files]); + } else { + setSupportingFiles(prev => [...prev, ...files]); + } + }; + + const removeFile = (fileName, type) => { + logToConsole('info', `Removing file`, { + fileName, + type + }); + + if (type === 'brief') { + setBriefFiles(prev => prev.filter(file => file.name !== fileName)); + } else { + setSupportingFiles(prev => prev.filter(file => file.name !== fileName)); + } + }; + + const resetChat = async () => { + try { + setIsProcessing(true); + logToConsole('info', 'Resetting chat session', { sessionId }); + const fetchOptions = getFetchDefaults(); + const response = await fetchWithTimeout(`${BACKEND_URL}/reset`, { + ...fetchOptions, + method: 'POST', + body: JSON.stringify({ sessionId }) + }); + + if (response.ok) { + // Reset the UI state + setMessages([{ + role: 'assistant', + content: 'Chat session has been reset. How can I help you today?' + }]); + setError(null); + setInputMessage(''); + + logToConsole('info', 'Chat session reset successfully'); + } else { + const errorData = await response.json(); + setError(errorData.error || 'Failed to reset chat'); + } + } catch (error) { + console.error('Error resetting chat:', error); + setError('Failed to reset chat. Please try again.'); + } finally { + setIsProcessing(false); + } + }; + + + const ImageViewer = ({ image, isOpen, onClose, allImages }) => { + if (!isOpen) return null; + + // Find the index of the current image in the allImages array + const currentImageIndex = allImages.findIndex(img => { + const imgFilename = typeof img === 'string' ? img : img.filename; + const selectedFilename = typeof image === 'string' ? image : image.filename; + return imgFilename === selectedFilename; + }); + + const [activeIndex, setActiveIndex] = useState(currentImageIndex >= 0 ? currentImageIndex : 0); + + // Handle both string and object formats for backward compatibility + const currentImage = allImages[activeIndex]; + const filename = typeof currentImage === 'string' ? currentImage : currentImage.filename; + const metadata = typeof currentImage === 'string' ? null : currentImage; + + const goToPrevious = (e) => { + e.stopPropagation(); + setActiveIndex((prevIndex) => (prevIndex > 0 ? prevIndex - 1 : allImages.length - 1)); + }; + + const goToNext = (e) => { + e.stopPropagation(); + setActiveIndex((prevIndex) => (prevIndex < allImages.length - 1 ? prevIndex + 1 : 0)); + }; + + return ( +
+
+ {allImages.length > 1 && ( + + )} + +
+ {metadata + {metadata && ( +
+
{metadata.document}
+
Page {metadata.page} {allImages.length > 1 ? `(${activeIndex + 1}/${allImages.length})` : ''}
+
+ )} +
+ + {allImages.length > 1 && ( + + )} + + +
+
+ ); + }; + + const MessageBubble = ({ message }) => { + const [selectedImage, setSelectedImage] = useState(null); + + const formatSources = (sources) => { + if (!sources || !Array.isArray(sources)) return []; + + return sources.map((source) => { + if (source && typeof source === 'object') { + if (source.content) { + return { + text: typeof source.content === 'string' + ? source.content + : JSON.stringify(source.content), + tool: source.tool_name || '' + }; + } + return { + text: JSON.stringify(source), + tool: '' + }; + } + return { + text: String(source), + tool: '' + }; + }).filter(source => source.text); + }; + + const formatReasoning = (reasoning) => { + if (!reasoning || !Array.isArray(reasoning)) return []; + + return reasoning.map((step, index) => { + const stepType = step.type || 'thought'; + let content = ''; + + if (step.action) { + content = `${step.action}${step.action_input ? `: ${step.action_input}` : ''}`; + } else if (step.observation) { + content = step.observation; + } else if (step.response) { + content = step.response; + } else if (step.thought) { + content = step.thought; + } + + return { + id: index, + type: stepType, + content: content, + thought: step.thought + }; + }).filter(step => step.content); + }; + + const sources = formatSources(message.sources); + const reasoningSteps = formatReasoning(message.reasoning); + const images = message.images || []; + + // Handle image click - open full-size viewer + const openImage = (image) => { + setSelectedImage(image); + }; + + // Close image viewer + const closeImage = () => { + setSelectedImage(null); + }; + + // Handle escape key to close image + useEffect(() => { + const handleEscKey = (e) => { + if (e.key === 'Escape' && selectedImage) { + closeImage(); + } + }; + + window.addEventListener('keydown', handleEscKey); + return () => window.removeEventListener('keydown', handleEscKey); + }, [selectedImage]); + + return ( +
+
+
+ + {/* Display image thumbnails if available */} + {images.length > 0 && ( +
+
Relevant Document Screenshots:
+
+ {images.map((image, idx) => { + // Handle both string and object formats for backward compatibility + const filename = typeof image === 'string' ? image : image.filename; + const metadata = typeof image === 'string' ? null : image; + + return ( +
openImage(image)} + > + {metadata + {metadata && ( +
+ pg. {metadata.page} +
+ )} +
+ ); + })} +
+
+ )} + +
+ {sources.length > 0 && ( + + + + + + + +
+

Sources Used:

+
    + {sources.map((source, idx) => ( +
  • + {source.tool && ( +
    + Tool: {source.tool} +
    + )} +
    {source.text}
    +
  • + ))} +
+
+ +
+
+
+
+ )} + + {reasoningSteps.length > 0 && ( + + + + + + + +
+

Reasoning Steps:

+
    + {reasoningSteps.map((step) => ( +
  • +
    + {step.type}: +
    +
    {step.content}
    + {step.thought && step.thought !== step.content && ( +
    + Thought: {step.thought} +
    + )} +
  • + ))} +
+
+ +
+
+
+
+ )} +
+
+ + {/* Image viewer modal */} + +
+ ); + }; + + const handleSubmit = async () => { + if (!inputMessage.trim() || isProcessing) return; + + const currentMessage = inputMessage; + setMessages(prev => [...prev, { role: 'user', content: currentMessage }]); + setInputMessage(''); + setIsProcessing(true); + + // ENHANCED DEBUGGING: Log the current state of sessionId and related variables + console.log('SUBMIT DEBUG DATA:', { + sessionId, + sessionIdType: typeof sessionId, + sessionIdLength: sessionId ? sessionId.length : 0, + hasActiveConversation: !!activeConversation, + activeConversationId: activeConversation ? activeConversation.id : null, + activeConversationSessionId: activeConversation ? activeConversation.session_id : null, + localStorageSessionId: localStorage.getItem('chatSessionId') + }); + + // Check if this is a blank chat with no active conversation - create one if needed + if (!activeConversation) { + logToConsole('info', 'No active conversation found when submitting message, creating a new one', { sessionId }); + try { + await handleCreateNewConversation(); + } catch (err) { + console.error('Error creating new conversation before sending message:', err); + // Continue with the chat request anyway, as the backend might handle this + } + } + + // ENHANCEMENT: Try to ensure we have a valid sessionId + const effectiveSessionId = sessionId || + (activeConversation ? activeConversation.session_id : null) || + localStorage.getItem('chatSessionId'); + + if (!effectiveSessionId) { + console.error('No valid sessionId found from any source'); + setError('Session ID is missing. Please refresh the page.'); + setIsProcessing(false); + return; + } + + try { + // Log the sessionId to help debug + console.log('Sending chat request with sessionId:', effectiveSessionId); + + const fetchOptions = getFetchDefaults(); + const response = await fetchWithTimeout(`${BACKEND_URL}/chat`, { + ...fetchOptions, + method: 'POST', + body: JSON.stringify({ + message: currentMessage, + sessionId: effectiveSessionId + }) + }); + + if (!response.ok) { + const errorData = await response.json(); + // If the server says chat is not initialized, reset UI to show upload screen + if (errorData.error === 'Chat system not initialized') { + setIsInitialized(false); + throw new Error('Chat system not initialized. Please upload files first.'); + } + throw new Error(errorData.error || 'Failed to get response'); + } + + const responseData = await response.json(); + + let assistantMessage; + if (responseData.status === 'success' && responseData.data) { + assistantMessage = { + role: 'assistant', + content: responseData.data.response || '', + sources: responseData.data.sources || [], + reasoning: responseData.data.reasoning || [], + images: responseData.data.images || [] + }; + } else if (responseData.result) { + assistantMessage = { + role: 'assistant', + content: responseData.result.response || '', + sources: responseData.result.sources || [], + reasoning: responseData.result.reasoning || [], + images: responseData.result.images || [] + }; + } else { + assistantMessage = { + role: 'assistant', + content: responseData.response || '', + sources: responseData.sources || [], + reasoning: responseData.reasoning || [], + images: responseData.images || [] + }; + } + + console.log('Processed assistant message:', assistantMessage); + setMessages(prev => [...prev, assistantMessage]); + setError(null); + + // Update the conversations list to get the AI-generated title + // Add a longer delay to allow backend to generate title using OpenAI + setTimeout(() => { + // Need a longer delay for OpenAI to generate the title + updateConversationsList(); + + // Check again after a bit longer in case the first attempt was too soon + setTimeout(() => { + updateConversationsList(); + }, 3000); // Check again after 3 more seconds + }, 2000); // Initial 2 second delay + + } catch (e) { + console.error('Error in chat:', e); + setError('Failed to process response. Please try again.'); + } finally { + setIsProcessing(false); + } + }; + +// Helper function to slice file into chunks and upload them +const uploadFileInChunks = async (file, fileType, sessionId, controller) => { + const chunkSize = 1024 * 1024; // 1MB chunks + const totalChunks = Math.ceil(file.size / chunkSize); + + // Step 1: Initialize the chunked upload + const fetchOptions = getFetchDefaults(); + const initResponse = await fetch(`${BACKEND_URL}/init-chunked-upload`, { + method: 'POST', + headers: { + ...fetchOptions.headers, + 'Content-Type': 'application/json' + }, + signal: controller.signal, + body: JSON.stringify({ + fileName: file.name, + fileType: fileType, // 'brief' or 'supporting' + fileSize: file.size, + mimeType: file.type, + totalChunks, + sessionId + }) + }); + + if (!initResponse.ok) { + const error = await initResponse.json(); + throw new Error(error.message || 'Failed to initialize chunked upload'); + } + + const { uploadId } = await initResponse.json(); + + // Step 2: Upload each chunk + for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { + const start = chunkIndex * chunkSize; + const end = Math.min(start + chunkSize, file.size); + const chunk = file.slice(start, end); + + const chunkFormData = new FormData(); + chunkFormData.append('chunk', chunk); + chunkFormData.append('uploadId', uploadId); + chunkFormData.append('chunkIndex', chunkIndex); + + const fetchOptions = getFetchDefaults(); + const chunkResponse = await fetch(`${BACKEND_URL}/upload-chunk`, { + method: 'POST', + body: chunkFormData, + signal: controller.signal, + headers: { + ...fetchOptions.headers, + 'X-Requested-With': 'XMLHttpRequest' + } + }); + + if (!chunkResponse.ok) { + throw new Error(`Failed to upload chunk ${chunkIndex}`); + } + } + + // Step 3: Finalize the upload + const finalizeOptions = getFetchDefaults(); + const finalizeResponse = await fetch(`${BACKEND_URL}/finalize-upload`, { + method: 'POST', + headers: { + ...finalizeOptions.headers, + 'Content-Type': 'application/json' + }, + signal: controller.signal, + body: JSON.stringify({ uploadId }) + }); + + if (!finalizeResponse.ok) { + throw new Error('Failed to finalize upload'); + } + + return { fileName: file.name, uploadId }; +}; + +// Main function to initialize chat +const initializeChat = async () => { + if (briefFiles.length === 0) { + setError('Please upload at least one brief file.'); + return; + } + + logToConsole('info', 'Starting chat initialization', { + briefFilesCount: briefFiles.length, + supportingFilesCount: supportingFiles.length + }); + + setIsProcessing(true); + + // Create an AbortController for proper timeout handling + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + controller.abort(); + logToConsole('error', 'Request timed out after 10 minutes'); + }, 600000); // 10 minute timeout + + try { + // Check for large files that might need chunked upload + const largeFiles = [...briefFiles, ...supportingFiles].filter(file => file.size > 1024 * 1024); + if (largeFiles.length > 0) { + logToConsole('info', 'Large files detected, using chunked upload approach', { + largeFiles: largeFiles.map(f => ({ name: f.name, size: f.size })) + }); + } + + // Upload all brief files + const briefUploads = []; + for (const file of briefFiles) { + if (file.size > 1024 * 1024) { // If file is larger than 1MB, use chunked upload + const result = await uploadFileInChunks(file, 'brief', sessionId, controller); + briefUploads.push(result); + } else { + // For small files, use regular upload + const smallFormData = new FormData(); + smallFormData.append('file', file); + smallFormData.append('fileType', 'brief'); + smallFormData.append('sessionId', sessionId); + + const fetchOptions = getFetchDefaults(); + const response = await fetch(`${BACKEND_URL}/upload-small-file`, { + method: 'POST', + body: smallFormData, + signal: controller.signal, + headers: { + ...fetchOptions.headers, + 'X-Requested-With': 'XMLHttpRequest' + } + }); + + if (!response.ok) { + throw new Error(`Failed to upload small file: ${file.name}`); + } + + const result = await response.json(); + briefUploads.push(result); + } + } + + // Upload all supporting files (similar approach) + const supportingUploads = []; + for (const file of supportingFiles) { + if (file.size > 1024 * 1024) { + const result = await uploadFileInChunks(file, 'supporting', sessionId, controller); + supportingUploads.push(result); + } else { + // Small file upload code similar to brief files + const smallFormData = new FormData(); + smallFormData.append('file', file); + smallFormData.append('fileType', 'supporting'); + smallFormData.append('sessionId', sessionId); + + const fetchOptions = getFetchDefaults(); + const response = await fetch(`${BACKEND_URL}/upload-small-file`, { + method: 'POST', + body: smallFormData, + signal: controller.signal, + headers: { + ...fetchOptions.headers, + 'X-Requested-With': 'XMLHttpRequest' + } + }); + + if (!response.ok) { + throw new Error(`Failed to upload small file: ${file.name}`); + } + + const result = await response.json(); + supportingUploads.push(result); + } + } + + // Now that all files are uploaded, initialize the chat + const finalizeData = { + sessionId, + briefFiles: briefUploads, + supportingFiles: supportingUploads + }; + + const finalizeOptions = getFetchDefaults(); + const finalizeResponse = await fetch(`${BACKEND_URL}/initialize-from-uploads`, { + method: 'POST', + headers: { + ...finalizeOptions.headers, + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + }, + body: JSON.stringify(finalizeData), + signal: controller.signal + }); + + // Clear the timeout + clearTimeout(timeoutId); + + if (!finalizeResponse.ok) { + const errorData = await finalizeResponse.json(); + throw new Error(errorData.error || `Server responded with ${finalizeResponse.status}`); + } + + const data = await finalizeResponse.json(); + + logToConsole('info', 'Chat initialization successful'); + setIsInitialized(true); + setMessages([{ + role: 'assistant', + content: 'Chat initialized! How can I help you?' + }]); + setError(null); + + } catch (err) { + // Clear the timeout if there's an error + clearTimeout(timeoutId); + + // Enhanced error logging + const errorDetails = { + message: err.message, + name: err.name, + stack: err.stack, + isAbortError: err.name === 'AbortError' + }; + + logToConsole('error', 'Initialization error', errorDetails); + console.error('Initialization error:', err); + + // Set appropriate error message for user + if (err.name === 'AbortError') { + setError('Request timed out. The file(s) may be too large.'); + } else { + setError(err.message || 'Failed to initialize chat'); + } + + setIsInitialized(false); + } finally { + setIsProcessing(false); + } +}; + + return ( + +
+ {/* Conversation Sidebar - Fixed to left side */} +
+
+

Conversations

+ +
+ +
+ {loadingConversations ? ( +
+ +
+ ) : conversations.length > 0 ? ( + conversations.map(convo => ( +
+
+
handleLoadConversation(convo)} + > +
{convo.title || "New conversation"}
+
+ {new Date(convo.last_updated).toLocaleDateString()} +
+
+ +
+
+ )) + ) : ( +
+ No conversations yet +
+ )} +
+ +
+
+
+ {getCurrentUser()} + +
+ + {!autoWidth && ( + + )} +
+
+
+ + {/* Resize handle */} +
+ + {/* Header with Netflix logo - completely rebuilt without any borders */} +
+ {/* Theme toggle in top right corner */} +
+ +
+ {/* Netflix N Logo Image */} + Netflix N Logo +

Playbook Bot

+
+ + {/* Main Chat Area - Centered with left margin to account for sidebar */} +
+
+
+ {isCheckingStatus ? ( +
+ +

Connecting to Netflix GPD Key Art Playbook Chatbot...

+
+ ) : !isInitialized ? ( +
+

Netflix GPD Key Art Playbook Chatbot

+

+ The system is currently initializing. Please try again later. +

+ +
+ ) : ( + <> +
+
+

+ {activeConversation?.title || "Netflix GPD Key Art Playbook Chatbot"} +

+
+ +
+
+

+ Ask questions about Netflix's artwork creation, branding guidelines, workflows, and processes. +

+
+ +
+
+ {messages.map((message, index) => ( + + ))} +
+
+
+ +
+ setInputMessage(e.target.value)} + onKeyPress={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); // Prevent any default behavior + if (!isProcessing && inputMessage.trim()) { + handleSubmit(); + } + } + }} + placeholder="Type your message..." + className="flex-1 p-2 border dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded focus:outline-none focus:ring-2 focus:ring-blue-500" + disabled={isProcessing} + /> + +
+ + )} + + {error && !isCheckingStatus && ( + + {error} + + )} +
+
+
+
+ + ); +} \ No newline at end of file diff --git a/chat-interface/src/App_11-19.jsx b/chat-interface/src/App_11-19.jsx new file mode 100644 index 0000000..6b8d72e --- /dev/null +++ b/chat-interface/src/App_11-19.jsx @@ -0,0 +1,691 @@ +import { useState, useRef, useEffect } from 'react'; +import { Send, Upload, Loader2, X, FileText, Info, FileDown } from 'lucide-react'; +import { Alert, AlertDescription } from "./components/ui/alert"; +import * as Tooltip from '@radix-ui/react-tooltip'; + +const BACKEND_URL = 'http://localhost:6173'; + +const fetchDefaults = { + mode: 'cors', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'Accept': '*/*', + 'Access-Control-Allow-Origin': '*', + } +}; + +export default function ChatInterface() { + const [messages, setMessages] = useState([]); + const [inputMessage, setInputMessage] = useState(''); + const [isProcessing, setIsProcessing] = useState(false); + const [briefFiles, setBriefFiles] = useState([]); + const [supportingFiles, setSupportingFiles] = useState([]); + const [isInitialized, setIsInitialized] = useState(false); + const [error, setError] = useState(null); + + const messagesEndRef = useRef(null); + const fileInputBriefRef = useRef(null); + const fileInputSupportingRef = useRef(null); + + useEffect(() => { + // Enable CORS for all fetch requests + fetch.defaults = fetchDefaults; + }, []); + + const logToConsole = (type, message, data = null) => { + const timestamp = new Date().toISOString(); + const logMessage = { + timestamp, + type, + message, + data + }; + console.log(JSON.stringify(logMessage, null, 2)); + }; + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }; + + useEffect(() => { + scrollToBottom(); + }, [messages]); + + const handleFilesChange = (e, type) => { + const files = Array.from(e.target.files); + logToConsole('info', `Handling ${type} files change`, { + fileCount: files.length, + fileDetails: files.map(f => ({ + name: f.name, + type: f.type, + size: f.size + })) + }); + + if (type === 'brief') { + setBriefFiles(prev => [...prev, ...files]); + } else { + setSupportingFiles(prev => [...prev, ...files]); + } + }; + + const removeFile = (fileName, type) => { + logToConsole('info', `Removing file`, { + fileName, + type + }); + + if (type === 'brief') { + setBriefFiles(prev => prev.filter(file => file.name !== fileName)); + } else { + setSupportingFiles(prev => prev.filter(file => file.name !== fileName)); + } + }; + + const MessageBubble = ({ message }) => { + // Helper function to safely extract source content + const formatSources = (sources) => { + if (!sources || !Array.isArray(sources)) return []; + + return sources.map((source) => { + // Handle the nested structure from the backend + if (source && typeof source === 'object') { + // Check for the different possible source structures + if (source.content) { + return { + text: typeof source.content === 'string' + ? source.content + : JSON.stringify(source.content), + tool: source.tool_name || '' + }; + } + // Fallback for other source structures + return { + text: JSON.stringify(source), + tool: '' + }; + } + // Handle string sources + return { + text: String(source), + tool: '' + }; + }).filter(source => source.text); // Remove empty sources + }; + + // Helper function to format reasoning steps + const formatReasoning = (reasoning) => { + if (!reasoning || !Array.isArray(reasoning)) return []; + + return reasoning.map((step, index) => { + // Handle the new reasoning step structure + const stepType = step.type || 'thought'; + let content = ''; + + if (step.action) { + content = `${step.action}${step.action_input ? `: ${step.action_input}` : ''}`; + } else if (step.observation) { + content = step.observation; + } else if (step.response) { + content = step.response; + } else if (step.thought) { + content = step.thought; + } + + return { + id: index, + type: stepType, + content: content, + thought: step.thought + }; + }).filter(step => step.content); + }; + + const sources = formatSources(message.sources); + const reasoningSteps = formatReasoning(message.reasoning); + + const handleDownloadBrief = async () => { + // First, send the message to generate the brief + const briefRequestMessage = "write a comprehensive, detailed, and organized marketing brief based on the entire history of this conversation"; + setMessages(prev => [...prev, { role: 'user', content: briefRequestMessage }]); + setIsProcessing(true); + + try { + // First request to generate the brief content + const response = await fetch(`${BACKEND_URL}/chat`, { + ...fetchDefaults, + method: 'POST', + body: JSON.stringify({ message: briefRequestMessage }) + }); + + if (!response.ok) { + throw new Error('Failed to generate brief'); + } + + const responseData = await response.json(); + + // Add the assistant's response to the chat + setMessages(prev => [...prev, { + role: 'assistant', + content: responseData.data.response, + sources: responseData.data.sources, + reasoning: responseData.data.reasoning + }]); + + // Then make a second request to download the document + const downloadResponse = await fetch(`${BACKEND_URL}/download-brief`, { + ...fetchDefaults, + method: 'POST', + body: JSON.stringify({ brief_content: responseData.data.response }) + }); + + if (!downloadResponse.ok) { + throw new Error('Failed to download brief'); + } + + // Get the blob from the response + const blob = await downloadResponse.blob(); + + // Create a download link and trigger it + const downloadUrl = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = downloadUrl; + link.download = 'marketing_brief.docx'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(downloadUrl); + + } catch (e) { + console.error('Error in download brief:', e); + setError('Failed to download brief. Please try again.'); + } finally { + setIsProcessing(false); + } + }; + + + return ( + +
+ {!isInitialized ? ( + // ... (initialization UI remains the same) + ) : ( + <> +
+ {messages.map((message, index) => ( + + ))} +
+
+ +
+ setInputMessage(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && handleSubmit()} + placeholder="Type your message..." + className="flex-1 p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500" + disabled={isProcessing} + /> + + +
+ + )} + + {error && ( + + {error} + + )} +
+ + ); + } + + + // return ( + //
+ //
+ //
{message.content}
+ + //
+ // {sources.length > 0 && ( + // + // + // + // + // + // + // + //
+ //

Sources Used:

+ //
    + // {sources.map((source, idx) => ( + //
  • + // {source.tool && ( + //
    + // Tool: {source.tool} + //
    + // )} + //
    {source.text}
    + //
  • + // ))} + //
+ //
+ // + //
+ //
+ //
+ //
+ // )} + + // {reasoningSteps.length > 0 && ( + // + // + // + // + // + // + // + //
+ //

Reasoning Steps:

+ //
    + // {reasoningSteps.map((step) => ( + //
  • + //
    + // {step.type}: + //
    + //
    {step.content}
    + // {step.thought && step.thought !== step.content && ( + //
    + // Thought: {step.thought} + //
    + // )} + //
  • + // ))} + //
+ //
+ // + //
+ //
+ //
+ //
+ // )} + //
+ //
+ //
+ // ); + // }; + + + + const handleSubmit = async () => { + if (!inputMessage.trim()) return; + + const currentMessage = inputMessage; + setMessages(prev => [...prev, { role: 'user', content: currentMessage }]); + setInputMessage(''); + setIsProcessing(true); + + try { + const response = await fetch(`${BACKEND_URL}/chat`, { + ...fetchDefaults, + method: 'POST', + body: JSON.stringify({ message: currentMessage }) + }); + + if (!response.ok) { + throw new Error('Failed to get response'); + } + + const responseData = await response.json(); + + // Log the full response data for debugging + console.log('Full response data:', responseData); + + // Extract the relevant data based on the response structure + let assistantMessage; + if (responseData.status === 'success' && responseData.data) { + // New response structure + assistantMessage = { + role: 'assistant', + content: responseData.data.response || '', + sources: responseData.data.sources || [], + reasoning: responseData.data.reasoning || [] + }; + } else if (responseData.result) { + // Alternative response structure + assistantMessage = { + role: 'assistant', + content: responseData.result.response || '', + sources: responseData.result.sources || [], + reasoning: responseData.result.reasoning || [] + }; + } else { + // Fallback for direct response structure + assistantMessage = { + role: 'assistant', + content: responseData.response || '', + sources: responseData.sources || [], + reasoning: responseData.reasoning || [] + }; + } + + // Log the processed message for debugging + console.log('Processed assistant message:', assistantMessage); + + setMessages(prev => [...prev, assistantMessage]); + setError(null); + + } catch (e) { + console.error('Error in chat:', e); + setError('Failed to process response. Please try again.'); + } finally { + setIsProcessing(false); + } + }; + + + + const initializeChat = async () => { + if (briefFiles.length === 0) { + setError('Please upload at least one brief file.'); + return; + } + + logToConsole('info', 'Starting chat initialization', { + briefFilesCount: briefFiles.length, + supportingFilesCount: supportingFiles.length, + briefFiles: briefFiles.map(f => ({ + name: f.name, + type: f.type, + size: f.size + })), + supportingFiles: supportingFiles.map(f => ({ + name: f.name, + type: f.type, + size: f.size + })) + }); + + setIsProcessing(true); + const formData = new FormData(); + + briefFiles.forEach(file => { + formData.append('brief', file); + logToConsole('debug', 'Appending brief file to FormData', { + fileName: file.name, + fileType: file.type, + fileSize: file.size + }); + }); + + supportingFiles.forEach(file => { + formData.append('supporting', file); + logToConsole('debug', 'Appending supporting file to FormData', { + fileName: file.name, + fileType: file.type, + fileSize: file.size + }); + }); + + try { + logToConsole('info', 'Sending initialization request to backend'); + + const response = await fetch(`${BACKEND_URL}/initialize`, { + method: 'POST', + mode: 'cors', + credentials: 'include', + body: formData, + // headers: { + // 'Accept': '*/*', + // 'Access-Control-Allow-Origin': '*', + // } + }); + + logToConsole('debug', 'Received response from backend', { + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()) + }); + + const textResponse = await response.text(); + logToConsole('debug', 'Response text received', { textResponse }); + + let data; + try { + data = JSON.parse(textResponse); + logToConsole('debug', 'Successfully parsed response JSON', { data }); + } catch (e) { + logToConsole('error', 'Failed to parse response JSON', { + error: e.message, + textResponse + }); + throw new Error('Invalid response format from server'); + } + + if (!response.ok) { + logToConsole('error', 'Backend returned error response', { + status: response.status, + error: data.error + }); + throw new Error(data.error || 'Failed to initialize chat'); + } + + setIsInitialized(true); + setMessages([{ + role: 'assistant', + content: 'Chat initialized! How can I help you?' + }]); + + setError(null); + logToConsole('info', 'Chat initialization completed successfully'); + + } catch (err) { + logToConsole('error', 'Chat initialization failed', { + error: err.message, + stack: err.stack + }); + + let errorMessage = 'Failed to initialize chat. '; + if (err.message) { + errorMessage += err.message; + } else { + errorMessage += 'Please try again.'; + } + + setError(errorMessage); + setIsInitialized(false); + } finally { + setIsProcessing(false); + } + }; + + return ( + +
+ {!isInitialized ? ( +
+

Upload Files to Start Chat

+ +
+

Brief Files (Required)

+
+ handleFilesChange(e, 'brief')} + multiple + accept=".pdf,.doc,.docx,.txt,.xls,.xlsx,.ppt,.pptx,.eml" + className="hidden" + /> + +
+
+ {briefFiles.map((file) => ( +
+ + {file.name} + +
+ ))} +
+
+ +
+

Supporting Files (Optional)

+
+ handleFilesChange(e, 'supporting')} + multiple + accept=".pdf,.doc,.docx,.txt,.xls,.xlsx,.ppt,.pptx,.eml" + className="hidden" + /> + +
+
+ {supportingFiles.map((file) => ( +
+ + {file.name} + +
+ ))} +
+
+ + +
+ ) : ( + <> +
+ {messages.map((message, index) => ( + + ))} +
+
+ +
+ setInputMessage(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && handleSubmit()} + placeholder="Type your message..." + className="flex-1 p-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500" + disabled={isProcessing} + /> + +
+ + )} + + {error && ( + + {error} + + )} +
+ + ); +} diff --git a/chat-interface/src/assets/react.svg b/chat-interface/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/chat-interface/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/chat-interface/src/auth.js b/chat-interface/src/auth.js new file mode 100644 index 0000000..5caa704 --- /dev/null +++ b/chat-interface/src/auth.js @@ -0,0 +1,119 @@ +// MSAL Authentication Configuration +const msalConfig = { + auth: { + clientId: "9079054c-9620-4757-a256-23413042f1ef", + authority: "https://login.microsoftonline.com/e519c2e6-bc6d-4fdf-8d9c-923c2f002385", + redirectUri: "https://ai-sandbox.oliver.solutions/format" + }, + cache: { + cacheLocation: "sessionStorage", + storeAuthStateInCookie: true, + } +}; + +const loginRequest = { + scopes: ["user.read"] +}; + +// Initialize MSAL object +let myMSALObj = null; +let thisUser = null; + +// Initialize MSAL when loaded +export function initializeMSAL() { + if (typeof msal !== 'undefined') { + myMSALObj = new msal.PublicClientApplication(msalConfig); + + // Check if there's a cached user + const accounts = myMSALObj.getAllAccounts(); + if (accounts.length > 0) { + thisUser = accounts[0].username; + // User is already logged in, show content + showProtectedContent(); + } else { + // Need to initialize login elements if not logged in + const signinButton = document.getElementById('signin-button'); + if (signinButton) { + signinButton.addEventListener('click', signIn); + } + } + } else { + console.error("MSAL library not loaded"); + } +} + +// Sign in with popup +export function signIn() { + if (!myMSALObj) { + initializeMSAL(); + } + + myMSALObj.loginPopup(loginRequest) + .then(loginResponse => { + console.log("User logged in:", loginResponse.account.username); + thisUser = loginResponse.account.username; + sessionStorage.setItem('msalUsername', loginResponse.account.username); + showProtectedContent(); + }).catch(error => { + console.error("Error during login:", error); + }); +} + +// Show the protected content (the chat interface) +function showProtectedContent() { + const loginContainer = document.getElementById('login-container'); + const rootContainer = document.getElementById('root'); + + if (loginContainer) { + loginContainer.style.display = 'none'; + } + + if (rootContainer) { + rootContainer.style.display = 'block'; + } + + // Dispatch an event that authentication is complete + window.dispatchEvent(new CustomEvent('authenticationComplete', { + detail: { + username: thisUser + } + })); +} + +// Get current user +export function getCurrentUser() { + if (!thisUser) { + thisUser = sessionStorage.getItem('msalUsername'); + } + return thisUser; +} + +// Check if user is authenticated +export function isAuthenticated() { + if (!myMSALObj) { + initializeMSAL(); + } + + return myMSALObj?.getAllAccounts().length > 0 || !!getCurrentUser(); +} + +// Sign out +export function signOut() { + if (!myMSALObj) { + initializeMSAL(); + } + + const logoutRequest = { + account: myMSALObj.getAccountByUsername(thisUser) + }; + + myMSALObj.logout(logoutRequest).then(() => { + thisUser = null; + sessionStorage.removeItem('msalUsername'); + // Redirect to login page + window.location.reload(); + }); +} + +// Register initialization on window load +window.addEventListener('DOMContentLoaded', initializeMSAL); diff --git a/chat-interface/src/components/ChatInterface.jsx b/chat-interface/src/components/ChatInterface.jsx new file mode 100644 index 0000000..046f5c0 --- /dev/null +++ b/chat-interface/src/components/ChatInterface.jsx @@ -0,0 +1,140 @@ +// Use environment variables for backend URL +// Define backend URL dynamically based on environment +const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'https://ai-sandbox.oliver.solutions/netflix_back_v2'; +console.log('ChatInterface using backend URL:', BACKEND_URL); + +const initializeChat = async ({ sessionId, briefFiles, supportingFiles, setError, setIsProcessing, setIsInitialized, setMessages }) => { + if (briefFiles.length === 0) { + setError('Please upload at least one brief file.'); + return; + } + + setIsProcessing(true); + const formData = new FormData(); + + // Add session ID to the form data + formData.append('sessionId', sessionId); + + briefFiles.forEach(file => { + formData.append('brief', file); + }); + + supportingFiles.forEach(file => { + formData.append('supporting', file); + }); + + try { + console.log('Sending initialize request...'); + const response = await fetch(`${BACKEND_URL}/initialize`, { + method: 'POST', + body: formData, // No Content-Type header for FormData + credentials: 'include', // Include credentials (cookies, auth headers) + }); + + console.log('Response status:', response.status); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to initialize chat'); + } + + const data = await response.json(); + console.log('Response data:', data); + + setIsInitialized(true); + setMessages([{ + role: 'assistant', + content: 'Chat initialized! How can I help you?' + }]); + setError(null); // Clear any previous errors + } catch (err) { + console.error('Error:', err); + setError(err.message || 'Failed to initialize chat. Please try again.'); + } finally { + setIsProcessing(false); + } +}; + +const handleSubmit = async ({ inputMessage, sessionId, setMessages, setInputMessage, setIsProcessing, setError, logToConsole }) => { + if (!inputMessage.trim()) return; + + const currentMessage = inputMessage; + setMessages(prev => [...prev, { role: 'user', content: currentMessage }]); + setInputMessage(''); + setIsProcessing(true); + + try { + const response = await fetch(`${BACKEND_URL}/chat`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + message: currentMessage, + sessionId: sessionId + }), + credentials: 'include', // Include credentials for cross-origin + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'Failed to get response'); + } + + const dataString = await response.text(); // Get the response as text + const data = JSON.parse(dataString); // Parse the JSON string + + // Extract response data, checking for different response formats + let responseText = ''; + let sources = []; + let reasoning = []; + let images = []; + + if (data.data) { + // New response format + responseText = data.data.response || ''; + sources = data.data.sources || []; + reasoning = data.data.reasoning || []; + images = data.data.images || []; + } else { + // Direct response format + responseText = data.response || ''; + sources = data.sources || []; + reasoning = data.reasoning || []; + images = data.images || []; + } + + // Log the images + if (images && images.length > 0) { + logToConsole('info', 'Images received in response', { imageCount: images.length, images }); + } + + setMessages(prev => { + const newMessages = [...prev]; + newMessages.push({ + role: 'assistant', + content: responseText, + sources: sources, + reasoning: reasoning, + images: images + }); + return newMessages; + }); + setError(null); // Clear any previous error + + } catch (e) { // Include the original JSON string in the error data + console.error('Error processing chat response:', e); + logToConsole('error', 'Chat error', { + error: e.message, + stack: e.stack, + response: e.response + }); + + setError('Failed to process response. Please check the logs for details.'); // Display a more user friendly error in UI + } finally { + setIsProcessing(false); + } +}; + +// Export the functions +export { initializeChat, handleSubmit }; \ No newline at end of file diff --git a/chat-interface/src/components/ConversationManager.jsx b/chat-interface/src/components/ConversationManager.jsx new file mode 100644 index 0000000..1c75c0a --- /dev/null +++ b/chat-interface/src/components/ConversationManager.jsx @@ -0,0 +1,206 @@ +// Get the backend URL from environment or use correct path as fallback +// Define backend URL dynamically based on environment +const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'https://ai-sandbox.oliver.solutions/netflix_back_v2'; +console.log('ConversationManager using backend URL:', BACKEND_URL); + +// Function to load user conversations +export const loadUserConversations = async (username, setConversations, setActiveConversation, loadConversation, setError) => { + try { + const response = await fetch(`${BACKEND_URL}/conversations`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-MS-USERNAME': username || '' // Add authenticated username to request + }, + credentials: 'include' + }); + + if (!response.ok) { + throw new Error('Failed to load conversations'); + } + + const data = await response.json(); + + if (data.status === 'success') { + setConversations(data.conversations || []); + + // If we have conversations and none are active, select the most recent one + if (data.conversations && data.conversations.length > 0 && setActiveConversation) { + // Sort by last_updated, newest first + const sortedConversations = [...data.conversations].sort((a, b) => + new Date(b.last_updated) - new Date(a.last_updated) + ); + + // Set the most recent conversation as active + setActiveConversation(sortedConversations[0]); + + // Load this conversation if load function provided + if (loadConversation) { + await loadConversation(sortedConversations[0]); + } + } + + return data.conversations || []; + } else { + throw new Error(data.error || 'Failed to load conversations'); + } + } catch (error) { + console.error('Error loading conversations:', error); + if (setError) { + setError('Failed to load conversations. Please try again later.'); + } + return []; + } +}; + +// Function to load a specific conversation's messages +export const loadConversationMessages = async (conversation, username, setMessages, setSessionId, setError) => { + try { + if (!conversation || !conversation.id) { + throw new Error('Invalid conversation'); + } + + const response = await fetch(`${BACKEND_URL}/conversations/${conversation.id}/messages`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'X-MS-USERNAME': username || '' // Add authenticated username to request + }, + credentials: 'include' + }); + + if (!response.ok) { + throw new Error('Failed to load conversation messages'); + } + + const data = await response.json(); + + if (data.status === 'success') { + // Format the messages for the UI + const formattedMessages = data.messages.map(msg => ({ + role: msg.role, + content: msg.content, + sources: msg.sources || [], + reasoning: msg.reasoning || [], + images: msg.images || [] + })); + + setMessages(formattedMessages); + + // Set the session ID if provided + if (setSessionId && conversation.session_id) { + setSessionId(conversation.session_id); + localStorage.setItem('chatSessionId', conversation.session_id); + } + + return formattedMessages; + } else { + throw new Error(data.error || 'Failed to load conversation messages'); + } + } catch (error) { + console.error('Error loading conversation messages:', error); + if (setError) { + setError('Failed to load conversation messages. Please try again later.'); + } + return []; + } +}; + +// Function to create a new conversation +export const createNewConversation = async (username, setConversations, setActiveConversation, setSessionId, setMessages, setError) => { + try { + const response = await fetch(`${BACKEND_URL}/conversations/new`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-MS-USERNAME': username || '' // Add authenticated username to request + }, + credentials: 'include' + }); + + if (!response.ok) { + throw new Error('Failed to create new conversation'); + } + + const data = await response.json(); + + if (data.status === 'success') { + // Create a new conversation object + const newConversation = { + id: data.conversation_id, + title: "New conversation", + created_at: new Date().toISOString(), + last_updated: new Date().toISOString(), + session_id: data.session_id + }; + + // Update the conversations list + setConversations(prev => [newConversation, ...prev]); + + // Set as active conversation + setActiveConversation(newConversation); + + // Set the session ID + if (setSessionId) { + setSessionId(data.session_id); + localStorage.setItem('chatSessionId', data.session_id); + } + + // Reset messages with welcome message + if (setMessages) { + setMessages([{ + role: 'assistant', + content: 'How can I help you today?' + }]); + } + + return newConversation; + } else { + throw new Error(data.error || 'Failed to create new conversation'); + } + } catch (error) { + console.error('Error creating new conversation:', error); + if (setError) { + setError('Failed to create new conversation. Please try again later.'); + } + return null; + } +}; + +// Function to delete a conversation +export const deleteConversation = async (conversationId, username, setConversations, setError) => { + try { + if (!conversationId) { + throw new Error('Invalid conversation ID'); + } + + const response = await fetch(`${BACKEND_URL}/conversations/${conversationId}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'X-MS-USERNAME': username || '' // Add authenticated username to request + }, + credentials: 'include' + }); + + if (!response.ok) { + throw new Error('Failed to delete conversation'); + } + + const data = await response.json(); + + if (data.status === 'success') { + // Remove the conversation from the list + setConversations(prev => prev.filter(convo => convo.id !== conversationId)); + return true; + } else { + throw new Error(data.error || 'Failed to delete conversation'); + } + } catch (error) { + console.error('Error deleting conversation:', error); + if (setError) { + setError('Failed to delete conversation. Please try again later.'); + } + return false; + } +}; \ No newline at end of file diff --git a/chat-interface/src/components/ThemeToggle.jsx b/chat-interface/src/components/ThemeToggle.jsx new file mode 100644 index 0000000..e2fe897 --- /dev/null +++ b/chat-interface/src/components/ThemeToggle.jsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { useTheme } from '../main'; +import { Moon, Sun } from 'lucide-react'; + +export default function ThemeToggle() { + const { darkMode, setDarkMode } = useTheme(); + + return ( + + ); +} diff --git a/chat-interface/src/components/ui/alert.jsx b/chat-interface/src/components/ui/alert.jsx new file mode 100644 index 0000000..8437323 --- /dev/null +++ b/chat-interface/src/components/ui/alert.jsx @@ -0,0 +1,37 @@ +import * as React from "react" +import { cn } from "../../lib/utils" + +const Alert = React.forwardRef(({ className, variant = "default", children, ...props }, ref) => { + const variantClasses = { + default: "bg-gray-100 text-gray-900", + destructive: "bg-red-100 text-red-900", + } + + return ( +
+ {children} +
+ ) +}) +Alert.displayName = "Alert" + +const AlertDescription = React.forwardRef(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertDescription } + diff --git a/chat-interface/src/index.css b/chat-interface/src/index.css new file mode 100644 index 0000000..5e4ccd3 --- /dev/null +++ b/chat-interface/src/index.css @@ -0,0 +1,131 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --border: 214.3 31.8% 91.4%; + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --border: 217.2 32.6% 17.5%; + } + + body { + @apply bg-black text-foreground; + border: none !important; + border-top: none !important; + border-bottom: none !important; + } +} + +/* Netflix theme colors */ +.netflix-red { + color: #E50914; +} + +.netflix-bg { + background-color: #141414; +} + +/* Prevent any border from appearing */ +.no-borders, .no-borders::before, .no-borders::after { + border: none !important; + border-top: none !important; + border-bottom: none !important; + border-left: none !important; + border-right: none !important; + outline: none !important; +} + +/* Markdown content styling */ +.markdown-content { + @apply text-base leading-relaxed; +} + +.markdown-content h1 { + @apply text-2xl font-bold mt-4 mb-2; +} + +.markdown-content h2 { + @apply text-xl font-bold mt-3 mb-2; +} + +.markdown-content h3 { + @apply text-lg font-bold mt-3 mb-1; +} + +.markdown-content p { + @apply my-2; +} + +.markdown-content ul { + @apply list-disc pl-5 my-2; +} + +.markdown-content ol { + @apply list-decimal pl-5 my-2; +} + +.markdown-content li { + @apply my-1; +} + +.markdown-content a { + @apply text-blue-600 hover:underline; +} + +.markdown-content code { + @apply font-mono bg-gray-200 px-1 py-0.5 rounded text-sm; +} + +.markdown-content pre { + @apply bg-gray-800 text-white p-3 rounded my-3 overflow-auto; +} + +.markdown-content pre code { + @apply bg-transparent text-white text-sm; +} + +.markdown-content blockquote { + @apply border-l-4 border-gray-300 pl-4 my-3 italic text-gray-700; +} + +.markdown-content table { + @apply border-collapse border border-gray-300 my-3 w-full; +} + +.markdown-content th { + @apply border border-gray-300 bg-gray-100 p-2 font-semibold; +} + +.markdown-content td { + @apply border border-gray-300 p-2; +} + +/* Different background for assistant markdown vs user text */ +.bg-blue-500 .markdown-content { + @apply text-white; +} + +.bg-blue-500 .markdown-content code { + @apply bg-blue-600 text-white; +} + +.bg-blue-500 .markdown-content a { + @apply text-white underline; +} + +.bg-blue-500 .markdown-content blockquote { + @apply border-white/50 text-white/90; +} + diff --git a/chat-interface/src/lib/utils.js b/chat-interface/src/lib/utils.js new file mode 100644 index 0000000..d510289 --- /dev/null +++ b/chat-interface/src/lib/utils.js @@ -0,0 +1,40 @@ +// Original utils.js file content +import { clsx } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs) { + return twMerge(clsx(inputs)) +} + +/** + * Enhanced fetch function with a 300-second timeout + * @param {string} url - URL to fetch + * @param {Object} options - Fetch options + * @returns {Promise} - Fetch promise with timeout + */ +export async function fetchWithTimeout(url, options = {}) { + const timeout = 300000; // 300 seconds in milliseconds + + const controller = new AbortController(); + const { signal } = controller; + + const timeoutId = setTimeout(() => { + controller.abort(); + }, timeout); + + try { + const response = await fetch(url, { + ...options, + signal, + }); + + clearTimeout(timeoutId); + return response; + } catch (error) { + clearTimeout(timeoutId); + if (error.name === 'AbortError') { + throw new Error(`Request timed out after ${timeout / 1000} seconds`); + } + throw error; + } +} \ No newline at end of file diff --git a/chat-interface/src/main.jsx b/chat-interface/src/main.jsx new file mode 100644 index 0000000..3e85a5a --- /dev/null +++ b/chat-interface/src/main.jsx @@ -0,0 +1,75 @@ +import React, { useEffect, useState, createContext, useContext } from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './index.css' +import { initializeMSAL, isAuthenticated } from './auth' + +// Initialize MSAL +initializeMSAL(); + +// Create Theme Context +export const ThemeContext = createContext(); + +export const ThemeProvider = ({ children }) => { + const [darkMode, setDarkMode] = useState(() => { + // Check local storage for user preference + const savedMode = localStorage.getItem('darkMode'); + return savedMode === 'true'; + }); + + // Apply dark mode class to html element + useEffect(() => { + if (darkMode) { + document.documentElement.classList.add('dark'); + } else { + document.documentElement.classList.remove('dark'); + } + // Save preference to localStorage + localStorage.setItem('darkMode', darkMode); + }, [darkMode]); + + return ( + + {children} + + ); +}; + +// Custom hook for using theme context +export const useTheme = () => useContext(ThemeContext); + +const AuthenticatedApp = () => { + const [authenticated, setAuthenticated] = useState(isAuthenticated()); + + useEffect(() => { + // Listen for authentication complete event + const handleAuth = () => { + setAuthenticated(true); + }; + + window.addEventListener('authenticationComplete', handleAuth); + + // Check if already authenticated + if (isAuthenticated()) { + handleAuth(); + } + + return () => { + window.removeEventListener('authenticationComplete', handleAuth); + }; + }, []); + + // The App component will only be rendered when user is authenticated + return authenticated ? ( + + + + ) : null; +}; + +ReactDOM.createRoot(document.getElementById('root')).render( + + + , +) + diff --git a/chat-interface/tailwind.config.js b/chat-interface/tailwind.config.js new file mode 100644 index 0000000..2955b1a --- /dev/null +++ b/chat-interface/tailwind.config.js @@ -0,0 +1,28 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,jsx,ts,tsx}", + ], + darkMode: 'class', // Enable class-based dark mode + theme: { + extend: { + colors: { + border: "hsl(var(--border))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))" + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + }, + }, + plugins: [], +} + diff --git a/chat-interface/update-backend.sh b/chat-interface/update-backend.sh new file mode 100755 index 0000000..71e7e06 --- /dev/null +++ b/chat-interface/update-backend.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +# This script updates the backend URL and rebuilds the application + +# Define the backend URL +BACKEND_URL="https://ai-sandbox.oliver.solutions/netflix_back_v2" + +# Create or update the environment files +echo "# Backend API URL +VITE_BACKEND_URL=${BACKEND_URL} + +# Base URL for the app (changes in production) +VITE_APP_BASE_URL=/" > .env + +echo "# Production backend API URL +VITE_BACKEND_URL=${BACKEND_URL} + +# Base URL for the app in production +VITE_APP_BASE_URL=/netflix_v2/" > .env.production + +# Update components files +for file in src/components/ChatInterface.jsx src/components/ConversationManager.jsx src/App.jsx; do + # Replace hardcoded backend URL references + sed -i '' "s|const BACKEND_URL = ['\"]\/netflix_back_v2['\"]|const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || '${BACKEND_URL}'|g" $file + sed -i '' "s|console.log('.*using backend URL (hardcoded):|console.log('Using backend URL:|g" $file +done + +# Update vite.config.js to use the replacement plugin +sed -i '' 's|plugins: \[react()\],|plugins: [react(), replaceBackendUrl],|g' vite.config.js +sed -i '' 's|code: src.replace(/\['"'\]\/netflix_back\['"'\]/g, '"\/netflix_back_v2"'),|code: src.replace(/\['"'\]\/netflix_back\['"'\]/g, `"${BACKEND_URL}"`),|g' vite.config.js + +# Force a complete rebuild by removing all assets +rm -rf dist/assets/* + +# Rebuild the application +npm run build + +echo "Update complete! The backend URL has been set to ${BACKEND_URL}" \ No newline at end of file diff --git a/chat-interface/vite.config.js b/chat-interface/vite.config.js new file mode 100644 index 0000000..400991d --- /dev/null +++ b/chat-interface/vite.config.js @@ -0,0 +1,124 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'path' + +export default defineConfig(({ mode }) => { + const baseUrl = mode === 'production' ? '/netflix_v2/' : '/'; + + // Create a replace plugin to ensure all '/netflix_back' gets replaced with the full URL + const replaceBackendUrl = { + name: 'replace-backend-url', + transform(src, id) { + if (id.endsWith('.js') || id.endsWith('.jsx')) { + return { + code: src.replace(/['"]\/netflix_back['"]/g, '"https://ai-sandbox.oliver.solutions/netflix_back_v2"'), + map: null + }; + } + } + }; + + return { + plugins: [react(), replaceBackendUrl], + base: baseUrl, + server: { + port: 5173, + proxy: { + // New MongoDB conversation endpoints + '/conversations': { + target: 'https://ai-sandbox.oliver.solutions/netflix_back_v2', + changeOrigin: true, + secure: true, + timeout: 300000 // 5 minutes timeout + }, + '/conversations/new': { + target: 'https://ai-sandbox.oliver.solutions/netflix_back_v2', + changeOrigin: true, + secure: true, + timeout: 300000 // 5 minutes timeout + }, + + // Original endpoints + '/chat': { + target: 'https://ai-sandbox.oliver.solutions/netflix_back_v2', + changeOrigin: true, + secure: true, + timeout: 300000 // 5 minutes timeout + }, + '/status': { + target: 'https://ai-sandbox.oliver.solutions/netflix_back_v2', + changeOrigin: true, + secure: true, + timeout: 300000 // 5 minutes timeout + }, + '/initialize': { + target: 'https://ai-sandbox.oliver.solutions/netflix_back_v2', + changeOrigin: true, + secure: true, + timeout: 300000 // 5 minutes timeout + }, + '/reset': { + target: 'https://ai-sandbox.oliver.solutions/netflix_back_v2', + changeOrigin: true, + secure: true, + timeout: 300000 // 5 minutes timeout + }, + '/download-brief': { + target: 'https://ai-sandbox.oliver.solutions/netflix_back_v2', + changeOrigin: true, + secure: true, + timeout: 300000 // 5 minutes timeout + }, + '/init-chunked-upload': { + target: 'https://ai-sandbox.oliver.solutions/netflix_back_v2', + changeOrigin: true, + secure: true, + timeout: 300000 // 5 minutes timeout + }, + '/upload-chunk': { + target: 'https://ai-sandbox.oliver.solutions/netflix_back_v2', + changeOrigin: true, + secure: true, + timeout: 300000 // 5 minutes timeout + }, + '/finalize-upload': { + target: 'https://ai-sandbox.oliver.solutions/netflix_back_v2', + changeOrigin: true, + secure: true, + timeout: 300000 // 5 minutes timeout + }, + '/upload-small-file': { + target: 'https://ai-sandbox.oliver.solutions/netflix_back_v2', + changeOrigin: true, + secure: true, + timeout: 300000 // 5 minutes timeout + }, + '/initialize-from-uploads': { + target: 'https://ai-sandbox.oliver.solutions/netflix_back_v2', + changeOrigin: true, + secure: true, + timeout: 300000 // 5 minutes timeout + }, + '/images': { + target: 'https://ai-sandbox.oliver.solutions/netflix_back_v2', + changeOrigin: true, + secure: true, + timeout: 300000 // 5 minutes timeout + }, + '/list-images': { + target: 'https://ai-sandbox.oliver.solutions/netflix_back_v2', + changeOrigin: true, + secure: true, + timeout: 300000 // 5 minutes timeout + }, + '/capture-screenshot': { + target: 'https://ai-sandbox.oliver.solutions/netflix_back_v2', + changeOrigin: true, + secure: true, + timeout: 300000 // 5 minutes timeout + } + } + } + }; +}) + diff --git a/chat-interface/web.config b/chat-interface/web.config new file mode 100644 index 0000000..75ec618 --- /dev/null +++ b/chat-interface/web.config @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..fb588f0 --- /dev/null +++ b/config.py @@ -0,0 +1,103 @@ +# netflix_chatbot/config.py +import os +from pathlib import Path +from dotenv import load_dotenv +import sys + +# Load environment variables from .env file +env_path = Path(__file__).resolve().parent / '.env' +if env_path.exists(): + # Force reload to ensure environment variables are set + load_dotenv(dotenv_path=env_path, override=True) + print(f"Loaded environment variables from {env_path}") +else: + print(f"WARNING: .env file not found at {env_path}", file=sys.stderr) + +# --- Directory Paths --- +BASE_DIR = Path(__file__).resolve().parent + +UPLOAD_DIR = BASE_DIR / 'uploads' +CHUNK_FOLDER = UPLOAD_DIR / 'chunks' +UPLOAD_METADATA_FOLDER = UPLOAD_DIR / 'metadata' +IMAGES_DIRECTORY = UPLOAD_DIR / 'images' + +SUPPORTING_FILES_DIR = BASE_DIR / 'supporting_files' +NETFLIX_DOCS_FOLDER = SUPPORTING_FILES_DIR / 'files_for_rag_store' + +INDEX_STORAGE_DIR = BASE_DIR / 'index_storage' +INDEX_PERSIST_PATH = INDEX_STORAGE_DIR / "netflix_docs_index" + +LOG_FILE_PATH = BASE_DIR / 'app.log' + +# Create necessary directories +os.makedirs(CHUNK_FOLDER, exist_ok=True) +os.makedirs(UPLOAD_METADATA_FOLDER, exist_ok=True) +os.makedirs(INDEX_STORAGE_DIR, exist_ok=True) +os.makedirs(IMAGES_DIRECTORY, exist_ok=True) + +# --- Application Settings --- +ALLOWED_EXTENSIONS = {'pdf', 'doc', 'docx', 'txt', 'xls', 'xlsx', 'ppt', 'pptx', 'eml'} +APPLICATION_ROOT = os.environ.get('APPLICATION_ROOT', '') # For running behind proxy +MAX_CONTENT_LENGTH = 500 * 1024 * 1024 # 500MB limit (adjust as needed) + +# --- API Keys --- +# Load from environment variables or use defaults (replace placeholders or set env vars) +OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "") +ANTHROPIC_API_KEY = os.environ.get("ANTHROPIC_API_KEY", "") +LLAMA_CLOUD_API_KEY = os.environ.get("LLAMA_CLOUD_API_KEY", "") + +# Ensure required keys are set +if not OPENAI_API_KEY: + print("ERROR: OPENAI_API_KEY not set in environment or .env file. This is required.", file=sys.stderr) + print("Please add OPENAI_API_KEY=your_key to your .env file.", file=sys.stderr) + print(f"Current environment keys: {list(filter(lambda k: 'key' in k.lower(), os.environ.keys()))}", file=sys.stderr) + +# Always set environment variables, even if empty - the error messages will be handled by the code +os.environ["OPENAI_API_KEY"] = OPENAI_API_KEY +os.environ["ANTHROPIC_API_KEY"] = ANTHROPIC_API_KEY +os.environ["LLAMA_CLOUD_API_KEY"] = LLAMA_CLOUD_API_KEY + +# Print API key status for debugging +print(f"OpenAI API key {'is set' if OPENAI_API_KEY else 'is NOT set'}", file=sys.stderr) + +# --- AI Model Configuration --- +LLM_MODEL = "chatgpt-4o-latest" # Or "gpt-4o" etc. +EMBEDDING_MODEL = "text-embedding-3-small" +LLM_TEMPERATURE = 0.3 +LLM_TIMEOUT = 300.0 # 5 minutes +AGENT_TIMEOUT = 600.0 # 10 minutes for the agent run +TOOL_EXECUTION_TIMEOUT = 300.0 # 5 minutes for individual tool calls + +# --- LlamaParse Configuration --- +LLAMA_PARSE_VENDOR_MODEL = "openai-gpt4o" # Verify model name +LLAMA_PARSE_MAX_TIMEOUT = 3600 # 1 hour + +# --- Indexing Configuration --- +# NODE_PARSER_CHUNK_SIZE = 2048 # Example if using SentenceSplitter +# NODE_PARSER_CHUNK_OVERLAP = 20 +# Use Semantic Splitter by default (see ai_core.py) +SIMILARITY_TOP_K = 10 +SIMILARITY_CUTOFF = 0.0 # Adjust if needed + +# --- CORS Configuration --- +CORS_ALLOWED_ORIGINS = ["http://localhost:5173", "https://ai-sandbox.oliver.solutions"] # Add production frontend URL +CORS_SUPPORTS_CREDENTIALS = True + +# --- Server Configuration --- +SERVER_HOST = "0.0.0.0" if os.environ.get("PRODUCTION", "false").lower() == "true" else "localhost" +SERVER_PORT = int(os.environ.get("PORT", "6175")) # Changed to 6175 +USE_RELOADER = os.environ.get("PRODUCTION", "false").lower() != "true" +LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO") # Changed default to INFO + +# Hypercorn specific timeouts (in seconds) +KEEP_ALIVE_TIMEOUT = 300 +READ_TIMEOUT = 300 +WRITE_TIMEOUT = 300 + +# --- MongoDB Configuration --- +# Assumes mongodb_utils handles connection details (e.g., via environment variables) + +# --- Neo4j Configuration --- +NEO4J_URL = os.environ.get("NEO4J_URL", "bolt://localhost:7687") +NEO4J_USERNAME = os.environ.get("NEO4J_USERNAME", "neo4j") +NEO4J_PASSWORD = os.environ.get("NEO4J_PASSWORD", "tavern-easy-museum-arthur-coconut-3483") # Default password from graphRAG.py \ No newline at end of file diff --git a/db/docker-compose.yml b/db/docker-compose.yml new file mode 100644 index 0000000..f97db7c --- /dev/null +++ b/db/docker-compose.yml @@ -0,0 +1,18 @@ +version: '3' +services: + mongodb: + image: mongo:latest + container_name: mongodb + ports: + - "27017:27017" + # Comment out authentication for local development + # environment: + # MONGO_INITDB_ROOT_USERNAME: netflix + # MONGO_INITDB_ROOT_PASSWORD: netflix + volumes: + - mongodb_data:/data/db + restart: always + +volumes: + mongodb_data: + driver: local diff --git a/document_generator.py b/document_generator.py new file mode 100644 index 0000000..0916e87 --- /dev/null +++ b/document_generator.py @@ -0,0 +1,339 @@ +# netflix_chatbot/document_generator.py + +import io +import re +import markdown2 +from bs4 import BeautifulSoup +from docx import Document +from docx.shared import Inches, Pt, RGBColor +from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK +from docx.oxml.shared import OxmlElement, qn +from docx.oxml import parse_xml + +from utils import log_structured + +# --- Helper for Horizontal Line --- +def add_horizontal_line(paragraph): + """Adds a horizontal line after the specified paragraph.""" + p = paragraph._p # the lxml element beneath the paragraph + pPr = p.get_or_add_pPr() # Get or add paragraph properties element + pBdr = OxmlElement('w:pBdr') # Create paragraph border element + # Add a bottom border + bottom_bdr = OxmlElement('w:bottom') + bottom_bdr.set(qn('w:val'), 'single') # Border style + bottom_bdr.set(qn('w:sz'), '6') # Border size (in 1/8 points) + bottom_bdr.set(qn('w:space'), '1') # Space between text and border + bottom_bdr.set(qn('w:color'), 'auto') # Border color + pBdr.append(bottom_bdr) + pPr.append(pBdr) + +# --- Inline Markdown to DOCX Run Formatting --- +def process_inline_formatting(paragraph, text): + """ + Processes simple inline markdown (bold, italic, code) within text + and adds formatted runs to the paragraph. + Handles nested formatting cautiously. + """ + # Regex to find **bold**, *italic*, _italic_, `code` segments + # It captures the marker and the content separately. + pattern = r'(\*\*|`|\*|_)(.*?)(\1)' + last_end = 0 + + for match in re.finditer(pattern, text): + start, end = match.span() + marker = match.group(1) + content = match.group(2) + + # Add preceding text if any + if start > last_end: + paragraph.add_run(text[last_end:start]) + + # Add formatted run + run = paragraph.add_run(content) + if marker == '**': + run.bold = True + elif marker == '*' or marker == '_': + run.italic = True + elif marker == '`': + run.font.name = 'Courier New' + # run.font.size = Pt(10) # Optional: Set size for code + + last_end = end + + # Add any remaining text after the last match + if last_end < len(text): + paragraph.add_run(text[last_end:]) + + +# --- HTML to DOCX Conversion --- +def convert_html_to_docx(doc: Document, html_content: str): + """ + Converts basic HTML content (from markdown conversion) to Word elements. + Handles common tags like paragraphs, headings, lists, bold, italic, code. + """ + # Pre-process HTML slightly for cleaner parsing + html_content = re.sub(r'\s*\n\s*', '\n', html_content).strip() # Normalize whitespace + html_content = f"{html_content}" # Wrap in body for better parsing + soup = BeautifulSoup(html_content, 'html.parser') + + # Recursive function to handle elements + def process_element(element, current_paragraph=None, current_style=None, in_list=False): + # Skip NavigableString if it's just whitespace or newline outside pre + if isinstance(element, str): + text = str(element).strip('\n') # Keep internal spaces, strip leading/trailing newlines + if text: # Only add if there's actual content + if current_paragraph: + run = current_paragraph.add_run(text) + if current_style: + if 'bold' in current_style: run.bold = True + if 'italic' in current_style: run.italic = True + if 'code' in current_style: run.font.name = 'Courier New' + else: + # Text outside paragraph usually means an error or whitespace + # log_structured('debug', f"Orphan text node found: '{text[:50]}...'") + pass # Or create a default paragraph: doc.add_paragraph(text) + return + + # --- Block Level Elements --- + if element.name in ['p', 'div']: + # Avoid creating paragraphs for empty containers unless they contain
+ text_content = element.get_text(strip=True) + has_br = element.find('br') + if text_content or has_br: + para = doc.add_paragraph() + # Apply list indentation if necessary (though lists handle their own paras) + # if in_list: para.paragraph_format.left_indent = Inches(0.5) + new_style = current_style.copy() if current_style else set() + for child in element.children: + process_element(child, para, new_style, in_list) + # else: skip empty p/div + + elif element.name in ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']: + try: + level = int(element.name[1]) + heading = doc.add_heading(level=level) + # Process children for inline formatting within heading + new_style = current_style.copy() if current_style else set() + for child in element.children: + process_element(child, heading, new_style, in_list) + # If no children processed (just text), add it directly + if not heading.runs: + heading.add_run(element.get_text(strip=True)) + except ValueError: pass # Should not happen with h1-h6 + + elif element.name == 'ul': + for li in element.find_all('li', recursive=False): + # Each li gets its own paragraph with bullet style + para = doc.add_paragraph(style='List Bullet') + new_style = current_style.copy() if current_style else set() + for child in li.children: + process_element(child, para, new_style, in_list=True) + # If li was empty or only contained whitespace + if not para.text.strip(): + para.text = "" # Ensure empty bullet point exists + + elif element.name == 'ol': + # Numbering is handled by the 'List Number' style + for li in element.find_all('li', recursive=False): + para = doc.add_paragraph(style='List Number') + new_style = current_style.copy() if current_style else set() + for child in li.children: + process_element(child, para, new_style, in_list=True) + if not para.text.strip(): + para.text = "" # Ensure empty numbered item exists + + # Note: 'li' is handled within 'ul'/'ol' processing. + + elif element.name == 'pre': + # Often contains a 'code' element, handle that + code_tag = element.find('code') + content = code_tag.get_text() if code_tag else element.get_text() + if content.strip(): + para = doc.add_paragraph(style='CodeStyle') # Requires 'CodeStyle' to be defined + # Preserve whitespace more carefully for
+                  run = para.add_run(content.strip('\n')) # Strip outer newlines only
+                  run.font.name = 'Courier New'
+                  # run.font.size = Pt(10)
+
+        elif element.name == 'blockquote':
+              para = doc.add_paragraph(style='Quote') # Requires 'Quote' style
+              new_style = current_style.copy() if current_style else set()
+              for child in element.children:
+                  process_element(child, para, new_style, in_list)
+
+        elif element.name == 'hr':
+             para = doc.add_paragraph()
+             add_horizontal_line(para)
+
+        elif element.name == 'br':
+            if current_paragraph:
+                current_paragraph.add_run().add_break() # Add line break within paragraph
+
+
+        # --- Inline Elements ---
+        elif element.name in ['strong', 'b']:
+            new_style = current_style.copy() if current_style else set()
+            new_style.add('bold')
+            for child in element.children:
+                process_element(child, current_paragraph, new_style, in_list)
+
+        elif element.name in ['em', 'i']:
+            new_style = current_style.copy() if current_style else set()
+            new_style.add('italic')
+            for child in element.children:
+                process_element(child, current_paragraph, new_style, in_list)
+
+        elif element.name == 'code':
+            # Handle inline code - assumes it's within a paragraph already
+            if current_paragraph:
+                text = element.get_text()
+                if text:
+                    run = current_paragraph.add_run(text)
+                    run.font.name = 'Courier New'
+                    # Add specific inline code style if desired
+            else:
+                 # Code tag not within a paragraph? Create one.
+                 para = doc.add_paragraph(style='CodeStyle')
+                 run = para.add_run(element.get_text())
+                 run.font.name = 'Courier New'
+
+
+        elif element.name == 'a':
+            # Add hyperlink if possible, otherwise just text
+            text = element.get_text(strip=True)
+            href = element.get('href')
+            if current_paragraph and text:
+                # python-docx doesn't have direct hyperlink support easily added here.
+                # Simplest: add text with underline and blue color.
+                run = current_paragraph.add_run(text)
+                run.underline = True
+                run.font.color.rgb = RGBColor(0x05, 0x63, 0xC1) # Standard link blue
+                # For actual hyperlinks, more complex XML manipulation is needed.
+
+
+        # --- Body/Other Tags: Process children ---
+        elif element.name in ['body', 'span', 'div']: # Treat span/div mostly as containers
+            new_style = current_style.copy() if current_style else set()
+            for child in element.children:
+                 process_element(child, current_paragraph, new_style, in_list)
+
+        # --- Ignored Tags ---
+        elif element.name in ['script', 'style', 'head', 'meta', 'title']:
+             pass # Ignore these tags and their content
+
+        else:
+            # Unknown tag: try to process its children if it's a container,
+            # or add its text content if it's inline-like.
+            log_structured('warning', f"Unhandled HTML tag encountered: <{element.name}>", {'content_preview': element.get_text(strip=True)[:50]})
+            # Default behavior: process children recursively
+            new_style = current_style.copy() if current_style else set()
+            for child in element.children:
+                 process_element(child, current_paragraph, new_style, in_list)
+
+
+    # Start processing from the top-level elements within the parsed body
+    body = soup.find('body')
+    if body:
+        for element in body.children:
+            process_element(element)
+
+
+# --- Main Markdown to DOCX Function ---
+def create_brief_docx(brief_content_markdown: str) -> io.BytesIO:
+    """
+    Creates a Word document (.docx) in memory from markdown content.
+
+    Args:
+        brief_content_markdown: The markdown string content.
+
+    Returns:
+        An io.BytesIO buffer containing the Word document.
+    """
+    doc = Document()
+
+    # --- Define Styles (Optional but recommended) ---
+    styles = doc.styles
+    # Normal style
+    style = styles['Normal']
+    font = style.font
+    font.name = 'Calibri' # Or Netflix specific font like 'Graphik' if installed
+    font.size = Pt(11)
+
+    # Code style (example)
+    try:
+        code_style = styles.add_style('CodeStyle', 1) # 1 for paragraph style
+        code_style.font.name = 'Courier New'
+        code_style.font.size = Pt(10)
+        # Prevent spell check for code blocks
+        code_style.element.rPr.rFonts.set(qn('w:ascii'), 'Courier New')
+        code_style.element.rPr.rFonts.set(qn('w:hAnsi'), 'Courier New')
+        # code_style.element.xpath('./w:rPr/w:lang')[0].set(qn('w:noProof'), '1') # Requires lxml maybe
+        p_fmt = code_style.paragraph_format
+        p_fmt.space_before = Pt(6)
+        p_fmt.space_after = Pt(6)
+    except ValueError:
+         log_structured('warning', "'CodeStyle' already exists. Using existing.")
+         code_style = styles['CodeStyle'] # Use existing if it fails to add
+
+    # Quote style (example)
+    try:
+         quote_style = styles.add_style('QuoteStyle', 1)
+         quote_style.font.italic = True
+         quote_style.paragraph_format.left_indent = Inches(0.5)
+         quote_style.paragraph_format.space_before = Pt(6)
+         quote_style.paragraph_format.space_after = Pt(6)
+    except ValueError:
+         log_structured('warning', "'QuoteStyle' already exists. Using existing.")
+         quote_style = styles['QuoteStyle']
+
+
+    # --- Document Header ---
+    title = doc.add_heading('Marketing Brief', 0)
+    title.alignment = WD_ALIGN_PARAGRAPH.CENTER
+
+    date_para = doc.add_paragraph()
+    date_para.alignment = WD_ALIGN_PARAGRAPH.RIGHT
+    date_run = date_para.add_run(datetime.now().strftime("%B %d, %Y"))
+    date_run.italic = True
+    # Add some space after date
+    date_para.paragraph_format.space_after = Pt(12)
+
+    # Add a horizontal line separator
+    hr_para = doc.add_paragraph()
+    add_horizontal_line(hr_para)
+    hr_para.paragraph_format.space_after = Pt(18) # Space after the line
+
+
+    # --- Convert Markdown to HTML ---
+    # Using markdown2 with recommended extras for broad compatibility
+    extras = [
+        "tables", "fenced-code-blocks", "header-ids", "footnotes",
+        "task_list", "code-friendly", "cuddled-lists", "markdown-in-html",
+        "strike", "spoiler", "target-blank-links", "smarty-pants" # Added smarty-pants
+    ]
+    html_content = markdown2.markdown(brief_content_markdown, extras=extras)
+
+    log_structured('debug', 'Converted markdown to HTML for DOCX generation', {
+        'md_preview': brief_content_markdown[:200],
+        'html_preview': html_content[:300]
+    })
+
+    # --- Convert HTML to Word Document Elements ---
+    try:
+        convert_html_to_docx(doc, html_content)
+    except Exception as conversion_err:
+         log_structured('error', "Error during HTML to DOCX conversion", {
+             'error': str(conversion_err),
+             'traceback': traceback.format_exc()
+         })
+         # Add error message to the document itself
+         doc.add_paragraph("Error: Could not fully convert content from HTML to DOCX.", style='Emphasis')
+         doc.add_paragraph(str(conversion_err))
+
+
+    # --- Save to Buffer ---
+    doc_buffer = io.BytesIO()
+    doc.save(doc_buffer)
+    doc_buffer.seek(0)
+
+    return doc_buffer
\ No newline at end of file
diff --git a/documentation/Project Tudum - Feedback.xlsx b/documentation/Project Tudum - Feedback.xlsx
new file mode 100644
index 0000000..27d0915
Binary files /dev/null and b/documentation/Project Tudum - Feedback.xlsx differ
diff --git a/flowchart.md b/flowchart.md
new file mode 100644
index 0000000..896bff6
--- /dev/null
+++ b/flowchart.md
@@ -0,0 +1,116 @@
+```mermaid
+graph TD
+    %% Main system components
+    Client[Chat Interface Frontend]
+    Server[Flask Server]
+    MongoDB[(MongoDB)]
+    Neo4j[(Neo4j)]
+    LLM[OpenAI LLM API]
+
+    %% Initialization flow
+    Init[System Initialization] --> |1. Start server| Server
+    Server --> |2. Connect to MongoDB| MongoDB
+    Server --> |3. Load or create index| IdxInit
+    Server --> |4. Connect to Neo4j| Neo4j
+
+    %% Subgraphs for clarity
+    subgraph "Initialization"
+        IdxInit[Initialize Global Index]
+        IdxInit --> |Load existing index| LoadIdx[Load from storage]
+        IdxInit --> |Build new index| BuildIdx[Process documents]
+        
+        BuildIdx --> LlamaParser[LlamaParse Text & Images]
+        LlamaParser --> VectorIdx[Create Vector Index]
+        LlamaParser --> ExtractImages[Extract Images]
+        
+        LoadIdx --> LoadGraph[Load GraphRAG components]
+        VectorIdx --> CreateGraph[Create GraphRAG components]
+        
+        CreateGraph --> KGExtract[Extract entities & relations]
+        KGExtract --> CommDetect[Community detection]
+        CommDetect --> CommSummary[Generate community summaries]
+        
+        LoadGraph -.-> |Check cache| CommCache[Load community cache]
+    end
+
+    %% Chat processing flow
+    Client --> |5. Send message with sessionId| Server
+    Server --> |6. Get session state| SessionManager
+    
+    subgraph "Session Management"
+        SessionManager[Session Manager]
+        SessionManager --> |Check cache| MemCache[In-memory cache]
+        SessionManager --> |Check DB| SessionDB[Session in MongoDB]
+        SessionManager --> |Create if needed| NewSession[Create new session]
+        NewSession --> NewConv[Create conversation]
+    end
+    
+    Server --> |7. Store user message| MongoDB
+    Server --> |8. Process query| Agent
+    
+    subgraph "Agent Workflow"
+        Agent[ReAct Agent]
+        Agent --> |Select tool| Tools
+        
+        subgraph "Query Tools"
+            Tools[Query Tools]
+            VectorTool[Vector Query Tool]
+            GraphTool[GraphRAG Tool]
+            
+            Tools --> VectorTool
+            Tools --> GraphTool
+            
+            VectorTool --> VectorRetriever[Vector Retriever]
+            VectorRetriever --> VectorRank[Top-K Similarity]
+            
+            GraphTool --> DualRetrieval[Dual Retrieval]
+            DualRetrieval --> VectorRetriever
+            DualRetrieval --> GraphRetriever[Graph Community Retriever]
+            
+            GraphRetriever --> ExtractEntities[Extract entities]
+            ExtractEntities --> MapCommunities[Map to communities]
+            MapCommunities --> GetSummaries[Get community summaries]
+        end
+        
+        VectorRank --> |Context| GenResponse[Generate Response]
+        GetSummaries --> |Context| GenResponse
+        GenResponse --> |Use LLM| LLM
+    end
+    
+    Agent --> |9. Format response with sources & images| Response
+    Response --> |10. Store assistant message| MongoDB
+    Response --> |11. Send response to client| Client
+    
+    %% Image handling flow
+    subgraph "Image Processing"
+        ImgProcess[Image Handling]
+        ExtractImages --> SaveImages[Save to images directory]
+        SaveImages --> LinkToNodes[Link images to nodes]
+        Client --> |12. Request images| ImgEndpoint[Image endpoint]
+        ImgEndpoint --> ServeImage[Serve image files]
+    end
+
+    %% Conversation management
+    subgraph "Conversation Management"
+        ConvMgmt[Conversation Management]
+        Client --> |List conversations| GetConvs[Get conversations]
+        GetConvs --> MongoDB
+        Client --> |Get messages| GetMsgs[Get messages]
+        GetMsgs --> MongoDB
+        Client --> |Create new conversation| CreateConv[Create conversation]
+        CreateConv --> MongoDB
+        Client --> |Delete conversation| DeleteConv[Delete conversation]
+        DeleteConv --> MongoDB
+    end
+
+%% Styling
+classDef primary fill:#3498db,stroke:#2980b9,color:white;
+classDef secondary fill:#2ecc71,stroke:#27ae60,color:white;
+classDef storage fill:#9b59b6,stroke:#8e44ad,color:white;
+classDef apiService fill:#e74c3c,stroke:#c0392b,color:white;
+
+class Client,Server,Agent,Response primary;
+class SessionManager,Tools,ImgProcess,ConvMgmt secondary;
+class MongoDB,Neo4j storage;
+class LLM apiService;
+```
\ No newline at end of file
diff --git a/graphRAG.py b/graphRAG.py
new file mode 100644
index 0000000..b90b79a
--- /dev/null
+++ b/graphRAG.py
@@ -0,0 +1,581 @@
+import os
+import json
+import re
+import asyncio
+import networkx as nx
+from collections import defaultdict
+from typing import Any, List, Callable, Optional, Union, Dict
+from dotenv import load_dotenv
+
+# Import LlamaIndex components
+from llama_index.core import Document, Settings
+from llama_index.core.node_parser import SentenceSplitter
+from llama_index.core import PropertyGraphIndex
+from llama_index.core.async_utils import run_jobs
+from llama_index.core.indices.property_graph.utils import default_parse_triplets_fn
+from llama_index.core.graph_stores.types import EntityNode, KG_NODES_KEY, KG_RELATIONS_KEY, Relation
+from llama_index.core.llms.llm import LLM
+from llama_index.core.prompts import PromptTemplate
+from llama_index.core.prompts.default_prompts import DEFAULT_KG_TRIPLET_EXTRACT_PROMPT
+from llama_index.core.schema import TransformComponent, BaseNode
+from llama_index.core.query_engine import CustomQueryEngine
+from llama_index.llms.openai import OpenAI
+from llama_index.core.llms import ChatMessage
+from llama_index.graph_stores.neo4j import Neo4jPropertyGraphStore
+from llama_index.core import SimpleDirectoryReader
+
+# Community detection (using NetworkX instead of graspologic as a fallback)
+try:
+    from community import best_partition  # python-louvain package
+except ImportError:
+    print("Community detection package not found, using NetworkX built-in community detection")
+
+# Load environment variables from .env file and set OpenAI API key from netflix_back_end.py
+load_dotenv()
+
+# Use API key from netflix_back_end.py as fallback
+if not os.environ.get("OPENAI_API_KEY"):
+    os.environ["OPENAI_API_KEY"] = "sk-proj-wXcoIn81Vwg4Iaw0vhmYT3BlbkFJmt1eOxeEAF1juUfhzMtk"
+
+# Define the GraphRAGExtractor class
+class GraphRAGExtractor(TransformComponent):
+    """Extract triples from a graph.
+
+    Uses an LLM and a simple prompt + output parsing to
+    extract paths (i.e. triples) and entity, relation descriptions
+    from text.
+
+    Args:
+        llm (LLM):
+            The language model to use.
+        extract_prompt (Union[str, PromptTemplate]):
+            The prompt to use for extracting triples.
+        parse_fn (callable):
+            A function to parse the output of the language
+            model.
+        num_workers (int):
+            The number of workers to use for parallel
+            processing.
+        max_paths_per_chunk (int):
+            The maximum number of paths to extract per chunk.
+    """
+
+    llm: LLM
+    extract_prompt: PromptTemplate
+    parse_fn: Callable
+    num_workers: int
+    max_paths_per_chunk: int
+
+    def __init__(
+        self,
+        llm: Optional[LLM] = None,
+        extract_prompt: Optional[Union[str, PromptTemplate]] = None,
+        parse_fn: Callable = default_parse_triplets_fn,
+        max_paths_per_chunk: int = 10,
+        num_workers: int = 4,
+    ) -> None:
+        """Init params."""
+        from llama_index.core import Settings
+
+        if isinstance(extract_prompt, str):
+            extract_prompt = PromptTemplate(extract_prompt)
+
+        super().__init__(
+            llm=llm or Settings.llm,
+            extract_prompt=extract_prompt or DEFAULT_KG_TRIPLET_EXTRACT_PROMPT,
+            parse_fn=parse_fn,
+            num_workers=num_workers,
+            max_paths_per_chunk=max_paths_per_chunk,
+        )
+
+    @classmethod
+    def class_name(cls) -> str:
+        return "GraphExtractor"
+
+    def __call__(
+        self, nodes: List[BaseNode], show_progress: bool = False, **kwargs: Any
+    ) -> List[BaseNode]:
+        """Extract triples from nodes."""
+        return asyncio.run(
+            self.acall(nodes, show_progress=show_progress, **kwargs)
+        )
+
+    async def _aextract(self, node: BaseNode) -> BaseNode:
+        """Extract triples from a node."""
+        assert hasattr(node, "text")
+
+        text = node.get_content(metadata_mode="llm")
+        try:
+            llm_response = await self.llm.apredict(
+                self.extract_prompt,
+                text=text,
+                max_knowledge_triplets=self.max_paths_per_chunk,
+            )
+            entities, entities_relationship = self.parse_fn(llm_response)
+        except ValueError:
+            entities = []
+            entities_relationship = []
+
+        existing_nodes = node.metadata.pop(KG_NODES_KEY, [])
+        existing_relations = node.metadata.pop(KG_RELATIONS_KEY, [])
+        
+        entity_metadata = node.metadata.copy()
+        for entity, entity_type, description in entities:
+            entity_metadata["entity_description"] = description
+            entity_node = EntityNode(
+                name=entity, label=entity_type,
+                properties=entity_metadata
+            )
+            existing_nodes.append(entity_node)
+
+        relation_metadata = node.metadata.copy()
+        for triple in entities_relationship:
+            subj, obj, rel, description = triple
+            relation_metadata["relationship_description"] = description
+            rel_node = Relation(
+                label=rel,
+                source_id=subj,
+                target_id=obj,
+                properties=relation_metadata,
+            )
+            existing_relations.append(rel_node)
+
+        node.metadata[KG_NODES_KEY] = existing_nodes
+        node.metadata[KG_RELATIONS_KEY] = existing_relations
+        return node
+
+    async def acall(
+        self, nodes: List[BaseNode], show_progress: bool = False, **kwargs: Any
+    ) -> List[BaseNode]:
+        """Extract triples from nodes async."""
+        jobs = []
+        for node in nodes:
+            jobs.append(self._aextract(node))
+
+        return await run_jobs(
+            jobs,
+            workers=self.num_workers,
+            show_progress=show_progress,
+            desc="Extracting paths from text",
+        )
+
+# Define the GraphRAGStore class
+class GraphRAGStore(Neo4jPropertyGraphStore):
+    community_summary = {}
+    entity_info = None
+    max_cluster_size = 5
+
+    def generate_community_summary(self, text):
+        """Generate summary for a given text using an LLM."""
+        messages = [
+            ChatMessage(
+                role="system",
+                content=(
+                    "You are provided with a set of "
+                    "relationships from a knowledge graph, each represented as "
+                    "entity1->entity2->relation-"
+                    ">relationship_description. Your task is to create a summary of "
+                    "these "
+                    "relationships. The summary should include "
+                    "the names of the entities involved and a concise synthesis "
+                    "of the relationship descriptions. The "
+                    "goal is to capture the most critical and relevant details that "
+                    "highlight the nature and significance of "
+                    "each relationship. Ensure that the summary is coherent and "
+                    "integrates the information in a way that "
+                    "emphasizes the key aspects of the relationships."
+                ),
+            ),
+            ChatMessage(role="user", content=text),
+        ]
+        response = OpenAI().chat(messages)
+        clean_response = re.sub(r"^assistant:\s*", "", str(response)).strip()
+        return clean_response
+
+    def build_communities(self):
+        """Builds communities from the graph and summarizes them."""
+        nx_graph = self._create_nx_graph()
+        
+        # Use either Leiden algorithm (from graspologic) or an alternative
+        try:
+            from graspologic.partition import hierarchical_leiden
+            community_hierarchical_clusters = hierarchical_leiden(
+                nx_graph, max_cluster_size=self.max_cluster_size
+            )
+            self.entity_info, community_info = self._collect_community_info(
+                nx_graph, community_hierarchical_clusters
+            )
+        except ImportError:
+            # Fallback to community detection using NetworkX or python-louvain
+            try:
+                from community import best_partition
+                partition = best_partition(nx_graph)
+                # Reformat partition data to expected structure
+                clusters = []
+                for node, cluster_id in partition.items():
+                    class Cluster:
+                        def __init__(self, node, cluster):
+                            self.node = node
+                            self.cluster = cluster
+                    clusters.append(Cluster(node, cluster_id))
+                self.entity_info, community_info = self._collect_community_info(
+                    nx_graph, clusters
+                )
+            except ImportError:
+                # Use NetworkX's built-in community detection
+                from networkx.algorithms import community
+                communities = community.greedy_modularity_communities(nx_graph)
+                clusters = []
+                for i, comm in enumerate(communities):
+                    for node in comm:
+                        class Cluster:
+                            def __init__(self, node, cluster):
+                                self.node = node
+                                self.cluster = cluster
+                        clusters.append(Cluster(node, i))
+                self.entity_info, community_info = self._collect_community_info(
+                    nx_graph, clusters
+                )
+                
+        self._summarize_communities(community_info)
+
+    def _create_nx_graph(self):
+        """Converts internal graph representation to NetworkX graph."""
+        nx_graph = nx.Graph()
+        triplets = self.get_triplets()
+        for entity1, relation, entity2 in triplets:
+            nx_graph.add_node(entity1.name)
+            nx_graph.add_node(entity2.name)
+            nx_graph.add_edge(
+                relation.source_id,
+                relation.target_id,
+                relationship=relation.label,
+                description=relation.properties.get("relationship_description", "No description provided"),
+            )
+        return nx_graph
+
+    def _collect_community_info(self, nx_graph, clusters):
+        """
+        Collect information for each node based on their community,
+        allowing entities to belong to multiple clusters.
+        """
+        entity_info = defaultdict(set)
+        community_info = defaultdict(list)
+
+        for item in clusters:
+            node = item.node
+            cluster_id = item.cluster
+
+            # Update entity_info
+            entity_info[node].add(cluster_id)
+
+            for neighbor in nx_graph.neighbors(node):
+                edge_data = nx_graph.get_edge_data(node, neighbor)
+                if edge_data:
+                    detail = f"{node} -> {neighbor} -> {edge_data['relationship']} -> {edge_data['description']}"
+                    community_info[cluster_id].append(detail)
+
+        # Convert sets to lists for easier serialization if needed
+        entity_info = {k: list(v) for k, v in entity_info.items()}
+
+        return dict(entity_info), dict(community_info)
+
+    def _summarize_communities(self, community_info):
+        """Generate and store summaries for each community."""
+        for community_id, details in community_info.items():
+            details_text = "\n".join(details) + "." # Ensure it ends with a period
+            self.community_summary[community_id] = self.generate_community_summary(details_text)
+
+    def get_community_summaries(self):
+        """Returns the community summaries, building them if not already done."""
+        if not self.community_summary:
+            self.build_communities()
+        return self.community_summary
+
+# Define the GraphRAGQueryEngine class
+class GraphRAGQueryEngine(CustomQueryEngine):
+    graph_store: Union[GraphRAGStore, Any]  # Accept any type of graph store
+    index: PropertyGraphIndex
+    llm: LLM
+    similarity_top_k: int = 20
+
+    def custom_query(self, query_str: str) -> str:
+        """Process query using either community-based approach or direct retrieval."""
+        # Check if we're using GraphRAGStore with communities or SimplePropertyGraphStore
+        if hasattr(self.graph_store, 'get_community_summaries'):
+            # GraphRAG approach with communities
+            entities = self.get_entities(query_str, self.similarity_top_k)
+            
+            community_ids = self.retrieve_entity_communities(
+                self.graph_store.entity_info, entities
+            )
+            community_summaries = self.graph_store.get_community_summaries()
+            community_answers = [
+                self.generate_answer_from_summary(community_summary, query_str)
+                for id, community_summary in community_summaries.items()
+                if id in community_ids
+            ]
+
+            final_answer = self.aggregate_answers(community_answers)
+            return final_answer
+        else:
+            # Simple approach for SimplePropertyGraphStore
+            # Just get relevant nodes and generate answer
+            nodes = self.index.as_retriever(
+                similarity_top_k=self.similarity_top_k
+            ).retrieve(query_str)
+            
+            if not nodes:
+                return "I couldn't find any relevant information to answer your question."
+            
+            # Combine text from all retrieved nodes
+            context = "\n\n".join([node.get_content() for node in nodes])
+            
+            # Generate answer using the LLM
+            prompt = f"Based on the following information, please answer this question: {query_str}\n\nInformation:\n{context}"
+            messages = [
+                ChatMessage(role="system", content=prompt),
+                ChatMessage(role="user", content="Please provide a comprehensive answer based on the information provided.")
+            ]
+            response = self.llm.chat(messages)
+            return str(response).strip()
+
+    def get_entities(self, query_str, similarity_top_k):
+        nodes_retrieved = self.index.as_retriever(
+            similarity_top_k=similarity_top_k
+        ).retrieve(query_str)
+
+        entities = set()
+        pattern = r"^(\w+(?:\s+\w+)*)\s*->\s*([a-zA-Z\s]+?)\s*->\s*(\w+(?:\s+\w+)*)$"
+
+        for node in nodes_retrieved:
+            matches = re.findall(
+                pattern, node.text, re.MULTILINE | re.IGNORECASE
+            )
+            for match in matches:
+                subject = match[0]
+                obj = match[2]
+                entities.add(subject)
+                entities.add(obj)
+
+        return list(entities)
+
+    def retrieve_entity_communities(self, entity_info, entities):
+        """
+        Retrieve cluster information for given entities,
+        allowing for multiple clusters per entity.
+
+        Args:
+            entity_info (dict): Dictionary mapping entities to their cluster IDs (list).
+            entities (list): List of entity names to retrieve information for.
+
+        Returns:
+            List of community or cluster IDs to which an entity belongs.
+        """
+        community_ids = []
+
+        for entity in entities:
+            if entity in entity_info:
+                community_ids.extend(entity_info[entity])
+
+        return list(set(community_ids))
+
+    def generate_answer_from_summary(self, community_summary, query):
+        """Generate an answer from a community summary based on a given query using LLM."""
+        prompt = (
+            f"Given the community summary: {community_summary}, "
+            f"how would you answer the following query? Query: {query}"
+        )
+        messages = [
+            ChatMessage(role="system", content=prompt),
+            ChatMessage(
+                role="user",
+                content="I need an answer based on the above information.",
+            ),
+        ]
+        response = self.llm.chat(messages)
+        cleaned_response = re.sub(r"^assistant:\s*", "", str(response)).strip()
+        return cleaned_response
+
+    def aggregate_answers(self, community_answers):
+        """Aggregate individual community answers into a final, coherent response."""
+        prompt = "Combine the following intermediate answers into a final, concise response."
+        messages = [
+            ChatMessage(role="system", content=prompt),
+            ChatMessage(
+                role="user",
+                content=f"Intermediate answers: {community_answers}",
+            ),
+        ]
+        final_response = self.llm.chat(messages)
+        cleaned_final_response = re.sub(
+            r"^assistant:\s*", "", str(final_response)
+        ).strip()
+        return cleaned_final_response
+
+def custom_parse_fn(response_str: str) -> Any:
+    """Custom parser for LLM responses that extract entities and relationships"""
+    json_pattern = r"\{.*\}"
+    match = re.search(json_pattern, response_str, re.DOTALL)
+    entities = []
+    relationships = []
+    
+    if not match:
+        return entities, relationships
+    
+    json_str = match.group(0)
+    try:
+        data = json.loads(json_str)
+        entities = [
+            (
+                entity["entity_name"],
+                entity["entity_type"],
+                entity.get("entity_description", f"Description of {entity['entity_name']}"),
+            )
+            for entity in data.get("entities", [])
+        ]
+        relationships = [
+            (
+                relation["source_entity"],
+                relation["target_entity"],
+                relation["relation"],
+                relation.get("relationship_description", f"Relationship between {relation['source_entity']} and {relation['target_entity']}"),
+            )
+            for relation in data.get("relationships", [])
+        ]
+        return entities, relationships
+    except (json.JSONDecodeError, KeyError) as e:
+        print(f"Error parsing response: {e}")
+        print(f"Problematic JSON: {json_str[:200]}...")
+        return entities, relationships
+
+# Define the prompt template for triple extraction
+KG_TRIPLET_EXTRACT_TMPL = """
+-Goal-
+Given a text document, identify all entities and their entity types from the text and all relationships among the identified entities.
+
+Given the text, extract up to {max_knowledge_triplets} entity-relation triplets.
+
+-Steps-
+1. Identify all entities. For each identified entity, extract the following information:
+- entity_name: Name of the entity, capitalized
+- entity_type: Type of the entity
+- entity_description: Comprehensive description of the entity's attributes and activities
+
+2. From the entities identified in step 1, identify all pairs of (source_entity, target_entity) that are *clearly related* to each other.
+For each pair of related entities, extract the following information:
+- source_entity: name of the source entity, as identified in step 1
+- target_entity: name of the target entity, as identified in step 1
+- relation: relationship between source_entity and target_entity
+- relationship_description: explanation as to why you think the source entity and the target entity are related to each other
+
+3. Output Formatting:
+- Return the result in valid JSON format with two keys: 'entities' (list of entity objects) and 'relationships' (list of relationship objects).
+- Exclude any text outside the JSON structure (e.g., no explanations or comments).
+- If no entities or relationships are identified, return empty lists: { "entities": [], "relationships": [] }.
+
+-Real Data-
+######################
+text: {text}
+######################
+output:
+"""
+
+def main():
+    print("Starting GraphRAG document processing...")
+    
+    # Load documents from specified directory
+    documents = SimpleDirectoryReader(
+        input_dir="supporting_files/files_for_rag_store"
+    ).load_data()
+    
+    print(f"Loaded {len(documents)} documents")
+    
+    # Create nodes using a sentence splitter
+    splitter = SentenceSplitter(chunk_size=1024, chunk_overlap=20)
+    nodes = splitter.get_nodes_from_documents(documents)
+    
+    print(f"Created {len(nodes)} nodes from documents")
+    
+    # Initialize the LLM
+    llm = OpenAI(model="gpt-4")
+    
+    # Create the knowledge graph extractor
+    kg_extractor = GraphRAGExtractor(
+        llm=llm,
+        extract_prompt=KG_TRIPLET_EXTRACT_TMPL,
+        max_paths_per_chunk=2,
+        parse_fn=custom_parse_fn,
+    )
+    
+    # Connect to Neo4j running in Docker
+    neo4j_username = "neo4j"
+    neo4j_password = "tavern-easy-museum-arthur-coconut-3483"
+    neo4j_url = "bolt://localhost:7687"
+    
+    print(f"Connecting to Neo4j at {neo4j_url} with username '{neo4j_username}'")
+    
+    # Create GraphRAGStore (our extended Neo4j store)
+    try:
+        graph_store = GraphRAGStore(
+            username=neo4j_username, 
+            password=neo4j_password,
+            url=neo4j_url
+        )
+        print("Successfully connected to Neo4j database")
+    except Exception as e:
+        print(f"Error connecting to Neo4j: {e}")
+        print("Falling back to in-memory graph store. Some features may be limited.")
+        # Fallback to in-memory graph store if Neo4j connection fails
+        from llama_index.core.graph_stores import SimplePropertyGraphStore
+        graph_store = SimplePropertyGraphStore()
+    
+    # Build the index
+    index = PropertyGraphIndex(
+        nodes=nodes,
+        kg_extractors=[kg_extractor],
+        property_graph_store=graph_store,
+        show_progress=True,
+    )
+    
+    print("Building graph communities...")
+    try:
+        # Build communities for graph-based querying
+        # Only for GraphRAGStore, not for SimplePropertyGraphStore
+        if hasattr(graph_store, 'build_communities'):
+            graph_store.build_communities()
+            print("Communities built successfully")
+        else:
+            print("Skipping community building (not using Neo4j)")
+    except Exception as e:
+        print(f"Error building communities: {e}")
+    
+    # Create the query engine
+    query_engine = GraphRAGQueryEngine(
+        graph_store=graph_store,
+        llm=llm,
+        index=index,
+        similarity_top_k=10,
+    )
+    
+    # Simple interactive query loop
+    print("\n--- GraphRAG Query System Ready ---")
+    print("Type 'exit' to quit")
+    
+    while True:
+        query = input("\nEnter your query: ")
+        
+        if query.lower() in ('exit', 'quit'):
+            break
+            
+        try:
+            response = query_engine.custom_query(query)
+            print("\nResponse:")
+            print(response)
+        except Exception as e:
+            print(f"Error processing query: {e}")
+    
+    print("GraphRAG session ended")
+
+if __name__ == "__main__":
+    main()
\ No newline at end of file
diff --git a/graph_rag_integration.py b/graph_rag_integration.py
new file mode 100644
index 0000000..2ba2fc2
--- /dev/null
+++ b/graph_rag_integration.py
@@ -0,0 +1,882 @@
+"""
+Netflix GraphRAG Integration
+
+Integrates GraphRAG functionality into the Netflix RAG pipeline.
+- GraphRAG for knowledge graph construction from semantically split nodes
+- Community detection and summarization for improved context retrieval
+"""
+
+import os
+import json
+import re
+import asyncio
+import networkx as nx
+from collections import defaultdict
+from typing import Any, List, Callable, Optional, Union, Dict
+from pathlib import Path
+
+# Import LlamaIndex components
+from llama_index.core import Document, Settings
+from llama_index.core.node_parser import SentenceSplitter
+from llama_index.core import PropertyGraphIndex
+from llama_index.core.async_utils import run_jobs
+from llama_index.core.indices.property_graph.utils import default_parse_triplets_fn
+from llama_index.core.graph_stores.types import EntityNode, KG_NODES_KEY, KG_RELATIONS_KEY, Relation
+from llama_index.core.llms.llm import LLM
+from llama_index.core.prompts import PromptTemplate
+from llama_index.core.prompts.default_prompts import DEFAULT_KG_TRIPLET_EXTRACT_PROMPT
+from llama_index.core.schema import TransformComponent, BaseNode
+from llama_index.core.query_engine import CustomQueryEngine
+from llama_index.llms.openai import OpenAI
+from llama_index.core.llms import ChatMessage
+from llama_index.graph_stores.neo4j import Neo4jPropertyGraphStore
+from llama_index.core import SimpleDirectoryReader
+from llama_index.core.vector_stores.types import VectorStoreInfo, MetadataInfo
+from llama_index.core.retrievers import VectorIndexRetriever
+
+# Community detection (using NetworkX instead of graspologic as a fallback)
+try:
+    from community import best_partition  # python-louvain package
+except ImportError:
+    print("Community detection package not found, using NetworkX built-in community detection")
+
+# Import from our modules
+from utils import logger, log_structured
+from config import NEO4J_URL, NEO4J_USERNAME, NEO4J_PASSWORD
+import config
+
+# Define the GraphRAGExtractor class
+class GraphRAGExtractor(TransformComponent):
+    """Extract triples from a graph.
+
+    Uses an LLM and a simple prompt + output parsing to
+    extract paths (i.e. triples) and entity, relation descriptions
+    from text.
+
+    Args:
+        llm (LLM):
+            The language model to use.
+        extract_prompt (Union[str, PromptTemplate]):
+            The prompt to use for extracting triples.
+        parse_fn (callable):
+            A function to parse the output of the language
+            model.
+        num_workers (int):
+            The number of workers to use for parallel
+            processing.
+        max_paths_per_chunk (int):
+            The maximum number of paths to extract per chunk.
+    """
+
+    llm: LLM
+    extract_prompt: PromptTemplate
+    parse_fn: Callable
+    num_workers: int
+    max_paths_per_chunk: int
+
+    def __init__(
+        self,
+        llm: Optional[LLM] = None,
+        extract_prompt: Optional[Union[str, PromptTemplate]] = None,
+        parse_fn: Callable = default_parse_triplets_fn,
+        max_paths_per_chunk: int = 10,
+        num_workers: int = 8,
+    ) -> None:
+        """Init params."""
+        from llama_index.core import Settings
+
+        if isinstance(extract_prompt, str):
+            extract_prompt = PromptTemplate(extract_prompt)
+
+        super().__init__(
+            llm=llm or Settings.llm,
+            extract_prompt=extract_prompt or DEFAULT_KG_TRIPLET_EXTRACT_PROMPT,
+            parse_fn=parse_fn,
+            num_workers=num_workers,
+            max_paths_per_chunk=max_paths_per_chunk,
+        )
+
+    @classmethod
+    def class_name(cls) -> str:
+        return "GraphExtractor"
+
+    def __call__(
+        self, nodes: List[BaseNode], show_progress: bool = False, **kwargs: Any
+    ) -> List[BaseNode]:
+        """Extract triples from nodes."""
+        return asyncio.run(
+            self.acall(nodes, show_progress=show_progress, **kwargs)
+        )
+
+    async def _aextract(self, node: BaseNode) -> BaseNode:
+        """Extract triples from a node."""
+        assert hasattr(node, "text")
+
+        text = node.get_content(metadata_mode="llm")
+        try:
+            llm_response = await self.llm.apredict(
+                self.extract_prompt,
+                text=text,
+                max_knowledge_triplets=self.max_paths_per_chunk,
+            )
+            entities, entities_relationship = self.parse_fn(llm_response)
+        except ValueError:
+            entities = []
+            entities_relationship = []
+
+        existing_nodes = node.metadata.pop(KG_NODES_KEY, [])
+        existing_relations = node.metadata.pop(KG_RELATIONS_KEY, [])
+        
+        entity_metadata = node.metadata.copy()
+        for entity, entity_type, description in entities:
+            entity_metadata["entity_description"] = description
+            entity_node = EntityNode(
+                name=entity, label=entity_type,
+                properties=entity_metadata
+            )
+            existing_nodes.append(entity_node)
+
+        relation_metadata = node.metadata.copy()
+        for triple in entities_relationship:
+            subj, obj, rel, description = triple
+            relation_metadata["relationship_description"] = description
+            rel_node = Relation(
+                label=rel,
+                source_id=subj,
+                target_id=obj,
+                properties=relation_metadata,
+            )
+            existing_relations.append(rel_node)
+
+        node.metadata[KG_NODES_KEY] = existing_nodes
+        node.metadata[KG_RELATIONS_KEY] = existing_relations
+        return node
+
+    async def acall(
+        self, nodes: List[BaseNode], show_progress: bool = False, **kwargs: Any
+    ) -> List[BaseNode]:
+        """Extract triples from nodes async."""
+        jobs = []
+        for node in nodes:
+            jobs.append(self._aextract(node))
+
+        return await run_jobs(
+            jobs,
+            workers=self.num_workers,
+            show_progress=show_progress,
+            desc="Extracting paths from text",
+        )
+
+# Define the GraphRAGStore class (integrating with Neo4j)
+import pickle
+import os
+from pathlib import Path
+
+class GraphRAGStore:
+    community_summary = {}
+    entity_info = None
+    max_cluster_size = 5
+    property_graph_store = None
+    communities_built = False  # Track if communities have been built
+    
+    # Path for cached community data
+    CACHE_DIR = Path("index_storage/graphrag_cache")
+    COMMUNITY_CACHE_FILE = CACHE_DIR / "community_summary.pickle"
+    ENTITY_INFO_CACHE_FILE = CACHE_DIR / "entity_info.pickle"
+    
+    def __init__(self, property_graph_store):
+        """Initialize with a property_graph_store (Neo4j or in-memory)."""
+        self.property_graph_store = property_graph_store
+        self.community_summary = {}
+        self.entity_info = None
+        self.communities_built = False
+        
+        # Ensure cache directory exists
+        os.makedirs(self.CACHE_DIR, exist_ok=True)
+        
+    def add_nodes(self, nodes):
+        """Add nodes to the property graph store."""
+        return self.property_graph_store.add_nodes(nodes)
+        
+    def add_relationships(self, relationships):
+        """Add relationships to the property graph store."""
+        return self.property_graph_store.add_relationships(relationships)
+    
+    def get_triplets(self):
+        """Get triplets from the property graph store."""
+        return self.property_graph_store.get_triplets()
+
+    def generate_community_summary(self, text):
+        """Generate summary for a given text using an LLM with handling for large contexts."""
+        
+        # Check if text is too long and chunk if needed
+        if len(text) > 30000:  # Approximate character limit
+            log_structured('info', f'Community text is large ({len(text)} chars). Chunking for summarization.')
+            # Split into smaller chunks (simple approach)
+            chunks = [text[i:i+30000] for i in range(0, len(text), 30000)]
+            summaries = []
+            
+            for i, chunk in enumerate(chunks):
+                try:
+                    # Use GPT-4o-mini model for better cost efficiency
+                    llm = OpenAI(model="gpt-4o-mini")
+                    messages = [
+                        ChatMessage(
+                            role="system",
+                            content="Summarize these knowledge graph relationships concisely."
+                        ),
+                        ChatMessage(role="user", content=chunk),
+                    ]
+                    response = llm.chat(messages)
+                    summaries.append(str(response).strip())
+                    log_structured('info', f'Successfully summarized community chunk {i+1}/{len(chunks)}')
+                except Exception as e:
+                    log_structured('error', f'Error summarizing community chunk {i+1}/{len(chunks)}: {e}')
+                    
+            # Then summarize the summaries
+            if summaries:
+                final_summary_text = "\n\n".join(summaries)
+                try:
+                    llm = OpenAI(model="gpt-4o-mini")
+                    messages = [
+                        ChatMessage(
+                            role="system",
+                            content="Create a coherent summary from these partial summaries."
+                        ),
+                        ChatMessage(role="user", content=final_summary_text),
+                    ]
+                    response = llm.chat(messages)
+                    return str(response).strip()
+                except Exception as e:
+                    log_structured('error', f'Error creating final summary from chunks: {e}')
+                    # Return the concatenated summaries if we can't summarize them
+                    return final_summary_text
+            else:
+                return "Unable to generate community summary due to size limitations."
+        
+        # For normal size text, use the larger model directly
+        try:
+            # Use GPT-4o-mini model for better cost efficiency
+            llm = OpenAI(model="gpt-4o-mini")
+            messages = [
+                ChatMessage(
+                    role="system",
+                    content=(
+                        "You are provided with a set of "
+                        "relationships from a knowledge graph, each represented as "
+                        "entity1->entity2->relation-"
+                        ">relationship_description. Your task is to create a summary of "
+                        "these relationships. The summary should include "
+                        "the names of the entities involved and a concise synthesis "
+                        "of the relationship descriptions. The "
+                        "goal is to capture the most critical and relevant details that "
+                        "highlight the nature and significance of "
+                        "each relationship. Ensure that the summary is coherent and "
+                        "integrates the information in a way that "
+                        "emphasizes the key aspects of the relationships."
+                    ),
+                ),
+                ChatMessage(role="user", content=text),
+            ]
+            response = llm.chat(messages)
+            clean_response = re.sub(r"^assistant:\s*", "", str(response)).strip()
+            return clean_response
+        except Exception as e:
+            log_structured('error', f'Error generating community summary: {e}')
+            return f"Error summarizing community: {str(e)}"
+
+    def build_communities(self):
+        """Builds communities from the graph and summarizes them."""
+        # Skip if communities are already built in this session
+        if self.communities_built:
+            log_structured('info', 'Communities already built in this session, skipping rebuild')
+            return
+            
+        # First check if we can load from cache
+        if self.load_from_cache():
+            log_structured('info', 'Using cached community data instead of rebuilding')
+            self.communities_built = True
+            return
+            
+        log_structured('info', 'Building communities from graph data')
+        nx_graph = self._create_nx_graph()
+        
+        # Use either Leiden algorithm (from graspologic) or an alternative
+        try:
+            from graspologic.partition import hierarchical_leiden
+            community_hierarchical_clusters = hierarchical_leiden(
+                nx_graph, max_cluster_size=self.max_cluster_size
+            )
+            self.entity_info, community_info = self._collect_community_info(
+                nx_graph, community_hierarchical_clusters
+            )
+        except ImportError:
+            # Fallback to community detection using NetworkX or python-louvain
+            try:
+                from community import best_partition
+                partition = best_partition(nx_graph)
+                # Reformat partition data to expected structure
+                clusters = []
+                for node, cluster_id in partition.items():
+                    class Cluster:
+                        def __init__(self, node, cluster):
+                            self.node = node
+                            self.cluster = cluster
+                    clusters.append(Cluster(node, cluster_id))
+                self.entity_info, community_info = self._collect_community_info(
+                    nx_graph, clusters
+                )
+            except ImportError:
+                # Use NetworkX's built-in community detection
+                from networkx.algorithms import community
+                communities = community.greedy_modularity_communities(nx_graph)
+                clusters = []
+                for i, comm in enumerate(communities):
+                    for node in comm:
+                        class Cluster:
+                            def __init__(self, node, cluster):
+                                self.node = node
+                                self.cluster = cluster
+                        clusters.append(Cluster(node, i))
+                self.entity_info, community_info = self._collect_community_info(
+                    nx_graph, clusters
+                )
+                
+        self._summarize_communities(community_info)
+        
+        # Cache the results after building
+        self.save_to_cache()
+        
+        # Mark communities as built for this session
+        self.communities_built = True
+
+    def _create_nx_graph(self):
+        """Converts internal graph representation to NetworkX graph."""
+        nx_graph = nx.Graph()
+        triplets = self.get_triplets()
+        for entity1, relation, entity2 in triplets:
+            nx_graph.add_node(entity1.name)
+            nx_graph.add_node(entity2.name)
+            nx_graph.add_edge(
+                relation.source_id,
+                relation.target_id,
+                relationship=relation.label,
+                description=relation.properties.get("relationship_description", "No description provided"),
+            )
+        return nx_graph
+
+    def _collect_community_info(self, nx_graph, clusters):
+        """
+        Collect information for each node based on their community,
+        allowing entities to belong to multiple clusters.
+        """
+        entity_info = defaultdict(set)
+        community_info = defaultdict(list)
+
+        for item in clusters:
+            node = item.node
+            cluster_id = item.cluster
+
+            # Update entity_info
+            entity_info[node].add(cluster_id)
+
+            for neighbor in nx_graph.neighbors(node):
+                edge_data = nx_graph.get_edge_data(node, neighbor)
+                if edge_data:
+                    detail = f"{node} -> {neighbor} -> {edge_data['relationship']} -> {edge_data['description']}"
+                    community_info[cluster_id].append(detail)
+
+        # Convert sets to lists for easier serialization if needed
+        entity_info = {k: list(v) for k, v in entity_info.items()}
+
+        return dict(entity_info), dict(community_info)
+
+    def _summarize_communities(self, community_info):
+        """Generate and store summaries for each community."""
+        for community_id, details in community_info.items():
+            details_text = "\n".join(details) + "." # Ensure it ends with a period
+            self.community_summary[community_id] = self.generate_community_summary(details_text)
+
+    def save_to_cache(self):
+        """Save community data to disk cache."""
+        try:
+            # Save community summary
+            with open(self.COMMUNITY_CACHE_FILE, 'wb') as f:
+                pickle.dump(self.community_summary, f)
+            
+            # Save entity info
+            with open(self.ENTITY_INFO_CACHE_FILE, 'wb') as f:
+                pickle.dump(self.entity_info, f)
+                
+            log_structured('info', 'Successfully cached GraphRAG community data', 
+                        {'community_count': len(self.community_summary),
+                         'entity_count': len(self.entity_info) if self.entity_info else 0})
+            return True
+        except Exception as e:
+            log_structured('error', f'Error saving GraphRAG cache: {e}')
+            return False
+    
+    def load_from_cache(self):
+        """Load community data from disk cache if available."""
+        if not self.COMMUNITY_CACHE_FILE.exists() or not self.ENTITY_INFO_CACHE_FILE.exists():
+            log_structured('info', 'GraphRAG cache files not found, will build communities from scratch')
+            return False
+        
+        try:
+            # Load community summary
+            with open(self.COMMUNITY_CACHE_FILE, 'rb') as f:
+                self.community_summary = pickle.load(f)
+            
+            # Load entity info
+            with open(self.ENTITY_INFO_CACHE_FILE, 'rb') as f:
+                self.entity_info = pickle.load(f)
+                
+            log_structured('info', 'Successfully loaded GraphRAG community data from cache', 
+                        {'community_count': len(self.community_summary),
+                         'entity_count': len(self.entity_info) if self.entity_info else 0})
+            
+            # Mark communities as built when successfully loaded from cache
+            self.communities_built = True
+            return True
+        except Exception as e:
+            log_structured('error', f'Error loading GraphRAG cache: {e}')
+            # Reset to empty in case of partial load
+            self.community_summary = {}
+            self.entity_info = None
+            self.communities_built = False
+            return False
+            
+    def get_community_summaries(self):
+        """Returns the community summaries, building them if not already done."""
+        if not self.community_summary:
+            # Try to load from cache first
+            if not self.load_from_cache():
+                # If cache load fails, build from scratch
+                self.build_communities()
+                # Cache the results for next time
+                self.save_to_cache()
+        return self.community_summary
+
+# Define the GraphRAGQueryEngine class
+from typing import Dict, Any
+
+class GraphRAGQueryEngine:
+    """Query engine that combines vector retrieval with graph-based community retrieval."""
+    
+    def __init__(
+        self, 
+        vector_retriever: VectorIndexRetriever,
+        graph_store: GraphRAGStore,
+        llm: Optional[LLM] = None,
+        similarity_top_k: int = 20
+    ):
+        """Initialize with both a vector retriever and graph store."""
+        # Initialize all required fields
+        self.vector_retriever = vector_retriever
+        self.graph_store = graph_store
+        self.llm = llm or Settings.llm
+        self.similarity_top_k = similarity_top_k
+        
+        # Check if communities are built, but don't try to build them here
+        # since that might cause errors with large graphs
+        if not hasattr(self.graph_store, 'entity_info') or self.graph_store.entity_info is None:
+            log_structured('warning', 'GraphRAGQueryEngine initialized without community data. Vector retrieval will still work, but community retrieval may be limited.')
+
+    def custom_query(self, query_str: str) -> Dict:
+        """Process query using both vector retrieval and community-based approach."""
+        log_structured('info', 'GraphRAG query engine: Starting dual retrieval', {'query': query_str})
+        
+        # Step 1: Get vector search results
+        vector_nodes = self.vector_retriever.retrieve(query_str)
+        vector_context = "\n\n".join([node.node.get_content() for node in vector_nodes])
+        log_structured('info', 'GraphRAG query engine: Vector retrieval complete', 
+                     {'node_count': len(vector_nodes)})
+        
+        # Step 2: Get GraphRAG community results (if communities exist)
+        graphrag_context = ""
+        community_ids = []
+        
+        if hasattr(self.graph_store, 'entity_info') and self.graph_store.entity_info is not None:
+            try:
+                entities = self.get_entities(query_str, vector_nodes)
+                community_ids = self.retrieve_entity_communities(self.graph_store.entity_info, entities)
+                
+                try:
+                    community_summaries = self.graph_store.get_community_summaries()
+                    
+                    if community_ids:
+                        filtered_summaries = {id: summary for id, summary in community_summaries.items() 
+                                          if id in community_ids}
+                        graphrag_context = "\n\n".join(filtered_summaries.values())
+                        log_structured('info', 'GraphRAG query engine: Community retrieval complete', 
+                                    {'community_count': len(filtered_summaries)})
+                    else:
+                        log_structured('info', 'GraphRAG query engine: No relevant communities found')
+                except Exception as e:
+                    log_structured('error', f'Error getting community summaries: {e}')
+                    # Continue without graph context
+            except Exception as e:
+                log_structured('error', f'Error during community retrieval: {e}')
+                # Continue with just vector context
+        else:
+            log_structured('warning', 'GraphRAG query engine: No community data available. Using only vector retrieval.')
+        
+        # Step 3: Combine contexts and generate answer
+        combined_result = {
+            "vector_context": vector_context,
+            "graphrag_context": graphrag_context,
+            "vector_nodes": vector_nodes,
+            "community_ids": community_ids
+        }
+        
+        return combined_result
+
+    def get_entities(self, query_str, vector_nodes):
+        """Extract entities from vector nodes that match the query."""
+        entities = set()
+        
+        # Extract entities from the retrieved nodes
+        for node_with_score in vector_nodes:
+            node = node_with_score.node
+            if hasattr(node, 'metadata') and KG_NODES_KEY in node.metadata:
+                for entity_node in node.metadata[KG_NODES_KEY]:
+                    if hasattr(entity_node, 'name'):
+                        entities.add(entity_node.name)
+        
+        # If no entities were found in metadata, try extracting them from text
+        if not entities:
+            pattern = r"(?:^|\s)([A-Z][a-zA-Z0-9\s]+)(?:\s|$)"
+            for node_with_score in vector_nodes:
+                matches = re.findall(pattern, node_with_score.node.get_content())
+                entities.update(matches)
+        
+        log_structured('debug', 'GraphRAG query engine: Extracted entities', 
+                     {'entities': list(entities), 'count': len(entities)})
+        return list(entities)
+
+    def retrieve_entity_communities(self, entity_info, entities):
+        """
+        Retrieve cluster information for given entities,
+        allowing for multiple clusters per entity.
+
+        Args:
+            entity_info (dict): Dictionary mapping entities to their cluster IDs (list).
+            entities (list): List of entity names to retrieve information for.
+
+        Returns:
+            List of community or cluster IDs to which an entity belongs.
+        """
+        community_ids = []
+
+        for entity in entities:
+            if entity in entity_info:
+                community_ids.extend(entity_info[entity])
+            else:
+                # Try case-insensitive matching as fallback
+                for stored_entity, clusters in entity_info.items():
+                    if stored_entity.lower() == entity.lower():
+                        community_ids.extend(clusters)
+                        break
+
+        return list(set(community_ids))
+
+def custom_parse_fn(response_str: str) -> Any:
+    """Custom parser for LLM responses that extract entities and relationships"""
+    json_pattern = r"\{.*\}"
+    match = re.search(json_pattern, response_str, re.DOTALL)
+    entities = []
+    relationships = []
+    
+    if not match:
+        return entities, relationships
+    
+    json_str = match.group(0)
+    try:
+        data = json.loads(json_str)
+        entities = [
+            (
+                entity["entity_name"],
+                entity["entity_type"],
+                entity.get("entity_description", f"Description of {entity['entity_name']}"),
+            )
+            for entity in data.get("entities", [])
+        ]
+        relationships = [
+            (
+                relation["source_entity"],
+                relation["target_entity"],
+                relation["relation"],
+                relation.get("relationship_description", f"Relationship between {relation['source_entity']} and {relation['target_entity']}"),
+            )
+            for relation in data.get("relationships", [])
+        ]
+        return entities, relationships
+    except (json.JSONDecodeError, KeyError) as e:
+        log_structured('error', f"Error parsing response: {e}", {'json_str': json_str[:200]})
+        return entities, relationships
+
+# Define the prompt template for triple extraction
+KG_TRIPLET_EXTRACT_TMPL = """
+-Goal-
+Given a text document, identify all entities and their entity types from the text and all relationships among the identified entities.
+
+Given the text, extract up to {max_knowledge_triplets} entity-relation triplets.
+
+-Steps-
+1. Identify all entities. For each identified entity, extract the following information:
+- entity_name: Name of the entity, capitalized
+- entity_type: Type of the entity
+- entity_description: Comprehensive description of the entity's attributes and activities
+
+2. From the entities identified in step 1, identify all pairs of (source_entity, target_entity) that are *clearly related* to each other.
+For each pair of related entities, extract the following information:
+- source_entity: name of the source entity, as identified in step 1
+- target_entity: name of the target entity, as identified in step 1
+- relation: relationship between source_entity and target_entity
+- relationship_description: explanation as to why you think the source entity and the target entity are related to each other
+
+3. Output Formatting:
+- Return the result in valid JSON format with two keys: 'entities' (list of entity objects) and 'relationships' (list of relationship objects).
+- Exclude any text outside the JSON structure (e.g., no explanations or comments).
+- If no entities or relationships are identified, return empty lists: { "entities": [], "relationships": [] }.
+
+-Real Data-
+######################
+text: {text}
+######################
+output:
+"""
+
+def create_graph_components(llm, nodes=None, max_paths_per_chunk=10, force_reindex=False):
+    """
+    Create GraphRAG components for the Netflix RAG pipeline.
+    
+    Args:
+        llm: The LLM to use for graph extraction and querying
+        nodes: List of nodes to create the graph from (only used if indexing is needed)
+        max_paths_per_chunk: Maximum number of paths to extract per chunk
+        force_reindex: If True, always recreate the index even if content exists
+        
+    Returns:
+        tuple: (graph_store, property_graph_index)
+    """
+    log_structured('info', 'Creating GraphRAG components')
+    
+    # Note: The graph_store object created here will automatically:
+    # 1. Try to load community data from cache files when build_communities() is called
+    # 2. Save to cache after building communities if loading failed
+    
+    # Connect to Neo4j - hard error if not available
+    property_graph_store = None
+    try:
+        log_structured('info', f'Connecting to Neo4j at {NEO4J_URL}')
+        property_graph_store = Neo4jPropertyGraphStore(
+            username=NEO4J_USERNAME, 
+            password=NEO4J_PASSWORD,
+            url=NEO4J_URL
+        )
+        log_structured('info', 'Successfully connected to Neo4j database')
+    except Exception as e:
+        log_structured('critical', f'FATAL ERROR: Cannot connect to Neo4j: {e}')
+        raise RuntimeError(f"Neo4j connection failed. This application requires Neo4j to be running. Error: {e}")
+    
+    # Create GraphRAGStore wrapper
+    graph_store = GraphRAGStore(property_graph_store)
+    
+    # Check if Neo4j already has content
+    triplets = graph_store.get_triplets()
+    has_existing_content = len(triplets) > 0
+    
+    log_structured('info', f'Neo4j check: Found {len(triplets)} triplets')
+    
+    if has_existing_content and not force_reindex:
+        log_structured('info', f'Neo4j already contains {len(triplets)} triplets. Skipping indexing.')
+        
+        # Create a minimal PropertyGraphIndex without indexing
+        property_graph_index = PropertyGraphIndex(
+            nodes=[],  # Empty nodes since we're not indexing
+            property_graph_store=property_graph_store,
+        )
+        
+        # Build communities from existing data (if not already built)
+        if not graph_store.communities_built:
+            log_structured('info', 'Building graph communities from existing Neo4j data')
+            try:
+                graph_store.build_communities()
+                log_structured('info', 'Communities built successfully')
+            except Exception as e:
+                log_structured('error', f'Error building communities: {e}')
+        else:
+            log_structured('info', 'Communities already built, skipping rebuild')
+    else:
+        # Need to perform indexing
+        if not nodes:
+            log_structured('error', 'No nodes provided for indexing and Neo4j is empty or force_reindex=True')
+            raise ValueError("Nodes must be provided for indexing when Neo4j is empty or force_reindex=True")
+        
+        # Create the knowledge graph extractor
+        kg_extractor = GraphRAGExtractor(
+            llm=llm,
+            extract_prompt=KG_TRIPLET_EXTRACT_TMPL,
+            max_paths_per_chunk=max_paths_per_chunk,
+            parse_fn=custom_parse_fn,
+        )
+        
+        if has_existing_content and force_reindex:
+            log_structured('info', 'Force reindexing requested. Clearing existing Neo4j data.')
+            try:
+                # Try to clear the graph using Neo4j's native query
+                # Note: This requires the Neo4j APOC plugin to be installed
+                from neo4j import GraphDatabase
+                driver = GraphDatabase.driver(NEO4J_URL, auth=(NEO4J_USERNAME, NEO4J_PASSWORD))
+                with driver.session() as session:
+                    session.run("MATCH (n) DETACH DELETE n")
+                driver.close()
+                log_structured('info', 'Successfully cleared Neo4j database')
+            except Exception as e:
+                log_structured('warning', f'Error clearing Neo4j database: {e}. Proceeding with indexing anyway.')
+        
+        # Build the property graph index
+        log_structured('info', 'Building PropertyGraphIndex', {'node_count': len(nodes)})
+        property_graph_index = PropertyGraphIndex(
+            nodes=nodes,
+            kg_extractors=[kg_extractor],
+            property_graph_store=property_graph_store,
+            show_progress=True,
+        )
+        
+        # Build communities
+        log_structured('info', 'Building graph communities')
+        try:
+            graph_store.build_communities()
+            log_structured('info', 'Communities built successfully')
+        except Exception as e:
+            log_structured('error', f'Error building communities: {e}')
+    
+    return graph_store, property_graph_index
+
+def create_graphrag_query_engine(vector_retriever, graph_store, llm, similarity_top_k=20):
+    """
+    Create GraphRAG query engine that combines vector and graph-based retrieval.
+    
+    Args:
+        vector_retriever: VectorIndexRetriever for standard retrieval
+        graph_store: GraphRAGStore for community-based retrieval
+        llm: LLM for generating answer
+        similarity_top_k: Number of top results to retrieve
+        
+    Returns:
+        GraphRAGQueryEngine: Query engine for hybrid retrieval
+    """
+    from utils import log_structured
+    
+    try:
+        # Explicitly validate inputs before passing to constructor
+        if vector_retriever is None:
+            raise ValueError("vector_retriever cannot be None")
+        if graph_store is None:
+            raise ValueError("graph_store cannot be None")
+        if llm is None:
+            raise ValueError("llm cannot be None")
+            
+        # Log for debugging
+        log_structured('debug', 'Creating GraphRAGQueryEngine with parameters', {
+            'vector_retriever_type': type(vector_retriever).__name__,
+            'graph_store_type': type(graph_store).__name__,
+            'llm_type': type(llm).__name__,
+            'similarity_top_k': similarity_top_k
+        })
+        
+        # Create the engine
+        return GraphRAGQueryEngine(
+            vector_retriever=vector_retriever,
+            graph_store=graph_store,
+            llm=llm,
+            similarity_top_k=similarity_top_k,
+        )
+    except Exception as e:
+        log_structured('error', f'Error in create_graphrag_query_engine: {e}')
+        raise  # Re-raise the exception for proper handling
+
+def generate_final_answer(query, retrieval_result, llm):
+    """
+    Generate a final answer using both vector and graph-based context.
+    
+    Args:
+        query: The user's query
+        retrieval_result: Result from GraphRAGQueryEngine with vector and graph contexts
+        llm: LLM for generating the final response
+        
+    Returns:
+        str: The final answer
+    """
+    vector_context = retrieval_result.get("vector_context", "")
+    graphrag_context = retrieval_result.get("graphrag_context", "")
+    
+    # Log the contexts for debugging (truncated for brevity)
+    log_structured('debug', 'Generating final answer with dual context', {
+        'query': query,
+        'vector_context_length': len(vector_context),
+        'graphrag_context_length': len(graphrag_context)
+    })
+    
+    if not vector_context and not graphrag_context:
+        return "I couldn't find any relevant information to answer your question."
+    
+    # If no model was provided or we're forcing to use a specific model
+    if llm is None or not hasattr(llm, 'chat'):
+        # Fallback to gpt-4o-mini for better cost efficiency
+        llm = OpenAI(model="gpt-4o-mini")
+        log_structured('info', 'Using gpt-4o-mini model for final answer generation')
+    
+    prompt = f"""
+    Based on the following information from two different sources, please answer this question: {query}
+
+    SOURCE 1 - VECTOR RETRIEVAL:
+    {vector_context}
+
+    SOURCE 2 - KNOWLEDGE GRAPH COMMUNITIES:
+    {graphrag_context}
+    
+    Answer the question based on all the provided information. If there are differences between the sources, 
+    try to reconcile them or note the discrepancy. Please be concise and direct.
+    """
+    
+    messages = [
+        ChatMessage(role="system", content=prompt),
+        ChatMessage(role="user", content="Please provide a comprehensive answer based on all the information provided.")
+    ]
+    
+    response = llm.chat(messages)
+    
+    # Extract just the message content, not the entire response object
+    if hasattr(response, 'message') and hasattr(response.message, 'content'):
+        content = response.message.content
+    elif hasattr(response, 'content'):
+        content = response.content
+    else:
+        # Fallback: convert to string but clean it
+        content = str(response)
+    
+    # Clean any remaining thinking patterns from the response
+    import re
+    thinking_patterns = [
+        r'(?i)Thought:.*?Action:.*?Action Input:.*',  # Remove the specific pattern
+        r'(?i)^Thought:.*',  # Remove any line starting with "Thought:"
+        r'(?i)Action:.*?Action Input:.*',  # Remove Action/Action Input patterns
+        r'(?i)^(Thought|Action|Observation):.*',  # Remove ReAct patterns
+    ]
+    
+    for pattern in thinking_patterns:
+        content = re.sub(pattern, '', content, flags=re.DOTALL | re.MULTILINE)
+    
+    # Clean up extra whitespace
+    content = re.sub(r'\n{3,}', '\n\n', content)
+    content = content.strip()
+    
+    # Final safety check
+    if not content or re.search(r'(?i)^(Thought|Action|Observation):', content):
+        log_structured('warning', 'GraphRAG final answer still contains thinking patterns, using fallback')
+        content = "I found relevant information in the Netflix marketing materials that can help answer your question. Please let me know if you need more specific details."
+    
+    return content
\ No newline at end of file
diff --git a/init_mongodb.py b/init_mongodb.py
new file mode 100644
index 0000000..14a8a40
--- /dev/null
+++ b/init_mongodb.py
@@ -0,0 +1,127 @@
+"""
+MongoDB Initialization Script for Netflix Chatbot
+
+This script initializes the MongoDB database with the necessary collections for the Netflix chatbot.
+It creates collections for users, conversations, and messages.
+
+Usage:
+    python init_mongodb.py
+"""
+
+import pymongo
+import logging
+from datetime import datetime
+import os
+import sys
+
+# Configure logging
+logging.basicConfig(
+    level=logging.INFO,
+    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
+    handlers=[
+        logging.StreamHandler(),
+        logging.FileHandler('mongodb_init.log')
+    ]
+)
+logger = logging.getLogger(__name__)
+
+# MongoDB connection information
+MONGO_URI = "mongodb://netflix:netflix@localhost:27017"
+DB_NAME = "netflix_chatbot"
+
+# Collection names
+USERS_COLLECTION = "users"
+CONVERSATIONS_COLLECTION = "conversations"
+MESSAGES_COLLECTION = "messages"
+
+def init_mongodb():
+    """Initialize MongoDB database and collections."""
+    try:
+        # Connect to MongoDB
+        logger.info("Connecting to MongoDB...")
+        client = pymongo.MongoClient(MONGO_URI)
+        
+        # Test connection
+        client.admin.command('ping')
+        logger.info("Successfully connected to MongoDB")
+        
+        # Create or access database
+        db = client[DB_NAME]
+        logger.info(f"Using database: {DB_NAME}")
+        
+        # Create collections if they don't exist
+        if USERS_COLLECTION not in db.list_collection_names():
+            db.create_collection(USERS_COLLECTION)
+            logger.info(f"Created collection: {USERS_COLLECTION}")
+            
+            # Create indexes for users collection
+            db[USERS_COLLECTION].create_index("username", unique=True)
+            # Create a unique sparse index for email - only enforces uniqueness when email exists
+            db[USERS_COLLECTION].create_index("email", unique=True, sparse=True)
+            logger.info("Created indexes for users collection")
+        
+        if CONVERSATIONS_COLLECTION not in db.list_collection_names():
+            db.create_collection(CONVERSATIONS_COLLECTION)
+            logger.info(f"Created collection: {CONVERSATIONS_COLLECTION}")
+            
+            # Create indexes for conversations collection
+            db[CONVERSATIONS_COLLECTION].create_index("user_id")
+            db[CONVERSATIONS_COLLECTION].create_index("session_id", unique=True)
+            db[CONVERSATIONS_COLLECTION].create_index("created_at")
+            db[CONVERSATIONS_COLLECTION].create_index("last_updated")
+            logger.info("Created indexes for conversations collection")
+        
+        if MESSAGES_COLLECTION not in db.list_collection_names():
+            db.create_collection(MESSAGES_COLLECTION)
+            logger.info(f"Created collection: {MESSAGES_COLLECTION}")
+            
+            # Create indexes for messages collection
+            db[MESSAGES_COLLECTION].create_index("conversation_id")
+            db[MESSAGES_COLLECTION].create_index("timestamp")
+            logger.info("Created indexes for messages collection")
+        
+        logger.info("MongoDB initialization completed successfully")
+        return True
+        
+    except pymongo.errors.ConnectionFailure as e:
+        logger.error(f"Could not connect to MongoDB: {e}")
+        return False
+    except Exception as e:
+        logger.error(f"An error occurred during MongoDB initialization: {e}")
+        return False
+
+def display_collection_info(client):
+    """Display information about the collections in the database."""
+    db = client[DB_NAME]
+    
+    logger.info("=== Database Structure ===")
+    for collection_name in db.list_collection_names():
+        count = db[collection_name].count_documents({})
+        logger.info(f"Collection: {collection_name}, Documents: {count}")
+        
+        # Display indexes
+        indexes = db[collection_name].index_information()
+        logger.info(f"  Indexes: {list(indexes.keys())}")
+
+if __name__ == "__main__":
+    if init_mongodb():
+        # Display collection information
+        client = pymongo.MongoClient(MONGO_URI)
+        display_collection_info(client)
+        
+        # Add sample user if none exist (optional)
+        db = client[DB_NAME]
+        if db[USERS_COLLECTION].count_documents({}) == 0:
+            sample_user = {
+                "username": "sample_user",
+                "email": "sample@example.com",
+                "created_at": datetime.utcnow(),
+                "last_login": datetime.utcnow()
+            }
+            db[USERS_COLLECTION].insert_one(sample_user)
+            logger.info("Added sample user for testing")
+        
+        logger.info("Initialization complete. The database is ready for use.")
+    else:
+        logger.error("Failed to initialize MongoDB. See logs for details.")
+        sys.exit(1)
\ No newline at end of file
diff --git a/json_utils.py b/json_utils.py
new file mode 100644
index 0000000..1298ee4
--- /dev/null
+++ b/json_utils.py
@@ -0,0 +1,133 @@
+# netflix_chatbot/json_utils.py
+import json
+import llama_index
+from llama_index.core.tools import ToolOutput
+from llama_index.core.agent.react.types import (
+    ActionReasoningStep,
+    ObservationReasoningStep,
+    ResponseReasoningStep,
+    BaseReasoningStep,
+)
+from llama_index.core.llms import ChatMessage, LLM, ChatResponse as LlamaResponse
+from llama_index.core.base.response.schema import Response
+from flask.json.provider import JSONProvider
+from bson import ObjectId # Import ObjectId if used in responses/data
+from datetime import datetime
+
+class CustomJSONEncoder(json.JSONEncoder):
+    """
+    Custom JSON Encoder to handle LlamaIndex objects, BSON ObjectId, and other types.
+    """
+    def default(self, obj):
+        try:
+            # Specific LlamaIndex Types
+            if isinstance(obj, ToolOutput):
+                return {
+                    'content': str(obj.content) if obj.content is not None else "",
+                    'tool_name': getattr(obj, 'tool_name', None),
+                    'raw_output': str(getattr(obj, 'raw_output', None)), # Safely convert raw_output
+                    'type': 'tool_output',
+                    'metadata': getattr(obj, 'metadata', {})
+                }
+            elif isinstance(obj, (llama_index.core.llms.ChatMessage, ChatMessage)):
+                 return {
+                     'role': str(obj.role),
+                     'content': str(obj.content),
+                     'additional_kwargs': obj.additional_kwargs if hasattr(obj, 'additional_kwargs') else {}
+                 }
+            elif isinstance(obj, (LlamaResponse, Response)):
+                 return {
+                    'content': str(getattr(obj, 'response', getattr(obj, 'message', None))),
+                    'metadata': getattr(obj, 'metadata', {}),
+                    'type': 'llm_response'
+                 }
+            elif isinstance(obj, ActionReasoningStep):
+                return {
+                    'type': 'action_step',
+                    'action': obj.action,
+                    'action_input': obj.action_input, # Should be serializable dict
+                    'thought': getattr(obj, 'thought', None)
+                }
+            elif isinstance(obj, ObservationReasoningStep):
+                return {
+                    'type': 'observation_step',
+                    'observation': str(obj.observation), # Ensure observation is string
+                    'thought': getattr(obj, 'thought', None)
+                }
+            elif isinstance(obj, ResponseReasoningStep):
+                return {
+                    'type': 'response_step',
+                    'response': str(obj.response), # Ensure response is string
+                    'is_streaming': getattr(obj, 'is_streaming', False),
+                    'thought': getattr(obj, 'thought', None)
+                }
+            elif isinstance(obj, BaseReasoningStep): # Catch-all for other steps
+                 return {
+                    'type': 'base_reasoning_step',
+                    'thought': getattr(obj, 'thought', None),
+                    'is_done': getattr(obj, 'is_done', False),
+                 }
+            # Handle LlamaIndex Document/Node related objects if needed
+            elif isinstance(obj, llama_index.core.schema.Document):
+                 return {
+                     'doc_id': obj.id_,
+                     'text_preview': obj.text[:100] + "..." if obj.text else "",
+                     'metadata': obj.metadata, # Metadata should be serializable
+                     'type': 'llama_document'
+                 }
+            elif isinstance(obj, llama_index.core.schema.NodeWithScore):
+                 return {
+                     'node': self.default(obj.node), # Recursively serialize the node
+                     'score': obj.score,
+                     'type': 'node_with_score'
+                 }
+            elif isinstance(obj, llama_index.core.schema.TextNode):
+                 return {
+                     'node_id': obj.id_,
+                     'text_preview': obj.text[:100] + "..." if obj.text else "",
+                     'metadata': obj.metadata,
+                     'type': 'text_node'
+                 }
+
+            # Common Python Types
+            elif isinstance(obj, datetime):
+                return obj.isoformat()
+            elif isinstance(obj, ObjectId):
+                return str(obj)
+            elif isinstance(obj, bytes):
+                 return "" # Or encode to base64 if needed
+
+            # General Fallback for objects with __dict__
+            elif hasattr(obj, '__dict__'):
+                # Filter out private/callable attributes, be cautious with recursion
+                try:
+                    d = {k: v for k, v in obj.__dict__.items()
+                         if not k.startswith('_') and not callable(v)}
+                    # Basic check to prevent deep recursion errors
+                    if len(d) > 50: # Arbitrary limit
+                         return f""
+                    return d
+                except Exception:
+                     return f""
+
+            # Final fallback using standard JSON encoding
+            return super().default(obj)
+
+        except Exception as e:
+            # Log the error? Be careful about logging sensitive data
+            # print(f"DEBUG: JSON encoding error for type {type(obj).__name__}: {e}")
+            return f""
+
+
+class CustomJSONProvider(JSONProvider):
+    """
+    Flask JSON Provider using the CustomJSONEncoder.
+    """
+    def dumps(self, obj, **kwargs):
+        kwargs.setdefault('cls', CustomJSONEncoder)
+        kwargs.setdefault('ensure_ascii', False) # Often useful for non-English text
+        kwargs.setdefault('indent', None) # No indent for production APIs
+        return json.dumps(obj, **kwargs)
+
+    def loads(self, s, **kwargs):
+        return json.loads(s, **kwargs)
\ No newline at end of file
diff --git a/main.py b/main.py
new file mode 100644
index 0000000..18dbbe5
--- /dev/null
+++ b/main.py
@@ -0,0 +1,168 @@
+# netflix_chatbot/main.py
+
+import asyncio
+import os
+import sys
+from flask import Flask
+from flask_cors import CORS
+
+# Ensure the project directory is in the Python path
+current_dir = os.path.dirname(os.path.abspath(__file__))
+if current_dir not in sys.path:
+    sys.path.insert(0, current_dir)
+
+# Import necessary components from our modules
+from config import (
+    APPLICATION_ROOT, MAX_CONTENT_LENGTH,
+    CORS_ALLOWED_ORIGINS, CORS_SUPPORTS_CREDENTIALS,
+    SERVER_HOST, SERVER_PORT, USE_RELOADER, LOG_LEVEL,
+    KEEP_ALIVE_TIMEOUT, READ_TIMEOUT, WRITE_TIMEOUT
+)
+from utils import logger, log_structured
+from json_utils import CustomJSONProvider
+from ai_core import initialize_global_index # Import the initialization function
+from shared_state import global_workflow_agent, is_agent_available # Import shared state
+from routes import register_routes
+from init_mongodb import init_mongodb # Your MongoDB initialization script
+
+# --- Flask App Initialization ---
+app = Flask(__name__)
+
+# Apply custom JSON provider for handling special types (LlamaIndex objects, etc.)
+app.json_provider_class = CustomJSONProvider
+app.json = CustomJSONProvider(app)
+
+# Configuration
+app.config['MAX_CONTENT_LENGTH'] = MAX_CONTENT_LENGTH
+if APPLICATION_ROOT:
+    app.config['APPLICATION_ROOT'] = APPLICATION_ROOT
+    # If using APPLICATION_ROOT, you might need to adjust route prefixes
+    # or use a Blueprint with url_prefix=APPLICATION_ROOT
+    log_structured('info', f"Flask Application Root set to: {APPLICATION_ROOT}")
+
+# CORS Configuration
+CORS(app,
+     resources={r"/*": {"origins": CORS_ALLOWED_ORIGINS}},
+     supports_credentials=CORS_SUPPORTS_CREDENTIALS,
+     # Expose custom headers if needed by the frontend
+     # expose_headers=["Content-Disposition"] # Example for downloads
+)
+log_structured('info', f"CORS configured for origins: {CORS_ALLOWED_ORIGINS}")
+
+
+# --- Register Routes ---
+# Pass the app object to the function in routes.py
+register_routes(app)
+log_structured('info', "Flask routes registered.")
+
+
+# --- Startup Function ---
+async def startup_event() -> bool:
+    """Tasks to run when the application starts.
+    
+    Returns:
+        bool: True if all startup tasks completed successfully, False otherwise
+    """
+    log_structured('info', "Application startup sequence initiated.")
+    all_success = True
+
+    # 1. Initialize MongoDB Connection & Schema (using your script)
+    log_structured('info', "Initializing MongoDB connection...")
+    mongo_success = False
+    try:
+        if init_mongodb():
+            log_structured('info', "MongoDB initialized successfully.")
+            mongo_success = True
+        else:
+            log_structured('warning', "MongoDB initialization script finished, but reported issues.")
+            all_success = False
+    except Exception as db_err:
+        log_structured('critical', "FATAL: MongoDB initialization failed.", {'error': str(db_err)})
+        all_success = False
+        # We'll continue in a degraded state
+
+    # 2. Initialize Global AI Index and Agent
+    log_structured('info', "Initializing global AI index and agent...")
+    index_success = await initialize_global_index()
+    
+    # Explicitly check the status after initialization
+    if not is_agent_available():
+        log_structured('critical', "After initialize_global_index, global_workflow_agent is still unavailable, even though function may have reported success")
+        all_success = False
+    elif not index_success:
+        log_structured('warning', "AI initialization reported failure, but will continue in degraded state")
+        all_success = False
+    else:
+        log_structured('info', "AI initialization successful, global_workflow_agent is available")
+
+    log_structured('info', f"Application startup sequence complete. Overall success: {all_success}")
+    return all_success
+
+
+# --- Shutdown Function (Optional) ---
+async def shutdown_event():
+    """Tasks to run when the application stops."""
+    log_structured('info', "Application shutdown sequence initiated.")
+    # Add any cleanup tasks here (e.g., closing connections if not handled elsewhere)
+    # Note: Hypercorn might not always guarantee graceful shutdown execution.
+    log_structured('info', "Application shutdown sequence complete.")
+
+
+# --- Main Execution Block ---
+if __name__ == '__main__':
+    from hypercorn.config import Config as HypercornConfig
+    from hypercorn.asyncio import serve as hypercorn_serve
+
+    # Create Hypercorn config object
+    config = HypercornConfig()
+
+    # Basic settings
+    config.bind = [f"{SERVER_HOST}:{SERVER_PORT}"]
+    config.use_reloader = USE_RELOADER
+    config.accesslog = '-' # Log to stdout/stderr
+    config.errorlog = '-' # Log to stdout/stderr
+    config.loglevel = LOG_LEVEL.upper()
+    config.worker_class = 'asyncio'
+
+    # Timeouts (ensure these are floats or ints)
+    config.keep_alive_timeout = float(KEEP_ALIVE_TIMEOUT)
+    config.read_timeout = float(READ_TIMEOUT)
+    config.write_timeout = float(WRITE_TIMEOUT)
+
+    # Request size limits (check Hypercorn docs for exact names, might vary slightly)
+    # These might apply to HTTP/1.1 or HTTP/2 differently.
+    # config.h11_max_incomplete_size = MAX_CONTENT_LENGTH # Example for HTTP/1.1
+    # config.h2_max_concurrent_streams = 100 # Example for HTTP/2
+    # config.max_app_buffer_size = MAX_CONTENT_LENGTH # Another potential setting
+    # It's safer to configure these via a reverse proxy (like Nginx) in production.
+    # Hypercorn's defaults are usually reasonable. Let's comment these out for now.
+
+    # Assign startup and shutdown handlers
+    config.startup_hooks = [startup_event]
+    config.shutdown_hooks = [shutdown_event]
+
+    log_structured('info', f"Starting Hypercorn server on {SERVER_HOST}:{SERVER_PORT}")
+    log_structured('info', f"Reload mode: {'Enabled' if USE_RELOADER else 'Disabled'}")
+
+    # Execute startup task before running the server
+    log_structured('info', "Manually executing startup sequence before server start")
+    startup_success = asyncio.run(startup_event())
+    
+    # Double-check that the agent is initialized
+    if not is_agent_available():
+        log_structured('critical', "After startup, global_workflow_agent is still unavailable. Forcing re-initialization...")
+        # Try once more to initialize
+        index_success = asyncio.run(initialize_global_index())
+        if not index_success or not is_agent_available():
+            log_structured('critical', "Emergency initialization also failed. Server will run but chat functionality will be impaired.")
+        else:
+            log_structured('info', "Emergency initialization succeeded.")
+
+    # Run the server
+    try:
+        asyncio.run(hypercorn_serve(app, config))
+    except KeyboardInterrupt:
+         log_structured('info', "Server stopped manually (KeyboardInterrupt).")
+    except Exception as run_err:
+         log_structured('critical', "Hypercorn server failed to run.", {'error': str(run_err)})
+         sys.exit(1)
\ No newline at end of file
diff --git a/mongodb_utils.py b/mongodb_utils.py
new file mode 100644
index 0000000..fa47310
--- /dev/null
+++ b/mongodb_utils.py
@@ -0,0 +1,458 @@
+"""
+MongoDB Utilities for Netflix Chatbot
+
+This module provides utility functions for interacting with MongoDB in the Netflix chatbot application.
+It includes functions for connecting to MongoDB, and managing users, conversations, and messages.
+"""
+
+import pymongo
+import logging
+from datetime import datetime
+import uuid
+from typing import Dict, List, Optional, Any, Union
+from bson.objectid import ObjectId
+import json
+
+# Configure logging
+logging.basicConfig(
+    level=logging.INFO,
+    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
+    handlers=[
+        logging.StreamHandler(),
+        logging.FileHandler('mongodb.log')
+    ]
+)
+logger = logging.getLogger(__name__)
+
+# MongoDB connection information
+MONGO_URI = "mongodb://netflix:netflix@localhost:27017"
+DB_NAME = "netflix_chatbot"
+
+# Collection names
+USERS_COLLECTION = "users"
+CONVERSATIONS_COLLECTION = "conversations"
+MESSAGES_COLLECTION = "messages"
+
+# Global MongoDB client
+mongo_client = None
+db = None
+
+def get_db():
+    """Get or initialize the MongoDB database connection."""
+    global mongo_client, db
+    
+    if mongo_client is None:
+        try:
+            mongo_client = pymongo.MongoClient(MONGO_URI)
+            mongo_client.admin.command('ping')  # Test connection
+            db = mongo_client[DB_NAME]
+            logger.info("Successfully connected to MongoDB")
+        except Exception as e:
+            logger.error(f"Failed to connect to MongoDB: {e}")
+            raise
+    
+    return db
+
+def close_connection():
+    """Close the MongoDB connection."""
+    global mongo_client
+    
+    if mongo_client:
+        mongo_client.close()
+        mongo_client = None
+        logger.info("MongoDB connection closed")
+
+# User functions
+def get_user_by_username(username: str) -> Optional[Dict]:
+    """Get a user by username."""
+    try:
+        db = get_db()
+        user = db[USERS_COLLECTION].find_one({"username": username})
+        return user
+    except Exception as e:
+        logger.error(f"Error getting user by username: {e}")
+        return None
+
+def create_or_update_user(username: str, email: Optional[str] = None) -> Optional[str]:
+    """Create a new user or update an existing one."""
+    try:
+        db = get_db()
+        
+        # Check if user exists
+        existing_user = db[USERS_COLLECTION].find_one({"username": username})
+        
+        if existing_user:
+            # Update last login
+            db[USERS_COLLECTION].update_one(
+                {"username": username},
+                {"$set": {"last_login": datetime.utcnow()}}
+            )
+            return str(existing_user["_id"])
+        else:
+            # Create new user
+            new_user = {
+                "username": username,
+                "created_at": datetime.utcnow(),
+                "last_login": datetime.utcnow()
+            }
+            
+            # Only include email if it's not None to avoid unique constraint issues
+            if email:
+                new_user["email"] = email
+                
+            result = db[USERS_COLLECTION].insert_one(new_user)
+            return str(result.inserted_id)
+    except Exception as e:
+        logger.error(f"Error creating or updating user: {e}")
+        # If the error is a duplicate key error, try to find the existing user
+        if "duplicate key error" in str(e) and "username" in str(e):
+            try:
+                existing_user = db[USERS_COLLECTION].find_one({"username": username})
+                if existing_user:
+                    return str(existing_user["_id"])
+            except:
+                pass
+        return None
+
+# Conversation functions
+def get_conversation(session_id: str) -> Optional[Dict]:
+    """Get a conversation by session ID."""
+    try:
+        db = get_db()
+        conversation = db[CONVERSATIONS_COLLECTION].find_one({"session_id": session_id})
+        return conversation
+    except Exception as e:
+        logger.error(f"Error getting conversation: {e}")
+        return None
+
+def get_conversation_by_id(conversation_id: str) -> Optional[Dict]:
+    """Get a conversation by its MongoDB ID."""
+    try:
+        db = get_db()
+        # Convert string ID to ObjectId
+        try:
+            obj_id = ObjectId(conversation_id)
+            conversation = db[CONVERSATIONS_COLLECTION].find_one({"_id": obj_id})
+            return conversation
+        except Exception as e:
+            logger.error(f"Error converting conversation ID to ObjectId: {e}")
+            return None
+    except Exception as e:
+        logger.error(f"Error getting conversation by ID: {e}")
+        return None
+
+def get_user_conversations(user_id: str) -> List[Dict]:
+    """Get all conversations for a user that are not marked as deleted."""
+    try:
+        db = get_db()
+        conversations = list(db[CONVERSATIONS_COLLECTION].find(
+            {
+                "user_id": user_id,
+                # Only return conversations that either don't have is_deleted or have it set to False
+                "$or": [
+                    {"is_deleted": {"$exists": False}},
+                    {"is_deleted": False}
+                ]
+            }
+        ).sort("last_updated", pymongo.DESCENDING))
+        return conversations
+    except Exception as e:
+        logger.error(f"Error getting user conversations: {e}")
+        return []
+
+def create_conversation(session_id: str, user_id: str, title: str = "New conversation") -> Optional[str]:
+    """Create a new conversation."""
+    try:
+        db = get_db()
+        
+        # Check if conversation already exists with this session_id
+        existing = db[CONVERSATIONS_COLLECTION].find_one({"session_id": session_id})
+        if existing:
+            return str(existing["_id"])
+        
+        # Create new conversation
+        new_conversation = {
+            "session_id": session_id,
+            "user_id": user_id,
+            "title": title,
+            "created_at": datetime.utcnow(),
+            "last_updated": datetime.utcnow()
+        }
+        result = db[CONVERSATIONS_COLLECTION].insert_one(new_conversation)
+        return str(result.inserted_id)
+    except Exception as e:
+        logger.error(f"Error creating conversation: {e}")
+        return None
+
+def update_conversation_title(conversation_id: str, title: str) -> bool:
+    """Update the title of a conversation."""
+    try:
+        db = get_db()
+        db[CONVERSATIONS_COLLECTION].update_one(
+            {"_id": ObjectId(conversation_id)},
+            {"$set": {"title": title, "last_updated": datetime.utcnow()}}
+        )
+        return True
+    except Exception as e:
+        logger.error(f"Error updating conversation title: {e}")
+        return False
+
+def update_conversation_timestamp(conversation_id: str) -> bool:
+    """Update the last_updated timestamp of a conversation."""
+    try:
+        db = get_db()
+        db[CONVERSATIONS_COLLECTION].update_one(
+            {"_id": ObjectId(conversation_id)},
+            {"$set": {"last_updated": datetime.utcnow()}}
+        )
+        return True
+    except Exception as e:
+        logger.error(f"Error updating conversation timestamp: {e}")
+        return False
+
+# Message functions
+def add_message(conversation_id: str, role: str, content: str, 
+                sources: Optional[List] = None, reasoning: Optional[List] = None, 
+                images: Optional[List] = None) -> Optional[str]:
+    """Add a message to a conversation."""
+    try:
+        db = get_db()
+        
+        # Prepare the message document
+        message = {
+            "conversation_id": conversation_id,
+            "role": role,
+            "content": content,
+            "timestamp": datetime.utcnow()
+        }
+        
+        # Add optional fields with serialization
+        if sources:
+            # Serialize sources
+            serialized_sources = json.loads(json.dumps(sources, default=serialize_custom_objects))
+            message["sources"] = serialized_sources
+            
+        if reasoning:
+            # Serialize reasoning steps
+            serialized_reasoning = json.loads(json.dumps(reasoning, default=serialize_custom_objects))
+            message["reasoning"] = serialized_reasoning
+            
+        if images:
+            # Serialize images
+            serialized_images = json.loads(json.dumps(images, default=serialize_custom_objects))
+            message["images"] = serialized_images
+        
+        # Insert the message
+        result = db[MESSAGES_COLLECTION].insert_one(message)
+        
+        # Update the conversation timestamp
+        update_conversation_timestamp(conversation_id)
+        
+        return str(result.inserted_id)
+    except Exception as e:
+        logger.error(f"Error adding message: {e}")
+        return None
+
+def serialize_custom_objects(obj):
+    """
+    Custom serialization function for MongoDB.
+    Handles special types like ActionReasoningStep and other custom classes.
+    """
+    if hasattr(obj, '__dict__'):
+        # For ActionReasoningStep, ObservationReasoningStep, etc.
+        if obj.__class__.__name__.endswith('ReasoningStep'):
+            result = {
+                'type': obj.__class__.__name__
+            }
+            
+            # Add attributes based on the specific type
+            if hasattr(obj, 'action'):
+                result['action'] = obj.action
+            if hasattr(obj, 'action_input'):
+                result['action_input'] = obj.action_input
+            if hasattr(obj, 'observation'):
+                result['observation'] = obj.observation
+            if hasattr(obj, 'response'):
+                result['response'] = obj.response
+            if hasattr(obj, 'thought'):
+                result['thought'] = obj.thought
+                
+            return result
+        
+        # For other objects with __dict__
+        return {k: v for k, v in obj.__dict__.items() 
+                if not k.startswith('_') and not callable(v)}
+    
+    # For objects with content property
+    if hasattr(obj, 'content'):
+        return str(obj.content)
+        
+    # For objects with string representation
+    try:
+        return str(obj)
+    except:
+        return f""
+
+def get_conversation_messages(conversation_id: str) -> List[Dict]:
+    """Get all messages in a conversation."""
+    try:
+        db = get_db()
+        messages = list(db[MESSAGES_COLLECTION].find(
+            {"conversation_id": conversation_id}
+        ).sort("timestamp", pymongo.ASCENDING))
+        return messages
+    except Exception as e:
+        logger.error(f"Error getting conversation messages: {e}")
+        return []
+
+def generate_conversation_title(conversation_id: str, content: List[Dict]) -> Optional[str]:
+    """
+    Generate a title for a conversation based on its content using AI.
+    
+    Args:
+        conversation_id: The ID of the conversation
+        content: List of messages in the conversation
+    
+    Returns:
+        A generated title, or None if generation failed
+    """
+    try:
+        from llama_index.llms.openai import OpenAI as LlamaOpenAI
+        
+        # Extract text from the conversation (first few messages)
+        conversation_text = "\n".join([
+            f"{msg['role']}: {msg['content']}" 
+            for msg in content[:5]  # Use first 5 messages or fewer
+        ])
+        
+        # Create LLM instance
+        llm = LlamaOpenAI(
+            model="chatgpt-4o-latest",
+            temperature=0.3,
+        )
+        
+        # Generate title
+        prompt = f"""
+        Based on the following conversation, generate a short, descriptive title (max 5 words):
+        
+        {conversation_text}
+        
+        Title:
+        """
+        
+        response = llm.complete(prompt)
+        title = response.text.strip()
+        
+        # Update the conversation with the new title
+        update_conversation_title(conversation_id, title)
+        
+        return title
+    except Exception as e:
+        logger.error(f"Error generating conversation title: {e}")
+        return "New conversation"  # Fallback title
+
+def delete_conversation(conversation_id: str, hard_delete: bool = False) -> bool:
+    """
+    Delete a conversation and its messages.
+    
+    Args:
+        conversation_id: The ID of the conversation to delete
+        hard_delete: If True, physically delete the records; if False, mark as deleted
+        
+    Returns:
+        True if successful, False otherwise
+    """
+    try:
+        db = get_db()
+        
+        if hard_delete:
+            # Permanently delete all messages in the conversation
+            db[MESSAGES_COLLECTION].delete_many({"conversation_id": conversation_id})
+            
+            # Permanently delete the conversation
+            db[CONVERSATIONS_COLLECTION].delete_one({"_id": ObjectId(conversation_id)})
+        else:
+            # Mark the conversation as deleted
+            db[CONVERSATIONS_COLLECTION].update_one(
+                {"_id": ObjectId(conversation_id)},
+                {"$set": {"is_deleted": True}}
+            )
+        
+        return True
+    except Exception as e:
+        logger.error(f"Error deleting conversation: {e}")
+        return False
+
+# Session state management
+def get_session_state(session_id: str) -> Optional[Dict]:
+    """
+    Get the session state from MongoDB.
+    
+    Args:
+        session_id: The session ID
+        
+    Returns:
+        The session state or None if not found
+    """
+    try:
+        conversation = get_conversation(session_id)
+        if conversation:
+            # Return a minimal session state
+            return {
+                "initialized": True,
+                "conversation_id": str(conversation["_id"]),
+                "user_id": conversation["user_id"]
+            }
+        return None
+    except Exception as e:
+        logger.error(f"Error getting session state: {e}")
+        return None
+
+def create_session_state(session_id: str, user_id: str, conversation_id: Optional[str] = None) -> Optional[Dict]:
+    """
+    Create a new session state in MongoDB.
+    
+    Args:
+        session_id: The session ID
+        user_id: The user ID
+        conversation_id: Optional conversation ID. If not provided, a new conversation will be created.
+        
+    Returns:
+        The created session state or None if creation failed
+    """
+    try:
+        if not conversation_id:
+            conversation_id = create_conversation(session_id, user_id)
+            
+        if conversation_id:
+            return {
+                "initialized": True,
+                "conversation_id": conversation_id,
+                "user_id": user_id
+            }
+        return None
+    except Exception as e:
+        logger.error(f"Error creating session state: {e}")
+        return None
+
+def update_session_state(session_id: str, state: Dict) -> bool:
+    """
+    Update the session state in MongoDB.
+    
+    Args:
+        session_id: The session ID
+        state: The new state to save
+        
+    Returns:
+        True if the update was successful, False otherwise
+    """
+    try:
+        conversation = get_conversation(session_id)
+        if conversation:
+            # If we need to store additional session state beyond the conversation
+            # we could add a separate collection for that
+            return True
+        return False
+    except Exception as e:
+        logger.error(f"Error updating session state: {e}")
+        return False
\ No newline at end of file
diff --git a/neo4j/docker-compose-neo4j.yml b/neo4j/docker-compose-neo4j.yml
new file mode 100644
index 0000000..40fb539
--- /dev/null
+++ b/neo4j/docker-compose-neo4j.yml
@@ -0,0 +1,28 @@
+version: '3'
+
+services:
+  neo4j:
+    image: neo4j:latest
+    container_name: netflix-graphrag-neo4j
+    ports:
+      - "7474:7474"  # HTTP
+      - "7687:7687"  # Bolt
+    volumes:
+      - ./neo4j/data:/data
+      - ./neo4j/plugins:/plugins
+      - ./neo4j/logs:/logs
+      - ./neo4j/import:/import
+    environment:
+      - NEO4J_AUTH=neo4j/tavern-easy-museum-arthur-coconut-3483  # Neo4j password
+      - NEO4J_apoc_export_file_enabled=true
+      - NEO4J_apoc_import_file_enabled=true
+      - NEO4J_apoc_import_file_use__neo4j__config=true
+      - NEO4JLABS_PLUGINS=["apoc"]  # Install APOC plugin
+    healthcheck:
+      test: ["CMD", "curl", "-f", "http://localhost:7474"]
+      interval: 10s
+      timeout: 5s
+      retries: 5
+
+# Neo4j Browser: http://localhost:7474
+# Credentials: neo4j/tavern-easy-museum-arthur-coconut-3483
\ No newline at end of file
diff --git a/netflix_technical_diagram.md b/netflix_technical_diagram.md
new file mode 100644
index 0000000..68ab7d7
--- /dev/null
+++ b/netflix_technical_diagram.md
@@ -0,0 +1,242 @@
+# Netflix GraphRAG Enhanced Marketing Chatbot - Technical Architecture
+
+## System Overview
+
+This system is a sophisticated chatbot application that combines vector search with graph-based knowledge representation to provide comprehensive answers about Netflix marketing materials, particularly the GPD Key Art Playbook.
+
+## Architecture Diagram
+
+```mermaid
+graph TB
+    %% User Interface Layer
+    User[👤 User] --> Frontend[🌐 React Frontend
Chat Interface] + + %% Frontend Components + Frontend --> |HTTP/WebSocket| Backend[⚙️ Flask Backend
Python + Hypercorn] + Frontend --> Auth[🔐 Microsoft Auth
MSAL + Dev Bypass] + + %% Backend Core Components + Backend --> Routes[📡 Flask Routes
API Endpoints] + Routes --> SessionMgr[📝 Session Manager
State Management] + Routes --> AICore[🧠 AI Core
Agent Orchestration] + + %% AI Processing Components + AICore --> |Uses| Agent[🤖 ReAct Agent
Custom Workflow] + Agent --> |Tool Selection| Tools[🔧 Query Engine Tools] + + %% Tool Ecosystem + Tools --> VectorTool[🔍 Vector Query Tool
LlamaIndex Retriever] + Tools --> GraphTool[🕸️ GraphRAG Tool
Hybrid Retrieval] + + %% Vector Search Pipeline + VectorTool --> VectorIndex[📊 Vector Store Index
OpenAI Embeddings] + VectorIndex --> VectorNodes[📄 Document Nodes
Chunked Content] + + %% GraphRAG Pipeline + GraphTool --> GraphStore[🗄️ GraphRAG Store
Neo4j Backend] + GraphTool --> PropertyIndex[🏗️ Property Graph Index
Knowledge Graph] + + %% Knowledge Graph Components + GraphStore --> Neo4j[(🔗 Neo4j Database
Graph Storage)] + PropertyIndex --> Entities[🏷️ Entity Nodes
Extracted Entities] + PropertyIndex --> Relations[↔️ Relationships
Entity Connections] + PropertyIndex --> Communities[👥 Communities
Clustered Groups] + + %% Document Processing Pipeline + Docs[📚 Netflix Documents
PDFs, DOCX, etc.] --> LlamaParse[🔄 LlamaParse
Document Processing] + LlamaParse --> |Text Extraction| TextChunks[📝 Text Chunks
Semantic Splitting] + LlamaParse --> |Image Extraction| Images[🖼️ Page Images
Visual Context] + + %% Content Processing + TextChunks --> VectorNodes + TextChunks --> |Knowledge Extraction| GraphExtractor[⚡ GraphRAG Extractor
Entity & Relation Mining] + GraphExtractor --> Entities + GraphExtractor --> Relations + + %% Storage Layer + Images --> ImageStore[🗂️ Image Storage
File System] + VectorNodes --> |Persistence| VectorDB[(💾 Vector Database
LlamaIndex Storage)] + + %% Conversation Management + SessionMgr --> MongoDB[(🍃 MongoDB
Conversation History)] + MongoDB --> Conversations[💬 Conversations
User Sessions] + MongoDB --> Messages[✉️ Messages
Chat History] + + %% External Services + AICore --> OpenAI[🔑 OpenAI API
LLM & Embeddings] + GraphStore --> |Community Summaries| OpenAI + LlamaParse --> |Processing| LlamaCloud[☁️ LlamaCloud
Document API] + + %% Response Flow + Agent --> |Generates| Response[📤 AI Response
Text + Sources + Images] + Response --> Routes + Routes --> |JSON API| Frontend + + %% Image Serving + ImageStore --> |HTTP Serve| Routes + Routes --> |Image URLs| Frontend + + %% Configuration + Config[⚙️ Configuration
Environment Variables] --> Backend + Config --> |API Keys| OpenAI + Config --> |Database URLs| MongoDB + Config --> |Neo4j Settings| Neo4j + + %% Styling + classDef frontend fill:#e1f5fe,stroke:#01579b,stroke-width:2px + classDef backend fill:#fff3e0,stroke:#e65100,stroke-width:2px + classDef ai fill:#e8f5e8,stroke:#2e7d32,stroke-width:2px + classDef storage fill:#fce4ec,stroke:#c2185b,stroke-width:2px + classDef external fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px + classDef docs fill:#fff8e1,stroke:#f57f17,stroke-width:2px + + class User,Frontend,Auth frontend + class Backend,Routes,SessionMgr,Config backend + class AICore,Agent,Tools,VectorTool,GraphTool,GraphExtractor ai + class VectorIndex,PropertyIndex,VectorNodes,GraphStore,Entities,Relations,Communities,ImageStore,VectorDB,MongoDB,Conversations,Messages storage + class OpenAI,LlamaCloud external + class Docs,LlamaParse,TextChunks,Images docs +``` + +## Component Details + +### 1. Frontend Layer (React + TypeScript) +- **React Chat Interface**: Modern chat UI with conversation management +- **Authentication**: Microsoft MSAL integration with development bypass +- **Features**: + - Real-time chat with typing indicators + - Image thumbnail display and full-screen viewer + - Source citation tooltips + - Conversation history and management + - Markdown rendering for rich responses + - Dark/light theme support + +### 2. Backend Layer (Python Flask + Hypercorn) +- **Flask Application**: Async-capable web framework +- **Route Handlers**: RESTful API endpoints for chat, images, conversations +- **Session Management**: Stateful conversation tracking with MongoDB +- **CORS Support**: Cross-origin resource sharing for frontend integration + +### 3. AI Processing Engine +- **ReAct Agent**: Custom workflow implementation using LlamaIndex + - Action-reasoning-observation loop + - Tool selection and execution + - Memory management for conversation context + - Timeout handling and error recovery + +### 4. Dual Retrieval System + +#### Vector Search (Traditional RAG) +- **Vector Store Index**: OpenAI embeddings with semantic search +- **Retriever**: Auto-retriever with metadata filtering +- **Post-processing**: Similarity cutoff and ranking + +#### GraphRAG (Enhanced Knowledge Retrieval) +- **Knowledge Graph**: Neo4j-based entity-relationship graph +- **Community Detection**: Hierarchical clustering for efficient querying +- **Hybrid Retrieval**: Combines vector search with graph traversal +- **Entity Extraction**: LLM-powered entity and relationship mining + +### 5. Document Processing Pipeline +- **LlamaParse Integration**: Advanced document parsing service +- **Multi-format Support**: PDF, DOCX, PPTX, Excel files +- **Text Extraction**: Semantic chunking with metadata preservation +- **Image Extraction**: Page-level screenshot generation +- **Metadata Enrichment**: File, page, and chunk-level annotations + +### 6. Storage Architecture + +#### Vector Storage +- **LlamaIndex Storage**: Persistent vector index with JSON serialization +- **Document Store**: Chunk-level document storage +- **Graph Store**: Index structure and metadata + +#### Graph Storage (Neo4j) +- **Entity Nodes**: Named entities with types and descriptions +- **Relationships**: Typed connections between entities +- **Communities**: Clustered entity groups for efficient querying +- **Caching**: Community summaries and entity information + +#### Conversation Storage (MongoDB) +- **Users Collection**: User profiles and authentication data +- **Conversations Collection**: Chat sessions with metadata +- **Messages Collection**: Individual messages with sources and reasoning + +### 7. Content Management +- **Image Serving**: Static file serving with URL encoding +- **Source Tracking**: Citation and provenance information +- **Reasoning Capture**: Step-by-step agent decision logging + +## Data Flow + +### 1. Document Ingestion Flow +``` +Documents → LlamaParse → [Text Chunks + Images] → Vector Index + Knowledge Graph +``` + +### 2. Query Processing Flow +``` +User Query → ReAct Agent → Tool Selection → [Vector + Graph Retrieval] → Response Synthesis → User +``` + +### 3. Conversation Flow +``` +User Message → Session Manager → AI Processing → Response Storage → Frontend Update +``` + +## Key Technologies + +### Backend Stack +- **Python 3.8+**: Core runtime +- **Flask**: Web framework +- **Hypercorn**: ASGI server for async support +- **LlamaIndex**: RAG framework and vector operations +- **NetworkX**: Graph algorithms and community detection + +### AI & ML Stack +- **OpenAI API**: GPT-4 for reasoning and embeddings +- **LlamaParse**: Document processing and image extraction +- **Semantic Splitter**: Advanced text chunking +- **Custom GraphRAG**: Hybrid retrieval implementation + +### Storage Stack +- **Neo4j**: Graph database for knowledge representation +- **MongoDB**: Document database for conversations +- **File System**: Image and document storage + +### Frontend Stack +- **React 18**: UI framework +- **Vite**: Build tool and development server +- **Tailwind CSS**: Utility-first styling +- **Showdown**: Markdown rendering +- **Microsoft MSAL**: Authentication library + +## Scalability Considerations + +### Performance Optimizations +- **Async Processing**: Non-blocking agent operations +- **Caching**: Community summaries and entity data +- **Connection Pooling**: Database connection management +- **Image Optimization**: Thumbnail generation and lazy loading + +### Infrastructure +- **Containerization**: Docker support for deployment +- **Environment Configuration**: Flexible config management +- **Error Handling**: Comprehensive logging and recovery +- **Security**: Input validation and authentication + +## Development & Deployment + +### Development Mode +- **Authentication Bypass**: Local development without MSAL +- **Hot Reloading**: Automatic server and frontend refresh +- **Debug Endpoints**: System status and reinitialization +- **Verbose Logging**: Detailed operation tracking + +### Production Considerations +- **HTTPS/SSL**: Secure communication +- **Load Balancing**: Multi-instance deployment +- **Database Clustering**: High availability storage +- **Monitoring**: Health checks and metrics collection + +This architecture provides a robust, scalable foundation for an AI-powered marketing knowledge system that combines the best of traditional vector search with advanced graph-based reasoning capabilities. \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..baafad6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,106 @@ +aiohappyeyeballs==2.6.1 +aiohttp==3.11.16 +aiosignal==1.3.2 +annotated-types==0.7.0 +anyio==4.9.0 +asgiref==3.8.1 +asyncio==3.4.3 +attrs==25.3.0 +banks==2.1.1 +beautifulsoup4==4.13.3 +blinker==1.9.0 +certifi==2025.1.31 +charset-normalizer==3.4.1 +click==8.1.8 +colorama==0.4.6 +dataclasses-json==0.6.7 +Deprecated==1.2.18 +dirtyjson==1.0.8 +distro==1.9.0 +dnspython==2.7.0 +filetype==1.2.0 +Flask==3.1.0 +flask-cors==5.0.1 +frozenlist==1.5.0 +fsspec==2025.3.2 +future==1.0.0 +greenlet==3.1.1 +griffe==1.7.2 +h11==0.14.0 +h2==4.2.0 +hpack==4.1.0 +httpcore==1.0.7 +httpx==0.28.1 +Hypercorn==0.17.3 +hyperframe==6.1.0 +idna==3.10 +itsdangerous==2.2.0 +Jinja2==3.1.6 +jiter==0.9.0 +joblib==1.4.2 +llama-cloud==0.1.17 +llama-cloud-services==0.6.9 +llama-index==0.12.33 +llama-index-agent-openai==0.4.6 +llama-index-cli==0.4.1 +llama-index-core==0.12.33.post1 +llama-index-embeddings-openai==0.3.1 +llama-index-graph-stores-neo4j==0.4.6 +llama-index-indices-managed-llama-cloud==0.6.11 +llama-index-llms-openai==0.3.30 +llama-index-multi-modal-llms-openai==0.4.3 +llama-index-program-openai==0.3.1 +llama-index-question-gen-openai==0.3.0 +llama-index-readers-file==0.4.7 +llama-index-readers-llama-parse==0.4.0 +llama-parse==0.6.4.post1 +lxml==5.3.2 +markdown2==2.5.3 +MarkupSafe==3.0.2 +marshmallow==3.26.1 +multidict==6.3.2 +mypy-extensions==1.0.0 +neo4j==5.28.1 +nest-asyncio==1.6.0 +networkx==3.4.2 +nltk==3.9.1 +numpy==2.2.4 +openai==1.71.0 +packaging==24.2 +pandas==2.2.3 +pillow==11.1.0 +platformdirs==4.3.7 +priority==2.0.0 +propcache==0.3.1 +pydantic==2.11.2 +pydantic_core==2.33.1 +pymongo==4.7.0 +pypdf==5.4.0 +python-dateutil==2.9.0.post0 +python-docx==1.1.2 +python-dotenv==1.1.0 +python-louvain==0.16 +pytz==2025.2 +PyYAML==6.0.2 +regex==2024.11.6 +requests==2.32.3 +setuptools==79.0.0 +six==1.17.0 +sniffio==1.3.1 +soupsieve==2.6 +SQLAlchemy==2.0.40 +striprtf==0.0.26 +tenacity==9.1.2 +tiktoken==0.9.0 +tqdm==4.67.1 +typing-inspect==0.9.0 +typing-inspection==0.4.0 +typing_extensions==4.13.1 +tzdata==2025.2 +urllib3==2.3.0 +uuid==1.30 +Werkzeug==3.1.3 +wheel==0.45.1 +wrapt==1.17.2 +wsproto==1.2.0 +yarl==1.19.0 diff --git a/routes.py b/routes.py new file mode 100644 index 0000000..5e554fc --- /dev/null +++ b/routes.py @@ -0,0 +1,1032 @@ +# netflix_chatbot/routes.py + +import os +import asyncio +import traceback +import uuid +import shutil +import urllib.parse +from datetime import datetime +from pathlib import Path + +from flask import ( + Flask, request, jsonify, make_response, send_from_directory, send_file, + Blueprint # Consider using Blueprint for larger apps +) +from werkzeug.utils import secure_filename # Keep if file uploads are reintroduced + +# Import from our modules +from utils import logger, log_structured, allowed_file # Keep allowed_file if uploads return +from json_utils import CustomJSONEncoder # Used indirectly by make_response/jsonify with custom provider +from session_manager import get_or_create_session_state, clear_chat_state_cache +from ai_core import initialize_global_index # Import initialization function +from shared_state import global_workflow_agent, global_index, is_agent_available # Import shared state variables +from document_generator import create_brief_docx +from config import IMAGES_DIRECTORY, NETFLIX_DOCS_FOLDER, BASE_DIR, SUPPORTING_FILES_DIR # Import necessary configs +from llama_index.core.tools import ToolOutput # Import ToolOutput for type checking +from llama_index.core.llms import ChatMessage # Import ChatMessage for memory management + +# Import MongoDB utilities +from mongodb_utils import ( + add_message, get_conversation_messages, get_user_conversations, + get_conversation_by_id, create_conversation, create_or_update_user, + delete_conversation as db_delete_conversation, # Alias to avoid conflict + generate_conversation_title, update_session_state +) + +# Create a Blueprint or define routes on the app passed from main.py +# Using direct app registration for simplicity here. +# If using Blueprint: +# api_bp = Blueprint('api', __name__) +# @api_bp.route('/chat', methods=['POST', 'OPTIONS']) +# async def chat(): ... +# In main.py: app.register_blueprint(api_bp) + +def register_routes(app: Flask): + + @app.route('/chat', methods=['POST', 'OPTIONS']) + async def chat(): + """Handle chat messages, interact with the AI agent, include image handling.""" + # Declare global variables we'll need to access + global global_workflow_agent + + if request.method == 'OPTIONS': + # Pre-flight request. Reply successfully: + resp = make_response(jsonify({'status': 'ok'})) + # Add necessary CORS headers if not handled globally by Flask-CORS middleware + # resp.headers['Access-Control-Allow-Origin'] = request.headers.get('Origin') + # resp.headers['Access-Control-Allow-Credentials'] = 'true' + # resp.headers['Access-Control-Allow-Methods'] = 'POST, OPTIONS' + # resp.headers['Access-Control-Allow-Headers'] = 'Content-Type, X-MS-USERNAME, Authorization' # Add any custom headers + return resp + + session_id = None # Initialize for error logging + try: + data = request.get_json() + if not data or 'message' not in data: + log_structured('warning', 'Chat request missing message payload') + return jsonify({'error': 'No message provided'}), 400 + + session_id = data.get('sessionId') + if not session_id: + log_structured('warning', 'Chat request missing sessionId') + return jsonify({'error': 'Session ID is required'}), 400 + + # Use X-MS-USERNAME or a similar header for authenticated username + username = request.headers.get('X-MS-USERNAME') # Adjust header name if different + # Check if we're in development mode + if not username and os.environ.get("PRODUCTION", "false").lower() == "false": + # For local development, create a default username + username = f"dev_user@local" + log_structured('info', f'Development mode: Using default username: {username}') + elif not username: + # Allow anonymous access based on session or return error? + # For now, allow anonymous tied to session_id + log_structured('info', f'Chat request from anonymous user (session: {session_id})') + # username = f"anonymous_{session_id[:8]}" # Or handle as error if auth is mandatory + # return jsonify({'error': 'Authentication required (X-MS-USERNAME header missing)'}), 401 + + log_structured('info', f'Chat request received', {'session_id': session_id, 'username': username or 'anonymous'}) + + # Get or create session state (maps session_id to user_id, conversation_id) + session_state = get_or_create_session_state(session_id, username) + + # Ensure session is initialized in both development and production modes + if not session_state.get('initialized'): + log_structured('info', 'Session not yet initialized. Initializing now.', {'session_id': session_id}) + # Initialize session properly + session_state['initialized'] = True + # Update the session state in the database to mark as initialized + update_session_state(session_id, {'initialized': True}) + + conversation_id = session_state.get('conversation_id') + user_id = session_state.get('user_id') # Get user_id for context if needed + + if not conversation_id or not user_id: + log_structured('error', 'Session state missing conversation_id or user_id', {'session_id': session_id, 'state': session_state}) + return jsonify({'error': 'Session setup incomplete. Please refresh or start a new chat.'}), 500 + + user_message = data['message'] + + # Store user message + add_message(conversation_id, 'user', user_message) + + log_structured('debug', 'Starting agent workflow run', { + 'session_id': session_id, + 'conversation_id': conversation_id, + 'user_id': user_id, + 'message_preview': user_message[:100] + }) + + # Check if global agent is available using the shared state helper + if not is_agent_available(): + log_structured('error', 'Global workflow agent is not initialized') + + # Try to initialize it on-demand if not available + try: + log_structured('info', 'Attempting to initialize global index and agent on-demand') + index_success = await initialize_global_index() + + # Let initialization complete, then check the global variable + if index_success: + log_structured('info', 'Index initialization reported success') + + # Force reload the global module variable + import importlib + import ai_core + importlib.reload(ai_core) + from ai_core import global_workflow_agent + + # Check if global_workflow_agent is now available + if global_workflow_agent: + # Test the agent with a simple function call + try: + # Just check if the run method exists and is callable + if hasattr(global_workflow_agent, 'run') and callable(global_workflow_agent.run): + log_structured('info', 'Successfully initialized global agent on-demand') + else: + log_structured('error', 'Agent initialized but run method is not callable') + except Exception as agent_test_err: + log_structured('error', f'Agent functional test failed: {str(agent_test_err)}') + else: + log_structured('error', 'Agent still not available after successful index initialization') + else: + log_structured('error', 'Index initialization reported failure') + except Exception as init_err: + log_structured('error', f'Error initializing agent on-demand: {str(init_err)}') + + # Final check + import ai_core + # We're already using the global variable from the function declaration + # Just reassign it from the imported module + global_workflow_agent = ai_core.global_workflow_agent + if not global_workflow_agent: + log_structured('critical', 'Failed to get or initialize global workflow agent') + + # Provide a more detailed error message for debugging + error_details = { + 'error': 'Chat agent is not available. Please try again later.', + 'details': 'The AI agent failed to initialize properly. This might be due to API key issues or other backend problems.', + 'code': 'AGENT_UNAVAILABLE' + } + return jsonify(error_details), 503 + + # Use the agent to process the query (with one final check to be sure) + if not is_agent_available(): + log_structured('critical', 'Global agent is unavailable after initialization checks') + error_details = { + 'error': 'Chat agent became unavailable. Please try again.', + 'code': 'AGENT_DISAPPEARED' + } + return jsonify(error_details), 500 + + # Import fresh reference to agent + from shared_state import global_workflow_agent + if global_workflow_agent is None: + log_structured('critical', 'Global agent is None after direct import from shared_state') + error_details = { + 'error': 'Chat agent became unavailable. Please try again.', + 'code': 'AGENT_DISAPPEARED' + } + return jsonify(error_details), 500 + + log_structured('info', 'Running agent to process query') + + # Get a fresh reference to the agent from shared state + from shared_state import global_workflow_agent as current_agent + + # Double-check that the agent is available + if current_agent is None or not hasattr(current_agent, 'run'): + log_structured('critical', 'Agent disappeared at runtime: null check before .run() call') + return jsonify({'error': 'Chat agent became unavailable just before processing. Please try again.', 'code': 'AGENT_GONE'}), 500 + + # Load conversation history into the agent's memory + if conversation_id: + try: + conv_messages = get_conversation_messages(conversation_id) + # Skip if we already have messages in memory (avoid duplication) + if current_agent.memory and len(current_agent.memory.get()) <= 1: + log_structured('info', f'Loading conversation history: {len(conv_messages)} messages', + {'conversation_id': conversation_id}) + + # Clear memory first to avoid duplicate history + current_agent.memory.reset() + + # Add messages to memory in the correct order + for msg in conv_messages: + # Skip the current message which will be added by the agent + if msg.get('content') == user_message and msg.get('role') == 'user': + continue + role = msg.get('role') + content = msg.get('content', '') + if role and content: + current_agent.memory.put(ChatMessage(role=role, content=content)) + except Exception as hist_err: + log_structured('error', f'Error loading conversation history: {str(hist_err)}', + {'conversation_id': conversation_id}) + # Continue without history rather than failing the request + + # Call the run method on the current agent + raw_response = await current_agent.run(user_message) + + log_structured('debug', 'Raw response received from workflow agent', { + 'session_id': session_id, 'conversation_id': conversation_id, + 'response_type': type(raw_response).__name__, + 'response_keys': list(raw_response.keys()) if isinstance(raw_response, dict) else None, + }) + + # --- Process Response and Extract Images --- + final_response_text = "Sorry, I encountered an issue generating a response." + final_sources = [] + final_reasoning = [] + images = [] + seen_images = set() # Track unique images added + + if isinstance(raw_response, dict): + final_response_text = str(raw_response.get('response', '')) + response_sources = raw_response.get('sources', []) # Should be list of ToolOutput + final_reasoning = raw_response.get('reasoning', []) # Should be list of ReasoningSteps + + log_structured('debug', f'Processing {len(response_sources)} sources for metadata and images', {'session_id': session_id}) + + # Extract source details and potential images + if response_sources and isinstance(response_sources, list): + for source_idx, tool_output_source in enumerate(response_sources): + if tool_output_source is None: + continue + + source_info = {'content': "Source content unavailable", 'metadata': {}} # Default + if isinstance(tool_output_source, ToolOutput): + source_info['content'] = str(tool_output_source.content) if tool_output_source.content else "No content" + source_info['tool_name'] = getattr(tool_output_source, 'tool_name', 'unknown_tool') + source_info['raw_output_preview'] = str(tool_output_source.raw_output)[:100] if hasattr(tool_output_source, 'raw_output') and tool_output_source.raw_output else "N/A" + + # Log GraphRAG retrieval information if present + if getattr(tool_output_source, 'tool_name', '') == 'GraphRAG': + raw_output = getattr(tool_output_source, 'raw_output', {}) + if isinstance(raw_output, dict): + # Extract and log vector and GraphRAG contexts + vector_context = raw_output.get('vector_context', '') + graphrag_context = raw_output.get('graphrag_context', '') + + # Add retrieval source information to the log + log_structured('info', 'GraphRAG Retrieval Results', { + 'session_id': session_id, + 'vector_retrieval_size': len(vector_context), + 'graphrag_retrieval_size': len(graphrag_context), + 'vector_retrieval_preview': vector_context[:200] + '...' if len(vector_context) > 200 else vector_context, + 'graphrag_retrieval_preview': graphrag_context[:200] + '...' if len(graphrag_context) > 200 else graphrag_context, + 'community_ids': raw_output.get('community_ids', []) + }) + + # Add context source information to the source_info + source_info['retrieval_method'] = 'graphrag_hybrid' + source_info['vector_context_length'] = len(vector_context) + source_info['graphrag_context_length'] = len(graphrag_context) + source_info['used_communities'] = raw_output.get('community_ids', []) + else: + # Standard vector retrieval + source_info['retrieval_method'] = 'vector_only' + + # Try accessing source_nodes from raw_output if it exists + raw_output = getattr(tool_output_source, 'raw_output', None) + if raw_output and hasattr(raw_output, 'source_nodes'): + nodes_metadata = [] + # Safely iterate over source_nodes + source_nodes = getattr(raw_output, 'source_nodes', []) + if source_nodes: + for node_idx, node_with_score in enumerate(source_nodes): + node = getattr(node_with_score, 'node', None) + if node and hasattr(node, 'metadata'): + node_meta = node.metadata or {} + nodes_metadata.append(node_meta) # Collect metadata for source info + + # Check for images within this node's metadata + node_image_paths = node_meta.get('image_paths', []) + if isinstance(node_image_paths, list): + for img_filename in node_image_paths: + if img_filename and isinstance(img_filename, str) and img_filename not in seen_images: + # Ensure IMAGES_DIRECTORY is a Path object + image_dir = Path(IMAGES_DIRECTORY) + image_full_path = image_dir / img_filename + + if image_full_path.is_file(): + # Extract doc name/page from filename (adjust parsing as needed) + doc_name = node_meta.get('filename', 'Unknown Document') + page_num = node_meta.get('source_page', 'Unknown Page') + + # URL encode the filename for the frontend + url_encoded_filename = urllib.parse.quote(img_filename) + + image_info = { + 'filename': img_filename, + 'url_encoded_filename': url_encoded_filename, + 'document': doc_name, + 'page': page_num, + 'source': doc_name # Redundant? Maybe use tool name? + } + images.append(image_info) + seen_images.add(img_filename) + + # Get node ID safely + node_id = getattr(node, 'id_', 'unknown_node') + log_structured('info', f'Added image from node metadata: {img_filename}', {'session_id': session_id, 'node_id': node_id}) + else: + log_structured('warning', f'Image file from metadata not found: {img_filename}', {'session_id': session_id, 'path_checked': str(image_full_path)}) + else: + if node_image_paths: # Log if it exists but isn't a list + node_id = getattr(node, 'id_', 'unknown_node') + log_structured('warning', f"'image_paths' in node metadata is not a list.", {'session_id': session_id, 'node_id': node_id, 'metadata_value': str(node_image_paths)}) + + # Add collected node metadata (or tool metadata) to the source info + # Initialize nodes_metadata if it doesn't exist + if not locals().get('nodes_metadata'): + nodes_metadata = [] + source_info['metadata'] = {'nodes': nodes_metadata} if nodes_metadata else getattr(tool_output_source, 'metadata', {}) + + else: + log_structured('warning', f'Source element is not a ToolOutput object', {'type': type(tool_output_source).__name__}) + source_info['content'] = str(tool_output_source) # Fallback + + # Add the processed source info regardless of type + final_sources.append(source_info) + + else: + # Handle cases where agent might return just a string (less ideal) + final_response_text = str(raw_response) + log_structured('warning', 'Agent response was not a dict, sources/reasoning unavailable.', {'response_type': type(raw_response).__name__}) + + log_structured('info', f'Final image count for response: {len(images)}', {'session_id': session_id, 'images': images}) + + # Structure the response to be sent to the frontend + processed_response = { + 'response': final_response_text, + 'sources': final_sources, # Use the processed sources + 'reasoning': final_reasoning, # Use the extracted reasoning steps + 'images': images # Use the validated list of image info dicts + } + + # Store assistant response in MongoDB + # Note: Ensure final_sources and final_reasoning are serializable by CustomJSONEncoder + add_message( + conversation_id, + 'assistant', + final_response_text, + sources=final_sources, + reasoning=final_reasoning, # Pass reasoning steps + images=images # Pass image info list + ) + + # Update conversation title (if needed) + conv_messages = get_conversation_messages(conversation_id) + # Generate title after first user/assistant pair usually + if conv_messages and len(conv_messages) == 2: + try: + new_title = generate_conversation_title(conversation_id, conv_messages) + log_structured('info', f'Generated initial conversation title: "{new_title}"', {'conversation_id': conversation_id}) + except Exception as title_err: + log_structured('error', f'Error generating conversation title: {title_err}', {'conversation_id': conversation_id}) + # Non-fatal error + + # Use make_response to ensure custom JSON encoder is used + response = make_response(jsonify({ + 'status': 'success', + 'data': processed_response + })) + response.headers['Content-Type'] = 'application/json' + return response + + except asyncio.TimeoutError: + log_structured('error', 'Chat request timed out', {'session_id': session_id}) + return jsonify({'status': 'error', 'error': 'Request timed out', 'detail': 'The request took too long to process.'}), 504 # Gateway Timeout + except Exception as e: + log_structured('error', 'Unhandled error in /chat endpoint', { + 'session_id': session_id, 'error': str(e), 'traceback': traceback.format_exc() + }) + response = make_response(jsonify({ + 'status': 'error', 'error': 'An internal server error occurred.', 'detail': str(e) + }), 500) + response.headers['Content-Type'] = 'application/json' + return response + + @app.route('/images/', methods=['GET']) + def serve_image(filename): + """Serve an image file from the designated image directory.""" + # Log the requested image + log_structured('debug', f'Image request received for: {filename}') + + # For filenames with spaces and special characters, don't use secure_filename + # Just check for basic path traversal attempts + if '..' in filename or filename.startswith('/'): + log_structured('warning', 'Possible path traversal attempt', {'requested': filename}) + return jsonify({'error': 'Invalid filename'}), 400 + + try: + # Try the main images directory first (using raw filename) + image_path = os.path.join(IMAGES_DIRECTORY, filename) + if os.path.exists(image_path) and os.path.isfile(image_path): + log_structured('debug', f'Serving image from IMAGES_DIRECTORY: {filename}') + return send_from_directory(IMAGES_DIRECTORY, filename, as_attachment=False) + + # If not found, check for public images in the chat interface public directory + public_images_dir = BASE_DIR / "chat-interface" / "public" / "images" + public_image_path = os.path.join(public_images_dir, filename) + if os.path.exists(public_image_path) and os.path.isfile(public_image_path): + log_structured('debug', f'Found image in public directory: {filename}') + return send_from_directory(public_images_dir, filename, as_attachment=False) + + # If still not found, look for the image in supporting files + supporting_images_dir = SUPPORTING_FILES_DIR + supporting_image_path = os.path.join(supporting_images_dir, filename) + if os.path.exists(supporting_image_path) and os.path.isfile(supporting_image_path): + log_structured('debug', f'Found image in supporting files: {filename}') + return send_from_directory(supporting_images_dir, filename, as_attachment=False) + + # Try with URL-decoded filename + import urllib.parse + decoded_filename = urllib.parse.unquote(filename) + if decoded_filename != filename: + decoded_image_path = os.path.join(IMAGES_DIRECTORY, decoded_filename) + if os.path.exists(decoded_image_path) and os.path.isfile(decoded_image_path): + log_structured('debug', f'Serving image with decoded filename: {decoded_filename}') + return send_from_directory(IMAGES_DIRECTORY, decoded_filename, as_attachment=False) + + log_structured('error', f'Image not found in any directory: {filename}') + return jsonify({'error': 'Image not found'}), 404 + except Exception as e: + log_structured('error', f'Error serving image {filename}', {'error': str(e)}) + return jsonify({'error': 'Server error serving image'}), 500 + + @app.route('/list-images', methods=['GET']) + def list_images(): + """List all available images in the images directory.""" + try: + if not os.path.exists(IMAGES_DIRECTORY): + log_structured('error', 'Images directory does not exist', {'path': str(IMAGES_DIRECTORY)}) + return jsonify({'status': 'error', 'message': 'Images directory not found'}), 404 + + image_files = [] + valid_extensions = ('.png', '.jpg', '.jpeg', '.gif', '.webp') # Add more if needed + + # Use os.listdir which is more reliable with special characters + for filename in os.listdir(IMAGES_DIRECTORY): + file_path = os.path.join(IMAGES_DIRECTORY, filename) + if os.path.isfile(file_path) and os.path.splitext(filename)[1].lower() in valid_extensions: + try: + stat_info = os.stat(file_path) + image_files.append({ + 'filename': filename, + 'url': f'/images/{urllib.parse.quote(filename)}', # URL encode for safety + 'size': stat_info.st_size, + 'created': datetime.fromtimestamp(stat_info.st_ctime).isoformat() + }) + except Exception as stat_err: + log_structured('error', f'Could not get stats for image file: {filename}', {'error': str(stat_err)}) + + # Sort by creation time, newest first + image_files.sort(key=lambda x: x['created'], reverse=True) + + return jsonify({ + 'status': 'success', + 'count': len(image_files), + 'images': image_files + }) + except Exception as e: + log_structured('error', 'Error listing images', {'error': str(e), 'traceback': traceback.format_exc()}) + return jsonify({'status': 'error', 'message': 'Failed to list images'}), 500 + + + # Example route for manual testing (consider removing in production) + @app.route('/capture-screenshot', methods=['POST']) + async def capture_screenshot(): + """Manually trigger LlamaParse image capture for a specific file (for testing).""" + # This uses parts of ai_core.process_documents_in_directory - refactor might be needed + # For simplicity, we'll re-implement the image parsing part here + # WARNING: This endpoint allows triggering processing on arbitrary paths if not secured. + # Consider adding authentication/authorization or removing it. + try: + data = request.get_json() + file_path_str = data.get('file_path') if data else None + + if not file_path_str: + return jsonify({'status': 'error', 'message': 'Missing file_path'}), 400 + + # Basic check: Ensure the file is within the expected docs folder for safety + target_path = Path(file_path_str).resolve() + allowed_dir = NETFLIX_DOCS_FOLDER.resolve() + if not str(target_path).startswith(str(allowed_dir)): + log_structured('warning', 'Attempt to access file outside allowed directory', {'requested': file_path_str, 'allowed': str(allowed_dir)}) + return jsonify({'status': 'error', 'message': 'File path is not allowed'}), 403 + + if not target_path.is_file(): + return jsonify({'status': 'error', 'message': f'File not found: {file_path_str}'}), 404 + + # --- Re-initialize LlamaParse for image capture --- + parser_images = None + image_results = [] + temp_dir = None + try: + from config import LLAMA_PARSE_VENDOR_MODEL, LLAMA_PARSE_MAX_TIMEOUT + import httpx + from llama_parse import LlamaParse # Local import for clarity + + custom_client_images = httpx.AsyncClient(timeout=LLAMA_PARSE_MAX_TIMEOUT) + parser_images = LlamaParse( + result_type="markdown", add_page_breaks=False, + system_prompt="Generate page images.", + use_vendor_multimodal_model=True, + vendor_multimodal_model_name=LLAMA_PARSE_VENDOR_MODEL, + premium_mode=False, max_timeout=LLAMA_PARSE_MAX_TIMEOUT, + custom_client=custom_client_images, verbose=True + ) + + log_structured('info', f'Attempting to capture images from file: {file_path_str} (Manual Trigger)') + temp_dir = IMAGES_DIRECTORY / f"temp_manual_{uuid.uuid4().hex[:8]}" + os.makedirs(temp_dir, exist_ok=True) + + # --- Execute image parsing (sync methods in thread) --- + loop = asyncio.get_running_loop() + from concurrent.futures import ThreadPoolExecutor + with ThreadPoolExecutor() as pool: + md_json_objs = await loop.run_in_executor(pool, parser_images.get_json_result, str(target_path)) + image_dicts = await loop.run_in_executor(pool, parser_images.get_images, md_json_objs, str(temp_dir)) + + # Ensure image_dicts is not None and is a list + image_dicts = image_dicts or [] + + log_structured('info', f'Manual capture: LlamaParse reported {len(image_dicts)} images for {target_path.name}') + + # --- Process and save images --- + if isinstance(image_dicts, list): # Safety check + for idx, img_info in enumerate(image_dicts): + if not isinstance(img_info, dict): + log_structured('warning', f'Image info is not a dict: {img_info}') + continue + + page_index_0_based = img_info.get('page', idx) + page_num_1_based = page_index_0_based + 1 + source_img_path_str = img_info.get('path') + + if source_img_path_str and os.path.exists(source_img_path_str): + source_img_path = Path(source_img_path_str) + try: + image_filename = f"{target_path.stem}_manual_page{page_num_1_based}_{uuid.uuid4().hex[:6]}.png" + dest_path = IMAGES_DIRECTORY / image_filename + shutil.copy2(source_img_path, dest_path) + image_results.append({ + 'success': True, 'page_number': page_num_1_based, + 'filename': image_filename, 'url': f'/images/{image_filename}' + }) + log_structured('info', f'Saved manual test image for page {page_num_1_based}', {'path': str(dest_path)}) + except Exception as img_err: + log_structured('error', f'Failed to save manual test image page {page_num_1_based}', {'error': str(img_err)}) + image_results.append({'success': False, 'page_number': page_num_1_based, 'error': str(img_err)}) + else: + image_results.append({'success': False, 'page_number': page_num_1_based, 'error': 'No image path found or path invalid'}) + + except Exception as parse_err: + log_structured('error', f'Error during manual image capture for {file_path_str}', {'error': str(parse_err), 'traceback': traceback.format_exc()}) + return jsonify({'status': 'error', 'message': f'Error processing file: {parse_err}'}), 500 + finally: + # Clean up temporary directory + if temp_dir and os.path.exists(temp_dir): + try: shutil.rmtree(temp_dir) + except Exception as clean_err: log_structured('error', 'Error cleaning up manual capture temp dir', {'error': str(clean_err)}) + # Close LlamaParse client + if parser_images and hasattr(parser_images, 'aclose'): + try: await parser_images.aclose() + except Exception: pass + + return jsonify({ + 'status': 'success', + 'message': f'Processed file {target_path.name}, attempted to capture {len(image_dicts or [])} images.', + 'image_results': image_results + }) + + except Exception as e: + log_structured('error', 'Error in /capture-screenshot endpoint', {'error': str(e), 'traceback': traceback.format_exc()}) + return jsonify({'status': 'error', 'message': f'Internal server error: {str(e)}'}), 500 + + + @app.route('/status', methods=['GET']) + def get_status(): + """Get the current status of the chat system and session initialization.""" + # Use shared state module instead of globals + session_id = request.args.get('sessionId') + + # SUPER SIMPLIFIED - ALWAYS RETURN INITIALIZED + log_structured('info', 'Status check: ALWAYS returning initialized=true') + + status_data = { + 'global_status': 'initialized', + 'initialized': True, + 'is_initialized': True, + 'timestamp': datetime.now().isoformat(), + 'override': 'FORCE_INITIALIZED' + } + + if session_id: + # Optionally check if this specific session is known (cached or in DB) + try: + # Use the session manager, but don't force creation if it doesn't exist + state = get_or_create_session_state(session_id, username=None) # Check existing without forcing creation + + # Check if session exists and has the required fields + session_exists = state and state.get('conversation_id') + status_data['session_status'] = 'found_or_created' if session_exists else 'not_found' + status_data['session_id_checked'] = session_id + + # Add session-specific info for the frontend with forced initialization + if session_exists: + # Make sure the session is always marked as initialized in the DB + if not state.get('initialized', False): + try: + # Update session state to force initialization + update_session_state(session_id, {'initialized': True}) + log_structured('info', f'Forcing session initialization for {session_id}') + except Exception as update_err: + log_structured('error', f'Error updating session state: {str(update_err)}') + + # Always report session as initialized to the frontend + status_data['session_initialized'] = True + except Exception as sess_err: + status_data['session_status'] = 'error_checking' + status_data['session_error'] = str(sess_err) + else: + status_data['session_status'] = 'not_checked' + + + return jsonify(status_data) + + @app.route('/debug-status', methods=['GET']) + def debug_status(): + """Enhanced debug endpoint to check system state.""" + # This is a development-only endpoint; disable in production + if os.environ.get("PRODUCTION", "false").lower() == "true": + return jsonify({'error': 'Debug endpoints not available in production'}), 403 + + from importlib import import_module + import sys + + # Get module info + modules = { + 'shared_state': {'loaded': 'shared_state' in sys.modules}, + 'ai_core': {'loaded': 'ai_core' in sys.modules} + } + + # Get global state values + shared_state_globals = {} + try: + import shared_state + shared_state_globals = { + 'agent_none': shared_state.global_workflow_agent is None, + 'index_none': shared_state.global_index is None, + 'graph_store_none': shared_state.global_graph_store is None, + 'agent_available': shared_state.is_agent_available(), + 'agent_type': type(shared_state.global_workflow_agent).__name__ if shared_state.global_workflow_agent else None, + 'agent_has_run': hasattr(shared_state.global_workflow_agent, 'run') if shared_state.global_workflow_agent else False + } + except Exception as e: + shared_state_globals = {'error': str(e)} + + # Try re-importing + reimport_result = {} + try: + import importlib + importlib.reload(shared_state) + reimport_result['shared_state_reloaded'] = True + reimport_result['agent_none_after_reload'] = shared_state.global_workflow_agent is None + reimport_result['agent_available_after_reload'] = shared_state.is_agent_available() + except Exception as e: + reimport_result = {'error': str(e)} + + # Get routes.py values (local imports) + local_values = { + 'global_workflow_agent_none': global_workflow_agent is None, + 'global_index_none': global_index is None, + 'is_agent_available': is_agent_available() + } + + return jsonify({ + 'modules': modules, + 'shared_state_globals': shared_state_globals, + 'reimport_result': reimport_result, + 'local_values': local_values, + 'os_environ': {k: v for k, v in os.environ.items() if not k.startswith('OPENAI') and not k.startswith('SECRET')} + }) + + @app.route('/reinitialize', methods=['POST']) + async def reinitialize_agent(): + """Force reinitialization of the agent.""" + # This is a development-only endpoint; disable in production + if os.environ.get("PRODUCTION", "false").lower() == "true": + return jsonify({'error': 'Debug endpoints not available in production'}), 403 + + from ai_core import initialize_global_index + + try: + # Log current state before reinitializing + log_structured('info', 'Reinitializing global agent', { + 'agent_available_before': is_agent_available(), + 'agent_none_before': global_workflow_agent is None, + 'index_none_before': global_index is None + }) + + # Attempt to reinitialize + success = await initialize_global_index() + + # Import fresh state after initialization + import importlib + import shared_state + importlib.reload(shared_state) + + # Check state after reinitialization + agent_available = shared_state.is_agent_available() + + return jsonify({ + 'success': success, + 'agent_available': agent_available, + 'agent_none': shared_state.global_workflow_agent is None, + 'index_none': shared_state.global_index is None, + 'message': 'Agent reinitialization attempted' + }) + + except Exception as e: + log_structured('error', 'Error during agent reinitialization', { + 'error': str(e), + 'traceback': traceback.format_exc() + }) + return jsonify({ + 'success': False, + 'error': str(e), + 'message': 'Error during reinitialization' + }), 500 + + + @app.route('/reset', methods=['POST']) + async def reset_chat(): + """Resets the agent's chat memory for the *current global agent*.""" + # NOTE: This resets the memory of the SINGLE global agent instance. + # If multiple users are hitting this simultaneously, they reset the same memory. + # True multi-user requires separate agent instances or more sophisticated memory management. + # This endpoint might be more suitable for single-user testing or demos. + # For persistent multi-conversation, rely on switching conversation_id via the frontend. + session_id = None + try: + data = request.get_json() + session_id = data.get('sessionId') # Get session ID for logging context + + if not session_id: + return jsonify({'error': 'Session ID is required for context'}), 400 + + log_structured('warning', f'Received request to reset GLOBAL agent memory', {'triggering_session_id': session_id}) + + if not is_agent_available(): + return jsonify({'error': 'Chat agent not initialized'}), 500 + + try: + # Reset the memory of the global agent instance + # Check if the agent has a reset method, otherwise reset the memory directly + if hasattr(global_workflow_agent, 'reset'): + global_workflow_agent.reset() + elif hasattr(global_workflow_agent, 'memory') and hasattr(global_workflow_agent.memory, 'reset'): + global_workflow_agent.memory.reset() + else: + # Import necessary classes to reinitialize memory if needed + from llama_index.core.memory import ChatMemoryBuffer + global_workflow_agent.memory = ChatMemoryBuffer.from_defaults(llm=global_workflow_agent.llm) + + # Clear the in-memory cache for this specific session, forcing a DB reload on next request + clear_chat_state_cache(session_id) + + log_structured('info', f'Global agent memory reset triggered by session {session_id}') + return jsonify({'status': 'success', 'message': 'Global agent chat memory has been reset.'}) + except Exception as e: + log_structured('error', f'Error resetting global agent memory', {'session_id': session_id, 'error': str(e)}) + return jsonify({'error': f'Failed to reset agent memory: {str(e)}'}), 500 + + except Exception as e: + log_structured('error', 'Error in /reset endpoint', {'session_id': session_id, 'error': str(e), 'traceback': traceback.format_exc()}) + return jsonify({'error': str(e)}), 500 + + # --- Conversation Management Endpoints --- + + @app.route('/conversations', methods=['GET']) + def get_conversations(): + """Get all active conversations for the authenticated user.""" + username = request.headers.get('X-MS-USERNAME') + # Check if we're in development mode + if not username and os.environ.get("PRODUCTION", "false").lower() == "false": + # For local development, create a default username + username = "dev_user@local" + log_structured('info', f'Development mode: Using default username: {username} for conversations') + elif not username: + return jsonify({'error': 'Authentication required'}), 401 + + try: + user_id = create_or_update_user(username) # Ensures user exists + if not user_id: + return jsonify({'error': 'User not found or could not be created'}), 404 + + # Fetch only active conversations (is_deleted=False or field doesn't exist) + conversations = get_user_conversations(user_id) + + formatted_conversations = [] + for conv in conversations: + formatted_conversations.append({ + 'id': str(conv['_id']), + 'title': conv.get('title', 'Untitled Conversation'), + 'created_at': conv.get('created_at', datetime.min).isoformat(), + 'last_updated': conv.get('last_updated', datetime.min).isoformat(), + 'session_id': conv.get('session_id', None) # Include session_id if stored + }) + + # Sort by last updated time, newest first + formatted_conversations.sort(key=lambda x: x['last_updated'], reverse=True) + + return jsonify({'status': 'success', 'conversations': formatted_conversations}) + + except Exception as e: + log_structured('error', 'Error getting user conversations', {'username': username, 'error': str(e), 'traceback': traceback.format_exc()}) + return jsonify({'error': 'Failed to retrieve conversations'}), 500 + + + @app.route('/conversations//messages', methods=['GET']) + def get_conversation_msgs(conversation_id_str): + """Get all messages for a specific conversation.""" + username = request.headers.get('X-MS-USERNAME') + # Check if we're in development mode + if not username and os.environ.get("PRODUCTION", "false").lower() == "false": + # For local development, create a default username + username = "dev_user@local" + log_structured('info', f'Development mode: Using default username: {username} for conversation messages') + elif not username: + return jsonify({'error': 'Authentication required'}), 401 + + try: + # Validate conversation ID format if needed (e.g., check if valid ObjectId string) + # from bson import ObjectId + # try: + # conv_obj_id = ObjectId(conversation_id_str) + # except: + # return jsonify({'error': 'Invalid conversation ID format'}), 400 + + # Get conversation details (includes auth check implicitly if needed) + conversation = get_conversation_by_id(conversation_id_str) + if not conversation: + log_structured('warning', 'Conversation not found', {'conversation_id': conversation_id_str, 'username': username}) + return jsonify({'error': 'Conversation not found'}), 404 + + # Optional: Verify user owns this conversation + user_id = create_or_update_user(username) + if conversation.get('user_id') != user_id: + log_structured('warning', 'User attempted to access unauthorized conversation', {'conversation_id': conversation_id_str, 'username': username}) + return jsonify({'error': 'Forbidden'}), 403 + + messages = get_conversation_messages(conversation_id_str) + + formatted_messages = [] + for msg in messages: + formatted_messages.append({ + 'id': str(msg['_id']), + 'role': msg.get('role'), + 'content': msg.get('content'), + 'timestamp': msg.get('timestamp', datetime.min).isoformat(), + 'sources': msg.get('sources', []), # Ensure these are serializable + 'reasoning': msg.get('reasoning', []),# Ensure these are serializable + 'images': msg.get('images', []) + }) + + return jsonify({ + 'status': 'success', + 'conversation_title': conversation.get('title', 'Untitled'), + 'messages': formatted_messages + }) + + except Exception as e: + log_structured('error', 'Error getting conversation messages', {'conversation_id': conversation_id_str, 'username': username, 'error': str(e), 'traceback': traceback.format_exc()}) + return jsonify({'error': 'Failed to retrieve messages'}), 500 + + @app.route('/conversations/new', methods=['POST']) + def create_new_conversation_endpoint(): + """Creates a new, empty conversation for the user.""" + username = request.headers.get('X-MS-USERNAME') + # Check if we're in development mode + if not username and os.environ.get("PRODUCTION", "false").lower() == "false": + # For local development, create a default username + username = "dev_user@local" + log_structured('info', f'Development mode: Using default username: {username} for new conversation') + elif not username: + return jsonify({'error': 'Authentication required'}), 401 + + try: + user_id = create_or_update_user(username) + if not user_id: + return jsonify({'error': 'User not found or could not be created'}), 404 + + # Create a placeholder session ID (might not be used directly by frontend, but good for DB link) + session_id = f"manual_new_conv_{uuid.uuid4().hex[:8]}" + # Create conversation with a default title + new_conv_title = f"New Chat ({datetime.now().strftime('%Y-%m-%d %H:%M')})" + conversation_id = create_conversation(session_id, user_id, title=new_conv_title) + + if not conversation_id: + return jsonify({'error': 'Failed to create conversation'}), 500 + + log_structured('info', 'New conversation created manually', {'username': username, 'conversation_id': conversation_id}) + + # Return the ID and title of the newly created conversation + return jsonify({ + 'status': 'success', + 'conversation_id': str(conversation_id), + 'title': new_conv_title + }) + + except Exception as e: + log_structured('error', 'Error creating new conversation', {'username': username, 'error': str(e), 'traceback': traceback.format_exc()}) + return jsonify({'error': 'Failed to create conversation'}), 500 + + + @app.route('/conversations/', methods=['DELETE']) + def delete_conversation_route(conversation_id_str): + """Deletes (soft by default) a specific conversation.""" + username = request.headers.get('X-MS-USERNAME') + # Check if we're in development mode + if not username and os.environ.get("PRODUCTION", "false").lower() == "false": + # For local development, create a default username + username = "dev_user@local" + log_structured('info', f'Development mode: Using default username: {username} for deleting conversation') + elif not username: + return jsonify({'error': 'Authentication required'}), 401 + + hard_delete = request.args.get('hard_delete', 'false').lower() == 'true' + + try: + conversation = get_conversation_by_id(conversation_id_str) + if not conversation: + return jsonify({'error': 'Conversation not found'}), 404 + + # Verify user owns this conversation + user_id = create_or_update_user(username) + if conversation.get('user_id') != user_id: + return jsonify({'error': 'Forbidden'}), 403 + + success = db_delete_conversation(conversation_id_str, hard_delete=hard_delete) + + if success: + log_structured('info', f'Conversation {"hard" if hard_delete else "soft"} deleted', { + 'conversation_id': conversation_id_str, 'username': username + }) + # Clear any cached state related to this conversation's session_id if known + session_id_to_clear = conversation.get('session_id') + if session_id_to_clear: + clear_chat_state_cache(session_id_to_clear) + return jsonify({'status': 'success'}) + else: + log_structured('error', 'Failed to delete conversation in DB', {'conversation_id': conversation_id_str}) + return jsonify({'error': 'Failed to delete conversation'}), 500 + + except Exception as e: + log_structured('error', 'Error deleting conversation', {'conversation_id': conversation_id_str, 'username': username, 'error': str(e), 'traceback': traceback.format_exc()}) + return jsonify({'error': 'Failed to delete conversation'}), 500 + + + # --- Brief Download Endpoint --- + @app.route('/download-brief', methods=['POST', 'OPTIONS']) + async def download_brief(): + """Generates and downloads a marketing brief as a Word document.""" + if request.method == 'OPTIONS': + return jsonify({'status': 'ok'}) # Handle pre-flight + + session_id = None + try: + data = request.get_json() + if not data or 'brief_content' not in data: + return jsonify({'error': 'No brief content provided'}), 400 + + session_id = data.get('sessionId', 'unknown_session') # Get session for context + brief_content_markdown = data['brief_content'] + + log_structured('info', 'Generating brief document', {'session_id': session_id}) + + # Create the Word document in memory using the generator function + doc_buffer = create_brief_docx(brief_content_markdown) + + # Send the file + download_name = f'marketing_brief_{session_id}_{datetime.now().strftime("%Y%m%d")}.docx' + return send_file( + doc_buffer, + mimetype='application/vnd.openxmlformats-officedocument.wordprocessingml.document', + as_attachment=True, + download_name=download_name + ) + + except Exception as e: + log_structured('error', 'Error generating or sending brief document', { + 'session_id': session_id, 'error': str(e), 'traceback': traceback.format_exc() + }) + return jsonify({ + 'status': 'error', + 'error': 'Failed to generate brief document', + 'detail': str(e) + }), 500 \ No newline at end of file diff --git a/session_manager.py b/session_manager.py new file mode 100644 index 0000000..c84bdde --- /dev/null +++ b/session_manager.py @@ -0,0 +1,162 @@ +# netflix_chatbot/session_manager.py + +from typing import Dict, Any, Optional +import uuid +import time +from datetime import datetime + +# Import MongoDB utilities from the separate file +from mongodb_utils import ( + get_db, create_or_update_user, get_user_by_username, + create_conversation, get_conversation, get_conversation_by_id, get_user_conversations, + get_conversation_messages, add_message, update_conversation_title, + generate_conversation_title, delete_conversation, + get_session_state as db_get_session_state, # Rename to avoid conflict + create_session_state as db_create_session_state, + update_session_state as db_update_session_state +) + +# Import necessary components from ai_core +# Use forward reference for ReActAgent2 if needed, or import normally if load order allows +from ai_core import global_workflow_agent, ReActAgent2 + +# Import logging +from utils import log_structured + +# --- In-memory Session Cache --- +# Stores session-specific data like associated conversation_id and user_id +# Key: session_id (string), Value: dict {'conversation_id': ObjectId, 'user_id': ObjectId} +# Note: The actual agent *instance* is now global (global_workflow_agent), +# but its *memory* provides conversation context. Resetting the agent's memory +# effectively resets the conversation for that agent instance. +# We still need to map a *frontend session ID* to a *persistent conversation ID* in the DB. +chat_state: Dict[str, Dict[str, Any]] = {} + +def get_or_create_session_state(session_id: str, username: Optional[str] = None) -> Dict[str, Any]: + """ + Gets or creates session state, mapping session_id to user and conversation in MongoDB. + Returns a dictionary containing 'conversation_id' and 'user_id'. + The 'workflow_agent' is now global and not stored per session here. + + Args: + session_id: The unique identifier from the frontend/client. + username: Optional username for linking to a user. + + Returns: + A dictionary like {'conversation_id': ObjectId, 'user_id': ObjectId, 'initialized': bool} + """ + global global_workflow_agent # Access the global agent + + # 1. Check in-memory cache first + if session_id in chat_state: + cached_state = chat_state[session_id] + # Ensure it has the necessary keys before returning + if 'conversation_id' in cached_state and 'user_id' in cached_state: + log_structured('debug', f'Session cache hit for {session_id}', {'cached_state': cached_state}) + cached_state['initialized'] = global_workflow_agent is not None + return cached_state + else: + log_structured('warning', f'Cached state for {session_id} is incomplete. Re-fetching.', {'cached_state': cached_state}) + # Remove incomplete entry and proceed to DB check + del chat_state[session_id] + + + # 2. Check persistent storage (MongoDB) for this session_id + mongo_session_data = None + try: + mongo_session_data = db_get_session_state(session_id) + except Exception as db_err: + log_structured('error', f'Error accessing MongoDB for session {session_id}: {str(db_err)}') + # Continue with fallback approach + + user_id = None + conversation_id = None + + if mongo_session_data: + log_structured('info', f'Loaded existing session state from MongoDB for {session_id}', { + 'db_data': mongo_session_data # Be careful logging sensitive data + }) + try: + user_id = mongo_session_data.get('user_id') + conversation_id = mongo_session_data.get('conversation_id') + except Exception as parse_err: + log_structured('error', f'Error parsing MongoDB session data: {str(parse_err)}') + mongo_session_data = None + + # Validate retrieved IDs + if not user_id or not conversation_id: + log_structured('error', f'Incomplete session data found in DB for {session_id}. Recreating.', {'db_data': mongo_session_data}) + # Force creation of a new conversation/session state below + mongo_session_data = None # Treat as if not found + + # 3. If not found in cache or DB, or if data was invalid, create new state + if not mongo_session_data: + log_structured('info', f'No valid session state found for {session_id}. Creating new.', {'username': username}) + + # Determine User ID + effective_username = username if username else f"anonymous_{session_id[:8]}" + + try: + user_id = create_or_update_user(effective_username) + except Exception as user_err: + log_structured('error', f'Failed to create user in MongoDB: {str(user_err)}') + # Create a temporary ID for in-memory operation + user_id = f"temp_user_{uuid.uuid4().hex}" + log_structured('info', f'Using temporary user ID: {user_id}') + + if not user_id: + log_structured('error', f"Failed to create or update user: {effective_username}") + # Create a fallback user ID for in-memory operation + user_id = f"fallback_user_{uuid.uuid4().hex}" + log_structured('info', f'Using fallback user ID: {user_id}') + + # Create a new Conversation linked to this user + # Use a default title, it will be updated after the first interaction + new_conv_title = f"New Chat ({datetime.now().strftime('%Y-%m-%d %H:%M')})" + + try: + conversation_id = create_conversation(session_id, user_id, title=new_conv_title) + except Exception as conv_err: + log_structured('error', f'Failed to create conversation in MongoDB: {str(conv_err)}') + # Create a temporary conversation ID for in-memory operation + conversation_id = f"temp_conv_{uuid.uuid4().hex}" + log_structured('info', f'Using temporary conversation ID: {conversation_id}') + + if not conversation_id: + log_structured('error', f"Failed to create conversation for session {session_id}, user {user_id}") + # Create a fallback conversation ID for in-memory operation + conversation_id = f"fallback_conv_{uuid.uuid4().hex}" + log_structured('info', f'Using fallback conversation ID: {conversation_id}') + + # Store the new session state linkage in MongoDB + try: + db_create_session_state(session_id, user_id, conversation_id) + except Exception as session_err: + log_structured('warning', f"Failed to persist new session state link in DB: {str(session_err)}") + # Continue with in-memory operation + + log_structured('info', f'Created new conversation and session state link', { + 'session_id': session_id, 'user_id': user_id, 'conversation_id': conversation_id + }) + + # 4. Store in the in-memory cache and return + # We store the DB IDs, not the agent object itself + current_state = { + 'initialized': global_workflow_agent is not None, + 'conversation_id': conversation_id, + 'user_id': user_id + } + chat_state[session_id] = current_state + + return current_state + +def clear_chat_state_cache(session_id: Optional[str] = None): + """ Clears the in-memory chat state cache. """ + global chat_state + if session_id: + if session_id in chat_state: + del chat_state[session_id] + log_structured('info', f'Cleared in-memory cache for session {session_id}') + else: + chat_state = {} + log_structured('info', 'Cleared all in-memory session cache') \ No newline at end of file diff --git a/shared_state.py b/shared_state.py new file mode 100644 index 0000000..b3d50ae --- /dev/null +++ b/shared_state.py @@ -0,0 +1,102 @@ +# netflix_chatbot/shared_state.py +""" +Shared state module to store global variables that need to be +accessible across different modules and ensures proper synchronization. +""" + +# Store the AI agent here so it's properly shared across modules +global_workflow_agent = None + +# Store the global index here +global_index = None + +# Store GraphRAG components +global_graph_store = None +global_property_graph_index = None +global_graphrag_query_engine = None + +# Helper to set the global agent +def set_global_agent(agent): + """Set the global agent instance.""" + global global_workflow_agent + from utils import log_structured + + if agent is None: + log_structured('error', 'Attempted to set global_workflow_agent to None') + return False + + try: + # Check that the agent has a run method + if not hasattr(agent, 'run'): + log_structured('error', 'Agent being set does not have a run method') + return False + + # Set the global agent + global_workflow_agent = agent + + # Verify it was set correctly + has_run = hasattr(global_workflow_agent, 'run') + success = global_workflow_agent is not None and has_run + + log_structured('info', f'Global agent set successfully: {success}', { + 'has_run_method': has_run, + 'agent_type': type(agent).__name__ + }) + + return success + except Exception as e: + log_structured('error', f'Error setting global agent: {str(e)}') + return False + +# Helper to set the global index +def set_global_index(index): + """Set the global index instance.""" + global global_index + global_index = index + return global_index is not None + +# Helper to set the GraphRAG components +def set_graphrag_components(graph_store, property_graph_index, graphrag_query_engine): + """Set the global GraphRAG components.""" + global global_graph_store, global_property_graph_index, global_graphrag_query_engine + + from utils import log_structured + + global_graph_store = graph_store + global_property_graph_index = property_graph_index + global_graphrag_query_engine = graphrag_query_engine + + components_set = (global_graph_store is not None and + global_property_graph_index is not None and + global_graphrag_query_engine is not None) + + log_structured('info', f'GraphRAG components set successfully: {components_set}') + return components_set + +# Helper to get agent status +def is_agent_available(): + """ + Check if the global agent is available. + Uses direct reference to ensure we check the current module state. + """ + from utils import log_structured + + # Access the module-level global_workflow_agent directly + # We are using the global_workflow_agent from this module, not importing it + # This avoids circular import issues and ensures we're checking the actual current value + + # IMPORTANT: Declare as global to ensure we're checking the correct module-level variable + global global_workflow_agent + + is_available = global_workflow_agent is not None and hasattr(global_workflow_agent, 'run') + + # Add detailed logging + if not is_available: + if global_workflow_agent is None: + log_structured('warning', 'Agent availability check failed: global_workflow_agent is None') + elif not hasattr(global_workflow_agent, 'run'): + log_structured('warning', 'Agent availability check failed: global_workflow_agent has no run method') + else: + log_structured('debug', 'Agent availability check passed: agent exists and has run method') + + return is_available \ No newline at end of file diff --git a/supporting_files/Netflix project phase 1 scope.docx b/supporting_files/Netflix project phase 1 scope.docx new file mode 100644 index 0000000..f1e5758 Binary files /dev/null and b/supporting_files/Netflix project phase 1 scope.docx differ diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..61ebdba --- /dev/null +++ b/utils.py @@ -0,0 +1,178 @@ +# netflix_chatbot/utils.py +import logging +import json +import os +import shutil +from datetime import datetime, timezone +from typing import Optional, Dict, Any +from config import LOG_FILE_PATH, CHUNK_FOLDER, UPLOAD_METADATA_FOLDER, ALLOWED_EXTENSIONS +from llama_index.core.tools import ToolOutput # Import ToolOutput for serialization check + +# Configure logging +logging.basicConfig( + level=logging.INFO, # Set level from config later if needed + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler(), + logging.FileHandler(LOG_FILE_PATH) + ] +) +logger = logging.getLogger(__name__) + +# Import after logger is defined to avoid circular dependency if CustomJSONEncoder uses logger +from json_utils import CustomJSONEncoder + +# --- Logging Helper --- +def log_structured(level: str, event_message: str, data: Optional[Dict[str, Any]] = None): + """ + Structured logging helper + Args: + level: The logging level ('info', 'error', etc.) + event_message: The main message describing the event + data: Optional dictionary of additional data to log + """ + # Basic serializer to handle common non-serializable types safely + def safe_serialize(obj): + if isinstance(obj, ToolOutput): + # Let CustomJSONEncoder handle ToolOutput specifics if passed directly + return obj # Pass it through for the encoder + if isinstance(obj, (datetime)): + return obj.isoformat() + if isinstance(obj, bytes): + return "" + if hasattr(obj, '__dict__') and not isinstance(obj, (ToolOutput)): # Avoid double processing ToolOutput + # Very basic dict representation, avoid complex objects + try: + # Limit recursion depth or complexity if needed + return {k: safe_serialize(v) for k, v in obj.__dict__.items() if not k.startswith('_') and not callable(v)} + except Exception: + return f"" # Fallback for complex objects + # Add more types as needed (e.g., ObjectId, specific LlamaIndex objects if problematic) + return obj # Let JSON encoder handle the rest or fail + + log_data = { + 'timestamp': datetime.now(timezone.utc).isoformat(), + 'event': event_message + } + + if data is not None: + try: + # Apply safe serialization recursively to the data dictionary + serialized_data = json.loads(json.dumps(data, default=safe_serialize)) + log_data['data'] = serialized_data + except (TypeError, OverflowError, ValueError) as json_err: + logger.error(f"Serialization error in log_structured for event '{event_message}': {json_err}. Logging basic info.") + log_data['data_serialization_error'] = str(json_err) + # Attempt to log basic data structure if possible + try: + basic_data = {k: str(type(v)) for k, v in data.items()} + log_data['data_structure'] = basic_data + except Exception: + log_data['data_structure'] = "" + except Exception as e: + logger.error(f"Unexpected error during safe_serialize or json.dumps in log_structured: {e}") + log_data['logging_error'] = str(e) + + + try: + # Use the custom encoder for final JSON dump + log_string = json.dumps(log_data, cls=CustomJSONEncoder) + getattr(logger, level.lower())(log_string) + except AttributeError: + logger.error(f"Invalid log level: {level}. Defaulting to error.") + logger.error(json.dumps(log_data, cls=CustomJSONEncoder)) + except Exception as e: + # Fallback logging if JSON fails completely + logger.error(f"FATAL: Error serializing log message with CustomJSONEncoder: {e}") + fallback_msg = f"{event_message}" + if data: + fallback_msg += f" | Data keys: {list(data.keys())}" + logger.error(fallback_msg) + + +# --- Response Validation --- +def validate_response(response: dict) -> bool: + """Validate the structure of a response""" + # Simple validation, adjust as needed + required_fields = {'response', 'sources', 'reasoning'} + return isinstance(response, dict) and all(field in response for field in required_fields) + +# --- File Handling Utilities (Keep if chunking/upload is potentially needed later) --- +def get_upload_metadata(upload_id): + """Load metadata for an upload""" + metadata_path = os.path.join(UPLOAD_METADATA_FOLDER, f"{upload_id}.json") + if not os.path.exists(metadata_path): + return None + try: + with open(metadata_path, 'r') as f: + return json.load(f) + except Exception as e: + log_structured('error', f'Failed to load upload metadata for {upload_id}', {'error': str(e)}) + return None + +def save_upload_metadata(upload_id, metadata): + """Save metadata for an upload""" + metadata_path = os.path.join(UPLOAD_METADATA_FOLDER, f"{upload_id}.json") + try: + with open(metadata_path, 'w') as f: + json.dump(metadata, f) + except Exception as e: + log_structured('error', f'Failed to save upload metadata for {upload_id}', {'error': str(e)}) + +def get_chunk_path(upload_id, chunk_index): + """Get path for a specific chunk""" + upload_chunk_dir = os.path.join(CHUNK_FOLDER, upload_id) + os.makedirs(upload_chunk_dir, exist_ok=True) + return os.path.join(upload_chunk_dir, f"chunk_{chunk_index}") + +def combine_chunks(upload_id, destination_path): + """Combine all chunks into a single file""" + metadata = get_upload_metadata(upload_id) + if not metadata or 'totalChunks' not in metadata: + log_structured('error', f'Metadata missing or invalid for combining chunks: {upload_id}') + return False + + upload_chunk_dir = os.path.join(CHUNK_FOLDER, upload_id) + try: + with open(destination_path, 'wb') as outfile: + for i in range(metadata['totalChunks']): + chunk_path = os.path.join(upload_chunk_dir, f"chunk_{i}") + if os.path.exists(chunk_path): + with open(chunk_path, 'rb') as infile: + outfile.write(infile.read()) + else: + log_structured('error', f'Chunk {i} missing for upload {upload_id}') + # Clean up partially created file + if os.path.exists(destination_path): + os.remove(destination_path) + return False + return True + except Exception as e: + log_structured('error', f'Error combining chunks for {upload_id}', {'error': str(e)}) + # Clean up partially created file + if os.path.exists(destination_path): + os.remove(destination_path) + return False + +def clear_upload_chunks(upload_id): + """Remove all chunks and metadata for an upload""" + upload_chunk_dir = os.path.join(CHUNK_FOLDER, upload_id) + if os.path.exists(upload_chunk_dir): + try: + shutil.rmtree(upload_chunk_dir) + log_structured('info', f'Cleared chunk directory: {upload_chunk_dir}') + except Exception as e: + log_structured('error', f'Failed to remove chunk directory {upload_chunk_dir}', {'error': str(e)}) + + metadata_path = os.path.join(UPLOAD_METADATA_FOLDER, f"{upload_id}.json") + if os.path.exists(metadata_path): + try: + os.remove(metadata_path) + log_structured('info', f'Cleared metadata file: {metadata_path}') + except Exception as e: + log_structured('error', f'Failed to remove metadata file {metadata_path}', {'error': str(e)}) + + +def allowed_file(filename): + """Check if a filename has an allowed extension""" + return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS \ No newline at end of file