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
+
+[](https://opensource.org/licenses/Apache-2.0)
+[](https://openjdk.org/projects/jdk/21/)
+[](https://spring.io/projects/spring-boot)
+[](https://reactjs.org/)
+[](https://docs.docker.com/compose/)
+[](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
+[](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
+
+
+
+ Default Button
+ Destructive
+ Outline
+ Secondary
+ Ghost
+ Loading...
+ Disabled
+
+
+
+ Small
+ Default
+ Large
+
+
+
+
+
+
+
+ {/* 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 && (
+
+
+
+ Upload File
+
+
+ )}
+
+
+
+ {/* 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)}
+
+
+ handleAction('View', doc.id)}
+ >
+
+
+ handleAction('Resync', doc.id)}
+ >
+
+
+ handleAction('Delete', doc.id)}
+ >
+
+
+
+
+
+ ))}
+
+
+
+
+
+ {/* Card Layouts */}
+
+
+
+ Statistics Card
+
+
+ 42
+ Total Documents
+
+
+
+ View All
+
+
+
+
+
+
+ Status Overview
+
+
+
+ Completed
+ 28
+
+
+ Processing
+ 12
+
+
+ Failed
+ 2
+
+
+
+
+
+
+ Quick Actions
+
+
+
+
+ Upload Document
+
+
+
+ Refresh All
+
+
+
+ Search Knowledge
+
+
+
+
+
+
+ )
+}
\ 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 */}
+
+ {navigation.map((item) => {
+ const Icon = item.icon
+ const isActive = location.pathname === item.href
+
+ return (
+
+
+ {item.name}
+
+ )
+ })}
+
+
+ {/* Footer */}
+
+
+ OpenContext Admin v1.0
+
+
+
+
+
+ {/* Main content */}
+
+
+ )
+}
\ 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
+ *
+ * Click me
+ *
+ * ```
+ */
+export const Button = React.forwardRef(
+ ({ className, variant = 'default', size = 'default', loading = false, children, disabled, ...props }, ref) => {
+ return (
+
+ {loading && (
+
+
+
+
+ )}
+ {children}
+
+ )
+ }
+)
+
+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{' '}
+
+ browse to upload
+
+
+
+
+
+
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 && (
+
+ {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) => (
+
+ )
+)
+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.
+
+
+
+ window.location.href = '/settings'}>
+ Go to Settings
+
+
+
+
+ )
+ }
+
+ 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}
+
+
+ ))}
+
+
+
+ window.location.href = '/documents'}
+ >
+ View All Documents
+
+
+
+
+ {/* Quick Actions */}
+
+
+ Quick Actions
+
+
+
+ window.location.href = '/documents'}
+ >
+
+ Upload Document
+ Add new files to knowledge base
+
+
+ window.location.href = '/search'}
+ >
+
+ Test Search
+ Try MCP search tools
+
+
+ window.location.href = '/settings'}
+ >
+
+ System Status
+ Check service health
+
+
+
+
+
+ )
+}
\ 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.
+
+
+
+ {isLoadingDocuments ? 'Refreshing...' : 'Refresh'}
+
+
+
+ {/* File Upload Section */}
+
+
+ Upload New Document
+
+
+
+
+ {selectedFile && (
+
+
+ {selectedFile.name}
+ ({formatFileSize(selectedFile.size)})
+
+
+ {uploadMutation.isPending ? 'Uploading...' : 'Upload Document'}
+
+
+ )}
+
+ {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))}
+
+ )}
+
+
+
+
+ handleResync(doc.id)}
+ disabled={resyncMutation.isPending || ['PARSING', 'CHUNKING', 'EMBEDDING', 'INDEXING', 'DELETING'].includes(doc.ingestionStatus)}
+ >
+ {resyncMutation.isPending ? 'Resyncing...' : 'Resync'}
+
+ handleDelete(doc.id)}
+ disabled={deleteMutation.isPending || doc.ingestionStatus === 'DELETING'}
+ >
+ {deleteMutation.isPending ? 'Deleting...' : 'Delete'}
+
+
+
+
+ ))
+ )}
+
+
+
+ {/* Pagination */}
+ {documentsPage && documentsPage.totalPages > 1 && (
+
+
+ Showing {currentPage * pageSize + 1} to{' '}
+ {Math.min((currentPage + 1) * pageSize, documentsPage.totalElements)} of{' '}
+ {documentsPage.totalElements} documents
+
+
+ handlePageChange(currentPage - 1)}
+ disabled={!documentsPage.hasPrevious}
+ >
+ Previous
+
+ handlePageChange(currentPage + 1)}
+ disabled={!documentsPage.hasNext}
+ >
+ Next
+
+
+
+ )}
+
+
+
+ )
+}
\ 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)
+
+
+
+
+
+ {searchError && (
+
+ Error: {searchError}
+
+ )}
+
+
+
+ {searchResults && (
+ Found {searchResults.results.length} results
+ )}
+
+ {(searchResults || searchError) && (
+
+ Clear Results
+
+ )}
+
+
+
+
+ {/* 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 && (
+ handleCopyContent(contentData.content)}
+ >
+
+ Copy
+
+ )}
+
+
+
+
+ {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"
+ />
+ setShowApiKey(!showApiKey)}
+ className="absolute right-2 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600"
+ >
+ {showApiKey ? : }
+
+
+
+
+ {/* Action Buttons */}
+
+
+
+ Save API Key
+
+
+
+
+ Test Connection
+
+
+ {apiKey && (
+
+
+ Clear Key
+
+ )}
+
+
+ {/* 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
+
+[](https://openjdk.org/projects/jdk/21/)
+[](https://spring.io/projects/spring-boot)
+[](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