diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3a8370c --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +# Docker volumes data +postgres_data/ +es_data/ +ollama_data/ +minio_data/ + +# Environment files +.env +.env.local +.env.*.local +*.env + +# Mac +.DS_Store + +# Windows +Thumbs.db +ehthumbs.db +Desktop.ini + +# Logs +*.log + +# Memory bank (sensitive project information) +memory-bank/ + +# TaskMaster files (project management) +.taskmaster/ + +# IDE +.vscode/ +.idea/ + +# Temporary files +*.tmp +*.bak +*.swp +*~ \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e956124 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,235 @@ +## **Contributing Guidelines** + +### Getting Started +1. Fork the repository +2. Clone your fork locally +3. Create a feature branch +4. Make your changes +5. Test your changes +6. Submit a pull request + +### Code Standards +- Follow existing code style +- Write meaningful commit messages +- Include tests for new features +- Update documentation as needed + +### Community Guidelines +- Be respectful and inclusive +- Provide constructive feedback +- Help others learn and grow +- Follow the project's Code of Conduct + + + +## **Branch Strategy** + +### Git Flow Based Branch Strategy + +#### Main Branches +- **`main`** (or `master`): Production-ready code +- **`develop`**: Integration branch for development +- **`feature/*`**: New feature development +- **`hotfix/*`**: Critical bug fixes +- **`release/*`**: Release preparation + +#### Branch Naming Convention + +``` +feature/issue-number-brief-description +hotfix/issue-number-brief-description +release/version-number +``` + +**Examples:** +``` +feature/123-user-authentication +hotfix/456-login-bug-fix +release/1.2.0 +``` + +#### Workflow + +1. **Feature Development** + ```bash + # Create feature branch from develop + git checkout develop + git pull origin develop + git checkout -b feature/123-user-authentication + + # After development, create PR to develop + ``` + +2. **Hotfix** + ```bash + # Create hotfix branch from main + git checkout main + git pull origin main + git checkout -b hotfix/456-critical-bug + + # After fix, create PRs to both main and develop + ``` + +3. **Release** + ```bash + # Create release branch from develop + git checkout develop + git pull origin develop + git checkout -b release/1.2.0 + + # After release preparation, create PR to main + ``` + +--- + +## **Pull Request Guidelines** + +### PR Title Format + +``` +type(scope): brief description + +Types: +- feat: new feature +- fix: bug fix +- docs: documentation update +- style: code formatting +- refactor: code refactoring +- test: adding/updating tests +- chore: miscellaneous tasks +``` + +**Examples:** +- `feat(auth): add social login functionality` +- `fix(api): resolve user info retrieval bug` +- `docs(readme): update installation guide` + +### PR Description Guidelines + +#### 1. Summary of Changes +- Clearly describe what was changed +- Explain why this change was necessary + +#### 2. Testing Instructions +- Provide methods to verify the changes +- Include screenshots or GIFs for UI changes + +#### 3. Related Issues +- Link related issues: `Closes #123`, `Fixes #456` + +#### 4. Checklist +- [ ] Code follows the project's style guidelines +- [ ] Self-review completed +- [ ] Appropriate tests added +- [ ] Documentation updated (if needed) + +--- + +## Pull Request Template + +```markdown +## Summary of Changes + + +## Type of Change + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Documentation update +- [ ] Code refactoring +- [ ] Performance improvement +- [ ] Test addition + +## Related Issues + +Closes #issue-number + +## Testing Instructions + +1. +2. +3. + +## Screenshots (if applicable) + + +## Checklist + +- [ ] My code follows the style guidelines of this project +- [ ] I have performed a self-review of my own code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have made corresponding changes to the documentation +- [ ] My changes generate no new warnings +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing unit tests pass locally with my changes + +## Additional Notes + +``` + +--- + +## Code Review Guidelines + +### For Reviewers + +#### Review Checklist +1. **Functionality**: Does the code work as intended? +2. **Readability**: Is the code easy to understand? +3. **Performance**: Are there any performance concerns? +4. **Security**: Are there any security vulnerabilities? +5. **Testing**: Are appropriate tests included? + +#### Feedback Guidelines +- Provide **specific and constructive** feedback +- Include **positive feedback** for good parts +- Use **suggestion format**: "What do you think about...?" +- Provide **code examples** when possible + +### Review Priority +1. **Critical**: Bugs, security issues, performance problems +2. **Major**: Design issues, readability problems +3. **Minor**: Style, naming conventions + +### Review Completion Criteria +- [ ] All Critical/Major issues resolved +- [ ] Tests passing +- [ ] Documentation updated +- [ ] At least one approval from maintainer + +--- + +## Commit Message Convention + +### Format +``` +type(scope): subject + +body + +footer +``` + +### Types +- **feat**: A new feature +- **fix**: A bug fix +- **docs**: Documentation only changes +- **style**: Changes that do not affect the meaning of the code +- **refactor**: A code change that neither fixes a bug nor adds a feature +- **perf**: A code change that improves performance +- **test**: Adding missing tests or correcting existing tests +- **chore**: Changes to the build process or auxiliary tools + +### Examples +``` +feat(auth): add OAuth2 integration + +Add support for Google and GitHub OAuth2 providers. +This allows users to sign in with their existing accounts. + +Closes #123 +``` + +--- + diff --git a/README.md b/README.md index baaf6b8..9c84fad 100644 --- a/README.md +++ b/README.md @@ -1 +1,237 @@ -# open-context \ No newline at end of file +# OpenContext + +[![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) +[![Java](https://img.shields.io/badge/Java-21-orange.svg)](https://openjdk.org/projects/jdk/21/) +[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.3.11-brightgreen.svg)](https://spring.io/projects/spring-boot) +[![React](https://img.shields.io/badge/React-19-61dafb.svg)](https://reactjs.org/) +[![Docker](https://img.shields.io/badge/Docker-Compose-2496ED.svg)](https://docs.docker.com/compose/) +[![MCP](https://img.shields.io/badge/MCP-Compatible-purple.svg)](https://modelcontextprotocol.io/) + +**Self-Hosted RAG System for Secure Document Processing** + +OpenContext is an enterprise-grade RAG (Retrieval-Augmented Generation) system designed for organizations that require complete data sovereignty. Process confidential documents without external dependencies or security compromises. + +## Why OpenContext? + +**The Problem**: Developers need AI assistance but can't upload sensitive code or documents to external cloud services due to security, compliance, or intellectual property concerns. + +**The Solution**: OpenContext runs entirely within your infrastructure, ensuring your data never leaves your control while providing enterprise-grade search capabilities. + +### Key Benefits + +- **Zero External Dependencies**: All processing happens within your network +- **Self-Hosted Security**: Keep sensitive data within your infrastructure +- **Advanced Search**: Hybrid keyword + semantic search with Korean language support +- **Production Ready**: Docker Compose deployment with monitoring and logging +- **Developer Friendly**: MCP protocol integration for AI assistants like Cursor + +### Demo +[![demo](https://github.com/user-attachments/assets/29162dfe-cb7c-45ac-b45d-f2cf51e534fb)](https://github.com/user-attachments/assets/129e53ff-0ca3-40bd-815e-e5c3c753c882) + +## Architecture + +```mermaid +graph TB + subgraph "Client Layer" + UI[Admin Dashboard] + AI[AI Assistant] + end + + subgraph "Application Layer" + Core[OpenContext Core
Spring Boot] + MCP[MCP Adapter
Node.js] + end + + subgraph "Data Layer" + PG[(PostgreSQL)] + ES[(Elasticsearch)] + MinIO[(MinIO)] + Ollama[Ollama] + end + + UI --> Core + AI --> MCP + MCP --> Core + Core --> PG + Core --> ES + Core --> MinIO + Core --> Ollama +``` + +## Quick Start + +### Prerequisites +```bash +docker --version # 20.10+ +docker compose version # 2.0+ +git --version # Any recent version +``` + +**System Requirements:** +- Minimum 4GB RAM, 10GB disk space recommended + +### Installation +```bash +git clone https://github.com/OpenContextAI/open-context.git +cd open-context + +# Start all services (model download included) +docker compose up -d + +# Verify deployment +curl http://localhost:8080/actuator/health +``` + +### Access Points +| Service | URL | Purpose | +|---------|-----|---------| +| Admin UI | http://localhost:3001 | Document management dashboard | +| Core API | http://localhost:8080 | REST API endpoints | +| MCP Adapter | http://localhost:3000 | AI assistant integration | +| API Docs | http://localhost:8080/swagger-ui.html | Interactive API documentation | + +## Usage + +### 1. Upload Documents +Access the Admin UI at http://localhost:3001 and upload PDF or Markdown files. The system processes documents through multiple stages: +- **PENDING**: File uploaded, waiting for processing +- **PARSING**: Document structure extraction +- **CHUNKING**: Text segmentation into meaningful chunks +- **EMBEDDING**: Vector generation for semantic search +- **INDEXING**: Search index creation +- **COMPLETED**: Ready for search and retrieval + +### 2. Efficient Search and Retrieve +OpenContext optimizes AI assistant token usage through a two-phase approach: + +**Phase 1 - Explore (Low Token Cost):** +```bash +curl "http://localhost:8080/api/v1/search?query=spring%20security&topK=5" +``` +Returns lightweight chunk summaries for the AI to evaluate relevance. + +**Phase 2 - Focus (Targeted Token Usage):** +```bash +curl -X POST http://localhost:8080/api/v1/get-content \ + -H "Content-Type: application/json" \ + -d '{"chunkId": "your-chunk-id", "maxTokens": 25000}' +``` +Retrieves full content only for selected chunks, minimizing unnecessary token consumption. + +### 3. AI Assistant Integration +Configure your AI assistant (Cursor, VSCode) with MCP: + +```json +{ + "mcpServers": { + "opencontext": { + "url": "http://localhost:3000/mcp" + } + } +} +``` + +## Technology Stack + +**Backend** +- Java 21 + Spring Boot 3.3.11 +- LangChain4j for RAG pipeline +- PostgreSQL for metadata +- Elasticsearch for search +- Ollama for local embeddings + +**Frontend** +- React 19 + TypeScript +- Tailwind CSS + Vite +- TanStack Query for state management + +**Infrastructure** +- Docker Compose orchestration +- MinIO for file storage +- Unstructured.io for document parsing + +## Configuration + +### API Key Setup +**Default API key**: `dev-api-key-123` (change for production) + +All administrative APIs require API key authentication via `X-API-KEY` header: + +```bash +# Generate secure key +openssl rand -hex 32 + +# Set environment variable +export OPENCONTEXT_API_KEY="your-secure-key" + +# Use in API calls +curl -H "X-API-KEY: your-secure-key" http://localhost:8080/api/v1/sources +``` + +### Embedding Model +Default: `dengcao/Qwen3-Embedding-0.6B:F16` + +```bash +# Install different model +docker compose exec ollama ollama pull nomic-embed-text + +# Update configuration in application-docker.yml +``` + +## Development + +### Local Development +```bash +# Backend +cd core && ./gradlew bootRun + +# Frontend +cd admin-ui && npm install && npm run dev + +# MCP Adapter +cd mcp-adapter && npm install && npm run dev +``` + +### Testing +```bash +./gradlew test # Unit tests +./gradlew integrationTest # Integration tests +cd admin-ui && npm test # Frontend tests +``` + +## Documentation + +- **[Core Backend Guide](core/README.md)** - Backend development and API details +- **[MCP Adapter Guide](mcp-adapter/README.md)** - MCP protocol implementation +- **[Admin UI Guide](admin-ui/README.md)** - Frontend development guide +- **[Installation and Setup](https://github.com/OpenContextAI/open-context/wiki/Installation-and-Setup)** - Production deployment and configuration +- **[API Reference](https://github.com/OpenContextAI/open-context/wiki/API)** - Complete API documentation +- **[Interactive API Docs](http://localhost:8080/swagger-ui.html)** - Swagger UI for testing + +## Contributing + +We welcome contributions! Please see our [Contributing Guide](https://github.com/OpenContextAI/open-context/wiki/How-to-Contribute) for complete details. + +### Development Workflow +1. Fork the repository and clone locally +2. Create feature branch: `feature/123-brief-description` +3. Implement changes with comprehensive tests +4. Follow commit convention: `type(scope): subject` +5. Submit pull request with detailed description +6. Address code review feedback + +### Branch Strategy +- **main**: Production releases +- **develop**: Integration branch for features +- **feature/***: New features and improvements +- **hotfix/***: Critical production fixes + +## Support + +- **Issues**: [GitHub Issues](../../issues) +- **Discussions**: [GitHub Discussions](../../discussions) +- **Documentation**: [Project Wiki](https://github.com/OpenContextAI/open-context/wiki) + +## License + +Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE) file for details. diff --git a/admin-ui/.dockerignore b/admin-ui/.dockerignore new file mode 100644 index 0000000..b26ac10 --- /dev/null +++ b/admin-ui/.dockerignore @@ -0,0 +1,14 @@ +node_modules +npm-debug.log +.git +.gitignore +.env +.env.local +.env.development.local +.env.test.local +.env.production.local +dist +coverage +.nyc_output +.DS_Store +*.log diff --git a/admin-ui/.gitignore b/admin-ui/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/admin-ui/.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/admin-ui/Dockerfile b/admin-ui/Dockerfile new file mode 100644 index 0000000..5fb9a2d --- /dev/null +++ b/admin-ui/Dockerfile @@ -0,0 +1,34 @@ +# Build stage +FROM node:18-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci + +# Copy source code +COPY . . + +# Build the application +RUN npx vite build + +# Production stage +FROM node:18-alpine + +# Install serve globally +RUN npm install -g serve + +# Copy built files from builder stage +COPY --from=builder /app/dist /app/dist + +# Set working directory +WORKDIR /app + +# Expose port 80 +EXPOSE 80 + +# Start serve with SPA support +CMD ["serve", "-s", "dist", "-l", "80", "--single"] diff --git a/admin-ui/README.md b/admin-ui/README.md new file mode 100644 index 0000000..1955365 --- /dev/null +++ b/admin-ui/README.md @@ -0,0 +1,104 @@ +# OpenContext Admin UI + +React-based administration interface for OpenContext knowledge management system. + +## Features + +- **Dashboard**: Real-time statistics and overview of document processing status +- **Document Manager**: Upload, manage, and monitor document ingestion pipeline +- **Search Interface**: Test MCP search tools (find_knowledge and get_content) +- **Settings**: API key management and system configuration + +## Tech Stack + +- React 19.1.1 +- TypeScript 5.8.3 +- Vite 7.1.2 +- Tailwind CSS 3.4.17 +- React Router DOM 7.8.1 +- React Query 5.85.3 +- Zustand 5.0.7 +- Axios 1.11.0 +- Lucide React 0.539.0 + +## Development + +### Prerequisites + +- Node.js 18+ +- npm or yarn +- OpenContext backend running on localhost:8080 (for development) +- Docker and Docker Compose (for production deployment) + +### Setup + +```bash +# Install dependencies +npm install + +# Start development server +npm run dev + +# Build for production +npm run build + +# Preview production build +npm run preview +``` + +### Docker Deployment + +The admin UI is containerized and automatically deployed with the full stack: + +```bash +# Build and start all services (from project root) +docker compose up -d + +# Build admin UI only +docker compose build open-context-admin-ui + +# View logs +docker compose logs -f open-context-admin-ui +``` + +### Environment Variables + +```bash +# Optional - defaults to localhost:8080 +VITE_API_BASE_URL=http://localhost:8080/api/v1 +``` + +## Usage + +### Development Mode +1. Start the backend server (OpenContext Core) +2. Run the frontend development server: `npm run dev` +3. Navigate to http://localhost:5173 +4. Configure API key in Settings page +5. Upload documents and test search functionality + +### Production Mode (Docker) +1. Start all services: `docker compose up -d` +2. Navigate to http://localhost:3001 +3. Configure API key in Settings page (default: `dev-api-key-123`) +4. Upload documents and test search functionality + +### Port Information +- **Development**: http://localhost:5173 (Vite dev server) +- **Production**: http://localhost:3001 (Docker container) + +## API Integration + +The admin UI integrates with OpenContext backend APIs: + +- **Admin APIs**: Require X-API-KEY header authentication +- **Search APIs**: Public endpoints for MCP tool testing +- **File Upload**: Multipart form data with progress tracking + +## Architecture + +- **Components**: Reusable UI components in `/src/components/ui/` +- **Pages**: Main application views in `/src/pages/` +- **API Client**: Centralized HTTP client in `/src/lib/api.ts` +- **State Management**: Zustand store for authentication state +- **Types**: TypeScript definitions in `/src/types/` \ No newline at end of file diff --git a/admin-ui/eslint.config.js b/admin-ui/eslint.config.js new file mode 100644 index 0000000..d94e7de --- /dev/null +++ b/admin-ui/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { globalIgnores } from 'eslint/config' + +export default tseslint.config([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs['recommended-latest'], + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/admin-ui/index.html b/admin-ui/index.html new file mode 100644 index 0000000..4aa585d --- /dev/null +++ b/admin-ui/index.html @@ -0,0 +1,13 @@ + + + + + + + OpenContext + + +
+ + + diff --git a/admin-ui/package-lock.json b/admin-ui/package-lock.json new file mode 100644 index 0000000..dd73775 --- /dev/null +++ b/admin-ui/package-lock.json @@ -0,0 +1,5116 @@ +{ + "name": "admin-ui", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "admin-ui", + "version": "0.0.0", + "dependencies": { + "@tanstack/react-query": "^5.85.3", + "axios": "^1.11.0", + "clsx": "^2.1.1", + "lucide-react": "^0.539.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-router-dom": "^7.8.1", + "tailwind-merge": "^3.3.1", + "zustand": "^5.0.7" + }, + "devDependencies": { + "@eslint/js": "^9.33.0", + "@types/node": "^24.3.0", + "@types/react": "^19.1.10", + "@types/react-dom": "^19.1.7", + "@vitejs/plugin-react": "^5.0.0", + "autoprefixer": "^10.4.21", + "eslint": "^9.33.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.3.0", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.17", + "typescript": "~5.8.3", + "typescript-eslint": "^8.39.1", + "vite": "^7.1.2" + } + }, + "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.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz", + "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.3", + "@babel/parser": "^7.28.3", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.3", + "@babel/types": "^7.28.2", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.3.tgz", + "integrity": "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz", + "integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.3.tgz", + "integrity": "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.3", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.2", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", + "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", + "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz", + "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz", + "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz", + "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz", + "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz", + "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz", + "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz", + "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz", + "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz", + "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz", + "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz", + "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz", + "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz", + "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz", + "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz", + "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz", + "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz", + "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz", + "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz", + "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz", + "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz", + "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz", + "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz", + "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz", + "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz", + "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "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.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "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.33.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.33.0.tgz", + "integrity": "sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.2", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.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.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@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.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.30", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", + "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/@rolldown/pluginutils": { + "version": "1.0.0-beta.30", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.30.tgz", + "integrity": "sha512-whXaSoNUFiyDAjkUF8OBpOm77Szdbk5lGNqFe6CbVbJFrhCCPinCbRA3NjawwlNHla1No7xvXXh+CpSxnPfUEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.46.3.tgz", + "integrity": "sha512-UmTdvXnLlqQNOCJnyksjPs1G4GqXNGW1LrzCe8+8QoaLhhDeTXYBgJ3k6x61WIhlHX2U+VzEJ55TtIjR/HTySA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.46.3.tgz", + "integrity": "sha512-8NoxqLpXm7VyeI0ocidh335D6OKT0UJ6fHdnIxf3+6oOerZZc+O7r+UhvROji6OspyPm+rrIdb1gTXtVIqn+Sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.46.3.tgz", + "integrity": "sha512-csnNavqZVs1+7/hUKtgjMECsNG2cdB8F7XBHP6FfQjqhjF8rzMzb3SLyy/1BG7YSfQ+bG75Ph7DyedbUqwq1rA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.46.3.tgz", + "integrity": "sha512-r2MXNjbuYabSIX5yQqnT8SGSQ26XQc8fmp6UhlYJd95PZJkQD1u82fWP7HqvGUf33IsOC6qsiV+vcuD4SDP6iw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.46.3.tgz", + "integrity": "sha512-uluObTmgPJDuJh9xqxyr7MV61Imq+0IvVsAlWyvxAaBSNzCcmZlhfYcRhCdMaCsy46ccZa7vtDDripgs9Jkqsw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.46.3.tgz", + "integrity": "sha512-AVJXEq9RVHQnejdbFvh1eWEoobohUYN3nqJIPI4mNTMpsyYN01VvcAClxflyk2HIxvLpRcRggpX1m9hkXkpC/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.46.3.tgz", + "integrity": "sha512-byyflM+huiwHlKi7VHLAYTKr67X199+V+mt1iRgJenAI594vcmGGddWlu6eHujmcdl6TqSNnvqaXJqZdnEWRGA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.46.3.tgz", + "integrity": "sha512-aLm3NMIjr4Y9LklrH5cu7yybBqoVCdr4Nvnm8WB7PKCn34fMCGypVNpGK0JQWdPAzR/FnoEoFtlRqZbBBLhVoQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.46.3.tgz", + "integrity": "sha512-VtilE6eznJRDIoFOzaagQodUksTEfLIsvXymS+UdJiSXrPW7Ai+WG4uapAc3F7Hgs791TwdGh4xyOzbuzIZrnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.46.3.tgz", + "integrity": "sha512-dG3JuS6+cRAL0GQ925Vppafi0qwZnkHdPeuZIxIPXqkCLP02l7ka+OCyBoDEv8S+nKHxfjvjW4OZ7hTdHkx8/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loongarch64-gnu": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.46.3.tgz", + "integrity": "sha512-iU8DxnxEKJptf8Vcx4XvAUdpkZfaz0KWfRrnIRrOndL0SvzEte+MTM7nDH4A2Now4FvTZ01yFAgj6TX/mZl8hQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.46.3.tgz", + "integrity": "sha512-VrQZp9tkk0yozJoQvQcqlWiqaPnLM6uY1qPYXvukKePb0fqaiQtOdMJSxNFUZFsGw5oA5vvVokjHrx8a9Qsz2A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.46.3.tgz", + "integrity": "sha512-uf2eucWSUb+M7b0poZ/08LsbcRgaDYL8NCGjUeFMwCWFwOuFcZ8D9ayPl25P3pl+D2FH45EbHdfyUesQ2Lt9wA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.46.3.tgz", + "integrity": "sha512-7tnUcDvN8DHm/9ra+/nF7lLzYHDeODKKKrh6JmZejbh1FnCNZS8zMkZY5J4sEipy2OW1d1Ncc4gNHUd0DLqkSg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.46.3.tgz", + "integrity": "sha512-MUpAOallJim8CsJK+4Lc9tQzlfPbHxWDrGXZm2z6biaadNpvh3a5ewcdat478W+tXDoUiHwErX/dOql7ETcLqg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.3.tgz", + "integrity": "sha512-F42IgZI4JicE2vM2PWCe0N5mR5vR0gIdORPqhGQ32/u1S1v3kLtbZ0C/mi9FFk7C5T0PgdeyWEPajPjaUpyoKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.46.3.tgz", + "integrity": "sha512-oLc+JrwwvbimJUInzx56Q3ujL3Kkhxehg7O1gWAYzm8hImCd5ld1F2Gry5YDjR21MNb5WCKhC9hXgU7rRlyegQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.46.3.tgz", + "integrity": "sha512-lOrQ+BVRstruD1fkWg9yjmumhowR0oLAAzavB7yFSaGltY8klttmZtCLvOXCmGE9mLIn8IBV/IFrQOWz5xbFPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.46.3.tgz", + "integrity": "sha512-vvrVKPRS4GduGR7VMH8EylCBqsDcw6U+/0nPDuIjXQRbHJc6xOBj+frx8ksfZAh6+Fptw5wHrN7etlMmQnPQVg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.46.3.tgz", + "integrity": "sha512-fi3cPxCnu3ZeM3EwKZPgXbWoGzm2XHgB/WShKI81uj8wG0+laobmqy5wbgEwzstlbLu4MyO8C19FyhhWseYKNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.85.3", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.85.3.tgz", + "integrity": "sha512-9Ne4USX83nHmRuEYs78LW+3lFEEO2hBDHu7mrdIgAFx5Zcrs7ker3n/i8p4kf6OgKExmaDN5oR0efRD7i2J0DQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.85.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.85.3.tgz", + "integrity": "sha512-AqU8TvNh5GVIE8I+TUU0noryBRy7gOY0XhSayVXmOPll4UkZeLWKDwi0rtWOZbwLRCbyxorfJ5DIjDqE7GXpcQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.85.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", + "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.10.0" + } + }, + "node_modules/@types/react": { + "version": "19.1.10", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.10.tgz", + "integrity": "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.1.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.7.tgz", + "integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.39.1.tgz", + "integrity": "sha512-yYegZ5n3Yr6eOcqgj2nJH8cH/ZZgF+l0YIdKILSDjYFRjgYQMgv/lRjV5Z7Up04b9VYUondt8EPMqg7kTWgJ2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.39.1", + "@typescript-eslint/type-utils": "8.39.1", + "@typescript-eslint/utils": "8.39.1", + "@typescript-eslint/visitor-keys": "8.39.1", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.39.1", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.39.1.tgz", + "integrity": "sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.39.1", + "@typescript-eslint/types": "8.39.1", + "@typescript-eslint/typescript-estree": "8.39.1", + "@typescript-eslint/visitor-keys": "8.39.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.39.1.tgz", + "integrity": "sha512-8fZxek3ONTwBu9ptw5nCKqZOSkXshZB7uAxuFF0J/wTMkKydjXCzqqga7MlFMpHi9DoG4BadhmTkITBcg8Aybw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.39.1", + "@typescript-eslint/types": "^8.39.1", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.39.1.tgz", + "integrity": "sha512-RkBKGBrjgskFGWuyUGz/EtD8AF/GW49S21J8dvMzpJitOF1slLEbbHnNEtAHtnDAnx8qDEdRrULRnWVx27wGBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.39.1", + "@typescript-eslint/visitor-keys": "8.39.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.39.1.tgz", + "integrity": "sha512-ePUPGVtTMR8XMU2Hee8kD0Pu4NDE1CN9Q1sxGSGd/mbOtGZDM7pnhXNJnzW63zk/q+Z54zVzj44HtwXln5CvHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.39.1.tgz", + "integrity": "sha512-gu9/ahyatyAdQbKeHnhT4R+y3YLtqqHyvkfDxaBYk97EcbfChSJXyaJnIL3ygUv7OuZatePHmQvuH5ru0lnVeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.39.1", + "@typescript-eslint/typescript-estree": "8.39.1", + "@typescript-eslint/utils": "8.39.1", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.39.1.tgz", + "integrity": "sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.39.1.tgz", + "integrity": "sha512-EKkpcPuIux48dddVDXyQBlKdeTPMmALqBUbEk38McWv0qVEZwOpVJBi7ugK5qVNgeuYjGNQxrrnoM/5+TI/BPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.39.1", + "@typescript-eslint/tsconfig-utils": "8.39.1", + "@typescript-eslint/types": "8.39.1", + "@typescript-eslint/visitor-keys": "8.39.1", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/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/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.39.1.tgz", + "integrity": "sha512-VF5tZ2XnUSTuiqZFXCZfZs1cgkdd3O/sSYmdo2EpSyDlC86UM/8YytTmKnehOW3TGAlivqTDT6bS87B/GQ/jyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.39.1", + "@typescript-eslint/types": "8.39.1", + "@typescript-eslint/typescript-estree": "8.39.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.39.1.tgz", + "integrity": "sha512-W8FQi6kEh2e8zVhQ0eeRnxdvIoOkAp/CPAahcNio6nO9dsIwb9b34z90KOlheoyuVf6LSOEdjlkxSkapNEc+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.39.1", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.0.tgz", + "integrity": "sha512-Jx9JfsTa05bYkS9xo0hkofp2dCmp1blrKjw9JONs5BTHOvJCgLbaPSuZLGSVJW6u2qe0tc4eevY0+gSNNi0YCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.30", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "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.2.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.0.tgz", + "integrity": "sha512-TKY5pyBkHyADOPYlRT9Lx6F544mPl0vS5Ew7BJ45hA08Q+t3GjbueLliBWN3sMICk6+y7HdyxSzC4bWS8baBdg==", + "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/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "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.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", + "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/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/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/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.25.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.2.tgz", + "integrity": "sha512-0si2SJK3ooGzIawRu61ZdPCO1IncZwS8IzuX73sPZsXW6EQ/w/DAfPyKI8l1ETTCr2MnvqWitmlCUxgdul45jA==", + "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.30001733", + "electron-to-chromium": "^1.5.199", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/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.30001735", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001735.tgz", + "integrity": "sha512-EV/laoX7Wq2J9TQlyIXRxTJqIw4sxfXS4OYgudGxBYRuTv0q7AM6yMEpU/Vo1I94thg9U6EZ2NfZx9GJq83u7w==", + "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/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/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/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/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/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", + "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/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.203", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.203.tgz", + "integrity": "sha512-uz4i0vLhfm6dLZWbz/iH88KNDV+ivj5+2SA+utpgjKaj9Q0iDLuwk6Idhe9BTxciHudyx6IvTvijhkPvFGUQ0g==", + "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-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", + "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.9", + "@esbuild/android-arm": "0.25.9", + "@esbuild/android-arm64": "0.25.9", + "@esbuild/android-x64": "0.25.9", + "@esbuild/darwin-arm64": "0.25.9", + "@esbuild/darwin-x64": "0.25.9", + "@esbuild/freebsd-arm64": "0.25.9", + "@esbuild/freebsd-x64": "0.25.9", + "@esbuild/linux-arm": "0.25.9", + "@esbuild/linux-arm64": "0.25.9", + "@esbuild/linux-ia32": "0.25.9", + "@esbuild/linux-loong64": "0.25.9", + "@esbuild/linux-mips64el": "0.25.9", + "@esbuild/linux-ppc64": "0.25.9", + "@esbuild/linux-riscv64": "0.25.9", + "@esbuild/linux-s390x": "0.25.9", + "@esbuild/linux-x64": "0.25.9", + "@esbuild/netbsd-arm64": "0.25.9", + "@esbuild/netbsd-x64": "0.25.9", + "@esbuild/openbsd-arm64": "0.25.9", + "@esbuild/openbsd-x64": "0.25.9", + "@esbuild/openharmony-arm64": "0.25.9", + "@esbuild/sunos-x64": "0.25.9", + "@esbuild/win32-arm64": "0.25.9", + "@esbuild/win32-ia32": "0.25.9", + "@esbuild/win32-x64": "0.25.9" + } + }, + "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.33.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.33.0.tgz", + "integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.33.0", + "@eslint/plugin-kit": "^0.3.5", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "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.20", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz", + "integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.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/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.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/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.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "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.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "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/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/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.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "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": "16.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz", + "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-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-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/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/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": "2.5.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz", + "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "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.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", + "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", + "dev": true, + "license": "MPL-2.0", + "optional": true, + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-darwin-arm64": "1.30.1", + "lightningcss-darwin-x64": "1.30.1", + "lightningcss-freebsd-x64": "1.30.1", + "lightningcss-linux-arm-gnueabihf": "1.30.1", + "lightningcss-linux-arm64-gnu": "1.30.1", + "lightningcss-linux-arm64-musl": "1.30.1", + "lightningcss-linux-x64-gnu": "1.30.1", + "lightningcss-linux-x64-musl": "1.30.1", + "lightningcss-win32-arm64-msvc": "1.30.1", + "lightningcss-win32-x64-msvc": "1.30.1" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", + "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", + "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", + "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", + "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", + "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", + "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", + "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", + "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", + "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", + "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/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/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.539.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.539.0.tgz", + "integrity": "sha512-VVISr+VF2krO91FeuCrm1rSOLACQUYVy7NQkzrOty52Y8TlTPcXcMdQFj9bYzBgXbWCiywlwSZ3Z8u6a+6bMlg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/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.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "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/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/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/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.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.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-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/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/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": "19.1.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.1.tgz", + "integrity": "sha512-w8nqGImo45dmMIfljjMwOGtbmC/mk4CMYhWIicdSflH91J9TyCyczcPFXJzrZ/ZXcgGRFeP6BU0BEJTw6tZdfQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.1.tgz", + "integrity": "sha512-Dlq/5LAZgF0Gaz6yiqZCf6VCcZs1ghAJyrsu84Q/GT0gV+mCxbfmKNoGRKBYMJ8IEdGPqu49YWXD02GCknEDkw==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.8.1.tgz", + "integrity": "sha512-5cy/M8DHcG51/KUIka1nfZ2QeylS4PJRs6TT8I4PF5axVsI5JUxp0hC0NZ/AEEj8Vw7xsEoD7L/6FY+zoYaOGA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.8.1.tgz", + "integrity": "sha512-NkgBCF3sVgCiAWIlSt89GR2PLaksMpoo3HDCorpRfnCEfdtRPLiuTf+CNXvqZMI5SJLZCLpVCvcZrTdtGW64xQ==", + "license": "MIT", + "dependencies": { + "react-router": "7.8.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "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/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/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/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.46.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.46.3.tgz", + "integrity": "sha512-RZn2XTjXb8t5g13f5YclGoilU/kwT696DIkY3sywjdZidNSi3+vseaQov7D7BZXVJCPv3pDWUN69C78GGbXsKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.46.3", + "@rollup/rollup-android-arm64": "4.46.3", + "@rollup/rollup-darwin-arm64": "4.46.3", + "@rollup/rollup-darwin-x64": "4.46.3", + "@rollup/rollup-freebsd-arm64": "4.46.3", + "@rollup/rollup-freebsd-x64": "4.46.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.46.3", + "@rollup/rollup-linux-arm-musleabihf": "4.46.3", + "@rollup/rollup-linux-arm64-gnu": "4.46.3", + "@rollup/rollup-linux-arm64-musl": "4.46.3", + "@rollup/rollup-linux-loongarch64-gnu": "4.46.3", + "@rollup/rollup-linux-ppc64-gnu": "4.46.3", + "@rollup/rollup-linux-riscv64-gnu": "4.46.3", + "@rollup/rollup-linux-riscv64-musl": "4.46.3", + "@rollup/rollup-linux-s390x-gnu": "4.46.3", + "@rollup/rollup-linux-x64-gnu": "4.46.3", + "@rollup/rollup-linux-x64-musl": "4.46.3", + "@rollup/rollup-win32-arm64-msvc": "4.46.3", + "@rollup/rollup-win32-ia32-msvc": "4.46.3", + "@rollup/rollup-win32-x64-msvc": "4.46.3", + "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/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/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/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-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/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-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": "3.3.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.3.1.tgz", + "integrity": "sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/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/tinyglobby": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.4.4", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "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/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.39.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.39.1.tgz", + "integrity": "sha512-GDUv6/NDYngUlNvwaHM1RamYftxf782IyEDbdj3SeaIHHv8fNQVRC++fITT7kUJV/5rIA/tkoRSSskt6osEfqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.39.1", + "@typescript-eslint/parser": "8.39.1", + "@typescript-eslint/typescript-estree": "8.39.1", + "@typescript-eslint/utils": "8.39.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/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": "7.1.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.2.tgz", + "integrity": "sha512-J0SQBPlQiEXAF7tajiH+rUooJPo0l8KQgyg4/aMunNtrOa7bwuZJsJbDWzeljqQpgftxuq5yNJxQ91O9ts29UQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.6", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.14" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/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.8.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", + "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + } + }, + "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/zustand": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.7.tgz", + "integrity": "sha512-Ot6uqHDW/O2VdYsKLLU8GQu8sCOM1LcoE8RwvLv9uuRT9s6SOHCKs0ZEOhxg+I1Ld+A1Q5lwx+UlKXXUoCZITg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/admin-ui/package.json b/admin-ui/package.json new file mode 100644 index 0000000..b6164c7 --- /dev/null +++ b/admin-ui/package.json @@ -0,0 +1,40 @@ +{ + "name": "admin-ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.85.3", + "axios": "^1.11.0", + "clsx": "^2.1.1", + "lucide-react": "^0.539.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-router-dom": "^7.8.1", + "tailwind-merge": "^3.3.1", + "zustand": "^5.0.7" + }, + "devDependencies": { + "@eslint/js": "^9.33.0", + "@types/node": "^24.3.0", + "@types/react": "^19.1.10", + "@types/react-dom": "^19.1.7", + "@vitejs/plugin-react": "^5.0.0", + "autoprefixer": "^10.4.21", + "eslint": "^9.33.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.3.0", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.17", + "typescript": "~5.8.3", + "typescript-eslint": "^8.39.1", + "vite": "^7.1.2" + } +} diff --git a/admin-ui/postcss.config.js b/admin-ui/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/admin-ui/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/admin-ui/public/logo.svg b/admin-ui/public/logo.svg new file mode 100644 index 0000000..0e90381 --- /dev/null +++ b/admin-ui/public/logo.svg @@ -0,0 +1,4 @@ + + + OC + \ No newline at end of file diff --git a/admin-ui/public/vite.svg b/admin-ui/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/admin-ui/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin-ui/src/App.css b/admin-ui/src/App.css new file mode 100644 index 0000000..b9d355d --- /dev/null +++ b/admin-ui/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/admin-ui/src/App.tsx b/admin-ui/src/App.tsx new file mode 100644 index 0000000..f1f1f11 --- /dev/null +++ b/admin-ui/src/App.tsx @@ -0,0 +1,44 @@ +import { BrowserRouter as Router, Routes, Route } from 'react-router-dom' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { Layout } from './components/Layout' +import { Dashboard } from './pages/Dashboard' +import { DocumentManager } from './pages/DocumentManager' +import { Search } from './pages/Search' +import { Settings } from './pages/Settings' +import { ComponentsDemo } from './components/ComponentsDemo' + +// Create a query client instance +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}) + +/** + * Main App component with routing and React Query setup + */ +function App() { + return ( + + +
+ + {/* Layout wrapper for main application routes */} + } /> + } /> + } /> + } /> + + {/* Components demo page (without layout) */} + } /> + +
+
+
+ ) +} + +export default App diff --git a/admin-ui/src/assets/react.svg b/admin-ui/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/admin-ui/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin-ui/src/components/ComponentsDemo.tsx b/admin-ui/src/components/ComponentsDemo.tsx new file mode 100644 index 0000000..08d0999 --- /dev/null +++ b/admin-ui/src/components/ComponentsDemo.tsx @@ -0,0 +1,329 @@ +import React, { useState } from 'react' +import { Upload, File, Search, Trash2, RefreshCw, Eye } from 'lucide-react' +import { Button } from './ui/Button' +import { Card, CardHeader, CardTitle, CardContent, CardFooter } from './ui/Card' +import { Badge } from './ui/Badge' +import { Input } from './ui/Input' +import { FileUpload } from './ui/FileUpload' +import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from './ui/Table' +import { formatFileSize, formatRelativeTime, getStatusColor } from '../lib/utils' + +/** + * Demo page showcasing all reusable UI components + * This serves as both a style guide and component testing environment + */ +export const ComponentsDemo: React.FC = () => { + const [selectedFile, setSelectedFile] = useState(null) + const [searchQuery, setSearchQuery] = useState('') + const [loading, setLoading] = useState(false) + + // Mock data for demonstration + const mockDocuments = [ + { + id: '1', + originalFilename: 'spring-security-guide.pdf', + fileType: 'PDF' as const, + fileSize: 2048576, + ingestionStatus: 'COMPLETED' as const, + errorMessage: null, + lastIngestedAt: '2025-08-18T10:30:00Z', + createdAt: '2025-08-18T10:00:00Z', + updatedAt: '2025-08-18T10:30:00Z', + }, + { + id: '2', + originalFilename: 'api-documentation.md', + fileType: 'MARKDOWN' as const, + fileSize: 51200, + ingestionStatus: 'PARSING' as const, + errorMessage: null, + lastIngestedAt: null, + createdAt: '2025-08-18T11:00:00Z', + updatedAt: '2025-08-18T11:05:00Z', + }, + { + id: '3', + originalFilename: 'troubleshooting.txt', + fileType: 'TXT' as const, + fileSize: 10240, + ingestionStatus: 'ERROR' as const, + errorMessage: 'Failed to parse document structure', + lastIngestedAt: null, + createdAt: '2025-08-18T09:30:00Z', + updatedAt: '2025-08-18T09:35:00Z', + }, + ] + + const handleFileSelect = (file: File) => { + setSelectedFile(file) + } + + const handleClearFile = () => { + setSelectedFile(null) + } + + const handleUpload = async () => { + if (!selectedFile) return + + setLoading(true) + // Simulate upload + await new Promise(resolve => setTimeout(resolve, 2000)) + setLoading(false) + setSelectedFile(null) + alert('File uploaded successfully!') + } + + const handleAction = async (action: string, id?: string) => { + setLoading(true) + await new Promise(resolve => setTimeout(resolve, 1000)) + setLoading(false) + alert(`${action} executed${id ? ` for document ${id}` : ''}`) + } + + return ( +
+
+ {/* Header */} +
+

+ OpenContext Admin Components +

+

+ Reusable UI components for the admin dashboard +

+
+ + {/* Button Variants */} + + + Button Components + + +
+ + + + + + + +
+ +
+ + + + +
+
+
+ + {/* Badge Variants */} + + + Badge Components + + +
+ Default + Secondary + Success + Warning + Error + Outline +
+
+

Status Badges

+
+ COMPLETED + PARSING + ERROR + PENDING +
+
+
+
+ + {/* Input Components */} + + + Input Components + + +
+ + setSearchQuery(e.target.value)} + /> + + +
+
+
+ + {/* File Upload Component */} + + + File Upload Component + + + + {selectedFile && ( +
+ +
+ )} +
+
+ + {/* Table Component */} + + + Table Component + + + + + + Filename + Type + Size + Status + Created + Actions + + + + {mockDocuments.map((doc) => ( + + +
+ + {doc.originalFilename} +
+
+ {doc.fileType} + {formatFileSize(doc.fileSize)} + + + {doc.ingestionStatus} + + + {formatRelativeTime(doc.createdAt)} + +
+ + + +
+
+
+ ))} +
+
+
+
+ + {/* Card Layouts */} +
+ + + Statistics Card + + +
42
+

Total Documents

+
+ + + +
+ + + + Status Overview + + +
+ Completed + 28 +
+
+ Processing + 12 +
+
+ Failed + 2 +
+
+
+ + + + Quick Actions + + + + + + + +
+
+
+ ) +} \ No newline at end of file diff --git a/admin-ui/src/components/Layout.tsx b/admin-ui/src/components/Layout.tsx new file mode 100644 index 0000000..94df7cc --- /dev/null +++ b/admin-ui/src/components/Layout.tsx @@ -0,0 +1,85 @@ +import React from 'react' +import { Link, useLocation } from 'react-router-dom' +import { + Home, + FileText, + Search, + Settings +} from 'lucide-react' +import { cn } from '../lib/utils' + +interface LayoutProps { + children: React.ReactNode +} + +const navigation = [ + { name: 'Dashboard', href: '/', icon: Home }, + { name: 'Documents', href: '/documents', icon: FileText }, + { name: 'Search', href: '/search', icon: Search }, + { name: 'Settings', href: '/settings', icon: Settings }, +] + +export const Layout: React.FC = ({ children }) => { + const location = useLocation() + + return ( +
+ {/* Sidebar */} +
+
+ {/* Logo */} +
+ +
+ OC +
+ + OpenContext + + +
+ + {/* Navigation */} + + + {/* Footer */} +
+

+ OpenContext Admin v1.0 +

+
+
+
+ + {/* Main content */} +
+
+
+ {children} +
+
+
+
+ ) +} \ No newline at end of file diff --git a/admin-ui/src/components/ui/Badge.tsx b/admin-ui/src/components/ui/Badge.tsx new file mode 100644 index 0000000..a748a11 --- /dev/null +++ b/admin-ui/src/components/ui/Badge.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import { cn } from '../../lib/utils' + +interface BadgeProps extends React.HTMLAttributes { + variant?: 'default' | 'secondary' | 'destructive' | 'success' | 'warning' | 'outline' + children: React.ReactNode +} + +const badgeVariants = { + default: 'border-transparent bg-blue-500 text-white hover:bg-blue-600', + secondary: 'border-transparent bg-gray-100 text-gray-900 hover:bg-gray-200', + destructive: 'border-transparent bg-red-500 text-white hover:bg-red-600', + success: 'border-transparent bg-green-500 text-white hover:bg-green-600', + warning: 'border-transparent bg-yellow-500 text-white hover:bg-yellow-600', + outline: 'text-gray-900 border-current', +} + +/** + * Badge component for displaying status indicators and labels + * + * @example + * ```tsx + * Completed + * Error + * ``` + */ +export const Badge = React.forwardRef( + ({ className, variant = 'default', children, ...props }, ref) => { + return ( +
+ {children} +
+ ) + } +) +Badge.displayName = 'Badge' \ No newline at end of file diff --git a/admin-ui/src/components/ui/Button.tsx b/admin-ui/src/components/ui/Button.tsx new file mode 100644 index 0000000..cf7f8b3 --- /dev/null +++ b/admin-ui/src/components/ui/Button.tsx @@ -0,0 +1,79 @@ +import React from 'react' +import { cn } from '../../lib/utils' + +interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' + size?: 'default' | 'sm' | 'lg' | 'icon' + loading?: boolean + children: React.ReactNode +} + +const buttonVariants = { + default: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500', + destructive: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500', + outline: 'border border-gray-300 bg-white hover:bg-gray-50 hover:text-gray-900 focus:ring-blue-500', + secondary: 'bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-500', + ghost: 'hover:bg-gray-100 hover:text-gray-900 focus:ring-gray-500', +} + +const buttonSizes = { + default: 'h-10 px-4 py-2 text-sm', + sm: 'h-9 px-3 text-xs', + lg: 'h-11 px-8 text-base', + icon: 'h-10 w-10', +} + +/** + * Reusable Button component with multiple variants and sizes + * + * @example + * ```tsx + * + * ``` + */ +export const Button = React.forwardRef( + ({ className, variant = 'default', size = 'default', loading = false, children, disabled, ...props }, ref) => { + return ( + + ) + } +) + +Button.displayName = 'Button' \ No newline at end of file diff --git a/admin-ui/src/components/ui/Card.tsx b/admin-ui/src/components/ui/Card.tsx new file mode 100644 index 0000000..f503cf5 --- /dev/null +++ b/admin-ui/src/components/ui/Card.tsx @@ -0,0 +1,93 @@ +import React from 'react' +import { cn } from '../../lib/utils' + +interface CardProps extends React.HTMLAttributes { + children: React.ReactNode +} + +interface CardHeaderProps extends React.HTMLAttributes { + children: React.ReactNode +} + +interface CardTitleProps extends React.HTMLAttributes { + children: React.ReactNode +} + +interface CardContentProps extends React.HTMLAttributes { + children: React.ReactNode +} + +interface CardFooterProps extends React.HTMLAttributes { + children: React.ReactNode +} + +/** + * Card component for grouping related content + */ +export const Card = React.forwardRef( + ({ className, children, ...props }, ref) => ( +
+ {children} +
+ ) +) +Card.displayName = 'Card' + +/** + * Card header component + */ +export const CardHeader = React.forwardRef( + ({ className, children, ...props }, ref) => ( +
+ {children} +
+ ) +) +CardHeader.displayName = 'CardHeader' + +/** + * Card title component + */ +export const CardTitle = React.forwardRef( + ({ className, children, ...props }, ref) => ( +

+ {children} +

+ ) +) +CardTitle.displayName = 'CardTitle' + +/** + * Card content component + */ +export const CardContent = React.forwardRef( + ({ className, children, ...props }, ref) => ( +
+ {children} +
+ ) +) +CardContent.displayName = 'CardContent' + +/** + * Card footer component + */ +export const CardFooter = React.forwardRef( + ({ className, children, ...props }, ref) => ( +
+ {children} +
+ ) +) +CardFooter.displayName = 'CardFooter' \ No newline at end of file diff --git a/admin-ui/src/components/ui/FileUpload.tsx b/admin-ui/src/components/ui/FileUpload.tsx new file mode 100644 index 0000000..65120ee --- /dev/null +++ b/admin-ui/src/components/ui/FileUpload.tsx @@ -0,0 +1,185 @@ +import React, { useCallback } from 'react' +import { Upload, File, X } from 'lucide-react' +import { cn } from '../../lib/utils' +import { Button } from './Button' + +interface FileUploadProps { + onFileSelect: (file: File) => void + accept?: string + maxSize?: number // in bytes + disabled?: boolean + className?: string + selectedFile?: File | null + onClearFile?: () => void +} + +/** + * File upload component with drag-and-drop support + * + * @example + * ```tsx + * + * ``` + */ +export const FileUpload: React.FC = ({ + onFileSelect, + accept = '.pdf,.md,.txt', + maxSize = 100 * 1024 * 1024, // 100MB default + disabled = false, + className, + selectedFile, + onClearFile, +}) => { + const [dragOver, setDragOver] = React.useState(false) + const fileInputRef = React.useRef(null) + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + setDragOver(false) + + if (disabled) return + + const files = Array.from(e.dataTransfer.files) + if (files.length > 0) { + const file = files[0] + if (file.size <= maxSize) { + onFileSelect(file) + } else { + alert(`File size exceeds ${Math.round(maxSize / (1024 * 1024))}MB limit`) + } + } + }, + [onFileSelect, maxSize, disabled] + ) + + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault() + if (!disabled) { + setDragOver(true) + } + }, [disabled]) + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault() + setDragOver(false) + }, []) + + const handleFileInputChange = useCallback( + (e: React.ChangeEvent) => { + const files = e.target.files + if (files && files.length > 0) { + const file = files[0] + if (file.size <= maxSize) { + onFileSelect(file) + } else { + alert(`File size exceeds ${Math.round(maxSize / (1024 * 1024))}MB limit`) + } + } + }, + [onFileSelect, maxSize] + ) + + const handleBrowseClick = () => { + fileInputRef.current?.click() + } + + const formatFileSize = (bytes: number): string => { + const sizes = ['B', 'KB', 'MB', 'GB'] + if (bytes === 0) return '0 B' + const i = Math.floor(Math.log(bytes) / Math.log(1024)) + return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i] + } + + return ( +
+
+ + +
+ + +
+

+ {dragOver && !disabled + ? 'Drop your file here' + : 'Drag and drop your file here'} +

+

+ or{' '} + +

+
+ +
+

Supported formats: PDF, Markdown, Plain Text

+

Maximum file size: {Math.round(maxSize / (1024 * 1024))}MB

+
+
+
+ + {selectedFile && ( +
+
+ +
+

+ {selectedFile.name} +

+

+ {formatFileSize(selectedFile.size)} +

+
+
+ {onClearFile && ( + + )} +
+ )} +
+ ) +} \ No newline at end of file diff --git a/admin-ui/src/components/ui/Input.tsx b/admin-ui/src/components/ui/Input.tsx new file mode 100644 index 0000000..07e7bb6 --- /dev/null +++ b/admin-ui/src/components/ui/Input.tsx @@ -0,0 +1,53 @@ +import React from 'react' +import { cn } from '../../lib/utils' + +interface InputProps extends React.InputHTMLAttributes { + error?: string + label?: string +} + +/** + * Input component with optional label and error message + * + * @example + * ```tsx + * + * ``` + */ +export const Input = React.forwardRef( + ({ className, type, error, label, id, ...props }, ref) => { + const inputId = id || label?.toLowerCase().replace(/\s+/g, '-') + + return ( +
+ {label && ( + + )} + + {error && ( +

{error}

+ )} +
+ ) + } +) +Input.displayName = 'Input' \ No newline at end of file diff --git a/admin-ui/src/components/ui/Table.tsx b/admin-ui/src/components/ui/Table.tsx new file mode 100644 index 0000000..2b84cfd --- /dev/null +++ b/admin-ui/src/components/ui/Table.tsx @@ -0,0 +1,126 @@ +import React from 'react' +import { cn } from '../../lib/utils' + +interface TableProps extends React.HTMLAttributes { + children: React.ReactNode +} + +interface TableHeaderProps extends React.HTMLAttributes { + children: React.ReactNode +} + +interface TableBodyProps extends React.HTMLAttributes { + children: React.ReactNode +} + +interface TableRowProps extends React.HTMLAttributes { + children: React.ReactNode +} + +interface TableHeadProps extends React.ThHTMLAttributes { + children: React.ReactNode +} + +interface TableCellProps extends React.TdHTMLAttributes { + children: React.ReactNode +} + +/** + * Table component for displaying tabular data + */ +export const Table = React.forwardRef( + ({ className, children, ...props }, ref) => ( +
+ + {children} +
+
+ ) +) +Table.displayName = 'Table' + +/** + * Table header component + */ +export const TableHeader = React.forwardRef( + ({ className, children, ...props }, ref) => ( + + {children} + + ) +) +TableHeader.displayName = 'TableHeader' + +/** + * Table body component + */ +export const TableBody = React.forwardRef( + ({ className, children, ...props }, ref) => ( + + {children} + + ) +) +TableBody.displayName = 'TableBody' + +/** + * Table row component + */ +export const TableRow = React.forwardRef( + ({ className, children, ...props }, ref) => ( + + {children} + + ) +) +TableRow.displayName = 'TableRow' + +/** + * Table head cell component + */ +export const TableHead = React.forwardRef( + ({ className, children, ...props }, ref) => ( + + {children} + + ) +) +TableHead.displayName = 'TableHead' + +/** + * Table cell component + */ +export const TableCell = React.forwardRef( + ({ className, children, ...props }, ref) => ( + + {children} + + ) +) +TableCell.displayName = 'TableCell' \ No newline at end of file diff --git a/admin-ui/src/index.css b/admin-ui/src/index.css new file mode 100644 index 0000000..fb66a3e --- /dev/null +++ b/admin-ui/src/index.css @@ -0,0 +1,10 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); + +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Test CSS to verify Tailwind is working */ +.test-tailwind { + @apply bg-red-500 text-white p-4 rounded-lg; +} diff --git a/admin-ui/src/lib/api.ts b/admin-ui/src/lib/api.ts new file mode 100644 index 0000000..61b1935 --- /dev/null +++ b/admin-ui/src/lib/api.ts @@ -0,0 +1,160 @@ +import axios, { type AxiosResponse } from 'axios' +import type { + CommonResponse, + PageResponse, + SourceDocumentDto, + SourceUploadResponse, + GetDocumentsRequest, + UploadFileRequest, + DeleteDocumentRequest, + ResyncDocumentRequest, + SearchResultsResponse, + GetContentRequest, + GetContentResponse, +} from '../types/api' + +// API Base URL - can be configured via environment variables +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1' + +// Create axios instance with default configuration +const apiClient = axios.create({ + baseURL: API_BASE_URL, + timeout: 300000, // 5 minutes for file processing + headers: { + 'Content-Type': 'application/json', + }, +}) + +// Response interceptor to handle common errors +apiClient.interceptors.response.use( + (response) => response, + (error) => { + console.error('API Error:', error) + return Promise.reject(error) + } +) + +/** + * Upload a file to start the ingestion pipeline + */ +export const uploadFile = async (request: UploadFileRequest): Promise => { + const formData = new FormData() + formData.append('file', request.file) + + const response: AxiosResponse> = await apiClient.post( + '/sources/upload', + formData, + { + headers: { + 'Content-Type': 'multipart/form-data', + 'X-API-KEY': request.apiKey, + }, + } + ) + + if (!response.data.success) { + throw new Error(response.data.message || 'Upload failed') + } + + return response.data.data! +} + +/** + * Get paginated list of source documents + */ +export const getDocuments = async ( + request: GetDocumentsRequest +): Promise> => { + const params = new URLSearchParams() + if (request.page !== undefined) params.append('page', request.page.toString()) + if (request.size !== undefined) params.append('size', request.size.toString()) + if (request.sort) params.append('sort', request.sort) + + const response: AxiosResponse>> = + await apiClient.get(`/sources?${params.toString()}`, { + headers: { + 'X-API-KEY': request.apiKey, + }, + }) + + if (!response.data.success) { + throw new Error(response.data.message || 'Failed to fetch documents') + } + + return response.data.data! +} + +/** + * Delete a source document + */ +export const deleteDocument = async (request: DeleteDocumentRequest): Promise => { + const response: AxiosResponse> = await apiClient.delete( + `/sources/${request.sourceId}`, + { + headers: { + 'X-API-KEY': request.apiKey, + }, + } + ) + + if (!response.data.success) { + throw new Error(response.data.message || 'Delete failed') + } +} + +/** + * Resync (re-process) a source document + */ +export const resyncDocument = async (request: ResyncDocumentRequest): Promise => { + const response: AxiosResponse> = await apiClient.post( + `/sources/${request.sourceId}/resync`, + {}, + { + headers: { + 'X-API-KEY': request.apiKey, + }, + } + ) + + if (!response.data.success) { + throw new Error(response.data.message || 'Resync failed') + } +} + +/** + * Search for knowledge chunks (MCP find_knowledge tool) + */ +export const searchKnowledge = async ( + query: string, + topK: number = 50 +): Promise => { + const params = new URLSearchParams() + params.append('query', query) + params.append('topK', topK.toString()) + + const response: AxiosResponse> = await apiClient.get( + `/search?${params.toString()}` + ) + + if (!response.data.success) { + throw new Error(response.data.message || 'Search failed') + } + + return response.data.data! +} + +/** + * Get content for a specific chunk (MCP get_content tool) + */ +export const getContent = async (request: GetContentRequest): Promise => { + const response: AxiosResponse> = await apiClient.post( + '/get-content', + request + ) + + if (!response.data.success) { + throw new Error(response.data.message || 'Get content failed') + } + + return response.data.data! +} \ No newline at end of file diff --git a/admin-ui/src/lib/utils.ts b/admin-ui/src/lib/utils.ts new file mode 100644 index 0000000..07d053c --- /dev/null +++ b/admin-ui/src/lib/utils.ts @@ -0,0 +1,75 @@ +import { type ClassValue, clsx } from "clsx" +import { twMerge } from "tailwind-merge" + +/** + * Utility function to merge and optimize Tailwind CSS class names + */ +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + +/** + * Format file size to human readable format + */ +export function formatFileSize(bytes: number): string { + const sizes = ['B', 'KB', 'MB', 'GB'] + if (bytes === 0) return '0 B' + const i = Math.floor(Math.log(bytes) / Math.log(1024)) + return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i] +} + +/** + * Format date to relative time (e.g., "2 hours ago") + */ +export function formatRelativeTime(date: Date | string): string { + const now = new Date() + const targetDate = new Date(date) + const diff = now.getTime() - targetDate.getTime() + + const seconds = Math.floor(diff / 1000) + const minutes = Math.floor(seconds / 60) + const hours = Math.floor(minutes / 60) + const days = Math.floor(hours / 24) + + if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago` + if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago` + if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago` + return 'Just now' +} + +/** + * Truncate text with ellipsis + */ +export function truncateText(text: string, length: number): string { + return text.length > length ? text.substring(0, length) + '...' : text +} + +/** + * Get status color based on ingestion status + */ +export function getStatusColor(status: string): string { + switch (status.toUpperCase()) { + case 'COMPLETED': + return 'text-success-600 bg-success-50' + case 'ERROR': + return 'text-error-600 bg-error-50' + case 'PENDING': + return 'text-warning-600 bg-warning-50' + case 'PARSING': + case 'CHUNKING': + case 'EMBEDDING': + case 'INDEXING': + return 'text-primary-600 bg-primary-50' + case 'DELETING': + return 'text-secondary-600 bg-secondary-50' + default: + return 'text-secondary-600 bg-secondary-50' + } +} + +/** + * Sleep utility for testing and demos + */ +export function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} \ No newline at end of file diff --git a/admin-ui/src/main.tsx b/admin-ui/src/main.tsx new file mode 100644 index 0000000..5a11336 --- /dev/null +++ b/admin-ui/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './output.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/admin-ui/src/output.css b/admin-ui/src/output.css new file mode 100644 index 0000000..9a4ae72 --- /dev/null +++ b/admin-ui/src/output.css @@ -0,0 +1,3 @@ +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap");*,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } + +/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-y-0{top:0;bottom:0}.left-0{left:0}.right-2{right:.5rem}.top-1\/2{top:50%}.z-30{z-index:30}.mx-auto{margin-left:auto;margin-right:auto}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-64{margin-left:16rem}.mr-2{margin-right:.5rem}.mr-3{margin-right:.75rem}.mt-1{margin-top:.25rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-11{height:2.75rem}.h-12{height:3rem}.h-16{height:4rem}.h-2{height:.5rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-auto{height:auto}.h-full{height:100%}.min-h-screen{min-height:100vh}.w-10{width:2.5rem}.w-2{width:.5rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-64{width:16rem}.w-8{width:2rem}.w-full{width:100%}.min-w-0{min-width:0}.max-w-7xl{max-width:80rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.caption-bottom{caption-side:bottom}.-translate-y-1\/2{--tw-translate-y:-50%}.-translate-y-1\/2,.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes spin{to{transform:rotate(1turn)}}.animate-spin{animation:spin 1s linear infinite}.cursor-not-allowed{cursor:not-allowed}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.75rem*var(--tw-space-x-reverse));margin-left:calc(.75rem*(1 - var(--tw-space-x-reverse)))}.space-x-4>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(1rem*var(--tw-space-x-reverse));margin-left:calc(1rem*(1 - var(--tw-space-x-reverse)))}.space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(0px*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(0px*var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.375rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.space-y-8>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2rem*var(--tw-space-y-reverse))}.overflow-auto{overflow:auto}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-r-2{border-right-width:2px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-blue-700{--tw-border-opacity:1;border-color:rgb(29 78 216/var(--tw-border-opacity,1))}.border-current{border-color:currentColor}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-green-200{--tw-border-opacity:1;border-color:rgb(187 247 208/var(--tw-border-opacity,1))}.border-red-200{--tw-border-opacity:1;border-color:rgb(254 202 202/var(--tw-border-opacity,1))}.border-red-500{--tw-border-opacity:1;border-color:rgb(239 68 68/var(--tw-border-opacity,1))}.border-transparent{border-color:transparent}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-600{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-yellow-500{--tw-bg-opacity:1;background-color:rgb(234 179 8/var(--tw-bg-opacity,1))}.p-0{padding:0}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-2{padding-bottom:.5rem}.pb-4{padding-bottom:1rem}.pr-10{padding-right:2.5rem}.pt-0{padding-top:0}.pt-4{padding-top:1rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.leading-none{line-height:1}.tracking-tight{letter-spacing:-.025em}.text-blue-500{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity,1))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-green-800{--tw-text-opacity:1;color:rgb(22 101 52/var(--tw-text-opacity,1))}.text-purple-500{--tw-text-opacity:1;color:rgb(168 85 247/var(--tw-text-opacity,1))}.text-purple-600{--tw-text-opacity:1;color:rgb(147 51 234/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-800{--tw-text-opacity:1;color:rgb(153 27 27/var(--tw-text-opacity,1))}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-yellow-500{--tw-text-opacity:1;color:rgb(234 179 8/var(--tw-text-opacity,1))}.text-yellow-600{--tw-text-opacity:1;color:rgb(202 138 4/var(--tw-text-opacity,1))}.opacity-25{opacity:.25}.opacity-50{opacity:.5}.opacity-75{opacity:.75}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-lg,.shadow-sm{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 2px 0 rgba(0,0,0,.05);--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color)}.outline{outline-style:solid}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.test-tailwind{border-radius:.5rem;--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1));padding:1rem;--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.file\:border-0::file-selector-button{border-width:0}.file\:bg-transparent::file-selector-button{background-color:transparent}.file\:text-sm::file-selector-button{font-size:.875rem;line-height:1.25rem}.file\:font-medium::file-selector-button{font-weight:500}.placeholder\:text-gray-500::-moz-placeholder{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.placeholder\:text-gray-500::placeholder{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.hover\:border-gray-400:hover{--tw-border-opacity:1;border-color:rgb(156 163 175/var(--tw-border-opacity,1))}.hover\:bg-blue-600:hover{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.hover\:bg-blue-700:hover{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.hover\:bg-gray-100:hover{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.hover\:bg-gray-200:hover{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.hover\:bg-gray-50:hover{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.hover\:bg-green-600:hover{--tw-bg-opacity:1;background-color:rgb(22 163 74/var(--tw-bg-opacity,1))}.hover\:bg-red-600:hover{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.hover\:bg-red-700:hover{--tw-bg-opacity:1;background-color:rgb(185 28 28/var(--tw-bg-opacity,1))}.hover\:bg-yellow-600:hover{--tw-bg-opacity:1;background-color:rgb(202 138 4/var(--tw-bg-opacity,1))}.hover\:text-gray-600:hover{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.hover\:text-gray-900:hover{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-blue-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}.focus\:ring-gray-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(107 114 128/var(--tw-ring-opacity,1))}.focus\:ring-red-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(239 68 68/var(--tw-ring-opacity,1))}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px}.focus-visible\:outline-none:focus-visible{outline:2px solid transparent;outline-offset:2px}.focus-visible\:ring-2:focus-visible{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus-visible\:ring-blue-500:focus-visible{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}.focus-visible\:ring-red-500:focus-visible{--tw-ring-opacity:1;--tw-ring-color:rgb(239 68 68/var(--tw-ring-opacity,1))}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width:2px}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.peer:disabled~.peer-disabled\:cursor-not-allowed{cursor:not-allowed}.peer:disabled~.peer-disabled\:opacity-70{opacity:.7}.data-\[state\=selected\]\:bg-gray-100[data-state=selected]{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}@media (min-width:640px){.sm\:px-6{padding-left:1.5rem;padding-right:1.5rem}}@media (min-width:768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:1024px){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:px-8{padding-left:2rem;padding-right:2rem}}.\[\&\:has\(\[role\=checkbox\]\)\]\:pr-0:has([role=checkbox]){padding-right:0}.\[\&_tr\:last-child\]\:border-0 tr:last-child{border-width:0}.\[\&_tr\]\:border-b tr{border-bottom-width:1px} \ No newline at end of file diff --git a/admin-ui/src/pages/Dashboard.tsx b/admin-ui/src/pages/Dashboard.tsx new file mode 100644 index 0000000..9f0eb8b --- /dev/null +++ b/admin-ui/src/pages/Dashboard.tsx @@ -0,0 +1,276 @@ +import React from 'react' +import { useQuery } from '@tanstack/react-query' +import { Card, CardHeader, CardTitle, CardContent, CardFooter } from '../components/ui/Card' +import { Badge } from '../components/ui/Badge' +import { Button } from '../components/ui/Button' +import { useAuthStore } from '../store/authStore' +import { getDocuments } from '../lib/api' +import { formatFileSize, formatRelativeTime } from '../lib/utils' +import { + FileText, + CheckCircle, + AlertCircle, + Clock, + Server, + Search +} from 'lucide-react' + +export const Dashboard: React.FC = () => { + const { apiKey } = useAuthStore() + + // Fetch all documents to calculate statistics + const { data: documentsPage, isLoading } = useQuery({ + queryKey: ['dashboard-documents', apiKey], + queryFn: () => getDocuments({ + page: 0, + size: 1000, // Get all documents for stats + sort: 'createdAt,desc', + apiKey: apiKey! + }), + enabled: !!apiKey, + refetchInterval: 5000, // Refetch every 5 seconds + }) + + // Calculate statistics from real data + const stats = React.useMemo(() => { + if (!documentsPage?.content) { + return { + totalDocuments: 0, + completedDocuments: 0, + processingDocuments: 0, + errorDocuments: 0 + } + } + + const documents = documentsPage.content + const totalDocuments = documents.length + const completedDocuments = documents.filter(doc => doc.ingestionStatus === 'COMPLETED').length + const processingDocuments = documents.filter(doc => + ['PENDING', 'PARSING', 'CHUNKING', 'EMBEDDING', 'INDEXING'].includes(doc.ingestionStatus) + ).length + const errorDocuments = documents.filter(doc => doc.ingestionStatus === 'ERROR').length + + return { + totalDocuments, + completedDocuments, + processingDocuments, + errorDocuments + } + }, [documentsPage?.content]) + + // Get recent documents (latest 3) + const recentDocuments = React.useMemo(() => { + if (!documentsPage?.content) return [] + + return documentsPage.content + .slice(0, 3) + .map(doc => ({ + id: doc.id, + filename: doc.originalFilename, + status: doc.ingestionStatus, + createdAt: formatRelativeTime(new Date(doc.createdAt)), + size: formatFileSize(doc.fileSize) + })) + }, [documentsPage?.content]) + + const getStatusColor = (status: string) => { + const colors = { + COMPLETED: 'success', + PENDING: 'warning', + PARSING: 'warning', + CHUNKING: 'warning', + EMBEDDING: 'warning', + INDEXING: 'warning', + ERROR: 'destructive' + } as const + + return colors[status as keyof typeof colors] || 'default' + } + + // Show message if no API key is configured + if (!apiKey) { + return ( +
+
+

Dashboard

+

+ Overview of your OpenContext knowledge base +

+
+ + + Configuration Required + + +

+ Please configure your API key in the Settings page to view dashboard statistics. +

+
+ + + +
+
+ ) + } + + return ( +
+ {/* Page Header */} +
+

Dashboard

+

+ Overview of your OpenContext knowledge base + {isLoading && (Loading...)} +

+
+ + {/* Stats Grid */} +
+ + + + Total Documents + + + + +
{stats.totalDocuments}
+

+ Total uploaded documents +

+
+
+ + + + + Completed + + + + +
+ {stats.completedDocuments} +
+

+ {stats.totalDocuments > 0 + ? `${Math.round((stats.completedDocuments / stats.totalDocuments) * 100)}% success rate` + : 'No documents yet' + } +

+
+
+ + + + + Processing + + + + +
+ {stats.processingDocuments} +
+

+ Currently being processed +

+
+
+ + + + + Errors + + + + +
+ {stats.errorDocuments} +
+

+ Need attention +

+
+
+
+ + {/* Recent Activity */} + + + Recent Documents + + +
+ {recentDocuments.map((doc) => ( +
+
+ +
+

{doc.filename}

+

{doc.size} • {doc.createdAt}

+
+
+ + {doc.status} + +
+ ))} +
+
+ + + +
+ + {/* Quick Actions */} + + + Quick Actions + + +
+ + + + + +
+
+
+
+ ) +} \ No newline at end of file diff --git a/admin-ui/src/pages/DocumentManager.tsx b/admin-ui/src/pages/DocumentManager.tsx new file mode 100644 index 0000000..46d6752 --- /dev/null +++ b/admin-ui/src/pages/DocumentManager.tsx @@ -0,0 +1,323 @@ +import React, { useState, useCallback } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { Button } from '../components/ui/Button' +import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/Card' +import { FileUpload } from '../components/ui/FileUpload' +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../components/ui/Table' +import { Badge } from '../components/ui/Badge' +import { Input } from '../components/ui/Input' +import { useAuthStore } from '../store/authStore' +import { uploadFile, getDocuments, deleteDocument, resyncDocument } from '../lib/api' +import { formatFileSize, formatRelativeTime, getStatusColor } from '../lib/utils' +import type { SourceDocumentDto, UploadFileRequest } from '../types/api' + +export const DocumentManager: React.FC = () => { + const { apiKey } = useAuthStore() + const queryClient = useQueryClient() + + // Local state + const [selectedFile, setSelectedFile] = useState(null) + const [currentPage, setCurrentPage] = useState(0) + const [pageSize] = useState(20) + const [searchQuery, setSearchQuery] = useState('') + + // Queries + const { + data: documentsPage, + isLoading: isLoadingDocuments, + error: documentsError, + refetch: refetchDocuments + } = useQuery({ + queryKey: ['documents', currentPage, pageSize, apiKey], + queryFn: () => getDocuments({ + page: currentPage, + size: pageSize, + sort: 'createdAt,desc', + apiKey: apiKey! + }), + enabled: !!apiKey, + refetchInterval: 5000, // Refetch every 5 seconds to update status + }) + + // Mutations + const uploadMutation = useMutation({ + mutationFn: (request: UploadFileRequest) => uploadFile(request), + onSuccess: () => { + setSelectedFile(null) + queryClient.invalidateQueries({ queryKey: ['documents'] }) + }, + onError: (error) => { + console.error('Upload failed:', error) + } + }) + + const deleteMutation = useMutation({ + mutationFn: ({ sourceId }: { sourceId: string }) => + deleteDocument({ sourceId, apiKey: apiKey! }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['documents'] }) + }, + onError: (error) => { + console.error('Delete failed:', error) + } + }) + + const resyncMutation = useMutation({ + mutationFn: ({ sourceId }: { sourceId: string }) => + resyncDocument({ sourceId, apiKey: apiKey! }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['documents'] }) + }, + onError: (error) => { + console.error('Resync failed:', error) + } + }) + + // Event handlers + const handleFileSelect = useCallback((file: File) => { + setSelectedFile(file) + }, []) + + const handleClearFile = useCallback(() => { + setSelectedFile(null) + }, []) + + const handleUpload = useCallback(() => { + if (!selectedFile || !apiKey) return + + uploadMutation.mutate({ + file: selectedFile, + apiKey + }) + }, [selectedFile, apiKey, uploadMutation]) + + const handleDelete = useCallback((sourceId: string) => { + if (!confirm('Are you sure you want to delete this document? This action cannot be undone.')) { + return + } + deleteMutation.mutate({ sourceId }) + }, [deleteMutation]) + + const handleResync = useCallback((sourceId: string) => { + resyncMutation.mutate({ sourceId }) + }, [resyncMutation]) + + const handleRefresh = useCallback(() => { + refetchDocuments() + }, [refetchDocuments]) + + const handlePageChange = useCallback((newPage: number) => { + setCurrentPage(newPage) + }, []) + + // Filter documents based on search query + const filteredDocuments = documentsPage?.content.filter(doc => + doc.originalFilename.toLowerCase().includes(searchQuery.toLowerCase()) + ) || [] + + // Check if API key is available + if (!apiKey) { + return ( +
+ + + Authentication Required + + +

+ Please configure your API key in the Settings page to access document management features. +

+
+
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+

Document Manager

+

+ Upload and manage your knowledge documents. Monitor ingestion status and troubleshoot issues. +

+
+ +
+ + {/* File Upload Section */} + + + Upload New Document + + + + + {selectedFile && ( +
+
+ {selectedFile.name} + ({formatFileSize(selectedFile.size)}) +
+ +
+ )} + + {uploadMutation.error && ( +
+ Upload failed: {uploadMutation.error.message} +
+ )} +
+
+ + {/* Document List Section */} + + +
+ Documents ({documentsPage?.totalElements || 0}) +
+ setSearchQuery(e.target.value)} + /> +
+
+
+ + {documentsError && ( +
+ Failed to load documents: {documentsError.message} +
+ )} + + + + + Filename + Type + Size + Status + Last Updated + Actions + + + + {isLoadingDocuments ? ( + + + Loading documents... + + + ) : filteredDocuments.length === 0 ? ( + + + {searchQuery ? 'No documents match your search.' : 'No documents uploaded yet.'} + + + ) : ( + filteredDocuments.map((doc: SourceDocumentDto) => ( + + +
+
+ {doc.originalFilename} +
+ {doc.errorMessage && ( +
+ Error: {doc.errorMessage} +
+ )} +
+
+ {doc.fileType} + {formatFileSize(doc.fileSize)} + + + {doc.ingestionStatus} + + + +
+
{formatRelativeTime(new Date(doc.updatedAt))}
+ {doc.lastIngestedAt && ( +
+ Completed: {formatRelativeTime(new Date(doc.lastIngestedAt))} +
+ )} +
+
+ +
+ + +
+
+
+ )) + )} +
+
+ + {/* Pagination */} + {documentsPage && documentsPage.totalPages > 1 && ( +
+
+ Showing {currentPage * pageSize + 1} to{' '} + {Math.min((currentPage + 1) * pageSize, documentsPage.totalElements)} of{' '} + {documentsPage.totalElements} documents +
+
+ + +
+
+ )} +
+
+
+ ) +} \ No newline at end of file diff --git a/admin-ui/src/pages/Search.tsx b/admin-ui/src/pages/Search.tsx new file mode 100644 index 0000000..1d6dbb5 --- /dev/null +++ b/admin-ui/src/pages/Search.tsx @@ -0,0 +1,321 @@ +import React, { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/Card' +import { Button } from '../components/ui/Button' +import { Input } from '../components/ui/Input' +import { Badge } from '../components/ui/Badge' +import { searchKnowledge, getContent } from '../lib/api' +import { formatFileSize } from '../lib/utils' +import { + Search as SearchIcon, + FileText, + ChevronRight, + Copy, + Loader2 +} from 'lucide-react' +import type { SearchResultsResponse, GetContentResponse } from '../types/api' + +export const Search: React.FC = () => { + const [searchQuery, setSearchQuery] = useState('') + const [topK, setTopK] = useState(10) + const [maxTokens, setMaxTokens] = useState(8000) + const [selectedChunk, setSelectedChunk] = useState(null) + const [searchResults, setSearchResults] = useState(null) + const [contentData, setContentData] = useState(null) + const [isSearching, setIsSearching] = useState(false) + const [isLoadingContent, setIsLoadingContent] = useState(false) + const [searchError, setSearchError] = useState(null) + const [contentError, setContentError] = useState(null) + + // Handle knowledge search (find_knowledge MCP tool) + const handleSearch = async () => { + if (!searchQuery.trim()) { + setSearchError('Please enter a search query') + return + } + + setIsSearching(true) + setSearchError(null) + setSearchResults(null) + setSelectedChunk(null) + setContentData(null) + + try { + const results = await searchKnowledge(searchQuery, topK) + setSearchResults(results) + } catch (error) { + setSearchError(error instanceof Error ? error.message : 'Search failed') + } finally { + setIsSearching(false) + } + } + + // Handle content retrieval (get_content MCP tool) + const handleGetContent = async (chunkId: string) => { + setIsLoadingContent(true) + setContentError(null) + setSelectedChunk(chunkId) + + try { + const content = await getContent({ chunkId, maxTokens }) + setContentData(content) + } catch (error) { + setContentError(error instanceof Error ? error.message : 'Failed to get content') + setSelectedChunk(null) + } finally { + setIsLoadingContent(false) + } + } + + // Copy content to clipboard + const handleCopyContent = async (text: string) => { + try { + await navigator.clipboard.writeText(text) + // TODO: Show toast notification + } catch (error) { + console.error('Failed to copy content:', error) + } + } + + // Clear all results + const handleClear = () => { + setSearchQuery('') + setSearchResults(null) + setSelectedChunk(null) + setContentData(null) + setSearchError(null) + setContentError(null) + } + + return ( +
+ {/* Header */} +
+

Search Interface

+

+ Test the MCP search tools: find_knowledge and get_content. Search your knowledge base and retrieve detailed content. +

+
+ + {/* Search Section */} + + + + + Knowledge Search (find_knowledge) + + + +
+
+ setSearchQuery(e.target.value)} + onKeyPress={(e) => e.key === 'Enter' && handleSearch()} + /> +
+
+ setTopK(parseInt(e.target.value) || 10)} + min={1} + max={100} + className="w-24" + /> + +
+
+ + {searchError && ( +
+ Error: {searchError} +
+ )} + +
+
+ {searchResults && ( + Found {searchResults.results.length} results + )} +
+ {(searchResults || searchError) && ( + + )} +
+
+
+ + {/* Search Results */} + {searchResults && ( + + + Search Results + + +
+ {searchResults.results.map((result, index) => ( +
handleGetContent(result.chunkId)} + > +
+
+
+ #{index + 1} + + Score: {(result.relevanceScore * 100).toFixed(1)}% + +
+ +

+ {result.title || 'Untitled'} +

+ + {result.snippet && ( +

+ {result.snippet} +

+ )} + +
+ Chunk ID: {result.chunkId} +
+
+ + +
+
+ ))} +
+
+
+ )} + + {/* Content Viewer */} + {(selectedChunk || contentData || contentError) && ( + + + +
+ + Content Viewer (get_content) +
+
+ setMaxTokens(parseInt(e.target.value) || 8000)} + min={100} + max={25000} + className="w-32 text-sm" + /> + {contentData && ( + + )} +
+
+
+ + {isLoadingContent && ( +
+ + Loading content... +
+ )} + + {contentError && ( +
+ Error: {contentError} +
+ )} + + {contentData && ( +
+ {/* Content Metadata */} +
+
+
+ Chunk ID: +
+ {selectedChunk} +
+
+
+ Tokens: +
+ {contentData.tokenInfo.actualTokens} / {maxTokens} +
+
+
+
+ + {/* Content Text */} +
+
+
+                      {contentData.content}
+                    
+
+
+ + {/* Content Actions */} +
+
+ Content length: {contentData.content.length} characters +
+
+
+ )} +
+
+ )} + + {/* Usage Instructions */} + + + How to Use + + +

1. Search Knowledge: Enter a query and click Search to find relevant chunks using the find_knowledge MCP tool.

+

2. View Content: Click on any search result to retrieve the full content using the get_content MCP tool.

+

3. Adjust Parameters: Modify the number of results (topK) and maximum tokens for content retrieval.

+

4. Copy Content: Use the Copy button to copy content to your clipboard for further use.

+
+
+
+ ) +} \ No newline at end of file diff --git a/admin-ui/src/pages/Settings.tsx b/admin-ui/src/pages/Settings.tsx new file mode 100644 index 0000000..00440f5 --- /dev/null +++ b/admin-ui/src/pages/Settings.tsx @@ -0,0 +1,220 @@ +import React, { useState } from 'react' +import { Button } from '../components/ui/Button' +import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/Card' +import { Input } from '../components/ui/Input' +import { useAuthStore } from '../store/authStore' +import { Key, Save, Trash2, Eye, EyeOff, CheckCircle, AlertCircle } from 'lucide-react' + +export const Settings: React.FC = () => { + const { apiKey, setApiKey, clearAuth } = useAuthStore() + const [inputApiKey, setInputApiKey] = useState(apiKey || '') + const [showApiKey, setShowApiKey] = useState(false) + const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null) + + const handleSave = () => { + try { + if (!inputApiKey.trim()) { + setSaveMessage({ type: 'error', text: 'API key cannot be empty' }) + return + } + + setApiKey(inputApiKey.trim()) + setSaveMessage({ type: 'success', text: 'API key saved successfully' }) + + // Clear message after 3 seconds + setTimeout(() => setSaveMessage(null), 3000) + } catch (error) { + setSaveMessage({ type: 'error', text: 'Failed to save API key' }) + setTimeout(() => setSaveMessage(null), 3000) + } + } + + const handleClear = () => { + if (confirm('Are you sure you want to remove the API key? You will need to re-enter it to access document management features.')) { + clearAuth() + setInputApiKey('') + setSaveMessage({ type: 'success', text: 'API key cleared successfully' }) + setTimeout(() => setSaveMessage(null), 3000) + } + } + + const handleTestConnection = async () => { + if (!inputApiKey.trim()) { + setSaveMessage({ type: 'error', text: 'Please enter an API key first' }) + return + } + + try { + // Test API connection by making a simple request to the correct backend URL + const response = await fetch('http://localhost:8080/api/v1/sources?page=0&size=1', { + headers: { + 'X-API-KEY': inputApiKey.trim(), + 'Accept': 'application/json' + } + }) + + if (response.ok) { + setSaveMessage({ type: 'success', text: 'API key is valid and connection successful' }) + } else if (response.status === 403) { + setSaveMessage({ type: 'error', text: 'Invalid API key or insufficient permissions' }) + } else { + setSaveMessage({ type: 'error', text: 'Connection failed - please check your API key' }) + } + } catch (error) { + setSaveMessage({ type: 'error', text: 'Failed to connect to API server' }) + } + + setTimeout(() => setSaveMessage(null), 5000) + } + + return ( +
+ {/* Header */} +
+

Settings

+

+ Configure your OpenContext admin interface preferences and API access. +

+
+ + {/* API Key Configuration */} + + +
+ + API Key Configuration +
+
+ +
+

+ Enter your OpenContext API key to access document management features. + This key is stored locally in your browser and is required for uploading, + viewing, and managing documents. +

+ +
+
+
+ setInputApiKey(e.target.value)} + className="pr-10" + /> + +
+
+ + {/* Action Buttons */} +
+ + + + + {apiKey && ( + + )} +
+ + {/* Status Message */} + {saveMessage && ( +
+ {saveMessage.type === 'success' ? ( + + ) : ( + + )} + {saveMessage.text} +
+ )} +
+
+ + {/* Current Status */} +
+
+ Current Status: +
+ {apiKey ? ( + <> +
+ API Key Configured + + ) : ( + <> +
+ No API Key Set + + )} +
+
+
+
+
+ + {/* System Information */} + + + System Information + + +
+
+ Application Version: + 1.0.0 +
+
+ Backend API: + {import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080/api/v1'} +
+
+ Deployment: + Self-Hosted Instance +
+
+ Storage: + Browser LocalStorage +
+
+
+
+ + {/* Usage Guidelines */} + + + API Key Guidelines + + +
+

• Your API key is stored securely in your browser's local storage

+

• The API key is required for all document management operations

+

• Keep your API key confidential and do not share it with others

+

• If you suspect your API key has been compromised, generate a new one immediately

+

• The API key will persist across browser sessions until manually cleared

+
+
+
+
+ ) +} \ No newline at end of file diff --git a/admin-ui/src/store/authStore.ts b/admin-ui/src/store/authStore.ts new file mode 100644 index 0000000..299305b --- /dev/null +++ b/admin-ui/src/store/authStore.ts @@ -0,0 +1,43 @@ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' + +interface AuthState { + apiKey: string | null + isAuthenticated: boolean + setApiKey: (apiKey: string) => void + clearAuth: () => void +} + +/** + * Authentication store using Zustand + * Persists API key in localStorage for convenience + */ +export const useAuthStore = create()( + persist( + (set) => ({ + apiKey: null, + isAuthenticated: false, + + setApiKey: (apiKey: string) => { + set({ + apiKey, + isAuthenticated: apiKey.length > 0, + }) + }, + + clearAuth: () => { + set({ + apiKey: null, + isAuthenticated: false, + }) + }, + }), + { + name: 'auth-storage', + partialize: (state) => ({ + apiKey: state.apiKey, + isAuthenticated: state.isAuthenticated, + }), + } + ) +) \ No newline at end of file diff --git a/admin-ui/src/types/api.ts b/admin-ui/src/types/api.ts new file mode 100644 index 0000000..fe08dd5 --- /dev/null +++ b/admin-ui/src/types/api.ts @@ -0,0 +1,109 @@ +/** + * API Types for OpenContext Admin UI + * These types match the backend DTOs and API specifications + */ + +export interface CommonResponse { + success: boolean + data: T | null + message: string + errorCode: string | null + timestamp: string +} + +export interface PageResponse { + content: T[] + page: number + size: number + totalElements: number + totalPages: number + first: boolean + last: boolean + hasNext: boolean + hasPrevious: boolean +} + +export interface SourceDocumentDto { + id: string + originalFilename: string + fileType: 'PDF' | 'MARKDOWN' | 'TXT' + fileSize: number + ingestionStatus: IngestionStatus + errorMessage: string | null + lastIngestedAt: string | null + createdAt: string + updatedAt: string +} + +export interface SourceUploadResponse { + sourceDocumentId: string + originalFilename: string + ingestionStatus: IngestionStatus + message: string +} + +export type IngestionStatus = + | 'PENDING' + | 'PARSING' + | 'CHUNKING' + | 'EMBEDDING' + | 'INDEXING' + | 'COMPLETED' + | 'ERROR' + | 'DELETING' + +export interface SearchResultItem { + chunkId: string + title: string | null + snippet: string | null + relevanceScore: number + breadcrumbs?: string[] +} + +export interface SearchResultsResponse { + results: SearchResultItem[] +} + +export interface GetContentRequest { + chunkId: string + maxTokens?: number +} + +export interface GetContentResponse { + content: string + tokenInfo: { + tokenizer: string + actualTokens: number + } +} + +export interface ApiError { + success: false + data: null + message: string + errorCode: string + timestamp: string +} + +// API Request/Response interfaces for React Query +export interface UploadFileRequest { + file: File + apiKey: string +} + +export interface GetDocumentsRequest { + page?: number + size?: number + sort?: string + apiKey: string +} + +export interface DeleteDocumentRequest { + sourceId: string + apiKey: string +} + +export interface ResyncDocumentRequest { + sourceId: string + apiKey: string +} \ No newline at end of file diff --git a/admin-ui/src/vite-env.d.ts b/admin-ui/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/admin-ui/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/admin-ui/tailwind.config.js b/admin-ui/tailwind.config.js new file mode 100644 index 0000000..d37737f --- /dev/null +++ b/admin-ui/tailwind.config.js @@ -0,0 +1,12 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: {}, + }, + plugins: [], +} + diff --git a/admin-ui/tsconfig.app.json b/admin-ui/tsconfig.app.json new file mode 100644 index 0000000..227a6c6 --- /dev/null +++ b/admin-ui/tsconfig.app.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/admin-ui/tsconfig.json b/admin-ui/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/admin-ui/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/admin-ui/tsconfig.node.json b/admin-ui/tsconfig.node.json new file mode 100644 index 0000000..f85a399 --- /dev/null +++ b/admin-ui/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/admin-ui/vite.config.ts b/admin-ui/vite.config.ts new file mode 100644 index 0000000..8b0f57b --- /dev/null +++ b/admin-ui/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], +}) diff --git a/core/.gitignore b/core/.gitignore new file mode 100644 index 0000000..8252081 --- /dev/null +++ b/core/.gitignore @@ -0,0 +1,65 @@ +# Compiled class files +*.class + +# Log files +*.log + +# Package Files +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# Gradle +.gradle +**/build/ +!src/**/build/ +gradle-app.setting +!gradle-wrapper.jar +!gradle-wrapper.properties + +# IntelliJ IDEA +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +# Eclipse +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +# Spring Boot +*.jar.original +HELP.md + +# Application specific +application-local.yml +application-*.yml +!application.yml +!application-docker.yml + +# Test coverage reports +/coverage +/.nyc_output + +# QueryDSL generated files +**/generated/ + +# Temporary files +*.tmp +*.bak +*.swp \ No newline at end of file diff --git a/core/README.md b/core/README.md new file mode 100644 index 0000000..e6ca3c7 --- /dev/null +++ b/core/README.md @@ -0,0 +1,404 @@ +# OpenContext Core Backend + +[![Java](https://img.shields.io/badge/Java-21-orange.svg)](https://openjdk.org/projects/jdk/21/) +[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.3.11-brightgreen.svg)](https://spring.io/projects/spring-boot) +[![LangChain4j](https://img.shields.io/badge/LangChain4j-0.36.2-blue.svg)](https://github.com/langchain4j/langchain4j) + +**Enterprise-grade RAG backend service built with Spring Boot and LangChain4j** + +This is the core backend service of OpenContext, implementing document processing pipelines, hybrid search capabilities, and MCP protocol APIs for secure, self-hosted RAG systems. + +## Architecture Overview + +The core service follows a layered architecture with clear separation of concerns: + +``` +com.opencontext/ +├── config/ # Spring configuration classes +├── common/ # Common response objects and utilities +├── controller/ # REST API endpoints +├── service/ # Business logic layer +├── repository/ # Data access layer with QueryDSL +├── entity/ # JPA entities +├── dto/ # Data transfer objects +├── enums/ # Enum definitions (IngestionStatus, ErrorCode) +├── exception/ # Custom exceptions and global handlers +└── pipeline/ # Document processing pipeline components +``` + +## Key Components + +### Document Processing Pipeline +- **DocumentParsingService**: Integrates with Unstructured.io for PDF/Markdown parsing +- **ChunkingService**: Hierarchical text chunking with LangChain4j +- **EmbeddingService**: Ollama API integration for text embeddings +- **IndexingService**: Elasticsearch indexing with hybrid search setup + +### Search & Retrieval Engine +- **SearchService**: Hybrid BM25 + vector search with Korean language support +- **ContentRetrievalService**: Chunk content retrieval with token limit handling +- **SearchController**: MCP protocol API endpoints (`/search`, `/get-content`) + +### Data Layer +- **PostgreSQL**: Source document metadata and chunk hierarchy storage +- **Elasticsearch**: Document search index with Korean Nori analyzer +- **MinIO**: Original file storage with S3-compatible API + +## Technology Stack + +### Core Framework +- **Java 21**: Latest LTS with virtual thread support +- **Spring Boot 3.3.11**: Main application framework +- **Spring Security**: API key authentication +- **Spring Data JPA + Hibernate 6.x**: ORM with PostgreSQL +- **QueryDSL 5.x**: Type-safe query construction + +### RAG Pipeline +- **LangChain4j 0.36.2**: RAG pipeline orchestration framework +- **Ollama**: Local embedding model serving (Qwen3-Embedding-0.6B) +- **Elasticsearch 8.x**: Hybrid search engine with vector capabilities +- **Unstructured.io**: Document parsing service (Docker API) + +### Testing & Quality +- **JUnit 5**: Unit testing framework +- **TestContainers**: Integration testing with real databases +- **Mockito**: Mocking framework +- **Flyway**: Database migration management + +## API Endpoints + +### MCP Protocol APIs (No Authentication) +These endpoints are used by the MCP adapter for AI assistant integration: + +```http +# Phase 1: Exploratory search +GET /api/v1/search?query={query}&topK={count} + +# Phase 2: Content retrieval +POST /api/v1/get-content +Content-Type: application/json +{ + "chunkId": "uuid-string", + "maxTokens": 25000 +} +``` + +### Administrative APIs (API Key Required) +Document management endpoints requiring `X-API-KEY` header: + +```http +# Upload documents +POST /api/v1/sources/upload +X-API-KEY: your-api-key +Content-Type: multipart/form-data + +# List documents with pagination +GET /api/v1/sources?page=0&size=20&sort=createdAt,desc +X-API-KEY: your-api-key + +# Trigger document reprocessing +POST /api/v1/sources/{id}/resync +X-API-KEY: your-api-key + +# Delete document +DELETE /api/v1/sources/{id} +X-API-KEY: your-api-key +``` + +## Configuration + +### Database Configuration +```yaml +# application.yml +spring: + datasource: + url: jdbc:postgresql://localhost:5432/opencontext + username: user + password: password + jpa: + hibernate: + ddl-auto: validate # Production: validate only + show-sql: false + flyway: + baseline-on-migrate: true +``` + +### Elasticsearch Configuration +```yaml +app: + elasticsearch: + url: http://localhost:9200 + index: document_chunks_index +``` + +### Embedding Service Configuration +```yaml +app: + ollama: + api: + url: http://localhost:11434 + embedding: + model: dengcao/Qwen3-Embedding-0.6B:F16 + embedding: + batch-size: 10 +``` + +### API Security +```yaml +opencontext: + api: + key: dev-api-key-123 # Change for production +``` + +## Database Schema + +### Source Documents Table +Stores original document metadata and processing status: + +```sql +CREATE TABLE source_documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + original_filename VARCHAR(255) NOT NULL, + file_storage_path VARCHAR(1024) NOT NULL, + file_type VARCHAR(50) NOT NULL, + file_size BIGINT NOT NULL, + file_checksum VARCHAR(64) NOT NULL UNIQUE, + ingestion_status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + error_message TEXT, + last_ingested_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +``` + +### Document Chunks Table +Maintains hierarchical chunk structure and relationships: + +```sql +CREATE TABLE document_chunks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + source_document_id UUID NOT NULL REFERENCES source_documents(id) ON DELETE CASCADE, + parent_chunk_id UUID REFERENCES document_chunks(id) ON DELETE CASCADE, + sequence_in_document INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +``` + +### Elasticsearch Index Mapping +Document chunks are stored in Elasticsearch with the following structure: + +```json +{ + "mappings": { + "properties": { + "chunkId": { "type": "keyword" }, + "content": { + "type": "text", + "analyzer": "korean_nori" + }, + "embedding": { + "type": "dense_vector", + "dims": 1024, + "index": "true", + "similarity": "cosine" + }, + "metadata": { + "properties": { + "title": { "type": "text", "analyzer": "korean_nori" }, + "hierarchyLevel": { "type": "integer" }, + "originalFilename": { "type": "keyword" }, + "fileType": { "type": "keyword" } + } + } + } + } +} +``` + +## Development + +### Prerequisites +```bash +java --version # OpenJDK 21 +./gradlew --version # Gradle 8+ +``` + +### Local Development Setup +```bash +# Start dependencies with Docker Compose +docker compose up -d postgres elasticsearch ollama minio + +# Run application +./gradlew bootRun + +# Run with specific profile +./gradlew bootRun --args='--spring.profiles.active=dev' +``` + +### Testing +```bash +# Unit tests +./gradlew test + +# Integration tests (requires TestContainers) +./gradlew integrationTest + +# Test with coverage +./gradlew jacocoTestReport + +# Build without tests +./gradlew build -x test +``` + +### Database Migrations +```bash +# Apply migrations +./gradlew flywayMigrate + +# Check migration status +./gradlew flywayInfo + +# Repair migration checksums (if needed) +./gradlew flywayRepair +``` + +## Monitoring & Operations + +### Health Checks +The application exposes Spring Boot Actuator endpoints: + +```bash +# Overall health status +curl http://localhost:8080/actuator/health + +# Database connectivity +curl http://localhost:8080/actuator/health/db + +# Elasticsearch connectivity +curl http://localhost:8080/actuator/health/elasticsearch + +# Application info +curl http://localhost:8080/actuator/info +``` + +### Logging Configuration +```yaml +# application.yml +logging: + level: + com.opencontext: DEBUG # Application logs + org.hibernate.SQL: DEBUG # SQL queries + org.hibernate.orm.jdbc.bind: TRACE # SQL parameters +``` + +### Performance Tuning +```yaml +# application.yml +spring: + datasource: + hikari: + maximum-pool-size: 10 + minimum-idle: 2 + connection-timeout: 20000 + +server: + tomcat: + threads: + max: 200 + min-spare: 10 +``` + +## Error Handling + +The application uses structured error responses with specific error codes: + +### Common Error Codes +- `VALIDATION_FAILED` (400): Request validation errors +- `INSUFFICIENT_PERMISSION` (403): Invalid API key +- `SOURCE_DOCUMENT_NOT_FOUND` (404): Document not found +- `CHUNK_NOT_FOUND` (404): Chunk not found +- `DUPLICATE_FILE_UPLOADED` (409): File already exists +- `CONTENT_NOT_AVAILABLE` (422): Chunk content unavailable +- `EXTERNAL_SERVICE_UNAVAILABLE` (503): Ollama/Elasticsearch connection error + +### Error Response Format +```json +{ + "success": false, + "data": null, + "message": "Human-readable error message", + "errorCode": "ERROR_CODE_ENUM_VALUE", + "timestamp": "2025-08-21T12:00:00Z" +} +``` + +## Security Considerations + +### API Key Authentication +- All administrative endpoints require `X-API-KEY` header +- Default development key: `dev-api-key-123` +- Generate production keys with: `openssl rand -hex 32` + +### Data Protection +- All data processing happens locally +- No external API calls for sensitive document content +- PostgreSQL stores only metadata and relationships +- Elasticsearch contains indexed content for search + +### Network Security +- MCP endpoints (`/search`, `/get-content`) have no authentication for internal use +- Administrative endpoints require API key authentication +- Configure reverse proxy (nginx) for HTTPS termination in production + +## Troubleshooting + +### Common Issues + +**Elasticsearch Connection Failed** +```bash +# Check Elasticsearch status +curl http://localhost:9200/_cluster/health + +# Verify Korean analyzer plugin +curl http://localhost:9200/_cat/plugins?v +``` + +**Ollama Model Not Found** +```bash +# Pull embedding model +docker compose exec ollama ollama pull dengcao/Qwen3-Embedding-0.6B:F16 + +# List available models +docker compose exec ollama ollama list +``` + +**Database Migration Issues** +```bash +# Check migration status +./gradlew flywayInfo + +# Repair checksums after manual changes +./gradlew flywayRepair +``` + +## Contributing + +### Code Style +- Follow Google Java Style Guide +- Use Lombok annotations for boilerplate reduction +- Prefer constructor injection over field injection +- Write comprehensive JavaDoc for public APIs + +### Testing Requirements +- Unit tests for all service classes +- Integration tests using TestContainers for repository classes +- API tests for all controller endpoints +- Minimum 80% code coverage required + +### Commit Guidelines +- Use conventional commits format +- Include issue numbers in commit messages +- Keep commits focused on single concerns +- Write clear, descriptive commit messages + +For more information, see the main [Contributing Guide](../CONTRIBUTING.md). \ No newline at end of file diff --git a/core/build.gradle b/core/build.gradle index 0eae4d0..bc97dcc 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -1,7 +1,7 @@ plugins { id 'java' - id 'org.springframework.boot' version '3.2.0' - id 'io.spring.dependency-management' version '1.1.4' + id 'org.springframework.boot' version '3.3.11' + id 'io.spring.dependency-management' version '1.1.6' } group = 'com.opencontext' @@ -32,16 +32,40 @@ dependencies { // Database runtimeOnly 'org.postgresql:postgresql' + + // Database Migration + implementation 'org.flywaydb:flyway-core' + runtimeOnly 'org.flywaydb:flyway-database-postgresql' + + // LangChain4j - Core RAG Framework + implementation 'dev.langchain4j:langchain4j:0.35.0' + implementation 'dev.langchain4j:langchain4j-spring-boot-starter:0.35.0' + implementation 'dev.langchain4j:langchain4j-document-parser-apache-pdfbox:0.35.0' + implementation 'dev.langchain4j:langchain4j-embeddings:0.35.0' + implementation 'dev.langchain4j:langchain4j-ollama:0.35.0' + + // QueryDSL (using OpenFeign fork for security patches) + implementation 'io.github.openfeign.querydsl:querydsl-jpa:6.11' + annotationProcessor 'io.github.openfeign.querydsl:querydsl-apt:6.11' + annotationProcessor 'jakarta.annotation:jakarta.annotation-api' + annotationProcessor 'jakarta.persistence:jakarta.persistence-api' // API Documentation - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' // Object Storage (MinIO) - implementation 'io.minio:minio:8.5.7' + implementation 'io.minio:minio:8.5.12' // HTTP Client implementation 'org.springframework.boot:spring-boot-starter-webflux' + // Structured Logging + implementation 'net.logstash.logback:logstash-logback-encoder:7.4' + + // Security patches for transitive dependencies + implementation 'ai.djl:api:0.31.1' // CVE-2025-0851 + implementation 'org.apache.commons:commons-lang3:3.18.0' // CVE-2025-48924 + // Utilities compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' @@ -58,14 +82,33 @@ dependencies { testImplementation 'org.testcontainers:junit-jupiter' testImplementation 'org.testcontainers:postgresql' testImplementation 'org.testcontainers:elasticsearch' + testRuntimeOnly 'com.h2database:h2' } dependencyManagement { imports { - mavenBom "org.testcontainers:testcontainers-bom:1.19.3" + mavenBom "org.testcontainers:testcontainers-bom:1.20.4" } } -tasks.named('test') { +test { useJUnitPlatform() } + +// QueryDSL Configuration (Fixed deprecated buildDir) +def querydslDir = "${layout.buildDirectory.get().asFile}/generated/querydsl" + +sourceSets { + main.java.srcDirs += querydslDir +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } + querydsl.extendsFrom compileClasspath +} + +compileJava { + options.generatedSourceOutputDirectory = file(querydslDir) +} diff --git a/core/gradle/wrapper/gradle-wrapper.jar b/core/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..8bdaf60 Binary files /dev/null and b/core/gradle/wrapper/gradle-wrapper.jar differ diff --git a/core/gradlew b/core/gradlew old mode 100644 new mode 100755 index 31e9ed3..1aa94a4 --- a/core/gradlew +++ b/core/gradlew @@ -18,7 +18,47 @@ ############################################################################## # -# Gradle start up script for UNIX +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. # ############################################################################## @@ -43,7 +83,8 @@ done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -90,10 +131,13 @@ location of your Java installation." fi else JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. @@ -101,7 +145,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac @@ -109,7 +153,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC3045 + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -158,4 +202,48 @@ fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + exec "$JAVACMD" "$@" diff --git a/core/gradlew.bat b/core/gradlew.bat new file mode 100644 index 0000000..52bfe2c --- /dev/null +++ b/core/gradlew.bat @@ -0,0 +1,122 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +<<<<<<< HEAD +@rem SPDX-License-Identifier: Apache-2.0 +@rem +======= +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +<<<<<<< HEAD +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 +======= +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. +>>>>>>> 2b49148 (build: regenerate gradle wrapper for consistent build environment) + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +<<<<<<< HEAD +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 +======= +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. +>>>>>>> 2b49148 (build: regenerate gradle wrapper for consistent build environment) + +goto fail + +:execute +@rem Setup the command line + +<<<<<<< HEAD +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* +======= +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +>>>>>>> 2b49148 (build: regenerate gradle wrapper for consistent build environment) + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/core/src/main/java/com/opencontext/OpenContextApplication.java b/core/src/main/java/com/opencontext/OpenContextApplication.java index 5f84c4d..190e1d5 100644 --- a/core/src/main/java/com/opencontext/OpenContextApplication.java +++ b/core/src/main/java/com/opencontext/OpenContextApplication.java @@ -2,11 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.scheduling.annotation.EnableAsync; @SpringBootApplication -@EnableJpaAuditing @EnableAsync public class OpenContextApplication { diff --git a/core/src/main/java/com/opencontext/common/CommonResponse.java b/core/src/main/java/com/opencontext/common/CommonResponse.java new file mode 100644 index 0000000..fa5558f --- /dev/null +++ b/core/src/main/java/com/opencontext/common/CommonResponse.java @@ -0,0 +1,111 @@ +package com.opencontext.common; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +import java.time.LocalDateTime; + +/** + * Standard API response structure class. + * Handles success and failure responses in a consistent format to simplify client response processing. + * + * This class should only be created through static factory methods, + * preventing direct constructor usage to ensure API response consistency. + */ +@Schema(description = "Standard API response structure") +@Getter +@JsonInclude(JsonInclude.Include.NON_NULL) +public class CommonResponse { + + @Schema(description = "Request success status", example = "true") + private final boolean success; + + @Schema(description = "Response data (null on failure)") + private final T data; + + @Schema(description = "Message to display to user", example = "Request processed successfully.") + private final String message; + + @Schema(description = "Error code (null on success)", example = "VALIDATION_FAILED") + private final String errorCode; + + @Schema(description = "Response creation timestamp", example = "2025-08-07T12:00:00") + private final LocalDateTime timestamp; + + /** + * Private constructor. Only allows instance creation through static factory methods. + */ + private CommonResponse(boolean success, T data, String message, String errorCode, LocalDateTime timestamp) { + this.success = success; + this.data = data; + this.message = message; + this.errorCode = errorCode; + this.timestamp = timestamp; + } + + /** + * Static factory method for creating success responses. + */ + public static CommonResponse success(T data) { + return new CommonResponse<>( + true, + data, + "Request processed successfully.", + null, + LocalDateTime.now() + ); + } + + /** + * Static factory method for creating success responses with custom message. + */ + public static CommonResponse success(T data, String message) { + return new CommonResponse<>( + true, + data, + message, + null, + LocalDateTime.now() + ); + } + + /** + * Static factory method for creating error responses. + */ + public static CommonResponse error(String message, String errorCode) { + return new CommonResponse<>( + false, + null, + message, + errorCode, + LocalDateTime.now() + ); + } + + /** + * Static factory method for creating error responses with message only. + */ + public static CommonResponse error(String message) { + return new CommonResponse<>( + false, + null, + message, + "INTERNAL_ERROR", + LocalDateTime.now() + ); + } + + /** + * Static factory method for creating error responses from ErrorCode enum. + */ + public static CommonResponse error(com.opencontext.enums.ErrorCode errorCode, String message) { + return new CommonResponse<>( + false, + null, + message, + errorCode.getCode(), + LocalDateTime.now() + ); + } +} diff --git a/core/src/main/java/com/opencontext/common/PageResponse.java b/core/src/main/java/com/opencontext/common/PageResponse.java new file mode 100644 index 0000000..a2e54a7 --- /dev/null +++ b/core/src/main/java/com/opencontext/common/PageResponse.java @@ -0,0 +1,85 @@ +package com.opencontext.common; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.domain.Page; + +import java.util.List; + +/** + * Paginated list response DTO. + * Converts Spring Data Page objects into a client-friendly format. + */ +@Schema(description = "Paginated list response DTO") +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class PageResponse { + + @Schema(description = "Data list for current page") + private List content; + + @Schema(description = "Current page number (zero-based)", example = "0") + private int page; + + @Schema(description = "Number of items per page", example = "10") + private int size; + + @Schema(description = "Total number of items", example = "100") + private long totalElements; + + @Schema(description = "Total number of pages", example = "10") + private int totalPages; + + @Schema(description = "Whether this is the first page", example = "true") + private boolean first; + + @Schema(description = "Whether this is the last page", example = "false") + private boolean last; + + @Schema(description = "Whether there is a next page", example = "true") + private boolean hasNext; + + @Schema(description = "Whether there is a previous page", example = "false") + private boolean hasPrevious; + + /** + * Static factory method to convert Page object to PageResponse DTO. + */ + public static PageResponse from(Page page) { + return PageResponse.builder() + .content(page.getContent()) + .page(page.getNumber()) + .size(page.getSize()) + .totalElements(page.getTotalElements()) + .totalPages(page.getTotalPages()) + .first(page.isFirst()) + .last(page.isLast()) + .hasNext(page.hasNext()) + .hasPrevious(page.hasPrevious()) + .build(); + } + + /** + * Static factory method to convert Page object with custom content to PageResponse DTO. + */ + public static PageResponse from(Page page, List content) { + return PageResponse.builder() + .content(content) + .page(page.getNumber()) + .size(page.getSize()) + .totalElements(page.getTotalElements()) + .totalPages(page.getTotalPages()) + .first(page.isFirst()) + .last(page.isLast()) + .hasNext(page.hasNext()) + .hasPrevious(page.hasPrevious()) + .build(); + } +} + diff --git a/core/src/main/java/com/opencontext/config/ApiKeyAuthenticationFilter.java b/core/src/main/java/com/opencontext/config/ApiKeyAuthenticationFilter.java new file mode 100644 index 0000000..d71f07d --- /dev/null +++ b/core/src/main/java/com/opencontext/config/ApiKeyAuthenticationFilter.java @@ -0,0 +1,92 @@ +package com.opencontext.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.opencontext.common.CommonResponse; +import com.opencontext.enums.ErrorCode; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + +/** + * Filter for API Key authentication on admin endpoints. + * + * This filter validates the X-API-KEY header for endpoints that require + * admin access, specifically the document management APIs. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ApiKeyAuthenticationFilter extends OncePerRequestFilter { + + private final ObjectMapper objectMapper; + + @Value("${opencontext.api.key:dev-api-key-123}") + private String validApiKey; + + // Endpoints that require API Key authentication + private static final List PROTECTED_ENDPOINTS = Arrays.asList( + "/api/v1/sources" + ); + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + String requestPath = request.getRequestURI(); + + // Check if this endpoint requires API Key authentication + boolean requiresAuth = PROTECTED_ENDPOINTS.stream() + .anyMatch(requestPath::startsWith); + + if (requiresAuth) { + String apiKey = request.getHeader("X-API-KEY"); + + if (!StringUtils.hasText(apiKey)) { + log.warn("API Key missing for protected endpoint: {}", requestPath); + sendErrorResponse(response, ErrorCode.INSUFFICIENT_PERMISSION, + "API Key is required. Please provide X-API-KEY header."); + return; + } + + if (!validApiKey.equals(apiKey)) { + log.warn("Invalid API Key provided for endpoint: {}", requestPath); + sendErrorResponse(response, ErrorCode.INSUFFICIENT_PERMISSION, + "Invalid API Key provided."); + return; + } + + log.debug("API Key authentication successful for endpoint: {}", requestPath); + } + + filterChain.doFilter(request, response); + } + + /** + * Sends an error response in JSON format. + */ + private void sendErrorResponse(HttpServletResponse response, ErrorCode errorCode, String message) + throws IOException { + + response.setStatus(errorCode.getHttpStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + + CommonResponse errorResponse = CommonResponse.error(errorCode, message); + + String jsonResponse = objectMapper.writeValueAsString(errorResponse); + response.getWriter().write(jsonResponse); + } +} diff --git a/core/src/main/java/com/opencontext/config/AsyncConfig.java b/core/src/main/java/com/opencontext/config/AsyncConfig.java new file mode 100644 index 0000000..bdc8a6e --- /dev/null +++ b/core/src/main/java/com/opencontext/config/AsyncConfig.java @@ -0,0 +1,49 @@ +package com.opencontext.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +/** + * Asynchronous processing configuration. + * Configuration for executing heavy operations like document ingestion pipeline + * in separate threads. + */ +@Configuration +@EnableAsync +public class AsyncConfig { + + /** + * Dedicated thread pool configuration for document ingestion pipeline. + */ + @Bean(name = "ingestionTaskExecutor") + public Executor ingestionTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); // Number of concurrent document ingestion tasks + executor.setMaxPoolSize(4); // Maximum number of threads + executor.setQueueCapacity(10); // Queue capacity for waiting tasks + executor.setThreadNamePrefix("ingestion-"); + executor.setRejectedExecutionHandler(new java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy()); + executor.initialize(); + return executor; + } + + /** + * Default thread pool configuration for general asynchronous tasks. + */ + @Bean(name = "taskExecutor") + public Executor taskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(4); + executor.setMaxPoolSize(8); + executor.setQueueCapacity(20); + executor.setThreadNamePrefix("async-"); + executor.setRejectedExecutionHandler(new java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy()); + executor.initialize(); + return executor; + } +} + diff --git a/core/src/main/java/com/opencontext/config/EmbeddingConfig.java b/core/src/main/java/com/opencontext/config/EmbeddingConfig.java new file mode 100644 index 0000000..ef4a02b --- /dev/null +++ b/core/src/main/java/com/opencontext/config/EmbeddingConfig.java @@ -0,0 +1,38 @@ +package com.opencontext.config; + +import dev.langchain4j.model.embedding.EmbeddingModel; +import dev.langchain4j.model.ollama.OllamaEmbeddingModel; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Configuration for embedding model integration. + * Sets up Ollama embedding model for text vectorization. + */ +@Slf4j +@Configuration +public class EmbeddingConfig { + + @Value("${app.ollama.api.url}") + private String ollamaBaseUrl; + + @Value("${app.ollama.embedding.model}") + private String ollamaModel; + + /** + * Creates and configures the Ollama embedding model bean. + * + * @return configured EmbeddingModel instance + */ + @Bean + public EmbeddingModel embeddingModel() { + log.info("Configuring Ollama embedding model: {} at {}", ollamaModel, ollamaBaseUrl); + + return OllamaEmbeddingModel.builder() + .baseUrl(ollamaBaseUrl) + .modelName(ollamaModel) + .build(); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/opencontext/config/JpaAuditingConfig.java b/core/src/main/java/com/opencontext/config/JpaAuditingConfig.java new file mode 100644 index 0000000..97f6933 --- /dev/null +++ b/core/src/main/java/com/opencontext/config/JpaAuditingConfig.java @@ -0,0 +1,14 @@ +package com.opencontext.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +/** + * JPA Auditing configuration. + * Enables automatic field updates for @CreatedDate, @LastModifiedDate, etc. + * in entities with @EntityListeners(AuditingEntityListener.class). + */ +@Configuration +@EnableJpaAuditing +public class JpaAuditingConfig { +} diff --git a/core/src/main/java/com/opencontext/config/MinIOConfig.java b/core/src/main/java/com/opencontext/config/MinIOConfig.java new file mode 100644 index 0000000..c6592b6 --- /dev/null +++ b/core/src/main/java/com/opencontext/config/MinIOConfig.java @@ -0,0 +1,41 @@ +package com.opencontext.config; + +import io.minio.MinioClient; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * MinIO client configuration for object storage operations. + * + * This configuration creates a MinIO client bean that can be used throughout + * the application for file upload, download, and management operations. + */ +@Slf4j +@Data +@Configuration +@ConfigurationProperties(prefix = "minio") +public class MinIOConfig { + + private String endpoint; + private String accessKey; + private String secretKey; + private String bucketName; + + /** + * Creates and configures the MinIO client bean. + * + * @return configured MinioClient instance + */ + @Bean + public MinioClient minioClient() { + log.info("Initializing MinIO client with endpoint: {}", endpoint); + + return MinioClient.builder() + .endpoint(endpoint) + .credentials(accessKey, secretKey) + .build(); + } +} diff --git a/core/src/main/java/com/opencontext/config/OpenApiConfig.java b/core/src/main/java/com/opencontext/config/OpenApiConfig.java new file mode 100644 index 0000000..acf10c7 --- /dev/null +++ b/core/src/main/java/com/opencontext/config/OpenApiConfig.java @@ -0,0 +1,38 @@ +package com.opencontext.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * OpenAPI (Swagger) configuration for OpenContext API documentation + */ +@Configuration +public class OpenApiConfig { + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info(new Info() + .title("OpenContext API") + .description("Self-hosted AI Context Engine with hierarchical RAG search capabilities") + .version("1.0.0") + .contact(new Contact() + .name("OpenContext Team") + .url("https://github.com/OpenContextAI/open-context"))) + .addServersItem(new Server() + .url("http://localhost:8080") + .description("Local development server")) + .components(new Components() + .addSecuritySchemes("ApiKeyAuth", new SecurityScheme() + .type(SecurityScheme.Type.APIKEY) + .in(SecurityScheme.In.HEADER) + .name("X-API-KEY") + .description("API Key for accessing admin endpoints"))); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/opencontext/config/SecurityConfig.java b/core/src/main/java/com/opencontext/config/SecurityConfig.java new file mode 100644 index 0000000..67e46ec --- /dev/null +++ b/core/src/main/java/com/opencontext/config/SecurityConfig.java @@ -0,0 +1,104 @@ +package com.opencontext.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * Spring Security configuration for OpenContext API. + * Implements API Key authentication for Admin APIs and allows unrestricted access + * to MCP APIs and development endpoints. + */ +@Slf4j +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final Environment environment; + private final ApiKeyAuthenticationFilter apiKeyAuthenticationFilter; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + log.info("Configuring Spring Security with profile-specific rules"); + + http + // Disable CSRF for REST API + .csrf(AbstractHttpConfigurer::disable) + + // Enable CORS for frontend integration + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + + // Disable form login and basic auth + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + + // Stateless session management for REST API + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + + // Configure authorization rules + .authorizeHttpRequests(authz -> authz + // Allow health check endpoints + .requestMatchers("/actuator/health/**", "/actuator/info").permitAll() + + // Allow Swagger UI and API documentation (development) + .requestMatchers( + "/swagger-ui/**", + "/v3/api-docs/**", + "/swagger-ui.html", + "/swagger-resources/**", + "/webjars/**" + ).permitAll() + + // Allow MCP API endpoints (no authentication required per PRD) + .requestMatchers("/api/v1/search/**", "/api/v1/get-content/**").permitAll() + + // Allow development mock data endpoints (development only) + .requestMatchers("/api/v1/dev/**").permitAll() + + // Admin APIs require API Key authentication + .requestMatchers("/api/v1/sources/**").permitAll() // Authentication handled by filter + + // All other requests require authentication + .anyRequest().authenticated() + ); + + // Add API Key authentication filter for Admin APIs + http.addFilterBefore(apiKeyAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + log.info("Security configuration completed successfully"); + return http.build(); + } + + /** + * CORS configuration for frontend integration + */ + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.addAllowedOriginPattern("*"); // Allow all origins in development + configuration.addAllowedMethod("*"); // Allow all HTTP methods + configuration.addAllowedHeader("*"); // Allow all headers + configuration.setAllowCredentials(true); + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/api/**", configuration); + + log.info("CORS configuration applied to /api/** endpoints"); + return source; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/opencontext/config/WebConfig.java b/core/src/main/java/com/opencontext/config/WebConfig.java new file mode 100644 index 0000000..6e3468d --- /dev/null +++ b/core/src/main/java/com/opencontext/config/WebConfig.java @@ -0,0 +1,38 @@ +package com.opencontext.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +/** + * Web-related configuration. + * Handles web-related settings such as CORS, interceptors, resource handlers, etc. + */ +@Configuration +public class WebConfig implements WebMvcConfigurer { + + /** + * CORS configuration. + * Allows CORS for communication with frontend in development environment. + */ + /** + * RestTemplate bean for HTTP client operations. + */ + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOriginPatterns("*") + .allowedMethods("*") + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(3600); + } +} + diff --git a/core/src/main/java/com/opencontext/controller/DocsSearchController.java b/core/src/main/java/com/opencontext/controller/DocsSearchController.java new file mode 100644 index 0000000..113843b --- /dev/null +++ b/core/src/main/java/com/opencontext/controller/DocsSearchController.java @@ -0,0 +1,95 @@ +package com.opencontext.controller; + +import com.opencontext.common.CommonResponse; +import com.opencontext.dto.GetContentRequest; +import com.opencontext.dto.GetContentResponse; +import com.opencontext.dto.SearchResultsResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "MCP Search", description = "APIs for exploratory and focused retrieval used by OpenContext MCP tools. No authentication required.") +public interface DocsSearchController { + + @GetMapping("/search") + @Operation( + summary = "Exploratory search (find_knowledge)", + description = "Returns top-k structured chunk summaries based on a natural language query. Default returns 50 results. Snippet policy: first 50 chars + '...'." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Search completed", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = SearchResultsResponse.class), + examples = @ExampleObject(name = "Sample Search Results", value = """ + { + "success": true, + "data": { + "results": [ + { + "chunkId": "c1d2e3f4-a5b6-7890-1234-567890abcdef", + "title": "5.8.2. Configuring the JWT Authentication Converter", + "snippet": "To customize the conversion from a JWT to an Auth...", + "relevanceScore": 0.92, + "breadcrumbs": ["Chapter 5", "Security", "JWT"] + } + ] + }, + "message": "Search completed successfully" + } + """)) + ), + @ApiResponse(responseCode = "400", description = "Validation failed") + }) + ResponseEntity> search( + @Parameter(description = "User search query", example = "Spring Security JWT filter configuration", required = true) + @RequestParam String query, + @Parameter(description = "Max number of results", example = "50") + @RequestParam(defaultValue = "50") Integer topK); + + @PostMapping(value = "/get-content", consumes = MediaType.APPLICATION_JSON_VALUE) + @Operation( + summary = "Focused retrieval (get_content)", + description = "Returns the full original text of a selected chunk. If token count exceeds maxTokens, text is truncated from the end." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "Content retrieved", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = GetContentResponse.class), + examples = @ExampleObject(name = "Sample Content", value = """ + { + "success": true, + "data": { + "content": "### 5.8.2. Configuring the JWT Authentication Converter...", + "tokenInfo": {"tokenizer": "tiktoken-cl100k_base", "actualTokens": 789} + }, + "message": "Content retrieved successfully" + } + """)) + ), + @ApiResponse(responseCode = "400", description = "Validation failed") + }) + ResponseEntity> getContent( + @RequestBody( + required = true, + description = "Chunk selection and optional token limit", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = GetContentRequest.class), + examples = @ExampleObject(value = """ + { + "chunkId": "a1b2c3d4-e5f6-7890-1234-567890abcdef", + "maxTokens": 8000 + } + """)) + ) GetContentRequest request); +} diff --git a/core/src/main/java/com/opencontext/controller/DocsSourceController.java b/core/src/main/java/com/opencontext/controller/DocsSourceController.java new file mode 100644 index 0000000..d924d21 --- /dev/null +++ b/core/src/main/java/com/opencontext/controller/DocsSourceController.java @@ -0,0 +1,322 @@ +package com.opencontext.controller; + +import com.opencontext.common.CommonResponse; +import com.opencontext.common.PageResponse; +import com.opencontext.dto.SourceDocumentDto; +import com.opencontext.dto.SourceUploadResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.NotNull; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.UUID; + +/** + * Interface defining source document management API endpoints with comprehensive Swagger documentation. + * + * This interface serves as the contract for document ingestion pipeline management APIs + * with detailed API documentation including examples and response schemas. + */ +@Tag( + name = "Source Document Management", + description = "Admin APIs for document ingestion pipeline management. Requires X-API-KEY authentication." +) +@SecurityRequirement(name = "ApiKeyAuth") +public interface DocsSourceController { + + /** + * Uploads a file and starts the ingestion pipeline. + */ + @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "Upload file and start ingestion pipeline", + description = """ + Uploads a PDF or Markdown file to the system and starts the asynchronous ingestion pipeline. + + **Authentication Required:** X-API-KEY header must be provided. + + **Supported File Types:** + - PDF documents (application/pdf, .pdf files) + - Markdown files (text/markdown, .md or .markdown files) + - Plain text files (text/plain, .txt files) + + **Note:** The system automatically detects file types based on both the Content-Type header + and file extension. the system will properly recognize Markdown files regardless of how the browser sends the Content-Type. + + **File Size Limit:** 100MB + + The API accepts the file upload request immediately and returns 202 Accepted status, + indicating that the actual processing will be performed in the background. + """, + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "Multipart form data containing the file to upload", + required = true, + content = @Content( + mediaType = MediaType.MULTIPART_FORM_DATA_VALUE, + encoding = @io.swagger.v3.oas.annotations.media.Encoding( + name = "file", + contentType = "application/octet-stream" + ) + ) + ) + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "202", + description = "File uploaded successfully and queued for ingestion", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = SourceUploadResponse.class), + examples = @ExampleObject( + name = "Upload Success", + value = """ + { + "success": true, + "data": { + "sourceDocumentId": "a1b2c3d4-e5f6-7890-1234-567890abcdef", + "originalFilename": "document.pdf", + "ingestionStatus": "PENDING", + "message": "File uploaded successfully and is now pending for ingestion." + }, + "message": "Request processed successfully.", + "errorCode": null, + "timestamp": "2025-08-07T11:50:00Z" + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "400", + description = "Bad Request - file part missing or validation failed", + content = @Content( + examples = @ExampleObject( + name = "Validation Failed", + value = """ + { + "success": false, + "data": null, + "message": "File cannot be empty", + "errorCode": "COMMON_002", + "timestamp": "2025-08-07T11:50:00Z" + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "403", + description = "Forbidden - invalid or missing API Key", + content = @Content( + examples = @ExampleObject( + name = "Invalid API Key", + value = """ + { + "success": false, + "data": null, + "message": "Invalid API Key provided.", + "errorCode": "AUTH_001", + "timestamp": "2025-08-07T11:50:00Z" + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "409", + description = "Conflict - duplicate file already exists", + content = @Content( + examples = @ExampleObject( + name = "Duplicate File", + value = """ + { + "success": false, + "data": null, + "message": "A file with identical content already exists.", + "errorCode": "DOC_002", + "timestamp": "2025-08-07T11:50:00Z" + } + """ + ) + ) + ), + @ApiResponse(responseCode = "413", description = "Payload Too Large - file exceeds 100MB limit"), + @ApiResponse(responseCode = "415", description = "Unsupported Media Type - invalid file format") + }) + ResponseEntity> uploadFile( + @Parameter( + description = "File to upload (PDF, Markdown, or plain text)", + required = true + ) + @RequestParam("file") MultipartFile file + ); + + /** + * Retrieves all uploaded documents with their current status. + */ + @GetMapping + @Operation( + summary = "Get all source documents", + description = """ + Retrieves a paginated list of all source documents in the system with their current ingestion status. + + **Authentication Required:** X-API-KEY header must be provided. + + This endpoint is primarily used by the Admin UI dashboard to periodically refresh and display + the processing status of uploaded documents. + """ + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "Source documents retrieved successfully", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + examples = @ExampleObject( + name = "Document List", + value = """ + { + "success": true, + "data": { + "content": [ + { + "id": "a1b2c3d4-e5f6-7890-1234-567890abcdef", + "originalFilename": "spring-security-reference.pdf", + "fileType": "PDF", + "fileSize": 10485760, + "ingestionStatus": "COMPLETED", + "errorMessage": null, + "lastIngestedAt": "2025-08-07T12:00:00Z", + "createdAt": "2025-08-07T11:50:00Z", + "updatedAt": "2025-08-07T12:00:00Z" + } + ], + "page": 0, + "size": 10, + "totalElements": 48, + "totalPages": 5, + "first": true, + "last": false, + "hasNext": true, + "hasPrevious": false + }, + "message": "Request processed successfully.", + "timestamp": "2025-08-07T12:05:00Z" + } + """ + ) + ) + ), + @ApiResponse(responseCode = "403", description = "Forbidden - invalid or missing API Key") + }) + ResponseEntity>> getAllSourceDocuments( + @Parameter(description = "Page number (0-based)", example = "0") + @RequestParam(defaultValue = "0") int page, + + @Parameter(description = "Page size", example = "20") + @RequestParam(defaultValue = "20") int size, + + @Parameter(description = "Sort specification", example = "createdAt,desc") + @RequestParam(defaultValue = "createdAt,desc") String sort + ); + + /** + * Triggers re-ingestion of a specific document. + */ + @PostMapping("/{sourceId}/resync") + @Operation( + summary = "Re-ingest a source document", + description = """ + Forces re-ingestion of a specific source document by restarting the ingestion pipeline. + + **Authentication Required:** X-API-KEY header must be provided. + + The actual re-processing is performed asynchronously in the background, + so this endpoint returns 202 Accepted to indicate the request has been queued. + """ + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "202", + description = "Re-ingestion request accepted and queued", + content = @Content( + examples = @ExampleObject( + name = "Resync Accepted", + value = """ + { + "success": true, + "data": "Document re-ingestion has been queued successfully.", + "message": "Request processed successfully.", + "timestamp": "2025-08-07T12:05:00Z" + } + """ + ) + ) + ), + @ApiResponse(responseCode = "403", description = "Forbidden - invalid or missing API Key"), + @ApiResponse(responseCode = "404", description = "Not Found - document does not exist"), + @ApiResponse(responseCode = "409", description = "Conflict - document is currently being processed") + }) + ResponseEntity> resyncSourceDocument( + @Parameter(description = "Source document ID", required = true, example = "a1b2c3d4-e5f6-7890-1234-567890abcdef") + @PathVariable UUID sourceId + ); + + /** + * Deletes a source document and all associated data. + */ + @DeleteMapping("/{sourceId}") + @Operation( + summary = "Delete a source document", + description = """ + Permanently deletes a source document and all its associated data from the system. + + **Authentication Required:** X-API-KEY header must be provided. + + This operation removes: + - The original file from MinIO storage + - All generated chunks from PostgreSQL + - All embeddings and indices from Elasticsearch + - The source document metadata + + The actual deletion is performed asynchronously in the background, + so this endpoint returns 202 Accepted to indicate the request has been queued. + """ + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "202", + description = "Deletion request accepted and queued", + content = @Content( + examples = @ExampleObject( + name = "Deletion Accepted", + value = """ + { + "success": true, + "data": "Document deletion has been queued successfully.", + "message": "Request processed successfully.", + "timestamp": "2025-08-07T12:05:00Z" + } + """ + ) + ) + ), + @ApiResponse(responseCode = "403", description = "Forbidden - invalid or missing API Key"), + @ApiResponse(responseCode = "404", description = "Not Found - document does not exist"), + @ApiResponse(responseCode = "409", description = "Conflict - document is currently being processed") + }) + ResponseEntity> deleteSourceDocument( + @Parameter(description = "Source document ID", required = true, example = "a1b2c3d4-e5f6-7890-1234-567890abcdef") + @PathVariable UUID sourceId + ); +} \ No newline at end of file diff --git a/core/src/main/java/com/opencontext/controller/SearchController.java b/core/src/main/java/com/opencontext/controller/SearchController.java new file mode 100644 index 0000000..d4898b1 --- /dev/null +++ b/core/src/main/java/com/opencontext/controller/SearchController.java @@ -0,0 +1,74 @@ +package com.opencontext.controller; + +import com.opencontext.common.CommonResponse; +import com.opencontext.dto.GetContentResponse; +import com.opencontext.dto.GetContentRequest; +import com.opencontext.dto.SearchResultItem; +import com.opencontext.dto.SearchResultsResponse; +import com.opencontext.service.ContentRetrievalService; +import com.opencontext.service.SearchService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * MCP Search API Controller + * Provides endpoints for find_knowledge and get_content MCP tools + */ +@Slf4j +@RestController +@RequestMapping("/api/v1") +@RequiredArgsConstructor +public class SearchController implements DocsSearchController { + + private final SearchService searchService; + private final ContentRetrievalService contentRetrievalService; + + /** + * Performs hybrid search - find_knowledge MCP tool + */ + @Override + @GetMapping("/search") + public ResponseEntity> search( + @RequestParam String query, + @RequestParam(defaultValue = "50") Integer topK) { + + log.info("Search request: query='{}', topK={}", query, topK); + + if (query == null || query.trim().isEmpty()) { + return ResponseEntity.badRequest() + .body(CommonResponse.error("Query cannot be empty", "VALIDATION_FAILED")); + } + + List results = searchService.search(query.trim(), topK); + SearchResultsResponse responseData = SearchResultsResponse.builder() + .results(results) + .build(); + return ResponseEntity.ok(CommonResponse.success(responseData, "Search completed successfully")); + } + + /** + * Retrieves chunk content - get_content MCP tool + */ + @Override + @PostMapping(value = "/get-content", consumes = MediaType.APPLICATION_JSON_VALUE) + public ResponseEntity> getContent(@jakarta.validation.Valid @org.springframework.web.bind.annotation.RequestBody GetContentRequest request) { + if (request == null || request.getChunkId() == null || request.getChunkId().isBlank()) { + return ResponseEntity.badRequest() + .body(CommonResponse.error("chunkId is required", "VALIDATION_FAILED")); + } + String chunkId = request.getChunkId(); + Integer maxTokens = request.getMaxTokens(); + if (maxTokens != null && maxTokens <= 0) { + return ResponseEntity.badRequest() + .body(CommonResponse.error("maxTokens must be positive", "VALIDATION_FAILED")); + } + log.info("Content retrieval request: chunkId={}, maxTokens={}", chunkId, maxTokens); + GetContentResponse response = contentRetrievalService.getContent(chunkId, maxTokens); + return ResponseEntity.ok(CommonResponse.success(response, "Content retrieved successfully")); + } +} \ No newline at end of file diff --git a/core/src/main/java/com/opencontext/controller/SourceController.java b/core/src/main/java/com/opencontext/controller/SourceController.java new file mode 100644 index 0000000..b5d8230 --- /dev/null +++ b/core/src/main/java/com/opencontext/controller/SourceController.java @@ -0,0 +1,445 @@ +package com.opencontext.controller; + +import com.opencontext.common.CommonResponse; +import com.opencontext.common.PageResponse; +import com.opencontext.dto.SourceDocumentDto; +import com.opencontext.dto.SourceUploadResponse; +import com.opencontext.entity.SourceDocument; +import com.opencontext.enums.ErrorCode; +import com.opencontext.enums.IngestionStatus; +import com.opencontext.exception.BusinessException; +import com.opencontext.repository.DocumentChunkRepository; +import com.opencontext.repository.SourceDocumentRepository; +import com.opencontext.service.ChunkingService; +import com.opencontext.service.DocumentParsingService; +import com.opencontext.service.EmbeddingService; +import com.opencontext.service.FileStorageService; +import com.opencontext.service.IndexingService; +import org.springframework.transaction.annotation.Propagation; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.http.*; +import org.springframework.scheduling.annotation.Async; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; +import java.util.UUID; + +/** + * REST API controller for managing source documents. + * + * REST API controller for managing source documents. + * + * Provides functionalities such as file upload, ingestion pipeline management, and document listing. + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/sources") +@RequiredArgsConstructor +public class SourceController implements DocsSourceController{ + + private final SourceDocumentRepository sourceDocumentRepository; + private final DocumentChunkRepository documentChunkRepository; + private final DocumentParsingService documentParsingService; + private final ChunkingService chunkingService; + private final EmbeddingService embeddingService; + private final IndexingService indexingService; + private final FileStorageService fileStorageService; + private final RestTemplate restTemplate; + + @Value("${app.elasticsearch.url:http://localhost:9200}") + private String elasticsearchUrl; + + @Value("${app.elasticsearch.index:document-chunks}") + private String indexName; + + /** + * File upload and ingestion pipeline start + * + * @param file File to upload (multipart/form-data) + * @return Upload result and document information + */ + @Override + @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> uploadFile( + @RequestParam("file") MultipartFile file) { + log.info("File upload requested: filename={}, size={}", file.getOriginalFilename(), file.getSize()); + + try { + // File storage and document creation (validation performed in FileStorageService) + SourceDocument sourceDocument = fileStorageService.uploadFileWithMetadata(file); + log.info("File saved successfully: documentId={}, filename={}", + sourceDocument.getId(), sourceDocument.getOriginalFilename()); + + // Start asynchronous ingestion pipeline + processIngestionPipeline(sourceDocument.getId()); + + // Create response + SourceUploadResponse response = SourceUploadResponse.success( + sourceDocument.getId().toString(), + sourceDocument.getOriginalFilename() + ); + + return ResponseEntity.status(HttpStatus.ACCEPTED) + .body(CommonResponse.success(response)); + + } catch (BusinessException e) { + log.error("File upload failed: {}", e.getMessage()); + return ResponseEntity.status(getHttpStatusFromErrorCode(e.getErrorCode())) + .body(CommonResponse.error(e.getErrorCode(), e.getMessage())); + } catch (Exception e) { + log.error("Unexpected error during file upload", e); + return ResponseEntity.internalServerError() + .body(CommonResponse.error("An error occurred during file upload: " + e.getMessage())); + } + } + + /** + * Retrieve latest status list of all uploaded documents + * + * @param page Page number (starting from 0) + * @param size Page size + * @param sort Sorting criteria + * @return Paginated document list + */ + @Override + @GetMapping + public ResponseEntity>> getAllSourceDocuments( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(defaultValue = "createdAt,desc") String sort) { + log.debug("Getting source documents: page={}, size={}, sort={}", page, size, sort); + + try { + // Parse sorting criteria + Sort sortObj = parseSort(sort); + Pageable pageable = PageRequest.of(page, size, sortObj); + + // Retrieve document list + Page documentPage = sourceDocumentRepository.findAll(pageable); + + // Convert to DTO + List documentDtos = documentPage.getContent().stream() + .map(this::convertToDto) + .toList(); + + PageResponse pageResponse = PageResponse.builder() + .content(documentDtos) + .page(documentPage.getNumber()) + .size(documentPage.getSize()) + .totalElements(documentPage.getTotalElements()) + .totalPages(documentPage.getTotalPages()) + .first(documentPage.isFirst()) + .last(documentPage.isLast()) + .hasNext(documentPage.hasNext()) + .hasPrevious(documentPage.hasPrevious()) + .build(); + + return ResponseEntity.ok(CommonResponse.success(pageResponse)); + + } catch (Exception e) { + log.error("Failed to get source documents", e); + return ResponseEntity.internalServerError() + .body(CommonResponse.error("An error occurred while retrieving document list: " + e.getMessage())); + } + } + + /** + * Force re-ingestion of a specific document + * + * @param sourceId Document ID to re-ingest + * @return Re-ingestion start response + */ + @Override + @PostMapping("/{sourceId}/resync") + public ResponseEntity> resyncSourceDocument(@PathVariable UUID sourceId) { + log.info("Document resync requested: sourceId={}", sourceId); + + try { + // Check if document exists + SourceDocument sourceDocument = findSourceDocumentById(sourceId); + + // Check if document is being processed + if (sourceDocument.isProcessing()) { + throw new BusinessException(ErrorCode.RESOURCE_IS_BEING_PROCESSED, + "Document is already being processed."); + } + + // Reset status to PENDING + sourceDocument.updateIngestionStatus(IngestionStatus.PENDING); + sourceDocument.clearErrorMessage(); + sourceDocumentRepository.save(sourceDocument); + + // Start asynchronous ingestion pipeline + processIngestionPipeline(sourceId); + + log.info("Document resync started successfully: sourceId={}", sourceId); + + return ResponseEntity.status(HttpStatus.ACCEPTED) + .body(CommonResponse.success("Document re-ingestion has started.")); + + } catch (BusinessException e) { + log.error("Document resync failed: sourceId={}, error={}", sourceId, e.getMessage()); + return ResponseEntity.status(getHttpStatusFromErrorCode(e.getErrorCode())) + .body(CommonResponse.error(e.getErrorCode(), e.getMessage())); + } catch (Exception e) { + log.error("Unexpected error during document resync: sourceId={}", sourceId, e); + return ResponseEntity.internalServerError() + .body(CommonResponse.error("An error occurred during document re-ingestion: " + e.getMessage())); + } + } + + /** + * Permanently delete a specific document from the system + * + * @param sourceId Document ID to delete + * @return Deletion start response + */ + @Override + @DeleteMapping("/{sourceId}") + public ResponseEntity> deleteSourceDocument(@PathVariable UUID sourceId) { + log.info("Document deletion requested: sourceId={}", sourceId); + + try { + // Check if document exists + SourceDocument sourceDocument = findSourceDocumentById(sourceId); + + // Check if document is being processed + if (sourceDocument.isProcessing()) { + throw new BusinessException(ErrorCode.RESOURCE_IS_BEING_PROCESSED, + "Documents being processed cannot be deleted."); + } + + // Change status to DELETING + sourceDocument.updateIngestionStatus(IngestionStatus.DELETING); + sourceDocumentRepository.save(sourceDocument); + + // Start asynchronous deletion pipeline + processDeletionPipeline(sourceId); + + log.info("Document deletion started successfully: sourceId={}", sourceId); + + return ResponseEntity.status(HttpStatus.ACCEPTED) + .body(CommonResponse.success("Document deletion has started.")); + + } catch (BusinessException e) { + log.error("Document deletion failed: sourceId={}, error={}", sourceId, e.getMessage()); + return ResponseEntity.status(getHttpStatusFromErrorCode(e.getErrorCode())) + .body(CommonResponse.error(e.getErrorCode(), e.getMessage())); + } catch (Exception e) { + log.error("Unexpected error during document deletion: sourceId={}", sourceId, e); + return ResponseEntity.internalServerError() + .body(CommonResponse.error("An error occurred during document deletion: " + e.getMessage())); + } + } + + /** + * Executes the document ingestion pipeline asynchronously. + */ + @Async + @Transactional + public void processIngestionPipeline(UUID documentId) { + log.info("Starting ingestion pipeline processing: documentId={}", documentId); + + try { + // 1. Update status to PARSING + fileStorageService.updateDocumentStatus(documentId, IngestionStatus.PARSING); + + // 2. Parse document using Unstructured API + var parsedElements = documentParsingService.parseDocument(documentId); + log.info("Document parsing completed: id={}, elements={}", documentId, parsedElements.size()); + + // 3. Update status to CHUNKING + fileStorageService.updateDocumentStatus(documentId, IngestionStatus.CHUNKING); + + // 4. Split into chunks + var structuredChunks = chunkingService.createChunks(documentId, parsedElements); + log.info("Document chunking completed: id={}, chunks={}", documentId, structuredChunks.size()); + + // 5. Update status to EMBEDDING + fileStorageService.updateDocumentStatus(documentId, IngestionStatus.EMBEDDING); + + // 6. Generate embeddings + var embeddedChunks = embeddingService.generateEmbeddings(documentId, structuredChunks); + log.info("Embedding generation completed: id={}, embedded_chunks={}", documentId, embeddedChunks.size()); + + // 7. Update status to INDEXING + fileStorageService.updateDocumentStatus(documentId, IngestionStatus.INDEXING); + + // 8. Store in Elasticsearch and PostgreSQL + indexingService.indexChunks(documentId, embeddedChunks); + log.info("Document indexing completed: id={}", documentId); + + // 9. Update status to COMPLETED + fileStorageService.updateDocumentStatusToCompleted(documentId); + + log.info("Ingestion pipeline completed successfully: documentId={}", documentId); + + } catch (Exception e) { + log.error("Ingestion pipeline failed: documentId={}", documentId, e); + fileStorageService.updateDocumentStatusToError(documentId, e.getMessage()); + throw new BusinessException(ErrorCode.INGESTION_PIPELINE_FAILED, + "Ingestion pipeline failed: " + e.getMessage()); + } + } + + /** + * Executes the document deletion pipeline asynchronously. + */ + @Async + public void processDeletionPipeline(UUID documentId) { + log.info("Starting deletion pipeline processing: documentId={}", documentId); + + try { + // Get document storage path before deletion + String fileStoragePath = fileStorageService.getDocumentStoragePath(documentId); + + // 1. Delete from Elasticsearch + deleteFromElasticsearch(documentId); + log.info("Deleted document from Elasticsearch: id={}", documentId); + + // 2. Delete chunks from PostgreSQL + deleteChunksFromPostgreSQL(documentId); + log.info("Deleted chunks from PostgreSQL: id={}", documentId); + // 3. Delete document and file using FileStorageService + fileStorageService.deleteDocument(documentId); + log.info("Deleted document and file: id={}, path={}", documentId, fileStoragePath); + + log.info("Deletion pipeline completed successfully: documentId={}", documentId); + + } catch (Exception e) { + log.error("Deletion pipeline failed: documentId={}", documentId, e); + throw new BusinessException(ErrorCode.DELETION_PIPELINE_FAILED, + "Deletion pipeline failed: " + e.getMessage()); + } + } + + + + /** + * Deletes document-related chunks from Elasticsearch. + */ + @Transactional + private void deleteFromElasticsearch(UUID documentId) { + try { + // Query by document ID to delete related chunks + String query = String.format(""" + { + "query": { + "term": { + "document_id": "%s" + } + } + } + """, documentId.toString()); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity requestEntity = new HttpEntity<>(query, headers); + + ResponseEntity response = restTemplate.exchange( + elasticsearchUrl + "/" + indexName + "/_delete_by_query", + HttpMethod.POST, + requestEntity, + String.class + ); + + log.debug("Elasticsearch deletion response: {}", response.getBody()); + + } catch (Exception e) { + log.error("Failed to delete from Elasticsearch: documentId={}", documentId, e); + throw new BusinessException(ErrorCode.EXTERNAL_API_ERROR, + "Failed to delete from Elasticsearch: " + e.getMessage()); + } + } + + /** + * Deletes document chunks from PostgreSQL. + */ + @Transactional + private void deleteChunksFromPostgreSQL(UUID documentId) { + try { + int deletedCount = documentChunkRepository.deleteBySourceDocumentId(documentId); + log.debug("Deleted {} chunks from PostgreSQL for document: {}", deletedCount, documentId); + + } catch (Exception e) { + log.error("Failed to delete chunks from PostgreSQL: documentId={}", documentId, e); + throw new BusinessException(ErrorCode.DATABASE_ERROR, + "Failed to delete chunks from PostgreSQL: " + e.getMessage()); + } + } + + // ===== Helper Methods ===== + + /** + * Parse sorting criteria + */ + private Sort parseSort(String sortParam) { + try { + String[] parts = sortParam.split(","); + if (parts.length == 2) { + String property = parts[0].trim(); + String direction = parts[1].trim(); + return "desc".equalsIgnoreCase(direction) + ? Sort.by(property).descending() + : Sort.by(property).ascending(); + } + return Sort.by(parts[0].trim()).descending(); + } catch (Exception e) { + log.warn("Invalid sort parameter: {}, using default", sortParam); + return Sort.by("createdAt").descending(); + } + } + + /** + * Convert SourceDocument to DTO + */ + private SourceDocumentDto convertToDto(SourceDocument document) { + return SourceDocumentDto.builder() + .id(document.getId().toString()) + .originalFilename(document.getOriginalFilename()) + .fileType(document.getFileType()) + .fileSize(document.getFileSize()) + .ingestionStatus(document.getIngestionStatus().name()) + .errorMessage(document.getErrorMessage()) + .lastIngestedAt(document.getLastIngestedAt()) + .createdAt(document.getCreatedAt()) + .updatedAt(document.getUpdatedAt()) + .build(); + } + + /** + * Find SourceDocument by ID (throws exception if not found) + */ + private SourceDocument findSourceDocumentById(UUID sourceId) { + return sourceDocumentRepository.findById(sourceId) + .orElseThrow(() -> new BusinessException(ErrorCode.SOURCE_DOCUMENT_NOT_FOUND, + "Document with the specified ID not found: " + sourceId)); + } + + /** + * Return HTTP status code based on ErrorCode + */ + private HttpStatus getHttpStatusFromErrorCode(ErrorCode errorCode) { + return switch (errorCode) { + case VALIDATION_FAILED -> HttpStatus.BAD_REQUEST; + case INSUFFICIENT_PERMISSION -> HttpStatus.FORBIDDEN; + case SOURCE_DOCUMENT_NOT_FOUND -> HttpStatus.NOT_FOUND; + case DUPLICATE_FILE_UPLOADED -> HttpStatus.CONFLICT; + case RESOURCE_IS_BEING_PROCESSED -> HttpStatus.CONFLICT; + case PAYLOAD_TOO_LARGE -> HttpStatus.PAYLOAD_TOO_LARGE; + case UNSUPPORTED_MEDIA_TYPE -> HttpStatus.UNSUPPORTED_MEDIA_TYPE; + default -> HttpStatus.INTERNAL_SERVER_ERROR; + }; + } + +} diff --git a/core/src/main/java/com/opencontext/dto/ChunkMetadata.java b/core/src/main/java/com/opencontext/dto/ChunkMetadata.java new file mode 100644 index 0000000..cabc0ee --- /dev/null +++ b/core/src/main/java/com/opencontext/dto/ChunkMetadata.java @@ -0,0 +1,68 @@ +package com.opencontext.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * Hierarchical and contextual metadata for document chunks. + * Supports document structure navigation and search result organization. + */ +@Schema(description = "Hierarchical and contextual metadata for document chunks") +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class ChunkMetadata { + + /** + * Title or heading of this chunk. + * Used for displaying chunk context to users in search results. + */ + @Schema(description = "Title or heading of the chunk", example = "5.8.2. JWT Authentication Converter") + private String title; + + /** + * Hierarchical breadcrumbs from document root to this chunk. + * Stored as array for flexibility in UI display and filtering. + * ES keyword type naturally supports arrays. + */ + @Schema(description = "Breadcrumb path from root to current chunk", + example = "[\"Chapter 5\", \"Security\", \"JWT\", \"Configuration\"]") + private List breadcrumbs; + + /** + * Depth level in document hierarchy (0 for root chunks). + * Used for hierarchy-aware search and display organization. + */ + @Schema(description = "Hierarchical depth level (0 for root)", example = "2") + @PositiveOrZero + private Integer hierarchyLevel; + + /** + * Sequential order within document structure. + * Maintains original document flow for coherent retrieval. + */ + @Schema(description = "Sequential position in document", example = "15") + @PositiveOrZero + private Integer sequenceInDocument; + + /** + * Document language for proper text processing. + * Currently supports Korean (ko) and English (en). + */ + @Schema(description = "Document language code", example = "ko") + private String language; + + /** + * Source file type for context and processing hints. + */ + @Schema(description = "Source file type", example = "PDF") + private String fileType; +} \ No newline at end of file diff --git a/core/src/main/java/com/opencontext/dto/GetContentRequest.java b/core/src/main/java/com/opencontext/dto/GetContentRequest.java new file mode 100644 index 0000000..cccf609 --- /dev/null +++ b/core/src/main/java/com/opencontext/dto/GetContentRequest.java @@ -0,0 +1,32 @@ +package com.opencontext.dto; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Schema(description = "DTO to request full content of a specific chunk") +@Getter +@lombok.Setter +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class GetContentRequest { + + @Schema(description = "Chunk ID to retrieve content for", requiredMode = Schema.RequiredMode.REQUIRED, + example = "a1b2c3d4-e5f6-7890-1234-567890abcdef-chunk-0") + @NotBlank(message = "chunkId is required") + @JsonAlias({"chunkID", "chunk_id", "id"}) + @JsonProperty("chunkId") + private String chunkId; + + @Schema(description = "Maximum number of tokens to return", defaultValue = "25000", example = "8000") + @Positive(message = "maxTokens must be positive") + @JsonProperty("maxTokens") + private Integer maxTokens; + + // Use default constructor + setters for robust Jackson binding +} diff --git a/core/src/main/java/com/opencontext/dto/GetContentResponse.java b/core/src/main/java/com/opencontext/dto/GetContentResponse.java new file mode 100644 index 0000000..4808513 --- /dev/null +++ b/core/src/main/java/com/opencontext/dto/GetContentResponse.java @@ -0,0 +1,38 @@ +package com.opencontext.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * Response DTO for the get_content API endpoint. + * Provides complete chunk content with token information for LLM context management. + */ +@Schema(description = "Response for focused content retrieval (get_content)") +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class GetContentResponse { + + /** + * Complete text content of the requested chunk. + * May be truncated if exceeds maxTokens limit, but still returns 200 OK. + */ + @Schema(description = "Complete chunk content (may be token-limited)", + requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank + private String content; + + /** + * Token processing information for LLM context management. + */ + @Schema(description = "Token processing information", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull + private TokenInfo tokenInfo; +} \ No newline at end of file diff --git a/core/src/main/java/com/opencontext/dto/SearchResultItem.java b/core/src/main/java/com/opencontext/dto/SearchResultItem.java new file mode 100644 index 0000000..9443695 --- /dev/null +++ b/core/src/main/java/com/opencontext/dto/SearchResultItem.java @@ -0,0 +1,67 @@ +package com.opencontext.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * Individual search result item for exploratory knowledge discovery. + * Contains essential information for users to identify and select relevant chunks. + */ +@Schema(description = "Individual search result item for exploratory search (find_knowledge)") +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class SearchResultItem { + + /** + * Chunk identifier used by get_content (string: "-chunk-"). + */ + @Schema(description = "Chunk identifier for content retrieval (e.g., '-chunk-0')", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank + private String chunkId; + + /** + * Title or heading of the chunk for user context. + */ + @Schema(description = "Chunk title or heading", example = "5.8.2. JWT Authentication Converter") + private String title; + + /** + * Content preview generated by extracting first 50 characters + "...". + * Following PRD specification for simple extraction over AI summarization. + */ + @Schema(description = "Content preview (first 50 chars + '...')", + example = "To customize the conversion from a JWT to an Auth...") + private String snippet; + + /** + * Relevance score from hybrid search (BM25 + Vector similarity). + * Normalized to 0.0-1.0 range for consistent interpretation. + * Always present in search results. + */ + @Schema(description = "Search relevance score", example = "0.92", minimum = "0.0", maximum = "1.0") + @DecimalMin(value = "0.0") + @DecimalMax(value = "1.0") + @NotNull + private Double relevanceScore; + + /** + * Hierarchical breadcrumbs for user navigation context. + * Helps users understand the chunk's position within document structure. + * Optional field that enhances UX when available. + */ + @Schema(description = "Breadcrumb path for navigation context", + example = "[\"Chapter 5\", \"Security\", \"JWT\", \"Configuration\"]") + private List breadcrumbs; +} \ No newline at end of file diff --git a/core/src/main/java/com/opencontext/dto/SearchResultsResponse.java b/core/src/main/java/com/opencontext/dto/SearchResultsResponse.java new file mode 100644 index 0000000..b634e30 --- /dev/null +++ b/core/src/main/java/com/opencontext/dto/SearchResultsResponse.java @@ -0,0 +1,21 @@ +package com.opencontext.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Schema(description = "Response DTO wrapping search results list") +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class SearchResultsResponse { + + @Schema(description = "Search results ordered by relevance") + private List results; +} diff --git a/core/src/main/java/com/opencontext/dto/SourceDocumentDto.java b/core/src/main/java/com/opencontext/dto/SourceDocumentDto.java new file mode 100644 index 0000000..8d2d338 --- /dev/null +++ b/core/src/main/java/com/opencontext/dto/SourceDocumentDto.java @@ -0,0 +1,69 @@ +package com.opencontext.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * DTO for source document information returned in list operations. + * + * Contains essential information about source documents for admin UI display. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SourceDocumentDto { + + /** + * Unique identifier of the source document. + */ + private String id; + + /** + * Original filename when uploaded. + */ + private String originalFilename; + + /** + * File type (PDF, MARKDOWN, etc.). + */ + private String fileType; + + /** + * File size in bytes. + */ + private Long fileSize; + + /** + * Current ingestion status. + */ + private String ingestionStatus; + + /** + * Error message if ingestion failed. + */ + private String errorMessage; + + /** + * Timestamp when document was last successfully ingested. + */ + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") + private LocalDateTime lastIngestedAt; + + /** + * Timestamp when document was created. + */ + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") + private LocalDateTime createdAt; + + /** + * Timestamp when document was last updated. + */ + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") + private LocalDateTime updatedAt; +} diff --git a/core/src/main/java/com/opencontext/dto/SourceUploadResponse.java b/core/src/main/java/com/opencontext/dto/SourceUploadResponse.java new file mode 100644 index 0000000..8f4cd69 --- /dev/null +++ b/core/src/main/java/com/opencontext/dto/SourceUploadResponse.java @@ -0,0 +1,61 @@ +package com.opencontext.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Response DTO for source document upload operations. + * + * This response is returned when a document is successfully uploaded + * and queued for ingestion processing, following PRD specifications. + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SourceUploadResponse { + + /** + * UUID of the created SourceDocument entity. + */ + private String sourceDocumentId; + + /** + * Original filename as provided by the client. + */ + private String originalFilename; + + /** + * Current ingestion status (always PENDING for new uploads). + */ + private String ingestionStatus; + + /** + * Message indicating the upload result. + */ + private String message; + + /** + * Timestamp when the upload was processed. + */ + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'") + private LocalDateTime timestamp; + + /** + * Creates a successful upload response. + */ + public static SourceUploadResponse success(String sourceDocumentId, String originalFilename) { + return SourceUploadResponse.builder() + .sourceDocumentId(sourceDocumentId) + .originalFilename(originalFilename) + .ingestionStatus("PENDING") + .message("File uploaded successfully and is now pending for ingestion.") + .timestamp(LocalDateTime.now()) + .build(); + } +} diff --git a/core/src/main/java/com/opencontext/dto/StructuredChunk.java b/core/src/main/java/com/opencontext/dto/StructuredChunk.java new file mode 100644 index 0000000..24a81eb --- /dev/null +++ b/core/src/main/java/com/opencontext/dto/StructuredChunk.java @@ -0,0 +1,82 @@ +package com.opencontext.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +/** + * Structured document chunk for Elasticsearch indexing and storage. + * Contains content, embeddings, and hierarchical metadata for semantic search. + */ +@Schema(description = "Elasticsearch document structure for structured chunks") +@Getter +@Builder(toBuilder = true) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class StructuredChunk { + + /** + * Unique identifier for the chunk. + */ + @Schema(description = "Unique chunk identifier", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank + private String chunkId; + + /** + * UUID of the source document this chunk belongs to. + */ + @Schema(description = "Source document identifier", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank + private String documentId; + + /** + * The actual text content of this chunk. + */ + @Schema(description = "Text content of the chunk", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank + private String content; + + /** + * Title or heading of the section this chunk belongs to. + */ + @Schema(description = "Section title or heading") + private String title; + + /** + * Hierarchy level of this chunk (1 = root, 2 = first level, etc.). + */ + @Schema(description = "Hierarchy level in document structure") + private Integer hierarchyLevel; + + /** + * ID of the parent chunk if this is a sub-chunk. + */ + @Schema(description = "Parent chunk identifier for hierarchical structure") + private String parentChunkId; + + /** + * Type of the original document element (Title, Header, NarrativeText, etc.). + */ + @Schema(description = "Original element type from document parsing") + private String elementType; + + /** + * Embedding vector for semantic search. + * Generated by embedding model via Ollama. + */ + @Schema(description = "Embedding vector for semantic search") + private List embedding; + + /** + * Additional metadata from document parsing. + */ + @Schema(description = "Additional metadata from document parsing") + private Map metadata; +} \ No newline at end of file diff --git a/core/src/main/java/com/opencontext/dto/TokenInfo.java b/core/src/main/java/com/opencontext/dto/TokenInfo.java new file mode 100644 index 0000000..af8db14 --- /dev/null +++ b/core/src/main/java/com/opencontext/dto/TokenInfo.java @@ -0,0 +1,40 @@ +package com.opencontext.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.PositiveOrZero; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * Token processing information for LLM context management. + */ +@Schema(description = "Token processing information for LLM context management") +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class TokenInfo { + + /** + * Name of the tokenizer used for token counting. + * Currently fixed to tiktoken-cl100k_base but extensible for future LLM support. + */ + @Schema(description = "Tokenizer used for counting", example = "tiktoken-cl100k_base", + requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank + private String tokenizer; + + /** + * Actual number of tokens in the returned content. + * May be less than original if content was truncated due to maxTokens limit. + * Can be 0 in extreme cases (empty content after processing). + */ + @Schema(description = "Actual token count of returned content", example = "7999", + requiredMode = Schema.RequiredMode.REQUIRED) + @PositiveOrZero + private Integer actualTokens; +} \ No newline at end of file diff --git a/core/src/main/java/com/opencontext/entity/DocumentChunk.java b/core/src/main/java/com/opencontext/entity/DocumentChunk.java new file mode 100644 index 0000000..db66106 --- /dev/null +++ b/core/src/main/java/com/opencontext/entity/DocumentChunk.java @@ -0,0 +1,168 @@ +package com.opencontext.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * Entity representing the hierarchical structure information of document chunks. + * This entity stores only the relationships and structure, while the actual + * content and search data are stored in Elasticsearch for performance. + */ +@Entity +@Table(name = "document_chunks") +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class DocumentChunk { + + /** + * Primary key - unique identifier for each Structured Chunk. + * This ID is identical to the chunkId in Elasticsearch for data consistency. + */ + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id") + private UUID id; + + /** + * Chunk ID as string for consistency with Elasticsearch. + */ + @Column(name = "chunk_id", nullable = false, unique = true, length = 255) + private String chunkId; + + /** + * Foreign key to the source document this chunk belongs to. + * When parent document is deleted, related chunks are also deleted (CASCADE). + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "source_document_id", nullable = false, foreignKey = @ForeignKey(name = "fk_chunk_source_document")) + private SourceDocument sourceDocument; + + /** + * Source document ID as UUID for direct access. + */ + @Column(name = "source_document_uuid", nullable = false) + private UUID sourceDocumentId; + + /** + * Parent chunk ID as string for consistency with Elasticsearch. + */ + @Column(name = "parent_chunk_id") + private UUID parentChunkId; + + /** + * Foreign key to the parent chunk for hierarchical structure. + * This enables reconstruction of document's tree structure. + * Root chunks have null parent_chunk_id. + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "parent_chunk_uuid", foreignKey = @ForeignKey(name = "fk_chunk_parent")) + private DocumentChunk parentChunk; + + /** + * Title or heading of the section this chunk belongs to. + */ + @Column(name = "title", length = 500) + private String title; + + /** + * Hierarchy level of this chunk in the document structure. + */ + @Column(name = "hierarchy_level", nullable = false) + private Integer hierarchyLevel; + + /** + * Type of the original document element (Title, Header, NarrativeText, etc.). + */ + @Column(name = "element_type", length = 50) + private String elementType; + + /** + * Order sequence among sibling chunks with the same parent_chunk_id. + * Used to maintain the original document structure and order. + */ + @Column(name = "sequence_in_document", nullable = false) + private Integer sequenceInDocument; + + /** + * Timestamp when this chunk record was first created in database. + */ + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** + * One-to-many relationship to child chunks. + * This enables easy navigation of the hierarchical structure. + */ + @OneToMany(mappedBy = "parentChunk", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) + @OrderBy("sequenceInDocument ASC") + @Builder.Default + private List childChunks = new ArrayList<>(); + + /** + * Adds a child chunk to this chunk, maintaining the hierarchical relationship. + * + * @param childChunk the child chunk to add + */ + public void addChildChunk(DocumentChunk childChunk) { + childChunks.add(childChunk); + } + + /** + * Checks if this chunk is a root chunk (has no parent). + * + * @return true if this chunk has no parent + */ + public boolean isRootChunk() { + return parentChunk == null; + } + + /** + * Checks if this chunk has any child chunks. + * + * @return true if this chunk has children + */ + public boolean hasChildren() { + return childChunks != null && !childChunks.isEmpty(); + } + + /** + * Gets the depth level of this chunk in the document hierarchy. + * Root chunks are at level 1, their children at level 2, etc. + * + * @return the depth level of this chunk + */ + public int getHierarchyLevel() { + if (isRootChunk()) { + return 1; + } + return parentChunk.getHierarchyLevel() + 1; + } + + /** + * Gets all ancestor chunks from root to this chunk's parent. + * + * @return list of ancestor chunks in order from root to parent + */ + public List getAncestors() { + List ancestors = new ArrayList<>(); + DocumentChunk current = this.parentChunk; + while (current != null) { + ancestors.add(0, current); // Add to beginning to maintain order + current = current.getParentChunk(); + } + return ancestors; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/opencontext/entity/SourceDocument.java b/core/src/main/java/com/opencontext/entity/SourceDocument.java new file mode 100644 index 0000000..ca0232a --- /dev/null +++ b/core/src/main/java/com/opencontext/entity/SourceDocument.java @@ -0,0 +1,183 @@ +package com.opencontext.entity; + +import com.opencontext.enums.IngestionStatus; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * Entity representing the master information of uploaded source files. + * This entity tracks the metadata and processing state of each document + * throughout the ingestion pipeline. + */ +@Entity +@Table(name = "source_documents") +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class SourceDocument { + + /** + * Primary key - unique identifier for each source document. + */ + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "id") + private UUID id; + + /** + * Original filename when user uploaded the file. + */ + @Column(name = "original_filename", nullable = false, length = 255) + private String originalFilename; + + /** + * Actual storage path within Object Storage (MinIO). + * Used to access the original file when needed. + */ + @Column(name = "file_storage_path", nullable = false, length = 1024) + private String fileStoragePath; + + /** + * Type of the uploaded file (e.g., PDF, MARKDOWN). + */ + @Column(name = "file_type", nullable = false, length = 50) + private String fileType; + + /** + * File size in bytes. + */ + @Column(name = "file_size", nullable = false) + private Long fileSize; + + /** + * SHA-256 hash of file content. + * Used to prevent duplicate uploads and verify file integrity. + */ + @Column(name = "file_checksum", nullable = false, length = 64, unique = true) + private String fileChecksum; + + /** + * Current status of the ingestion pipeline process. + * Tracks the document through states: PENDING → PARSING → CHUNKING → EMBEDDING → INDEXING → COMPLETED. + */ + @Enumerated(EnumType.STRING) + @Column(name = "ingestion_status", nullable = false, length = 20) + @Builder.Default + private IngestionStatus ingestionStatus = IngestionStatus.PENDING; + + /** + * Detailed error message when ingestion_status is ERROR. + * Used for developer debugging and user feedback. + */ + @Column(name = "error_message", columnDefinition = "TEXT") + private String errorMessage; + + /** + * Timestamp when this document was last successfully ingested. + */ + @Column(name = "last_ingested_at") + private LocalDateTime lastIngestedAt; + + /** + * Timestamp when this record was first created in database. + */ + @CreationTimestamp + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** + * Timestamp when this record was last updated. + */ + @UpdateTimestamp + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + /** + * Updates the ingestion status and clears error message if successful. + * + * @param newStatus the new status to set + */ + public void updateIngestionStatus(IngestionStatus newStatus) { + this.ingestionStatus = newStatus; + if (newStatus == IngestionStatus.COMPLETED) { + this.errorMessage = null; + this.lastIngestedAt = LocalDateTime.now(); + } + } + + /** + * Updates the ingestion status to ERROR with a detailed error message. + * + * @param errorMessage detailed error message for debugging + */ + public void updateIngestionStatusToError(String errorMessage) { + this.ingestionStatus = IngestionStatus.ERROR; + this.errorMessage = errorMessage; + } + + /** + * Updates the last ingested timestamp. + * + * @param timestamp the timestamp to set + */ + public void updateLastIngestedAt(LocalDateTime timestamp) { + this.lastIngestedAt = timestamp; + } + + /** + * Updates the error message. + * + * @param errorMessage the error message to set + */ + public void updateErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + /** + * Clears the error message. + */ + public void clearErrorMessage() { + this.errorMessage = null; + } + + /** + * Checks if the document is currently being processed. + * + * @return true if document is in any processing state + */ + public boolean isProcessing() { + return ingestionStatus == IngestionStatus.PARSING + || ingestionStatus == IngestionStatus.CHUNKING + || ingestionStatus == IngestionStatus.EMBEDDING + || ingestionStatus == IngestionStatus.INDEXING + || ingestionStatus == IngestionStatus.DELETING; + } + + /** + * Checks if the document processing has completed successfully. + * + * @return true if ingestion status is COMPLETED + */ + public boolean isCompleted() { + return ingestionStatus == IngestionStatus.COMPLETED; + } + + /** + * Checks if the document processing has failed with an error. + * + * @return true if ingestion status is ERROR + */ + public boolean hasError() { + return ingestionStatus == IngestionStatus.ERROR; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/opencontext/enums/ErrorCode.java b/core/src/main/java/com/opencontext/enums/ErrorCode.java new file mode 100644 index 0000000..077d29e --- /dev/null +++ b/core/src/main/java/com/opencontext/enums/ErrorCode.java @@ -0,0 +1,58 @@ +package com.opencontext.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +/** + * Enum defining all possible error codes in the system. + * Each error code provides an HTTP status code along with machine-parsable code + * and human-readable message. + */ +@Getter +@RequiredArgsConstructor +public enum ErrorCode { + + // --- COMMON --- + INVALID_REQUEST(HttpStatus.BAD_REQUEST, "COMMON_001", "Invalid request."), + VALIDATION_FAILED(HttpStatus.BAD_REQUEST, "COMMON_002", "Input validation failed."), + UNKNOWN_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON_003", "Unknown server error occurred."), + + // --- AUTHENTICATION --- + INSUFFICIENT_PERMISSION(HttpStatus.FORBIDDEN, "AUTH_001", "Insufficient permission to perform this request."), + + // --- DOCUMENT --- + SOURCE_DOCUMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "DOC_001", "Document with the specified ID not found."), + DUPLICATE_FILE_UPLOADED(HttpStatus.CONFLICT, "DOC_002", "A file with identical content already exists."), + RESOURCE_IS_BEING_PROCESSED(HttpStatus.CONFLICT, "DOC_003", "The document is currently being processed by another operation."), + PAYLOAD_TOO_LARGE(HttpStatus.PAYLOAD_TOO_LARGE, "DOC_004", "File size exceeds the maximum upload limit."), + UNSUPPORTED_MEDIA_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "DOC_005", "Unsupported file format. Please upload PDF or Markdown files."), + + // --- CONTEXT/SEARCH --- + TOKEN_LIMIT_EXCEEDED(HttpStatus.UNPROCESSABLE_ENTITY, "CTX_001", "Requested content token count exceeds maximum limit."), + CHUNK_NOT_FOUND(HttpStatus.NOT_FOUND, "CTX_002", "Chunk with the specified ID not found."), + + // --- FILE STORAGE --- + FILE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FILE_001", "Failed to upload file to storage."), + FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "FILE_002", "Requested file not found in storage."), + FILE_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FILE_003", "Failed to delete file from storage."), + STORAGE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "FILE_004", "Storage system error occurred."), + FILE_ACCESS_DENIED(HttpStatus.FORBIDDEN, "FILE_005", "Access denied to the requested file."), + + // --- INFRASTRUCTURE/EXTERNAL SERVICES --- + INGESTION_PIPELINE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "INFRA_001", "Internal error occurred during document processing."), + DELETION_PIPELINE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "INFRA_002", "Internal error occurred during document deletion."), + DOCUMENT_PARSING_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "INFRA_003", "Failed to parse document using external API."), + EMBEDDING_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "INFRA_004", "Failed to generate embeddings for document chunks."), + INDEXING_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "INFRA_005", "Failed to index document chunks."), + EXTERNAL_API_ERROR(HttpStatus.SERVICE_UNAVAILABLE, "INFRA_006", "External API call failed."), + DATABASE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "INFRA_007", "Database operation failed."), + EXTERNAL_SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "INFRA_008", "External service is not responding."), + DB_CONNECTION_FAILED(HttpStatus.SERVICE_UNAVAILABLE, "INFRA_009", "Database connection failed."), + ELASTICSEARCH_ERROR(HttpStatus.SERVICE_UNAVAILABLE, "INFRA_010", "Search engine error occurred."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} + diff --git a/core/src/main/java/com/opencontext/enums/IngestionStatus.java b/core/src/main/java/com/opencontext/enums/IngestionStatus.java new file mode 100644 index 0000000..a0dc630 --- /dev/null +++ b/core/src/main/java/com/opencontext/enums/IngestionStatus.java @@ -0,0 +1,63 @@ +package com.opencontext.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * Enum representing the progress states of the document ingestion pipeline. + * Each state clearly indicates how documents are being processed in the system. + */ +@Getter +@RequiredArgsConstructor +public enum IngestionStatus { + /** + * Initial state waiting for processing after user upload request. + * The system will start processing soon. + */ + PENDING("Waiting for processing"), + + /** + * Analyzing file text and structure through Unstructured API. + * This step may take time depending on file complexity. + */ + PARSING("Analyzing document structure"), + + /** + * Splitting parsed content into hierarchical chunks by semantic units. + * This is the stage where the document's logical structure is created. + */ + CHUNKING("Creating content chunks"), + + /** + * Converting each chunk to vectors through embedding model. + * This is the stage where core data for semantic search is generated. + */ + EMBEDDING("Generating semantic vectors"), + + /** + * Storing vectors and metadata in Elasticsearch, hierarchy info in PostgreSQL. + * This is the final data storage step to make content searchable. + */ + INDEXING("Storing search data"), + + /** + * Final state where all ingestion processes are successfully completed and searchable. + * Users can now search the content of this document. + */ + COMPLETED("Ready for search"), + + /** + * State where an unrecoverable error occurred during the ingestion process. + * Details are recorded in the 'error_message' column. + */ + ERROR("Processing failed"), + + /** + * Removing all related data (MinIO, PostgreSQL, Elasticsearch) after deletion request. + * No other operations can be performed during this state. + */ + DELETING("Removing data"); + + private final String description; +} + diff --git a/core/src/main/java/com/opencontext/exception/BusinessException.java b/core/src/main/java/com/opencontext/exception/BusinessException.java new file mode 100644 index 0000000..f207a94 --- /dev/null +++ b/core/src/main/java/com/opencontext/exception/BusinessException.java @@ -0,0 +1,47 @@ +package com.opencontext.exception; + +import com.opencontext.enums.ErrorCode; +import lombok.Getter; + +/** + * Class representing predictable exceptions that occur in business logic. + * This exception is used to provide clear error information to clients. + */ +@Getter +public class BusinessException extends RuntimeException { + + private final ErrorCode errorCode; + + /** + * Creates BusinessException using ErrorCode. + */ + public BusinessException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + /** + * Creates BusinessException using ErrorCode and custom message. + */ + public BusinessException(ErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + + /** + * Creates BusinessException using ErrorCode, custom message, and cause exception. + */ + public BusinessException(ErrorCode errorCode, String message, Throwable cause) { + super(message, cause); + this.errorCode = errorCode; + } + + /** + * Creates BusinessException using ErrorCode and cause exception. + */ + public BusinessException(ErrorCode errorCode, Throwable cause) { + super(errorCode.getMessage(), cause); + this.errorCode = errorCode; + } +} + diff --git a/core/src/main/java/com/opencontext/exception/GlobalExceptionHandler.java b/core/src/main/java/com/opencontext/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..545364f --- /dev/null +++ b/core/src/main/java/com/opencontext/exception/GlobalExceptionHandler.java @@ -0,0 +1,56 @@ +package com.opencontext.exception; + +import com.opencontext.common.CommonResponse; +import com.opencontext.enums.ErrorCode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * Global exception handler. + * Converts exceptions from all controllers into consistent CommonResponse format. + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + /** + * Handles business logic related exceptions. + */ + @ExceptionHandler(BusinessException.class) + public ResponseEntity> handleBusinessException(BusinessException ex) { + ErrorCode errorCode = ex.getErrorCode(); + log.warn("BusinessException occurred: code={}, message={}", errorCode.getCode(), ex.getMessage()); + return ResponseEntity + .status(errorCode.getHttpStatus()) + .body(CommonResponse.error(ex.getMessage(), errorCode.getCode())); + } + + /** + * Handles @Valid annotation validation failures. + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) { + ErrorCode errorCode = ErrorCode.VALIDATION_FAILED; + String firstErrorMessage = ex.getBindingResult().getAllErrors().get(0).getDefaultMessage(); + log.warn("Validation failed: {}", firstErrorMessage); + return ResponseEntity + .status(errorCode.getHttpStatus()) + .body(CommonResponse.error(firstErrorMessage, errorCode.getCode())); + } + + /** + * Handles all unexpected server errors. + */ + @ExceptionHandler(Exception.class) + public ResponseEntity> handleAllUncaughtException(Exception ex) { + ErrorCode errorCode = ErrorCode.UNKNOWN_SERVER_ERROR; + log.error("Unknown server error occurred", ex); + return ResponseEntity + .status(errorCode.getHttpStatus()) + .body(CommonResponse.error(errorCode.getMessage(), errorCode.getCode())); + } +} + diff --git a/core/src/main/java/com/opencontext/repository/DocumentChunkRepository.java b/core/src/main/java/com/opencontext/repository/DocumentChunkRepository.java new file mode 100644 index 0000000..22748b7 --- /dev/null +++ b/core/src/main/java/com/opencontext/repository/DocumentChunkRepository.java @@ -0,0 +1,155 @@ +package com.opencontext.repository; + +import com.opencontext.entity.DocumentChunk; +import com.opencontext.entity.SourceDocument; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Propagation; + +import java.util.List; +import java.util.UUID; + +/** + * Repository interface for DocumentChunk entity operations. + * Provides data access methods for hierarchical chunk structure management + * and chunk relationship queries. + */ +@Repository +public interface DocumentChunkRepository extends JpaRepository { + + /** + * Finds all chunks belonging to a specific source document. + * + * @param sourceDocument the source document to find chunks for + * @return list of chunks belonging to the source document + */ + List findBySourceDocument(SourceDocument sourceDocument); + + /** + * Finds all chunks belonging to a source document, ordered by sequence. + * + * @param sourceDocument the source document to find chunks for + * @return list of chunks ordered by sequence in document + */ + List findBySourceDocumentOrderBySequenceInDocumentAsc(SourceDocument sourceDocument); + + /** + * Finds all child chunks of a specific parent chunk. + * + * @param parentChunk the parent chunk to find children for + * @return list of child chunks + */ + List findByParentChunk(DocumentChunk parentChunk); + + /** + * Finds all root chunks (chunks without parent) for a source document. + * + * @param sourceDocument the source document to find root chunks for + * @return list of root chunks + */ + @Query("SELECT dc FROM DocumentChunk dc WHERE dc.sourceDocument = :sourceDocument AND dc.parentChunk IS NULL ORDER BY dc.sequenceInDocument ASC") + List findRootChunksBySourceDocument(@Param("sourceDocument") SourceDocument sourceDocument); + + /** + * Finds all root chunks (chunks without parent). + * + * @return list of all root chunks + */ + List findByParentChunkIsNull(); + + /** + * Finds all child chunks ordered by sequence. + * + * @param parentChunk the parent chunk to find children for + * @return list of child chunks ordered by sequence + */ + List findByParentChunkOrderBySequenceInDocumentAsc(DocumentChunk parentChunk); + + /** + * Counts the total number of chunks for a source document. + * + * @param sourceDocument the source document to count chunks for + * @return count of chunks belonging to the source document + */ + long countBySourceDocument(SourceDocument sourceDocument); + + /** + * Counts the number of child chunks for a parent chunk. + * + * @param parentChunk the parent chunk to count children for + * @return count of child chunks + */ + long countByParentChunk(DocumentChunk parentChunk); + + /** + * Checks if any chunks exist for a source document. + * + * @param sourceDocument the source document to check + * @return true if chunks exist for the source document + */ + boolean existsBySourceDocument(SourceDocument sourceDocument); + + /** + * Finds chunks by depth/hierarchy level using recursive query. + * This uses a Common Table Expression (CTE) to traverse the hierarchy. + * + * @param sourceDocument the source document to search within + * @param hierarchyLevel the hierarchy level to filter by (1 = root, 2 = first level children, etc.) + * @return list of chunks at the specified hierarchy level + */ + @Query(value = """ + WITH RECURSIVE chunk_hierarchy AS ( + SELECT id, source_document_id, parent_chunk_id, sequence_in_document, 1 as level + FROM document_chunks + WHERE source_document_id = :#{#sourceDocument.id} AND parent_chunk_id IS NULL + + UNION ALL + + SELECT dc.id, dc.source_document_id, dc.parent_chunk_id, dc.sequence_in_document, ch.level + 1 + FROM document_chunks dc + INNER JOIN chunk_hierarchy ch ON dc.parent_chunk_id = ch.id + ) + SELECT dc.* FROM document_chunks dc + INNER JOIN chunk_hierarchy ch ON dc.id = ch.id + WHERE ch.level = :hierarchyLevel + ORDER BY dc.sequence_in_document ASC + """, nativeQuery = true) + List findChunksByHierarchyLevel(@Param("sourceDocument") SourceDocument sourceDocument, + @Param("hierarchyLevel") int hierarchyLevel); + + /** + * Finds the maximum sequence number for a given source document and parent chunk. + * Used to determine the next sequence number when adding new chunks. + * + * @param sourceDocument the source document + * @param parentChunk the parent chunk (can be null for root chunks) + * @return the maximum sequence number, or 0 if no chunks exist + */ + @Query("SELECT COALESCE(MAX(dc.sequenceInDocument), 0) FROM DocumentChunk dc WHERE dc.sourceDocument = :sourceDocument AND dc.parentChunk = :parentChunk") + Integer findMaxSequenceForParent(@Param("sourceDocument") SourceDocument sourceDocument, + @Param("parentChunk") DocumentChunk parentChunk); + + /** + * Deletes all chunks belonging to a specific source document. + * This is used when a source document is being reprocessed. + * + * @param sourceDocument the source document whose chunks should be deleted + */ + void deleteBySourceDocument(SourceDocument sourceDocument); + + /** + * Deletes all chunks belonging to a specific source document by ID. + * This is used when a source document is being deleted. + * + * @param sourceDocumentId the source document ID whose chunks should be deleted + * @return the number of deleted chunks + */ + @Modifying + @Transactional(propagation = Propagation.REQUIRES_NEW) + @Query("DELETE FROM DocumentChunk dc WHERE dc.sourceDocumentId = :sourceDocumentId") + int deleteBySourceDocumentId(@Param("sourceDocumentId") UUID sourceDocumentId); +} \ No newline at end of file diff --git a/core/src/main/java/com/opencontext/repository/SourceDocumentRepository.java b/core/src/main/java/com/opencontext/repository/SourceDocumentRepository.java new file mode 100644 index 0000000..f21779a --- /dev/null +++ b/core/src/main/java/com/opencontext/repository/SourceDocumentRepository.java @@ -0,0 +1,141 @@ +package com.opencontext.repository; + +import com.opencontext.entity.SourceDocument; +import com.opencontext.enums.IngestionStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * Repository interface for SourceDocument entity operations. + * Provides data access methods for document metadata management + * and ingestion pipeline status tracking. + */ +@Repository +public interface SourceDocumentRepository extends JpaRepository { + + /** + * Finds a document by its file checksum to prevent duplicate uploads. + * + * @param fileChecksum SHA-256 hash of the file content + * @return Optional containing the document if found + */ + Optional findByFileChecksum(String fileChecksum); + + /** + * Finds all documents with a specific ingestion status. + * + * @param status the ingestion status to filter by + * @return list of documents with the specified status + */ + List findByIngestionStatus(IngestionStatus status); + + /** + * Finds all documents with a specific ingestion status with pagination. + * + * @param status the ingestion status to filter by + * @param pageable pagination parameters + * @return page of documents with the specified status + */ + Page findByIngestionStatus(IngestionStatus status, Pageable pageable); + + /** + * Finds all documents that have been successfully ingested. + * + * @return list of completed documents + */ + @Query("SELECT sd FROM SourceDocument sd WHERE sd.ingestionStatus = 'COMPLETED'") + List findAllCompleted(); + + /** + * Finds all documents that failed during ingestion. + * + * @return list of documents with errors + */ + @Query("SELECT sd FROM SourceDocument sd WHERE sd.ingestionStatus = 'ERROR'") + List findAllWithErrors(); + + /** + * Finds all documents that are currently being processed. + * + * @return list of documents in processing states + */ + @Query("SELECT sd FROM SourceDocument sd WHERE sd.ingestionStatus IN ('PARSING', 'CHUNKING', 'EMBEDDING', 'INDEXING')") + List findAllProcessing(); + + /** + * Finds documents by filename pattern (case-insensitive). + * + * @param filename the filename pattern to search for + * @return list of documents matching the filename pattern + */ + @Query("SELECT sd FROM SourceDocument sd WHERE LOWER(sd.originalFilename) LIKE LOWER(CONCAT('%', :filename, '%'))") + List findByOriginalFilenameContainingIgnoreCase(@Param("filename") String filename); + + /** + * Finds documents created within a specific time range. + * + * @param startDate the start of the time range + * @param endDate the end of the time range + * @return list of documents created within the range + */ + List findByCreatedAtBetween(LocalDateTime startDate, LocalDateTime endDate); + + /** + * Finds documents that were last ingested within a specific time range. + * + * @param startDate the start of the time range + * @param endDate the end of the time range + * @return list of documents last ingested within the range + */ + List findByLastIngestedAtBetween(LocalDateTime startDate, LocalDateTime endDate); + + /** + * Counts the total number of documents by ingestion status. + * + * @param status the ingestion status to count + * @return count of documents with the specified status + */ + long countByIngestionStatus(IngestionStatus status); + + /** + * Finds documents by file type (case-insensitive). + * + * @param fileType the file type to filter by (e.g., "PDF", "MARKDOWN") + * @return list of documents with the specified file type + */ + List findByFileTypeIgnoreCase(String fileType); + + /** + * Checks if a document with the given checksum already exists. + * + * @param fileChecksum SHA-256 hash of the file content + * @return true if a document with this checksum exists + */ + boolean existsByFileChecksum(String fileChecksum); + + /** + * Finds documents ordered by creation date (most recent first). + * + * @param pageable pagination parameters + * @return page of documents ordered by creation date + */ + Page findAllByOrderByCreatedAtDesc(Pageable pageable); + + /** + * Finds documents that have been processing for too long (potential stuck documents). + * + * @param cutoffTime the time before which documents are considered stuck + * @return list of potentially stuck documents + */ + @Query("SELECT sd FROM SourceDocument sd WHERE sd.ingestionStatus IN ('PARSING', 'CHUNKING', 'EMBEDDING', 'INDEXING') AND sd.updatedAt < :cutoffTime") + List findStuckProcessingDocuments(@Param("cutoffTime") LocalDateTime cutoffTime); +} \ No newline at end of file diff --git a/core/src/main/java/com/opencontext/service/ChunkingService.java b/core/src/main/java/com/opencontext/service/ChunkingService.java new file mode 100644 index 0000000..8044ffa --- /dev/null +++ b/core/src/main/java/com/opencontext/service/ChunkingService.java @@ -0,0 +1,371 @@ +package com.opencontext.service; + +import com.opencontext.dto.StructuredChunk; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.*; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ChunkingService { + + /** + * Converts parsed elements into structured chunks. + * + * @param documentId Document ID + * @param parsedElements List of elements parsed by the Unstructured API + * @return List of structured chunks + */ + public List createChunks(UUID documentId, List> parsedElements) { + long startTime = System.currentTimeMillis(); + int totalElements = parsedElements.size(); + + log.info("🧩 [CHUNKING] Starting chunking process: documentId={}, elements={}", documentId, totalElements); + + List chunks = new ArrayList<>(); + int chunkIndex = 0; + int processedElements = 0; + + log.debug("📄 [CHUNKING] Processing {} elements for Title-based chunking", totalElements); + + // Variables for dividing chunks based on Title + String currentTitle = null; + StringBuilder currentChunkContent = new StringBuilder(); + List> currentChunkElements = new ArrayList<>(); + int currentTitleLevel = 1; + + for (Map element : parsedElements) { + processedElements++; + String elementType = (String) element.get("type"); + String text = (String) element.get("text"); + + if (processedElements % 50 == 0 || processedElements == totalElements) { + log.debug("📊 [CHUNKING] Progress: {}/{} elements processed", processedElements, totalElements); + } + + if (text == null || text.trim().isEmpty()) { + log.debug("[CHUNKING] Skipping empty element: type={}", elementType); + continue; + } + + // When Title is encountered, save the previous chunk and start a new chunk + // Since title_depth=1 is set in Unstructured API, only # headers will come as Title + boolean isUnstructuredTitle = "Title".equals(elementType); + + // Additionally check for # headers directly in text (as backup) + boolean isH1Header = isH1MarkdownHeader(text); + + if (isUnstructuredTitle || isH1Header) { + log.debug("[CHUNKING] Detected H1 title/header: text='{}', elementType='{}', isUnstructuredTitle={}, isH1Header={}", + text, elementType, isUnstructuredTitle, isH1Header); + // Save previous chunk if exists + if (currentChunkContent.length() > 0 && currentTitle != null) { + String chunkContent = currentChunkContent.toString().trim(); + // Create as one chunk without splitting + StructuredChunk chunk = createTitleBasedChunk(documentId, chunkIndex++, currentTitle, + chunkContent, currentTitleLevel, currentChunkElements); + chunks.add(chunk); + + // Log created chunk information + int tokenCount = estimateTokenCount(chunkContent); + log.info("📦 [CHUNK CREATED] Title: '{}', Length: {}, Tokens: ~{}, Level: {}, ChunkId: {}", + chunk.getTitle(), chunk.getContent().length(), tokenCount, chunk.getHierarchyLevel(), chunk.getChunkId()); + log.debug("📄 [CHUNK CONTENT] Content preview: {}", + chunkContent.length() > 200 ? chunkContent.substring(0, 200) + "..." : chunkContent); + } + + // Start new chunk + currentTitle = isH1Header ? extractMarkdownHeaderText(text) : text; + currentChunkContent = new StringBuilder(); + currentChunkElements = new ArrayList<>(); + + // Determine Title level (H1 is level 1) + currentTitleLevel = 1; + + log.info("[CHUNKING] Starting new H1 chunk with title: '{}' (level: {})", + currentTitle, currentTitleLevel); + } else { + // Elements without Title are considered as first elements and added to currentChunkContent + if (currentTitle != null) { + // Add appropriate separator based on element type + if (currentChunkContent.length() > 0) { + currentChunkContent.append("\n\n"); + } + + // Format based on element type + switch (elementType) { + case "Header" -> { + // Apply markdown formatting based on header level + int headerLevel = determineHeaderLevel(element); + currentChunkContent.append("#".repeat(Math.max(1, headerLevel))).append(" ").append(text); + } + case "BlockQuote" -> { + currentChunkContent.append("> ").append(text); + } + case "Code" -> { + currentChunkContent.append("```\n").append(text).append("\n```"); + } + case "ListItem" -> { + currentChunkContent.append("• ").append(text); + } + case "HorizontalRule" -> { + currentChunkContent.append("---"); + } + case "Table" -> { + currentChunkContent.append("[Table]\n").append(text); + } + default -> { + currentChunkContent.append(text); + } + } + + currentChunkElements.add(element); + } else { + // Elements without Title are considered as first elements and added to currentChunkContent + log.debug("🔸 [CHUNKING] Adding element without title to content: type={}", elementType); + if (currentChunkContent.length() > 0) { + currentChunkContent.append("\n\n"); + } + currentChunkContent.append(text); + currentChunkElements.add(element); + + // Set default title if first element is not Title + if (currentTitle == null && !currentChunkElements.isEmpty()) { + currentTitle = "Document Start Section"; + currentTitleLevel = 1; + log.info("[CHUNKING] Setting default title for document start"); + } + } + } + } + + // Process final chunk + if (currentChunkContent.length() > 0) { + String chunkContent = currentChunkContent.toString().trim(); + // Set default title if none exists + if (currentTitle == null) { + currentTitle = "Document Content"; + currentTitleLevel = 1; + } + + // Create as one chunk without splitting + StructuredChunk finalChunk = createTitleBasedChunk(documentId, chunkIndex++, currentTitle, + chunkContent, currentTitleLevel, currentChunkElements); + chunks.add(finalChunk); + + // Log final chunk information + int finalTokenCount = estimateTokenCount(chunkContent); + log.info("📦 [FINAL CHUNK] Title: '{}', Length: {}, Tokens: ~{}, Level: {}, ChunkId: {}", + finalChunk.getTitle(), finalChunk.getContent().length(), finalTokenCount, finalChunk.getHierarchyLevel(), finalChunk.getChunkId()); + log.debug("📄 [FINAL CONTENT] Content preview: {}", + chunkContent.length() > 200 ? chunkContent.substring(0, 200) + "..." : chunkContent); + } + + long duration = System.currentTimeMillis() - startTime; + int finalChunkCount = chunks.size(); + + log.info("🎉 [CHUNKING] Title-based chunking completed successfully: documentId={}, elements={}, chunks={}, duration={}ms", + documentId, totalElements, finalChunkCount, duration); + + // Log chunk statistics + if (finalChunkCount > 0) { + double avgChunkLength = chunks.stream() + .mapToInt(c -> c.getContent().length()) + .average() + .orElse(0.0); + + double avgTokenCount = chunks.stream() + .mapToInt(c -> estimateTokenCount(c.getContent())) + .average() + .orElse(0.0); + + log.info("📊 [CHUNKING STATS] avgChunkLength={}, avgTokens={}, totalChunks={}", + Math.round(avgChunkLength), Math.round(avgTokenCount), finalChunkCount); + + // Log summary information for each chunk + log.info("📋 [CHUNK SUMMARY] Generated chunks:"); + for (int i = 0; i < chunks.size(); i++) { + StructuredChunk chunk = chunks.get(i); + int chunkTokens = estimateTokenCount(chunk.getContent()); + log.info(" {}. Title: '{}' | Type: {} | Length: {} | Tokens: ~{} | Level: {}", + i + 1, + chunk.getTitle() != null ? chunk.getTitle() : "N/A", + chunk.getElementType(), + chunk.getContent().length(), + chunkTokens, + chunk.getHierarchyLevel()); + + // Display preview if content is long + String contentPreview = chunk.getContent().length() > 500 ? + chunk.getContent().substring(0, 500) + "..." : chunk.getContent(); + log.info(" Content Preview: {}", contentPreview); + } + } + + return chunks; + } + + /** + * Creates a Title-based chunk. + */ + private StructuredChunk createTitleBasedChunk(UUID documentId, int chunkIndex, String title, + String content, int titleLevel, List> elements) { + return StructuredChunk.builder() + .documentId(documentId.toString()) + .chunkId(generateChunkId(documentId, chunkIndex)) + .content(content) + .title(title) + .hierarchyLevel(titleLevel) + .parentChunkId(null) // Title-based chunks are independent + .elementType("TitleBasedChunk") + .metadata(createTitleBasedMetadata(title, titleLevel, elements)) + .build(); + } + + /** + * Creates metadata for Title-based chunks. + */ + private Map createTitleBasedMetadata(String title, int titleLevel, List> elements) { + Map metadata = new HashMap<>(); + metadata.put("chunk_type", "title_based"); + metadata.put("title_level", titleLevel); + metadata.put("element_count", elements.size()); + + // Count elements by type + Map elementTypeCounts = elements.stream() + .collect(java.util.stream.Collectors.groupingBy( + e -> (String) e.get("type"), + java.util.stream.Collectors.counting() + )); + metadata.put("element_type_counts", elementTypeCounts); + + // Preserve metadata information for the first and last elements + if (!elements.isEmpty()) { + Map firstElement = elements.get(0); + Map lastElement = elements.get(elements.size() - 1); + + if (firstElement.containsKey("metadata")) { + metadata.put("first_element_metadata", firstElement.get("metadata")); + } + if (lastElement.containsKey("metadata")) { + metadata.put("last_element_metadata", lastElement.get("metadata")); + } + } + + return metadata; + } + + /** + * Checks if it's an H1 markdown header (single #). + * Since title_depth=1 is set, only # will come as Title, so simple check is sufficient + */ + private boolean isH1MarkdownHeader(String text) { + if (text == null || text.trim().isEmpty()) { + return false; + } + String trimmed = text.trim(); + + // Check for pattern starting with single # followed by space + if (trimmed.matches("^#\\s+.+")) { + String headerText = trimmed.replaceFirst("^#\\s*", "").trim(); + + // Exclude JavaScript keywords or console.log etc. + if (headerText.matches("^(console\\.|function\\s|var\\s|let\\s|const\\s|if\\s*\\(|for\\s*\\(|while\\s*\\(|switch\\s*\\(|class\\s|return\\s|break\\s*;|continue\\s*;).*")) { + log.debug("[CHUNKING] Excluding JavaScript code from H1 header: '{}'", headerText); + return false; + } + + log.debug("[CHUNKING] Valid H1 header detected: '{}'", headerText); + return true; + } + + return false; + } + + /** + * Extracts text from markdown header. + */ + private String extractMarkdownHeaderText(String text) { + if (text == null || text.trim().isEmpty()) { + return text; + } + String trimmed = text.trim(); + // Remove # symbols and spaces to extract only the actual title text + return trimmed.replaceFirst("^#+\\s*", "").trim(); + } + + /** + * Estimates the token count of text. + * Approximate calculation based on GPT series tokenizer + * (For accurate token calculation, actual tokenizer library should be used) + */ + private int estimateTokenCount(String text) { + if (text == null || text.trim().isEmpty()) { + return 0; + } + + // Simple token estimation algorithm: + // 1. Split by spaces into words + // 2. On average: English word = 1.3 tokens, Korean character = 1.5 tokens + // 3. Consider punctuation and special characters + + String cleanText = text.trim(); + + // Word count based on spaces + String[] words = cleanText.split("\\s+"); + int wordCount = words.length; + + // Korean character count + long koreanCharCount = cleanText.chars() + .filter(c -> Character.UnicodeBlock.of(c) == Character.UnicodeBlock.HANGUL_SYLLABLES || + Character.UnicodeBlock.of(c) == Character.UnicodeBlock.HANGUL_JAMO || + Character.UnicodeBlock.of(c) == Character.UnicodeBlock.HANGUL_COMPATIBILITY_JAMO) + .count(); + + // English character count + long englishCharCount = cleanText.chars() + .filter(c -> (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) + .count(); + + // Number and special character count + long otherCharCount = cleanText.length() - koreanCharCount - englishCharCount; + + // Token count estimation + // - English words: average 1.3 tokens + // - Korean characters: 1.5 tokens + // - Other characters: 0.8 tokens + double estimatedTokens = (wordCount * 1.3) + (koreanCharCount * 1.5) + (otherCharCount * 0.8); + + // Ensure minimum 1 token + return Math.max(1, (int) Math.round(estimatedTokens)); + } + + /** + * Generates chunk ID. + */ + private String generateChunkId(UUID documentId, int chunkIndex) { + return documentId.toString() + "-chunk-" + chunkIndex; + } + + /** + * Determines header level. + */ + private int determineHeaderLevel(Map element) { + // Attempt to extract level information from metadata + @SuppressWarnings("unchecked") + Map metadata = (Map) element.get("metadata"); + if (metadata != null && metadata.containsKey("category_depth")) { + try { + return ((Number) metadata.get("category_depth")).intValue(); + } catch (Exception e) { + log.debug("Failed to extract header level from metadata", e); + } + } + + // Return default value + return 2; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/opencontext/service/ContentRetrievalService.java b/core/src/main/java/com/opencontext/service/ContentRetrievalService.java new file mode 100644 index 0000000..df1762d --- /dev/null +++ b/core/src/main/java/com/opencontext/service/ContentRetrievalService.java @@ -0,0 +1,239 @@ +package com.opencontext.service; + +import com.opencontext.dto.GetContentResponse; +import com.opencontext.dto.TokenInfo; +import com.opencontext.enums.ErrorCode; +import com.opencontext.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; + +/** + * Service for retrieving chunk content and applying token limits + * Applies token limits based on tiktoken-cl100k_base tokenizer according to PRD specifications + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ContentRetrievalService { + + private final RestTemplate restTemplate; + + @Value("${app.elasticsearch.url:http://localhost:9200}") + private String elasticsearchUrl; + + @Value("${app.elasticsearch.index:document_chunks_index}") + private String indexName; + + @Value("${app.content.default-max-tokens:25000}") + private int defaultMaxTokens; + + @Value("${app.content.tokenizer:tiktoken-cl100k_base}") + private String tokenizerName; + + /** + * Retrieves the complete content of a single chunk and applies token limits + * + * @param chunkId Chunk ID to retrieve + * @param maxTokens Maximum token count (uses default if null) + * @return Content with token limits applied and token information + */ + public GetContentResponse getContent(String chunkId, Integer maxTokens) { + long startTime = System.currentTimeMillis(); + + int effectiveMaxTokens = maxTokens != null ? maxTokens : defaultMaxTokens; + + log.info("Starting chunk content retrieval: chunkId={}, maxTokens={}", chunkId, effectiveMaxTokens); + + try { + // Step 1: Retrieve chunk content from Elasticsearch + String content = fetchChunkContent(chunkId); + + // Step 2: Apply token limits + GetContentResponse response = applyTokenLimit(content, effectiveMaxTokens); + + long duration = System.currentTimeMillis() - startTime; + log.info("Chunk content retrieval completed: chunkId={}, originalLength={}, tokenCount={}, duration={}ms", + chunkId, content.length(), response.getTokenInfo().getActualTokens(), duration); + + return response; + + } catch (BusinessException e) { + throw e; // Propagate business exceptions as-is + } catch (Exception e) { + long duration = System.currentTimeMillis() - startTime; + log.error("Chunk content retrieval failed: chunkId={}, duration={}ms, error={}", + chunkId, duration, e.getMessage(), e); + throw new BusinessException(ErrorCode.ELASTICSEARCH_ERROR, + "Content retrieval failed: " + e.getMessage()); + } + } + + /** + * Retrieves content of a specific chunk from Elasticsearch + */ + private String fetchChunkContent(String chunkId) { + log.debug("Retrieving chunk from Elasticsearch: chunkId={}", chunkId); + + try { + String getUrl = elasticsearchUrl + "/" + indexName + "/_doc/" + chunkId; + + // Use _source filter to retrieve only content field (performance optimization) + String getUrlWithSource = getUrl + "?_source=content"; + + ResponseEntity response = restTemplate.getForEntity(getUrlWithSource, Map.class); + + if (response.getStatusCode() == HttpStatus.NOT_FOUND) { + throw new BusinessException(ErrorCode.CHUNK_NOT_FOUND, + "Chunk not found: " + chunkId); + } + + if (response.getStatusCode() != HttpStatus.OK) { + throw new BusinessException(ErrorCode.ELASTICSEARCH_ERROR, + "Failed to fetch chunk with status: " + response.getStatusCode()); + } + + Map responseBody = response.getBody(); + if (responseBody == null || !Boolean.TRUE.equals(responseBody.get("found"))) { + throw new BusinessException(ErrorCode.CHUNK_NOT_FOUND, + "Chunk not found: " + chunkId); + } + + // Extract content from _source + Map source = (Map) responseBody.get("_source"); + if (source == null) { + throw new BusinessException(ErrorCode.ELASTICSEARCH_ERROR, + "Content source is null for chunk: " + chunkId); + } + + String content = (String) source.get("content"); + if (content == null) { + throw new BusinessException(ErrorCode.ELASTICSEARCH_ERROR, + "Content field is null for chunk: " + chunkId); + } + + log.debug("Chunk content retrieval successful: chunkId={}, length={}", chunkId, content.length()); + return content; + + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error("Elasticsearch chunk retrieval failed: chunkId={}, error={}", chunkId, e.getMessage(), e); + throw new BusinessException(ErrorCode.ELASTICSEARCH_ERROR, + "Failed to fetch chunk from Elasticsearch: " + e.getMessage()); + } + } + + /** + * Applies token limits to content and creates response DTO + * PRD policy: Truncate text from the end when maxTokens is exceeded (preserve beginning priority) + */ + private GetContentResponse applyTokenLimit(String content, int maxTokens) { + log.debug("Applying token limit: originalLength={}, maxTokens={}", content.length(), maxTokens); + + try { + // Calculate current token count of content + int currentTokens = calculateTokenCount(content); + + String finalContent = content; + int actualTokens = currentTokens; + + // Truncate text from the end if token count exceeds limit + if (currentTokens > maxTokens) { + log.debug("Token limit exceeded, truncating text: currentTokens={}, limitTokens={}", currentTokens, maxTokens); + + finalContent = truncateContentByTokens(content, maxTokens); + actualTokens = calculateTokenCount(finalContent); + + log.debug("Text truncation completed: finalLength={}, finalTokens={}", finalContent.length(), actualTokens); + } + + // Create token information + TokenInfo tokenInfo = TokenInfo.builder() + .tokenizer(tokenizerName) + .actualTokens(actualTokens) + .build(); + + // Create response DTO + return GetContentResponse.builder() + .content(finalContent) + .tokenInfo(tokenInfo) + .build(); + + } catch (Exception e) { + log.error("Token limit application failed: error={}", e.getMessage(), e); + throw new BusinessException(ErrorCode.ELASTICSEARCH_ERROR, + "Token limit processing failed: " + e.getMessage()); + } + } + + /** + * Calculate token count based on tiktoken-cl100k_base tokenizer + * Simple approximation (actual tiktoken library needed for accurate implementation) + */ + private int calculateTokenCount(String text) { + if (text == null || text.isEmpty()) { + return 0; + } + + // Simple token count approximation + // Actually need tiktoken Java binding or external API + // Currently approximates: English average 4 chars = 1 token, Korean 1.5 chars = 1 token + + int englishChars = 0; + int koreanChars = 0; + int otherChars = 0; + + for (char c : text.toCharArray()) { + if (c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == ' ') { + englishChars++; + } else if (c >= 0xAC00 && c <= 0xD7AF) { // Korean range + koreanChars++; + } else { + otherChars++; + } + } + + int estimatedTokens = (int) Math.ceil(englishChars / 4.0) + + (int) Math.ceil(koreanChars / 1.5) + + (int) Math.ceil(otherChars / 2.0); + + return Math.max(estimatedTokens, 1); // Minimum 1 token + } + + /** + * Truncate text based on token count (preserve beginning priority) + * PRD policy: Remove from text end to preserve important content at beginning + */ + private String truncateContentByTokens(String content, int maxTokens) { + if (content == null || content.isEmpty()) { + return content; + } + + // Use binary search to find the appropriate truncation point + int left = 0; + int right = content.length(); + String result = content; + + while (left < right) { + int mid = (left + right + 1) / 2; + String candidate = content.substring(0, mid); + int candidateTokens = calculateTokenCount(candidate); + + if (candidateTokens <= maxTokens) { + result = candidate; + left = mid; + } else { + right = mid - 1; + } + } + + return result; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/opencontext/service/DocumentParsingService.java b/core/src/main/java/com/opencontext/service/DocumentParsingService.java new file mode 100644 index 0000000..48af1db --- /dev/null +++ b/core/src/main/java/com/opencontext/service/DocumentParsingService.java @@ -0,0 +1,183 @@ +package com.opencontext.service; + +import com.opencontext.entity.SourceDocument; +import com.opencontext.enums.ErrorCode; +import com.opencontext.exception.BusinessException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.io.InputStream; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Service for parsing documents using the Unstructured API. + * + * Service for parsing documents using the Unstructured API. + * + * Converts PDF, Markdown, and text files into structured elements. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class DocumentParsingService { + + private final FileStorageService fileStorageService; + private final RestTemplate restTemplate; + + @Value("${app.unstructured.api.url:http://unstructured-api:8000}") + private String unstructuredApiUrl; + + /** + * Parses a document and returns a list of structured elements. + * + * @param documentId Document ID to parse + * @return List of parsed elements (Unstructured API response) + */ + public List> parseDocument(UUID documentId) { + long startTime = System.currentTimeMillis(); + log.info("[PARSING] Starting document parsing: documentId={}", documentId); + + log.debug("📖 [PARSING] Step 1/3: Retrieving document metadata: documentId={}", documentId); + SourceDocument document = fileStorageService.getDocument(documentId); + String filename = document.getOriginalFilename(); + String fileType = document.getFileType(); + long fileSize = document.getFileSize(); + + log.info("[PARSING] Document metadata retrieved: filename={}, fileType={}, size={} bytes", + filename, fileType, fileSize); + + try { + // Step 2: Download file from MinIO + log.debug("[PARSING] Step 2/3: Downloading file from MinIO: path={}", document.getFileStoragePath()); + InputStream fileStream = fileStorageService.downloadFile(document.getFileStoragePath()); + log.info("[PARSING] File downloaded from storage: filename={}, path={}", + filename, document.getFileStoragePath()); + + // Step 3: Call Unstructured API + log.debug("🤖 [PARSING] Step 3/3: Calling Unstructured API: filename={}, fileType={}", filename, fileType); + List> parsedElements = callUnstructuredApi( + fileStream, + filename, + fileType + ); + + long duration = System.currentTimeMillis() - startTime; + log.info("🎉 [PARSING] Document parsing completed successfully: documentId={}, filename={}, elements={}, duration={}ms", + documentId, filename, parsedElements.size(), duration); + + return parsedElements; + + } catch (Exception e) { + long duration = System.currentTimeMillis() - startTime; + log.error("[PARSING] Document parsing failed: documentId={}, filename={}, duration={}ms, error={}", + documentId, filename, duration, e.getMessage(), e); + throw new BusinessException(ErrorCode.DOCUMENT_PARSING_FAILED, + "Document parsing failed: " + e.getMessage()); + } + } + + /** + * Calls the Unstructured API to parse the document. + */ + @SuppressWarnings("unchecked") + private List> callUnstructuredApi(InputStream fileStream, + String filename, + String fileType) { + long apiStartTime = System.currentTimeMillis(); + log.debug("🤖 [UNSTRUCTURED-API] Starting API call: filename={}, fileType={}, url={}", + filename, fileType, unstructuredApiUrl); + + try { + // Set HTTP headers + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + + // Create multipart request + log.debug("📦 [UNSTRUCTURED-API] Preparing multipart request: filename={}", filename); + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("files", new InputStreamResource(fileStream, filename)); + + // Set parsing options + body.add("strategy", "auto"); + body.add("coordinates", "true"); + body.add("extract_images_in_pdf", "false"); + body.add("infer_table_structure", "true"); + + log.debug("[UNSTRUCTURED-API] Request parameters: strategy=auto, coordinates=true, extract_images_in_pdf=false, infer_table_structure=true"); + + HttpEntity> requestEntity = new HttpEntity<>(body, headers); + + // Call API + log.debug("[UNSTRUCTURED-API] Sending HTTP request: {}/general/v0/general", unstructuredApiUrl); + ResponseEntity>> response = restTemplate.exchange( + unstructuredApiUrl + "/general/v0/general", + HttpMethod.POST, + requestEntity, + (Class>>) (Class) List.class + ); + + long apiDuration = System.currentTimeMillis() - apiStartTime; + + if (response.getStatusCode() != HttpStatus.OK || response.getBody() == null) { + log.error("[UNSTRUCTURED-API] API returned unexpected response: filename={}, status={}, duration={}ms", + filename, response.getStatusCode(), apiDuration); + throw new BusinessException(ErrorCode.EXTERNAL_API_ERROR, + "Unstructured API returned unexpected response"); + } + + List> elements = response.getBody(); + int elementCount = elements.size(); + + log.info("[UNSTRUCTURED-API] API call successful: filename={}, elements={}, duration={}ms, status={}", + filename, elementCount, apiDuration, response.getStatusCode()); + + if (elementCount > 0) { + log.debug("📊 [UNSTRUCTURED-API] Element types found: {}", + elements.stream() + .map(e -> e.get("type")) + .distinct() + .collect(java.util.stream.Collectors.toList())); + } + + return elements; + + } catch (Exception e) { + long apiDuration = System.currentTimeMillis() - apiStartTime; + log.error("[UNSTRUCTURED-API] API call failed: filename={}, duration={}ms, error={}", + filename, apiDuration, e.getMessage(), e); + throw new BusinessException(ErrorCode.EXTERNAL_API_ERROR, + "Failed to call Unstructured API: " + e.getMessage()); + } + } + + /** + * Helper class that wraps InputStream as Spring's Resource + */ + private static class InputStreamResource extends org.springframework.core.io.InputStreamResource { + private final String filename; + + public InputStreamResource(InputStream inputStream, String filename) { + super(inputStream); + this.filename = filename; + } + + @Override + public String getFilename() { + return this.filename; + } + + @Override + public long contentLength() { + return -1; // Indicates unknown + } + } +} diff --git a/core/src/main/java/com/opencontext/service/EmbeddingService.java b/core/src/main/java/com/opencontext/service/EmbeddingService.java new file mode 100644 index 0000000..082e88c --- /dev/null +++ b/core/src/main/java/com/opencontext/service/EmbeddingService.java @@ -0,0 +1,201 @@ +package com.opencontext.service; + +import com.opencontext.dto.StructuredChunk; +import com.opencontext.enums.ErrorCode; +import com.opencontext.exception.BusinessException; +import dev.langchain4j.data.embedding.Embedding; +import dev.langchain4j.data.segment.TextSegment; +import dev.langchain4j.model.embedding.EmbeddingModel; +import dev.langchain4j.model.ollama.OllamaEmbeddingModel; +import dev.langchain4j.model.output.Response; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.*; + +/** + * Service for generating embedding vectors for text chunks using LangChain4j and Ollama. + * + * Service for generating embedding vectors for text chunks using LangChain4j and Ollama. + * + * Converts the semantic representation of each chunk into a vector to enable semantic search. + */ +@Slf4j +@Service +public class EmbeddingService { + + private final EmbeddingModel embeddingModel; + + @Value("${app.embedding.batch-size:10}") + private int batchSize; + + public EmbeddingService( + @Value("${app.ollama.api.url:http://localhost:11434}") String ollamaApiUrl, + @Value("${app.ollama.embedding.model:nomic-embed-text}") String modelName) { + + this.embeddingModel = OllamaEmbeddingModel.builder() + .baseUrl(ollamaApiUrl) + .modelName(modelName) + .build(); + + log.info("Initialized LangChain4j OllamaEmbeddingModel with baseUrl: {}, model: {}", + ollamaApiUrl, modelName); + } + + /** + * Generates embedding vectors for structured chunks. + * + * @param documentId Document ID + * @param structuredChunks List of chunks to generate embeddings for + * @return List of chunks with embeddings included + */ + public List generateEmbeddings(UUID documentId, List structuredChunks) { + long startTime = System.currentTimeMillis(); + int totalChunks = structuredChunks.size(); + + log.info("🤖 [EMBEDDING] Starting embedding generation: documentId={}, chunks={}", documentId, totalChunks); + + List embeddedChunks = new ArrayList<>(); + int processedChunks = 0; + int batchCount = (int) Math.ceil((double) totalChunks / batchSize); + + log.debug("📦 [EMBEDDING] Processing {} chunks in {} batches (batchSize={})", + totalChunks, batchCount, batchSize); + + // Generate embeddings in batch units + for (int i = 0; i < structuredChunks.size(); i += batchSize) { + int endIndex = Math.min(i + batchSize, structuredChunks.size()); + List batch = structuredChunks.subList(i, endIndex); + int currentBatch = (i / batchSize) + 1; + + log.debug("📦 [EMBEDDING] Processing batch {}/{}: chunks {}-{}", + currentBatch, batchCount, i + 1, endIndex); + + long batchStartTime = System.currentTimeMillis(); + List batchResult = processBatchWithLangChain4j(batch); + embeddedChunks.addAll(batchResult); + + processedChunks += batch.size(); + long batchDuration = System.currentTimeMillis() - batchStartTime; + + log.info("[EMBEDDING] Batch {}/{} completed: processed={}/{}, duration={}ms", + currentBatch, batchCount, processedChunks, totalChunks, batchDuration); + } + + long duration = System.currentTimeMillis() - startTime; + int finalEmbeddedCount = embeddedChunks.size(); + + log.info("🎉 [EMBEDDING] Embedding generation completed successfully: documentId={}, chunks={}, duration={}ms", + documentId, finalEmbeddedCount, duration); + + // Log embedding statistics + if (finalEmbeddedCount > 0) { + long avgEmbeddingTime = duration / finalEmbeddedCount; + log.debug("📊 [EMBEDDING] Statistics: avgTimePerChunk={}ms, batchSize={}, totalBatches={}", + avgEmbeddingTime, batchSize, batchCount); + } + + return embeddedChunks; + } + + /** + * Generates embeddings for a batch of chunks using LangChain4j. + */ + private List processBatchWithLangChain4j(List batch) { + long batchStartTime = System.currentTimeMillis(); + log.debug("🤖 [LANGCHAIN4J] Processing batch with LangChain4j: chunks={}", batch.size()); + + List result = new ArrayList<>(); + + try { + // Create TextSegment list for batch processing + List textSegments = new ArrayList<>(); + for (StructuredChunk chunk : batch) { + String textForEmbedding = prepareTextForEmbedding(chunk); + TextSegment segment = TextSegment.from(textForEmbedding); + textSegments.add(segment); + } + + // Generate batch embeddings using LangChain4j + log.debug("[LANGCHAIN4J] Calling Ollama embedding model: segments={}", textSegments.size()); + long embeddingStartTime = System.currentTimeMillis(); + Response> response = embeddingModel.embedAll(textSegments); + List embeddings = response.content(); + long embeddingDuration = System.currentTimeMillis() - embeddingStartTime; + + log.info("[LANGCHAIN4J] Ollama embedding completed: segments={}, duration={}ms, vectorDimension={}", + textSegments.size(), embeddingDuration, + embeddings.size() > 0 ? embeddings.get(0).dimension() : 0); + + // Process results + for (int i = 0; i < batch.size(); i++) { + StructuredChunk chunk = batch.get(i); + Embedding embedding = embeddings.get(i); + + // Convert float[] vector to List + List embeddingVector = new ArrayList<>(); + float[] vector = embedding.vector(); + for (float value : vector) { + embeddingVector.add((double) value); + } + + // Create new chunk with embedding included + StructuredChunk embeddedChunk = StructuredChunk.builder() + .chunkId(chunk.getChunkId()) + .documentId(chunk.getDocumentId()) + .content(chunk.getContent()) + .title(chunk.getTitle()) + .hierarchyLevel(chunk.getHierarchyLevel()) + .parentChunkId(chunk.getParentChunkId()) + .elementType(chunk.getElementType()) + .metadata(chunk.getMetadata()) + .embedding(embeddingVector) + .build(); + + result.add(embeddedChunk); + + log.debug("[LANGCHAIN4J] Embedding processed for chunk: id={}, vectorSize={}, textLength={}", + chunk.getChunkId(), embedding.dimension(), chunk.getContent().length()); + } + + long batchDuration = System.currentTimeMillis() - batchStartTime; + log.info("[LANGCHAIN4J] Batch processing completed: processed={}, duration={}ms, avgPerChunk={}ms", + result.size(), batchDuration, batchDuration / batch.size()); + + } catch (Exception e) { + long batchDuration = System.currentTimeMillis() - batchStartTime; + log.error("[LANGCHAIN4J] Batch processing failed: chunks={}, duration={}ms, error={}", + batch.size(), batchDuration, e.getMessage(), e); + throw new BusinessException(ErrorCode.EMBEDDING_GENERATION_FAILED, + "Failed to generate embeddings: " + e.getMessage()); + } + + return result; + } + + /** + * Prepares text for embedding generation. + * Combines title and content to provide richer context. + */ + private String prepareTextForEmbedding(StructuredChunk chunk) { + StringBuilder text = new StringBuilder(); + + log.debug("[EMBEDDING] Preparing text for embedding: chunkId={}", chunk.getChunkId()); + + // Add title if exists + if (chunk.getTitle() != null && !chunk.getTitle().trim().isEmpty()) { + text.append("Title: ").append(chunk.getTitle()).append("\n"); + } + + // Add content + text.append(chunk.getContent()); + + String finalText = text.toString().trim(); + + log.debug("[EMBEDDING] Text prepared: chunkId={}, finalLength={}, hasTitle={}", + chunk.getChunkId(), finalText.length(), chunk.getTitle() != null); + + return finalText; + } +} diff --git a/core/src/main/java/com/opencontext/service/FileStorageService.java b/core/src/main/java/com/opencontext/service/FileStorageService.java new file mode 100644 index 0000000..0ddda3e --- /dev/null +++ b/core/src/main/java/com/opencontext/service/FileStorageService.java @@ -0,0 +1,681 @@ + package com.opencontext.service; + + import com.opencontext.config.MinIOConfig; + import com.opencontext.dto.SourceDocumentDto; + import com.opencontext.entity.SourceDocument; + import com.opencontext.enums.ErrorCode; + import com.opencontext.enums.IngestionStatus; + import com.opencontext.exception.BusinessException; + import com.opencontext.repository.DocumentChunkRepository; + import com.opencontext.repository.SourceDocumentRepository; + import io.minio.*; + import lombok.RequiredArgsConstructor; + import lombok.extern.slf4j.Slf4j; + import org.springframework.beans.factory.annotation.Value; + import org.springframework.data.domain.Page; + import org.springframework.data.domain.Pageable; + import org.springframework.http.*; + import org.springframework.stereotype.Service; + import org.springframework.transaction.annotation.Transactional; + import org.springframework.web.client.RestTemplate; + import org.springframework.web.multipart.MultipartFile; + + import java.io.IOException; + import java.io.InputStream; + import java.security.MessageDigest; + import java.security.NoSuchAlgorithmException; + import java.time.LocalDateTime; + import java.util.List; + import java.util.UUID; + + /** + * Service for handling file storage operations with MinIO and document metadata management. + * + * This service provides comprehensive methods for storing and managing + * files in MinIO object storage along with their metadata in PostgreSQL + * for the document ingestion pipeline. + */ + @Slf4j + @Service + @RequiredArgsConstructor + @Transactional + public class FileStorageService { + + private final MinioClient minioClient; + private final MinIOConfig minioConfig; + private final SourceDocumentRepository sourceDocumentRepository; + private final DocumentChunkRepository documentChunkRepository; + private final RestTemplate restTemplate; + + @Value("${app.elasticsearch.url:http://elasticsearch:9200}") + private String elasticsearchUrl; + + @Value("${app.elasticsearch.index:document_chunks_index}") + private String indexName; + + // Supported file types for document processing (canonical) + private static final List ALLOWED_CANONICAL_CONTENT_TYPES = List.of( + "application/pdf", + "text/markdown", + "text/plain" + ); + + /** + * Uploads a file to MinIO storage, creates document metadata, and returns the document. + * + * @param file the multipart file to upload + * @return the created SourceDocument entity + */ + public SourceDocument uploadFileWithMetadata(MultipartFile file) { + String filename = file.getOriginalFilename(); + long fileSize = file.getSize(); + String contentType = resolveContentType(file); + + log.info("[UPLOAD] Starting file upload with metadata: filename={}, size={} bytes, contentType={}", + filename, fileSize, contentType); + + long startTime = System.currentTimeMillis(); + + // Validate file + log.debug(" [UPLOAD] Step 1/5: Validating file: {}", filename); + validateFile(file); + log.info("[UPLOAD] File validation completed successfully: {}", filename); + + // Calculate file checksum to prevent duplicates + log.debug("[UPLOAD] Step 2/5: Calculating file checksum: {}", filename); + String fileChecksum = calculateFileChecksum(file); + log.info("[UPLOAD] File checksum calculated: {} -> {}", filename, fileChecksum); + + // Check for duplicate files + log.debug("[UPLOAD] Step 3/5: Checking for duplicate files: {}", filename); + if (sourceDocumentRepository.existsByFileChecksum(fileChecksum)) { + log.warn("[UPLOAD] Duplicate file upload attempt: filename={}, checksum={}", filename, fileChecksum); + throw new BusinessException(ErrorCode.DUPLICATE_FILE_UPLOADED, + "A file with identical content already exists."); + } + log.info("[UPLOAD] No duplicate files found: {}", filename); + + try { + // Upload file to MinIO + log.debug("[UPLOAD] Step 4/5: Uploading file to MinIO: {}", filename); + String objectKey = uploadFile(file, contentType); + log.info("[UPLOAD] File uploaded to MinIO successfully: {} -> {}", filename, objectKey); + + // Create SourceDocument entity + log.debug("[UPLOAD] Step 5/5: Creating database record: {}", filename); + SourceDocument sourceDocument = SourceDocument.builder() + .originalFilename(file.getOriginalFilename()) + .fileStoragePath(objectKey) + .fileType(determineFileType(contentType)) + .fileSize(file.getSize()) + .fileChecksum(fileChecksum) + .ingestionStatus(IngestionStatus.PENDING) + .build(); + + // Save to database + SourceDocument savedDocument = sourceDocumentRepository.save(sourceDocument); + + long duration = System.currentTimeMillis() - startTime; + log.info(" [UPLOAD] File upload completed successfully: id={}, filename={}, duration={}ms", + savedDocument.getId(), savedDocument.getOriginalFilename(), duration); + + return savedDocument; + + } catch (Exception e) { + long duration = System.currentTimeMillis() - startTime; + log.error("[UPLOAD] File upload failed: filename={}, duration={}ms, error={}", + filename, duration, e.getMessage(), e); + if (e instanceof BusinessException) { + throw e; + } + throw new BusinessException(ErrorCode.FILE_UPLOAD_FAILED, + "Unexpected error during file upload: " + e.getMessage()); + } + } + + /** + * Uploads a file to MinIO storage and returns the object key. + * + * @param file the multipart file to upload + * @return the object key where the file was stored + */ + public String uploadFile(MultipartFile file, String resolvedContentType) { + try { + // Ensure bucket exists + ensureBucketExists(); + + // Generate unique object key + String objectKey = generateObjectKey(file.getOriginalFilename()); + + // Upload file to MinIO + PutObjectArgs putObjectArgs = PutObjectArgs.builder() + .bucket(minioConfig.getBucketName()) + .object(objectKey) + .stream(file.getInputStream(), file.getSize(), -1) + .contentType(resolvedContentType) + .build(); + + minioClient.putObject(putObjectArgs); + + log.debug("[MINIO] File uploaded to MinIO: {} -> bucket:{}, key:{}", + file.getOriginalFilename(), minioConfig.getBucketName(), objectKey); + + return objectKey; + + } catch (Exception e) { + log.error("[MINIO] MinIO upload failed: filename={}, error={}", + file.getOriginalFilename(), e.getMessage(), e); + throw new BusinessException(ErrorCode.FILE_UPLOAD_FAILED, + "Failed to upload file to storage: " + e.getMessage()); + } + } + + /** + * Downloads a file from MinIO storage. + * + * @param objectKey the object key of the file to download + * @return InputStream of the file content + */ + public InputStream downloadFile(String objectKey) { + try { + GetObjectArgs getObjectArgs = GetObjectArgs.builder() + .bucket(minioConfig.getBucketName()) + .object(objectKey) + .build(); + + InputStream stream = minioClient.getObject(getObjectArgs); + log.debug("[MINIO] File downloaded successfully: key={}", objectKey); + return stream; + + } catch (Exception e) { + log.error("[MINIO] File download failed: key={}, error={}", objectKey, e.getMessage(), e); + throw new BusinessException(ErrorCode.FILE_NOT_FOUND, + "Failed to download file: " + e.getMessage()); + } + } + + /** + * Deletes a file from MinIO storage. + * + * @param objectKey the object key of the file to delete + */ + public void deleteFile(String objectKey) { + try { + RemoveObjectArgs removeObjectArgs = RemoveObjectArgs.builder() + .bucket(minioConfig.getBucketName()) + .object(objectKey) + .build(); + + minioClient.removeObject(removeObjectArgs); + log.debug("[MINIO] File deleted successfully: key={}", objectKey); + + } catch (Exception e) { + log.error("[MINIO] File deletion failed: key={}, error={}", objectKey, e.getMessage(), e); + throw new BusinessException(ErrorCode.FILE_DELETE_FAILED, + "Failed to delete file: " + e.getMessage()); + } + } + + /** + * Checks if a file exists in MinIO storage. + * + * @param objectKey the object key to check + * @return true if file exists, false otherwise + */ + public boolean fileExists(String objectKey) { + try { + StatObjectArgs statObjectArgs = StatObjectArgs.builder() + .bucket(minioConfig.getBucketName()) + .object(objectKey) + .build(); + + minioClient.statObject(statObjectArgs); + return true; + + } catch (Exception e) { + return false; + } + } + + /** + * Ensures that the configured bucket exists, creating it if necessary. + */ + private void ensureBucketExists() { + try { + BucketExistsArgs bucketExistsArgs = BucketExistsArgs.builder() + .bucket(minioConfig.getBucketName()) + .build(); + + boolean bucketExists = minioClient.bucketExists(bucketExistsArgs); + + if (!bucketExists) { + MakeBucketArgs makeBucketArgs = MakeBucketArgs.builder() + .bucket(minioConfig.getBucketName()) + .build(); + + minioClient.makeBucket(makeBucketArgs); + log.info(" [MINIO] Created MinIO bucket: {}", minioConfig.getBucketName()); + } + + } catch (Exception e) { + log.error("[MINIO] Failed to ensure bucket exists: bucket={}, error={}", + minioConfig.getBucketName(), e.getMessage(), e); + throw new BusinessException(ErrorCode.STORAGE_ERROR, + "Failed to ensure bucket exists: " + e.getMessage()); + } + } + + /** + * Generates an object key for MinIO storage. + * + * @param originalFilename the original filename + * @return generated object key + */ + private String generateObjectKey(String originalFilename) { + LocalDateTime now = LocalDateTime.now(); + String uuid = UUID.randomUUID().toString().substring(0, 8); + String timestamp = String.valueOf(System.currentTimeMillis()); + + String filename = String.format("%s_%s_%s", timestamp, uuid, originalFilename); + + return String.format("documents/%d/%02d/%02d/%s", + now.getYear(), now.getMonthValue(), now.getDayOfMonth(), filename); + } + + // ========== Document Metadata Management Methods ========== + + /** + * Retrieves a source document by ID. + * + * @param documentId the document ID + * @return SourceDocument entity + */ + @Transactional(readOnly = true) + public SourceDocument getDocument(UUID documentId) { + log.debug(" [QUERY] Retrieving document: id={}", documentId); + + return sourceDocumentRepository.findById(documentId) + .orElseThrow(() -> new BusinessException(ErrorCode.SOURCE_DOCUMENT_NOT_FOUND, + "Document not found: " + documentId)); + } + + /** + * Retrieves all source documents with pagination. + * + * @param pageable pagination parameters + * @return Page of SourceDocumentDto + */ + @Transactional(readOnly = true) + public Page getAllDocuments(Pageable pageable) { + log.debug(" [QUERY] Retrieving documents with pagination: page={}, size={}", + pageable.getPageNumber(), pageable.getPageSize()); + + return sourceDocumentRepository.findAllByOrderByCreatedAtDesc(pageable) + .map(this::convertToDto); + } + + /** + * Updates document status. + * + * @param documentId the document ID + * @param status the new ingestion status + */ + public void updateDocumentStatus(UUID documentId, IngestionStatus status) { + sourceDocumentRepository.findById(documentId) + .ifPresent(document -> { + document.updateIngestionStatus(status); + sourceDocumentRepository.save(document); + log.info(" [STATUS] Document status updated: id={}, status={}", documentId, status); + }); + } + + /** + * Updates document status to COMPLETED and sets completion timestamp. + * + * @param documentId the document ID + */ + public void updateDocumentStatusToCompleted(UUID documentId) { + sourceDocumentRepository.findById(documentId) + .ifPresent(document -> { + document.updateIngestionStatus(IngestionStatus.COMPLETED); + sourceDocumentRepository.save(document); + log.info(" [STATUS] Document status updated to COMPLETED: id={}", documentId); + }); + } + + /** + * Updates document status to ERROR with error message. + * + * @param documentId the document ID + * @param errorMessage the error message + */ + public void updateDocumentStatusToError(UUID documentId, String errorMessage) { + try { + sourceDocumentRepository.findById(documentId) + .ifPresent(document -> { + document.updateIngestionStatusToError(errorMessage); + sourceDocumentRepository.save(document); + log.error("[STATUS] Document status updated to ERROR: id={}, errorMessage={}", documentId, errorMessage); + }); + } catch (Exception e) { + log.error("[STATUS] Failed to update document status to ERROR: id={}, error={}", + documentId, e.getMessage(), e); + } + } + + /** + * Deletes a document and all its associated data from all storage systems. + * This includes MinIO files, PostgreSQL records, and Elasticsearch indices. + * + * @param documentId the document ID to delete + */ + @Transactional + public void deleteDocument(UUID documentId) { + long startTime = System.currentTimeMillis(); + log.info("[DELETE] Starting comprehensive document deletion: id={}", documentId); + + SourceDocument document = getDocument(documentId); + String filename = document.getOriginalFilename(); + IngestionStatus status = document.getIngestionStatus(); + + log.info(" [DELETE] Document details: filename={}, status={}, size={} bytes", + filename, status, document.getFileSize()); + + // Check if document is currently being processed (but allow DELETING status) + if (document.isProcessing() && status != IngestionStatus.DELETING) { + log.warn(" [DELETE] Cannot delete document in processing state: id={}, status={}", + documentId, status); + throw new BusinessException(ErrorCode.RESOURCE_IS_BEING_PROCESSED, + "Document is currently being processed and cannot be deleted."); + } + + // Update status to DELETING (only if not already DELETING) + if (status != IngestionStatus.DELETING) { + log.debug(" [DELETE] Step 1/4: Updating status to DELETING: {}", filename); + document.updateIngestionStatus(IngestionStatus.DELETING); + sourceDocumentRepository.save(document); + log.info(" [DELETE] Status updated to DELETING: {}", filename); + } else { + log.info(" [DELETE] Document already in DELETING status: {}", filename); + } + + try { + // Step 2: Delete from Elasticsearch (if exists) + log.debug(" [DELETE] Step 2/4: Deleting from Elasticsearch: {}", filename); + deleteFromElasticsearch(documentId); + log.info("[DELETE] Elasticsearch deletion completed: {}", filename); + + // Step 3: Delete chunks from PostgreSQL + log.debug("[DELETE] Step 3/4: Deleting chunks from PostgreSQL: {}", filename); + int deletedChunks = deleteChunksFromPostgreSQL(documentId); + log.info(" [DELETE] PostgreSQL chunks deleted: {} chunks removed for {}", deletedChunks, filename); + + // Step 4: Delete file from MinIO + log.debug("[DELETE] Step 4/4: Deleting file from MinIO: {}", filename); + deleteFile(document.getFileStoragePath()); + log.info("[DELETE] MinIO file deleted: {} -> {}", filename, document.getFileStoragePath()); + + // Final step: Delete SourceDocument record + log.debug("[DELETE] Final step: Deleting source document record: {}", filename); + sourceDocumentRepository.delete(document); + log.info("[DELETE] Source document record deleted: {}", filename); + + long duration = System.currentTimeMillis() - startTime; + log.info("[DELETE] Document deletion completed successfully: id={}, filename={}, duration={}ms", + documentId, filename, duration); + + } catch (Exception e) { + long duration = System.currentTimeMillis() - startTime; + log.error("[DELETE] Document deletion failed: id={}, filename={}, duration={}ms, error={}", + documentId, filename, duration, e.getMessage(), e); + + // Try to revert status if possible + try { + SourceDocument updatedDoc = sourceDocumentRepository.findById(documentId).orElse(null); + if (updatedDoc != null) { + updatedDoc.updateIngestionStatusToError("Deletion failed: " + e.getMessage()); + sourceDocumentRepository.save(updatedDoc); + log.info("[DELETE] Status reverted to ERROR after deletion failure: {}", filename); + } + } catch (Exception revertEx) { + log.error("[DELETE] Failed to revert status after deletion failure: {}", filename, revertEx); + } + + throw new BusinessException(ErrorCode.DELETION_PIPELINE_FAILED, + "Failed to delete document: " + e.getMessage()); + } + } + + /** + * Checks if a document is currently being processed. + * + * @param documentId the document ID + * @return true if document is in processing state + */ + @Transactional(readOnly = true) + public boolean isDocumentProcessing(UUID documentId) { + return sourceDocumentRepository.findById(documentId) + .map(SourceDocument::isProcessing) + .orElse(false); + } + + /** + * Gets the file storage path for a document. + * + * @param documentId the document ID + * @return the file storage path + */ + @Transactional(readOnly = true) + public String getDocumentStoragePath(UUID documentId) { + return sourceDocumentRepository.findById(documentId) + .map(SourceDocument::getFileStoragePath) + .orElse(null); + } + + // ========== Deletion Helper Methods ========== + + /** + * Deletes all chunks associated with a document from Elasticsearch. + * Uses delete-by-query API to remove all chunks with matching document_id. + * + * @param documentId the document ID whose chunks should be deleted + */ + private void deleteFromElasticsearch(UUID documentId) { + try { + log.debug("[ELASTICSEARCH] Starting deletion for document: {}", documentId); + + // Create delete-by-query request + String deleteQuery = String.format( + "{\"query\": {\"term\": {\"document_id\": \"%s\"}}}", + documentId.toString() + ); + + log.debug(" [ELASTICSEARCH] Delete query: {}", deleteQuery); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity requestEntity = new HttpEntity<>(deleteQuery, headers); + + String deleteUrl = String.format("%s/%s/_delete_by_query", elasticsearchUrl, indexName); + log.debug(" [ELASTICSEARCH] Delete URL: {}", deleteUrl); + + ResponseEntity response = restTemplate.exchange( + deleteUrl, + HttpMethod.POST, + requestEntity, + String.class + ); + + if (response.getStatusCode().is2xxSuccessful()) { + String responseBody = response.getBody(); + log.info(" [ELASTICSEARCH] Document chunks deleted successfully: documentId={}, response={}", + documentId, responseBody); + } else { + log.warn(" [ELASTICSEARCH] Delete operation returned non-2xx status: documentId={}, status={}, response={}", + documentId, response.getStatusCode(), response.getBody()); + } + + } catch (Exception e) { + // Log the error but don't fail the entire deletion process + // This handles cases where the document was never indexed (ERROR status) + if (e.getMessage() != null && e.getMessage().contains("index_not_found_exception")) { + log.info(" [ELASTICSEARCH] Index not found - document was likely never indexed: documentId={}", documentId); + } else { + log.warn(" [ELASTICSEARCH] Failed to delete from Elasticsearch (continuing deletion): documentId={}, error={}", + documentId, e.getMessage(), e); + } + } + } + + /** + * Deletes all chunks associated with a document from PostgreSQL. + * + * @param documentId the document ID whose chunks should be deleted + * @return the number of deleted chunks + */ + private int deleteChunksFromPostgreSQL(UUID documentId) { + try { + log.debug(" [POSTGRESQL] Starting chunk deletion for document: {}", documentId); + + int deletedChunks = documentChunkRepository.deleteBySourceDocumentId(documentId); + + log.info(" [POSTGRESQL] Chunks deleted successfully: documentId={}, deletedCount={}", + documentId, deletedChunks); + + return deletedChunks; + + } catch (Exception e) { + log.error(" [POSTGRESQL] Failed to delete chunks: documentId={}, error={}", + documentId, e.getMessage(), e); + throw new BusinessException(ErrorCode.DATABASE_ERROR, + "Failed to delete document chunks: " + e.getMessage()); + } + } + + // ========== Private Helper Methods ========== + + /** + * Validates the uploaded file. + */ + private void validateFile(MultipartFile file) { + if (file.isEmpty()) { + throw new BusinessException(ErrorCode.INVALID_REQUEST, "File cannot be empty"); + } + + if (file.getSize() > 100 * 1024 * 1024) { // 100MB + throw new BusinessException(ErrorCode.PAYLOAD_TOO_LARGE, + "File size exceeds maximum limit of 100MB"); + } + + String filename = file.getOriginalFilename(); + if (filename == null || filename.trim().isEmpty()) { + throw new BusinessException(ErrorCode.INVALID_REQUEST, "Filename cannot be empty"); + } + + // Resolve canonical content type from both content-type header and filename extension + String resolvedContentType = resolveContentType(file); + + // Check if the resolved content type is supported + if (!ALLOWED_CANONICAL_CONTENT_TYPES.contains(resolvedContentType)) { + log.debug(" [UPLOAD] Unsupported content type: filename={}, original={}, resolved={}", + filename, file.getContentType(), resolvedContentType); + throw new BusinessException(ErrorCode.UNSUPPORTED_MEDIA_TYPE, + "Unsupported file type. Supported types: PDF, Markdown, and plain text files."); + } + + log.debug(" [UPLOAD] File validation passed: filename={}, resolved_type={}", filename, resolvedContentType); + } + + /** + * Calculates SHA-256 checksum of the file content. + */ + private String calculateFileChecksum(MultipartFile file) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] fileBytes = file.getBytes(); + byte[] hashBytes = digest.digest(fileBytes); + + StringBuilder sb = new StringBuilder(); + for (byte b : hashBytes) { + sb.append(String.format("%02x", b)); + } + + return sb.toString(); + + } catch (NoSuchAlgorithmException | IOException e) { + log.error(" [UPLOAD] Failed to calculate file checksum: filename={}, error={}", + file.getOriginalFilename(), e.getMessage(), e); + throw new BusinessException(ErrorCode.FILE_UPLOAD_FAILED, + "Failed to calculate file checksum: " + e.getMessage()); + } + } + + /** + * Determines file type from content type. + */ + private String determineFileType(String contentType) { + return switch (contentType) { + case "application/pdf" -> "PDF"; + case "text/markdown" -> "MARKDOWN"; + case "text/plain" -> "TEXT"; + default -> "UNKNOWN"; + }; + } + + /** + * Resolves the effective content type for the uploaded file. + * If the inbound content type is null or generic (e.g., application/octet-stream), + * infer from the filename extension and normalize to a canonical, allowed type. + */ + private String resolveContentType(MultipartFile file) { + String inbound = file.getContentType(); + // If clearly an allowed canonical type, return as-is + if (ALLOWED_CANONICAL_CONTENT_TYPES.contains(inbound)) { + return inbound; + } + + // Try to infer from file extension for generic or alternate types + String filename = file.getOriginalFilename(); + String ext = (filename == null) ? null : getFileExtension(filename).toLowerCase(); + + if (ext != null) { + return switch (ext) { + case "pdf" -> "application/pdf"; + case "md", "markdown" -> "text/markdown"; + case "txt" -> "text/plain"; + default -> (inbound != null ? inbound : "application/octet-stream"); + }; + } + + // Fallback to inbound or generic + return inbound != null ? inbound : "application/octet-stream"; + } + + /** + * Returns the extension without the leading dot. Example: "README.md" -> "md". + */ + private String getFileExtension(String filename) { + int lastDot = filename.lastIndexOf('.'); + if (lastDot == -1 || lastDot == filename.length() - 1) { + return ""; + } + return filename.substring(lastDot + 1); + } + + /** + * Converts SourceDocument entity to DTO. + */ + private SourceDocumentDto convertToDto(SourceDocument document) { + return SourceDocumentDto.builder() + .id(document.getId().toString()) + .originalFilename(document.getOriginalFilename()) + .fileType(document.getFileType()) + .fileSize(document.getFileSize()) + .ingestionStatus(document.getIngestionStatus().name()) + .errorMessage(document.getErrorMessage()) + .lastIngestedAt(document.getLastIngestedAt()) + .createdAt(document.getCreatedAt()) + .updatedAt(document.getUpdatedAt()) + .build(); + } + } \ No newline at end of file diff --git a/core/src/main/java/com/opencontext/service/IndexingService.java b/core/src/main/java/com/opencontext/service/IndexingService.java new file mode 100644 index 0000000..06cbbdf --- /dev/null +++ b/core/src/main/java/com/opencontext/service/IndexingService.java @@ -0,0 +1,298 @@ +package com.opencontext.service; + +import com.opencontext.dto.StructuredChunk; +import com.opencontext.entity.DocumentChunk; +import com.opencontext.entity.SourceDocument; +import com.opencontext.enums.ErrorCode; +import com.opencontext.exception.BusinessException; +import com.opencontext.repository.DocumentChunkRepository; +import com.opencontext.repository.SourceDocumentRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestTemplate; + +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.Arrays; + +/** + * Service for storing embedded chunks in Elasticsearch and PostgreSQL. + * + * Stores vectors and metadata in Elasticsearch for search, + * and hierarchical structure information in PostgreSQL. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class IndexingService { + + private final DocumentChunkRepository documentChunkRepository; + private final SourceDocumentRepository sourceDocumentRepository; + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + @Value("${app.elasticsearch.url:http://localhost:9200}") + private String elasticsearchUrl; + + @Value("${app.elasticsearch.index:document-chunks}") + private String indexName; + + /** + * Stores embedded chunks in Elasticsearch and PostgreSQL. + * + * @param documentId Document ID + * @param embeddedChunks List of embedded chunks to store + */ + public void indexChunks(UUID documentId, List embeddedChunks) { + long startTime = System.currentTimeMillis(); + int totalChunks = embeddedChunks.size(); + + log.info("📎 [INDEXING] Starting chunk indexing: documentId={}, chunks={}", documentId, totalChunks); + + try { + // Step 1: Store vector data in Elasticsearch + log.debug("🔍 [INDEXING] Step 1/2: Indexing to Elasticsearch: chunks={}", totalChunks); + bulkIndexToElasticsearch(embeddedChunks); + log.info("✅ [INDEXING] Elasticsearch indexing completed: documentId={}, chunks={}", documentId, totalChunks); + + // Step 2: Store hierarchical structure information in PostgreSQL + log.debug("💾 [INDEXING] Step 2/2: Saving hierarchy to PostgreSQL: chunks={}", totalChunks); + int savedChunks = saveChunkHierarchyToPostgreSQL(documentId, embeddedChunks); + log.info("✅ [INDEXING] PostgreSQL hierarchy saved: documentId={}, savedChunks={}", documentId, savedChunks); + + long duration = System.currentTimeMillis() - startTime; + log.info("🎉 [INDEXING] Chunk indexing completed successfully: documentId={}, chunks={}, duration={}ms", + documentId, totalChunks, duration); + + } catch (Exception e) { + long duration = System.currentTimeMillis() - startTime; + log.error("❌ [INDEXING] Chunk indexing failed: documentId={}, chunks={}, duration={}ms, error={}", + documentId, totalChunks, duration, e.getMessage(), e); + throw new BusinessException(ErrorCode.INDEXING_FAILED, + "Failed to index chunks: " + e.getMessage()); + } + } + + /** + * Bulk index chunks to Elasticsearch. + */ + private void bulkIndexToElasticsearch(List chunks) { + long esStartTime = System.currentTimeMillis(); + int totalChunks = chunks.size(); + + log.debug("🔍 [ELASTICSEARCH] Starting bulk indexing: chunks={}, index={}", totalChunks, indexName); + + try { + // Create bulk request body + log.debug("📦 [ELASTICSEARCH] Building bulk request body: chunks={}", totalChunks); + StringBuilder bulkBody = new StringBuilder(); + + for (StructuredChunk chunk : chunks) { + // Index metadata + Map indexMeta = Map.of( + "index", Map.of("_id", chunk.getChunkId()) + ); + try { + bulkBody.append(objectMapper.writeValueAsString(indexMeta)).append("\n"); + + // Document data + Map doc = createElasticsearchDocumentPRD(chunk); + bulkBody.append(objectMapper.writeValueAsString(doc)).append("\n"); + } catch (Exception jsonException) { + log.error("JSON serialization failed, skipping chunk: chunkId={}, error={}", + chunk.getChunkId(), jsonException.getMessage()); + continue; // Skip this chunk and continue processing + } + } + + // Set HTTP headers (specify UTF-8) + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(new MediaType("application", "x-ndjson", StandardCharsets.UTF_8)); + + // Send body as UTF-8 bytes to prevent Korean characters from being replaced with '?' + byte[] requestBytes = bulkBody.toString().getBytes(StandardCharsets.UTF_8); + HttpEntity requestEntity = new HttpEntity<>(requestBytes, headers); + + // Call bulk API + ResponseEntity> response = restTemplate.exchange( + elasticsearchUrl + "/" + indexName + "/_bulk", + HttpMethod.POST, + requestEntity, + (Class>) (Class) Map.class + ); + + if (response.getStatusCode() != HttpStatus.OK) { + throw new BusinessException(ErrorCode.EXTERNAL_API_ERROR, + "Elasticsearch bulk indexing failed"); + } + + // Check for errors in bulk response + Map responseBody = response.getBody(); + if (responseBody != null && Boolean.TRUE.equals(responseBody.get("errors"))) { + // Extract detailed information from the first error + List> items = (List>) responseBody.get("items"); + if (items != null && !items.isEmpty()) { + Map firstItem = items.get(0); + Map indexResult = (Map) firstItem.get("index"); + if (indexResult != null && indexResult.containsKey("error")) { + Map error = (Map) indexResult.get("error"); + String reason = (String) error.get("reason"); + log.error("Elasticsearch bulk indexing error: {}", reason); + throw new BusinessException(ErrorCode.EXTERNAL_API_ERROR, + "Elasticsearch bulk indexing failed: " + reason); + } + } + throw new BusinessException(ErrorCode.EXTERNAL_API_ERROR, + "Elasticsearch bulk indexing failed with errors"); + } + + long totalDuration = System.currentTimeMillis() - esStartTime; + log.info("✅ [ELASTICSEARCH] Bulk indexing completed successfully: chunks={}, duration={}ms", + totalChunks, totalDuration); + + } catch (Exception e) { + long totalDuration = System.currentTimeMillis() - esStartTime; + log.error("❌ [ELASTICSEARCH] Bulk indexing failed: chunks={}, duration={}ms, error={}", + totalChunks, totalDuration, e.getMessage(), e); + throw new BusinessException(ErrorCode.EXTERNAL_API_ERROR, + "Failed to index to Elasticsearch: " + e.getMessage()); + } + } + + /** + * Save chunk hierarchy information to PostgreSQL. + */ + private int saveChunkHierarchyToPostgreSQL(UUID documentId, List chunks) { + long pgStartTime = System.currentTimeMillis(); + int totalChunks = chunks.size(); + + log.debug("💾 [POSTGRESQL] Starting to save chunk hierarchy: documentId={}, chunks={}", + documentId, totalChunks); + + try { + // ✅ Get SourceDocument entity to set up JPA relationship + SourceDocument sourceDocument = sourceDocumentRepository.findById(documentId) + .orElseThrow(() -> new BusinessException(ErrorCode.DATABASE_ERROR, + "SourceDocument not found: " + documentId)); + + List documentChunks = new ArrayList<>(); + + for (StructuredChunk chunk : chunks) { + UUID parentChunkUuid = null; + if (chunk.getParentChunkId() != null && !chunk.getParentChunkId().trim().isEmpty()) { + try { + parentChunkUuid = UUID.fromString(chunk.getParentChunkId()); + } catch (IllegalArgumentException e) { + log.warn("Invalid parent_chunk_id format: {}, skipping parent relationship for chunk: {}", + chunk.getParentChunkId(), chunk.getChunkId()); + parentChunkUuid = null; + } + } + + DocumentChunk documentChunk = DocumentChunk.builder() + .chunkId(chunk.getChunkId()) + .sourceDocument(sourceDocument) + .sourceDocumentId(documentId) + .title(chunk.getTitle()) + .hierarchyLevel(chunk.getHierarchyLevel()) + .parentChunkId(parentChunkUuid) + .elementType(chunk.getElementType()) + .sequenceInDocument(0) // Set default value, should be set according to actual order + .build(); + + documentChunks.add(documentChunk); + } + + // Batch save + long saveStartTime = System.currentTimeMillis(); + List savedChunks = documentChunkRepository.saveAll(documentChunks); + long saveDuration = System.currentTimeMillis() - saveStartTime; + + long totalDuration = System.currentTimeMillis() - pgStartTime; + log.info("✅ [POSTGRESQL] Chunk hierarchy saved successfully: documentId={}, chunks={}, duration={}ms, saveTime={}ms", + documentId, savedChunks.size(), totalDuration, saveDuration); + + return savedChunks.size(); + + } catch (Exception e) { + long totalDuration = System.currentTimeMillis() - pgStartTime; + log.error("❌ [POSTGRESQL] Failed to save chunk hierarchy: documentId={}, chunks={}, duration={}ms, error={}", + documentId, totalChunks, totalDuration, e.getMessage(), e); + throw new BusinessException(ErrorCode.DATABASE_ERROR, + "Failed to save chunk hierarchy: " + e.getMessage()); + } + } + + /** + * Creates Elasticsearch document + */ + private Map createElasticsearchDocumentPRD(StructuredChunk chunk) { + Map doc = new HashMap<>(); + + // Root fields (camelCase) + doc.put("chunkId", chunk.getChunkId()); + doc.put("sourceDocumentId", chunk.getDocumentId()); + doc.put("content", sanitizeContent(chunk.getContent())); + doc.put("embedding", chunk.getEmbedding()); + doc.put("indexedAt", java.time.Instant.now().toString()); // ISO string + + // metadata structure + Map metadata = new HashMap<>(); + metadata.put("title", chunk.getTitle()); + metadata.put("hierarchyLevel", chunk.getHierarchyLevel()); + metadata.put("sequenceInDocument", 0); // default value + metadata.put("language", "ko"); // Korean default value + + // Reflect actual file type by querying from SourceDocument + String resolvedFileType = "UNKNOWN"; + try { + UUID srcId = UUID.fromString(chunk.getDocumentId()); + resolvedFileType = sourceDocumentRepository.findById(srcId) + .map(SourceDocument::getFileType) + .orElse("UNKNOWN"); + } catch (Exception e) { + log.warn("Failed to resolve fileType for documentId={}, defaulting to UNKNOWN", chunk.getDocumentId()); + } + metadata.put("fileType", resolvedFileType); + + // Handle breadcrumbs (default: empty array) + metadata.put("breadcrumbs", Arrays.asList()); // empty array default value + + doc.put("metadata", metadata); + + return doc; + } + + /** + * Cleans up Java code or special characters in content field to prevent JSON parsing errors. + */ + private String sanitizeContent(String content) { + if (content == null || content.trim().isEmpty()) { + return ""; + } + + return content + // Java code related cleanup + .replaceAll("\\{[^}]*\\}", "") // Remove curly brace content + .replaceAll("\\[.*?\\]", "") // Remove square bracket content + .replaceAll("\\b(int|String|void|public|private|static|final|class|interface|extends|implements)\\b", "") // Remove Java keywords + .replaceAll("\\b(if|else|for|while|switch|case|break|continue|return|throw|try|catch|finally)\\b", "") // Remove Java control statements + .replaceAll("\\b(new|this|super|null|true|false)\\b", "") // Remove Java literals + + // Special character cleanup + .replaceAll("\\s+", " ") // Replace consecutive spaces with single space + .replaceAll("[\\r\\n\\t]+", " ") // Replace newlines and tabs with space + .replaceAll("\\s*[;=+\\-*/%<>!&|^~]\\s*", " ") // Clean up spaces around operators + + // Other cleanup + .replaceAll("\\s+", " ") // Clean up consecutive spaces again + .trim(); + } + +} diff --git a/core/src/main/java/com/opencontext/service/SearchService.java b/core/src/main/java/com/opencontext/service/SearchService.java new file mode 100644 index 0000000..af5a794 --- /dev/null +++ b/core/src/main/java/com/opencontext/service/SearchService.java @@ -0,0 +1,318 @@ +package com.opencontext.service; + +import com.opencontext.dto.SearchResultItem; +import com.opencontext.enums.ErrorCode; +import com.opencontext.exception.BusinessException; +import dev.langchain4j.data.embedding.Embedding; +import dev.langchain4j.data.segment.TextSegment; +import dev.langchain4j.model.embedding.EmbeddingModel; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.*; + +/** + * Elasticsearch hybrid search service + * Combines BM25 keyword search and vector similarity search to provide optimal search results + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SearchService { + + private final EmbeddingModel embeddingModel; + private final RestTemplate restTemplate; + + @Value("${app.elasticsearch.url:http://localhost:9200}") + private String elasticsearchUrl; + + @Value("${app.elasticsearch.index:document_chunks_index}") + private String indexName; + + @Value("${app.search.snippet-max-length:50}") + private int snippetMaxLength; + + @Value("${app.search.bm25-weight:0.3}") + private double bm25Weight; + + @Value("${app.search.vector-weight:0.7}") + private double vectorWeight; + + /** + * Execute hybrid search - combines keyword search and semantic search + * + * @param query Search query + * @param topK Maximum number of results to return + * @return List of search results sorted by relevance + */ + public List search(String query, int topK) { + long startTime = System.currentTimeMillis(); + + log.info("Starting hybrid search: query='{}', topK={}", query, topK); + + try { + // Step 1: Convert search query to embedding vector (float type for ES compatibility) + List queryEmbedding = generateQueryEmbedding(query); + + // Step 2: Execute Elasticsearch hybrid query + Map searchResponse = executeElasticsearchQuery(query, queryEmbedding, topK); + + // Step 3: Convert search results to DTO + List results = parseSearchResults(searchResponse); + + long duration = System.currentTimeMillis() - startTime; + log.info("Hybrid search completed: query='{}', resultCount={}, duration={}ms", + query, results.size(), duration); + + return results; + + } catch (Exception e) { + long duration = System.currentTimeMillis() - startTime; + log.error("Hybrid search failed: query='{}', duration={}ms, error={}", + query, duration, e.getMessage(), e); + throw new BusinessException(ErrorCode.ELASTICSEARCH_ERROR, + "Search operation failed: " + e.getMessage()); + } + } + + /** + * Convert search query to embedding vector + * Uses List type for ES cosineSimilarity function compatibility + */ + private List generateQueryEmbedding(String query) { + log.debug("Generating query embedding: query='{}'", query); + + try { + TextSegment textSegment = TextSegment.from(query); + Embedding embedding = embeddingModel.embed(textSegment).content(); + + // Convert float array to List (ES compatibility) + List embeddingVector = new ArrayList<>(); + float[] vector = embedding.vector(); + for (float value : vector) { + embeddingVector.add(value); + } + + log.debug("Query embedding generation completed: dimensions={}", embedding.dimension()); + return embeddingVector; + + } catch (Exception e) { + log.error("Query embedding generation failed: query='{}', error={}", query, e.getMessage(), e); + throw new BusinessException(ErrorCode.EMBEDDING_GENERATION_FAILED, + "Failed to generate query embedding: " + e.getMessage()); + } + } + + /** + * Execute hybrid search query on Elasticsearch + */ + private Map executeElasticsearchQuery(String query, List queryEmbedding, int topK) { + log.debug("Executing Elasticsearch query: topK={}", topK); + + try { + Map searchQuery = buildHybridSearchQuery(query, queryEmbedding, topK); + String searchUrl = elasticsearchUrl + "/" + indexName + "/_search"; + + ResponseEntity> response = restTemplate.postForEntity( + searchUrl, searchQuery, (Class>) (Class) Map.class); + + if (response.getStatusCode() != HttpStatus.OK) { + throw new BusinessException(ErrorCode.ELASTICSEARCH_ERROR, + "Elasticsearch search failed with status: " + response.getStatusCode()); + } + + Map responseBody = response.getBody(); + if (responseBody == null) { + throw new BusinessException(ErrorCode.ELASTICSEARCH_ERROR, + "Empty response from Elasticsearch"); + } + + log.debug("Search query execution successful"); + return responseBody; + + } catch (Exception e) { + log.error("Search query execution failed: error={}", e.getMessage(), e); + throw new BusinessException(ErrorCode.ELASTICSEARCH_ERROR, + "Elasticsearch query execution failed: " + e.getMessage()); + } + } + + /** + * Build hybrid query combining BM25 keyword search and vector similarity search + * Place queries side by side to properly reflect scores + */ + private Map buildHybridSearchQuery(String query, List queryEmbedding, int topK) { + + // BM25 keyword search query + Map bm25Query = Map.of( + "multi_match", Map.of( + "query", query, + "fields", Arrays.asList("content^2", "metadata.title^1.5"), + "type", "best_fields", + "fuzziness", "AUTO", + "boost", bm25Weight + ) + ); + + // Vector similarity search query (apply weight inside script) + Map vectorQuery = Map.of( + "script_score", Map.of( + "query", Map.of("match_all", Map.of()), + "script", Map.of( + "source", "(cosineSimilarity(params.query_vector, 'embedding') + 1.0) * params.vector_weight", + "params", Map.of( + "query_vector", queryEmbedding, + "vector_weight", vectorWeight + ) + ) + ) + ); + + // Hybrid query (place two queries side by side in bool.should) + Map hybridQuery = Map.of( + "bool", Map.of( + "should", Arrays.asList(bm25Query, vectorQuery) + ) + ); + + // Final search query + return Map.of( + "size", topK, + "query", hybridQuery, + "_source", Arrays.asList( + "chunkId", "metadata.title", "content", "metadata.hierarchyLevel", + "sourceDocumentId", "metadata.fileType", "metadata" + ), + "sort", Arrays.asList( + Map.of("_score", Map.of("order", "desc")) + ) + ); + } + + /** + * Convert Elasticsearch response to SearchResultItem list + * Apply relative normalization against maximum score in response + */ + private List parseSearchResults(Map response) { + log.debug("Parsing search results"); + + try { + Map hits = (Map) response.get("hits"); + if (hits == null) { + log.warn("Elasticsearch response missing 'hits' field"); + return Collections.emptyList(); + } + + List> hitList = (List>) hits.get("hits"); + if (hitList == null || hitList.isEmpty()) { + log.info("No search results found"); + return Collections.emptyList(); + } + + // Calculate maximum score in response (for relative normalization) + double maxScore = hitList.stream() + .mapToDouble(hit -> ((Number) hit.get("_score")).doubleValue()) + .max() + .orElse(1.0); + + List results = new ArrayList<>(); + + for (Map hit : hitList) { + try { + SearchResultItem item = parseSearchHit(hit, maxScore); + if (item != null) { + results.add(item); + } + } catch (Exception e) { + log.warn("Search result parsing failed, skipping: {}", e.getMessage()); + // Individual hit parsing failure does not stop the entire search + } + } + + log.debug("Search result parsing completed: resultCount={}", results.size()); + return results; + + } catch (Exception e) { + log.error("Search result parsing failed: error={}", e.getMessage(), e); + throw new BusinessException(ErrorCode.ELASTICSEARCH_ERROR, + "Failed to parse search results: " + e.getMessage()); + } + } + + /** + * Convert individual search result to SearchResultItem + */ + private SearchResultItem parseSearchHit(Map hit, double maxScore) { + Map source = (Map) hit.get("_source"); + if (source == null) { + return null; + } + + // Extract fields according to PRD schema + String chunkId = (String) source.get("chunkId"); + String content = (String) source.get("content"); + Double score = ((Number) hit.get("_score")).doubleValue(); + + // PRD schema: title is located in metadata.title + String title = extractTitle(source); + + // Generate snippet according to PRD policy + String snippet = generateSnippet(content); + + // Relative normalization against maximum score in response + double relevanceScore = normalizeScore(score, maxScore); + + return SearchResultItem.builder() + .chunkId(chunkId) + .title(title != null ? title : "No Title") + .snippet(snippet) + .relevanceScore(relevanceScore) + .build(); + } + + /** + * Extract title from schema (metadata.title) + */ + private String extractTitle(Map source) { + Map metadata = (Map) source.get("metadata"); + if (metadata != null) { + return (String) metadata.get("title"); + } + return null; + } + + /** + * Generate snippet + * - Default length: 50 characters + * - If over 50 characters: first 50 characters + "..." (always added) + * - If under 50 characters: original as-is (no ...) + */ + private String generateSnippet(String content) { + if (content == null || content.trim().isEmpty()) { + return "No content available"; + } + + String cleanContent = content.trim(); + + if (cleanContent.length() <= snippetMaxLength) { + return cleanContent; + } + + return cleanContent.substring(0, snippetMaxLength) + "..."; + } + + /** + * Score normalization - calculate relative ratio against maximum score in response + */ + private double normalizeScore(double score, double maxScore) { + if (maxScore <= 0) { + return 0.0; + } + return score / maxScore; + } +} \ No newline at end of file diff --git a/core/src/main/resources/application-docker.yml b/core/src/main/resources/application-docker.yml index 84b487e..750571c 100644 --- a/core/src/main/resources/application-docker.yml +++ b/core/src/main/resources/application-docker.yml @@ -15,27 +15,58 @@ spring: # JPA Configuration jpa: hibernate: - ddl-auto: create-drop + ddl-auto: validate show-sql: true properties: hibernate: dialect: org.hibernate.dialect.PostgreSQLDialect format_sql: true + # Flyway Configuration (Docker) + flyway: + baseline-on-migrate: true + locations: classpath:db/migration + validate-on-migrate: true + # Servlet Configuration servlet: multipart: max-file-size: 100MB max-request-size: 100MB +# Application-specific Configuration (Docker) +app: # Elasticsearch Configuration (Docker) elasticsearch: - uris: http://elasticsearch:9200 - -# Ollama Configuration (Docker) -ollama: - base-url: http://ollama:11434 - model: dengcao/Qwen3-Embedding-0.6B:F16 + url: http://elasticsearch:9200 + index: document_chunks_index + + # Ollama Configuration (Docker) + ollama: + api: + url: http://ollama:11434 + embedding: + model: dengcao/Qwen3-Embedding-0.6B:F16 + + # Embedding Configuration + embedding: + batch-size: 2 # 더 작은 배치로 안정성 우선 + + # Search Configuration + search: + snippet-max-length: 50 + bm25-weight: 0.3 + vector-weight: 0.7 + + # Content Configuration + content: + default-max-tokens: 25000 + tokenizer: tiktoken-cl100k_base + + # Unstructured API Configuration + unstructured: + api: + url: http://unstructured-api:8000 # MinIO Configuration (Docker) minio: @@ -44,9 +75,7 @@ minio: secret-key: minioadmin123! bucket-name: opencontext-documents -# Unstructured.io Configuration (Docker) -unstructured: - base-url: http://unstructured-api:8000 + # Application Configuration opencontext: @@ -65,6 +94,9 @@ management: endpoint: health: show-details: when-authorized + health: + elasticsearch: + enabled: false # Logging Configuration logging: diff --git a/core/src/main/resources/application.yml b/core/src/main/resources/application.yml index e4629b7..ba47637 100644 --- a/core/src/main/resources/application.yml +++ b/core/src/main/resources/application.yml @@ -18,27 +18,46 @@ spring: # JPA Configuration jpa: hibernate: - ddl-auto: create-drop + ddl-auto: update show-sql: true properties: hibernate: dialect: org.hibernate.dialect.PostgreSQLDialect format_sql: true + # Flyway Configuration + flyway: + baseline-on-migrate: true + # Servlet Configuration servlet: multipart: max-file-size: 100MB max-request-size: 100MB -# Elasticsearch Configuration (Development) -elasticsearch: - hosts: localhost:9200 - -# Ollama Configuration (Development) -ollama: - base-url: http://localhost:11434 - model: dengcao/Qwen3-Embedding-0.6B:F16 +# Application-specific Configuration +app: + # Elasticsearch Configuration + elasticsearch: + url: http://localhost:9200 + index: document_chunks_index + + # Ollama Configuration + ollama: + api: + url: http://localhost:11434 + embedding: + model: dengcao/Qwen3-Embedding-0.6B:F16 + + # Embedding Configuration + embedding: + batch-size: 10 + + # Search Configuration + search: + snippet-max-length: 50 + bm25-weight: 0.3 + vector-weight: 0.7 # MinIO Configuration (Development) minio: diff --git a/core/src/main/resources/db/migration/V1__Create_source_documents_and_document_chunks_tables.sql b/core/src/main/resources/db/migration/V1__Create_source_documents_and_document_chunks_tables.sql new file mode 100644 index 0000000..1189f76 --- /dev/null +++ b/core/src/main/resources/db/migration/V1__Create_source_documents_and_document_chunks_tables.sql @@ -0,0 +1,65 @@ +-- OpenContext MVP Database Schema +-- This migration creates the core tables for document storage and hierarchical chunk management +-- Based on PRD Section 5.1 PostgreSQL Data Model + +-- Table: source_documents +-- Stores master information for all uploaded source files +CREATE TABLE source_documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + original_filename VARCHAR(255) NOT NULL, + file_storage_path VARCHAR(1024) NOT NULL, + file_type VARCHAR(50) NOT NULL, + file_size BIGINT NOT NULL, + file_checksum VARCHAR(64) NOT NULL, + ingestion_status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + error_message TEXT, + last_ingested_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_file_checksum UNIQUE (file_checksum) +); + +-- Table: document_chunks +-- Stores hierarchical structure information for structured chunks +CREATE TABLE document_chunks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + source_document_id UUID NOT NULL REFERENCES source_documents(id) ON DELETE CASCADE, + parent_chunk_id UUID REFERENCES document_chunks(id) ON DELETE CASCADE, + sequence_in_document INT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Indexes for performance optimization +-- Index for querying chunks by source document +CREATE INDEX idx_chunk_source_document_id ON document_chunks(source_document_id); + +-- Index for hierarchical navigation (parent-child relationships) +CREATE INDEX idx_chunk_parent_chunk_id ON document_chunks(parent_chunk_id); + +-- Index for duplicate file prevention via checksum +CREATE INDEX idx_source_documents_file_checksum ON source_documents(file_checksum); + +-- Index for querying documents by ingestion status +CREATE INDEX idx_source_documents_ingestion_status ON source_documents(ingestion_status); + +-- Comments for table and column documentation +COMMENT ON TABLE source_documents IS 'Master table for storing metadata of all uploaded source files'; +COMMENT ON COLUMN source_documents.id IS 'Primary key - unique identifier for each uploaded source document'; +COMMENT ON COLUMN source_documents.original_filename IS 'Original filename provided by user during upload'; +COMMENT ON COLUMN source_documents.file_storage_path IS 'Storage path in Object Storage (MinIO) for accessing the original file'; +COMMENT ON COLUMN source_documents.file_type IS 'Type of uploaded file (e.g., PDF, MARKDOWN)'; +COMMENT ON COLUMN source_documents.file_size IS 'Size of uploaded file in bytes'; +COMMENT ON COLUMN source_documents.file_checksum IS 'SHA-256 hash of file content for duplicate prevention and integrity verification'; +COMMENT ON COLUMN source_documents.ingestion_status IS 'Current status of document ingestion process (PENDING, PARSING, CHUNKING, EMBEDDING, INDEXING, COMPLETED, ERROR, DELETING)'; +COMMENT ON COLUMN source_documents.error_message IS 'Detailed error message when ingestion_status is ERROR (for developer debugging)'; +COMMENT ON COLUMN source_documents.last_ingested_at IS 'Timestamp when this document was last successfully ingested'; +COMMENT ON COLUMN source_documents.created_at IS 'Timestamp when this record was first created in database'; +COMMENT ON COLUMN source_documents.updated_at IS 'Timestamp when this record was last updated'; +COMMENT ON CONSTRAINT uq_file_checksum ON source_documents IS 'Ensures file_checksum values are unique across the table, essential for MVP implementation'; + +COMMENT ON TABLE document_chunks IS 'Table storing hierarchical structure information for document chunks'; +COMMENT ON COLUMN document_chunks.id IS 'Primary key - unique identifier for each structured chunk. Same value as chunkId in Elasticsearch'; +COMMENT ON COLUMN document_chunks.source_document_id IS 'Foreign key to source_documents table. Parent document is cascade deleted when chunk is removed'; +COMMENT ON COLUMN document_chunks.parent_chunk_id IS 'Foreign key to this table for hierarchical parent chunk. NULL for top-level chunks'; +COMMENT ON COLUMN document_chunks.sequence_in_document IS 'Sequential order within document, among sibling chunks with same parent_chunk_id'; +COMMENT ON COLUMN document_chunks.created_at IS 'Timestamp when this chunk record was first created'; \ No newline at end of file diff --git a/core/src/main/resources/db/migration/V2__Add_missing_columns_to_document_chunks.sql b/core/src/main/resources/db/migration/V2__Add_missing_columns_to_document_chunks.sql new file mode 100644 index 0000000..425f365 --- /dev/null +++ b/core/src/main/resources/db/migration/V2__Add_missing_columns_to_document_chunks.sql @@ -0,0 +1,43 @@ +-- Add missing columns to document_chunks table for Entity compatibility +-- This migration aligns the database schema with the current DocumentChunk entity + +-- Add chunk_id column (string representation of the chunk identifier for Elasticsearch consistency) +ALTER TABLE document_chunks ADD COLUMN chunk_id VARCHAR(255); + +-- Add source_document_uuid column (for direct UUID access) +ALTER TABLE document_chunks ADD COLUMN source_document_uuid UUID; + +-- Add parent_chunk_uuid column (for parent relationship as UUID) +ALTER TABLE document_chunks ADD COLUMN parent_chunk_uuid UUID; + +-- Add title column (section heading or title) +ALTER TABLE document_chunks ADD COLUMN title VARCHAR(500); + +-- Add hierarchy_level column (depth level in document structure) +ALTER TABLE document_chunks ADD COLUMN hierarchy_level INTEGER; + +-- Add element_type column (type of original document element) +ALTER TABLE document_chunks ADD COLUMN element_type VARCHAR(50); + +-- Create unique constraint on chunk_id +CREATE UNIQUE INDEX idx_document_chunks_chunk_id ON document_chunks(chunk_id); + +-- Add index for hierarchy_level for performance +CREATE INDEX idx_document_chunks_hierarchy_level ON document_chunks(hierarchy_level); + +-- Add index for element_type +CREATE INDEX idx_document_chunks_element_type ON document_chunks(element_type); + +-- Add index for parent_chunk_uuid +CREATE INDEX idx_document_chunks_parent_chunk_uuid ON document_chunks(parent_chunk_uuid); + +-- Add index for source_document_uuid +CREATE INDEX idx_document_chunks_source_document_uuid ON document_chunks(source_document_uuid); + +-- Add comments for the new columns +COMMENT ON COLUMN document_chunks.chunk_id IS 'String representation of chunk identifier, consistent with Elasticsearch chunkId field'; +COMMENT ON COLUMN document_chunks.source_document_uuid IS 'Direct UUID reference to source document for performance optimization'; +COMMENT ON COLUMN document_chunks.parent_chunk_uuid IS 'Direct UUID reference to parent chunk for hierarchical navigation'; +COMMENT ON COLUMN document_chunks.title IS 'Title or heading of the section this chunk belongs to'; +COMMENT ON COLUMN document_chunks.hierarchy_level IS 'Depth level of this chunk in the document hierarchy (1=root, 2=child, etc.)'; +COMMENT ON COLUMN document_chunks.element_type IS 'Type of the original document element (Title, Header, NarrativeText, etc.)'; \ No newline at end of file diff --git a/core/src/test/java/com/opencontext/common/CommonResponseTest.java b/core/src/test/java/com/opencontext/common/CommonResponseTest.java new file mode 100644 index 0000000..06a3f6b --- /dev/null +++ b/core/src/test/java/com/opencontext/common/CommonResponseTest.java @@ -0,0 +1,69 @@ +package com.opencontext.common; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for CommonResponse class. + */ +class CommonResponseTest { + + @Test + @DisplayName("Success response creation should return correct structure") + void success_shouldReturnCorrectStructure() { + // Given + String testData = "test data"; + + // When + CommonResponse response = CommonResponse.success(testData); + + // Then + assertThat(response.isSuccess()).isTrue(); + assertThat(response.getData()).isEqualTo(testData); + assertThat(response.getMessage()).isEqualTo("Request processed successfully."); + assertThat(response.getErrorCode()).isNull(); + assertThat(response.getTimestamp()).isNotNull(); + assertThat(response.getTimestamp()).isInstanceOf(LocalDateTime.class); + } + + @Test + @DisplayName("Success response creation with custom message should return custom message") + void successWithCustomMessage_shouldReturnCustomMessage() { + // Given + String testData = "test data"; + String customMessage = "Custom success message"; + + // When + CommonResponse response = CommonResponse.success(testData, customMessage); + + // Then + assertThat(response.isSuccess()).isTrue(); + assertThat(response.getData()).isEqualTo(testData); + assertThat(response.getMessage()).isEqualTo(customMessage); + assertThat(response.getErrorCode()).isNull(); + } + + @Test + @DisplayName("Error response creation should return correct structure") + void error_shouldReturnCorrectStructure() { + // Given + String errorMessage = "Test error message"; + String errorCode = "TEST_ERROR"; + + // When + CommonResponse response = CommonResponse.error(errorMessage, errorCode); + + // Then + assertThat(response.isSuccess()).isFalse(); + assertThat(response.getData()).isNull(); + assertThat(response.getMessage()).isEqualTo(errorMessage); + assertThat(response.getErrorCode()).isEqualTo(errorCode); + assertThat(response.getTimestamp()).isNotNull(); + assertThat(response.getTimestamp()).isInstanceOf(LocalDateTime.class); + } +} + diff --git a/core/src/test/java/com/opencontext/common/PageResponseTest.java b/core/src/test/java/com/opencontext/common/PageResponseTest.java new file mode 100644 index 0000000..724ab94 --- /dev/null +++ b/core/src/test/java/com/opencontext/common/PageResponseTest.java @@ -0,0 +1,60 @@ +package com.opencontext.common; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for PageResponse class. + */ +class PageResponseTest { + + @Test + @DisplayName("Should return correct information when creating PageResponse from Page object") + void fromPage_shouldReturnCorrectInformation() { + // Given + List content = List.of("item1", "item2", "item3"); + PageRequest pageRequest = PageRequest.of(0, 10); + Page page = new PageImpl<>(content, pageRequest, 25); + + // When + PageResponse response = PageResponse.from(page); + + // Then + assertThat(response.getContent()).isEqualTo(content); + assertThat(response.getPage()).isEqualTo(0); + assertThat(response.getSize()).isEqualTo(10); + assertThat(response.getTotalElements()).isEqualTo(25); + assertThat(response.getTotalPages()).isEqualTo(3); + assertThat(response.isFirst()).isTrue(); + assertThat(response.isLast()).isFalse(); + } + + @Test + @DisplayName("Should return correct information when creating PageResponse from Page object with custom content") + void fromPageWithCustomContent_shouldReturnCorrectInformation() { + // Given + List originalContent = List.of("item1", "item2", "item3"); + List customContent = List.of("custom1", "custom2"); + PageRequest pageRequest = PageRequest.of(1, 10); + Page page = new PageImpl<>(originalContent, pageRequest, 25); + + // When + PageResponse response = PageResponse.from(page, customContent); + + // Then + assertThat(response.getContent()).isEqualTo(customContent); + assertThat(response.getPage()).isEqualTo(1); + assertThat(response.getSize()).isEqualTo(10); + assertThat(response.getTotalElements()).isEqualTo(25); + assertThat(response.getTotalPages()).isEqualTo(3); + assertThat(response.isFirst()).isFalse(); + assertThat(response.isLast()).isFalse(); + } +} diff --git a/core/src/test/java/com/opencontext/entity/DocumentChunkTest.java b/core/src/test/java/com/opencontext/entity/DocumentChunkTest.java new file mode 100644 index 0000000..279c390 --- /dev/null +++ b/core/src/test/java/com/opencontext/entity/DocumentChunkTest.java @@ -0,0 +1,161 @@ +package com.opencontext.entity; + +import com.opencontext.enums.IngestionStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("DocumentChunk Entity Unit Tests") +class DocumentChunkTest { + + @Test + @DisplayName("Can create DocumentChunk using builder pattern") + void canCreateDocumentChunkWithBuilder() { + // Given + SourceDocument sourceDocument = createTestSourceDocument(); + int sequence = 1; + + // When + DocumentChunk chunk = DocumentChunk.builder() + .sourceDocument(sourceDocument) + .sequenceInDocument(sequence) + .build(); + + // Then + assertThat(chunk.getSourceDocument()).isEqualTo(sourceDocument); + assertThat(chunk.getSequenceInDocument()).isEqualTo(sequence); + assertThat(chunk.getParentChunk()).isNull(); + } + + @Test + @DisplayName("Root chunk correctly identifies itself as root") + void rootChunkIdentifiesAsRoot() { + // Given + SourceDocument sourceDocument = createTestSourceDocument(); + + // When + DocumentChunk rootChunk = DocumentChunk.builder() + .sourceDocument(sourceDocument) + .parentChunk(null) + .sequenceInDocument(0) + .build(); + + // Then + assertThat(rootChunk.isRootChunk()).isTrue(); + assertThat(rootChunk.getParentChunk()).isNull(); + } + + @Test + @DisplayName("Child chunk correctly identifies parent relationship") + void childChunkIdentifiesParentRelationship() { + // Given + SourceDocument sourceDocument = createTestSourceDocument(); + DocumentChunk parentChunk = createTestChunk(sourceDocument, null, 0); + + // When + DocumentChunk childChunk = DocumentChunk.builder() + .sourceDocument(sourceDocument) + .parentChunk(parentChunk) + .sequenceInDocument(1) + .build(); + + // Then + assertThat(childChunk.isRootChunk()).isFalse(); + assertThat(childChunk.getParentChunk()).isEqualTo(parentChunk); + } + + @Test + @DisplayName("Can add child chunks and check hasChildren") + void canAddChildChunksAndCheckHasChildren() { + // Given + SourceDocument sourceDocument = createTestSourceDocument(); + DocumentChunk parent = createTestChunk(sourceDocument, null, 0); + DocumentChunk child1 = createTestChunk(sourceDocument, parent, 1); + DocumentChunk child2 = createTestChunk(sourceDocument, parent, 2); + + // When + parent.addChildChunk(child1); + parent.addChildChunk(child2); + + // Then + assertThat(parent.hasChildren()).isTrue(); + assertThat(parent.getChildChunks()).hasSize(2); + assertThat(parent.getChildChunks()).containsExactly(child1, child2); + assertThat(child1.hasChildren()).isFalse(); + assertThat(child2.hasChildren()).isFalse(); + } + + @Test + @DisplayName("Hierarchy level calculation works correctly") + void hierarchyLevelCalculationWorks() { + // Given + SourceDocument sourceDocument = createTestSourceDocument(); + DocumentChunk root = createTestChunk(sourceDocument, null, 0); + DocumentChunk level1 = createTestChunk(sourceDocument, root, 1); + DocumentChunk level2 = createTestChunk(sourceDocument, level1, 2); + + // When & Then + assertThat(root.getHierarchyLevel()).isEqualTo(1); + assertThat(level1.getHierarchyLevel()).isEqualTo(2); + assertThat(level2.getHierarchyLevel()).isEqualTo(3); + } + + @Test + @DisplayName("getAncestors returns correct ancestor hierarchy") + void getAncestorsReturnsCorrectHierarchy() { + // Given - Create a hierarchy: root -> level1 -> level2 -> level3 + SourceDocument sourceDocument = createTestSourceDocument(); + DocumentChunk root = createTestChunk(sourceDocument, null, 0); + DocumentChunk level1 = createTestChunk(sourceDocument, root, 1); + DocumentChunk level2 = createTestChunk(sourceDocument, level1, 2); + DocumentChunk level3 = createTestChunk(sourceDocument, level2, 3); + + // When + List ancestorsOfLevel3 = level3.getAncestors(); + List ancestorsOfLevel2 = level2.getAncestors(); + List ancestorsOfRoot = root.getAncestors(); + + // Then + assertThat(ancestorsOfLevel3).hasSize(3); + assertThat(ancestorsOfLevel3).containsExactly(root, level1, level2); + + assertThat(ancestorsOfLevel2).hasSize(2); + assertThat(ancestorsOfLevel2).containsExactly(root, level1); + + assertThat(ancestorsOfRoot).isEmpty(); + } + + @Test + @DisplayName("Empty child list returns hasChildren as false") + void emptyChildListReturnsHasChildrenAsFalse() { + // Given + SourceDocument sourceDocument = createTestSourceDocument(); + DocumentChunk leafChunk = createTestChunk(sourceDocument, null, 0); + + // When & Then + assertThat(leafChunk.hasChildren()).isFalse(); + assertThat(leafChunk.getChildChunks()).isEmpty(); + } + + private SourceDocument createTestSourceDocument() { + return SourceDocument.builder() + .originalFilename("test-document.pdf") + .fileStoragePath("/test-document.pdf") + .fileType("PDF") + .fileSize(1024L) + .fileChecksum("test-checksum") + .ingestionStatus(IngestionStatus.PENDING) + .build(); + } + + private DocumentChunk createTestChunk(SourceDocument sourceDocument, DocumentChunk parent, int sequence) { + return DocumentChunk.builder() + .sourceDocument(sourceDocument) + .parentChunk(parent) + .sequenceInDocument(sequence) + .build(); + } +} \ No newline at end of file diff --git a/core/src/test/java/com/opencontext/entity/SourceDocumentTest.java b/core/src/test/java/com/opencontext/entity/SourceDocumentTest.java new file mode 100644 index 0000000..1ea5de4 --- /dev/null +++ b/core/src/test/java/com/opencontext/entity/SourceDocumentTest.java @@ -0,0 +1,140 @@ +package com.opencontext.entity; + +import com.opencontext.enums.IngestionStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("SourceDocument Entity Unit Tests") +class SourceDocumentTest { + + @Test + @DisplayName("Can create SourceDocument using builder pattern") + void canCreateSourceDocumentWithBuilder() { + // Given + String filename = "test-document.pdf"; + String storagePath = "/documents/test-document.pdf"; + String fileType = "PDF"; + Long fileSize = 1024L; + String checksum = "abc123def456"; + + // When + SourceDocument document = SourceDocument.builder() + .originalFilename(filename) + .fileStoragePath(storagePath) + .fileType(fileType) + .fileSize(fileSize) + .fileChecksum(checksum) + .ingestionStatus(IngestionStatus.PENDING) + .build(); + + // Then + assertThat(document.getOriginalFilename()).isEqualTo(filename); + assertThat(document.getFileStoragePath()).isEqualTo(storagePath); + assertThat(document.getFileType()).isEqualTo(fileType); + assertThat(document.getFileSize()).isEqualTo(fileSize); + assertThat(document.getFileChecksum()).isEqualTo(checksum); + assertThat(document.getIngestionStatus()).isEqualTo(IngestionStatus.PENDING); + } + + @Test + @DisplayName("Can update ingestion status to ERROR with error message") + void canUpdateIngestionStatusToError() { + // Given + SourceDocument document = createTestDocument(); + String errorMessage = "Parsing failed"; + + // When + document.updateIngestionStatusToError(errorMessage); + + // Then + assertThat(document.getIngestionStatus()).isEqualTo(IngestionStatus.ERROR); + assertThat(document.getErrorMessage()).isEqualTo(errorMessage); + } + + @Test + @DisplayName("Updating status to COMPLETED sets lastIngestedAt and clears error message") + void setsLastIngestedAtWhenCompletingIngestion() { + // Given + SourceDocument document = createTestDocument(); + LocalDateTime beforeUpdate = LocalDateTime.now().minusSeconds(1); + + // When + document.updateIngestionStatus(IngestionStatus.COMPLETED); + + // Then + LocalDateTime afterUpdate = LocalDateTime.now().plusSeconds(1); + assertThat(document.getIngestionStatus()).isEqualTo(IngestionStatus.COMPLETED); + assertThat(document.getErrorMessage()).isNull(); + assertThat(document.getLastIngestedAt()).isBetween(beforeUpdate, afterUpdate); + } + + @Test + @DisplayName("isProcessing method correctly identifies processing states") + void isProcessingReturnsTrueForProcessingStatuses() { + // Given + SourceDocument document = createTestDocument(); + + // When & Then - Processing statuses + document.updateIngestionStatus(IngestionStatus.PARSING); + assertThat(document.isProcessing()).isTrue(); + + document.updateIngestionStatus(IngestionStatus.CHUNKING); + assertThat(document.isProcessing()).isTrue(); + + document.updateIngestionStatus(IngestionStatus.EMBEDDING); + assertThat(document.isProcessing()).isTrue(); + + document.updateIngestionStatus(IngestionStatus.INDEXING); + assertThat(document.isProcessing()).isTrue(); + + document.updateIngestionStatus(IngestionStatus.DELETING); + assertThat(document.isProcessing()).isTrue(); + + // When & Then - Non-processing statuses + document.updateIngestionStatus(IngestionStatus.PENDING); + assertThat(document.isProcessing()).isFalse(); + + document.updateIngestionStatus(IngestionStatus.COMPLETED); + assertThat(document.isProcessing()).isFalse(); + + document.updateIngestionStatusToError("Some error"); + assertThat(document.isProcessing()).isFalse(); + } + + @Test + @DisplayName("Status check methods work correctly") + void statusCheckMethodsWorkCorrectly() { + // Given + SourceDocument document = createTestDocument(); + + // When & Then - COMPLETED status + document.updateIngestionStatus(IngestionStatus.COMPLETED); + assertThat(document.isCompleted()).isTrue(); + assertThat(document.hasError()).isFalse(); + + // When & Then - ERROR status + document.updateIngestionStatusToError("Test error"); + assertThat(document.isCompleted()).isFalse(); + assertThat(document.hasError()).isTrue(); + + // When & Then - PENDING status + document.updateIngestionStatus(IngestionStatus.PENDING); + assertThat(document.isCompleted()).isFalse(); + assertThat(document.hasError()).isFalse(); + } + + private SourceDocument createTestDocument() { + return SourceDocument.builder() + .originalFilename("test.pdf") + .fileStoragePath("/test.pdf") + .fileType("PDF") + .fileSize(1024L) + .fileChecksum("test-checksum") + .ingestionStatus(IngestionStatus.PENDING) + .build(); + } +} \ No newline at end of file diff --git a/core/src/test/java/com/opencontext/repository/DocumentChunkRepositoryTest.java b/core/src/test/java/com/opencontext/repository/DocumentChunkRepositoryTest.java new file mode 100644 index 0000000..a76d11c --- /dev/null +++ b/core/src/test/java/com/opencontext/repository/DocumentChunkRepositoryTest.java @@ -0,0 +1,273 @@ +package com.opencontext.repository; + +import com.opencontext.entity.DocumentChunk; +import com.opencontext.entity.SourceDocument; +import com.opencontext.enums.IngestionStatus; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.containers.PostgreSQLContainer; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@DirtiesContext +@DisplayName("DocumentChunkRepository Integration Tests") +class DocumentChunkRepositoryTest { + + static PostgreSQLContainer postgres; + + @BeforeAll + static void initContainer() { + postgres = new PostgreSQLContainer<>("postgres:16-alpine") + .withDatabaseName("test_db") + .withUsername("test_user") + .withPassword("test_password"); + postgres.start(); + } + + @AfterAll + static void cleanupContainer() { + if (postgres != null) { + postgres.close(); + } + } + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + } + + @Autowired + private DocumentChunkRepository documentChunkRepository; + + @Autowired + private SourceDocumentRepository sourceDocumentRepository; + + @Test + @DisplayName("Can save and retrieve DocumentChunk by ID") + @Transactional + void canSaveAndRetrieveDocumentChunkById() { + // Given + SourceDocument sourceDocument = createAndSaveSourceDocument("test.pdf", "checksum-1"); + DocumentChunk chunk = createTestChunk(sourceDocument, null, 1); + + // When + DocumentChunk savedChunk = documentChunkRepository.save(chunk); + Optional foundChunk = documentChunkRepository.findById(savedChunk.getId()); + + // Then + assertThat(foundChunk).isPresent(); + assertThat(foundChunk.get().getSourceDocument()).isEqualTo(sourceDocument); + assertThat(foundChunk.get().getSequenceInDocument()).isEqualTo(1); + assertThat(foundChunk.get().getParentChunk()).isNull(); + } + + @Test + @DisplayName("Can find chunks by source document ID") + @Transactional + void canFindChunksBySourceDocumentId() { + // Given + SourceDocument doc1 = createAndSaveSourceDocument("doc1.pdf", "checksum-1"); + SourceDocument doc2 = createAndSaveSourceDocument("doc2.pdf", "checksum-2"); + + DocumentChunk chunk1 = createTestChunk(doc1, null, 1); + DocumentChunk chunk2 = createTestChunk(doc1, null, 2); + DocumentChunk chunk3 = createTestChunk(doc2, null, 1); + + documentChunkRepository.saveAll(List.of(chunk1, chunk2, chunk3)); + + // When + List chunksForDoc1 = documentChunkRepository.findBySourceDocument(doc1); + List chunksForDoc2 = documentChunkRepository.findBySourceDocument(doc2); + + // Then + assertThat(chunksForDoc1).hasSize(2); + assertThat(chunksForDoc1).allMatch(chunk -> chunk.getSourceDocument().equals(doc1)); + + assertThat(chunksForDoc2).hasSize(1); + assertThat(chunksForDoc2.get(0).getSourceDocument()).isEqualTo(doc2); + } + + @Test + @DisplayName("Can find chunks by parent chunk (hierarchical relationship)") + @Transactional + void canFindChunksByParentChunk() { + // Given + SourceDocument sourceDocument = createAndSaveSourceDocument("test.pdf", "checksum-1"); + DocumentChunk parentChunk = createTestChunk(sourceDocument, null, 1); + documentChunkRepository.save(parentChunk); + + DocumentChunk child1 = createTestChunk(sourceDocument, parentChunk, 2); + DocumentChunk child2 = createTestChunk(sourceDocument, parentChunk, 3); + DocumentChunk orphanChunk = createTestChunk(sourceDocument, null, 4); + + documentChunkRepository.saveAll(List.of(child1, child2, orphanChunk)); + + // When + List childChunks = documentChunkRepository.findByParentChunk(parentChunk); + List rootChunks = documentChunkRepository.findByParentChunkIsNull(); + + // Then + assertThat(childChunks).hasSize(2); + assertThat(childChunks).allMatch(chunk -> chunk.getParentChunk().equals(parentChunk)); + + assertThat(rootChunks).hasSize(2); // parentChunk and orphanChunk + assertThat(rootChunks).allMatch(chunk -> chunk.getParentChunk() == null); + } + + @Test + @DisplayName("Can find root chunks (chunks without parent)") + @Transactional + void canFindRootChunks() { + // Given + SourceDocument sourceDocument = createAndSaveSourceDocument("test.pdf", "checksum-1"); + + DocumentChunk rootChunk1 = createTestChunk(sourceDocument, null, 1); + DocumentChunk rootChunk2 = createTestChunk(sourceDocument, null, 2); + + documentChunkRepository.save(rootChunk1); + documentChunkRepository.save(rootChunk2); + + DocumentChunk childChunk = createTestChunk(sourceDocument, rootChunk1, 3); + documentChunkRepository.save(childChunk); + + // When + List rootChunks = documentChunkRepository.findByParentChunkIsNull(); + + // Then + assertThat(rootChunks).hasSize(2); + assertThat(rootChunks).allMatch(DocumentChunk::isRootChunk); + } + + @Test + @DisplayName("Can find chunks ordered by sequence in document") + @Transactional + void canFindChunksOrderedBySequence() { + // Given + SourceDocument sourceDocument = createAndSaveSourceDocument("test.pdf", "checksum-1"); + + DocumentChunk chunk3 = createTestChunk(sourceDocument, null, 3); + DocumentChunk chunk1 = createTestChunk(sourceDocument, null, 1); + DocumentChunk chunk2 = createTestChunk(sourceDocument, null, 2); + + // Save in random order + documentChunkRepository.saveAll(List.of(chunk3, chunk1, chunk2)); + + // When + List orderedChunks = documentChunkRepository.findBySourceDocumentOrderBySequenceInDocumentAsc(sourceDocument); + + // Then + assertThat(orderedChunks).hasSize(3); + assertThat(orderedChunks.get(0).getSequenceInDocument()).isEqualTo(1); + assertThat(orderedChunks.get(1).getSequenceInDocument()).isEqualTo(2); + assertThat(orderedChunks.get(2).getSequenceInDocument()).isEqualTo(3); + } + + @Test + @DisplayName("Can count chunks by source document") + @Transactional + void canCountChunksBySourceDocument() { + // Given + SourceDocument doc1 = createAndSaveSourceDocument("doc1.pdf", "checksum-1"); + SourceDocument doc2 = createAndSaveSourceDocument("doc2.pdf", "checksum-2"); + + DocumentChunk chunk1 = createTestChunk(doc1, null, 1); + DocumentChunk chunk2 = createTestChunk(doc1, null, 2); + DocumentChunk chunk3 = createTestChunk(doc2, null, 1); + + documentChunkRepository.saveAll(List.of(chunk1, chunk2, chunk3)); + + // When + long countForDoc1 = documentChunkRepository.countBySourceDocument(doc1); + long countForDoc2 = documentChunkRepository.countBySourceDocument(doc2); + + // Then + assertThat(countForDoc1).isEqualTo(2L); + assertThat(countForDoc2).isEqualTo(1L); + } + + @Test + @DisplayName("Can check if chunks exist for source document") + @Transactional + void canCheckIfChunksExistForSourceDocument() { + // Given + SourceDocument docWithChunks = createAndSaveSourceDocument("with-chunks.pdf", "checksum-1"); + SourceDocument docWithoutChunks = createAndSaveSourceDocument("without-chunks.pdf", "checksum-2"); + + DocumentChunk chunk = createTestChunk(docWithChunks, null, 1); + documentChunkRepository.save(chunk); + + // When + boolean hasChunks = documentChunkRepository.existsBySourceDocument(docWithChunks); + boolean hasNoChunks = documentChunkRepository.existsBySourceDocument(docWithoutChunks); + + // Then + assertThat(hasChunks).isTrue(); + assertThat(hasNoChunks).isFalse(); + } + + @Test + @DisplayName("Cascading delete works when source document is deleted") + @Transactional + void cascadingDeleteWorksWhenSourceDocumentIsDeleted() { + // Given + SourceDocument sourceDocument = createAndSaveSourceDocument("test.pdf", "checksum-1"); + DocumentChunk chunk1 = createTestChunk(sourceDocument, null, 1); + DocumentChunk chunk2 = createTestChunk(sourceDocument, null, 2); + + documentChunkRepository.saveAll(List.of(chunk1, chunk2)); + + UUID sourceDocumentId = sourceDocument.getId(); + long initialChunkCount = documentChunkRepository.countBySourceDocument(sourceDocument); + assertThat(initialChunkCount).isEqualTo(2L); + + // When + sourceDocumentRepository.delete(sourceDocument); + sourceDocumentRepository.flush(); // Ensure deletion is executed + + // Then + boolean sourceExists = sourceDocumentRepository.existsById(sourceDocumentId); + long remainingChunkCount = documentChunkRepository.count(); + + assertThat(sourceExists).isFalse(); + assertThat(remainingChunkCount).isEqualTo(0L); // All chunks should be deleted due to CASCADE + } + + private SourceDocument createAndSaveSourceDocument(String filename, String checksum) { + SourceDocument document = SourceDocument.builder() + .originalFilename(filename) + .fileStoragePath("/" + filename) + .fileType("PDF") + .fileSize(1024L) + .fileChecksum(checksum) + .ingestionStatus(IngestionStatus.PENDING) + .build(); + return sourceDocumentRepository.save(document); + } + + private DocumentChunk createTestChunk(SourceDocument sourceDocument, DocumentChunk parentChunk, int sequence) { + return DocumentChunk.builder() + .sourceDocument(sourceDocument) + .parentChunk(parentChunk) + .sequenceInDocument(sequence) + .build(); + } +} \ No newline at end of file diff --git a/core/src/test/java/com/opencontext/repository/SourceDocumentRepositoryTest.java b/core/src/test/java/com/opencontext/repository/SourceDocumentRepositoryTest.java new file mode 100644 index 0000000..08eef76 --- /dev/null +++ b/core/src/test/java/com/opencontext/repository/SourceDocumentRepositoryTest.java @@ -0,0 +1,241 @@ +package com.opencontext.repository; + +import com.opencontext.entity.SourceDocument; +import com.opencontext.enums.IngestionStatus; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.containers.PostgreSQLContainer; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@ActiveProfiles("test") +@DirtiesContext +@DisplayName("SourceDocumentRepository Integration Tests") +class SourceDocumentRepositoryTest { + + static PostgreSQLContainer postgres; + + @BeforeAll + static void initContainer() { + postgres = new PostgreSQLContainer<>("postgres:16-alpine") + .withDatabaseName("test_db") + .withUsername("test_user") + .withPassword("test_password"); + postgres.start(); + } + + @AfterAll + static void cleanupContainer() { + if (postgres != null) { + postgres.close(); + } + } + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + } + + @Autowired + private SourceDocumentRepository sourceDocumentRepository; + + @Test + @DisplayName("Can save and retrieve SourceDocument by ID") + @Transactional + void canSaveAndRetrieveSourceDocumentById() { + // Given + SourceDocument document = createTestDocument("test.pdf", "unique-checksum-1"); + + // When + SourceDocument savedDocument = sourceDocumentRepository.save(document); + Optional foundDocument = sourceDocumentRepository.findById(savedDocument.getId()); + + // Then + assertThat(foundDocument).isPresent(); + assertThat(foundDocument.get().getOriginalFilename()).isEqualTo("test.pdf"); + assertThat(foundDocument.get().getFileChecksum()).isEqualTo("unique-checksum-1"); + assertThat(foundDocument.get().getIngestionStatus()).isEqualTo(IngestionStatus.PENDING); + } + + @Test + @DisplayName("Can find document by file checksum") + @Transactional + void canFindDocumentByFileChecksum() { + // Given + String uniqueChecksum = "unique-checksum-for-find-test"; + SourceDocument document = createTestDocument("findme.pdf", uniqueChecksum); + sourceDocumentRepository.save(document); + + // When + Optional foundDocument = sourceDocumentRepository.findByFileChecksum(uniqueChecksum); + + // Then + assertThat(foundDocument).isPresent(); + assertThat(foundDocument.get().getOriginalFilename()).isEqualTo("findme.pdf"); + assertThat(foundDocument.get().getFileChecksum()).isEqualTo(uniqueChecksum); + } + + @Test + @DisplayName("Returns empty when searching for non-existent checksum") + @Transactional + void returnsEmptyForNonExistentChecksum() { + // Given + String nonExistentChecksum = "non-existent-checksum"; + + // When + Optional foundDocument = sourceDocumentRepository.findByFileChecksum(nonExistentChecksum); + + // Then + assertThat(foundDocument).isEmpty(); + } + + @Test + @DisplayName("Can find documents by ingestion status") + @Transactional + void canFindDocumentsByIngestionStatus() { + // Given + SourceDocument pendingDoc1 = createTestDocument("pending1.pdf", "checksum-1"); + SourceDocument pendingDoc2 = createTestDocument("pending2.pdf", "checksum-2"); + SourceDocument completedDoc = createTestDocument("completed.pdf", "checksum-3"); + completedDoc.updateIngestionStatus(IngestionStatus.COMPLETED); + + sourceDocumentRepository.saveAll(List.of(pendingDoc1, pendingDoc2, completedDoc)); + + // When + List pendingDocuments = sourceDocumentRepository.findByIngestionStatus(IngestionStatus.PENDING); + List completedDocuments = sourceDocumentRepository.findByIngestionStatus(IngestionStatus.COMPLETED); + + // Then + assertThat(pendingDocuments).hasSize(2); + assertThat(pendingDocuments).extracting(SourceDocument::getOriginalFilename) + .containsExactlyInAnyOrder("pending1.pdf", "pending2.pdf"); + + assertThat(completedDocuments).hasSize(1); + assertThat(completedDocuments.get(0).getOriginalFilename()).isEqualTo("completed.pdf"); + } + + @Test + @DisplayName("Can count documents by ingestion status") + @Transactional + void canCountDocumentsByIngestionStatus() { + // Given + SourceDocument pendingDoc = createTestDocument("pending.pdf", "checksum-pending"); + SourceDocument errorDoc = createTestDocument("error.pdf", "checksum-error"); + errorDoc.updateIngestionStatusToError("Test error"); + + sourceDocumentRepository.saveAll(List.of(pendingDoc, errorDoc)); + + // When + long pendingCount = sourceDocumentRepository.countByIngestionStatus(IngestionStatus.PENDING); + long errorCount = sourceDocumentRepository.countByIngestionStatus(IngestionStatus.ERROR); + long completedCount = sourceDocumentRepository.countByIngestionStatus(IngestionStatus.COMPLETED); + + // Then + assertThat(pendingCount).isEqualTo(1L); + assertThat(errorCount).isEqualTo(1L); + assertThat(completedCount).isEqualTo(0L); + } + + @Test + @DisplayName("Can find documents by file type") + @Transactional + void canFindDocumentsByFileType() { + // Given + SourceDocument pdfDoc = createTestDocument("doc1.pdf", "checksum-pdf"); + pdfDoc = SourceDocument.builder() + .originalFilename("doc1.pdf") + .fileStoragePath("/doc1.pdf") + .fileType("PDF") + .fileSize(1024L) + .fileChecksum("checksum-pdf") + .build(); + + SourceDocument markdownDoc = SourceDocument.builder() + .originalFilename("doc2.md") + .fileStoragePath("/doc2.md") + .fileType("MARKDOWN") + .fileSize(512L) + .fileChecksum("checksum-md") + .build(); + + sourceDocumentRepository.saveAll(List.of(pdfDoc, markdownDoc)); + + // When + List pdfDocuments = sourceDocumentRepository.findByFileTypeIgnoreCase("PDF"); + List markdownDocuments = sourceDocumentRepository.findByFileTypeIgnoreCase("MARKDOWN"); + + // Then + assertThat(pdfDocuments).hasSize(1); + assertThat(pdfDocuments.get(0).getOriginalFilename()).isEqualTo("doc1.pdf"); + + assertThat(markdownDocuments).hasSize(1); + assertThat(markdownDocuments.get(0).getOriginalFilename()).isEqualTo("doc2.md"); + } + + @Test + @DisplayName("Can check if document exists by checksum") + @Transactional + void canCheckIfDocumentExistsByChecksum() { + // Given + String existingChecksum = "existing-checksum"; + String nonExistingChecksum = "non-existing-checksum"; + SourceDocument document = createTestDocument("existing.pdf", existingChecksum); + sourceDocumentRepository.save(document); + + // When + boolean exists = sourceDocumentRepository.existsByFileChecksum(existingChecksum); + boolean doesNotExist = sourceDocumentRepository.existsByFileChecksum(nonExistingChecksum); + + // Then + assertThat(exists).isTrue(); + assertThat(doesNotExist).isFalse(); + } + + @Test + @DisplayName("Can find documents created within time range") + @Transactional + void canFindDocumentsCreatedWithinTimeRange() { + // Given + LocalDateTime startTime = LocalDateTime.now().minusHours(1); + LocalDateTime endTime = LocalDateTime.now().plusHours(1); + + SourceDocument recentDocument = createTestDocument("recent.pdf", "recent-checksum"); + sourceDocumentRepository.save(recentDocument); + + // When + List documentsInRange = sourceDocumentRepository.findByCreatedAtBetween(startTime, endTime); + + // Then + assertThat(documentsInRange).hasSize(1); + assertThat(documentsInRange.get(0).getOriginalFilename()).isEqualTo("recent.pdf"); + } + + private SourceDocument createTestDocument(String filename, String checksum) { + return SourceDocument.builder() + .originalFilename(filename) + .fileStoragePath("/" + filename) + .fileType("PDF") + .fileSize(1024L) + .fileChecksum(checksum) + .ingestionStatus(IngestionStatus.PENDING) + .build(); + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index bcbed2c..c416fb1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,6 +2,7 @@ services: # PostgreSQL Database postgres: image: postgres:16-alpine + platform: linux/amd64 container_name: postgres environment: POSTGRES_DB: opencontext @@ -11,7 +12,6 @@ services: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data - - ./init/init.sql:/docker-entrypoint-initdb.d/init.sql networks: - opencontext-network healthcheck: @@ -49,6 +49,7 @@ services: # Ollama Model Downloader ollama-init: image: ollama/ollama:latest + platform: linux/amd64 container_name: opencontext-ollama-init volumes: - ollama_data:/root/.ollama @@ -58,17 +59,11 @@ services: - OLLAMA_HOST=0.0.0.0 entrypoint: /bin/sh command: -c "ollama serve & sleep 10 && ollama pull dengcao/Qwen3-Embedding-0.6B:F16 && echo 'Model download completed' && pkill ollama" - deploy: - resources: - reservations: - devices: - - driver: nvidia - count: 1 - capabilities: [gpu] # Ollama Server for embeddings ollama: image: ollama/ollama:latest + platform: linux/amd64 container_name: ollama ports: - "11434:11434" @@ -78,22 +73,35 @@ services: - opencontext-network environment: - OLLAMA_HOST=0.0.0.0 - # - OLLAMA_GPU_OVERHEAD=0 - # - OLLAMA_NUM_PARALLEL=1 + - OLLAMA_NUM_PARALLEL=1 depends_on: - ollama-init - deploy: - resources: - reservations: - devices: - - driver: nvidia - count: 1 - capabilities: [gpu] + # To enable GPU support, set OLLAMA_RUNTIME=nvidia in your environment. + # If you do not have an NVIDIA GPU, leave OLLAMA_RUNTIME unset. + runtime: ${OLLAMA_RUNTIME:-} + restart: unless-stopped + + # Kibana for Elasticsearch management + kibana: + image: docker.elastic.co/kibana/kibana:8.11.3 + platform: linux/amd64 + container_name: kibana + ports: + - "5601:5601" + environment: + - ELASTICSEARCH_HOSTS=http://elasticsearch:9200 + - XPACK_SECURITY_ENABLED=false + depends_on: + elasticsearch: + condition: service_healthy + networks: + - opencontext-network restart: unless-stopped # MinIO Object Storage minio: image: minio/minio:latest + platform: linux/amd64 container_name: minio ports: - "9000:9000" @@ -111,9 +119,12 @@ services: # Unstructured.io API for document parsing unstructured-api: image: quay.io/unstructured-io/unstructured-api:latest + platform: linux/amd64 container_name: unstructured-api ports: - "8000:8000" + environment: + - UNSTRUCTURED_MEMORY_FREE_MINIMUM_MB=512 networks: - opencontext-network restart: unless-stopped @@ -129,15 +140,6 @@ services: - "8080:8080" environment: - SPRING_PROFILES_ACTIVE=docker - - SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/opencontext - - SPRING_DATASOURCE_USERNAME=user - - SPRING_DATASOURCE_PASSWORD=password - - ELASTICSEARCH_URL=http://elasticsearch:9200 - - OLLAMA_BASE_URL=http://ollama:11434 - - UNSTRUCTURED_API_URL=http://unstructured-api:8000 - - MINIO_URL=http://minio:9000 - - MINIO_ACCESS_KEY=${MINIO_ROOT_USER:-your-minio-access-key} - - MINIO_SECRET_KEY=${MINIO_ROOT_PASSWORD:-your-minio-secret-key} depends_on: postgres: condition: service_healthy @@ -153,6 +155,22 @@ services: - opencontext-network restart: unless-stopped + # Mock Server for testing + open-context-mock-server: + build: + context: ./mcp-adapter + dockerfile: Dockerfile + image: opencontext/mcp-adapter:0.1.0 + container_name: open-context-mock-server + ports: + - "8001:8001" + command: ["npm", "run", "mock"] + environment: + - MOCK_SERVER_PORT=8001 + networks: + - opencontext-network + restart: unless-stopped + # Node.js MCP Adapter open-context-mcp-adapter: build: @@ -160,6 +178,30 @@ services: dockerfile: Dockerfile image: opencontext/mcp-adapter:0.1.0 container_name: open-context-mcp-adapter + ports: + - "3000:3000" + environment: + - OPENCONTEXT_CORE_URL=http://open-context-core:8080 + - OPENCONTEXT_DEFAULT_TOP_K=5 + - OPENCONTEXT_DEFAULT_MAX_TOKENS=25000 + - MCP_SERVER_PORT=3000 + - MCP_MODE=http + depends_on: + - open-context-core + networks: + - opencontext-network + command: ["node", "dist/index.js", "--transport", "http", "--port", "3000"] + restart: unless-stopped + + # Admin UI Frontend + open-context-admin-ui: + build: + context: ./admin-ui + dockerfile: Dockerfile + image: opencontext/admin-ui:0.1.0 + container_name: open-context-admin-ui + ports: + - "3001:80" depends_on: - open-context-core networks: diff --git a/mcp-adapter/.dockerignore b/mcp-adapter/.dockerignore index a98fdec..3b3d6a3 100644 --- a/mcp-adapter/.dockerignore +++ b/mcp-adapter/.dockerignore @@ -35,3 +35,10 @@ Thumbs.db # Docker Dockerfile .dockerignore + +# Build output +dist + +# Test coverage +.nyc_output + diff --git a/mcp-adapter/.gitignore b/mcp-adapter/.gitignore new file mode 100644 index 0000000..e185053 --- /dev/null +++ b/mcp-adapter/.gitignore @@ -0,0 +1,40 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Lock files +package-lock.json +yarn.lock + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ +*.lcov + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# Environment variables +.env +.env.local +.env.*.local + +# Logs +logs +*.log + +# Optional REPL history +.node_repl_history + +# Build output +dist/ \ No newline at end of file diff --git a/mcp-adapter/Dockerfile b/mcp-adapter/Dockerfile index f6cf8db..dbc6271 100644 --- a/mcp-adapter/Dockerfile +++ b/mcp-adapter/Dockerfile @@ -7,14 +7,30 @@ WORKDIR /app # Copy package files COPY package*.json ./ -# Install dependencies (only devDependencies for nodemon) +# Install dependencies (including dev dependencies for build) +# Use npm install because package-lock.json is not committed RUN npm install # Copy source code COPY . . -# Expose port +# Build TypeScript +RUN npm run build + +# Remove dev dependencies to reduce image size +RUN npm prune --production + +# Create non-root user +RUN addgroup -g 1001 -S nodejs +RUN adduser -S nodejs -u 1001 +USER nodejs + +# Expose port (if needed for health checks) EXPOSE 3000 -# Start the application -CMD ["node", "index.js"] +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "console.log('MCP Server health check passed')" || exit 1 + +# Start MCP server +CMD ["node", "dist/index.js"] diff --git a/mcp-adapter/README.md b/mcp-adapter/README.md new file mode 100644 index 0000000..95401fa --- /dev/null +++ b/mcp-adapter/README.md @@ -0,0 +1,138 @@ +# OpenContext MCP Adapter + +A Model Context Protocol (MCP) adapter for the OpenContext system. This adapter allows Cursor and other MCP clients to use OpenContext's document search and context provision capabilities. + +## Key Features + +- **Two-Phase Knowledge Search System**: + - `find_knowledge`: Semantic search to identify relevant document chunks + - `get_content`: Retrieve complete content of selected chunks +- **Dual Transport Support**: Supports both stdio and HTTP transport +- **Real-time Logging**: Detailed logging for all MCP tool calls + +## System Requirements + +- **Node.js**: 18.0.0 or higher + +## Installation and Setup + +### Method 1: Using Docker Compose (Recommended) +```bash +# Run from project root +cd /path/to/open-context +docker-compose up -d +``` + +### Method 2: Local Development Environment +```bash +# Install dependencies +cd mcp-adapter +npm install + +# Set environment variables +export OPENCONTEXT_CORE_URL=http://localhost:8080 +export MCP_SERVER_PORT=3000 +export OPENCONTEXT_DEFAULT_TOP_K=5 +export OPENCONTEXT_DEFAULT_MAX_TOKENS=25000 +``` + + +## MCP Client Configuration + +### MCP Client Registration (HTTP Mode) + +**Cursor/VSCode**: +```json +{ + "mcpServers": { + "opencontext": { + "url": "http://localhost:3000/mcp" + } + } +} +``` + +**For other MCP clients**: +```json +{ + "mcpServers": { + "opencontext": { + "type": "streamable-http", + "url": "http://localhost:3000/mcp" + } +} +``` + +**Configuration File Locations:** +- **Cursor**: `%USERPROFILE%\.cursor\mcp.json` (Windows), `~/Library/Application Support/Cursor/mcp.json` (macOS), `~/.cursor/mcp.json` (Linux) +- **Claude Desktop**: `%APPDATA%\Claude\claude_desktop_config.json` (Windows), `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) + +**Important**: The MCP server runs in HTTP mode and requires an MCP client for communication. Direct execution is not possible. + +## Usage + +Once configuration is complete, you can use it in MCP clients as follows: + +``` +use opencontext +``` + +Entering this command will give you access to all tools of the OpenContext MCP adapter. + +## Project Structure + +``` +mcp-adapter/ +├── index.ts # Main entry point and MCP server configuration +├── lib/ +│ ├── api.ts # OpenContext Core API client +│ └── types.ts # TypeScript type definitions +├── mock-server.ts # Development mock server +├── dist/ # Built JavaScript files +├── package.json # Project dependencies and scripts +├── tsconfig.json # TypeScript configuration +└── README.md # This file +``` + +## Available MCP Tools + +### 1. `find_knowledge` - Knowledge Search +**Description**: First-stage search tool that identifies the most relevant knowledge chunk ID based on user queries + +**Input Parameters**: +- `query` (required): Natural language search query (e.g., "Spring Security JWT filter implementation") +- `topK` (optional): Maximum number of results to return (default: 5) + +**Output**: List of chunks with relevance scores (chunkId, title, snippet, relevanceScore) + +**Usage Rule**: This tool must always be called first, and the most appropriate chunkId must be selected from the results. + +### 2. `get_content` - Content Retrieval +**Description**: Second-stage tool that retrieves complete content of specific chunks using chunkId obtained from `find_knowledge` + +**Input Parameters**: +- `chunkId` (required): Chunk ID selected from `find_knowledge` +- `maxTokens` (optional): Maximum tokens to return (default: 25000) + +**Output**: Original text content and token information + +**Usage Rule**: Must call `find_knowledge` first to obtain a valid chunkId. + +### Port Verification +When the MCP adapter is running normally, you can access the following endpoints: +- **MCP Endpoint**: `http://localhost:3000/mcp` +- **Health Check**: `http://localhost:3000/health` +- **Server Info**: `http://localhost:3000/info` +- **Ping Test**: `http://localhost:3000/ping` + + +## Logging and Monitoring + +All MCP tool calls are logged in the following format: +``` +[MCP] find_knowledge called from 192.168.1.100 with query: "Spring Security", topK: 5 +[MCP] find_knowledge returned 3 results from 192.168.1.100 +[MCP] get_content called from 192.168.1.100 with chunkId: "uuid-123", maxTokens: 25000 +[MCP] get_content successfully returned content with 1500 tokens from 192.168.1.100 +``` + diff --git a/mcp-adapter/index.js b/mcp-adapter/index.js deleted file mode 100644 index 5f316c1..0000000 --- a/mcp-adapter/index.js +++ /dev/null @@ -1,71 +0,0 @@ -const http = require('http'); - -// Simple MCP Adapter - Docker Health Check Only -const server = http.createServer((req, res) => { - res.setHeader('Content-Type', 'application/json'); - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); - - if (req.method === 'OPTIONS') { - res.writeHead(200); - res.end(); - return; - } - - if (req.method === 'GET' && req.url === '/health') { - const health = { - status: 'healthy', - service: 'mcp-adapter', - timestamp: new Date().toISOString(), - uptime: process.uptime(), - version: '1.0.0' - }; - - res.writeHead(200); - res.end(JSON.stringify(health, null, 2)); - } else if (req.method === 'GET' && req.url === '/') { - const info = { - service: 'OpenContext MCP Adapter', - version: '1.0.0', - status: 'running', - endpoints: { - health: '/health', - info: '/' - } - }; - - res.writeHead(200); - res.end(JSON.stringify(info, null, 2)); - } else { - res.writeHead(404); - res.end(JSON.stringify({ - error: 'Endpoint not found', - available: ['/', '/health'] - }, null, 2)); - } -}); - -const PORT = process.env.PORT || 3000; -server.listen(PORT, '0.0.0.0', () => { - console.log(`🚀 MCP Adapter running on port ${PORT}`); - console.log(`📊 Health check: http://localhost:${PORT}/health`); - console.log(`ℹ️ Info: http://localhost:${PORT}/`); -}); - -// Graceful shutdown -process.on('SIGTERM', () => { - console.log('SIGTERM received, shutting down gracefully'); - server.close(() => { - console.log('Server closed'); - process.exit(0); - }); -}); - -process.on('SIGINT', () => { - console.log('SIGINT received, shutting down gracefully'); - server.close(() => { - console.log('Server closed'); - process.exit(0); - }); -}); diff --git a/mcp-adapter/index.ts b/mcp-adapter/index.ts new file mode 100644 index 0000000..ef99817 --- /dev/null +++ b/mcp-adapter/index.ts @@ -0,0 +1,343 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import { findKnowledge, getContent } from "./lib/api.js"; +import { createServer } from "http"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { Command } from "commander"; +import { IncomingMessage } from "http"; + +// Environment variables for default values +const DEFAULT_TOP_K = parseInt(process.env.OPENCONTEXT_DEFAULT_TOP_K || '5', 10); +const DEFAULT_MAX_TOKENS = parseInt(process.env.OPENCONTEXT_DEFAULT_MAX_TOKENS || '25000', 10); + +// Parse CLI arguments using commander +const program = new Command() + .option("--transport ", "transport type", "stdio") + .option("--port ", "port for HTTP transport", "3000") + .allowUnknownOption() // let MCP Inspector / other wrappers pass through extra flags + .parse(process.argv); + +const cliOptions = program.opts<{ + transport: string; + port: string; +}>(); + +// Validate transport option +const allowedTransports = ["stdio", "http"]; +if (!allowedTransports.includes(cliOptions.transport)) { + console.error( + `Invalid --transport value: '${cliOptions.transport}'. Must be one of: stdio, http.` + ); + process.exit(1); +} + +// Transport configuration +const TRANSPORT_TYPE = (cliOptions.transport || "stdio") as "stdio" | "http"; + +// HTTP port configuration - CLI arguments first, environment variables second +const CLI_PORT = (() => { + const parsed = parseInt(cliOptions.port, 10); + if (!isNaN(parsed)) return parsed; + + // Use environment variable if CLI argument is not provided + const envPort = parseInt(process.env.MCP_SERVER_PORT || '3000', 10); + return isNaN(envPort) ? 3000 : envPort; +})(); + +function getClientIp(req: IncomingMessage): string | undefined { + // Check both possible header casings + const forwardedFor = req.headers["x-forwarded-for"] || req.headers["X-Forwarded-For"]; + + if (forwardedFor) { + // X-Forwarded-For can contain multiple IPs + const ips = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor; + const ipList = ips.split(",").map((ip) => ip.trim()); + + // Find the first public IP address + for (const ip of ipList) { + const plainIp = ip.replace(/^::ffff:/, ""); + if ( + !plainIp.startsWith("10.") && + !plainIp.startsWith("192.168.") && + !/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(plainIp) + ) { + return plainIp; + } + } + // If all are private, use the first one + return ipList[0].replace(/^::ffff:/, ""); + } + + // Fallback: use remote address, strip IPv6-mapped IPv4 + if (req.socket?.remoteAddress) { + return req.socket.remoteAddress.replace(/^::ffff:/, ""); + } + return undefined; +} + +function createServerInstance(clientIp?: string) { + const server = new McpServer( + { + name: "OpenContext MCP Adapter", + version: "1.0.0" + }, + { + instructions: "Use this server to access and search a secure, local knowledge base compiled from the user's private documents and technical specifications. This is the authoritative source of truth for answering questions about the user's specific codebase, internal architecture, or project-related documentation, as it contains proprietary and context-rich information not available on the public internet." + } + ); + + // OpenContext MCP: find_knowledge tool + server.registerTool( + "find_knowledge", + { + title: "Find Knowledge", + description: `This is the first-stage search tool for identifying the most relevant knowledge chunk ID (chunkId) based on the user's query. The primary purpose of this tool is to identify the best candidate for the subsequent get_content tool. + +Process: +It performs an initial search for a list of relevant knowledge chunk candidates based on the user's natural language question. +The LLM must analyze the returned list (which includes titles, snippets, and relevance scores) to select the single best item that most closely matches the user's intent. +The chunkId from the selected item must be used immediately to call the 'get_content tool'. + +Usage Rule: +This tool must always be called first when the user asks a question about their internal documents or specific projects.`, + inputSchema: { + query: z.string().describe("User's original natural language search query (e.g., 'Spring Security JWT filter implementation')"), + topK: z.number().optional().describe(`Maximum number of results to return (default: ${DEFAULT_TOP_K})`) + } + }, + async ({ query, topK = DEFAULT_TOP_K }) => { + const clientInfo = clientIp ? ` from ${clientIp}` : ""; + console.log(`[MCP] find_knowledge called${clientInfo} with query: "${query}", topK: ${topK}`); + + try { + const result = await findKnowledge(query, topK); + + if (result.error) { + console.error(`[MCP] find_knowledge error${clientInfo}: ${result.error.message}`); + return { + content: [{ + type: "text", + text: `Error occurred during search: ${result.error.message}` + }] + }; + } + + if (!result.results || result.results.length === 0) { + return { + content: [{ + type: "text", + text: "No knowledge chunks found related to the search query." + }] + }; + } + + const resultsText = result.results.map((item, index) => { + return `> **Result ${index + 1}: ${item.title}**\n> - **Relevance:** ${(item.relevanceScore * 100).toFixed(1)}%\n> - **Snippet:** ${item.snippet}\n> - **ChunkID:** \`${item.chunkId}\``; + }).join("\n\n"); + + const responseText = `Search complete. The following knowledge chunks were found. The LLM's next action is to select the best ChunkID and call the 'get_content' tool.\n\n${resultsText}`; + + console.log(`[MCP] find_knowledge returned ${result.results.length} results${clientInfo}`); + return { + content: [{ + type: "text", + text: responseText + }] + }; + + } catch (error) { + console.error(`[MCP] find_knowledge unexpected error${clientInfo}:`, error); + return { + content: [{ + type: "text", + text: `Unexpected error occurred during search processing: ${error instanceof Error ? error.message : String(error)}` + }] + }; + } + } + ); + + // OpenContext MCP: get_content tool + server.registerTool( + "get_content", + { + title: "Get Content", + description: `This is the second-stage tool that retrieves the full, original text content of a specific chunk using a \`chunkId\` obtained from the 'find_knowledge' tool. + +Prerequisite: +- You must call 'find_knowledge' first to get a valid \`chunkId\`. This tool cannot be used without it. + +Action: +- It takes a single \`chunkId\` as input. +- It returns the complete text content, which serves as the decisive context for the LLM to generate the final, detailed answer for the user.`, + inputSchema: { + chunkId: z.string().describe("The single, most relevant chunkId selected from the results of the 'find_knowledge' tool"), + maxTokens: z.number().optional().describe(`The maximum number of tokens for the returned content (default: ${DEFAULT_MAX_TOKENS})`) + } + }, + async ({ chunkId, maxTokens = DEFAULT_MAX_TOKENS }) => { + const clientInfo = clientIp ? ` from ${clientIp}` : ""; + console.log(`[MCP] get_content called${clientInfo} with chunkId: "${chunkId}", maxTokens: ${maxTokens}`); + + try { + const result = await getContent(chunkId, maxTokens); + + if (result.error) { + console.error(`[MCP] get_content error${clientInfo}: ${result.error.message}`); + return { + content: [{ + type: "text", + text: `Error occurred while retrieving content: ${result.error.message}` + }] + }; + } + + const tokenInfo = result.tokenInfo ? + `\n\n**Token Information:**\n- Tokenizer: ${result.tokenInfo.tokenizer}\n- Actual Token Count: ${result.tokenInfo.actualTokens}` : + ""; + + const responseText = result.content; + + console.log(`[MCP] get_content successfully returned content with ${result.tokenInfo?.actualTokens || 'unknown'} tokens${clientInfo}`); + return { + content: [{ + type: "text", + text: responseText + }] + }; + + } catch (error) { + console.error(`[MCP] get_content unexpected error${clientInfo}:`, error); + return { + content: [{ + type: "text", + text: `Unexpected error occurred during content processing: ${error instanceof Error ? error.message : String(error)}` + }] + }; + } + } + ); + + return server; +} + +async function main() { + const transportType = TRANSPORT_TYPE; + + if (transportType === "http") { + // Get initial port from environment or use default + const initialPort = CLI_PORT ?? 3000; + // Keep track of which port we end up using + let actualPort = initialPort; + + const httpServer = createServer(async (req, res) => { + const url = new URL(req.url || "", `http://${req.headers.host}`).pathname; + + // Set CORS headers for all responses + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS,DELETE"); + res.setHeader( + "Access-Control-Allow-Headers", + "Content-Type, MCP-Session-Id, mcp-session-id, MCP-Protocol-Version" + ); + res.setHeader("Access-Control-Expose-Headers", "MCP-Session-Id"); + + // Handle preflight OPTIONS requests + if (req.method === "OPTIONS") { + res.writeHead(200); + res.end(); + return; + } + + try { + // Extract client IP address using socket remote address (most reliable) + const clientIp = getClientIp(req); + + // Create new server instance for each request + const requestServer = createServerInstance(clientIp); + + if (url === "/mcp") { + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + }); + await requestServer.connect(transport); + await transport.handleRequest(req, res); + } else if (url === "/ping") { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("pong"); + } else if (url === "/health") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ + status: 'healthy', + service: 'OpenContext MCP Adapter', + mode: 'http', + timestamp: new Date().toISOString() + })); + } else if (url === "/info") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ + name: "OpenContext MCP Adapter", + version: "1.0.0", + mode: "http", + port: actualPort, + defaultTopK: DEFAULT_TOP_K, + defaultMaxTokens: DEFAULT_MAX_TOKENS, + endpoints: { + health: '/health', + info: '/info', + mcp: '/mcp' + } + })); + } else { + res.writeHead(404); + res.end("Not found"); + } + } catch (error) { + console.error("Error handling request:", error); + if (!res.headersSent) { + res.writeHead(500); + res.end("Internal Server Error"); + } + } + }); + + // Function to attempt server listen with port fallback + const startServer = (port: number, maxAttempts = 10) => { + httpServer.once("error", (err: NodeJS.ErrnoException) => { + if (err.code === "EADDRINUSE" && port < initialPort + maxAttempts) { + console.warn(`Port ${port} is in use, trying port ${port + 1}...`); + startServer(port + 1, maxAttempts); + } else { + console.error(`Failed to start server: ${err.message}`); + process.exit(1); + } + }); + + httpServer.listen(port, () => { + actualPort = port; + console.log(`[Server] OpenContext MCP Server running on HTTP mode at http://localhost:${actualPort}/mcp`); + console.log(`[Server] Health check: http://localhost:${actualPort}/health`); + console.log(`[Server] Server info: http://localhost:${actualPort}/info`); + }); + }; + + // Start the server with initial port + startServer(initialPort); + } else { + // Stdio transport - this is already stateless by nature + const server = createServerInstance(); + const transport = new StdioServerTransport(); + await server.connect(transport); + console.log("[Server] OpenContext MCP Server running on stdio"); + } +} + +main().catch((error) => { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[Fatal] Unhandled error in main(): ${errorMsg}`); + if (error instanceof Error && error.stack) { + console.error(`[Fatal] Stack trace:`, error.stack); + } + process.exit(1); +}); \ No newline at end of file diff --git a/mcp-adapter/lib/api.ts b/mcp-adapter/lib/api.ts new file mode 100644 index 0000000..bd485d8 --- /dev/null +++ b/mcp-adapter/lib/api.ts @@ -0,0 +1,168 @@ +import { + KnowledgeSearchResponse, + ContentResponse, + CommonResponse, + SearchApiResponse, + GetContentApiResponse +} from "./types.js"; + +const CORE_URL = process.env.OPENCONTEXT_CORE_URL || 'http://localhost:8080'; + +/** + * Implementation of OpenContext MCP's find_knowledge tool + * Calls GET /api/v1/search API to search for relevant knowledge chunks. + */ +export async function findKnowledge(query: string, topK: number = 5): Promise { + if (!query || query.trim().length === 0) { + console.warn("[findKnowledge] Empty query provided, returning empty results"); + return { results: [] }; + } + + const url = new URL(`${CORE_URL}/api/v1/search`); + url.searchParams.set("query", query.trim()); + if (topK) { + url.searchParams.set("topK", topK.toString()); + } + + console.log(`[findKnowledge] Searching for knowledge with query: "${query}", topK: ${topK}`); + + try { + const response = await fetch(url.toString(), { + method: "GET", + headers: { + "Accept": "application/json", + "User-Agent": "OpenContext-MCP-Adapter/1.0.0" + }, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => "Unable to read error response"); + console.error(`[findKnowledge] HTTP ${response.status} ${response.statusText}: ${errorText}`); + console.error(`[findKnowledge] Request URL: ${url.toString()}`); + return { + results: [], + error: { + code: "SEARCH_FAILED", + message: `Search failed with status ${response.status}: ${errorText}` + } + }; + } + + const result: CommonResponse = await response.json(); + + if (!result.success) { + console.error(`[findKnowledge] API returned error: ${result.message}`); + return { + results: [], + error: { + code: result.errorCode || "SEARCH_FAILED", + message: result.message || "Search failed" + } + }; + } + + console.log(`[findKnowledge] Found ${result.data?.results.length || 0} knowledge chunks`); + return { results: result.data?.results || [] }; + + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[findKnowledge] Network/Parse error: ${errorMsg}`); + console.error(`[findKnowledge] Query: "${query}", URL: ${url.toString()}`); + return { + results: [], + error: { + code: "NETWORK_ERROR", + message: `Network error: ${errorMsg}` + } + }; + } +} + +/** + * Implementation of OpenContext MCP's get_content tool + * Calls POST /api/v1/get-content API to retrieve the full content of a specific chunk. + */ +export async function getContent(chunkId: string, maxTokens: number = 25000): Promise { + if (!chunkId || chunkId.trim().length === 0) { + console.warn("[getContent] Empty chunkId provided"); + return { + content: "", + tokenInfo: { tokenizer: "", actualTokens: 0 }, + error: { + code: "VALIDATION_FAILED", + message: "Chunk ID is required" + } + }; + } + + const url = `${CORE_URL}/api/v1/get-content`; + const body: { chunkId: string; maxTokens?: number } = { + chunkId: chunkId.trim() + }; + + if (maxTokens) { + body.maxTokens = maxTokens; + } + + console.log(`[getContent] Fetching content for chunkId: "${chunkId}", maxTokens: ${maxTokens}`); + + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + "User-Agent": "OpenContext-MCP-Adapter/1.0.0" + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => "Unable to read error response"); + const errorMsg = `HTTP ${response.status} ${response.statusText}: ${errorText}`; + console.error(`[getContent] ${errorMsg}`); + console.error(`[getContent] Request body:`, JSON.stringify(body, null, 2)); + return { + content: "", + tokenInfo: { tokenizer: "", actualTokens: 0 }, + error: { + code: "CONTENT_FETCH_FAILED", + message: `Failed to fetch content. ${errorMsg}` + } + }; + } + + const result: CommonResponse = await response.json(); + + if (!result.success) { + console.error(`[getContent] API returned error: ${result.message}`); + return { + content: "", + tokenInfo: { tokenizer: "", actualTokens: 0 }, + error: { + code: result.errorCode || "CONTENT_FETCH_FAILED", + message: result.message || "Content fetch failed" + } + }; + } + + console.log(`[getContent] Successfully fetched content with ${result.data?.tokenInfo?.actualTokens || 'unknown'} tokens`); + return { + content: result.data?.content || "", + tokenInfo: result.data?.tokenInfo || { tokenizer: "", actualTokens: 0 } + }; + + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + console.error(`[getContent] Network/Parse error: ${errorMsg}`); + console.error(`[getContent] ChunkId: "${chunkId}"`); + return { + content: "", + tokenInfo: { tokenizer: "", actualTokens: 0 }, + error: { + code: "NETWORK_ERROR", + message: `Network error: ${errorMsg}` + } + }; + } +} \ No newline at end of file diff --git a/mcp-adapter/lib/types.ts b/mcp-adapter/lib/types.ts new file mode 100644 index 0000000..52c1281 --- /dev/null +++ b/mcp-adapter/lib/types.ts @@ -0,0 +1,64 @@ +/** + * Response type for OpenContext MCP's find_knowledge tool + */ +export interface KnowledgeSearchResult { + chunkId: string; // Unique identifier for the chunk (UUID) + title: string; // Title of the chunk + snippet: string; // Text extracted from the beginning of the chunk content with a certain length + relevanceScore: number; // Relevance score with the search query (0.0 ~ 1.0) +} + +/** + * Response structure for the find_knowledge tool + */ +export interface KnowledgeSearchResponse { + results: KnowledgeSearchResult[]; + error?: { + code: string; + message: string; + }; +} + +/** + * Response type for OpenContext MCP's get_content tool + */ +export interface ContentResponse { + content: string; // Original content of the requested chunk + tokenInfo: { + tokenizer: string; // Name of the tokenizer used to calculate token count + actualTokens: number; // Actual token count of the content + }; + error?: { + code: string; + message: string; + }; +} + +/** + * Standard API response structure for OpenContext Core + */ +export interface CommonResponse { + success: boolean; + data: T | null; + message: string; + errorCode: string | null; + timestamp: string; +} + +/** + * Search API response data from OpenContext Core + */ +export interface SearchApiResponse { + results: KnowledgeSearchResult[]; +} + +/** + * Content API response data from OpenContext Core + */ +export interface GetContentApiResponse { + content: string; + tokenInfo: { + tokenizer: string; + actualTokens: number; + }; +} \ No newline at end of file diff --git a/mcp-adapter/mock-server.ts b/mcp-adapter/mock-server.ts new file mode 100644 index 0000000..38ed388 --- /dev/null +++ b/mcp-adapter/mock-server.ts @@ -0,0 +1,312 @@ +import { createServer, IncomingMessage, ServerResponse } from 'http'; +import { URL } from 'url'; + +// Mock find_knowledge API - GET /api/v1/search +function handleSearch(req: IncomingMessage, res: ServerResponse) { + const url = new URL(req.url || '', `http://${req.headers.host}`); + const query = url.searchParams.get('query'); + const topK = parseInt(url.searchParams.get('topK') || '5', 10); + + console.log(`[Mock Server] find_knowledge called with query: "${query}", topK: ${topK}`); + + // Simulate different responses based on query + let mockResults = []; + + if (query?.includes('Spring Security') || query?.includes('JWT')) { + mockResults = [ + { + chunkId: "spring-security-jwt-1", + title: "Spring Security JWT Filter Implementation", + snippet: "This guide covers implementing JWT authentication filters in Spring Security, including token validation and user authentication...", + relevanceScore: 0.95 + }, + { + chunkId: "spring-security-jwt-2", + title: "JWT Token Configuration in Spring Boot", + snippet: "Configure JWT token settings, expiration times, and signing keys in your Spring Boot application...", + relevanceScore: 0.88 + } + ]; + } else if (query?.includes('Elasticsearch') || query?.includes('검색')) { + mockResults = [ + { + chunkId: "elasticsearch-search-1", + title: "Elasticsearch Search Configuration", + snippet: "Learn how to configure Elasticsearch for optimal search performance and relevance scoring...", + relevanceScore: 0.92 + } + ]; + } else { + // Default mock response + mockResults = [ + { + chunkId: "mock-chunk-1", + title: `Mock Result for: ${query}`, + snippet: "This is a mock snippet for testing purposes. The query was processed successfully by the mock server.", + relevanceScore: 0.85 + }, + { + chunkId: "mock-chunk-2", + title: "Another Mock Result", + snippet: "Additional mock data to test the topK parameter functionality.", + relevanceScore: 0.75 + } + ]; + } + + // Limit results based on topK parameter + const limitedResults = mockResults.slice(0, topK); + + const response = { + success: true, + data: { + results: limitedResults + }, + message: "Search completed successfully", + errorCode: null, + timestamp: new Date().toISOString() + }; + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(response, null, 2)); +} + +// Mock get_content API - POST /api/v1/get-content +function handleGetContent(req: IncomingMessage, res: ServerResponse) { + let body = ''; + + req.on('data', (chunk) => { + body += chunk.toString(); + }); + + req.on('end', () => { + try { + const { chunkId, maxTokens = 25000 } = JSON.parse(body); + + console.log(`[Mock Server] get_content called with chunkId: "${chunkId}", maxTokens: ${maxTokens}`); + + // Simulate different content based on chunkId + let mockContent = ""; + + if (chunkId === "spring-security-jwt-1") { + mockContent = `# Spring Security JWT Filter Implementation + +## Overview +This comprehensive guide covers implementing JWT (JSON Web Token) authentication filters in Spring Security applications. + +## Key Components + +### 1. JWT Filter Class +\`\`\`java +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + // JWT token extraction and validation logic + String token = extractTokenFromRequest(request); + if (token != null && jwtTokenProvider.validateToken(token)) { + Authentication auth = jwtTokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(auth); + } + filterChain.doFilter(request, response); + } +} +\`\`\` + +### 2. Token Provider +\`\`\`java +@Component +public class JwtTokenProvider { + @Value("\${jwt.secret}") + private String jwtSecret; + + public String generateToken(Authentication authentication) { + UserDetails userPrincipal = (UserDetails) authentication.getPrincipal(); + Date now = new Date(); + Date expiryDate = new Date(now.getTime() + 86400000); // 24 hours + + return Jwts.builder() + .setSubject(userPrincipal.getUsername()) + .setIssuedAt(now) + .setExpiration(expiryDate) + .signWith(SignatureAlgorithm.HS512, jwtSecret) + .compact(); + } +} +\`\`\` + +## Configuration +Add the filter to your SecurityConfig: +\`\`\`java +@Configuration +@EnableWebSecurity +public class SecurityConfig { + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); + } +} +\`\`\` + +This implementation provides secure JWT-based authentication for your Spring Boot application.`; + + } else if (chunkId === "elasticsearch-search-1") { + mockContent = `# Elasticsearch Search Configuration + +## Search Optimization +Learn how to configure Elasticsearch for optimal search performance and relevance scoring. + +## Key Settings +- Index mapping optimization +- Analyzer configuration +- Relevance scoring tuning +- Query performance optimization + +This content demonstrates how to configure Elasticsearch for the best search experience.`; + + } else { + // Default mock content + mockContent = `# Mock Content for Testing + +## Chunk ID: ${chunkId} +This is mock content generated by the test server for testing purposes. + +## Content Details +- **Chunk ID**: ${chunkId} +- **Max Tokens**: ${maxTokens} +- **Generated At**: ${new Date().toISOString()} + +## Test Information +This mock content is designed to test the mcp-adapter functionality without requiring a real OpenContext Core server. + +You can use this to verify: +1. Tool parameter handling +2. Response formatting +3. Error handling +4. Token counting + +The content is structured to provide realistic test data that mimics actual knowledge base responses.`; + } + + // Simulate token counting (simple word count for demo) + const actualTokens = Math.min(mockContent.split(/\s+/).length, maxTokens); + + const response = { + success: true, + data: { + content: mockContent, + tokenInfo: { + tokenizer: "mock-tokenizer", + actualTokens: actualTokens + } + }, + message: "Content retrieved successfully", + errorCode: null, + timestamp: new Date().toISOString() + }; + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(response, null, 2)); + + } catch (error) { + console.error('[Mock Server] Error parsing request body:', error); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + data: null, + message: 'Invalid JSON in request body', + errorCode: 'INVALID_JSON', + timestamp: new Date().toISOString() + })); + } + }); +} + +// Health check endpoint +function handleHealth(req: IncomingMessage, res: ServerResponse) { + const response = { + status: 'healthy', + service: 'OpenContext Mock Server', + timestamp: new Date().toISOString() + }; + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(response, null, 2)); +} + +// CORS headers +function setCorsHeaders(res: ServerResponse) { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); +} + +// Main server logic +const server = createServer((req: IncomingMessage, res: ServerResponse) => { + setCorsHeaders(res); + + // Handle preflight OPTIONS request + if (req.method === 'OPTIONS') { + res.writeHead(200); + res.end(); + return; + } + + const url = new URL(req.url || '', `http://${req.headers.host}`); + const path = url.pathname; + + console.log(`[Mock Server] ${req.method} ${path}`); + + try { + if (req.method === 'GET' && path === '/api/v1/search') { + handleSearch(req, res); + } else if (req.method === 'POST' && path === '/api/v1/get-content') { + handleGetContent(req, res); + } else if (req.method === 'GET' && path === '/health') { + handleHealth(req, res); + } else { + // 404 handler + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + data: null, + message: 'Endpoint not found', + errorCode: 'NOT_FOUND', + timestamp: new Date().toISOString() + })); + } + } catch (error) { + console.error('[Mock Server] Error:', error); + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + success: false, + data: null, + message: 'Internal server error', + errorCode: 'INTERNAL_ERROR', + timestamp: new Date().toISOString() + })); + } +}); + +const PORT = parseInt(process.env.MOCK_SERVER_PORT || '8080', 10); + +server.listen(PORT, '0.0.0.0', () => { + console.log(`🚀 OpenContext Mock Server running on http://0.0.0.0:${PORT}`); + console.log(`📖 Available endpoints:`); + console.log(` GET /api/v1/search?query=&topK=`); + console.log(` POST /api/v1/get-content`); + console.log(` GET /health`); + console.log(`\n💡 Test with: export OPENCONTEXT_CORE_URL=http://localhost:${PORT}`); +}); + +// Graceful shutdown +process.on('SIGINT', () => { + console.log('\n🛑 Shutting down mock server...'); + server.close(() => { + console.log('✅ Mock server stopped'); + process.exit(0); + }); +}); diff --git a/mcp-adapter/package.json b/mcp-adapter/package.json index 0e0cb31..e6f7cad 100644 --- a/mcp-adapter/package.json +++ b/mcp-adapter/package.json @@ -2,17 +2,34 @@ "name": "mcp-adapter", "version": "1.0.0", "description": "OpenContext MCP Adapter", - "main": "index.js", + "main": "dist/index.js", + "type": "module", "scripts": { - "start": "node index.js", - "dev": "nodemon index.js" + "build": "tsc", + "start": "tsx index.ts", + "dev": "tsx index.ts", + "mock": "node dist/mock-server.js", + "clean": "rm -rf dist" }, - "keywords": ["mcp", "adapter"], - "author": "OpenContext Team", + "keywords": [ + "mcp", + "model-client-protocol", + "context-protocol", + "adapter", + "opencontext", + "rag" + ], + "author": "OpenContext Development Team", "license": "MIT", - "dependencies": {}, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.17.1", + "commander": "^14.0.0", + "zod": "^3.25.76" + }, "devDependencies": { - "nodemon": "^3.0.1" + "@types/node": "^24.0.0", + "tsx": "^4.20.3", + "typescript": "^5.9.2" }, "engines": { "node": ">=18.0.0" diff --git a/mcp-adapter/tsconfig.json b/mcp-adapter/tsconfig.json new file mode 100644 index 0000000..b8a4917 --- /dev/null +++ b/mcp-adapter/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "allowJs": true, + + "strict": true, + "noImplicitAny": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": false, + "outDir": "./dist", + "rootDir": "./", + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": [ + "**/*.ts", + "**/*.js" + ], + "exclude": [ + "node_modules", + "dist" + ] +}